test_document_ttl.cpp 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. // v2.8.0 — document TTL lifecycle across write paths.
  2. //
  3. // The bug this pins down: `update()` WIPED a document's expiry.
  4. //
  5. // updateIfVersion built a fresh Document from the caller's request, and
  6. // UpdateRequest has no ttl field, so the incoming expiresAt was always 0. It
  7. // deliberately carried createdAt and createdBy across from the stored document
  8. // but not expiresAt - so any TTL'd document that was ever updated became
  9. // permanent. `patch()` mutates the stored document in place and therefore always
  10. // preserved it, which is why the two paths disagreed.
  11. //
  12. // This matters most where it is least visible: a workflow execution written with
  13. // a TTL and then updated as its status changes (the normal case) silently lost
  14. // its expiry and accumulated forever.
  15. #include <atomic>
  16. #include <chrono>
  17. #include <filesystem>
  18. #include <iostream>
  19. #include <string>
  20. #include <unistd.h>
  21. #include <nlohmann/json.hpp>
  22. #include "memory_store.hpp"
  23. using namespace smartbotic::database;
  24. namespace {
  25. int g_pass = 0;
  26. int g_fail = 0;
  27. void check(bool cond, const char* msg) {
  28. if (cond) { ++g_pass; }
  29. else { ++g_fail; std::cerr << "FAIL: " << msg << "\n"; }
  30. }
  31. struct Fixture {
  32. MemoryStore store;
  33. Fixture() : store(MemoryStore::Config{}) {
  34. store.start();
  35. CollectionOptions o;
  36. store.createCollection("things", o);
  37. }
  38. ~Fixture() { store.stop(); }
  39. };
  40. Document mkDocIn(const std::string& coll, const std::string& id) {
  41. Document d;
  42. d.id = id;
  43. d.collection = coll;
  44. d.set_data(nlohmann::json{{"n", 1}});
  45. return d;
  46. }
  47. Document mkDoc(const std::string& id, uint64_t expiresAt = 0) {
  48. Document d;
  49. d.id = id;
  50. d.collection = "things";
  51. d.expiresAt = expiresAt;
  52. d.set_data(nlohmann::json{{"n", 1}});
  53. return d;
  54. }
  55. uint64_t future_ms(uint64_t secs) {
  56. const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
  57. std::chrono::system_clock::now().time_since_epoch()).count();
  58. return static_cast<uint64_t>(now) + secs * 1000ULL;
  59. }
  60. // -------------------------------------------------------------------------
  61. void test_insert_sets_expiry() {
  62. Fixture f;
  63. const uint64_t exp = future_ms(3600);
  64. f.store.insert("things", mkDoc("a", exp));
  65. auto got = f.store.get("things", "a");
  66. check(got.has_value(), "inserted");
  67. check(got && got->expiresAt == exp, "insert stores the expiry it was given");
  68. }
  69. // THE regression. An update with no TTL must not silently make the document
  70. // permanent.
  71. void test_update_preserves_expiry() {
  72. Fixture f;
  73. const uint64_t exp = future_ms(3600);
  74. f.store.insert("things", mkDoc("a", exp));
  75. auto stored = f.store.get("things", "a");
  76. Document next = mkDoc("a"); // no expiry, exactly as UpdateRequest arrives
  77. next.set_data(nlohmann::json{{"n", 2}});
  78. f.store.updateIfVersion("things", "a", next, stored->version);
  79. auto after = f.store.get("things", "a");
  80. check(after.has_value(), "still present after update");
  81. check(after && after->data().value("n", 0) == 2, "the update applied");
  82. check(after && after->expiresAt == exp,
  83. "update MUST preserve the existing expiry - wiping it makes a TTL'd "
  84. "document permanent");
  85. }
  86. void test_update_can_change_expiry_when_given_one() {
  87. Fixture f;
  88. f.store.insert("things", mkDoc("a", future_ms(60)));
  89. auto stored = f.store.get("things", "a");
  90. const uint64_t later = future_ms(7200);
  91. Document next = mkDoc("a", later); // explicit new expiry
  92. f.store.updateIfVersion("things", "a", next, stored->version);
  93. auto after = f.store.get("things", "a");
  94. check(after && after->expiresAt == later,
  95. "an explicit expiry on update replaces the old one");
  96. }
  97. void test_update_of_a_document_with_no_expiry_stays_permanent() {
  98. Fixture f;
  99. f.store.insert("things", mkDoc("a")); // no TTL
  100. auto stored = f.store.get("things", "a");
  101. Document next = mkDoc("a");
  102. f.store.updateIfVersion("things", "a", next, stored->version);
  103. auto after = f.store.get("things", "a");
  104. check(after && after->expiresAt == 0,
  105. "preserving must not invent an expiry for a document that had none");
  106. }
  107. void test_patch_preserves_expiry() {
  108. Fixture f;
  109. const uint64_t exp = future_ms(3600);
  110. f.store.insert("things", mkDoc("a", exp));
  111. f.store.patchDocument("things", "a", nlohmann::json{{"n", 5}}, "");
  112. auto after = f.store.get("things", "a");
  113. check(after && after->expiresAt == exp, "patch preserves the expiry");
  114. check(after && after->data().value("n", 0) == 5, "and applies the patch");
  115. }
  116. // v2.8.0 — an expiry can now be changed or cleared without rewriting the whole
  117. // document. Before this, ttl_seconds existed only on insert/upsert, so the only
  118. // way to alter a TTL was a full upsert.
  119. void test_expiry_override_on_update() {
  120. Fixture f;
  121. const uint64_t original = future_ms(60);
  122. f.store.insert("things", mkDoc("a", original));
  123. auto stored = f.store.get("things", "a");
  124. // Absent override -> preserve (the default).
  125. Document next = mkDoc("a");
  126. f.store.updateIfVersion("things", "a", next, stored->version);
  127. check(f.store.get("things", "a")->expiresAt == original,
  128. "no override preserves the existing expiry");
  129. // Explicit new expiry -> replace.
  130. const uint64_t later = future_ms(7200);
  131. stored = f.store.get("things", "a");
  132. f.store.updateIfVersion("things", "a", mkDoc("a"), stored->version, later);
  133. check(f.store.get("things", "a")->expiresAt == later,
  134. "an override replaces the expiry");
  135. // Explicit 0 -> clear, making the document permanent.
  136. stored = f.store.get("things", "a");
  137. f.store.updateIfVersion("things", "a", mkDoc("a"), stored->version, uint64_t{0});
  138. check(f.store.get("things", "a")->expiresAt == 0,
  139. "an override of 0 CLEARS the expiry - this is how a document is made "
  140. "permanent, and is why the field needs presence semantics on the wire");
  141. }
  142. void test_expiry_override_on_patch() {
  143. Fixture f;
  144. const uint64_t original = future_ms(60);
  145. f.store.insert("things", mkDoc("a", original));
  146. f.store.patchDocument("things", "a", nlohmann::json{{"n", 2}}, "");
  147. check(f.store.get("things", "a")->expiresAt == original,
  148. "patch without an override preserves the expiry");
  149. const uint64_t later = future_ms(3600);
  150. f.store.patchDocument("things", "a", nlohmann::json{{"n", 3}}, "", later);
  151. check(f.store.get("things", "a")->expiresAt == later,
  152. "patch with an override replaces the expiry");
  153. check(f.store.get("things", "a")->data().value("n", 0) == 3,
  154. "and still applies the patch");
  155. f.store.patchDocument("things", "a", nlohmann::json{{"n", 4}}, "", uint64_t{0});
  156. check(f.store.get("things", "a")->expiresAt == 0,
  157. "patch with an override of 0 clears the expiry");
  158. }
  159. // v2.8.0 — collection settings that were frozen at creation.
  160. //
  161. // createCollection on an existing collection used to update ONLY maxVersions, so
  162. // defaultTtlSeconds could never be revised and vectorDimension was unreachable
  163. // entirely (alterCollection can set it but has no RPC and no call sites). That
  164. // stranded collections created through the pre-2.4.5 createCollection bug, where
  165. // the options went to a phantom collection and the real one got defaults.
  166. void test_collection_default_ttl_is_changeable() {
  167. Fixture f;
  168. CollectionOptions o;
  169. o.defaultTtlSeconds = 0;
  170. f.store.createCollection("ttlcoll", o);
  171. f.store.insert("ttlcoll", mkDocIn("ttlcoll", "before"));
  172. check(f.store.get("ttlcoll", "before")->expiresAt == 0,
  173. "inserted with no default: no expiry");
  174. // Revise the policy.
  175. o.defaultTtlSeconds = 3600;
  176. f.store.createCollection("ttlcoll", o); // existing collection
  177. f.store.insert("ttlcoll", mkDocIn("ttlcoll", "after"));
  178. check(f.store.get("ttlcoll", "after")->expiresAt > 0,
  179. "a document inserted after the change inherits the new default");
  180. check(f.store.get("ttlcoll", "before")->expiresAt == 0,
  181. "and existing documents keep what they were written with - the change "
  182. "is not retroactive");
  183. // And back to none.
  184. o.defaultTtlSeconds = 0;
  185. f.store.createCollection("ttlcoll", o);
  186. f.store.insert("ttlcoll", mkDocIn("ttlcoll", "third"));
  187. check(f.store.get("ttlcoll", "third")->expiresAt == 0,
  188. "clearing the default works too - 0 is a real value, not 'unset'");
  189. }
  190. void test_vector_dimension_recovery() {
  191. Fixture f;
  192. CollectionOptions o;
  193. o.vectorDimension = 0; // the broken-createCollection state
  194. f.store.createCollection("vec", o);
  195. // Settable while the collection holds no vectors.
  196. o.vectorDimension = 4;
  197. f.store.createCollection("vec", o);
  198. auto info = f.store.getCollectionInfo("vec");
  199. check(info && info->options.vectorDimension == 4,
  200. "vector_dimension can be set on a collection that holds no vectors");
  201. // Now it holds one, so the dimension must be refused rather than changed.
  202. Document d = mkDocIn("vec", "v1");
  203. d.set_data(nlohmann::json{{"_vector", {1.0, 0.0, 0.0, 0.0}}});
  204. f.store.insert("vec", d);
  205. o.vectorDimension = 8;
  206. f.store.createCollection("vec", o);
  207. auto after = f.store.getCollectionInfo("vec");
  208. check(after && after->options.vectorDimension == 4,
  209. "once vectors exist the dimension is immutable - changing it would make "
  210. "the stored vectors unreadable by the search kernel");
  211. }
  212. } // namespace
  213. int main() {
  214. std::cout << "=== test_document_ttl ===\n";
  215. test_insert_sets_expiry();
  216. test_update_preserves_expiry();
  217. test_update_can_change_expiry_when_given_one();
  218. test_update_of_a_document_with_no_expiry_stays_permanent();
  219. test_patch_preserves_expiry();
  220. test_expiry_override_on_update();
  221. test_expiry_override_on_patch();
  222. test_collection_default_ttl_is_changeable();
  223. test_vector_dimension_recovery();
  224. std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
  225. return g_fail == 0 ? 0 : 1;
  226. }