@feltdb/core 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +165 -0
- package/dist/create/server-source/Cargo.toml +9 -0
- package/dist/create/server-source/crates/feltdb/Cargo.toml +3 -0
- package/dist/create/server-source/crates/feltdb/benches/gate13_baseline.rs +44 -44
- package/dist/create/server-source/crates/feltdb/benches/gate13_phase_7_1_release_economics.rs +12 -24
- package/dist/create/server-source/crates/feltdb/benches/gate_13_redux.rs +7 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_13_regression_runner.rs +13 -10
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_concurrent_writer_scaling.rs +12 -9
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_production_admission_revalidation.rs +78 -25
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc2_admission_contract.rs +16 -13
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_rc_root_cause.rs +13 -5
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync1_queued_prototype.rs +41 -22
- package/dist/create/server-source/crates/feltdb/benches/gate_14a_sync_economics.rs +33 -15
- package/dist/create/server-source/crates/feltdb/benches/gate_14b_causal_backlog_scaling.rs +100 -33
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_contract_test.rs +56 -20
- package/dist/create/server-source/crates/feltdb/benches/gate_14c_replication_scaling.rs +116 -41
- package/dist/create/server-source/crates/feltdb/benches/gate_14d_combined_dimension_scaling.rs +186 -55
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_2_optimization_benchmark.rs +64 -26
- package/dist/create/server-source/crates/feltdb/benches/phase_7_1_3_crossover_analysis.rs +46 -15
- package/dist/create/server-source/crates/feltdb/src/admission.rs +8 -15
- package/dist/create/server-source/crates/feltdb/src/admission_contract_tests.rs +43 -13
- package/dist/create/server-source/crates/feltdb/src/adversarial_transport.rs +15 -42
- package/dist/create/server-source/crates/feltdb/src/analytics.rs +65 -19
- package/dist/create/server-source/crates/feltdb/src/application.rs +113 -30
- package/dist/create/server-source/crates/feltdb/src/authorization_security_tests.rs +475 -140
- package/dist/create/server-source/crates/feltdb/src/cardinality_diagnostics.rs +17 -15
- package/dist/create/server-source/crates/feltdb/src/cardinality_endpoint.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/causal_backlog_bound.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier.rs +266 -114
- package/dist/create/server-source/crates/feltdb/src/causal_dependency_barrier_phase_7_1.rs +25 -7
- package/dist/create/server-source/crates/feltdb/src/concurrency_fuzzing.rs +10 -15
- package/dist/create/server-source/crates/feltdb/src/consistency_contract.rs +3 -11
- package/dist/create/server-source/crates/feltdb/src/crash_atomic_boundary.rs +14 -5
- package/dist/create/server-source/crates/feltdb/src/crash_injection.rs +21 -25
- package/dist/create/server-source/crates/feltdb/src/crash_recovery_tests.rs +14 -11
- package/dist/create/server-source/crates/feltdb/src/dedup_bound_investigation.rs +103 -22
- package/dist/create/server-source/crates/feltdb/src/distributed_indexing.rs +18 -15
- package/dist/create/server-source/crates/feltdb/src/durability_guarantees.rs +12 -8
- package/dist/create/server-source/crates/feltdb/src/durable_dedup_set.rs +1 -5
- package/dist/create/server-source/crates/feltdb/src/durable_operation_identity.rs +87 -23
- package/dist/create/server-source/crates/feltdb/src/durable_operation_log.rs +3 -7
- package/dist/create/server-source/crates/feltdb/src/durable_sync.rs +10 -9
- package/dist/create/server-source/crates/feltdb/src/in_process_transport.rs +1 -6
- package/dist/create/server-source/crates/feltdb/src/indexing.rs +35 -38
- package/dist/create/server-source/crates/feltdb/src/lib.rs +1648 -46
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
- package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
- package/dist/create/server-source/crates/feltdb/src/operation.rs +39 -0
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +79 -70
- package/dist/create/server-source/crates/feltdb/src/phase1c_atomicity_proof.rs +3 -3
- package/dist/create/server-source/crates/feltdb/src/phase5_integration.rs +33 -11
- package/dist/create/server-source/crates/feltdb/src/phase5_scenarios.rs +6 -6
- package/dist/create/server-source/crates/feltdb/src/phase6_adversarial_scenarios.rs +14 -56
- package/dist/create/server-source/crates/feltdb/src/phase6_convergence_validator.rs +29 -27
- package/dist/create/server-source/crates/feltdb/src/phase6_persistence.rs +35 -17
- package/dist/create/server-source/crates/feltdb/src/phase_1c_real_tcp.rs +8 -2
- package/dist/create/server-source/crates/feltdb/src/phase_2a_failures.rs +59 -15
- package/dist/create/server-source/crates/feltdb/src/phase_2b_network.rs +70 -17
- package/dist/create/server-source/crates/feltdb/src/phase_2c_cascading.rs +23 -6
- package/dist/create/server-source/crates/feltdb/src/phase_3_durability.rs +12 -3
- package/dist/create/server-source/crates/feltdb/src/phase_4_baseline.rs +41 -11
- package/dist/create/server-source/crates/feltdb/src/phase_5_soak.rs +56 -25
- package/dist/create/server-source/crates/feltdb/src/policy_evaluation.rs +701 -245
- package/dist/create/server-source/crates/feltdb/src/production_api.rs +31 -13
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/replay_fuzzing.rs +5 -5
- package/dist/create/server-source/crates/feltdb/src/replica_acknowledgements.rs +48 -18
- package/dist/create/server-source/crates/feltdb/src/replica_membership.rs +30 -11
- package/dist/create/server-source/crates/feltdb/src/replication_manager.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/replication_protocol.rs +4 -3
- package/dist/create/server-source/crates/feltdb/src/sharding.rs +36 -10
- package/dist/create/server-source/crates/feltdb/src/state_conflict_contract.rs +516 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +13 -4
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1565 -536
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
- package/dist/create/server-source/crates/feltdb/src/storage.rs +9 -3
- package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/tcp_transport.rs +6 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_api.rs +24 -35
- package/dist/create/server-source/crates/feltdb/src/transaction_invariants.rs +24 -8
- package/dist/create/server-source/crates/feltdb/src/transaction_preconditions.rs +248 -59
- package/dist/create/server-source/crates/feltdb/src/transactions.rs +17 -20
- package/dist/create/server-source/crates/feltdb/src/trigger_contract.rs +749 -0
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +1 -0
- package/dist/create/server-source/crates/feltdb/src/workload.rs +512 -4
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +3 -4
- package/dist/create/server-source/crates/feltdb/tests/bounded_read_contract.rs +132 -0
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/compaction_stall_contract.rs +272 -0
- package/dist/create/server-source/crates/feltdb/tests/crash_durability_contract.rs +467 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +309 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_backup_contract.rs +445 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_corruption_contract.rs +518 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
- package/dist/create/server-source/crates/feltdb/tests/operational_health_contract.rs +278 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +2 -1
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +80 -25
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1365 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
- package/dist/create/server-source/crates/feltdb/tests/replicated_history_contract.rs +417 -0
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +442 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +16 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +695 -47
- package/dist/create/server-source/crates/feltdb-server/src/metrics.rs +21 -0
- package/dist/studio-app/assets/{feltdb_wasm-CVQWgXO-.js → feltdb_wasm-C1VhI-U5.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-C8HXbAXb.wasm +0 -0
- package/dist/studio-app/assets/{index-DwgNAIIX.js → index-Bbos1m2U.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workload.d.ts +2 -1
- package/dist/workload.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
|
@@ -62,7 +62,7 @@ use feltdb::{
|
|
|
62
62
|
},
|
|
63
63
|
workload::{CreateWorkload, WorkloadStore},
|
|
64
64
|
AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
|
|
65
|
-
PeerAdvertisement, PeerId, RecordPrecondition, StoredRow,
|
|
65
|
+
PeerAdvertisement, PeerId, RecordPrecondition, StateTriggerStore, StoredRow,
|
|
66
66
|
};
|
|
67
67
|
use feltdb_server::{
|
|
68
68
|
app_state::{AppState, BoundedQueryCursor},
|
|
@@ -1421,9 +1421,10 @@ struct WorkerReconcileRequest {
|
|
|
1421
1421
|
fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
|
|
1422
1422
|
let status = match error.code.as_str() {
|
|
1423
1423
|
"WORKLOAD_NOT_FOUND" => StatusCode::NOT_FOUND,
|
|
1424
|
-
"WORKLOAD_STALE_FENCE"
|
|
1425
|
-
|
|
1426
|
-
|
|
1424
|
+
"WORKLOAD_STALE_FENCE"
|
|
1425
|
+
| "WORKLOAD_TRANSITION_DENIED"
|
|
1426
|
+
| "WORKLOAD_NOT_READY"
|
|
1427
|
+
| "WORKLOAD_NOT_YET_ELIGIBLE" => StatusCode::CONFLICT,
|
|
1427
1428
|
"WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
|
|
1428
1429
|
"WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
|
|
1429
1430
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
@@ -6882,7 +6883,8 @@ async fn application_openapi() -> Json<Value> {
|
|
|
6882
6883
|
},
|
|
6883
6884
|
"security": [{ "bearer": [] }],
|
|
6884
6885
|
"paths": {
|
|
6885
|
-
"/health": { "get": { "security": [], "responses": { "200": { "description": "
|
|
6886
|
+
"/health": { "get": { "security": [], "responses": { "200": { "description": "Process liveness" } } } },
|
|
6887
|
+
"/health/ready": { "get": { "security": [], "responses": { "200": { "description": "Storage and scoped-operation readiness" }, "503": { "description": "Not ready" } } } },
|
|
6886
6888
|
"/application": { "get": { "responses": { "200": { "description": "Active application contract identity" } } } },
|
|
6887
6889
|
"/schema": { "get": { "responses": { "200": { "description": "Active application schema" } } } },
|
|
6888
6890
|
"/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
|
|
@@ -8090,6 +8092,8 @@ struct Config {
|
|
|
8090
8092
|
peers: Vec<String>,
|
|
8091
8093
|
peer_token_env: String,
|
|
8092
8094
|
sync_interval: Duration,
|
|
8095
|
+
max_in_flight: usize,
|
|
8096
|
+
request_deadline: Duration,
|
|
8093
8097
|
audit: PathBuf,
|
|
8094
8098
|
allowed_origins: Vec<axum::http::HeaderValue>,
|
|
8095
8099
|
worker_enabled: bool,
|
|
@@ -8118,6 +8122,11 @@ impl Config {
|
|
|
8118
8122
|
let mut peers = Vec::new();
|
|
8119
8123
|
let mut peer_token_env = "FELTDB_PEER_TOKEN".to_string();
|
|
8120
8124
|
let mut sync_interval = Duration::from_secs(2);
|
|
8125
|
+
// Concurrency past this point does not buy throughput: the database
|
|
8126
|
+
// serializes every read and write on one lock. It only decides whether
|
|
8127
|
+
// an overload is shed quickly or queued invisibly.
|
|
8128
|
+
let mut max_in_flight = 64usize;
|
|
8129
|
+
let mut request_deadline = Duration::from_secs(10);
|
|
8121
8130
|
let mut audit = None;
|
|
8122
8131
|
let mut allowed_origins = std::env::var("FELTDB_ALLOWED_ORIGINS")
|
|
8123
8132
|
.ok()
|
|
@@ -8164,6 +8173,23 @@ impl Config {
|
|
|
8164
8173
|
}
|
|
8165
8174
|
sync_interval = Duration::from_millis(milliseconds);
|
|
8166
8175
|
}
|
|
8176
|
+
"--max-in-flight" => {
|
|
8177
|
+
max_in_flight = value()?
|
|
8178
|
+
.parse()
|
|
8179
|
+
.map_err(|_| "invalid --max-in-flight".to_string())?;
|
|
8180
|
+
if max_in_flight == 0 {
|
|
8181
|
+
return Err("--max-in-flight must be at least 1".to_string());
|
|
8182
|
+
}
|
|
8183
|
+
}
|
|
8184
|
+
"--request-deadline-ms" => {
|
|
8185
|
+
let milliseconds: u64 = value()?
|
|
8186
|
+
.parse()
|
|
8187
|
+
.map_err(|_| "invalid --request-deadline-ms".to_string())?;
|
|
8188
|
+
if milliseconds < 100 {
|
|
8189
|
+
return Err("--request-deadline-ms must be at least 100".to_string());
|
|
8190
|
+
}
|
|
8191
|
+
request_deadline = Duration::from_millis(milliseconds);
|
|
8192
|
+
}
|
|
8167
8193
|
"--audit" => audit = Some(PathBuf::from(value()?)),
|
|
8168
8194
|
"--allow-origin" => allowed_origins.push(
|
|
8169
8195
|
value()?
|
|
@@ -8203,6 +8229,8 @@ impl Config {
|
|
|
8203
8229
|
peers,
|
|
8204
8230
|
peer_token_env,
|
|
8205
8231
|
sync_interval,
|
|
8232
|
+
max_in_flight,
|
|
8233
|
+
request_deadline,
|
|
8206
8234
|
audit,
|
|
8207
8235
|
allowed_origins,
|
|
8208
8236
|
worker_enabled,
|
|
@@ -8289,6 +8317,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8289
8317
|
if manage_workloads()? {
|
|
8290
8318
|
return Ok(());
|
|
8291
8319
|
}
|
|
8320
|
+
if manage_state_triggers()? {
|
|
8321
|
+
return Ok(());
|
|
8322
|
+
}
|
|
8292
8323
|
if manage_backup()? {
|
|
8293
8324
|
return Ok(());
|
|
8294
8325
|
}
|
|
@@ -8336,6 +8367,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8336
8367
|
let grant_store = GrantStore::load(config.data.with_extension("grants.json"))?;
|
|
8337
8368
|
let sync_store = SyncStore::load(config.data.with_extension("sync.json"))?;
|
|
8338
8369
|
let workload_store = WorkloadStore::load(config.data.with_extension("workloads.json"))?;
|
|
8370
|
+
let state_trigger_store = StateTriggerStore::load(config.data.with_extension("triggers.json"))?;
|
|
8339
8371
|
let mesh_store = WorkerMeshStore::load(config.data.with_extension("workers.json"))?;
|
|
8340
8372
|
let content_store = ContentStore::new(config.data.with_extension("content"))?;
|
|
8341
8373
|
let artifact_store = ArtifactStore::load(
|
|
@@ -8352,10 +8384,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8352
8384
|
let audit_handle = AuditLog::new(config.audit.clone());
|
|
8353
8385
|
let state = AppState {
|
|
8354
8386
|
started_at: Instant::now(),
|
|
8387
|
+
admission: Arc::new(tokio::sync::Semaphore::new(config.max_in_flight)),
|
|
8388
|
+
request_deadline: config.request_deadline,
|
|
8355
8389
|
ids: Arc::new(AtomicU64::new(db.sequence()?)),
|
|
8356
8390
|
db,
|
|
8357
8391
|
namespace: Arc::from(config.namespace.clone()),
|
|
8358
8392
|
auth_enabled: config.auth_enabled,
|
|
8393
|
+
// Argon2 is deliberately memory-hard. Bound concurrent legacy/session
|
|
8394
|
+
// verification so a burst waits asynchronously instead of exhausting
|
|
8395
|
+
// a small production VM with one allocation per request.
|
|
8396
|
+
authentication_workers: Arc::new(tokio::sync::Semaphore::new(2)),
|
|
8359
8397
|
keys: KeyStore::load(&config.keys)?,
|
|
8360
8398
|
metrics: Metrics::default(),
|
|
8361
8399
|
cluster: ClusterStore::load(
|
|
@@ -8403,6 +8441,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8403
8441
|
grants: Arc::new(std::sync::Mutex::new(grant_store)),
|
|
8404
8442
|
sync: Arc::new(std::sync::Mutex::new(sync_store)),
|
|
8405
8443
|
workloads: Arc::new(std::sync::Mutex::new(workload_store)),
|
|
8444
|
+
state_triggers: Arc::new(std::sync::Mutex::new(state_trigger_store)),
|
|
8406
8445
|
mesh: Arc::new(std::sync::Mutex::new(mesh_store)),
|
|
8407
8446
|
readiness_probe: Arc::new(readiness_probe),
|
|
8408
8447
|
bounded_query_cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
|
@@ -9057,6 +9096,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9057
9096
|
|
|
9058
9097
|
let mut app = Router::new()
|
|
9059
9098
|
.route("/health", get(health))
|
|
9099
|
+
.route("/health/ready", get(health_ready))
|
|
9060
9100
|
.route("/v1/health", get(health))
|
|
9061
9101
|
.route("/v1/openapi.json", get(application_openapi))
|
|
9062
9102
|
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
@@ -9080,6 +9120,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9080
9120
|
)
|
|
9081
9121
|
.merge(protected)
|
|
9082
9122
|
.merge(query_execution_diagnostics_router())
|
|
9123
|
+
.layer(middleware::from_fn_with_state(state.clone(), admit_request))
|
|
9083
9124
|
.layer(middleware::from_fn(protocol_version))
|
|
9084
9125
|
.layer(middleware::from_fn(attribute_request))
|
|
9085
9126
|
.layer(middleware::from_fn_with_state(state.clone(), count_request))
|
|
@@ -9165,16 +9206,52 @@ async fn authenticate(
|
|
|
9165
9206
|
.headers()
|
|
9166
9207
|
.get(AUTHORIZATION)
|
|
9167
9208
|
.and_then(|header| header.to_str().ok())
|
|
9168
|
-
.and_then(|header| header.strip_prefix("Bearer "))
|
|
9169
|
-
|
|
9209
|
+
.and_then(|header| header.strip_prefix("Bearer "))
|
|
9210
|
+
.map(str::to_owned);
|
|
9211
|
+
let keys = state.keys.clone();
|
|
9212
|
+
let identities = state.identities.clone();
|
|
9213
|
+
let namespace = state.namespace.clone();
|
|
9214
|
+
let authentication_worker = match state.authentication_workers.clone().acquire_owned().await
|
|
9215
|
+
{
|
|
9216
|
+
Ok(permit) => permit,
|
|
9217
|
+
Err(error) => {
|
|
9218
|
+
tracing::error!(%error, "authentication worker limit closed");
|
|
9219
|
+
return ApiError(
|
|
9220
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9221
|
+
"authentication temporarily unavailable".into(),
|
|
9222
|
+
)
|
|
9223
|
+
.into_response();
|
|
9224
|
+
}
|
|
9225
|
+
};
|
|
9226
|
+
let (machine, actor) = match tokio::task::spawn_blocking(move || {
|
|
9227
|
+
let _authentication_worker = authentication_worker;
|
|
9228
|
+
let machine = token
|
|
9229
|
+
.as_deref()
|
|
9230
|
+
.and_then(|token| keys.authenticate(token, &namespace));
|
|
9231
|
+
let actor = if machine.is_none() {
|
|
9232
|
+
token
|
|
9233
|
+
.as_deref()
|
|
9234
|
+
.and_then(|token| identities.authenticate_session(token))
|
|
9235
|
+
} else {
|
|
9236
|
+
None
|
|
9237
|
+
};
|
|
9238
|
+
(machine, actor)
|
|
9239
|
+
})
|
|
9240
|
+
.await
|
|
9241
|
+
{
|
|
9242
|
+
Ok(authentication) => authentication,
|
|
9243
|
+
Err(error) => {
|
|
9244
|
+
tracing::error!(%error, "bearer authentication worker failed");
|
|
9245
|
+
return ApiError(
|
|
9246
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9247
|
+
"authentication temporarily unavailable".into(),
|
|
9248
|
+
)
|
|
9249
|
+
.into_response();
|
|
9250
|
+
}
|
|
9251
|
+
};
|
|
9170
9252
|
// A valid service key is already authoritative. Do not feed that
|
|
9171
9253
|
// high-entropy bearer token through the Argon2-backed human-session
|
|
9172
9254
|
// verifier as well; doing so blocks the request worker needlessly.
|
|
9173
|
-
let actor = if machine.is_none() {
|
|
9174
|
-
token.and_then(|token| state.identities.authenticate_session(token))
|
|
9175
|
-
} else {
|
|
9176
|
-
None
|
|
9177
|
-
};
|
|
9178
9255
|
let human = request
|
|
9179
9256
|
.headers()
|
|
9180
9257
|
.get(COOKIE)
|
|
@@ -9425,6 +9502,234 @@ async fn attribute_request(request: Request<axum::body::Body>, next: Next) -> Re
|
|
|
9425
9502
|
next.run(request).await
|
|
9426
9503
|
}
|
|
9427
9504
|
|
|
9505
|
+
/// The overall status, from the conditions that actually bear on it.
|
|
9506
|
+
///
|
|
9507
|
+
/// Storage participates. Before it did not, so a database that had discarded an
|
|
9508
|
+
/// incomplete final append reported exactly what a cleanly replayed one did —
|
|
9509
|
+
/// which is the difference between an operator learning their last write was
|
|
9510
|
+
/// lost and never finding out.
|
|
9511
|
+
fn overall_status(
|
|
9512
|
+
membership_healthy: bool,
|
|
9513
|
+
clock_healthy: bool,
|
|
9514
|
+
storage: &feltdb::StorageHealth,
|
|
9515
|
+
) -> &'static str {
|
|
9516
|
+
if membership_healthy && clock_healthy && storage.is_clean() {
|
|
9517
|
+
"healthy"
|
|
9518
|
+
} else {
|
|
9519
|
+
"degraded"
|
|
9520
|
+
}
|
|
9521
|
+
}
|
|
9522
|
+
|
|
9523
|
+
#[cfg(test)]
|
|
9524
|
+
mod admission_tests {
|
|
9525
|
+
use super::overloaded;
|
|
9526
|
+
use axum::http::StatusCode;
|
|
9527
|
+
use axum::response::IntoResponse;
|
|
9528
|
+
|
|
9529
|
+
/// An overload is refused with a status and a hint, not a hang.
|
|
9530
|
+
///
|
|
9531
|
+
/// The managed incident produced client-side timeouts and upstream 502s
|
|
9532
|
+
/// because the server had no inbound deadline and no admission bound: a
|
|
9533
|
+
/// request that could not proceed simply waited. A caller cannot act on
|
|
9534
|
+
/// that. It can act on this.
|
|
9535
|
+
#[test]
|
|
9536
|
+
fn an_overload_is_an_explicit_refusal_with_a_retry_hint() {
|
|
9537
|
+
let response = overloaded("server is at its in-flight request limit").into_response();
|
|
9538
|
+
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
9539
|
+
assert_eq!(
|
|
9540
|
+
response
|
|
9541
|
+
.headers()
|
|
9542
|
+
.get(axum::http::header::RETRY_AFTER)
|
|
9543
|
+
.and_then(|value| value.to_str().ok()),
|
|
9544
|
+
Some("1"),
|
|
9545
|
+
"a caller is told when to come back"
|
|
9546
|
+
);
|
|
9547
|
+
}
|
|
9548
|
+
|
|
9549
|
+
/// 503 rather than 500: the request was refused, not mishandled.
|
|
9550
|
+
#[test]
|
|
9551
|
+
fn an_overload_is_not_reported_as_a_server_fault() {
|
|
9552
|
+
let response = overloaded("request exceeded the server deadline").into_response();
|
|
9553
|
+
assert_ne!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
9554
|
+
assert!(response.status().is_server_error());
|
|
9555
|
+
}
|
|
9556
|
+
}
|
|
9557
|
+
|
|
9558
|
+
#[cfg(test)]
|
|
9559
|
+
mod health_status_tests {
|
|
9560
|
+
use super::overall_status;
|
|
9561
|
+
use feltdb::StorageHealth;
|
|
9562
|
+
|
|
9563
|
+
const RECOVERED: StorageHealth = StorageHealth::RecoveredIncompleteWrite {
|
|
9564
|
+
byte_offset: 128,
|
|
9565
|
+
discarded_bytes: 40,
|
|
9566
|
+
};
|
|
9567
|
+
|
|
9568
|
+
/// Storage participates in the overall status.
|
|
9569
|
+
///
|
|
9570
|
+
/// It did not before: a database that had discarded an incomplete final
|
|
9571
|
+
/// append reported exactly what a cleanly replayed one did, so the
|
|
9572
|
+
/// distinction the durable-log work established never reached an operator.
|
|
9573
|
+
#[test]
|
|
9574
|
+
fn a_recovered_database_is_reported_degraded() {
|
|
9575
|
+
assert_eq!(overall_status(true, true, &StorageHealth::Clean), "healthy");
|
|
9576
|
+
assert_eq!(overall_status(true, true, &RECOVERED), "degraded");
|
|
9577
|
+
}
|
|
9578
|
+
|
|
9579
|
+
/// And the conditions that already counted still do, so this narrows
|
|
9580
|
+
/// nothing.
|
|
9581
|
+
#[test]
|
|
9582
|
+
fn membership_and_clock_still_count() {
|
|
9583
|
+
assert_eq!(
|
|
9584
|
+
overall_status(false, true, &StorageHealth::Clean),
|
|
9585
|
+
"degraded"
|
|
9586
|
+
);
|
|
9587
|
+
assert_eq!(
|
|
9588
|
+
overall_status(true, false, &StorageHealth::Clean),
|
|
9589
|
+
"degraded"
|
|
9590
|
+
);
|
|
9591
|
+
assert_eq!(overall_status(false, false, &RECOVERED), "degraded");
|
|
9592
|
+
}
|
|
9593
|
+
|
|
9594
|
+
/// The reported storage value names an observation rather than asserting
|
|
9595
|
+
/// the property that is still unproven.
|
|
9596
|
+
#[test]
|
|
9597
|
+
fn the_storage_label_is_an_observation_not_a_promise() {
|
|
9598
|
+
assert_eq!(StorageHealth::Clean.label(), "clean");
|
|
9599
|
+
assert_eq!(RECOVERED.label(), "recovered-incomplete-write");
|
|
9600
|
+
for label in [StorageHealth::Clean.label(), RECOVERED.label()] {
|
|
9601
|
+
assert_ne!(label, "durable", "health must not claim durability");
|
|
9602
|
+
}
|
|
9603
|
+
}
|
|
9604
|
+
}
|
|
9605
|
+
|
|
9606
|
+
/// Shed load at the door, and abandon work the caller is no longer waiting for.
|
|
9607
|
+
///
|
|
9608
|
+
/// Two failures this replaces, both observed in production:
|
|
9609
|
+
///
|
|
9610
|
+
/// - **No inbound deadline.** A slow operation ran until the *client* gave up,
|
|
9611
|
+
/// and then kept running, so the load that caused the stall outlived every
|
|
9612
|
+
/// request that reported it.
|
|
9613
|
+
/// - **No admission bound.** Requests queued on the database's single lock
|
|
9614
|
+
/// until an upstream proxy returned 502. Concurrency past the bound buys no
|
|
9615
|
+
/// throughput — every read and write serializes on that lock — so all it
|
|
9616
|
+
/// decides is whether an overload is refused quickly or hidden.
|
|
9617
|
+
///
|
|
9618
|
+
/// Both now produce an explicit `503` with `Retry-After`, which is a signal a
|
|
9619
|
+
/// caller can act on rather than a timeout it has to interpret.
|
|
9620
|
+
async fn admit_request(
|
|
9621
|
+
State(state): State<AppState>,
|
|
9622
|
+
request: axum::extract::Request,
|
|
9623
|
+
next: Next,
|
|
9624
|
+
) -> axum::response::Response {
|
|
9625
|
+
let Ok(_permit) = state.admission.clone().try_acquire_owned() else {
|
|
9626
|
+
state.metrics.request_shed();
|
|
9627
|
+
return overloaded("server is at its in-flight request limit");
|
|
9628
|
+
};
|
|
9629
|
+
|
|
9630
|
+
match tokio::time::timeout(state.request_deadline, next.run(request)).await {
|
|
9631
|
+
Ok(response) => response,
|
|
9632
|
+
Err(_) => {
|
|
9633
|
+
state.metrics.request_deadline_exceeded();
|
|
9634
|
+
overloaded("request exceeded the server deadline")
|
|
9635
|
+
}
|
|
9636
|
+
}
|
|
9637
|
+
}
|
|
9638
|
+
|
|
9639
|
+
/// A refusal a caller can act on: an explicit status, a stable code, and a hint.
|
|
9640
|
+
fn overloaded(detail: &str) -> axum::response::Response {
|
|
9641
|
+
let mut response = (
|
|
9642
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9643
|
+
Json(json!({
|
|
9644
|
+
"error": detail,
|
|
9645
|
+
"code": "SERVICE_OVERLOADED",
|
|
9646
|
+
})),
|
|
9647
|
+
)
|
|
9648
|
+
.into_response();
|
|
9649
|
+
response.headers_mut().insert(
|
|
9650
|
+
axum::http::header::RETRY_AFTER,
|
|
9651
|
+
axum::http::HeaderValue::from_static("1"),
|
|
9652
|
+
);
|
|
9653
|
+
response
|
|
9654
|
+
}
|
|
9655
|
+
|
|
9656
|
+
/// Readiness: whether this server can actually serve a scoped operation.
|
|
9657
|
+
///
|
|
9658
|
+
/// `/health` answers "is this process alive". It reads cluster state and the
|
|
9659
|
+
/// lease clock and never touches storage, so it cannot fail for a database
|
|
9660
|
+
/// reason — which is exactly how a deployment reported `200 healthy` while
|
|
9661
|
+
/// every scoped read timed out.
|
|
9662
|
+
///
|
|
9663
|
+
/// This endpoint takes the database lock and performs a real bounded read, off
|
|
9664
|
+
/// the runtime and under the same deadline a request gets. **A server whose
|
|
9665
|
+
/// scoped operations are stalled reports `503` here.**
|
|
9666
|
+
async fn health_ready(State(state): State<AppState>) -> axum::response::Response {
|
|
9667
|
+
let probe = state.clone();
|
|
9668
|
+
let started = Instant::now();
|
|
9669
|
+
let outcome = tokio::time::timeout(
|
|
9670
|
+
state.request_deadline,
|
|
9671
|
+
tokio::task::spawn_blocking(move || {
|
|
9672
|
+
// A real scoped operation: acquire the state lock and read through
|
|
9673
|
+
// it. Bounded, so a large database does not make readiness slow.
|
|
9674
|
+
let health = probe.db.health();
|
|
9675
|
+
let _ = probe
|
|
9676
|
+
.db
|
|
9677
|
+
.list_collection_page("_flow_capabilities", None, 1)?;
|
|
9678
|
+
Ok::<_, feltdb::FlowError>(health)
|
|
9679
|
+
}),
|
|
9680
|
+
)
|
|
9681
|
+
.await;
|
|
9682
|
+
|
|
9683
|
+
let waited_ms = started.elapsed().as_millis() as u64;
|
|
9684
|
+
match outcome {
|
|
9685
|
+
Ok(Ok(Ok(health))) => {
|
|
9686
|
+
let ready = health.is_nominal();
|
|
9687
|
+
let body = Json(json!({
|
|
9688
|
+
"ready": ready,
|
|
9689
|
+
"storage": health.storage.label(),
|
|
9690
|
+
"durable_format": health.durable_format.to_string(),
|
|
9691
|
+
"probe_ms": waited_ms,
|
|
9692
|
+
}));
|
|
9693
|
+
if ready {
|
|
9694
|
+
(StatusCode::OK, body).into_response()
|
|
9695
|
+
} else {
|
|
9696
|
+
// Usable, but not in the condition a clean open produces.
|
|
9697
|
+
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
|
|
9698
|
+
}
|
|
9699
|
+
}
|
|
9700
|
+
Ok(Ok(Err(error))) => (
|
|
9701
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9702
|
+
Json(json!({
|
|
9703
|
+
"ready": false,
|
|
9704
|
+
"error": error.to_string(),
|
|
9705
|
+
"code": "STORAGE_UNAVAILABLE",
|
|
9706
|
+
"probe_ms": waited_ms,
|
|
9707
|
+
})),
|
|
9708
|
+
)
|
|
9709
|
+
.into_response(),
|
|
9710
|
+
Ok(Err(error)) => (
|
|
9711
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9712
|
+
Json(json!({
|
|
9713
|
+
"ready": false,
|
|
9714
|
+
"error": error.to_string(),
|
|
9715
|
+
"code": "STORAGE_PROBE_FAILED",
|
|
9716
|
+
"probe_ms": waited_ms,
|
|
9717
|
+
})),
|
|
9718
|
+
)
|
|
9719
|
+
.into_response(),
|
|
9720
|
+
Err(_) => (
|
|
9721
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9722
|
+
Json(json!({
|
|
9723
|
+
"ready": false,
|
|
9724
|
+
"error": "storage probe exceeded the server deadline",
|
|
9725
|
+
"code": "STORAGE_STALLED",
|
|
9726
|
+
"probe_ms": waited_ms,
|
|
9727
|
+
})),
|
|
9728
|
+
)
|
|
9729
|
+
.into_response(),
|
|
9730
|
+
}
|
|
9731
|
+
}
|
|
9732
|
+
|
|
9428
9733
|
async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>> {
|
|
9429
9734
|
let membership = match state.cluster.proposal().map(|value| value.phase) {
|
|
9430
9735
|
Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
|
|
@@ -9432,16 +9737,16 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
9432
9737
|
_ => "healthy",
|
|
9433
9738
|
};
|
|
9434
9739
|
let clock = state.lease_clock.is_healthy();
|
|
9740
|
+
// Observed, not asserted. An incompatible or corrupt database never opens,
|
|
9741
|
+
// so a running server has already established its format; what remains to
|
|
9742
|
+
// report is whether replay discarded anything.
|
|
9743
|
+
let database = state.db.health();
|
|
9435
9744
|
Json(HealthResponse {
|
|
9436
|
-
status:
|
|
9437
|
-
"healthy"
|
|
9438
|
-
} else {
|
|
9439
|
-
"degraded"
|
|
9440
|
-
},
|
|
9745
|
+
status: overall_status(membership == "healthy", clock, &database.storage),
|
|
9441
9746
|
version: env!("CARGO_PKG_VERSION"),
|
|
9442
9747
|
git_commit: option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
|
|
9443
9748
|
runtime: "self-hosted",
|
|
9444
|
-
storage:
|
|
9749
|
+
storage: database.storage.label(),
|
|
9445
9750
|
fabric: "healthy",
|
|
9446
9751
|
execution: if state.worker_enabled {
|
|
9447
9752
|
"autonomous"
|
|
@@ -9482,22 +9787,33 @@ async fn managed_diagnostics(State(state): State<AppState>) -> Json<ManagedDiagn
|
|
|
9482
9787
|
}
|
|
9483
9788
|
|
|
9484
9789
|
async fn readiness(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
9485
|
-
|
|
9486
|
-
|
|
9790
|
+
let readiness_probe = state.readiness_probe.clone();
|
|
9791
|
+
let db = state.db.clone();
|
|
9792
|
+
let (state_version, storage) = tokio::task::spawn_blocking(move || {
|
|
9793
|
+
if let Some(parent) = readiness_probe.parent() {
|
|
9794
|
+
std::fs::create_dir_all(parent)
|
|
9795
|
+
.map_err(|error| ApiError(StatusCode::SERVICE_UNAVAILABLE, error.to_string()))?;
|
|
9796
|
+
}
|
|
9797
|
+
let mut probe = OpenOptions::new()
|
|
9798
|
+
.create(true)
|
|
9799
|
+
.write(true)
|
|
9800
|
+
.truncate(true)
|
|
9801
|
+
.open(readiness_probe.as_ref())
|
|
9487
9802
|
.map_err(|error| ApiError(StatusCode::SERVICE_UNAVAILABLE, error.to_string()))?;
|
|
9488
|
-
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
.
|
|
9493
|
-
|
|
9494
|
-
|
|
9495
|
-
|
|
9496
|
-
|
|
9497
|
-
|
|
9498
|
-
|
|
9499
|
-
|
|
9500
|
-
|
|
9803
|
+
probe
|
|
9804
|
+
.write_all(b"feltdb-ready-v1\n")
|
|
9805
|
+
.and_then(|_| probe.sync_all())
|
|
9806
|
+
.map_err(|error| ApiError(StatusCode::SERVICE_UNAVAILABLE, error.to_string()))?;
|
|
9807
|
+
Ok::<_, ApiError>((db.sequence()?, db.instance_id()?))
|
|
9808
|
+
})
|
|
9809
|
+
.await
|
|
9810
|
+
.map_err(|error| {
|
|
9811
|
+
tracing::error!(%error, "readiness worker failed");
|
|
9812
|
+
ApiError(
|
|
9813
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
9814
|
+
"readiness check temporarily unavailable".into(),
|
|
9815
|
+
)
|
|
9816
|
+
})??;
|
|
9501
9817
|
Ok(Json(json!({
|
|
9502
9818
|
"status": "ready",
|
|
9503
9819
|
"authority": "rust",
|
|
@@ -9566,10 +9882,15 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
9566
9882
|
.map(str::to_string)
|
|
9567
9883
|
.collect()
|
|
9568
9884
|
};
|
|
9569
|
-
|
|
9885
|
+
// `--worker <id>` issues a credential that authenticates as that
|
|
9886
|
+
// one worker rather than as a service. It is a narrowing: the
|
|
9887
|
+
// worker lifecycle routes accept it only for its own worker id, and
|
|
9888
|
+
// it cannot satisfy a check that wants a human or a service.
|
|
9889
|
+
let key = store.create_for_worker(
|
|
9570
9890
|
name,
|
|
9571
9891
|
values("--scope", "state:read,state:write,events:read"),
|
|
9572
9892
|
values("--namespace", "default"),
|
|
9893
|
+
option("--worker"),
|
|
9573
9894
|
)?;
|
|
9574
9895
|
println!(
|
|
9575
9896
|
"API key created (id: {}). This secret will not be shown again:\n{}",
|
|
@@ -9584,7 +9905,7 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
9584
9905
|
}
|
|
9585
9906
|
println!("API key revoked: {id}");
|
|
9586
9907
|
}
|
|
9587
|
-
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--id key-id]".into()),
|
|
9908
|
+
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--id key-id]".into()),
|
|
9588
9909
|
}
|
|
9589
9910
|
Ok(true)
|
|
9590
9911
|
}
|
|
@@ -9682,6 +10003,61 @@ fn manage_workloads() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
9682
10003
|
Ok(true)
|
|
9683
10004
|
}
|
|
9684
10005
|
|
|
10006
|
+
fn manage_state_triggers() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10007
|
+
let arguments: Vec<String> = std::env::args().collect();
|
|
10008
|
+
if arguments.get(1).map(String::as_str) != Some("trigger") {
|
|
10009
|
+
return Ok(false);
|
|
10010
|
+
}
|
|
10011
|
+
let command = arguments.get(2).map(String::as_str).unwrap_or("list");
|
|
10012
|
+
let option = |name: &str| {
|
|
10013
|
+
arguments
|
|
10014
|
+
.iter()
|
|
10015
|
+
.position(|value| value == name)
|
|
10016
|
+
.and_then(|index| arguments.get(index + 1))
|
|
10017
|
+
.cloned()
|
|
10018
|
+
};
|
|
10019
|
+
let data = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
|
|
10020
|
+
let trigger_path = PathBuf::from(option("--store").unwrap_or_else(|| {
|
|
10021
|
+
data.with_extension("triggers.json")
|
|
10022
|
+
.to_string_lossy()
|
|
10023
|
+
.into_owned()
|
|
10024
|
+
}));
|
|
10025
|
+
let mut triggers = StateTriggerStore::load(trigger_path)?;
|
|
10026
|
+
match command {
|
|
10027
|
+
"define" => {
|
|
10028
|
+
let input = option("--input").ok_or("trigger define requires --input trigger.json")?;
|
|
10029
|
+
let trigger: feltdb::StateTrigger = serde_json::from_slice(&std::fs::read(input)?)?;
|
|
10030
|
+
triggers.define(trigger)?;
|
|
10031
|
+
println!("{}", serde_json::to_string_pretty(&triggers.list())?);
|
|
10032
|
+
}
|
|
10033
|
+
"list" => println!("{}", serde_json::to_string_pretty(&triggers.list())?),
|
|
10034
|
+
"status" => println!(
|
|
10035
|
+
"{}",
|
|
10036
|
+
serde_json::to_string_pretty(&json!({
|
|
10037
|
+
"triggers": triggers.triggers.len(),
|
|
10038
|
+
"cursors": triggers.cursor_versions(),
|
|
10039
|
+
}))?
|
|
10040
|
+
),
|
|
10041
|
+
"evaluate" => {
|
|
10042
|
+
let db = FeltDb::open(&data)?;
|
|
10043
|
+
let workload_path = PathBuf::from(
|
|
10044
|
+
option("--workload-store")
|
|
10045
|
+
.unwrap_or_else(|| data.with_extension("workloads.json").to_string_lossy().into_owned()),
|
|
10046
|
+
);
|
|
10047
|
+
let mut workloads = WorkloadStore::load(workload_path)?;
|
|
10048
|
+
let produced = triggers.evaluate(&db, &mut workloads, unix_seconds_i64())?;
|
|
10049
|
+
println!("{}", serde_json::to_string_pretty(&produced)?);
|
|
10050
|
+
}
|
|
10051
|
+
_ => {
|
|
10052
|
+
return Err(
|
|
10053
|
+
"usage: feltdb-server trigger <define|list|status|evaluate> [--data path] [--store path] [--workload-store path] [--input trigger.json]"
|
|
10054
|
+
.into(),
|
|
10055
|
+
)
|
|
10056
|
+
}
|
|
10057
|
+
}
|
|
10058
|
+
Ok(true)
|
|
10059
|
+
}
|
|
10060
|
+
|
|
9685
10061
|
fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
|
|
9686
10062
|
let arguments: Vec<String> = std::env::args().collect();
|
|
9687
10063
|
if arguments.get(1).map(String::as_str) != Some("backup") {
|
|
@@ -10006,9 +10382,25 @@ fn start_peer_sessions(
|
|
|
10006
10382
|
}
|
|
10007
10383
|
}
|
|
10008
10384
|
}
|
|
10009
|
-
|
|
10010
|
-
|
|
10011
|
-
|
|
10385
|
+
// Policy-driven, and off the runtime. Compaction rewrites the log
|
|
10386
|
+
// while holding the lock that serializes every read and write, so
|
|
10387
|
+
// running it on every tick stopped the world on every tick — and
|
|
10388
|
+
// running it inline blocked a runtime worker while it did.
|
|
10389
|
+
let compactor = state.clone();
|
|
10390
|
+
let compaction_peers = peers.clone();
|
|
10391
|
+
match tokio::task::spawn_blocking(move || {
|
|
10392
|
+
compactor.db.maybe_compact_operation_log(&compaction_peers)
|
|
10393
|
+
})
|
|
10394
|
+
.await
|
|
10395
|
+
{
|
|
10396
|
+
Ok(Ok(outcome)) => {
|
|
10397
|
+
state.metrics.operations_compacted(outcome.pruned() as u64);
|
|
10398
|
+
if outcome.rewrote_log() {
|
|
10399
|
+
tracing::debug!(pruned = outcome.pruned(), "durable log rewritten");
|
|
10400
|
+
}
|
|
10401
|
+
}
|
|
10402
|
+
Ok(Err(error)) => tracing::warn!(%error, "operation compaction failed"),
|
|
10403
|
+
Err(error) => tracing::warn!(%error, "operation compaction task failed"),
|
|
10012
10404
|
}
|
|
10013
10405
|
tokio::time::sleep(interval).await;
|
|
10014
10406
|
}
|
|
@@ -10103,6 +10495,197 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
|
|
|
10103
10495
|
}
|
|
10104
10496
|
execute_agent_run(state, &row.value, &worker).await?;
|
|
10105
10497
|
}
|
|
10498
|
+
|
|
10499
|
+
evaluate_state_triggers(state)?;
|
|
10500
|
+
execute_eligible_workloads(state, &worker)?;
|
|
10501
|
+
Ok(())
|
|
10502
|
+
}
|
|
10503
|
+
|
|
10504
|
+
/// How long an autonomous claim holds its lease.
|
|
10505
|
+
///
|
|
10506
|
+
/// Long enough that an ordinary capability program finishes inside it, short
|
|
10507
|
+
/// enough that a worker dying mid-execution returns the work quickly. The lease
|
|
10508
|
+
/// is the only thing that makes worker death recoverable, so this is a real
|
|
10509
|
+
/// bound rather than a formality.
|
|
10510
|
+
const WORKLOAD_LEASE_MS: u64 = 30_000;
|
|
10511
|
+
|
|
10512
|
+
fn state_trigger_store(
|
|
10513
|
+
state: &AppState,
|
|
10514
|
+
) -> Result<std::sync::MutexGuard<'_, feltdb::StateTriggerStore>, ApiError> {
|
|
10515
|
+
state.state_triggers.lock().map_err(|_| {
|
|
10516
|
+
ApiError(
|
|
10517
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
10518
|
+
"state trigger store unavailable".into(),
|
|
10519
|
+
)
|
|
10520
|
+
})
|
|
10521
|
+
}
|
|
10522
|
+
|
|
10523
|
+
fn state_trigger_error(error: feltdb::StateTriggerError) -> ApiError {
|
|
10524
|
+
let status = match error.code.as_str() {
|
|
10525
|
+
"TRIGGER_INVALID" | "TRIGGER_UNAUTHORIZED" => StatusCode::BAD_REQUEST,
|
|
10526
|
+
"WORKLOAD_UNAUTHORIZED" => StatusCode::FORBIDDEN,
|
|
10527
|
+
"TRIGGER_STATE_REPLAY_FAILED" | "TRIGGER_STORAGE_FAILURE" | "WORKLOAD_STORAGE_FAILURE" => {
|
|
10528
|
+
StatusCode::SERVICE_UNAVAILABLE
|
|
10529
|
+
}
|
|
10530
|
+
_ => StatusCode::CONFLICT,
|
|
10531
|
+
};
|
|
10532
|
+
ApiError(
|
|
10533
|
+
status,
|
|
10534
|
+
serde_json::to_string(&error).unwrap_or(error.message),
|
|
10535
|
+
)
|
|
10536
|
+
}
|
|
10537
|
+
|
|
10538
|
+
fn evaluate_state_triggers(state: &AppState) -> Result<(), ApiError> {
|
|
10539
|
+
let mut triggers = state_trigger_store(state)?;
|
|
10540
|
+
if triggers.triggers.is_empty() {
|
|
10541
|
+
return Ok(());
|
|
10542
|
+
}
|
|
10543
|
+
let mut workloads = workload_store(state)?;
|
|
10544
|
+
triggers
|
|
10545
|
+
.evaluate(&state.db, &mut workloads, unix_seconds_i64())
|
|
10546
|
+
.map_err(state_trigger_error)?;
|
|
10547
|
+
Ok(())
|
|
10548
|
+
}
|
|
10549
|
+
|
|
10550
|
+
/// The durable workload store, or a service error if its lock is poisoned.
|
|
10551
|
+
fn workload_store(
|
|
10552
|
+
state: &AppState,
|
|
10553
|
+
) -> Result<std::sync::MutexGuard<'_, feltdb::workload::WorkloadStore>, ApiError> {
|
|
10554
|
+
state.workloads.lock().map_err(|_| {
|
|
10555
|
+
ApiError(
|
|
10556
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
10557
|
+
"workload store unavailable".into(),
|
|
10558
|
+
)
|
|
10559
|
+
})
|
|
10560
|
+
}
|
|
10561
|
+
|
|
10562
|
+
/// The capabilities this node can actually run.
|
|
10563
|
+
///
|
|
10564
|
+
/// This is what the worker presents to [`WorkloadStore::claim`], which requires
|
|
10565
|
+
/// the workload's `capability_snapshot` to be a subset of it. That check is
|
|
10566
|
+
/// therefore load-bearing rather than ceremonial: a workload whose declared
|
|
10567
|
+
/// capabilities this node cannot execute is never claimed by it, and stays
|
|
10568
|
+
/// `Ready` for a node that can.
|
|
10569
|
+
fn worker_capabilities(state: &AppState) -> Result<BTreeSet<String>, ApiError> {
|
|
10570
|
+
let mut capabilities: BTreeSet<String> = ["search", "identity"]
|
|
10571
|
+
.into_iter()
|
|
10572
|
+
.map(str::to_string)
|
|
10573
|
+
.collect();
|
|
10574
|
+
for row in state.db.list_collection("_flow_capabilities")? {
|
|
10575
|
+
if let Some((_, name)) = row.key.split_once(':') {
|
|
10576
|
+
capabilities.insert(name.to_string());
|
|
10577
|
+
}
|
|
10578
|
+
}
|
|
10579
|
+
Ok(capabilities)
|
|
10580
|
+
}
|
|
10581
|
+
|
|
10582
|
+
/// Claim and execute the durable workloads this node is eligible to run.
|
|
10583
|
+
///
|
|
10584
|
+
/// This is the composition that makes `WorkloadStore` an execution primitive
|
|
10585
|
+
/// rather than a work data model. It adds no queue and no worker system: the
|
|
10586
|
+
/// scan is the pass that already runs, the claim is the existing lease and
|
|
10587
|
+
/// fencing, the execution is the existing capability interpreter, and the
|
|
10588
|
+
/// transitions are the existing state machine.
|
|
10589
|
+
///
|
|
10590
|
+
/// Temporal eligibility needs no special handling here — `Workload::is_eligible_at`
|
|
10591
|
+
/// filters candidates and `WorkloadStore::claim` refuses anything early, so
|
|
10592
|
+
/// `not_before` and retry backoff gate this path by construction.
|
|
10593
|
+
///
|
|
10594
|
+
/// The store lock is released before execution. A capability program reads
|
|
10595
|
+
/// application state through the same database, so holding the workload lock
|
|
10596
|
+
/// across it would make every other workload operation wait on unrelated work.
|
|
10597
|
+
fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiError> {
|
|
10598
|
+
let capabilities = worker_capabilities(state)?;
|
|
10599
|
+
let now = unix_seconds_i64();
|
|
10600
|
+
let candidates: Vec<(String, String, Value)> = {
|
|
10601
|
+
let store = workload_store(state)?;
|
|
10602
|
+
store
|
|
10603
|
+
.workloads
|
|
10604
|
+
.values()
|
|
10605
|
+
// Selection, not enforcement. `WorkloadStore::claim` independently
|
|
10606
|
+
// refuses work that is early or beyond this node's capabilities, and
|
|
10607
|
+
// that is where the guarantee lives; filtering here only avoids
|
|
10608
|
+
// attempting a claim, every pass, that is known to be refused.
|
|
10609
|
+
.filter(|workload| {
|
|
10610
|
+
workload.state == feltdb::workload::WorkloadState::Ready
|
|
10611
|
+
&& workload.is_eligible_at(now)
|
|
10612
|
+
&& workload.capability_snapshot.is_subset(&capabilities)
|
|
10613
|
+
})
|
|
10614
|
+
.map(|workload| {
|
|
10615
|
+
(
|
|
10616
|
+
workload.workload_id.clone(),
|
|
10617
|
+
workload.definition_id.clone(),
|
|
10618
|
+
workload.input.clone(),
|
|
10619
|
+
)
|
|
10620
|
+
})
|
|
10621
|
+
.collect()
|
|
10622
|
+
};
|
|
10623
|
+
|
|
10624
|
+
for (id, definition, input) in candidates {
|
|
10625
|
+
// Unlike the filter above, this one is enforcement: nothing downstream
|
|
10626
|
+
// knows whether `definition_id` names something runnable. Claiming a
|
|
10627
|
+
// workload only to dead-letter it would consume work another node could
|
|
10628
|
+
// have executed, so an unrunnable definition is left untouched.
|
|
10629
|
+
if !is_executable_capability(state, &definition)? {
|
|
10630
|
+
continue;
|
|
10631
|
+
}
|
|
10632
|
+
let claim = match workload_store(state)?.claim(
|
|
10633
|
+
&id,
|
|
10634
|
+
worker,
|
|
10635
|
+
&capabilities,
|
|
10636
|
+
unix_seconds_i64(),
|
|
10637
|
+
WORKLOAD_LEASE_MS,
|
|
10638
|
+
) {
|
|
10639
|
+
Ok(claim) => claim,
|
|
10640
|
+
// Another worker took it, or eligibility moved between the scan and
|
|
10641
|
+
// the claim. Both are ordinary outcomes of concurrent workers, not
|
|
10642
|
+
// failures of this pass.
|
|
10643
|
+
Err(_) => continue,
|
|
10644
|
+
};
|
|
10645
|
+
workload_store(state)?
|
|
10646
|
+
.start(
|
|
10647
|
+
&id,
|
|
10648
|
+
&claim.claim_id,
|
|
10649
|
+
worker,
|
|
10650
|
+
claim.fencing_token,
|
|
10651
|
+
unix_seconds_i64(),
|
|
10652
|
+
)
|
|
10653
|
+
.map_err(workload_error)?;
|
|
10654
|
+
|
|
10655
|
+
match execute_named_capability(state, &definition, &input) {
|
|
10656
|
+
Ok(output) => {
|
|
10657
|
+
workload_store(state)?
|
|
10658
|
+
.complete(
|
|
10659
|
+
&id,
|
|
10660
|
+
&claim.claim_id,
|
|
10661
|
+
worker,
|
|
10662
|
+
claim.fencing_token,
|
|
10663
|
+
&format!("result_{id}_{}", claim.fencing_token),
|
|
10664
|
+
output,
|
|
10665
|
+
vec![],
|
|
10666
|
+
1,
|
|
10667
|
+
unix_seconds_i64(),
|
|
10668
|
+
)
|
|
10669
|
+
.map_err(workload_error)?;
|
|
10670
|
+
}
|
|
10671
|
+
Err(error) => {
|
|
10672
|
+
// A capability program that fails does so deterministically, so
|
|
10673
|
+
// this failure kind is deliberately outside the default
|
|
10674
|
+
// `retry_on` set: retrying it would burn attempts to reach the
|
|
10675
|
+
// same dead letter. Only the message is carried, because the
|
|
10676
|
+
// store refuses failure text that looks like a secret.
|
|
10677
|
+
let _ = workload_store(state)?.fail(
|
|
10678
|
+
&id,
|
|
10679
|
+
&claim.claim_id,
|
|
10680
|
+
worker,
|
|
10681
|
+
claim.fencing_token,
|
|
10682
|
+
"execution_error",
|
|
10683
|
+
&format!("capability {definition} failed with status {}", error.0),
|
|
10684
|
+
unix_seconds_i64(),
|
|
10685
|
+
);
|
|
10686
|
+
}
|
|
10687
|
+
}
|
|
10688
|
+
}
|
|
10106
10689
|
Ok(())
|
|
10107
10690
|
}
|
|
10108
10691
|
|
|
@@ -10138,10 +10721,10 @@ fn execute_builtin_capability(
|
|
|
10138
10721
|
Ok(Value::Array(
|
|
10139
10722
|
state
|
|
10140
10723
|
.db
|
|
10141
|
-
.
|
|
10724
|
+
.query_collection(collection, Some(limit), |row| {
|
|
10725
|
+
row.value.to_string().to_lowercase().contains(&needle)
|
|
10726
|
+
})?
|
|
10142
10727
|
.into_iter()
|
|
10143
|
-
.filter(|row| row.value.to_string().to_lowercase().contains(&needle))
|
|
10144
|
-
.take(limit)
|
|
10145
10728
|
.map(|row| row.value)
|
|
10146
10729
|
.collect(),
|
|
10147
10730
|
))
|
|
@@ -11182,11 +11765,11 @@ async fn get_provenance(
|
|
|
11182
11765
|
Path((collection, id)): Path<(String, String)>,
|
|
11183
11766
|
) -> Result<Json<Value>, ApiError> {
|
|
11184
11767
|
let key = record_key(&collection, &id)?;
|
|
11768
|
+
// A keyed lookup. This used to materialize the whole collection — cloning
|
|
11769
|
+
// every row under the global state lock — to find one record.
|
|
11185
11770
|
let row = state
|
|
11186
11771
|
.db
|
|
11187
|
-
.
|
|
11188
|
-
.into_iter()
|
|
11189
|
-
.find(|row| row.key == key)
|
|
11772
|
+
.get_collection_record(&collection, &key)?
|
|
11190
11773
|
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "record not found".to_string()))?;
|
|
11191
11774
|
let operation = row.operation.as_ref();
|
|
11192
11775
|
Ok(Json(json!({
|
|
@@ -11216,12 +11799,14 @@ async fn search_collection(
|
|
|
11216
11799
|
));
|
|
11217
11800
|
}
|
|
11218
11801
|
let needle = request.query.to_lowercase();
|
|
11802
|
+
// Borrows each row and stops at the limit, rather than cloning the whole
|
|
11803
|
+
// collection and filtering afterwards. Only matches are cloned.
|
|
11219
11804
|
let records = state
|
|
11220
11805
|
.db
|
|
11221
|
-
.
|
|
11806
|
+
.query_collection(&collection, Some(request.limit), |row| {
|
|
11807
|
+
row.value.to_string().to_lowercase().contains(&needle)
|
|
11808
|
+
})?
|
|
11222
11809
|
.into_iter()
|
|
11223
|
-
.filter(|row| row.value.to_string().to_lowercase().contains(&needle))
|
|
11224
|
-
.take(request.limit)
|
|
11225
11810
|
.map(record_response)
|
|
11226
11811
|
.collect();
|
|
11227
11812
|
Ok(Json(records))
|
|
@@ -13008,6 +13593,69 @@ async fn shutdown_signal() {
|
|
|
13008
13593
|
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
|
|
13009
13594
|
}
|
|
13010
13595
|
|
|
13596
|
+
#[cfg(test)]
|
|
13597
|
+
mod worker_identity_tests {
|
|
13598
|
+
use super::grant_subject;
|
|
13599
|
+
use feltdb::authorization::Subject as GrantSubject;
|
|
13600
|
+
use feltdb_server::auth::Principal;
|
|
13601
|
+
|
|
13602
|
+
fn principal(subject_type: &str, identity: Option<&str>) -> Principal {
|
|
13603
|
+
Principal {
|
|
13604
|
+
key_id: "key-1".into(),
|
|
13605
|
+
scopes: vec!["*".into()],
|
|
13606
|
+
subject_type: subject_type.into(),
|
|
13607
|
+
identity_id: identity.map(str::to_string),
|
|
13608
|
+
session_id: None,
|
|
13609
|
+
}
|
|
13610
|
+
}
|
|
13611
|
+
|
|
13612
|
+
/// The workload worker lifecycle compares the authenticated subject against
|
|
13613
|
+
/// the `worker_id` in the request, so which subject each principal maps to
|
|
13614
|
+
/// *is* the security boundary. Nothing but the authenticated principal
|
|
13615
|
+
/// participates in this mapping.
|
|
13616
|
+
#[test]
|
|
13617
|
+
fn only_a_worker_principal_maps_to_a_worker_subject() {
|
|
13618
|
+
assert_eq!(
|
|
13619
|
+
grant_subject(&principal("worker", Some("worker-x"))),
|
|
13620
|
+
GrantSubject::Worker("worker-x".into()),
|
|
13621
|
+
);
|
|
13622
|
+
// Every other principal maps somewhere a worker check can never accept,
|
|
13623
|
+
// no matter what identity it carries or what scopes it holds.
|
|
13624
|
+
for (subject_type, expected) in [
|
|
13625
|
+
("human", GrantSubject::Human("worker-x".into())),
|
|
13626
|
+
("user", GrantSubject::Human("worker-x".into())),
|
|
13627
|
+
("service", GrantSubject::Service("worker-x".into())),
|
|
13628
|
+
("api_key", GrantSubject::Service("worker-x".into())),
|
|
13629
|
+
("agent", GrantSubject::Agent("worker-x".into())),
|
|
13630
|
+
] {
|
|
13631
|
+
let subject = grant_subject(&principal(subject_type, Some("worker-x")));
|
|
13632
|
+
assert_eq!(subject, expected, "{subject_type} mapped unexpectedly");
|
|
13633
|
+
assert_ne!(
|
|
13634
|
+
subject,
|
|
13635
|
+
GrantSubject::Worker("worker-x".into()),
|
|
13636
|
+
"{subject_type} must never satisfy a worker check"
|
|
13637
|
+
);
|
|
13638
|
+
}
|
|
13639
|
+
}
|
|
13640
|
+
|
|
13641
|
+
/// A worker principal is one worker, not any worker.
|
|
13642
|
+
#[test]
|
|
13643
|
+
fn a_worker_subject_is_bound_to_its_own_identity() {
|
|
13644
|
+
let subject = grant_subject(&principal("worker", Some("worker-x")));
|
|
13645
|
+
assert_ne!(subject, GrantSubject::Worker("worker-y".into()));
|
|
13646
|
+
}
|
|
13647
|
+
|
|
13648
|
+
/// Without an identity the principal falls back to its key id, so a worker
|
|
13649
|
+
/// credential missing its worker id cannot silently become a wildcard.
|
|
13650
|
+
#[test]
|
|
13651
|
+
fn a_worker_principal_without_an_identity_falls_back_to_its_key_id() {
|
|
13652
|
+
assert_eq!(
|
|
13653
|
+
grant_subject(&principal("worker", None)),
|
|
13654
|
+
GrantSubject::Worker("key-1".into()),
|
|
13655
|
+
);
|
|
13656
|
+
}
|
|
13657
|
+
}
|
|
13658
|
+
|
|
13011
13659
|
#[cfg(test)]
|
|
13012
13660
|
mod authority_gate_tests {
|
|
13013
13661
|
use super::{api_error_body, authorize_transaction_collections, state_authorization};
|