@feltdb/core 0.13.0 → 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 (92) 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 +27 -6
  12. package/dist/collection.d.ts +3 -1
  13. package/dist/collection.d.ts.map +1 -1
  14. package/dist/collection.js +30 -8
  15. package/dist/create/build-identity.json +4 -4
  16. package/dist/create/cli.js +1 -1
  17. package/dist/create/create.js +4 -6
  18. package/dist/create/package-versions.js +1 -1
  19. package/dist/create/server-source/Cargo.lock +6 -6
  20. package/dist/create/server-source/Cargo.toml +1 -1
  21. package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
  22. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
  23. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +137 -1
  24. package/dist/create/server-source/crates/feltdb/src/workload.rs +1427 -91
  25. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  26. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  27. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  28. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  29. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
  30. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  31. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  32. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  33. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  34. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  35. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/main.rs +879 -84
  37. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  38. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  39. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  40. package/dist/db.d.ts +64 -9
  41. package/dist/db.d.ts.map +1 -1
  42. package/dist/db.js +153 -27
  43. package/dist/deployment.d.ts +19 -0
  44. package/dist/deployment.d.ts.map +1 -1
  45. package/dist/deployment.js +38 -2
  46. package/dist/embedded-query.d.ts.map +1 -1
  47. package/dist/embedded-query.js +5 -4
  48. package/dist/embedded-transaction.d.ts +8 -4
  49. package/dist/embedded-transaction.d.ts.map +1 -1
  50. package/dist/embedded-transaction.js +55 -19
  51. package/dist/error-codes.d.ts +31 -2
  52. package/dist/error-codes.d.ts.map +1 -1
  53. package/dist/error-codes.js +41 -2
  54. package/dist/feltdb.d.ts +7 -0
  55. package/dist/feltdb.d.ts.map +1 -1
  56. package/dist/file-db.d.ts +27 -0
  57. package/dist/file-db.d.ts.map +1 -1
  58. package/dist/file-db.js +145 -7
  59. package/dist/http-client.d.ts +11 -3
  60. package/dist/http-client.d.ts.map +1 -1
  61. package/dist/http-client.js +27 -14
  62. package/dist/http-db.d.ts +27 -1
  63. package/dist/http-db.d.ts.map +1 -1
  64. package/dist/http-db.js +252 -54
  65. package/dist/index.js +1 -1
  66. package/dist/local-state-lock.d.ts +34 -0
  67. package/dist/local-state-lock.d.ts.map +1 -0
  68. package/dist/local-state-lock.js +225 -0
  69. package/dist/reactive-graph.d.ts +0 -3
  70. package/dist/reactive-graph.d.ts.map +1 -1
  71. package/dist/reactive-graph.js +11 -1
  72. package/dist/runtime-registry.d.ts +7 -2
  73. package/dist/runtime-registry.d.ts.map +1 -1
  74. package/dist/runtime-registry.js +2 -2
  75. package/dist/studio/components/index.js +1 -1
  76. package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
  77. package/dist/studio/index.js +1 -1
  78. package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-BjM1ylL3.js} +1 -1
  79. package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
  80. package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
  81. package/dist/studio-app/index.html +1 -1
  82. package/dist/transaction.d.ts +27 -11
  83. package/dist/transaction.d.ts.map +1 -1
  84. package/dist/transaction.js +20 -14
  85. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  86. package/dist/wasm/package.json +1 -1
  87. package/dist/workload.d.ts +31 -3
  88. package/dist/workload.d.ts.map +1 -1
  89. package/dist/workload.js +28 -3
  90. package/package.json +1 -1
  91. package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
  92. package/dist/studio-app/assets/index-Dql1hS5m.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)? {
@@ -342,9 +370,121 @@ impl WorkloadStore {
342
370
  idempotency,
343
371
  result_ids,
344
372
  document_versions,
373
+ synced_revision,
374
+ refresh_disabled: false,
345
375
  })
346
376
  }
347
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
+
348
488
  pub fn migrate_legacy_file(db: FeltDb, path: &Path) -> Result<Self, WorkloadError> {
349
489
  let bytes = std::fs::read(path).map_err(io)?;
350
490
  let legacy: LegacyWorkloadStore = serde_json::from_slice(&bytes).map_err(io)?;
@@ -469,6 +609,21 @@ impl WorkloadStore {
469
609
  "idempotency key is required",
470
610
  ));
471
611
  }
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
+ ));
626
+ }
472
627
  if input.authorization_snapshot.grant.subject != input.requested_by
473
628
  || input.authorization_snapshot.grant.tenant_id != input.tenant_id
474
629
  || input.authorization_snapshot.grant.application_id != input.application_id
@@ -484,19 +639,25 @@ impl WorkloadStore {
484
639
  &input.requested_by,
485
640
  &input.idempotency_key,
486
641
  );
487
- if let Some(id) = self.idempotency.get(&idempotency) {
488
- let existing = self.workloads[id].clone();
489
- return replay(existing, &input);
490
- }
491
642
  // The scoped idempotency identity is the record identity. Competing
492
643
  // admissions therefore contend on one canonical require-absent CAS
493
644
  // even when their payloads differ; no separate durable uniqueness
494
645
  // index is needed.
495
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
+ }
496
657
  let request = input.clone();
497
658
  let input_hash = hash(&input.input);
498
659
  let mut workload = Workload {
499
- contract_version: 1,
660
+ contract_version: DEADLINE_CONTRACT_VERSION,
500
661
  workload_id: id.clone(),
501
662
  tenant_id: input.tenant_id,
502
663
  application_id: input.application_id,
@@ -585,6 +746,9 @@ impl WorkloadStore {
585
746
  id: &str,
586
747
  action: impl FnOnce(&mut Self, &mut Workload) -> Result<R, WorkloadError>,
587
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)?;
588
752
  let workload = self
589
753
  .workloads
590
754
  .get(id)
@@ -610,30 +774,18 @@ impl WorkloadStore {
610
774
  pub(crate) fn db_for_tests(&self) -> FeltDb {
611
775
  self.db.clone()
612
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
+ }
613
783
  /// Replace this projection's copy of a workload with the committed row.
614
784
  fn reload_committed(&mut self, key: &str) -> Result<Option<Workload>, WorkloadError> {
615
- let committed = self
616
- .db
617
- .query_collection(DURABLE_WORK_COLLECTION, Some(1), |row| row.key == key)
618
- .map_err(io)?;
619
- let Some(row) = committed.into_iter().next() else {
620
- return Ok(None);
621
- };
622
- let version = row
623
- .value
624
- .get("__version")
625
- .and_then(Value::as_u64)
626
- .unwrap_or(1);
627
- let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
628
- if let Some(result) = &workload.result {
629
- self.result_ids
630
- .insert(result.result_id.clone(), workload.workload_id.clone());
785
+ match self.db.live_row(DURABLE_WORK_COLLECTION, key) {
786
+ Some(row) => self.adopt(row).map(Some),
787
+ None => Ok(None),
631
788
  }
632
- self.document_versions
633
- .insert(workload.workload_id.clone(), version);
634
- self.workloads
635
- .insert(workload.workload_id.clone(), workload.clone());
636
- Ok(Some(workload))
637
789
  }
638
790
  fn transition(
639
791
  &mut self,
@@ -675,6 +827,7 @@ impl WorkloadStore {
675
827
  now: i64,
676
828
  lease_ms: u64,
677
829
  ) -> Result<WorkloadClaim, WorkloadError> {
830
+ self.enforce_deadline(id, now)?;
678
831
  // A Ready workload that has already used its budget (an operator
679
832
  // re-arm beyond it, or a record left by unbounded recovery before it
680
833
  // was bounded) is dead-lettered here rather than claimed again.
@@ -798,6 +951,7 @@ impl WorkloadStore {
798
951
  token: u64,
799
952
  now: i64,
800
953
  ) -> Result<(), WorkloadError> {
954
+ self.enforce_deadline(id, now)?;
801
955
  self.edit(id, |store, w| {
802
956
  Self::fence(w, claim, worker, token, now)?;
803
957
  let attempt = w.attempts.last_mut().unwrap();
@@ -815,6 +969,7 @@ impl WorkloadStore {
815
969
  now: i64,
816
970
  extend_ms: u64,
817
971
  ) -> Result<WorkloadClaim, WorkloadError> {
972
+ self.enforce_deadline(id, now)?;
818
973
  self.edit(id, |_, w| {
819
974
  Self::fence(w, claim, worker, token, now)?;
820
975
  let current = w.current_claim.as_mut().unwrap();
@@ -824,7 +979,7 @@ impl WorkloadStore {
824
979
  })
825
980
  }
826
981
  pub fn execution_context(
827
- &self,
982
+ &mut self,
828
983
  id: &str,
829
984
  claim: &str,
830
985
  worker: &str,
@@ -832,7 +987,8 @@ impl WorkloadStore {
832
987
  now: i64,
833
988
  secret_references: Vec<String>,
834
989
  ) -> Result<WorkloadExecutionContext, WorkloadError> {
835
- let workload = self.get(id)?;
990
+ self.enforce_deadline(id, now)?;
991
+ let workload = self.fresh(id)?;
836
992
  Self::fence(workload, claim, worker, token, now)?;
837
993
  if secret_references
838
994
  .iter()
@@ -853,6 +1009,8 @@ impl WorkloadStore {
853
1009
  secret_references,
854
1010
  expires_at: current.expires_at,
855
1011
  fencing_token: token,
1012
+ input: workload.input.clone(),
1013
+ checkpoints: workload.checkpoints.clone(),
856
1014
  })
857
1015
  }
858
1016
  pub fn checkpoint(
@@ -865,6 +1023,7 @@ impl WorkloadStore {
865
1023
  now: i64,
866
1024
  ) -> Result<WorkloadCheckpoint, WorkloadError> {
867
1025
  ensure_safe(&state)?;
1026
+ self.enforce_deadline(id, now)?;
868
1027
  self.edit(id, |store, w| {
869
1028
  Self::fence(w, claim, worker, token, now)?;
870
1029
  if !matches!(
@@ -911,13 +1070,28 @@ impl WorkloadStore {
911
1070
  ensure_safe(&output)?;
912
1071
  if let Some(workload_id) = self.result_ids.get(result_id) {
913
1072
  if workload_id == id {
914
- 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
+ ));
915
1088
  }
916
1089
  return Err(WorkloadError::new(
917
1090
  "WORKLOAD_RESULT_DUPLICATE",
918
1091
  "result id belongs to another workload",
919
1092
  ));
920
1093
  }
1094
+ self.enforce_deadline(id, now)?;
921
1095
  let result = self.edit(id, |store, w| {
922
1096
  Self::fence(w, claim, worker, token, now)?;
923
1097
  if !matches!(
@@ -968,6 +1142,31 @@ impl WorkloadStore {
968
1142
  kind: &str,
969
1143
  error: &str,
970
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,
971
1170
  ) -> Result<WorkloadState, WorkloadError> {
972
1171
  if sensitive_text(error) {
973
1172
  return Err(WorkloadError::new(
@@ -975,23 +1174,65 @@ impl WorkloadStore {
975
1174
  "errors may not contain secret material",
976
1175
  ));
977
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)?;
978
1200
  self.edit(id, |store, w| {
979
1201
  Self::fence(w, claim, worker, token, now)?;
1202
+ let started = w.state != WorkloadState::Claimed;
1203
+ let effect = effect.or((!started).then_some("none"));
980
1204
  let attempt = w.attempts.last_mut().unwrap();
981
1205
  attempt.status = AttemptStatus::Failed;
982
1206
  attempt.ended_at = Some(now);
983
1207
  attempt.error = Some(error.into());
1208
+ let attempt_id = attempt.attempt_id.clone();
984
1209
  w.last_failure = Some(format!("{kind}: {error}"));
985
1210
  w.current_claim = None;
986
- store.transition(
987
- w,
988
- WorkloadState::Failed,
989
- worker,
990
- now,
991
- [("failure_kind".into(), kind.into())].into(),
992
- )?;
993
- let retryable =
994
- w.retry_policy.retry_on.contains(kind) && w.attempt_count < attempt_budget(w);
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();
995
1236
  if retryable {
996
1237
  w.retry_count += 1;
997
1238
  let multiplier = if w.retry_policy.backoff == BackoffPolicy::Exponential {
@@ -1005,15 +1246,9 @@ impl WorkloadStore {
1005
1246
  .saturating_mul(multiplier)
1006
1247
  .min(w.retry_policy.max_delay_ms);
1007
1248
  w.next_attempt_at = Some(now + (delay / 1000) as i64);
1008
- store.transition(w, WorkloadState::Ready, "rust.retry", now, BTreeMap::new())?;
1249
+ store.transition(w, WorkloadState::Ready, "rust.retry", now, evidence)?;
1009
1250
  } else {
1010
- store.transition(
1011
- w,
1012
- WorkloadState::DeadLetter,
1013
- "rust.retry",
1014
- now,
1015
- BTreeMap::new(),
1016
- )?;
1251
+ store.transition(w, WorkloadState::DeadLetter, "rust.retry", now, evidence)?;
1017
1252
  }
1018
1253
  Ok(w.state)
1019
1254
  })
@@ -1096,9 +1331,13 @@ impl WorkloadStore {
1096
1331
  "workload cannot time out in its current state",
1097
1332
  ));
1098
1333
  }
1099
- if let Some(attempt) = w.attempts.last_mut() {
1100
- attempt.status = AttemptStatus::TimedOut;
1101
- 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
+ }
1102
1341
  }
1103
1342
  w.current_claim = None;
1104
1343
  store.transition(w, WorkloadState::TimedOut, actor, now, BTreeMap::new())?;
@@ -1172,7 +1411,93 @@ impl WorkloadStore {
1172
1411
  store.transition(w, WorkloadState::Ready, actor, now, evidence)
1173
1412
  })
1174
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(())
1497
+ })
1498
+ }
1175
1499
  pub fn recover_expired(&mut self, now: i64) -> Result<Vec<String>, WorkloadError> {
1500
+ self.refresh()?;
1176
1501
  let ids = self
1177
1502
  .workloads
1178
1503
  .values()
@@ -1193,6 +1518,25 @@ impl WorkloadStore {
1193
1518
  let mut recovered = Vec::with_capacity(ids.len());
1194
1519
  for id in ids {
1195
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
+ }
1196
1540
  let lost = w.current_claim.take();
1197
1541
  if let Some(attempt) = w.attempts.last_mut() {
1198
1542
  attempt.status = AttemptStatus::WorkerLost;
@@ -1340,6 +1684,7 @@ fn legal(from: WorkloadState, to: WorkloadState) -> bool {
1340
1684
  | (Created, Cancelled)
1341
1685
  | (Failed, Cancelled)
1342
1686
  | (DeadLetter, Cancelled)
1687
+ | (Claimed, Failed)
1343
1688
  | (Running, Failed)
1344
1689
  | (Checkpointed, Failed)
1345
1690
  | (Claimed, Recovering)
@@ -1437,21 +1782,200 @@ fn idempotency_index(
1437
1782
  }
1438
1783
  /// Answer a repeated key: the original workload for the same request, a
1439
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
+ }
1440
1839
  fn replay(existing: Workload, input: &CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
1441
- let same = existing.requested_by == input.requested_by
1442
- && existing.definition_id == input.definition_id
1443
- && existing.definition_revision == input.definition_revision
1444
- && existing.environment == input.environment
1445
- && existing.input_hash == hash(&input.input)
1446
- && existing.capability_snapshot == input.capability_snapshot;
1447
- if same {
1448
- Ok((existing, false))
1449
- } else {
1450
- Err(WorkloadError::new(
1451
- "IDEMPOTENCY_KEY_REUSED",
1452
- "idempotency key was already used for a different request",
1453
- ))
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;
1454
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
+ })
1455
1979
  }
1456
1980
  /// Operator re-arms recorded in the workload's own history.
1457
1981
  fn operator_retries(w: &Workload) -> u32 {
@@ -1700,7 +2224,11 @@ mod tests {
1700
2224
  let id = store.create(create()).unwrap().workload_id;
1701
2225
  let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
1702
2226
  // A writer that loaded the workload before either recovery.
1703
- let mut stale = WorkloadStore::from_db(db.clone()).unwrap();
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();
1704
2232
 
1705
2233
  let (mut first, mut second, recovered) = race_recovery(&db, claim.expires_at);
1706
2234
  // Exactly one writer committed the recovery; the other reported none.
@@ -2001,7 +2529,11 @@ mod tests {
2001
2529
  let caps = ["email:send".into()].into();
2002
2530
 
2003
2531
  let mut winner = WorkloadStore::from_db(db.clone()).unwrap();
2004
- 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();
2005
2537
  winner.claim(&id, "worker-a", &caps, 11, 1_000).unwrap();
2006
2538
  let conflict = loser.claim(&id, "worker-b", &caps, 11, 1_000).unwrap_err();
2007
2539
  assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
@@ -2023,7 +2555,9 @@ mod tests {
2023
2555
  let mut recovery = WorkloadStore::from_db(restarted_db.clone()).unwrap();
2024
2556
  recovery.recover_expired(13).unwrap();
2025
2557
  let mut restarted_winner = WorkloadStore::from_db(restarted_db.clone()).unwrap();
2026
- 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();
2027
2561
  restarted_winner
2028
2562
  .claim(&id, "worker-c", &caps, 13, 10_000)
2029
2563
  .unwrap();
@@ -2370,52 +2904,182 @@ mod tests {
2370
2904
  let _ = fs::remove_file(path);
2371
2905
  }
2372
2906
  #[test]
2373
- fn cancellation_race_and_duplicate_completion_are_deterministic() {
2374
- let (path, mut store) = store("cancel");
2907
+ fn a_repeated_completion_replays_only_for_the_claim_and_output_that_produced_it() {
2908
+ let (path, mut store) = store("complete-replay");
2375
2909
  let w = store.create(create()).unwrap();
2376
- let caps = ["email:send".into()].into();
2377
- let claim = store.claim(&w.workload_id, "a", &caps, 11, 10_000).unwrap();
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();
2378
2914
  store
2379
- .start(
2380
- &w.workload_id,
2381
- &claim.claim_id,
2382
- "a",
2383
- claim.fencing_token,
2384
- 12,
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,
2385
2948
  )
2386
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.
2387
2953
  assert_eq!(
2388
- store.cancel(&w.workload_id, "user", 13).unwrap(),
2389
- WorkloadState::CancellationRequested
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"
2390
2969
  );
2970
+ // A stale worker naming the winner's result id learns nothing landed for it.
2391
2971
  assert_eq!(
2392
2972
  store
2393
2973
  .complete(
2394
- &w.workload_id,
2395
- &claim.claim_id,
2974
+ &id,
2975
+ &lost.claim_id,
2396
2976
  "a",
2397
- claim.fencing_token,
2398
- "r",
2399
- Value::Null,
2977
+ lost.fencing_token,
2978
+ "r1",
2979
+ output.clone(),
2400
2980
  vec![],
2401
2981
  1,
2402
- 14
2982
+ 15
2403
2983
  )
2404
2984
  .unwrap_err()
2405
2985
  .code,
2406
- "WORKLOAD_TRANSITION_DENIED"
2986
+ "WORKLOAD_RESULT_CONFLICT"
2407
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();
2408
3001
  store
2409
- .confirm_cancel(
2410
- &w.workload_id,
2411
- &claim.claim_id,
3002
+ .start(&id, &lost.claim_id, "a", lost.fencing_token, 11)
3003
+ .unwrap();
3004
+ store
3005
+ .checkpoint(
3006
+ &id,
3007
+ &lost.claim_id,
2412
3008
  "a",
2413
- claim.fencing_token,
2414
- 14,
3009
+ lost.fencing_token,
3010
+ serde_json::json!({"step": "reserve", "status": "done"}),
3011
+ 11,
2415
3012
  )
2416
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.
2417
3027
  assert_eq!(
2418
- store.get(&w.workload_id).unwrap().state,
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]
3037
+ fn cancellation_race_and_duplicate_completion_are_deterministic() {
3038
+ let (path, mut store) = store("cancel");
3039
+ let w = store.create(create()).unwrap();
3040
+ let caps = ["email:send".into()].into();
3041
+ let claim = store.claim(&w.workload_id, "a", &caps, 11, 10_000).unwrap();
3042
+ store
3043
+ .start(
3044
+ &w.workload_id,
3045
+ &claim.claim_id,
3046
+ "a",
3047
+ claim.fencing_token,
3048
+ 12,
3049
+ )
3050
+ .unwrap();
3051
+ assert_eq!(
3052
+ store.cancel(&w.workload_id, "user", 13).unwrap(),
3053
+ WorkloadState::CancellationRequested
3054
+ );
3055
+ assert_eq!(
3056
+ store
3057
+ .complete(
3058
+ &w.workload_id,
3059
+ &claim.claim_id,
3060
+ "a",
3061
+ claim.fencing_token,
3062
+ "r",
3063
+ Value::Null,
3064
+ vec![],
3065
+ 1,
3066
+ 14
3067
+ )
3068
+ .unwrap_err()
3069
+ .code,
3070
+ "WORKLOAD_TRANSITION_DENIED"
3071
+ );
3072
+ store
3073
+ .confirm_cancel(
3074
+ &w.workload_id,
3075
+ &claim.claim_id,
3076
+ "a",
3077
+ claim.fencing_token,
3078
+ 14,
3079
+ )
3080
+ .unwrap();
3081
+ assert_eq!(
3082
+ store.get(&w.workload_id).unwrap().state,
2419
3083
  WorkloadState::Cancelled
2420
3084
  );
2421
3085
  let _ = fs::remove_file(path);
@@ -2883,4 +3547,676 @@ mod tests {
2883
3547
  );
2884
3548
  let _ = fs::remove_file(path);
2885
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
+ }
2886
4222
  }