| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- // 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 <atomic>
- #include <chrono>
- #include <filesystem>
- #include <iostream>
- #include <string>
- #include <unistd.h>
- #include <nlohmann/json.hpp>
- #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::milliseconds>(
- std::chrono::system_clock::now().time_since_epoch()).count();
- return static_cast<uint64_t>(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;
- }
|