@feltdb/core 0.8.3 → 0.8.4
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/crates/feltdb/src/equality_index.rs +595 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +547 -115
- package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +11 -2
- package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +292 -2
- package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
- package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +443 -0
- package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +233 -0
- package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +892 -0
- package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +474 -28
- 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-CVQWgXO-.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
- package/dist/studio-app/assets/index-DwgNAIIX.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/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
|
@@ -7861,6 +7861,54 @@ struct SyncPullRequest {
|
|
|
7861
7861
|
struct SyncPullResponse {
|
|
7862
7862
|
operations: Vec<Operation>,
|
|
7863
7863
|
versions: HashMap<String, u64>,
|
|
7864
|
+
catchup: CatchupStatus,
|
|
7865
|
+
}
|
|
7866
|
+
|
|
7867
|
+
#[derive(Serialize, Deserialize)]
|
|
7868
|
+
struct CatchupStatus {
|
|
7869
|
+
peer_id: String,
|
|
7870
|
+
peer_position: HashMap<String, u64>,
|
|
7871
|
+
authority_position: HashMap<String, u64>,
|
|
7872
|
+
retained_from: HashMap<String, u64>,
|
|
7873
|
+
retained_through: HashMap<String, u64>,
|
|
7874
|
+
lag: u64,
|
|
7875
|
+
recoverability: String,
|
|
7876
|
+
catchup_mode: String,
|
|
7877
|
+
recovery_required: bool,
|
|
7878
|
+
}
|
|
7879
|
+
|
|
7880
|
+
fn catchup_status(
|
|
7881
|
+
peer_id: String,
|
|
7882
|
+
peer_position: HashMap<String, u64>,
|
|
7883
|
+
authority_position: HashMap<String, u64>,
|
|
7884
|
+
retained_from: HashMap<String, u64>,
|
|
7885
|
+
) -> CatchupStatus {
|
|
7886
|
+
let lag = authority_position.iter().map(|(origin, sequence)| {
|
|
7887
|
+
sequence.saturating_sub(peer_position.get(origin).copied().unwrap_or(0))
|
|
7888
|
+
}).sum();
|
|
7889
|
+
let recovery_required = authority_position.iter().any(|(origin, through)| {
|
|
7890
|
+
let position = peer_position.get(origin).copied().unwrap_or(0);
|
|
7891
|
+
let floor = retained_from.get(origin).copied().unwrap_or(through.saturating_add(1));
|
|
7892
|
+
position < *through && position.saturating_add(1) < floor
|
|
7893
|
+
});
|
|
7894
|
+
let catchup_mode = if recovery_required {
|
|
7895
|
+
"snapshot_rebootstrap_required"
|
|
7896
|
+
} else if lag == 0 {
|
|
7897
|
+
"current"
|
|
7898
|
+
} else {
|
|
7899
|
+
"incremental_replay"
|
|
7900
|
+
};
|
|
7901
|
+
CatchupStatus {
|
|
7902
|
+
peer_id,
|
|
7903
|
+
peer_position,
|
|
7904
|
+
retained_through: authority_position.clone(),
|
|
7905
|
+
authority_position,
|
|
7906
|
+
retained_from,
|
|
7907
|
+
lag,
|
|
7908
|
+
recoverability: if recovery_required { "rebootstrap_required" } else { "recoverable" }.into(),
|
|
7909
|
+
catchup_mode: catchup_mode.into(),
|
|
7910
|
+
recovery_required,
|
|
7911
|
+
}
|
|
7864
7912
|
}
|
|
7865
7913
|
|
|
7866
7914
|
#[derive(Serialize, Deserialize)]
|
|
@@ -8250,6 +8298,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8250
8298
|
let config = Config::from_args().map_err(|error| format!("configuration error: {error}"))?;
|
|
8251
8299
|
validate_production_configuration(&config)?;
|
|
8252
8300
|
let db = FeltDb::open(&config.data)?;
|
|
8301
|
+
// Indexes are derived execution structures, so they are declared by
|
|
8302
|
+
// configuration and built from the durable state this open just replayed.
|
|
8303
|
+
// Nothing about them is read from disk: a restart re-derives every bucket
|
|
8304
|
+
// from the records that survived, which is why there is no index format,
|
|
8305
|
+
// no index log, and nothing to repair.
|
|
8306
|
+
let declared_indexes = declare_configured_equality_indexes(&db)?;
|
|
8307
|
+
if !declared_indexes.is_empty() {
|
|
8308
|
+
println!(
|
|
8309
|
+
"feltdb: equality indexes maintained for {}",
|
|
8310
|
+
declared_indexes.join(", ")
|
|
8311
|
+
);
|
|
8312
|
+
}
|
|
8253
8313
|
let peer_token = match std::env::var(&config.peer_token_env) {
|
|
8254
8314
|
Ok(token) => Some(Arc::<str>::from(token)),
|
|
8255
8315
|
Err(_) if config.peers.is_empty() => None,
|
|
@@ -8287,6 +8347,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8287
8347
|
let causal_store = CausalStore::load(config.data.with_extension("causal-events.json"))?;
|
|
8288
8348
|
let provider_store = ProviderStore::load(config.data.with_extension("providers.json"))?;
|
|
8289
8349
|
let readiness_probe = config.data.with_extension("readiness");
|
|
8350
|
+
// Constructed before the state so the same handle can be stopped after
|
|
8351
|
+
// serving: the pipeline outlives the router by exactly one shutdown.
|
|
8352
|
+
let audit_handle = AuditLog::new(config.audit.clone());
|
|
8290
8353
|
let state = AppState {
|
|
8291
8354
|
started_at: Instant::now(),
|
|
8292
8355
|
ids: Arc::new(AtomicU64::new(db.sequence()?)),
|
|
@@ -8299,7 +8362,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8299
8362
|
config.data.with_extension("cluster.json"),
|
|
8300
8363
|
config.peers.clone(),
|
|
8301
8364
|
)?,
|
|
8302
|
-
audit:
|
|
8365
|
+
audit: audit_handle.clone(),
|
|
8303
8366
|
peer_client,
|
|
8304
8367
|
peer_token,
|
|
8305
8368
|
lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
|
|
@@ -9016,7 +9079,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9016
9079
|
axum::routing::delete(delete_certification_fixture),
|
|
9017
9080
|
)
|
|
9018
9081
|
.merge(protected)
|
|
9082
|
+
.merge(query_execution_diagnostics_router())
|
|
9019
9083
|
.layer(middleware::from_fn(protocol_version))
|
|
9084
|
+
.layer(middleware::from_fn(attribute_request))
|
|
9020
9085
|
.layer(middleware::from_fn_with_state(state.clone(), count_request))
|
|
9021
9086
|
.layer(RequestBodyLimitLayer::new(1024 * 1024))
|
|
9022
9087
|
.layer(TraceLayer::new_for_http())
|
|
@@ -9049,9 +9114,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9049
9114
|
"FeltDB server ready at http://{bound_address} (namespace: {})",
|
|
9050
9115
|
config.namespace
|
|
9051
9116
|
);
|
|
9052
|
-
axum::serve(listener, app)
|
|
9117
|
+
let serving = axum::serve(listener, app)
|
|
9053
9118
|
.with_graceful_shutdown(shutdown_signal())
|
|
9054
|
-
.await
|
|
9119
|
+
.await;
|
|
9120
|
+
// The audit writer owns the stream, so an orderly stop is what makes the
|
|
9121
|
+
// last durability group durable. Run it whether or not serving ended
|
|
9122
|
+
// cleanly: a server that failed still recorded events, and losing the tail
|
|
9123
|
+
// of the security log because of an unrelated bind error would be its own
|
|
9124
|
+
// defect. Dropping the last handle would do this too; doing it here means
|
|
9125
|
+
// it happens at a point in the process the operator can reason about.
|
|
9126
|
+
audit_handle.shutdown();
|
|
9127
|
+
serving?;
|
|
9055
9128
|
Ok(())
|
|
9056
9129
|
}
|
|
9057
9130
|
|
|
@@ -9081,6 +9154,12 @@ async fn authenticate(
|
|
|
9081
9154
|
mut request: Request<axum::body::Body>,
|
|
9082
9155
|
next: Next,
|
|
9083
9156
|
) -> Response {
|
|
9157
|
+
// Everything up to the handoff below is authorization: key lookup, session
|
|
9158
|
+
// resolution and the capability check. It is timed separately from the rest
|
|
9159
|
+
// of the request because "the request costs something outside state" is not
|
|
9160
|
+
// an answer to PR36's question — which part of the request does is.
|
|
9161
|
+
let authorization =
|
|
9162
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Authorization);
|
|
9084
9163
|
let principal = if state.auth_enabled {
|
|
9085
9164
|
let token = request
|
|
9086
9165
|
.headers()
|
|
@@ -9271,6 +9350,7 @@ async fn authenticate(
|
|
|
9271
9350
|
.key_id
|
|
9272
9351
|
.clone();
|
|
9273
9352
|
let target = request.uri().path().to_string();
|
|
9353
|
+
drop(authorization);
|
|
9274
9354
|
let response = next.run(request).await;
|
|
9275
9355
|
let status = response.status().as_u16();
|
|
9276
9356
|
audit(
|
|
@@ -9292,6 +9372,18 @@ fn audit(
|
|
|
9292
9372
|
outcome: &str,
|
|
9293
9373
|
status: u16,
|
|
9294
9374
|
) {
|
|
9375
|
+
// Every authenticated request records one of these. Since PR38 the request
|
|
9376
|
+
// waits only for ACCEPTANCE — the audit writer's append — and never for a
|
|
9377
|
+
// durability barrier; the barrier happens afterwards, in a bounded group,
|
|
9378
|
+
// on the writer's own threads.
|
|
9379
|
+
//
|
|
9380
|
+
// The error is still discarded, and that is still deliberate: PR37 proved
|
|
9381
|
+
// request success does not depend on audit durability, and PR38 does not
|
|
9382
|
+
// change that policy. What changed is that the failure is no longer
|
|
9383
|
+
// invisible — `AuditLog::record` has already counted it, timestamped it and
|
|
9384
|
+
// degraded the audit health state by the time this returns.
|
|
9385
|
+
let _span =
|
|
9386
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::AuditWrite);
|
|
9295
9387
|
if let Err(error) = state.audit.record(AuditEvent {
|
|
9296
9388
|
timestamp_ms: 0,
|
|
9297
9389
|
namespace: &state.namespace,
|
|
@@ -9318,6 +9410,21 @@ async fn protocol_version(request: Request<axum::body::Body>, next: Next) -> Res
|
|
|
9318
9410
|
next.run(request).await
|
|
9319
9411
|
}
|
|
9320
9412
|
|
|
9413
|
+
/// Time a whole request inside the server, from the middleware stack down.
|
|
9414
|
+
///
|
|
9415
|
+
/// This is the server's own share of a request. Subtracting it from the client's
|
|
9416
|
+
/// measured latency leaves transport, connection handling, and the JSON decode
|
|
9417
|
+
/// and encode that axum performs in the extractor and the response — none of
|
|
9418
|
+
/// which can be bracketed from inside a handler. PR36 reports that difference as
|
|
9419
|
+
/// a bounded residual rather than pretending to have decomposed it.
|
|
9420
|
+
///
|
|
9421
|
+
/// Disabled, this costs one relaxed atomic load per request.
|
|
9422
|
+
async fn attribute_request(request: Request<axum::body::Body>, next: Next) -> Response {
|
|
9423
|
+
let _span =
|
|
9424
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HttpHandler);
|
|
9425
|
+
next.run(request).await
|
|
9426
|
+
}
|
|
9427
|
+
|
|
9321
9428
|
async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>> {
|
|
9322
9429
|
let membership = match state.cluster.proposal().map(|value| value.phase) {
|
|
9323
9430
|
Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
|
|
@@ -9749,10 +9856,21 @@ async fn create_online_backup(
|
|
|
9749
9856
|
Ok(Json(manifest))
|
|
9750
9857
|
}
|
|
9751
9858
|
|
|
9752
|
-
|
|
9753
|
-
|
|
9754
|
-
|
|
9755
|
-
|
|
9859
|
+
/// Network counters, plus the audit subsystem's health.
|
|
9860
|
+
///
|
|
9861
|
+
/// The audit block is here rather than on a route of its own because PR37's
|
|
9862
|
+
/// finding was that losing security evidence was invisible, and a signal an
|
|
9863
|
+
/// operator has to know to go and look for is not much better than no signal.
|
|
9864
|
+
/// It rides with the metrics they already read.
|
|
9865
|
+
async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
|
|
9866
|
+
let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
|
|
9867
|
+
if let Some(fields) = body.as_object_mut() {
|
|
9868
|
+
fields.insert(
|
|
9869
|
+
"audit".to_string(),
|
|
9870
|
+
serde_json::to_value(state.audit.health()).unwrap_or(Value::Null),
|
|
9871
|
+
);
|
|
9872
|
+
}
|
|
9873
|
+
Json(body)
|
|
9756
9874
|
}
|
|
9757
9875
|
|
|
9758
9876
|
async fn sync_pull(
|
|
@@ -9760,11 +9878,25 @@ async fn sync_pull(
|
|
|
9760
9878
|
Json(request): Json<SyncPullRequest>,
|
|
9761
9879
|
) -> Result<Json<SyncPullResponse>, ApiError> {
|
|
9762
9880
|
authorize_sync_member(&state, &request.requester, request.membership_epoch)?;
|
|
9763
|
-
let
|
|
9881
|
+
let peer_position = request.versions;
|
|
9882
|
+
let authority_position = state.db.operation_versions()?;
|
|
9883
|
+
let retained_from = state.db.retained_operation_floors()?;
|
|
9884
|
+
let catchup = catchup_status(request.requester, peer_position.clone(), authority_position.clone(), retained_from);
|
|
9885
|
+
if catchup.recovery_required {
|
|
9886
|
+
return Err(ApiError::structured(
|
|
9887
|
+
StatusCode::CONFLICT,
|
|
9888
|
+
serde_json::to_value(&catchup).unwrap_or_else(|_| json!({
|
|
9889
|
+
"catchup_mode": "snapshot_rebootstrap_required",
|
|
9890
|
+
"recovery_required": true
|
|
9891
|
+
})),
|
|
9892
|
+
));
|
|
9893
|
+
}
|
|
9894
|
+
let operations = state.db.operations_since(&peer_position)?;
|
|
9764
9895
|
state.metrics.sync_sent(operations.len() as u64);
|
|
9765
9896
|
Ok(Json(SyncPullResponse {
|
|
9766
9897
|
operations,
|
|
9767
|
-
versions:
|
|
9898
|
+
versions: authority_position,
|
|
9899
|
+
catchup,
|
|
9768
9900
|
}))
|
|
9769
9901
|
}
|
|
9770
9902
|
|
|
@@ -11795,7 +11927,15 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
|
|
|
11795
11927
|
}
|
|
11796
11928
|
}
|
|
11797
11929
|
|
|
11798
|
-
|
|
11930
|
+
/// Apply one condition to the field value the record supplies for it.
|
|
11931
|
+
///
|
|
11932
|
+
/// Taking the field value rather than the record lets the scan path evaluate a
|
|
11933
|
+
/// condition against a borrowed record plus its authority-supplied `recordId`,
|
|
11934
|
+
/// without first cloning the record to insert that field.
|
|
11935
|
+
fn condition_matches_field(
|
|
11936
|
+
actual: Option<&Value>,
|
|
11937
|
+
condition: &BoundedQueryCondition,
|
|
11938
|
+
) -> Result<bool, ApiError> {
|
|
11799
11939
|
if condition.operators.len() != 1 || condition.field.trim().is_empty() {
|
|
11800
11940
|
return Err(bounded_query_error(
|
|
11801
11941
|
"INVALID_QUERY",
|
|
@@ -11807,7 +11947,6 @@ fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Resul
|
|
|
11807
11947
|
.iter()
|
|
11808
11948
|
.next()
|
|
11809
11949
|
.expect("validated operator");
|
|
11810
|
-
let actual = record.get(&condition.field);
|
|
11811
11950
|
if operator == "eq" {
|
|
11812
11951
|
return Ok(actual == Some(expected));
|
|
11813
11952
|
}
|
|
@@ -11832,11 +11971,166 @@ fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Resul
|
|
|
11832
11971
|
})
|
|
11833
11972
|
}
|
|
11834
11973
|
|
|
11974
|
+
/// The record identity the query surface exposes as `recordId`.
|
|
11975
|
+
fn bounded_query_record_id(key: &str) -> &str {
|
|
11976
|
+
key.split_once(':').map(|(_, id)| id).unwrap_or(key)
|
|
11977
|
+
}
|
|
11978
|
+
|
|
11979
|
+
/// Does a stored record satisfy every condition of a bounded query?
|
|
11980
|
+
///
|
|
11981
|
+
/// This is the same conjunction the materializing path applied, evaluated
|
|
11982
|
+
/// against a borrowed record. `recordId` is authority metadata rather than
|
|
11983
|
+
/// caller-controlled document data, so it shadows a document field of that name
|
|
11984
|
+
/// exactly where the materializing path inserted it: on object records only.
|
|
11985
|
+
fn bounded_query_matches(row: &feltdb::StoredRow, conditions: &[BoundedQueryCondition]) -> bool {
|
|
11986
|
+
let record_id = row
|
|
11987
|
+
.value
|
|
11988
|
+
.is_object()
|
|
11989
|
+
.then(|| Value::String(bounded_query_record_id(&row.key).to_string()));
|
|
11990
|
+
conditions.iter().all(|condition| {
|
|
11991
|
+
let actual = match record_id.as_ref() {
|
|
11992
|
+
Some(id) if condition.field == "recordId" => Some(id),
|
|
11993
|
+
_ => row.value.get(&condition.field),
|
|
11994
|
+
};
|
|
11995
|
+
condition_matches_field(actual, condition).unwrap_or(false)
|
|
11996
|
+
})
|
|
11997
|
+
}
|
|
11998
|
+
|
|
11999
|
+
/// Declare the equality indexes this authority maintains.
|
|
12000
|
+
///
|
|
12001
|
+
/// `FELTDB_EQUALITY_INDEXES=orders.status,orders.tenantId` — a comma-separated
|
|
12002
|
+
/// list of `collection.field`, split at the first dot so a field name may itself
|
|
12003
|
+
/// contain one. Declaration is explicit and static on purpose: PR35 adds an
|
|
12004
|
+
/// equality execution primitive, not a planner, so nothing here inspects a
|
|
12005
|
+
/// workload, collects statistics, or decides on its own what deserves an index.
|
|
12006
|
+
///
|
|
12007
|
+
/// Each declaration is populated from authoritative state as it is made, so the
|
|
12008
|
+
/// authority never serves a query against a half-built index.
|
|
12009
|
+
fn declare_configured_equality_indexes(db: &FeltDb) -> Result<Vec<String>, String> {
|
|
12010
|
+
let Ok(declaration) = std::env::var("FELTDB_EQUALITY_INDEXES") else {
|
|
12011
|
+
return Ok(Vec::new());
|
|
12012
|
+
};
|
|
12013
|
+
let mut declared = Vec::new();
|
|
12014
|
+
for entry in declaration.split(',').map(str::trim).filter(|entry| !entry.is_empty()) {
|
|
12015
|
+
let Some((collection, field)) = entry.split_once('.') else {
|
|
12016
|
+
return Err(format!(
|
|
12017
|
+
"FELTDB_EQUALITY_INDEXES entry {entry:?} must be written as collection.field"
|
|
12018
|
+
));
|
|
12019
|
+
};
|
|
12020
|
+
db.create_equality_index(collection, field)
|
|
12021
|
+
.map_err(|error| format!("cannot index {entry}: {error}"))?;
|
|
12022
|
+
declared.push(format!("{collection}.{field}"));
|
|
12023
|
+
}
|
|
12024
|
+
Ok(declared)
|
|
12025
|
+
}
|
|
12026
|
+
|
|
12027
|
+
/// Which execution a bounded query is allowed to take.
|
|
12028
|
+
///
|
|
12029
|
+
/// `Auto` is the only mode the product has: use the index when one applies,
|
|
12030
|
+
/// scan otherwise. `Scan` and `Index` exist so a test can run the *same* query
|
|
12031
|
+
/// down both paths and compare the externally visible result, which is the only
|
|
12032
|
+
/// way to demonstrate that an optimization changed nothing but cost. They are
|
|
12033
|
+
/// not a public API: the mode arrives on an undocumented request header that is
|
|
12034
|
+
/// read only when the process was started with `FELTDB_QUERY_DIAGNOSTICS=1`, so
|
|
12035
|
+
/// a production authority has exactly one execution mode and no way to be asked
|
|
12036
|
+
/// for another. The request body — the query contract itself — is untouched.
|
|
12037
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
12038
|
+
enum BoundedQueryExecution {
|
|
12039
|
+
Auto,
|
|
12040
|
+
Scan,
|
|
12041
|
+
Index,
|
|
12042
|
+
}
|
|
12043
|
+
|
|
12044
|
+
/// Test instrumentation is compiled in but inert unless the process opted in,
|
|
12045
|
+
/// evaluated once so a request cannot pay for the lookup.
|
|
12046
|
+
fn query_diagnostics_enabled() -> bool {
|
|
12047
|
+
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
|
12048
|
+
*ENABLED.get_or_init(|| std::env::var("FELTDB_QUERY_DIAGNOSTICS").as_deref() == Ok("1"))
|
|
12049
|
+
}
|
|
12050
|
+
|
|
12051
|
+
const QUERY_EXECUTION_HEADER: &str = "x-feltdb-query-execution";
|
|
12052
|
+
|
|
12053
|
+
fn bounded_query_execution(headers: &HeaderMap) -> Result<BoundedQueryExecution, ApiError> {
|
|
12054
|
+
if !query_diagnostics_enabled() {
|
|
12055
|
+
return Ok(BoundedQueryExecution::Auto);
|
|
12056
|
+
}
|
|
12057
|
+
match headers
|
|
12058
|
+
.get(QUERY_EXECUTION_HEADER)
|
|
12059
|
+
.and_then(|value| value.to_str().ok())
|
|
12060
|
+
{
|
|
12061
|
+
None | Some("auto") => Ok(BoundedQueryExecution::Auto),
|
|
12062
|
+
Some("scan") => Ok(BoundedQueryExecution::Scan),
|
|
12063
|
+
Some("index") => Ok(BoundedQueryExecution::Index),
|
|
12064
|
+
Some(other) => Err(bounded_query_error(
|
|
12065
|
+
"INVALID_QUERY",
|
|
12066
|
+
format!("unsupported query execution mode: {other}"),
|
|
12067
|
+
)),
|
|
12068
|
+
}
|
|
12069
|
+
}
|
|
12070
|
+
|
|
12071
|
+
/// The equality conditions of a query, as candidate-selection inputs.
|
|
12072
|
+
///
|
|
12073
|
+
/// Only `eq` contributes: every other operator is a comparison this index does
|
|
12074
|
+
/// not model, and PR35 adds no range index. `recordId` is excluded because on
|
|
12075
|
+
/// this surface it is authority metadata that shadows a document field of the
|
|
12076
|
+
/// same name on object-valued records only — an index over the document field
|
|
12077
|
+
/// would answer a different question from the one `bounded_query_matches` asks,
|
|
12078
|
+
/// so a `recordId` condition is never index eligible and its query falls back to
|
|
12079
|
+
/// the scan (or is served by candidates from a different, indexed condition).
|
|
12080
|
+
///
|
|
12081
|
+
/// Conditions the index cannot use are simply absent here. They are not dropped
|
|
12082
|
+
/// from the query: `bounded_query_matches` still evaluates every one of them
|
|
12083
|
+
/// against authoritative records.
|
|
12084
|
+
fn bounded_query_equalities(conditions: &[BoundedQueryCondition]) -> Vec<(&str, &Value)> {
|
|
12085
|
+
conditions
|
|
12086
|
+
.iter()
|
|
12087
|
+
.filter(|condition| condition.field != "recordId" && condition.operators.len() == 1)
|
|
12088
|
+
.filter_map(|condition| {
|
|
12089
|
+
let (operator, expected) = condition.operators.iter().next()?;
|
|
12090
|
+
(operator == "eq").then_some((condition.field.as_str(), expected))
|
|
12091
|
+
})
|
|
12092
|
+
.collect()
|
|
12093
|
+
}
|
|
12094
|
+
|
|
12095
|
+
/// Evaluate a bounded query's conjunction against authoritative records.
|
|
12096
|
+
///
|
|
12097
|
+
/// Two executions, one result. The indexed execution narrows *which* records the
|
|
12098
|
+
/// predicate sees; `bounded_query_matches` — unchanged, and the sole authority on
|
|
12099
|
+
/// what matches — decides the answer on either path. When no index applies the
|
|
12100
|
+
/// scan runs exactly as PR34 left it.
|
|
12101
|
+
fn bounded_query_records(
|
|
12102
|
+
state: &AppState,
|
|
12103
|
+
request: &BoundedQueryRequest,
|
|
12104
|
+
execution: BoundedQueryExecution,
|
|
12105
|
+
) -> Result<Vec<feltdb::StoredRow>, ApiError> {
|
|
12106
|
+
if execution != BoundedQueryExecution::Scan {
|
|
12107
|
+
let equalities = bounded_query_equalities(&request.conditions);
|
|
12108
|
+
let indexed = state.db.query_collection_by_equality(
|
|
12109
|
+
&request.collection,
|
|
12110
|
+
&equalities,
|
|
12111
|
+
|row| bounded_query_matches(row, &request.conditions),
|
|
12112
|
+
)?;
|
|
12113
|
+
if let Some(records) = indexed {
|
|
12114
|
+
return Ok(records);
|
|
12115
|
+
}
|
|
12116
|
+
if execution == BoundedQueryExecution::Index {
|
|
12117
|
+
// Only reachable through the test-only header. A forced-index run
|
|
12118
|
+
// that silently scanned would make an equivalence test prove nothing.
|
|
12119
|
+
return Err(bounded_query_error(
|
|
12120
|
+
"INDEX_UNAVAILABLE",
|
|
12121
|
+
"no equality index applies to this query",
|
|
12122
|
+
));
|
|
12123
|
+
}
|
|
12124
|
+
}
|
|
12125
|
+
Ok(state.db.query_collection(&request.collection, None, |row| {
|
|
12126
|
+
bounded_query_matches(row, &request.conditions)
|
|
12127
|
+
})?)
|
|
12128
|
+
}
|
|
12129
|
+
|
|
11835
12130
|
fn issue_bounded_cursor(
|
|
11836
12131
|
state: &AppState,
|
|
11837
12132
|
mut cursor: BoundedQueryCursor,
|
|
11838
|
-
) -> Result<String, ApiError> {
|
|
11839
|
-
let token = uuid::Uuid::new_v4().simple().to_string();
|
|
12133
|
+
) -> Result<String, ApiError> { let token = uuid::Uuid::new_v4().simple().to_string();
|
|
11840
12134
|
let now = unix_seconds_i64().max(0) as u64;
|
|
11841
12135
|
cursor.created_at = now;
|
|
11842
12136
|
let mut cursors = state.bounded_query_cursors.lock().map_err(|_| {
|
|
@@ -11859,8 +12153,12 @@ fn issue_bounded_cursor(
|
|
|
11859
12153
|
async fn execute_bounded_query(
|
|
11860
12154
|
State(state): State<AppState>,
|
|
11861
12155
|
Extension(principal): Extension<Principal>,
|
|
12156
|
+
headers: HeaderMap,
|
|
11862
12157
|
Json(request): Json<BoundedQueryRequest>,
|
|
11863
12158
|
) -> Result<Json<BoundedQueryPage>, ApiError> {
|
|
12159
|
+
let _handler =
|
|
12160
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12161
|
+
let execution = bounded_query_execution(&headers)?;
|
|
11864
12162
|
validate_segment(&request.collection)?;
|
|
11865
12163
|
if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
|
|
11866
12164
|
return Err(bounded_query_error(
|
|
@@ -11928,30 +12226,40 @@ async fn execute_bounded_query(
|
|
|
11928
12226
|
}
|
|
11929
12227
|
(cursor.records, cursor.position)
|
|
11930
12228
|
} else {
|
|
11931
|
-
|
|
11932
|
-
|
|
12229
|
+
// Bounded queries evaluate their conjunction against borrowed records
|
|
12230
|
+
// and clone only the matches, so a query never pays to materialize the
|
|
12231
|
+
// records it is about to discard. An equality index, where one applies,
|
|
12232
|
+
// narrows which records the conjunction is evaluated against; it does
|
|
12233
|
+
// not decide the answer and it does not order it. Ordering is part of
|
|
12234
|
+
// this surface's contract, so the matching set — not the collection, and
|
|
12235
|
+
// not an index bucket — is what gets sorted, and the limit is applied to
|
|
12236
|
+
// that ordered set. Nothing below this line knows which execution ran.
|
|
12237
|
+
let matches = bounded_query_records(&state, &request, execution)?;
|
|
12238
|
+
// Result materialization happens outside the state lock, so it is a cost
|
|
12239
|
+
// this request pays alone rather than one it imposes on every other
|
|
12240
|
+
// operation. PR36 measures it separately for exactly that reason.
|
|
12241
|
+
let materialization = feltdb::workload_diagnostics::span(
|
|
12242
|
+
feltdb::workload_diagnostics::Phase::ResultMaterialization,
|
|
12243
|
+
);
|
|
12244
|
+
let mut records = Vec::with_capacity(matches.len());
|
|
12245
|
+
for row in matches {
|
|
11933
12246
|
let mut value = row.value;
|
|
11934
12247
|
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
12248
|
// `recordId` is authority metadata for this query surface, not
|
|
11941
12249
|
// caller-controlled document data. It is the final total-order
|
|
11942
12250
|
// 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);
|
|
12251
|
+
object.insert(
|
|
12252
|
+
"recordId".into(),
|
|
12253
|
+
Value::String(bounded_query_record_id(&row.key).to_string()),
|
|
12254
|
+
);
|
|
11951
12255
|
}
|
|
12256
|
+
records.push(value);
|
|
11952
12257
|
}
|
|
12258
|
+
drop(materialization);
|
|
11953
12259
|
// The authority always adds its immutable record identity as the final
|
|
11954
12260
|
// tie-breaker, so equal user sort values still form a total order.
|
|
12261
|
+
let ordering =
|
|
12262
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Ordering);
|
|
11955
12263
|
records.sort_by(|left, right| {
|
|
11956
12264
|
for order in &request.order_by {
|
|
11957
12265
|
let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
|
|
@@ -11965,6 +12273,7 @@ async fn execute_bounded_query(
|
|
|
11965
12273
|
}
|
|
11966
12274
|
query_scalar_cmp(left.get("recordId"), right.get("recordId"))
|
|
11967
12275
|
});
|
|
12276
|
+
drop(ordering);
|
|
11968
12277
|
(Arc::new(records), 0)
|
|
11969
12278
|
};
|
|
11970
12279
|
let end = position.saturating_add(request.limit).min(records.len());
|
|
@@ -11991,6 +12300,131 @@ async fn execute_bounded_query(
|
|
|
11991
12300
|
}))
|
|
11992
12301
|
}
|
|
11993
12302
|
|
|
12303
|
+
/// Collection-read counters, mounted only when the process opts in.
|
|
12304
|
+
///
|
|
12305
|
+
/// This is test instrumentation for the PR34 no-materialization proof, not part
|
|
12306
|
+
/// of the query contract, so a server only carries the route when it is started
|
|
12307
|
+
/// with `FELTDB_QUERY_DIAGNOSTICS=1`. It returns counts, never record data.
|
|
12308
|
+
fn query_execution_diagnostics_router() -> Router<AppState> {
|
|
12309
|
+
let mut router = Router::new();
|
|
12310
|
+
if std::env::var("FELTDB_QUERY_DIAGNOSTICS").as_deref() == Ok("1") {
|
|
12311
|
+
router = router.route(
|
|
12312
|
+
"/internal/query-execution-diagnostics",
|
|
12313
|
+
get(query_execution_diagnostics),
|
|
12314
|
+
);
|
|
12315
|
+
}
|
|
12316
|
+
// Phase attribution is a separate switch from the execution counters,
|
|
12317
|
+
// because it is a separate cost: the counters are a handful of atomic adds
|
|
12318
|
+
// per query, while attribution reads the clock at every instrumented
|
|
12319
|
+
// boundary. Keeping them apart lets a run take execution counts without
|
|
12320
|
+
// paying for timing, which is how the headline PR36 figures are measured.
|
|
12321
|
+
if std::env::var("FELTDB_WORKLOAD_DIAGNOSTICS").as_deref() == Ok("1") {
|
|
12322
|
+
router = router
|
|
12323
|
+
.route(
|
|
12324
|
+
"/internal/workload-diagnostics",
|
|
12325
|
+
get(workload_phase_diagnostics),
|
|
12326
|
+
)
|
|
12327
|
+
.route(
|
|
12328
|
+
"/internal/workload-diagnostics/reset",
|
|
12329
|
+
axum::routing::post(reset_workload_phase_diagnostics),
|
|
12330
|
+
);
|
|
12331
|
+
}
|
|
12332
|
+
router
|
|
12333
|
+
}
|
|
12334
|
+
|
|
12335
|
+
/// Phase attribution: where elapsed time went, as counts and nanoseconds.
|
|
12336
|
+
///
|
|
12337
|
+
/// Test instrumentation, mounted only under `FELTDB_WORKLOAD_DIAGNOSTICS=1`.
|
|
12338
|
+
/// It reports durations and call counts and nothing else — no record, no field,
|
|
12339
|
+
/// no value, no id — and no product surface reads it.
|
|
12340
|
+
///
|
|
12341
|
+
/// `parent` is part of the payload because the phases nest: a caller that summed
|
|
12342
|
+
/// every row would double-count the work reported inside `state_lock_hold` and
|
|
12343
|
+
/// `indexed_query`. The nesting is declared here so an analysis can compute
|
|
12344
|
+
/// exclusive time instead of guessing at it.
|
|
12345
|
+
async fn workload_phase_diagnostics() -> Json<Value> {
|
|
12346
|
+
let phases: Vec<Value> = feltdb::workload_diagnostics::counters()
|
|
12347
|
+
.into_iter()
|
|
12348
|
+
.map(|counter| {
|
|
12349
|
+
json!({
|
|
12350
|
+
"phase": counter.phase.name(),
|
|
12351
|
+
"parent": counter.phase.parent().map(|parent| parent.name()),
|
|
12352
|
+
"calls": counter.calls,
|
|
12353
|
+
"nanos": counter.nanos,
|
|
12354
|
+
"meanNanos": counter.mean_nanos(),
|
|
12355
|
+
})
|
|
12356
|
+
})
|
|
12357
|
+
.collect();
|
|
12358
|
+
Json(json!({
|
|
12359
|
+
"enabled": feltdb::workload_diagnostics::enabled(),
|
|
12360
|
+
"phases": phases,
|
|
12361
|
+
}))
|
|
12362
|
+
}
|
|
12363
|
+
|
|
12364
|
+
/// Zero the phase counters, so a measured window starts from a clean base.
|
|
12365
|
+
async fn reset_workload_phase_diagnostics() -> Json<Value> {
|
|
12366
|
+
feltdb::workload_diagnostics::reset();
|
|
12367
|
+
Json(json!({ "reset": true }))
|
|
12368
|
+
}
|
|
12369
|
+
|
|
12370
|
+
/// Ask the diagnostics route to also verify the index against records.
|
|
12371
|
+
#[derive(Deserialize)]
|
|
12372
|
+
struct QueryDiagnosticsScope {
|
|
12373
|
+
#[serde(default)]
|
|
12374
|
+
verify: Option<String>,
|
|
12375
|
+
}
|
|
12376
|
+
|
|
12377
|
+
async fn query_execution_diagnostics(
|
|
12378
|
+
State(state): State<AppState>,
|
|
12379
|
+
Query(scope): Query<QueryDiagnosticsScope>,
|
|
12380
|
+
) -> Json<Value> {
|
|
12381
|
+
let counters = feltdb::query_execution_diagnostics::counters();
|
|
12382
|
+
// Verifying rebuilds the whole index from records to compare against the
|
|
12383
|
+
// live one, so it is linear in collection size and is asked for explicitly.
|
|
12384
|
+
// A counter read that silently paid for a rebuild would make every
|
|
12385
|
+
// measurement that brackets a query with a counter read measure the rebuild.
|
|
12386
|
+
let consistency = match scope.verify.as_deref() {
|
|
12387
|
+
Some("1") => state
|
|
12388
|
+
.db
|
|
12389
|
+
.verify_equality_index()
|
|
12390
|
+
.map(|outcome| Value::Bool(outcome.is_ok()))
|
|
12391
|
+
.unwrap_or(Value::Null),
|
|
12392
|
+
_ => Value::Null,
|
|
12393
|
+
};
|
|
12394
|
+
Json(json!({
|
|
12395
|
+
"fullCollectionMaterializations": counters.full_collection_materializations,
|
|
12396
|
+
"fullCollectionRecordsCloned": counters.full_collection_records_cloned,
|
|
12397
|
+
"collectionScans": counters.collection_scans,
|
|
12398
|
+
"scanRecordsVisited": counters.scan_records_visited,
|
|
12399
|
+
"scanRecordsMaterialized": counters.scan_records_materialized,
|
|
12400
|
+
"queriesTotal": counters.queries_total,
|
|
12401
|
+
"queriesScan": counters.queries_scan,
|
|
12402
|
+
"queriesIndexed": counters.queries_indexed,
|
|
12403
|
+
"indexHits": counters.index_hits,
|
|
12404
|
+
"indexMisses": counters.index_misses,
|
|
12405
|
+
"indexCandidatesExamined": counters.index_candidates_examined,
|
|
12406
|
+
"recordsPredicateEvaluated": counters.records_predicate_evaluated,
|
|
12407
|
+
"equalityIndexes": state
|
|
12408
|
+
.db
|
|
12409
|
+
.equality_indexes()
|
|
12410
|
+
.map(|indexes| {
|
|
12411
|
+
indexes
|
|
12412
|
+
.into_iter()
|
|
12413
|
+
.map(|(collection, field)| format!("{collection}.{field}"))
|
|
12414
|
+
.collect::<Vec<_>>()
|
|
12415
|
+
})
|
|
12416
|
+
.unwrap_or_default(),
|
|
12417
|
+
"equalityIndexStats": state.db.equality_index_stats().map(|stats| json!({
|
|
12418
|
+
"indexedFields": stats.indexed_fields,
|
|
12419
|
+
"valueBuckets": stats.value_buckets,
|
|
12420
|
+
"entries": stats.entries,
|
|
12421
|
+
})).unwrap_or(Value::Null),
|
|
12422
|
+
// Null unless `?verify=1` asked for the check; a null here means "not
|
|
12423
|
+
// checked", never "checked and inconsistent".
|
|
12424
|
+
"equalityIndexConsistent": consistency,
|
|
12425
|
+
}))
|
|
12426
|
+
}
|
|
12427
|
+
|
|
11994
12428
|
async fn list_records(
|
|
11995
12429
|
State(state): State<AppState>,
|
|
11996
12430
|
Path(collection): Path<String>,
|
|
@@ -12015,6 +12449,8 @@ async fn get_record(
|
|
|
12015
12449
|
State(state): State<AppState>,
|
|
12016
12450
|
Path((collection, id)): Path<(String, String)>,
|
|
12017
12451
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
12452
|
+
let _handler =
|
|
12453
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12018
12454
|
if collection == PROPOSAL_COLLECTION
|
|
12019
12455
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12020
12456
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -12123,6 +12559,8 @@ async fn commit_transaction(
|
|
|
12123
12559
|
State(state): State<AppState>,
|
|
12124
12560
|
Json(request): Json<AtomicTxRequest>,
|
|
12125
12561
|
) -> Result<(StatusCode, Json<AtomicTxResponse>), ApiError> {
|
|
12562
|
+
let _handler =
|
|
12563
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12126
12564
|
if request.transaction_id.trim().is_empty() {
|
|
12127
12565
|
return Err(ApiError(
|
|
12128
12566
|
StatusCode::BAD_REQUEST,
|
|
@@ -12272,6 +12710,8 @@ async fn create_record(
|
|
|
12272
12710
|
Path(collection): Path<String>,
|
|
12273
12711
|
Json(mut record): Json<CreateRecord>,
|
|
12274
12712
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
12713
|
+
let _handler =
|
|
12714
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12275
12715
|
if collection == PROPOSAL_COLLECTION
|
|
12276
12716
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12277
12717
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -12316,6 +12756,8 @@ async fn update_record(
|
|
|
12316
12756
|
Path((collection, id)): Path<(String, String)>,
|
|
12317
12757
|
Json(changes): Json<Map<String, Value>>,
|
|
12318
12758
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
12759
|
+
let _handler =
|
|
12760
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12319
12761
|
if collection == PROPOSAL_COLLECTION
|
|
12320
12762
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12321
12763
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -12353,6 +12795,8 @@ async fn compare_and_set_record(
|
|
|
12353
12795
|
Path((collection, id)): Path<(String, String)>,
|
|
12354
12796
|
Json(request): Json<CasRecordRequest>,
|
|
12355
12797
|
) -> Result<Response, ApiError> {
|
|
12798
|
+
let _handler =
|
|
12799
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12356
12800
|
let key = record_key(&collection, &id)?;
|
|
12357
12801
|
let result = state.db.compare_and_set_json(
|
|
12358
12802
|
&key,
|
|
@@ -12442,6 +12886,8 @@ async fn delete_record(
|
|
|
12442
12886
|
State(state): State<AppState>,
|
|
12443
12887
|
Path((collection, id)): Path<(String, String)>,
|
|
12444
12888
|
) -> Result<StatusCode, ApiError> {
|
|
12889
|
+
let _handler =
|
|
12890
|
+
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
12445
12891
|
if collection == PROPOSAL_COLLECTION
|
|
12446
12892
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12447
12893
|
|| collection == CONVERGENCE_COLLECTION
|