@feltdb/core 0.12.2 → 0.15.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 (126) 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 +48 -0
  5. package/dist/agent-state.d.ts.map +1 -1
  6. package/dist/agent-state.js +174 -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 +4 -4
  11. package/dist/cli/commands.js +34 -7
  12. package/dist/cli/workspace-integration.js +8 -0
  13. package/dist/collection.d.ts +3 -1
  14. package/dist/collection.d.ts.map +1 -1
  15. package/dist/collection.js +30 -8
  16. package/dist/create/build-identity.json +4 -4
  17. package/dist/create/cli.js +47 -38
  18. package/dist/create/create.js +21 -8
  19. package/dist/create/package-versions.js +1 -1
  20. package/dist/create/server-source/Cargo.lock +6 -6
  21. package/dist/create/server-source/Cargo.toml +1 -1
  22. package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
  23. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
  24. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +341 -2
  25. package/dist/create/server-source/crates/feltdb/src/workload.rs +2094 -126
  26. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
  27. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
  28. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  29. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  30. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  31. package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
  32. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  33. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +113 -25
  34. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  35. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  37. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  38. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  39. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +4 -0
  40. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1740 -236
  41. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  42. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  43. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  44. package/dist/create/setup-summary.js +40 -0
  45. package/dist/db.d.ts +91 -10
  46. package/dist/db.d.ts.map +1 -1
  47. package/dist/db.js +170 -31
  48. package/dist/deployment.d.ts +19 -0
  49. package/dist/deployment.d.ts.map +1 -1
  50. package/dist/deployment.js +38 -2
  51. package/dist/development-runtime-bridge.d.ts +20 -0
  52. package/dist/development-runtime-bridge.d.ts.map +1 -1
  53. package/dist/development-runtime-bridge.js +61 -12
  54. package/dist/embedded-query.d.ts.map +1 -1
  55. package/dist/embedded-query.js +5 -4
  56. package/dist/embedded-transaction.d.ts +8 -4
  57. package/dist/embedded-transaction.d.ts.map +1 -1
  58. package/dist/embedded-transaction.js +55 -19
  59. package/dist/error-codes.d.ts +31 -2
  60. package/dist/error-codes.d.ts.map +1 -1
  61. package/dist/error-codes.js +41 -2
  62. package/dist/feltdb.d.ts +7 -0
  63. package/dist/feltdb.d.ts.map +1 -1
  64. package/dist/file-db.d.ts +27 -0
  65. package/dist/file-db.d.ts.map +1 -1
  66. package/dist/file-db.js +145 -7
  67. package/dist/http-client.d.ts +11 -3
  68. package/dist/http-client.d.ts.map +1 -1
  69. package/dist/http-client.js +27 -14
  70. package/dist/http-db.d.ts +40 -1
  71. package/dist/http-db.d.ts.map +1 -1
  72. package/dist/http-db.js +285 -58
  73. package/dist/index.js +1 -1
  74. package/dist/local-state-lock.d.ts +34 -0
  75. package/dist/local-state-lock.d.ts.map +1 -0
  76. package/dist/local-state-lock.js +225 -0
  77. package/dist/reactive-graph.d.ts +0 -3
  78. package/dist/reactive-graph.d.ts.map +1 -1
  79. package/dist/reactive-graph.js +11 -1
  80. package/dist/runtime-registry.d.ts +7 -2
  81. package/dist/runtime-registry.d.ts.map +1 -1
  82. package/dist/runtime-registry.js +2 -2
  83. package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
  84. package/dist/studio/app.d.ts +8 -1
  85. package/dist/studio/app.d.ts.map +1 -1
  86. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  87. package/dist/studio/components/KeyManagementPanel.js +1 -1
  88. package/dist/studio/components/index.js +2 -2
  89. package/dist/studio/{components-B4eKIUN5.js → components-CJmzntBi.js} +8 -7
  90. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  91. package/dist/studio/control-plane/Instance.d.ts +4 -1
  92. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  93. package/dist/studio/control-plane/Operations.d.ts +4 -1
  94. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  95. package/dist/studio/control-plane/index.js +2 -2
  96. package/dist/studio/control-plane/primitives.d.ts +16 -0
  97. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  98. package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
  99. package/dist/studio/index.d.ts +1 -0
  100. package/dist/studio/index.d.ts.map +1 -1
  101. package/dist/studio/index.js +384 -247
  102. package/dist/studio/navigation.d.ts +57 -0
  103. package/dist/studio/navigation.d.ts.map +1 -0
  104. package/dist/studio/studio.css +1 -1
  105. package/dist/studio/utils/control-plane-api.d.ts +2 -0
  106. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  107. package/dist/studio/utils/control-plane-api.js +5 -1
  108. package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-BjM1ylL3.js} +1 -1
  109. package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
  110. package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
  111. package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
  112. package/dist/studio-app/index.html +2 -2
  113. package/dist/transaction.d.ts +27 -11
  114. package/dist/transaction.d.ts.map +1 -1
  115. package/dist/transaction.js +20 -14
  116. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  117. package/dist/wasm/package.json +1 -1
  118. package/dist/workload.d.ts +31 -3
  119. package/dist/workload.d.ts.map +1 -1
  120. package/dist/workload.js +28 -3
  121. package/dist/workspace/local-development-authority.d.ts +19 -0
  122. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  123. package/dist/workspace/local-development-authority.js +335 -15
  124. package/package.json +1 -1
  125. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  126. package/dist/studio-app/assets/index-DoVvbIay.js +0 -30
@@ -1,7 +1,7 @@
1
1
  //! Durable workload authority. Workers execute; this state machine decides.
2
2
  use crate::{
3
3
  authorization::{SignedGrant, Subject},
4
- AtomicMutation, FeltDb, FlowError, RecordPrecondition,
4
+ AtomicMutation, FeltDb, FlowError, RecordPrecondition, StoredRow,
5
5
  };
6
6
  use rand::{distributions::Alphanumeric, Rng};
7
7
  use serde::{Deserialize, Serialize};
@@ -11,6 +11,11 @@ use std::collections::{BTreeMap, BTreeSet};
11
11
  use std::path::Path;
12
12
  pub const WORKLOAD_CONTRACT_VERSION: u32 = 1;
13
13
  pub const DURABLE_WORK_COLLECTION: &str = "system_durable_work";
14
+ /// Workloads admitted at this contract version or later have their `timeouts`
15
+ /// enforced. Earlier records were admitted when timeouts were stored and
16
+ /// ignored; they keep that behaviour, so an upgrade never times out work whose
17
+ /// requester was told nothing would.
18
+ pub const DEADLINE_CONTRACT_VERSION: u32 = 2;
14
19
 
15
20
  #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
16
21
  #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
@@ -153,6 +158,13 @@ pub struct WorkloadExecutionContext {
153
158
  pub secret_references: Vec<String>,
154
159
  pub expires_at: i64,
155
160
  pub fencing_token: u64,
161
+ /// The work's input, so the claiming worker can execute it.
162
+ #[serde(default)]
163
+ pub input: Value,
164
+ /// Every committed checkpoint, from this and earlier attempts, in order. A
165
+ /// resumed attempt reads these to learn how far lost attempts got.
166
+ #[serde(default)]
167
+ pub checkpoints: Vec<WorkloadCheckpoint>,
156
168
  }
157
169
  #[derive(Debug, Clone, Serialize, Deserialize)]
158
170
  pub struct WorkloadEvent {
@@ -269,12 +281,16 @@ pub struct CreateWorkload {
269
281
  pub struct WorkloadError {
270
282
  pub code: String,
271
283
  pub message: String,
284
+ /// Structured detail for the caller, e.g. which request fields conflict.
285
+ #[serde(default, skip_serializing_if = "Option::is_none")]
286
+ pub details: Option<Value>,
272
287
  }
273
288
  impl WorkloadError {
274
289
  fn new(code: &str, message: impl Into<String>) -> Self {
275
290
  Self {
276
291
  code: code.into(),
277
292
  message: message.into(),
293
+ details: None,
278
294
  }
279
295
  }
280
296
  }
@@ -294,6 +310,15 @@ pub struct WorkloadStore {
294
310
  /// Rebuildable cache of FeltDB-owned document versions. This is never
295
311
  /// serialized and never advances independently of a canonical commit.
296
312
  document_versions: BTreeMap<String, u64>,
313
+ /// The store revision this projection last reconciled with. A store that
314
+ /// has moved past it (a commit by another writer, or a replicated
315
+ /// operation) is re-read before the projection answers.
316
+ synced_revision: u64,
317
+ /// Always false outside tests, which set it to act on the projection
318
+ /// without re-reading the store: that reproduces another writer
319
+ /// committing between a refresh and a commit — the window only the storage
320
+ /// guard closes.
321
+ refresh_disabled: bool,
297
322
  }
298
323
 
299
324
  #[derive(Serialize, Deserialize)]
@@ -314,6 +339,9 @@ impl WorkloadStore {
314
339
  }
315
340
 
316
341
  pub fn from_db(db: FeltDb) -> Result<Self, WorkloadError> {
342
+ // Read before listing: a commit that lands in between is re-read by
343
+ // the next refresh rather than missed.
344
+ let synced_revision = db.current_revision().map_err(io)?;
317
345
  let mut workloads = BTreeMap::new();
318
346
  let mut document_versions = BTreeMap::new();
319
347
  for row in db.list_collection(DURABLE_WORK_COLLECTION).map_err(io)? {
@@ -329,16 +357,9 @@ impl WorkloadStore {
329
357
  document_versions.insert(workload.workload_id.clone(), version);
330
358
  workloads.insert(workload.workload_id.clone(), workload);
331
359
  }
332
- let mut idempotency = BTreeMap::new();
360
+ let idempotency = idempotency_index(&workloads, idempotency_scope);
333
361
  let mut result_ids = BTreeMap::new();
334
362
  for workload in workloads.values() {
335
- idempotency.insert(
336
- format!(
337
- "{}:{}:{}",
338
- workload.tenant_id, workload.application_id, workload.idempotency_key
339
- ),
340
- workload.workload_id.clone(),
341
- );
342
363
  if let Some(result) = &workload.result {
343
364
  result_ids.insert(result.result_id.clone(), workload.workload_id.clone());
344
365
  }
@@ -349,9 +370,121 @@ impl WorkloadStore {
349
370
  idempotency,
350
371
  result_ids,
351
372
  document_versions,
373
+ synced_revision,
374
+ refresh_disabled: false,
352
375
  })
353
376
  }
354
377
 
378
+ /// Reconcile the projection with the committed store.
379
+ ///
380
+ /// The store, not this projection, is authoritative. When its revision has
381
+ /// moved since the last reconciliation, every workload row whose document
382
+ /// version differs from the projection's is re-read; rows are borrowed and
383
+ /// only changed ones are cloned. Commits by another writer of the store and
384
+ /// operations replicated from a peer both become visible here, without a
385
+ /// restart.
386
+ pub fn refresh(&mut self) -> Result<(), WorkloadError> {
387
+ if self.refresh_disabled {
388
+ return Ok(());
389
+ }
390
+ let revision = self.db.current_revision().map_err(io)?;
391
+ if revision == self.synced_revision {
392
+ return Ok(());
393
+ }
394
+ let known = &self.document_versions;
395
+ let changed = self
396
+ .db
397
+ .query_collection(DURABLE_WORK_COLLECTION, None, |row| {
398
+ let version = row
399
+ .value
400
+ .get("__version")
401
+ .and_then(Value::as_u64)
402
+ .unwrap_or(1);
403
+ let id = row.key.rsplit('/').next().unwrap_or_default();
404
+ known.get(id) != Some(&version)
405
+ })
406
+ .map_err(io)?;
407
+ for row in changed {
408
+ self.adopt(row)?;
409
+ }
410
+ self.synced_revision = revision;
411
+ Ok(())
412
+ }
413
+
414
+ /// Re-read one workload from the store when its committed version differs
415
+ /// from the projection's: a keyed read, not a scan. An id the projection
416
+ /// has never seen (admitted by another writer) falls back to [`Self::refresh`].
417
+ fn refresh_id(&mut self, id: &str) -> Result<(), WorkloadError> {
418
+ match self.workloads.get(id).map(work_key) {
419
+ Some(key) => self.refresh_key(&key).map(|_| ()),
420
+ None => self.refresh(),
421
+ }
422
+ }
423
+
424
+ fn refresh_key(&mut self, key: &str) -> Result<Option<Workload>, WorkloadError> {
425
+ if self.refresh_disabled {
426
+ let id = key.rsplit('/').next().unwrap_or_default();
427
+ return Ok(self.workloads.get(id).cloned());
428
+ }
429
+ let Some(row) = self.db.live_row(DURABLE_WORK_COLLECTION, key) else {
430
+ return Ok(None);
431
+ };
432
+ let version = row
433
+ .value
434
+ .get("__version")
435
+ .and_then(Value::as_u64)
436
+ .unwrap_or(1);
437
+ let id = key.rsplit('/').next().unwrap_or_default();
438
+ if self.document_versions.get(id) == Some(&version) {
439
+ return Ok(self.workloads.get(id).cloned());
440
+ }
441
+ self.adopt(row).map(Some)
442
+ }
443
+
444
+ /// Install one committed row in the projection and its derived indexes.
445
+ fn adopt(&mut self, row: StoredRow) -> Result<Workload, WorkloadError> {
446
+ let version = row
447
+ .value
448
+ .get("__version")
449
+ .and_then(Value::as_u64)
450
+ .unwrap_or(1);
451
+ let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
452
+ if let Some(result) = &workload.result {
453
+ self.result_ids
454
+ .insert(result.result_id.clone(), workload.workload_id.clone());
455
+ }
456
+ self.idempotency.insert(
457
+ idempotency_scope(
458
+ &workload.tenant_id,
459
+ &workload.application_id,
460
+ &workload.requested_by,
461
+ &workload.idempotency_key,
462
+ ),
463
+ workload.workload_id.clone(),
464
+ );
465
+ self.document_versions
466
+ .insert(workload.workload_id.clone(), version);
467
+ self.workloads
468
+ .insert(workload.workload_id.clone(), workload.clone());
469
+ Ok(workload)
470
+ }
471
+
472
+ /// A workload as committed in the store (see [`Self::refresh`]).
473
+ pub fn fresh(&mut self, id: &str) -> Result<&Workload, WorkloadError> {
474
+ self.refresh_id(id)?;
475
+ self.get(id)
476
+ }
477
+
478
+ /// An application's workloads as committed in the store.
479
+ pub fn fresh_list(
480
+ &mut self,
481
+ tenant: &str,
482
+ application: &str,
483
+ ) -> Result<Vec<Workload>, WorkloadError> {
484
+ self.refresh()?;
485
+ Ok(self.list(tenant, application))
486
+ }
487
+
355
488
  pub fn migrate_legacy_file(db: FeltDb, path: &Path) -> Result<Self, WorkloadError> {
356
489
  let bytes = std::fs::read(path).map_err(io)?;
357
490
  let legacy: LegacyWorkloadStore = serde_json::from_slice(&bytes).map_err(io)?;
@@ -382,7 +515,7 @@ impl WorkloadStore {
382
515
  }
383
516
  // The maps are verification evidence, not migrated authorities.
384
517
  let rebuilt = Self::from_db(store.db.clone())?;
385
- if legacy.idempotency != rebuilt.idempotency
518
+ if legacy.idempotency != idempotency_index(&rebuilt.workloads, legacy_idempotency_scope)
386
519
  || legacy.result_ids != rebuilt.result_ids
387
520
  || legacy.event_sequence != derived_event_sequence(&rebuilt.workloads)
388
521
  {
@@ -454,6 +587,21 @@ impl WorkloadStore {
454
587
  Ok(())
455
588
  }
456
589
  pub fn create(&mut self, input: CreateWorkload) -> Result<Workload, WorkloadError> {
590
+ self.admit(input).map(|(workload, _)| workload)
591
+ }
592
+
593
+ /// Create a workload, or return the one this requester already created
594
+ /// under the same key.
595
+ ///
596
+ /// The key is scoped to the tenant, the application **and the requester**,
597
+ /// so one requester's key can never return, reveal or block another's
598
+ /// workload. A repeated key is answered with the original workload only when
599
+ /// the request is the same — definition, revision, environment, input and
600
+ /// capability snapshot; otherwise it is refused with
601
+ /// `IDEMPOTENCY_KEY_REUSED` and nothing is created. The requester's
602
+ /// authorization snapshot is checked before any replay. The boolean is
603
+ /// `true` when this call created the workload.
604
+ pub fn admit(&mut self, input: CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
457
605
  ensure_safe(&input.input)?;
458
606
  if input.idempotency_key.trim().is_empty() {
459
607
  return Err(WorkloadError::new(
@@ -461,12 +609,20 @@ impl WorkloadStore {
461
609
  "idempotency key is required",
462
610
  ));
463
611
  }
464
- let idempotency = format!(
465
- "{}:{}:{}",
466
- input.tenant_id, input.application_id, input.idempotency_key
467
- );
468
- if let Some(id) = self.idempotency.get(&idempotency) {
469
- return Ok(self.workloads[id].clone());
612
+ let t = &input.timeouts;
613
+ if [
614
+ t.queue_ms,
615
+ t.claim_ms,
616
+ t.execution_ms,
617
+ t.heartbeat_ms,
618
+ t.overall_ms,
619
+ ]
620
+ .contains(&Some(0))
621
+ {
622
+ return Err(WorkloadError::new(
623
+ "WORKLOAD_INVALID",
624
+ "a timeout must be at least 1 ms; omit it for no deadline",
625
+ ));
470
626
  }
471
627
  if input.authorization_snapshot.grant.subject != input.requested_by
472
628
  || input.authorization_snapshot.grant.tenant_id != input.tenant_id
@@ -477,14 +633,31 @@ impl WorkloadStore {
477
633
  "authorization snapshot does not bind the requester",
478
634
  ));
479
635
  }
636
+ let idempotency = idempotency_scope(
637
+ &input.tenant_id,
638
+ &input.application_id,
639
+ &input.requested_by,
640
+ &input.idempotency_key,
641
+ );
480
642
  // The scoped idempotency identity is the record identity. Competing
481
643
  // admissions therefore contend on one canonical require-absent CAS
482
644
  // even when their payloads differ; no separate durable uniqueness
483
645
  // index is needed.
484
646
  let id = format!("workload_{}", &hex(&Sha256::digest(&idempotency))[..24]);
647
+ // Another writer, or a peer, may have admitted this identity: the
648
+ // store is asked by key, not the projection.
649
+ let committed_key = format!("{}/{}/{}", input.tenant_id, input.application_id, id);
650
+ if let Some(existing) = self.refresh_key(&committed_key)? {
651
+ return replay(existing, &input);
652
+ }
653
+ if let Some(existing_id) = self.idempotency.get(&idempotency) {
654
+ let existing = self.workloads[existing_id].clone();
655
+ return replay(existing, &input);
656
+ }
657
+ let request = input.clone();
485
658
  let input_hash = hash(&input.input);
486
659
  let mut workload = Workload {
487
- contract_version: 1,
660
+ contract_version: DEADLINE_CONTRACT_VERSION,
488
661
  workload_id: id.clone(),
489
662
  tenant_id: input.tenant_id,
490
663
  application_id: input.application_id,
@@ -535,14 +708,23 @@ impl WorkloadStore {
535
708
  input.created_at,
536
709
  evidence,
537
710
  )?;
711
+ let key = work_key(&workload);
538
712
  self.idempotency.insert(idempotency.clone(), id.clone());
539
713
  self.workloads.insert(id.clone(), workload.clone());
540
714
  if let Err(error) = self.persist_workload(&id, "workload.created") {
541
715
  self.workloads.remove(&id);
542
716
  self.idempotency.remove(&idempotency);
717
+ // Another writer of the same store (a second process, or a replica)
718
+ // admitted this identity first. Converge on what it committed.
719
+ if error.code == "WORKLOAD_CONFLICT" {
720
+ if let Some(existing) = self.reload_committed(&key)? {
721
+ self.idempotency.insert(idempotency, id);
722
+ return replay(existing, &request);
723
+ }
724
+ }
543
725
  return Err(error);
544
726
  }
545
- Ok(workload)
727
+ Ok((workload, true))
546
728
  }
547
729
  pub fn get(&self, id: &str) -> Result<&Workload, WorkloadError> {
548
730
  self.workloads
@@ -564,6 +746,9 @@ impl WorkloadStore {
564
746
  id: &str,
565
747
  action: impl FnOnce(&mut Self, &mut Workload) -> Result<R, WorkloadError>,
566
748
  ) -> Result<R, WorkloadError> {
749
+ // Act only on the committed version: a projection that missed another
750
+ // writer's commit would otherwise lose its guarded commit at best.
751
+ self.refresh_id(id)?;
567
752
  let workload = self
568
753
  .workloads
569
754
  .get(id)
@@ -573,11 +758,35 @@ impl WorkloadStore {
573
758
  let result = action(self, &mut edited)?;
574
759
  self.workloads.insert(id.into(), edited);
575
760
  if let Err(error) = self.persist_workload(id, "workload.mutated") {
576
- self.workloads.insert(id.into(), workload);
761
+ self.workloads.insert(id.into(), workload.clone());
762
+ // Another writer of the same store committed this workload first.
763
+ // Nothing of this edit was committed; adopt what was, so this
764
+ // projection does not keep acting on a version that no longer
765
+ // exists (every later guarded commit from it would conflict).
766
+ if error.code == "WORKLOAD_CONFLICT" {
767
+ self.reload_committed(&work_key(&workload))?;
768
+ }
577
769
  return Err(error);
578
770
  }
579
771
  Ok(result)
580
772
  }
773
+ #[cfg(test)]
774
+ pub(crate) fn db_for_tests(&self) -> FeltDb {
775
+ self.db.clone()
776
+ }
777
+ /// See `refresh_disabled`.
778
+ #[cfg(test)]
779
+ pub(crate) fn without_refresh(mut self) -> Self {
780
+ self.refresh_disabled = true;
781
+ self
782
+ }
783
+ /// Replace this projection's copy of a workload with the committed row.
784
+ fn reload_committed(&mut self, key: &str) -> Result<Option<Workload>, WorkloadError> {
785
+ match self.db.live_row(DURABLE_WORK_COLLECTION, key) {
786
+ Some(row) => self.adopt(row).map(Some),
787
+ None => Ok(None),
788
+ }
789
+ }
581
790
  fn transition(
582
791
  &mut self,
583
792
  w: &mut Workload,
@@ -618,6 +827,28 @@ impl WorkloadStore {
618
827
  now: i64,
619
828
  lease_ms: u64,
620
829
  ) -> Result<WorkloadClaim, WorkloadError> {
830
+ self.enforce_deadline(id, now)?;
831
+ // A Ready workload that has already used its budget (an operator
832
+ // re-arm beyond it, or a record left by unbounded recovery before it
833
+ // was bounded) is dead-lettered here rather than claimed again.
834
+ let exhausted = self
835
+ .get(id)
836
+ .is_ok_and(|w| w.state == WorkloadState::Ready && w.attempt_count >= attempt_budget(w));
837
+ if exhausted {
838
+ self.edit(id, |store, w| {
839
+ let evidence = [
840
+ ("reason".into(), "attempt_budget_exhausted".into()),
841
+ ("attempts_used".into(), w.attempt_count.to_string()),
842
+ ("attempt_budget".into(), attempt_budget(w).to_string()),
843
+ ]
844
+ .into();
845
+ store.transition(w, WorkloadState::DeadLetter, "rust.claim", now, evidence)
846
+ })?;
847
+ return Err(WorkloadError::new(
848
+ "WORKLOAD_ATTEMPTS_EXHAUSTED",
849
+ "workload has used its attempt budget",
850
+ ));
851
+ }
621
852
  self.edit(id, |store, w| {
622
853
  if w.state != WorkloadState::Ready {
623
854
  return Err(WorkloadError::new(
@@ -720,6 +951,7 @@ impl WorkloadStore {
720
951
  token: u64,
721
952
  now: i64,
722
953
  ) -> Result<(), WorkloadError> {
954
+ self.enforce_deadline(id, now)?;
723
955
  self.edit(id, |store, w| {
724
956
  Self::fence(w, claim, worker, token, now)?;
725
957
  let attempt = w.attempts.last_mut().unwrap();
@@ -737,6 +969,7 @@ impl WorkloadStore {
737
969
  now: i64,
738
970
  extend_ms: u64,
739
971
  ) -> Result<WorkloadClaim, WorkloadError> {
972
+ self.enforce_deadline(id, now)?;
740
973
  self.edit(id, |_, w| {
741
974
  Self::fence(w, claim, worker, token, now)?;
742
975
  let current = w.current_claim.as_mut().unwrap();
@@ -746,7 +979,7 @@ impl WorkloadStore {
746
979
  })
747
980
  }
748
981
  pub fn execution_context(
749
- &self,
982
+ &mut self,
750
983
  id: &str,
751
984
  claim: &str,
752
985
  worker: &str,
@@ -754,7 +987,8 @@ impl WorkloadStore {
754
987
  now: i64,
755
988
  secret_references: Vec<String>,
756
989
  ) -> Result<WorkloadExecutionContext, WorkloadError> {
757
- let workload = self.get(id)?;
990
+ self.enforce_deadline(id, now)?;
991
+ let workload = self.fresh(id)?;
758
992
  Self::fence(workload, claim, worker, token, now)?;
759
993
  if secret_references
760
994
  .iter()
@@ -775,6 +1009,8 @@ impl WorkloadStore {
775
1009
  secret_references,
776
1010
  expires_at: current.expires_at,
777
1011
  fencing_token: token,
1012
+ input: workload.input.clone(),
1013
+ checkpoints: workload.checkpoints.clone(),
778
1014
  })
779
1015
  }
780
1016
  pub fn checkpoint(
@@ -787,6 +1023,7 @@ impl WorkloadStore {
787
1023
  now: i64,
788
1024
  ) -> Result<WorkloadCheckpoint, WorkloadError> {
789
1025
  ensure_safe(&state)?;
1026
+ self.enforce_deadline(id, now)?;
790
1027
  self.edit(id, |store, w| {
791
1028
  Self::fence(w, claim, worker, token, now)?;
792
1029
  if !matches!(
@@ -833,13 +1070,28 @@ impl WorkloadStore {
833
1070
  ensure_safe(&output)?;
834
1071
  if let Some(workload_id) = self.result_ids.get(result_id) {
835
1072
  if workload_id == id {
836
- return Ok(self.get(id)?.result.clone().unwrap());
1073
+ // A replay is the reporting attempt retrying the same report
1074
+ // (e.g. after a lost response). Anything else naming this
1075
+ // result id did not produce it and must not be told it landed.
1076
+ let result = self.get(id)?.result.clone().unwrap();
1077
+ let same_report = result.produced_by == worker
1078
+ && result.provenance.get("claim_id").map(String::as_str) == Some(claim)
1079
+ && result.provenance.get("fencing_token") == Some(&token.to_string())
1080
+ && result.output_hash == hash(&output);
1081
+ if same_report {
1082
+ return Ok(result);
1083
+ }
1084
+ return Err(WorkloadError::new(
1085
+ "WORKLOAD_RESULT_CONFLICT",
1086
+ "the result id was already recorded by a different report",
1087
+ ));
837
1088
  }
838
1089
  return Err(WorkloadError::new(
839
1090
  "WORKLOAD_RESULT_DUPLICATE",
840
1091
  "result id belongs to another workload",
841
1092
  ));
842
1093
  }
1094
+ self.enforce_deadline(id, now)?;
843
1095
  let result = self.edit(id, |store, w| {
844
1096
  Self::fence(w, claim, worker, token, now)?;
845
1097
  if !matches!(
@@ -890,6 +1142,31 @@ impl WorkloadStore {
890
1142
  kind: &str,
891
1143
  error: &str,
892
1144
  now: i64,
1145
+ ) -> Result<WorkloadState, WorkloadError> {
1146
+ self.fail_with_effect(id, claim, worker, token, kind, error, None, now)
1147
+ }
1148
+ /// A worker's failure report for its current attempt.
1149
+ ///
1150
+ /// Accepted from `CLAIMED` (the worker could not start) as well as from a
1151
+ /// started attempt, fenced like every worker call. `effect` says what the
1152
+ /// worker knows about external effects: `none` — it failed before causing
1153
+ /// any (implied for an attempt that never started); `unknown` — an effect
1154
+ /// may have happened. Without it, the policy decides by `kind`, as before.
1155
+ /// An `unknown` effect is decided like a lost worker: retried only when the
1156
+ /// policy retries `worker_failure`, whatever `kind` says. Repeating the same
1157
+ /// report replays its outcome; any other report from an attempt that
1158
+ /// already reported gets the fence's answer (`CLAIM_INVALID`/`STALE_FENCE`).
1159
+ #[allow(clippy::too_many_arguments)]
1160
+ pub fn fail_with_effect(
1161
+ &mut self,
1162
+ id: &str,
1163
+ claim: &str,
1164
+ worker: &str,
1165
+ token: u64,
1166
+ kind: &str,
1167
+ error: &str,
1168
+ effect: Option<&str>,
1169
+ now: i64,
893
1170
  ) -> Result<WorkloadState, WorkloadError> {
894
1171
  if sensitive_text(error) {
895
1172
  return Err(WorkloadError::new(
@@ -897,23 +1174,65 @@ impl WorkloadStore {
897
1174
  "errors may not contain secret material",
898
1175
  ));
899
1176
  }
1177
+ if effect.is_some_and(|e| !matches!(e, "none" | "unknown")) {
1178
+ return Err(WorkloadError::new(
1179
+ "WORKLOAD_INVALID",
1180
+ "effect must be \"none\" or \"unknown\"",
1181
+ ));
1182
+ }
1183
+ // The same report repeated (a lost response) replays its outcome. Any
1184
+ // other report from an attempt that already reported is answered by
1185
+ // the fence, as before: its claim is no longer current.
1186
+ let current = self.fresh(id)?.clone();
1187
+ if let Some(attempt) = reported_attempt(&current, claim, worker, token) {
1188
+ let same = attempt.status == AttemptStatus::Failed
1189
+ && attempt.error.as_deref() == Some(error)
1190
+ && failure_event(&current, &attempt.attempt_id).is_some_and(|event| {
1191
+ let recorded_effect = event.evidence.get("effect").map(String::as_str);
1192
+ event.evidence.get("failure_kind").map(String::as_str) == Some(kind)
1193
+ && (effect.is_none() || effect == recorded_effect)
1194
+ });
1195
+ if same {
1196
+ return Ok(current.state);
1197
+ }
1198
+ }
1199
+ self.enforce_deadline(id, now)?;
900
1200
  self.edit(id, |store, w| {
901
1201
  Self::fence(w, claim, worker, token, now)?;
1202
+ let started = w.state != WorkloadState::Claimed;
1203
+ let effect = effect.or((!started).then_some("none"));
902
1204
  let attempt = w.attempts.last_mut().unwrap();
903
1205
  attempt.status = AttemptStatus::Failed;
904
1206
  attempt.ended_at = Some(now);
905
1207
  attempt.error = Some(error.into());
1208
+ let attempt_id = attempt.attempt_id.clone();
906
1209
  w.last_failure = Some(format!("{kind}: {error}"));
907
1210
  w.current_claim = None;
908
- store.transition(
909
- w,
910
- WorkloadState::Failed,
911
- worker,
912
- now,
913
- [("failure_kind".into(), kind.into())].into(),
914
- )?;
915
- let retryable = w.retry_policy.retry_on.contains(kind)
916
- && w.attempt_count < w.retry_policy.max_attempts;
1211
+ let mut evidence: BTreeMap<String, String> = [
1212
+ ("failure_kind".into(), kind.into()),
1213
+ ("attempt_id".into(), attempt_id),
1214
+ ]
1215
+ .into();
1216
+ if let Some(effect) = effect {
1217
+ evidence.insert("effect".into(), effect.into());
1218
+ }
1219
+ store.transition(w, WorkloadState::Failed, worker, now, evidence)?;
1220
+ let unknown = effect == Some("unknown");
1221
+ let allowed =
1222
+ w.retry_policy
1223
+ .retry_on
1224
+ .contains(if unknown { "worker_failure" } else { kind });
1225
+ let decision = if !allowed && unknown {
1226
+ "effect_unknown_not_retryable"
1227
+ } else if !allowed {
1228
+ "failure_kind_not_retryable"
1229
+ } else if w.attempt_count >= attempt_budget(w) {
1230
+ "attempt_budget_exhausted"
1231
+ } else {
1232
+ "retry"
1233
+ };
1234
+ let retryable = decision == "retry";
1235
+ let evidence: BTreeMap<String, String> = [("decision".into(), decision.into())].into();
917
1236
  if retryable {
918
1237
  w.retry_count += 1;
919
1238
  let multiplier = if w.retry_policy.backoff == BackoffPolicy::Exponential {
@@ -927,15 +1246,9 @@ impl WorkloadStore {
927
1246
  .saturating_mul(multiplier)
928
1247
  .min(w.retry_policy.max_delay_ms);
929
1248
  w.next_attempt_at = Some(now + (delay / 1000) as i64);
930
- store.transition(w, WorkloadState::Ready, "rust.retry", now, BTreeMap::new())?;
1249
+ store.transition(w, WorkloadState::Ready, "rust.retry", now, evidence)?;
931
1250
  } else {
932
- store.transition(
933
- w,
934
- WorkloadState::DeadLetter,
935
- "rust.retry",
936
- now,
937
- BTreeMap::new(),
938
- )?;
1251
+ store.transition(w, WorkloadState::DeadLetter, "rust.retry", now, evidence)?;
939
1252
  }
940
1253
  Ok(w.state)
941
1254
  })
@@ -1018,14 +1331,17 @@ impl WorkloadStore {
1018
1331
  "workload cannot time out in its current state",
1019
1332
  ));
1020
1333
  }
1021
- if let Some(attempt) = w.attempts.last_mut() {
1022
- attempt.status = AttemptStatus::TimedOut;
1023
- attempt.ended_at = Some(now);
1334
+ // A Ready workload has no attempt in progress: the last attempt's
1335
+ // recorded outcome is not this timeout's to overwrite.
1336
+ if w.state != WorkloadState::Ready {
1337
+ if let Some(attempt) = w.attempts.last_mut() {
1338
+ attempt.status = AttemptStatus::TimedOut;
1339
+ attempt.ended_at = Some(now);
1340
+ }
1024
1341
  }
1025
1342
  w.current_claim = None;
1026
1343
  store.transition(w, WorkloadState::TimedOut, actor, now, BTreeMap::new())?;
1027
- if w.attempt_count < w.retry_policy.max_attempts
1028
- && w.retry_policy.retry_on.contains("timeout")
1344
+ if w.attempt_count < attempt_budget(w) && w.retry_policy.retry_on.contains("timeout")
1029
1345
  {
1030
1346
  w.retry_count += 1;
1031
1347
  w.next_attempt_at = Some(now + (w.retry_policy.initial_delay_ms / 1000) as i64);
@@ -1043,6 +1359,21 @@ impl WorkloadStore {
1043
1359
  })
1044
1360
  }
1045
1361
  pub fn retry(&mut self, id: &str, actor: &str, now: i64) -> Result<(), WorkloadError> {
1362
+ self.retry_with_reason(id, actor, now, None)
1363
+ }
1364
+ /// An operator's re-arm of failed, dead-lettered or timed-out work.
1365
+ ///
1366
+ /// Each re-arm grants one more attempt, as it always has, and is recorded
1367
+ /// in the history with its actor and reason. A workload may be re-armed at
1368
+ /// most `retry_policy.max_attempts` times, so operator retries cannot
1369
+ /// become an unbounded loop.
1370
+ pub fn retry_with_reason(
1371
+ &mut self,
1372
+ id: &str,
1373
+ actor: &str,
1374
+ now: i64,
1375
+ reason: Option<&str>,
1376
+ ) -> Result<(), WorkloadError> {
1046
1377
  self.edit(id, |store, w| {
1047
1378
  if !matches!(
1048
1379
  w.state,
@@ -1053,12 +1384,120 @@ impl WorkloadStore {
1053
1384
  "workload is not retryable",
1054
1385
  ));
1055
1386
  }
1387
+ let previous = operator_retries(w);
1388
+ if previous >= w.retry_policy.max_attempts {
1389
+ return Err(WorkloadError::new(
1390
+ "WORKLOAD_RETRY_EXHAUSTED",
1391
+ "workload has used every operator retry its policy allows",
1392
+ ));
1393
+ }
1056
1394
  w.next_attempt_at = Some(now);
1057
1395
  w.current_claim = None;
1058
- store.transition(w, WorkloadState::Ready, actor, now, BTreeMap::new())
1396
+ let mut evidence: BTreeMap<String, String> = [
1397
+ ("retry".into(), "operator".into()),
1398
+ ("operator_retry".into(), (previous + 1).to_string()),
1399
+ (
1400
+ "attempt_budget".into(),
1401
+ w.retry_policy
1402
+ .max_attempts
1403
+ .saturating_add(previous + 1)
1404
+ .to_string(),
1405
+ ),
1406
+ ]
1407
+ .into();
1408
+ if let Some(reason) = reason.filter(|reason| !reason.trim().is_empty()) {
1409
+ evidence.insert("reason".into(), reason.chars().take(256).collect());
1410
+ }
1411
+ store.transition(w, WorkloadState::Ready, actor, now, evidence)
1412
+ })
1413
+ }
1414
+ /// Time out every workload past one of its deadlines (see
1415
+ /// [`deadline_breach`]), as the recovery pass does each second. Deadlines
1416
+ /// are computed from the workload's committed timestamps, so the decision
1417
+ /// is the same after a restart and on any writer of the store.
1418
+ pub fn enforce_deadlines(&mut self, now: i64) -> Result<Vec<String>, WorkloadError> {
1419
+ self.refresh()?;
1420
+ let due = self
1421
+ .workloads
1422
+ .values()
1423
+ .filter_map(|w| deadline_breach(w, now).map(|breach| (w.workload_id.clone(), breach)))
1424
+ .collect::<Vec<_>>();
1425
+ let mut expired = Vec::with_capacity(due.len());
1426
+ for (id, (deadline, at)) in due {
1427
+ match self.expire(&id, deadline, at, now) {
1428
+ Ok(()) => expired.push(id),
1429
+ Err(error) if error.code == "WORKLOAD_CONFLICT" => {}
1430
+ Err(error) => return Err(error),
1431
+ }
1432
+ }
1433
+ Ok(expired)
1434
+ }
1435
+ /// Refuse a worker call on a workload past a deadline, timing it out
1436
+ /// first, in the same way the recovery pass would have.
1437
+ fn enforce_deadline(&mut self, id: &str, now: i64) -> Result<(), WorkloadError> {
1438
+ let Some((deadline, at)) = deadline_breach(self.fresh(id)?, now) else {
1439
+ return Ok(());
1440
+ };
1441
+ self.expire(id, deadline, at, now)?;
1442
+ let mut error = WorkloadError::new(
1443
+ "WORKLOAD_TIMED_OUT",
1444
+ format!("the workload's {deadline} deadline passed"),
1445
+ );
1446
+ error.details = Some(serde_json::json!({ "deadline": deadline, "deadline_at": at }));
1447
+ Err(error)
1448
+ }
1449
+ fn expire(&mut self, id: &str, deadline: &str, at: i64, now: i64) -> Result<(), WorkloadError> {
1450
+ self.edit(id, |store, w| {
1451
+ // Re-check on the committed version: a heartbeat or completion that
1452
+ // committed first decides, and this changes nothing.
1453
+ if deadline_breach(w, now).map(|(kind, _)| kind) != Some(deadline) {
1454
+ return Err(WorkloadError::new(
1455
+ "WORKLOAD_CONFLICT",
1456
+ "the workload changed before its deadline was enforced",
1457
+ ));
1458
+ }
1459
+ let lost = w.current_claim.take();
1460
+ if w.state != WorkloadState::Ready {
1461
+ if let Some(attempt) = w.attempts.last_mut() {
1462
+ attempt.status = AttemptStatus::TimedOut;
1463
+ attempt.ended_at = Some(now);
1464
+ attempt.error = Some(format!("timeout: {deadline} deadline passed"));
1465
+ }
1466
+ }
1467
+ let mut evidence: BTreeMap<String, String> = [
1468
+ ("deadline".into(), deadline.into()),
1469
+ ("deadline_at".into(), at.to_string()),
1470
+ ]
1471
+ .into();
1472
+ if let Some(claim) = &lost {
1473
+ evidence.insert("claim_id".into(), claim.claim_id.clone());
1474
+ evidence.insert("worker_id".into(), claim.worker_id.clone());
1475
+ }
1476
+ store.transition(w, WorkloadState::TimedOut, "rust.deadline", now, evidence)?;
1477
+ // The overall and queue deadlines bound the work, not an attempt:
1478
+ // another attempt cannot meet them.
1479
+ let decision = if matches!(deadline, "overall" | "queue") {
1480
+ "deadline_not_retryable"
1481
+ } else if !w.retry_policy.retry_on.contains("timeout") {
1482
+ "timeout_not_retryable"
1483
+ } else if w.attempt_count >= attempt_budget(w) {
1484
+ "attempt_budget_exhausted"
1485
+ } else {
1486
+ "retry"
1487
+ };
1488
+ let evidence: BTreeMap<String, String> = [("decision".into(), decision.into())].into();
1489
+ if decision == "retry" {
1490
+ w.retry_count += 1;
1491
+ w.next_attempt_at = Some(now + (w.retry_policy.initial_delay_ms / 1000) as i64);
1492
+ store.transition(w, WorkloadState::Ready, "rust.deadline", now, evidence)?;
1493
+ } else {
1494
+ store.transition(w, WorkloadState::DeadLetter, "rust.deadline", now, evidence)?;
1495
+ }
1496
+ Ok(())
1059
1497
  })
1060
1498
  }
1061
1499
  pub fn recover_expired(&mut self, now: i64) -> Result<Vec<String>, WorkloadError> {
1500
+ self.refresh()?;
1062
1501
  let ids = self
1063
1502
  .workloads
1064
1503
  .values()
@@ -1076,41 +1515,90 @@ impl WorkloadStore {
1076
1515
  })
1077
1516
  .map(|w| w.workload_id.clone())
1078
1517
  .collect::<Vec<_>>();
1079
- for id in &ids {
1080
- self.edit(id, |store, w| {
1518
+ let mut recovered = Vec::with_capacity(ids.len());
1519
+ for id in ids {
1520
+ let outcome = self.edit(&id, |store, w| {
1521
+ // Decide on the committed version: another writer may have
1522
+ // recovered (or the worker completed) it since it was listed.
1523
+ let still_expired = w
1524
+ .current_claim
1525
+ .as_ref()
1526
+ .is_some_and(|c| c.expires_at <= now)
1527
+ && matches!(
1528
+ w.state,
1529
+ WorkloadState::Claimed
1530
+ | WorkloadState::Running
1531
+ | WorkloadState::Checkpointed
1532
+ | WorkloadState::CancellationRequested
1533
+ );
1534
+ if !still_expired {
1535
+ return Err(WorkloadError::new(
1536
+ "WORKLOAD_CONFLICT",
1537
+ "the workload changed before it was recovered",
1538
+ ));
1539
+ }
1540
+ let lost = w.current_claim.take();
1081
1541
  if let Some(attempt) = w.attempts.last_mut() {
1082
1542
  attempt.status = AttemptStatus::WorkerLost;
1083
1543
  attempt.ended_at = Some(now);
1544
+ attempt.error = Some("worker_lost: claim lease expired".into());
1545
+ }
1546
+ let mut evidence: BTreeMap<String, String> = [
1547
+ ("recovery_reason".into(), "lease_expired".into()),
1548
+ ("attempts_used".into(), w.attempt_count.to_string()),
1549
+ ("attempt_budget".into(), attempt_budget(w).to_string()),
1550
+ ]
1551
+ .into();
1552
+ if let Some(claim) = &lost {
1553
+ evidence.insert("claim_id".into(), claim.claim_id.clone());
1554
+ evidence.insert("worker_id".into(), claim.worker_id.clone());
1555
+ evidence.insert("lease_expired_at".into(), claim.expires_at.to_string());
1556
+ }
1557
+ if let Some(attempt) = w.attempts.last() {
1558
+ evidence.insert("attempt_id".into(), attempt.attempt_id.clone());
1084
1559
  }
1085
- w.current_claim = None;
1086
1560
  if w.state == WorkloadState::CancellationRequested {
1087
- store.transition(
1088
- w,
1089
- WorkloadState::Cancelled,
1090
- "rust.recovery",
1091
- now,
1092
- BTreeMap::new(),
1093
- )?;
1561
+ store.transition(w, WorkloadState::Cancelled, "rust.recovery", now, evidence)?;
1094
1562
  } else {
1095
1563
  store.transition(
1096
1564
  w,
1097
1565
  WorkloadState::Recovering,
1098
1566
  "rust.recovery",
1099
1567
  now,
1100
- BTreeMap::new(),
1568
+ evidence,
1101
1569
  )?;
1570
+ // A lost worker is the policy's `worker_failure`. Whether
1571
+ // the lost attempt had effects is unknown to FeltDB, so a
1572
+ // retry re-executes it: at-least-once, never exactly-once.
1573
+ let (to, decision) = if !w.retry_policy.retry_on.contains("worker_failure") {
1574
+ (WorkloadState::DeadLetter, "worker_failure_not_retryable")
1575
+ } else if w.attempt_count >= attempt_budget(w) {
1576
+ (WorkloadState::DeadLetter, "attempt_budget_exhausted")
1577
+ } else {
1578
+ (WorkloadState::Ready, "retry")
1579
+ };
1102
1580
  store.transition(
1103
1581
  w,
1104
- WorkloadState::Ready,
1582
+ to,
1105
1583
  "rust.recovery",
1106
1584
  now,
1107
- BTreeMap::new(),
1585
+ [("decision".into(), decision.into())].into(),
1108
1586
  )?;
1109
1587
  }
1110
1588
  Ok(())
1111
- })?;
1589
+ });
1590
+ match outcome {
1591
+ Ok(()) => recovered.push(id),
1592
+ // Another writer of this store committed the workload first —
1593
+ // in a race, its own recovery. Nothing of this attempt was
1594
+ // committed and `edit` has adopted the committed workload, so
1595
+ // the attempt is recovered once, by that writer. If it still
1596
+ // needs recovery, the next pass sees it.
1597
+ Err(error) if error.code == "WORKLOAD_CONFLICT" => {}
1598
+ Err(error) => return Err(error),
1599
+ }
1112
1600
  }
1113
- Ok(ids)
1601
+ Ok(recovered)
1114
1602
  }
1115
1603
  }
1116
1604
 
@@ -1196,6 +1684,7 @@ fn legal(from: WorkloadState, to: WorkloadState) -> bool {
1196
1684
  | (Created, Cancelled)
1197
1685
  | (Failed, Cancelled)
1198
1686
  | (DeadLetter, Cancelled)
1687
+ | (Claimed, Failed)
1199
1688
  | (Running, Failed)
1200
1689
  | (Checkpointed, Failed)
1201
1690
  | (Claimed, Recovering)
@@ -1211,6 +1700,8 @@ fn legal(from: WorkloadState, to: WorkloadState) -> bool {
1211
1700
  | (Checkpointed, TimedOut)
1212
1701
  | (Ready, TimedOut)
1213
1702
  | (TimedOut, DeadLetter)
1703
+ | (Recovering, DeadLetter)
1704
+ | (Ready, DeadLetter)
1214
1705
  )
1215
1706
  }
1216
1707
  pub fn can_transition(from: WorkloadState, to: WorkloadState) -> bool {
@@ -1259,6 +1750,248 @@ fn ensure_safe(value: &Value) -> Result<(), WorkloadError> {
1259
1750
  fn sensitive_text(v: &str) -> bool {
1260
1751
  v.contains("BEGIN PRIVATE KEY") || v.starts_with("sk-") || v.starts_with("fdb_live_")
1261
1752
  }
1753
+ /// The identity a caller's idempotency key is unique within: tenant,
1754
+ /// application and requester. Encoded as a JSON array, so no choice of key or
1755
+ /// identifier can make two different scopes coincide.
1756
+ fn idempotency_scope(tenant: &str, application: &str, requester: &Subject, key: &str) -> String {
1757
+ serde_json::json!([tenant, application, requester.kind(), requester.id(), key]).to_string()
1758
+ }
1759
+ /// The scope before keys were bound to their requester, kept only to verify
1760
+ /// legacy store files during migration.
1761
+ fn legacy_idempotency_scope(
1762
+ tenant: &str,
1763
+ application: &str,
1764
+ _requester: &Subject,
1765
+ key: &str,
1766
+ ) -> String {
1767
+ format!("{tenant}:{application}:{key}")
1768
+ }
1769
+ fn idempotency_index(
1770
+ workloads: &BTreeMap<String, Workload>,
1771
+ scope: fn(&str, &str, &Subject, &str) -> String,
1772
+ ) -> BTreeMap<String, String> {
1773
+ workloads
1774
+ .values()
1775
+ .map(|w| {
1776
+ (
1777
+ scope(&w.tenant_id, &w.application_id, &w.requested_by, &w.idempotency_key),
1778
+ w.workload_id.clone(),
1779
+ )
1780
+ })
1781
+ .collect()
1782
+ }
1783
+ /// Answer a repeated key: the original workload for the same request, a
1784
+ /// refusal for a different one. Never the original for a different request.
1785
+ /// The execution-affecting part of an admission, normalized, field by field.
1786
+ ///
1787
+ /// A replay under the same key is answered with the original workload only when
1788
+ /// every one of these is equal. Optional fields are compared after their
1789
+ /// defaults apply (serde fills an omitted `retry_policy`, `timeouts`,
1790
+ /// `priority`, `connection_scopes` or `dependencies`); sets compare as sets and
1791
+ /// dependencies regardless of order; the input compares by its canonical hash,
1792
+ /// so member order is not part of it. Provenance that does not change what runs
1793
+ /// — `trigger`, `session_id`, `created_at`, and the authorization snapshot,
1794
+ /// which is verified before any replay — is not compared.
1795
+ fn execution_fields(
1796
+ definition_id: &str,
1797
+ definition_revision: &str,
1798
+ environment: &str,
1799
+ input_hash: &str,
1800
+ capability_snapshot: &BTreeSet<String>,
1801
+ connection_scopes: &BTreeSet<String>,
1802
+ priority: i32,
1803
+ retry_policy: &WorkloadRetryPolicy,
1804
+ timeouts: &WorkloadTimeouts,
1805
+ dependencies: &[WorkloadDependency],
1806
+ not_before: Option<i64>,
1807
+ ) -> Vec<(&'static str, Value)> {
1808
+ let mut dependencies = dependencies
1809
+ .iter()
1810
+ .map(|d| serde_json::json!([d.workload_id, d.required_state]))
1811
+ .collect::<Vec<_>>();
1812
+ dependencies.sort_by_key(|d| d.to_string());
1813
+ vec![
1814
+ ("definition_id", definition_id.into()),
1815
+ ("definition_revision", definition_revision.into()),
1816
+ ("environment", environment.into()),
1817
+ ("input", input_hash.into()),
1818
+ (
1819
+ "capability_snapshot",
1820
+ serde_json::json!(capability_snapshot),
1821
+ ),
1822
+ ("connection_scopes", serde_json::json!(connection_scopes)),
1823
+ ("priority", priority.into()),
1824
+ ("retry_policy", serde_json::json!(retry_policy)),
1825
+ ("timeouts", serde_json::json!(timeouts)),
1826
+ ("dependencies", Value::Array(dependencies)),
1827
+ ("not_before", serde_json::json!(not_before)),
1828
+ ]
1829
+ }
1830
+ /// The `not_before` a workload was admitted with, from its creation event
1831
+ /// (`next_attempt_at` moves with retries; the admission evidence does not).
1832
+ fn admitted_not_before(w: &Workload) -> Option<i64> {
1833
+ w.execution_history
1834
+ .iter()
1835
+ .find(|event| event.to == WorkloadState::Ready)
1836
+ .and_then(|event| event.evidence.get("not_before"))
1837
+ .and_then(|value| value.parse().ok())
1838
+ }
1839
+ fn replay(existing: Workload, input: &CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
1840
+ let recorded = execution_fields(
1841
+ &existing.definition_id,
1842
+ &existing.definition_revision,
1843
+ &existing.environment,
1844
+ &existing.input_hash,
1845
+ &existing.capability_snapshot,
1846
+ &existing.connection_scopes,
1847
+ existing.priority,
1848
+ &existing.retry_policy,
1849
+ &existing.timeouts,
1850
+ &existing.dependencies,
1851
+ admitted_not_before(&existing),
1852
+ );
1853
+ let requested = execution_fields(
1854
+ &input.definition_id,
1855
+ &input.definition_revision,
1856
+ &input.environment,
1857
+ &hash(&input.input),
1858
+ &input.capability_snapshot,
1859
+ &input.connection_scopes,
1860
+ input.priority,
1861
+ &input.retry_policy,
1862
+ &input.timeouts,
1863
+ &input.dependencies,
1864
+ input.not_before,
1865
+ );
1866
+ let mut conflicting = recorded
1867
+ .iter()
1868
+ .zip(&requested)
1869
+ .filter(|((_, left), (_, right))| left != right)
1870
+ .map(|((name, _), _)| *name)
1871
+ .collect::<Vec<_>>();
1872
+ if existing.requested_by != input.requested_by {
1873
+ conflicting.insert(0, "requested_by");
1874
+ }
1875
+ if conflicting.is_empty() {
1876
+ return Ok((existing, false));
1877
+ }
1878
+ let mut error = WorkloadError::new(
1879
+ "IDEMPOTENCY_KEY_REUSED",
1880
+ "idempotency key was already used for a different request",
1881
+ );
1882
+ error.details = Some(serde_json::json!({ "fields": conflicting }));
1883
+ Err(error)
1884
+ }
1885
+ /// Seconds a millisecond timeout lasts, rounded up so a deadline is never early.
1886
+ fn deadline_seconds(ms: u64) -> i64 {
1887
+ ms.div_ceil(1000) as i64
1888
+ }
1889
+ /// The first deadline a workload has passed at `now`, and when it fell.
1890
+ ///
1891
+ /// | Deadline | Measured from | While |
1892
+ /// | --- | --- | --- |
1893
+ /// | `overall` | admission (`created_at`) | not terminal |
1894
+ /// | `queue` | becoming `READY` (or its `next_attempt_at`, if later) | `READY` |
1895
+ /// | `claim` | the claim | `CLAIMED` |
1896
+ /// | `execution` | the attempt's start | `RUNNING`, `CHECKPOINTED` |
1897
+ /// | `heartbeat` | the last heartbeat (or the claim) | claimed |
1898
+ ///
1899
+ /// Only workloads admitted at [`DEADLINE_CONTRACT_VERSION`] or later.
1900
+ pub fn deadline_breach(w: &Workload, now: i64) -> Option<(&'static str, i64)> {
1901
+ use WorkloadState::*;
1902
+ if w.contract_version < DEADLINE_CONTRACT_VERSION {
1903
+ return None;
1904
+ }
1905
+ let active = matches!(w.state, Ready | Claimed | Running | Checkpointed);
1906
+ if !active {
1907
+ return None;
1908
+ }
1909
+ let t = &w.timeouts;
1910
+ let claim = w.current_claim.as_ref();
1911
+ let candidates = [
1912
+ (
1913
+ "overall",
1914
+ t.overall_ms.map(|ms| w.created_at + deadline_seconds(ms)),
1915
+ ),
1916
+ (
1917
+ "queue",
1918
+ t.queue_ms.filter(|_| w.state == Ready).map(|ms| {
1919
+ let ready_since = w
1920
+ .execution_history
1921
+ .iter()
1922
+ .rev()
1923
+ .find(|event| event.to == Ready)
1924
+ .map_or(w.created_at, |event| event.at);
1925
+ ready_since.max(w.next_attempt_at.unwrap_or(i64::MIN)) + deadline_seconds(ms)
1926
+ }),
1927
+ ),
1928
+ (
1929
+ "claim",
1930
+ t.claim_ms
1931
+ .filter(|_| w.state == Claimed)
1932
+ .and_then(|ms| claim.map(|c| c.claimed_at + deadline_seconds(ms))),
1933
+ ),
1934
+ (
1935
+ "execution",
1936
+ t.execution_ms
1937
+ .filter(|_| matches!(w.state, Running | Checkpointed))
1938
+ .and_then(|ms| {
1939
+ w.attempts
1940
+ .last()
1941
+ .and_then(|a| a.started_at)
1942
+ .map(|started| started + deadline_seconds(ms))
1943
+ }),
1944
+ ),
1945
+ (
1946
+ "heartbeat",
1947
+ t.heartbeat_ms
1948
+ .and_then(|ms| claim.map(|c| c.last_heartbeat + deadline_seconds(ms))),
1949
+ ),
1950
+ ];
1951
+ candidates
1952
+ .into_iter()
1953
+ .find_map(|(kind, at)| at.filter(|at| now >= *at).map(|at| (kind, at)))
1954
+ }
1955
+ /// The attempt this fence named, when that attempt has already reported its
1956
+ /// outcome (failed or succeeded). Lost and timed-out attempts are not reports.
1957
+ fn reported_attempt<'w>(
1958
+ w: &'w Workload,
1959
+ claim: &str,
1960
+ worker: &str,
1961
+ token: u64,
1962
+ ) -> Option<&'w WorkloadAttempt> {
1963
+ w.attempts.iter().find(|attempt| {
1964
+ attempt.claim_id == claim
1965
+ && attempt.worker_id == worker
1966
+ && attempt.fencing_token == token
1967
+ && matches!(
1968
+ attempt.status,
1969
+ AttemptStatus::Failed | AttemptStatus::Succeeded
1970
+ )
1971
+ })
1972
+ }
1973
+ /// The `FAILED` event a failure report of `attempt_id` recorded.
1974
+ fn failure_event<'w>(w: &'w Workload, attempt_id: &str) -> Option<&'w WorkloadEvent> {
1975
+ w.execution_history.iter().rev().find(|event| {
1976
+ event.to == WorkloadState::Failed
1977
+ && event.evidence.get("attempt_id").map(String::as_str) == Some(attempt_id)
1978
+ })
1979
+ }
1980
+ /// Operator re-arms recorded in the workload's own history.
1981
+ fn operator_retries(w: &Workload) -> u32 {
1982
+ w.execution_history
1983
+ .iter()
1984
+ .filter(|event| event.evidence.get("retry").map(String::as_str) == Some("operator"))
1985
+ .count() as u32
1986
+ }
1987
+ /// How many claims the workload may have in total: its policy's
1988
+ /// `max_attempts`, plus one for each operator re-arm (which is what an operator
1989
+ /// retry has always granted).
1990
+ fn attempt_budget(w: &Workload) -> u32 {
1991
+ w.retry_policy
1992
+ .max_attempts
1993
+ .saturating_add(operator_retries(w))
1994
+ }
1262
1995
  fn work_key(workload: &Workload) -> String {
1263
1996
  format!(
1264
1997
  "{}/{}/{}",
@@ -1312,8 +2045,12 @@ mod tests {
1312
2045
  };
1313
2046
  use std::{fs, path::PathBuf};
1314
2047
  fn create() -> CreateWorkload {
2048
+ create_as("a1")
2049
+ }
2050
+ /// The fixture request, made by agent `agent`.
2051
+ fn create_as(agent: &str) -> CreateWorkload {
1315
2052
  let signer = GrantSigner::new("k", vec![1; 32]).unwrap();
1316
- let subject = Subject::Agent("a1".into());
2053
+ let subject = Subject::Agent(agent.into());
1317
2054
  let grant = Grant {
1318
2055
  contract_version: 1,
1319
2056
  id: "g".into(),
@@ -1355,66 +2092,404 @@ mod tests {
1355
2092
  created_at: 10,
1356
2093
  }
1357
2094
  }
1358
- fn store(name: &str) -> (PathBuf, WorkloadStore) {
1359
- let path =
1360
- std::env::temp_dir().join(format!("workload-{name}-{}.json", std::process::id()));
1361
- let store = WorkloadStore::load(&path).unwrap();
1362
- (path, store)
2095
+ fn capabilities() -> BTreeSet<String> {
2096
+ ["email:send".into()].into()
1363
2097
  }
2098
+
1364
2099
  #[test]
1365
- fn legacy_migration_is_idempotent_conflict_detecting_and_source_independent() {
1366
- let (source_db, mut source) = store("legacy-source-builder");
1367
- let workload = source.create(create()).unwrap();
1368
- let legacy = LegacyWorkloadStore {
1369
- workloads: source.workloads.clone(),
1370
- idempotency: source.idempotency.clone(),
1371
- result_ids: source.result_ids.clone(),
1372
- event_sequence: derived_event_sequence(&source.workloads),
1373
- };
1374
- let legacy_path = source_db.with_extension("legacy-workloads.json");
1375
- fs::write(&legacy_path, serde_json::to_vec(&legacy).unwrap()).unwrap();
1376
- let canonical_path = source_db.with_extension("canonical.log");
1377
- let db = FeltDb::open(&canonical_path).unwrap();
1378
- let migrated = WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
1379
- assert_eq!(
1380
- migrated.get(&workload.workload_id).unwrap().input_hash,
1381
- workload.input_hash
1382
- );
1383
- WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
1384
- fs::remove_file(&legacy_path).unwrap();
1385
- let reopened = WorkloadStore::from_db(FeltDb::open(&canonical_path).unwrap()).unwrap();
1386
- assert_eq!(
1387
- reopened.get(&workload.workload_id).unwrap().workload_id,
1388
- workload.workload_id
1389
- );
2100
+ fn idempotency_keys_are_bound_to_their_requester_and_request() {
2101
+ let (path, mut store) = store("requester-scoped-keys");
2102
+ let (original, created) = store.admit(create_as("a1")).unwrap();
2103
+ assert!(created);
1390
2104
 
1391
- let mut conflicting = legacy;
1392
- conflicting
1393
- .workloads
1394
- .get_mut(&workload.workload_id)
1395
- .unwrap()
1396
- .priority += 1;
1397
- fs::write(&legacy_path, serde_json::to_vec(&conflicting).unwrap()).unwrap();
2105
+ // The same requester and request: the original, not a second workload.
2106
+ let (replayed, created) = store.admit(create_as("a1")).unwrap();
2107
+ assert!(!created);
2108
+ assert_eq!(replayed.workload_id, original.workload_id);
2109
+
2110
+ // The same key for a different request is refused and creates nothing.
2111
+ let mut different = create_as("a1");
2112
+ different.input = serde_json::json!({"id": 2});
2113
+ assert_eq!(store.admit(different).unwrap_err().code, "IDEMPOTENCY_KEY_REUSED");
2114
+ let mut other_definition = create_as("a1");
2115
+ other_definition.definition_id = "other".into();
1398
2116
  assert_eq!(
1399
- WorkloadStore::migrate_legacy_file(db, &legacy_path)
1400
- .err()
1401
- .unwrap()
1402
- .code,
1403
- "WORKLOAD_MIGRATION_CONFLICT"
2117
+ store.admit(other_definition).unwrap_err().code,
2118
+ "IDEMPOTENCY_KEY_REUSED"
1404
2119
  );
1405
- let _ = fs::remove_file(source_db);
1406
- let _ = fs::remove_file(canonical_path);
1407
- let _ = fs::remove_file(legacy_path);
2120
+
2121
+ // Another requester's identical key is its own workload: it neither
2122
+ // returns, reveals nor blocks the first requester's.
2123
+ let (theirs, created) = store.admit(create_as("a2")).unwrap();
2124
+ assert!(created);
2125
+ assert_ne!(theirs.workload_id, original.workload_id);
2126
+ assert_eq!(store.workloads.len(), 2);
2127
+
2128
+ // A snapshot that does not bind the requester is refused before any
2129
+ // replay, so a key cannot be used to fetch a workload.
2130
+ let mut unbound = create_as("a1");
2131
+ unbound.requested_by = Subject::Agent("a2".into());
2132
+ assert_eq!(store.admit(unbound).unwrap_err().code, "WORKLOAD_UNAUTHORIZED");
2133
+ let _ = fs::remove_file(path);
1408
2134
  }
1409
2135
 
1410
2136
  #[test]
1411
- fn canonical_history_reconstructs_work_identity_provenance() {
1412
- let (path, mut store) = store("provenance");
2137
+ fn lease_recovery_records_the_lost_worker_and_is_bounded_by_the_attempt_budget() {
2138
+ let (path, mut store) = store("bounded-recovery");
1413
2139
  let workload = store.create(create()).unwrap();
1414
- let transaction = store
1415
- .db
1416
- .committed_transactions_after(0, usize::MAX)
1417
- .unwrap()
2140
+ let id = workload.workload_id.clone();
2141
+ for attempt in 1..=3u32 {
2142
+ let now = 100 * attempt as i64;
2143
+ let claim = store.claim(&id, "worker-a", &capabilities(), now, 10_000).unwrap();
2144
+ assert_eq!(store.recover_expired(claim.expires_at).unwrap(), vec![id.clone()]);
2145
+ let recovered = store.get(&id).unwrap();
2146
+ let lost = recovered.attempts.last().unwrap();
2147
+ assert_eq!(lost.status, AttemptStatus::WorkerLost);
2148
+ assert_eq!(lost.claim_id, claim.claim_id);
2149
+ let recovering = recovered
2150
+ .execution_history
2151
+ .iter()
2152
+ .rev()
2153
+ .find(|event| event.to == WorkloadState::Recovering)
2154
+ .unwrap();
2155
+ assert_eq!(recovering.evidence["recovery_reason"], "lease_expired");
2156
+ assert_eq!(recovering.evidence["worker_id"], "worker-a");
2157
+ assert_eq!(recovering.evidence["claim_id"], claim.claim_id);
2158
+ assert_eq!(recovering.evidence["attempts_used"], attempt.to_string());
2159
+ let decision = &recovered.execution_history.last().unwrap();
2160
+ if attempt < 3 {
2161
+ assert_eq!(recovered.state, WorkloadState::Ready);
2162
+ assert_eq!(decision.evidence["decision"], "retry");
2163
+ } else {
2164
+ assert_eq!(recovered.state, WorkloadState::DeadLetter);
2165
+ assert_eq!(decision.evidence["decision"], "attempt_budget_exhausted");
2166
+ }
2167
+ }
2168
+ assert_eq!(store.get(&id).unwrap().attempt_count, 3);
2169
+ assert_eq!(
2170
+ store.claim(&id, "worker-b", &capabilities(), 1_000, 10_000).unwrap_err().code,
2171
+ "WORKLOAD_NOT_READY"
2172
+ );
2173
+ let _ = fs::remove_file(path);
2174
+ }
2175
+
2176
+ /// The recovery history of `id` as committed, read by a fresh projection.
2177
+ fn committed_workload(db: &FeltDb, id: &str) -> Workload {
2178
+ WorkloadStore::from_db(db.clone()).unwrap().get(id).unwrap().clone()
2179
+ }
2180
+
2181
+ fn recoveries(workload: &Workload) -> usize {
2182
+ workload
2183
+ .execution_history
2184
+ .iter()
2185
+ .filter(|event| event.to == WorkloadState::Recovering)
2186
+ .count()
2187
+ }
2188
+
2189
+ /// Two writers of one store recover the same expired workload at once.
2190
+ fn race_recovery(db: &FeltDb, now: i64) -> (WorkloadStore, WorkloadStore, Vec<Vec<String>>) {
2191
+ let stores = [
2192
+ WorkloadStore::from_db(db.clone()).unwrap(),
2193
+ WorkloadStore::from_db(db.clone()).unwrap(),
2194
+ ];
2195
+ let barrier = std::sync::Barrier::new(2);
2196
+ let results = std::thread::scope(|scope| {
2197
+ let handles = stores
2198
+ .into_iter()
2199
+ .map(|mut store| {
2200
+ let barrier = &barrier;
2201
+ scope.spawn(move || {
2202
+ barrier.wait();
2203
+ let recovered = store.recover_expired(now).unwrap();
2204
+ (store, recovered)
2205
+ })
2206
+ })
2207
+ .collect::<Vec<_>>();
2208
+ handles
2209
+ .into_iter()
2210
+ .map(|handle| handle.join().unwrap())
2211
+ .collect::<Vec<_>>()
2212
+ });
2213
+ let mut results = results.into_iter();
2214
+ let (first, first_recovered) = results.next().unwrap();
2215
+ let (second, second_recovered) = results.next().unwrap();
2216
+ (first, second, vec![first_recovered, second_recovered])
2217
+ }
2218
+
2219
+ #[test]
2220
+ fn concurrent_recovery_commits_one_transition_and_the_loser_converges() {
2221
+ for round in 0..20 {
2222
+ let (path, mut store) = store(&format!("concurrent-recovery-{round}"));
2223
+ let db = store.db.clone();
2224
+ let id = store.create(create()).unwrap().workload_id;
2225
+ let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
2226
+ // A writer that loaded the workload before either recovery.
2227
+ // Commits land in this projection's window between refresh and
2228
+ // commit: only the storage guard can refuse them.
2229
+ let mut stale = WorkloadStore::from_db(db.clone())
2230
+ .unwrap()
2231
+ .without_refresh();
2232
+
2233
+ let (mut first, mut second, recovered) = race_recovery(&db, claim.expires_at);
2234
+ // Exactly one writer committed the recovery; the other reported none.
2235
+ let mut outcomes = recovered.clone();
2236
+ outcomes.sort();
2237
+ assert_eq!(outcomes, vec![vec![], vec![id.clone()]], "round {round}");
2238
+
2239
+ let committed = committed_workload(&db, &id);
2240
+ assert_eq!(recoveries(&committed), 1, "one recovery transition");
2241
+ assert_eq!(committed.state, WorkloadState::Ready);
2242
+ assert_eq!(committed.attempt_count, 1);
2243
+ assert_eq!(committed.attempts.len(), 1);
2244
+ assert_eq!(committed.attempts[0].status, AttemptStatus::WorkerLost);
2245
+ assert!(committed.current_claim.is_none());
2246
+ let recovering = committed
2247
+ .execution_history
2248
+ .iter()
2249
+ .find(|event| event.to == WorkloadState::Recovering)
2250
+ .unwrap();
2251
+ assert_eq!(recovering.evidence["claim_id"], claim.claim_id);
2252
+ assert_eq!(recovering.evidence["attempts_used"], "1");
2253
+
2254
+ // The losing writer adopted the committed workload instead of
2255
+ // keeping its own, uncommitted view.
2256
+ let expected = serde_json::to_value(&committed).unwrap();
2257
+ assert_eq!(serde_json::to_value(first.get(&id).unwrap()).unwrap(), expected);
2258
+ assert_eq!(serde_json::to_value(second.get(&id).unwrap()).unwrap(), expected);
2259
+ // And neither recovers it again, or commits anything more.
2260
+ assert!(first.recover_expired(claim.expires_at + 1).unwrap().is_empty());
2261
+ assert!(second.recover_expired(claim.expires_at + 1).unwrap().is_empty());
2262
+ assert_eq!(
2263
+ serde_json::to_value(committed_workload(&db, &id)).unwrap(),
2264
+ expected,
2265
+ "no further commit"
2266
+ );
2267
+
2268
+ // The lost claimant cannot complete, even through a projection
2269
+ // that still shows its claim as current and unexpired: the guarded
2270
+ // commit refuses the stale version.
2271
+ let refused = stale
2272
+ .start(&id, &claim.claim_id, "worker-a", claim.fencing_token, claim.expires_at - 1)
2273
+ .unwrap_err();
2274
+ assert_eq!(refused.code, "WORKLOAD_CONFLICT");
2275
+ assert_eq!(stale.get(&id).unwrap().state, WorkloadState::Ready);
2276
+ let refused = stale
2277
+ .complete(
2278
+ &id,
2279
+ &claim.claim_id,
2280
+ "worker-a",
2281
+ claim.fencing_token,
2282
+ "late-result",
2283
+ serde_json::json!({"late": true}),
2284
+ vec![],
2285
+ 1,
2286
+ claim.expires_at - 1,
2287
+ )
2288
+ .unwrap_err();
2289
+ assert_eq!(refused.code, "WORKLOAD_CLAIM_INVALID");
2290
+ // Through a current projection its fence no longer matches.
2291
+ let refused = first
2292
+ .heartbeat(&id, &claim.claim_id, "worker-a", claim.fencing_token, claim.expires_at - 1, 10_000)
2293
+ .unwrap_err();
2294
+ assert_eq!(refused.code, "WORKLOAD_CLAIM_INVALID");
2295
+ assert_eq!(serde_json::to_value(committed_workload(&db, &id)).unwrap(), expected);
2296
+ drop((store, first, second, stale, db));
2297
+ let _ = fs::remove_file(path);
2298
+ }
2299
+ }
2300
+
2301
+ #[test]
2302
+ fn concurrent_recovery_never_exceeds_the_attempt_budget() {
2303
+ let (path, mut store) = store("concurrent-recovery-budget");
2304
+ let db = store.db.clone();
2305
+ let id = store.create(create()).unwrap().workload_id;
2306
+ for attempt in 1..=3i64 {
2307
+ // Each attempt is claimed by an up-to-date writer and lost.
2308
+ let mut claimer = WorkloadStore::from_db(db.clone()).unwrap();
2309
+ let claim = claimer
2310
+ .claim(&id, "worker-a", &capabilities(), 100 * attempt, 10_000)
2311
+ .unwrap();
2312
+ let (_, _, recovered) = race_recovery(&db, claim.expires_at);
2313
+ assert_eq!(recovered.concat(), vec![id.clone()], "attempt {attempt}");
2314
+ let committed = committed_workload(&db, &id);
2315
+ assert_eq!(recoveries(&committed), attempt as usize);
2316
+ assert_eq!(committed.attempt_count, attempt as u32);
2317
+ }
2318
+ let committed = committed_workload(&db, &id);
2319
+ assert_eq!(committed.state, WorkloadState::DeadLetter);
2320
+ assert_eq!(
2321
+ committed.execution_history.last().unwrap().evidence["decision"],
2322
+ "attempt_budget_exhausted"
2323
+ );
2324
+ assert!(committed
2325
+ .attempts
2326
+ .iter()
2327
+ .all(|attempt| attempt.status == AttemptStatus::WorkerLost));
2328
+ let refused = WorkloadStore::from_db(db.clone())
2329
+ .unwrap()
2330
+ .claim(&id, "worker-b", &capabilities(), 10_000, 10_000)
2331
+ .unwrap_err();
2332
+ assert_eq!(refused.code, "WORKLOAD_NOT_READY");
2333
+ assert_eq!(committed_workload(&db, &id).attempt_count, 3);
2334
+ drop((store, db));
2335
+ let _ = fs::remove_file(path);
2336
+ }
2337
+
2338
+ #[test]
2339
+ fn a_policy_that_does_not_retry_worker_failure_dead_letters_a_lost_attempt() {
2340
+ let (path, mut store) = store("no-worker-failure-retry");
2341
+ let mut request = create();
2342
+ request.retry_policy.retry_on = ["timeout".into()].into();
2343
+ let id = store.create(request).unwrap().workload_id;
2344
+ let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
2345
+ store.recover_expired(claim.expires_at).unwrap();
2346
+ let workload = store.get(&id).unwrap();
2347
+ assert_eq!(workload.state, WorkloadState::DeadLetter);
2348
+ assert_eq!(
2349
+ workload.execution_history.last().unwrap().evidence["decision"],
2350
+ "worker_failure_not_retryable"
2351
+ );
2352
+ let _ = fs::remove_file(path);
2353
+ }
2354
+
2355
+ #[test]
2356
+ fn claiming_exhausted_ready_work_dead_letters_it_instead() {
2357
+ let (path, mut store) = store("exhausted-claim");
2358
+ let id = store.create(create()).unwrap().workload_id;
2359
+ // What unbounded recovery used to leave behind: Ready, budget spent.
2360
+ let mut legacy = store.get(&id).unwrap().clone();
2361
+ legacy.attempt_count = 7;
2362
+ store.workloads.insert(id.clone(), legacy);
2363
+ store.persist_workload(&id, "fixture.legacy").unwrap();
2364
+ let refused = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap_err();
2365
+ assert_eq!(refused.code, "WORKLOAD_ATTEMPTS_EXHAUSTED");
2366
+ let workload = store.get(&id).unwrap();
2367
+ assert_eq!(workload.state, WorkloadState::DeadLetter);
2368
+ assert_eq!(
2369
+ workload.execution_history.last().unwrap().evidence["reason"],
2370
+ "attempt_budget_exhausted"
2371
+ );
2372
+ // Durable: a restart sees the same outcome.
2373
+ let reopened = WorkloadStore::from_db(store.db.clone()).unwrap();
2374
+ assert_eq!(reopened.get(&id).unwrap().state, WorkloadState::DeadLetter);
2375
+ let _ = fs::remove_file(path);
2376
+ }
2377
+
2378
+ #[test]
2379
+ fn operator_retry_grants_one_attempt_is_recorded_and_is_bounded() {
2380
+ let (path, mut store) = store("operator-retry");
2381
+ let id = store.create(create()).unwrap().workload_id;
2382
+ // Claim and lose leases until the workload stops being claimable.
2383
+ fn exhaust(store: &mut WorkloadStore, id: &str, now: &mut i64) {
2384
+ loop {
2385
+ *now += 100;
2386
+ match store.claim(id, "worker-a", &capabilities(), *now, 10_000) {
2387
+ Ok(claim) => {
2388
+ store.recover_expired(claim.expires_at).unwrap();
2389
+ }
2390
+ Err(error) => {
2391
+ assert!(
2392
+ matches!(
2393
+ error.code.as_str(),
2394
+ "WORKLOAD_NOT_READY" | "WORKLOAD_ATTEMPTS_EXHAUSTED"
2395
+ ),
2396
+ "{error:?}"
2397
+ );
2398
+ return;
2399
+ }
2400
+ }
2401
+ }
2402
+ }
2403
+ let mut now = 100;
2404
+ exhaust(&mut store, &id, &mut now);
2405
+ assert_eq!(store.get(&id).unwrap().attempt_count, 3);
2406
+ for retry in 1..=3u32 {
2407
+ now += 100;
2408
+ store
2409
+ .retry_with_reason(&id, "operator-1", now, Some("dependency restored"))
2410
+ .unwrap();
2411
+ let event = store.get(&id).unwrap().execution_history.last().unwrap().clone();
2412
+ assert_eq!(event.actor, "operator-1");
2413
+ assert_eq!(event.evidence["retry"], "operator");
2414
+ assert_eq!(event.evidence["reason"], "dependency restored");
2415
+ assert_eq!(event.evidence["operator_retry"], retry.to_string());
2416
+ exhaust(&mut store, &id, &mut now);
2417
+ assert_eq!(
2418
+ store.get(&id).unwrap().attempt_count,
2419
+ 3 + retry,
2420
+ "each operator retry grants exactly one attempt"
2421
+ );
2422
+ assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
2423
+ }
2424
+ assert_eq!(
2425
+ store.retry(&id, "operator-1", now + 100).unwrap_err().code,
2426
+ "WORKLOAD_RETRY_EXHAUSTED"
2427
+ );
2428
+ assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
2429
+ let _ = fs::remove_file(path);
2430
+ }
2431
+
2432
+ fn store(name: &str) -> (PathBuf, WorkloadStore) {
2433
+ let path =
2434
+ std::env::temp_dir().join(format!("workload-{name}-{}.json", std::process::id()));
2435
+ let store = WorkloadStore::load(&path).unwrap();
2436
+ (path, store)
2437
+ }
2438
+ #[test]
2439
+ fn legacy_migration_is_idempotent_conflict_detecting_and_source_independent() {
2440
+ let (source_db, mut source) = store("legacy-source-builder");
2441
+ let workload = source.create(create()).unwrap();
2442
+ let legacy = LegacyWorkloadStore {
2443
+ workloads: source.workloads.clone(),
2444
+ // What a pre-requester-scope release wrote.
2445
+ idempotency: idempotency_index(&source.workloads, legacy_idempotency_scope),
2446
+ result_ids: source.result_ids.clone(),
2447
+ event_sequence: derived_event_sequence(&source.workloads),
2448
+ };
2449
+ let legacy_path = source_db.with_extension("legacy-workloads.json");
2450
+ fs::write(&legacy_path, serde_json::to_vec(&legacy).unwrap()).unwrap();
2451
+ let canonical_path = source_db.with_extension("canonical.log");
2452
+ let db = FeltDb::open(&canonical_path).unwrap();
2453
+ let migrated = WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
2454
+ assert_eq!(
2455
+ migrated.get(&workload.workload_id).unwrap().input_hash,
2456
+ workload.input_hash
2457
+ );
2458
+ WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
2459
+ fs::remove_file(&legacy_path).unwrap();
2460
+ let reopened = WorkloadStore::from_db(FeltDb::open(&canonical_path).unwrap()).unwrap();
2461
+ assert_eq!(
2462
+ reopened.get(&workload.workload_id).unwrap().workload_id,
2463
+ workload.workload_id
2464
+ );
2465
+
2466
+ let mut conflicting = legacy;
2467
+ conflicting
2468
+ .workloads
2469
+ .get_mut(&workload.workload_id)
2470
+ .unwrap()
2471
+ .priority += 1;
2472
+ fs::write(&legacy_path, serde_json::to_vec(&conflicting).unwrap()).unwrap();
2473
+ assert_eq!(
2474
+ WorkloadStore::migrate_legacy_file(db, &legacy_path)
2475
+ .err()
2476
+ .unwrap()
2477
+ .code,
2478
+ "WORKLOAD_MIGRATION_CONFLICT"
2479
+ );
2480
+ let _ = fs::remove_file(source_db);
2481
+ let _ = fs::remove_file(canonical_path);
2482
+ let _ = fs::remove_file(legacy_path);
2483
+ }
2484
+
2485
+ #[test]
2486
+ fn canonical_history_reconstructs_work_identity_provenance() {
2487
+ let (path, mut store) = store("provenance");
2488
+ let workload = store.create(create()).unwrap();
2489
+ let transaction = store
2490
+ .db
2491
+ .committed_transactions_after(0, usize::MAX)
2492
+ .unwrap()
1418
2493
  .into_iter()
1419
2494
  .last()
1420
2495
  .unwrap();
@@ -1454,11 +2529,18 @@ mod tests {
1454
2529
  let caps = ["email:send".into()].into();
1455
2530
 
1456
2531
  let mut winner = WorkloadStore::from_db(db.clone()).unwrap();
1457
- let mut loser = WorkloadStore::from_db(db.clone()).unwrap();
2532
+ // The loser's claim races the winner's inside the window between its
2533
+ // refresh and its commit, which only the storage guard closes.
2534
+ let mut loser = WorkloadStore::from_db(db.clone())
2535
+ .unwrap()
2536
+ .without_refresh();
1458
2537
  winner.claim(&id, "worker-a", &caps, 11, 1_000).unwrap();
1459
2538
  let conflict = loser.claim(&id, "worker-b", &caps, 11, 1_000).unwrap_err();
1460
2539
  assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
1461
- assert_eq!(loser.get(&id).unwrap().state, WorkloadState::Ready);
2540
+ // The loser committed nothing and now projects the winner's claim.
2541
+ let projected = loser.get(&id).unwrap();
2542
+ assert_eq!(projected.state, WorkloadState::Claimed);
2543
+ assert_eq!(projected.current_claim.as_ref().unwrap().worker_id, "worker-a");
1462
2544
 
1463
2545
  let row = db
1464
2546
  .list_collection(DURABLE_WORK_COLLECTION)
@@ -1473,7 +2555,9 @@ mod tests {
1473
2555
  let mut recovery = WorkloadStore::from_db(restarted_db.clone()).unwrap();
1474
2556
  recovery.recover_expired(13).unwrap();
1475
2557
  let mut restarted_winner = WorkloadStore::from_db(restarted_db.clone()).unwrap();
1476
- let mut restarted_loser = WorkloadStore::from_db(restarted_db.clone()).unwrap();
2558
+ let mut restarted_loser = WorkloadStore::from_db(restarted_db.clone())
2559
+ .unwrap()
2560
+ .without_refresh();
1477
2561
  restarted_winner
1478
2562
  .claim(&id, "worker-c", &caps, 13, 10_000)
1479
2563
  .unwrap();
@@ -1481,10 +2565,9 @@ mod tests {
1481
2565
  .claim(&id, "worker-d", &caps, 13, 10_000)
1482
2566
  .unwrap_err();
1483
2567
  assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
1484
- assert_eq!(
1485
- restarted_loser.get(&id).unwrap().state,
1486
- WorkloadState::Ready
1487
- );
2568
+ let projected = restarted_loser.get(&id).unwrap();
2569
+ assert_eq!(projected.state, WorkloadState::Claimed);
2570
+ assert_eq!(projected.current_claim.as_ref().unwrap().worker_id, "worker-c");
1488
2571
  drop((recovery, restarted_winner, restarted_loser, restarted_db));
1489
2572
  let _ = fs::remove_file(path);
1490
2573
  }
@@ -1499,22 +2582,103 @@ mod tests {
1499
2582
  let mut first = WorkloadStore::from_db(db.clone()).unwrap();
1500
2583
  let mut second = WorkloadStore::from_db(db.clone()).unwrap();
1501
2584
  let accepted = first.create(create()).unwrap();
2585
+ // A second writer of the same store loses the require-absent CAS and
2586
+ // converges on what was committed: a different request is refused as a
2587
+ // reused key, never answered with the original.
1502
2588
  let mut competing = create();
1503
2589
  competing.input = serde_json::json!({"id": 2});
1504
2590
  let conflict = second.create(competing).unwrap_err();
1505
- assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
1506
- assert!(
1507
- second.workloads.is_empty(),
1508
- "failed projection was rolled back"
2591
+ assert_eq!(conflict.code, "IDEMPOTENCY_KEY_REUSED");
2592
+ assert_eq!(
2593
+ second.workloads.keys().collect::<Vec<_>>(),
2594
+ vec![&accepted.workload_id],
2595
+ "only the committed workload is projected"
1509
2596
  );
2597
+ // The same request from a third, equally stale writer converges on it.
2598
+ let mut third = WorkloadStore::from_db(db.clone()).unwrap();
2599
+ third.workloads.clear();
2600
+ third.idempotency.clear();
2601
+ third.document_versions.clear();
2602
+ let (converged, created) = third.admit(create()).unwrap();
2603
+ assert!(!created);
2604
+ assert_eq!(converged.workload_id, accepted.workload_id);
1510
2605
 
1511
- drop((first, second, db));
2606
+ drop((first, second, third, db));
1512
2607
  let restarted = WorkloadStore::from_db(FeltDb::open(&path).unwrap()).unwrap();
1513
2608
  assert_eq!(restarted.workloads.len(), 1);
1514
- assert_eq!(restarted.idempotency["t:app:one"], accepted.workload_id);
2609
+ assert_eq!(
2610
+ restarted.idempotency[&idempotency_scope("t", "app", &accepted.requested_by, "one")],
2611
+ accepted.workload_id
2612
+ );
2613
+ let _ = fs::remove_file(path);
2614
+ }
2615
+
2616
+ #[test]
2617
+ fn idempotency_scopes_cannot_be_made_to_coincide() {
2618
+ let (path, mut store) = store("scope-separators");
2619
+ let mut first = create_as("x:k1");
2620
+ first.idempotency_key = "k2".into();
2621
+ let mut second = create_as("x");
2622
+ second.idempotency_key = "k1:k2".into();
2623
+ let first = store.create(first).unwrap();
2624
+ let second = store.create(second).unwrap();
2625
+ assert_ne!(first.workload_id, second.workload_id);
1515
2626
  let _ = fs::remove_file(path);
1516
2627
  }
1517
2628
 
2629
+ #[test]
2630
+ fn concurrent_admissions_by_competing_writers_converge_or_conflict() {
2631
+ for round in 0..20 {
2632
+ let (path, seed) = store(&format!("concurrent-admission-{round}"));
2633
+ let db = seed.db.clone();
2634
+ let admit_concurrently = |requests: [CreateWorkload; 2]| {
2635
+ let barrier = std::sync::Barrier::new(2);
2636
+ std::thread::scope(|scope| {
2637
+ requests
2638
+ .into_iter()
2639
+ .map(|request| {
2640
+ let mut writer = WorkloadStore::from_db(db.clone()).unwrap();
2641
+ let barrier = &barrier;
2642
+ scope.spawn(move || {
2643
+ barrier.wait();
2644
+ writer.admit(request)
2645
+ })
2646
+ })
2647
+ .collect::<Vec<_>>()
2648
+ .into_iter()
2649
+ .map(|handle| handle.join().unwrap())
2650
+ .collect::<Vec<_>>()
2651
+ })
2652
+ };
2653
+ // The same request: both writers answer with one workload, and
2654
+ // exactly one of them created it.
2655
+ let same = admit_concurrently([create(), create()]);
2656
+ let (first, first_created) = same[0].as_ref().unwrap().clone();
2657
+ let (second, second_created) = same[1].as_ref().unwrap().clone();
2658
+ assert_eq!(first.workload_id, second.workload_id);
2659
+ assert!(first_created ^ second_created, "round {round}");
2660
+
2661
+ // Different requests under one key: one is admitted, the other is
2662
+ // refused as a reused key and never answered with the winner.
2663
+ let mut left = create();
2664
+ left.idempotency_key = "contested".into();
2665
+ let mut right = left.clone();
2666
+ right.input = serde_json::json!({"id": 2});
2667
+ let contested = admit_concurrently([left, right]);
2668
+ let admitted = contested.iter().filter(|outcome| outcome.is_ok()).count();
2669
+ assert_eq!(admitted, 1, "round {round}");
2670
+ assert!(contested.iter().any(|outcome| outcome
2671
+ .as_ref()
2672
+ .is_err_and(|error| error.code == "IDEMPOTENCY_KEY_REUSED")));
2673
+ let (winner, _) = contested.iter().find_map(|o| o.as_ref().ok()).unwrap();
2674
+ let stored = WorkloadStore::from_db(db.clone()).unwrap();
2675
+ assert_eq!(stored.workloads.len(), 2);
2676
+ assert_eq!(stored.get(&winner.workload_id).unwrap().input, winner.input);
2677
+ drop((seed, stored, db));
2678
+ let _ = fs::remove_file(path);
2679
+ }
2680
+ }
2681
+
1518
2682
  #[test]
1519
2683
  fn pre_versioned_canonical_rows_upgrade_through_the_standard_cas_contract() {
1520
2684
  let (path, mut store) = store("pre-versioned-upgrade");
@@ -1589,7 +2753,9 @@ mod tests {
1589
2753
  store.document_versions.clear();
1590
2754
  let rebuilt = WorkloadStore::from_db(store.db.clone()).unwrap();
1591
2755
  assert_eq!(
1592
- rebuilt.idempotency.get("t:app:one"),
2756
+ rebuilt
2757
+ .idempotency
2758
+ .get(&idempotency_scope("t", "app", &workload.requested_by, "one")),
1593
2759
  Some(&workload.workload_id)
1594
2760
  );
1595
2761
  assert_eq!(
@@ -1738,6 +2904,136 @@ mod tests {
1738
2904
  let _ = fs::remove_file(path);
1739
2905
  }
1740
2906
  #[test]
2907
+ fn a_repeated_completion_replays_only_for_the_claim_and_output_that_produced_it() {
2908
+ let (path, mut store) = store("complete-replay");
2909
+ let w = store.create(create()).unwrap();
2910
+ let id = w.workload_id.clone();
2911
+ let caps = capabilities();
2912
+ // Worker a loses its lease; worker b completes the work.
2913
+ let lost = store.claim(&id, "a", &caps, 11, 1000).unwrap();
2914
+ store
2915
+ .start(&id, &lost.claim_id, "a", lost.fencing_token, 11)
2916
+ .unwrap();
2917
+ store.recover_expired(13).unwrap();
2918
+ let won = store.claim(&id, "b", &caps, 13, 10_000).unwrap();
2919
+ store
2920
+ .start(&id, &won.claim_id, "b", won.fencing_token, 13)
2921
+ .unwrap();
2922
+ let output = serde_json::json!({"sent": 1});
2923
+ let first = store
2924
+ .complete(
2925
+ &id,
2926
+ &won.claim_id,
2927
+ "b",
2928
+ won.fencing_token,
2929
+ "r1",
2930
+ output.clone(),
2931
+ vec![],
2932
+ 1,
2933
+ 14,
2934
+ )
2935
+ .unwrap();
2936
+ // The reporting attempt retrying after a lost response gets the same result.
2937
+ let replay = store
2938
+ .complete(
2939
+ &id,
2940
+ &won.claim_id,
2941
+ "b",
2942
+ won.fencing_token,
2943
+ "r1",
2944
+ output.clone(),
2945
+ vec![],
2946
+ 1,
2947
+ 15,
2948
+ )
2949
+ .unwrap();
2950
+ assert_eq!(replay.output_hash, first.output_hash);
2951
+ assert_eq!(replay.attempt_id, first.attempt_id);
2952
+ // The same result id with a different output is a conflict, not a replay.
2953
+ assert_eq!(
2954
+ store
2955
+ .complete(
2956
+ &id,
2957
+ &won.claim_id,
2958
+ "b",
2959
+ won.fencing_token,
2960
+ "r1",
2961
+ serde_json::json!({"sent": 2}),
2962
+ vec![],
2963
+ 1,
2964
+ 15
2965
+ )
2966
+ .unwrap_err()
2967
+ .code,
2968
+ "WORKLOAD_RESULT_CONFLICT"
2969
+ );
2970
+ // A stale worker naming the winner's result id learns nothing landed for it.
2971
+ assert_eq!(
2972
+ store
2973
+ .complete(
2974
+ &id,
2975
+ &lost.claim_id,
2976
+ "a",
2977
+ lost.fencing_token,
2978
+ "r1",
2979
+ output.clone(),
2980
+ vec![],
2981
+ 1,
2982
+ 15
2983
+ )
2984
+ .unwrap_err()
2985
+ .code,
2986
+ "WORKLOAD_RESULT_CONFLICT"
2987
+ );
2988
+ let committed = store.get(&id).unwrap();
2989
+ assert_eq!(committed.state, WorkloadState::Succeeded);
2990
+ assert_eq!(committed.result.as_ref().unwrap().output, output);
2991
+ assert_eq!(committed.result.as_ref().unwrap().produced_by, "b");
2992
+ let _ = fs::remove_file(path);
2993
+ }
2994
+ #[test]
2995
+ fn a_resumed_attempt_reads_the_input_and_the_lost_attempts_checkpoints_through_its_fence() {
2996
+ let (path, mut store) = store("resume-context");
2997
+ let w = store.create(create()).unwrap();
2998
+ let id = w.workload_id.clone();
2999
+ let caps = capabilities();
3000
+ let lost = store.claim(&id, "a", &caps, 11, 1000).unwrap();
3001
+ store
3002
+ .start(&id, &lost.claim_id, "a", lost.fencing_token, 11)
3003
+ .unwrap();
3004
+ store
3005
+ .checkpoint(
3006
+ &id,
3007
+ &lost.claim_id,
3008
+ "a",
3009
+ lost.fencing_token,
3010
+ serde_json::json!({"step": "reserve", "status": "done"}),
3011
+ 11,
3012
+ )
3013
+ .unwrap();
3014
+ store.recover_expired(13).unwrap();
3015
+ let next = store.claim(&id, "b", &caps, 13, 10_000).unwrap();
3016
+ let context = store
3017
+ .execution_context(&id, &next.claim_id, "b", next.fencing_token, 13, vec![])
3018
+ .unwrap();
3019
+ assert_eq!(context.input, serde_json::json!({"id": 1}));
3020
+ assert_eq!(context.checkpoints.len(), 1);
3021
+ assert_eq!(context.checkpoints[0].state["step"], "reserve");
3022
+ assert_ne!(
3023
+ context.checkpoints[0].attempt_id, context.attempt_id,
3024
+ "written by the lost attempt"
3025
+ );
3026
+ // The lost worker's fence reads nothing.
3027
+ assert_eq!(
3028
+ store
3029
+ .execution_context(&id, &lost.claim_id, "a", lost.fencing_token, 13, vec![])
3030
+ .unwrap_err()
3031
+ .code,
3032
+ "WORKLOAD_STALE_FENCE"
3033
+ );
3034
+ let _ = fs::remove_file(path);
3035
+ }
3036
+ #[test]
1741
3037
  fn cancellation_race_and_duplicate_completion_are_deterministic() {
1742
3038
  let (path, mut store) = store("cancel");
1743
3039
  let w = store.create(create()).unwrap();
@@ -2251,4 +3547,676 @@ mod tests {
2251
3547
  );
2252
3548
  let _ = fs::remove_file(path);
2253
3549
  }
3550
+
3551
+ // ---- PR 2: authoritative reads, canonical admission, failure reports, deadlines ----
3552
+
3553
+ fn version_of(store: &WorkloadStore, id: &str) -> u64 {
3554
+ let db = store.db_for_tests();
3555
+ let w = store.get(id).unwrap();
3556
+ db.live_row(DURABLE_WORK_COLLECTION, &work_key(w))
3557
+ .and_then(|row| row.value.get("__version").and_then(Value::as_u64))
3558
+ .unwrap()
3559
+ }
3560
+
3561
+ #[test]
3562
+ fn another_writers_commits_are_visible_and_acted_on_without_restart() {
3563
+ let (path, mut a) = store("two-writers");
3564
+ let mut b = WorkloadStore::from_db(a.db_for_tests()).unwrap();
3565
+ let id = a.create(create()).unwrap().workload_id;
3566
+ // B never saw the admission, yet reads it from the store.
3567
+ assert!(b.get(&id).is_err(), "the projection alone is stale");
3568
+ assert_eq!(b.fresh(&id).unwrap().state, WorkloadState::Ready);
3569
+ assert_eq!(b.fresh_list("t", "app").unwrap().len(), 1);
3570
+ // A claims; B's claim decides on the committed claim, not its own view.
3571
+ let claim = a
3572
+ .claim(&id, "worker-a", &capabilities(), 11, 10_000)
3573
+ .unwrap();
3574
+ assert_eq!(
3575
+ b.claim(&id, "worker-b", &capabilities(), 11, 10_000)
3576
+ .unwrap_err()
3577
+ .code,
3578
+ "WORKLOAD_NOT_READY"
3579
+ );
3580
+ // A transition committed through A is what B fences against.
3581
+ b.start(&id, &claim.claim_id, "worker-a", claim.fencing_token, 12)
3582
+ .unwrap();
3583
+ assert_eq!(a.fresh(&id).unwrap().state, WorkloadState::Running);
3584
+ // B replays an admission A committed, by key, from the store.
3585
+ let mut c = WorkloadStore::from_db(a.db_for_tests()).unwrap();
3586
+ c.workloads.clear();
3587
+ c.idempotency.clear();
3588
+ c.document_versions.clear();
3589
+ let (replayed, created) = c.admit(create()).unwrap();
3590
+ assert!(!created);
3591
+ assert_eq!(replayed.workload_id, id);
3592
+ let _ = fs::remove_file(path);
3593
+ }
3594
+
3595
+ #[test]
3596
+ fn a_replay_matches_only_an_equivalent_request_and_names_every_conflicting_field() {
3597
+ let (path, mut store) = store("canonical-admission");
3598
+ let mut original = create();
3599
+ original.input = serde_json::json!({"a": 1, "b": 2});
3600
+ original.dependencies = vec![
3601
+ WorkloadDependency {
3602
+ workload_id: "w1".into(),
3603
+ required_state: WorkloadState::Succeeded,
3604
+ },
3605
+ WorkloadDependency {
3606
+ workload_id: "w2".into(),
3607
+ required_state: WorkloadState::Succeeded,
3608
+ },
3609
+ ];
3610
+ let first = store.create(original.clone()).unwrap();
3611
+
3612
+ // Equivalent after normalization: member order, dependency order, the
3613
+ // defaults written out, and provenance that does not change what runs.
3614
+ let mut equivalent = original.clone();
3615
+ equivalent.input = serde_json::json!({"b": 2, "a": 1});
3616
+ equivalent.dependencies.reverse();
3617
+ equivalent.retry_policy = WorkloadRetryPolicy::default();
3618
+ equivalent.timeouts = WorkloadTimeouts::default();
3619
+ equivalent.priority = 0;
3620
+ equivalent.trigger = "retried-by-client".into();
3621
+ equivalent.session_id = Some("another-session".into());
3622
+ equivalent.created_at = 99;
3623
+ let (replayed, created) = store.admit(equivalent).unwrap();
3624
+ assert!(!created);
3625
+ assert_eq!(replayed.workload_id, first.workload_id);
3626
+
3627
+ let mutations: Vec<(&str, Box<dyn Fn(&mut CreateWorkload)>)> = vec![
3628
+ (
3629
+ "definition_id",
3630
+ Box::new(|r| r.definition_id = "other".into()),
3631
+ ),
3632
+ (
3633
+ "definition_revision",
3634
+ Box::new(|r| r.definition_revision = "r2".into()),
3635
+ ),
3636
+ (
3637
+ "environment",
3638
+ Box::new(|r| r.environment = "staging".into()),
3639
+ ),
3640
+ (
3641
+ "input",
3642
+ Box::new(|r| r.input = serde_json::json!({"a": 1, "b": 3})),
3643
+ ),
3644
+ (
3645
+ "capability_snapshot",
3646
+ Box::new(|r| {
3647
+ r.capability_snapshot.insert("sms:send".into());
3648
+ }),
3649
+ ),
3650
+ (
3651
+ "connection_scopes",
3652
+ Box::new(|r| {
3653
+ r.connection_scopes.insert("crm".into());
3654
+ }),
3655
+ ),
3656
+ ("priority", Box::new(|r| r.priority = 5)),
3657
+ (
3658
+ "retry_policy",
3659
+ Box::new(|r| r.retry_policy.max_attempts = 9),
3660
+ ),
3661
+ (
3662
+ "retry_policy",
3663
+ Box::new(|r| {
3664
+ r.retry_policy.retry_on.remove("worker_failure");
3665
+ }),
3666
+ ),
3667
+ (
3668
+ "timeouts",
3669
+ Box::new(|r| r.timeouts.execution_ms = Some(60_000)),
3670
+ ),
3671
+ (
3672
+ "dependencies",
3673
+ Box::new(|r| r.dependencies.pop().map(|_| ()).unwrap()),
3674
+ ),
3675
+ ("not_before", Box::new(|r| r.not_before = Some(5_000))),
3676
+ ];
3677
+ for (field, mutate) in mutations {
3678
+ let mut request = original.clone();
3679
+ mutate(&mut request);
3680
+ let error = store.admit(request).unwrap_err();
3681
+ assert_eq!(error.code, "IDEMPOTENCY_KEY_REUSED", "{field}");
3682
+ assert_eq!(
3683
+ error.details.unwrap()["fields"],
3684
+ serde_json::json!([field]),
3685
+ "{field}"
3686
+ );
3687
+ }
3688
+ // Several at once are all named.
3689
+ let mut request = original.clone();
3690
+ request.priority = 1;
3691
+ request.environment = "staging".into();
3692
+ let fields = store.admit(request).unwrap_err().details.unwrap()["fields"].clone();
3693
+ assert_eq!(fields, serde_json::json!(["environment", "priority"]));
3694
+
3695
+ // After a restart: the same answers from the committed record.
3696
+ drop(store);
3697
+ let mut reopened = WorkloadStore::load(&path).unwrap();
3698
+ assert!(!reopened.admit(original.clone()).unwrap().1);
3699
+ let mut changed = original.clone();
3700
+ changed.retry_policy.initial_delay_ms = 5;
3701
+ assert_eq!(
3702
+ reopened.admit(changed).unwrap_err().code,
3703
+ "IDEMPOTENCY_KEY_REUSED"
3704
+ );
3705
+ assert_eq!(reopened.workloads.len(), 1, "nothing was created");
3706
+ let _ = fs::remove_file(path);
3707
+ }
3708
+
3709
+ #[test]
3710
+ fn concurrent_conflicting_admissions_admit_one_and_refuse_the_other() {
3711
+ for round in 0..10 {
3712
+ let path = std::env::temp_dir().join(format!(
3713
+ "workload-admission-race-{round}-{}.log",
3714
+ std::process::id()
3715
+ ));
3716
+ let _ = fs::remove_file(&path);
3717
+ let db = FeltDb::open(&path).unwrap();
3718
+ let stores = [
3719
+ WorkloadStore::from_db(db.clone()).unwrap(),
3720
+ WorkloadStore::from_db(db.clone()).unwrap(),
3721
+ ];
3722
+ let barrier = std::sync::Barrier::new(2);
3723
+ let outcomes = std::thread::scope(|scope| {
3724
+ stores
3725
+ .into_iter()
3726
+ .enumerate()
3727
+ .map(|(i, mut store)| {
3728
+ let barrier = &barrier;
3729
+ scope.spawn(move || {
3730
+ let mut request = create();
3731
+ request.priority = i as i32;
3732
+ barrier.wait();
3733
+ store
3734
+ .admit(request)
3735
+ .map(|(w, created)| (w.priority, created))
3736
+ })
3737
+ })
3738
+ .collect::<Vec<_>>()
3739
+ .into_iter()
3740
+ .map(|handle| handle.join().unwrap())
3741
+ .collect::<Vec<_>>()
3742
+ });
3743
+ let admitted = outcomes
3744
+ .iter()
3745
+ .filter(|o| matches!(o, Ok((_, true))))
3746
+ .count();
3747
+ let refused = outcomes
3748
+ .iter()
3749
+ .filter(|o| {
3750
+ o.as_ref()
3751
+ .is_err_and(|e| e.code == "IDEMPOTENCY_KEY_REUSED")
3752
+ })
3753
+ .count();
3754
+ assert_eq!((admitted, refused), (1, 1), "round {round}: {outcomes:?}");
3755
+ let _ = fs::remove_file(path);
3756
+ }
3757
+ }
3758
+
3759
+ fn claimed(
3760
+ name: &str,
3761
+ policy: impl FnOnce(&mut CreateWorkload),
3762
+ ) -> (PathBuf, WorkloadStore, String, WorkloadClaim) {
3763
+ let (path, mut store) = store(name);
3764
+ let mut request = create();
3765
+ policy(&mut request);
3766
+ let id = store.create(request).unwrap().workload_id;
3767
+ let claim = store.claim(&id, "a", &capabilities(), 11, 10_000).unwrap();
3768
+ (path, store, id, claim)
3769
+ }
3770
+
3771
+ #[test]
3772
+ fn a_worker_reports_failure_from_claimed_fenced_and_by_policy() {
3773
+ // Could not start: a known failure before any effect, retried by kind.
3774
+ let (path, mut store, id, claim) = claimed("fail-claimed", |_| {});
3775
+ let state = store
3776
+ .fail_with_effect(
3777
+ &id,
3778
+ &claim.claim_id,
3779
+ "a",
3780
+ claim.fencing_token,
3781
+ "transient_connection",
3782
+ "no route",
3783
+ None,
3784
+ 12,
3785
+ )
3786
+ .unwrap();
3787
+ assert_eq!(state, WorkloadState::Ready);
3788
+ let w = store.get(&id).unwrap();
3789
+ let failed = w
3790
+ .execution_history
3791
+ .iter()
3792
+ .find(|e| e.to == WorkloadState::Failed)
3793
+ .unwrap();
3794
+ assert_eq!(
3795
+ failed.evidence["effect"], "none",
3796
+ "never started, so no effect"
3797
+ );
3798
+ assert_eq!(w.attempts[0].status, AttemptStatus::Failed);
3799
+ // The identical report again (lost response) replays and commits nothing.
3800
+ let version = version_of(&store, &id);
3801
+ assert_eq!(
3802
+ store
3803
+ .fail_with_effect(
3804
+ &id,
3805
+ &claim.claim_id,
3806
+ "a",
3807
+ claim.fencing_token,
3808
+ "transient_connection",
3809
+ "no route",
3810
+ None,
3811
+ 13
3812
+ )
3813
+ .unwrap(),
3814
+ WorkloadState::Ready
3815
+ );
3816
+ assert_eq!(version_of(&store, &id), version);
3817
+ // A different report from the same attempt is answered by its fence.
3818
+ assert_eq!(
3819
+ store
3820
+ .fail_with_effect(
3821
+ &id,
3822
+ &claim.claim_id,
3823
+ "a",
3824
+ claim.fencing_token,
3825
+ "timeout",
3826
+ "other",
3827
+ None,
3828
+ 13
3829
+ )
3830
+ .unwrap_err()
3831
+ .code,
3832
+ "WORKLOAD_CLAIM_INVALID"
3833
+ );
3834
+ // So is a completion: the failure stands.
3835
+ assert_eq!(
3836
+ store
3837
+ .complete(
3838
+ &id,
3839
+ &claim.claim_id,
3840
+ "a",
3841
+ claim.fencing_token,
3842
+ "r",
3843
+ Value::Null,
3844
+ vec![],
3845
+ 1,
3846
+ 13
3847
+ )
3848
+ .unwrap_err()
3849
+ .code,
3850
+ "WORKLOAD_CLAIM_INVALID"
3851
+ );
3852
+ // Another worker's claim fences the old attempt out.
3853
+ let next = store.claim(&id, "b", &capabilities(), 14, 10_000).unwrap();
3854
+ assert_eq!(
3855
+ store
3856
+ .fail_with_effect(
3857
+ &id,
3858
+ &claim.claim_id,
3859
+ "a",
3860
+ claim.fencing_token,
3861
+ "timeout",
3862
+ "late",
3863
+ None,
3864
+ 15
3865
+ )
3866
+ .unwrap_err()
3867
+ .code,
3868
+ "WORKLOAD_STALE_FENCE"
3869
+ );
3870
+ assert_eq!(
3871
+ store
3872
+ .get(&id)
3873
+ .unwrap()
3874
+ .current_claim
3875
+ .as_ref()
3876
+ .unwrap()
3877
+ .claim_id,
3878
+ next.claim_id
3879
+ );
3880
+ let _ = fs::remove_file(path);
3881
+ }
3882
+
3883
+ #[test]
3884
+ fn failure_policy_decides_retry_and_an_unknown_effect_is_decided_like_a_lost_worker() {
3885
+ // A kind the policy does not retry dead-letters.
3886
+ let (path, mut store, id, claim) = claimed("fail-kind", |_| {});
3887
+ store
3888
+ .start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
3889
+ .unwrap();
3890
+ assert_eq!(
3891
+ store
3892
+ .fail_with_effect(
3893
+ &id,
3894
+ &claim.claim_id,
3895
+ "a",
3896
+ claim.fencing_token,
3897
+ "validation",
3898
+ "bad input",
3899
+ Some("none"),
3900
+ 13
3901
+ )
3902
+ .unwrap(),
3903
+ WorkloadState::DeadLetter
3904
+ );
3905
+ assert_eq!(
3906
+ store
3907
+ .get(&id)
3908
+ .unwrap()
3909
+ .execution_history
3910
+ .last()
3911
+ .unwrap()
3912
+ .evidence["decision"],
3913
+ "failure_kind_not_retryable"
3914
+ );
3915
+ let _ = fs::remove_file(path);
3916
+
3917
+ // A retryable kind with an unknown effect, under a policy that does not
3918
+ // re-run lost work: not retried automatically.
3919
+ let (path, mut store, id, claim) = claimed("fail-unknown", |r| {
3920
+ r.retry_policy.retry_on = ["timeout".into(), "transient_connection".into()].into();
3921
+ });
3922
+ store
3923
+ .start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
3924
+ .unwrap();
3925
+ assert_eq!(
3926
+ store
3927
+ .fail_with_effect(
3928
+ &id,
3929
+ &claim.claim_id,
3930
+ "a",
3931
+ claim.fencing_token,
3932
+ "timeout",
3933
+ "charge sent, no answer",
3934
+ Some("unknown"),
3935
+ 13
3936
+ )
3937
+ .unwrap(),
3938
+ WorkloadState::DeadLetter
3939
+ );
3940
+ let w = store.get(&id).unwrap();
3941
+ assert_eq!(
3942
+ w.execution_history.last().unwrap().evidence["decision"],
3943
+ "effect_unknown_not_retryable"
3944
+ );
3945
+ // An operator can still re-arm it, with a reason.
3946
+ store
3947
+ .retry_with_reason(&id, "operator", 14, Some("verified: not charged"))
3948
+ .unwrap();
3949
+ assert_eq!(store.get(&id).unwrap().state, WorkloadState::Ready);
3950
+ let _ = fs::remove_file(path);
3951
+
3952
+ // The same report under a policy that re-runs lost work is retried.
3953
+ let (path, mut store, id, claim) = claimed("fail-unknown-retry", |_| {});
3954
+ store
3955
+ .start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
3956
+ .unwrap();
3957
+ assert_eq!(
3958
+ store
3959
+ .fail_with_effect(
3960
+ &id,
3961
+ &claim.claim_id,
3962
+ "a",
3963
+ claim.fencing_token,
3964
+ "validation",
3965
+ "x",
3966
+ Some("unknown"),
3967
+ 13
3968
+ )
3969
+ .unwrap(),
3970
+ WorkloadState::Ready
3971
+ );
3972
+ // An invalid effect is refused before anything changes.
3973
+ assert_eq!(
3974
+ store
3975
+ .fail_with_effect(&id, "c", "a", 9, "x", "y", Some("maybe"), 14)
3976
+ .unwrap_err()
3977
+ .code,
3978
+ "WORKLOAD_INVALID"
3979
+ );
3980
+ let _ = fs::remove_file(path);
3981
+
3982
+ // The attempt budget bounds retries.
3983
+ let (path, mut store, id, mut claim) =
3984
+ claimed("fail-budget", |r| r.retry_policy.max_attempts = 2);
3985
+ assert_eq!(
3986
+ store
3987
+ .fail(
3988
+ &id,
3989
+ &claim.claim_id,
3990
+ "a",
3991
+ claim.fencing_token,
3992
+ "timeout",
3993
+ "x",
3994
+ 12
3995
+ )
3996
+ .unwrap(),
3997
+ WorkloadState::Ready
3998
+ );
3999
+ let next = store.get(&id).unwrap().next_attempt_at.unwrap();
4000
+ claim = store
4001
+ .claim(&id, "a", &capabilities(), next, 10_000)
4002
+ .unwrap();
4003
+ assert_eq!(
4004
+ store
4005
+ .fail(
4006
+ &id,
4007
+ &claim.claim_id,
4008
+ "a",
4009
+ claim.fencing_token,
4010
+ "timeout",
4011
+ "x",
4012
+ next
4013
+ )
4014
+ .unwrap(),
4015
+ WorkloadState::DeadLetter
4016
+ );
4017
+ assert_eq!(
4018
+ store
4019
+ .get(&id)
4020
+ .unwrap()
4021
+ .execution_history
4022
+ .last()
4023
+ .unwrap()
4024
+ .evidence["decision"],
4025
+ "attempt_budget_exhausted"
4026
+ );
4027
+ let _ = fs::remove_file(path);
4028
+ }
4029
+
4030
+ #[test]
4031
+ fn failure_racing_completion_commits_exactly_one_outcome() {
4032
+ for completion_first in [true, false] {
4033
+ let (path, mut store, id, claim) = claimed(&format!("race-{completion_first}"), |_| {});
4034
+ store
4035
+ .start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
4036
+ .unwrap();
4037
+ let complete = |s: &mut WorkloadStore| {
4038
+ s.complete(
4039
+ &id,
4040
+ &claim.claim_id,
4041
+ "a",
4042
+ claim.fencing_token,
4043
+ "r",
4044
+ Value::Null,
4045
+ vec![],
4046
+ 1,
4047
+ 13,
4048
+ )
4049
+ .map(|_| ())
4050
+ };
4051
+ let fail = |s: &mut WorkloadStore| {
4052
+ s.fail(
4053
+ &id,
4054
+ &claim.claim_id,
4055
+ "a",
4056
+ claim.fencing_token,
4057
+ "timeout",
4058
+ "x",
4059
+ 13,
4060
+ )
4061
+ .map(|_| ())
4062
+ };
4063
+ let (first, second) = if completion_first {
4064
+ (complete(&mut store), fail(&mut store))
4065
+ } else {
4066
+ (fail(&mut store), complete(&mut store))
4067
+ };
4068
+ assert!(first.is_ok());
4069
+ assert_eq!(second.unwrap_err().code, "WORKLOAD_CLAIM_INVALID");
4070
+ let w = store.get(&id).unwrap();
4071
+ assert_eq!(
4072
+ w.state,
4073
+ if completion_first {
4074
+ WorkloadState::Succeeded
4075
+ } else {
4076
+ WorkloadState::Ready
4077
+ }
4078
+ );
4079
+ assert_eq!(w.attempts.len(), 1);
4080
+ let _ = fs::remove_file(path);
4081
+ }
4082
+ }
4083
+
4084
+ #[test]
4085
+ fn deadlines_are_enforced_from_committed_timestamps_and_race_safely() {
4086
+ // execution: a heartbeat inside the deadline keeps the attempt; after it,
4087
+ // every worker call is refused and the attempt is timed out, once.
4088
+ let (path, mut store, id, claim) =
4089
+ claimed("deadline-exec", |r| r.timeouts.execution_ms = Some(5_000));
4090
+ store
4091
+ .start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
4092
+ .unwrap();
4093
+ store
4094
+ .heartbeat(&id, &claim.claim_id, "a", claim.fencing_token, 16, 10_000)
4095
+ .unwrap();
4096
+ assert!(store.enforce_deadlines(16).unwrap().is_empty());
4097
+ let refused = store
4098
+ .complete(
4099
+ &id,
4100
+ &claim.claim_id,
4101
+ "a",
4102
+ claim.fencing_token,
4103
+ "late",
4104
+ Value::Null,
4105
+ vec![],
4106
+ 1,
4107
+ 17,
4108
+ )
4109
+ .unwrap_err();
4110
+ assert_eq!(refused.code, "WORKLOAD_TIMED_OUT");
4111
+ assert_eq!(refused.details.unwrap()["deadline"], "execution");
4112
+ let w = store.get(&id).unwrap().clone();
4113
+ assert_eq!(
4114
+ w.state,
4115
+ WorkloadState::Ready,
4116
+ "timeout is retryable by default"
4117
+ );
4118
+ assert_eq!(w.attempts[0].status, AttemptStatus::TimedOut);
4119
+ assert!(w.result.is_none(), "a late completion never lands");
4120
+ assert!(
4121
+ store.enforce_deadlines(30).unwrap().is_empty(),
4122
+ "timed out once"
4123
+ );
4124
+ let _ = fs::remove_file(path);
4125
+
4126
+ // heartbeat: the gap since the last heartbeat; a heartbeat that commits
4127
+ // first moves the deadline, the recovery pass after it changes nothing.
4128
+ let (path, mut store, id, claim) = claimed("deadline-heartbeat", |r| {
4129
+ r.timeouts.heartbeat_ms = Some(3_000)
4130
+ });
4131
+ store
4132
+ .start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
4133
+ .unwrap();
4134
+ store
4135
+ .heartbeat(&id, &claim.claim_id, "a", claim.fencing_token, 13, 10_000)
4136
+ .unwrap();
4137
+ assert!(store.enforce_deadlines(15).unwrap().is_empty());
4138
+ assert_eq!(store.enforce_deadlines(16).unwrap(), vec![id.clone()]);
4139
+ assert_eq!(
4140
+ store
4141
+ .heartbeat(&id, &claim.claim_id, "a", claim.fencing_token, 16, 10_000)
4142
+ .unwrap_err()
4143
+ .code,
4144
+ "WORKLOAD_CLAIM_INVALID",
4145
+ "the timed-out claim is gone"
4146
+ );
4147
+ let _ = fs::remove_file(path);
4148
+
4149
+ // claim: claimed but never started; not retried when the policy says so.
4150
+ let (path, mut store, id, claim) = claimed("deadline-claim", |r| {
4151
+ r.timeouts.claim_ms = Some(2_000);
4152
+ r.retry_policy.retry_on = ["worker_failure".into()].into();
4153
+ });
4154
+ let refused = store
4155
+ .start(&id, &claim.claim_id, "a", claim.fencing_token, 13)
4156
+ .unwrap_err();
4157
+ assert_eq!(refused.code, "WORKLOAD_TIMED_OUT");
4158
+ let w = store.get(&id).unwrap();
4159
+ assert_eq!(w.state, WorkloadState::DeadLetter);
4160
+ assert_eq!(
4161
+ w.execution_history.last().unwrap().evidence["decision"],
4162
+ "timeout_not_retryable"
4163
+ );
4164
+ let _ = fs::remove_file(path);
4165
+
4166
+ // queue and overall bound the work: dead-lettered, never retried, and a
4167
+ // queued workload's previous attempt is not rewritten.
4168
+ let (path, mut store) = super::tests::store("deadline-queue");
4169
+ let mut request = create();
4170
+ request.timeouts.queue_ms = Some(4_000);
4171
+ let id = store.create(request).unwrap().workload_id;
4172
+ assert_eq!(
4173
+ store
4174
+ .claim(&id, "a", &capabilities(), 14, 10_000)
4175
+ .unwrap_err()
4176
+ .code,
4177
+ "WORKLOAD_TIMED_OUT"
4178
+ );
4179
+ let w = store.get(&id).unwrap();
4180
+ assert_eq!(w.state, WorkloadState::DeadLetter);
4181
+ assert_eq!(
4182
+ w.execution_history.last().unwrap().evidence["decision"],
4183
+ "deadline_not_retryable"
4184
+ );
4185
+ assert!(w.attempts.is_empty());
4186
+ let _ = fs::remove_file(path);
4187
+
4188
+ let (path, mut store, id, claim) =
4189
+ claimed("deadline-overall", |r| r.timeouts.overall_ms = Some(10_000));
4190
+ store
4191
+ .start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
4192
+ .unwrap();
4193
+ // Restart: the deadline comes from the committed record.
4194
+ drop(store);
4195
+ let mut store = WorkloadStore::load(&path).unwrap();
4196
+ assert!(store.enforce_deadlines(19).unwrap().is_empty());
4197
+ assert_eq!(store.enforce_deadlines(20).unwrap(), vec![id.clone()]);
4198
+ assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
4199
+ let _ = fs::remove_file(path);
4200
+ }
4201
+
4202
+ #[test]
4203
+ fn deadlines_are_versioned_and_zero_is_refused() {
4204
+ let (path, mut store) = store("deadline-version");
4205
+ let mut request = create();
4206
+ request.timeouts.queue_ms = Some(0);
4207
+ assert_eq!(store.create(request).unwrap_err().code, "WORKLOAD_INVALID");
4208
+ // A record admitted before deadlines were enforced keeps its contract.
4209
+ let mut request = create();
4210
+ request.timeouts.queue_ms = Some(1_000);
4211
+ let id = store.create(request).unwrap().workload_id;
4212
+ assert_eq!(
4213
+ store.get(&id).unwrap().contract_version,
4214
+ DEADLINE_CONTRACT_VERSION
4215
+ );
4216
+ let mut legacy = store.get(&id).unwrap().clone();
4217
+ legacy.contract_version = 1;
4218
+ assert_eq!(deadline_breach(&legacy, 1_000_000), None);
4219
+ assert!(deadline_breach(store.get(&id).unwrap(), 1_000_000).is_some());
4220
+ let _ = fs::remove_file(path);
4221
+ }
2254
4222
  }