// v2.8.0 — document TTL lifecycle across write paths. // // The bug this pins down: `update()` WIPED a document's expiry. // // updateIfVersion built a fresh Document from the caller's request, and // UpdateRequest has no ttl field, so the incoming expiresAt was always 0. It // deliberately carried createdAt and createdBy across from the stored document // but not expiresAt - so any TTL'd document that was ever updated became // permanent. `patch()` mutates the stored document in place and therefore always // preserved it, which is why the two paths disagreed. // // This matters most where it is least visible: a workflow execution written with // a TTL and then updated as its status changes (the normal case) silently lost // its expiry and accumulated forever. #include #include #include #include #include #include #include #include "memory_store.hpp" using namespace smartbotic::database; namespace { int g_pass = 0; int g_fail = 0; void check(bool cond, const char* msg) { if (cond) { ++g_pass; } else { ++g_fail; std::cerr << "FAIL: " << msg << "\n"; } } struct Fixture { MemoryStore store; Fixture() : store(MemoryStore::Config{}) { store.start(); CollectionOptions o; store.createCollection("things", o); } ~Fixture() { store.stop(); } }; Document mkDoc(const std::string& id, uint64_t expiresAt = 0) { Document d; d.id = id; d.collection = "things"; d.expiresAt = expiresAt; d.set_data(nlohmann::json{{"n", 1}}); return d; } uint64_t future_ms(uint64_t secs) { const auto now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); return static_cast(now) + secs * 1000ULL; } // ------------------------------------------------------------------------- void test_insert_sets_expiry() { Fixture f; const uint64_t exp = future_ms(3600); f.store.insert("things", mkDoc("a", exp)); auto got = f.store.get("things", "a"); check(got.has_value(), "inserted"); check(got && got->expiresAt == exp, "insert stores the expiry it was given"); } // THE regression. An update with no TTL must not silently make the document // permanent. void test_update_preserves_expiry() { Fixture f; const uint64_t exp = future_ms(3600); f.store.insert("things", mkDoc("a", exp)); auto stored = f.store.get("things", "a"); Document next = mkDoc("a"); // no expiry, exactly as UpdateRequest arrives next.set_data(nlohmann::json{{"n", 2}}); f.store.updateIfVersion("things", "a", next, stored->version); auto after = f.store.get("things", "a"); check(after.has_value(), "still present after update"); check(after && after->data().value("n", 0) == 2, "the update applied"); check(after && after->expiresAt == exp, "update MUST preserve the existing expiry - wiping it makes a TTL'd " "document permanent"); } void test_update_can_change_expiry_when_given_one() { Fixture f; f.store.insert("things", mkDoc("a", future_ms(60))); auto stored = f.store.get("things", "a"); const uint64_t later = future_ms(7200); Document next = mkDoc("a", later); // explicit new expiry f.store.updateIfVersion("things", "a", next, stored->version); auto after = f.store.get("things", "a"); check(after && after->expiresAt == later, "an explicit expiry on update replaces the old one"); } void test_update_of_a_document_with_no_expiry_stays_permanent() { Fixture f; f.store.insert("things", mkDoc("a")); // no TTL auto stored = f.store.get("things", "a"); Document next = mkDoc("a"); f.store.updateIfVersion("things", "a", next, stored->version); auto after = f.store.get("things", "a"); check(after && after->expiresAt == 0, "preserving must not invent an expiry for a document that had none"); } void test_patch_preserves_expiry() { Fixture f; const uint64_t exp = future_ms(3600); f.store.insert("things", mkDoc("a", exp)); f.store.patchDocument("things", "a", nlohmann::json{{"n", 5}}, ""); auto after = f.store.get("things", "a"); check(after && after->expiresAt == exp, "patch preserves the expiry"); check(after && after->data().value("n", 0) == 5, "and applies the patch"); } } // namespace int main() { std::cout << "=== test_document_ttl ===\n"; test_insert_sets_expiry(); test_update_preserves_expiry(); test_update_can_change_expiry_when_given_one(); test_update_of_a_document_with_no_expiry_stays_permanent(); test_patch_preserves_expiry(); std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n"; return g_fail == 0 ? 0 : 1; }