@feltdb/core 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +27 -6
- 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 +1 -1
- package/dist/create/create.js +4 -6
- 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 +137 -1
- package/dist/create/server-source/crates/feltdb/src/workload.rs +1427 -91
- 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/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -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 +3 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +879 -84
- 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/db.d.ts +64 -9
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +153 -27
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- 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 +27 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +252 -54
- 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/components/index.js +1 -1
- package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
- package/dist/studio/index.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.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/index.html +1 -1
- 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/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//! Durable workload authority. Workers execute; this state machine decides.
|
|
2
2
|
use crate::{
|
|
3
3
|
authorization::{SignedGrant, Subject},
|
|
4
|
-
AtomicMutation, FeltDb, FlowError, RecordPrecondition,
|
|
4
|
+
AtomicMutation, FeltDb, FlowError, RecordPrecondition, StoredRow,
|
|
5
5
|
};
|
|
6
6
|
use rand::{distributions::Alphanumeric, Rng};
|
|
7
7
|
use serde::{Deserialize, Serialize};
|
|
@@ -11,6 +11,11 @@ use std::collections::{BTreeMap, BTreeSet};
|
|
|
11
11
|
use std::path::Path;
|
|
12
12
|
pub const WORKLOAD_CONTRACT_VERSION: u32 = 1;
|
|
13
13
|
pub const DURABLE_WORK_COLLECTION: &str = "system_durable_work";
|
|
14
|
+
/// Workloads admitted at this contract version or later have their `timeouts`
|
|
15
|
+
/// enforced. Earlier records were admitted when timeouts were stored and
|
|
16
|
+
/// ignored; they keep that behaviour, so an upgrade never times out work whose
|
|
17
|
+
/// requester was told nothing would.
|
|
18
|
+
pub const DEADLINE_CONTRACT_VERSION: u32 = 2;
|
|
14
19
|
|
|
15
20
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
16
21
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
@@ -153,6 +158,13 @@ pub struct WorkloadExecutionContext {
|
|
|
153
158
|
pub secret_references: Vec<String>,
|
|
154
159
|
pub expires_at: i64,
|
|
155
160
|
pub fencing_token: u64,
|
|
161
|
+
/// The work's input, so the claiming worker can execute it.
|
|
162
|
+
#[serde(default)]
|
|
163
|
+
pub input: Value,
|
|
164
|
+
/// Every committed checkpoint, from this and earlier attempts, in order. A
|
|
165
|
+
/// resumed attempt reads these to learn how far lost attempts got.
|
|
166
|
+
#[serde(default)]
|
|
167
|
+
pub checkpoints: Vec<WorkloadCheckpoint>,
|
|
156
168
|
}
|
|
157
169
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
158
170
|
pub struct WorkloadEvent {
|
|
@@ -269,12 +281,16 @@ pub struct CreateWorkload {
|
|
|
269
281
|
pub struct WorkloadError {
|
|
270
282
|
pub code: String,
|
|
271
283
|
pub message: String,
|
|
284
|
+
/// Structured detail for the caller, e.g. which request fields conflict.
|
|
285
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
286
|
+
pub details: Option<Value>,
|
|
272
287
|
}
|
|
273
288
|
impl WorkloadError {
|
|
274
289
|
fn new(code: &str, message: impl Into<String>) -> Self {
|
|
275
290
|
Self {
|
|
276
291
|
code: code.into(),
|
|
277
292
|
message: message.into(),
|
|
293
|
+
details: None,
|
|
278
294
|
}
|
|
279
295
|
}
|
|
280
296
|
}
|
|
@@ -294,6 +310,15 @@ pub struct WorkloadStore {
|
|
|
294
310
|
/// Rebuildable cache of FeltDB-owned document versions. This is never
|
|
295
311
|
/// serialized and never advances independently of a canonical commit.
|
|
296
312
|
document_versions: BTreeMap<String, u64>,
|
|
313
|
+
/// The store revision this projection last reconciled with. A store that
|
|
314
|
+
/// has moved past it (a commit by another writer, or a replicated
|
|
315
|
+
/// operation) is re-read before the projection answers.
|
|
316
|
+
synced_revision: u64,
|
|
317
|
+
/// Always false outside tests, which set it to act on the projection
|
|
318
|
+
/// without re-reading the store: that reproduces another writer
|
|
319
|
+
/// committing between a refresh and a commit — the window only the storage
|
|
320
|
+
/// guard closes.
|
|
321
|
+
refresh_disabled: bool,
|
|
297
322
|
}
|
|
298
323
|
|
|
299
324
|
#[derive(Serialize, Deserialize)]
|
|
@@ -314,6 +339,9 @@ impl WorkloadStore {
|
|
|
314
339
|
}
|
|
315
340
|
|
|
316
341
|
pub fn from_db(db: FeltDb) -> Result<Self, WorkloadError> {
|
|
342
|
+
// Read before listing: a commit that lands in between is re-read by
|
|
343
|
+
// the next refresh rather than missed.
|
|
344
|
+
let synced_revision = db.current_revision().map_err(io)?;
|
|
317
345
|
let mut workloads = BTreeMap::new();
|
|
318
346
|
let mut document_versions = BTreeMap::new();
|
|
319
347
|
for row in db.list_collection(DURABLE_WORK_COLLECTION).map_err(io)? {
|
|
@@ -342,9 +370,121 @@ impl WorkloadStore {
|
|
|
342
370
|
idempotency,
|
|
343
371
|
result_ids,
|
|
344
372
|
document_versions,
|
|
373
|
+
synced_revision,
|
|
374
|
+
refresh_disabled: false,
|
|
345
375
|
})
|
|
346
376
|
}
|
|
347
377
|
|
|
378
|
+
/// Reconcile the projection with the committed store.
|
|
379
|
+
///
|
|
380
|
+
/// The store, not this projection, is authoritative. When its revision has
|
|
381
|
+
/// moved since the last reconciliation, every workload row whose document
|
|
382
|
+
/// version differs from the projection's is re-read; rows are borrowed and
|
|
383
|
+
/// only changed ones are cloned. Commits by another writer of the store and
|
|
384
|
+
/// operations replicated from a peer both become visible here, without a
|
|
385
|
+
/// restart.
|
|
386
|
+
pub fn refresh(&mut self) -> Result<(), WorkloadError> {
|
|
387
|
+
if self.refresh_disabled {
|
|
388
|
+
return Ok(());
|
|
389
|
+
}
|
|
390
|
+
let revision = self.db.current_revision().map_err(io)?;
|
|
391
|
+
if revision == self.synced_revision {
|
|
392
|
+
return Ok(());
|
|
393
|
+
}
|
|
394
|
+
let known = &self.document_versions;
|
|
395
|
+
let changed = self
|
|
396
|
+
.db
|
|
397
|
+
.query_collection(DURABLE_WORK_COLLECTION, None, |row| {
|
|
398
|
+
let version = row
|
|
399
|
+
.value
|
|
400
|
+
.get("__version")
|
|
401
|
+
.and_then(Value::as_u64)
|
|
402
|
+
.unwrap_or(1);
|
|
403
|
+
let id = row.key.rsplit('/').next().unwrap_or_default();
|
|
404
|
+
known.get(id) != Some(&version)
|
|
405
|
+
})
|
|
406
|
+
.map_err(io)?;
|
|
407
|
+
for row in changed {
|
|
408
|
+
self.adopt(row)?;
|
|
409
|
+
}
|
|
410
|
+
self.synced_revision = revision;
|
|
411
|
+
Ok(())
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/// Re-read one workload from the store when its committed version differs
|
|
415
|
+
/// from the projection's: a keyed read, not a scan. An id the projection
|
|
416
|
+
/// has never seen (admitted by another writer) falls back to [`Self::refresh`].
|
|
417
|
+
fn refresh_id(&mut self, id: &str) -> Result<(), WorkloadError> {
|
|
418
|
+
match self.workloads.get(id).map(work_key) {
|
|
419
|
+
Some(key) => self.refresh_key(&key).map(|_| ()),
|
|
420
|
+
None => self.refresh(),
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
fn refresh_key(&mut self, key: &str) -> Result<Option<Workload>, WorkloadError> {
|
|
425
|
+
if self.refresh_disabled {
|
|
426
|
+
let id = key.rsplit('/').next().unwrap_or_default();
|
|
427
|
+
return Ok(self.workloads.get(id).cloned());
|
|
428
|
+
}
|
|
429
|
+
let Some(row) = self.db.live_row(DURABLE_WORK_COLLECTION, key) else {
|
|
430
|
+
return Ok(None);
|
|
431
|
+
};
|
|
432
|
+
let version = row
|
|
433
|
+
.value
|
|
434
|
+
.get("__version")
|
|
435
|
+
.and_then(Value::as_u64)
|
|
436
|
+
.unwrap_or(1);
|
|
437
|
+
let id = key.rsplit('/').next().unwrap_or_default();
|
|
438
|
+
if self.document_versions.get(id) == Some(&version) {
|
|
439
|
+
return Ok(self.workloads.get(id).cloned());
|
|
440
|
+
}
|
|
441
|
+
self.adopt(row).map(Some)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/// Install one committed row in the projection and its derived indexes.
|
|
445
|
+
fn adopt(&mut self, row: StoredRow) -> Result<Workload, WorkloadError> {
|
|
446
|
+
let version = row
|
|
447
|
+
.value
|
|
448
|
+
.get("__version")
|
|
449
|
+
.and_then(Value::as_u64)
|
|
450
|
+
.unwrap_or(1);
|
|
451
|
+
let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
|
|
452
|
+
if let Some(result) = &workload.result {
|
|
453
|
+
self.result_ids
|
|
454
|
+
.insert(result.result_id.clone(), workload.workload_id.clone());
|
|
455
|
+
}
|
|
456
|
+
self.idempotency.insert(
|
|
457
|
+
idempotency_scope(
|
|
458
|
+
&workload.tenant_id,
|
|
459
|
+
&workload.application_id,
|
|
460
|
+
&workload.requested_by,
|
|
461
|
+
&workload.idempotency_key,
|
|
462
|
+
),
|
|
463
|
+
workload.workload_id.clone(),
|
|
464
|
+
);
|
|
465
|
+
self.document_versions
|
|
466
|
+
.insert(workload.workload_id.clone(), version);
|
|
467
|
+
self.workloads
|
|
468
|
+
.insert(workload.workload_id.clone(), workload.clone());
|
|
469
|
+
Ok(workload)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/// A workload as committed in the store (see [`Self::refresh`]).
|
|
473
|
+
pub fn fresh(&mut self, id: &str) -> Result<&Workload, WorkloadError> {
|
|
474
|
+
self.refresh_id(id)?;
|
|
475
|
+
self.get(id)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/// An application's workloads as committed in the store.
|
|
479
|
+
pub fn fresh_list(
|
|
480
|
+
&mut self,
|
|
481
|
+
tenant: &str,
|
|
482
|
+
application: &str,
|
|
483
|
+
) -> Result<Vec<Workload>, WorkloadError> {
|
|
484
|
+
self.refresh()?;
|
|
485
|
+
Ok(self.list(tenant, application))
|
|
486
|
+
}
|
|
487
|
+
|
|
348
488
|
pub fn migrate_legacy_file(db: FeltDb, path: &Path) -> Result<Self, WorkloadError> {
|
|
349
489
|
let bytes = std::fs::read(path).map_err(io)?;
|
|
350
490
|
let legacy: LegacyWorkloadStore = serde_json::from_slice(&bytes).map_err(io)?;
|
|
@@ -469,6 +609,21 @@ impl WorkloadStore {
|
|
|
469
609
|
"idempotency key is required",
|
|
470
610
|
));
|
|
471
611
|
}
|
|
612
|
+
let t = &input.timeouts;
|
|
613
|
+
if [
|
|
614
|
+
t.queue_ms,
|
|
615
|
+
t.claim_ms,
|
|
616
|
+
t.execution_ms,
|
|
617
|
+
t.heartbeat_ms,
|
|
618
|
+
t.overall_ms,
|
|
619
|
+
]
|
|
620
|
+
.contains(&Some(0))
|
|
621
|
+
{
|
|
622
|
+
return Err(WorkloadError::new(
|
|
623
|
+
"WORKLOAD_INVALID",
|
|
624
|
+
"a timeout must be at least 1 ms; omit it for no deadline",
|
|
625
|
+
));
|
|
626
|
+
}
|
|
472
627
|
if input.authorization_snapshot.grant.subject != input.requested_by
|
|
473
628
|
|| input.authorization_snapshot.grant.tenant_id != input.tenant_id
|
|
474
629
|
|| input.authorization_snapshot.grant.application_id != input.application_id
|
|
@@ -484,19 +639,25 @@ impl WorkloadStore {
|
|
|
484
639
|
&input.requested_by,
|
|
485
640
|
&input.idempotency_key,
|
|
486
641
|
);
|
|
487
|
-
if let Some(id) = self.idempotency.get(&idempotency) {
|
|
488
|
-
let existing = self.workloads[id].clone();
|
|
489
|
-
return replay(existing, &input);
|
|
490
|
-
}
|
|
491
642
|
// The scoped idempotency identity is the record identity. Competing
|
|
492
643
|
// admissions therefore contend on one canonical require-absent CAS
|
|
493
644
|
// even when their payloads differ; no separate durable uniqueness
|
|
494
645
|
// index is needed.
|
|
495
646
|
let id = format!("workload_{}", &hex(&Sha256::digest(&idempotency))[..24]);
|
|
647
|
+
// Another writer, or a peer, may have admitted this identity: the
|
|
648
|
+
// store is asked by key, not the projection.
|
|
649
|
+
let committed_key = format!("{}/{}/{}", input.tenant_id, input.application_id, id);
|
|
650
|
+
if let Some(existing) = self.refresh_key(&committed_key)? {
|
|
651
|
+
return replay(existing, &input);
|
|
652
|
+
}
|
|
653
|
+
if let Some(existing_id) = self.idempotency.get(&idempotency) {
|
|
654
|
+
let existing = self.workloads[existing_id].clone();
|
|
655
|
+
return replay(existing, &input);
|
|
656
|
+
}
|
|
496
657
|
let request = input.clone();
|
|
497
658
|
let input_hash = hash(&input.input);
|
|
498
659
|
let mut workload = Workload {
|
|
499
|
-
contract_version:
|
|
660
|
+
contract_version: DEADLINE_CONTRACT_VERSION,
|
|
500
661
|
workload_id: id.clone(),
|
|
501
662
|
tenant_id: input.tenant_id,
|
|
502
663
|
application_id: input.application_id,
|
|
@@ -585,6 +746,9 @@ impl WorkloadStore {
|
|
|
585
746
|
id: &str,
|
|
586
747
|
action: impl FnOnce(&mut Self, &mut Workload) -> Result<R, WorkloadError>,
|
|
587
748
|
) -> Result<R, WorkloadError> {
|
|
749
|
+
// Act only on the committed version: a projection that missed another
|
|
750
|
+
// writer's commit would otherwise lose its guarded commit at best.
|
|
751
|
+
self.refresh_id(id)?;
|
|
588
752
|
let workload = self
|
|
589
753
|
.workloads
|
|
590
754
|
.get(id)
|
|
@@ -610,30 +774,18 @@ impl WorkloadStore {
|
|
|
610
774
|
pub(crate) fn db_for_tests(&self) -> FeltDb {
|
|
611
775
|
self.db.clone()
|
|
612
776
|
}
|
|
777
|
+
/// See `refresh_disabled`.
|
|
778
|
+
#[cfg(test)]
|
|
779
|
+
pub(crate) fn without_refresh(mut self) -> Self {
|
|
780
|
+
self.refresh_disabled = true;
|
|
781
|
+
self
|
|
782
|
+
}
|
|
613
783
|
/// Replace this projection's copy of a workload with the committed row.
|
|
614
784
|
fn reload_committed(&mut self, key: &str) -> Result<Option<Workload>, WorkloadError> {
|
|
615
|
-
|
|
616
|
-
.
|
|
617
|
-
|
|
618
|
-
.map_err(io)?;
|
|
619
|
-
let Some(row) = committed.into_iter().next() else {
|
|
620
|
-
return Ok(None);
|
|
621
|
-
};
|
|
622
|
-
let version = row
|
|
623
|
-
.value
|
|
624
|
-
.get("__version")
|
|
625
|
-
.and_then(Value::as_u64)
|
|
626
|
-
.unwrap_or(1);
|
|
627
|
-
let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
|
|
628
|
-
if let Some(result) = &workload.result {
|
|
629
|
-
self.result_ids
|
|
630
|
-
.insert(result.result_id.clone(), workload.workload_id.clone());
|
|
785
|
+
match self.db.live_row(DURABLE_WORK_COLLECTION, key) {
|
|
786
|
+
Some(row) => self.adopt(row).map(Some),
|
|
787
|
+
None => Ok(None),
|
|
631
788
|
}
|
|
632
|
-
self.document_versions
|
|
633
|
-
.insert(workload.workload_id.clone(), version);
|
|
634
|
-
self.workloads
|
|
635
|
-
.insert(workload.workload_id.clone(), workload.clone());
|
|
636
|
-
Ok(Some(workload))
|
|
637
789
|
}
|
|
638
790
|
fn transition(
|
|
639
791
|
&mut self,
|
|
@@ -675,6 +827,7 @@ impl WorkloadStore {
|
|
|
675
827
|
now: i64,
|
|
676
828
|
lease_ms: u64,
|
|
677
829
|
) -> Result<WorkloadClaim, WorkloadError> {
|
|
830
|
+
self.enforce_deadline(id, now)?;
|
|
678
831
|
// A Ready workload that has already used its budget (an operator
|
|
679
832
|
// re-arm beyond it, or a record left by unbounded recovery before it
|
|
680
833
|
// was bounded) is dead-lettered here rather than claimed again.
|
|
@@ -798,6 +951,7 @@ impl WorkloadStore {
|
|
|
798
951
|
token: u64,
|
|
799
952
|
now: i64,
|
|
800
953
|
) -> Result<(), WorkloadError> {
|
|
954
|
+
self.enforce_deadline(id, now)?;
|
|
801
955
|
self.edit(id, |store, w| {
|
|
802
956
|
Self::fence(w, claim, worker, token, now)?;
|
|
803
957
|
let attempt = w.attempts.last_mut().unwrap();
|
|
@@ -815,6 +969,7 @@ impl WorkloadStore {
|
|
|
815
969
|
now: i64,
|
|
816
970
|
extend_ms: u64,
|
|
817
971
|
) -> Result<WorkloadClaim, WorkloadError> {
|
|
972
|
+
self.enforce_deadline(id, now)?;
|
|
818
973
|
self.edit(id, |_, w| {
|
|
819
974
|
Self::fence(w, claim, worker, token, now)?;
|
|
820
975
|
let current = w.current_claim.as_mut().unwrap();
|
|
@@ -824,7 +979,7 @@ impl WorkloadStore {
|
|
|
824
979
|
})
|
|
825
980
|
}
|
|
826
981
|
pub fn execution_context(
|
|
827
|
-
&self,
|
|
982
|
+
&mut self,
|
|
828
983
|
id: &str,
|
|
829
984
|
claim: &str,
|
|
830
985
|
worker: &str,
|
|
@@ -832,7 +987,8 @@ impl WorkloadStore {
|
|
|
832
987
|
now: i64,
|
|
833
988
|
secret_references: Vec<String>,
|
|
834
989
|
) -> Result<WorkloadExecutionContext, WorkloadError> {
|
|
835
|
-
|
|
990
|
+
self.enforce_deadline(id, now)?;
|
|
991
|
+
let workload = self.fresh(id)?;
|
|
836
992
|
Self::fence(workload, claim, worker, token, now)?;
|
|
837
993
|
if secret_references
|
|
838
994
|
.iter()
|
|
@@ -853,6 +1009,8 @@ impl WorkloadStore {
|
|
|
853
1009
|
secret_references,
|
|
854
1010
|
expires_at: current.expires_at,
|
|
855
1011
|
fencing_token: token,
|
|
1012
|
+
input: workload.input.clone(),
|
|
1013
|
+
checkpoints: workload.checkpoints.clone(),
|
|
856
1014
|
})
|
|
857
1015
|
}
|
|
858
1016
|
pub fn checkpoint(
|
|
@@ -865,6 +1023,7 @@ impl WorkloadStore {
|
|
|
865
1023
|
now: i64,
|
|
866
1024
|
) -> Result<WorkloadCheckpoint, WorkloadError> {
|
|
867
1025
|
ensure_safe(&state)?;
|
|
1026
|
+
self.enforce_deadline(id, now)?;
|
|
868
1027
|
self.edit(id, |store, w| {
|
|
869
1028
|
Self::fence(w, claim, worker, token, now)?;
|
|
870
1029
|
if !matches!(
|
|
@@ -911,13 +1070,28 @@ impl WorkloadStore {
|
|
|
911
1070
|
ensure_safe(&output)?;
|
|
912
1071
|
if let Some(workload_id) = self.result_ids.get(result_id) {
|
|
913
1072
|
if workload_id == id {
|
|
914
|
-
|
|
1073
|
+
// A replay is the reporting attempt retrying the same report
|
|
1074
|
+
// (e.g. after a lost response). Anything else naming this
|
|
1075
|
+
// result id did not produce it and must not be told it landed.
|
|
1076
|
+
let result = self.get(id)?.result.clone().unwrap();
|
|
1077
|
+
let same_report = result.produced_by == worker
|
|
1078
|
+
&& result.provenance.get("claim_id").map(String::as_str) == Some(claim)
|
|
1079
|
+
&& result.provenance.get("fencing_token") == Some(&token.to_string())
|
|
1080
|
+
&& result.output_hash == hash(&output);
|
|
1081
|
+
if same_report {
|
|
1082
|
+
return Ok(result);
|
|
1083
|
+
}
|
|
1084
|
+
return Err(WorkloadError::new(
|
|
1085
|
+
"WORKLOAD_RESULT_CONFLICT",
|
|
1086
|
+
"the result id was already recorded by a different report",
|
|
1087
|
+
));
|
|
915
1088
|
}
|
|
916
1089
|
return Err(WorkloadError::new(
|
|
917
1090
|
"WORKLOAD_RESULT_DUPLICATE",
|
|
918
1091
|
"result id belongs to another workload",
|
|
919
1092
|
));
|
|
920
1093
|
}
|
|
1094
|
+
self.enforce_deadline(id, now)?;
|
|
921
1095
|
let result = self.edit(id, |store, w| {
|
|
922
1096
|
Self::fence(w, claim, worker, token, now)?;
|
|
923
1097
|
if !matches!(
|
|
@@ -968,6 +1142,31 @@ impl WorkloadStore {
|
|
|
968
1142
|
kind: &str,
|
|
969
1143
|
error: &str,
|
|
970
1144
|
now: i64,
|
|
1145
|
+
) -> Result<WorkloadState, WorkloadError> {
|
|
1146
|
+
self.fail_with_effect(id, claim, worker, token, kind, error, None, now)
|
|
1147
|
+
}
|
|
1148
|
+
/// A worker's failure report for its current attempt.
|
|
1149
|
+
///
|
|
1150
|
+
/// Accepted from `CLAIMED` (the worker could not start) as well as from a
|
|
1151
|
+
/// started attempt, fenced like every worker call. `effect` says what the
|
|
1152
|
+
/// worker knows about external effects: `none` — it failed before causing
|
|
1153
|
+
/// any (implied for an attempt that never started); `unknown` — an effect
|
|
1154
|
+
/// may have happened. Without it, the policy decides by `kind`, as before.
|
|
1155
|
+
/// An `unknown` effect is decided like a lost worker: retried only when the
|
|
1156
|
+
/// policy retries `worker_failure`, whatever `kind` says. Repeating the same
|
|
1157
|
+
/// report replays its outcome; any other report from an attempt that
|
|
1158
|
+
/// already reported gets the fence's answer (`CLAIM_INVALID`/`STALE_FENCE`).
|
|
1159
|
+
#[allow(clippy::too_many_arguments)]
|
|
1160
|
+
pub fn fail_with_effect(
|
|
1161
|
+
&mut self,
|
|
1162
|
+
id: &str,
|
|
1163
|
+
claim: &str,
|
|
1164
|
+
worker: &str,
|
|
1165
|
+
token: u64,
|
|
1166
|
+
kind: &str,
|
|
1167
|
+
error: &str,
|
|
1168
|
+
effect: Option<&str>,
|
|
1169
|
+
now: i64,
|
|
971
1170
|
) -> Result<WorkloadState, WorkloadError> {
|
|
972
1171
|
if sensitive_text(error) {
|
|
973
1172
|
return Err(WorkloadError::new(
|
|
@@ -975,23 +1174,65 @@ impl WorkloadStore {
|
|
|
975
1174
|
"errors may not contain secret material",
|
|
976
1175
|
));
|
|
977
1176
|
}
|
|
1177
|
+
if effect.is_some_and(|e| !matches!(e, "none" | "unknown")) {
|
|
1178
|
+
return Err(WorkloadError::new(
|
|
1179
|
+
"WORKLOAD_INVALID",
|
|
1180
|
+
"effect must be \"none\" or \"unknown\"",
|
|
1181
|
+
));
|
|
1182
|
+
}
|
|
1183
|
+
// The same report repeated (a lost response) replays its outcome. Any
|
|
1184
|
+
// other report from an attempt that already reported is answered by
|
|
1185
|
+
// the fence, as before: its claim is no longer current.
|
|
1186
|
+
let current = self.fresh(id)?.clone();
|
|
1187
|
+
if let Some(attempt) = reported_attempt(¤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)?;
|
|
978
1200
|
self.edit(id, |store, w| {
|
|
979
1201
|
Self::fence(w, claim, worker, token, now)?;
|
|
1202
|
+
let started = w.state != WorkloadState::Claimed;
|
|
1203
|
+
let effect = effect.or((!started).then_some("none"));
|
|
980
1204
|
let attempt = w.attempts.last_mut().unwrap();
|
|
981
1205
|
attempt.status = AttemptStatus::Failed;
|
|
982
1206
|
attempt.ended_at = Some(now);
|
|
983
1207
|
attempt.error = Some(error.into());
|
|
1208
|
+
let attempt_id = attempt.attempt_id.clone();
|
|
984
1209
|
w.last_failure = Some(format!("{kind}: {error}"));
|
|
985
1210
|
w.current_claim = None;
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1211
|
+
let mut evidence: BTreeMap<String, String> = [
|
|
1212
|
+
("failure_kind".into(), kind.into()),
|
|
1213
|
+
("attempt_id".into(), attempt_id),
|
|
1214
|
+
]
|
|
1215
|
+
.into();
|
|
1216
|
+
if let Some(effect) = effect {
|
|
1217
|
+
evidence.insert("effect".into(), effect.into());
|
|
1218
|
+
}
|
|
1219
|
+
store.transition(w, WorkloadState::Failed, worker, now, evidence)?;
|
|
1220
|
+
let unknown = effect == Some("unknown");
|
|
1221
|
+
let allowed =
|
|
1222
|
+
w.retry_policy
|
|
1223
|
+
.retry_on
|
|
1224
|
+
.contains(if unknown { "worker_failure" } else { kind });
|
|
1225
|
+
let decision = if !allowed && unknown {
|
|
1226
|
+
"effect_unknown_not_retryable"
|
|
1227
|
+
} else if !allowed {
|
|
1228
|
+
"failure_kind_not_retryable"
|
|
1229
|
+
} else if w.attempt_count >= attempt_budget(w) {
|
|
1230
|
+
"attempt_budget_exhausted"
|
|
1231
|
+
} else {
|
|
1232
|
+
"retry"
|
|
1233
|
+
};
|
|
1234
|
+
let retryable = decision == "retry";
|
|
1235
|
+
let evidence: BTreeMap<String, String> = [("decision".into(), decision.into())].into();
|
|
995
1236
|
if retryable {
|
|
996
1237
|
w.retry_count += 1;
|
|
997
1238
|
let multiplier = if w.retry_policy.backoff == BackoffPolicy::Exponential {
|
|
@@ -1005,15 +1246,9 @@ impl WorkloadStore {
|
|
|
1005
1246
|
.saturating_mul(multiplier)
|
|
1006
1247
|
.min(w.retry_policy.max_delay_ms);
|
|
1007
1248
|
w.next_attempt_at = Some(now + (delay / 1000) as i64);
|
|
1008
|
-
store.transition(w, WorkloadState::Ready, "rust.retry", now,
|
|
1249
|
+
store.transition(w, WorkloadState::Ready, "rust.retry", now, evidence)?;
|
|
1009
1250
|
} else {
|
|
1010
|
-
store.transition(
|
|
1011
|
-
w,
|
|
1012
|
-
WorkloadState::DeadLetter,
|
|
1013
|
-
"rust.retry",
|
|
1014
|
-
now,
|
|
1015
|
-
BTreeMap::new(),
|
|
1016
|
-
)?;
|
|
1251
|
+
store.transition(w, WorkloadState::DeadLetter, "rust.retry", now, evidence)?;
|
|
1017
1252
|
}
|
|
1018
1253
|
Ok(w.state)
|
|
1019
1254
|
})
|
|
@@ -1096,9 +1331,13 @@ impl WorkloadStore {
|
|
|
1096
1331
|
"workload cannot time out in its current state",
|
|
1097
1332
|
));
|
|
1098
1333
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1334
|
+
// A Ready workload has no attempt in progress: the last attempt's
|
|
1335
|
+
// recorded outcome is not this timeout's to overwrite.
|
|
1336
|
+
if w.state != WorkloadState::Ready {
|
|
1337
|
+
if let Some(attempt) = w.attempts.last_mut() {
|
|
1338
|
+
attempt.status = AttemptStatus::TimedOut;
|
|
1339
|
+
attempt.ended_at = Some(now);
|
|
1340
|
+
}
|
|
1102
1341
|
}
|
|
1103
1342
|
w.current_claim = None;
|
|
1104
1343
|
store.transition(w, WorkloadState::TimedOut, actor, now, BTreeMap::new())?;
|
|
@@ -1172,7 +1411,93 @@ impl WorkloadStore {
|
|
|
1172
1411
|
store.transition(w, WorkloadState::Ready, actor, now, evidence)
|
|
1173
1412
|
})
|
|
1174
1413
|
}
|
|
1414
|
+
/// Time out every workload past one of its deadlines (see
|
|
1415
|
+
/// [`deadline_breach`]), as the recovery pass does each second. Deadlines
|
|
1416
|
+
/// are computed from the workload's committed timestamps, so the decision
|
|
1417
|
+
/// is the same after a restart and on any writer of the store.
|
|
1418
|
+
pub fn enforce_deadlines(&mut self, now: i64) -> Result<Vec<String>, WorkloadError> {
|
|
1419
|
+
self.refresh()?;
|
|
1420
|
+
let due = self
|
|
1421
|
+
.workloads
|
|
1422
|
+
.values()
|
|
1423
|
+
.filter_map(|w| deadline_breach(w, now).map(|breach| (w.workload_id.clone(), breach)))
|
|
1424
|
+
.collect::<Vec<_>>();
|
|
1425
|
+
let mut expired = Vec::with_capacity(due.len());
|
|
1426
|
+
for (id, (deadline, at)) in due {
|
|
1427
|
+
match self.expire(&id, deadline, at, now) {
|
|
1428
|
+
Ok(()) => expired.push(id),
|
|
1429
|
+
Err(error) if error.code == "WORKLOAD_CONFLICT" => {}
|
|
1430
|
+
Err(error) => return Err(error),
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
Ok(expired)
|
|
1434
|
+
}
|
|
1435
|
+
/// Refuse a worker call on a workload past a deadline, timing it out
|
|
1436
|
+
/// first, in the same way the recovery pass would have.
|
|
1437
|
+
fn enforce_deadline(&mut self, id: &str, now: i64) -> Result<(), WorkloadError> {
|
|
1438
|
+
let Some((deadline, at)) = deadline_breach(self.fresh(id)?, now) else {
|
|
1439
|
+
return Ok(());
|
|
1440
|
+
};
|
|
1441
|
+
self.expire(id, deadline, at, now)?;
|
|
1442
|
+
let mut error = WorkloadError::new(
|
|
1443
|
+
"WORKLOAD_TIMED_OUT",
|
|
1444
|
+
format!("the workload's {deadline} deadline passed"),
|
|
1445
|
+
);
|
|
1446
|
+
error.details = Some(serde_json::json!({ "deadline": deadline, "deadline_at": at }));
|
|
1447
|
+
Err(error)
|
|
1448
|
+
}
|
|
1449
|
+
fn expire(&mut self, id: &str, deadline: &str, at: i64, now: i64) -> Result<(), WorkloadError> {
|
|
1450
|
+
self.edit(id, |store, w| {
|
|
1451
|
+
// Re-check on the committed version: a heartbeat or completion that
|
|
1452
|
+
// committed first decides, and this changes nothing.
|
|
1453
|
+
if deadline_breach(w, now).map(|(kind, _)| kind) != Some(deadline) {
|
|
1454
|
+
return Err(WorkloadError::new(
|
|
1455
|
+
"WORKLOAD_CONFLICT",
|
|
1456
|
+
"the workload changed before its deadline was enforced",
|
|
1457
|
+
));
|
|
1458
|
+
}
|
|
1459
|
+
let lost = w.current_claim.take();
|
|
1460
|
+
if w.state != WorkloadState::Ready {
|
|
1461
|
+
if let Some(attempt) = w.attempts.last_mut() {
|
|
1462
|
+
attempt.status = AttemptStatus::TimedOut;
|
|
1463
|
+
attempt.ended_at = Some(now);
|
|
1464
|
+
attempt.error = Some(format!("timeout: {deadline} deadline passed"));
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
let mut evidence: BTreeMap<String, String> = [
|
|
1468
|
+
("deadline".into(), deadline.into()),
|
|
1469
|
+
("deadline_at".into(), at.to_string()),
|
|
1470
|
+
]
|
|
1471
|
+
.into();
|
|
1472
|
+
if let Some(claim) = &lost {
|
|
1473
|
+
evidence.insert("claim_id".into(), claim.claim_id.clone());
|
|
1474
|
+
evidence.insert("worker_id".into(), claim.worker_id.clone());
|
|
1475
|
+
}
|
|
1476
|
+
store.transition(w, WorkloadState::TimedOut, "rust.deadline", now, evidence)?;
|
|
1477
|
+
// The overall and queue deadlines bound the work, not an attempt:
|
|
1478
|
+
// another attempt cannot meet them.
|
|
1479
|
+
let decision = if matches!(deadline, "overall" | "queue") {
|
|
1480
|
+
"deadline_not_retryable"
|
|
1481
|
+
} else if !w.retry_policy.retry_on.contains("timeout") {
|
|
1482
|
+
"timeout_not_retryable"
|
|
1483
|
+
} else if w.attempt_count >= attempt_budget(w) {
|
|
1484
|
+
"attempt_budget_exhausted"
|
|
1485
|
+
} else {
|
|
1486
|
+
"retry"
|
|
1487
|
+
};
|
|
1488
|
+
let evidence: BTreeMap<String, String> = [("decision".into(), decision.into())].into();
|
|
1489
|
+
if decision == "retry" {
|
|
1490
|
+
w.retry_count += 1;
|
|
1491
|
+
w.next_attempt_at = Some(now + (w.retry_policy.initial_delay_ms / 1000) as i64);
|
|
1492
|
+
store.transition(w, WorkloadState::Ready, "rust.deadline", now, evidence)?;
|
|
1493
|
+
} else {
|
|
1494
|
+
store.transition(w, WorkloadState::DeadLetter, "rust.deadline", now, evidence)?;
|
|
1495
|
+
}
|
|
1496
|
+
Ok(())
|
|
1497
|
+
})
|
|
1498
|
+
}
|
|
1175
1499
|
pub fn recover_expired(&mut self, now: i64) -> Result<Vec<String>, WorkloadError> {
|
|
1500
|
+
self.refresh()?;
|
|
1176
1501
|
let ids = self
|
|
1177
1502
|
.workloads
|
|
1178
1503
|
.values()
|
|
@@ -1193,6 +1518,25 @@ impl WorkloadStore {
|
|
|
1193
1518
|
let mut recovered = Vec::with_capacity(ids.len());
|
|
1194
1519
|
for id in ids {
|
|
1195
1520
|
let outcome = self.edit(&id, |store, w| {
|
|
1521
|
+
// Decide on the committed version: another writer may have
|
|
1522
|
+
// recovered (or the worker completed) it since it was listed.
|
|
1523
|
+
let still_expired = w
|
|
1524
|
+
.current_claim
|
|
1525
|
+
.as_ref()
|
|
1526
|
+
.is_some_and(|c| c.expires_at <= now)
|
|
1527
|
+
&& matches!(
|
|
1528
|
+
w.state,
|
|
1529
|
+
WorkloadState::Claimed
|
|
1530
|
+
| WorkloadState::Running
|
|
1531
|
+
| WorkloadState::Checkpointed
|
|
1532
|
+
| WorkloadState::CancellationRequested
|
|
1533
|
+
);
|
|
1534
|
+
if !still_expired {
|
|
1535
|
+
return Err(WorkloadError::new(
|
|
1536
|
+
"WORKLOAD_CONFLICT",
|
|
1537
|
+
"the workload changed before it was recovered",
|
|
1538
|
+
));
|
|
1539
|
+
}
|
|
1196
1540
|
let lost = w.current_claim.take();
|
|
1197
1541
|
if let Some(attempt) = w.attempts.last_mut() {
|
|
1198
1542
|
attempt.status = AttemptStatus::WorkerLost;
|
|
@@ -1340,6 +1684,7 @@ fn legal(from: WorkloadState, to: WorkloadState) -> bool {
|
|
|
1340
1684
|
| (Created, Cancelled)
|
|
1341
1685
|
| (Failed, Cancelled)
|
|
1342
1686
|
| (DeadLetter, Cancelled)
|
|
1687
|
+
| (Claimed, Failed)
|
|
1343
1688
|
| (Running, Failed)
|
|
1344
1689
|
| (Checkpointed, Failed)
|
|
1345
1690
|
| (Claimed, Recovering)
|
|
@@ -1437,21 +1782,200 @@ fn idempotency_index(
|
|
|
1437
1782
|
}
|
|
1438
1783
|
/// Answer a repeated key: the original workload for the same request, a
|
|
1439
1784
|
/// refusal for a different one. Never the original for a different request.
|
|
1785
|
+
/// The execution-affecting part of an admission, normalized, field by field.
|
|
1786
|
+
///
|
|
1787
|
+
/// A replay under the same key is answered with the original workload only when
|
|
1788
|
+
/// every one of these is equal. Optional fields are compared after their
|
|
1789
|
+
/// defaults apply (serde fills an omitted `retry_policy`, `timeouts`,
|
|
1790
|
+
/// `priority`, `connection_scopes` or `dependencies`); sets compare as sets and
|
|
1791
|
+
/// dependencies regardless of order; the input compares by its canonical hash,
|
|
1792
|
+
/// so member order is not part of it. Provenance that does not change what runs
|
|
1793
|
+
/// — `trigger`, `session_id`, `created_at`, and the authorization snapshot,
|
|
1794
|
+
/// which is verified before any replay — is not compared.
|
|
1795
|
+
fn execution_fields(
|
|
1796
|
+
definition_id: &str,
|
|
1797
|
+
definition_revision: &str,
|
|
1798
|
+
environment: &str,
|
|
1799
|
+
input_hash: &str,
|
|
1800
|
+
capability_snapshot: &BTreeSet<String>,
|
|
1801
|
+
connection_scopes: &BTreeSet<String>,
|
|
1802
|
+
priority: i32,
|
|
1803
|
+
retry_policy: &WorkloadRetryPolicy,
|
|
1804
|
+
timeouts: &WorkloadTimeouts,
|
|
1805
|
+
dependencies: &[WorkloadDependency],
|
|
1806
|
+
not_before: Option<i64>,
|
|
1807
|
+
) -> Vec<(&'static str, Value)> {
|
|
1808
|
+
let mut dependencies = dependencies
|
|
1809
|
+
.iter()
|
|
1810
|
+
.map(|d| serde_json::json!([d.workload_id, d.required_state]))
|
|
1811
|
+
.collect::<Vec<_>>();
|
|
1812
|
+
dependencies.sort_by_key(|d| d.to_string());
|
|
1813
|
+
vec![
|
|
1814
|
+
("definition_id", definition_id.into()),
|
|
1815
|
+
("definition_revision", definition_revision.into()),
|
|
1816
|
+
("environment", environment.into()),
|
|
1817
|
+
("input", input_hash.into()),
|
|
1818
|
+
(
|
|
1819
|
+
"capability_snapshot",
|
|
1820
|
+
serde_json::json!(capability_snapshot),
|
|
1821
|
+
),
|
|
1822
|
+
("connection_scopes", serde_json::json!(connection_scopes)),
|
|
1823
|
+
("priority", priority.into()),
|
|
1824
|
+
("retry_policy", serde_json::json!(retry_policy)),
|
|
1825
|
+
("timeouts", serde_json::json!(timeouts)),
|
|
1826
|
+
("dependencies", Value::Array(dependencies)),
|
|
1827
|
+
("not_before", serde_json::json!(not_before)),
|
|
1828
|
+
]
|
|
1829
|
+
}
|
|
1830
|
+
/// The `not_before` a workload was admitted with, from its creation event
|
|
1831
|
+
/// (`next_attempt_at` moves with retries; the admission evidence does not).
|
|
1832
|
+
fn admitted_not_before(w: &Workload) -> Option<i64> {
|
|
1833
|
+
w.execution_history
|
|
1834
|
+
.iter()
|
|
1835
|
+
.find(|event| event.to == WorkloadState::Ready)
|
|
1836
|
+
.and_then(|event| event.evidence.get("not_before"))
|
|
1837
|
+
.and_then(|value| value.parse().ok())
|
|
1838
|
+
}
|
|
1440
1839
|
fn replay(existing: Workload, input: &CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
|
|
1441
|
-
let
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1840
|
+
let recorded = execution_fields(
|
|
1841
|
+
&existing.definition_id,
|
|
1842
|
+
&existing.definition_revision,
|
|
1843
|
+
&existing.environment,
|
|
1844
|
+
&existing.input_hash,
|
|
1845
|
+
&existing.capability_snapshot,
|
|
1846
|
+
&existing.connection_scopes,
|
|
1847
|
+
existing.priority,
|
|
1848
|
+
&existing.retry_policy,
|
|
1849
|
+
&existing.timeouts,
|
|
1850
|
+
&existing.dependencies,
|
|
1851
|
+
admitted_not_before(&existing),
|
|
1852
|
+
);
|
|
1853
|
+
let requested = execution_fields(
|
|
1854
|
+
&input.definition_id,
|
|
1855
|
+
&input.definition_revision,
|
|
1856
|
+
&input.environment,
|
|
1857
|
+
&hash(&input.input),
|
|
1858
|
+
&input.capability_snapshot,
|
|
1859
|
+
&input.connection_scopes,
|
|
1860
|
+
input.priority,
|
|
1861
|
+
&input.retry_policy,
|
|
1862
|
+
&input.timeouts,
|
|
1863
|
+
&input.dependencies,
|
|
1864
|
+
input.not_before,
|
|
1865
|
+
);
|
|
1866
|
+
let mut conflicting = recorded
|
|
1867
|
+
.iter()
|
|
1868
|
+
.zip(&requested)
|
|
1869
|
+
.filter(|((_, left), (_, right))| left != right)
|
|
1870
|
+
.map(|((name, _), _)| *name)
|
|
1871
|
+
.collect::<Vec<_>>();
|
|
1872
|
+
if existing.requested_by != input.requested_by {
|
|
1873
|
+
conflicting.insert(0, "requested_by");
|
|
1874
|
+
}
|
|
1875
|
+
if conflicting.is_empty() {
|
|
1876
|
+
return Ok((existing, false));
|
|
1877
|
+
}
|
|
1878
|
+
let mut error = WorkloadError::new(
|
|
1879
|
+
"IDEMPOTENCY_KEY_REUSED",
|
|
1880
|
+
"idempotency key was already used for a different request",
|
|
1881
|
+
);
|
|
1882
|
+
error.details = Some(serde_json::json!({ "fields": conflicting }));
|
|
1883
|
+
Err(error)
|
|
1884
|
+
}
|
|
1885
|
+
/// Seconds a millisecond timeout lasts, rounded up so a deadline is never early.
|
|
1886
|
+
fn deadline_seconds(ms: u64) -> i64 {
|
|
1887
|
+
ms.div_ceil(1000) as i64
|
|
1888
|
+
}
|
|
1889
|
+
/// The first deadline a workload has passed at `now`, and when it fell.
|
|
1890
|
+
///
|
|
1891
|
+
/// | Deadline | Measured from | While |
|
|
1892
|
+
/// | --- | --- | --- |
|
|
1893
|
+
/// | `overall` | admission (`created_at`) | not terminal |
|
|
1894
|
+
/// | `queue` | becoming `READY` (or its `next_attempt_at`, if later) | `READY` |
|
|
1895
|
+
/// | `claim` | the claim | `CLAIMED` |
|
|
1896
|
+
/// | `execution` | the attempt's start | `RUNNING`, `CHECKPOINTED` |
|
|
1897
|
+
/// | `heartbeat` | the last heartbeat (or the claim) | claimed |
|
|
1898
|
+
///
|
|
1899
|
+
/// Only workloads admitted at [`DEADLINE_CONTRACT_VERSION`] or later.
|
|
1900
|
+
pub fn deadline_breach(w: &Workload, now: i64) -> Option<(&'static str, i64)> {
|
|
1901
|
+
use WorkloadState::*;
|
|
1902
|
+
if w.contract_version < DEADLINE_CONTRACT_VERSION {
|
|
1903
|
+
return None;
|
|
1454
1904
|
}
|
|
1905
|
+
let active = matches!(w.state, Ready | Claimed | Running | Checkpointed);
|
|
1906
|
+
if !active {
|
|
1907
|
+
return None;
|
|
1908
|
+
}
|
|
1909
|
+
let t = &w.timeouts;
|
|
1910
|
+
let claim = w.current_claim.as_ref();
|
|
1911
|
+
let candidates = [
|
|
1912
|
+
(
|
|
1913
|
+
"overall",
|
|
1914
|
+
t.overall_ms.map(|ms| w.created_at + deadline_seconds(ms)),
|
|
1915
|
+
),
|
|
1916
|
+
(
|
|
1917
|
+
"queue",
|
|
1918
|
+
t.queue_ms.filter(|_| w.state == Ready).map(|ms| {
|
|
1919
|
+
let ready_since = w
|
|
1920
|
+
.execution_history
|
|
1921
|
+
.iter()
|
|
1922
|
+
.rev()
|
|
1923
|
+
.find(|event| event.to == Ready)
|
|
1924
|
+
.map_or(w.created_at, |event| event.at);
|
|
1925
|
+
ready_since.max(w.next_attempt_at.unwrap_or(i64::MIN)) + deadline_seconds(ms)
|
|
1926
|
+
}),
|
|
1927
|
+
),
|
|
1928
|
+
(
|
|
1929
|
+
"claim",
|
|
1930
|
+
t.claim_ms
|
|
1931
|
+
.filter(|_| w.state == Claimed)
|
|
1932
|
+
.and_then(|ms| claim.map(|c| c.claimed_at + deadline_seconds(ms))),
|
|
1933
|
+
),
|
|
1934
|
+
(
|
|
1935
|
+
"execution",
|
|
1936
|
+
t.execution_ms
|
|
1937
|
+
.filter(|_| matches!(w.state, Running | Checkpointed))
|
|
1938
|
+
.and_then(|ms| {
|
|
1939
|
+
w.attempts
|
|
1940
|
+
.last()
|
|
1941
|
+
.and_then(|a| a.started_at)
|
|
1942
|
+
.map(|started| started + deadline_seconds(ms))
|
|
1943
|
+
}),
|
|
1944
|
+
),
|
|
1945
|
+
(
|
|
1946
|
+
"heartbeat",
|
|
1947
|
+
t.heartbeat_ms
|
|
1948
|
+
.and_then(|ms| claim.map(|c| c.last_heartbeat + deadline_seconds(ms))),
|
|
1949
|
+
),
|
|
1950
|
+
];
|
|
1951
|
+
candidates
|
|
1952
|
+
.into_iter()
|
|
1953
|
+
.find_map(|(kind, at)| at.filter(|at| now >= *at).map(|at| (kind, at)))
|
|
1954
|
+
}
|
|
1955
|
+
/// The attempt this fence named, when that attempt has already reported its
|
|
1956
|
+
/// outcome (failed or succeeded). Lost and timed-out attempts are not reports.
|
|
1957
|
+
fn reported_attempt<'w>(
|
|
1958
|
+
w: &'w Workload,
|
|
1959
|
+
claim: &str,
|
|
1960
|
+
worker: &str,
|
|
1961
|
+
token: u64,
|
|
1962
|
+
) -> Option<&'w WorkloadAttempt> {
|
|
1963
|
+
w.attempts.iter().find(|attempt| {
|
|
1964
|
+
attempt.claim_id == claim
|
|
1965
|
+
&& attempt.worker_id == worker
|
|
1966
|
+
&& attempt.fencing_token == token
|
|
1967
|
+
&& matches!(
|
|
1968
|
+
attempt.status,
|
|
1969
|
+
AttemptStatus::Failed | AttemptStatus::Succeeded
|
|
1970
|
+
)
|
|
1971
|
+
})
|
|
1972
|
+
}
|
|
1973
|
+
/// The `FAILED` event a failure report of `attempt_id` recorded.
|
|
1974
|
+
fn failure_event<'w>(w: &'w Workload, attempt_id: &str) -> Option<&'w WorkloadEvent> {
|
|
1975
|
+
w.execution_history.iter().rev().find(|event| {
|
|
1976
|
+
event.to == WorkloadState::Failed
|
|
1977
|
+
&& event.evidence.get("attempt_id").map(String::as_str) == Some(attempt_id)
|
|
1978
|
+
})
|
|
1455
1979
|
}
|
|
1456
1980
|
/// Operator re-arms recorded in the workload's own history.
|
|
1457
1981
|
fn operator_retries(w: &Workload) -> u32 {
|
|
@@ -1700,7 +2224,11 @@ mod tests {
|
|
|
1700
2224
|
let id = store.create(create()).unwrap().workload_id;
|
|
1701
2225
|
let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
|
|
1702
2226
|
// A writer that loaded the workload before either recovery.
|
|
1703
|
-
|
|
2227
|
+
// Commits land in this projection's window between refresh and
|
|
2228
|
+
// commit: only the storage guard can refuse them.
|
|
2229
|
+
let mut stale = WorkloadStore::from_db(db.clone())
|
|
2230
|
+
.unwrap()
|
|
2231
|
+
.without_refresh();
|
|
1704
2232
|
|
|
1705
2233
|
let (mut first, mut second, recovered) = race_recovery(&db, claim.expires_at);
|
|
1706
2234
|
// Exactly one writer committed the recovery; the other reported none.
|
|
@@ -2001,7 +2529,11 @@ mod tests {
|
|
|
2001
2529
|
let caps = ["email:send".into()].into();
|
|
2002
2530
|
|
|
2003
2531
|
let mut winner = WorkloadStore::from_db(db.clone()).unwrap();
|
|
2004
|
-
|
|
2532
|
+
// The loser's claim races the winner's inside the window between its
|
|
2533
|
+
// refresh and its commit, which only the storage guard closes.
|
|
2534
|
+
let mut loser = WorkloadStore::from_db(db.clone())
|
|
2535
|
+
.unwrap()
|
|
2536
|
+
.without_refresh();
|
|
2005
2537
|
winner.claim(&id, "worker-a", &caps, 11, 1_000).unwrap();
|
|
2006
2538
|
let conflict = loser.claim(&id, "worker-b", &caps, 11, 1_000).unwrap_err();
|
|
2007
2539
|
assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
|
|
@@ -2023,7 +2555,9 @@ mod tests {
|
|
|
2023
2555
|
let mut recovery = WorkloadStore::from_db(restarted_db.clone()).unwrap();
|
|
2024
2556
|
recovery.recover_expired(13).unwrap();
|
|
2025
2557
|
let mut restarted_winner = WorkloadStore::from_db(restarted_db.clone()).unwrap();
|
|
2026
|
-
let mut restarted_loser = WorkloadStore::from_db(restarted_db.clone())
|
|
2558
|
+
let mut restarted_loser = WorkloadStore::from_db(restarted_db.clone())
|
|
2559
|
+
.unwrap()
|
|
2560
|
+
.without_refresh();
|
|
2027
2561
|
restarted_winner
|
|
2028
2562
|
.claim(&id, "worker-c", &caps, 13, 10_000)
|
|
2029
2563
|
.unwrap();
|
|
@@ -2370,52 +2904,182 @@ mod tests {
|
|
|
2370
2904
|
let _ = fs::remove_file(path);
|
|
2371
2905
|
}
|
|
2372
2906
|
#[test]
|
|
2373
|
-
fn
|
|
2374
|
-
let (path, mut store) = store("
|
|
2907
|
+
fn a_repeated_completion_replays_only_for_the_claim_and_output_that_produced_it() {
|
|
2908
|
+
let (path, mut store) = store("complete-replay");
|
|
2375
2909
|
let w = store.create(create()).unwrap();
|
|
2376
|
-
let
|
|
2377
|
-
let
|
|
2910
|
+
let id = w.workload_id.clone();
|
|
2911
|
+
let caps = capabilities();
|
|
2912
|
+
// Worker a loses its lease; worker b completes the work.
|
|
2913
|
+
let lost = store.claim(&id, "a", &caps, 11, 1000).unwrap();
|
|
2378
2914
|
store
|
|
2379
|
-
.start(
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2915
|
+
.start(&id, &lost.claim_id, "a", lost.fencing_token, 11)
|
|
2916
|
+
.unwrap();
|
|
2917
|
+
store.recover_expired(13).unwrap();
|
|
2918
|
+
let won = store.claim(&id, "b", &caps, 13, 10_000).unwrap();
|
|
2919
|
+
store
|
|
2920
|
+
.start(&id, &won.claim_id, "b", won.fencing_token, 13)
|
|
2921
|
+
.unwrap();
|
|
2922
|
+
let output = serde_json::json!({"sent": 1});
|
|
2923
|
+
let first = store
|
|
2924
|
+
.complete(
|
|
2925
|
+
&id,
|
|
2926
|
+
&won.claim_id,
|
|
2927
|
+
"b",
|
|
2928
|
+
won.fencing_token,
|
|
2929
|
+
"r1",
|
|
2930
|
+
output.clone(),
|
|
2931
|
+
vec![],
|
|
2932
|
+
1,
|
|
2933
|
+
14,
|
|
2934
|
+
)
|
|
2935
|
+
.unwrap();
|
|
2936
|
+
// The reporting attempt retrying after a lost response gets the same result.
|
|
2937
|
+
let replay = store
|
|
2938
|
+
.complete(
|
|
2939
|
+
&id,
|
|
2940
|
+
&won.claim_id,
|
|
2941
|
+
"b",
|
|
2942
|
+
won.fencing_token,
|
|
2943
|
+
"r1",
|
|
2944
|
+
output.clone(),
|
|
2945
|
+
vec![],
|
|
2946
|
+
1,
|
|
2947
|
+
15,
|
|
2385
2948
|
)
|
|
2386
2949
|
.unwrap();
|
|
2950
|
+
assert_eq!(replay.output_hash, first.output_hash);
|
|
2951
|
+
assert_eq!(replay.attempt_id, first.attempt_id);
|
|
2952
|
+
// The same result id with a different output is a conflict, not a replay.
|
|
2387
2953
|
assert_eq!(
|
|
2388
|
-
store
|
|
2389
|
-
|
|
2954
|
+
store
|
|
2955
|
+
.complete(
|
|
2956
|
+
&id,
|
|
2957
|
+
&won.claim_id,
|
|
2958
|
+
"b",
|
|
2959
|
+
won.fencing_token,
|
|
2960
|
+
"r1",
|
|
2961
|
+
serde_json::json!({"sent": 2}),
|
|
2962
|
+
vec![],
|
|
2963
|
+
1,
|
|
2964
|
+
15
|
|
2965
|
+
)
|
|
2966
|
+
.unwrap_err()
|
|
2967
|
+
.code,
|
|
2968
|
+
"WORKLOAD_RESULT_CONFLICT"
|
|
2390
2969
|
);
|
|
2970
|
+
// A stale worker naming the winner's result id learns nothing landed for it.
|
|
2391
2971
|
assert_eq!(
|
|
2392
2972
|
store
|
|
2393
2973
|
.complete(
|
|
2394
|
-
&
|
|
2395
|
-
&
|
|
2974
|
+
&id,
|
|
2975
|
+
&lost.claim_id,
|
|
2396
2976
|
"a",
|
|
2397
|
-
|
|
2398
|
-
"
|
|
2399
|
-
|
|
2977
|
+
lost.fencing_token,
|
|
2978
|
+
"r1",
|
|
2979
|
+
output.clone(),
|
|
2400
2980
|
vec![],
|
|
2401
2981
|
1,
|
|
2402
|
-
|
|
2982
|
+
15
|
|
2403
2983
|
)
|
|
2404
2984
|
.unwrap_err()
|
|
2405
2985
|
.code,
|
|
2406
|
-
"
|
|
2986
|
+
"WORKLOAD_RESULT_CONFLICT"
|
|
2407
2987
|
);
|
|
2988
|
+
let committed = store.get(&id).unwrap();
|
|
2989
|
+
assert_eq!(committed.state, WorkloadState::Succeeded);
|
|
2990
|
+
assert_eq!(committed.result.as_ref().unwrap().output, output);
|
|
2991
|
+
assert_eq!(committed.result.as_ref().unwrap().produced_by, "b");
|
|
2992
|
+
let _ = fs::remove_file(path);
|
|
2993
|
+
}
|
|
2994
|
+
#[test]
|
|
2995
|
+
fn a_resumed_attempt_reads_the_input_and_the_lost_attempts_checkpoints_through_its_fence() {
|
|
2996
|
+
let (path, mut store) = store("resume-context");
|
|
2997
|
+
let w = store.create(create()).unwrap();
|
|
2998
|
+
let id = w.workload_id.clone();
|
|
2999
|
+
let caps = capabilities();
|
|
3000
|
+
let lost = store.claim(&id, "a", &caps, 11, 1000).unwrap();
|
|
2408
3001
|
store
|
|
2409
|
-
.
|
|
2410
|
-
|
|
2411
|
-
|
|
3002
|
+
.start(&id, &lost.claim_id, "a", lost.fencing_token, 11)
|
|
3003
|
+
.unwrap();
|
|
3004
|
+
store
|
|
3005
|
+
.checkpoint(
|
|
3006
|
+
&id,
|
|
3007
|
+
&lost.claim_id,
|
|
2412
3008
|
"a",
|
|
2413
|
-
|
|
2414
|
-
|
|
3009
|
+
lost.fencing_token,
|
|
3010
|
+
serde_json::json!({"step": "reserve", "status": "done"}),
|
|
3011
|
+
11,
|
|
2415
3012
|
)
|
|
2416
3013
|
.unwrap();
|
|
3014
|
+
store.recover_expired(13).unwrap();
|
|
3015
|
+
let next = store.claim(&id, "b", &caps, 13, 10_000).unwrap();
|
|
3016
|
+
let context = store
|
|
3017
|
+
.execution_context(&id, &next.claim_id, "b", next.fencing_token, 13, vec![])
|
|
3018
|
+
.unwrap();
|
|
3019
|
+
assert_eq!(context.input, serde_json::json!({"id": 1}));
|
|
3020
|
+
assert_eq!(context.checkpoints.len(), 1);
|
|
3021
|
+
assert_eq!(context.checkpoints[0].state["step"], "reserve");
|
|
3022
|
+
assert_ne!(
|
|
3023
|
+
context.checkpoints[0].attempt_id, context.attempt_id,
|
|
3024
|
+
"written by the lost attempt"
|
|
3025
|
+
);
|
|
3026
|
+
// The lost worker's fence reads nothing.
|
|
2417
3027
|
assert_eq!(
|
|
2418
|
-
store
|
|
3028
|
+
store
|
|
3029
|
+
.execution_context(&id, &lost.claim_id, "a", lost.fencing_token, 13, vec![])
|
|
3030
|
+
.unwrap_err()
|
|
3031
|
+
.code,
|
|
3032
|
+
"WORKLOAD_STALE_FENCE"
|
|
3033
|
+
);
|
|
3034
|
+
let _ = fs::remove_file(path);
|
|
3035
|
+
}
|
|
3036
|
+
#[test]
|
|
3037
|
+
fn cancellation_race_and_duplicate_completion_are_deterministic() {
|
|
3038
|
+
let (path, mut store) = store("cancel");
|
|
3039
|
+
let w = store.create(create()).unwrap();
|
|
3040
|
+
let caps = ["email:send".into()].into();
|
|
3041
|
+
let claim = store.claim(&w.workload_id, "a", &caps, 11, 10_000).unwrap();
|
|
3042
|
+
store
|
|
3043
|
+
.start(
|
|
3044
|
+
&w.workload_id,
|
|
3045
|
+
&claim.claim_id,
|
|
3046
|
+
"a",
|
|
3047
|
+
claim.fencing_token,
|
|
3048
|
+
12,
|
|
3049
|
+
)
|
|
3050
|
+
.unwrap();
|
|
3051
|
+
assert_eq!(
|
|
3052
|
+
store.cancel(&w.workload_id, "user", 13).unwrap(),
|
|
3053
|
+
WorkloadState::CancellationRequested
|
|
3054
|
+
);
|
|
3055
|
+
assert_eq!(
|
|
3056
|
+
store
|
|
3057
|
+
.complete(
|
|
3058
|
+
&w.workload_id,
|
|
3059
|
+
&claim.claim_id,
|
|
3060
|
+
"a",
|
|
3061
|
+
claim.fencing_token,
|
|
3062
|
+
"r",
|
|
3063
|
+
Value::Null,
|
|
3064
|
+
vec![],
|
|
3065
|
+
1,
|
|
3066
|
+
14
|
|
3067
|
+
)
|
|
3068
|
+
.unwrap_err()
|
|
3069
|
+
.code,
|
|
3070
|
+
"WORKLOAD_TRANSITION_DENIED"
|
|
3071
|
+
);
|
|
3072
|
+
store
|
|
3073
|
+
.confirm_cancel(
|
|
3074
|
+
&w.workload_id,
|
|
3075
|
+
&claim.claim_id,
|
|
3076
|
+
"a",
|
|
3077
|
+
claim.fencing_token,
|
|
3078
|
+
14,
|
|
3079
|
+
)
|
|
3080
|
+
.unwrap();
|
|
3081
|
+
assert_eq!(
|
|
3082
|
+
store.get(&w.workload_id).unwrap().state,
|
|
2419
3083
|
WorkloadState::Cancelled
|
|
2420
3084
|
);
|
|
2421
3085
|
let _ = fs::remove_file(path);
|
|
@@ -2883,4 +3547,676 @@ mod tests {
|
|
|
2883
3547
|
);
|
|
2884
3548
|
let _ = fs::remove_file(path);
|
|
2885
3549
|
}
|
|
3550
|
+
|
|
3551
|
+
// ---- PR 2: authoritative reads, canonical admission, failure reports, deadlines ----
|
|
3552
|
+
|
|
3553
|
+
fn version_of(store: &WorkloadStore, id: &str) -> u64 {
|
|
3554
|
+
let db = store.db_for_tests();
|
|
3555
|
+
let w = store.get(id).unwrap();
|
|
3556
|
+
db.live_row(DURABLE_WORK_COLLECTION, &work_key(w))
|
|
3557
|
+
.and_then(|row| row.value.get("__version").and_then(Value::as_u64))
|
|
3558
|
+
.unwrap()
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
#[test]
|
|
3562
|
+
fn another_writers_commits_are_visible_and_acted_on_without_restart() {
|
|
3563
|
+
let (path, mut a) = store("two-writers");
|
|
3564
|
+
let mut b = WorkloadStore::from_db(a.db_for_tests()).unwrap();
|
|
3565
|
+
let id = a.create(create()).unwrap().workload_id;
|
|
3566
|
+
// B never saw the admission, yet reads it from the store.
|
|
3567
|
+
assert!(b.get(&id).is_err(), "the projection alone is stale");
|
|
3568
|
+
assert_eq!(b.fresh(&id).unwrap().state, WorkloadState::Ready);
|
|
3569
|
+
assert_eq!(b.fresh_list("t", "app").unwrap().len(), 1);
|
|
3570
|
+
// A claims; B's claim decides on the committed claim, not its own view.
|
|
3571
|
+
let claim = a
|
|
3572
|
+
.claim(&id, "worker-a", &capabilities(), 11, 10_000)
|
|
3573
|
+
.unwrap();
|
|
3574
|
+
assert_eq!(
|
|
3575
|
+
b.claim(&id, "worker-b", &capabilities(), 11, 10_000)
|
|
3576
|
+
.unwrap_err()
|
|
3577
|
+
.code,
|
|
3578
|
+
"WORKLOAD_NOT_READY"
|
|
3579
|
+
);
|
|
3580
|
+
// A transition committed through A is what B fences against.
|
|
3581
|
+
b.start(&id, &claim.claim_id, "worker-a", claim.fencing_token, 12)
|
|
3582
|
+
.unwrap();
|
|
3583
|
+
assert_eq!(a.fresh(&id).unwrap().state, WorkloadState::Running);
|
|
3584
|
+
// B replays an admission A committed, by key, from the store.
|
|
3585
|
+
let mut c = WorkloadStore::from_db(a.db_for_tests()).unwrap();
|
|
3586
|
+
c.workloads.clear();
|
|
3587
|
+
c.idempotency.clear();
|
|
3588
|
+
c.document_versions.clear();
|
|
3589
|
+
let (replayed, created) = c.admit(create()).unwrap();
|
|
3590
|
+
assert!(!created);
|
|
3591
|
+
assert_eq!(replayed.workload_id, id);
|
|
3592
|
+
let _ = fs::remove_file(path);
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
#[test]
|
|
3596
|
+
fn a_replay_matches_only_an_equivalent_request_and_names_every_conflicting_field() {
|
|
3597
|
+
let (path, mut store) = store("canonical-admission");
|
|
3598
|
+
let mut original = create();
|
|
3599
|
+
original.input = serde_json::json!({"a": 1, "b": 2});
|
|
3600
|
+
original.dependencies = vec![
|
|
3601
|
+
WorkloadDependency {
|
|
3602
|
+
workload_id: "w1".into(),
|
|
3603
|
+
required_state: WorkloadState::Succeeded,
|
|
3604
|
+
},
|
|
3605
|
+
WorkloadDependency {
|
|
3606
|
+
workload_id: "w2".into(),
|
|
3607
|
+
required_state: WorkloadState::Succeeded,
|
|
3608
|
+
},
|
|
3609
|
+
];
|
|
3610
|
+
let first = store.create(original.clone()).unwrap();
|
|
3611
|
+
|
|
3612
|
+
// Equivalent after normalization: member order, dependency order, the
|
|
3613
|
+
// defaults written out, and provenance that does not change what runs.
|
|
3614
|
+
let mut equivalent = original.clone();
|
|
3615
|
+
equivalent.input = serde_json::json!({"b": 2, "a": 1});
|
|
3616
|
+
equivalent.dependencies.reverse();
|
|
3617
|
+
equivalent.retry_policy = WorkloadRetryPolicy::default();
|
|
3618
|
+
equivalent.timeouts = WorkloadTimeouts::default();
|
|
3619
|
+
equivalent.priority = 0;
|
|
3620
|
+
equivalent.trigger = "retried-by-client".into();
|
|
3621
|
+
equivalent.session_id = Some("another-session".into());
|
|
3622
|
+
equivalent.created_at = 99;
|
|
3623
|
+
let (replayed, created) = store.admit(equivalent).unwrap();
|
|
3624
|
+
assert!(!created);
|
|
3625
|
+
assert_eq!(replayed.workload_id, first.workload_id);
|
|
3626
|
+
|
|
3627
|
+
let mutations: Vec<(&str, Box<dyn Fn(&mut CreateWorkload)>)> = vec![
|
|
3628
|
+
(
|
|
3629
|
+
"definition_id",
|
|
3630
|
+
Box::new(|r| r.definition_id = "other".into()),
|
|
3631
|
+
),
|
|
3632
|
+
(
|
|
3633
|
+
"definition_revision",
|
|
3634
|
+
Box::new(|r| r.definition_revision = "r2".into()),
|
|
3635
|
+
),
|
|
3636
|
+
(
|
|
3637
|
+
"environment",
|
|
3638
|
+
Box::new(|r| r.environment = "staging".into()),
|
|
3639
|
+
),
|
|
3640
|
+
(
|
|
3641
|
+
"input",
|
|
3642
|
+
Box::new(|r| r.input = serde_json::json!({"a": 1, "b": 3})),
|
|
3643
|
+
),
|
|
3644
|
+
(
|
|
3645
|
+
"capability_snapshot",
|
|
3646
|
+
Box::new(|r| {
|
|
3647
|
+
r.capability_snapshot.insert("sms:send".into());
|
|
3648
|
+
}),
|
|
3649
|
+
),
|
|
3650
|
+
(
|
|
3651
|
+
"connection_scopes",
|
|
3652
|
+
Box::new(|r| {
|
|
3653
|
+
r.connection_scopes.insert("crm".into());
|
|
3654
|
+
}),
|
|
3655
|
+
),
|
|
3656
|
+
("priority", Box::new(|r| r.priority = 5)),
|
|
3657
|
+
(
|
|
3658
|
+
"retry_policy",
|
|
3659
|
+
Box::new(|r| r.retry_policy.max_attempts = 9),
|
|
3660
|
+
),
|
|
3661
|
+
(
|
|
3662
|
+
"retry_policy",
|
|
3663
|
+
Box::new(|r| {
|
|
3664
|
+
r.retry_policy.retry_on.remove("worker_failure");
|
|
3665
|
+
}),
|
|
3666
|
+
),
|
|
3667
|
+
(
|
|
3668
|
+
"timeouts",
|
|
3669
|
+
Box::new(|r| r.timeouts.execution_ms = Some(60_000)),
|
|
3670
|
+
),
|
|
3671
|
+
(
|
|
3672
|
+
"dependencies",
|
|
3673
|
+
Box::new(|r| r.dependencies.pop().map(|_| ()).unwrap()),
|
|
3674
|
+
),
|
|
3675
|
+
("not_before", Box::new(|r| r.not_before = Some(5_000))),
|
|
3676
|
+
];
|
|
3677
|
+
for (field, mutate) in mutations {
|
|
3678
|
+
let mut request = original.clone();
|
|
3679
|
+
mutate(&mut request);
|
|
3680
|
+
let error = store.admit(request).unwrap_err();
|
|
3681
|
+
assert_eq!(error.code, "IDEMPOTENCY_KEY_REUSED", "{field}");
|
|
3682
|
+
assert_eq!(
|
|
3683
|
+
error.details.unwrap()["fields"],
|
|
3684
|
+
serde_json::json!([field]),
|
|
3685
|
+
"{field}"
|
|
3686
|
+
);
|
|
3687
|
+
}
|
|
3688
|
+
// Several at once are all named.
|
|
3689
|
+
let mut request = original.clone();
|
|
3690
|
+
request.priority = 1;
|
|
3691
|
+
request.environment = "staging".into();
|
|
3692
|
+
let fields = store.admit(request).unwrap_err().details.unwrap()["fields"].clone();
|
|
3693
|
+
assert_eq!(fields, serde_json::json!(["environment", "priority"]));
|
|
3694
|
+
|
|
3695
|
+
// After a restart: the same answers from the committed record.
|
|
3696
|
+
drop(store);
|
|
3697
|
+
let mut reopened = WorkloadStore::load(&path).unwrap();
|
|
3698
|
+
assert!(!reopened.admit(original.clone()).unwrap().1);
|
|
3699
|
+
let mut changed = original.clone();
|
|
3700
|
+
changed.retry_policy.initial_delay_ms = 5;
|
|
3701
|
+
assert_eq!(
|
|
3702
|
+
reopened.admit(changed).unwrap_err().code,
|
|
3703
|
+
"IDEMPOTENCY_KEY_REUSED"
|
|
3704
|
+
);
|
|
3705
|
+
assert_eq!(reopened.workloads.len(), 1, "nothing was created");
|
|
3706
|
+
let _ = fs::remove_file(path);
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
#[test]
|
|
3710
|
+
fn concurrent_conflicting_admissions_admit_one_and_refuse_the_other() {
|
|
3711
|
+
for round in 0..10 {
|
|
3712
|
+
let path = std::env::temp_dir().join(format!(
|
|
3713
|
+
"workload-admission-race-{round}-{}.log",
|
|
3714
|
+
std::process::id()
|
|
3715
|
+
));
|
|
3716
|
+
let _ = fs::remove_file(&path);
|
|
3717
|
+
let db = FeltDb::open(&path).unwrap();
|
|
3718
|
+
let stores = [
|
|
3719
|
+
WorkloadStore::from_db(db.clone()).unwrap(),
|
|
3720
|
+
WorkloadStore::from_db(db.clone()).unwrap(),
|
|
3721
|
+
];
|
|
3722
|
+
let barrier = std::sync::Barrier::new(2);
|
|
3723
|
+
let outcomes = std::thread::scope(|scope| {
|
|
3724
|
+
stores
|
|
3725
|
+
.into_iter()
|
|
3726
|
+
.enumerate()
|
|
3727
|
+
.map(|(i, mut store)| {
|
|
3728
|
+
let barrier = &barrier;
|
|
3729
|
+
scope.spawn(move || {
|
|
3730
|
+
let mut request = create();
|
|
3731
|
+
request.priority = i as i32;
|
|
3732
|
+
barrier.wait();
|
|
3733
|
+
store
|
|
3734
|
+
.admit(request)
|
|
3735
|
+
.map(|(w, created)| (w.priority, created))
|
|
3736
|
+
})
|
|
3737
|
+
})
|
|
3738
|
+
.collect::<Vec<_>>()
|
|
3739
|
+
.into_iter()
|
|
3740
|
+
.map(|handle| handle.join().unwrap())
|
|
3741
|
+
.collect::<Vec<_>>()
|
|
3742
|
+
});
|
|
3743
|
+
let admitted = outcomes
|
|
3744
|
+
.iter()
|
|
3745
|
+
.filter(|o| matches!(o, Ok((_, true))))
|
|
3746
|
+
.count();
|
|
3747
|
+
let refused = outcomes
|
|
3748
|
+
.iter()
|
|
3749
|
+
.filter(|o| {
|
|
3750
|
+
o.as_ref()
|
|
3751
|
+
.is_err_and(|e| e.code == "IDEMPOTENCY_KEY_REUSED")
|
|
3752
|
+
})
|
|
3753
|
+
.count();
|
|
3754
|
+
assert_eq!((admitted, refused), (1, 1), "round {round}: {outcomes:?}");
|
|
3755
|
+
let _ = fs::remove_file(path);
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
fn claimed(
|
|
3760
|
+
name: &str,
|
|
3761
|
+
policy: impl FnOnce(&mut CreateWorkload),
|
|
3762
|
+
) -> (PathBuf, WorkloadStore, String, WorkloadClaim) {
|
|
3763
|
+
let (path, mut store) = store(name);
|
|
3764
|
+
let mut request = create();
|
|
3765
|
+
policy(&mut request);
|
|
3766
|
+
let id = store.create(request).unwrap().workload_id;
|
|
3767
|
+
let claim = store.claim(&id, "a", &capabilities(), 11, 10_000).unwrap();
|
|
3768
|
+
(path, store, id, claim)
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
#[test]
|
|
3772
|
+
fn a_worker_reports_failure_from_claimed_fenced_and_by_policy() {
|
|
3773
|
+
// Could not start: a known failure before any effect, retried by kind.
|
|
3774
|
+
let (path, mut store, id, claim) = claimed("fail-claimed", |_| {});
|
|
3775
|
+
let state = store
|
|
3776
|
+
.fail_with_effect(
|
|
3777
|
+
&id,
|
|
3778
|
+
&claim.claim_id,
|
|
3779
|
+
"a",
|
|
3780
|
+
claim.fencing_token,
|
|
3781
|
+
"transient_connection",
|
|
3782
|
+
"no route",
|
|
3783
|
+
None,
|
|
3784
|
+
12,
|
|
3785
|
+
)
|
|
3786
|
+
.unwrap();
|
|
3787
|
+
assert_eq!(state, WorkloadState::Ready);
|
|
3788
|
+
let w = store.get(&id).unwrap();
|
|
3789
|
+
let failed = w
|
|
3790
|
+
.execution_history
|
|
3791
|
+
.iter()
|
|
3792
|
+
.find(|e| e.to == WorkloadState::Failed)
|
|
3793
|
+
.unwrap();
|
|
3794
|
+
assert_eq!(
|
|
3795
|
+
failed.evidence["effect"], "none",
|
|
3796
|
+
"never started, so no effect"
|
|
3797
|
+
);
|
|
3798
|
+
assert_eq!(w.attempts[0].status, AttemptStatus::Failed);
|
|
3799
|
+
// The identical report again (lost response) replays and commits nothing.
|
|
3800
|
+
let version = version_of(&store, &id);
|
|
3801
|
+
assert_eq!(
|
|
3802
|
+
store
|
|
3803
|
+
.fail_with_effect(
|
|
3804
|
+
&id,
|
|
3805
|
+
&claim.claim_id,
|
|
3806
|
+
"a",
|
|
3807
|
+
claim.fencing_token,
|
|
3808
|
+
"transient_connection",
|
|
3809
|
+
"no route",
|
|
3810
|
+
None,
|
|
3811
|
+
13
|
|
3812
|
+
)
|
|
3813
|
+
.unwrap(),
|
|
3814
|
+
WorkloadState::Ready
|
|
3815
|
+
);
|
|
3816
|
+
assert_eq!(version_of(&store, &id), version);
|
|
3817
|
+
// A different report from the same attempt is answered by its fence.
|
|
3818
|
+
assert_eq!(
|
|
3819
|
+
store
|
|
3820
|
+
.fail_with_effect(
|
|
3821
|
+
&id,
|
|
3822
|
+
&claim.claim_id,
|
|
3823
|
+
"a",
|
|
3824
|
+
claim.fencing_token,
|
|
3825
|
+
"timeout",
|
|
3826
|
+
"other",
|
|
3827
|
+
None,
|
|
3828
|
+
13
|
|
3829
|
+
)
|
|
3830
|
+
.unwrap_err()
|
|
3831
|
+
.code,
|
|
3832
|
+
"WORKLOAD_CLAIM_INVALID"
|
|
3833
|
+
);
|
|
3834
|
+
// So is a completion: the failure stands.
|
|
3835
|
+
assert_eq!(
|
|
3836
|
+
store
|
|
3837
|
+
.complete(
|
|
3838
|
+
&id,
|
|
3839
|
+
&claim.claim_id,
|
|
3840
|
+
"a",
|
|
3841
|
+
claim.fencing_token,
|
|
3842
|
+
"r",
|
|
3843
|
+
Value::Null,
|
|
3844
|
+
vec![],
|
|
3845
|
+
1,
|
|
3846
|
+
13
|
|
3847
|
+
)
|
|
3848
|
+
.unwrap_err()
|
|
3849
|
+
.code,
|
|
3850
|
+
"WORKLOAD_CLAIM_INVALID"
|
|
3851
|
+
);
|
|
3852
|
+
// Another worker's claim fences the old attempt out.
|
|
3853
|
+
let next = store.claim(&id, "b", &capabilities(), 14, 10_000).unwrap();
|
|
3854
|
+
assert_eq!(
|
|
3855
|
+
store
|
|
3856
|
+
.fail_with_effect(
|
|
3857
|
+
&id,
|
|
3858
|
+
&claim.claim_id,
|
|
3859
|
+
"a",
|
|
3860
|
+
claim.fencing_token,
|
|
3861
|
+
"timeout",
|
|
3862
|
+
"late",
|
|
3863
|
+
None,
|
|
3864
|
+
15
|
|
3865
|
+
)
|
|
3866
|
+
.unwrap_err()
|
|
3867
|
+
.code,
|
|
3868
|
+
"WORKLOAD_STALE_FENCE"
|
|
3869
|
+
);
|
|
3870
|
+
assert_eq!(
|
|
3871
|
+
store
|
|
3872
|
+
.get(&id)
|
|
3873
|
+
.unwrap()
|
|
3874
|
+
.current_claim
|
|
3875
|
+
.as_ref()
|
|
3876
|
+
.unwrap()
|
|
3877
|
+
.claim_id,
|
|
3878
|
+
next.claim_id
|
|
3879
|
+
);
|
|
3880
|
+
let _ = fs::remove_file(path);
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
#[test]
|
|
3884
|
+
fn failure_policy_decides_retry_and_an_unknown_effect_is_decided_like_a_lost_worker() {
|
|
3885
|
+
// A kind the policy does not retry dead-letters.
|
|
3886
|
+
let (path, mut store, id, claim) = claimed("fail-kind", |_| {});
|
|
3887
|
+
store
|
|
3888
|
+
.start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
|
|
3889
|
+
.unwrap();
|
|
3890
|
+
assert_eq!(
|
|
3891
|
+
store
|
|
3892
|
+
.fail_with_effect(
|
|
3893
|
+
&id,
|
|
3894
|
+
&claim.claim_id,
|
|
3895
|
+
"a",
|
|
3896
|
+
claim.fencing_token,
|
|
3897
|
+
"validation",
|
|
3898
|
+
"bad input",
|
|
3899
|
+
Some("none"),
|
|
3900
|
+
13
|
|
3901
|
+
)
|
|
3902
|
+
.unwrap(),
|
|
3903
|
+
WorkloadState::DeadLetter
|
|
3904
|
+
);
|
|
3905
|
+
assert_eq!(
|
|
3906
|
+
store
|
|
3907
|
+
.get(&id)
|
|
3908
|
+
.unwrap()
|
|
3909
|
+
.execution_history
|
|
3910
|
+
.last()
|
|
3911
|
+
.unwrap()
|
|
3912
|
+
.evidence["decision"],
|
|
3913
|
+
"failure_kind_not_retryable"
|
|
3914
|
+
);
|
|
3915
|
+
let _ = fs::remove_file(path);
|
|
3916
|
+
|
|
3917
|
+
// A retryable kind with an unknown effect, under a policy that does not
|
|
3918
|
+
// re-run lost work: not retried automatically.
|
|
3919
|
+
let (path, mut store, id, claim) = claimed("fail-unknown", |r| {
|
|
3920
|
+
r.retry_policy.retry_on = ["timeout".into(), "transient_connection".into()].into();
|
|
3921
|
+
});
|
|
3922
|
+
store
|
|
3923
|
+
.start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
|
|
3924
|
+
.unwrap();
|
|
3925
|
+
assert_eq!(
|
|
3926
|
+
store
|
|
3927
|
+
.fail_with_effect(
|
|
3928
|
+
&id,
|
|
3929
|
+
&claim.claim_id,
|
|
3930
|
+
"a",
|
|
3931
|
+
claim.fencing_token,
|
|
3932
|
+
"timeout",
|
|
3933
|
+
"charge sent, no answer",
|
|
3934
|
+
Some("unknown"),
|
|
3935
|
+
13
|
|
3936
|
+
)
|
|
3937
|
+
.unwrap(),
|
|
3938
|
+
WorkloadState::DeadLetter
|
|
3939
|
+
);
|
|
3940
|
+
let w = store.get(&id).unwrap();
|
|
3941
|
+
assert_eq!(
|
|
3942
|
+
w.execution_history.last().unwrap().evidence["decision"],
|
|
3943
|
+
"effect_unknown_not_retryable"
|
|
3944
|
+
);
|
|
3945
|
+
// An operator can still re-arm it, with a reason.
|
|
3946
|
+
store
|
|
3947
|
+
.retry_with_reason(&id, "operator", 14, Some("verified: not charged"))
|
|
3948
|
+
.unwrap();
|
|
3949
|
+
assert_eq!(store.get(&id).unwrap().state, WorkloadState::Ready);
|
|
3950
|
+
let _ = fs::remove_file(path);
|
|
3951
|
+
|
|
3952
|
+
// The same report under a policy that re-runs lost work is retried.
|
|
3953
|
+
let (path, mut store, id, claim) = claimed("fail-unknown-retry", |_| {});
|
|
3954
|
+
store
|
|
3955
|
+
.start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
|
|
3956
|
+
.unwrap();
|
|
3957
|
+
assert_eq!(
|
|
3958
|
+
store
|
|
3959
|
+
.fail_with_effect(
|
|
3960
|
+
&id,
|
|
3961
|
+
&claim.claim_id,
|
|
3962
|
+
"a",
|
|
3963
|
+
claim.fencing_token,
|
|
3964
|
+
"validation",
|
|
3965
|
+
"x",
|
|
3966
|
+
Some("unknown"),
|
|
3967
|
+
13
|
|
3968
|
+
)
|
|
3969
|
+
.unwrap(),
|
|
3970
|
+
WorkloadState::Ready
|
|
3971
|
+
);
|
|
3972
|
+
// An invalid effect is refused before anything changes.
|
|
3973
|
+
assert_eq!(
|
|
3974
|
+
store
|
|
3975
|
+
.fail_with_effect(&id, "c", "a", 9, "x", "y", Some("maybe"), 14)
|
|
3976
|
+
.unwrap_err()
|
|
3977
|
+
.code,
|
|
3978
|
+
"WORKLOAD_INVALID"
|
|
3979
|
+
);
|
|
3980
|
+
let _ = fs::remove_file(path);
|
|
3981
|
+
|
|
3982
|
+
// The attempt budget bounds retries.
|
|
3983
|
+
let (path, mut store, id, mut claim) =
|
|
3984
|
+
claimed("fail-budget", |r| r.retry_policy.max_attempts = 2);
|
|
3985
|
+
assert_eq!(
|
|
3986
|
+
store
|
|
3987
|
+
.fail(
|
|
3988
|
+
&id,
|
|
3989
|
+
&claim.claim_id,
|
|
3990
|
+
"a",
|
|
3991
|
+
claim.fencing_token,
|
|
3992
|
+
"timeout",
|
|
3993
|
+
"x",
|
|
3994
|
+
12
|
|
3995
|
+
)
|
|
3996
|
+
.unwrap(),
|
|
3997
|
+
WorkloadState::Ready
|
|
3998
|
+
);
|
|
3999
|
+
let next = store.get(&id).unwrap().next_attempt_at.unwrap();
|
|
4000
|
+
claim = store
|
|
4001
|
+
.claim(&id, "a", &capabilities(), next, 10_000)
|
|
4002
|
+
.unwrap();
|
|
4003
|
+
assert_eq!(
|
|
4004
|
+
store
|
|
4005
|
+
.fail(
|
|
4006
|
+
&id,
|
|
4007
|
+
&claim.claim_id,
|
|
4008
|
+
"a",
|
|
4009
|
+
claim.fencing_token,
|
|
4010
|
+
"timeout",
|
|
4011
|
+
"x",
|
|
4012
|
+
next
|
|
4013
|
+
)
|
|
4014
|
+
.unwrap(),
|
|
4015
|
+
WorkloadState::DeadLetter
|
|
4016
|
+
);
|
|
4017
|
+
assert_eq!(
|
|
4018
|
+
store
|
|
4019
|
+
.get(&id)
|
|
4020
|
+
.unwrap()
|
|
4021
|
+
.execution_history
|
|
4022
|
+
.last()
|
|
4023
|
+
.unwrap()
|
|
4024
|
+
.evidence["decision"],
|
|
4025
|
+
"attempt_budget_exhausted"
|
|
4026
|
+
);
|
|
4027
|
+
let _ = fs::remove_file(path);
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
#[test]
|
|
4031
|
+
fn failure_racing_completion_commits_exactly_one_outcome() {
|
|
4032
|
+
for completion_first in [true, false] {
|
|
4033
|
+
let (path, mut store, id, claim) = claimed(&format!("race-{completion_first}"), |_| {});
|
|
4034
|
+
store
|
|
4035
|
+
.start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
|
|
4036
|
+
.unwrap();
|
|
4037
|
+
let complete = |s: &mut WorkloadStore| {
|
|
4038
|
+
s.complete(
|
|
4039
|
+
&id,
|
|
4040
|
+
&claim.claim_id,
|
|
4041
|
+
"a",
|
|
4042
|
+
claim.fencing_token,
|
|
4043
|
+
"r",
|
|
4044
|
+
Value::Null,
|
|
4045
|
+
vec![],
|
|
4046
|
+
1,
|
|
4047
|
+
13,
|
|
4048
|
+
)
|
|
4049
|
+
.map(|_| ())
|
|
4050
|
+
};
|
|
4051
|
+
let fail = |s: &mut WorkloadStore| {
|
|
4052
|
+
s.fail(
|
|
4053
|
+
&id,
|
|
4054
|
+
&claim.claim_id,
|
|
4055
|
+
"a",
|
|
4056
|
+
claim.fencing_token,
|
|
4057
|
+
"timeout",
|
|
4058
|
+
"x",
|
|
4059
|
+
13,
|
|
4060
|
+
)
|
|
4061
|
+
.map(|_| ())
|
|
4062
|
+
};
|
|
4063
|
+
let (first, second) = if completion_first {
|
|
4064
|
+
(complete(&mut store), fail(&mut store))
|
|
4065
|
+
} else {
|
|
4066
|
+
(fail(&mut store), complete(&mut store))
|
|
4067
|
+
};
|
|
4068
|
+
assert!(first.is_ok());
|
|
4069
|
+
assert_eq!(second.unwrap_err().code, "WORKLOAD_CLAIM_INVALID");
|
|
4070
|
+
let w = store.get(&id).unwrap();
|
|
4071
|
+
assert_eq!(
|
|
4072
|
+
w.state,
|
|
4073
|
+
if completion_first {
|
|
4074
|
+
WorkloadState::Succeeded
|
|
4075
|
+
} else {
|
|
4076
|
+
WorkloadState::Ready
|
|
4077
|
+
}
|
|
4078
|
+
);
|
|
4079
|
+
assert_eq!(w.attempts.len(), 1);
|
|
4080
|
+
let _ = fs::remove_file(path);
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
#[test]
|
|
4085
|
+
fn deadlines_are_enforced_from_committed_timestamps_and_race_safely() {
|
|
4086
|
+
// execution: a heartbeat inside the deadline keeps the attempt; after it,
|
|
4087
|
+
// every worker call is refused and the attempt is timed out, once.
|
|
4088
|
+
let (path, mut store, id, claim) =
|
|
4089
|
+
claimed("deadline-exec", |r| r.timeouts.execution_ms = Some(5_000));
|
|
4090
|
+
store
|
|
4091
|
+
.start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
|
|
4092
|
+
.unwrap();
|
|
4093
|
+
store
|
|
4094
|
+
.heartbeat(&id, &claim.claim_id, "a", claim.fencing_token, 16, 10_000)
|
|
4095
|
+
.unwrap();
|
|
4096
|
+
assert!(store.enforce_deadlines(16).unwrap().is_empty());
|
|
4097
|
+
let refused = store
|
|
4098
|
+
.complete(
|
|
4099
|
+
&id,
|
|
4100
|
+
&claim.claim_id,
|
|
4101
|
+
"a",
|
|
4102
|
+
claim.fencing_token,
|
|
4103
|
+
"late",
|
|
4104
|
+
Value::Null,
|
|
4105
|
+
vec![],
|
|
4106
|
+
1,
|
|
4107
|
+
17,
|
|
4108
|
+
)
|
|
4109
|
+
.unwrap_err();
|
|
4110
|
+
assert_eq!(refused.code, "WORKLOAD_TIMED_OUT");
|
|
4111
|
+
assert_eq!(refused.details.unwrap()["deadline"], "execution");
|
|
4112
|
+
let w = store.get(&id).unwrap().clone();
|
|
4113
|
+
assert_eq!(
|
|
4114
|
+
w.state,
|
|
4115
|
+
WorkloadState::Ready,
|
|
4116
|
+
"timeout is retryable by default"
|
|
4117
|
+
);
|
|
4118
|
+
assert_eq!(w.attempts[0].status, AttemptStatus::TimedOut);
|
|
4119
|
+
assert!(w.result.is_none(), "a late completion never lands");
|
|
4120
|
+
assert!(
|
|
4121
|
+
store.enforce_deadlines(30).unwrap().is_empty(),
|
|
4122
|
+
"timed out once"
|
|
4123
|
+
);
|
|
4124
|
+
let _ = fs::remove_file(path);
|
|
4125
|
+
|
|
4126
|
+
// heartbeat: the gap since the last heartbeat; a heartbeat that commits
|
|
4127
|
+
// first moves the deadline, the recovery pass after it changes nothing.
|
|
4128
|
+
let (path, mut store, id, claim) = claimed("deadline-heartbeat", |r| {
|
|
4129
|
+
r.timeouts.heartbeat_ms = Some(3_000)
|
|
4130
|
+
});
|
|
4131
|
+
store
|
|
4132
|
+
.start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
|
|
4133
|
+
.unwrap();
|
|
4134
|
+
store
|
|
4135
|
+
.heartbeat(&id, &claim.claim_id, "a", claim.fencing_token, 13, 10_000)
|
|
4136
|
+
.unwrap();
|
|
4137
|
+
assert!(store.enforce_deadlines(15).unwrap().is_empty());
|
|
4138
|
+
assert_eq!(store.enforce_deadlines(16).unwrap(), vec![id.clone()]);
|
|
4139
|
+
assert_eq!(
|
|
4140
|
+
store
|
|
4141
|
+
.heartbeat(&id, &claim.claim_id, "a", claim.fencing_token, 16, 10_000)
|
|
4142
|
+
.unwrap_err()
|
|
4143
|
+
.code,
|
|
4144
|
+
"WORKLOAD_CLAIM_INVALID",
|
|
4145
|
+
"the timed-out claim is gone"
|
|
4146
|
+
);
|
|
4147
|
+
let _ = fs::remove_file(path);
|
|
4148
|
+
|
|
4149
|
+
// claim: claimed but never started; not retried when the policy says so.
|
|
4150
|
+
let (path, mut store, id, claim) = claimed("deadline-claim", |r| {
|
|
4151
|
+
r.timeouts.claim_ms = Some(2_000);
|
|
4152
|
+
r.retry_policy.retry_on = ["worker_failure".into()].into();
|
|
4153
|
+
});
|
|
4154
|
+
let refused = store
|
|
4155
|
+
.start(&id, &claim.claim_id, "a", claim.fencing_token, 13)
|
|
4156
|
+
.unwrap_err();
|
|
4157
|
+
assert_eq!(refused.code, "WORKLOAD_TIMED_OUT");
|
|
4158
|
+
let w = store.get(&id).unwrap();
|
|
4159
|
+
assert_eq!(w.state, WorkloadState::DeadLetter);
|
|
4160
|
+
assert_eq!(
|
|
4161
|
+
w.execution_history.last().unwrap().evidence["decision"],
|
|
4162
|
+
"timeout_not_retryable"
|
|
4163
|
+
);
|
|
4164
|
+
let _ = fs::remove_file(path);
|
|
4165
|
+
|
|
4166
|
+
// queue and overall bound the work: dead-lettered, never retried, and a
|
|
4167
|
+
// queued workload's previous attempt is not rewritten.
|
|
4168
|
+
let (path, mut store) = super::tests::store("deadline-queue");
|
|
4169
|
+
let mut request = create();
|
|
4170
|
+
request.timeouts.queue_ms = Some(4_000);
|
|
4171
|
+
let id = store.create(request).unwrap().workload_id;
|
|
4172
|
+
assert_eq!(
|
|
4173
|
+
store
|
|
4174
|
+
.claim(&id, "a", &capabilities(), 14, 10_000)
|
|
4175
|
+
.unwrap_err()
|
|
4176
|
+
.code,
|
|
4177
|
+
"WORKLOAD_TIMED_OUT"
|
|
4178
|
+
);
|
|
4179
|
+
let w = store.get(&id).unwrap();
|
|
4180
|
+
assert_eq!(w.state, WorkloadState::DeadLetter);
|
|
4181
|
+
assert_eq!(
|
|
4182
|
+
w.execution_history.last().unwrap().evidence["decision"],
|
|
4183
|
+
"deadline_not_retryable"
|
|
4184
|
+
);
|
|
4185
|
+
assert!(w.attempts.is_empty());
|
|
4186
|
+
let _ = fs::remove_file(path);
|
|
4187
|
+
|
|
4188
|
+
let (path, mut store, id, claim) =
|
|
4189
|
+
claimed("deadline-overall", |r| r.timeouts.overall_ms = Some(10_000));
|
|
4190
|
+
store
|
|
4191
|
+
.start(&id, &claim.claim_id, "a", claim.fencing_token, 12)
|
|
4192
|
+
.unwrap();
|
|
4193
|
+
// Restart: the deadline comes from the committed record.
|
|
4194
|
+
drop(store);
|
|
4195
|
+
let mut store = WorkloadStore::load(&path).unwrap();
|
|
4196
|
+
assert!(store.enforce_deadlines(19).unwrap().is_empty());
|
|
4197
|
+
assert_eq!(store.enforce_deadlines(20).unwrap(), vec![id.clone()]);
|
|
4198
|
+
assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
|
|
4199
|
+
let _ = fs::remove_file(path);
|
|
4200
|
+
}
|
|
4201
|
+
|
|
4202
|
+
#[test]
|
|
4203
|
+
fn deadlines_are_versioned_and_zero_is_refused() {
|
|
4204
|
+
let (path, mut store) = store("deadline-version");
|
|
4205
|
+
let mut request = create();
|
|
4206
|
+
request.timeouts.queue_ms = Some(0);
|
|
4207
|
+
assert_eq!(store.create(request).unwrap_err().code, "WORKLOAD_INVALID");
|
|
4208
|
+
// A record admitted before deadlines were enforced keeps its contract.
|
|
4209
|
+
let mut request = create();
|
|
4210
|
+
request.timeouts.queue_ms = Some(1_000);
|
|
4211
|
+
let id = store.create(request).unwrap().workload_id;
|
|
4212
|
+
assert_eq!(
|
|
4213
|
+
store.get(&id).unwrap().contract_version,
|
|
4214
|
+
DEADLINE_CONTRACT_VERSION
|
|
4215
|
+
);
|
|
4216
|
+
let mut legacy = store.get(&id).unwrap().clone();
|
|
4217
|
+
legacy.contract_version = 1;
|
|
4218
|
+
assert_eq!(deadline_breach(&legacy, 1_000_000), None);
|
|
4219
|
+
assert!(deadline_breach(store.get(&id).unwrap(), 1_000_000).is_some());
|
|
4220
|
+
let _ = fs::remove_file(path);
|
|
4221
|
+
}
|
|
2886
4222
|
}
|