@feltdb/core 0.12.2 → 0.13.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 (67) hide show
  1. package/dist/build-identity.json +4 -4
  2. package/dist/cli/commands.js +8 -2
  3. package/dist/cli/workspace-integration.js +8 -0
  4. package/dist/create/build-identity.json +4 -4
  5. package/dist/create/cli.js +46 -37
  6. package/dist/create/create.js +17 -2
  7. package/dist/create/package-versions.js +1 -1
  8. package/dist/create/server-source/Cargo.lock +4 -4
  9. package/dist/create/server-source/Cargo.toml +1 -1
  10. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +204 -1
  11. package/dist/create/server-source/crates/feltdb/src/workload.rs +685 -53
  12. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
  13. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
  14. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  15. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  16. package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +28 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  19. package/dist/create/server-source/crates/feltdb-server/src/main.rs +844 -135
  20. package/dist/create/setup-summary.js +40 -0
  21. package/dist/db.d.ts +27 -1
  22. package/dist/db.d.ts.map +1 -1
  23. package/dist/db.js +17 -4
  24. package/dist/development-runtime-bridge.d.ts +20 -0
  25. package/dist/development-runtime-bridge.d.ts.map +1 -1
  26. package/dist/development-runtime-bridge.js +61 -12
  27. package/dist/http-db.d.ts +13 -0
  28. package/dist/http-db.d.ts.map +1 -1
  29. package/dist/http-db.js +33 -4
  30. package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
  31. package/dist/studio/app.d.ts +8 -1
  32. package/dist/studio/app.d.ts.map +1 -1
  33. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  34. package/dist/studio/components/KeyManagementPanel.js +1 -1
  35. package/dist/studio/components/index.js +2 -2
  36. package/dist/studio/{components-B4eKIUN5.js → components-BSGmHfif.js} +6 -6
  37. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  38. package/dist/studio/control-plane/Instance.d.ts +4 -1
  39. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  40. package/dist/studio/control-plane/Operations.d.ts +4 -1
  41. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  42. package/dist/studio/control-plane/index.js +2 -2
  43. package/dist/studio/control-plane/primitives.d.ts +16 -0
  44. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  45. package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
  46. package/dist/studio/index.d.ts +1 -0
  47. package/dist/studio/index.d.ts.map +1 -1
  48. package/dist/studio/index.js +384 -247
  49. package/dist/studio/navigation.d.ts +57 -0
  50. package/dist/studio/navigation.d.ts.map +1 -0
  51. package/dist/studio/studio.css +1 -1
  52. package/dist/studio/utils/control-plane-api.d.ts +2 -0
  53. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  54. package/dist/studio/utils/control-plane-api.js +5 -1
  55. package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-3hDngl1h.js} +1 -1
  56. package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
  57. package/dist/studio-app/assets/index-Dql1hS5m.js +30 -0
  58. package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
  59. package/dist/studio-app/index.html +2 -2
  60. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  61. package/dist/wasm/package.json +1 -1
  62. package/dist/workspace/local-development-authority.d.ts +19 -0
  63. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  64. package/dist/workspace/local-development-authority.js +335 -15
  65. package/package.json +1 -1
  66. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  67. package/dist/studio-app/assets/index-DoVvbIay.js +0 -30
@@ -329,16 +329,9 @@ impl WorkloadStore {
329
329
  document_versions.insert(workload.workload_id.clone(), version);
330
330
  workloads.insert(workload.workload_id.clone(), workload);
331
331
  }
332
- let mut idempotency = BTreeMap::new();
332
+ let idempotency = idempotency_index(&workloads, idempotency_scope);
333
333
  let mut result_ids = BTreeMap::new();
334
334
  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
335
  if let Some(result) = &workload.result {
343
336
  result_ids.insert(result.result_id.clone(), workload.workload_id.clone());
344
337
  }
@@ -382,7 +375,7 @@ impl WorkloadStore {
382
375
  }
383
376
  // The maps are verification evidence, not migrated authorities.
384
377
  let rebuilt = Self::from_db(store.db.clone())?;
385
- if legacy.idempotency != rebuilt.idempotency
378
+ if legacy.idempotency != idempotency_index(&rebuilt.workloads, legacy_idempotency_scope)
386
379
  || legacy.result_ids != rebuilt.result_ids
387
380
  || legacy.event_sequence != derived_event_sequence(&rebuilt.workloads)
388
381
  {
@@ -454,6 +447,21 @@ impl WorkloadStore {
454
447
  Ok(())
455
448
  }
456
449
  pub fn create(&mut self, input: CreateWorkload) -> Result<Workload, WorkloadError> {
450
+ self.admit(input).map(|(workload, _)| workload)
451
+ }
452
+
453
+ /// Create a workload, or return the one this requester already created
454
+ /// under the same key.
455
+ ///
456
+ /// The key is scoped to the tenant, the application **and the requester**,
457
+ /// so one requester's key can never return, reveal or block another's
458
+ /// workload. A repeated key is answered with the original workload only when
459
+ /// the request is the same — definition, revision, environment, input and
460
+ /// capability snapshot; otherwise it is refused with
461
+ /// `IDEMPOTENCY_KEY_REUSED` and nothing is created. The requester's
462
+ /// authorization snapshot is checked before any replay. The boolean is
463
+ /// `true` when this call created the workload.
464
+ pub fn admit(&mut self, input: CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
457
465
  ensure_safe(&input.input)?;
458
466
  if input.idempotency_key.trim().is_empty() {
459
467
  return Err(WorkloadError::new(
@@ -461,13 +469,6 @@ impl WorkloadStore {
461
469
  "idempotency key is required",
462
470
  ));
463
471
  }
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());
470
- }
471
472
  if input.authorization_snapshot.grant.subject != input.requested_by
472
473
  || input.authorization_snapshot.grant.tenant_id != input.tenant_id
473
474
  || input.authorization_snapshot.grant.application_id != input.application_id
@@ -477,11 +478,22 @@ impl WorkloadStore {
477
478
  "authorization snapshot does not bind the requester",
478
479
  ));
479
480
  }
481
+ let idempotency = idempotency_scope(
482
+ &input.tenant_id,
483
+ &input.application_id,
484
+ &input.requested_by,
485
+ &input.idempotency_key,
486
+ );
487
+ if let Some(id) = self.idempotency.get(&idempotency) {
488
+ let existing = self.workloads[id].clone();
489
+ return replay(existing, &input);
490
+ }
480
491
  // The scoped idempotency identity is the record identity. Competing
481
492
  // admissions therefore contend on one canonical require-absent CAS
482
493
  // even when their payloads differ; no separate durable uniqueness
483
494
  // index is needed.
484
495
  let id = format!("workload_{}", &hex(&Sha256::digest(&idempotency))[..24]);
496
+ let request = input.clone();
485
497
  let input_hash = hash(&input.input);
486
498
  let mut workload = Workload {
487
499
  contract_version: 1,
@@ -535,14 +547,23 @@ impl WorkloadStore {
535
547
  input.created_at,
536
548
  evidence,
537
549
  )?;
550
+ let key = work_key(&workload);
538
551
  self.idempotency.insert(idempotency.clone(), id.clone());
539
552
  self.workloads.insert(id.clone(), workload.clone());
540
553
  if let Err(error) = self.persist_workload(&id, "workload.created") {
541
554
  self.workloads.remove(&id);
542
555
  self.idempotency.remove(&idempotency);
556
+ // Another writer of the same store (a second process, or a replica)
557
+ // admitted this identity first. Converge on what it committed.
558
+ if error.code == "WORKLOAD_CONFLICT" {
559
+ if let Some(existing) = self.reload_committed(&key)? {
560
+ self.idempotency.insert(idempotency, id);
561
+ return replay(existing, &request);
562
+ }
563
+ }
543
564
  return Err(error);
544
565
  }
545
- Ok(workload)
566
+ Ok((workload, true))
546
567
  }
547
568
  pub fn get(&self, id: &str) -> Result<&Workload, WorkloadError> {
548
569
  self.workloads
@@ -573,11 +594,47 @@ impl WorkloadStore {
573
594
  let result = action(self, &mut edited)?;
574
595
  self.workloads.insert(id.into(), edited);
575
596
  if let Err(error) = self.persist_workload(id, "workload.mutated") {
576
- self.workloads.insert(id.into(), workload);
597
+ self.workloads.insert(id.into(), workload.clone());
598
+ // Another writer of the same store committed this workload first.
599
+ // Nothing of this edit was committed; adopt what was, so this
600
+ // projection does not keep acting on a version that no longer
601
+ // exists (every later guarded commit from it would conflict).
602
+ if error.code == "WORKLOAD_CONFLICT" {
603
+ self.reload_committed(&work_key(&workload))?;
604
+ }
577
605
  return Err(error);
578
606
  }
579
607
  Ok(result)
580
608
  }
609
+ #[cfg(test)]
610
+ pub(crate) fn db_for_tests(&self) -> FeltDb {
611
+ self.db.clone()
612
+ }
613
+ /// Replace this projection's copy of a workload with the committed row.
614
+ fn reload_committed(&mut self, key: &str) -> Result<Option<Workload>, WorkloadError> {
615
+ let committed = self
616
+ .db
617
+ .query_collection(DURABLE_WORK_COLLECTION, Some(1), |row| row.key == key)
618
+ .map_err(io)?;
619
+ let Some(row) = committed.into_iter().next() else {
620
+ return Ok(None);
621
+ };
622
+ let version = row
623
+ .value
624
+ .get("__version")
625
+ .and_then(Value::as_u64)
626
+ .unwrap_or(1);
627
+ let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
628
+ if let Some(result) = &workload.result {
629
+ self.result_ids
630
+ .insert(result.result_id.clone(), workload.workload_id.clone());
631
+ }
632
+ self.document_versions
633
+ .insert(workload.workload_id.clone(), version);
634
+ self.workloads
635
+ .insert(workload.workload_id.clone(), workload.clone());
636
+ Ok(Some(workload))
637
+ }
581
638
  fn transition(
582
639
  &mut self,
583
640
  w: &mut Workload,
@@ -618,6 +675,27 @@ impl WorkloadStore {
618
675
  now: i64,
619
676
  lease_ms: u64,
620
677
  ) -> Result<WorkloadClaim, WorkloadError> {
678
+ // A Ready workload that has already used its budget (an operator
679
+ // re-arm beyond it, or a record left by unbounded recovery before it
680
+ // was bounded) is dead-lettered here rather than claimed again.
681
+ let exhausted = self
682
+ .get(id)
683
+ .is_ok_and(|w| w.state == WorkloadState::Ready && w.attempt_count >= attempt_budget(w));
684
+ if exhausted {
685
+ self.edit(id, |store, w| {
686
+ let evidence = [
687
+ ("reason".into(), "attempt_budget_exhausted".into()),
688
+ ("attempts_used".into(), w.attempt_count.to_string()),
689
+ ("attempt_budget".into(), attempt_budget(w).to_string()),
690
+ ]
691
+ .into();
692
+ store.transition(w, WorkloadState::DeadLetter, "rust.claim", now, evidence)
693
+ })?;
694
+ return Err(WorkloadError::new(
695
+ "WORKLOAD_ATTEMPTS_EXHAUSTED",
696
+ "workload has used its attempt budget",
697
+ ));
698
+ }
621
699
  self.edit(id, |store, w| {
622
700
  if w.state != WorkloadState::Ready {
623
701
  return Err(WorkloadError::new(
@@ -912,8 +990,8 @@ impl WorkloadStore {
912
990
  now,
913
991
  [("failure_kind".into(), kind.into())].into(),
914
992
  )?;
915
- let retryable = w.retry_policy.retry_on.contains(kind)
916
- && w.attempt_count < w.retry_policy.max_attempts;
993
+ let retryable =
994
+ w.retry_policy.retry_on.contains(kind) && w.attempt_count < attempt_budget(w);
917
995
  if retryable {
918
996
  w.retry_count += 1;
919
997
  let multiplier = if w.retry_policy.backoff == BackoffPolicy::Exponential {
@@ -1024,8 +1102,7 @@ impl WorkloadStore {
1024
1102
  }
1025
1103
  w.current_claim = None;
1026
1104
  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")
1105
+ if w.attempt_count < attempt_budget(w) && w.retry_policy.retry_on.contains("timeout")
1029
1106
  {
1030
1107
  w.retry_count += 1;
1031
1108
  w.next_attempt_at = Some(now + (w.retry_policy.initial_delay_ms / 1000) as i64);
@@ -1043,6 +1120,21 @@ impl WorkloadStore {
1043
1120
  })
1044
1121
  }
1045
1122
  pub fn retry(&mut self, id: &str, actor: &str, now: i64) -> Result<(), WorkloadError> {
1123
+ self.retry_with_reason(id, actor, now, None)
1124
+ }
1125
+ /// An operator's re-arm of failed, dead-lettered or timed-out work.
1126
+ ///
1127
+ /// Each re-arm grants one more attempt, as it always has, and is recorded
1128
+ /// in the history with its actor and reason. A workload may be re-armed at
1129
+ /// most `retry_policy.max_attempts` times, so operator retries cannot
1130
+ /// become an unbounded loop.
1131
+ pub fn retry_with_reason(
1132
+ &mut self,
1133
+ id: &str,
1134
+ actor: &str,
1135
+ now: i64,
1136
+ reason: Option<&str>,
1137
+ ) -> Result<(), WorkloadError> {
1046
1138
  self.edit(id, |store, w| {
1047
1139
  if !matches!(
1048
1140
  w.state,
@@ -1053,9 +1145,31 @@ impl WorkloadStore {
1053
1145
  "workload is not retryable",
1054
1146
  ));
1055
1147
  }
1148
+ let previous = operator_retries(w);
1149
+ if previous >= w.retry_policy.max_attempts {
1150
+ return Err(WorkloadError::new(
1151
+ "WORKLOAD_RETRY_EXHAUSTED",
1152
+ "workload has used every operator retry its policy allows",
1153
+ ));
1154
+ }
1056
1155
  w.next_attempt_at = Some(now);
1057
1156
  w.current_claim = None;
1058
- store.transition(w, WorkloadState::Ready, actor, now, BTreeMap::new())
1157
+ let mut evidence: BTreeMap<String, String> = [
1158
+ ("retry".into(), "operator".into()),
1159
+ ("operator_retry".into(), (previous + 1).to_string()),
1160
+ (
1161
+ "attempt_budget".into(),
1162
+ w.retry_policy
1163
+ .max_attempts
1164
+ .saturating_add(previous + 1)
1165
+ .to_string(),
1166
+ ),
1167
+ ]
1168
+ .into();
1169
+ if let Some(reason) = reason.filter(|reason| !reason.trim().is_empty()) {
1170
+ evidence.insert("reason".into(), reason.chars().take(256).collect());
1171
+ }
1172
+ store.transition(w, WorkloadState::Ready, actor, now, evidence)
1059
1173
  })
1060
1174
  }
1061
1175
  pub fn recover_expired(&mut self, now: i64) -> Result<Vec<String>, WorkloadError> {
@@ -1076,41 +1190,71 @@ impl WorkloadStore {
1076
1190
  })
1077
1191
  .map(|w| w.workload_id.clone())
1078
1192
  .collect::<Vec<_>>();
1079
- for id in &ids {
1080
- self.edit(id, |store, w| {
1193
+ let mut recovered = Vec::with_capacity(ids.len());
1194
+ for id in ids {
1195
+ let outcome = self.edit(&id, |store, w| {
1196
+ let lost = w.current_claim.take();
1081
1197
  if let Some(attempt) = w.attempts.last_mut() {
1082
1198
  attempt.status = AttemptStatus::WorkerLost;
1083
1199
  attempt.ended_at = Some(now);
1200
+ attempt.error = Some("worker_lost: claim lease expired".into());
1201
+ }
1202
+ let mut evidence: BTreeMap<String, String> = [
1203
+ ("recovery_reason".into(), "lease_expired".into()),
1204
+ ("attempts_used".into(), w.attempt_count.to_string()),
1205
+ ("attempt_budget".into(), attempt_budget(w).to_string()),
1206
+ ]
1207
+ .into();
1208
+ if let Some(claim) = &lost {
1209
+ evidence.insert("claim_id".into(), claim.claim_id.clone());
1210
+ evidence.insert("worker_id".into(), claim.worker_id.clone());
1211
+ evidence.insert("lease_expired_at".into(), claim.expires_at.to_string());
1212
+ }
1213
+ if let Some(attempt) = w.attempts.last() {
1214
+ evidence.insert("attempt_id".into(), attempt.attempt_id.clone());
1084
1215
  }
1085
- w.current_claim = None;
1086
1216
  if w.state == WorkloadState::CancellationRequested {
1087
- store.transition(
1088
- w,
1089
- WorkloadState::Cancelled,
1090
- "rust.recovery",
1091
- now,
1092
- BTreeMap::new(),
1093
- )?;
1217
+ store.transition(w, WorkloadState::Cancelled, "rust.recovery", now, evidence)?;
1094
1218
  } else {
1095
1219
  store.transition(
1096
1220
  w,
1097
1221
  WorkloadState::Recovering,
1098
1222
  "rust.recovery",
1099
1223
  now,
1100
- BTreeMap::new(),
1224
+ evidence,
1101
1225
  )?;
1226
+ // A lost worker is the policy's `worker_failure`. Whether
1227
+ // the lost attempt had effects is unknown to FeltDB, so a
1228
+ // retry re-executes it: at-least-once, never exactly-once.
1229
+ let (to, decision) = if !w.retry_policy.retry_on.contains("worker_failure") {
1230
+ (WorkloadState::DeadLetter, "worker_failure_not_retryable")
1231
+ } else if w.attempt_count >= attempt_budget(w) {
1232
+ (WorkloadState::DeadLetter, "attempt_budget_exhausted")
1233
+ } else {
1234
+ (WorkloadState::Ready, "retry")
1235
+ };
1102
1236
  store.transition(
1103
1237
  w,
1104
- WorkloadState::Ready,
1238
+ to,
1105
1239
  "rust.recovery",
1106
1240
  now,
1107
- BTreeMap::new(),
1241
+ [("decision".into(), decision.into())].into(),
1108
1242
  )?;
1109
1243
  }
1110
1244
  Ok(())
1111
- })?;
1245
+ });
1246
+ match outcome {
1247
+ Ok(()) => recovered.push(id),
1248
+ // Another writer of this store committed the workload first —
1249
+ // in a race, its own recovery. Nothing of this attempt was
1250
+ // committed and `edit` has adopted the committed workload, so
1251
+ // the attempt is recovered once, by that writer. If it still
1252
+ // needs recovery, the next pass sees it.
1253
+ Err(error) if error.code == "WORKLOAD_CONFLICT" => {}
1254
+ Err(error) => return Err(error),
1255
+ }
1112
1256
  }
1113
- Ok(ids)
1257
+ Ok(recovered)
1114
1258
  }
1115
1259
  }
1116
1260
 
@@ -1211,6 +1355,8 @@ fn legal(from: WorkloadState, to: WorkloadState) -> bool {
1211
1355
  | (Checkpointed, TimedOut)
1212
1356
  | (Ready, TimedOut)
1213
1357
  | (TimedOut, DeadLetter)
1358
+ | (Recovering, DeadLetter)
1359
+ | (Ready, DeadLetter)
1214
1360
  )
1215
1361
  }
1216
1362
  pub fn can_transition(from: WorkloadState, to: WorkloadState) -> bool {
@@ -1259,6 +1405,69 @@ fn ensure_safe(value: &Value) -> Result<(), WorkloadError> {
1259
1405
  fn sensitive_text(v: &str) -> bool {
1260
1406
  v.contains("BEGIN PRIVATE KEY") || v.starts_with("sk-") || v.starts_with("fdb_live_")
1261
1407
  }
1408
+ /// The identity a caller's idempotency key is unique within: tenant,
1409
+ /// application and requester. Encoded as a JSON array, so no choice of key or
1410
+ /// identifier can make two different scopes coincide.
1411
+ fn idempotency_scope(tenant: &str, application: &str, requester: &Subject, key: &str) -> String {
1412
+ serde_json::json!([tenant, application, requester.kind(), requester.id(), key]).to_string()
1413
+ }
1414
+ /// The scope before keys were bound to their requester, kept only to verify
1415
+ /// legacy store files during migration.
1416
+ fn legacy_idempotency_scope(
1417
+ tenant: &str,
1418
+ application: &str,
1419
+ _requester: &Subject,
1420
+ key: &str,
1421
+ ) -> String {
1422
+ format!("{tenant}:{application}:{key}")
1423
+ }
1424
+ fn idempotency_index(
1425
+ workloads: &BTreeMap<String, Workload>,
1426
+ scope: fn(&str, &str, &Subject, &str) -> String,
1427
+ ) -> BTreeMap<String, String> {
1428
+ workloads
1429
+ .values()
1430
+ .map(|w| {
1431
+ (
1432
+ scope(&w.tenant_id, &w.application_id, &w.requested_by, &w.idempotency_key),
1433
+ w.workload_id.clone(),
1434
+ )
1435
+ })
1436
+ .collect()
1437
+ }
1438
+ /// Answer a repeated key: the original workload for the same request, a
1439
+ /// refusal for a different one. Never the original for a different request.
1440
+ fn replay(existing: Workload, input: &CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
1441
+ let same = existing.requested_by == input.requested_by
1442
+ && existing.definition_id == input.definition_id
1443
+ && existing.definition_revision == input.definition_revision
1444
+ && existing.environment == input.environment
1445
+ && existing.input_hash == hash(&input.input)
1446
+ && existing.capability_snapshot == input.capability_snapshot;
1447
+ if same {
1448
+ Ok((existing, false))
1449
+ } else {
1450
+ Err(WorkloadError::new(
1451
+ "IDEMPOTENCY_KEY_REUSED",
1452
+ "idempotency key was already used for a different request",
1453
+ ))
1454
+ }
1455
+ }
1456
+ /// Operator re-arms recorded in the workload's own history.
1457
+ fn operator_retries(w: &Workload) -> u32 {
1458
+ w.execution_history
1459
+ .iter()
1460
+ .filter(|event| event.evidence.get("retry").map(String::as_str) == Some("operator"))
1461
+ .count() as u32
1462
+ }
1463
+ /// How many claims the workload may have in total: its policy's
1464
+ /// `max_attempts`, plus one for each operator re-arm (which is what an operator
1465
+ /// retry has always granted).
1466
+ fn attempt_budget(w: &Workload) -> u32 {
1467
+ w.retry_policy
1468
+ .max_attempts
1469
+ .saturating_add(operator_retries(w))
1470
+ }
1262
1471
  fn work_key(workload: &Workload) -> String {
1263
1472
  format!(
1264
1473
  "{}/{}/{}",
@@ -1312,8 +1521,12 @@ mod tests {
1312
1521
  };
1313
1522
  use std::{fs, path::PathBuf};
1314
1523
  fn create() -> CreateWorkload {
1524
+ create_as("a1")
1525
+ }
1526
+ /// The fixture request, made by agent `agent`.
1527
+ fn create_as(agent: &str) -> CreateWorkload {
1315
1528
  let signer = GrantSigner::new("k", vec![1; 32]).unwrap();
1316
- let subject = Subject::Agent("a1".into());
1529
+ let subject = Subject::Agent(agent.into());
1317
1530
  let grant = Grant {
1318
1531
  contract_version: 1,
1319
1532
  id: "g".into(),
@@ -1355,6 +1568,339 @@ mod tests {
1355
1568
  created_at: 10,
1356
1569
  }
1357
1570
  }
1571
+ fn capabilities() -> BTreeSet<String> {
1572
+ ["email:send".into()].into()
1573
+ }
1574
+
1575
+ #[test]
1576
+ fn idempotency_keys_are_bound_to_their_requester_and_request() {
1577
+ let (path, mut store) = store("requester-scoped-keys");
1578
+ let (original, created) = store.admit(create_as("a1")).unwrap();
1579
+ assert!(created);
1580
+
1581
+ // The same requester and request: the original, not a second workload.
1582
+ let (replayed, created) = store.admit(create_as("a1")).unwrap();
1583
+ assert!(!created);
1584
+ assert_eq!(replayed.workload_id, original.workload_id);
1585
+
1586
+ // The same key for a different request is refused and creates nothing.
1587
+ let mut different = create_as("a1");
1588
+ different.input = serde_json::json!({"id": 2});
1589
+ assert_eq!(store.admit(different).unwrap_err().code, "IDEMPOTENCY_KEY_REUSED");
1590
+ let mut other_definition = create_as("a1");
1591
+ other_definition.definition_id = "other".into();
1592
+ assert_eq!(
1593
+ store.admit(other_definition).unwrap_err().code,
1594
+ "IDEMPOTENCY_KEY_REUSED"
1595
+ );
1596
+
1597
+ // Another requester's identical key is its own workload: it neither
1598
+ // returns, reveals nor blocks the first requester's.
1599
+ let (theirs, created) = store.admit(create_as("a2")).unwrap();
1600
+ assert!(created);
1601
+ assert_ne!(theirs.workload_id, original.workload_id);
1602
+ assert_eq!(store.workloads.len(), 2);
1603
+
1604
+ // A snapshot that does not bind the requester is refused before any
1605
+ // replay, so a key cannot be used to fetch a workload.
1606
+ let mut unbound = create_as("a1");
1607
+ unbound.requested_by = Subject::Agent("a2".into());
1608
+ assert_eq!(store.admit(unbound).unwrap_err().code, "WORKLOAD_UNAUTHORIZED");
1609
+ let _ = fs::remove_file(path);
1610
+ }
1611
+
1612
+ #[test]
1613
+ fn lease_recovery_records_the_lost_worker_and_is_bounded_by_the_attempt_budget() {
1614
+ let (path, mut store) = store("bounded-recovery");
1615
+ let workload = store.create(create()).unwrap();
1616
+ let id = workload.workload_id.clone();
1617
+ for attempt in 1..=3u32 {
1618
+ let now = 100 * attempt as i64;
1619
+ let claim = store.claim(&id, "worker-a", &capabilities(), now, 10_000).unwrap();
1620
+ assert_eq!(store.recover_expired(claim.expires_at).unwrap(), vec![id.clone()]);
1621
+ let recovered = store.get(&id).unwrap();
1622
+ let lost = recovered.attempts.last().unwrap();
1623
+ assert_eq!(lost.status, AttemptStatus::WorkerLost);
1624
+ assert_eq!(lost.claim_id, claim.claim_id);
1625
+ let recovering = recovered
1626
+ .execution_history
1627
+ .iter()
1628
+ .rev()
1629
+ .find(|event| event.to == WorkloadState::Recovering)
1630
+ .unwrap();
1631
+ assert_eq!(recovering.evidence["recovery_reason"], "lease_expired");
1632
+ assert_eq!(recovering.evidence["worker_id"], "worker-a");
1633
+ assert_eq!(recovering.evidence["claim_id"], claim.claim_id);
1634
+ assert_eq!(recovering.evidence["attempts_used"], attempt.to_string());
1635
+ let decision = &recovered.execution_history.last().unwrap();
1636
+ if attempt < 3 {
1637
+ assert_eq!(recovered.state, WorkloadState::Ready);
1638
+ assert_eq!(decision.evidence["decision"], "retry");
1639
+ } else {
1640
+ assert_eq!(recovered.state, WorkloadState::DeadLetter);
1641
+ assert_eq!(decision.evidence["decision"], "attempt_budget_exhausted");
1642
+ }
1643
+ }
1644
+ assert_eq!(store.get(&id).unwrap().attempt_count, 3);
1645
+ assert_eq!(
1646
+ store.claim(&id, "worker-b", &capabilities(), 1_000, 10_000).unwrap_err().code,
1647
+ "WORKLOAD_NOT_READY"
1648
+ );
1649
+ let _ = fs::remove_file(path);
1650
+ }
1651
+
1652
+ /// The recovery history of `id` as committed, read by a fresh projection.
1653
+ fn committed_workload(db: &FeltDb, id: &str) -> Workload {
1654
+ WorkloadStore::from_db(db.clone()).unwrap().get(id).unwrap().clone()
1655
+ }
1656
+
1657
+ fn recoveries(workload: &Workload) -> usize {
1658
+ workload
1659
+ .execution_history
1660
+ .iter()
1661
+ .filter(|event| event.to == WorkloadState::Recovering)
1662
+ .count()
1663
+ }
1664
+
1665
+ /// Two writers of one store recover the same expired workload at once.
1666
+ fn race_recovery(db: &FeltDb, now: i64) -> (WorkloadStore, WorkloadStore, Vec<Vec<String>>) {
1667
+ let stores = [
1668
+ WorkloadStore::from_db(db.clone()).unwrap(),
1669
+ WorkloadStore::from_db(db.clone()).unwrap(),
1670
+ ];
1671
+ let barrier = std::sync::Barrier::new(2);
1672
+ let results = std::thread::scope(|scope| {
1673
+ let handles = stores
1674
+ .into_iter()
1675
+ .map(|mut store| {
1676
+ let barrier = &barrier;
1677
+ scope.spawn(move || {
1678
+ barrier.wait();
1679
+ let recovered = store.recover_expired(now).unwrap();
1680
+ (store, recovered)
1681
+ })
1682
+ })
1683
+ .collect::<Vec<_>>();
1684
+ handles
1685
+ .into_iter()
1686
+ .map(|handle| handle.join().unwrap())
1687
+ .collect::<Vec<_>>()
1688
+ });
1689
+ let mut results = results.into_iter();
1690
+ let (first, first_recovered) = results.next().unwrap();
1691
+ let (second, second_recovered) = results.next().unwrap();
1692
+ (first, second, vec![first_recovered, second_recovered])
1693
+ }
1694
+
1695
+ #[test]
1696
+ fn concurrent_recovery_commits_one_transition_and_the_loser_converges() {
1697
+ for round in 0..20 {
1698
+ let (path, mut store) = store(&format!("concurrent-recovery-{round}"));
1699
+ let db = store.db.clone();
1700
+ let id = store.create(create()).unwrap().workload_id;
1701
+ let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
1702
+ // A writer that loaded the workload before either recovery.
1703
+ let mut stale = WorkloadStore::from_db(db.clone()).unwrap();
1704
+
1705
+ let (mut first, mut second, recovered) = race_recovery(&db, claim.expires_at);
1706
+ // Exactly one writer committed the recovery; the other reported none.
1707
+ let mut outcomes = recovered.clone();
1708
+ outcomes.sort();
1709
+ assert_eq!(outcomes, vec![vec![], vec![id.clone()]], "round {round}");
1710
+
1711
+ let committed = committed_workload(&db, &id);
1712
+ assert_eq!(recoveries(&committed), 1, "one recovery transition");
1713
+ assert_eq!(committed.state, WorkloadState::Ready);
1714
+ assert_eq!(committed.attempt_count, 1);
1715
+ assert_eq!(committed.attempts.len(), 1);
1716
+ assert_eq!(committed.attempts[0].status, AttemptStatus::WorkerLost);
1717
+ assert!(committed.current_claim.is_none());
1718
+ let recovering = committed
1719
+ .execution_history
1720
+ .iter()
1721
+ .find(|event| event.to == WorkloadState::Recovering)
1722
+ .unwrap();
1723
+ assert_eq!(recovering.evidence["claim_id"], claim.claim_id);
1724
+ assert_eq!(recovering.evidence["attempts_used"], "1");
1725
+
1726
+ // The losing writer adopted the committed workload instead of
1727
+ // keeping its own, uncommitted view.
1728
+ let expected = serde_json::to_value(&committed).unwrap();
1729
+ assert_eq!(serde_json::to_value(first.get(&id).unwrap()).unwrap(), expected);
1730
+ assert_eq!(serde_json::to_value(second.get(&id).unwrap()).unwrap(), expected);
1731
+ // And neither recovers it again, or commits anything more.
1732
+ assert!(first.recover_expired(claim.expires_at + 1).unwrap().is_empty());
1733
+ assert!(second.recover_expired(claim.expires_at + 1).unwrap().is_empty());
1734
+ assert_eq!(
1735
+ serde_json::to_value(committed_workload(&db, &id)).unwrap(),
1736
+ expected,
1737
+ "no further commit"
1738
+ );
1739
+
1740
+ // The lost claimant cannot complete, even through a projection
1741
+ // that still shows its claim as current and unexpired: the guarded
1742
+ // commit refuses the stale version.
1743
+ let refused = stale
1744
+ .start(&id, &claim.claim_id, "worker-a", claim.fencing_token, claim.expires_at - 1)
1745
+ .unwrap_err();
1746
+ assert_eq!(refused.code, "WORKLOAD_CONFLICT");
1747
+ assert_eq!(stale.get(&id).unwrap().state, WorkloadState::Ready);
1748
+ let refused = stale
1749
+ .complete(
1750
+ &id,
1751
+ &claim.claim_id,
1752
+ "worker-a",
1753
+ claim.fencing_token,
1754
+ "late-result",
1755
+ serde_json::json!({"late": true}),
1756
+ vec![],
1757
+ 1,
1758
+ claim.expires_at - 1,
1759
+ )
1760
+ .unwrap_err();
1761
+ assert_eq!(refused.code, "WORKLOAD_CLAIM_INVALID");
1762
+ // Through a current projection its fence no longer matches.
1763
+ let refused = first
1764
+ .heartbeat(&id, &claim.claim_id, "worker-a", claim.fencing_token, claim.expires_at - 1, 10_000)
1765
+ .unwrap_err();
1766
+ assert_eq!(refused.code, "WORKLOAD_CLAIM_INVALID");
1767
+ assert_eq!(serde_json::to_value(committed_workload(&db, &id)).unwrap(), expected);
1768
+ drop((store, first, second, stale, db));
1769
+ let _ = fs::remove_file(path);
1770
+ }
1771
+ }
1772
+
1773
+ #[test]
1774
+ fn concurrent_recovery_never_exceeds_the_attempt_budget() {
1775
+ let (path, mut store) = store("concurrent-recovery-budget");
1776
+ let db = store.db.clone();
1777
+ let id = store.create(create()).unwrap().workload_id;
1778
+ for attempt in 1..=3i64 {
1779
+ // Each attempt is claimed by an up-to-date writer and lost.
1780
+ let mut claimer = WorkloadStore::from_db(db.clone()).unwrap();
1781
+ let claim = claimer
1782
+ .claim(&id, "worker-a", &capabilities(), 100 * attempt, 10_000)
1783
+ .unwrap();
1784
+ let (_, _, recovered) = race_recovery(&db, claim.expires_at);
1785
+ assert_eq!(recovered.concat(), vec![id.clone()], "attempt {attempt}");
1786
+ let committed = committed_workload(&db, &id);
1787
+ assert_eq!(recoveries(&committed), attempt as usize);
1788
+ assert_eq!(committed.attempt_count, attempt as u32);
1789
+ }
1790
+ let committed = committed_workload(&db, &id);
1791
+ assert_eq!(committed.state, WorkloadState::DeadLetter);
1792
+ assert_eq!(
1793
+ committed.execution_history.last().unwrap().evidence["decision"],
1794
+ "attempt_budget_exhausted"
1795
+ );
1796
+ assert!(committed
1797
+ .attempts
1798
+ .iter()
1799
+ .all(|attempt| attempt.status == AttemptStatus::WorkerLost));
1800
+ let refused = WorkloadStore::from_db(db.clone())
1801
+ .unwrap()
1802
+ .claim(&id, "worker-b", &capabilities(), 10_000, 10_000)
1803
+ .unwrap_err();
1804
+ assert_eq!(refused.code, "WORKLOAD_NOT_READY");
1805
+ assert_eq!(committed_workload(&db, &id).attempt_count, 3);
1806
+ drop((store, db));
1807
+ let _ = fs::remove_file(path);
1808
+ }
1809
+
1810
+ #[test]
1811
+ fn a_policy_that_does_not_retry_worker_failure_dead_letters_a_lost_attempt() {
1812
+ let (path, mut store) = store("no-worker-failure-retry");
1813
+ let mut request = create();
1814
+ request.retry_policy.retry_on = ["timeout".into()].into();
1815
+ let id = store.create(request).unwrap().workload_id;
1816
+ let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
1817
+ store.recover_expired(claim.expires_at).unwrap();
1818
+ let workload = store.get(&id).unwrap();
1819
+ assert_eq!(workload.state, WorkloadState::DeadLetter);
1820
+ assert_eq!(
1821
+ workload.execution_history.last().unwrap().evidence["decision"],
1822
+ "worker_failure_not_retryable"
1823
+ );
1824
+ let _ = fs::remove_file(path);
1825
+ }
1826
+
1827
+ #[test]
1828
+ fn claiming_exhausted_ready_work_dead_letters_it_instead() {
1829
+ let (path, mut store) = store("exhausted-claim");
1830
+ let id = store.create(create()).unwrap().workload_id;
1831
+ // What unbounded recovery used to leave behind: Ready, budget spent.
1832
+ let mut legacy = store.get(&id).unwrap().clone();
1833
+ legacy.attempt_count = 7;
1834
+ store.workloads.insert(id.clone(), legacy);
1835
+ store.persist_workload(&id, "fixture.legacy").unwrap();
1836
+ let refused = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap_err();
1837
+ assert_eq!(refused.code, "WORKLOAD_ATTEMPTS_EXHAUSTED");
1838
+ let workload = store.get(&id).unwrap();
1839
+ assert_eq!(workload.state, WorkloadState::DeadLetter);
1840
+ assert_eq!(
1841
+ workload.execution_history.last().unwrap().evidence["reason"],
1842
+ "attempt_budget_exhausted"
1843
+ );
1844
+ // Durable: a restart sees the same outcome.
1845
+ let reopened = WorkloadStore::from_db(store.db.clone()).unwrap();
1846
+ assert_eq!(reopened.get(&id).unwrap().state, WorkloadState::DeadLetter);
1847
+ let _ = fs::remove_file(path);
1848
+ }
1849
+
1850
+ #[test]
1851
+ fn operator_retry_grants_one_attempt_is_recorded_and_is_bounded() {
1852
+ let (path, mut store) = store("operator-retry");
1853
+ let id = store.create(create()).unwrap().workload_id;
1854
+ // Claim and lose leases until the workload stops being claimable.
1855
+ fn exhaust(store: &mut WorkloadStore, id: &str, now: &mut i64) {
1856
+ loop {
1857
+ *now += 100;
1858
+ match store.claim(id, "worker-a", &capabilities(), *now, 10_000) {
1859
+ Ok(claim) => {
1860
+ store.recover_expired(claim.expires_at).unwrap();
1861
+ }
1862
+ Err(error) => {
1863
+ assert!(
1864
+ matches!(
1865
+ error.code.as_str(),
1866
+ "WORKLOAD_NOT_READY" | "WORKLOAD_ATTEMPTS_EXHAUSTED"
1867
+ ),
1868
+ "{error:?}"
1869
+ );
1870
+ return;
1871
+ }
1872
+ }
1873
+ }
1874
+ }
1875
+ let mut now = 100;
1876
+ exhaust(&mut store, &id, &mut now);
1877
+ assert_eq!(store.get(&id).unwrap().attempt_count, 3);
1878
+ for retry in 1..=3u32 {
1879
+ now += 100;
1880
+ store
1881
+ .retry_with_reason(&id, "operator-1", now, Some("dependency restored"))
1882
+ .unwrap();
1883
+ let event = store.get(&id).unwrap().execution_history.last().unwrap().clone();
1884
+ assert_eq!(event.actor, "operator-1");
1885
+ assert_eq!(event.evidence["retry"], "operator");
1886
+ assert_eq!(event.evidence["reason"], "dependency restored");
1887
+ assert_eq!(event.evidence["operator_retry"], retry.to_string());
1888
+ exhaust(&mut store, &id, &mut now);
1889
+ assert_eq!(
1890
+ store.get(&id).unwrap().attempt_count,
1891
+ 3 + retry,
1892
+ "each operator retry grants exactly one attempt"
1893
+ );
1894
+ assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
1895
+ }
1896
+ assert_eq!(
1897
+ store.retry(&id, "operator-1", now + 100).unwrap_err().code,
1898
+ "WORKLOAD_RETRY_EXHAUSTED"
1899
+ );
1900
+ assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
1901
+ let _ = fs::remove_file(path);
1902
+ }
1903
+
1358
1904
  fn store(name: &str) -> (PathBuf, WorkloadStore) {
1359
1905
  let path =
1360
1906
  std::env::temp_dir().join(format!("workload-{name}-{}.json", std::process::id()));
@@ -1367,7 +1913,8 @@ mod tests {
1367
1913
  let workload = source.create(create()).unwrap();
1368
1914
  let legacy = LegacyWorkloadStore {
1369
1915
  workloads: source.workloads.clone(),
1370
- idempotency: source.idempotency.clone(),
1916
+ // What a pre-requester-scope release wrote.
1917
+ idempotency: idempotency_index(&source.workloads, legacy_idempotency_scope),
1371
1918
  result_ids: source.result_ids.clone(),
1372
1919
  event_sequence: derived_event_sequence(&source.workloads),
1373
1920
  };
@@ -1458,7 +2005,10 @@ mod tests {
1458
2005
  winner.claim(&id, "worker-a", &caps, 11, 1_000).unwrap();
1459
2006
  let conflict = loser.claim(&id, "worker-b", &caps, 11, 1_000).unwrap_err();
1460
2007
  assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
1461
- assert_eq!(loser.get(&id).unwrap().state, WorkloadState::Ready);
2008
+ // The loser committed nothing and now projects the winner's claim.
2009
+ let projected = loser.get(&id).unwrap();
2010
+ assert_eq!(projected.state, WorkloadState::Claimed);
2011
+ assert_eq!(projected.current_claim.as_ref().unwrap().worker_id, "worker-a");
1462
2012
 
1463
2013
  let row = db
1464
2014
  .list_collection(DURABLE_WORK_COLLECTION)
@@ -1481,10 +2031,9 @@ mod tests {
1481
2031
  .claim(&id, "worker-d", &caps, 13, 10_000)
1482
2032
  .unwrap_err();
1483
2033
  assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
1484
- assert_eq!(
1485
- restarted_loser.get(&id).unwrap().state,
1486
- WorkloadState::Ready
1487
- );
2034
+ let projected = restarted_loser.get(&id).unwrap();
2035
+ assert_eq!(projected.state, WorkloadState::Claimed);
2036
+ assert_eq!(projected.current_claim.as_ref().unwrap().worker_id, "worker-c");
1488
2037
  drop((recovery, restarted_winner, restarted_loser, restarted_db));
1489
2038
  let _ = fs::remove_file(path);
1490
2039
  }
@@ -1499,22 +2048,103 @@ mod tests {
1499
2048
  let mut first = WorkloadStore::from_db(db.clone()).unwrap();
1500
2049
  let mut second = WorkloadStore::from_db(db.clone()).unwrap();
1501
2050
  let accepted = first.create(create()).unwrap();
2051
+ // A second writer of the same store loses the require-absent CAS and
2052
+ // converges on what was committed: a different request is refused as a
2053
+ // reused key, never answered with the original.
1502
2054
  let mut competing = create();
1503
2055
  competing.input = serde_json::json!({"id": 2});
1504
2056
  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"
2057
+ assert_eq!(conflict.code, "IDEMPOTENCY_KEY_REUSED");
2058
+ assert_eq!(
2059
+ second.workloads.keys().collect::<Vec<_>>(),
2060
+ vec![&accepted.workload_id],
2061
+ "only the committed workload is projected"
1509
2062
  );
1510
-
1511
- drop((first, second, db));
2063
+ // The same request from a third, equally stale writer converges on it.
2064
+ let mut third = WorkloadStore::from_db(db.clone()).unwrap();
2065
+ third.workloads.clear();
2066
+ third.idempotency.clear();
2067
+ third.document_versions.clear();
2068
+ let (converged, created) = third.admit(create()).unwrap();
2069
+ assert!(!created);
2070
+ assert_eq!(converged.workload_id, accepted.workload_id);
2071
+
2072
+ drop((first, second, third, db));
1512
2073
  let restarted = WorkloadStore::from_db(FeltDb::open(&path).unwrap()).unwrap();
1513
2074
  assert_eq!(restarted.workloads.len(), 1);
1514
- assert_eq!(restarted.idempotency["t:app:one"], accepted.workload_id);
2075
+ assert_eq!(
2076
+ restarted.idempotency[&idempotency_scope("t", "app", &accepted.requested_by, "one")],
2077
+ accepted.workload_id
2078
+ );
1515
2079
  let _ = fs::remove_file(path);
1516
2080
  }
1517
2081
 
2082
+ #[test]
2083
+ fn idempotency_scopes_cannot_be_made_to_coincide() {
2084
+ let (path, mut store) = store("scope-separators");
2085
+ let mut first = create_as("x:k1");
2086
+ first.idempotency_key = "k2".into();
2087
+ let mut second = create_as("x");
2088
+ second.idempotency_key = "k1:k2".into();
2089
+ let first = store.create(first).unwrap();
2090
+ let second = store.create(second).unwrap();
2091
+ assert_ne!(first.workload_id, second.workload_id);
2092
+ let _ = fs::remove_file(path);
2093
+ }
2094
+
2095
+ #[test]
2096
+ fn concurrent_admissions_by_competing_writers_converge_or_conflict() {
2097
+ for round in 0..20 {
2098
+ let (path, seed) = store(&format!("concurrent-admission-{round}"));
2099
+ let db = seed.db.clone();
2100
+ let admit_concurrently = |requests: [CreateWorkload; 2]| {
2101
+ let barrier = std::sync::Barrier::new(2);
2102
+ std::thread::scope(|scope| {
2103
+ requests
2104
+ .into_iter()
2105
+ .map(|request| {
2106
+ let mut writer = WorkloadStore::from_db(db.clone()).unwrap();
2107
+ let barrier = &barrier;
2108
+ scope.spawn(move || {
2109
+ barrier.wait();
2110
+ writer.admit(request)
2111
+ })
2112
+ })
2113
+ .collect::<Vec<_>>()
2114
+ .into_iter()
2115
+ .map(|handle| handle.join().unwrap())
2116
+ .collect::<Vec<_>>()
2117
+ })
2118
+ };
2119
+ // The same request: both writers answer with one workload, and
2120
+ // exactly one of them created it.
2121
+ let same = admit_concurrently([create(), create()]);
2122
+ let (first, first_created) = same[0].as_ref().unwrap().clone();
2123
+ let (second, second_created) = same[1].as_ref().unwrap().clone();
2124
+ assert_eq!(first.workload_id, second.workload_id);
2125
+ assert!(first_created ^ second_created, "round {round}");
2126
+
2127
+ // Different requests under one key: one is admitted, the other is
2128
+ // refused as a reused key and never answered with the winner.
2129
+ let mut left = create();
2130
+ left.idempotency_key = "contested".into();
2131
+ let mut right = left.clone();
2132
+ right.input = serde_json::json!({"id": 2});
2133
+ let contested = admit_concurrently([left, right]);
2134
+ let admitted = contested.iter().filter(|outcome| outcome.is_ok()).count();
2135
+ assert_eq!(admitted, 1, "round {round}");
2136
+ assert!(contested.iter().any(|outcome| outcome
2137
+ .as_ref()
2138
+ .is_err_and(|error| error.code == "IDEMPOTENCY_KEY_REUSED")));
2139
+ let (winner, _) = contested.iter().find_map(|o| o.as_ref().ok()).unwrap();
2140
+ let stored = WorkloadStore::from_db(db.clone()).unwrap();
2141
+ assert_eq!(stored.workloads.len(), 2);
2142
+ assert_eq!(stored.get(&winner.workload_id).unwrap().input, winner.input);
2143
+ drop((seed, stored, db));
2144
+ let _ = fs::remove_file(path);
2145
+ }
2146
+ }
2147
+
1518
2148
  #[test]
1519
2149
  fn pre_versioned_canonical_rows_upgrade_through_the_standard_cas_contract() {
1520
2150
  let (path, mut store) = store("pre-versioned-upgrade");
@@ -1589,7 +2219,9 @@ mod tests {
1589
2219
  store.document_versions.clear();
1590
2220
  let rebuilt = WorkloadStore::from_db(store.db.clone()).unwrap();
1591
2221
  assert_eq!(
1592
- rebuilt.idempotency.get("t:app:one"),
2222
+ rebuilt
2223
+ .idempotency
2224
+ .get(&idempotency_scope("t", "app", &workload.requested_by, "one")),
1593
2225
  Some(&workload.workload_id)
1594
2226
  );
1595
2227
  assert_eq!(