@feltdb/core 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands.js +4 -1
- package/dist/cli/provisioning-neutrality.js +79 -0
- package/dist/collection.d.ts +43 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +192 -22
- package/dist/create/create.js +25 -21
- package/dist/create/managed-account.js +11 -0
- 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/equality_index.rs +595 -0
- 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 +1050 -117
- package/dist/create/server-source/crates/feltdb/src/managed_cas_tests.rs +4 -1
- package/dist/create/server-source/crates/feltdb/src/metrics.rs +0 -1
- package/dist/create/server-source/crates/feltdb/src/multi_node_convergence.rs +1 -2
- package/dist/create/server-source/crates/feltdb/src/multi_operation_transaction.rs +107 -30
- package/dist/create/server-source/crates/feltdb/src/observability.rs +19 -6
- package/dist/create/server-source/crates/feltdb/src/operation_algebra.rs +12 -11
- package/dist/create/server-source/crates/feltdb/src/operation_log.rs +9 -4
- package/dist/create/server-source/crates/feltdb/src/p1_application_atomicity.rs +65 -18
- package/dist/create/server-source/crates/feltdb/src/p1_atomicity_acceptance.rs +193 -57
- package/dist/create/server-source/crates/feltdb/src/partition_reconciliation.rs +37 -27
- package/dist/create/server-source/crates/feltdb/src/permutation_scheduler.rs +38 -10
- package/dist/create/server-source/crates/feltdb/src/persistence_reality.rs +20 -14
- package/dist/create/server-source/crates/feltdb/src/phase1b_acceptance.rs +394 -229
- package/dist/create/server-source/crates/feltdb/src/phase1c1_acceptance.rs +8 -6
- package/dist/create/server-source/crates/feltdb/src/phase1c2_acceptance.rs +11 -13
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +90 -72
- 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_execution_diagnostics.rs +126 -0
- 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 +305 -6
- package/dist/create/server-source/crates/feltdb/src/state_diff_contract.rs +222 -0
- package/dist/create/server-source/crates/feltdb/src/state_facade.rs +82 -54
- package/dist/create/server-source/crates/feltdb/src/state_hash.rs +2 -2
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1514 -537
- package/dist/create/server-source/crates/feltdb/src/state_transition_store.rs +6 -3
- package/dist/create/server-source/crates/feltdb/src/state_trigger.rs +672 -0
- package/dist/create/server-source/crates/feltdb/src/submission.rs +5 -11
- package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
- 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 +442 -0
- package/dist/create/server-source/crates/feltdb/tests/branching_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/current_revision_authority_evidence.rs +288 -0
- package/dist/create/server-source/crates/feltdb/tests/durable_format_compatibility.rs +392 -0
- package/dist/create/server-source/crates/feltdb/tests/feltdb_state_boundary_tests.rs +436 -220
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_conflict_contract_corpus.json +1916 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/state_diff_contract_corpus.json +1878 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/trigger_contract_corpus.json +1862 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +234 -0
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +947 -0
- package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +5 -8
- package/dist/create/server-source/crates/feltdb/tests/pr8_vocabulary_assessment.rs +52 -44
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase2_boundary_tests.rs +33 -16
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3a_path_a_tests.rs +22 -7
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_authorized_mutations.rs +41 -22
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_role_based_authorization.rs +25 -8
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_simple_auth_delete.rs +9 -6
- package/dist/create/server-source/crates/feltdb/tests/pr9_phase3c_team_delete_role_authorization.rs +120 -69
- package/dist/create/server-source/crates/feltdb/tests/pr9_teams_role_based_access.rs +21 -10
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +1032 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_application.rs +868 -0
- package/dist/create/server-source/crates/feltdb/tests/reconciliation_wire_format_evidence.rs +221 -0
- package/dist/create/server-source/crates/feltdb/tests/resource_scoped_revisions.rs +338 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_identity_contract.rs +1039 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_model_decision.rs +739 -0
- package/dist/create/server-source/crates/feltdb/tests/revision_retention_boundary_evidence.rs +427 -0
- package/dist/create/server-source/crates/feltdb/tests/saas_authorization_integration.rs +3 -3
- package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +25 -22
- package/dist/create/server-source/crates/feltdb/tests/state_conflict_contract_conformance.rs +1799 -0
- package/dist/create/server-source/crates/feltdb/tests/state_diff_contract_conformance.rs +1316 -0
- package/dist/create/server-source/crates/feltdb/tests/state_model_integration.rs +53 -61
- package/dist/create/server-source/crates/feltdb/tests/state_persistence_integration.rs +156 -61
- package/dist/create/server-source/crates/feltdb/tests/state_store_boundary_evidence.rs +299 -0
- package/dist/create/server-source/crates/feltdb/tests/sync_divergence_evidence.rs +255 -0
- package/dist/create/server-source/crates/feltdb/tests/three_way_input_boundary_evidence.rs +249 -0
- package/dist/create/server-source/crates/feltdb/tests/trigger_contract_conformance.rs +994 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +2 -1
- package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +164 -13
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +800 -34
- package/dist/db.d.ts +33 -34
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +74 -20
- package/dist/deployment.d.ts +30 -0
- package/dist/deployment.d.ts.map +1 -0
- package/dist/deployment.js +130 -0
- package/dist/embedded-transaction.d.ts +22 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +51 -5
- package/dist/feltdb.d.ts +14 -2
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.js +1 -1
- package/dist/http-client.d.ts +14 -0
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +23 -5
- package/dist/http-db.d.ts +119 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +346 -31
- package/dist/index-core.d.ts +2 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +35 -21
- package/dist/managed-recovery.d.ts +192 -0
- package/dist/managed-recovery.d.ts.map +1 -0
- package/dist/managed-recovery.js +242 -0
- package/dist/memory-db.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-DaNwCLRX.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DnsHNv6g.wasm +0 -0
- package/dist/studio-app/assets/index-j8IlhNqJ.js +29 -0
- package/dist/studio-app/index.html +1 -1
- package/dist/transaction.d.ts +30 -0
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +41 -0
- 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-ClhDHp0S.wasm +0 -0
- package/dist/studio-app/assets/index-B0k4UAlI.js +0 -29
|
@@ -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,
|
|
@@ -7861,6 +7862,54 @@ struct SyncPullRequest {
|
|
|
7861
7862
|
struct SyncPullResponse {
|
|
7862
7863
|
operations: Vec<Operation>,
|
|
7863
7864
|
versions: HashMap<String, u64>,
|
|
7865
|
+
catchup: CatchupStatus,
|
|
7866
|
+
}
|
|
7867
|
+
|
|
7868
|
+
#[derive(Serialize, Deserialize)]
|
|
7869
|
+
struct CatchupStatus {
|
|
7870
|
+
peer_id: String,
|
|
7871
|
+
peer_position: HashMap<String, u64>,
|
|
7872
|
+
authority_position: HashMap<String, u64>,
|
|
7873
|
+
retained_from: HashMap<String, u64>,
|
|
7874
|
+
retained_through: HashMap<String, u64>,
|
|
7875
|
+
lag: u64,
|
|
7876
|
+
recoverability: String,
|
|
7877
|
+
catchup_mode: String,
|
|
7878
|
+
recovery_required: bool,
|
|
7879
|
+
}
|
|
7880
|
+
|
|
7881
|
+
fn catchup_status(
|
|
7882
|
+
peer_id: String,
|
|
7883
|
+
peer_position: HashMap<String, u64>,
|
|
7884
|
+
authority_position: HashMap<String, u64>,
|
|
7885
|
+
retained_from: HashMap<String, u64>,
|
|
7886
|
+
) -> CatchupStatus {
|
|
7887
|
+
let lag = authority_position.iter().map(|(origin, sequence)| {
|
|
7888
|
+
sequence.saturating_sub(peer_position.get(origin).copied().unwrap_or(0))
|
|
7889
|
+
}).sum();
|
|
7890
|
+
let recovery_required = authority_position.iter().any(|(origin, through)| {
|
|
7891
|
+
let position = peer_position.get(origin).copied().unwrap_or(0);
|
|
7892
|
+
let floor = retained_from.get(origin).copied().unwrap_or(through.saturating_add(1));
|
|
7893
|
+
position < *through && position.saturating_add(1) < floor
|
|
7894
|
+
});
|
|
7895
|
+
let catchup_mode = if recovery_required {
|
|
7896
|
+
"snapshot_rebootstrap_required"
|
|
7897
|
+
} else if lag == 0 {
|
|
7898
|
+
"current"
|
|
7899
|
+
} else {
|
|
7900
|
+
"incremental_replay"
|
|
7901
|
+
};
|
|
7902
|
+
CatchupStatus {
|
|
7903
|
+
peer_id,
|
|
7904
|
+
peer_position,
|
|
7905
|
+
retained_through: authority_position.clone(),
|
|
7906
|
+
authority_position,
|
|
7907
|
+
retained_from,
|
|
7908
|
+
lag,
|
|
7909
|
+
recoverability: if recovery_required { "rebootstrap_required" } else { "recoverable" }.into(),
|
|
7910
|
+
catchup_mode: catchup_mode.into(),
|
|
7911
|
+
recovery_required,
|
|
7912
|
+
}
|
|
7864
7913
|
}
|
|
7865
7914
|
|
|
7866
7915
|
#[derive(Serialize, Deserialize)]
|
|
@@ -8241,6 +8290,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8241
8290
|
if manage_workloads()? {
|
|
8242
8291
|
return Ok(());
|
|
8243
8292
|
}
|
|
8293
|
+
if manage_state_triggers()? {
|
|
8294
|
+
return Ok(());
|
|
8295
|
+
}
|
|
8244
8296
|
if manage_backup()? {
|
|
8245
8297
|
return Ok(());
|
|
8246
8298
|
}
|
|
@@ -8250,6 +8302,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8250
8302
|
let config = Config::from_args().map_err(|error| format!("configuration error: {error}"))?;
|
|
8251
8303
|
validate_production_configuration(&config)?;
|
|
8252
8304
|
let db = FeltDb::open(&config.data)?;
|
|
8305
|
+
// Indexes are derived execution structures, so they are declared by
|
|
8306
|
+
// configuration and built from the durable state this open just replayed.
|
|
8307
|
+
// Nothing about them is read from disk: a restart re-derives every bucket
|
|
8308
|
+
// from the records that survived, which is why there is no index format,
|
|
8309
|
+
// no index log, and nothing to repair.
|
|
8310
|
+
let declared_indexes = declare_configured_equality_indexes(&db)?;
|
|
8311
|
+
if !declared_indexes.is_empty() {
|
|
8312
|
+
println!(
|
|
8313
|
+
"feltdb: equality indexes maintained for {}",
|
|
8314
|
+
declared_indexes.join(", ")
|
|
8315
|
+
);
|
|
8316
|
+
}
|
|
8253
8317
|
let peer_token = match std::env::var(&config.peer_token_env) {
|
|
8254
8318
|
Ok(token) => Some(Arc::<str>::from(token)),
|
|
8255
8319
|
Err(_) if config.peers.is_empty() => None,
|
|
@@ -8276,6 +8340,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8276
8340
|
let grant_store = GrantStore::load(config.data.with_extension("grants.json"))?;
|
|
8277
8341
|
let sync_store = SyncStore::load(config.data.with_extension("sync.json"))?;
|
|
8278
8342
|
let workload_store = WorkloadStore::load(config.data.with_extension("workloads.json"))?;
|
|
8343
|
+
let state_trigger_store = StateTriggerStore::load(config.data.with_extension("triggers.json"))?;
|
|
8279
8344
|
let mesh_store = WorkerMeshStore::load(config.data.with_extension("workers.json"))?;
|
|
8280
8345
|
let content_store = ContentStore::new(config.data.with_extension("content"))?;
|
|
8281
8346
|
let artifact_store = ArtifactStore::load(
|
|
@@ -8287,6 +8352,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8287
8352
|
let causal_store = CausalStore::load(config.data.with_extension("causal-events.json"))?;
|
|
8288
8353
|
let provider_store = ProviderStore::load(config.data.with_extension("providers.json"))?;
|
|
8289
8354
|
let readiness_probe = config.data.with_extension("readiness");
|
|
8355
|
+
// Constructed before the state so the same handle can be stopped after
|
|
8356
|
+
// serving: the pipeline outlives the router by exactly one shutdown.
|
|
8357
|
+
let audit_handle = AuditLog::new(config.audit.clone());
|
|
8290
8358
|
let state = AppState {
|
|
8291
8359
|
started_at: Instant::now(),
|
|
8292
8360
|
ids: Arc::new(AtomicU64::new(db.sequence()?)),
|
|
@@ -8299,7 +8367,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8299
8367
|
config.data.with_extension("cluster.json"),
|
|
8300
8368
|
config.peers.clone(),
|
|
8301
8369
|
)?,
|
|
8302
|
-
audit:
|
|
8370
|
+
audit: audit_handle.clone(),
|
|
8303
8371
|
peer_client,
|
|
8304
8372
|
peer_token,
|
|
8305
8373
|
lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
|
|
@@ -8340,6 +8408,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8340
8408
|
grants: Arc::new(std::sync::Mutex::new(grant_store)),
|
|
8341
8409
|
sync: Arc::new(std::sync::Mutex::new(sync_store)),
|
|
8342
8410
|
workloads: Arc::new(std::sync::Mutex::new(workload_store)),
|
|
8411
|
+
state_triggers: Arc::new(std::sync::Mutex::new(state_trigger_store)),
|
|
8343
8412
|
mesh: Arc::new(std::sync::Mutex::new(mesh_store)),
|
|
8344
8413
|
readiness_probe: Arc::new(readiness_probe),
|
|
8345
8414
|
bounded_query_cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
|
@@ -9016,7 +9085,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9016
9085
|
axum::routing::delete(delete_certification_fixture),
|
|
9017
9086
|
)
|
|
9018
9087
|
.merge(protected)
|
|
9088
|
+
.merge(query_execution_diagnostics_router())
|
|
9019
9089
|
.layer(middleware::from_fn(protocol_version))
|
|
9090
|
+
.layer(middleware::from_fn(attribute_request))
|
|
9020
9091
|
.layer(middleware::from_fn_with_state(state.clone(), count_request))
|
|
9021
9092
|
.layer(RequestBodyLimitLayer::new(1024 * 1024))
|
|
9022
9093
|
.layer(TraceLayer::new_for_http())
|
|
@@ -9049,9 +9120,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9049
9120
|
"FeltDB server ready at http://{bound_address} (namespace: {})",
|
|
9050
9121
|
config.namespace
|
|
9051
9122
|
);
|
|
9052
|
-
axum::serve(listener, app)
|
|
9123
|
+
let serving = axum::serve(listener, app)
|
|
9053
9124
|
.with_graceful_shutdown(shutdown_signal())
|
|
9054
|
-
.await
|
|
9125
|
+
.await;
|
|
9126
|
+
// The audit writer owns the stream, so an orderly stop is what makes the
|
|
9127
|
+
// last durability group durable. Run it whether or not serving ended
|
|
9128
|
+
// cleanly: a server that failed still recorded events, and losing the tail
|
|
9129
|
+
// of the security log because of an unrelated bind error would be its own
|
|
9130
|
+
// defect. Dropping the last handle would do this too; doing it here means
|
|
9131
|
+
// it happens at a point in the process the operator can reason about.
|
|
9132
|
+
audit_handle.shutdown();
|
|
9133
|
+
serving?;
|
|
9055
9134
|
Ok(())
|
|
9056
9135
|
}
|
|
9057
9136
|
|
|
@@ -9081,6 +9160,12 @@ async fn authenticate(
|
|
|
9081
9160
|
mut request: Request<axum::body::Body>,
|
|
9082
9161
|
next: Next,
|
|
9083
9162
|
) -> Response {
|
|
9163
|
+
// Everything up to the handoff below is authorization: key lookup, session
|
|
9164
|
+
// resolution and the capability check. It is timed separately from the rest
|
|
9165
|
+
// of the request because "the request costs something outside state" is not
|
|
9166
|
+
// an answer to PR36's question — which part of the request does is.
|
|
9167
|
+
let authorization =
|
|
9168
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Authorization);
|
|
9084
9169
|
let principal = if state.auth_enabled {
|
|
9085
9170
|
let token = request
|
|
9086
9171
|
.headers()
|
|
@@ -9271,6 +9356,7 @@ async fn authenticate(
|
|
|
9271
9356
|
.key_id
|
|
9272
9357
|
.clone();
|
|
9273
9358
|
let target = request.uri().path().to_string();
|
|
9359
|
+
drop(authorization);
|
|
9274
9360
|
let response = next.run(request).await;
|
|
9275
9361
|
let status = response.status().as_u16();
|
|
9276
9362
|
audit(
|
|
@@ -9292,6 +9378,18 @@ fn audit(
|
|
|
9292
9378
|
outcome: &str,
|
|
9293
9379
|
status: u16,
|
|
9294
9380
|
) {
|
|
9381
|
+
// Every authenticated request records one of these. Since PR38 the request
|
|
9382
|
+
// waits only for ACCEPTANCE — the audit writer's append — and never for a
|
|
9383
|
+
// durability barrier; the barrier happens afterwards, in a bounded group,
|
|
9384
|
+
// on the writer's own threads.
|
|
9385
|
+
//
|
|
9386
|
+
// The error is still discarded, and that is still deliberate: PR37 proved
|
|
9387
|
+
// request success does not depend on audit durability, and PR38 does not
|
|
9388
|
+
// change that policy. What changed is that the failure is no longer
|
|
9389
|
+
// invisible — `AuditLog::record` has already counted it, timestamped it and
|
|
9390
|
+
// degraded the audit health state by the time this returns.
|
|
9391
|
+
let _span =
|
|
9392
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::AuditWrite);
|
|
9295
9393
|
if let Err(error) = state.audit.record(AuditEvent {
|
|
9296
9394
|
timestamp_ms: 0,
|
|
9297
9395
|
namespace: &state.namespace,
|
|
@@ -9318,6 +9416,21 @@ async fn protocol_version(request: Request<axum::body::Body>, next: Next) -> Res
|
|
|
9318
9416
|
next.run(request).await
|
|
9319
9417
|
}
|
|
9320
9418
|
|
|
9419
|
+
/// Time a whole request inside the server, from the middleware stack down.
|
|
9420
|
+
///
|
|
9421
|
+
/// This is the server's own share of a request. Subtracting it from the client's
|
|
9422
|
+
/// measured latency leaves transport, connection handling, and the JSON decode
|
|
9423
|
+
/// and encode that axum performs in the extractor and the response — none of
|
|
9424
|
+
/// which can be bracketed from inside a handler. PR36 reports that difference as
|
|
9425
|
+
/// a bounded residual rather than pretending to have decomposed it.
|
|
9426
|
+
///
|
|
9427
|
+
/// Disabled, this costs one relaxed atomic load per request.
|
|
9428
|
+
async fn attribute_request(request: Request<axum::body::Body>, next: Next) -> Response {
|
|
9429
|
+
let _span =
|
|
9430
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HttpHandler);
|
|
9431
|
+
next.run(request).await
|
|
9432
|
+
}
|
|
9433
|
+
|
|
9321
9434
|
async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>> {
|
|
9322
9435
|
let membership = match state.cluster.proposal().map(|value| value.phase) {
|
|
9323
9436
|
Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
|
|
@@ -9459,10 +9572,15 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
9459
9572
|
.map(str::to_string)
|
|
9460
9573
|
.collect()
|
|
9461
9574
|
};
|
|
9462
|
-
|
|
9575
|
+
// `--worker <id>` issues a credential that authenticates as that
|
|
9576
|
+
// one worker rather than as a service. It is a narrowing: the
|
|
9577
|
+
// worker lifecycle routes accept it only for its own worker id, and
|
|
9578
|
+
// it cannot satisfy a check that wants a human or a service.
|
|
9579
|
+
let key = store.create_for_worker(
|
|
9463
9580
|
name,
|
|
9464
9581
|
values("--scope", "state:read,state:write,events:read"),
|
|
9465
9582
|
values("--namespace", "default"),
|
|
9583
|
+
option("--worker"),
|
|
9466
9584
|
)?;
|
|
9467
9585
|
println!(
|
|
9468
9586
|
"API key created (id: {}). This secret will not be shown again:\n{}",
|
|
@@ -9477,7 +9595,7 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
9477
9595
|
}
|
|
9478
9596
|
println!("API key revoked: {id}");
|
|
9479
9597
|
}
|
|
9480
|
-
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--id key-id]".into()),
|
|
9598
|
+
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--id key-id]".into()),
|
|
9481
9599
|
}
|
|
9482
9600
|
Ok(true)
|
|
9483
9601
|
}
|
|
@@ -9575,6 +9693,61 @@ fn manage_workloads() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
9575
9693
|
Ok(true)
|
|
9576
9694
|
}
|
|
9577
9695
|
|
|
9696
|
+
fn manage_state_triggers() -> Result<bool, Box<dyn std::error::Error>> {
|
|
9697
|
+
let arguments: Vec<String> = std::env::args().collect();
|
|
9698
|
+
if arguments.get(1).map(String::as_str) != Some("trigger") {
|
|
9699
|
+
return Ok(false);
|
|
9700
|
+
}
|
|
9701
|
+
let command = arguments.get(2).map(String::as_str).unwrap_or("list");
|
|
9702
|
+
let option = |name: &str| {
|
|
9703
|
+
arguments
|
|
9704
|
+
.iter()
|
|
9705
|
+
.position(|value| value == name)
|
|
9706
|
+
.and_then(|index| arguments.get(index + 1))
|
|
9707
|
+
.cloned()
|
|
9708
|
+
};
|
|
9709
|
+
let data = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
|
|
9710
|
+
let trigger_path = PathBuf::from(option("--store").unwrap_or_else(|| {
|
|
9711
|
+
data.with_extension("triggers.json")
|
|
9712
|
+
.to_string_lossy()
|
|
9713
|
+
.into_owned()
|
|
9714
|
+
}));
|
|
9715
|
+
let mut triggers = StateTriggerStore::load(trigger_path)?;
|
|
9716
|
+
match command {
|
|
9717
|
+
"define" => {
|
|
9718
|
+
let input = option("--input").ok_or("trigger define requires --input trigger.json")?;
|
|
9719
|
+
let trigger: feltdb::StateTrigger = serde_json::from_slice(&std::fs::read(input)?)?;
|
|
9720
|
+
triggers.define(trigger)?;
|
|
9721
|
+
println!("{}", serde_json::to_string_pretty(&triggers.list())?);
|
|
9722
|
+
}
|
|
9723
|
+
"list" => println!("{}", serde_json::to_string_pretty(&triggers.list())?),
|
|
9724
|
+
"status" => println!(
|
|
9725
|
+
"{}",
|
|
9726
|
+
serde_json::to_string_pretty(&json!({
|
|
9727
|
+
"triggers": triggers.triggers.len(),
|
|
9728
|
+
"cursors": triggers.cursor_versions(),
|
|
9729
|
+
}))?
|
|
9730
|
+
),
|
|
9731
|
+
"evaluate" => {
|
|
9732
|
+
let db = FeltDb::open(&data)?;
|
|
9733
|
+
let workload_path = PathBuf::from(
|
|
9734
|
+
option("--workload-store")
|
|
9735
|
+
.unwrap_or_else(|| data.with_extension("workloads.json").to_string_lossy().into_owned()),
|
|
9736
|
+
);
|
|
9737
|
+
let mut workloads = WorkloadStore::load(workload_path)?;
|
|
9738
|
+
let produced = triggers.evaluate(&db, &mut workloads, unix_seconds_i64())?;
|
|
9739
|
+
println!("{}", serde_json::to_string_pretty(&produced)?);
|
|
9740
|
+
}
|
|
9741
|
+
_ => {
|
|
9742
|
+
return Err(
|
|
9743
|
+
"usage: feltdb-server trigger <define|list|status|evaluate> [--data path] [--store path] [--workload-store path] [--input trigger.json]"
|
|
9744
|
+
.into(),
|
|
9745
|
+
)
|
|
9746
|
+
}
|
|
9747
|
+
}
|
|
9748
|
+
Ok(true)
|
|
9749
|
+
}
|
|
9750
|
+
|
|
9578
9751
|
fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
|
|
9579
9752
|
let arguments: Vec<String> = std::env::args().collect();
|
|
9580
9753
|
if arguments.get(1).map(String::as_str) != Some("backup") {
|
|
@@ -9749,10 +9922,21 @@ async fn create_online_backup(
|
|
|
9749
9922
|
Ok(Json(manifest))
|
|
9750
9923
|
}
|
|
9751
9924
|
|
|
9752
|
-
|
|
9753
|
-
|
|
9754
|
-
|
|
9755
|
-
|
|
9925
|
+
/// Network counters, plus the audit subsystem's health.
|
|
9926
|
+
///
|
|
9927
|
+
/// The audit block is here rather than on a route of its own because PR37's
|
|
9928
|
+
/// finding was that losing security evidence was invisible, and a signal an
|
|
9929
|
+
/// operator has to know to go and look for is not much better than no signal.
|
|
9930
|
+
/// It rides with the metrics they already read.
|
|
9931
|
+
async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
|
|
9932
|
+
let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
|
|
9933
|
+
if let Some(fields) = body.as_object_mut() {
|
|
9934
|
+
fields.insert(
|
|
9935
|
+
"audit".to_string(),
|
|
9936
|
+
serde_json::to_value(state.audit.health()).unwrap_or(Value::Null),
|
|
9937
|
+
);
|
|
9938
|
+
}
|
|
9939
|
+
Json(body)
|
|
9756
9940
|
}
|
|
9757
9941
|
|
|
9758
9942
|
async fn sync_pull(
|
|
@@ -9760,11 +9944,25 @@ async fn sync_pull(
|
|
|
9760
9944
|
Json(request): Json<SyncPullRequest>,
|
|
9761
9945
|
) -> Result<Json<SyncPullResponse>, ApiError> {
|
|
9762
9946
|
authorize_sync_member(&state, &request.requester, request.membership_epoch)?;
|
|
9763
|
-
let
|
|
9947
|
+
let peer_position = request.versions;
|
|
9948
|
+
let authority_position = state.db.operation_versions()?;
|
|
9949
|
+
let retained_from = state.db.retained_operation_floors()?;
|
|
9950
|
+
let catchup = catchup_status(request.requester, peer_position.clone(), authority_position.clone(), retained_from);
|
|
9951
|
+
if catchup.recovery_required {
|
|
9952
|
+
return Err(ApiError::structured(
|
|
9953
|
+
StatusCode::CONFLICT,
|
|
9954
|
+
serde_json::to_value(&catchup).unwrap_or_else(|_| json!({
|
|
9955
|
+
"catchup_mode": "snapshot_rebootstrap_required",
|
|
9956
|
+
"recovery_required": true
|
|
9957
|
+
})),
|
|
9958
|
+
));
|
|
9959
|
+
}
|
|
9960
|
+
let operations = state.db.operations_since(&peer_position)?;
|
|
9764
9961
|
state.metrics.sync_sent(operations.len() as u64);
|
|
9765
9962
|
Ok(Json(SyncPullResponse {
|
|
9766
9963
|
operations,
|
|
9767
|
-
versions:
|
|
9964
|
+
versions: authority_position,
|
|
9965
|
+
catchup,
|
|
9768
9966
|
}))
|
|
9769
9967
|
}
|
|
9770
9968
|
|
|
@@ -9971,6 +10169,197 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
|
|
|
9971
10169
|
}
|
|
9972
10170
|
execute_agent_run(state, &row.value, &worker).await?;
|
|
9973
10171
|
}
|
|
10172
|
+
|
|
10173
|
+
evaluate_state_triggers(state)?;
|
|
10174
|
+
execute_eligible_workloads(state, &worker)?;
|
|
10175
|
+
Ok(())
|
|
10176
|
+
}
|
|
10177
|
+
|
|
10178
|
+
/// How long an autonomous claim holds its lease.
|
|
10179
|
+
///
|
|
10180
|
+
/// Long enough that an ordinary capability program finishes inside it, short
|
|
10181
|
+
/// enough that a worker dying mid-execution returns the work quickly. The lease
|
|
10182
|
+
/// is the only thing that makes worker death recoverable, so this is a real
|
|
10183
|
+
/// bound rather than a formality.
|
|
10184
|
+
const WORKLOAD_LEASE_MS: u64 = 30_000;
|
|
10185
|
+
|
|
10186
|
+
fn state_trigger_store(
|
|
10187
|
+
state: &AppState,
|
|
10188
|
+
) -> Result<std::sync::MutexGuard<'_, feltdb::StateTriggerStore>, ApiError> {
|
|
10189
|
+
state.state_triggers.lock().map_err(|_| {
|
|
10190
|
+
ApiError(
|
|
10191
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
10192
|
+
"state trigger store unavailable".into(),
|
|
10193
|
+
)
|
|
10194
|
+
})
|
|
10195
|
+
}
|
|
10196
|
+
|
|
10197
|
+
fn state_trigger_error(error: feltdb::StateTriggerError) -> ApiError {
|
|
10198
|
+
let status = match error.code.as_str() {
|
|
10199
|
+
"TRIGGER_INVALID" | "TRIGGER_UNAUTHORIZED" => StatusCode::BAD_REQUEST,
|
|
10200
|
+
"WORKLOAD_UNAUTHORIZED" => StatusCode::FORBIDDEN,
|
|
10201
|
+
"TRIGGER_STATE_REPLAY_FAILED" | "TRIGGER_STORAGE_FAILURE" | "WORKLOAD_STORAGE_FAILURE" => {
|
|
10202
|
+
StatusCode::SERVICE_UNAVAILABLE
|
|
10203
|
+
}
|
|
10204
|
+
_ => StatusCode::CONFLICT,
|
|
10205
|
+
};
|
|
10206
|
+
ApiError(
|
|
10207
|
+
status,
|
|
10208
|
+
serde_json::to_string(&error).unwrap_or(error.message),
|
|
10209
|
+
)
|
|
10210
|
+
}
|
|
10211
|
+
|
|
10212
|
+
fn evaluate_state_triggers(state: &AppState) -> Result<(), ApiError> {
|
|
10213
|
+
let mut triggers = state_trigger_store(state)?;
|
|
10214
|
+
if triggers.triggers.is_empty() {
|
|
10215
|
+
return Ok(());
|
|
10216
|
+
}
|
|
10217
|
+
let mut workloads = workload_store(state)?;
|
|
10218
|
+
triggers
|
|
10219
|
+
.evaluate(&state.db, &mut workloads, unix_seconds_i64())
|
|
10220
|
+
.map_err(state_trigger_error)?;
|
|
10221
|
+
Ok(())
|
|
10222
|
+
}
|
|
10223
|
+
|
|
10224
|
+
/// The durable workload store, or a service error if its lock is poisoned.
|
|
10225
|
+
fn workload_store(
|
|
10226
|
+
state: &AppState,
|
|
10227
|
+
) -> Result<std::sync::MutexGuard<'_, feltdb::workload::WorkloadStore>, ApiError> {
|
|
10228
|
+
state.workloads.lock().map_err(|_| {
|
|
10229
|
+
ApiError(
|
|
10230
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
10231
|
+
"workload store unavailable".into(),
|
|
10232
|
+
)
|
|
10233
|
+
})
|
|
10234
|
+
}
|
|
10235
|
+
|
|
10236
|
+
/// The capabilities this node can actually run.
|
|
10237
|
+
///
|
|
10238
|
+
/// This is what the worker presents to [`WorkloadStore::claim`], which requires
|
|
10239
|
+
/// the workload's `capability_snapshot` to be a subset of it. That check is
|
|
10240
|
+
/// therefore load-bearing rather than ceremonial: a workload whose declared
|
|
10241
|
+
/// capabilities this node cannot execute is never claimed by it, and stays
|
|
10242
|
+
/// `Ready` for a node that can.
|
|
10243
|
+
fn worker_capabilities(state: &AppState) -> Result<BTreeSet<String>, ApiError> {
|
|
10244
|
+
let mut capabilities: BTreeSet<String> = ["search", "identity"]
|
|
10245
|
+
.into_iter()
|
|
10246
|
+
.map(str::to_string)
|
|
10247
|
+
.collect();
|
|
10248
|
+
for row in state.db.list_collection("_flow_capabilities")? {
|
|
10249
|
+
if let Some((_, name)) = row.key.split_once(':') {
|
|
10250
|
+
capabilities.insert(name.to_string());
|
|
10251
|
+
}
|
|
10252
|
+
}
|
|
10253
|
+
Ok(capabilities)
|
|
10254
|
+
}
|
|
10255
|
+
|
|
10256
|
+
/// Claim and execute the durable workloads this node is eligible to run.
|
|
10257
|
+
///
|
|
10258
|
+
/// This is the composition that makes `WorkloadStore` an execution primitive
|
|
10259
|
+
/// rather than a work data model. It adds no queue and no worker system: the
|
|
10260
|
+
/// scan is the pass that already runs, the claim is the existing lease and
|
|
10261
|
+
/// fencing, the execution is the existing capability interpreter, and the
|
|
10262
|
+
/// transitions are the existing state machine.
|
|
10263
|
+
///
|
|
10264
|
+
/// Temporal eligibility needs no special handling here — `Workload::is_eligible_at`
|
|
10265
|
+
/// filters candidates and `WorkloadStore::claim` refuses anything early, so
|
|
10266
|
+
/// `not_before` and retry backoff gate this path by construction.
|
|
10267
|
+
///
|
|
10268
|
+
/// The store lock is released before execution. A capability program reads
|
|
10269
|
+
/// application state through the same database, so holding the workload lock
|
|
10270
|
+
/// across it would make every other workload operation wait on unrelated work.
|
|
10271
|
+
fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiError> {
|
|
10272
|
+
let capabilities = worker_capabilities(state)?;
|
|
10273
|
+
let now = unix_seconds_i64();
|
|
10274
|
+
let candidates: Vec<(String, String, Value)> = {
|
|
10275
|
+
let store = workload_store(state)?;
|
|
10276
|
+
store
|
|
10277
|
+
.workloads
|
|
10278
|
+
.values()
|
|
10279
|
+
// Selection, not enforcement. `WorkloadStore::claim` independently
|
|
10280
|
+
// refuses work that is early or beyond this node's capabilities, and
|
|
10281
|
+
// that is where the guarantee lives; filtering here only avoids
|
|
10282
|
+
// attempting a claim, every pass, that is known to be refused.
|
|
10283
|
+
.filter(|workload| {
|
|
10284
|
+
workload.state == feltdb::workload::WorkloadState::Ready
|
|
10285
|
+
&& workload.is_eligible_at(now)
|
|
10286
|
+
&& workload.capability_snapshot.is_subset(&capabilities)
|
|
10287
|
+
})
|
|
10288
|
+
.map(|workload| {
|
|
10289
|
+
(
|
|
10290
|
+
workload.workload_id.clone(),
|
|
10291
|
+
workload.definition_id.clone(),
|
|
10292
|
+
workload.input.clone(),
|
|
10293
|
+
)
|
|
10294
|
+
})
|
|
10295
|
+
.collect()
|
|
10296
|
+
};
|
|
10297
|
+
|
|
10298
|
+
for (id, definition, input) in candidates {
|
|
10299
|
+
// Unlike the filter above, this one is enforcement: nothing downstream
|
|
10300
|
+
// knows whether `definition_id` names something runnable. Claiming a
|
|
10301
|
+
// workload only to dead-letter it would consume work another node could
|
|
10302
|
+
// have executed, so an unrunnable definition is left untouched.
|
|
10303
|
+
if !is_executable_capability(state, &definition)? {
|
|
10304
|
+
continue;
|
|
10305
|
+
}
|
|
10306
|
+
let claim = match workload_store(state)?.claim(
|
|
10307
|
+
&id,
|
|
10308
|
+
worker,
|
|
10309
|
+
&capabilities,
|
|
10310
|
+
unix_seconds_i64(),
|
|
10311
|
+
WORKLOAD_LEASE_MS,
|
|
10312
|
+
) {
|
|
10313
|
+
Ok(claim) => claim,
|
|
10314
|
+
// Another worker took it, or eligibility moved between the scan and
|
|
10315
|
+
// the claim. Both are ordinary outcomes of concurrent workers, not
|
|
10316
|
+
// failures of this pass.
|
|
10317
|
+
Err(_) => continue,
|
|
10318
|
+
};
|
|
10319
|
+
workload_store(state)?
|
|
10320
|
+
.start(
|
|
10321
|
+
&id,
|
|
10322
|
+
&claim.claim_id,
|
|
10323
|
+
worker,
|
|
10324
|
+
claim.fencing_token,
|
|
10325
|
+
unix_seconds_i64(),
|
|
10326
|
+
)
|
|
10327
|
+
.map_err(workload_error)?;
|
|
10328
|
+
|
|
10329
|
+
match execute_named_capability(state, &definition, &input) {
|
|
10330
|
+
Ok(output) => {
|
|
10331
|
+
workload_store(state)?
|
|
10332
|
+
.complete(
|
|
10333
|
+
&id,
|
|
10334
|
+
&claim.claim_id,
|
|
10335
|
+
worker,
|
|
10336
|
+
claim.fencing_token,
|
|
10337
|
+
&format!("result_{id}_{}", claim.fencing_token),
|
|
10338
|
+
output,
|
|
10339
|
+
vec![],
|
|
10340
|
+
1,
|
|
10341
|
+
unix_seconds_i64(),
|
|
10342
|
+
)
|
|
10343
|
+
.map_err(workload_error)?;
|
|
10344
|
+
}
|
|
10345
|
+
Err(error) => {
|
|
10346
|
+
// A capability program that fails does so deterministically, so
|
|
10347
|
+
// this failure kind is deliberately outside the default
|
|
10348
|
+
// `retry_on` set: retrying it would burn attempts to reach the
|
|
10349
|
+
// same dead letter. Only the message is carried, because the
|
|
10350
|
+
// store refuses failure text that looks like a secret.
|
|
10351
|
+
let _ = workload_store(state)?.fail(
|
|
10352
|
+
&id,
|
|
10353
|
+
&claim.claim_id,
|
|
10354
|
+
worker,
|
|
10355
|
+
claim.fencing_token,
|
|
10356
|
+
"execution_error",
|
|
10357
|
+
&format!("capability {definition} failed with status {}", error.0),
|
|
10358
|
+
unix_seconds_i64(),
|
|
10359
|
+
);
|
|
10360
|
+
}
|
|
10361
|
+
}
|
|
10362
|
+
}
|
|
9974
10363
|
Ok(())
|
|
9975
10364
|
}
|
|
9976
10365
|
|
|
@@ -11795,7 +12184,15 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
|
|
|
11795
12184
|
}
|
|
11796
12185
|
}
|
|
11797
12186
|
|
|
11798
|
-
|
|
12187
|
+
/// Apply one condition to the field value the record supplies for it.
|
|
12188
|
+
///
|
|
12189
|
+
/// Taking the field value rather than the record lets the scan path evaluate a
|
|
12190
|
+
/// condition against a borrowed record plus its authority-supplied `recordId`,
|
|
12191
|
+
/// without first cloning the record to insert that field.
|
|
12192
|
+
fn condition_matches_field(
|
|
12193
|
+
actual: Option<&Value>,
|
|
12194
|
+
condition: &BoundedQueryCondition,
|
|
12195
|
+
) -> Result<bool, ApiError> {
|
|
11799
12196
|
if condition.operators.len() != 1 || condition.field.trim().is_empty() {
|
|
11800
12197
|
return Err(bounded_query_error(
|
|
11801
12198
|
"INVALID_QUERY",
|
|
@@ -11807,7 +12204,6 @@ fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Resul
|
|
|
11807
12204
|
.iter()
|
|
11808
12205
|
.next()
|
|
11809
12206
|
.expect("validated operator");
|
|
11810
|
-
let actual = record.get(&condition.field);
|
|
11811
12207
|
if operator == "eq" {
|
|
11812
12208
|
return Ok(actual == Some(expected));
|
|
11813
12209
|
}
|
|
@@ -11832,11 +12228,166 @@ fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Resul
|
|
|
11832
12228
|
})
|
|
11833
12229
|
}
|
|
11834
12230
|
|
|
12231
|
+
/// The record identity the query surface exposes as `recordId`.
|
|
12232
|
+
fn bounded_query_record_id(key: &str) -> &str {
|
|
12233
|
+
key.split_once(':').map(|(_, id)| id).unwrap_or(key)
|
|
12234
|
+
}
|
|
12235
|
+
|
|
12236
|
+
/// Does a stored record satisfy every condition of a bounded query?
|
|
12237
|
+
///
|
|
12238
|
+
/// This is the same conjunction the materializing path applied, evaluated
|
|
12239
|
+
/// against a borrowed record. `recordId` is authority metadata rather than
|
|
12240
|
+
/// caller-controlled document data, so it shadows a document field of that name
|
|
12241
|
+
/// exactly where the materializing path inserted it: on object records only.
|
|
12242
|
+
fn bounded_query_matches(row: &feltdb::StoredRow, conditions: &[BoundedQueryCondition]) -> bool {
|
|
12243
|
+
let record_id = row
|
|
12244
|
+
.value
|
|
12245
|
+
.is_object()
|
|
12246
|
+
.then(|| Value::String(bounded_query_record_id(&row.key).to_string()));
|
|
12247
|
+
conditions.iter().all(|condition| {
|
|
12248
|
+
let actual = match record_id.as_ref() {
|
|
12249
|
+
Some(id) if condition.field == "recordId" => Some(id),
|
|
12250
|
+
_ => row.value.get(&condition.field),
|
|
12251
|
+
};
|
|
12252
|
+
condition_matches_field(actual, condition).unwrap_or(false)
|
|
12253
|
+
})
|
|
12254
|
+
}
|
|
12255
|
+
|
|
12256
|
+
/// Declare the equality indexes this authority maintains.
|
|
12257
|
+
///
|
|
12258
|
+
/// `FELTDB_EQUALITY_INDEXES=orders.status,orders.tenantId` — a comma-separated
|
|
12259
|
+
/// list of `collection.field`, split at the first dot so a field name may itself
|
|
12260
|
+
/// contain one. Declaration is explicit and static on purpose: PR35 adds an
|
|
12261
|
+
/// equality execution primitive, not a planner, so nothing here inspects a
|
|
12262
|
+
/// workload, collects statistics, or decides on its own what deserves an index.
|
|
12263
|
+
///
|
|
12264
|
+
/// Each declaration is populated from authoritative state as it is made, so the
|
|
12265
|
+
/// authority never serves a query against a half-built index.
|
|
12266
|
+
fn declare_configured_equality_indexes(db: &FeltDb) -> Result<Vec<String>, String> {
|
|
12267
|
+
let Ok(declaration) = std::env::var("FELTDB_EQUALITY_INDEXES") else {
|
|
12268
|
+
return Ok(Vec::new());
|
|
12269
|
+
};
|
|
12270
|
+
let mut declared = Vec::new();
|
|
12271
|
+
for entry in declaration.split(',').map(str::trim).filter(|entry| !entry.is_empty()) {
|
|
12272
|
+
let Some((collection, field)) = entry.split_once('.') else {
|
|
12273
|
+
return Err(format!(
|
|
12274
|
+
"FELTDB_EQUALITY_INDEXES entry {entry:?} must be written as collection.field"
|
|
12275
|
+
));
|
|
12276
|
+
};
|
|
12277
|
+
db.create_equality_index(collection, field)
|
|
12278
|
+
.map_err(|error| format!("cannot index {entry}: {error}"))?;
|
|
12279
|
+
declared.push(format!("{collection}.{field}"));
|
|
12280
|
+
}
|
|
12281
|
+
Ok(declared)
|
|
12282
|
+
}
|
|
12283
|
+
|
|
12284
|
+
/// Which execution a bounded query is allowed to take.
|
|
12285
|
+
///
|
|
12286
|
+
/// `Auto` is the only mode the product has: use the index when one applies,
|
|
12287
|
+
/// scan otherwise. `Scan` and `Index` exist so a test can run the *same* query
|
|
12288
|
+
/// down both paths and compare the externally visible result, which is the only
|
|
12289
|
+
/// way to demonstrate that an optimization changed nothing but cost. They are
|
|
12290
|
+
/// not a public API: the mode arrives on an undocumented request header that is
|
|
12291
|
+
/// read only when the process was started with `FELTDB_QUERY_DIAGNOSTICS=1`, so
|
|
12292
|
+
/// a production authority has exactly one execution mode and no way to be asked
|
|
12293
|
+
/// for another. The request body — the query contract itself — is untouched.
|
|
12294
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
12295
|
+
enum BoundedQueryExecution {
|
|
12296
|
+
Auto,
|
|
12297
|
+
Scan,
|
|
12298
|
+
Index,
|
|
12299
|
+
}
|
|
12300
|
+
|
|
12301
|
+
/// Test instrumentation is compiled in but inert unless the process opted in,
|
|
12302
|
+
/// evaluated once so a request cannot pay for the lookup.
|
|
12303
|
+
fn query_diagnostics_enabled() -> bool {
|
|
12304
|
+
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
|
12305
|
+
*ENABLED.get_or_init(|| std::env::var("FELTDB_QUERY_DIAGNOSTICS").as_deref() == Ok("1"))
|
|
12306
|
+
}
|
|
12307
|
+
|
|
12308
|
+
const QUERY_EXECUTION_HEADER: &str = "x-feltdb-query-execution";
|
|
12309
|
+
|
|
12310
|
+
fn bounded_query_execution(headers: &HeaderMap) -> Result<BoundedQueryExecution, ApiError> {
|
|
12311
|
+
if !query_diagnostics_enabled() {
|
|
12312
|
+
return Ok(BoundedQueryExecution::Auto);
|
|
12313
|
+
}
|
|
12314
|
+
match headers
|
|
12315
|
+
.get(QUERY_EXECUTION_HEADER)
|
|
12316
|
+
.and_then(|value| value.to_str().ok())
|
|
12317
|
+
{
|
|
12318
|
+
None | Some("auto") => Ok(BoundedQueryExecution::Auto),
|
|
12319
|
+
Some("scan") => Ok(BoundedQueryExecution::Scan),
|
|
12320
|
+
Some("index") => Ok(BoundedQueryExecution::Index),
|
|
12321
|
+
Some(other) => Err(bounded_query_error(
|
|
12322
|
+
"INVALID_QUERY",
|
|
12323
|
+
format!("unsupported query execution mode: {other}"),
|
|
12324
|
+
)),
|
|
12325
|
+
}
|
|
12326
|
+
}
|
|
12327
|
+
|
|
12328
|
+
/// The equality conditions of a query, as candidate-selection inputs.
|
|
12329
|
+
///
|
|
12330
|
+
/// Only `eq` contributes: every other operator is a comparison this index does
|
|
12331
|
+
/// not model, and PR35 adds no range index. `recordId` is excluded because on
|
|
12332
|
+
/// this surface it is authority metadata that shadows a document field of the
|
|
12333
|
+
/// same name on object-valued records only — an index over the document field
|
|
12334
|
+
/// would answer a different question from the one `bounded_query_matches` asks,
|
|
12335
|
+
/// so a `recordId` condition is never index eligible and its query falls back to
|
|
12336
|
+
/// the scan (or is served by candidates from a different, indexed condition).
|
|
12337
|
+
///
|
|
12338
|
+
/// Conditions the index cannot use are simply absent here. They are not dropped
|
|
12339
|
+
/// from the query: `bounded_query_matches` still evaluates every one of them
|
|
12340
|
+
/// against authoritative records.
|
|
12341
|
+
fn bounded_query_equalities(conditions: &[BoundedQueryCondition]) -> Vec<(&str, &Value)> {
|
|
12342
|
+
conditions
|
|
12343
|
+
.iter()
|
|
12344
|
+
.filter(|condition| condition.field != "recordId" && condition.operators.len() == 1)
|
|
12345
|
+
.filter_map(|condition| {
|
|
12346
|
+
let (operator, expected) = condition.operators.iter().next()?;
|
|
12347
|
+
(operator == "eq").then_some((condition.field.as_str(), expected))
|
|
12348
|
+
})
|
|
12349
|
+
.collect()
|
|
12350
|
+
}
|
|
12351
|
+
|
|
12352
|
+
/// Evaluate a bounded query's conjunction against authoritative records.
|
|
12353
|
+
///
|
|
12354
|
+
/// Two executions, one result. The indexed execution narrows *which* records the
|
|
12355
|
+
/// predicate sees; `bounded_query_matches` — unchanged, and the sole authority on
|
|
12356
|
+
/// what matches — decides the answer on either path. When no index applies the
|
|
12357
|
+
/// scan runs exactly as PR34 left it.
|
|
12358
|
+
fn bounded_query_records(
|
|
12359
|
+
state: &AppState,
|
|
12360
|
+
request: &BoundedQueryRequest,
|
|
12361
|
+
execution: BoundedQueryExecution,
|
|
12362
|
+
) -> Result<Vec<feltdb::StoredRow>, ApiError> {
|
|
12363
|
+
if execution != BoundedQueryExecution::Scan {
|
|
12364
|
+
let equalities = bounded_query_equalities(&request.conditions);
|
|
12365
|
+
let indexed = state.db.query_collection_by_equality(
|
|
12366
|
+
&request.collection,
|
|
12367
|
+
&equalities,
|
|
12368
|
+
|row| bounded_query_matches(row, &request.conditions),
|
|
12369
|
+
)?;
|
|
12370
|
+
if let Some(records) = indexed {
|
|
12371
|
+
return Ok(records);
|
|
12372
|
+
}
|
|
12373
|
+
if execution == BoundedQueryExecution::Index {
|
|
12374
|
+
// Only reachable through the test-only header. A forced-index run
|
|
12375
|
+
// that silently scanned would make an equivalence test prove nothing.
|
|
12376
|
+
return Err(bounded_query_error(
|
|
12377
|
+
"INDEX_UNAVAILABLE",
|
|
12378
|
+
"no equality index applies to this query",
|
|
12379
|
+
));
|
|
12380
|
+
}
|
|
12381
|
+
}
|
|
12382
|
+
Ok(state.db.query_collection(&request.collection, None, |row| {
|
|
12383
|
+
bounded_query_matches(row, &request.conditions)
|
|
12384
|
+
})?)
|
|
12385
|
+
}
|
|
12386
|
+
|
|
11835
12387
|
fn issue_bounded_cursor(
|
|
11836
12388
|
state: &AppState,
|
|
11837
12389
|
mut cursor: BoundedQueryCursor,
|
|
11838
|
-
) -> Result<String, ApiError> {
|
|
11839
|
-
let token = uuid::Uuid::new_v4().simple().to_string();
|
|
12390
|
+
) -> Result<String, ApiError> { let token = uuid::Uuid::new_v4().simple().to_string();
|
|
11840
12391
|
let now = unix_seconds_i64().max(0) as u64;
|
|
11841
12392
|
cursor.created_at = now;
|
|
11842
12393
|
let mut cursors = state.bounded_query_cursors.lock().map_err(|_| {
|
|
@@ -11859,8 +12410,12 @@ fn issue_bounded_cursor(
|
|
|
11859
12410
|
async fn execute_bounded_query(
|
|
11860
12411
|
State(state): State<AppState>,
|
|
11861
12412
|
Extension(principal): Extension<Principal>,
|
|
12413
|
+
headers: HeaderMap,
|
|
11862
12414
|
Json(request): Json<BoundedQueryRequest>,
|
|
11863
12415
|
) -> Result<Json<BoundedQueryPage>, ApiError> {
|
|
12416
|
+
let _handler =
|
|
12417
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12418
|
+
let execution = bounded_query_execution(&headers)?;
|
|
11864
12419
|
validate_segment(&request.collection)?;
|
|
11865
12420
|
if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
|
|
11866
12421
|
return Err(bounded_query_error(
|
|
@@ -11928,30 +12483,40 @@ async fn execute_bounded_query(
|
|
|
11928
12483
|
}
|
|
11929
12484
|
(cursor.records, cursor.position)
|
|
11930
12485
|
} else {
|
|
11931
|
-
|
|
11932
|
-
|
|
12486
|
+
// Bounded queries evaluate their conjunction against borrowed records
|
|
12487
|
+
// and clone only the matches, so a query never pays to materialize the
|
|
12488
|
+
// records it is about to discard. An equality index, where one applies,
|
|
12489
|
+
// narrows which records the conjunction is evaluated against; it does
|
|
12490
|
+
// not decide the answer and it does not order it. Ordering is part of
|
|
12491
|
+
// this surface's contract, so the matching set — not the collection, and
|
|
12492
|
+
// not an index bucket — is what gets sorted, and the limit is applied to
|
|
12493
|
+
// that ordered set. Nothing below this line knows which execution ran.
|
|
12494
|
+
let matches = bounded_query_records(&state, &request, execution)?;
|
|
12495
|
+
// Result materialization happens outside the state lock, so it is a cost
|
|
12496
|
+
// this request pays alone rather than one it imposes on every other
|
|
12497
|
+
// operation. PR36 measures it separately for exactly that reason.
|
|
12498
|
+
let materialization = feltdb::workload_diagnostics::span(
|
|
12499
|
+
feltdb::workload_diagnostics::Phase::ResultMaterialization,
|
|
12500
|
+
);
|
|
12501
|
+
let mut records = Vec::with_capacity(matches.len());
|
|
12502
|
+
for row in matches {
|
|
11933
12503
|
let mut value = row.value;
|
|
11934
12504
|
if let Some(object) = value.as_object_mut() {
|
|
11935
|
-
let id = row
|
|
11936
|
-
.key
|
|
11937
|
-
.split_once(':')
|
|
11938
|
-
.map(|(_, id)| id)
|
|
11939
|
-
.unwrap_or(&row.key);
|
|
11940
12505
|
// `recordId` is authority metadata for this query surface, not
|
|
11941
12506
|
// caller-controlled document data. It is the final total-order
|
|
11942
12507
|
// tie-breaker even when a document contains a field by that name.
|
|
11943
|
-
object.insert(
|
|
11944
|
-
|
|
11945
|
-
|
|
11946
|
-
|
|
11947
|
-
.iter()
|
|
11948
|
-
.all(|condition| condition_matches(&value, condition).unwrap_or(false))
|
|
11949
|
-
{
|
|
11950
|
-
records.push(value);
|
|
12508
|
+
object.insert(
|
|
12509
|
+
"recordId".into(),
|
|
12510
|
+
Value::String(bounded_query_record_id(&row.key).to_string()),
|
|
12511
|
+
);
|
|
11951
12512
|
}
|
|
12513
|
+
records.push(value);
|
|
11952
12514
|
}
|
|
12515
|
+
drop(materialization);
|
|
11953
12516
|
// The authority always adds its immutable record identity as the final
|
|
11954
12517
|
// tie-breaker, so equal user sort values still form a total order.
|
|
12518
|
+
let ordering =
|
|
12519
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Ordering);
|
|
11955
12520
|
records.sort_by(|left, right| {
|
|
11956
12521
|
for order in &request.order_by {
|
|
11957
12522
|
let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
|
|
@@ -11965,6 +12530,7 @@ async fn execute_bounded_query(
|
|
|
11965
12530
|
}
|
|
11966
12531
|
query_scalar_cmp(left.get("recordId"), right.get("recordId"))
|
|
11967
12532
|
});
|
|
12533
|
+
drop(ordering);
|
|
11968
12534
|
(Arc::new(records), 0)
|
|
11969
12535
|
};
|
|
11970
12536
|
let end = position.saturating_add(request.limit).min(records.len());
|
|
@@ -11991,6 +12557,131 @@ async fn execute_bounded_query(
|
|
|
11991
12557
|
}))
|
|
11992
12558
|
}
|
|
11993
12559
|
|
|
12560
|
+
/// Collection-read counters, mounted only when the process opts in.
|
|
12561
|
+
///
|
|
12562
|
+
/// This is test instrumentation for the PR34 no-materialization proof, not part
|
|
12563
|
+
/// of the query contract, so a server only carries the route when it is started
|
|
12564
|
+
/// with `FELTDB_QUERY_DIAGNOSTICS=1`. It returns counts, never record data.
|
|
12565
|
+
fn query_execution_diagnostics_router() -> Router<AppState> {
|
|
12566
|
+
let mut router = Router::new();
|
|
12567
|
+
if std::env::var("FELTDB_QUERY_DIAGNOSTICS").as_deref() == Ok("1") {
|
|
12568
|
+
router = router.route(
|
|
12569
|
+
"/internal/query-execution-diagnostics",
|
|
12570
|
+
get(query_execution_diagnostics),
|
|
12571
|
+
);
|
|
12572
|
+
}
|
|
12573
|
+
// Phase attribution is a separate switch from the execution counters,
|
|
12574
|
+
// because it is a separate cost: the counters are a handful of atomic adds
|
|
12575
|
+
// per query, while attribution reads the clock at every instrumented
|
|
12576
|
+
// boundary. Keeping them apart lets a run take execution counts without
|
|
12577
|
+
// paying for timing, which is how the headline PR36 figures are measured.
|
|
12578
|
+
if std::env::var("FELTDB_WORKLOAD_DIAGNOSTICS").as_deref() == Ok("1") {
|
|
12579
|
+
router = router
|
|
12580
|
+
.route(
|
|
12581
|
+
"/internal/workload-diagnostics",
|
|
12582
|
+
get(workload_phase_diagnostics),
|
|
12583
|
+
)
|
|
12584
|
+
.route(
|
|
12585
|
+
"/internal/workload-diagnostics/reset",
|
|
12586
|
+
axum::routing::post(reset_workload_phase_diagnostics),
|
|
12587
|
+
);
|
|
12588
|
+
}
|
|
12589
|
+
router
|
|
12590
|
+
}
|
|
12591
|
+
|
|
12592
|
+
/// Phase attribution: where elapsed time went, as counts and nanoseconds.
|
|
12593
|
+
///
|
|
12594
|
+
/// Test instrumentation, mounted only under `FELTDB_WORKLOAD_DIAGNOSTICS=1`.
|
|
12595
|
+
/// It reports durations and call counts and nothing else — no record, no field,
|
|
12596
|
+
/// no value, no id — and no product surface reads it.
|
|
12597
|
+
///
|
|
12598
|
+
/// `parent` is part of the payload because the phases nest: a caller that summed
|
|
12599
|
+
/// every row would double-count the work reported inside `state_lock_hold` and
|
|
12600
|
+
/// `indexed_query`. The nesting is declared here so an analysis can compute
|
|
12601
|
+
/// exclusive time instead of guessing at it.
|
|
12602
|
+
async fn workload_phase_diagnostics() -> Json<Value> {
|
|
12603
|
+
let phases: Vec<Value> = feltdb::workload_diagnostics::counters()
|
|
12604
|
+
.into_iter()
|
|
12605
|
+
.map(|counter| {
|
|
12606
|
+
json!({
|
|
12607
|
+
"phase": counter.phase.name(),
|
|
12608
|
+
"parent": counter.phase.parent().map(|parent| parent.name()),
|
|
12609
|
+
"calls": counter.calls,
|
|
12610
|
+
"nanos": counter.nanos,
|
|
12611
|
+
"meanNanos": counter.mean_nanos(),
|
|
12612
|
+
})
|
|
12613
|
+
})
|
|
12614
|
+
.collect();
|
|
12615
|
+
Json(json!({
|
|
12616
|
+
"enabled": feltdb::workload_diagnostics::enabled(),
|
|
12617
|
+
"phases": phases,
|
|
12618
|
+
}))
|
|
12619
|
+
}
|
|
12620
|
+
|
|
12621
|
+
/// Zero the phase counters, so a measured window starts from a clean base.
|
|
12622
|
+
async fn reset_workload_phase_diagnostics() -> Json<Value> {
|
|
12623
|
+
feltdb::workload_diagnostics::reset();
|
|
12624
|
+
Json(json!({ "reset": true }))
|
|
12625
|
+
}
|
|
12626
|
+
|
|
12627
|
+
/// Ask the diagnostics route to also verify the index against records.
|
|
12628
|
+
#[derive(Deserialize)]
|
|
12629
|
+
struct QueryDiagnosticsScope {
|
|
12630
|
+
#[serde(default)]
|
|
12631
|
+
verify: Option<String>,
|
|
12632
|
+
}
|
|
12633
|
+
|
|
12634
|
+
async fn query_execution_diagnostics(
|
|
12635
|
+
State(state): State<AppState>,
|
|
12636
|
+
Query(scope): Query<QueryDiagnosticsScope>,
|
|
12637
|
+
) -> Json<Value> {
|
|
12638
|
+
let counters = feltdb::query_execution_diagnostics::counters();
|
|
12639
|
+
// Verifying rebuilds the whole index from records to compare against the
|
|
12640
|
+
// live one, so it is linear in collection size and is asked for explicitly.
|
|
12641
|
+
// A counter read that silently paid for a rebuild would make every
|
|
12642
|
+
// measurement that brackets a query with a counter read measure the rebuild.
|
|
12643
|
+
let consistency = match scope.verify.as_deref() {
|
|
12644
|
+
Some("1") => state
|
|
12645
|
+
.db
|
|
12646
|
+
.verify_equality_index()
|
|
12647
|
+
.map(|outcome| Value::Bool(outcome.is_ok()))
|
|
12648
|
+
.unwrap_or(Value::Null),
|
|
12649
|
+
_ => Value::Null,
|
|
12650
|
+
};
|
|
12651
|
+
Json(json!({
|
|
12652
|
+
"fullCollectionMaterializations": counters.full_collection_materializations,
|
|
12653
|
+
"fullCollectionRecordsCloned": counters.full_collection_records_cloned,
|
|
12654
|
+
"collectionScans": counters.collection_scans,
|
|
12655
|
+
"scanRecordsVisited": counters.scan_records_visited,
|
|
12656
|
+
"scanRecordsMaterialized": counters.scan_records_materialized,
|
|
12657
|
+
"queriesTotal": counters.queries_total,
|
|
12658
|
+
"queriesScan": counters.queries_scan,
|
|
12659
|
+
"queriesIndexed": counters.queries_indexed,
|
|
12660
|
+
"indexHits": counters.index_hits,
|
|
12661
|
+
"indexMisses": counters.index_misses,
|
|
12662
|
+
"indexCandidatesExamined": counters.index_candidates_examined,
|
|
12663
|
+
"recordsPredicateEvaluated": counters.records_predicate_evaluated,
|
|
12664
|
+
"equalityIndexes": state
|
|
12665
|
+
.db
|
|
12666
|
+
.equality_indexes()
|
|
12667
|
+
.map(|indexes| {
|
|
12668
|
+
indexes
|
|
12669
|
+
.into_iter()
|
|
12670
|
+
.map(|(collection, field)| format!("{collection}.{field}"))
|
|
12671
|
+
.collect::<Vec<_>>()
|
|
12672
|
+
})
|
|
12673
|
+
.unwrap_or_default(),
|
|
12674
|
+
"equalityIndexStats": state.db.equality_index_stats().map(|stats| json!({
|
|
12675
|
+
"indexedFields": stats.indexed_fields,
|
|
12676
|
+
"valueBuckets": stats.value_buckets,
|
|
12677
|
+
"entries": stats.entries,
|
|
12678
|
+
})).unwrap_or(Value::Null),
|
|
12679
|
+
// Null unless `?verify=1` asked for the check; a null here means "not
|
|
12680
|
+
// checked", never "checked and inconsistent".
|
|
12681
|
+
"equalityIndexConsistent": consistency,
|
|
12682
|
+
}))
|
|
12683
|
+
}
|
|
12684
|
+
|
|
11994
12685
|
async fn list_records(
|
|
11995
12686
|
State(state): State<AppState>,
|
|
11996
12687
|
Path(collection): Path<String>,
|
|
@@ -12015,6 +12706,8 @@ async fn get_record(
|
|
|
12015
12706
|
State(state): State<AppState>,
|
|
12016
12707
|
Path((collection, id)): Path<(String, String)>,
|
|
12017
12708
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
12709
|
+
let _handler =
|
|
12710
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12018
12711
|
if collection == PROPOSAL_COLLECTION
|
|
12019
12712
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12020
12713
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -12123,6 +12816,8 @@ async fn commit_transaction(
|
|
|
12123
12816
|
State(state): State<AppState>,
|
|
12124
12817
|
Json(request): Json<AtomicTxRequest>,
|
|
12125
12818
|
) -> Result<(StatusCode, Json<AtomicTxResponse>), ApiError> {
|
|
12819
|
+
let _handler =
|
|
12820
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12126
12821
|
if request.transaction_id.trim().is_empty() {
|
|
12127
12822
|
return Err(ApiError(
|
|
12128
12823
|
StatusCode::BAD_REQUEST,
|
|
@@ -12272,6 +12967,8 @@ async fn create_record(
|
|
|
12272
12967
|
Path(collection): Path<String>,
|
|
12273
12968
|
Json(mut record): Json<CreateRecord>,
|
|
12274
12969
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
12970
|
+
let _handler =
|
|
12971
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12275
12972
|
if collection == PROPOSAL_COLLECTION
|
|
12276
12973
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12277
12974
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -12316,6 +13013,8 @@ async fn update_record(
|
|
|
12316
13013
|
Path((collection, id)): Path<(String, String)>,
|
|
12317
13014
|
Json(changes): Json<Map<String, Value>>,
|
|
12318
13015
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
13016
|
+
let _handler =
|
|
13017
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12319
13018
|
if collection == PROPOSAL_COLLECTION
|
|
12320
13019
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12321
13020
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -12353,6 +13052,8 @@ async fn compare_and_set_record(
|
|
|
12353
13052
|
Path((collection, id)): Path<(String, String)>,
|
|
12354
13053
|
Json(request): Json<CasRecordRequest>,
|
|
12355
13054
|
) -> Result<Response, ApiError> {
|
|
13055
|
+
let _handler =
|
|
13056
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12356
13057
|
let key = record_key(&collection, &id)?;
|
|
12357
13058
|
let result = state.db.compare_and_set_json(
|
|
12358
13059
|
&key,
|
|
@@ -12442,6 +13143,8 @@ async fn delete_record(
|
|
|
12442
13143
|
State(state): State<AppState>,
|
|
12443
13144
|
Path((collection, id)): Path<(String, String)>,
|
|
12444
13145
|
) -> Result<StatusCode, ApiError> {
|
|
13146
|
+
let _handler =
|
|
13147
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12445
13148
|
if collection == PROPOSAL_COLLECTION
|
|
12446
13149
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12447
13150
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -12562,6 +13265,69 @@ async fn shutdown_signal() {
|
|
|
12562
13265
|
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
|
|
12563
13266
|
}
|
|
12564
13267
|
|
|
13268
|
+
#[cfg(test)]
|
|
13269
|
+
mod worker_identity_tests {
|
|
13270
|
+
use super::grant_subject;
|
|
13271
|
+
use feltdb::authorization::Subject as GrantSubject;
|
|
13272
|
+
use feltdb_server::auth::Principal;
|
|
13273
|
+
|
|
13274
|
+
fn principal(subject_type: &str, identity: Option<&str>) -> Principal {
|
|
13275
|
+
Principal {
|
|
13276
|
+
key_id: "key-1".into(),
|
|
13277
|
+
scopes: vec!["*".into()],
|
|
13278
|
+
subject_type: subject_type.into(),
|
|
13279
|
+
identity_id: identity.map(str::to_string),
|
|
13280
|
+
session_id: None,
|
|
13281
|
+
}
|
|
13282
|
+
}
|
|
13283
|
+
|
|
13284
|
+
/// The workload worker lifecycle compares the authenticated subject against
|
|
13285
|
+
/// the `worker_id` in the request, so which subject each principal maps to
|
|
13286
|
+
/// *is* the security boundary. Nothing but the authenticated principal
|
|
13287
|
+
/// participates in this mapping.
|
|
13288
|
+
#[test]
|
|
13289
|
+
fn only_a_worker_principal_maps_to_a_worker_subject() {
|
|
13290
|
+
assert_eq!(
|
|
13291
|
+
grant_subject(&principal("worker", Some("worker-x"))),
|
|
13292
|
+
GrantSubject::Worker("worker-x".into()),
|
|
13293
|
+
);
|
|
13294
|
+
// Every other principal maps somewhere a worker check can never accept,
|
|
13295
|
+
// no matter what identity it carries or what scopes it holds.
|
|
13296
|
+
for (subject_type, expected) in [
|
|
13297
|
+
("human", GrantSubject::Human("worker-x".into())),
|
|
13298
|
+
("user", GrantSubject::Human("worker-x".into())),
|
|
13299
|
+
("service", GrantSubject::Service("worker-x".into())),
|
|
13300
|
+
("api_key", GrantSubject::Service("worker-x".into())),
|
|
13301
|
+
("agent", GrantSubject::Agent("worker-x".into())),
|
|
13302
|
+
] {
|
|
13303
|
+
let subject = grant_subject(&principal(subject_type, Some("worker-x")));
|
|
13304
|
+
assert_eq!(subject, expected, "{subject_type} mapped unexpectedly");
|
|
13305
|
+
assert_ne!(
|
|
13306
|
+
subject,
|
|
13307
|
+
GrantSubject::Worker("worker-x".into()),
|
|
13308
|
+
"{subject_type} must never satisfy a worker check"
|
|
13309
|
+
);
|
|
13310
|
+
}
|
|
13311
|
+
}
|
|
13312
|
+
|
|
13313
|
+
/// A worker principal is one worker, not any worker.
|
|
13314
|
+
#[test]
|
|
13315
|
+
fn a_worker_subject_is_bound_to_its_own_identity() {
|
|
13316
|
+
let subject = grant_subject(&principal("worker", Some("worker-x")));
|
|
13317
|
+
assert_ne!(subject, GrantSubject::Worker("worker-y".into()));
|
|
13318
|
+
}
|
|
13319
|
+
|
|
13320
|
+
/// Without an identity the principal falls back to its key id, so a worker
|
|
13321
|
+
/// credential missing its worker id cannot silently become a wildcard.
|
|
13322
|
+
#[test]
|
|
13323
|
+
fn a_worker_principal_without_an_identity_falls_back_to_its_key_id() {
|
|
13324
|
+
assert_eq!(
|
|
13325
|
+
grant_subject(&principal("worker", None)),
|
|
13326
|
+
GrantSubject::Worker("key-1".into()),
|
|
13327
|
+
);
|
|
13328
|
+
}
|
|
13329
|
+
}
|
|
13330
|
+
|
|
12565
13331
|
#[cfg(test)]
|
|
12566
13332
|
mod authority_gate_tests {
|
|
12567
13333
|
use super::{api_error_body, authorize_transaction_collections, state_authorization};
|