@feltdb/core 0.13.0 → 0.16.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 +68 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +210 -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 +12 -4
- package/dist/cli/commands.js +214 -10
- package/dist/cli/publish-engine.js +116 -16
- package/dist/collection.d.ts +3 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +33 -8
- package/dist/create/build-identity.json +12 -4
- package/dist/create/cli.js +1 -1
- package/dist/create/create.js +4 -6
- package/dist/create/managed-account.js +93 -35
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +8 -511
- package/dist/create/server-source/Cargo.toml +2 -13
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +85 -25
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
- package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +3 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1134 -96
- 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/server-source/crates/feltdb-wasm/Cargo.toml +1 -1
- package/dist/db.d.ts +75 -10
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +168 -29
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- package/dist/embedded-query.d.ts.map +1 -1
- package/dist/embedded-query.js +5 -4
- package/dist/embedded-transaction.d.ts +8 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +55 -19
- package/dist/error-codes.d.ts +36 -2
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +50 -3
- 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 +43 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +287 -59
- 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/managed-recovery.d.ts +6 -0
- package/dist/managed-recovery.d.ts.map +1 -1
- package/dist/managed-recovery.js +9 -0
- package/dist/reactive-graph.d.ts +0 -3
- package/dist/reactive-graph.d.ts.map +1 -1
- package/dist/reactive-graph.js +11 -1
- package/dist/runtime-registry.d.ts +7 -2
- package/dist/runtime-registry.d.ts.map +1 -1
- package/dist/runtime-registry.js +2 -2
- package/dist/studio/components/index.js +1 -1
- package/dist/studio/{components-BSGmHfif.js → components-CJmzntBi.js} +2 -1
- package/dist/studio/index.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-3hDngl1h.js → feltdb_wasm-DtybUY0q.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DsgIUwaB.wasm +0 -0
- package/dist/studio-app/assets/index-C4OffsZl.js +37 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +27 -11
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +20 -14
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +86 -6
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +51 -3
- package/dist/workspace/workspace-connection.d.ts.map +1 -1
- package/dist/workspace/workspace-connection.js +14 -3
- package/package.json +1 -1
- package/dist/create/server-source/crates/feltdb/Cargo.lock +0 -175
- package/dist/create/server-source/crates/feltdb/Cargo.toml +0 -102
- package/dist/create/server-source/crates/feltdb/LICENSE +0 -21
- package/dist/create/server-source/crates/feltdb/README.md +0 -75
- package/dist/create/server-source/crates/feltdb/benches/baselines/gate-13-redux.json +0 -370
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +0 -589
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +0 -247
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +0 -440
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +0 -275
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +0 -381
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +0 -467
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +0 -489
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +0 -281
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +0 -606
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +0 -531
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +0 -462
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +0 -505
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +0 -484
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +0 -758
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +0 -421
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +0 -329
- package/dist/create/server-source/crates/feltdb/src/acceptance_tests.rs +0 -1705
- package/dist/create/server-source/crates/feltdb/src/acquisition.rs +0 -286
- package/dist/create/server-source/crates/feltdb/src/admission.rs +0 -185
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +0 -507
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +0 -539
- package/dist/create/server-source/crates/feltdb/src/agent_state.rs +0 -320
- package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +0 -1545
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +0 -521
- package/dist/create/server-source/crates/feltdb/src/application.rs +0 -2674
- package/dist/create/server-source/crates/feltdb/src/application_runtime.rs +0 -1070
- package/dist/create/server-source/crates/feltdb/src/authority.rs +0 -479
- package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +0 -847
- package/dist/create/server-source/crates/feltdb/src/authorization.rs +0 -1030
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +0 -1122
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +0 -32
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +0 -19
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +0 -1474
- package/dist/create/server-source/crates/feltdb/src/capabilities/mod.rs +0 -8
- package/dist/create/server-source/crates/feltdb/src/capabilities/search.rs +0 -418
- package/dist/create/server-source/crates/feltdb/src/capabilities/vector.rs +0 -482
- package/dist/create/server-source/crates/feltdb/src/capability.rs +0 -903
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +0 -263
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -77
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +0 -496
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +0 -2574
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +0 -212
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +0 -422
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +0 -445
- package/dist/create/server-source/crates/feltdb/src/content_distribution.rs +0 -465
- package/dist/create/server-source/crates/feltdb/src/convergence.rs +0 -634
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +0 -427
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +0 -376
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +0 -365
- package/dist/create/server-source/crates/feltdb/src/cron.rs +0 -294
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +0 -483
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +0 -477
- package/dist/create/server-source/crates/feltdb/src/distributed_tests.rs +0 -1003
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +0 -1334
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +0 -368
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +0 -231
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +0 -482
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +0 -203
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +0 -317
- package/dist/create/server-source/crates/feltdb/src/equality_index.rs +0 -595
- package/dist/create/server-source/crates/feltdb/src/execution.rs +0 -426
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +0 -214
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +0 -776
- package/dist/create/server-source/crates/feltdb/src/lib.rs +0 -7274
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +0 -234
- package/dist/create/server-source/crates/feltdb/src/materialization.rs +0 -184
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -266
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +0 -310
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +0 -736
- package/dist/create/server-source/crates/feltdb/src/observability.rs +0 -379
- package/dist/create/server-source/crates/feltdb/src/operation.rs +0 -290
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +0 -439
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +0 -349
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +0 -332
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +0 -718
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +0 -487
- package/dist/create/server-source/crates/feltdb/src/peer_registry.rs +0 -166
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +0 -289
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +0 -566
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +0 -3391
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +0 -203
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +0 -261
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +0 -502
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +0 -216
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +0 -303
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +0 -323
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +0 -531
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +0 -406
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +0 -436
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +0 -387
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +0 -378
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +0 -359
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +0 -372
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +0 -404
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +0 -376
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +0 -461
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +0 -2125
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +0 -418
- package/dist/create/server-source/crates/feltdb/src/provenance.rs +0 -207
- package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +0 -126
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +0 -218
- package/dist/create/server-source/crates/feltdb/src/read_basis.rs +0 -257
- package/dist/create/server-source/crates/feltdb/src/references.rs +0 -404
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +0 -401
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +0 -501
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +0 -680
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +0 -163
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +0 -133
- package/dist/create/server-source/crates/feltdb/src/routing.rs +0 -409
- package/dist/create/server-source/crates/feltdb/src/semantic_decision.rs +0 -2029
- package/dist/create/server-source/crates/feltdb/src/semantic_query.rs +0 -599
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +0 -549
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +0 -516
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +0 -5894
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +0 -222
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +0 -320
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +0 -291
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +0 -4158
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +0 -379
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +0 -673
- package/dist/create/server-source/crates/feltdb/src/storage.rs +0 -273
- package/dist/create/server-source/crates/feltdb/src/submission.rs +0 -471
- package/dist/create/server-source/crates/feltdb/src/sync.rs +0 -495
- package/dist/create/server-source/crates/feltdb/src/sync_contract.rs +0 -822
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +0 -433
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +0 -462
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +0 -965
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +0 -1120
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +0 -643
- package/dist/create/server-source/crates/feltdb/src/trigger.rs +0 -390
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +0 -749
- package/dist/create/server-source/crates/feltdb/src/versioned_object_substrate.rs +0 -708
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +0 -1133
- package/dist/create/server-source/crates/feltdb/src/workflow.rs +0 -713
- package/dist/create/server-source/crates/feltdb/src/workflow_acceptance_tests.rs +0 -510
- package/dist/create/server-source/crates/feltdb/src/workflow_integration.rs +0 -354
- package/dist/create/server-source/crates/feltdb/src/workflow_runtime.rs +0 -594
- package/dist/create/server-source/crates/feltdb/src/workload.rs +0 -2886
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +0 -456
- package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +0 -1188
- package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +0 -2884
- package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +0 -297
- package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +0 -534
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +0 -132
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/common/mod.rs +0 -96
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +0 -272
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +0 -467
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +0 -309
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +0 -445
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +0 -518
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +0 -392
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +0 -850
- package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +0 -23340
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +0 -1916
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +0 -1878
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +0 -1862
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_evidence.rs +0 -123
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/local_process_ownership.rs +0 -69
- package/dist/create/server-source/crates/feltdb/tests/managed_incident_regression.rs +0 -191
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +0 -278
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +0 -234
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +0 -947
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +0 -404
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +0 -916
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +0 -1045
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +0 -347
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +0 -361
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +0 -330
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +0 -93
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +0 -622
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +0 -517
- package/dist/create/server-source/crates/feltdb/tests/production_certification.rs +0 -1153
- package/dist/create/server-source/crates/feltdb/tests/production_contract.rs +0 -771
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +0 -1383
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +0 -868
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +0 -221
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +0 -417
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +0 -338
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +0 -1039
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +0 -739
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +0 -452
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +0 -81
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +0 -438
- package/dist/create/server-source/crates/feltdb/tests/semantic_decision.rs +0 -1165
- package/dist/create/server-source/crates/feltdb/tests/semantic_query.rs +0 -767
- package/dist/create/server-source/crates/feltdb/tests/semantic_query_contract.rs +0 -363
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +0 -1799
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +0 -1316
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +0 -358
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +0 -365
- package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +0 -918
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +0 -299
- package/dist/create/server-source/crates/feltdb/tests/storage_independent_versioned_object_substrate.rs +0 -328
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +0 -255
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +0 -249
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +0 -994
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +0 -484
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +0 -30
|
@@ -1,2886 +0,0 @@
|
|
|
1
|
-
//! Durable workload authority. Workers execute; this state machine decides.
|
|
2
|
-
use crate::{
|
|
3
|
-
authorization::{SignedGrant, Subject},
|
|
4
|
-
AtomicMutation, FeltDb, FlowError, RecordPrecondition,
|
|
5
|
-
};
|
|
6
|
-
use rand::{distributions::Alphanumeric, Rng};
|
|
7
|
-
use serde::{Deserialize, Serialize};
|
|
8
|
-
use serde_json::Value;
|
|
9
|
-
use sha2::{Digest, Sha256};
|
|
10
|
-
use std::collections::{BTreeMap, BTreeSet};
|
|
11
|
-
use std::path::Path;
|
|
12
|
-
pub const WORKLOAD_CONTRACT_VERSION: u32 = 1;
|
|
13
|
-
pub const DURABLE_WORK_COLLECTION: &str = "system_durable_work";
|
|
14
|
-
|
|
15
|
-
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
16
|
-
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
17
|
-
pub enum WorkloadState {
|
|
18
|
-
Created,
|
|
19
|
-
Ready,
|
|
20
|
-
Claimed,
|
|
21
|
-
Running,
|
|
22
|
-
Checkpointed,
|
|
23
|
-
Recovering,
|
|
24
|
-
CancellationRequested,
|
|
25
|
-
Succeeded,
|
|
26
|
-
Failed,
|
|
27
|
-
Cancelled,
|
|
28
|
-
TimedOut,
|
|
29
|
-
DeadLetter,
|
|
30
|
-
}
|
|
31
|
-
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
32
|
-
#[serde(rename_all = "snake_case")]
|
|
33
|
-
pub enum BackoffPolicy {
|
|
34
|
-
Fixed,
|
|
35
|
-
Exponential,
|
|
36
|
-
}
|
|
37
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
38
|
-
pub struct WorkloadRetryPolicy {
|
|
39
|
-
pub max_attempts: u32,
|
|
40
|
-
pub initial_delay_ms: u64,
|
|
41
|
-
pub max_delay_ms: u64,
|
|
42
|
-
pub backoff: BackoffPolicy,
|
|
43
|
-
#[serde(default)]
|
|
44
|
-
pub retry_on: BTreeSet<String>,
|
|
45
|
-
}
|
|
46
|
-
impl Default for WorkloadRetryPolicy {
|
|
47
|
-
fn default() -> Self {
|
|
48
|
-
Self {
|
|
49
|
-
max_attempts: 3,
|
|
50
|
-
initial_delay_ms: 1000,
|
|
51
|
-
max_delay_ms: 300_000,
|
|
52
|
-
backoff: BackoffPolicy::Exponential,
|
|
53
|
-
retry_on: [
|
|
54
|
-
"timeout".into(),
|
|
55
|
-
"worker_failure".into(),
|
|
56
|
-
"transient_connection".into(),
|
|
57
|
-
]
|
|
58
|
-
.into(),
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
63
|
-
pub struct WorkloadTimeouts {
|
|
64
|
-
pub queue_ms: Option<u64>,
|
|
65
|
-
pub claim_ms: Option<u64>,
|
|
66
|
-
pub execution_ms: Option<u64>,
|
|
67
|
-
pub heartbeat_ms: Option<u64>,
|
|
68
|
-
pub overall_ms: Option<u64>,
|
|
69
|
-
}
|
|
70
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
71
|
-
pub struct WorkloadDependency {
|
|
72
|
-
pub workload_id: String,
|
|
73
|
-
#[serde(default = "succeeded_state")]
|
|
74
|
-
pub required_state: WorkloadState,
|
|
75
|
-
}
|
|
76
|
-
fn succeeded_state() -> WorkloadState {
|
|
77
|
-
WorkloadState::Succeeded
|
|
78
|
-
}
|
|
79
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
80
|
-
pub struct WorkloadClaim {
|
|
81
|
-
pub claim_id: String,
|
|
82
|
-
pub workload_id: String,
|
|
83
|
-
pub worker_id: String,
|
|
84
|
-
pub lease_id: String,
|
|
85
|
-
pub fencing_token: u64,
|
|
86
|
-
pub capability_snapshot: BTreeSet<String>,
|
|
87
|
-
pub claimed_at: i64,
|
|
88
|
-
pub expires_at: i64,
|
|
89
|
-
pub last_heartbeat: i64,
|
|
90
|
-
}
|
|
91
|
-
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
92
|
-
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
93
|
-
pub enum AttemptStatus {
|
|
94
|
-
Claimed,
|
|
95
|
-
Running,
|
|
96
|
-
Checkpointed,
|
|
97
|
-
Succeeded,
|
|
98
|
-
Failed,
|
|
99
|
-
TimedOut,
|
|
100
|
-
Cancelled,
|
|
101
|
-
WorkerLost,
|
|
102
|
-
}
|
|
103
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
104
|
-
pub struct WorkloadAttempt {
|
|
105
|
-
pub attempt_id: String,
|
|
106
|
-
pub workload_id: String,
|
|
107
|
-
pub worker_id: String,
|
|
108
|
-
pub claim_id: String,
|
|
109
|
-
pub fencing_token: u64,
|
|
110
|
-
pub started_at: Option<i64>,
|
|
111
|
-
pub ended_at: Option<i64>,
|
|
112
|
-
pub input_hash: String,
|
|
113
|
-
pub result_hash: Option<String>,
|
|
114
|
-
pub error: Option<String>,
|
|
115
|
-
#[serde(default)]
|
|
116
|
-
pub resource_usage: BTreeMap<String, u64>,
|
|
117
|
-
pub status: AttemptStatus,
|
|
118
|
-
}
|
|
119
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
120
|
-
pub struct WorkloadCheckpoint {
|
|
121
|
-
pub checkpoint_id: String,
|
|
122
|
-
pub workload_id: String,
|
|
123
|
-
pub attempt_id: String,
|
|
124
|
-
pub sequence: u64,
|
|
125
|
-
pub state: Value,
|
|
126
|
-
pub state_hash: String,
|
|
127
|
-
pub created_at: i64,
|
|
128
|
-
pub produced_by: String,
|
|
129
|
-
}
|
|
130
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
131
|
-
pub struct WorkloadResult {
|
|
132
|
-
pub result_id: String,
|
|
133
|
-
pub workload_id: String,
|
|
134
|
-
pub attempt_id: String,
|
|
135
|
-
pub status: String,
|
|
136
|
-
pub output: Value,
|
|
137
|
-
pub output_hash: String,
|
|
138
|
-
#[serde(default)]
|
|
139
|
-
pub artifacts: Vec<String>,
|
|
140
|
-
#[serde(default)]
|
|
141
|
-
pub provenance: BTreeMap<String, String>,
|
|
142
|
-
pub produced_by: String,
|
|
143
|
-
pub created_at: i64,
|
|
144
|
-
pub schema_version: u64,
|
|
145
|
-
}
|
|
146
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
147
|
-
pub struct WorkloadExecutionContext {
|
|
148
|
-
pub workload_id: String,
|
|
149
|
-
pub attempt_id: String,
|
|
150
|
-
pub worker_id: String,
|
|
151
|
-
pub capabilities: BTreeSet<String>,
|
|
152
|
-
pub connection_scopes: BTreeSet<String>,
|
|
153
|
-
pub secret_references: Vec<String>,
|
|
154
|
-
pub expires_at: i64,
|
|
155
|
-
pub fencing_token: u64,
|
|
156
|
-
}
|
|
157
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
158
|
-
pub struct WorkloadEvent {
|
|
159
|
-
pub sequence: u64,
|
|
160
|
-
pub event_type: String,
|
|
161
|
-
pub from: Option<WorkloadState>,
|
|
162
|
-
pub to: WorkloadState,
|
|
163
|
-
pub actor: String,
|
|
164
|
-
pub at: i64,
|
|
165
|
-
#[serde(default)]
|
|
166
|
-
pub evidence: BTreeMap<String, String>,
|
|
167
|
-
}
|
|
168
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
169
|
-
pub struct Workload {
|
|
170
|
-
pub contract_version: u32,
|
|
171
|
-
pub workload_id: String,
|
|
172
|
-
pub tenant_id: String,
|
|
173
|
-
pub application_id: String,
|
|
174
|
-
pub environment: String,
|
|
175
|
-
pub definition_id: String,
|
|
176
|
-
pub definition_revision: String,
|
|
177
|
-
pub trigger: String,
|
|
178
|
-
pub input: Value,
|
|
179
|
-
pub input_hash: String,
|
|
180
|
-
pub requested_by: Subject,
|
|
181
|
-
#[serde(default)]
|
|
182
|
-
pub session_id: Option<String>,
|
|
183
|
-
pub authorization_snapshot: SignedGrant,
|
|
184
|
-
pub capability_snapshot: BTreeSet<String>,
|
|
185
|
-
pub connection_scopes: BTreeSet<String>,
|
|
186
|
-
pub priority: i32,
|
|
187
|
-
pub idempotency_key: String,
|
|
188
|
-
pub created_at: i64,
|
|
189
|
-
pub updated_at: i64,
|
|
190
|
-
pub state: WorkloadState,
|
|
191
|
-
pub execution_history: Vec<WorkloadEvent>,
|
|
192
|
-
pub attempts: Vec<WorkloadAttempt>,
|
|
193
|
-
pub checkpoints: Vec<WorkloadCheckpoint>,
|
|
194
|
-
pub result: Option<WorkloadResult>,
|
|
195
|
-
pub retry_policy: WorkloadRetryPolicy,
|
|
196
|
-
pub timeouts: WorkloadTimeouts,
|
|
197
|
-
pub attempt_count: u32,
|
|
198
|
-
pub retry_count: u32,
|
|
199
|
-
/// The instant, in unix seconds, before which this workload must not be
|
|
200
|
-
/// claimed. `None` means "eligible as soon as it is `Ready`".
|
|
201
|
-
///
|
|
202
|
-
/// This is the single temporal eligibility concept in the workload model.
|
|
203
|
-
/// It is what retry backoff sets after a failure, what a timeout sets before
|
|
204
|
-
/// re-arming, and what [`CreateWorkload::not_before`] sets at creation, so a
|
|
205
|
-
/// workload that is not yet executable is always not-yet-executable for the
|
|
206
|
-
/// same reason and is refused by the same check in [`WorkloadStore::claim`].
|
|
207
|
-
pub next_attempt_at: Option<i64>,
|
|
208
|
-
pub last_failure: Option<String>,
|
|
209
|
-
pub current_claim: Option<WorkloadClaim>,
|
|
210
|
-
pub fencing_token: u64,
|
|
211
|
-
#[serde(default)]
|
|
212
|
-
pub dependencies: Vec<WorkloadDependency>,
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
impl Workload {
|
|
216
|
-
/// Has this workload's eligibility instant arrived?
|
|
217
|
-
///
|
|
218
|
-
/// Eligibility is only half of claimability — the state must also be
|
|
219
|
-
/// `Ready`, and [`WorkloadStore::claim`] checks both. This answers the
|
|
220
|
-
/// temporal half alone so a scheduler can skip a workload that is merely
|
|
221
|
-
/// early without attempting a claim it knows will be refused.
|
|
222
|
-
pub fn is_eligible_at(&self, now: i64) -> bool {
|
|
223
|
-
self.next_attempt_at.is_none_or(|instant| instant <= now)
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
228
|
-
pub struct CreateWorkload {
|
|
229
|
-
pub tenant_id: String,
|
|
230
|
-
pub application_id: String,
|
|
231
|
-
pub environment: String,
|
|
232
|
-
pub definition_id: String,
|
|
233
|
-
pub definition_revision: String,
|
|
234
|
-
pub trigger: String,
|
|
235
|
-
pub input: Value,
|
|
236
|
-
pub requested_by: Subject,
|
|
237
|
-
#[serde(default)]
|
|
238
|
-
pub session_id: Option<String>,
|
|
239
|
-
pub authorization_snapshot: SignedGrant,
|
|
240
|
-
pub capability_snapshot: BTreeSet<String>,
|
|
241
|
-
#[serde(default)]
|
|
242
|
-
pub connection_scopes: BTreeSet<String>,
|
|
243
|
-
#[serde(default)]
|
|
244
|
-
pub priority: i32,
|
|
245
|
-
pub idempotency_key: String,
|
|
246
|
-
#[serde(default)]
|
|
247
|
-
pub retry_policy: WorkloadRetryPolicy,
|
|
248
|
-
#[serde(default)]
|
|
249
|
-
pub timeouts: WorkloadTimeouts,
|
|
250
|
-
#[serde(default)]
|
|
251
|
-
pub dependencies: Vec<WorkloadDependency>,
|
|
252
|
-
/// The instant, in unix seconds, before which this workload must not be
|
|
253
|
-
/// claimed. Omitted or `None` means it is claimable as soon as it is
|
|
254
|
-
/// `Ready`, which is what every workload did before this field existed.
|
|
255
|
-
///
|
|
256
|
-
/// A workload created with `not_before` is durable immediately and
|
|
257
|
-
/// executable later: it reaches `Ready`, survives restart, and is refused to
|
|
258
|
-
/// every worker until the instant arrives. It is *not* a schedule — there is
|
|
259
|
-
/// no recurrence, no expression, and no component that re-arms it. Creating
|
|
260
|
-
/// the next occurrence is the caller's business.
|
|
261
|
-
///
|
|
262
|
-
/// A `not_before` already in the past is not an error. It simply describes a
|
|
263
|
-
/// workload that is eligible now, which is the same thing as omitting it.
|
|
264
|
-
#[serde(default)]
|
|
265
|
-
pub not_before: Option<i64>,
|
|
266
|
-
pub created_at: i64,
|
|
267
|
-
}
|
|
268
|
-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
269
|
-
pub struct WorkloadError {
|
|
270
|
-
pub code: String,
|
|
271
|
-
pub message: String,
|
|
272
|
-
}
|
|
273
|
-
impl WorkloadError {
|
|
274
|
-
fn new(code: &str, message: impl Into<String>) -> Self {
|
|
275
|
-
Self {
|
|
276
|
-
code: code.into(),
|
|
277
|
-
message: message.into(),
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
impl std::fmt::Display for WorkloadError {
|
|
282
|
-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
283
|
-
write!(f, "{}: {}", self.code, self.message)
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
impl std::error::Error for WorkloadError {}
|
|
287
|
-
|
|
288
|
-
#[derive(Clone)]
|
|
289
|
-
pub struct WorkloadStore {
|
|
290
|
-
db: FeltDb,
|
|
291
|
-
pub workloads: BTreeMap<String, Workload>,
|
|
292
|
-
idempotency: BTreeMap<String, String>,
|
|
293
|
-
result_ids: BTreeMap<String, String>,
|
|
294
|
-
/// Rebuildable cache of FeltDB-owned document versions. This is never
|
|
295
|
-
/// serialized and never advances independently of a canonical commit.
|
|
296
|
-
document_versions: BTreeMap<String, u64>,
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
#[derive(Serialize, Deserialize)]
|
|
300
|
-
struct LegacyWorkloadStore {
|
|
301
|
-
#[serde(default)]
|
|
302
|
-
workloads: BTreeMap<String, Workload>,
|
|
303
|
-
#[serde(default)]
|
|
304
|
-
idempotency: BTreeMap<String, String>,
|
|
305
|
-
#[serde(default)]
|
|
306
|
-
result_ids: BTreeMap<String, String>,
|
|
307
|
-
#[serde(default)]
|
|
308
|
-
event_sequence: u64,
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
impl WorkloadStore {
|
|
312
|
-
pub fn load(path: impl AsRef<Path>) -> Result<Self, WorkloadError> {
|
|
313
|
-
Self::from_db(FeltDb::open(path).map_err(io)?)
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
pub fn from_db(db: FeltDb) -> Result<Self, WorkloadError> {
|
|
317
|
-
let mut workloads = BTreeMap::new();
|
|
318
|
-
let mut document_versions = BTreeMap::new();
|
|
319
|
-
for row in db.list_collection(DURABLE_WORK_COLLECTION).map_err(io)? {
|
|
320
|
-
let version = row
|
|
321
|
-
.value
|
|
322
|
-
.get("__version")
|
|
323
|
-
.and_then(Value::as_u64)
|
|
324
|
-
// Canonical FeltDB CAS defines a pre-versioned document as
|
|
325
|
-
// version 1. The next guarded mutation writes version 2, which
|
|
326
|
-
// upgrades rows created before Work adopted RecordPrecondition.
|
|
327
|
-
.unwrap_or(1);
|
|
328
|
-
let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
|
|
329
|
-
document_versions.insert(workload.workload_id.clone(), version);
|
|
330
|
-
workloads.insert(workload.workload_id.clone(), workload);
|
|
331
|
-
}
|
|
332
|
-
let idempotency = idempotency_index(&workloads, idempotency_scope);
|
|
333
|
-
let mut result_ids = BTreeMap::new();
|
|
334
|
-
for workload in workloads.values() {
|
|
335
|
-
if let Some(result) = &workload.result {
|
|
336
|
-
result_ids.insert(result.result_id.clone(), workload.workload_id.clone());
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
Ok(Self {
|
|
340
|
-
db,
|
|
341
|
-
workloads,
|
|
342
|
-
idempotency,
|
|
343
|
-
result_ids,
|
|
344
|
-
document_versions,
|
|
345
|
-
})
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
pub fn migrate_legacy_file(db: FeltDb, path: &Path) -> Result<Self, WorkloadError> {
|
|
349
|
-
let bytes = std::fs::read(path).map_err(io)?;
|
|
350
|
-
let legacy: LegacyWorkloadStore = serde_json::from_slice(&bytes).map_err(io)?;
|
|
351
|
-
let mut store = Self::from_db(db)?;
|
|
352
|
-
for (id, workload) in legacy.workloads {
|
|
353
|
-
if id != workload.workload_id {
|
|
354
|
-
return Err(WorkloadError::new(
|
|
355
|
-
"WORKLOAD_MIGRATION_CONFLICT",
|
|
356
|
-
format!(
|
|
357
|
-
"legacy key {id} does not match workload identity {}",
|
|
358
|
-
workload.workload_id
|
|
359
|
-
),
|
|
360
|
-
));
|
|
361
|
-
}
|
|
362
|
-
if let Some(existing) = store.workloads.get(&id) {
|
|
363
|
-
if serde_json::to_value(existing).map_err(io)?
|
|
364
|
-
!= serde_json::to_value(&workload).map_err(io)?
|
|
365
|
-
{
|
|
366
|
-
return Err(WorkloadError::new(
|
|
367
|
-
"WORKLOAD_MIGRATION_CONFLICT",
|
|
368
|
-
format!("canonical workload {id} differs from legacy source"),
|
|
369
|
-
));
|
|
370
|
-
}
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
store.workloads.insert(id.clone(), workload);
|
|
374
|
-
store.persist_workload(&id, "workload_migration")?;
|
|
375
|
-
}
|
|
376
|
-
// The maps are verification evidence, not migrated authorities.
|
|
377
|
-
let rebuilt = Self::from_db(store.db.clone())?;
|
|
378
|
-
if legacy.idempotency != idempotency_index(&rebuilt.workloads, legacy_idempotency_scope)
|
|
379
|
-
|| legacy.result_ids != rebuilt.result_ids
|
|
380
|
-
|| legacy.event_sequence != derived_event_sequence(&rebuilt.workloads)
|
|
381
|
-
{
|
|
382
|
-
return Err(WorkloadError::new(
|
|
383
|
-
"WORKLOAD_MIGRATION_CONFLICT",
|
|
384
|
-
"legacy derived indexes do not match canonical work aggregates",
|
|
385
|
-
));
|
|
386
|
-
}
|
|
387
|
-
Ok(rebuilt)
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
fn persist_workload(&mut self, id: &str, action: &str) -> Result<(), WorkloadError> {
|
|
391
|
-
let workload = self
|
|
392
|
-
.workloads
|
|
393
|
-
.get(id)
|
|
394
|
-
.ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))?;
|
|
395
|
-
let key = work_key(workload);
|
|
396
|
-
let expected_version = self.document_versions.get(id).copied();
|
|
397
|
-
let transaction_id = runtime_id("work_tx");
|
|
398
|
-
let aggregate_digest = hash(&serde_json::to_value(workload).map_err(io)?);
|
|
399
|
-
let commit = self
|
|
400
|
-
.db
|
|
401
|
-
.apply_atomic_transaction_guarded(
|
|
402
|
-
&transaction_id,
|
|
403
|
-
None,
|
|
404
|
-
None,
|
|
405
|
-
&[],
|
|
406
|
-
&[RecordPrecondition {
|
|
407
|
-
capability: DURABLE_WORK_COLLECTION.into(),
|
|
408
|
-
key: key.clone(),
|
|
409
|
-
require_absent: expected_version.is_none(),
|
|
410
|
-
expected_version,
|
|
411
|
-
expected_epoch: None,
|
|
412
|
-
expected_lease_id: None,
|
|
413
|
-
}],
|
|
414
|
-
&[AtomicMutation {
|
|
415
|
-
capability: DURABLE_WORK_COLLECTION.into(),
|
|
416
|
-
key,
|
|
417
|
-
value: Some(serde_json::to_value(workload).map_err(io)?),
|
|
418
|
-
}],
|
|
419
|
-
Some(serde_json::json!({
|
|
420
|
-
"kind": action,
|
|
421
|
-
"subject": workload.requested_by.id(),
|
|
422
|
-
"principal_id": workload.requested_by.id(),
|
|
423
|
-
"agent_id": (workload.requested_by.kind() == "agent").then(|| workload.requested_by.id()),
|
|
424
|
-
"session_id": workload.session_id,
|
|
425
|
-
"delegator_id": workload.authorization_snapshot.grant.issuer.id(),
|
|
426
|
-
"authorization_decision_id": workload.authorization_snapshot.grant.id,
|
|
427
|
-
"tenant": workload.tenant_id,
|
|
428
|
-
"application": workload.application_id,
|
|
429
|
-
"work_id": workload.workload_id,
|
|
430
|
-
"operation_id": transaction_id,
|
|
431
|
-
"attempt_id": workload.attempts.last().map(|attempt| attempt.attempt_id.as_str()),
|
|
432
|
-
"worker_id": workload.current_claim.as_ref().map(|claim| claim.worker_id.as_str()),
|
|
433
|
-
"result_id": workload.result.as_ref().map(|result| result.result_id.as_str()),
|
|
434
|
-
"source_kind": (action == "workload_migration").then_some("WorkloadStore"),
|
|
435
|
-
"source_digest": aggregate_digest,
|
|
436
|
-
"migration_outcome": (action == "workload_migration").then_some("imported"),
|
|
437
|
-
})),
|
|
438
|
-
)
|
|
439
|
-
.map_err(workload_storage_error)?;
|
|
440
|
-
let version = commit
|
|
441
|
-
.rows
|
|
442
|
-
.first()
|
|
443
|
-
.and_then(|row| row.value.get("__version"))
|
|
444
|
-
.and_then(Value::as_u64)
|
|
445
|
-
.ok_or_else(|| io("canonical work commit has no document version"))?;
|
|
446
|
-
self.document_versions.insert(id.into(), version);
|
|
447
|
-
Ok(())
|
|
448
|
-
}
|
|
449
|
-
pub fn create(&mut self, input: CreateWorkload) -> Result<Workload, WorkloadError> {
|
|
450
|
-
self.admit(input).map(|(workload, _)| workload)
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/// Create a workload, or return the one this requester already created
|
|
454
|
-
/// under the same key.
|
|
455
|
-
///
|
|
456
|
-
/// The key is scoped to the tenant, the application **and the requester**,
|
|
457
|
-
/// so one requester's key can never return, reveal or block another's
|
|
458
|
-
/// workload. A repeated key is answered with the original workload only when
|
|
459
|
-
/// the request is the same — definition, revision, environment, input and
|
|
460
|
-
/// capability snapshot; otherwise it is refused with
|
|
461
|
-
/// `IDEMPOTENCY_KEY_REUSED` and nothing is created. The requester's
|
|
462
|
-
/// authorization snapshot is checked before any replay. The boolean is
|
|
463
|
-
/// `true` when this call created the workload.
|
|
464
|
-
pub fn admit(&mut self, input: CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
|
|
465
|
-
ensure_safe(&input.input)?;
|
|
466
|
-
if input.idempotency_key.trim().is_empty() {
|
|
467
|
-
return Err(WorkloadError::new(
|
|
468
|
-
"WORKLOAD_INVALID",
|
|
469
|
-
"idempotency key is required",
|
|
470
|
-
));
|
|
471
|
-
}
|
|
472
|
-
if input.authorization_snapshot.grant.subject != input.requested_by
|
|
473
|
-
|| input.authorization_snapshot.grant.tenant_id != input.tenant_id
|
|
474
|
-
|| input.authorization_snapshot.grant.application_id != input.application_id
|
|
475
|
-
{
|
|
476
|
-
return Err(WorkloadError::new(
|
|
477
|
-
"WORKLOAD_UNAUTHORIZED",
|
|
478
|
-
"authorization snapshot does not bind the requester",
|
|
479
|
-
));
|
|
480
|
-
}
|
|
481
|
-
let idempotency = idempotency_scope(
|
|
482
|
-
&input.tenant_id,
|
|
483
|
-
&input.application_id,
|
|
484
|
-
&input.requested_by,
|
|
485
|
-
&input.idempotency_key,
|
|
486
|
-
);
|
|
487
|
-
if let Some(id) = self.idempotency.get(&idempotency) {
|
|
488
|
-
let existing = self.workloads[id].clone();
|
|
489
|
-
return replay(existing, &input);
|
|
490
|
-
}
|
|
491
|
-
// The scoped idempotency identity is the record identity. Competing
|
|
492
|
-
// admissions therefore contend on one canonical require-absent CAS
|
|
493
|
-
// even when their payloads differ; no separate durable uniqueness
|
|
494
|
-
// index is needed.
|
|
495
|
-
let id = format!("workload_{}", &hex(&Sha256::digest(&idempotency))[..24]);
|
|
496
|
-
let request = input.clone();
|
|
497
|
-
let input_hash = hash(&input.input);
|
|
498
|
-
let mut workload = Workload {
|
|
499
|
-
contract_version: 1,
|
|
500
|
-
workload_id: id.clone(),
|
|
501
|
-
tenant_id: input.tenant_id,
|
|
502
|
-
application_id: input.application_id,
|
|
503
|
-
environment: input.environment,
|
|
504
|
-
definition_id: input.definition_id,
|
|
505
|
-
definition_revision: input.definition_revision,
|
|
506
|
-
trigger: input.trigger,
|
|
507
|
-
input: input.input,
|
|
508
|
-
input_hash,
|
|
509
|
-
requested_by: input.requested_by,
|
|
510
|
-
session_id: input.session_id,
|
|
511
|
-
authorization_snapshot: input.authorization_snapshot,
|
|
512
|
-
capability_snapshot: input.capability_snapshot,
|
|
513
|
-
connection_scopes: input.connection_scopes,
|
|
514
|
-
priority: input.priority,
|
|
515
|
-
idempotency_key: input.idempotency_key,
|
|
516
|
-
created_at: input.created_at,
|
|
517
|
-
updated_at: input.created_at,
|
|
518
|
-
state: WorkloadState::Created,
|
|
519
|
-
execution_history: vec![],
|
|
520
|
-
attempts: vec![],
|
|
521
|
-
checkpoints: vec![],
|
|
522
|
-
result: None,
|
|
523
|
-
retry_policy: input.retry_policy,
|
|
524
|
-
timeouts: input.timeouts,
|
|
525
|
-
attempt_count: 0,
|
|
526
|
-
retry_count: 0,
|
|
527
|
-
// Creation-time eligibility and retry backoff are the same thing, so
|
|
528
|
-
// they are the same field. A workload with `not_before` is created
|
|
529
|
-
// exactly as any other and is simply not yet claimable.
|
|
530
|
-
next_attempt_at: input.not_before,
|
|
531
|
-
last_failure: None,
|
|
532
|
-
current_claim: None,
|
|
533
|
-
fencing_token: 0,
|
|
534
|
-
dependencies: input.dependencies,
|
|
535
|
-
};
|
|
536
|
-
// The durable history records the eligibility the workload was created
|
|
537
|
-
// with, so "why was this Ready and unclaimed for an hour" is answerable
|
|
538
|
-
// from the workload itself rather than from the caller's intent.
|
|
539
|
-
let evidence = match workload.next_attempt_at {
|
|
540
|
-
Some(instant) => [("not_before".into(), instant.to_string())].into(),
|
|
541
|
-
None => BTreeMap::new(),
|
|
542
|
-
};
|
|
543
|
-
self.transition(
|
|
544
|
-
&mut workload,
|
|
545
|
-
WorkloadState::Ready,
|
|
546
|
-
"rust",
|
|
547
|
-
input.created_at,
|
|
548
|
-
evidence,
|
|
549
|
-
)?;
|
|
550
|
-
let key = work_key(&workload);
|
|
551
|
-
self.idempotency.insert(idempotency.clone(), id.clone());
|
|
552
|
-
self.workloads.insert(id.clone(), workload.clone());
|
|
553
|
-
if let Err(error) = self.persist_workload(&id, "workload.created") {
|
|
554
|
-
self.workloads.remove(&id);
|
|
555
|
-
self.idempotency.remove(&idempotency);
|
|
556
|
-
// Another writer of the same store (a second process, or a replica)
|
|
557
|
-
// admitted this identity first. Converge on what it committed.
|
|
558
|
-
if error.code == "WORKLOAD_CONFLICT" {
|
|
559
|
-
if let Some(existing) = self.reload_committed(&key)? {
|
|
560
|
-
self.idempotency.insert(idempotency, id);
|
|
561
|
-
return replay(existing, &request);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
return Err(error);
|
|
565
|
-
}
|
|
566
|
-
Ok((workload, true))
|
|
567
|
-
}
|
|
568
|
-
pub fn get(&self, id: &str) -> Result<&Workload, WorkloadError> {
|
|
569
|
-
self.workloads
|
|
570
|
-
.get(id)
|
|
571
|
-
.ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))
|
|
572
|
-
}
|
|
573
|
-
pub fn list(&self, tenant: &str, application: &str) -> Vec<Workload> {
|
|
574
|
-
let mut values = self
|
|
575
|
-
.workloads
|
|
576
|
-
.values()
|
|
577
|
-
.filter(|v| v.tenant_id == tenant && v.application_id == application)
|
|
578
|
-
.cloned()
|
|
579
|
-
.collect::<Vec<_>>();
|
|
580
|
-
values.sort_by_key(|v| (std::cmp::Reverse(v.priority), v.created_at));
|
|
581
|
-
values
|
|
582
|
-
}
|
|
583
|
-
fn edit<R>(
|
|
584
|
-
&mut self,
|
|
585
|
-
id: &str,
|
|
586
|
-
action: impl FnOnce(&mut Self, &mut Workload) -> Result<R, WorkloadError>,
|
|
587
|
-
) -> Result<R, WorkloadError> {
|
|
588
|
-
let workload = self
|
|
589
|
-
.workloads
|
|
590
|
-
.get(id)
|
|
591
|
-
.cloned()
|
|
592
|
-
.ok_or_else(|| WorkloadError::new("WORKLOAD_NOT_FOUND", "workload not found"))?;
|
|
593
|
-
let mut edited = workload.clone();
|
|
594
|
-
let result = action(self, &mut edited)?;
|
|
595
|
-
self.workloads.insert(id.into(), edited);
|
|
596
|
-
if let Err(error) = self.persist_workload(id, "workload.mutated") {
|
|
597
|
-
self.workloads.insert(id.into(), workload.clone());
|
|
598
|
-
// Another writer of the same store committed this workload first.
|
|
599
|
-
// Nothing of this edit was committed; adopt what was, so this
|
|
600
|
-
// projection does not keep acting on a version that no longer
|
|
601
|
-
// exists (every later guarded commit from it would conflict).
|
|
602
|
-
if error.code == "WORKLOAD_CONFLICT" {
|
|
603
|
-
self.reload_committed(&work_key(&workload))?;
|
|
604
|
-
}
|
|
605
|
-
return Err(error);
|
|
606
|
-
}
|
|
607
|
-
Ok(result)
|
|
608
|
-
}
|
|
609
|
-
#[cfg(test)]
|
|
610
|
-
pub(crate) fn db_for_tests(&self) -> FeltDb {
|
|
611
|
-
self.db.clone()
|
|
612
|
-
}
|
|
613
|
-
/// Replace this projection's copy of a workload with the committed row.
|
|
614
|
-
fn reload_committed(&mut self, key: &str) -> Result<Option<Workload>, WorkloadError> {
|
|
615
|
-
let committed = self
|
|
616
|
-
.db
|
|
617
|
-
.query_collection(DURABLE_WORK_COLLECTION, Some(1), |row| row.key == key)
|
|
618
|
-
.map_err(io)?;
|
|
619
|
-
let Some(row) = committed.into_iter().next() else {
|
|
620
|
-
return Ok(None);
|
|
621
|
-
};
|
|
622
|
-
let version = row
|
|
623
|
-
.value
|
|
624
|
-
.get("__version")
|
|
625
|
-
.and_then(Value::as_u64)
|
|
626
|
-
.unwrap_or(1);
|
|
627
|
-
let workload: Workload = serde_json::from_value(row.value).map_err(io)?;
|
|
628
|
-
if let Some(result) = &workload.result {
|
|
629
|
-
self.result_ids
|
|
630
|
-
.insert(result.result_id.clone(), workload.workload_id.clone());
|
|
631
|
-
}
|
|
632
|
-
self.document_versions
|
|
633
|
-
.insert(workload.workload_id.clone(), version);
|
|
634
|
-
self.workloads
|
|
635
|
-
.insert(workload.workload_id.clone(), workload.clone());
|
|
636
|
-
Ok(Some(workload))
|
|
637
|
-
}
|
|
638
|
-
fn transition(
|
|
639
|
-
&mut self,
|
|
640
|
-
w: &mut Workload,
|
|
641
|
-
to: WorkloadState,
|
|
642
|
-
actor: &str,
|
|
643
|
-
at: i64,
|
|
644
|
-
evidence: BTreeMap<String, String>,
|
|
645
|
-
) -> Result<(), WorkloadError> {
|
|
646
|
-
if !legal(w.state, to) {
|
|
647
|
-
return Err(WorkloadError::new(
|
|
648
|
-
"WORKLOAD_TRANSITION_DENIED",
|
|
649
|
-
format!("{:?} cannot transition to {:?}", w.state, to),
|
|
650
|
-
));
|
|
651
|
-
}
|
|
652
|
-
let from = w.state;
|
|
653
|
-
w.state = to;
|
|
654
|
-
w.updated_at = at;
|
|
655
|
-
let sequence = w
|
|
656
|
-
.execution_history
|
|
657
|
-
.last()
|
|
658
|
-
.map_or(1, |event| event.sequence + 1);
|
|
659
|
-
w.execution_history.push(WorkloadEvent {
|
|
660
|
-
sequence,
|
|
661
|
-
event_type: event_name(to).into(),
|
|
662
|
-
from: Some(from),
|
|
663
|
-
to,
|
|
664
|
-
actor: actor.into(),
|
|
665
|
-
at,
|
|
666
|
-
evidence,
|
|
667
|
-
});
|
|
668
|
-
Ok(())
|
|
669
|
-
}
|
|
670
|
-
pub fn claim(
|
|
671
|
-
&mut self,
|
|
672
|
-
id: &str,
|
|
673
|
-
worker: &str,
|
|
674
|
-
capabilities: &BTreeSet<String>,
|
|
675
|
-
now: i64,
|
|
676
|
-
lease_ms: u64,
|
|
677
|
-
) -> Result<WorkloadClaim, WorkloadError> {
|
|
678
|
-
// A Ready workload that has already used its budget (an operator
|
|
679
|
-
// re-arm beyond it, or a record left by unbounded recovery before it
|
|
680
|
-
// was bounded) is dead-lettered here rather than claimed again.
|
|
681
|
-
let exhausted = self
|
|
682
|
-
.get(id)
|
|
683
|
-
.is_ok_and(|w| w.state == WorkloadState::Ready && w.attempt_count >= attempt_budget(w));
|
|
684
|
-
if exhausted {
|
|
685
|
-
self.edit(id, |store, w| {
|
|
686
|
-
let evidence = [
|
|
687
|
-
("reason".into(), "attempt_budget_exhausted".into()),
|
|
688
|
-
("attempts_used".into(), w.attempt_count.to_string()),
|
|
689
|
-
("attempt_budget".into(), attempt_budget(w).to_string()),
|
|
690
|
-
]
|
|
691
|
-
.into();
|
|
692
|
-
store.transition(w, WorkloadState::DeadLetter, "rust.claim", now, evidence)
|
|
693
|
-
})?;
|
|
694
|
-
return Err(WorkloadError::new(
|
|
695
|
-
"WORKLOAD_ATTEMPTS_EXHAUSTED",
|
|
696
|
-
"workload has used its attempt budget",
|
|
697
|
-
));
|
|
698
|
-
}
|
|
699
|
-
self.edit(id, |store, w| {
|
|
700
|
-
if w.state != WorkloadState::Ready {
|
|
701
|
-
return Err(WorkloadError::new(
|
|
702
|
-
"WORKLOAD_NOT_READY",
|
|
703
|
-
"workload is not eligible",
|
|
704
|
-
));
|
|
705
|
-
}
|
|
706
|
-
// Separated from the state check because the two mean opposite
|
|
707
|
-
// things to a worker: a workload in the wrong state may never become
|
|
708
|
-
// claimable, while one that is merely early certainly will, at an
|
|
709
|
-
// instant the error names. Collapsing them into one code left a
|
|
710
|
-
// polling worker unable to tell "come back" from "give up".
|
|
711
|
-
if !w.is_eligible_at(now) {
|
|
712
|
-
return Err(WorkloadError::new(
|
|
713
|
-
"WORKLOAD_NOT_YET_ELIGIBLE",
|
|
714
|
-
format!(
|
|
715
|
-
"workload is not claimable until {}",
|
|
716
|
-
w.next_attempt_at.unwrap_or(now)
|
|
717
|
-
),
|
|
718
|
-
));
|
|
719
|
-
}
|
|
720
|
-
if !w.capability_snapshot.is_subset(capabilities) {
|
|
721
|
-
return Err(WorkloadError::new(
|
|
722
|
-
"WORKLOAD_CAPABILITY_DENIED",
|
|
723
|
-
"worker lacks required capability snapshot",
|
|
724
|
-
));
|
|
725
|
-
}
|
|
726
|
-
w.fencing_token += 1;
|
|
727
|
-
w.attempt_count += 1;
|
|
728
|
-
let seed = format!("{}:{}:{}", w.workload_id, w.fencing_token, worker);
|
|
729
|
-
let claim = WorkloadClaim {
|
|
730
|
-
claim_id: format!("claim_{}", &hex(&Sha256::digest(seed.as_bytes()))[..20]),
|
|
731
|
-
workload_id: w.workload_id.clone(),
|
|
732
|
-
worker_id: worker.into(),
|
|
733
|
-
lease_id: format!("lease_{}", w.fencing_token),
|
|
734
|
-
fencing_token: w.fencing_token,
|
|
735
|
-
capability_snapshot: w.capability_snapshot.clone(),
|
|
736
|
-
claimed_at: now,
|
|
737
|
-
expires_at: now + (lease_ms / 1000) as i64,
|
|
738
|
-
last_heartbeat: now,
|
|
739
|
-
};
|
|
740
|
-
w.attempts.push(WorkloadAttempt {
|
|
741
|
-
attempt_id: format!("attempt_{}_{}", w.workload_id, w.attempt_count),
|
|
742
|
-
workload_id: w.workload_id.clone(),
|
|
743
|
-
worker_id: worker.into(),
|
|
744
|
-
claim_id: claim.claim_id.clone(),
|
|
745
|
-
fencing_token: claim.fencing_token,
|
|
746
|
-
started_at: None,
|
|
747
|
-
ended_at: None,
|
|
748
|
-
input_hash: w.input_hash.clone(),
|
|
749
|
-
result_hash: None,
|
|
750
|
-
error: None,
|
|
751
|
-
resource_usage: BTreeMap::new(),
|
|
752
|
-
status: AttemptStatus::Claimed,
|
|
753
|
-
});
|
|
754
|
-
w.current_claim = Some(claim.clone());
|
|
755
|
-
store.transition(
|
|
756
|
-
w,
|
|
757
|
-
WorkloadState::Claimed,
|
|
758
|
-
worker,
|
|
759
|
-
now,
|
|
760
|
-
[("fencing_token".into(), claim.fencing_token.to_string())].into(),
|
|
761
|
-
)?;
|
|
762
|
-
Ok(claim)
|
|
763
|
-
})
|
|
764
|
-
}
|
|
765
|
-
fn fence(
|
|
766
|
-
w: &Workload,
|
|
767
|
-
claim: &str,
|
|
768
|
-
worker: &str,
|
|
769
|
-
token: u64,
|
|
770
|
-
now: i64,
|
|
771
|
-
) -> Result<(), WorkloadError> {
|
|
772
|
-
let current = w
|
|
773
|
-
.current_claim
|
|
774
|
-
.as_ref()
|
|
775
|
-
.ok_or_else(|| WorkloadError::new("WORKLOAD_CLAIM_INVALID", "no active claim"))?;
|
|
776
|
-
if current.claim_id != claim
|
|
777
|
-
|| current.worker_id != worker
|
|
778
|
-
|| current.fencing_token != token
|
|
779
|
-
{
|
|
780
|
-
return Err(WorkloadError::new(
|
|
781
|
-
"WORKLOAD_STALE_FENCE",
|
|
782
|
-
"claim or fencing token is stale",
|
|
783
|
-
));
|
|
784
|
-
}
|
|
785
|
-
if current.expires_at <= now {
|
|
786
|
-
return Err(WorkloadError::new(
|
|
787
|
-
"WORKLOAD_LEASE_EXPIRED",
|
|
788
|
-
"claim lease expired",
|
|
789
|
-
));
|
|
790
|
-
}
|
|
791
|
-
Ok(())
|
|
792
|
-
}
|
|
793
|
-
pub fn start(
|
|
794
|
-
&mut self,
|
|
795
|
-
id: &str,
|
|
796
|
-
claim: &str,
|
|
797
|
-
worker: &str,
|
|
798
|
-
token: u64,
|
|
799
|
-
now: i64,
|
|
800
|
-
) -> Result<(), WorkloadError> {
|
|
801
|
-
self.edit(id, |store, w| {
|
|
802
|
-
Self::fence(w, claim, worker, token, now)?;
|
|
803
|
-
let attempt = w.attempts.last_mut().unwrap();
|
|
804
|
-
attempt.status = AttemptStatus::Running;
|
|
805
|
-
attempt.started_at = Some(now);
|
|
806
|
-
store.transition(w, WorkloadState::Running, worker, now, BTreeMap::new())
|
|
807
|
-
})
|
|
808
|
-
}
|
|
809
|
-
pub fn heartbeat(
|
|
810
|
-
&mut self,
|
|
811
|
-
id: &str,
|
|
812
|
-
claim: &str,
|
|
813
|
-
worker: &str,
|
|
814
|
-
token: u64,
|
|
815
|
-
now: i64,
|
|
816
|
-
extend_ms: u64,
|
|
817
|
-
) -> Result<WorkloadClaim, WorkloadError> {
|
|
818
|
-
self.edit(id, |_, w| {
|
|
819
|
-
Self::fence(w, claim, worker, token, now)?;
|
|
820
|
-
let current = w.current_claim.as_mut().unwrap();
|
|
821
|
-
current.last_heartbeat = now;
|
|
822
|
-
current.expires_at = now + (extend_ms / 1000) as i64;
|
|
823
|
-
Ok(current.clone())
|
|
824
|
-
})
|
|
825
|
-
}
|
|
826
|
-
pub fn execution_context(
|
|
827
|
-
&self,
|
|
828
|
-
id: &str,
|
|
829
|
-
claim: &str,
|
|
830
|
-
worker: &str,
|
|
831
|
-
token: u64,
|
|
832
|
-
now: i64,
|
|
833
|
-
secret_references: Vec<String>,
|
|
834
|
-
) -> Result<WorkloadExecutionContext, WorkloadError> {
|
|
835
|
-
let workload = self.get(id)?;
|
|
836
|
-
Self::fence(workload, claim, worker, token, now)?;
|
|
837
|
-
if secret_references
|
|
838
|
-
.iter()
|
|
839
|
-
.any(|v| !v.starts_with("secret://"))
|
|
840
|
-
{
|
|
841
|
-
return Err(WorkloadError::new(
|
|
842
|
-
"WORKLOAD_SECRET_REJECTED",
|
|
843
|
-
"execution context accepts secret references only",
|
|
844
|
-
));
|
|
845
|
-
}
|
|
846
|
-
let current = workload.current_claim.as_ref().unwrap();
|
|
847
|
-
Ok(WorkloadExecutionContext {
|
|
848
|
-
workload_id: id.into(),
|
|
849
|
-
attempt_id: workload.attempts.last().unwrap().attempt_id.clone(),
|
|
850
|
-
worker_id: worker.into(),
|
|
851
|
-
capabilities: current.capability_snapshot.clone(),
|
|
852
|
-
connection_scopes: workload.connection_scopes.clone(),
|
|
853
|
-
secret_references,
|
|
854
|
-
expires_at: current.expires_at,
|
|
855
|
-
fencing_token: token,
|
|
856
|
-
})
|
|
857
|
-
}
|
|
858
|
-
pub fn checkpoint(
|
|
859
|
-
&mut self,
|
|
860
|
-
id: &str,
|
|
861
|
-
claim: &str,
|
|
862
|
-
worker: &str,
|
|
863
|
-
token: u64,
|
|
864
|
-
state: Value,
|
|
865
|
-
now: i64,
|
|
866
|
-
) -> Result<WorkloadCheckpoint, WorkloadError> {
|
|
867
|
-
ensure_safe(&state)?;
|
|
868
|
-
self.edit(id, |store, w| {
|
|
869
|
-
Self::fence(w, claim, worker, token, now)?;
|
|
870
|
-
if !matches!(
|
|
871
|
-
w.state,
|
|
872
|
-
WorkloadState::Running | WorkloadState::Checkpointed
|
|
873
|
-
) {
|
|
874
|
-
return Err(WorkloadError::new(
|
|
875
|
-
"WORKLOAD_TRANSITION_DENIED",
|
|
876
|
-
"checkpoint requires a running workload",
|
|
877
|
-
));
|
|
878
|
-
}
|
|
879
|
-
let attempt = w.attempts.last_mut().unwrap();
|
|
880
|
-
attempt.status = AttemptStatus::Checkpointed;
|
|
881
|
-
let sequence = w.checkpoints.len() as u64 + 1;
|
|
882
|
-
let checkpoint = WorkloadCheckpoint {
|
|
883
|
-
checkpoint_id: format!("checkpoint_{}_{}", w.workload_id, sequence),
|
|
884
|
-
workload_id: w.workload_id.clone(),
|
|
885
|
-
attempt_id: attempt.attempt_id.clone(),
|
|
886
|
-
sequence,
|
|
887
|
-
state_hash: hash(&state),
|
|
888
|
-
state,
|
|
889
|
-
created_at: now,
|
|
890
|
-
produced_by: worker.into(),
|
|
891
|
-
};
|
|
892
|
-
w.checkpoints.push(checkpoint.clone());
|
|
893
|
-
if w.state == WorkloadState::Running {
|
|
894
|
-
store.transition(w, WorkloadState::Checkpointed, worker, now, BTreeMap::new())?;
|
|
895
|
-
}
|
|
896
|
-
Ok(checkpoint)
|
|
897
|
-
})
|
|
898
|
-
}
|
|
899
|
-
pub fn complete(
|
|
900
|
-
&mut self,
|
|
901
|
-
id: &str,
|
|
902
|
-
claim: &str,
|
|
903
|
-
worker: &str,
|
|
904
|
-
token: u64,
|
|
905
|
-
result_id: &str,
|
|
906
|
-
output: Value,
|
|
907
|
-
artifacts: Vec<String>,
|
|
908
|
-
schema_version: u64,
|
|
909
|
-
now: i64,
|
|
910
|
-
) -> Result<WorkloadResult, WorkloadError> {
|
|
911
|
-
ensure_safe(&output)?;
|
|
912
|
-
if let Some(workload_id) = self.result_ids.get(result_id) {
|
|
913
|
-
if workload_id == id {
|
|
914
|
-
return Ok(self.get(id)?.result.clone().unwrap());
|
|
915
|
-
}
|
|
916
|
-
return Err(WorkloadError::new(
|
|
917
|
-
"WORKLOAD_RESULT_DUPLICATE",
|
|
918
|
-
"result id belongs to another workload",
|
|
919
|
-
));
|
|
920
|
-
}
|
|
921
|
-
let result = self.edit(id, |store, w| {
|
|
922
|
-
Self::fence(w, claim, worker, token, now)?;
|
|
923
|
-
if !matches!(
|
|
924
|
-
w.state,
|
|
925
|
-
WorkloadState::Running | WorkloadState::Checkpointed
|
|
926
|
-
) {
|
|
927
|
-
return Err(WorkloadError::new(
|
|
928
|
-
"WORKLOAD_TRANSITION_DENIED",
|
|
929
|
-
"completion requires running state",
|
|
930
|
-
));
|
|
931
|
-
}
|
|
932
|
-
let attempt = w.attempts.last_mut().unwrap();
|
|
933
|
-
let output_hash = hash(&output);
|
|
934
|
-
attempt.status = AttemptStatus::Succeeded;
|
|
935
|
-
attempt.ended_at = Some(now);
|
|
936
|
-
attempt.result_hash = Some(output_hash.clone());
|
|
937
|
-
let result = WorkloadResult {
|
|
938
|
-
result_id: result_id.into(),
|
|
939
|
-
workload_id: w.workload_id.clone(),
|
|
940
|
-
attempt_id: attempt.attempt_id.clone(),
|
|
941
|
-
status: "succeeded".into(),
|
|
942
|
-
output,
|
|
943
|
-
output_hash,
|
|
944
|
-
artifacts,
|
|
945
|
-
provenance: [
|
|
946
|
-
("claim_id".into(), claim.into()),
|
|
947
|
-
("fencing_token".into(), token.to_string()),
|
|
948
|
-
]
|
|
949
|
-
.into(),
|
|
950
|
-
produced_by: worker.into(),
|
|
951
|
-
created_at: now,
|
|
952
|
-
schema_version,
|
|
953
|
-
};
|
|
954
|
-
w.result = Some(result.clone());
|
|
955
|
-
w.current_claim = None;
|
|
956
|
-
store.transition(w, WorkloadState::Succeeded, worker, now, BTreeMap::new())?;
|
|
957
|
-
Ok(result)
|
|
958
|
-
})?;
|
|
959
|
-
self.result_ids.insert(result_id.into(), id.into());
|
|
960
|
-
Ok(result)
|
|
961
|
-
}
|
|
962
|
-
pub fn fail(
|
|
963
|
-
&mut self,
|
|
964
|
-
id: &str,
|
|
965
|
-
claim: &str,
|
|
966
|
-
worker: &str,
|
|
967
|
-
token: u64,
|
|
968
|
-
kind: &str,
|
|
969
|
-
error: &str,
|
|
970
|
-
now: i64,
|
|
971
|
-
) -> Result<WorkloadState, WorkloadError> {
|
|
972
|
-
if sensitive_text(error) {
|
|
973
|
-
return Err(WorkloadError::new(
|
|
974
|
-
"WORKLOAD_SECRET_REJECTED",
|
|
975
|
-
"errors may not contain secret material",
|
|
976
|
-
));
|
|
977
|
-
}
|
|
978
|
-
self.edit(id, |store, w| {
|
|
979
|
-
Self::fence(w, claim, worker, token, now)?;
|
|
980
|
-
let attempt = w.attempts.last_mut().unwrap();
|
|
981
|
-
attempt.status = AttemptStatus::Failed;
|
|
982
|
-
attempt.ended_at = Some(now);
|
|
983
|
-
attempt.error = Some(error.into());
|
|
984
|
-
w.last_failure = Some(format!("{kind}: {error}"));
|
|
985
|
-
w.current_claim = None;
|
|
986
|
-
store.transition(
|
|
987
|
-
w,
|
|
988
|
-
WorkloadState::Failed,
|
|
989
|
-
worker,
|
|
990
|
-
now,
|
|
991
|
-
[("failure_kind".into(), kind.into())].into(),
|
|
992
|
-
)?;
|
|
993
|
-
let retryable =
|
|
994
|
-
w.retry_policy.retry_on.contains(kind) && w.attempt_count < attempt_budget(w);
|
|
995
|
-
if retryable {
|
|
996
|
-
w.retry_count += 1;
|
|
997
|
-
let multiplier = if w.retry_policy.backoff == BackoffPolicy::Exponential {
|
|
998
|
-
2u64.saturating_pow(w.retry_count.saturating_sub(1))
|
|
999
|
-
} else {
|
|
1000
|
-
1
|
|
1001
|
-
};
|
|
1002
|
-
let delay = w
|
|
1003
|
-
.retry_policy
|
|
1004
|
-
.initial_delay_ms
|
|
1005
|
-
.saturating_mul(multiplier)
|
|
1006
|
-
.min(w.retry_policy.max_delay_ms);
|
|
1007
|
-
w.next_attempt_at = Some(now + (delay / 1000) as i64);
|
|
1008
|
-
store.transition(w, WorkloadState::Ready, "rust.retry", now, BTreeMap::new())?;
|
|
1009
|
-
} else {
|
|
1010
|
-
store.transition(
|
|
1011
|
-
w,
|
|
1012
|
-
WorkloadState::DeadLetter,
|
|
1013
|
-
"rust.retry",
|
|
1014
|
-
now,
|
|
1015
|
-
BTreeMap::new(),
|
|
1016
|
-
)?;
|
|
1017
|
-
}
|
|
1018
|
-
Ok(w.state)
|
|
1019
|
-
})
|
|
1020
|
-
}
|
|
1021
|
-
pub fn cancel(
|
|
1022
|
-
&mut self,
|
|
1023
|
-
id: &str,
|
|
1024
|
-
actor: &str,
|
|
1025
|
-
now: i64,
|
|
1026
|
-
) -> Result<WorkloadState, WorkloadError> {
|
|
1027
|
-
self.edit(id, |store, w| {
|
|
1028
|
-
match w.state {
|
|
1029
|
-
WorkloadState::Ready
|
|
1030
|
-
| WorkloadState::Created
|
|
1031
|
-
| WorkloadState::Failed
|
|
1032
|
-
| WorkloadState::DeadLetter => {
|
|
1033
|
-
w.current_claim = None;
|
|
1034
|
-
store.transition(w, WorkloadState::Cancelled, actor, now, BTreeMap::new())?
|
|
1035
|
-
}
|
|
1036
|
-
WorkloadState::Claimed | WorkloadState::Running | WorkloadState::Checkpointed => {
|
|
1037
|
-
store.transition(
|
|
1038
|
-
w,
|
|
1039
|
-
WorkloadState::CancellationRequested,
|
|
1040
|
-
actor,
|
|
1041
|
-
now,
|
|
1042
|
-
BTreeMap::new(),
|
|
1043
|
-
)?
|
|
1044
|
-
}
|
|
1045
|
-
WorkloadState::CancellationRequested => {}
|
|
1046
|
-
_ => {
|
|
1047
|
-
return Err(WorkloadError::new(
|
|
1048
|
-
"WORKLOAD_TRANSITION_DENIED",
|
|
1049
|
-
"terminal workload cannot be cancelled",
|
|
1050
|
-
))
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
Ok(w.state)
|
|
1054
|
-
})
|
|
1055
|
-
}
|
|
1056
|
-
pub fn confirm_cancel(
|
|
1057
|
-
&mut self,
|
|
1058
|
-
id: &str,
|
|
1059
|
-
claim: &str,
|
|
1060
|
-
worker: &str,
|
|
1061
|
-
token: u64,
|
|
1062
|
-
now: i64,
|
|
1063
|
-
) -> Result<(), WorkloadError> {
|
|
1064
|
-
self.edit(id, |store, w| {
|
|
1065
|
-
Self::fence(w, claim, worker, token, now)?;
|
|
1066
|
-
if w.state != WorkloadState::CancellationRequested {
|
|
1067
|
-
return Err(WorkloadError::new(
|
|
1068
|
-
"WORKLOAD_TRANSITION_DENIED",
|
|
1069
|
-
"cancellation was not requested",
|
|
1070
|
-
));
|
|
1071
|
-
}
|
|
1072
|
-
if let Some(attempt) = w.attempts.last_mut() {
|
|
1073
|
-
attempt.status = AttemptStatus::Cancelled;
|
|
1074
|
-
attempt.ended_at = Some(now);
|
|
1075
|
-
}
|
|
1076
|
-
w.current_claim = None;
|
|
1077
|
-
store.transition(w, WorkloadState::Cancelled, worker, now, BTreeMap::new())
|
|
1078
|
-
})
|
|
1079
|
-
}
|
|
1080
|
-
pub fn timeout(
|
|
1081
|
-
&mut self,
|
|
1082
|
-
id: &str,
|
|
1083
|
-
actor: &str,
|
|
1084
|
-
now: i64,
|
|
1085
|
-
) -> Result<WorkloadState, WorkloadError> {
|
|
1086
|
-
self.edit(id, |store, w| {
|
|
1087
|
-
if !matches!(
|
|
1088
|
-
w.state,
|
|
1089
|
-
WorkloadState::Ready
|
|
1090
|
-
| WorkloadState::Claimed
|
|
1091
|
-
| WorkloadState::Running
|
|
1092
|
-
| WorkloadState::Checkpointed
|
|
1093
|
-
) {
|
|
1094
|
-
return Err(WorkloadError::new(
|
|
1095
|
-
"WORKLOAD_TRANSITION_DENIED",
|
|
1096
|
-
"workload cannot time out in its current state",
|
|
1097
|
-
));
|
|
1098
|
-
}
|
|
1099
|
-
if let Some(attempt) = w.attempts.last_mut() {
|
|
1100
|
-
attempt.status = AttemptStatus::TimedOut;
|
|
1101
|
-
attempt.ended_at = Some(now);
|
|
1102
|
-
}
|
|
1103
|
-
w.current_claim = None;
|
|
1104
|
-
store.transition(w, WorkloadState::TimedOut, actor, now, BTreeMap::new())?;
|
|
1105
|
-
if w.attempt_count < attempt_budget(w) && w.retry_policy.retry_on.contains("timeout")
|
|
1106
|
-
{
|
|
1107
|
-
w.retry_count += 1;
|
|
1108
|
-
w.next_attempt_at = Some(now + (w.retry_policy.initial_delay_ms / 1000) as i64);
|
|
1109
|
-
store.transition(w, WorkloadState::Ready, "rust.retry", now, BTreeMap::new())?;
|
|
1110
|
-
} else {
|
|
1111
|
-
store.transition(
|
|
1112
|
-
w,
|
|
1113
|
-
WorkloadState::DeadLetter,
|
|
1114
|
-
"rust.timeout",
|
|
1115
|
-
now,
|
|
1116
|
-
BTreeMap::new(),
|
|
1117
|
-
)?;
|
|
1118
|
-
}
|
|
1119
|
-
Ok(w.state)
|
|
1120
|
-
})
|
|
1121
|
-
}
|
|
1122
|
-
pub fn retry(&mut self, id: &str, actor: &str, now: i64) -> Result<(), WorkloadError> {
|
|
1123
|
-
self.retry_with_reason(id, actor, now, None)
|
|
1124
|
-
}
|
|
1125
|
-
/// An operator's re-arm of failed, dead-lettered or timed-out work.
|
|
1126
|
-
///
|
|
1127
|
-
/// Each re-arm grants one more attempt, as it always has, and is recorded
|
|
1128
|
-
/// in the history with its actor and reason. A workload may be re-armed at
|
|
1129
|
-
/// most `retry_policy.max_attempts` times, so operator retries cannot
|
|
1130
|
-
/// become an unbounded loop.
|
|
1131
|
-
pub fn retry_with_reason(
|
|
1132
|
-
&mut self,
|
|
1133
|
-
id: &str,
|
|
1134
|
-
actor: &str,
|
|
1135
|
-
now: i64,
|
|
1136
|
-
reason: Option<&str>,
|
|
1137
|
-
) -> Result<(), WorkloadError> {
|
|
1138
|
-
self.edit(id, |store, w| {
|
|
1139
|
-
if !matches!(
|
|
1140
|
-
w.state,
|
|
1141
|
-
WorkloadState::Failed | WorkloadState::DeadLetter | WorkloadState::TimedOut
|
|
1142
|
-
) {
|
|
1143
|
-
return Err(WorkloadError::new(
|
|
1144
|
-
"WORKLOAD_TRANSITION_DENIED",
|
|
1145
|
-
"workload is not retryable",
|
|
1146
|
-
));
|
|
1147
|
-
}
|
|
1148
|
-
let previous = operator_retries(w);
|
|
1149
|
-
if previous >= w.retry_policy.max_attempts {
|
|
1150
|
-
return Err(WorkloadError::new(
|
|
1151
|
-
"WORKLOAD_RETRY_EXHAUSTED",
|
|
1152
|
-
"workload has used every operator retry its policy allows",
|
|
1153
|
-
));
|
|
1154
|
-
}
|
|
1155
|
-
w.next_attempt_at = Some(now);
|
|
1156
|
-
w.current_claim = None;
|
|
1157
|
-
let mut evidence: BTreeMap<String, String> = [
|
|
1158
|
-
("retry".into(), "operator".into()),
|
|
1159
|
-
("operator_retry".into(), (previous + 1).to_string()),
|
|
1160
|
-
(
|
|
1161
|
-
"attempt_budget".into(),
|
|
1162
|
-
w.retry_policy
|
|
1163
|
-
.max_attempts
|
|
1164
|
-
.saturating_add(previous + 1)
|
|
1165
|
-
.to_string(),
|
|
1166
|
-
),
|
|
1167
|
-
]
|
|
1168
|
-
.into();
|
|
1169
|
-
if let Some(reason) = reason.filter(|reason| !reason.trim().is_empty()) {
|
|
1170
|
-
evidence.insert("reason".into(), reason.chars().take(256).collect());
|
|
1171
|
-
}
|
|
1172
|
-
store.transition(w, WorkloadState::Ready, actor, now, evidence)
|
|
1173
|
-
})
|
|
1174
|
-
}
|
|
1175
|
-
pub fn recover_expired(&mut self, now: i64) -> Result<Vec<String>, WorkloadError> {
|
|
1176
|
-
let ids = self
|
|
1177
|
-
.workloads
|
|
1178
|
-
.values()
|
|
1179
|
-
.filter(|w| {
|
|
1180
|
-
w.current_claim
|
|
1181
|
-
.as_ref()
|
|
1182
|
-
.is_some_and(|c| c.expires_at <= now)
|
|
1183
|
-
&& matches!(
|
|
1184
|
-
w.state,
|
|
1185
|
-
WorkloadState::Claimed
|
|
1186
|
-
| WorkloadState::Running
|
|
1187
|
-
| WorkloadState::Checkpointed
|
|
1188
|
-
| WorkloadState::CancellationRequested
|
|
1189
|
-
)
|
|
1190
|
-
})
|
|
1191
|
-
.map(|w| w.workload_id.clone())
|
|
1192
|
-
.collect::<Vec<_>>();
|
|
1193
|
-
let mut recovered = Vec::with_capacity(ids.len());
|
|
1194
|
-
for id in ids {
|
|
1195
|
-
let outcome = self.edit(&id, |store, w| {
|
|
1196
|
-
let lost = w.current_claim.take();
|
|
1197
|
-
if let Some(attempt) = w.attempts.last_mut() {
|
|
1198
|
-
attempt.status = AttemptStatus::WorkerLost;
|
|
1199
|
-
attempt.ended_at = Some(now);
|
|
1200
|
-
attempt.error = Some("worker_lost: claim lease expired".into());
|
|
1201
|
-
}
|
|
1202
|
-
let mut evidence: BTreeMap<String, String> = [
|
|
1203
|
-
("recovery_reason".into(), "lease_expired".into()),
|
|
1204
|
-
("attempts_used".into(), w.attempt_count.to_string()),
|
|
1205
|
-
("attempt_budget".into(), attempt_budget(w).to_string()),
|
|
1206
|
-
]
|
|
1207
|
-
.into();
|
|
1208
|
-
if let Some(claim) = &lost {
|
|
1209
|
-
evidence.insert("claim_id".into(), claim.claim_id.clone());
|
|
1210
|
-
evidence.insert("worker_id".into(), claim.worker_id.clone());
|
|
1211
|
-
evidence.insert("lease_expired_at".into(), claim.expires_at.to_string());
|
|
1212
|
-
}
|
|
1213
|
-
if let Some(attempt) = w.attempts.last() {
|
|
1214
|
-
evidence.insert("attempt_id".into(), attempt.attempt_id.clone());
|
|
1215
|
-
}
|
|
1216
|
-
if w.state == WorkloadState::CancellationRequested {
|
|
1217
|
-
store.transition(w, WorkloadState::Cancelled, "rust.recovery", now, evidence)?;
|
|
1218
|
-
} else {
|
|
1219
|
-
store.transition(
|
|
1220
|
-
w,
|
|
1221
|
-
WorkloadState::Recovering,
|
|
1222
|
-
"rust.recovery",
|
|
1223
|
-
now,
|
|
1224
|
-
evidence,
|
|
1225
|
-
)?;
|
|
1226
|
-
// A lost worker is the policy's `worker_failure`. Whether
|
|
1227
|
-
// the lost attempt had effects is unknown to FeltDB, so a
|
|
1228
|
-
// retry re-executes it: at-least-once, never exactly-once.
|
|
1229
|
-
let (to, decision) = if !w.retry_policy.retry_on.contains("worker_failure") {
|
|
1230
|
-
(WorkloadState::DeadLetter, "worker_failure_not_retryable")
|
|
1231
|
-
} else if w.attempt_count >= attempt_budget(w) {
|
|
1232
|
-
(WorkloadState::DeadLetter, "attempt_budget_exhausted")
|
|
1233
|
-
} else {
|
|
1234
|
-
(WorkloadState::Ready, "retry")
|
|
1235
|
-
};
|
|
1236
|
-
store.transition(
|
|
1237
|
-
w,
|
|
1238
|
-
to,
|
|
1239
|
-
"rust.recovery",
|
|
1240
|
-
now,
|
|
1241
|
-
[("decision".into(), decision.into())].into(),
|
|
1242
|
-
)?;
|
|
1243
|
-
}
|
|
1244
|
-
Ok(())
|
|
1245
|
-
});
|
|
1246
|
-
match outcome {
|
|
1247
|
-
Ok(()) => recovered.push(id),
|
|
1248
|
-
// Another writer of this store committed the workload first —
|
|
1249
|
-
// in a race, its own recovery. Nothing of this attempt was
|
|
1250
|
-
// committed and `edit` has adopted the committed workload, so
|
|
1251
|
-
// the attempt is recovered once, by that writer. If it still
|
|
1252
|
-
// needs recovery, the next pass sees it.
|
|
1253
|
-
Err(error) if error.code == "WORKLOAD_CONFLICT" => {}
|
|
1254
|
-
Err(error) => return Err(error),
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
Ok(recovered)
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
pub trait WorkScheduler {
|
|
1262
|
-
fn enqueue(&mut self, workload_id: &str);
|
|
1263
|
-
fn claim(
|
|
1264
|
-
&mut self,
|
|
1265
|
-
store: &mut WorkloadStore,
|
|
1266
|
-
tenant: &str,
|
|
1267
|
-
application: &str,
|
|
1268
|
-
worker: &str,
|
|
1269
|
-
capabilities: &BTreeSet<String>,
|
|
1270
|
-
now: i64,
|
|
1271
|
-
lease_ms: u64,
|
|
1272
|
-
) -> Result<Option<WorkloadClaim>, WorkloadError>;
|
|
1273
|
-
fn release(&mut self, claim_id: &str);
|
|
1274
|
-
}
|
|
1275
|
-
#[derive(Default)]
|
|
1276
|
-
pub struct LocalDurableScheduler {
|
|
1277
|
-
queue: BTreeSet<String>,
|
|
1278
|
-
}
|
|
1279
|
-
impl WorkScheduler for LocalDurableScheduler {
|
|
1280
|
-
fn enqueue(&mut self, id: &str) {
|
|
1281
|
-
self.queue.insert(id.into());
|
|
1282
|
-
}
|
|
1283
|
-
fn claim(
|
|
1284
|
-
&mut self,
|
|
1285
|
-
store: &mut WorkloadStore,
|
|
1286
|
-
tenant: &str,
|
|
1287
|
-
application: &str,
|
|
1288
|
-
worker: &str,
|
|
1289
|
-
capabilities: &BTreeSet<String>,
|
|
1290
|
-
now: i64,
|
|
1291
|
-
lease_ms: u64,
|
|
1292
|
-
) -> Result<Option<WorkloadClaim>, WorkloadError> {
|
|
1293
|
-
let selected = store
|
|
1294
|
-
.list(tenant, application)
|
|
1295
|
-
.into_iter()
|
|
1296
|
-
.filter(|w| {
|
|
1297
|
-
// Eligibility is part of selection, not a reason to fail. A
|
|
1298
|
-
// workload that is merely early is skipped so the scheduler can
|
|
1299
|
-
// hand out work that is ready now; without this it would pick an
|
|
1300
|
-
// ineligible workload, have `store.claim` refuse it, and return
|
|
1301
|
-
// an error while claimable work sat behind it.
|
|
1302
|
-
w.state == WorkloadState::Ready
|
|
1303
|
-
&& w.is_eligible_at(now)
|
|
1304
|
-
&& w.capability_snapshot.is_subset(capabilities)
|
|
1305
|
-
})
|
|
1306
|
-
.find(|w| {
|
|
1307
|
-
w.dependencies.iter().all(|d| {
|
|
1308
|
-
store
|
|
1309
|
-
.workloads
|
|
1310
|
-
.get(&d.workload_id)
|
|
1311
|
-
.is_some_and(|dependency| dependency.state == d.required_state)
|
|
1312
|
-
})
|
|
1313
|
-
})
|
|
1314
|
-
.map(|w| w.workload_id);
|
|
1315
|
-
match selected {
|
|
1316
|
-
Some(id) => store
|
|
1317
|
-
.claim(&id, worker, capabilities, now, lease_ms)
|
|
1318
|
-
.map(Some),
|
|
1319
|
-
None => Ok(None),
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
fn release(&mut self, _: &str) {}
|
|
1323
|
-
}
|
|
1324
|
-
fn legal(from: WorkloadState, to: WorkloadState) -> bool {
|
|
1325
|
-
use WorkloadState::*;
|
|
1326
|
-
matches!(
|
|
1327
|
-
(from, to),
|
|
1328
|
-
(Created, Ready)
|
|
1329
|
-
| (Ready, Claimed)
|
|
1330
|
-
| (Claimed, Running)
|
|
1331
|
-
| (Running, Checkpointed)
|
|
1332
|
-
| (Checkpointed, Checkpointed)
|
|
1333
|
-
| (Running, Succeeded)
|
|
1334
|
-
| (Checkpointed, Succeeded)
|
|
1335
|
-
| (Claimed, CancellationRequested)
|
|
1336
|
-
| (Running, CancellationRequested)
|
|
1337
|
-
| (Checkpointed, CancellationRequested)
|
|
1338
|
-
| (CancellationRequested, Cancelled)
|
|
1339
|
-
| (Ready, Cancelled)
|
|
1340
|
-
| (Created, Cancelled)
|
|
1341
|
-
| (Failed, Cancelled)
|
|
1342
|
-
| (DeadLetter, Cancelled)
|
|
1343
|
-
| (Running, Failed)
|
|
1344
|
-
| (Checkpointed, Failed)
|
|
1345
|
-
| (Claimed, Recovering)
|
|
1346
|
-
| (Running, Recovering)
|
|
1347
|
-
| (Checkpointed, Recovering)
|
|
1348
|
-
| (Recovering, Ready)
|
|
1349
|
-
| (Failed, Ready)
|
|
1350
|
-
| (TimedOut, Ready)
|
|
1351
|
-
| (DeadLetter, Ready)
|
|
1352
|
-
| (Failed, DeadLetter)
|
|
1353
|
-
| (Running, TimedOut)
|
|
1354
|
-
| (Claimed, TimedOut)
|
|
1355
|
-
| (Checkpointed, TimedOut)
|
|
1356
|
-
| (Ready, TimedOut)
|
|
1357
|
-
| (TimedOut, DeadLetter)
|
|
1358
|
-
| (Recovering, DeadLetter)
|
|
1359
|
-
| (Ready, DeadLetter)
|
|
1360
|
-
)
|
|
1361
|
-
}
|
|
1362
|
-
pub fn can_transition(from: WorkloadState, to: WorkloadState) -> bool {
|
|
1363
|
-
legal(from, to)
|
|
1364
|
-
}
|
|
1365
|
-
fn event_name(state: WorkloadState) -> &'static str {
|
|
1366
|
-
use WorkloadState::*;
|
|
1367
|
-
match state {
|
|
1368
|
-
Created => "workload.created",
|
|
1369
|
-
Ready => "workload.ready",
|
|
1370
|
-
Claimed => "workload.claimed",
|
|
1371
|
-
Running => "workload.started",
|
|
1372
|
-
Checkpointed => "workload.checkpointed",
|
|
1373
|
-
Recovering => "workload.recovered",
|
|
1374
|
-
CancellationRequested => "workload.cancellation_requested",
|
|
1375
|
-
Succeeded => "workload.completed",
|
|
1376
|
-
Failed => "workload.failed",
|
|
1377
|
-
Cancelled => "workload.cancelled",
|
|
1378
|
-
TimedOut => "workload.timed_out",
|
|
1379
|
-
DeadLetter => "workload.dead_lettered",
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
fn ensure_safe(value: &Value) -> Result<(), WorkloadError> {
|
|
1383
|
-
fn walk(value: &Value) -> bool {
|
|
1384
|
-
match value {
|
|
1385
|
-
Value::Object(map) => map.iter().any(|(k, v)| {
|
|
1386
|
-
matches!(
|
|
1387
|
-
k.to_ascii_lowercase().as_str(),
|
|
1388
|
-
"password" | "secret" | "token" | "api_key" | "private_key"
|
|
1389
|
-
) || walk(v)
|
|
1390
|
-
}),
|
|
1391
|
-
Value::Array(values) => values.iter().any(walk),
|
|
1392
|
-
Value::String(v) => sensitive_text(v),
|
|
1393
|
-
_ => false,
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
if walk(value) {
|
|
1397
|
-
Err(WorkloadError::new(
|
|
1398
|
-
"WORKLOAD_SECRET_REJECTED",
|
|
1399
|
-
"plaintext secrets are forbidden in workload state",
|
|
1400
|
-
))
|
|
1401
|
-
} else {
|
|
1402
|
-
Ok(())
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
fn sensitive_text(v: &str) -> bool {
|
|
1406
|
-
v.contains("BEGIN PRIVATE KEY") || v.starts_with("sk-") || v.starts_with("fdb_live_")
|
|
1407
|
-
}
|
|
1408
|
-
/// The identity a caller's idempotency key is unique within: tenant,
|
|
1409
|
-
/// application and requester. Encoded as a JSON array, so no choice of key or
|
|
1410
|
-
/// identifier can make two different scopes coincide.
|
|
1411
|
-
fn idempotency_scope(tenant: &str, application: &str, requester: &Subject, key: &str) -> String {
|
|
1412
|
-
serde_json::json!([tenant, application, requester.kind(), requester.id(), key]).to_string()
|
|
1413
|
-
}
|
|
1414
|
-
/// The scope before keys were bound to their requester, kept only to verify
|
|
1415
|
-
/// legacy store files during migration.
|
|
1416
|
-
fn legacy_idempotency_scope(
|
|
1417
|
-
tenant: &str,
|
|
1418
|
-
application: &str,
|
|
1419
|
-
_requester: &Subject,
|
|
1420
|
-
key: &str,
|
|
1421
|
-
) -> String {
|
|
1422
|
-
format!("{tenant}:{application}:{key}")
|
|
1423
|
-
}
|
|
1424
|
-
fn idempotency_index(
|
|
1425
|
-
workloads: &BTreeMap<String, Workload>,
|
|
1426
|
-
scope: fn(&str, &str, &Subject, &str) -> String,
|
|
1427
|
-
) -> BTreeMap<String, String> {
|
|
1428
|
-
workloads
|
|
1429
|
-
.values()
|
|
1430
|
-
.map(|w| {
|
|
1431
|
-
(
|
|
1432
|
-
scope(&w.tenant_id, &w.application_id, &w.requested_by, &w.idempotency_key),
|
|
1433
|
-
w.workload_id.clone(),
|
|
1434
|
-
)
|
|
1435
|
-
})
|
|
1436
|
-
.collect()
|
|
1437
|
-
}
|
|
1438
|
-
/// Answer a repeated key: the original workload for the same request, a
|
|
1439
|
-
/// refusal for a different one. Never the original for a different request.
|
|
1440
|
-
fn replay(existing: Workload, input: &CreateWorkload) -> Result<(Workload, bool), WorkloadError> {
|
|
1441
|
-
let same = existing.requested_by == input.requested_by
|
|
1442
|
-
&& existing.definition_id == input.definition_id
|
|
1443
|
-
&& existing.definition_revision == input.definition_revision
|
|
1444
|
-
&& existing.environment == input.environment
|
|
1445
|
-
&& existing.input_hash == hash(&input.input)
|
|
1446
|
-
&& existing.capability_snapshot == input.capability_snapshot;
|
|
1447
|
-
if same {
|
|
1448
|
-
Ok((existing, false))
|
|
1449
|
-
} else {
|
|
1450
|
-
Err(WorkloadError::new(
|
|
1451
|
-
"IDEMPOTENCY_KEY_REUSED",
|
|
1452
|
-
"idempotency key was already used for a different request",
|
|
1453
|
-
))
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
/// Operator re-arms recorded in the workload's own history.
|
|
1457
|
-
fn operator_retries(w: &Workload) -> u32 {
|
|
1458
|
-
w.execution_history
|
|
1459
|
-
.iter()
|
|
1460
|
-
.filter(|event| event.evidence.get("retry").map(String::as_str) == Some("operator"))
|
|
1461
|
-
.count() as u32
|
|
1462
|
-
}
|
|
1463
|
-
/// How many claims the workload may have in total: its policy's
|
|
1464
|
-
/// `max_attempts`, plus one for each operator re-arm (which is what an operator
|
|
1465
|
-
/// retry has always granted).
|
|
1466
|
-
fn attempt_budget(w: &Workload) -> u32 {
|
|
1467
|
-
w.retry_policy
|
|
1468
|
-
.max_attempts
|
|
1469
|
-
.saturating_add(operator_retries(w))
|
|
1470
|
-
}
|
|
1471
|
-
fn work_key(workload: &Workload) -> String {
|
|
1472
|
-
format!(
|
|
1473
|
-
"{}/{}/{}",
|
|
1474
|
-
workload.tenant_id, workload.application_id, workload.workload_id
|
|
1475
|
-
)
|
|
1476
|
-
}
|
|
1477
|
-
fn derived_event_sequence(workloads: &BTreeMap<String, Workload>) -> u64 {
|
|
1478
|
-
workloads
|
|
1479
|
-
.values()
|
|
1480
|
-
.flat_map(|workload| workload.execution_history.iter())
|
|
1481
|
-
.map(|event| event.sequence)
|
|
1482
|
-
.max()
|
|
1483
|
-
.unwrap_or_default()
|
|
1484
|
-
}
|
|
1485
|
-
fn runtime_id(prefix: &str) -> String {
|
|
1486
|
-
let value: String = rand::thread_rng()
|
|
1487
|
-
.sample_iter(&Alphanumeric)
|
|
1488
|
-
.take(24)
|
|
1489
|
-
.map(char::from)
|
|
1490
|
-
.collect();
|
|
1491
|
-
format!("{prefix}_{value}")
|
|
1492
|
-
}
|
|
1493
|
-
fn io(e: impl std::fmt::Display) -> WorkloadError {
|
|
1494
|
-
WorkloadError::new("WORKLOAD_STORAGE_FAILURE", e.to_string())
|
|
1495
|
-
}
|
|
1496
|
-
fn workload_storage_error(error: FlowError) -> WorkloadError {
|
|
1497
|
-
match error {
|
|
1498
|
-
FlowError::PreconditionFailed(_) | FlowError::RevisionConflict { .. } => {
|
|
1499
|
-
WorkloadError::new("WORKLOAD_CONFLICT", error.to_string())
|
|
1500
|
-
}
|
|
1501
|
-
FlowError::CapabilityError(ref message) if message.starts_with("PRECONDITION_FAILED:") => {
|
|
1502
|
-
WorkloadError::new("WORKLOAD_CONFLICT", error.to_string())
|
|
1503
|
-
}
|
|
1504
|
-
_ => io(error),
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
fn hash(value: &Value) -> String {
|
|
1508
|
-
hex(&Sha256::digest(
|
|
1509
|
-
serde_json::to_vec(value).unwrap_or_default(),
|
|
1510
|
-
))
|
|
1511
|
-
}
|
|
1512
|
-
fn hex(bytes: &[u8]) -> String {
|
|
1513
|
-
bytes.iter().map(|v| format!("{v:02x}")).collect()
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
#[cfg(test)]
|
|
1517
|
-
mod tests {
|
|
1518
|
-
use super::*;
|
|
1519
|
-
use crate::authorization::{
|
|
1520
|
-
Grant, GrantConstraints, GrantSigner, GrantStatus, ResourceScope, ResourceScopeKind,
|
|
1521
|
-
};
|
|
1522
|
-
use std::{fs, path::PathBuf};
|
|
1523
|
-
fn create() -> CreateWorkload {
|
|
1524
|
-
create_as("a1")
|
|
1525
|
-
}
|
|
1526
|
-
/// The fixture request, made by agent `agent`.
|
|
1527
|
-
fn create_as(agent: &str) -> CreateWorkload {
|
|
1528
|
-
let signer = GrantSigner::new("k", vec![1; 32]).unwrap();
|
|
1529
|
-
let subject = Subject::Agent(agent.into());
|
|
1530
|
-
let grant = Grant {
|
|
1531
|
-
contract_version: 1,
|
|
1532
|
-
id: "g".into(),
|
|
1533
|
-
issuer: Subject::Human("h".into()),
|
|
1534
|
-
subject: subject.clone(),
|
|
1535
|
-
tenant_id: "t".into(),
|
|
1536
|
-
application_id: "app".into(),
|
|
1537
|
-
capabilities: ["workload:execute".into()].into(),
|
|
1538
|
-
resources: vec![ResourceScope {
|
|
1539
|
-
kind: ResourceScopeKind::Workload,
|
|
1540
|
-
uri: "flow://app/workloads/*".into(),
|
|
1541
|
-
}],
|
|
1542
|
-
constraints: GrantConstraints::default(),
|
|
1543
|
-
issued_at: 1,
|
|
1544
|
-
expires_at: 9999,
|
|
1545
|
-
parent_grant: None,
|
|
1546
|
-
revision: 1,
|
|
1547
|
-
status: GrantStatus::Active,
|
|
1548
|
-
};
|
|
1549
|
-
CreateWorkload {
|
|
1550
|
-
tenant_id: "t".into(),
|
|
1551
|
-
application_id: "app".into(),
|
|
1552
|
-
environment: "production".into(),
|
|
1553
|
-
definition_id: "send".into(),
|
|
1554
|
-
definition_revision: "r1".into(),
|
|
1555
|
-
trigger: "manual".into(),
|
|
1556
|
-
input: serde_json::json!({"id":1}),
|
|
1557
|
-
requested_by: subject,
|
|
1558
|
-
session_id: Some("session-1".into()),
|
|
1559
|
-
authorization_snapshot: signer.sign(grant).unwrap(),
|
|
1560
|
-
capability_snapshot: ["email:send".into()].into(),
|
|
1561
|
-
connection_scopes: BTreeSet::new(),
|
|
1562
|
-
priority: 0,
|
|
1563
|
-
idempotency_key: "one".into(),
|
|
1564
|
-
retry_policy: Default::default(),
|
|
1565
|
-
timeouts: Default::default(),
|
|
1566
|
-
dependencies: vec![],
|
|
1567
|
-
not_before: None,
|
|
1568
|
-
created_at: 10,
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
fn capabilities() -> BTreeSet<String> {
|
|
1572
|
-
["email:send".into()].into()
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
#[test]
|
|
1576
|
-
fn idempotency_keys_are_bound_to_their_requester_and_request() {
|
|
1577
|
-
let (path, mut store) = store("requester-scoped-keys");
|
|
1578
|
-
let (original, created) = store.admit(create_as("a1")).unwrap();
|
|
1579
|
-
assert!(created);
|
|
1580
|
-
|
|
1581
|
-
// The same requester and request: the original, not a second workload.
|
|
1582
|
-
let (replayed, created) = store.admit(create_as("a1")).unwrap();
|
|
1583
|
-
assert!(!created);
|
|
1584
|
-
assert_eq!(replayed.workload_id, original.workload_id);
|
|
1585
|
-
|
|
1586
|
-
// The same key for a different request is refused and creates nothing.
|
|
1587
|
-
let mut different = create_as("a1");
|
|
1588
|
-
different.input = serde_json::json!({"id": 2});
|
|
1589
|
-
assert_eq!(store.admit(different).unwrap_err().code, "IDEMPOTENCY_KEY_REUSED");
|
|
1590
|
-
let mut other_definition = create_as("a1");
|
|
1591
|
-
other_definition.definition_id = "other".into();
|
|
1592
|
-
assert_eq!(
|
|
1593
|
-
store.admit(other_definition).unwrap_err().code,
|
|
1594
|
-
"IDEMPOTENCY_KEY_REUSED"
|
|
1595
|
-
);
|
|
1596
|
-
|
|
1597
|
-
// Another requester's identical key is its own workload: it neither
|
|
1598
|
-
// returns, reveals nor blocks the first requester's.
|
|
1599
|
-
let (theirs, created) = store.admit(create_as("a2")).unwrap();
|
|
1600
|
-
assert!(created);
|
|
1601
|
-
assert_ne!(theirs.workload_id, original.workload_id);
|
|
1602
|
-
assert_eq!(store.workloads.len(), 2);
|
|
1603
|
-
|
|
1604
|
-
// A snapshot that does not bind the requester is refused before any
|
|
1605
|
-
// replay, so a key cannot be used to fetch a workload.
|
|
1606
|
-
let mut unbound = create_as("a1");
|
|
1607
|
-
unbound.requested_by = Subject::Agent("a2".into());
|
|
1608
|
-
assert_eq!(store.admit(unbound).unwrap_err().code, "WORKLOAD_UNAUTHORIZED");
|
|
1609
|
-
let _ = fs::remove_file(path);
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
#[test]
|
|
1613
|
-
fn lease_recovery_records_the_lost_worker_and_is_bounded_by_the_attempt_budget() {
|
|
1614
|
-
let (path, mut store) = store("bounded-recovery");
|
|
1615
|
-
let workload = store.create(create()).unwrap();
|
|
1616
|
-
let id = workload.workload_id.clone();
|
|
1617
|
-
for attempt in 1..=3u32 {
|
|
1618
|
-
let now = 100 * attempt as i64;
|
|
1619
|
-
let claim = store.claim(&id, "worker-a", &capabilities(), now, 10_000).unwrap();
|
|
1620
|
-
assert_eq!(store.recover_expired(claim.expires_at).unwrap(), vec![id.clone()]);
|
|
1621
|
-
let recovered = store.get(&id).unwrap();
|
|
1622
|
-
let lost = recovered.attempts.last().unwrap();
|
|
1623
|
-
assert_eq!(lost.status, AttemptStatus::WorkerLost);
|
|
1624
|
-
assert_eq!(lost.claim_id, claim.claim_id);
|
|
1625
|
-
let recovering = recovered
|
|
1626
|
-
.execution_history
|
|
1627
|
-
.iter()
|
|
1628
|
-
.rev()
|
|
1629
|
-
.find(|event| event.to == WorkloadState::Recovering)
|
|
1630
|
-
.unwrap();
|
|
1631
|
-
assert_eq!(recovering.evidence["recovery_reason"], "lease_expired");
|
|
1632
|
-
assert_eq!(recovering.evidence["worker_id"], "worker-a");
|
|
1633
|
-
assert_eq!(recovering.evidence["claim_id"], claim.claim_id);
|
|
1634
|
-
assert_eq!(recovering.evidence["attempts_used"], attempt.to_string());
|
|
1635
|
-
let decision = &recovered.execution_history.last().unwrap();
|
|
1636
|
-
if attempt < 3 {
|
|
1637
|
-
assert_eq!(recovered.state, WorkloadState::Ready);
|
|
1638
|
-
assert_eq!(decision.evidence["decision"], "retry");
|
|
1639
|
-
} else {
|
|
1640
|
-
assert_eq!(recovered.state, WorkloadState::DeadLetter);
|
|
1641
|
-
assert_eq!(decision.evidence["decision"], "attempt_budget_exhausted");
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
assert_eq!(store.get(&id).unwrap().attempt_count, 3);
|
|
1645
|
-
assert_eq!(
|
|
1646
|
-
store.claim(&id, "worker-b", &capabilities(), 1_000, 10_000).unwrap_err().code,
|
|
1647
|
-
"WORKLOAD_NOT_READY"
|
|
1648
|
-
);
|
|
1649
|
-
let _ = fs::remove_file(path);
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
/// The recovery history of `id` as committed, read by a fresh projection.
|
|
1653
|
-
fn committed_workload(db: &FeltDb, id: &str) -> Workload {
|
|
1654
|
-
WorkloadStore::from_db(db.clone()).unwrap().get(id).unwrap().clone()
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
fn recoveries(workload: &Workload) -> usize {
|
|
1658
|
-
workload
|
|
1659
|
-
.execution_history
|
|
1660
|
-
.iter()
|
|
1661
|
-
.filter(|event| event.to == WorkloadState::Recovering)
|
|
1662
|
-
.count()
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
/// Two writers of one store recover the same expired workload at once.
|
|
1666
|
-
fn race_recovery(db: &FeltDb, now: i64) -> (WorkloadStore, WorkloadStore, Vec<Vec<String>>) {
|
|
1667
|
-
let stores = [
|
|
1668
|
-
WorkloadStore::from_db(db.clone()).unwrap(),
|
|
1669
|
-
WorkloadStore::from_db(db.clone()).unwrap(),
|
|
1670
|
-
];
|
|
1671
|
-
let barrier = std::sync::Barrier::new(2);
|
|
1672
|
-
let results = std::thread::scope(|scope| {
|
|
1673
|
-
let handles = stores
|
|
1674
|
-
.into_iter()
|
|
1675
|
-
.map(|mut store| {
|
|
1676
|
-
let barrier = &barrier;
|
|
1677
|
-
scope.spawn(move || {
|
|
1678
|
-
barrier.wait();
|
|
1679
|
-
let recovered = store.recover_expired(now).unwrap();
|
|
1680
|
-
(store, recovered)
|
|
1681
|
-
})
|
|
1682
|
-
})
|
|
1683
|
-
.collect::<Vec<_>>();
|
|
1684
|
-
handles
|
|
1685
|
-
.into_iter()
|
|
1686
|
-
.map(|handle| handle.join().unwrap())
|
|
1687
|
-
.collect::<Vec<_>>()
|
|
1688
|
-
});
|
|
1689
|
-
let mut results = results.into_iter();
|
|
1690
|
-
let (first, first_recovered) = results.next().unwrap();
|
|
1691
|
-
let (second, second_recovered) = results.next().unwrap();
|
|
1692
|
-
(first, second, vec![first_recovered, second_recovered])
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
#[test]
|
|
1696
|
-
fn concurrent_recovery_commits_one_transition_and_the_loser_converges() {
|
|
1697
|
-
for round in 0..20 {
|
|
1698
|
-
let (path, mut store) = store(&format!("concurrent-recovery-{round}"));
|
|
1699
|
-
let db = store.db.clone();
|
|
1700
|
-
let id = store.create(create()).unwrap().workload_id;
|
|
1701
|
-
let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
|
|
1702
|
-
// A writer that loaded the workload before either recovery.
|
|
1703
|
-
let mut stale = WorkloadStore::from_db(db.clone()).unwrap();
|
|
1704
|
-
|
|
1705
|
-
let (mut first, mut second, recovered) = race_recovery(&db, claim.expires_at);
|
|
1706
|
-
// Exactly one writer committed the recovery; the other reported none.
|
|
1707
|
-
let mut outcomes = recovered.clone();
|
|
1708
|
-
outcomes.sort();
|
|
1709
|
-
assert_eq!(outcomes, vec![vec![], vec![id.clone()]], "round {round}");
|
|
1710
|
-
|
|
1711
|
-
let committed = committed_workload(&db, &id);
|
|
1712
|
-
assert_eq!(recoveries(&committed), 1, "one recovery transition");
|
|
1713
|
-
assert_eq!(committed.state, WorkloadState::Ready);
|
|
1714
|
-
assert_eq!(committed.attempt_count, 1);
|
|
1715
|
-
assert_eq!(committed.attempts.len(), 1);
|
|
1716
|
-
assert_eq!(committed.attempts[0].status, AttemptStatus::WorkerLost);
|
|
1717
|
-
assert!(committed.current_claim.is_none());
|
|
1718
|
-
let recovering = committed
|
|
1719
|
-
.execution_history
|
|
1720
|
-
.iter()
|
|
1721
|
-
.find(|event| event.to == WorkloadState::Recovering)
|
|
1722
|
-
.unwrap();
|
|
1723
|
-
assert_eq!(recovering.evidence["claim_id"], claim.claim_id);
|
|
1724
|
-
assert_eq!(recovering.evidence["attempts_used"], "1");
|
|
1725
|
-
|
|
1726
|
-
// The losing writer adopted the committed workload instead of
|
|
1727
|
-
// keeping its own, uncommitted view.
|
|
1728
|
-
let expected = serde_json::to_value(&committed).unwrap();
|
|
1729
|
-
assert_eq!(serde_json::to_value(first.get(&id).unwrap()).unwrap(), expected);
|
|
1730
|
-
assert_eq!(serde_json::to_value(second.get(&id).unwrap()).unwrap(), expected);
|
|
1731
|
-
// And neither recovers it again, or commits anything more.
|
|
1732
|
-
assert!(first.recover_expired(claim.expires_at + 1).unwrap().is_empty());
|
|
1733
|
-
assert!(second.recover_expired(claim.expires_at + 1).unwrap().is_empty());
|
|
1734
|
-
assert_eq!(
|
|
1735
|
-
serde_json::to_value(committed_workload(&db, &id)).unwrap(),
|
|
1736
|
-
expected,
|
|
1737
|
-
"no further commit"
|
|
1738
|
-
);
|
|
1739
|
-
|
|
1740
|
-
// The lost claimant cannot complete, even through a projection
|
|
1741
|
-
// that still shows its claim as current and unexpired: the guarded
|
|
1742
|
-
// commit refuses the stale version.
|
|
1743
|
-
let refused = stale
|
|
1744
|
-
.start(&id, &claim.claim_id, "worker-a", claim.fencing_token, claim.expires_at - 1)
|
|
1745
|
-
.unwrap_err();
|
|
1746
|
-
assert_eq!(refused.code, "WORKLOAD_CONFLICT");
|
|
1747
|
-
assert_eq!(stale.get(&id).unwrap().state, WorkloadState::Ready);
|
|
1748
|
-
let refused = stale
|
|
1749
|
-
.complete(
|
|
1750
|
-
&id,
|
|
1751
|
-
&claim.claim_id,
|
|
1752
|
-
"worker-a",
|
|
1753
|
-
claim.fencing_token,
|
|
1754
|
-
"late-result",
|
|
1755
|
-
serde_json::json!({"late": true}),
|
|
1756
|
-
vec![],
|
|
1757
|
-
1,
|
|
1758
|
-
claim.expires_at - 1,
|
|
1759
|
-
)
|
|
1760
|
-
.unwrap_err();
|
|
1761
|
-
assert_eq!(refused.code, "WORKLOAD_CLAIM_INVALID");
|
|
1762
|
-
// Through a current projection its fence no longer matches.
|
|
1763
|
-
let refused = first
|
|
1764
|
-
.heartbeat(&id, &claim.claim_id, "worker-a", claim.fencing_token, claim.expires_at - 1, 10_000)
|
|
1765
|
-
.unwrap_err();
|
|
1766
|
-
assert_eq!(refused.code, "WORKLOAD_CLAIM_INVALID");
|
|
1767
|
-
assert_eq!(serde_json::to_value(committed_workload(&db, &id)).unwrap(), expected);
|
|
1768
|
-
drop((store, first, second, stale, db));
|
|
1769
|
-
let _ = fs::remove_file(path);
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
#[test]
|
|
1774
|
-
fn concurrent_recovery_never_exceeds_the_attempt_budget() {
|
|
1775
|
-
let (path, mut store) = store("concurrent-recovery-budget");
|
|
1776
|
-
let db = store.db.clone();
|
|
1777
|
-
let id = store.create(create()).unwrap().workload_id;
|
|
1778
|
-
for attempt in 1..=3i64 {
|
|
1779
|
-
// Each attempt is claimed by an up-to-date writer and lost.
|
|
1780
|
-
let mut claimer = WorkloadStore::from_db(db.clone()).unwrap();
|
|
1781
|
-
let claim = claimer
|
|
1782
|
-
.claim(&id, "worker-a", &capabilities(), 100 * attempt, 10_000)
|
|
1783
|
-
.unwrap();
|
|
1784
|
-
let (_, _, recovered) = race_recovery(&db, claim.expires_at);
|
|
1785
|
-
assert_eq!(recovered.concat(), vec![id.clone()], "attempt {attempt}");
|
|
1786
|
-
let committed = committed_workload(&db, &id);
|
|
1787
|
-
assert_eq!(recoveries(&committed), attempt as usize);
|
|
1788
|
-
assert_eq!(committed.attempt_count, attempt as u32);
|
|
1789
|
-
}
|
|
1790
|
-
let committed = committed_workload(&db, &id);
|
|
1791
|
-
assert_eq!(committed.state, WorkloadState::DeadLetter);
|
|
1792
|
-
assert_eq!(
|
|
1793
|
-
committed.execution_history.last().unwrap().evidence["decision"],
|
|
1794
|
-
"attempt_budget_exhausted"
|
|
1795
|
-
);
|
|
1796
|
-
assert!(committed
|
|
1797
|
-
.attempts
|
|
1798
|
-
.iter()
|
|
1799
|
-
.all(|attempt| attempt.status == AttemptStatus::WorkerLost));
|
|
1800
|
-
let refused = WorkloadStore::from_db(db.clone())
|
|
1801
|
-
.unwrap()
|
|
1802
|
-
.claim(&id, "worker-b", &capabilities(), 10_000, 10_000)
|
|
1803
|
-
.unwrap_err();
|
|
1804
|
-
assert_eq!(refused.code, "WORKLOAD_NOT_READY");
|
|
1805
|
-
assert_eq!(committed_workload(&db, &id).attempt_count, 3);
|
|
1806
|
-
drop((store, db));
|
|
1807
|
-
let _ = fs::remove_file(path);
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
#[test]
|
|
1811
|
-
fn a_policy_that_does_not_retry_worker_failure_dead_letters_a_lost_attempt() {
|
|
1812
|
-
let (path, mut store) = store("no-worker-failure-retry");
|
|
1813
|
-
let mut request = create();
|
|
1814
|
-
request.retry_policy.retry_on = ["timeout".into()].into();
|
|
1815
|
-
let id = store.create(request).unwrap().workload_id;
|
|
1816
|
-
let claim = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap();
|
|
1817
|
-
store.recover_expired(claim.expires_at).unwrap();
|
|
1818
|
-
let workload = store.get(&id).unwrap();
|
|
1819
|
-
assert_eq!(workload.state, WorkloadState::DeadLetter);
|
|
1820
|
-
assert_eq!(
|
|
1821
|
-
workload.execution_history.last().unwrap().evidence["decision"],
|
|
1822
|
-
"worker_failure_not_retryable"
|
|
1823
|
-
);
|
|
1824
|
-
let _ = fs::remove_file(path);
|
|
1825
|
-
}
|
|
1826
|
-
|
|
1827
|
-
#[test]
|
|
1828
|
-
fn claiming_exhausted_ready_work_dead_letters_it_instead() {
|
|
1829
|
-
let (path, mut store) = store("exhausted-claim");
|
|
1830
|
-
let id = store.create(create()).unwrap().workload_id;
|
|
1831
|
-
// What unbounded recovery used to leave behind: Ready, budget spent.
|
|
1832
|
-
let mut legacy = store.get(&id).unwrap().clone();
|
|
1833
|
-
legacy.attempt_count = 7;
|
|
1834
|
-
store.workloads.insert(id.clone(), legacy);
|
|
1835
|
-
store.persist_workload(&id, "fixture.legacy").unwrap();
|
|
1836
|
-
let refused = store.claim(&id, "worker-a", &capabilities(), 100, 10_000).unwrap_err();
|
|
1837
|
-
assert_eq!(refused.code, "WORKLOAD_ATTEMPTS_EXHAUSTED");
|
|
1838
|
-
let workload = store.get(&id).unwrap();
|
|
1839
|
-
assert_eq!(workload.state, WorkloadState::DeadLetter);
|
|
1840
|
-
assert_eq!(
|
|
1841
|
-
workload.execution_history.last().unwrap().evidence["reason"],
|
|
1842
|
-
"attempt_budget_exhausted"
|
|
1843
|
-
);
|
|
1844
|
-
// Durable: a restart sees the same outcome.
|
|
1845
|
-
let reopened = WorkloadStore::from_db(store.db.clone()).unwrap();
|
|
1846
|
-
assert_eq!(reopened.get(&id).unwrap().state, WorkloadState::DeadLetter);
|
|
1847
|
-
let _ = fs::remove_file(path);
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
#[test]
|
|
1851
|
-
fn operator_retry_grants_one_attempt_is_recorded_and_is_bounded() {
|
|
1852
|
-
let (path, mut store) = store("operator-retry");
|
|
1853
|
-
let id = store.create(create()).unwrap().workload_id;
|
|
1854
|
-
// Claim and lose leases until the workload stops being claimable.
|
|
1855
|
-
fn exhaust(store: &mut WorkloadStore, id: &str, now: &mut i64) {
|
|
1856
|
-
loop {
|
|
1857
|
-
*now += 100;
|
|
1858
|
-
match store.claim(id, "worker-a", &capabilities(), *now, 10_000) {
|
|
1859
|
-
Ok(claim) => {
|
|
1860
|
-
store.recover_expired(claim.expires_at).unwrap();
|
|
1861
|
-
}
|
|
1862
|
-
Err(error) => {
|
|
1863
|
-
assert!(
|
|
1864
|
-
matches!(
|
|
1865
|
-
error.code.as_str(),
|
|
1866
|
-
"WORKLOAD_NOT_READY" | "WORKLOAD_ATTEMPTS_EXHAUSTED"
|
|
1867
|
-
),
|
|
1868
|
-
"{error:?}"
|
|
1869
|
-
);
|
|
1870
|
-
return;
|
|
1871
|
-
}
|
|
1872
|
-
}
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
let mut now = 100;
|
|
1876
|
-
exhaust(&mut store, &id, &mut now);
|
|
1877
|
-
assert_eq!(store.get(&id).unwrap().attempt_count, 3);
|
|
1878
|
-
for retry in 1..=3u32 {
|
|
1879
|
-
now += 100;
|
|
1880
|
-
store
|
|
1881
|
-
.retry_with_reason(&id, "operator-1", now, Some("dependency restored"))
|
|
1882
|
-
.unwrap();
|
|
1883
|
-
let event = store.get(&id).unwrap().execution_history.last().unwrap().clone();
|
|
1884
|
-
assert_eq!(event.actor, "operator-1");
|
|
1885
|
-
assert_eq!(event.evidence["retry"], "operator");
|
|
1886
|
-
assert_eq!(event.evidence["reason"], "dependency restored");
|
|
1887
|
-
assert_eq!(event.evidence["operator_retry"], retry.to_string());
|
|
1888
|
-
exhaust(&mut store, &id, &mut now);
|
|
1889
|
-
assert_eq!(
|
|
1890
|
-
store.get(&id).unwrap().attempt_count,
|
|
1891
|
-
3 + retry,
|
|
1892
|
-
"each operator retry grants exactly one attempt"
|
|
1893
|
-
);
|
|
1894
|
-
assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
|
|
1895
|
-
}
|
|
1896
|
-
assert_eq!(
|
|
1897
|
-
store.retry(&id, "operator-1", now + 100).unwrap_err().code,
|
|
1898
|
-
"WORKLOAD_RETRY_EXHAUSTED"
|
|
1899
|
-
);
|
|
1900
|
-
assert_eq!(store.get(&id).unwrap().state, WorkloadState::DeadLetter);
|
|
1901
|
-
let _ = fs::remove_file(path);
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
fn store(name: &str) -> (PathBuf, WorkloadStore) {
|
|
1905
|
-
let path =
|
|
1906
|
-
std::env::temp_dir().join(format!("workload-{name}-{}.json", std::process::id()));
|
|
1907
|
-
let store = WorkloadStore::load(&path).unwrap();
|
|
1908
|
-
(path, store)
|
|
1909
|
-
}
|
|
1910
|
-
#[test]
|
|
1911
|
-
fn legacy_migration_is_idempotent_conflict_detecting_and_source_independent() {
|
|
1912
|
-
let (source_db, mut source) = store("legacy-source-builder");
|
|
1913
|
-
let workload = source.create(create()).unwrap();
|
|
1914
|
-
let legacy = LegacyWorkloadStore {
|
|
1915
|
-
workloads: source.workloads.clone(),
|
|
1916
|
-
// What a pre-requester-scope release wrote.
|
|
1917
|
-
idempotency: idempotency_index(&source.workloads, legacy_idempotency_scope),
|
|
1918
|
-
result_ids: source.result_ids.clone(),
|
|
1919
|
-
event_sequence: derived_event_sequence(&source.workloads),
|
|
1920
|
-
};
|
|
1921
|
-
let legacy_path = source_db.with_extension("legacy-workloads.json");
|
|
1922
|
-
fs::write(&legacy_path, serde_json::to_vec(&legacy).unwrap()).unwrap();
|
|
1923
|
-
let canonical_path = source_db.with_extension("canonical.log");
|
|
1924
|
-
let db = FeltDb::open(&canonical_path).unwrap();
|
|
1925
|
-
let migrated = WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
|
|
1926
|
-
assert_eq!(
|
|
1927
|
-
migrated.get(&workload.workload_id).unwrap().input_hash,
|
|
1928
|
-
workload.input_hash
|
|
1929
|
-
);
|
|
1930
|
-
WorkloadStore::migrate_legacy_file(db.clone(), &legacy_path).unwrap();
|
|
1931
|
-
fs::remove_file(&legacy_path).unwrap();
|
|
1932
|
-
let reopened = WorkloadStore::from_db(FeltDb::open(&canonical_path).unwrap()).unwrap();
|
|
1933
|
-
assert_eq!(
|
|
1934
|
-
reopened.get(&workload.workload_id).unwrap().workload_id,
|
|
1935
|
-
workload.workload_id
|
|
1936
|
-
);
|
|
1937
|
-
|
|
1938
|
-
let mut conflicting = legacy;
|
|
1939
|
-
conflicting
|
|
1940
|
-
.workloads
|
|
1941
|
-
.get_mut(&workload.workload_id)
|
|
1942
|
-
.unwrap()
|
|
1943
|
-
.priority += 1;
|
|
1944
|
-
fs::write(&legacy_path, serde_json::to_vec(&conflicting).unwrap()).unwrap();
|
|
1945
|
-
assert_eq!(
|
|
1946
|
-
WorkloadStore::migrate_legacy_file(db, &legacy_path)
|
|
1947
|
-
.err()
|
|
1948
|
-
.unwrap()
|
|
1949
|
-
.code,
|
|
1950
|
-
"WORKLOAD_MIGRATION_CONFLICT"
|
|
1951
|
-
);
|
|
1952
|
-
let _ = fs::remove_file(source_db);
|
|
1953
|
-
let _ = fs::remove_file(canonical_path);
|
|
1954
|
-
let _ = fs::remove_file(legacy_path);
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
#[test]
|
|
1958
|
-
fn canonical_history_reconstructs_work_identity_provenance() {
|
|
1959
|
-
let (path, mut store) = store("provenance");
|
|
1960
|
-
let workload = store.create(create()).unwrap();
|
|
1961
|
-
let transaction = store
|
|
1962
|
-
.db
|
|
1963
|
-
.committed_transactions_after(0, usize::MAX)
|
|
1964
|
-
.unwrap()
|
|
1965
|
-
.into_iter()
|
|
1966
|
-
.last()
|
|
1967
|
-
.unwrap();
|
|
1968
|
-
let audit = transaction.audit.unwrap();
|
|
1969
|
-
assert_eq!(audit["work_id"], workload.workload_id);
|
|
1970
|
-
assert_eq!(audit["session_id"], workload.session_id.as_deref().unwrap());
|
|
1971
|
-
assert_eq!(audit["agent_id"], workload.requested_by.id());
|
|
1972
|
-
assert_eq!(
|
|
1973
|
-
audit["delegator_id"],
|
|
1974
|
-
workload.authorization_snapshot.grant.issuer.id()
|
|
1975
|
-
);
|
|
1976
|
-
assert!(transaction.commit_revision > transaction.base_revision);
|
|
1977
|
-
assert_eq!(transaction.work_id.as_deref(), Some(workload.workload_id.as_str()));
|
|
1978
|
-
assert_eq!(transaction.agent_id.as_deref(), Some(workload.requested_by.id()));
|
|
1979
|
-
assert_eq!(transaction.session_id, workload.session_id);
|
|
1980
|
-
assert_eq!(
|
|
1981
|
-
transaction.delegator_id.as_deref(),
|
|
1982
|
-
Some(workload.authorization_snapshot.grant.issuer.id())
|
|
1983
|
-
);
|
|
1984
|
-
assert_eq!(transaction.operation_ids.len(), 1);
|
|
1985
|
-
assert_ne!(transaction.operation_ids[0], transaction.transaction_id);
|
|
1986
|
-
drop(store);
|
|
1987
|
-
let reopened = WorkloadStore::load(&path).unwrap();
|
|
1988
|
-
assert_eq!(
|
|
1989
|
-
reopened.get(&workload.workload_id).unwrap().workload_id,
|
|
1990
|
-
workload.workload_id
|
|
1991
|
-
);
|
|
1992
|
-
let _ = fs::remove_file(path);
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
#[test]
|
|
1996
|
-
fn competing_writers_use_canonical_document_cas_before_and_after_restart() {
|
|
1997
|
-
let (path, mut seed) = store("canonical-cas");
|
|
1998
|
-
let workload = seed.create(create()).unwrap();
|
|
1999
|
-
let id = workload.workload_id.clone();
|
|
2000
|
-
let db = seed.db.clone();
|
|
2001
|
-
let caps = ["email:send".into()].into();
|
|
2002
|
-
|
|
2003
|
-
let mut winner = WorkloadStore::from_db(db.clone()).unwrap();
|
|
2004
|
-
let mut loser = WorkloadStore::from_db(db.clone()).unwrap();
|
|
2005
|
-
winner.claim(&id, "worker-a", &caps, 11, 1_000).unwrap();
|
|
2006
|
-
let conflict = loser.claim(&id, "worker-b", &caps, 11, 1_000).unwrap_err();
|
|
2007
|
-
assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
|
|
2008
|
-
// The loser committed nothing and now projects the winner's claim.
|
|
2009
|
-
let projected = loser.get(&id).unwrap();
|
|
2010
|
-
assert_eq!(projected.state, WorkloadState::Claimed);
|
|
2011
|
-
assert_eq!(projected.current_claim.as_ref().unwrap().worker_id, "worker-a");
|
|
2012
|
-
|
|
2013
|
-
let row = db
|
|
2014
|
-
.list_collection(DURABLE_WORK_COLLECTION)
|
|
2015
|
-
.unwrap()
|
|
2016
|
-
.into_iter()
|
|
2017
|
-
.find(|row| row.key.ends_with(&id))
|
|
2018
|
-
.unwrap();
|
|
2019
|
-
assert_eq!(row.value["__version"], 2);
|
|
2020
|
-
|
|
2021
|
-
drop((seed, winner, loser, db));
|
|
2022
|
-
let restarted_db = FeltDb::open(&path).unwrap();
|
|
2023
|
-
let mut recovery = WorkloadStore::from_db(restarted_db.clone()).unwrap();
|
|
2024
|
-
recovery.recover_expired(13).unwrap();
|
|
2025
|
-
let mut restarted_winner = WorkloadStore::from_db(restarted_db.clone()).unwrap();
|
|
2026
|
-
let mut restarted_loser = WorkloadStore::from_db(restarted_db.clone()).unwrap();
|
|
2027
|
-
restarted_winner
|
|
2028
|
-
.claim(&id, "worker-c", &caps, 13, 10_000)
|
|
2029
|
-
.unwrap();
|
|
2030
|
-
let conflict = restarted_loser
|
|
2031
|
-
.claim(&id, "worker-d", &caps, 13, 10_000)
|
|
2032
|
-
.unwrap_err();
|
|
2033
|
-
assert_eq!(conflict.code, "WORKLOAD_CONFLICT");
|
|
2034
|
-
let projected = restarted_loser.get(&id).unwrap();
|
|
2035
|
-
assert_eq!(projected.state, WorkloadState::Claimed);
|
|
2036
|
-
assert_eq!(projected.current_claim.as_ref().unwrap().worker_id, "worker-c");
|
|
2037
|
-
drop((recovery, restarted_winner, restarted_loser, restarted_db));
|
|
2038
|
-
let _ = fs::remove_file(path);
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
#[test]
|
|
2042
|
-
fn competing_admissions_share_one_canonical_idempotency_identity() {
|
|
2043
|
-
let path = std::env::temp_dir().join(format!(
|
|
2044
|
-
"workload-idempotency-cas-{}.log",
|
|
2045
|
-
std::process::id()
|
|
2046
|
-
));
|
|
2047
|
-
let db = FeltDb::open(&path).unwrap();
|
|
2048
|
-
let mut first = WorkloadStore::from_db(db.clone()).unwrap();
|
|
2049
|
-
let mut second = WorkloadStore::from_db(db.clone()).unwrap();
|
|
2050
|
-
let accepted = first.create(create()).unwrap();
|
|
2051
|
-
// A second writer of the same store loses the require-absent CAS and
|
|
2052
|
-
// converges on what was committed: a different request is refused as a
|
|
2053
|
-
// reused key, never answered with the original.
|
|
2054
|
-
let mut competing = create();
|
|
2055
|
-
competing.input = serde_json::json!({"id": 2});
|
|
2056
|
-
let conflict = second.create(competing).unwrap_err();
|
|
2057
|
-
assert_eq!(conflict.code, "IDEMPOTENCY_KEY_REUSED");
|
|
2058
|
-
assert_eq!(
|
|
2059
|
-
second.workloads.keys().collect::<Vec<_>>(),
|
|
2060
|
-
vec![&accepted.workload_id],
|
|
2061
|
-
"only the committed workload is projected"
|
|
2062
|
-
);
|
|
2063
|
-
// The same request from a third, equally stale writer converges on it.
|
|
2064
|
-
let mut third = WorkloadStore::from_db(db.clone()).unwrap();
|
|
2065
|
-
third.workloads.clear();
|
|
2066
|
-
third.idempotency.clear();
|
|
2067
|
-
third.document_versions.clear();
|
|
2068
|
-
let (converged, created) = third.admit(create()).unwrap();
|
|
2069
|
-
assert!(!created);
|
|
2070
|
-
assert_eq!(converged.workload_id, accepted.workload_id);
|
|
2071
|
-
|
|
2072
|
-
drop((first, second, third, db));
|
|
2073
|
-
let restarted = WorkloadStore::from_db(FeltDb::open(&path).unwrap()).unwrap();
|
|
2074
|
-
assert_eq!(restarted.workloads.len(), 1);
|
|
2075
|
-
assert_eq!(
|
|
2076
|
-
restarted.idempotency[&idempotency_scope("t", "app", &accepted.requested_by, "one")],
|
|
2077
|
-
accepted.workload_id
|
|
2078
|
-
);
|
|
2079
|
-
let _ = fs::remove_file(path);
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
#[test]
|
|
2083
|
-
fn idempotency_scopes_cannot_be_made_to_coincide() {
|
|
2084
|
-
let (path, mut store) = store("scope-separators");
|
|
2085
|
-
let mut first = create_as("x:k1");
|
|
2086
|
-
first.idempotency_key = "k2".into();
|
|
2087
|
-
let mut second = create_as("x");
|
|
2088
|
-
second.idempotency_key = "k1:k2".into();
|
|
2089
|
-
let first = store.create(first).unwrap();
|
|
2090
|
-
let second = store.create(second).unwrap();
|
|
2091
|
-
assert_ne!(first.workload_id, second.workload_id);
|
|
2092
|
-
let _ = fs::remove_file(path);
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
#[test]
|
|
2096
|
-
fn concurrent_admissions_by_competing_writers_converge_or_conflict() {
|
|
2097
|
-
for round in 0..20 {
|
|
2098
|
-
let (path, seed) = store(&format!("concurrent-admission-{round}"));
|
|
2099
|
-
let db = seed.db.clone();
|
|
2100
|
-
let admit_concurrently = |requests: [CreateWorkload; 2]| {
|
|
2101
|
-
let barrier = std::sync::Barrier::new(2);
|
|
2102
|
-
std::thread::scope(|scope| {
|
|
2103
|
-
requests
|
|
2104
|
-
.into_iter()
|
|
2105
|
-
.map(|request| {
|
|
2106
|
-
let mut writer = WorkloadStore::from_db(db.clone()).unwrap();
|
|
2107
|
-
let barrier = &barrier;
|
|
2108
|
-
scope.spawn(move || {
|
|
2109
|
-
barrier.wait();
|
|
2110
|
-
writer.admit(request)
|
|
2111
|
-
})
|
|
2112
|
-
})
|
|
2113
|
-
.collect::<Vec<_>>()
|
|
2114
|
-
.into_iter()
|
|
2115
|
-
.map(|handle| handle.join().unwrap())
|
|
2116
|
-
.collect::<Vec<_>>()
|
|
2117
|
-
})
|
|
2118
|
-
};
|
|
2119
|
-
// The same request: both writers answer with one workload, and
|
|
2120
|
-
// exactly one of them created it.
|
|
2121
|
-
let same = admit_concurrently([create(), create()]);
|
|
2122
|
-
let (first, first_created) = same[0].as_ref().unwrap().clone();
|
|
2123
|
-
let (second, second_created) = same[1].as_ref().unwrap().clone();
|
|
2124
|
-
assert_eq!(first.workload_id, second.workload_id);
|
|
2125
|
-
assert!(first_created ^ second_created, "round {round}");
|
|
2126
|
-
|
|
2127
|
-
// Different requests under one key: one is admitted, the other is
|
|
2128
|
-
// refused as a reused key and never answered with the winner.
|
|
2129
|
-
let mut left = create();
|
|
2130
|
-
left.idempotency_key = "contested".into();
|
|
2131
|
-
let mut right = left.clone();
|
|
2132
|
-
right.input = serde_json::json!({"id": 2});
|
|
2133
|
-
let contested = admit_concurrently([left, right]);
|
|
2134
|
-
let admitted = contested.iter().filter(|outcome| outcome.is_ok()).count();
|
|
2135
|
-
assert_eq!(admitted, 1, "round {round}");
|
|
2136
|
-
assert!(contested.iter().any(|outcome| outcome
|
|
2137
|
-
.as_ref()
|
|
2138
|
-
.is_err_and(|error| error.code == "IDEMPOTENCY_KEY_REUSED")));
|
|
2139
|
-
let (winner, _) = contested.iter().find_map(|o| o.as_ref().ok()).unwrap();
|
|
2140
|
-
let stored = WorkloadStore::from_db(db.clone()).unwrap();
|
|
2141
|
-
assert_eq!(stored.workloads.len(), 2);
|
|
2142
|
-
assert_eq!(stored.get(&winner.workload_id).unwrap().input, winner.input);
|
|
2143
|
-
drop((seed, stored, db));
|
|
2144
|
-
let _ = fs::remove_file(path);
|
|
2145
|
-
}
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
#[test]
|
|
2149
|
-
fn pre_versioned_canonical_rows_upgrade_through_the_standard_cas_contract() {
|
|
2150
|
-
let (path, mut store) = store("pre-versioned-upgrade");
|
|
2151
|
-
let workload = store.create(create()).unwrap();
|
|
2152
|
-
let db = store.db.clone();
|
|
2153
|
-
db.apply_atomic_transaction(
|
|
2154
|
-
"fixture:pre-versioned-work",
|
|
2155
|
-
None,
|
|
2156
|
-
&[],
|
|
2157
|
-
&[AtomicMutation {
|
|
2158
|
-
capability: DURABLE_WORK_COLLECTION.into(),
|
|
2159
|
-
key: work_key(&workload),
|
|
2160
|
-
value: Some(serde_json::to_value(&workload).unwrap()),
|
|
2161
|
-
}],
|
|
2162
|
-
None,
|
|
2163
|
-
)
|
|
2164
|
-
.unwrap();
|
|
2165
|
-
|
|
2166
|
-
let mut upgraded = WorkloadStore::from_db(db.clone()).unwrap();
|
|
2167
|
-
upgraded
|
|
2168
|
-
.claim(
|
|
2169
|
-
&workload.workload_id,
|
|
2170
|
-
"worker",
|
|
2171
|
-
&["email:send".into()].into(),
|
|
2172
|
-
11,
|
|
2173
|
-
10_000,
|
|
2174
|
-
)
|
|
2175
|
-
.unwrap();
|
|
2176
|
-
let row = db
|
|
2177
|
-
.list_collection(DURABLE_WORK_COLLECTION)
|
|
2178
|
-
.unwrap()
|
|
2179
|
-
.into_iter()
|
|
2180
|
-
.find(|row| row.key.ends_with(&workload.workload_id))
|
|
2181
|
-
.unwrap();
|
|
2182
|
-
assert_eq!(row.value["__version"], 2);
|
|
2183
|
-
let _ = fs::remove_file(path);
|
|
2184
|
-
}
|
|
2185
|
-
|
|
2186
|
-
#[test]
|
|
2187
|
-
fn derived_indexes_rebuild_exclusively_from_canonical_work() {
|
|
2188
|
-
let (path, mut store) = store("derived-index-rebuild");
|
|
2189
|
-
let workload = store.create(create()).unwrap();
|
|
2190
|
-
let caps = ["email:send".into()].into();
|
|
2191
|
-
let claim = store
|
|
2192
|
-
.claim(&workload.workload_id, "worker", &caps, 11, 10_000)
|
|
2193
|
-
.unwrap();
|
|
2194
|
-
store
|
|
2195
|
-
.start(
|
|
2196
|
-
&workload.workload_id,
|
|
2197
|
-
&claim.claim_id,
|
|
2198
|
-
"worker",
|
|
2199
|
-
claim.fencing_token,
|
|
2200
|
-
12,
|
|
2201
|
-
)
|
|
2202
|
-
.unwrap();
|
|
2203
|
-
store
|
|
2204
|
-
.complete(
|
|
2205
|
-
&workload.workload_id,
|
|
2206
|
-
&claim.claim_id,
|
|
2207
|
-
"worker",
|
|
2208
|
-
claim.fencing_token,
|
|
2209
|
-
"result-rebuilt",
|
|
2210
|
-
serde_json::json!({"ok": true}),
|
|
2211
|
-
vec![],
|
|
2212
|
-
1,
|
|
2213
|
-
13,
|
|
2214
|
-
)
|
|
2215
|
-
.unwrap();
|
|
2216
|
-
|
|
2217
|
-
store.idempotency.clear();
|
|
2218
|
-
store.result_ids.clear();
|
|
2219
|
-
store.document_versions.clear();
|
|
2220
|
-
let rebuilt = WorkloadStore::from_db(store.db.clone()).unwrap();
|
|
2221
|
-
assert_eq!(
|
|
2222
|
-
rebuilt
|
|
2223
|
-
.idempotency
|
|
2224
|
-
.get(&idempotency_scope("t", "app", &workload.requested_by, "one")),
|
|
2225
|
-
Some(&workload.workload_id)
|
|
2226
|
-
);
|
|
2227
|
-
assert_eq!(
|
|
2228
|
-
rebuilt.result_ids.get("result-rebuilt"),
|
|
2229
|
-
Some(&workload.workload_id)
|
|
2230
|
-
);
|
|
2231
|
-
assert!(rebuilt.document_versions[&workload.workload_id] > 1);
|
|
2232
|
-
assert_eq!(
|
|
2233
|
-
rebuilt
|
|
2234
|
-
.get(&workload.workload_id)
|
|
2235
|
-
.unwrap()
|
|
2236
|
-
.execution_history
|
|
2237
|
-
.iter()
|
|
2238
|
-
.map(|event| event.sequence)
|
|
2239
|
-
.collect::<Vec<_>>(),
|
|
2240
|
-
vec![1, 2, 3, 4]
|
|
2241
|
-
);
|
|
2242
|
-
let duplicate = WorkloadStore::from_db(store.db.clone())
|
|
2243
|
-
.unwrap()
|
|
2244
|
-
.create(create())
|
|
2245
|
-
.unwrap();
|
|
2246
|
-
assert_eq!(duplicate.workload_id, workload.workload_id);
|
|
2247
|
-
let _ = fs::remove_file(path);
|
|
2248
|
-
}
|
|
2249
|
-
#[test]
|
|
2250
|
-
fn idempotent_create_and_legal_lifecycle() {
|
|
2251
|
-
let (path, mut store) = store("lifecycle");
|
|
2252
|
-
let one = store.create(create()).unwrap();
|
|
2253
|
-
let two = store.create(create()).unwrap();
|
|
2254
|
-
assert_eq!(one.workload_id, two.workload_id);
|
|
2255
|
-
let caps = ["email:send".into()].into();
|
|
2256
|
-
let claim = store
|
|
2257
|
-
.claim(&one.workload_id, "worker", &caps, 11, 10_000)
|
|
2258
|
-
.unwrap();
|
|
2259
|
-
store
|
|
2260
|
-
.start(
|
|
2261
|
-
&one.workload_id,
|
|
2262
|
-
&claim.claim_id,
|
|
2263
|
-
"worker",
|
|
2264
|
-
claim.fencing_token,
|
|
2265
|
-
12,
|
|
2266
|
-
)
|
|
2267
|
-
.unwrap();
|
|
2268
|
-
let result = store
|
|
2269
|
-
.complete(
|
|
2270
|
-
&one.workload_id,
|
|
2271
|
-
&claim.claim_id,
|
|
2272
|
-
"worker",
|
|
2273
|
-
claim.fencing_token,
|
|
2274
|
-
"result-1",
|
|
2275
|
-
serde_json::json!({"ok":true}),
|
|
2276
|
-
vec![],
|
|
2277
|
-
1,
|
|
2278
|
-
13,
|
|
2279
|
-
)
|
|
2280
|
-
.unwrap();
|
|
2281
|
-
assert_eq!(result.result_id, "result-1");
|
|
2282
|
-
assert!(store
|
|
2283
|
-
.start(
|
|
2284
|
-
&one.workload_id,
|
|
2285
|
-
&claim.claim_id,
|
|
2286
|
-
"worker",
|
|
2287
|
-
claim.fencing_token,
|
|
2288
|
-
14
|
|
2289
|
-
)
|
|
2290
|
-
.is_err());
|
|
2291
|
-
let recovered = WorkloadStore::load(&path).unwrap();
|
|
2292
|
-
assert_eq!(
|
|
2293
|
-
recovered.get(&one.workload_id).unwrap().state,
|
|
2294
|
-
WorkloadState::Succeeded
|
|
2295
|
-
);
|
|
2296
|
-
let _ = fs::remove_file(path);
|
|
2297
|
-
}
|
|
2298
|
-
#[test]
|
|
2299
|
-
fn stale_fence_and_lease_recovery() {
|
|
2300
|
-
let (path, mut store) = store("fence");
|
|
2301
|
-
let w = store.create(create()).unwrap();
|
|
2302
|
-
let caps = ["email:send".into()].into();
|
|
2303
|
-
let old = store.claim(&w.workload_id, "a", &caps, 11, 1000).unwrap();
|
|
2304
|
-
store
|
|
2305
|
-
.start(&w.workload_id, &old.claim_id, "a", old.fencing_token, 11)
|
|
2306
|
-
.unwrap();
|
|
2307
|
-
store.recover_expired(13).unwrap();
|
|
2308
|
-
let new = store.claim(&w.workload_id, "b", &caps, 13, 10_000).unwrap();
|
|
2309
|
-
assert!(new.fencing_token > old.fencing_token);
|
|
2310
|
-
assert_eq!(
|
|
2311
|
-
store
|
|
2312
|
-
.complete(
|
|
2313
|
-
&w.workload_id,
|
|
2314
|
-
&old.claim_id,
|
|
2315
|
-
"a",
|
|
2316
|
-
old.fencing_token,
|
|
2317
|
-
"stale",
|
|
2318
|
-
Value::Null,
|
|
2319
|
-
vec![],
|
|
2320
|
-
1,
|
|
2321
|
-
14
|
|
2322
|
-
)
|
|
2323
|
-
.unwrap_err()
|
|
2324
|
-
.code,
|
|
2325
|
-
"WORKLOAD_STALE_FENCE"
|
|
2326
|
-
);
|
|
2327
|
-
let _ = fs::remove_file(path);
|
|
2328
|
-
}
|
|
2329
|
-
#[test]
|
|
2330
|
-
fn retry_dead_letter_checkpoint_and_secret_safety() {
|
|
2331
|
-
let (path, mut store) = store("retry");
|
|
2332
|
-
let mut input = create();
|
|
2333
|
-
input.retry_policy.max_attempts = 1;
|
|
2334
|
-
let w = store.create(input).unwrap();
|
|
2335
|
-
let caps = ["email:send".into()].into();
|
|
2336
|
-
let claim = store.claim(&w.workload_id, "a", &caps, 11, 10_000).unwrap();
|
|
2337
|
-
store
|
|
2338
|
-
.start(
|
|
2339
|
-
&w.workload_id,
|
|
2340
|
-
&claim.claim_id,
|
|
2341
|
-
"a",
|
|
2342
|
-
claim.fencing_token,
|
|
2343
|
-
12,
|
|
2344
|
-
)
|
|
2345
|
-
.unwrap();
|
|
2346
|
-
assert!(store
|
|
2347
|
-
.checkpoint(
|
|
2348
|
-
&w.workload_id,
|
|
2349
|
-
&claim.claim_id,
|
|
2350
|
-
"a",
|
|
2351
|
-
claim.fencing_token,
|
|
2352
|
-
serde_json::json!({"password":"bad"}),
|
|
2353
|
-
13
|
|
2354
|
-
)
|
|
2355
|
-
.is_err());
|
|
2356
|
-
assert_eq!(
|
|
2357
|
-
store
|
|
2358
|
-
.fail(
|
|
2359
|
-
&w.workload_id,
|
|
2360
|
-
&claim.claim_id,
|
|
2361
|
-
"a",
|
|
2362
|
-
claim.fencing_token,
|
|
2363
|
-
"worker_failure",
|
|
2364
|
-
"crashed",
|
|
2365
|
-
13
|
|
2366
|
-
)
|
|
2367
|
-
.unwrap(),
|
|
2368
|
-
WorkloadState::DeadLetter
|
|
2369
|
-
);
|
|
2370
|
-
let _ = fs::remove_file(path);
|
|
2371
|
-
}
|
|
2372
|
-
#[test]
|
|
2373
|
-
fn cancellation_race_and_duplicate_completion_are_deterministic() {
|
|
2374
|
-
let (path, mut store) = store("cancel");
|
|
2375
|
-
let w = store.create(create()).unwrap();
|
|
2376
|
-
let caps = ["email:send".into()].into();
|
|
2377
|
-
let claim = store.claim(&w.workload_id, "a", &caps, 11, 10_000).unwrap();
|
|
2378
|
-
store
|
|
2379
|
-
.start(
|
|
2380
|
-
&w.workload_id,
|
|
2381
|
-
&claim.claim_id,
|
|
2382
|
-
"a",
|
|
2383
|
-
claim.fencing_token,
|
|
2384
|
-
12,
|
|
2385
|
-
)
|
|
2386
|
-
.unwrap();
|
|
2387
|
-
assert_eq!(
|
|
2388
|
-
store.cancel(&w.workload_id, "user", 13).unwrap(),
|
|
2389
|
-
WorkloadState::CancellationRequested
|
|
2390
|
-
);
|
|
2391
|
-
assert_eq!(
|
|
2392
|
-
store
|
|
2393
|
-
.complete(
|
|
2394
|
-
&w.workload_id,
|
|
2395
|
-
&claim.claim_id,
|
|
2396
|
-
"a",
|
|
2397
|
-
claim.fencing_token,
|
|
2398
|
-
"r",
|
|
2399
|
-
Value::Null,
|
|
2400
|
-
vec![],
|
|
2401
|
-
1,
|
|
2402
|
-
14
|
|
2403
|
-
)
|
|
2404
|
-
.unwrap_err()
|
|
2405
|
-
.code,
|
|
2406
|
-
"WORKLOAD_TRANSITION_DENIED"
|
|
2407
|
-
);
|
|
2408
|
-
store
|
|
2409
|
-
.confirm_cancel(
|
|
2410
|
-
&w.workload_id,
|
|
2411
|
-
&claim.claim_id,
|
|
2412
|
-
"a",
|
|
2413
|
-
claim.fencing_token,
|
|
2414
|
-
14,
|
|
2415
|
-
)
|
|
2416
|
-
.unwrap();
|
|
2417
|
-
assert_eq!(
|
|
2418
|
-
store.get(&w.workload_id).unwrap().state,
|
|
2419
|
-
WorkloadState::Cancelled
|
|
2420
|
-
);
|
|
2421
|
-
let _ = fs::remove_file(path);
|
|
2422
|
-
}
|
|
2423
|
-
#[test]
|
|
2424
|
-
fn scheduler_respects_dependencies_and_tenant_application_isolation() {
|
|
2425
|
-
let (path, mut store) = store("dependency");
|
|
2426
|
-
let parent = store.create(create()).unwrap();
|
|
2427
|
-
let mut child = create();
|
|
2428
|
-
child.idempotency_key = "child".into();
|
|
2429
|
-
child.dependencies = vec![WorkloadDependency {
|
|
2430
|
-
workload_id: parent.workload_id.clone(),
|
|
2431
|
-
required_state: WorkloadState::Succeeded,
|
|
2432
|
-
}];
|
|
2433
|
-
let child = store.create(child).unwrap();
|
|
2434
|
-
assert_eq!(store.list("other", "app").len(), 0);
|
|
2435
|
-
let caps = ["email:send".into()].into();
|
|
2436
|
-
let mut scheduler = LocalDurableScheduler::default();
|
|
2437
|
-
let first = scheduler
|
|
2438
|
-
.claim(&mut store, "t", "app", "worker", &caps, 11, 10_000)
|
|
2439
|
-
.unwrap()
|
|
2440
|
-
.unwrap();
|
|
2441
|
-
assert_eq!(first.workload_id, parent.workload_id);
|
|
2442
|
-
assert_ne!(first.workload_id, child.workload_id);
|
|
2443
|
-
let _ = fs::remove_file(path);
|
|
2444
|
-
}
|
|
2445
|
-
|
|
2446
|
-
// ------------------------------------------------------------------
|
|
2447
|
-
// Temporal work eligibility.
|
|
2448
|
-
//
|
|
2449
|
-
// The invariant under test: a workload may exist durably before it is
|
|
2450
|
-
// executable. Every test below drives the real store — creating, claiming,
|
|
2451
|
-
// reloading from disk — rather than asserting that a field or a function
|
|
2452
|
-
// name exists.
|
|
2453
|
-
// ------------------------------------------------------------------
|
|
2454
|
-
|
|
2455
|
-
/// A store on a path unique to this test, cleared first so a recycled
|
|
2456
|
-
/// process id cannot hand a test a previous run's file.
|
|
2457
|
-
fn temporal_store(name: &str) -> (PathBuf, WorkloadStore) {
|
|
2458
|
-
let path = std::env::temp_dir().join(format!(
|
|
2459
|
-
"workload-temporal-{name}-{}-{}.json",
|
|
2460
|
-
std::process::id(),
|
|
2461
|
-
rand::random::<u64>()
|
|
2462
|
-
));
|
|
2463
|
-
let _ = fs::remove_file(&path);
|
|
2464
|
-
(path.clone(), WorkloadStore::load(&path).unwrap())
|
|
2465
|
-
}
|
|
2466
|
-
|
|
2467
|
-
fn scheduled(not_before: i64) -> CreateWorkload {
|
|
2468
|
-
let mut input = create();
|
|
2469
|
-
input.not_before = Some(not_before);
|
|
2470
|
-
input
|
|
2471
|
-
}
|
|
2472
|
-
|
|
2473
|
-
fn caps() -> BTreeSet<String> {
|
|
2474
|
-
["email:send".into()].into()
|
|
2475
|
-
}
|
|
2476
|
-
|
|
2477
|
-
#[test]
|
|
2478
|
-
fn scheduled_work_is_not_claimable_before_not_before() {
|
|
2479
|
-
let (path, mut store) = temporal_store("before");
|
|
2480
|
-
let workload = store.create(scheduled(2_000)).unwrap();
|
|
2481
|
-
|
|
2482
|
-
// Durable and Ready immediately: existing before it is executable is
|
|
2483
|
-
// the whole point.
|
|
2484
|
-
assert_eq!(workload.state, WorkloadState::Ready);
|
|
2485
|
-
assert_eq!(workload.next_attempt_at, Some(2_000));
|
|
2486
|
-
|
|
2487
|
-
let refused = store
|
|
2488
|
-
.claim(&workload.workload_id, "worker", &caps(), 1_999, 10_000)
|
|
2489
|
-
.unwrap_err();
|
|
2490
|
-
assert_eq!(refused.code, "WORKLOAD_NOT_YET_ELIGIBLE");
|
|
2491
|
-
assert!(
|
|
2492
|
-
refused.message.contains("2000"),
|
|
2493
|
-
"the refusal names the instant the worker should return at: {}",
|
|
2494
|
-
refused.message
|
|
2495
|
-
);
|
|
2496
|
-
|
|
2497
|
-
// Refusing a claim is not a state change. Nothing executed.
|
|
2498
|
-
let after = store.get(&workload.workload_id).unwrap();
|
|
2499
|
-
assert_eq!(after.state, WorkloadState::Ready);
|
|
2500
|
-
assert_eq!(after.attempt_count, 0);
|
|
2501
|
-
assert!(after.attempts.is_empty());
|
|
2502
|
-
assert!(after.current_claim.is_none());
|
|
2503
|
-
let _ = fs::remove_file(path);
|
|
2504
|
-
}
|
|
2505
|
-
|
|
2506
|
-
#[test]
|
|
2507
|
-
fn scheduled_work_becomes_claimable_after_not_before() {
|
|
2508
|
-
let (path, mut store) = temporal_store("after");
|
|
2509
|
-
let workload = store.create(scheduled(2_000)).unwrap();
|
|
2510
|
-
|
|
2511
|
-
// The boundary belongs to the eligible side: at exactly `not_before`
|
|
2512
|
-
// the workload is claimable, so a worker polling on the second does not
|
|
2513
|
-
// have to wait a further tick.
|
|
2514
|
-
let claim = store
|
|
2515
|
-
.claim(&workload.workload_id, "worker", &caps(), 2_000, 10_000)
|
|
2516
|
-
.expect("eligible at the instant itself");
|
|
2517
|
-
assert_eq!(claim.worker_id, "worker");
|
|
2518
|
-
assert_eq!(
|
|
2519
|
-
store.get(&workload.workload_id).unwrap().state,
|
|
2520
|
-
WorkloadState::Claimed
|
|
2521
|
-
);
|
|
2522
|
-
let _ = fs::remove_file(path);
|
|
2523
|
-
}
|
|
2524
|
-
|
|
2525
|
-
#[test]
|
|
2526
|
-
fn scheduled_work_survives_restart_before_eligibility() {
|
|
2527
|
-
let (path, mut store) = temporal_store("restart-before");
|
|
2528
|
-
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
2529
|
-
drop(store);
|
|
2530
|
-
|
|
2531
|
-
// Restart the authority. The workload is still here, still Ready, still
|
|
2532
|
-
// carrying its eligibility, and still refused.
|
|
2533
|
-
let mut reopened = WorkloadStore::load(&path).unwrap();
|
|
2534
|
-
let recovered = reopened.get(&id).unwrap();
|
|
2535
|
-
assert_eq!(recovered.state, WorkloadState::Ready);
|
|
2536
|
-
assert_eq!(recovered.next_attempt_at, Some(2_000));
|
|
2537
|
-
|
|
2538
|
-
assert_eq!(
|
|
2539
|
-
reopened
|
|
2540
|
-
.claim(&id, "worker", &caps(), 1_999, 10_000)
|
|
2541
|
-
.unwrap_err()
|
|
2542
|
-
.code,
|
|
2543
|
-
"WORKLOAD_NOT_YET_ELIGIBLE"
|
|
2544
|
-
);
|
|
2545
|
-
let _ = fs::remove_file(path);
|
|
2546
|
-
}
|
|
2547
|
-
|
|
2548
|
-
#[test]
|
|
2549
|
-
fn scheduled_work_executes_after_restart() {
|
|
2550
|
-
let (path, mut store) = temporal_store("restart-after");
|
|
2551
|
-
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
2552
|
-
drop(store);
|
|
2553
|
-
|
|
2554
|
-
let mut reopened = WorkloadStore::load(&path).unwrap();
|
|
2555
|
-
let claim = reopened
|
|
2556
|
-
.claim(&id, "worker", &caps(), 2_001, 10_000)
|
|
2557
|
-
.expect("claimable after restart once eligible");
|
|
2558
|
-
reopened
|
|
2559
|
-
.start(&id, &claim.claim_id, "worker", claim.fencing_token, 2_002)
|
|
2560
|
-
.unwrap();
|
|
2561
|
-
reopened
|
|
2562
|
-
.complete(
|
|
2563
|
-
&id,
|
|
2564
|
-
&claim.claim_id,
|
|
2565
|
-
"worker",
|
|
2566
|
-
claim.fencing_token,
|
|
2567
|
-
"result-scheduled",
|
|
2568
|
-
serde_json::json!({"ok": true}),
|
|
2569
|
-
vec![],
|
|
2570
|
-
1,
|
|
2571
|
-
2_003,
|
|
2572
|
-
)
|
|
2573
|
-
.unwrap();
|
|
2574
|
-
|
|
2575
|
-
// And the outcome is durable across a second restart.
|
|
2576
|
-
let again = WorkloadStore::load(&path).unwrap();
|
|
2577
|
-
let finished = again.get(&id).unwrap();
|
|
2578
|
-
assert_eq!(finished.state, WorkloadState::Succeeded);
|
|
2579
|
-
assert_eq!(
|
|
2580
|
-
finished.result.as_ref().map(|r| r.result_id.as_str()),
|
|
2581
|
-
Some("result-scheduled")
|
|
2582
|
-
);
|
|
2583
|
-
let _ = fs::remove_file(path);
|
|
2584
|
-
}
|
|
2585
|
-
|
|
2586
|
-
#[test]
|
|
2587
|
-
fn immediate_work_behavior_is_unchanged() {
|
|
2588
|
-
let (path, mut store) = temporal_store("immediate");
|
|
2589
|
-
// `create()` sets no `not_before`, which is what every caller did
|
|
2590
|
-
// before this field existed.
|
|
2591
|
-
let workload = store.create(create()).unwrap();
|
|
2592
|
-
assert_eq!(workload.next_attempt_at, None);
|
|
2593
|
-
assert!(workload.is_eligible_at(workload.created_at));
|
|
2594
|
-
|
|
2595
|
-
// Claimable at its own creation instant, with no waiting.
|
|
2596
|
-
store
|
|
2597
|
-
.claim(
|
|
2598
|
-
&workload.workload_id,
|
|
2599
|
-
"worker",
|
|
2600
|
-
&caps(),
|
|
2601
|
-
workload.created_at,
|
|
2602
|
-
10_000,
|
|
2603
|
-
)
|
|
2604
|
-
.expect("work with no eligibility instant is claimable immediately");
|
|
2605
|
-
let _ = fs::remove_file(path);
|
|
2606
|
-
}
|
|
2607
|
-
|
|
2608
|
-
#[test]
|
|
2609
|
-
fn multiple_workers_claim_temporally_eligible_work_once() {
|
|
2610
|
-
let (path, mut store) = temporal_store("contention");
|
|
2611
|
-
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
2612
|
-
|
|
2613
|
-
// Both workers are polling and both see eligibility open at the same
|
|
2614
|
-
// instant. Exactly one wins.
|
|
2615
|
-
let winner = store
|
|
2616
|
-
.claim(&id, "worker-a", &caps(), 2_000, 10_000)
|
|
2617
|
-
.unwrap();
|
|
2618
|
-
let loser = store
|
|
2619
|
-
.claim(&id, "worker-b", &caps(), 2_000, 10_000)
|
|
2620
|
-
.unwrap_err();
|
|
2621
|
-
assert_eq!(
|
|
2622
|
-
loser.code, "WORKLOAD_NOT_READY",
|
|
2623
|
-
"the second worker is refused on state, not on time: the workload is claimed"
|
|
2624
|
-
);
|
|
2625
|
-
|
|
2626
|
-
// And the loser cannot complete work it never owned.
|
|
2627
|
-
assert_eq!(
|
|
2628
|
-
store
|
|
2629
|
-
.complete(
|
|
2630
|
-
&id,
|
|
2631
|
-
&winner.claim_id,
|
|
2632
|
-
"worker-b",
|
|
2633
|
-
winner.fencing_token,
|
|
2634
|
-
"duplicate",
|
|
2635
|
-
Value::Null,
|
|
2636
|
-
vec![],
|
|
2637
|
-
1,
|
|
2638
|
-
2_001,
|
|
2639
|
-
)
|
|
2640
|
-
.unwrap_err()
|
|
2641
|
-
.code,
|
|
2642
|
-
"WORKLOAD_STALE_FENCE"
|
|
2643
|
-
);
|
|
2644
|
-
assert_eq!(store.get(&id).unwrap().attempt_count, 1);
|
|
2645
|
-
let _ = fs::remove_file(path);
|
|
2646
|
-
}
|
|
2647
|
-
|
|
2648
|
-
#[test]
|
|
2649
|
-
fn retry_sets_next_eligibility_without_duplicate_execution() {
|
|
2650
|
-
let (path, mut store) = temporal_store("retry");
|
|
2651
|
-
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
2652
|
-
|
|
2653
|
-
let first = store
|
|
2654
|
-
.claim(&id, "worker-a", &caps(), 2_000, 10_000)
|
|
2655
|
-
.unwrap();
|
|
2656
|
-
store
|
|
2657
|
-
.start(&id, &first.claim_id, "worker-a", first.fencing_token, 2_001)
|
|
2658
|
-
.unwrap();
|
|
2659
|
-
let state = store
|
|
2660
|
-
.fail(
|
|
2661
|
-
&id,
|
|
2662
|
-
&first.claim_id,
|
|
2663
|
-
"worker-a",
|
|
2664
|
-
first.fencing_token,
|
|
2665
|
-
"timeout",
|
|
2666
|
-
"upstream did not answer",
|
|
2667
|
-
2_002,
|
|
2668
|
-
)
|
|
2669
|
-
.unwrap();
|
|
2670
|
-
|
|
2671
|
-
// Retry re-arms the same eligibility field creation used, so there is
|
|
2672
|
-
// one temporal concept rather than two competing ones.
|
|
2673
|
-
assert_eq!(state, WorkloadState::Ready);
|
|
2674
|
-
let backing_off = store.get(&id).unwrap();
|
|
2675
|
-
let next = backing_off.next_attempt_at.expect("retry set eligibility");
|
|
2676
|
-
assert!(
|
|
2677
|
-
next > 2_002,
|
|
2678
|
-
"backoff pushed eligibility into the future: {next}"
|
|
2679
|
-
);
|
|
2680
|
-
|
|
2681
|
-
assert_eq!(
|
|
2682
|
-
store
|
|
2683
|
-
.claim(&id, "worker-b", &caps(), next - 1, 10_000)
|
|
2684
|
-
.unwrap_err()
|
|
2685
|
-
.code,
|
|
2686
|
-
"WORKLOAD_NOT_YET_ELIGIBLE"
|
|
2687
|
-
);
|
|
2688
|
-
|
|
2689
|
-
let second = store.claim(&id, "worker-b", &caps(), next, 10_000).unwrap();
|
|
2690
|
-
assert!(second.fencing_token > first.fencing_token);
|
|
2691
|
-
|
|
2692
|
-
// The first attempt cannot land a result after the retry was handed on.
|
|
2693
|
-
assert_eq!(
|
|
2694
|
-
store
|
|
2695
|
-
.complete(
|
|
2696
|
-
&id,
|
|
2697
|
-
&first.claim_id,
|
|
2698
|
-
"worker-a",
|
|
2699
|
-
first.fencing_token,
|
|
2700
|
-
"stale",
|
|
2701
|
-
Value::Null,
|
|
2702
|
-
vec![],
|
|
2703
|
-
1,
|
|
2704
|
-
next,
|
|
2705
|
-
)
|
|
2706
|
-
.unwrap_err()
|
|
2707
|
-
.code,
|
|
2708
|
-
"WORKLOAD_STALE_FENCE"
|
|
2709
|
-
);
|
|
2710
|
-
assert_eq!(store.get(&id).unwrap().attempt_count, 2);
|
|
2711
|
-
let _ = fs::remove_file(path);
|
|
2712
|
-
}
|
|
2713
|
-
|
|
2714
|
-
#[test]
|
|
2715
|
-
fn cancellation_before_not_before_prevents_execution() {
|
|
2716
|
-
let (path, mut store) = temporal_store("cancel");
|
|
2717
|
-
let id = store.create(scheduled(2_000)).unwrap().workload_id;
|
|
2718
|
-
|
|
2719
|
-
// Cancelling work that is durable but not yet executable is the whole
|
|
2720
|
-
// reason to be able to see it before it runs.
|
|
2721
|
-
assert_eq!(
|
|
2722
|
-
store.cancel(&id, "operator", 1_500).unwrap(),
|
|
2723
|
-
WorkloadState::Cancelled
|
|
2724
|
-
);
|
|
2725
|
-
|
|
2726
|
-
// Eligibility arriving does not resurrect it.
|
|
2727
|
-
assert_eq!(
|
|
2728
|
-
store
|
|
2729
|
-
.claim(&id, "worker", &caps(), 2_001, 10_000)
|
|
2730
|
-
.unwrap_err()
|
|
2731
|
-
.code,
|
|
2732
|
-
"WORKLOAD_NOT_READY"
|
|
2733
|
-
);
|
|
2734
|
-
|
|
2735
|
-
let reopened = WorkloadStore::load(&path).unwrap();
|
|
2736
|
-
let cancelled = reopened.get(&id).unwrap();
|
|
2737
|
-
assert_eq!(cancelled.state, WorkloadState::Cancelled);
|
|
2738
|
-
assert!(cancelled.attempts.is_empty(), "nothing ever executed");
|
|
2739
|
-
let _ = fs::remove_file(path);
|
|
2740
|
-
}
|
|
2741
|
-
|
|
2742
|
-
#[test]
|
|
2743
|
-
fn a_worker_dying_after_a_claim_returns_the_work_and_it_executes_once() {
|
|
2744
|
-
let (path, mut store) = temporal_store("worker-death");
|
|
2745
|
-
let id = store.create(create()).unwrap().workload_id;
|
|
2746
|
-
|
|
2747
|
-
// Worker A claims a short lease, starts, and dies without completing.
|
|
2748
|
-
let dead = store.claim(&id, "worker-a", &caps(), 10, 1_000).unwrap();
|
|
2749
|
-
store
|
|
2750
|
-
.start(&id, &dead.claim_id, "worker-a", dead.fencing_token, 10)
|
|
2751
|
-
.unwrap();
|
|
2752
|
-
assert_eq!(
|
|
2753
|
-
store.get(&id).unwrap().state,
|
|
2754
|
-
WorkloadState::Running,
|
|
2755
|
-
"the work was in flight when its worker died"
|
|
2756
|
-
);
|
|
2757
|
-
|
|
2758
|
-
// The lease expiring is the only thing that can notice.
|
|
2759
|
-
let recovered = store.recover_expired(12).unwrap();
|
|
2760
|
-
assert_eq!(recovered, vec![id.clone()]);
|
|
2761
|
-
let back = store.get(&id).unwrap();
|
|
2762
|
-
assert_eq!(back.state, WorkloadState::Ready, "returned to the pool");
|
|
2763
|
-
assert_eq!(
|
|
2764
|
-
back.attempts.last().map(|attempt| attempt.status),
|
|
2765
|
-
Some(AttemptStatus::WorkerLost),
|
|
2766
|
-
"and the dead attempt is recorded as lost rather than erased"
|
|
2767
|
-
);
|
|
2768
|
-
|
|
2769
|
-
// Worker B picks it up and finishes it.
|
|
2770
|
-
let live = store.claim(&id, "worker-b", &caps(), 12, 10_000).unwrap();
|
|
2771
|
-
assert!(live.fencing_token > dead.fencing_token);
|
|
2772
|
-
store
|
|
2773
|
-
.start(&id, &live.claim_id, "worker-b", live.fencing_token, 13)
|
|
2774
|
-
.unwrap();
|
|
2775
|
-
store
|
|
2776
|
-
.complete(
|
|
2777
|
-
&id,
|
|
2778
|
-
&live.claim_id,
|
|
2779
|
-
"worker-b",
|
|
2780
|
-
live.fencing_token,
|
|
2781
|
-
"result-recovered",
|
|
2782
|
-
serde_json::json!({"ok": true}),
|
|
2783
|
-
vec![],
|
|
2784
|
-
1,
|
|
2785
|
-
14,
|
|
2786
|
-
)
|
|
2787
|
-
.unwrap();
|
|
2788
|
-
|
|
2789
|
-
// Exactly one execution succeeded, and the dead worker cannot add another.
|
|
2790
|
-
let finished = store.get(&id).unwrap();
|
|
2791
|
-
assert_eq!(finished.state, WorkloadState::Succeeded);
|
|
2792
|
-
assert_eq!(
|
|
2793
|
-
finished
|
|
2794
|
-
.attempts
|
|
2795
|
-
.iter()
|
|
2796
|
-
.filter(|attempt| attempt.status == AttemptStatus::Succeeded)
|
|
2797
|
-
.count(),
|
|
2798
|
-
1
|
|
2799
|
-
);
|
|
2800
|
-
// The dead worker cannot add a second result. Its claim was released by
|
|
2801
|
-
// the recovery, and completion released the successor's, so it is
|
|
2802
|
-
// refused for having no claim at all rather than a stale one.
|
|
2803
|
-
assert_eq!(
|
|
2804
|
-
store
|
|
2805
|
-
.complete(
|
|
2806
|
-
&id,
|
|
2807
|
-
&dead.claim_id,
|
|
2808
|
-
"worker-a",
|
|
2809
|
-
dead.fencing_token,
|
|
2810
|
-
"resurrected",
|
|
2811
|
-
Value::Null,
|
|
2812
|
-
vec![],
|
|
2813
|
-
1,
|
|
2814
|
-
15,
|
|
2815
|
-
)
|
|
2816
|
-
.unwrap_err()
|
|
2817
|
-
.code,
|
|
2818
|
-
"WORKLOAD_CLAIM_INVALID"
|
|
2819
|
-
);
|
|
2820
|
-
let _ = fs::remove_file(path);
|
|
2821
|
-
}
|
|
2822
|
-
|
|
2823
|
-
#[test]
|
|
2824
|
-
fn a_recovered_workload_refuses_its_dead_worker_while_the_successor_still_holds_it() {
|
|
2825
|
-
let (path, mut store) = temporal_store("worker-death-inflight");
|
|
2826
|
-
let id = store.create(create()).unwrap().workload_id;
|
|
2827
|
-
|
|
2828
|
-
let dead = store.claim(&id, "worker-a", &caps(), 10, 1_000).unwrap();
|
|
2829
|
-
store
|
|
2830
|
-
.start(&id, &dead.claim_id, "worker-a", dead.fencing_token, 10)
|
|
2831
|
-
.unwrap();
|
|
2832
|
-
store.recover_expired(12).unwrap();
|
|
2833
|
-
let live = store.claim(&id, "worker-b", &caps(), 12, 10_000).unwrap();
|
|
2834
|
-
|
|
2835
|
-
// While the successor is mid-flight, the resurrected worker is refused
|
|
2836
|
-
// on the fence itself — the case a lease exists to decide.
|
|
2837
|
-
assert_eq!(
|
|
2838
|
-
store
|
|
2839
|
-
.complete(
|
|
2840
|
-
&id,
|
|
2841
|
-
&dead.claim_id,
|
|
2842
|
-
"worker-a",
|
|
2843
|
-
dead.fencing_token,
|
|
2844
|
-
"resurrected",
|
|
2845
|
-
Value::Null,
|
|
2846
|
-
vec![],
|
|
2847
|
-
1,
|
|
2848
|
-
13,
|
|
2849
|
-
)
|
|
2850
|
-
.unwrap_err()
|
|
2851
|
-
.code,
|
|
2852
|
-
"WORKLOAD_STALE_FENCE"
|
|
2853
|
-
);
|
|
2854
|
-
assert_eq!(store.get(&id).unwrap().state, WorkloadState::Claimed);
|
|
2855
|
-
assert!(live.fencing_token > dead.fencing_token);
|
|
2856
|
-
let _ = fs::remove_file(path);
|
|
2857
|
-
}
|
|
2858
|
-
|
|
2859
|
-
#[test]
|
|
2860
|
-
fn the_scheduler_skips_ineligible_work_rather_than_failing_on_it() {
|
|
2861
|
-
let (path, mut store) = temporal_store("scheduler");
|
|
2862
|
-
let mut early = create();
|
|
2863
|
-
early.idempotency_key = "early".into();
|
|
2864
|
-
early.not_before = Some(9_000);
|
|
2865
|
-
let early = store.create(early).unwrap();
|
|
2866
|
-
|
|
2867
|
-
let mut ready = create();
|
|
2868
|
-
ready.idempotency_key = "ready".into();
|
|
2869
|
-
let ready = store.create(ready).unwrap();
|
|
2870
|
-
|
|
2871
|
-
// The ineligible workload sorts first by id in some orderings and not
|
|
2872
|
-
// others, so what matters is that the scheduler never returns it and
|
|
2873
|
-
// never fails because of it.
|
|
2874
|
-
let mut scheduler = LocalDurableScheduler::default();
|
|
2875
|
-
let claim = scheduler
|
|
2876
|
-
.claim(&mut store, "t", "app", "worker", &caps(), 1_000, 10_000)
|
|
2877
|
-
.expect("an ineligible workload must not fail the whole pass")
|
|
2878
|
-
.expect("the eligible workload is still handed out");
|
|
2879
|
-
assert_eq!(claim.workload_id, ready.workload_id);
|
|
2880
|
-
assert_eq!(
|
|
2881
|
-
store.get(&early.workload_id).unwrap().state,
|
|
2882
|
-
WorkloadState::Ready
|
|
2883
|
-
);
|
|
2884
|
-
let _ = fs::remove_file(path);
|
|
2885
|
-
}
|
|
2886
|
-
}
|