@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.
Files changed (126) hide show
  1. package/dist/agent-runtime.d.ts +64 -2
  2. package/dist/agent-runtime.d.ts.map +1 -1
  3. package/dist/agent-runtime.js +121 -11
  4. package/dist/agent-state.d.ts +48 -0
  5. package/dist/agent-state.d.ts.map +1 -1
  6. package/dist/agent-state.js +174 -12
  7. package/dist/agent.d.ts +10 -1
  8. package/dist/agent.d.ts.map +1 -1
  9. package/dist/agent.js +10 -1
  10. package/dist/build-identity.json +4 -4
  11. package/dist/cli/commands.js +34 -7
  12. package/dist/cli/workspace-integration.js +8 -0
  13. package/dist/collection.d.ts +3 -1
  14. package/dist/collection.d.ts.map +1 -1
  15. package/dist/collection.js +30 -8
  16. package/dist/create/build-identity.json +4 -4
  17. package/dist/create/cli.js +47 -38
  18. package/dist/create/create.js +21 -8
  19. package/dist/create/package-versions.js +1 -1
  20. package/dist/create/server-source/Cargo.lock +6 -6
  21. package/dist/create/server-source/Cargo.toml +1 -1
  22. package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
  23. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
  24. package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +341 -2
  25. package/dist/create/server-source/crates/feltdb/src/workload.rs +2094 -126
  26. package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
  27. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
  28. package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
  29. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
  30. package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
  31. package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
  32. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
  33. package/dist/create/server-source/crates/feltdb-server/src/auth.rs +113 -25
  34. package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
  35. package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
  36. package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
  37. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
  38. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
  39. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +4 -0
  40. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1740 -236
  41. package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
  42. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
  43. package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
  44. package/dist/create/setup-summary.js +40 -0
  45. package/dist/db.d.ts +91 -10
  46. package/dist/db.d.ts.map +1 -1
  47. package/dist/db.js +170 -31
  48. package/dist/deployment.d.ts +19 -0
  49. package/dist/deployment.d.ts.map +1 -1
  50. package/dist/deployment.js +38 -2
  51. package/dist/development-runtime-bridge.d.ts +20 -0
  52. package/dist/development-runtime-bridge.d.ts.map +1 -1
  53. package/dist/development-runtime-bridge.js +61 -12
  54. package/dist/embedded-query.d.ts.map +1 -1
  55. package/dist/embedded-query.js +5 -4
  56. package/dist/embedded-transaction.d.ts +8 -4
  57. package/dist/embedded-transaction.d.ts.map +1 -1
  58. package/dist/embedded-transaction.js +55 -19
  59. package/dist/error-codes.d.ts +31 -2
  60. package/dist/error-codes.d.ts.map +1 -1
  61. package/dist/error-codes.js +41 -2
  62. package/dist/feltdb.d.ts +7 -0
  63. package/dist/feltdb.d.ts.map +1 -1
  64. package/dist/file-db.d.ts +27 -0
  65. package/dist/file-db.d.ts.map +1 -1
  66. package/dist/file-db.js +145 -7
  67. package/dist/http-client.d.ts +11 -3
  68. package/dist/http-client.d.ts.map +1 -1
  69. package/dist/http-client.js +27 -14
  70. package/dist/http-db.d.ts +40 -1
  71. package/dist/http-db.d.ts.map +1 -1
  72. package/dist/http-db.js +285 -58
  73. package/dist/index.js +1 -1
  74. package/dist/local-state-lock.d.ts +34 -0
  75. package/dist/local-state-lock.d.ts.map +1 -0
  76. package/dist/local-state-lock.js +225 -0
  77. package/dist/reactive-graph.d.ts +0 -3
  78. package/dist/reactive-graph.d.ts.map +1 -1
  79. package/dist/reactive-graph.js +11 -1
  80. package/dist/runtime-registry.d.ts +7 -2
  81. package/dist/runtime-registry.d.ts.map +1 -1
  82. package/dist/runtime-registry.js +2 -2
  83. package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
  84. package/dist/studio/app.d.ts +8 -1
  85. package/dist/studio/app.d.ts.map +1 -1
  86. package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
  87. package/dist/studio/components/KeyManagementPanel.js +1 -1
  88. package/dist/studio/components/index.js +2 -2
  89. package/dist/studio/{components-B4eKIUN5.js → components-CJmzntBi.js} +8 -7
  90. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  91. package/dist/studio/control-plane/Instance.d.ts +4 -1
  92. package/dist/studio/control-plane/Instance.d.ts.map +1 -1
  93. package/dist/studio/control-plane/Operations.d.ts +4 -1
  94. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  95. package/dist/studio/control-plane/index.js +2 -2
  96. package/dist/studio/control-plane/primitives.d.ts +16 -0
  97. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  98. package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
  99. package/dist/studio/index.d.ts +1 -0
  100. package/dist/studio/index.d.ts.map +1 -1
  101. package/dist/studio/index.js +384 -247
  102. package/dist/studio/navigation.d.ts +57 -0
  103. package/dist/studio/navigation.d.ts.map +1 -0
  104. package/dist/studio/studio.css +1 -1
  105. package/dist/studio/utils/control-plane-api.d.ts +2 -0
  106. package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
  107. package/dist/studio/utils/control-plane-api.js +5 -1
  108. package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-BjM1ylL3.js} +1 -1
  109. package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
  110. package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
  111. package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
  112. package/dist/studio-app/index.html +2 -2
  113. package/dist/transaction.d.ts +27 -11
  114. package/dist/transaction.d.ts.map +1 -1
  115. package/dist/transaction.js +20 -14
  116. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  117. package/dist/wasm/package.json +1 -1
  118. package/dist/workload.d.ts +31 -3
  119. package/dist/workload.d.ts.map +1 -1
  120. package/dist/workload.js +28 -3
  121. package/dist/workspace/local-development-authority.d.ts +19 -0
  122. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  123. package/dist/workspace/local-development-authority.js +335 -15
  124. package/package.json +1 -1
  125. package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
  126. 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
- if db
2482
- .has_applied_transaction(&storage_transaction_id)
2483
- .map_err(StateFailure::storage)?
2484
- {
2485
- if db
2486
- .applied_transaction_payload_hash(&storage_transaction_id)
2487
- .map_err(StateFailure::storage)?
2488
- .as_deref()
2489
- != Some(payload_hash.as_str())
2490
- {
2491
- return Err(StateFailure::new(
2492
- "IDEMPOTENCY_CONFLICT",
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
- return Err(StateFailure::new("CONFLICT", "record already exists"));
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 = Some(refusal.key().to_string());
2932
- failure.failure = serde_json::to_value(refusal.as_ref()).ok();
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
- execute_transaction(&db, &schema, &stale, None)
3267
- .unwrap_err()
3268
- .code,
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
- workloads.recover_expired(now)?;
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.claims.remove(claim_id);
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());