@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
|
@@ -1061,6 +1061,13 @@ impl StateFailure {
|
|
|
1061
1061
|
}
|
|
1062
1062
|
}
|
|
1063
1063
|
|
|
1064
|
+
fn idempotency_conflict() -> StateFailure {
|
|
1065
|
+
StateFailure::new(
|
|
1066
|
+
"IDEMPOTENCY_CONFLICT",
|
|
1067
|
+
"transaction ID was already committed with a different canonical payload",
|
|
1068
|
+
)
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1064
1071
|
fn collection<'a>(
|
|
1065
1072
|
schema: &'a StateSchema,
|
|
1066
1073
|
name: &str,
|
|
@@ -2478,53 +2485,114 @@ fn execute_transaction_with_policies(
|
|
|
2478
2485
|
transaction_id
|
|
2479
2486
|
);
|
|
2480
2487
|
let payload_hash = format!("sha256:{:x}", Sha256::digest(&canonical));
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
"transaction ID was already committed with a different canonical payload",
|
|
2494
|
-
));
|
|
2495
|
-
}
|
|
2496
|
-
let state = db.sequence().map_err(StateFailure::storage)?;
|
|
2497
|
-
let cursor = db
|
|
2498
|
-
.operation_versions()
|
|
2499
|
-
.map_err(StateFailure::storage)?
|
|
2500
|
-
.into_iter()
|
|
2501
|
-
.collect();
|
|
2502
|
-
let audit = TransactionAudit {
|
|
2503
|
-
transaction_id: transaction_id.clone(),
|
|
2504
|
-
subject: request.authorization.subject.clone(),
|
|
2505
|
-
tenant: request.tenant_id.clone(),
|
|
2506
|
-
application: request.application_id.clone(),
|
|
2507
|
-
revision: request.revision_id.clone(),
|
|
2508
|
-
schema_version: request.schema_version,
|
|
2509
|
-
state_before: state,
|
|
2510
|
-
state_after: state,
|
|
2511
|
-
operations: request.operations.len(),
|
|
2512
|
-
authorization_decisions: vec!["state:write allowed".into()],
|
|
2513
|
-
causal_parent: request.causal_parent,
|
|
2514
|
-
result: "duplicate".into(),
|
|
2515
|
-
};
|
|
2516
|
-
return Ok(TransactionResult {
|
|
2517
|
-
transaction_id,
|
|
2518
|
-
base_revision: db.current_revision().map_err(StateFailure::storage)?,
|
|
2519
|
-
commit_revision: db.current_revision().map_err(StateFailure::storage)?,
|
|
2520
|
-
status: crate::TransactionStatus::Committed,
|
|
2521
|
-
state_before: state,
|
|
2522
|
-
state_after: state,
|
|
2523
|
-
causal_cursor: cursor,
|
|
2524
|
-
duplicate: true,
|
|
2525
|
-
audit,
|
|
2526
|
-
});
|
|
2488
|
+
// A retried id is answered before anything is evaluated against current
|
|
2489
|
+
// state, with the original commit: its revisions and positions, not the
|
|
2490
|
+
// store's current ones. The same answer the commit lock gives a retry that
|
|
2491
|
+
// races past this check.
|
|
2492
|
+
if let Some(result) = committed_result(
|
|
2493
|
+
db,
|
|
2494
|
+
request,
|
|
2495
|
+
&transaction_id,
|
|
2496
|
+
&storage_transaction_id,
|
|
2497
|
+
&payload_hash,
|
|
2498
|
+
)? {
|
|
2499
|
+
return Ok(result);
|
|
2527
2500
|
}
|
|
2501
|
+
match commit_new_transaction(
|
|
2502
|
+
db,
|
|
2503
|
+
schema,
|
|
2504
|
+
request,
|
|
2505
|
+
uniform_write_policy,
|
|
2506
|
+
collection_write_policies,
|
|
2507
|
+
&transaction_id,
|
|
2508
|
+
&storage_transaction_id,
|
|
2509
|
+
&payload_hash,
|
|
2510
|
+
) {
|
|
2511
|
+
Ok(result) => Ok(result),
|
|
2512
|
+
// The request was evaluated against state read before the commit
|
|
2513
|
+
// lock. An identical request that committed in between makes this one
|
|
2514
|
+
// its duplicate, and the duplicate -- the original commit -- is the
|
|
2515
|
+
// answer, not the refusal that later state produced (a create that
|
|
2516
|
+
// now "exists", a fence that now "changed").
|
|
2517
|
+
Err(failure) => match committed_result(
|
|
2518
|
+
db,
|
|
2519
|
+
request,
|
|
2520
|
+
&transaction_id,
|
|
2521
|
+
&storage_transaction_id,
|
|
2522
|
+
&payload_hash,
|
|
2523
|
+
)? {
|
|
2524
|
+
Some(result) => Ok(result),
|
|
2525
|
+
None => Err(failure),
|
|
2526
|
+
},
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/// A retried id, answered with the original commit: its revisions and
|
|
2531
|
+
/// positions, never the store's current ones. `IDEMPOTENCY_CONFLICT` when the id
|
|
2532
|
+
/// committed with a different payload; `None` when it has not committed.
|
|
2533
|
+
fn committed_result(
|
|
2534
|
+
db: &FeltDb,
|
|
2535
|
+
request: &TransactionRequest,
|
|
2536
|
+
transaction_id: &str,
|
|
2537
|
+
storage_transaction_id: &str,
|
|
2538
|
+
payload_hash: &str,
|
|
2539
|
+
) -> Result<Option<TransactionResult>, StateFailure> {
|
|
2540
|
+
let Some(original) = db
|
|
2541
|
+
.committed_transaction(storage_transaction_id, Some(payload_hash))
|
|
2542
|
+
.map_err(|error| match error {
|
|
2543
|
+
crate::FlowError::TransactionIdPayloadMismatch { .. } => idempotency_conflict(),
|
|
2544
|
+
other => StateFailure::storage(other),
|
|
2545
|
+
})?
|
|
2546
|
+
else {
|
|
2547
|
+
return Ok(None);
|
|
2548
|
+
};
|
|
2549
|
+
let cursor = db
|
|
2550
|
+
.operation_versions()
|
|
2551
|
+
.map_err(StateFailure::storage)?
|
|
2552
|
+
.into_iter()
|
|
2553
|
+
.collect();
|
|
2554
|
+
let audit = TransactionAudit {
|
|
2555
|
+
transaction_id: transaction_id.to_string(),
|
|
2556
|
+
subject: request.authorization.subject.clone(),
|
|
2557
|
+
tenant: request.tenant_id.clone(),
|
|
2558
|
+
application: request.application_id.clone(),
|
|
2559
|
+
revision: request.revision_id.clone(),
|
|
2560
|
+
schema_version: request.schema_version,
|
|
2561
|
+
state_before: original.state_before,
|
|
2562
|
+
state_after: original.state_after,
|
|
2563
|
+
operations: request.operations.len(),
|
|
2564
|
+
authorization_decisions: vec!["state:write allowed".into()],
|
|
2565
|
+
causal_parent: request.causal_parent,
|
|
2566
|
+
result: "duplicate".into(),
|
|
2567
|
+
};
|
|
2568
|
+
Ok(Some(TransactionResult {
|
|
2569
|
+
transaction_id: transaction_id.to_string(),
|
|
2570
|
+
base_revision: original.base_revision,
|
|
2571
|
+
commit_revision: original.commit_revision,
|
|
2572
|
+
status: original.status,
|
|
2573
|
+
state_before: original.state_before,
|
|
2574
|
+
state_after: original.state_after,
|
|
2575
|
+
causal_cursor: cursor,
|
|
2576
|
+
duplicate: true,
|
|
2577
|
+
audit,
|
|
2578
|
+
}))
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/// Evaluate and commit a transaction id that has not committed.
|
|
2582
|
+
#[allow(clippy::too_many_arguments)]
|
|
2583
|
+
fn commit_new_transaction(
|
|
2584
|
+
db: &FeltDb,
|
|
2585
|
+
schema: &StateSchema,
|
|
2586
|
+
request: &TransactionRequest,
|
|
2587
|
+
uniform_write_policy: Option<&PolicySubject>,
|
|
2588
|
+
collection_write_policies: Option<&BTreeMap<String, PolicySubject>>,
|
|
2589
|
+
transaction_id: &str,
|
|
2590
|
+
storage_transaction_id: &str,
|
|
2591
|
+
payload_hash: &str,
|
|
2592
|
+
) -> Result<TransactionResult, StateFailure> {
|
|
2593
|
+
let transaction_id = transaction_id.to_string();
|
|
2594
|
+
let storage_transaction_id = storage_transaction_id.to_string();
|
|
2595
|
+
let payload_hash = payload_hash.to_string();
|
|
2528
2596
|
let namespace = request
|
|
2529
2597
|
.state_namespace
|
|
2530
2598
|
.as_deref()
|
|
@@ -2753,7 +2821,19 @@ fn execute_transaction_with_policies(
|
|
|
2753
2821
|
let value = match operation.kind {
|
|
2754
2822
|
TransactionOperationKind::Insert => {
|
|
2755
2823
|
if existing_value.is_some() {
|
|
2756
|
-
|
|
2824
|
+
// A create is conditional on absence: the same structured
|
|
2825
|
+
// refusal a `requireAbsent` create gets on every route.
|
|
2826
|
+
let refusal = crate::PreconditionFailure::Present {
|
|
2827
|
+
collection: capability.clone(),
|
|
2828
|
+
key: key.clone(),
|
|
2829
|
+
};
|
|
2830
|
+
let mut failure =
|
|
2831
|
+
StateFailure::new("PRECONDITION_FAILED", &refusal.to_string());
|
|
2832
|
+
failure.transaction = Some(transaction_id.clone());
|
|
2833
|
+
failure.resource = Some(format!("{}:{}", operation.collection, key));
|
|
2834
|
+
failure.failure =
|
|
2835
|
+
Some(refusal.public_json(&crate::ReadLayout::Namespace(namespace.clone())));
|
|
2836
|
+
return Err(failure);
|
|
2757
2837
|
}
|
|
2758
2838
|
let mut value = operation.value.clone();
|
|
2759
2839
|
match definition {
|
|
@@ -2924,12 +3004,18 @@ fn execute_transaction_with_policies(
|
|
|
2924
3004
|
// "this deployment is broken", so it is carried through as
|
|
2925
3005
|
// structure rather than flattened into the message.
|
|
2926
3006
|
if let FlowError::PreconditionFailed(refusal) = &error {
|
|
3007
|
+
let layout = crate::ReadLayout::Namespace(namespace.clone());
|
|
3008
|
+
let public = refusal.public_json(&layout);
|
|
2927
3009
|
let mut failure =
|
|
2928
3010
|
StateFailure::new("PRECONDITION_FAILED", &error.to_string());
|
|
2929
3011
|
failure.transaction = Some(transaction_id.clone());
|
|
2930
3012
|
failure.causal_position = db.sequence().ok();
|
|
2931
|
-
failure.resource =
|
|
2932
|
-
|
|
3013
|
+
failure.resource = public
|
|
3014
|
+
.get("key")
|
|
3015
|
+
.and_then(Value::as_str)
|
|
3016
|
+
.map(str::to_owned)
|
|
3017
|
+
.or_else(|| Some(refusal.key().to_string()));
|
|
3018
|
+
failure.failure = Some(public);
|
|
2933
3019
|
if let PreconditionFailure::Version {
|
|
2934
3020
|
expected, actual, ..
|
|
2935
3021
|
} = refusal.as_ref()
|
|
@@ -2949,6 +3035,21 @@ fn execute_transaction_with_policies(
|
|
|
2949
3035
|
failure.actual = Some(Value::from(*actual));
|
|
2950
3036
|
return failure;
|
|
2951
3037
|
}
|
|
3038
|
+
if let FlowError::TransactionIdPayloadMismatch { .. } = &error {
|
|
3039
|
+
// Two different payloads raced past the check above under one
|
|
3040
|
+
// id; the commit lock admitted the first. Same refusal.
|
|
3041
|
+
let mut failure = idempotency_conflict();
|
|
3042
|
+
failure.transaction = Some(transaction_id.clone());
|
|
3043
|
+
return failure;
|
|
3044
|
+
}
|
|
3045
|
+
if let FlowError::Io(_) | FlowError::Serde(_) = &error {
|
|
3046
|
+
// The commit could not be made durable. Never a lost race and
|
|
3047
|
+
// never a success: the outcome must be treated as unknown and
|
|
3048
|
+
// reconciled by retrying with the same transaction id.
|
|
3049
|
+
let mut failure = StateFailure::new("DURABILITY_FAILURE", &error.to_string());
|
|
3050
|
+
failure.transaction = Some(transaction_id.clone());
|
|
3051
|
+
return failure;
|
|
3052
|
+
}
|
|
2952
3053
|
let text = error.to_string();
|
|
2953
3054
|
if text.contains("INVALID_READ_SET:") {
|
|
2954
3055
|
// A malformed or foreign basis is a caller error, never a lost race.
|
|
@@ -3262,11 +3363,19 @@ mod tests {
|
|
|
3262
3363
|
let mut stale = request;
|
|
3263
3364
|
stale.transaction_id = Some("stale".into());
|
|
3264
3365
|
stale.causal_parent = Some(0);
|
|
3366
|
+
// A create of a record that exists is refused as a structured presence
|
|
3367
|
+
// precondition, as a `requireAbsent` create is on every other route.
|
|
3368
|
+
let refused = execute_transaction(&db, &schema, &stale, None).unwrap_err();
|
|
3369
|
+
assert_eq!(refused.code, "PRECONDITION_FAILED");
|
|
3370
|
+
let failure = refused.failure.expect("structured failure");
|
|
3371
|
+
assert_eq!(failure["predicate"], "present");
|
|
3372
|
+
assert_eq!(failure["collection"], "incidents");
|
|
3373
|
+
assert_eq!(failure["id"], "one");
|
|
3374
|
+
assert_eq!(failure["key"], "incidents:one");
|
|
3265
3375
|
assert_eq!(
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
"CONFLICT"
|
|
3376
|
+
db.export_snapshot().unwrap().rows.len(),
|
|
3377
|
+
2,
|
|
3378
|
+
"nothing written"
|
|
3270
3379
|
);
|
|
3271
3380
|
}
|
|
3272
3381
|
/// A conditional write through the canonical contract has to mean the same
|
|
@@ -486,6 +486,88 @@ impl WorkerMeshStore {
|
|
|
486
486
|
}
|
|
487
487
|
self.persist()
|
|
488
488
|
}
|
|
489
|
+
/// Recover work whose claim lease expired, and release those claims from
|
|
490
|
+
/// the workers that held them so their capacity is not held by work they
|
|
491
|
+
/// no longer own. Recovery itself — `WorkerLost`, the attempt budget, the
|
|
492
|
+
/// decision — is `WorkloadStore::recover_expired`. Worker liveness is not
|
|
493
|
+
/// judged here; that is `detect_failures`.
|
|
494
|
+
/// Time out work past a deadline ([`WorkloadStore::enforce_deadlines`]) and
|
|
495
|
+
/// release the claims the committed workloads no longer hold, as
|
|
496
|
+
/// [`Self::recover_expired_work`] does for expired leases.
|
|
497
|
+
pub fn enforce_deadlines(
|
|
498
|
+
&mut self,
|
|
499
|
+
now: i64,
|
|
500
|
+
workloads: &mut WorkloadStore,
|
|
501
|
+
) -> Result<Vec<String>, WorkerMeshError> {
|
|
502
|
+
// The claims this node's projection knows, listed before the store is
|
|
503
|
+
// re-read: a claim another writer already ended must still be released
|
|
504
|
+
// here, so the list cannot come from the refreshed projection.
|
|
505
|
+
let claimed: Vec<(String, String, String)> = workloads
|
|
506
|
+
.workloads
|
|
507
|
+
.values()
|
|
508
|
+
.filter_map(|w| {
|
|
509
|
+
w.current_claim.as_ref().map(|claim| {
|
|
510
|
+
(
|
|
511
|
+
w.workload_id.clone(),
|
|
512
|
+
claim.worker_id.clone(),
|
|
513
|
+
claim.claim_id.clone(),
|
|
514
|
+
)
|
|
515
|
+
})
|
|
516
|
+
})
|
|
517
|
+
.collect();
|
|
518
|
+
let expired = workloads.enforce_deadlines(now)?;
|
|
519
|
+
for (workload_id, worker_id, claim_id) in claimed {
|
|
520
|
+
let still_current = workloads
|
|
521
|
+
.workloads
|
|
522
|
+
.get(&workload_id)
|
|
523
|
+
.and_then(|w| w.current_claim.as_ref())
|
|
524
|
+
.is_some_and(|claim| claim.claim_id == claim_id);
|
|
525
|
+
// Releasing is idempotent: a claim the worker no longer holds is a no-op.
|
|
526
|
+
if !still_current && self.workers.contains_key(&worker_id) {
|
|
527
|
+
self.release_claim(&worker_id, &claim_id)?;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
Ok(expired)
|
|
531
|
+
}
|
|
532
|
+
pub fn recover_expired_work(
|
|
533
|
+
&mut self,
|
|
534
|
+
now: i64,
|
|
535
|
+
workloads: &mut WorkloadStore,
|
|
536
|
+
) -> Result<Vec<String>, WorkerMeshError> {
|
|
537
|
+
// Listed before `recover_expired` re-reads the store, for the same
|
|
538
|
+
// reason as in `enforce_deadlines`.
|
|
539
|
+
let expired: Vec<(String, String, String)> = workloads
|
|
540
|
+
.workloads
|
|
541
|
+
.values()
|
|
542
|
+
.filter_map(|w| w.current_claim.as_ref().map(|claim| (w, claim)))
|
|
543
|
+
.filter(|(_, claim)| claim.expires_at <= now)
|
|
544
|
+
.map(|(w, claim)| {
|
|
545
|
+
(
|
|
546
|
+
w.workload_id.clone(),
|
|
547
|
+
claim.worker_id.clone(),
|
|
548
|
+
claim.claim_id.clone(),
|
|
549
|
+
)
|
|
550
|
+
})
|
|
551
|
+
.collect();
|
|
552
|
+
let recovered = workloads.recover_expired(now)?;
|
|
553
|
+
for (workload_id, worker_id, claim_id) in expired {
|
|
554
|
+
// Release a claim only once the committed workload no longer holds
|
|
555
|
+
// it, whichever writer of the store recovered it.
|
|
556
|
+
let still_current = workloads
|
|
557
|
+
.workloads
|
|
558
|
+
.get(&workload_id)
|
|
559
|
+
.and_then(|w| w.current_claim.as_ref())
|
|
560
|
+
.is_some_and(|claim| claim.claim_id == claim_id);
|
|
561
|
+
let held = self
|
|
562
|
+
.workers
|
|
563
|
+
.get(&worker_id)
|
|
564
|
+
.is_some_and(|worker| worker.claims.contains(&claim_id));
|
|
565
|
+
if held && !still_current {
|
|
566
|
+
self.release_claim(&worker_id, &claim_id)?;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
Ok(recovered)
|
|
570
|
+
}
|
|
489
571
|
pub fn detect_failures(
|
|
490
572
|
&mut self,
|
|
491
573
|
now: i64,
|
|
@@ -507,7 +589,7 @@ impl WorkerMeshStore {
|
|
|
507
589
|
worker.claims.clear();
|
|
508
590
|
}
|
|
509
591
|
}
|
|
510
|
-
|
|
592
|
+
self.recover_expired_work(now, workloads)?;
|
|
511
593
|
self.persist()?;
|
|
512
594
|
Ok(failed)
|
|
513
595
|
}
|
|
@@ -603,7 +685,10 @@ impl WorkerMeshStore {
|
|
|
603
685
|
.workers
|
|
604
686
|
.get_mut(worker_id)
|
|
605
687
|
.ok_or_else(|| WorkerMeshError::new("WORKER_NOT_FOUND", "worker not found"))?;
|
|
606
|
-
worker.
|
|
688
|
+
// Releasing is idempotent: only a claim the worker still holds frees capacity.
|
|
689
|
+
if !worker.claims.remove(claim_id) {
|
|
690
|
+
return Ok(());
|
|
691
|
+
}
|
|
607
692
|
worker.capacity.active = worker.capacity.active.saturating_sub(1);
|
|
608
693
|
if worker.lifecycle == WorkerLifecycle::Draining && worker.claims.is_empty() {
|
|
609
694
|
worker.lifecycle = WorkerLifecycle::Offline
|
|
@@ -852,6 +937,260 @@ mod tests {
|
|
|
852
937
|
let _ = fs::remove_file(w);
|
|
853
938
|
}
|
|
854
939
|
#[test]
|
|
940
|
+
fn releasing_a_claim_twice_frees_capacity_once() {
|
|
941
|
+
let (m, wpath, mut mesh, mut workloads, grant) = setup("release-twice");
|
|
942
|
+
let worker = mesh
|
|
943
|
+
.register(WorkerRegistration {
|
|
944
|
+
tenant_id: "t".into(),
|
|
945
|
+
application_id: "a".into(),
|
|
946
|
+
name: "edge".into(),
|
|
947
|
+
runtime: WorkerRuntimeKind::Customer,
|
|
948
|
+
version: "1".into(),
|
|
949
|
+
capabilities: ["documents.extract".into()].into(),
|
|
950
|
+
workload_kinds: BTreeSet::new(),
|
|
951
|
+
resource_profile: Default::default(),
|
|
952
|
+
labels: BTreeMap::new(),
|
|
953
|
+
capacity: WorkerCapacity {
|
|
954
|
+
concurrency: 2,
|
|
955
|
+
active: 0,
|
|
956
|
+
},
|
|
957
|
+
registered_by: Subject::Service("registrar".into()),
|
|
958
|
+
authorization_grant: grant.clone(),
|
|
959
|
+
registered_at: 10,
|
|
960
|
+
})
|
|
961
|
+
.unwrap();
|
|
962
|
+
mesh.heartbeat(
|
|
963
|
+
&worker.worker_id,
|
|
964
|
+
WorkerHeartbeat {
|
|
965
|
+
worker_id: worker.worker_id.clone(),
|
|
966
|
+
version: "1".into(),
|
|
967
|
+
capability_version: 1,
|
|
968
|
+
capacity: WorkerCapacity {
|
|
969
|
+
concurrency: 2,
|
|
970
|
+
active: 0,
|
|
971
|
+
},
|
|
972
|
+
health: WorkerHealth::Healthy,
|
|
973
|
+
timestamp: 11,
|
|
974
|
+
running_claims: BTreeSet::new(),
|
|
975
|
+
},
|
|
976
|
+
2,
|
|
977
|
+
)
|
|
978
|
+
.unwrap();
|
|
979
|
+
let subject = grant.grant.subject.clone();
|
|
980
|
+
let create = |key: &str| CreateWorkload {
|
|
981
|
+
tenant_id: "t".into(),
|
|
982
|
+
application_id: "a".into(),
|
|
983
|
+
environment: "production".into(),
|
|
984
|
+
definition_id: "extract".into(),
|
|
985
|
+
definition_revision: "r".into(),
|
|
986
|
+
trigger: "manual".into(),
|
|
987
|
+
input: serde_json::json!({"file": key}),
|
|
988
|
+
requested_by: subject.clone(),
|
|
989
|
+
session_id: None,
|
|
990
|
+
authorization_snapshot: grant.clone(),
|
|
991
|
+
capability_snapshot: ["documents.extract".into()].into(),
|
|
992
|
+
connection_scopes: BTreeSet::new(),
|
|
993
|
+
priority: 0,
|
|
994
|
+
idempotency_key: key.into(),
|
|
995
|
+
retry_policy: WorkloadRetryPolicy::default(),
|
|
996
|
+
timeouts: WorkloadTimeouts::default(),
|
|
997
|
+
dependencies: vec![],
|
|
998
|
+
not_before: None,
|
|
999
|
+
created_at: 11,
|
|
1000
|
+
};
|
|
1001
|
+
let mut claims = vec![];
|
|
1002
|
+
for key in ["first", "second"] {
|
|
1003
|
+
let work = workloads.create(create(key)).unwrap();
|
|
1004
|
+
mesh.place_and_claim(
|
|
1005
|
+
&mut workloads,
|
|
1006
|
+
&work.workload_id,
|
|
1007
|
+
&worker.worker_id,
|
|
1008
|
+
12,
|
|
1009
|
+
10_000,
|
|
1010
|
+
)
|
|
1011
|
+
.unwrap();
|
|
1012
|
+
claims.push(
|
|
1013
|
+
workloads
|
|
1014
|
+
.get(&work.workload_id)
|
|
1015
|
+
.unwrap()
|
|
1016
|
+
.current_claim
|
|
1017
|
+
.clone()
|
|
1018
|
+
.unwrap()
|
|
1019
|
+
.claim_id,
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
assert_eq!(mesh.workers[&worker.worker_id].capacity.active, 2);
|
|
1023
|
+
// A completion retried after a lost response releases the same claim again.
|
|
1024
|
+
mesh.release_claim(&worker.worker_id, &claims[0]).unwrap();
|
|
1025
|
+
mesh.release_claim(&worker.worker_id, &claims[0]).unwrap();
|
|
1026
|
+
let state = &mesh.workers[&worker.worker_id];
|
|
1027
|
+
assert_eq!(state.claims.len(), 1);
|
|
1028
|
+
assert_eq!(state.capacity.active, 1, "the second claim is still held");
|
|
1029
|
+
let _ = fs::remove_file(m);
|
|
1030
|
+
let _ = fs::remove_file(wpath);
|
|
1031
|
+
}
|
|
1032
|
+
#[test]
|
|
1033
|
+
fn a_claim_recovered_by_another_writer_is_still_released() {
|
|
1034
|
+
let (m, wpath, mut mesh, mut workloads, grant) = setup("release-raced");
|
|
1035
|
+
let worker = register(&mut mesh, grant.clone());
|
|
1036
|
+
let heartbeat = |timestamp| WorkerHeartbeat {
|
|
1037
|
+
worker_id: worker.worker_id.clone(),
|
|
1038
|
+
version: "1".into(),
|
|
1039
|
+
capability_version: 1,
|
|
1040
|
+
capacity: WorkerCapacity {
|
|
1041
|
+
concurrency: 1,
|
|
1042
|
+
active: 0,
|
|
1043
|
+
},
|
|
1044
|
+
health: WorkerHealth::Healthy,
|
|
1045
|
+
timestamp,
|
|
1046
|
+
running_claims: BTreeSet::new(),
|
|
1047
|
+
};
|
|
1048
|
+
mesh.heartbeat(&worker.worker_id, heartbeat(11), 2).unwrap();
|
|
1049
|
+
let subject = grant.grant.subject.clone();
|
|
1050
|
+
let create = |key: &str| CreateWorkload {
|
|
1051
|
+
tenant_id: "t".into(),
|
|
1052
|
+
application_id: "a".into(),
|
|
1053
|
+
environment: "production".into(),
|
|
1054
|
+
definition_id: "extract".into(),
|
|
1055
|
+
definition_revision: "r".into(),
|
|
1056
|
+
trigger: "manual".into(),
|
|
1057
|
+
input: serde_json::json!({"file": key}),
|
|
1058
|
+
requested_by: subject.clone(),
|
|
1059
|
+
session_id: None,
|
|
1060
|
+
authorization_snapshot: grant.clone(),
|
|
1061
|
+
capability_snapshot: ["documents.extract".into()].into(),
|
|
1062
|
+
connection_scopes: BTreeSet::new(),
|
|
1063
|
+
priority: 0,
|
|
1064
|
+
idempotency_key: key.into(),
|
|
1065
|
+
retry_policy: WorkloadRetryPolicy::default(),
|
|
1066
|
+
timeouts: WorkloadTimeouts::default(),
|
|
1067
|
+
dependencies: vec![],
|
|
1068
|
+
not_before: None,
|
|
1069
|
+
created_at: 11,
|
|
1070
|
+
};
|
|
1071
|
+
let first = workloads.create(create("first")).unwrap();
|
|
1072
|
+
let second = workloads.create(create("second")).unwrap();
|
|
1073
|
+
mesh.place_and_claim(
|
|
1074
|
+
&mut workloads,
|
|
1075
|
+
&first.workload_id,
|
|
1076
|
+
&worker.worker_id,
|
|
1077
|
+
12,
|
|
1078
|
+
1000,
|
|
1079
|
+
)
|
|
1080
|
+
.unwrap();
|
|
1081
|
+
assert_eq!(mesh.workers[&worker.worker_id].capacity.active, 1);
|
|
1082
|
+
// Another writer of the same store recovers the work first.
|
|
1083
|
+
let mut other = WorkloadStore::from_db(workloads.db_for_tests()).unwrap();
|
|
1084
|
+
assert_eq!(
|
|
1085
|
+
other.recover_expired(14).unwrap(),
|
|
1086
|
+
vec![first.workload_id.clone()]
|
|
1087
|
+
);
|
|
1088
|
+
// This node's pass loses the guarded commit, adopts the committed
|
|
1089
|
+
// workload, does not report it as its own recovery, and still frees the
|
|
1090
|
+
// worker, whose claim is no longer the workload's.
|
|
1091
|
+
assert!(mesh
|
|
1092
|
+
.recover_expired_work(14, &mut workloads)
|
|
1093
|
+
.unwrap()
|
|
1094
|
+
.is_empty());
|
|
1095
|
+
let recovered = workloads.get(&first.workload_id).unwrap();
|
|
1096
|
+
assert_eq!(recovered.state, crate::workload::WorkloadState::Ready);
|
|
1097
|
+
assert_eq!(
|
|
1098
|
+
recovered
|
|
1099
|
+
.execution_history
|
|
1100
|
+
.iter()
|
|
1101
|
+
.filter(|event| event.to == crate::workload::WorkloadState::Recovering)
|
|
1102
|
+
.count(),
|
|
1103
|
+
1
|
|
1104
|
+
);
|
|
1105
|
+
assert!(mesh.workers[&worker.worker_id].claims.is_empty());
|
|
1106
|
+
assert_eq!(mesh.workers[&worker.worker_id].capacity.active, 0);
|
|
1107
|
+
let _ = second;
|
|
1108
|
+
let _ = fs::remove_file(m);
|
|
1109
|
+
let _ = fs::remove_file(wpath);
|
|
1110
|
+
}
|
|
1111
|
+
#[test]
|
|
1112
|
+
fn recovering_an_expired_claim_releases_it_from_a_live_worker() {
|
|
1113
|
+
let (m, wpath, mut mesh, mut workloads, grant) = setup("release-expired");
|
|
1114
|
+
let worker = register(&mut mesh, grant.clone());
|
|
1115
|
+
let heartbeat = |timestamp| WorkerHeartbeat {
|
|
1116
|
+
worker_id: worker.worker_id.clone(),
|
|
1117
|
+
version: "1".into(),
|
|
1118
|
+
capability_version: 1,
|
|
1119
|
+
capacity: WorkerCapacity {
|
|
1120
|
+
concurrency: 1,
|
|
1121
|
+
active: 0,
|
|
1122
|
+
},
|
|
1123
|
+
health: WorkerHealth::Healthy,
|
|
1124
|
+
timestamp,
|
|
1125
|
+
running_claims: BTreeSet::new(),
|
|
1126
|
+
};
|
|
1127
|
+
mesh.heartbeat(&worker.worker_id, heartbeat(11), 2).unwrap();
|
|
1128
|
+
let subject = grant.grant.subject.clone();
|
|
1129
|
+
let create = |key: &str| CreateWorkload {
|
|
1130
|
+
tenant_id: "t".into(),
|
|
1131
|
+
application_id: "a".into(),
|
|
1132
|
+
environment: "production".into(),
|
|
1133
|
+
definition_id: "extract".into(),
|
|
1134
|
+
definition_revision: "r".into(),
|
|
1135
|
+
trigger: "manual".into(),
|
|
1136
|
+
input: serde_json::json!({"file": key}),
|
|
1137
|
+
requested_by: subject.clone(),
|
|
1138
|
+
session_id: None,
|
|
1139
|
+
authorization_snapshot: grant.clone(),
|
|
1140
|
+
capability_snapshot: ["documents.extract".into()].into(),
|
|
1141
|
+
connection_scopes: BTreeSet::new(),
|
|
1142
|
+
priority: 0,
|
|
1143
|
+
idempotency_key: key.into(),
|
|
1144
|
+
retry_policy: WorkloadRetryPolicy::default(),
|
|
1145
|
+
timeouts: WorkloadTimeouts::default(),
|
|
1146
|
+
dependencies: vec![],
|
|
1147
|
+
not_before: None,
|
|
1148
|
+
created_at: 11,
|
|
1149
|
+
};
|
|
1150
|
+
let first = workloads.create(create("first")).unwrap();
|
|
1151
|
+
let second = workloads.create(create("second")).unwrap();
|
|
1152
|
+
mesh.place_and_claim(
|
|
1153
|
+
&mut workloads,
|
|
1154
|
+
&first.workload_id,
|
|
1155
|
+
&worker.worker_id,
|
|
1156
|
+
12,
|
|
1157
|
+
1000,
|
|
1158
|
+
)
|
|
1159
|
+
.unwrap();
|
|
1160
|
+
assert_eq!(mesh.workers[&worker.worker_id].capacity.active, 1);
|
|
1161
|
+
// The lease expired at 13. The server's recovery is claim recovery: it
|
|
1162
|
+
// recovers the work and frees the worker, and does not judge liveness.
|
|
1163
|
+
let recovered = mesh.recover_expired_work(14, &mut workloads).unwrap();
|
|
1164
|
+
assert_eq!(recovered, vec![first.workload_id.clone()]);
|
|
1165
|
+
let live = &mesh.workers[&worker.worker_id];
|
|
1166
|
+
assert!(live.claims.is_empty());
|
|
1167
|
+
assert_eq!(live.capacity.active, 0);
|
|
1168
|
+
assert_eq!(live.lifecycle, WorkerLifecycle::Ready);
|
|
1169
|
+
assert_eq!(
|
|
1170
|
+
workloads.get(&first.workload_id).unwrap().state,
|
|
1171
|
+
crate::workload::WorkloadState::Ready
|
|
1172
|
+
);
|
|
1173
|
+
// Its capacity is usable again.
|
|
1174
|
+
mesh.place_and_claim(
|
|
1175
|
+
&mut workloads,
|
|
1176
|
+
&second.workload_id,
|
|
1177
|
+
&worker.worker_id,
|
|
1178
|
+
15,
|
|
1179
|
+
10_000,
|
|
1180
|
+
)
|
|
1181
|
+
.unwrap();
|
|
1182
|
+
// Long past its heartbeat timeout, claim recovery still leaves the
|
|
1183
|
+
// worker's lifecycle alone; only `detect_failures` marks it offline.
|
|
1184
|
+
mesh.recover_expired_work(1_000, &mut workloads).unwrap();
|
|
1185
|
+
assert_ne!(
|
|
1186
|
+
mesh.workers[&worker.worker_id].lifecycle,
|
|
1187
|
+
WorkerLifecycle::Offline
|
|
1188
|
+
);
|
|
1189
|
+
assert!(mesh.workers[&worker.worker_id].claims.is_empty());
|
|
1190
|
+
let _ = fs::remove_file(m);
|
|
1191
|
+
let _ = fs::remove_file(wpath);
|
|
1192
|
+
}
|
|
1193
|
+
#[test]
|
|
855
1194
|
fn placement_claim_fencing_failure_and_recovery() {
|
|
856
1195
|
let (m, wpath, mut mesh, mut workloads, grant) = setup("claim");
|
|
857
1196
|
let worker = register(&mut mesh, grant.clone());
|