// 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 mkDocIn(const std::string& coll, const std::string& id) { Document d; d.id = id; d.collection = coll; d.set_data(nlohmann::json{{"n", 1}}); return d; } 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"); } // v2.8.0 — an expiry can now be changed or cleared without rewriting the whole // document. Before this, ttl_seconds existed only on insert/upsert, so the only // way to alter a TTL was a full upsert. void test_expiry_override_on_update() { Fixture f; const uint64_t original = future_ms(60); f.store.insert("things", mkDoc("a", original)); auto stored = f.store.get("things", "a"); // Absent override -> preserve (the default). Document next = mkDoc("a"); f.store.updateIfVersion("things", "a", next, stored->version); check(f.store.get("things", "a")->expiresAt == original, "no override preserves the existing expiry"); // Explicit new expiry -> replace. const uint64_t later = future_ms(7200); stored = f.store.get("things", "a"); f.store.updateIfVersion("things", "a", mkDoc("a"), stored->version, later); check(f.store.get("things", "a")->expiresAt == later, "an override replaces the expiry"); // Explicit 0 -> clear, making the document permanent. stored = f.store.get("things", "a"); f.store.updateIfVersion("things", "a", mkDoc("a"), stored->version, uint64_t{0}); check(f.store.get("things", "a")->expiresAt == 0, "an override of 0 CLEARS the expiry - this is how a document is made " "permanent, and is why the field needs presence semantics on the wire"); } void test_expiry_override_on_patch() { Fixture f; const uint64_t original = future_ms(60); f.store.insert("things", mkDoc("a", original)); f.store.patchDocument("things", "a", nlohmann::json{{"n", 2}}, ""); check(f.store.get("things", "a")->expiresAt == original, "patch without an override preserves the expiry"); const uint64_t later = future_ms(3600); f.store.patchDocument("things", "a", nlohmann::json{{"n", 3}}, "", later); check(f.store.get("things", "a")->expiresAt == later, "patch with an override replaces the expiry"); check(f.store.get("things", "a")->data().value("n", 0) == 3, "and still applies the patch"); f.store.patchDocument("things", "a", nlohmann::json{{"n", 4}}, "", uint64_t{0}); check(f.store.get("things", "a")->expiresAt == 0, "patch with an override of 0 clears the expiry"); } // v2.8.0 — collection settings that were frozen at creation. // // createCollection on an existing collection used to update ONLY maxVersions, so // defaultTtlSeconds could never be revised and vectorDimension was unreachable // entirely (alterCollection can set it but has no RPC and no call sites). That // stranded collections created through the pre-2.4.5 createCollection bug, where // the options went to a phantom collection and the real one got defaults. void test_collection_default_ttl_is_changeable() { Fixture f; CollectionOptions o; o.defaultTtlSeconds = 0; f.store.createCollection("ttlcoll", o); f.store.insert("ttlcoll", mkDocIn("ttlcoll", "before")); check(f.store.get("ttlcoll", "before")->expiresAt == 0, "inserted with no default: no expiry"); // Revise the policy. o.defaultTtlSeconds = 3600; f.store.createCollection("ttlcoll", o); // existing collection f.store.insert("ttlcoll", mkDocIn("ttlcoll", "after")); check(f.store.get("ttlcoll", "after")->expiresAt > 0, "a document inserted after the change inherits the new default"); check(f.store.get("ttlcoll", "before")->expiresAt == 0, "and existing documents keep what they were written with - the change " "is not retroactive"); // And back to none. o.defaultTtlSeconds = 0; f.store.createCollection("ttlcoll", o); f.store.insert("ttlcoll", mkDocIn("ttlcoll", "third")); check(f.store.get("ttlcoll", "third")->expiresAt == 0, "clearing the default works too - 0 is a real value, not 'unset'"); } void test_vector_dimension_recovery() { Fixture f; CollectionOptions o; o.vectorDimension = 0; // the broken-createCollection state f.store.createCollection("vec", o); // Settable while the collection holds no vectors. o.vectorDimension = 4; f.store.createCollection("vec", o); auto info = f.store.getCollectionInfo("vec"); check(info && info->options.vectorDimension == 4, "vector_dimension can be set on a collection that holds no vectors"); // Now it holds one, so the dimension must be refused rather than changed. Document d = mkDocIn("vec", "v1"); d.set_data(nlohmann::json{{"_vector", {1.0, 0.0, 0.0, 0.0}}}); f.store.insert("vec", d); o.vectorDimension = 8; f.store.createCollection("vec", o); auto after = f.store.getCollectionInfo("vec"); check(after && after->options.vectorDimension == 4, "once vectors exist the dimension is immutable - changing it would make " "the stored vectors unreadable by the search kernel"); } } // 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(); test_expiry_override_on_update(); test_expiry_override_on_patch(); test_collection_default_ttl_is_changeable(); test_vector_dimension_recovery(); std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n"; return g_fail == 0 ? 0 : 1; }