@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.
Files changed (59) hide show
  1. package/dist/cli/commands.js +4 -1
  2. package/dist/cli/provisioning-neutrality.js +79 -0
  3. package/dist/collection.d.ts +43 -1
  4. package/dist/collection.d.ts.map +1 -1
  5. package/dist/collection.js +192 -22
  6. package/dist/create/create.js +25 -21
  7. package/dist/create/managed-account.js +11 -0
  8. package/dist/create/package-versions.js +1 -1
  9. package/dist/create/server-source/crates/feltdb/src/equality_index.rs +595 -0
  10. package/dist/create/server-source/crates/feltdb/src/lib.rs +547 -115
  11. package/dist/create/server-source/crates/feltdb/src/phase1c3_acceptance.rs +11 -2
  12. package/dist/create/server-source/crates/feltdb/src/query_execution_diagnostics.rs +126 -0
  13. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +292 -2
  14. package/dist/create/server-source/crates/feltdb/src/sync.rs +12 -0
  15. package/dist/create/server-source/crates/feltdb/src/workload_diagnostics.rs +443 -0
  16. package/dist/create/server-source/crates/feltdb/tests/pr34_query_collection.rs +233 -0
  17. package/dist/create/server-source/crates/feltdb/tests/pr35_equality_index.rs +892 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/audit.rs +1137 -29
  19. package/dist/create/server-source/crates/feltdb-server/src/main.rs +474 -28
  20. package/dist/db.d.ts +33 -34
  21. package/dist/db.d.ts.map +1 -1
  22. package/dist/db.js +74 -20
  23. package/dist/deployment.d.ts +30 -0
  24. package/dist/deployment.d.ts.map +1 -0
  25. package/dist/deployment.js +130 -0
  26. package/dist/embedded-transaction.d.ts +22 -4
  27. package/dist/embedded-transaction.d.ts.map +1 -1
  28. package/dist/embedded-transaction.js +51 -5
  29. package/dist/feltdb.d.ts +14 -2
  30. package/dist/feltdb.d.ts.map +1 -1
  31. package/dist/file-db.js +1 -1
  32. package/dist/http-client.d.ts +14 -0
  33. package/dist/http-client.d.ts.map +1 -1
  34. package/dist/http-client.js +23 -5
  35. package/dist/http-db.d.ts +119 -1
  36. package/dist/http-db.d.ts.map +1 -1
  37. package/dist/http-db.js +346 -31
  38. package/dist/index-core.d.ts +2 -0
  39. package/dist/index-core.d.ts.map +1 -1
  40. package/dist/index-core.js +2 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +9 -0
  43. package/dist/indexeddb-db.d.ts.map +1 -1
  44. package/dist/indexeddb-db.js +35 -21
  45. package/dist/managed-recovery.d.ts +192 -0
  46. package/dist/managed-recovery.d.ts.map +1 -0
  47. package/dist/managed-recovery.js +242 -0
  48. package/dist/memory-db.js +1 -1
  49. package/dist/studio-app/assets/{feltdb_wasm-DB8cX151.js → feltdb_wasm-CVQWgXO-.js} +1 -1
  50. package/dist/studio-app/assets/feltdb_wasm_bg-CNVpvaZV.wasm +0 -0
  51. package/dist/studio-app/assets/index-DwgNAIIX.js +29 -0
  52. package/dist/studio-app/index.html +1 -1
  53. package/dist/transaction.d.ts +30 -0
  54. package/dist/transaction.d.ts.map +1 -1
  55. package/dist/transaction.js +41 -0
  56. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  57. package/package.json +1 -1
  58. package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
  59. 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: AuditLog::new(config.audit.clone()),
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
- async fn network_metrics(
9753
- State(state): State<AppState>,
9754
- ) -> Json<feltdb_server::metrics::MetricsSnapshot> {
9755
- Json(state.metrics.snapshot())
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 operations = state.db.operations_since(&request.versions)?;
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: state.db.operation_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
- fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Result<bool, ApiError> {
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
- let mut records = Vec::new();
11932
- for row in state.db.list_collection(&request.collection)? {
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("recordId".into(), Value::String(id.to_string()));
11944
- }
11945
- if request
11946
- .conditions
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