@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.
- package/dist/build-identity.json +4 -4
- package/dist/cli/commands.js +8 -2
- package/dist/cli/workspace-integration.js +8 -0
- package/dist/create/build-identity.json +4 -4
- package/dist/create/cli.js +46 -37
- package/dist/create/create.js +17 -2
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +4 -4
- package/dist/create/server-source/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +204 -1
- package/dist/create/server-source/crates/feltdb/src/workload.rs +685 -53
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +28 -0
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +844 -135
- package/dist/create/setup-summary.js +40 -0
- package/dist/db.d.ts +27 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +17 -4
- package/dist/development-runtime-bridge.d.ts +20 -0
- package/dist/development-runtime-bridge.d.ts.map +1 -1
- package/dist/development-runtime-bridge.js +61 -12
- package/dist/http-db.d.ts +13 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +33 -4
- package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
- package/dist/studio/app.d.ts +8 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.js +1 -1
- package/dist/studio/components/index.js +2 -2
- package/dist/studio/{components-B4eKIUN5.js → components-BSGmHfif.js} +6 -6
- package/dist/studio/control-plane/Data.d.ts.map +1 -1
- package/dist/studio/control-plane/Instance.d.ts +4 -1
- package/dist/studio/control-plane/Instance.d.ts.map +1 -1
- package/dist/studio/control-plane/Operations.d.ts +4 -1
- package/dist/studio/control-plane/Operations.d.ts.map +1 -1
- package/dist/studio/control-plane/index.js +2 -2
- package/dist/studio/control-plane/primitives.d.ts +16 -0
- package/dist/studio/control-plane/primitives.d.ts.map +1 -1
- package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
- package/dist/studio/index.d.ts +1 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +384 -247
- package/dist/studio/navigation.d.ts +57 -0
- package/dist/studio/navigation.d.ts.map +1 -0
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/control-plane-api.d.ts +2 -0
- package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.js +5 -1
- package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-3hDngl1h.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +30 -0
- package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
- package/dist/studio-app/index.html +2 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workspace/local-development-authority.d.ts +19 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +335 -15
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
- 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
|
|
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.
|
|
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 =
|
|
916
|
-
&& w.attempt_count < w
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1238
|
+
to,
|
|
1105
1239
|
"rust.recovery",
|
|
1106
1240
|
now,
|
|
1107
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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, "
|
|
1506
|
-
|
|
1507
|
-
second.workloads.
|
|
1508
|
-
|
|
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
|
-
|
|
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!(
|
|
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
|
|
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!(
|