@feltdb/core 0.8.4 → 0.8.5
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/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +165 -0
- package/dist/create/server-source/Cargo.toml +9 -0
- package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
- package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
- package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
- package/dist/create/server-source/crates/feltdb/src/lib.rs +507 -6
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
- package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +79 -70
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +13 -4
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1514 -537
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
- package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +3 -4
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +2 -1
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +80 -25
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1032 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +2 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +326 -6
- package/dist/studio-app/assets/{feltdb_wasm-CVQWgXO-.js → feltdb_wasm-DaNwCLRX.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
- package/dist/studio-app/assets/{index-DwgNAIIX.js → index-j8IlhNqJ.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workload.d.ts +2 -1
- package/dist/workload.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
|
@@ -190,6 +190,14 @@ pub struct Workload {
|
|
|
190
190
|
pub timeouts: WorkloadTimeouts,
|
|
191
191
|
pub attempt_count: u32,
|
|
192
192
|
pub retry_count: u32,
|
|
193
|
+
/// The instant, in unix seconds, before which this workload must not be
|
|
194
|
+
/// claimed. `None` means "eligible as soon as it is `Ready`".
|
|
195
|
+
///
|
|
196
|
+
/// This is the single temporal eligibility concept in the workload model.
|
|
197
|
+
/// It is what retry backoff sets after a failure, what a timeout sets before
|
|
198
|
+
/// re-arming, and what [`CreateWorkload::not_before`] sets at creation, so a
|
|
199
|
+
/// workload that is not yet executable is always not-yet-executable for the
|
|
200
|
+
/// same reason and is refused by the same check in [`WorkloadStore::claim`].
|
|
193
201
|
pub next_attempt_at: Option<i64>,
|
|
194
202
|
pub last_failure: Option<String>,
|
|
195
203
|
pub current_claim: Option<WorkloadClaim>,
|
|
@@ -197,6 +205,19 @@ pub struct Workload {
|
|
|
197
205
|
#[serde(default)]
|
|
198
206
|
pub dependencies: Vec<WorkloadDependency>,
|
|
199
207
|
}
|
|
208
|
+
|
|
209
|
+
impl Workload {
|
|
210
|
+
/// Has this workload's eligibility instant arrived?
|
|
211
|
+
///
|
|
212
|
+
/// Eligibility is only half of claimability — the state must also be
|
|
213
|
+
/// `Ready`, and [`WorkloadStore::claim`] checks both. This answers the
|
|
214
|
+
/// temporal half alone so a scheduler can skip a workload that is merely
|
|
215
|
+
/// early without attempting a claim it knows will be refused.
|
|
216
|
+
pub fn is_eligible_at(&self, now: i64) -> bool {
|
|
217
|
+
self.next_attempt_at.is_none_or(|instant| instant <= now)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
200
221
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
201
222
|
pub struct CreateWorkload {
|
|
202
223
|
pub tenant_id: String,
|
|
@@ -220,6 +241,20 @@ pub struct CreateWorkload {
|
|
|
220
241
|
pub timeouts: WorkloadTimeouts,
|
|
221
242
|
#[serde(default)]
|
|
222
243
|
pub dependencies: Vec<WorkloadDependency>,
|
|
244
|
+
/// The instant, in unix seconds, before which this workload must not be
|
|
245
|
+
/// claimed. Omitted or `None` means it is claimable as soon as it is
|
|
246
|
+
/// `Ready`, which is what every workload did before this field existed.
|
|
247
|
+
///
|
|
248
|
+
/// A workload created with `not_before` is durable immediately and
|
|
249
|
+
/// executable later: it reaches `Ready`, survives restart, and is refused to
|
|
250
|
+
/// every worker until the instant arrives. It is *not* a schedule — there is
|
|
251
|
+
/// no recurrence, no expression, and no component that re-arms it. Creating
|
|
252
|
+
/// the next occurrence is the caller's business.
|
|
253
|
+
///
|
|
254
|
+
/// A `not_before` already in the past is not an error. It simply describes a
|
|
255
|
+
/// workload that is eligible now, which is the same thing as omitting it.
|
|
256
|
+
#[serde(default)]
|
|
257
|
+
pub not_before: Option<i64>,
|
|
223
258
|
pub created_at: i64,
|
|
224
259
|
}
|
|
225
260
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
@@ -338,18 +373,28 @@ impl WorkloadStore {
|
|
|
338
373
|
timeouts: input.timeouts,
|
|
339
374
|
attempt_count: 0,
|
|
340
375
|
retry_count: 0,
|
|
341
|
-
|
|
376
|
+
// Creation-time eligibility and retry backoff are the same thing, so
|
|
377
|
+
// they are the same field. A workload with `not_before` is created
|
|
378
|
+
// exactly as any other and is simply not yet claimable.
|
|
379
|
+
next_attempt_at: input.not_before,
|
|
342
380
|
last_failure: None,
|
|
343
381
|
current_claim: None,
|
|
344
382
|
fencing_token: 0,
|
|
345
383
|
dependencies: input.dependencies,
|
|
346
384
|
};
|
|
385
|
+
// The durable history records the eligibility the workload was created
|
|
386
|
+
// with, so "why was this Ready and unclaimed for an hour" is answerable
|
|
387
|
+
// from the workload itself rather than from the caller's intent.
|
|
388
|
+
let evidence = match workload.next_attempt_at {
|
|
389
|
+
Some(instant) => [("not_before".into(), instant.to_string())].into(),
|
|
390
|
+
None => BTreeMap::new(),
|
|
391
|
+
};
|
|
347
392
|
self.transition(
|
|
348
393
|
&mut workload,
|
|
349
394
|
WorkloadState::Ready,
|
|
350
395
|
"rust",
|
|
351
396
|
input.created_at,
|
|
352
|
-
|
|
397
|
+
evidence,
|
|
353
398
|
)?;
|
|
354
399
|
self.idempotency.insert(idempotency, id.clone());
|
|
355
400
|
self.workloads.insert(id, workload.clone());
|
|
@@ -425,12 +470,26 @@ impl WorkloadStore {
|
|
|
425
470
|
lease_ms: u64,
|
|
426
471
|
) -> Result<WorkloadClaim, WorkloadError> {
|
|
427
472
|
self.edit(id, |store, w| {
|
|
428
|
-
if w.state != WorkloadState::Ready
|
|
473
|
+
if w.state != WorkloadState::Ready {
|
|
429
474
|
return Err(WorkloadError::new(
|
|
430
475
|
"WORKLOAD_NOT_READY",
|
|
431
476
|
"workload is not eligible",
|
|
432
477
|
));
|
|
433
478
|
}
|
|
479
|
+
// Separated from the state check because the two mean opposite
|
|
480
|
+
// things to a worker: a workload in the wrong state may never become
|
|
481
|
+
// claimable, while one that is merely early certainly will, at an
|
|
482
|
+
// instant the error names. Collapsing them into one code left a
|
|
483
|
+
// polling worker unable to tell "come back" from "give up".
|
|
484
|
+
if !w.is_eligible_at(now) {
|
|
485
|
+
return Err(WorkloadError::new(
|
|
486
|
+
"WORKLOAD_NOT_YET_ELIGIBLE",
|
|
487
|
+
format!(
|
|
488
|
+
"workload is not claimable until {}",
|
|
489
|
+
w.next_attempt_at.unwrap_or(now)
|
|
490
|
+
),
|
|
491
|
+
));
|
|
492
|
+
}
|
|
434
493
|
if !w.capability_snapshot.is_subset(capabilities) {
|
|
435
494
|
return Err(WorkloadError::new(
|
|
436
495
|
"WORKLOAD_CAPABILITY_DENIED",
|
|
@@ -943,7 +1002,14 @@ impl WorkScheduler for LocalDurableScheduler {
|
|
|
943
1002
|
.list(tenant, application)
|
|
944
1003
|
.into_iter()
|
|
945
1004
|
.filter(|w| {
|
|
946
|
-
|
|
1005
|
+
// Eligibility is part of selection, not a reason to fail. A
|
|
1006
|
+
// workload that is merely early is skipped so the scheduler can
|
|
1007
|
+
// hand out work that is ready now; without this it would pick an
|
|
1008
|
+
// ineligible workload, have `store.claim` refuse it, and return
|
|
1009
|
+
// an error while claimable work sat behind it.
|
|
1010
|
+
w.state == WorkloadState::Ready
|
|
1011
|
+
&& w.is_eligible_at(now)
|
|
1012
|
+
&& w.capability_snapshot.is_subset(capabilities)
|
|
947
1013
|
})
|
|
948
1014
|
.find(|w| {
|
|
949
1015
|
w.dependencies.iter().all(|d| {
|
|
@@ -1102,6 +1168,7 @@ mod tests {
|
|
|
1102
1168
|
retry_policy: Default::default(),
|
|
1103
1169
|
timeouts: Default::default(),
|
|
1104
1170
|
dependencies: vec![],
|
|
1171
|
+
not_before: None,
|
|
1105
1172
|
created_at: 10,
|
|
1106
1173
|
}
|
|
1107
1174
|
}
|
|
@@ -1307,4 +1374,445 @@ mod tests {
|
|
|
1307
1374
|
assert_ne!(first.workload_id, child.workload_id);
|
|
1308
1375
|
let _ = fs::remove_file(path);
|
|
1309
1376
|
}
|
|
1377
|
+
|
|
1378
|
+
// ------------------------------------------------------------------
|
|
1379
|
+
// Temporal work eligibility.
|
|
1380
|
+
//
|
|
1381
|
+
// The invariant under test: a workload may exist durably before it is
|
|
1382
|
+
// executable. Every test below drives the real store — creating, claiming,
|
|
1383
|
+
// reloading from disk — rather than asserting that a field or a function
|
|
1384
|
+
// name exists.
|
|
1385
|
+
// ------------------------------------------------------------------
|
|
1386
|
+
|
|
1387
|
+
/// A store on a path unique to this test, cleared first so a recycled
|
|
1388
|
+
/// process id cannot hand a test a previous run's file.
|
|
1389
|
+
fn temporal_store(name: &str) -> (PathBuf, WorkloadStore) {
|
|
1390
|
+
let path = std::env::temp_dir().join(format!(
|
|
1391
|
+
"workload-temporal-{name}-{}-{}.json",
|
|
1392
|
+
std::process::id(),
|
|
1393
|
+
rand::random::<u64>()
|
|
1394
|
+
));
|
|
1395
|
+
let _ = fs::remove_file(&path);
|
|
1396
|
+
(path.clone(), WorkloadStore::load(&path).unwrap())
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
fn scheduled(not_before: i64) -> CreateWorkload {
|
|
1400
|
+
let mut input = create();
|
|
1401
|
+
input.not_before = Some(not_before);
|
|
1402
|
+
input
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
fn caps() -> BTreeSet<String> {
|
|
1406
|
+
["email:send".into()].into()
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
#[test]
|
|
1410
|
+
fn scheduled_work_is_not_claimable_before_not_before() {
|
|
1411
|
+
let (path, mut store) = temporal_store("before");
|
|
1412
|
+
let workload = store.create(scheduled(2_000)).unwrap();
|
|
1413
|
+
|
|
1414
|
+
// Durable and Ready immediately: existing before it is executable is
|
|
1415
|
+
// the whole point.
|
|
1416
|
+
assert_eq!(workload.state, WorkloadState::Ready);
|
|
1417
|
+
assert_eq!(workload.next_attempt_at, Some(2_000));
|
|
1418
|
+
|
|
1419
|
+
let refused = store
|
|
1420
|
+
.claim(&workload.workload_id, "worker", &caps(), 1_999, 10_000)
|
|
1421
|
+
.unwrap_err();
|
|
1422
|
+
assert_eq!(refused.code, "WORKLOAD_NOT_YET_ELIGIBLE");
|
|
1423
|
+
assert!(
|
|
1424
|
+
refused.message.contains("2000"),
|
|
1425
|
+
"the refusal names the instant the worker should return at: {}",
|
|
1426
|
+
refused.message
|
|
1427
|
+
);
|
|
1428
|
+
|
|
1429
|
+
// Refusing a claim is not a state change. Nothing executed.
|
|
1430
|
+
let after = store.get(&workload.workload_id).unwrap();
|
|
1431
|
+
assert_eq!(after.state, WorkloadState::Ready);
|
|
1432
|
+
assert_eq!(after.attempt_count, 0);
|
|
1433
|
+
assert!(after.attempts.is_empty());
|
|
1434
|
+
assert!(after.current_claim.is_none());
|
|
1435
|
+
let _ = fs::remove_file(path);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
#[test]
|
|
1439
|
+
fn scheduled_work_becomes_claimable_after_not_before() {
|
|
1440
|
+
let (path, mut store) = temporal_store("after");
|
|
1441
|
+
let workload = store.create(scheduled(2_000)).unwrap();
|
|
1442
|
+
|
|
1443
|
+
// The boundary belongs to the eligible side: at exactly `not_before`
|
|
1444
|
+
// the workload is claimable, so a worker polling on the second does not
|
|
1445
|
+
// have to wait a further tick.
|
|
1446
|
+
let claim = store
|
|
1447
|
+
.claim(&workload.workload_id, "worker", &caps(), 2_000, 10_000)
|
|
1448
|
+
.expect("eligible at the instant itself");
|
|
1449
|
+
assert_eq!(claim.worker_id, "worker");
|
|
1450
|
+
assert_eq!(
|
|
1451
|
+
store.get(&workload.workload_id).unwrap().state,
|
|
1452
|
+
WorkloadState::Claimed
|
|
1453
|
+
);
|
|
1454
|
+
let _ = fs::remove_file(path);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
#[test]
|
|
1458
|
+
fn scheduled_work_survives_restart_before_eligibility() {
|
|
1459
|
+
let (path, mut store) = temporal_store("restart-before");
|
|
1460
|
+
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
1461
|
+
drop(store);
|
|
1462
|
+
|
|
1463
|
+
// Restart the authority. The workload is still here, still Ready, still
|
|
1464
|
+
// carrying its eligibility, and still refused.
|
|
1465
|
+
let mut reopened = WorkloadStore::load(&path).unwrap();
|
|
1466
|
+
let recovered = reopened.get(&id).unwrap();
|
|
1467
|
+
assert_eq!(recovered.state, WorkloadState::Ready);
|
|
1468
|
+
assert_eq!(recovered.next_attempt_at, Some(2_000));
|
|
1469
|
+
|
|
1470
|
+
assert_eq!(
|
|
1471
|
+
reopened
|
|
1472
|
+
.claim(&id, "worker", &caps(), 1_999, 10_000)
|
|
1473
|
+
.unwrap_err()
|
|
1474
|
+
.code,
|
|
1475
|
+
"WORKLOAD_NOT_YET_ELIGIBLE"
|
|
1476
|
+
);
|
|
1477
|
+
let _ = fs::remove_file(path);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
#[test]
|
|
1481
|
+
fn scheduled_work_executes_after_restart() {
|
|
1482
|
+
let (path, mut store) = temporal_store("restart-after");
|
|
1483
|
+
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
1484
|
+
drop(store);
|
|
1485
|
+
|
|
1486
|
+
let mut reopened = WorkloadStore::load(&path).unwrap();
|
|
1487
|
+
let claim = reopened
|
|
1488
|
+
.claim(&id, "worker", &caps(), 2_001, 10_000)
|
|
1489
|
+
.expect("claimable after restart once eligible");
|
|
1490
|
+
reopened
|
|
1491
|
+
.start(&id, &claim.claim_id, "worker", claim.fencing_token, 2_002)
|
|
1492
|
+
.unwrap();
|
|
1493
|
+
reopened
|
|
1494
|
+
.complete(
|
|
1495
|
+
&id,
|
|
1496
|
+
&claim.claim_id,
|
|
1497
|
+
"worker",
|
|
1498
|
+
claim.fencing_token,
|
|
1499
|
+
"result-scheduled",
|
|
1500
|
+
serde_json::json!({"ok": true}),
|
|
1501
|
+
vec![],
|
|
1502
|
+
1,
|
|
1503
|
+
2_003,
|
|
1504
|
+
)
|
|
1505
|
+
.unwrap();
|
|
1506
|
+
|
|
1507
|
+
// And the outcome is durable across a second restart.
|
|
1508
|
+
let again = WorkloadStore::load(&path).unwrap();
|
|
1509
|
+
let finished = again.get(&id).unwrap();
|
|
1510
|
+
assert_eq!(finished.state, WorkloadState::Succeeded);
|
|
1511
|
+
assert_eq!(
|
|
1512
|
+
finished.result.as_ref().map(|r| r.result_id.as_str()),
|
|
1513
|
+
Some("result-scheduled")
|
|
1514
|
+
);
|
|
1515
|
+
let _ = fs::remove_file(path);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
#[test]
|
|
1519
|
+
fn immediate_work_behavior_is_unchanged() {
|
|
1520
|
+
let (path, mut store) = temporal_store("immediate");
|
|
1521
|
+
// `create()` sets no `not_before`, which is what every caller did
|
|
1522
|
+
// before this field existed.
|
|
1523
|
+
let workload = store.create(create()).unwrap();
|
|
1524
|
+
assert_eq!(workload.next_attempt_at, None);
|
|
1525
|
+
assert!(workload.is_eligible_at(workload.created_at));
|
|
1526
|
+
|
|
1527
|
+
// Claimable at its own creation instant, with no waiting.
|
|
1528
|
+
store
|
|
1529
|
+
.claim(
|
|
1530
|
+
&workload.workload_id,
|
|
1531
|
+
"worker",
|
|
1532
|
+
&caps(),
|
|
1533
|
+
workload.created_at,
|
|
1534
|
+
10_000,
|
|
1535
|
+
)
|
|
1536
|
+
.expect("work with no eligibility instant is claimable immediately");
|
|
1537
|
+
let _ = fs::remove_file(path);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
#[test]
|
|
1541
|
+
fn multiple_workers_claim_temporally_eligible_work_once() {
|
|
1542
|
+
let (path, mut store) = temporal_store("contention");
|
|
1543
|
+
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
1544
|
+
|
|
1545
|
+
// Both workers are polling and both see eligibility open at the same
|
|
1546
|
+
// instant. Exactly one wins.
|
|
1547
|
+
let winner = store
|
|
1548
|
+
.claim(&id, "worker-a", &caps(), 2_000, 10_000)
|
|
1549
|
+
.unwrap();
|
|
1550
|
+
let loser = store
|
|
1551
|
+
.claim(&id, "worker-b", &caps(), 2_000, 10_000)
|
|
1552
|
+
.unwrap_err();
|
|
1553
|
+
assert_eq!(
|
|
1554
|
+
loser.code, "WORKLOAD_NOT_READY",
|
|
1555
|
+
"the second worker is refused on state, not on time: the workload is claimed"
|
|
1556
|
+
);
|
|
1557
|
+
|
|
1558
|
+
// And the loser cannot complete work it never owned.
|
|
1559
|
+
assert_eq!(
|
|
1560
|
+
store
|
|
1561
|
+
.complete(
|
|
1562
|
+
&id,
|
|
1563
|
+
&winner.claim_id,
|
|
1564
|
+
"worker-b",
|
|
1565
|
+
winner.fencing_token,
|
|
1566
|
+
"duplicate",
|
|
1567
|
+
Value::Null,
|
|
1568
|
+
vec![],
|
|
1569
|
+
1,
|
|
1570
|
+
2_001,
|
|
1571
|
+
)
|
|
1572
|
+
.unwrap_err()
|
|
1573
|
+
.code,
|
|
1574
|
+
"WORKLOAD_STALE_FENCE"
|
|
1575
|
+
);
|
|
1576
|
+
assert_eq!(store.get(&id).unwrap().attempt_count, 1);
|
|
1577
|
+
let _ = fs::remove_file(path);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
#[test]
|
|
1581
|
+
fn retry_sets_next_eligibility_without_duplicate_execution() {
|
|
1582
|
+
let (path, mut store) = temporal_store("retry");
|
|
1583
|
+
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
1584
|
+
|
|
1585
|
+
let first = store
|
|
1586
|
+
.claim(&id, "worker-a", &caps(), 2_000, 10_000)
|
|
1587
|
+
.unwrap();
|
|
1588
|
+
store
|
|
1589
|
+
.start(&id, &first.claim_id, "worker-a", first.fencing_token, 2_001)
|
|
1590
|
+
.unwrap();
|
|
1591
|
+
let state = store
|
|
1592
|
+
.fail(
|
|
1593
|
+
&id,
|
|
1594
|
+
&first.claim_id,
|
|
1595
|
+
"worker-a",
|
|
1596
|
+
first.fencing_token,
|
|
1597
|
+
"timeout",
|
|
1598
|
+
"upstream did not answer",
|
|
1599
|
+
2_002,
|
|
1600
|
+
)
|
|
1601
|
+
.unwrap();
|
|
1602
|
+
|
|
1603
|
+
// Retry re-arms the same eligibility field creation used, so there is
|
|
1604
|
+
// one temporal concept rather than two competing ones.
|
|
1605
|
+
assert_eq!(state, WorkloadState::Ready);
|
|
1606
|
+
let backing_off = store.get(&id).unwrap();
|
|
1607
|
+
let next = backing_off.next_attempt_at.expect("retry set eligibility");
|
|
1608
|
+
assert!(
|
|
1609
|
+
next > 2_002,
|
|
1610
|
+
"backoff pushed eligibility into the future: {next}"
|
|
1611
|
+
);
|
|
1612
|
+
|
|
1613
|
+
assert_eq!(
|
|
1614
|
+
store
|
|
1615
|
+
.claim(&id, "worker-b", &caps(), next - 1, 10_000)
|
|
1616
|
+
.unwrap_err()
|
|
1617
|
+
.code,
|
|
1618
|
+
"WORKLOAD_NOT_YET_ELIGIBLE"
|
|
1619
|
+
);
|
|
1620
|
+
|
|
1621
|
+
let second = store.claim(&id, "worker-b", &caps(), next, 10_000).unwrap();
|
|
1622
|
+
assert!(second.fencing_token > first.fencing_token);
|
|
1623
|
+
|
|
1624
|
+
// The first attempt cannot land a result after the retry was handed on.
|
|
1625
|
+
assert_eq!(
|
|
1626
|
+
store
|
|
1627
|
+
.complete(
|
|
1628
|
+
&id,
|
|
1629
|
+
&first.claim_id,
|
|
1630
|
+
"worker-a",
|
|
1631
|
+
first.fencing_token,
|
|
1632
|
+
"stale",
|
|
1633
|
+
Value::Null,
|
|
1634
|
+
vec![],
|
|
1635
|
+
1,
|
|
1636
|
+
next,
|
|
1637
|
+
)
|
|
1638
|
+
.unwrap_err()
|
|
1639
|
+
.code,
|
|
1640
|
+
"WORKLOAD_STALE_FENCE"
|
|
1641
|
+
);
|
|
1642
|
+
assert_eq!(store.get(&id).unwrap().attempt_count, 2);
|
|
1643
|
+
let _ = fs::remove_file(path);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
#[test]
|
|
1647
|
+
fn cancellation_before_not_before_prevents_execution() {
|
|
1648
|
+
let (path, mut store) = temporal_store("cancel");
|
|
1649
|
+
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
1650
|
+
|
|
1651
|
+
// Cancelling work that is durable but not yet executable is the whole
|
|
1652
|
+
// reason to be able to see it before it runs.
|
|
1653
|
+
assert_eq!(
|
|
1654
|
+
store.cancel(&id, "operator", 1_500).unwrap(),
|
|
1655
|
+
WorkloadState::Cancelled
|
|
1656
|
+
);
|
|
1657
|
+
|
|
1658
|
+
// Eligibility arriving does not resurrect it.
|
|
1659
|
+
assert_eq!(
|
|
1660
|
+
store
|
|
1661
|
+
.claim(&id, "worker", &caps(), 2_001, 10_000)
|
|
1662
|
+
.unwrap_err()
|
|
1663
|
+
.code,
|
|
1664
|
+
"WORKLOAD_NOT_READY"
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1667
|
+
let reopened = WorkloadStore::load(&path).unwrap();
|
|
1668
|
+
let cancelled = reopened.get(&id).unwrap();
|
|
1669
|
+
assert_eq!(cancelled.state, WorkloadState::Cancelled);
|
|
1670
|
+
assert!(cancelled.attempts.is_empty(), "nothing ever executed");
|
|
1671
|
+
let _ = fs::remove_file(path);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
#[test]
|
|
1675
|
+
fn a_worker_dying_after_a_claim_returns_the_work_and_it_executes_once() {
|
|
1676
|
+
let (path, mut store) = temporal_store("worker-death");
|
|
1677
|
+
let id = store.create(create()).unwrap().workload_id;
|
|
1678
|
+
|
|
1679
|
+
// Worker A claims a short lease, starts, and dies without completing.
|
|
1680
|
+
let dead = store.claim(&id, "worker-a", &caps(), 10, 1_000).unwrap();
|
|
1681
|
+
store
|
|
1682
|
+
.start(&id, &dead.claim_id, "worker-a", dead.fencing_token, 10)
|
|
1683
|
+
.unwrap();
|
|
1684
|
+
assert_eq!(
|
|
1685
|
+
store.get(&id).unwrap().state,
|
|
1686
|
+
WorkloadState::Running,
|
|
1687
|
+
"the work was in flight when its worker died"
|
|
1688
|
+
);
|
|
1689
|
+
|
|
1690
|
+
// The lease expiring is the only thing that can notice.
|
|
1691
|
+
let recovered = store.recover_expired(12).unwrap();
|
|
1692
|
+
assert_eq!(recovered, vec![id.clone()]);
|
|
1693
|
+
let back = store.get(&id).unwrap();
|
|
1694
|
+
assert_eq!(back.state, WorkloadState::Ready, "returned to the pool");
|
|
1695
|
+
assert_eq!(
|
|
1696
|
+
back.attempts.last().map(|attempt| attempt.status),
|
|
1697
|
+
Some(AttemptStatus::WorkerLost),
|
|
1698
|
+
"and the dead attempt is recorded as lost rather than erased"
|
|
1699
|
+
);
|
|
1700
|
+
|
|
1701
|
+
// Worker B picks it up and finishes it.
|
|
1702
|
+
let live = store.claim(&id, "worker-b", &caps(), 12, 10_000).unwrap();
|
|
1703
|
+
assert!(live.fencing_token > dead.fencing_token);
|
|
1704
|
+
store
|
|
1705
|
+
.start(&id, &live.claim_id, "worker-b", live.fencing_token, 13)
|
|
1706
|
+
.unwrap();
|
|
1707
|
+
store
|
|
1708
|
+
.complete(
|
|
1709
|
+
&id,
|
|
1710
|
+
&live.claim_id,
|
|
1711
|
+
"worker-b",
|
|
1712
|
+
live.fencing_token,
|
|
1713
|
+
"result-recovered",
|
|
1714
|
+
serde_json::json!({"ok": true}),
|
|
1715
|
+
vec![],
|
|
1716
|
+
1,
|
|
1717
|
+
14,
|
|
1718
|
+
)
|
|
1719
|
+
.unwrap();
|
|
1720
|
+
|
|
1721
|
+
// Exactly one execution succeeded, and the dead worker cannot add another.
|
|
1722
|
+
let finished = store.get(&id).unwrap();
|
|
1723
|
+
assert_eq!(finished.state, WorkloadState::Succeeded);
|
|
1724
|
+
assert_eq!(
|
|
1725
|
+
finished
|
|
1726
|
+
.attempts
|
|
1727
|
+
.iter()
|
|
1728
|
+
.filter(|attempt| attempt.status == AttemptStatus::Succeeded)
|
|
1729
|
+
.count(),
|
|
1730
|
+
1
|
|
1731
|
+
);
|
|
1732
|
+
// The dead worker cannot add a second result. Its claim was released by
|
|
1733
|
+
// the recovery, and completion released the successor's, so it is
|
|
1734
|
+
// refused for having no claim at all rather than a stale one.
|
|
1735
|
+
assert_eq!(
|
|
1736
|
+
store
|
|
1737
|
+
.complete(
|
|
1738
|
+
&id,
|
|
1739
|
+
&dead.claim_id,
|
|
1740
|
+
"worker-a",
|
|
1741
|
+
dead.fencing_token,
|
|
1742
|
+
"resurrected",
|
|
1743
|
+
Value::Null,
|
|
1744
|
+
vec![],
|
|
1745
|
+
1,
|
|
1746
|
+
15,
|
|
1747
|
+
)
|
|
1748
|
+
.unwrap_err()
|
|
1749
|
+
.code,
|
|
1750
|
+
"WORKLOAD_CLAIM_INVALID"
|
|
1751
|
+
);
|
|
1752
|
+
let _ = fs::remove_file(path);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
#[test]
|
|
1756
|
+
fn a_recovered_workload_refuses_its_dead_worker_while_the_successor_still_holds_it() {
|
|
1757
|
+
let (path, mut store) = temporal_store("worker-death-inflight");
|
|
1758
|
+
let id = store.create(create()).unwrap().workload_id;
|
|
1759
|
+
|
|
1760
|
+
let dead = store.claim(&id, "worker-a", &caps(), 10, 1_000).unwrap();
|
|
1761
|
+
store
|
|
1762
|
+
.start(&id, &dead.claim_id, "worker-a", dead.fencing_token, 10)
|
|
1763
|
+
.unwrap();
|
|
1764
|
+
store.recover_expired(12).unwrap();
|
|
1765
|
+
let live = store.claim(&id, "worker-b", &caps(), 12, 10_000).unwrap();
|
|
1766
|
+
|
|
1767
|
+
// While the successor is mid-flight, the resurrected worker is refused
|
|
1768
|
+
// on the fence itself — the case a lease exists to decide.
|
|
1769
|
+
assert_eq!(
|
|
1770
|
+
store
|
|
1771
|
+
.complete(
|
|
1772
|
+
&id,
|
|
1773
|
+
&dead.claim_id,
|
|
1774
|
+
"worker-a",
|
|
1775
|
+
dead.fencing_token,
|
|
1776
|
+
"resurrected",
|
|
1777
|
+
Value::Null,
|
|
1778
|
+
vec![],
|
|
1779
|
+
1,
|
|
1780
|
+
13,
|
|
1781
|
+
)
|
|
1782
|
+
.unwrap_err()
|
|
1783
|
+
.code,
|
|
1784
|
+
"WORKLOAD_STALE_FENCE"
|
|
1785
|
+
);
|
|
1786
|
+
assert_eq!(store.get(&id).unwrap().state, WorkloadState::Claimed);
|
|
1787
|
+
assert!(live.fencing_token > dead.fencing_token);
|
|
1788
|
+
let _ = fs::remove_file(path);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
#[test]
|
|
1792
|
+
fn the_scheduler_skips_ineligible_work_rather_than_failing_on_it() {
|
|
1793
|
+
let (path, mut store) = temporal_store("scheduler");
|
|
1794
|
+
let mut early = create();
|
|
1795
|
+
early.idempotency_key = "early".into();
|
|
1796
|
+
early.not_before = Some(9_000);
|
|
1797
|
+
let early = store.create(early).unwrap();
|
|
1798
|
+
|
|
1799
|
+
let mut ready = create();
|
|
1800
|
+
ready.idempotency_key = "ready".into();
|
|
1801
|
+
let ready = store.create(ready).unwrap();
|
|
1802
|
+
|
|
1803
|
+
// The ineligible workload sorts first by id in some orderings and not
|
|
1804
|
+
// others, so what matters is that the scheduler never returns it and
|
|
1805
|
+
// never fails because of it.
|
|
1806
|
+
let mut scheduler = LocalDurableScheduler::default();
|
|
1807
|
+
let claim = scheduler
|
|
1808
|
+
.claim(&mut store, "t", "app", "worker", &caps(), 1_000, 10_000)
|
|
1809
|
+
.expect("an ineligible workload must not fail the whole pass")
|
|
1810
|
+
.expect("the eligible workload is still handed out");
|
|
1811
|
+
assert_eq!(claim.workload_id, ready.workload_id);
|
|
1812
|
+
assert_eq!(
|
|
1813
|
+
store.get(&early.workload_id).unwrap().state,
|
|
1814
|
+
WorkloadState::Ready
|
|
1815
|
+
);
|
|
1816
|
+
let _ = fs::remove_file(path);
|
|
1817
|
+
}
|
|
1310
1818
|
}
|
|
@@ -231,10 +231,9 @@ impl Phase {
|
|
|
231
231
|
// Nesting them under `AuditWrite` or `HttpHandler` would let a
|
|
232
232
|
// reader compute a share of a span they do not run inside, and
|
|
233
233
|
// that share could exceed one hundred per cent.
|
|
234
|
-
Phase::AuditOpen
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
| Phase::AuditClose => None,
|
|
234
|
+
Phase::AuditOpen | Phase::AuditAppend | Phase::AuditGroupSync | Phase::AuditClose => {
|
|
235
|
+
None
|
|
236
|
+
}
|
|
238
237
|
}
|
|
239
238
|
}
|
|
240
239
|
}
|