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