@feltdb/core 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/dist/agent-runtime.d.ts +64 -2
  2. package/dist/agent-runtime.d.ts.map +1 -1
  3. package/dist/agent-runtime.js +121 -11
  4. package/dist/agent-state.d.ts +68 -0
  5. package/dist/agent-state.d.ts.map +1 -1
  6. package/dist/agent-state.js +210 -12
  7. package/dist/agent.d.ts +10 -1
  8. package/dist/agent.d.ts.map +1 -1
  9. package/dist/agent.js +10 -1
  10. package/dist/build-identity.json +12 -4
  11. package/dist/cli/commands.js +214 -10
  12. package/dist/cli/publish-engine.js +116 -16
  13. package/dist/collection.d.ts +3 -1
  14. package/dist/collection.d.ts.map +1 -1
  15. package/dist/collection.js +33 -8
  16. package/dist/create/build-identity.json +12 -4
  17. package/dist/create/cli.js +1 -1
  18. package/dist/create/create.js +4 -6
  19. package/dist/create/managed-account.js +93 -35
  20. package/dist/create/package-versions.js +1 -1
  21. package/dist/create/server-source/Cargo.lock +8 -511
  22. package/dist/create/server-source/Cargo.toml +2 -13
  23. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  24. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  25. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  26. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  27. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
  28. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  29. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  30. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  32. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  33. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
  34. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1134 -96
  35. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  37. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  38. package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +1 -1
  39. package/dist/db.d.ts +75 -10
  40. package/dist/db.d.ts.map +1 -1
  41. package/dist/db.js +168 -29
  42. package/dist/deployment.d.ts +19 -0
  43. package/dist/deployment.d.ts.map +1 -1
  44. package/dist/deployment.js +38 -2
  45. package/dist/embedded-query.d.ts.map +1 -1
  46. package/dist/embedded-query.js +5 -4
  47. package/dist/embedded-transaction.d.ts +8 -4
  48. package/dist/embedded-transaction.d.ts.map +1 -1
  49. package/dist/embedded-transaction.js +55 -19
  50. package/dist/error-codes.d.ts +36 -2
  51. package/dist/error-codes.d.ts.map +1 -1
  52. package/dist/error-codes.js +50 -3
  53. package/dist/feltdb.d.ts +7 -0
  54. package/dist/feltdb.d.ts.map +1 -1
  55. package/dist/file-db.d.ts +27 -0
  56. package/dist/file-db.d.ts.map +1 -1
  57. package/dist/file-db.js +145 -7
  58. package/dist/http-client.d.ts +11 -3
  59. package/dist/http-client.d.ts.map +1 -1
  60. package/dist/http-client.js +27 -14
  61. package/dist/http-db.d.ts +43 -1
  62. package/dist/http-db.d.ts.map +1 -1
  63. package/dist/http-db.js +287 -59
  64. package/dist/index.js +1 -1
  65. package/dist/local-state-lock.d.ts +34 -0
  66. package/dist/local-state-lock.d.ts.map +1 -0
  67. package/dist/local-state-lock.js +225 -0
  68. package/dist/managed-recovery.d.ts +6 -0
  69. package/dist/managed-recovery.d.ts.map +1 -1
  70. package/dist/managed-recovery.js +9 -0
  71. package/dist/reactive-graph.d.ts +0 -3
  72. package/dist/reactive-graph.d.ts.map +1 -1
  73. package/dist/reactive-graph.js +11 -1
  74. package/dist/runtime-registry.d.ts +7 -2
  75. package/dist/runtime-registry.d.ts.map +1 -1
  76. package/dist/runtime-registry.js +2 -2
  77. package/dist/studio/components/index.js +1 -1
  78. package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
  79. package/dist/studio/index.js +1 -1
  80. package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-DtybUY0q.js} +1 -1
  81. package/dist/studio-app/assets/feltdb_wasm_bg-DsgIUwaB.wasm +0 -0
  82. package/dist/studio-app/assets/index-C4OffsZl.js +37 -0
  83. package/dist/studio-app/index.html +1 -1
  84. package/dist/transaction.d.ts +27 -11
  85. package/dist/transaction.d.ts.map +1 -1
  86. package/dist/transaction.js +20 -14
  87. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  88. package/dist/wasm/package.json +1 -1
  89. package/dist/workload.d.ts +86 -6
  90. package/dist/workload.d.ts.map +1 -1
  91. package/dist/workload.js +51 -3
  92. package/dist/workspace/workspace-connection.d.ts.map +1 -1
  93. package/dist/workspace/workspace-connection.js +14 -3
  94. package/package.json +1 -1
  95. package/dist/create/server-source/crates/feltdb/Cargo.lock +0 -175
  96. package/dist/create/server-source/crates/feltdb/Cargo.toml +0 -102
  97. package/dist/create/server-source/crates/feltdb/LICENSE +0 -21
  98. package/dist/create/server-source/crates/feltdb/README.md +0 -75
  99. package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +0 -370
  100. package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +0 -589
  101. package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +0 -247
  102. package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +0 -440
  103. package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +0 -275
  104. package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +0 -381
  105. package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +0 -467
  106. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +0 -489
  107. package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +0 -281
  108. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +0 -606
  109. package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +0 -531
  110. package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +0 -462
  111. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +0 -505
  112. package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +0 -484
  113. package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +0 -758
  114. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +0 -421
  115. package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +0 -329
  116. package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +0 -1705
  117. package/dist/create/server-source/crates/feltdb/src/acquisition.rs +0 -286
  118. package/dist/create/server-source/crates/feltdb/src/admission.rs +0 -185
  119. package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +0 -507
  120. package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +0 -539
  121. package/dist/create/server-source/crates/feltdb/src/agent_state.rs +0 -320
  122. package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +0 -1545
  123. package/dist/create/server-source/crates/feltdb/src/analytics.rs +0 -521
  124. package/dist/create/server-source/crates/feltdb/src/application.rs +0 -2674
  125. package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +0 -1070
  126. package/dist/create/server-source/crates/feltdb/src/authority.rs +0 -479
  127. package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +0 -847
  128. package/dist/create/server-source/crates/feltdb/src/authorization.rs +0 -1030
  129. package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +0 -1122
  130. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +0 -32
  131. package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +0 -19
  132. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +0 -1474
  133. package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +0 -8
  134. package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +0 -418
  135. package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +0 -482
  136. package/dist/create/server-source/crates/feltdb/src/capability.rs +0 -903
  137. package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +0 -263
  138. package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -77
  139. package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +0 -496
  140. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +0 -2574
  141. package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +0 -212
  142. package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +0 -422
  143. package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +0 -445
  144. package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +0 -465
  145. package/dist/create/server-source/crates/feltdb/src/convergence.rs +0 -634
  146. package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +0 -427
  147. package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +0 -376
  148. package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +0 -365
  149. package/dist/create/server-source/crates/feltdb/src/cron.rs +0 -294
  150. package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +0 -483
  151. package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +0 -477
  152. package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +0 -1003
  153. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +0 -1334
  154. package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +0 -368
  155. package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +0 -231
  156. package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +0 -482
  157. package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +0 -203
  158. package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +0 -317
  159. package/dist/create/server-source/crates/feltdb/src/equality_index.rs +0 -595
  160. package/dist/create/server-source/crates/feltdb/src/execution.rs +0 -426
  161. package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +0 -214
  162. package/dist/create/server-source/crates/feltdb/src/indexing.rs +0 -776
  163. package/dist/create/server-source/crates/feltdb/src/lib.rs +0 -7274
  164. package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +0 -234
  165. package/dist/create/server-source/crates/feltdb/src/materialization.rs +0 -184
  166. package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -266
  167. package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +0 -310
  168. package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +0 -736
  169. package/dist/create/server-source/crates/feltdb/src/observability.rs +0 -379
  170. package/dist/create/server-source/crates/feltdb/src/operation.rs +0 -290
  171. package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +0 -439
  172. package/dist/create/server-source/crates/feltdb/src/operation_log.rs +0 -349
  173. package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +0 -332
  174. package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +0 -718
  175. package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +0 -487
  176. package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +0 -166
  177. package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +0 -289
  178. package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +0 -566
  179. package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +0 -3391
  180. package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +0 -203
  181. package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +0 -261
  182. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +0 -502
  183. package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +0 -216
  184. package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +0 -303
  185. package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +0 -323
  186. package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +0 -531
  187. package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +0 -406
  188. package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +0 -436
  189. package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +0 -387
  190. package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +0 -378
  191. package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +0 -359
  192. package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +0 -372
  193. package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +0 -404
  194. package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +0 -376
  195. package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +0 -461
  196. package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +0 -2125
  197. package/dist/create/server-source/crates/feltdb/src/production_api.rs +0 -418
  198. package/dist/create/server-source/crates/feltdb/src/provenance.rs +0 -207
  199. package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +0 -126
  200. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +0 -218
  201. package/dist/create/server-source/crates/feltdb/src/read_basis.rs +0 -257
  202. package/dist/create/server-source/crates/feltdb/src/references.rs +0 -404
  203. package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +0 -401
  204. package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +0 -501
  205. package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +0 -680
  206. package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +0 -163
  207. package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +0 -133
  208. package/dist/create/server-source/crates/feltdb/src/routing.rs +0 -409
  209. package/dist/create/server-source/crates/feltdb/src/semantic_decision.rs +0 -2029
  210. package/dist/create/server-source/crates/feltdb/src/semantic_query.rs +0 -599
  211. package/dist/create/server-source/crates/feltdb/src/sharding.rs +0 -549
  212. package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +0 -516
  213. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +0 -5894
  214. package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +0 -222
  215. package/dist/create/server-source/crates/feltdb/src/state_facade.rs +0 -320
  216. package/dist/create/server-source/crates/feltdb/src/state_hash.rs +0 -291
  217. package/dist/create/server-source/crates/feltdb/src/state_model.rs +0 -4158
  218. package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +0 -379
  219. package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +0 -673
  220. package/dist/create/server-source/crates/feltdb/src/storage.rs +0 -273
  221. package/dist/create/server-source/crates/feltdb/src/submission.rs +0 -471
  222. package/dist/create/server-source/crates/feltdb/src/sync.rs +0 -495
  223. package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +0 -822
  224. package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +0 -433
  225. package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +0 -462
  226. package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +0 -965
  227. package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +0 -1120
  228. package/dist/create/server-source/crates/feltdb/src/transactions.rs +0 -643
  229. package/dist/create/server-source/crates/feltdb/src/trigger.rs +0 -390
  230. package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +0 -749
  231. package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +0 -708
  232. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +0 -1133
  233. package/dist/create/server-source/crates/feltdb/src/workflow.rs +0 -713
  234. package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +0 -510
  235. package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +0 -354
  236. package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +0 -594
  237. package/dist/create/server-source/crates/feltdb/src/workload.rs +0 -2886
  238. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +0 -456
  239. package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +0 -1188
  240. package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +0 -2884
  241. package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +0 -297
  242. package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +0 -534
  243. package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +0 -132
  244. package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +0 -299
  245. package/dist/create/server-source/crates/feltdb/tests/common/mod.rs +0 -96
  246. package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +0 -272
  247. package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +0 -467
  248. package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +0 -309
  249. package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +0 -445
  250. package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +0 -518
  251. package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +0 -392
  252. package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +0 -850
  253. package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +0 -23340
  254. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +0 -1916
  255. package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +0 -1878
  256. package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +0 -1862
  257. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +0 -123
  258. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +0 -299
  259. package/dist/create/server-source/crates/feltdb/tests/local_process_ownership.rs +0 -69
  260. package/dist/create/server-source/crates/feltdb/tests/managed_incident_regression.rs +0 -191
  261. package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +0 -278
  262. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +0 -234
  263. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +0 -947
  264. package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +0 -404
  265. package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +0 -916
  266. package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +0 -1045
  267. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +0 -347
  268. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +0 -361
  269. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +0 -330
  270. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +0 -93
  271. package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +0 -622
  272. package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +0 -517
  273. package/dist/create/server-source/crates/feltdb/tests/production_certification.rs +0 -1153
  274. package/dist/create/server-source/crates/feltdb/tests/production_contract.rs +0 -771
  275. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +0 -1383
  276. package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +0 -868
  277. package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +0 -221
  278. package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +0 -417
  279. package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +0 -338
  280. package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +0 -1039
  281. package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +0 -739
  282. package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +0 -452
  283. package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +0 -81
  284. package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +0 -438
  285. package/dist/create/server-source/crates/feltdb/tests/semantic_decision.rs +0 -1165
  286. package/dist/create/server-source/crates/feltdb/tests/semantic_query.rs +0 -767
  287. package/dist/create/server-source/crates/feltdb/tests/semantic_query_contract.rs +0 -363
  288. package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +0 -1799
  289. package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +0 -1316
  290. package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +0 -358
  291. package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +0 -365
  292. package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +0 -918
  293. package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +0 -299
  294. package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +0 -328
  295. package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +0 -255
  296. package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +0 -249
  297. package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +0 -994
  298. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +0 -484
  299. package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
  300. package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
@@ -1,2886 +0,0 @@
1
- //! Durable workload authority. Workers execute; this state machine decides.
2
- use crate::{
3
- authorization::{SignedGrant, Subject},
4
- AtomicMutation, FeltDb, FlowError, RecordPrecondition,
5
- };
6
- use rand::{distributions::Alphanumeric, Rng};
7
- use serde::{Deserialize, Serialize};
8
- use serde_json::Value;
9
- use sha2::{Digest, Sha256};
10
- use std::collections::{BTreeMap, BTreeSet};
11
- use std::path::Path;
12
- pub const WORKLOAD_CONTRACT_VERSION: u32 = 1;
13
- pub const DURABLE_WORK_COLLECTION: &str = "system_durable_work";
14
-
15
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
16
- #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
17
- pub enum WorkloadState {
18
- Created,
19
- Ready,
20
- Claimed,
21
- Running,
22
- Checkpointed,
23
- Recovering,
24
- CancellationRequested,
25
- Succeeded,
26
- Failed,
27
- Cancelled,
28
- TimedOut,
29
- DeadLetter,
30
- }
31
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32
- #[serde(rename_all = "snake_case")]
33
- pub enum BackoffPolicy {
34
- Fixed,
35
- Exponential,
36
- }
37
- #[derive(Debug, Clone, Serialize, Deserialize)]
38
- pub struct WorkloadRetryPolicy {
39
- pub max_attempts: u32,
40
- pub initial_delay_ms: u64,
41
- pub max_delay_ms: u64,
42
- pub backoff: BackoffPolicy,
43
- #[serde(default)]
44
- pub retry_on: BTreeSet<String>,
45
- }
46
- impl Default for WorkloadRetryPolicy {
47
- fn default() -> Self {
48
- Self {
49
- max_attempts: 3,
50
- initial_delay_ms: 1000,
51
- max_delay_ms: 300_000,
52
- backoff: BackoffPolicy::Exponential,
53
- retry_on: [
54
- "timeout".into(),
55
- "worker_failure".into(),
56
- "transient_connection".into(),
57
- ]
58
- .into(),
59
- }
60
- }
61
- }
62
- #[derive(Debug, Clone, Serialize, Deserialize, Default)]
63
- pub struct WorkloadTimeouts {
64
- pub queue_ms: Option<u64>,
65
- pub claim_ms: Option<u64>,
66
- pub execution_ms: Option<u64>,
67
- pub heartbeat_ms: Option<u64>,
68
- pub overall_ms: Option<u64>,
69
- }
70
- #[derive(Debug, Clone, Serialize, Deserialize)]
71
- pub struct WorkloadDependency {
72
- pub workload_id: String,
73
- #[serde(default = "succeeded_state")]
74
- pub required_state: WorkloadState,
75
- }
76
- fn succeeded_state() -> WorkloadState {
77
- WorkloadState::Succeeded
78
- }
79
- #[derive(Debug, Clone, Serialize, Deserialize)]
80
- pub struct WorkloadClaim {
81
- pub claim_id: String,
82
- pub workload_id: String,
83
- pub worker_id: String,
84
- pub lease_id: String,
85
- pub fencing_token: u64,
86
- pub capability_snapshot: BTreeSet<String>,
87
- pub claimed_at: i64,
88
- pub expires_at: i64,
89
- pub last_heartbeat: i64,
90
- }
91
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
92
- #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
93
- pub enum AttemptStatus {
94
- Claimed,
95
- Running,
96
- Checkpointed,
97
- Succeeded,
98
- Failed,
99
- TimedOut,
100
- Cancelled,
101
- WorkerLost,
102
- }
103
- #[derive(Debug, Clone, Serialize, Deserialize)]
104
- pub struct WorkloadAttempt {
105
- pub attempt_id: String,
106
- pub workload_id: String,
107
- pub worker_id: String,
108
- pub claim_id: String,
109
- pub fencing_token: u64,
110
- pub started_at: Option<i64>,
111
- pub ended_at: Option<i64>,
112
- pub input_hash: String,
113
- pub result_hash: Option<String>,
114
- pub error: Option<String>,
115
- #[serde(default)]
116
- pub resource_usage: BTreeMap<String, u64>,
117
- pub status: AttemptStatus,
118
- }
119
- #[derive(Debug, Clone, Serialize, Deserialize)]
120
- pub struct WorkloadCheckpoint {
121
- pub checkpoint_id: String,
122
- pub workload_id: String,
123
- pub attempt_id: String,
124
- pub sequence: u64,
125
- pub state: Value,
126
- pub state_hash: String,
127
- pub created_at: i64,
128
- pub produced_by: String,
129
- }
130
- #[derive(Debug, Clone, Serialize, Deserialize)]
131
- pub struct WorkloadResult {
132
- pub result_id: String,
133
- pub workload_id: String,
134
- pub attempt_id: String,
135
- pub status: String,
136
- pub output: Value,
137
- pub output_hash: String,
138
- #[serde(default)]
139
- pub artifacts: Vec<String>,
140
- #[serde(default)]
141
- pub provenance: BTreeMap<String, String>,
142
- pub produced_by: String,
143
- pub created_at: i64,
144
- pub schema_version: u64,
145
- }
146
- #[derive(Debug, Clone, Serialize, Deserialize)]
147
- pub struct WorkloadExecutionContext {
148
- pub workload_id: String,
149
- pub attempt_id: String,
150
- pub worker_id: String,
151
- pub capabilities: BTreeSet<String>,
152
- pub connection_scopes: BTreeSet<String>,
153
- pub secret_references: Vec<String>,
154
- pub expires_at: i64,
155
- pub fencing_token: u64,
156
- }
157
- #[derive(Debug, Clone, Serialize, Deserialize)]
158
- pub struct WorkloadEvent {
159
- pub sequence: u64,
160
- pub event_type: String,
161
- pub from: Option<WorkloadState>,
162
- pub to: WorkloadState,
163
- pub actor: String,
164
- pub at: i64,
165
- #[serde(default)]
166
- pub evidence: BTreeMap<String, String>,
167
- }
168
- #[derive(Debug, Clone, Serialize, Deserialize)]
169
- pub struct Workload {
170
- pub contract_version: u32,
171
- pub workload_id: String,
172
- pub tenant_id: String,
173
- pub application_id: String,
174
- pub environment: String,
175
- pub definition_id: String,
176
- pub definition_revision: String,
177
- pub trigger: String,
178
- pub input: Value,
179
- pub input_hash: String,
180
- pub requested_by: Subject,
181
- #[serde(default)]
182
- pub session_id: Option<String>,
183
- pub authorization_snapshot: SignedGrant,
184
- pub capability_snapshot: BTreeSet<String>,
185
- pub connection_scopes: BTreeSet<String>,
186
- pub priority: i32,
187
- pub idempotency_key: String,
188
- pub created_at: i64,
189
- pub updated_at: i64,
190
- pub state: WorkloadState,
191
- pub execution_history: Vec<WorkloadEvent>,
192
- pub attempts: Vec<WorkloadAttempt>,
193
- pub checkpoints: Vec<WorkloadCheckpoint>,
194
- pub result: Option<WorkloadResult>,
195
- pub retry_policy: WorkloadRetryPolicy,
196
- pub timeouts: WorkloadTimeouts,
197
- pub attempt_count: u32,
198
- pub retry_count: u32,
199
- /// The instant, in unix seconds, before which this workload must not be
200
- /// claimed. `None` means "eligible as soon as it is `Ready`".
201
- ///
202
- /// This is the single temporal eligibility concept in the workload model.
203
- /// It is what retry backoff sets after a failure, what a timeout sets before
204
- /// re-arming, and what [`CreateWorkload::not_before`] sets at creation, so a
205
- /// workload that is not yet executable is always not-yet-executable for the
206
- /// same reason and is refused by the same check in [`WorkloadStore::claim`].
207
- pub next_attempt_at: Option<i64>,
208
- pub last_failure: Option<String>,
209
- pub current_claim: Option<WorkloadClaim>,
210
- pub fencing_token: u64,
211
- #[serde(default)]
212
- pub dependencies: Vec<WorkloadDependency>,
213
- }
214
-
215
- impl Workload {
216
- /// Has this workload's eligibility instant arrived?
217
- ///
218
- /// Eligibility is only half of claimability — the state must also be
219
- /// `Ready`, and [`WorkloadStore::claim`] checks both. This answers the
220
- /// temporal half alone so a scheduler can skip a workload that is merely
221
- /// early without attempting a claim it knows will be refused.
222
- pub fn is_eligible_at(&self, now: i64) -> bool {
223
- self.next_attempt_at.is_none_or(|instant| instant <= now)
224
- }
225
- }
226
-
227
- #[derive(Debug, Clone, Serialize, Deserialize)]
228
- pub struct CreateWorkload {
229
- pub tenant_id: String,
230
- pub application_id: String,
231
- pub environment: String,
232
- pub definition_id: String,
233
- pub definition_revision: String,
234
- pub trigger: String,
235
- pub input: Value,
236
- pub requested_by: Subject,
237
- #[serde(default)]
238
- pub session_id: Option<String>,
239
- pub authorization_snapshot: SignedGrant,
240
- pub capability_snapshot: BTreeSet<String>,
241
- #[serde(default)]
242
- pub connection_scopes: BTreeSet<String>,
243
- #[serde(default)]
244
- pub priority: i32,
245
- pub idempotency_key: String,
246
- #[serde(default)]
247
- pub retry_policy: WorkloadRetryPolicy,
248
- #[serde(default)]
249
- pub timeouts: WorkloadTimeouts,
250
- #[serde(default)]
251
- pub dependencies: Vec<WorkloadDependency>,
252
- /// The instant, in unix seconds, before which this workload must not be
253
- /// claimed. Omitted or `None` means it is claimable as soon as it is
254
- /// `Ready`, which is what every workload did before this field existed.
255
- ///
256
- /// A workload created with `not_before` is durable immediately and
257
- /// executable later: it reaches `Ready`, survives restart, and is refused to
258
- /// every worker until the instant arrives. It is *not* a schedule — there is
259
- /// no recurrence, no expression, and no component that re-arms it. Creating
260
- /// the next occurrence is the caller's business.
261
- ///
262
- /// A `not_before` already in the past is not an error. It simply describes a
263
- /// workload that is eligible now, which is the same thing as omitting it.
264
- #[serde(default)]
265
- pub not_before: Option<i64>,
266
- pub created_at: i64,
267
- }
268
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
269
- pub struct WorkloadError {
270
- pub code: String,
271
- pub message: String,
272
- }
273
- impl WorkloadError {
274
- fn new(code: &str, message: impl Into<String>) -> Self {
275
- Self {
276
- code: code.into(),
277
- message: message.into(),
278
- }
279
- }
280
- }
281
- impl std::fmt::Display for WorkloadError {
282
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283
- write!(f, "{}: {}", self.code, self.message)
284
- }
285
- }
286
- impl std::error::Error for WorkloadError {}
287
-
288
- #[derive(Clone)]
289
- pub struct WorkloadStore {
290
- db: FeltDb,
291
- pub workloads: BTreeMap<String, Workload>,
292
- idempotency: BTreeMap<String, String>,
293
- result_ids: BTreeMap<String, String>,
294
- /// Rebuildable cache of FeltDB-owned document versions. This is never
295
- /// serialized and never advances independently of a canonical commit.
296
- document_versions: BTreeMap<String, u64>,
297
- }
298
-
299
- #[derive(Serialize, Deserialize)]
300
- struct LegacyWorkloadStore {
301
- #[serde(default)]
302
- workloads: BTreeMap<String, Workload>,
303
- #[serde(default)]
304
- idempotency: BTreeMap<String, String>,
305
- #[serde(default)]
306
- result_ids: BTreeMap<String, String>,
307
- #[serde(default)]
308
- event_sequence: u64,
309
- }
310
-
311
- impl WorkloadStore {
312
- pub fn load(path: impl AsRef<Path>) -> Result<Self, WorkloadError> {
313
- Self::from_db(FeltDb::open(path).map_err(io)?)
314
- }
315
-
316
- pub fn from_db(db: FeltDb) -> Result<Self, WorkloadError> {
317
- let mut workloads = BTreeMap::new();
318
- let mut document_versions = BTreeMap::new();
319
- for row in db.list_collection(DURABLE_WORK_COLLECTION).map_err(io)? {
320
- let version = row
321
- .value
322
- .get("__version")
323
- .and_then(Value::as_u64)
324
- // Canonical FeltDB CAS defines a pre-versioned document as
325
- // version 1. The next guarded mutation writes version 2, which
326
- // upgrades rows created before Work adopted RecordPrecondition.
327
- .unwrap_or(1);
328
- let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
329
- document_versions.insert(workload.workload_id.clone(), version);
330
- workloads.insert(workload.workload_id.clone(), workload);
331
- }
332
- let idempotency = idempotency_index(&workloads, idempotency_scope);
333
- let mut result_ids = BTreeMap::new();
334
- for workload in workloads.values() {
335
- if let Some(result) = &workload.result {
336
- result_ids.insert(result.result_id.clone(), workload.workload_id.clone());
337
- }
338
- }
339
- Ok(Self {
340
- db,
341
- workloads,
342
- idempotency,
343
- result_ids,
344
- document_versions,
345
- })
346
- }
347
-
348
- pub fn migrate_legacy_file(db: FeltDb, path: &Path) -> Result<Self, WorkloadError> {
349
- let bytes = std::fs::read(path).map_err(io)?;
350
- let legacy: LegacyWorkloadStore = serde_json::from_slice(&bytes).map_err(io)?;
351
- let mut store = Self::from_db(db)?;
352
- for (id, workload) in legacy.workloads {
353
- if id != workload.workload_id {
354
- return Err(WorkloadError::new(
355
- "WORKLOAD_MIGRATION_CONFLICT",
356
- format!(
357
- "legacy key {id} does not match workload identity {}",
358
- workload.workload_id
359
- ),
360
- ));
361
- }
362
- if let Some(existing) = store.workloads.get(&id) {
363
- if serde_json::to_value(existing).map_err(io)?
364
- != serde_json::to_value(&workload).map_err(io)?
365
- {
366
- return Err(WorkloadError::new(
367
- "WORKLOAD_MIGRATION_CONFLICT",
368
- format!("canonical workload {id} differs from legacy source"),
369
- ));
370
- }
371
- continue;
372
- }
373
- store.workloads.insert(id.clone(), workload);
374
- store.persist_workload(&id, "workload_migration")?;
375
- }
376
- // The maps are verification evidence, not migrated authorities.
377
- let rebuilt = Self::from_db(store.db.clone())?;
378
- if legacy.idempotency != idempotency_index(&rebuilt.workloads, legacy_idempotency_scope)
379
- || legacy.result_ids != rebuilt.result_ids
380
- || legacy.event_sequence != derived_event_sequence(&rebuilt.workloads)
381
- {
382
- return Err(WorkloadError::new(
383
- "WORKLOAD_MIGRATION_CONFLICT",
384
- "legacy derived indexes do not match canonical work aggregates",
385
- ));
386
- }
387
- Ok(rebuilt)
388
- }
389
-
390
- fn persist_workload(&mut self, id: &str, action: &str) -> Result<(), WorkloadError> {
391
- let workload = self
392
- .workloads
393
- .get(id)
394
- .ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))?;
395
- let key = work_key(workload);
396
- let expected_version = self.document_versions.get(id).copied();
397
- let transaction_id = runtime_id("work_tx");
398
- let aggregate_digest = hash(&serde_json::to_value(workload).map_err(io)?);
399
- let commit = self
400
- .db
401
- .apply_atomic_transaction_guarded(
402
- &transaction_id,
403
- None,
404
- None,
405
- &[],
406
- &[RecordPrecondition {
407
- capability: DURABLE_WORK_COLLECTION.into(),
408
- key: key.clone(),
409
- require_absent: expected_version.is_none(),
410
- expected_version,
411
- expected_epoch: None,
412
- expected_lease_id: None,
413
- }],
414
- &[AtomicMutation {
415
- capability: DURABLE_WORK_COLLECTION.into(),
416
- key,
417
- value: Some(serde_json::to_value(workload).map_err(io)?),
418
- }],
419
- Some(serde_json::json!({
420
- "kind": action,
421
- "subject": workload.requested_by.id(),
422
- "principal_id": workload.requested_by.id(),
423
- "agent_id": (workload.requested_by.kind() == "agent").then(|| workload.requested_by.id()),
424
- "session_id": workload.session_id,
425
- "delegator_id": workload.authorization_snapshot.grant.issuer.id(),
426
- "authorization_decision_id": workload.authorization_snapshot.grant.id,
427
- "tenant": workload.tenant_id,
428
- "application": workload.application_id,
429
- "work_id": workload.workload_id,
430
- "operation_id": transaction_id,
431
- "attempt_id": workload.attempts.last().map(|attempt| attempt.attempt_id.as_str()),
432
- "worker_id": workload.current_claim.as_ref().map(|claim| claim.worker_id.as_str()),
433
- "result_id": workload.result.as_ref().map(|result| result.result_id.as_str()),
434
- "source_kind": (action == "workload_migration").then_some("WorkloadStore"),
435
- "source_digest": aggregate_digest,
436
- "migration_outcome": (action == "workload_migration").then_some("imported"),
437
- })),
438
- )
439
- .map_err(workload_storage_error)?;
440
- let version = commit
441
- .rows
442
- .first()
443
- .and_then(|row| row.value.get("__version"))
444
- .and_then(Value::as_u64)
445
- .ok_or_else(|| io("canonical work commit has no document version"))?;
446
- self.document_versions.insert(id.into(), version);
447
- Ok(())
448
- }
449
- pub fn create(&mut self, input: CreateWorkload) -> Result<Workload, WorkloadError> {
450
- self.admit(input).map(|(workload, _)| workload)
451
- }
452
-
453
- /// Create a workload, or return the one this requester already created
454
- /// under the same key.
455
- ///
456
- /// The key is scoped to the tenant, the application **and the requester**,
457
- /// so one requester's key can never return, reveal or block another's
458
- /// workload. A repeated key is answered with the original workload only when
459
- /// the request is the same — definition, revision, environment, input and
460
- /// capability snapshot; otherwise it is refused with
461
- /// `IDEMPOTENCY_KEY_REUSED` and nothing is created. The requester's
462
- /// authorization snapshot is checked before any replay. The boolean is
463
- /// `true` when this call created the workload.
464
- pub fn admit(&mut self, input: CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
465
- ensure_safe(&input.input)?;
466
- if input.idempotency_key.trim().is_empty() {
467
- return Err(WorkloadError::new(
468
- "WORKLOAD_INVALID",
469
- "idempotency key is required",
470
- ));
471
- }
472
- if input.authorization_snapshot.grant.subject != input.requested_by
473
- || input.authorization_snapshot.grant.tenant_id != input.tenant_id
474
- || input.authorization_snapshot.grant.application_id != input.application_id
475
- {
476
- return Err(WorkloadError::new(
477
- "WORKLOAD_UNAUTHORIZED",
478
- "authorization snapshot does not bind the requester",
479
- ));
480
- }
481
- let idempotency = idempotency_scope(
482
- &input.tenant_id,
483
- &input.application_id,
484
- &input.requested_by,
485
- &input.idempotency_key,
486
- );
487
- if let Some(id) = self.idempotency.get(&idempotency) {
488
- let existing = self.workloads[id].clone();
489
- return replay(existing, &input);
490
- }
491
- // The scoped idempotency identity is the record identity. Competing
492
- // admissions therefore contend on one canonical require-absent CAS
493
- // even when their payloads differ; no separate durable uniqueness
494
- // index is needed.
495
- let id = format!("workload_{}", &hex(&Sha256::digest(&idempotency))[..24]);
496
- let request = input.clone();
497
- let input_hash = hash(&input.input);
498
- let mut workload = Workload {
499
- contract_version: 1,
500
- workload_id: id.clone(),
501
- tenant_id: input.tenant_id,
502
- application_id: input.application_id,
503
- environment: input.environment,
504
- definition_id: input.definition_id,
505
- definition_revision: input.definition_revision,
506
- trigger: input.trigger,
507
- input: input.input,
508
- input_hash,
509
- requested_by: input.requested_by,
510
- session_id: input.session_id,
511
- authorization_snapshot: input.authorization_snapshot,
512
- capability_snapshot: input.capability_snapshot,
513
- connection_scopes: input.connection_scopes,
514
- priority: input.priority,
515
- idempotency_key: input.idempotency_key,
516
- created_at: input.created_at,
517
- updated_at: input.created_at,
518
- state: WorkloadState::Created,
519
- execution_history: vec![],
520
- attempts: vec![],
521
- checkpoints: vec![],
522
- result: None,
523
- retry_policy: input.retry_policy,
524
- timeouts: input.timeouts,
525
- attempt_count: 0,
526
- retry_count: 0,
527
- // Creation-time eligibility and retry backoff are the same thing, so
528
- // they are the same field. A workload with `not_before` is created
529
- // exactly as any other and is simply not yet claimable.
530
- next_attempt_at: input.not_before,
531
- last_failure: None,
532
- current_claim: None,
533
- fencing_token: 0,
534
- dependencies: input.dependencies,
535
- };
536
- // The durable history records the eligibility the workload was created
537
- // with, so "why was this Ready and unclaimed for an hour" is answerable
538
- // from the workload itself rather than from the caller's intent.
539
- let evidence = match workload.next_attempt_at {
540
- Some(instant) => [("not_before".into(), instant.to_string())].into(),
541
- None => BTreeMap::new(),
542
- };
543
- self.transition(
544
- &mut workload,
545
- WorkloadState::Ready,
546
- "rust",
547
- input.created_at,
548
- evidence,
549
- )?;
550
- let key = work_key(&workload);
551
- self.idempotency.insert(idempotency.clone(), id.clone());
552
- self.workloads.insert(id.clone(), workload.clone());
553
- if let Err(error) = self.persist_workload(&id, "workload.created") {
554
- self.workloads.remove(&id);
555
- self.idempotency.remove(&idempotency);
556
- // Another writer of the same store (a second process, or a replica)
557
- // admitted this identity first. Converge on what it committed.
558
- if error.code == "WORKLOAD_CONFLICT" {
559
- if let Some(existing) = self.reload_committed(&key)? {
560
- self.idempotency.insert(idempotency, id);
561
- return replay(existing, &request);
562
- }
563
- }
564
- return Err(error);
565
- }
566
- Ok((workload, true))
567
- }
568
- pub fn get(&self, id: &str) -> Result<&Workload, WorkloadError> {
569
- self.workloads
570
- .get(id)
571
- .ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))
572
- }
573
- pub fn list(&self, tenant: &str, application: &str) -> Vec<Workload> {
574
- let mut values = self
575
- .workloads
576
- .values()
577
- .filter(|v| v.tenant_id == tenant && v.application_id == application)
578
- .cloned()
579
- .collect::<Vec<_>>();
580
- values.sort_by_key(|v| (std::cmp::Reverse(v.priority), v.created_at));
581
- values
582
- }
583
- fn edit<R>(
584
- &mut self,
585
- id: &str,
586
- action: impl FnOnce(&mut Self, &mut Workload) -> Result<R, WorkloadError>,
587
- ) -> Result<R, WorkloadError> {
588
- let workload = self
589
- .workloads
590
- .get(id)
591
- .cloned()
592
- .ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))?;
593
- let mut edited = workload.clone();
594
- let result = action(self, &mut edited)?;
595
- self.workloads.insert(id.into(), edited);
596
- if let Err(error) = self.persist_workload(id, "workload.mutated") {
597
- self.workloads.insert(id.into(), workload.clone());
598
- // Another writer of the same store committed this workload first.
599
- // Nothing of this edit was committed; adopt what was, so this
600
- // projection does not keep acting on a version that no longer
601
- // exists (every later guarded commit from it would conflict).
602
- if error.code == "WORKLOAD_CONFLICT" {
603
- self.reload_committed(&work_key(&workload))?;
604
- }
605
- return Err(error);
606
- }
607
- Ok(result)
608
- }
609
- #[cfg(test)]
610
- pub(crate) fn db_for_tests(&self) -> FeltDb {
611
- self.db.clone()
612
- }
613
- /// Replace this projection's copy of a workload with the committed row.
614
- fn reload_committed(&mut self, key: &str) -> Result<Option<Workload>, WorkloadError> {
615
- let committed = self
616
- .db
617
- .query_collection(DURABLE_WORK_COLLECTION, Some(1), |row| row.key == key)
618
- .map_err(io)?;
619
- let Some(row) = committed.into_iter().next() else {
620
- return Ok(None);
621
- };
622
- let version = row
623
- .value
624
- .get("__version")
625
- .and_then(Value::as_u64)
626
- .unwrap_or(1);
627
- let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
628
- if let Some(result) = &workload.result {
629
- self.result_ids
630
- .insert(result.result_id.clone(), workload.workload_id.clone());
631
- }
632
- self.document_versions
633
- .insert(workload.workload_id.clone(), version);
634
- self.workloads
635
- .insert(workload.workload_id.clone(), workload.clone());
636
- Ok(Some(workload))
637
- }
638
- fn transition(
639
- &mut self,
640
- w: &mut Workload,
641
- to: WorkloadState,
642
- actor: &str,
643
- at: i64,
644
- evidence: BTreeMap<String, String>,
645
- ) -> Result<(), WorkloadError> {
646
- if !legal(w.state, to) {
647
- return Err(WorkloadError::new(
648
- "WORKLOAD_TRANSITION_DENIED",
649
- format!("{:?} cannot transition to {:?}", w.state, to),
650
- ));
651
- }
652
- let from = w.state;
653
- w.state = to;
654
- w.updated_at = at;
655
- let sequence = w
656
- .execution_history
657
- .last()
658
- .map_or(1, |event| event.sequence + 1);
659
- w.execution_history.push(WorkloadEvent {
660
- sequence,
661
- event_type: event_name(to).into(),
662
- from: Some(from),
663
- to,
664
- actor: actor.into(),
665
- at,
666
- evidence,
667
- });
668
- Ok(())
669
- }
670
- pub fn claim(
671
- &mut self,
672
- id: &str,
673
- worker: &str,
674
- capabilities: &BTreeSet<String>,
675
- now: i64,
676
- lease_ms: u64,
677
- ) -> Result<WorkloadClaim, WorkloadError> {
678
- // A Ready workload that has already used its budget (an operator
679
- // re-arm beyond it, or a record left by unbounded recovery before it
680
- // was bounded) is dead-lettered here rather than claimed again.
681
- let exhausted = self
682
- .get(id)
683
- .is_ok_and(|w| w.state == WorkloadState::Ready && w.attempt_count >= attempt_budget(w));
684
- if exhausted {
685
- self.edit(id, |store, w| {
686
- let evidence = [
687
- ("reason".into(), "attempt_budget_exhausted".into()),
688
- ("attempts_used".into(), w.attempt_count.to_string()),
689
- ("attempt_budget".into(), attempt_budget(w).to_string()),
690
- ]
691
- .into();
692
- store.transition(w, WorkloadState::DeadLetter, "rust.claim", now, evidence)
693
- })?;
694
- return Err(WorkloadError::new(
695
- "WORKLOAD_ATTEMPTS_EXHAUSTED",
696
- "workload has used its attempt budget",
697
- ));
698
- }
699
- self.edit(id, |store, w| {
700
- if w.state != WorkloadState::Ready {
701
- return Err(WorkloadError::new(
702
- "WORKLOAD_NOT_READY",
703
- "workload is not eligible",
704
- ));
705
- }
706
- // Separated from the state check because the two mean opposite
707
- // things to a worker: a workload in the wrong state may never become
708
- // claimable, while one that is merely early certainly will, at an
709
- // instant the error names. Collapsing them into one code left a
710
- // polling worker unable to tell "come back" from "give up".
711
- if !w.is_eligible_at(now) {
712
- return Err(WorkloadError::new(
713
- "WORKLOAD_NOT_YET_ELIGIBLE",
714
- format!(
715
- "workload is not claimable until {}",
716
- w.next_attempt_at.unwrap_or(now)
717
- ),
718
- ));
719
- }
720
- if !w.capability_snapshot.is_subset(capabilities) {
721
- return Err(WorkloadError::new(
722
- "WORKLOAD_CAPABILITY_DENIED",
723
- "worker lacks required capability snapshot",
724
- ));
725
- }
726
- w.fencing_token += 1;
727
- w.attempt_count += 1;
728
- let seed = format!("{}:{}:{}", w.workload_id, w.fencing_token, worker);
729
- let claim = WorkloadClaim {
730
- claim_id: format!("claim_{}", &hex(&Sha256::digest(seed.as_bytes()))[..20]),
731
- workload_id: w.workload_id.clone(),
732
- worker_id: worker.into(),
733
- lease_id: format!("lease_{}", w.fencing_token),
734
- fencing_token: w.fencing_token,
735
- capability_snapshot: w.capability_snapshot.clone(),
736
- claimed_at: now,
737
- expires_at: now + (lease_ms / 1000) as i64,
738
- last_heartbeat: now,
739
- };
740
- w.attempts.push(WorkloadAttempt {
741
- attempt_id: format!("attempt_{}_{}", w.workload_id, w.attempt_count),
742
- workload_id: w.workload_id.clone(),
743
- worker_id: worker.into(),
744
- claim_id: claim.claim_id.clone(),
745
- fencing_token: claim.fencing_token,
746
- started_at: None,
747
- ended_at: None,
748
- input_hash: w.input_hash.clone(),
749
- result_hash: None,
750
- error: None,
751
- resource_usage: BTreeMap::new(),
752
- status: AttemptStatus::Claimed,
753
- });
754
- w.current_claim = Some(claim.clone());
755
- store.transition(
756
- w,
757
- WorkloadState::Claimed,
758
- worker,
759
- now,
760
- [("fencing_token".into(), claim.fencing_token.to_string())].into(),
761
- )?;
762
- Ok(claim)
763
- })
764
- }
765
- fn fence(
766
- w: &Workload,
767
- claim: &str,
768
- worker: &str,
769
- token: u64,
770
- now: i64,
771
- ) -> Result<(), WorkloadError> {
772
- let current = w
773
- .current_claim
774
- .as_ref()
775
- .ok_or_else(|| WorkloadError::new("WORKLOAD_CLAIM_INVALID", "no active claim"))?;
776
- if current.claim_id != claim
777
- || current.worker_id != worker
778
- || current.fencing_token != token
779
- {
780
- return Err(WorkloadError::new(
781
- "WORKLOAD_STALE_FENCE",
782
- "claim or fencing token is stale",
783
- ));
784
- }
785
- if current.expires_at <= now {
786
- return Err(WorkloadError::new(
787
- "WORKLOAD_LEASE_EXPIRED",
788
- "claim lease expired",
789
- ));
790
- }
791
- Ok(())
792
- }
793
- pub fn start(
794
- &mut self,
795
- id: &str,
796
- claim: &str,
797
- worker: &str,
798
- token: u64,
799
- now: i64,
800
- ) -> Result<(), WorkloadError> {
801
- self.edit(id, |store, w| {
802
- Self::fence(w, claim, worker, token, now)?;
803
- let attempt = w.attempts.last_mut().unwrap();
804
- attempt.status = AttemptStatus::Running;
805
- attempt.started_at = Some(now);
806
- store.transition(w, WorkloadState::Running, worker, now, BTreeMap::new())
807
- })
808
- }
809
- pub fn heartbeat(
810
- &mut self,
811
- id: &str,
812
- claim: &str,
813
- worker: &str,
814
- token: u64,
815
- now: i64,
816
- extend_ms: u64,
817
- ) -> Result<WorkloadClaim, WorkloadError> {
818
- self.edit(id, |_, w| {
819
- Self::fence(w, claim, worker, token, now)?;
820
- let current = w.current_claim.as_mut().unwrap();
821
- current.last_heartbeat = now;
822
- current.expires_at = now + (extend_ms / 1000) as i64;
823
- Ok(current.clone())
824
- })
825
- }
826
- pub fn execution_context(
827
- &self,
828
- id: &str,
829
- claim: &str,
830
- worker: &str,
831
- token: u64,
832
- now: i64,
833
- secret_references: Vec<String>,
834
- ) -> Result<WorkloadExecutionContext, WorkloadError> {
835
- let workload = self.get(id)?;
836
- Self::fence(workload, claim, worker, token, now)?;
837
- if secret_references
838
- .iter()
839
- .any(|v| !v.starts_with("secret://"))
840
- {
841
- return Err(WorkloadError::new(
842
- "WORKLOAD_SECRET_REJECTED",
843
- "execution context accepts secret references only",
844
- ));
845
- }
846
- let current = workload.current_claim.as_ref().unwrap();
847
- Ok(WorkloadExecutionContext {
848
- workload_id: id.into(),
849
- attempt_id: workload.attempts.last().unwrap().attempt_id.clone(),
850
- worker_id: worker.into(),
851
- capabilities: current.capability_snapshot.clone(),
852
- connection_scopes: workload.connection_scopes.clone(),
853
- secret_references,
854
- expires_at: current.expires_at,
855
- fencing_token: token,
856
- })
857
- }
858
- pub fn checkpoint(
859
- &mut self,
860
- id: &str,
861
- claim: &str,
862
- worker: &str,
863
- token: u64,
864
- state: Value,
865
- now: i64,
866
- ) -> Result<WorkloadCheckpoint, WorkloadError> {
867
- ensure_safe(&state)?;
868
- self.edit(id, |store, w| {
869
- Self::fence(w, claim, worker, token, now)?;
870
- if !matches!(
871
- w.state,
872
- WorkloadState::Running | WorkloadState::Checkpointed
873
- ) {
874
- return Err(WorkloadError::new(
875
- "WORKLOAD_TRANSITION_DENIED",
876
- "checkpoint requires a running workload",
877
- ));
878
- }
879
- let attempt = w.attempts.last_mut().unwrap();
880
- attempt.status = AttemptStatus::Checkpointed;
881
- let sequence = w.checkpoints.len() as u64 + 1;
882
- let checkpoint = WorkloadCheckpoint {
883
- checkpoint_id: format!("checkpoint_{}_{}", w.workload_id, sequence),
884
- workload_id: w.workload_id.clone(),
885
- attempt_id: attempt.attempt_id.clone(),
886
- sequence,
887
- state_hash: hash(&state),
888
- state,
889
- created_at: now,
890
- produced_by: worker.into(),
891
- };
892
- w.checkpoints.push(checkpoint.clone());
893
- if w.state == WorkloadState::Running {
894
- store.transition(w, WorkloadState::Checkpointed, worker, now, BTreeMap::new())?;
895
- }
896
- Ok(checkpoint)
897
- })
898
- }
899
- pub fn complete(
900
- &mut self,
901
- id: &str,
902
- claim: &str,
903
- worker: &str,
904
- token: u64,
905
- result_id: &str,
906
- output: Value,
907
- artifacts: Vec<String>,
908
- schema_version: u64,
909
- now: i64,
910
- ) -> Result<WorkloadResult, WorkloadError> {
911
- ensure_safe(&output)?;
912
- if let Some(workload_id) = self.result_ids.get(result_id) {
913
- if workload_id == id {
914
- return Ok(self.get(id)?.result.clone().unwrap());
915
- }
916
- return Err(WorkloadError::new(
917
- "WORKLOAD_RESULT_DUPLICATE",
918
- "result id belongs to another workload",
919
- ));
920
- }
921
- let result = self.edit(id, |store, w| {
922
- Self::fence(w, claim, worker, token, now)?;
923
- if !matches!(
924
- w.state,
925
- WorkloadState::Running | WorkloadState::Checkpointed
926
- ) {
927
- return Err(WorkloadError::new(
928
- "WORKLOAD_TRANSITION_DENIED",
929
- "completion requires running state",
930
- ));
931
- }
932
- let attempt = w.attempts.last_mut().unwrap();
933
- let output_hash = hash(&output);
934
- attempt.status = AttemptStatus::Succeeded;
935
- attempt.ended_at = Some(now);
936
- attempt.result_hash = Some(output_hash.clone());
937
- let result = WorkloadResult {
938
- result_id: result_id.into(),
939
- workload_id: w.workload_id.clone(),
940
- attempt_id: attempt.attempt_id.clone(),
941
- status: "succeeded".into(),
942
- output,
943
- output_hash,
944
- artifacts,
945
- provenance: [
946
- ("claim_id".into(), claim.into()),
947
- ("fencing_token".into(), token.to_string()),
948
- ]
949
- .into(),
950
- produced_by: worker.into(),
951
- created_at: now,
952
- schema_version,
953
- };
954
- w.result = Some(result.clone());
955
- w.current_claim = None;
956
- store.transition(w, WorkloadState::Succeeded, worker, now, BTreeMap::new())?;
957
- Ok(result)
958
- })?;
959
- self.result_ids.insert(result_id.into(), id.into());
960
- Ok(result)
961
- }
962
- pub fn fail(
963
- &mut self,
964
- id: &str,
965
- claim: &str,
966
- worker: &str,
967
- token: u64,
968
- kind: &str,
969
- error: &str,
970
- now: i64,
971
- ) -> Result<WorkloadState, WorkloadError> {
972
- if sensitive_text(error) {
973
- return Err(WorkloadError::new(
974
- "WORKLOAD_SECRET_REJECTED",
975
- "errors may not contain secret material",
976
- ));
977
- }
978
- self.edit(id, |store, w| {
979
- Self::fence(w, claim, worker, token, now)?;
980
- let attempt = w.attempts.last_mut().unwrap();
981
- attempt.status = AttemptStatus::Failed;
982
- attempt.ended_at = Some(now);
983
- attempt.error = Some(error.into());
984
- w.last_failure = Some(format!("{kind}: {error}"));
985
- w.current_claim = None;
986
- store.transition(
987
- w,
988
- WorkloadState::Failed,
989
- worker,
990
- now,
991
- [("failure_kind".into(), kind.into())].into(),
992
- )?;
993
- let retryable =
994
- w.retry_policy.retry_on.contains(kind) && w.attempt_count < attempt_budget(w);
995
- if retryable {
996
- w.retry_count += 1;
997
- let multiplier = if w.retry_policy.backoff == BackoffPolicy::Exponential {
998
- 2u64.saturating_pow(w.retry_count.saturating_sub(1))
999
- } else {
1000
- 1
1001
- };
1002
- let delay = w
1003
- .retry_policy
1004
- .initial_delay_ms
1005
- .saturating_mul(multiplier)
1006
- .min(w.retry_policy.max_delay_ms);
1007
- w.next_attempt_at = Some(now + (delay / 1000) as i64);
1008
- store.transition(w, WorkloadState::Ready, "rust.retry", now, BTreeMap::new())?;
1009
- } else {
1010
- store.transition(
1011
- w,
1012
- WorkloadState::DeadLetter,
1013
- "rust.retry",
1014
- now,
1015
- BTreeMap::new(),
1016
- )?;
1017
- }
1018
- Ok(w.state)
1019
- })
1020
- }
1021
- pub fn cancel(
1022
- &mut self,
1023
- id: &str,
1024
- actor: &str,
1025
- now: i64,
1026
- ) -> Result<WorkloadState, WorkloadError> {
1027
- self.edit(id, |store, w| {
1028
- match w.state {
1029
- WorkloadState::Ready
1030
- | WorkloadState::Created
1031
- | WorkloadState::Failed
1032
- | WorkloadState::DeadLetter => {
1033
- w.current_claim = None;
1034
- store.transition(w, WorkloadState::Cancelled, actor, now, BTreeMap::new())?
1035
- }
1036
- WorkloadState::Claimed | WorkloadState::Running | WorkloadState::Checkpointed => {
1037
- store.transition(
1038
- w,
1039
- WorkloadState::CancellationRequested,
1040
- actor,
1041
- now,
1042
- BTreeMap::new(),
1043
- )?
1044
- }
1045
- WorkloadState::CancellationRequested => {}
1046
- _ => {
1047
- return Err(WorkloadError::new(
1048
- "WORKLOAD_TRANSITION_DENIED",
1049
- "terminal workload cannot be cancelled",
1050
- ))
1051
- }
1052
- }
1053
- Ok(w.state)
1054
- })
1055
- }
1056
- pub fn confirm_cancel(
1057
- &mut self,
1058
- id: &str,
1059
- claim: &str,
1060
- worker: &str,
1061
- token: u64,
1062
- now: i64,
1063
- ) -> Result<(), WorkloadError> {
1064
- self.edit(id, |store, w| {
1065
- Self::fence(w, claim, worker, token, now)?;
1066
- if w.state != WorkloadState::CancellationRequested {
1067
- return Err(WorkloadError::new(
1068
- "WORKLOAD_TRANSITION_DENIED",
1069
- "cancellation was not requested",
1070
- ));
1071
- }
1072
- if let Some(attempt) = w.attempts.last_mut() {
1073
- attempt.status = AttemptStatus::Cancelled;
1074
- attempt.ended_at = Some(now);
1075
- }
1076
- w.current_claim = None;
1077
- store.transition(w, WorkloadState::Cancelled, worker, now, BTreeMap::new())
1078
- })
1079
- }
1080
- pub fn timeout(
1081
- &mut self,
1082
- id: &str,
1083
- actor: &str,
1084
- now: i64,
1085
- ) -> Result<WorkloadState, WorkloadError> {
1086
- self.edit(id, |store, w| {
1087
- if !matches!(
1088
- w.state,
1089
- WorkloadState::Ready
1090
- | WorkloadState::Claimed
1091
- | WorkloadState::Running
1092
- | WorkloadState::Checkpointed
1093
- ) {
1094
- return Err(WorkloadError::new(
1095
- "WORKLOAD_TRANSITION_DENIED",
1096
- "workload cannot time out in its current state",
1097
- ));
1098
- }
1099
- if let Some(attempt) = w.attempts.last_mut() {
1100
- attempt.status = AttemptStatus::TimedOut;
1101
- attempt.ended_at = Some(now);
1102
- }
1103
- w.current_claim = None;
1104
- store.transition(w, WorkloadState::TimedOut, actor, now, BTreeMap::new())?;
1105
- if w.attempt_count < attempt_budget(w) && w.retry_policy.retry_on.contains("timeout")
1106
- {
1107
- w.retry_count += 1;
1108
- w.next_attempt_at = Some(now + (w.retry_policy.initial_delay_ms / 1000) as i64);
1109
- store.transition(w, WorkloadState::Ready, "rust.retry", now, BTreeMap::new())?;
1110
- } else {
1111
- store.transition(
1112
- w,
1113
- WorkloadState::DeadLetter,
1114
- "rust.timeout",
1115
- now,
1116
- BTreeMap::new(),
1117
- )?;
1118
- }
1119
- Ok(w.state)
1120
- })
1121
- }
1122
- pub fn retry(&mut self, id: &str, actor: &str, now: i64) -> Result<(), WorkloadError> {
1123
- self.retry_with_reason(id, actor, now, None)
1124
- }
1125
- /// An operator's re-arm of failed, dead-lettered or timed-out work.
1126
- ///
1127
- /// Each re-arm grants one more attempt, as it always has, and is recorded
1128
- /// in the history with its actor and reason. A workload may be re-armed at
1129
- /// most `retry_policy.max_attempts` times, so operator retries cannot
1130
- /// become an unbounded loop.
1131
- pub fn retry_with_reason(
1132
- &mut self,
1133
- id: &str,
1134
- actor: &str,
1135
- now: i64,
1136
- reason: Option<&str>,
1137
- ) -> Result<(), WorkloadError> {
1138
- self.edit(id, |store, w| {
1139
- if !matches!(
1140
- w.state,
1141
- WorkloadState::Failed | WorkloadState::DeadLetter | WorkloadState::TimedOut
1142
- ) {
1143
- return Err(WorkloadError::new(
1144
- "WORKLOAD_TRANSITION_DENIED",
1145
- "workload is not retryable",
1146
- ));
1147
- }
1148
- let previous = operator_retries(w);
1149
- if previous >= w.retry_policy.max_attempts {
1150
- return Err(WorkloadError::new(
1151
- "WORKLOAD_RETRY_EXHAUSTED",
1152
- "workload has used every operator retry its policy allows",
1153
- ));
1154
- }
1155
- w.next_attempt_at = Some(now);
1156
- w.current_claim = None;
1157
- let mut evidence: BTreeMap<String, String> = [
1158
- ("retry".into(), "operator".into()),
1159
- ("operator_retry".into(), (previous + 1).to_string()),
1160
- (
1161
- "attempt_budget".into(),
1162
- w.retry_policy
1163
- .max_attempts
1164
- .saturating_add(previous + 1)
1165
- .to_string(),
1166
- ),
1167
- ]
1168
- .into();
1169
- if let Some(reason) = reason.filter(|reason| !reason.trim().is_empty()) {
1170
- evidence.insert("reason".into(), reason.chars().take(256).collect());
1171
- }
1172
- store.transition(w, WorkloadState::Ready, actor, now, evidence)
1173
- })
1174
- }
1175
- pub fn recover_expired(&mut self, now: i64) -> Result<Vec<String>, WorkloadError> {
1176
- let ids = self
1177
- .workloads
1178
- .values()
1179
- .filter(|w| {
1180
- w.current_claim
1181
- .as_ref()
1182
- .is_some_and(|c| c.expires_at <= now)
1183
- && matches!(
1184
- w.state,
1185
- WorkloadState::Claimed
1186
- | WorkloadState::Running
1187
- | WorkloadState::Checkpointed
1188
- | WorkloadState::CancellationRequested
1189
- )
1190
- })
1191
- .map(|w| w.workload_id.clone())
1192
- .collect::<Vec<_>>();
1193
- let mut recovered = Vec::with_capacity(ids.len());
1194
- for id in ids {
1195
- let outcome = self.edit(&id, |store, w| {
1196
- let lost = w.current_claim.take();
1197
- if let Some(attempt) = w.attempts.last_mut() {
1198
- attempt.status = AttemptStatus::WorkerLost;
1199
- attempt.ended_at = Some(now);
1200
- attempt.error = Some("worker_lost: claim lease expired".into());
1201
- }
1202
- let mut evidence: BTreeMap<String, String> = [
1203
- ("recovery_reason".into(), "lease_expired".into()),
1204
- ("attempts_used".into(), w.attempt_count.to_string()),
1205
- ("attempt_budget".into(), attempt_budget(w).to_string()),
1206
- ]
1207
- .into();
1208
- if let Some(claim) = &lost {
1209
- evidence.insert("claim_id".into(), claim.claim_id.clone());
1210
- evidence.insert("worker_id".into(), claim.worker_id.clone());
1211
- evidence.insert("lease_expired_at".into(), claim.expires_at.to_string());
1212
- }
1213
- if let Some(attempt) = w.attempts.last() {
1214
- evidence.insert("attempt_id".into(), attempt.attempt_id.clone());
1215
- }
1216
- if w.state == WorkloadState::CancellationRequested {
1217
- store.transition(w, WorkloadState::Cancelled, "rust.recovery", now, evidence)?;
1218
- } else {
1219
- store.transition(
1220
- w,
1221
- WorkloadState::Recovering,
1222
- "rust.recovery",
1223
- now,
1224
- evidence,
1225
- )?;
1226
- // A lost worker is the policy's `worker_failure`. Whether
1227
- // the lost attempt had effects is unknown to FeltDB, so a
1228
- // retry re-executes it: at-least-once, never exactly-once.
1229
- let (to, decision) = if !w.retry_policy.retry_on.contains("worker_failure") {
1230
- (WorkloadState::DeadLetter, "worker_failure_not_retryable")
1231
- } else if w.attempt_count >= attempt_budget(w) {
1232
- (WorkloadState::DeadLetter, "attempt_budget_exhausted")
1233
- } else {
1234
- (WorkloadState::Ready, "retry")
1235
- };
1236
- store.transition(
1237
- w,
1238
- to,
1239
- "rust.recovery",
1240
- now,
1241
- [("decision".into(), decision.into())].into(),
1242
- )?;
1243
- }
1244
- Ok(())
1245
- });
1246
- match outcome {
1247
- Ok(()) => recovered.push(id),
1248
- // Another writer of this store committed the workload first —
1249
- // in a race, its own recovery. Nothing of this attempt was
1250
- // committed and `edit` has adopted the committed workload, so
1251
- // the attempt is recovered once, by that writer. If it still
1252
- // needs recovery, the next pass sees it.
1253
- Err(error) if error.code == "WORKLOAD_CONFLICT" => {}
1254
- Err(error) => return Err(error),
1255
- }
1256
- }
1257
- Ok(recovered)
1258
- }
1259
- }
1260
-
1261
- pub trait WorkScheduler {
1262
- fn enqueue(&mut self, workload_id: &str);
1263
- fn claim(
1264
- &mut self,
1265
- store: &mut WorkloadStore,
1266
- tenant: &str,
1267
- application: &str,
1268
- worker: &str,
1269
- capabilities: &BTreeSet<String>,
1270
- now: i64,
1271
- lease_ms: u64,
1272
- ) -> Result<Option<WorkloadClaim>, WorkloadError>;
1273
- fn release(&mut self, claim_id: &str);
1274
- }
1275
- #[derive(Default)]
1276
- pub struct LocalDurableScheduler {
1277
- queue: BTreeSet<String>,
1278
- }
1279
- impl WorkScheduler for LocalDurableScheduler {
1280
- fn enqueue(&mut self, id: &str) {
1281
- self.queue.insert(id.into());
1282
- }
1283
- fn claim(
1284
- &mut self,
1285
- store: &mut WorkloadStore,
1286
- tenant: &str,
1287
- application: &str,
1288
- worker: &str,
1289
- capabilities: &BTreeSet<String>,
1290
- now: i64,
1291
- lease_ms: u64,
1292
- ) -> Result<Option<WorkloadClaim>, WorkloadError> {
1293
- let selected = store
1294
- .list(tenant, application)
1295
- .into_iter()
1296
- .filter(|w| {
1297
- // Eligibility is part of selection, not a reason to fail. A
1298
- // workload that is merely early is skipped so the scheduler can
1299
- // hand out work that is ready now; without this it would pick an
1300
- // ineligible workload, have `store.claim` refuse it, and return
1301
- // an error while claimable work sat behind it.
1302
- w.state == WorkloadState::Ready
1303
- && w.is_eligible_at(now)
1304
- && w.capability_snapshot.is_subset(capabilities)
1305
- })
1306
- .find(|w| {
1307
- w.dependencies.iter().all(|d| {
1308
- store
1309
- .workloads
1310
- .get(&d.workload_id)
1311
- .is_some_and(|dependency| dependency.state == d.required_state)
1312
- })
1313
- })
1314
- .map(|w| w.workload_id);
1315
- match selected {
1316
- Some(id) => store
1317
- .claim(&id, worker, capabilities, now, lease_ms)
1318
- .map(Some),
1319
- None => Ok(None),
1320
- }
1321
- }
1322
- fn release(&mut self, _: &str) {}
1323
- }
1324
- fn legal(from: WorkloadState, to: WorkloadState) -> bool {
1325
- use WorkloadState::*;
1326
- matches!(
1327
- (from, to),
1328
- (Created, Ready)
1329
- | (Ready, Claimed)
1330
- | (Claimed, Running)
1331
- | (Running, Checkpointed)
1332
- | (Checkpointed, Checkpointed)
1333
- | (Running, Succeeded)
1334
- | (Checkpointed, Succeeded)
1335
- | (Claimed, CancellationRequested)
1336
- | (Running, CancellationRequested)
1337
- | (Checkpointed, CancellationRequested)
1338
- | (CancellationRequested, Cancelled)
1339
- | (Ready, Cancelled)
1340
- | (Created, Cancelled)
1341
- | (Failed, Cancelled)
1342
- | (DeadLetter, Cancelled)
1343
- | (Running, Failed)
1344
- | (Checkpointed, Failed)
1345
- | (Claimed, Recovering)
1346
- | (Running, Recovering)
1347
- | (Checkpointed, Recovering)
1348
- | (Recovering, Ready)
1349
- | (Failed, Ready)
1350
- | (TimedOut, Ready)
1351
- | (DeadLetter, Ready)
1352
- | (Failed, DeadLetter)
1353
- | (Running, TimedOut)
1354
- | (Claimed, TimedOut)
1355
- | (Checkpointed, TimedOut)
1356
- | (Ready, TimedOut)
1357
- | (TimedOut, DeadLetter)
1358
- | (Recovering, DeadLetter)
1359
- | (Ready, DeadLetter)
1360
- )
1361
- }
1362
- pub fn can_transition(from: WorkloadState, to: WorkloadState) -> bool {
1363
- legal(from, to)
1364
- }
1365
- fn event_name(state: WorkloadState) -> &'static str {
1366
- use WorkloadState::*;
1367
- match state {
1368
- Created => "workload.created",
1369
- Ready => "workload.ready",
1370
- Claimed => "workload.claimed",
1371
- Running => "workload.started",
1372
- Checkpointed => "workload.checkpointed",
1373
- Recovering => "workload.recovered",
1374
- CancellationRequested => "workload.cancellation_requested",
1375
- Succeeded => "workload.completed",
1376
- Failed => "workload.failed",
1377
- Cancelled => "workload.cancelled",
1378
- TimedOut => "workload.timed_out",
1379
- DeadLetter => "workload.dead_lettered",
1380
- }
1381
- }
1382
- fn ensure_safe(value: &Value) -> Result<(), WorkloadError> {
1383
- fn walk(value: &Value) -> bool {
1384
- match value {
1385
- Value::Object(map) => map.iter().any(|(k, v)| {
1386
- matches!(
1387
- k.to_ascii_lowercase().as_str(),
1388
- "password" | "secret" | "token" | "api_key" | "private_key"
1389
- ) || walk(v)
1390
- }),
1391
- Value::Array(values) => values.iter().any(walk),
1392
- Value::String(v) => sensitive_text(v),
1393
- _ => false,
1394
- }
1395
- }
1396
- if walk(value) {
1397
- Err(WorkloadError::new(
1398
- "WORKLOAD_SECRET_REJECTED",
1399
- "plaintext secrets are forbidden in workload state",
1400
- ))
1401
- } else {
1402
- Ok(())
1403
- }
1404
- }
1405
- fn sensitive_text(v: &str) -> bool {
1406
- v.contains("BEGIN PRIVATE KEY") || v.starts_with("sk-") || v.starts_with("fdb_live_")
1407
- }
1408
- /// The identity a caller's idempotency key is unique within: tenant,
1409
- /// application and requester. Encoded as a JSON array, so no choice of key or
1410
- /// identifier can make two different scopes coincide.
1411
- fn idempotency_scope(tenant: &str, application: &str, requester: &Subject, key: &str) -> String {
1412
- serde_json::json!([tenant, application, requester.kind(), requester.id(), key]).to_string()
1413
- }
1414
- /// The scope before keys were bound to their requester, kept only to verify
1415
- /// legacy store files during migration.
1416
- fn legacy_idempotency_scope(
1417
- tenant: &str,
1418
- application: &str,
1419
- _requester: &Subject,
1420
- key: &str,
1421
- ) -> String {
1422
- format!("{tenant}:{application}:{key}")
1423
- }
1424
- fn idempotency_index(
1425
- workloads: &BTreeMap<String, Workload>,
1426
- scope: fn(&str, &str, &Subject, &str) -> String,
1427
- ) -> BTreeMap<String, String> {
1428
- workloads
1429
- .values()
1430
- .map(|w| {
1431
- (
1432
- scope(&w.tenant_id, &w.application_id, &w.requested_by, &w.idempotency_key),
1433
- w.workload_id.clone(),
1434
- )
1435
- })
1436
- .collect()
1437
- }
1438
- /// Answer a repeated key: the original workload for the same request, a
1439
- /// refusal for a different one. Never the original for a different request.
1440
- fn replay(existing: Workload, input: &CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
1441
- let same = existing.requested_by == input.requested_by
1442
- && existing.definition_id == input.definition_id
1443
- && existing.definition_revision == input.definition_revision
1444
- && existing.environment == input.environment
1445
- && existing.input_hash == hash(&input.input)
1446
- && existing.capability_snapshot == input.capability_snapshot;
1447
- if same {
1448
- Ok((existing, false))
1449
- } else {
1450
- Err(WorkloadError::new(
1451
- "IDEMPOTENCY_KEY_REUSED",
1452
- "idempotency key was already used for a different request",
1453
- ))
1454
- }
1455
- }
1456
- /// Operator re-arms recorded in the workload's own history.
1457
- fn operator_retries(w: &Workload) -> u32 {
1458
- w.execution_history
1459
- .iter()
1460
- .filter(|event| event.evidence.get("retry").map(String::as_str) == Some("operator"))
1461
- .count() as u32
1462
- }
1463
- /// How many claims the workload may have in total: its policy's
1464
- /// `max_attempts`, plus one for each operator re-arm (which is what an operator
1465
- /// retry has always granted).
1466
- fn attempt_budget(w: &Workload) -> u32 {
1467
- w.retry_policy
1468
- .max_attempts
1469
- .saturating_add(operator_retries(w))
1470
- }
1471
- fn work_key(workload: &Workload) -> String {
1472
- format!(
1473
- "{}/{}/{}",
1474
- workload.tenant_id, workload.application_id, workload.workload_id
1475
- )
1476
- }
1477
- fn derived_event_sequence(workloads: &BTreeMap<String, Workload>) -> u64 {
1478
- workloads
1479
- .values()
1480
- .flat_map(|workload| workload.execution_history.iter())
1481
- .map(|event| event.sequence)
1482
- .max()
1483
- .unwrap_or_default()
1484
- }
1485
- fn runtime_id(prefix: &str) -> String {
1486
- let value: String = rand::thread_rng()
1487
- .sample_iter(&Alphanumeric)
1488
- .take(24)
1489
- .map(char::from)
1490
- .collect();
1491
- format!("{prefix}_{value}")
1492
- }
1493
- fn io(e: impl std::fmt::Display) -> WorkloadError {
1494
- WorkloadError::new("WORKLOAD_STORAGE_FAILURE", e.to_string())
1495
- }
1496
- fn workload_storage_error(error: FlowError) -> WorkloadError {
1497
- match error {
1498
- FlowError::PreconditionFailed(_) | FlowError::RevisionConflict { .. } => {
1499
- WorkloadError::new("WORKLOAD_CONFLICT", error.to_string())
1500
- }
1501
- FlowError::CapabilityError(ref message) if message.starts_with("PRECONDITION_FAILED:") => {
1502
- WorkloadError::new("WORKLOAD_CONFLICT", error.to_string())
1503
- }
1504
- _ => io(error),
1505
- }
1506
- }
1507
- fn hash(value: &Value) -> String {
1508
- hex(&Sha256::digest(
1509
- serde_json::to_vec(value).unwrap_or_default(),
1510
- ))
1511
- }
1512
- fn hex(bytes: &[u8]) -> String {
1513
- bytes.iter().map(|v| format!("{v:02x}")).collect()
1514
- }
1515
-
1516
- #[cfg(test)]
1517
- mod tests {
1518
- use super::*;
1519
- use crate::authorization::{
1520
- Grant, GrantConstraints, GrantSigner, GrantStatus, ResourceScope, ResourceScopeKind,
1521
- };
1522
- use std::{fs, path::PathBuf};
1523
- fn create() -> CreateWorkload {
1524
- create_as("a1")
1525
- }
1526
- /// The fixture request, made by agent `agent`.
1527
- fn create_as(agent: &str) -> CreateWorkload {
1528
- let signer = GrantSigner::new("k", vec![1; 32]).unwrap();
1529
- let subject = Subject::Agent(agent.into());
1530
- let grant = Grant {
1531
- contract_version: 1,
1532
- id: "g".into(),
1533
- issuer: Subject::Human("h".into()),
1534
- subject: subject.clone(),
1535
- tenant_id: "t".into(),
1536
- application_id: "app".into(),
1537
- capabilities: ["workload:execute".into()].into(),
1538
- resources: vec![ResourceScope {
1539
- kind: ResourceScopeKind::Workload,
1540
- uri: "flow://app/workloads/*".into(),
1541
- }],
1542
- constraints: GrantConstraints::default(),
1543
- issued_at: 1,
1544
- expires_at: 9999,
1545
- parent_grant: None,
1546
- revision: 1,
1547
- status: GrantStatus::Active,
1548
- };
1549
- CreateWorkload {
1550
- tenant_id: "t".into(),
1551
- application_id: "app".into(),
1552
- environment: "production".into(),
1553
- definition_id: "send".into(),
1554
- definition_revision: "r1".into(),
1555
- trigger: "manual".into(),
1556
- input: serde_json::json!({"id":1}),
1557
- requested_by: subject,
1558
- session_id: Some("session-1".into()),
1559
- authorization_snapshot: signer.sign(grant).unwrap(),
1560
- capability_snapshot: ["email:send".into()].into(),
1561
- connection_scopes: BTreeSet::new(),
1562
- priority: 0,
1563
- idempotency_key: "one".into(),
1564
- retry_policy: Default::default(),
1565
- timeouts: Default::default(),
1566
- dependencies: vec![],
1567
- not_before: None,
1568
- created_at: 10,
1569
- }
1570
- }
1571
- fn capabilities() -> BTreeSet<String> {
1572
- ["email:send".into()].into()
1573
- }
1574
-
1575
- #[test]
1576
- fn idempotency_keys_are_bound_to_their_requester_and_request() {
1577
- let (path, mut store) = store("requester-scoped-keys");
1578
- let (original, created) = store.admit(create_as("a1")).unwrap();
1579
- assert!(created);
1580
-
1581
- // The same requester and request: the original, not a second workload.
1582
- let (replayed, created) = store.admit(create_as("a1")).unwrap();
1583
- assert!(!created);
1584
- assert_eq!(replayed.workload_id, original.workload_id);
1585
-
1586
- // The same key for a different request is refused and creates nothing.
1587
- let mut different = create_as("a1");
1588
- different.input = serde_json::json!({"id": 2});
1589
- assert_eq!(store.admit(different).unwrap_err().code, "IDEMPOTENCY_KEY_REUSED");
1590
- let mut other_definition = create_as("a1");
1591
- other_definition.definition_id = "other".into();
1592
- assert_eq!(
1593
- store.admit(other_definition).unwrap_err().code,
1594
- "IDEMPOTENCY_KEY_REUSED"
1595
- );
1596
-
1597
- // Another requester's identical key is its own workload: it neither
1598
- // returns, reveals nor blocks the first requester's.
1599
- let (theirs, created) = store.admit(create_as("a2")).unwrap();
1600
- assert!(created);
1601
- assert_ne!(theirs.workload_id, original.workload_id);
1602
- assert_eq!(store.workloads.len(), 2);
1603
-
1604
- // A snapshot that does not bind the requester is refused before any
1605
- // replay, so a key cannot be used to fetch a workload.
1606
- let mut unbound = create_as("a1");
1607
- unbound.requested_by = Subject::Agent("a2".into());
1608
- assert_eq!(store.admit(unbound).unwrap_err().code, "WORKLOAD_UNAUTHORIZED");
1609
- let _ = fs::remove_file(path);
1610
- }
1611
-
1612
- #[test]
1613
- fn lease_recovery_records_the_lost_worker_and_is_bounded_by_the_attempt_budget() {
1614
- let (path, mut store) = store("bounded-recovery");
1615
- let workload = store.create(create()).unwrap();
1616
- let id = workload.workload_id.clone();
1617
- for attempt in 1..=3u32 {
1618
- let now = 100 * attempt as i64;
1619
- let claim = store.claim(&id, "worker-a", &capabilities(), now, 10_000).unwrap();
1620
- assert_eq!(store.recover_expired(claim.expires_at).unwrap(), vec![id.clone()]);
1621
- let recovered = store.get(&id).unwrap();
1622
- let lost = recovered.attempts.last().unwrap();
1623
- assert_eq!(lost.status, AttemptStatus::WorkerLost);
1624
- assert_eq!(lost.claim_id, claim.claim_id);
1625
- let recovering = recovered
1626
- .execution_history
1627
- .iter()
1628
- .rev()
1629
- .find(|event| event.to == WorkloadState::Recovering)
1630
- .unwrap();
1631
- assert_eq!(recovering.evidence["recovery_reason"], "lease_expired");
1632
- assert_eq!(recovering.evidence["worker_id"], "worker-a");
1633
- assert_eq!(recovering.evidence["claim_id"], claim.claim_id);
1634
- assert_eq!(recovering.evidence["attempts_used"], attempt.to_string());
1635
- let decision = &recovered.execution_history.last().unwrap();
1636
- if attempt < 3 {
1637
- assert_eq!(recovered.state, WorkloadState::Ready);
1638
- assert_eq!(decision.evidence["decision"], "retry");
1639
- } else {
1640
- assert_eq!(recovered.state, WorkloadState::DeadLetter);
1641
- assert_eq!(decision.evidence["decision"], "attempt_budget_exhausted");
1642
- }
1643
- }
1644
- assert_eq!(store.get(&id).unwrap().attempt_count, 3);
1645
- assert_eq!(
1646
- store.claim(&id, "worker-b", &capabilities(), 1_000, 10_000).unwrap_err().code,
1647
- "WORKLOAD_NOT_READY"
1648
- );
1649
- let _ = fs::remove_file(path);
1650
- }
1651
-
1652
- /// The recovery history of `id` as committed, read by a fresh projection.
1653
- fn committed_workload(db: &FeltDb, id: &str) -> Workload {
1654
- WorkloadStore::from_db(db.clone()).unwrap().get(id).unwrap().clone()
1655
- }
1656
-
1657
- fn recoveries(workload: &Workload) -> usize {
1658
- workload
1659
- .execution_history
1660
- .iter()
1661
- .filter(|event| event.to == WorkloadState::Recovering)
1662
- .count()
1663
- }
1664
-
1665
- /// Two writers of one store recover the same expired workload at once.
1666
- fn race_recovery(db: &FeltDb, now: i64) -> (WorkloadStore, WorkloadStore, Vec<Vec<String>>) {
1667
- let stores = [
1668
- WorkloadStore::from_db(db.clone()).unwrap(),
1669
- WorkloadStore::from_db(db.clone()).unwrap(),
1670
- ];
1671
- let barrier = std::sync::Barrier::new(2);
1672
- let results = std::thread::scope(|scope| {
1673
- let handles = stores
1674
- .into_iter()
1675
- .map(|mut store| {
1676
- let barrier = &barrier;
1677
- scope.spawn(move || {
1678
- barrier.wait();
1679
- let recovered = store.recover_expired(now).unwrap();
1680
- (store, recovered)
1681
- })
1682
- })
1683
- .collect::<Vec<_>>();
1684
- handles
1685
- .into_iter()
1686
- .map(|handle| handle.join().unwrap())
1687
- .collect::<Vec<_>>()
1688
- });
1689
- let mut results = results.into_iter();
1690
- let (first, first_recovered) = results.next().unwrap();
1691
- let (second, second_recovered) = results.next().unwrap();
1692
- (first, second, vec![first_recovered, second_recovered])
1693
- }
1694
-
1695
- #[test]
1696
- fn concurrent_recovery_commits_one_transition_and_the_loser_converges() {
1697
- for round in 0..20 {
1698
- let (path, mut store) = store(&format!("concurrent-recovery-{round}"));
1699
- let db = store.db.clone();
1700
- let id = store.create(create()).unwrap().workload_id;
1701
- let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
1702
- // A writer that loaded the workload before either recovery.
1703
- let mut stale = WorkloadStore::from_db(db.clone()).unwrap();
1704
-
1705
- let (mut first, mut second, recovered) = race_recovery(&db, claim.expires_at);
1706
- // Exactly one writer committed the recovery; the other reported none.
1707
- let mut outcomes = recovered.clone();
1708
- outcomes.sort();
1709
- assert_eq!(outcomes, vec![vec![], vec![id.clone()]], "round {round}");
1710
-
1711
- let committed = committed_workload(&db, &id);
1712
- assert_eq!(recoveries(&committed), 1, "one recovery transition");
1713
- assert_eq!(committed.state, WorkloadState::Ready);
1714
- assert_eq!(committed.attempt_count, 1);
1715
- assert_eq!(committed.attempts.len(), 1);
1716
- assert_eq!(committed.attempts[0].status, AttemptStatus::WorkerLost);
1717
- assert!(committed.current_claim.is_none());
1718
- let recovering = committed
1719
- .execution_history
1720
- .iter()
1721
- .find(|event| event.to == WorkloadState::Recovering)
1722
- .unwrap();
1723
- assert_eq!(recovering.evidence["claim_id"], claim.claim_id);
1724
- assert_eq!(recovering.evidence["attempts_used"], "1");
1725
-
1726
- // The losing writer adopted the committed workload instead of
1727
- // keeping its own, uncommitted view.
1728
- let expected = serde_json::to_value(&committed).unwrap();
1729
- assert_eq!(serde_json::to_value(first.get(&id).unwrap()).unwrap(), expected);
1730
- assert_eq!(serde_json::to_value(second.get(&id).unwrap()).unwrap(), expected);
1731
- // And neither recovers it again, or commits anything more.
1732
- assert!(first.recover_expired(claim.expires_at + 1).unwrap().is_empty());
1733
- assert!(second.recover_expired(claim.expires_at + 1).unwrap().is_empty());
1734
- assert_eq!(
1735
- serde_json::to_value(committed_workload(&db, &id)).unwrap(),
1736
- expected,
1737
- "no further commit"
1738
- );
1739
-
1740
- // The lost claimant cannot complete, even through a projection
1741
- // that still shows its claim as current and unexpired: the guarded
1742
- // commit refuses the stale version.
1743
- let refused = stale
1744
- .start(&id, &claim.claim_id, "worker-a", claim.fencing_token, claim.expires_at - 1)
1745
- .unwrap_err();
1746
- assert_eq!(refused.code, "WORKLOAD_CONFLICT");
1747
- assert_eq!(stale.get(&id).unwrap().state, WorkloadState::Ready);
1748
- let refused = stale
1749
- .complete(
1750
- &id,
1751
- &claim.claim_id,
1752
- "worker-a",
1753
- claim.fencing_token,
1754
- "late-result",
1755
- serde_json::json!({"late": true}),
1756
- vec![],
1757
- 1,
1758
- claim.expires_at - 1,
1759
- )
1760
- .unwrap_err();
1761
- assert_eq!(refused.code, "WORKLOAD_CLAIM_INVALID");
1762
- // Through a current projection its fence no longer matches.
1763
- let refused = first
1764
- .heartbeat(&id, &claim.claim_id, "worker-a", claim.fencing_token, claim.expires_at - 1, 10_000)
1765
- .unwrap_err();
1766
- assert_eq!(refused.code, "WORKLOAD_CLAIM_INVALID");
1767
- assert_eq!(serde_json::to_value(committed_workload(&db, &id)).unwrap(), expected);
1768
- drop((store, first, second, stale, db));
1769
- let _ = fs::remove_file(path);
1770
- }
1771
- }
1772
-
1773
- #[test]
1774
- fn concurrent_recovery_never_exceeds_the_attempt_budget() {
1775
- let (path, mut store) = store("concurrent-recovery-budget");
1776
- let db = store.db.clone();
1777
- let id = store.create(create()).unwrap().workload_id;
1778
- for attempt in 1..=3i64 {
1779
- // Each attempt is claimed by an up-to-date writer and lost.
1780
- let mut claimer = WorkloadStore::from_db(db.clone()).unwrap();
1781
- let claim = claimer
1782
- .claim(&id, "worker-a", &capabilities(), 100 * attempt, 10_000)
1783
- .unwrap();
1784
- let (_, _, recovered) = race_recovery(&db, claim.expires_at);
1785
- assert_eq!(recovered.concat(), vec![id.clone()], "attempt {attempt}");
1786
- let committed = committed_workload(&db, &id);
1787
- assert_eq!(recoveries(&committed), attempt as usize);
1788
- assert_eq!(committed.attempt_count, attempt as u32);
1789
- }
1790
- let committed = committed_workload(&db, &id);
1791
- assert_eq!(committed.state, WorkloadState::DeadLetter);
1792
- assert_eq!(
1793
- committed.execution_history.last().unwrap().evidence["decision"],
1794
- "attempt_budget_exhausted"
1795
- );
1796
- assert!(committed
1797
- .attempts
1798
- .iter()
1799
- .all(|attempt| attempt.status == AttemptStatus::WorkerLost));
1800
- let refused = WorkloadStore::from_db(db.clone())
1801
- .unwrap()
1802
- .claim(&id, "worker-b", &capabilities(), 10_000, 10_000)
1803
- .unwrap_err();
1804
- assert_eq!(refused.code, "WORKLOAD_NOT_READY");
1805
- assert_eq!(committed_workload(&db, &id).attempt_count, 3);
1806
- drop((store, db));
1807
- let _ = fs::remove_file(path);
1808
- }
1809
-
1810
- #[test]
1811
- fn a_policy_that_does_not_retry_worker_failure_dead_letters_a_lost_attempt() {
1812
- let (path, mut store) = store("no-worker-failure-retry");
1813
- let mut request = create();
1814
- request.retry_policy.retry_on = ["timeout".into()].into();
1815
- let id = store.create(request).unwrap().workload_id;
1816
- let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
1817
- store.recover_expired(claim.expires_at).unwrap();
1818
- let workload = store.get(&id).unwrap();
1819
- assert_eq!(workload.state, WorkloadState::DeadLetter);
1820
- assert_eq!(
1821
- workload.execution_history.last().unwrap().evidence["decision"],
1822
- "worker_failure_not_retryable"
1823
- );
1824
- let _ = fs::remove_file(path);
1825
- }
1826
-
1827
- #[test]
1828
- fn claiming_exhausted_ready_work_dead_letters_it_instead() {
1829
- let (path, mut store) = store("exhausted-claim");
1830
- let id = store.create(create()).unwrap().workload_id;
1831
- // What unbounded recovery used to leave behind: Ready, budget spent.
1832
- let mut legacy = store.get(&id).unwrap().clone();
1833
- legacy.attempt_count = 7;
1834
- store.workloads.insert(id.clone(), legacy);
1835
- store.persist_workload(&id, "fixture.legacy").unwrap();
1836
- let refused = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap_err();
1837
- assert_eq!(refused.code, "WORKLOAD_ATTEMPTS_EXHAUSTED");
1838
- let workload = store.get(&id).unwrap();
1839
- assert_eq!(workload.state, WorkloadState::DeadLetter);
1840
- assert_eq!(
1841
- workload.execution_history.last().unwrap().evidence["reason"],
1842
- "attempt_budget_exhausted"
1843
- );
1844
- // Durable: a restart sees the same outcome.
1845
- let reopened = WorkloadStore::from_db(store.db.clone()).unwrap();
1846
- assert_eq!(reopened.get(&id).unwrap().state, WorkloadState::DeadLetter);
1847
- let _ = fs::remove_file(path);
1848
- }
1849
-
1850
- #[test]
1851
- fn operator_retry_grants_one_attempt_is_recorded_and_is_bounded() {
1852
- let (path, mut store) = store("operator-retry");
1853
- let id = store.create(create()).unwrap().workload_id;
1854
- // Claim and lose leases until the workload stops being claimable.
1855
- fn exhaust(store: &mut WorkloadStore, id: &str, now: &mut i64) {
1856
- loop {
1857
- *now += 100;
1858
- match store.claim(id, "worker-a", &capabilities(), *now, 10_000) {
1859
- Ok(claim) => {
1860
- store.recover_expired(claim.expires_at).unwrap();
1861
- }
1862
- Err(error) => {
1863
- assert!(
1864
- matches!(
1865
- error.code.as_str(),
1866
- "WORKLOAD_NOT_READY" | "WORKLOAD_ATTEMPTS_EXHAUSTED"
1867
- ),
1868
- "{error:?}"
1869
- );
1870
- return;
1871
- }
1872
- }
1873
- }
1874
- }
1875
- let mut now = 100;
1876
- exhaust(&mut store, &id, &mut now);
1877
- assert_eq!(store.get(&id).unwrap().attempt_count, 3);
1878
- for retry in 1..=3u32 {
1879
- now += 100;
1880
- store
1881
- .retry_with_reason(&id, "operator-1", now, Some("dependency restored"))
1882
- .unwrap();
1883
- let event = store.get(&id).unwrap().execution_history.last().unwrap().clone();
1884
- assert_eq!(event.actor, "operator-1");
1885
- assert_eq!(event.evidence["retry"], "operator");
1886
- assert_eq!(event.evidence["reason"], "dependency restored");
1887
- assert_eq!(event.evidence["operator_retry"], retry.to_string());
1888
- exhaust(&mut store, &id, &mut now);
1889
- assert_eq!(
1890
- store.get(&id).unwrap().attempt_count,
1891
- 3 + retry,
1892
- "each operator retry grants exactly one attempt"
1893
- );
1894
- assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
1895
- }
1896
- assert_eq!(
1897
- store.retry(&id, "operator-1", now + 100).unwrap_err().code,
1898
- "WORKLOAD_RETRY_EXHAUSTED"
1899
- );
1900
- assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
1901
- let _ = fs::remove_file(path);
1902
- }
1903
-
1904
- fn store(name: &str) -> (PathBuf, WorkloadStore) {
1905
- let path =
1906
- std::env::temp_dir().join(format!("workload-{name}-{}.json", std::process::id()));
1907
- let store = WorkloadStore::load(&path).unwrap();
1908
- (path, store)
1909
- }
1910
- #[test]
1911
- fn legacy_migration_is_idempotent_conflict_detecting_and_source_independent() {
1912
- let (source_db, mut source) = store("legacy-source-builder");
1913
- let workload = source.create(create()).unwrap();
1914
- let legacy = LegacyWorkloadStore {
1915
- workloads: source.workloads.clone(),
1916
- // What a pre-requester-scope release wrote.
1917
- idempotency: idempotency_index(&source.workloads, legacy_idempotency_scope),
1918
- result_ids: source.result_ids.clone(),
1919
- event_sequence: derived_event_sequence(&source.workloads),
1920
- };
1921
- let legacy_path = source_db.with_extension("legacy-workloads.json");
1922
- fs::write(&legacy_path, serde_json::to_vec(&legacy).unwrap()).unwrap();
1923
- let canonical_path = source_db.with_extension("canonical.log");
1924
- let db = FeltDb::open(&canonical_path).unwrap();
1925
- let migrated = WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
1926
- assert_eq!(
1927
- migrated.get(&workload.workload_id).unwrap().input_hash,
1928
- workload.input_hash
1929
- );
1930
- WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
1931
- fs::remove_file(&legacy_path).unwrap();
1932
- let reopened = WorkloadStore::from_db(FeltDb::open(&canonical_path).unwrap()).unwrap();
1933
- assert_eq!(
1934
- reopened.get(&workload.workload_id).unwrap().workload_id,
1935
- workload.workload_id
1936
- );
1937
-
1938
- let mut conflicting = legacy;
1939
- conflicting
1940
- .workloads
1941
- .get_mut(&workload.workload_id)
1942
- .unwrap()
1943
- .priority += 1;
1944
- fs::write(&legacy_path, serde_json::to_vec(&conflicting).unwrap()).unwrap();
1945
- assert_eq!(
1946
- WorkloadStore::migrate_legacy_file(db, &legacy_path)
1947
- .err()
1948
- .unwrap()
1949
- .code,
1950
- "WORKLOAD_MIGRATION_CONFLICT"
1951
- );
1952
- let _ = fs::remove_file(source_db);
1953
- let _ = fs::remove_file(canonical_path);
1954
- let _ = fs::remove_file(legacy_path);
1955
- }
1956
-
1957
- #[test]
1958
- fn canonical_history_reconstructs_work_identity_provenance() {
1959
- let (path, mut store) = store("provenance");
1960
- let workload = store.create(create()).unwrap();
1961
- let transaction = store
1962
- .db
1963
- .committed_transactions_after(0, usize::MAX)
1964
- .unwrap()
1965
- .into_iter()
1966
- .last()
1967
- .unwrap();
1968
- let audit = transaction.audit.unwrap();
1969
- assert_eq!(audit["work_id"], workload.workload_id);
1970
- assert_eq!(audit["session_id"], workload.session_id.as_deref().unwrap());
1971
- assert_eq!(audit["agent_id"], workload.requested_by.id());
1972
- assert_eq!(
1973
- audit["delegator_id"],
1974
- workload.authorization_snapshot.grant.issuer.id()
1975
- );
1976
- assert!(transaction.commit_revision > transaction.base_revision);
1977
- assert_eq!(transaction.work_id.as_deref(), Some(workload.workload_id.as_str()));
1978
- assert_eq!(transaction.agent_id.as_deref(), Some(workload.requested_by.id()));
1979
- assert_eq!(transaction.session_id, workload.session_id);
1980
- assert_eq!(
1981
- transaction.delegator_id.as_deref(),
1982
- Some(workload.authorization_snapshot.grant.issuer.id())
1983
- );
1984
- assert_eq!(transaction.operation_ids.len(), 1);
1985
- assert_ne!(transaction.operation_ids[0], transaction.transaction_id);
1986
- drop(store);
1987
- let reopened = WorkloadStore::load(&path).unwrap();
1988
- assert_eq!(
1989
- reopened.get(&workload.workload_id).unwrap().workload_id,
1990
- workload.workload_id
1991
- );
1992
- let _ = fs::remove_file(path);
1993
- }
1994
-
1995
- #[test]
1996
- fn competing_writers_use_canonical_document_cas_before_and_after_restart() {
1997
- let (path, mut seed) = store("canonical-cas");
1998
- let workload = seed.create(create()).unwrap();
1999
- let id = workload.workload_id.clone();
2000
- let db = seed.db.clone();
2001
- let caps = ["email:send".into()].into();
2002
-
2003
- let mut winner = WorkloadStore::from_db(db.clone()).unwrap();
2004
- let mut loser = WorkloadStore::from_db(db.clone()).unwrap();
2005
- winner.claim(&id, "worker-a", &caps, 11, 1_000).unwrap();
2006
- let conflict = loser.claim(&id, "worker-b", &caps, 11, 1_000).unwrap_err();
2007
- assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
2008
- // The loser committed nothing and now projects the winner's claim.
2009
- let projected = loser.get(&id).unwrap();
2010
- assert_eq!(projected.state, WorkloadState::Claimed);
2011
- assert_eq!(projected.current_claim.as_ref().unwrap().worker_id, "worker-a");
2012
-
2013
- let row = db
2014
- .list_collection(DURABLE_WORK_COLLECTION)
2015
- .unwrap()
2016
- .into_iter()
2017
- .find(|row| row.key.ends_with(&id))
2018
- .unwrap();
2019
- assert_eq!(row.value["__version"], 2);
2020
-
2021
- drop((seed, winner, loser, db));
2022
- let restarted_db = FeltDb::open(&path).unwrap();
2023
- let mut recovery = WorkloadStore::from_db(restarted_db.clone()).unwrap();
2024
- recovery.recover_expired(13).unwrap();
2025
- let mut restarted_winner = WorkloadStore::from_db(restarted_db.clone()).unwrap();
2026
- let mut restarted_loser = WorkloadStore::from_db(restarted_db.clone()).unwrap();
2027
- restarted_winner
2028
- .claim(&id, "worker-c", &caps, 13, 10_000)
2029
- .unwrap();
2030
- let conflict = restarted_loser
2031
- .claim(&id, "worker-d", &caps, 13, 10_000)
2032
- .unwrap_err();
2033
- assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
2034
- let projected = restarted_loser.get(&id).unwrap();
2035
- assert_eq!(projected.state, WorkloadState::Claimed);
2036
- assert_eq!(projected.current_claim.as_ref().unwrap().worker_id, "worker-c");
2037
- drop((recovery, restarted_winner, restarted_loser, restarted_db));
2038
- let _ = fs::remove_file(path);
2039
- }
2040
-
2041
- #[test]
2042
- fn competing_admissions_share_one_canonical_idempotency_identity() {
2043
- let path = std::env::temp_dir().join(format!(
2044
- "workload-idempotency-cas-{}.log",
2045
- std::process::id()
2046
- ));
2047
- let db = FeltDb::open(&path).unwrap();
2048
- let mut first = WorkloadStore::from_db(db.clone()).unwrap();
2049
- let mut second = WorkloadStore::from_db(db.clone()).unwrap();
2050
- let accepted = first.create(create()).unwrap();
2051
- // A second writer of the same store loses the require-absent CAS and
2052
- // converges on what was committed: a different request is refused as a
2053
- // reused key, never answered with the original.
2054
- let mut competing = create();
2055
- competing.input = serde_json::json!({"id": 2});
2056
- let conflict = second.create(competing).unwrap_err();
2057
- assert_eq!(conflict.code, "IDEMPOTENCY_KEY_REUSED");
2058
- assert_eq!(
2059
- second.workloads.keys().collect::<Vec<_>>(),
2060
- vec![&accepted.workload_id],
2061
- "only the committed workload is projected"
2062
- );
2063
- // The same request from a third, equally stale writer converges on it.
2064
- let mut third = WorkloadStore::from_db(db.clone()).unwrap();
2065
- third.workloads.clear();
2066
- third.idempotency.clear();
2067
- third.document_versions.clear();
2068
- let (converged, created) = third.admit(create()).unwrap();
2069
- assert!(!created);
2070
- assert_eq!(converged.workload_id, accepted.workload_id);
2071
-
2072
- drop((first, second, third, db));
2073
- let restarted = WorkloadStore::from_db(FeltDb::open(&path).unwrap()).unwrap();
2074
- assert_eq!(restarted.workloads.len(), 1);
2075
- assert_eq!(
2076
- restarted.idempotency[&idempotency_scope("t", "app", &accepted.requested_by, "one")],
2077
- accepted.workload_id
2078
- );
2079
- let _ = fs::remove_file(path);
2080
- }
2081
-
2082
- #[test]
2083
- fn idempotency_scopes_cannot_be_made_to_coincide() {
2084
- let (path, mut store) = store("scope-separators");
2085
- let mut first = create_as("x:k1");
2086
- first.idempotency_key = "k2".into();
2087
- let mut second = create_as("x");
2088
- second.idempotency_key = "k1:k2".into();
2089
- let first = store.create(first).unwrap();
2090
- let second = store.create(second).unwrap();
2091
- assert_ne!(first.workload_id, second.workload_id);
2092
- let _ = fs::remove_file(path);
2093
- }
2094
-
2095
- #[test]
2096
- fn concurrent_admissions_by_competing_writers_converge_or_conflict() {
2097
- for round in 0..20 {
2098
- let (path, seed) = store(&format!("concurrent-admission-{round}"));
2099
- let db = seed.db.clone();
2100
- let admit_concurrently = |requests: [CreateWorkload; 2]| {
2101
- let barrier = std::sync::Barrier::new(2);
2102
- std::thread::scope(|scope| {
2103
- requests
2104
- .into_iter()
2105
- .map(|request| {
2106
- let mut writer = WorkloadStore::from_db(db.clone()).unwrap();
2107
- let barrier = &barrier;
2108
- scope.spawn(move || {
2109
- barrier.wait();
2110
- writer.admit(request)
2111
- })
2112
- })
2113
- .collect::<Vec<_>>()
2114
- .into_iter()
2115
- .map(|handle| handle.join().unwrap())
2116
- .collect::<Vec<_>>()
2117
- })
2118
- };
2119
- // The same request: both writers answer with one workload, and
2120
- // exactly one of them created it.
2121
- let same = admit_concurrently([create(), create()]);
2122
- let (first, first_created) = same[0].as_ref().unwrap().clone();
2123
- let (second, second_created) = same[1].as_ref().unwrap().clone();
2124
- assert_eq!(first.workload_id, second.workload_id);
2125
- assert!(first_created ^ second_created, "round {round}");
2126
-
2127
- // Different requests under one key: one is admitted, the other is
2128
- // refused as a reused key and never answered with the winner.
2129
- let mut left = create();
2130
- left.idempotency_key = "contested".into();
2131
- let mut right = left.clone();
2132
- right.input = serde_json::json!({"id": 2});
2133
- let contested = admit_concurrently([left, right]);
2134
- let admitted = contested.iter().filter(|outcome| outcome.is_ok()).count();
2135
- assert_eq!(admitted, 1, "round {round}");
2136
- assert!(contested.iter().any(|outcome| outcome
2137
- .as_ref()
2138
- .is_err_and(|error| error.code == "IDEMPOTENCY_KEY_REUSED")));
2139
- let (winner, _) = contested.iter().find_map(|o| o.as_ref().ok()).unwrap();
2140
- let stored = WorkloadStore::from_db(db.clone()).unwrap();
2141
- assert_eq!(stored.workloads.len(), 2);
2142
- assert_eq!(stored.get(&winner.workload_id).unwrap().input, winner.input);
2143
- drop((seed, stored, db));
2144
- let _ = fs::remove_file(path);
2145
- }
2146
- }
2147
-
2148
- #[test]
2149
- fn pre_versioned_canonical_rows_upgrade_through_the_standard_cas_contract() {
2150
- let (path, mut store) = store("pre-versioned-upgrade");
2151
- let workload = store.create(create()).unwrap();
2152
- let db = store.db.clone();
2153
- db.apply_atomic_transaction(
2154
- "fixture:pre-versioned-work",
2155
- None,
2156
- &[],
2157
- &[AtomicMutation {
2158
- capability: DURABLE_WORK_COLLECTION.into(),
2159
- key: work_key(&workload),
2160
- value: Some(serde_json::to_value(&workload).unwrap()),
2161
- }],
2162
- None,
2163
- )
2164
- .unwrap();
2165
-
2166
- let mut upgraded = WorkloadStore::from_db(db.clone()).unwrap();
2167
- upgraded
2168
- .claim(
2169
- &workload.workload_id,
2170
- "worker",
2171
- &["email:send".into()].into(),
2172
- 11,
2173
- 10_000,
2174
- )
2175
- .unwrap();
2176
- let row = db
2177
- .list_collection(DURABLE_WORK_COLLECTION)
2178
- .unwrap()
2179
- .into_iter()
2180
- .find(|row| row.key.ends_with(&workload.workload_id))
2181
- .unwrap();
2182
- assert_eq!(row.value["__version"], 2);
2183
- let _ = fs::remove_file(path);
2184
- }
2185
-
2186
- #[test]
2187
- fn derived_indexes_rebuild_exclusively_from_canonical_work() {
2188
- let (path, mut store) = store("derived-index-rebuild");
2189
- let workload = store.create(create()).unwrap();
2190
- let caps = ["email:send".into()].into();
2191
- let claim = store
2192
- .claim(&workload.workload_id, "worker", &caps, 11, 10_000)
2193
- .unwrap();
2194
- store
2195
- .start(
2196
- &workload.workload_id,
2197
- &claim.claim_id,
2198
- "worker",
2199
- claim.fencing_token,
2200
- 12,
2201
- )
2202
- .unwrap();
2203
- store
2204
- .complete(
2205
- &workload.workload_id,
2206
- &claim.claim_id,
2207
- "worker",
2208
- claim.fencing_token,
2209
- "result-rebuilt",
2210
- serde_json::json!({"ok": true}),
2211
- vec![],
2212
- 1,
2213
- 13,
2214
- )
2215
- .unwrap();
2216
-
2217
- store.idempotency.clear();
2218
- store.result_ids.clear();
2219
- store.document_versions.clear();
2220
- let rebuilt = WorkloadStore::from_db(store.db.clone()).unwrap();
2221
- assert_eq!(
2222
- rebuilt
2223
- .idempotency
2224
- .get(&idempotency_scope("t", "app", &workload.requested_by, "one")),
2225
- Some(&workload.workload_id)
2226
- );
2227
- assert_eq!(
2228
- rebuilt.result_ids.get("result-rebuilt"),
2229
- Some(&workload.workload_id)
2230
- );
2231
- assert!(rebuilt.document_versions[&workload.workload_id] > 1);
2232
- assert_eq!(
2233
- rebuilt
2234
- .get(&workload.workload_id)
2235
- .unwrap()
2236
- .execution_history
2237
- .iter()
2238
- .map(|event| event.sequence)
2239
- .collect::<Vec<_>>(),
2240
- vec![1, 2, 3, 4]
2241
- );
2242
- let duplicate = WorkloadStore::from_db(store.db.clone())
2243
- .unwrap()
2244
- .create(create())
2245
- .unwrap();
2246
- assert_eq!(duplicate.workload_id, workload.workload_id);
2247
- let _ = fs::remove_file(path);
2248
- }
2249
- #[test]
2250
- fn idempotent_create_and_legal_lifecycle() {
2251
- let (path, mut store) = store("lifecycle");
2252
- let one = store.create(create()).unwrap();
2253
- let two = store.create(create()).unwrap();
2254
- assert_eq!(one.workload_id, two.workload_id);
2255
- let caps = ["email:send".into()].into();
2256
- let claim = store
2257
- .claim(&one.workload_id, "worker", &caps, 11, 10_000)
2258
- .unwrap();
2259
- store
2260
- .start(
2261
- &one.workload_id,
2262
- &claim.claim_id,
2263
- "worker",
2264
- claim.fencing_token,
2265
- 12,
2266
- )
2267
- .unwrap();
2268
- let result = store
2269
- .complete(
2270
- &one.workload_id,
2271
- &claim.claim_id,
2272
- "worker",
2273
- claim.fencing_token,
2274
- "result-1",
2275
- serde_json::json!({"ok":true}),
2276
- vec![],
2277
- 1,
2278
- 13,
2279
- )
2280
- .unwrap();
2281
- assert_eq!(result.result_id, "result-1");
2282
- assert!(store
2283
- .start(
2284
- &one.workload_id,
2285
- &claim.claim_id,
2286
- "worker",
2287
- claim.fencing_token,
2288
- 14
2289
- )
2290
- .is_err());
2291
- let recovered = WorkloadStore::load(&path).unwrap();
2292
- assert_eq!(
2293
- recovered.get(&one.workload_id).unwrap().state,
2294
- WorkloadState::Succeeded
2295
- );
2296
- let _ = fs::remove_file(path);
2297
- }
2298
- #[test]
2299
- fn stale_fence_and_lease_recovery() {
2300
- let (path, mut store) = store("fence");
2301
- let w = store.create(create()).unwrap();
2302
- let caps = ["email:send".into()].into();
2303
- let old = store.claim(&w.workload_id, "a", &caps, 11, 1000).unwrap();
2304
- store
2305
- .start(&w.workload_id, &old.claim_id, "a", old.fencing_token, 11)
2306
- .unwrap();
2307
- store.recover_expired(13).unwrap();
2308
- let new = store.claim(&w.workload_id, "b", &caps, 13, 10_000).unwrap();
2309
- assert!(new.fencing_token > old.fencing_token);
2310
- assert_eq!(
2311
- store
2312
- .complete(
2313
- &w.workload_id,
2314
- &old.claim_id,
2315
- "a",
2316
- old.fencing_token,
2317
- "stale",
2318
- Value::Null,
2319
- vec![],
2320
- 1,
2321
- 14
2322
- )
2323
- .unwrap_err()
2324
- .code,
2325
- "WORKLOAD_STALE_FENCE"
2326
- );
2327
- let _ = fs::remove_file(path);
2328
- }
2329
- #[test]
2330
- fn retry_dead_letter_checkpoint_and_secret_safety() {
2331
- let (path, mut store) = store("retry");
2332
- let mut input = create();
2333
- input.retry_policy.max_attempts = 1;
2334
- let w = store.create(input).unwrap();
2335
- let caps = ["email:send".into()].into();
2336
- let claim = store.claim(&w.workload_id, "a", &caps, 11, 10_000).unwrap();
2337
- store
2338
- .start(
2339
- &w.workload_id,
2340
- &claim.claim_id,
2341
- "a",
2342
- claim.fencing_token,
2343
- 12,
2344
- )
2345
- .unwrap();
2346
- assert!(store
2347
- .checkpoint(
2348
- &w.workload_id,
2349
- &claim.claim_id,
2350
- "a",
2351
- claim.fencing_token,
2352
- serde_json::json!({"password":"bad"}),
2353
- 13
2354
- )
2355
- .is_err());
2356
- assert_eq!(
2357
- store
2358
- .fail(
2359
- &w.workload_id,
2360
- &claim.claim_id,
2361
- "a",
2362
- claim.fencing_token,
2363
- "worker_failure",
2364
- "crashed",
2365
- 13
2366
- )
2367
- .unwrap(),
2368
- WorkloadState::DeadLetter
2369
- );
2370
- let _ = fs::remove_file(path);
2371
- }
2372
- #[test]
2373
- fn cancellation_race_and_duplicate_completion_are_deterministic() {
2374
- let (path, mut store) = store("cancel");
2375
- let w = store.create(create()).unwrap();
2376
- let caps = ["email:send".into()].into();
2377
- let claim = store.claim(&w.workload_id, "a", &caps, 11, 10_000).unwrap();
2378
- store
2379
- .start(
2380
- &w.workload_id,
2381
- &claim.claim_id,
2382
- "a",
2383
- claim.fencing_token,
2384
- 12,
2385
- )
2386
- .unwrap();
2387
- assert_eq!(
2388
- store.cancel(&w.workload_id, "user", 13).unwrap(),
2389
- WorkloadState::CancellationRequested
2390
- );
2391
- assert_eq!(
2392
- store
2393
- .complete(
2394
- &w.workload_id,
2395
- &claim.claim_id,
2396
- "a",
2397
- claim.fencing_token,
2398
- "r",
2399
- Value::Null,
2400
- vec![],
2401
- 1,
2402
- 14
2403
- )
2404
- .unwrap_err()
2405
- .code,
2406
- "WORKLOAD_TRANSITION_DENIED"
2407
- );
2408
- store
2409
- .confirm_cancel(
2410
- &w.workload_id,
2411
- &claim.claim_id,
2412
- "a",
2413
- claim.fencing_token,
2414
- 14,
2415
- )
2416
- .unwrap();
2417
- assert_eq!(
2418
- store.get(&w.workload_id).unwrap().state,
2419
- WorkloadState::Cancelled
2420
- );
2421
- let _ = fs::remove_file(path);
2422
- }
2423
- #[test]
2424
- fn scheduler_respects_dependencies_and_tenant_application_isolation() {
2425
- let (path, mut store) = store("dependency");
2426
- let parent = store.create(create()).unwrap();
2427
- let mut child = create();
2428
- child.idempotency_key = "child".into();
2429
- child.dependencies = vec![WorkloadDependency {
2430
- workload_id: parent.workload_id.clone(),
2431
- required_state: WorkloadState::Succeeded,
2432
- }];
2433
- let child = store.create(child).unwrap();
2434
- assert_eq!(store.list("other", "app").len(), 0);
2435
- let caps = ["email:send".into()].into();
2436
- let mut scheduler = LocalDurableScheduler::default();
2437
- let first = scheduler
2438
- .claim(&mut store, "t", "app", "worker", &caps, 11, 10_000)
2439
- .unwrap()
2440
- .unwrap();
2441
- assert_eq!(first.workload_id, parent.workload_id);
2442
- assert_ne!(first.workload_id, child.workload_id);
2443
- let _ = fs::remove_file(path);
2444
- }
2445
-
2446
- // ------------------------------------------------------------------
2447
- // Temporal work eligibility.
2448
- //
2449
- // The invariant under test: a workload may exist durably before it is
2450
- // executable. Every test below drives the real store — creating, claiming,
2451
- // reloading from disk — rather than asserting that a field or a function
2452
- // name exists.
2453
- // ------------------------------------------------------------------
2454
-
2455
- /// A store on a path unique to this test, cleared first so a recycled
2456
- /// process id cannot hand a test a previous run's file.
2457
- fn temporal_store(name: &str) -> (PathBuf, WorkloadStore) {
2458
- let path = std::env::temp_dir().join(format!(
2459
- "workload-temporal-{name}-{}-{}.json",
2460
- std::process::id(),
2461
- rand::random::<u64>()
2462
- ));
2463
- let _ = fs::remove_file(&path);
2464
- (path.clone(), WorkloadStore::load(&path).unwrap())
2465
- }
2466
-
2467
- fn scheduled(not_before: i64) -> CreateWorkload {
2468
- let mut input = create();
2469
- input.not_before = Some(not_before);
2470
- input
2471
- }
2472
-
2473
- fn caps() -> BTreeSet<String> {
2474
- ["email:send".into()].into()
2475
- }
2476
-
2477
- #[test]
2478
- fn scheduled_work_is_not_claimable_before_not_before() {
2479
- let (path, mut store) = temporal_store("before");
2480
- let workload = store.create(scheduled(2_000)).unwrap();
2481
-
2482
- // Durable and Ready immediately: existing before it is executable is
2483
- // the whole point.
2484
- assert_eq!(workload.state, WorkloadState::Ready);
2485
- assert_eq!(workload.next_attempt_at, Some(2_000));
2486
-
2487
- let refused = store
2488
- .claim(&workload.workload_id, "worker", &caps(), 1_999, 10_000)
2489
- .unwrap_err();
2490
- assert_eq!(refused.code, "WORKLOAD_NOT_YET_ELIGIBLE");
2491
- assert!(
2492
- refused.message.contains("2000"),
2493
- "the refusal names the instant the worker should return at: {}",
2494
- refused.message
2495
- );
2496
-
2497
- // Refusing a claim is not a state change. Nothing executed.
2498
- let after = store.get(&workload.workload_id).unwrap();
2499
- assert_eq!(after.state, WorkloadState::Ready);
2500
- assert_eq!(after.attempt_count, 0);
2501
- assert!(after.attempts.is_empty());
2502
- assert!(after.current_claim.is_none());
2503
- let _ = fs::remove_file(path);
2504
- }
2505
-
2506
- #[test]
2507
- fn scheduled_work_becomes_claimable_after_not_before() {
2508
- let (path, mut store) = temporal_store("after");
2509
- let workload = store.create(scheduled(2_000)).unwrap();
2510
-
2511
- // The boundary belongs to the eligible side: at exactly `not_before`
2512
- // the workload is claimable, so a worker polling on the second does not
2513
- // have to wait a further tick.
2514
- let claim = store
2515
- .claim(&workload.workload_id, "worker", &caps(), 2_000, 10_000)
2516
- .expect("eligible at the instant itself");
2517
- assert_eq!(claim.worker_id, "worker");
2518
- assert_eq!(
2519
- store.get(&workload.workload_id).unwrap().state,
2520
- WorkloadState::Claimed
2521
- );
2522
- let _ = fs::remove_file(path);
2523
- }
2524
-
2525
- #[test]
2526
- fn scheduled_work_survives_restart_before_eligibility() {
2527
- let (path, mut store) = temporal_store("restart-before");
2528
- let id = store.create(scheduled(2_000)).unwrap().workload_id;
2529
- drop(store);
2530
-
2531
- // Restart the authority. The workload is still here, still Ready, still
2532
- // carrying its eligibility, and still refused.
2533
- let mut reopened = WorkloadStore::load(&path).unwrap();
2534
- let recovered = reopened.get(&id).unwrap();
2535
- assert_eq!(recovered.state, WorkloadState::Ready);
2536
- assert_eq!(recovered.next_attempt_at, Some(2_000));
2537
-
2538
- assert_eq!(
2539
- reopened
2540
- .claim(&id, "worker", &caps(), 1_999, 10_000)
2541
- .unwrap_err()
2542
- .code,
2543
- "WORKLOAD_NOT_YET_ELIGIBLE"
2544
- );
2545
- let _ = fs::remove_file(path);
2546
- }
2547
-
2548
- #[test]
2549
- fn scheduled_work_executes_after_restart() {
2550
- let (path, mut store) = temporal_store("restart-after");
2551
- let id = store.create(scheduled(2_000)).unwrap().workload_id;
2552
- drop(store);
2553
-
2554
- let mut reopened = WorkloadStore::load(&path).unwrap();
2555
- let claim = reopened
2556
- .claim(&id, "worker", &caps(), 2_001, 10_000)
2557
- .expect("claimable after restart once eligible");
2558
- reopened
2559
- .start(&id, &claim.claim_id, "worker", claim.fencing_token, 2_002)
2560
- .unwrap();
2561
- reopened
2562
- .complete(
2563
- &id,
2564
- &claim.claim_id,
2565
- "worker",
2566
- claim.fencing_token,
2567
- "result-scheduled",
2568
- serde_json::json!({"ok": true}),
2569
- vec![],
2570
- 1,
2571
- 2_003,
2572
- )
2573
- .unwrap();
2574
-
2575
- // And the outcome is durable across a second restart.
2576
- let again = WorkloadStore::load(&path).unwrap();
2577
- let finished = again.get(&id).unwrap();
2578
- assert_eq!(finished.state, WorkloadState::Succeeded);
2579
- assert_eq!(
2580
- finished.result.as_ref().map(|r| r.result_id.as_str()),
2581
- Some("result-scheduled")
2582
- );
2583
- let _ = fs::remove_file(path);
2584
- }
2585
-
2586
- #[test]
2587
- fn immediate_work_behavior_is_unchanged() {
2588
- let (path, mut store) = temporal_store("immediate");
2589
- // `create()` sets no `not_before`, which is what every caller did
2590
- // before this field existed.
2591
- let workload = store.create(create()).unwrap();
2592
- assert_eq!(workload.next_attempt_at, None);
2593
- assert!(workload.is_eligible_at(workload.created_at));
2594
-
2595
- // Claimable at its own creation instant, with no waiting.
2596
- store
2597
- .claim(
2598
- &workload.workload_id,
2599
- "worker",
2600
- &caps(),
2601
- workload.created_at,
2602
- 10_000,
2603
- )
2604
- .expect("work with no eligibility instant is claimable immediately");
2605
- let _ = fs::remove_file(path);
2606
- }
2607
-
2608
- #[test]
2609
- fn multiple_workers_claim_temporally_eligible_work_once() {
2610
- let (path, mut store) = temporal_store("contention");
2611
- let id = store.create(scheduled(2_000)).unwrap().workload_id;
2612
-
2613
- // Both workers are polling and both see eligibility open at the same
2614
- // instant. Exactly one wins.
2615
- let winner = store
2616
- .claim(&id, "worker-a", &caps(), 2_000, 10_000)
2617
- .unwrap();
2618
- let loser = store
2619
- .claim(&id, "worker-b", &caps(), 2_000, 10_000)
2620
- .unwrap_err();
2621
- assert_eq!(
2622
- loser.code, "WORKLOAD_NOT_READY",
2623
- "the second worker is refused on state, not on time: the workload is claimed"
2624
- );
2625
-
2626
- // And the loser cannot complete work it never owned.
2627
- assert_eq!(
2628
- store
2629
- .complete(
2630
- &id,
2631
- &winner.claim_id,
2632
- "worker-b",
2633
- winner.fencing_token,
2634
- "duplicate",
2635
- Value::Null,
2636
- vec![],
2637
- 1,
2638
- 2_001,
2639
- )
2640
- .unwrap_err()
2641
- .code,
2642
- "WORKLOAD_STALE_FENCE"
2643
- );
2644
- assert_eq!(store.get(&id).unwrap().attempt_count, 1);
2645
- let _ = fs::remove_file(path);
2646
- }
2647
-
2648
- #[test]
2649
- fn retry_sets_next_eligibility_without_duplicate_execution() {
2650
- let (path, mut store) = temporal_store("retry");
2651
- let id = store.create(scheduled(2_000)).unwrap().workload_id;
2652
-
2653
- let first = store
2654
- .claim(&id, "worker-a", &caps(), 2_000, 10_000)
2655
- .unwrap();
2656
- store
2657
- .start(&id, &first.claim_id, "worker-a", first.fencing_token, 2_001)
2658
- .unwrap();
2659
- let state = store
2660
- .fail(
2661
- &id,
2662
- &first.claim_id,
2663
- "worker-a",
2664
- first.fencing_token,
2665
- "timeout",
2666
- "upstream did not answer",
2667
- 2_002,
2668
- )
2669
- .unwrap();
2670
-
2671
- // Retry re-arms the same eligibility field creation used, so there is
2672
- // one temporal concept rather than two competing ones.
2673
- assert_eq!(state, WorkloadState::Ready);
2674
- let backing_off = store.get(&id).unwrap();
2675
- let next = backing_off.next_attempt_at.expect("retry set eligibility");
2676
- assert!(
2677
- next > 2_002,
2678
- "backoff pushed eligibility into the future: {next}"
2679
- );
2680
-
2681
- assert_eq!(
2682
- store
2683
- .claim(&id, "worker-b", &caps(), next - 1, 10_000)
2684
- .unwrap_err()
2685
- .code,
2686
- "WORKLOAD_NOT_YET_ELIGIBLE"
2687
- );
2688
-
2689
- let second = store.claim(&id, "worker-b", &caps(), next, 10_000).unwrap();
2690
- assert!(second.fencing_token > first.fencing_token);
2691
-
2692
- // The first attempt cannot land a result after the retry was handed on.
2693
- assert_eq!(
2694
- store
2695
- .complete(
2696
- &id,
2697
- &first.claim_id,
2698
- "worker-a",
2699
- first.fencing_token,
2700
- "stale",
2701
- Value::Null,
2702
- vec![],
2703
- 1,
2704
- next,
2705
- )
2706
- .unwrap_err()
2707
- .code,
2708
- "WORKLOAD_STALE_FENCE"
2709
- );
2710
- assert_eq!(store.get(&id).unwrap().attempt_count, 2);
2711
- let _ = fs::remove_file(path);
2712
- }
2713
-
2714
- #[test]
2715
- fn cancellation_before_not_before_prevents_execution() {
2716
- let (path, mut store) = temporal_store("cancel");
2717
- let id = store.create(scheduled(2_000)).unwrap().workload_id;
2718
-
2719
- // Cancelling work that is durable but not yet executable is the whole
2720
- // reason to be able to see it before it runs.
2721
- assert_eq!(
2722
- store.cancel(&id, "operator", 1_500).unwrap(),
2723
- WorkloadState::Cancelled
2724
- );
2725
-
2726
- // Eligibility arriving does not resurrect it.
2727
- assert_eq!(
2728
- store
2729
- .claim(&id, "worker", &caps(), 2_001, 10_000)
2730
- .unwrap_err()
2731
- .code,
2732
- "WORKLOAD_NOT_READY"
2733
- );
2734
-
2735
- let reopened = WorkloadStore::load(&path).unwrap();
2736
- let cancelled = reopened.get(&id).unwrap();
2737
- assert_eq!(cancelled.state, WorkloadState::Cancelled);
2738
- assert!(cancelled.attempts.is_empty(), "nothing ever executed");
2739
- let _ = fs::remove_file(path);
2740
- }
2741
-
2742
- #[test]
2743
- fn a_worker_dying_after_a_claim_returns_the_work_and_it_executes_once() {
2744
- let (path, mut store) = temporal_store("worker-death");
2745
- let id = store.create(create()).unwrap().workload_id;
2746
-
2747
- // Worker A claims a short lease, starts, and dies without completing.
2748
- let dead = store.claim(&id, "worker-a", &caps(), 10, 1_000).unwrap();
2749
- store
2750
- .start(&id, &dead.claim_id, "worker-a", dead.fencing_token, 10)
2751
- .unwrap();
2752
- assert_eq!(
2753
- store.get(&id).unwrap().state,
2754
- WorkloadState::Running,
2755
- "the work was in flight when its worker died"
2756
- );
2757
-
2758
- // The lease expiring is the only thing that can notice.
2759
- let recovered = store.recover_expired(12).unwrap();
2760
- assert_eq!(recovered, vec![id.clone()]);
2761
- let back = store.get(&id).unwrap();
2762
- assert_eq!(back.state, WorkloadState::Ready, "returned to the pool");
2763
- assert_eq!(
2764
- back.attempts.last().map(|attempt| attempt.status),
2765
- Some(AttemptStatus::WorkerLost),
2766
- "and the dead attempt is recorded as lost rather than erased"
2767
- );
2768
-
2769
- // Worker B picks it up and finishes it.
2770
- let live = store.claim(&id, "worker-b", &caps(), 12, 10_000).unwrap();
2771
- assert!(live.fencing_token > dead.fencing_token);
2772
- store
2773
- .start(&id, &live.claim_id, "worker-b", live.fencing_token, 13)
2774
- .unwrap();
2775
- store
2776
- .complete(
2777
- &id,
2778
- &live.claim_id,
2779
- "worker-b",
2780
- live.fencing_token,
2781
- "result-recovered",
2782
- serde_json::json!({"ok": true}),
2783
- vec![],
2784
- 1,
2785
- 14,
2786
- )
2787
- .unwrap();
2788
-
2789
- // Exactly one execution succeeded, and the dead worker cannot add another.
2790
- let finished = store.get(&id).unwrap();
2791
- assert_eq!(finished.state, WorkloadState::Succeeded);
2792
- assert_eq!(
2793
- finished
2794
- .attempts
2795
- .iter()
2796
- .filter(|attempt| attempt.status == AttemptStatus::Succeeded)
2797
- .count(),
2798
- 1
2799
- );
2800
- // The dead worker cannot add a second result. Its claim was released by
2801
- // the recovery, and completion released the successor's, so it is
2802
- // refused for having no claim at all rather than a stale one.
2803
- assert_eq!(
2804
- store
2805
- .complete(
2806
- &id,
2807
- &dead.claim_id,
2808
- "worker-a",
2809
- dead.fencing_token,
2810
- "resurrected",
2811
- Value::Null,
2812
- vec![],
2813
- 1,
2814
- 15,
2815
- )
2816
- .unwrap_err()
2817
- .code,
2818
- "WORKLOAD_CLAIM_INVALID"
2819
- );
2820
- let _ = fs::remove_file(path);
2821
- }
2822
-
2823
- #[test]
2824
- fn a_recovered_workload_refuses_its_dead_worker_while_the_successor_still_holds_it() {
2825
- let (path, mut store) = temporal_store("worker-death-inflight");
2826
- let id = store.create(create()).unwrap().workload_id;
2827
-
2828
- let dead = store.claim(&id, "worker-a", &caps(), 10, 1_000).unwrap();
2829
- store
2830
- .start(&id, &dead.claim_id, "worker-a", dead.fencing_token, 10)
2831
- .unwrap();
2832
- store.recover_expired(12).unwrap();
2833
- let live = store.claim(&id, "worker-b", &caps(), 12, 10_000).unwrap();
2834
-
2835
- // While the successor is mid-flight, the resurrected worker is refused
2836
- // on the fence itself — the case a lease exists to decide.
2837
- assert_eq!(
2838
- store
2839
- .complete(
2840
- &id,
2841
- &dead.claim_id,
2842
- "worker-a",
2843
- dead.fencing_token,
2844
- "resurrected",
2845
- Value::Null,
2846
- vec![],
2847
- 1,
2848
- 13,
2849
- )
2850
- .unwrap_err()
2851
- .code,
2852
- "WORKLOAD_STALE_FENCE"
2853
- );
2854
- assert_eq!(store.get(&id).unwrap().state, WorkloadState::Claimed);
2855
- assert!(live.fencing_token > dead.fencing_token);
2856
- let _ = fs::remove_file(path);
2857
- }
2858
-
2859
- #[test]
2860
- fn the_scheduler_skips_ineligible_work_rather_than_failing_on_it() {
2861
- let (path, mut store) = temporal_store("scheduler");
2862
- let mut early = create();
2863
- early.idempotency_key = "early".into();
2864
- early.not_before = Some(9_000);
2865
- let early = store.create(early).unwrap();
2866
-
2867
- let mut ready = create();
2868
- ready.idempotency_key = "ready".into();
2869
- let ready = store.create(ready).unwrap();
2870
-
2871
- // The ineligible workload sorts first by id in some orderings and not
2872
- // others, so what matters is that the scheduler never returns it and
2873
- // never fails because of it.
2874
- let mut scheduler = LocalDurableScheduler::default();
2875
- let claim = scheduler
2876
- .claim(&mut store, "t", "app", "worker", &caps(), 1_000, 10_000)
2877
- .expect("an ineligible workload must not fail the whole pass")
2878
- .expect("the eligible workload is still handed out");
2879
- assert_eq!(claim.workload_id, ready.workload_id);
2880
- assert_eq!(
2881
- store.get(&early.workload_id).unwrap().state,
2882
- WorkloadState::Ready
2883
- );
2884
- let _ = fs::remove_file(path);
2885
- }
2886
- }