@feltdb/core 0.11.9 → 0.12.1

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 (56) hide show
  1. package/dist/agent-state.d.ts +489 -0
  2. package/dist/agent-state.d.ts.map +1 -0
  3. package/dist/agent-state.js +993 -0
  4. package/dist/build-identity.json +19 -0
  5. package/dist/cli/index.js +0 -0
  6. package/dist/create/build-identity.json +19 -0
  7. package/dist/create/package-versions.js +1 -1
  8. package/dist/create/server-source/crates/feltdb/src/lib.rs +11 -0
  9. package/dist/create/server-source/crates/feltdb/src/query_performance.rs +4 -1
  10. package/dist/create/server-source/crates/feltdb/src/state_model.rs +854 -155
  11. package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +31 -13
  12. package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +918 -0
  13. package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +15 -2
  14. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +4 -0
  15. package/dist/create/server-source/crates/feltdb-server/benches/query_shape_observer.rs +136 -0
  16. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +3 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/main.rs +74 -5
  19. package/dist/create/server-source/crates/feltdb-server/src/query_shape.rs +1137 -0
  20. package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.expected.json +300 -0
  21. package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.json +25 -0
  22. package/dist/create/server-source/crates/feltdb-server/tests/query_shape_trace.rs +128 -0
  23. package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
  24. package/dist/index-core.d.ts +1 -0
  25. package/dist/index-core.d.ts.map +1 -1
  26. package/dist/index-core.js +1 -0
  27. package/dist/studio/app.d.ts.map +1 -1
  28. package/dist/studio/components/GlobalSearch.d.ts.map +1 -1
  29. package/dist/studio/components/index.js +1 -1
  30. package/dist/studio/{components-BHTKlUgT.js → components-Bivm2lX6.js} +2 -1
  31. package/dist/studio/control-plane/Agents.d.ts.map +1 -1
  32. package/dist/studio/control-plane/Data.d.ts.map +1 -1
  33. package/dist/studio/control-plane/Operations.d.ts.map +1 -1
  34. package/dist/studio/control-plane/Overview.d.ts.map +1 -1
  35. package/dist/studio/control-plane/Schema.d.ts.map +1 -1
  36. package/dist/studio/control-plane/State.d.ts +11 -0
  37. package/dist/studio/control-plane/State.d.ts.map +1 -1
  38. package/dist/studio/control-plane/index.d.ts +1 -1
  39. package/dist/studio/control-plane/index.d.ts.map +1 -1
  40. package/dist/studio/control-plane/index.js +2 -2
  41. package/dist/studio/control-plane/primitives.d.ts +14 -2
  42. package/dist/studio/control-plane/primitives.d.ts.map +1 -1
  43. package/dist/studio/control-plane-CLm69XwX.js +4121 -0
  44. package/dist/studio/index.js +266 -235
  45. package/dist/studio/studio.css +1 -1
  46. package/dist/studio-app/assets/{feltdb_wasm-DGGqm9GK.js → feltdb_wasm-DACcD-zs.js} +1 -1
  47. package/dist/studio-app/assets/feltdb_wasm_bg-BejThWID.wasm +0 -0
  48. package/dist/studio-app/assets/index-CiC8VY-a.css +1 -0
  49. package/dist/studio-app/assets/index-CoZaxnlm.js +30 -0
  50. package/dist/studio-app/index.html +2 -2
  51. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  52. package/package.json +2 -2
  53. package/dist/studio/control-plane-DP8ljTDO.js +0 -3207
  54. package/dist/studio-app/assets/feltdb_wasm_bg-BYJo87WY.wasm +0 -0
  55. package/dist/studio-app/assets/index-BrsF-kWL.css +0 -1
  56. package/dist/studio-app/assets/index-qLeZcPW_.js +0 -30
@@ -51,6 +51,11 @@ struct Workload {
51
51
  struct Measurement {
52
52
  elapsed: Duration,
53
53
  log_bytes: u64,
54
+ /// `log_bytes` without this database's instance id. Every record carries
55
+ /// that id, and it is derived from the log's path and printed in unpadded
56
+ /// hex, so two logs at different paths can differ in size by a few bytes
57
+ /// per record while holding identical databases.
58
+ identity_free_log_bytes: u64,
54
59
  revisions: usize,
55
60
  }
56
61
 
@@ -93,9 +98,14 @@ fn run(directory: &Path, workload: Workload, mode: DurabilityMode) -> Measuremen
93
98
  .map(|resource| store.history_of(&format!("bench:{resource}")).len())
94
99
  .sum();
95
100
 
101
+ let log = std::fs::read_to_string(&path).unwrap();
102
+ let instance_id = db.instance_id().unwrap();
103
+ let identity_bytes = log.matches(instance_id.as_str()).count() * instance_id.len();
104
+
96
105
  Measurement {
97
106
  elapsed,
98
- log_bytes: std::fs::metadata(&path).unwrap().len(),
107
+ log_bytes: log.len() as u64,
108
+ identity_free_log_bytes: (log.len() - identity_bytes) as u64,
99
109
  revisions,
100
110
  }
101
111
  }
@@ -217,7 +227,10 @@ fn a_barrier_per_write_costs_more_than_none() {
217
227
 
218
228
  // Durability is the only thing that changed. The database is identical.
219
229
  assert_eq!(flushed.revisions, synced.revisions);
220
- assert_eq!(flushed.log_bytes, synced.log_bytes);
230
+ assert_eq!(
231
+ flushed.identity_free_log_bytes,
232
+ synced.identity_free_log_bytes
233
+ );
221
234
  }
222
235
 
223
236
  /// Changing the mode changes the contract and nothing else: the same writes
@@ -23,3 +23,7 @@ tower-http = { version = "0.6", features = ["cors", "limit", "trace"] }
23
23
  tracing = "0.1"
24
24
  tracing-subscriber = { version = "0.3", features = ["env-filter"] }
25
25
  uuid = { version = "1.6", features = ["v4"] }
26
+
27
+ [[bench]]
28
+ name = "query_shape_observer"
29
+ harness = false
@@ -0,0 +1,136 @@
1
+ //! Direct per-operation cost of the query-shape observer.
2
+ //!
3
+ //! This isolates what the observer adds to one `POST /query`: building the
4
+ //! structural input, normalizing, and recording one outcome. It is deliberately
5
+ //! not an end-to-end number — an HTTP round trip costs orders of magnitude more
6
+ //! and would hide it — and it says nothing about contention with the database's
7
+ //! state lock, which the observer never takes. The end-to-end comparison lives
8
+ //! in `tools/benchmarks/query-shape-observer.mjs`.
9
+ //!
10
+ //! cargo bench --locked -p feltdb-server --bench query_shape_observer
11
+ //!
12
+ //! Output is one JSON object. Each figure is the median of `ROUNDS` timed
13
+ //! rounds, in nanoseconds per operation, on this machine and build profile
14
+ //! only.
15
+
16
+ use feltdb_server::query_shape::{Access, ObserverConfig, QueryShapeObserver, ShapeInput};
17
+ use std::hint::black_box;
18
+ use std::sync::Arc;
19
+ use std::time::Instant;
20
+
21
+ const ROUNDS: usize = 7;
22
+ const OPERATIONS: usize = 200_000;
23
+
24
+ fn median(mut values: Vec<f64>) -> f64 {
25
+ values.sort_by(|a, b| a.partial_cmp(b).unwrap());
26
+ values[values.len() / 2]
27
+ }
28
+
29
+ /// One simulated query: the same `Vec` construction the handler performs.
30
+ fn observe(observer: &QueryShapeObserver, collection: &str, field: &str) {
31
+ let predicates = vec![(field, "eq"), ("kind", "eq")];
32
+ let order_by = vec![("n", "asc")];
33
+ let observation = observer.begin(&ShapeInput {
34
+ collection,
35
+ predicates: &predicates,
36
+ order_by: &order_by,
37
+ limit: 50,
38
+ has_semantic_clause: false,
39
+ });
40
+ observation.finish_executed(Access::Indexed, black_box(40), black_box(40));
41
+ }
42
+
43
+ fn per_op(mut run: impl FnMut(usize)) -> f64 {
44
+ median(
45
+ (0..ROUNDS)
46
+ .map(|_| {
47
+ let started = Instant::now();
48
+ for index in 0..OPERATIONS {
49
+ run(index);
50
+ }
51
+ started.elapsed().as_nanos() as f64 / OPERATIONS as f64
52
+ })
53
+ .collect(),
54
+ )
55
+ }
56
+
57
+ fn main() {
58
+ let observer = QueryShapeObserver::new(ObserverConfig::with_capacity(128));
59
+
60
+ // The cost of building the input with no observer at all: the floor.
61
+ let floor = per_op(|_| {
62
+ let predicates = vec![("tenant", "eq"), ("kind", "eq")];
63
+ let order_by = vec![("n", "asc")];
64
+ black_box((&predicates, &order_by));
65
+ });
66
+
67
+ let one_shape = per_op(|_| observe(&observer, "orders", "tenant"));
68
+
69
+ let fixed: Vec<String> = (0..64).map(|i| format!("c{i}")).collect();
70
+ let sixty_four_shapes = per_op(|index| observe(&observer, &fixed[index % 64], "tenant"));
71
+
72
+ // Every operation admits a new shape into a full table: the eviction scan.
73
+ let churn_names: Vec<String> = (0..4096).map(|i| format!("churn{i}")).collect();
74
+ let churn = QueryShapeObserver::new(ObserverConfig::with_capacity(128));
75
+ let eviction_churn = per_op(|index| observe(&churn, &churn_names[index % 4096], "tenant"));
76
+ let churn_max = QueryShapeObserver::new(ObserverConfig::with_capacity(1024));
77
+ let eviction_churn_max_capacity =
78
+ per_op(|index| observe(&churn_max, &churn_names[index % 4096], "tenant"));
79
+
80
+ let reserved = per_op(|_| {
81
+ let order_by = vec![("n", "asc")];
82
+ black_box(observer.begin(&ShapeInput {
83
+ collection: "_feltdb.Proposal",
84
+ predicates: &[],
85
+ order_by: &order_by,
86
+ limit: 50,
87
+ has_semantic_clause: false,
88
+ }));
89
+ });
90
+
91
+ // Contention: four threads recording into one observer.
92
+ let shared = Arc::new(QueryShapeObserver::new(ObserverConfig::with_capacity(128)));
93
+ let contended = median(
94
+ (0..ROUNDS)
95
+ .map(|_| {
96
+ let started = Instant::now();
97
+ let handles: Vec<_> = (0..4)
98
+ .map(|thread| {
99
+ let shared = shared.clone();
100
+ std::thread::spawn(move || {
101
+ let name = format!("t{thread}");
102
+ for _ in 0..OPERATIONS / 4 {
103
+ observe(&shared, &name, "tenant");
104
+ }
105
+ })
106
+ })
107
+ .collect();
108
+ for handle in handles {
109
+ handle.join().unwrap();
110
+ }
111
+ started.elapsed().as_nanos() as f64 / OPERATIONS as f64
112
+ })
113
+ .collect(),
114
+ );
115
+
116
+ let snapshot = churn_max.snapshot();
117
+ println!(
118
+ "{}",
119
+ serde_json::json!({
120
+ "benchmark": "query_shape_observer",
121
+ "profile": if cfg!(debug_assertions) { "debug" } else { "release" },
122
+ "operations_per_round": OPERATIONS,
123
+ "rounds": ROUNDS,
124
+ "unit": "ns/op (median of rounds)",
125
+ "input_construction_floor_no_observer": floor,
126
+ "begin_finish_one_shape": one_shape,
127
+ "begin_finish_64_shapes": sixty_four_shapes,
128
+ "begin_finish_every_op_evicts_capacity_128": eviction_churn,
129
+ "begin_finish_every_op_evicts_capacity_1024": eviction_churn_max_capacity,
130
+ "reserved_collection_skipped": reserved,
131
+ "four_threads_one_observer_wall_per_op": contended,
132
+ "capacity_1024_memory_bound_bytes": snapshot.memory_bound_bytes,
133
+ "capacity_1024_memory_estimate_bytes_at_full": snapshot.memory_estimate_bytes,
134
+ })
135
+ );
136
+ }
@@ -86,6 +86,9 @@ pub struct AppState {
86
86
  pub state_triggers: Arc<std::sync::Mutex<StateTriggerStore>>,
87
87
  pub mesh: Arc<std::sync::Mutex<WorkerMeshStore>>,
88
88
  pub readiness_probe: Arc<PathBuf>,
89
+ /// Opt-in, value-free query-shape observer for `POST /query`, or `None`
90
+ /// (the default). Diagnostic only: nothing reads it to answer a request.
91
+ pub query_shapes: Option<Arc<crate::query_shape::QueryShapeObserver>>,
89
92
  pub bounded_query_cursors: Arc<std::sync::Mutex<HashMap<String, BoundedQueryCursor>>>,
90
93
  /// The deployment's semantic runtime binding for bounded semantic query
91
94
  /// composition, or `None` when the deployment configures no runtime. A
@@ -27,6 +27,7 @@ pub mod metrics;
27
27
  pub mod portable_bundle;
28
28
  pub mod principals;
29
29
  pub mod providers;
30
+ pub mod query_shape;
30
31
  pub mod releases;
31
32
  pub mod request_telemetry;
32
33
  pub mod sessions;
@@ -96,6 +96,7 @@ use feltdb_server::{
96
96
  portable_bundle::{BundleArtifact, BundleStore, ExportOptions, ImportMode, PortableBundle},
97
97
  principals::{PrincipalKind, PrincipalStatus},
98
98
  providers::{ProviderManifest, ProviderStatus, ProviderStore},
99
+ query_shape::{Access, ObserverConfig, QueryShapeObserver, ShapeInput},
99
100
  releases::{
100
101
  provider_for, AuthorizedDeploymentContext, DeploymentPlan, DeploymentProfile,
101
102
  DeploymentProvider, DeploymentProviderKind, DomainBinding, ReleaseManifest, ReleaseStore,
@@ -8766,6 +8767,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8766
8767
  // Constructed before the state so the same handle can be stopped after
8767
8768
  // serving: the pipeline outlives the router by exactly one shutdown.
8768
8769
  let audit_handle = AuditLog::new(config.audit.clone());
8770
+ // Opt-in and off by default. A malformed setting is a startup error rather
8771
+ // than a silent default, so a mistyped exclusion list cannot yield an
8772
+ // observer that records what the operator meant to exclude.
8773
+ let query_shapes = ObserverConfig::from_environment()?.map(|config| {
8774
+ println!(
8775
+ "feltdb: query-shape observer enabled (capacity {}, value-free, in-memory)",
8776
+ config.capacity
8777
+ );
8778
+ Arc::new(QueryShapeObserver::new(config))
8779
+ });
8769
8780
  let state = AppState {
8770
8781
  started_at: Instant::now(),
8771
8782
  admission: Arc::new(tokio::sync::Semaphore::new(config.max_in_flight)),
@@ -8829,6 +8840,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8829
8840
  state_triggers: Arc::new(std::sync::Mutex::new(state_trigger_store)),
8830
8841
  mesh: Arc::new(std::sync::Mutex::new(mesh_store)),
8831
8842
  readiness_probe: Arc::new(readiness_probe),
8843
+ query_shapes,
8832
8844
  bounded_query_cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
8833
8845
  semantic_runtime: configured_semantic_runtime()?,
8834
8846
  };
@@ -13520,7 +13532,7 @@ fn bounded_query_records(
13520
13532
  state: &AppState,
13521
13533
  request: &BoundedQueryRequest,
13522
13534
  execution: BoundedQueryExecution,
13523
- ) -> Result<Vec<feltdb::StoredRow>, ApiError> {
13535
+ ) -> Result<(Vec<feltdb::StoredRow>, Access), ApiError> {
13524
13536
  if execution != BoundedQueryExecution::Scan {
13525
13537
  let equalities = bounded_query_equalities(&request.conditions);
13526
13538
  let indexed =
@@ -13530,7 +13542,7 @@ fn bounded_query_records(
13530
13542
  bounded_query_matches(row, &request.conditions)
13531
13543
  })?;
13532
13544
  if let Some(records) = indexed {
13533
- return Ok(records);
13545
+ return Ok((records, Access::Indexed));
13534
13546
  }
13535
13547
  if execution == BoundedQueryExecution::Index {
13536
13548
  // Only reachable through the test-only header. A forced-index run
@@ -13541,11 +13553,12 @@ fn bounded_query_records(
13541
13553
  ));
13542
13554
  }
13543
13555
  }
13544
- Ok(state
13556
+ let scanned = state
13545
13557
  .db
13546
13558
  .query_collection(&request.collection, None, |row| {
13547
13559
  bounded_query_matches(row, &request.conditions)
13548
- })?)
13560
+ })?;
13561
+ Ok((scanned, Access::Scan))
13549
13562
  }
13550
13563
 
13551
13564
  fn issue_bounded_cursor(
@@ -13628,6 +13641,37 @@ async fn execute_bounded_query(
13628
13641
  feltdb::validate_semantic_clause(clause).map_err(semantic_query_error)?;
13629
13642
  }
13630
13643
  let hash = bounded_query_hash(&request)?;
13644
+ // Diagnostic only, and only when opted in. The observation sees structure
13645
+ // (names, operators, directions), never a value, and any failure inside it
13646
+ // is its own: nothing below reads it to decide a result. Dropped without
13647
+ // being finished — an early `?` return, a cancelled request — it records an
13648
+ // error outcome for the shape.
13649
+ let observation = state.query_shapes.as_deref().map(|observer| {
13650
+ let predicates: Vec<(&str, &str)> = request
13651
+ .conditions
13652
+ .iter()
13653
+ .filter_map(|condition| {
13654
+ condition
13655
+ .operators
13656
+ .keys()
13657
+ .next()
13658
+ .map(|operator| (condition.field.as_str(), operator.as_str()))
13659
+ })
13660
+ .collect();
13661
+ let order_by: Vec<(&str, &str)> = request
13662
+ .order_by
13663
+ .iter()
13664
+ .map(|order| (order.field.as_str(), order.direction.as_str()))
13665
+ .collect();
13666
+ observer.begin(&ShapeInput {
13667
+ collection: &request.collection,
13668
+ predicates: &predicates,
13669
+ order_by: &order_by,
13670
+ limit: request.limit,
13671
+ has_semantic_clause: request.semantic.is_some(),
13672
+ })
13673
+ });
13674
+ let mut executed: Option<(Access, usize)> = None;
13631
13675
  let (records, position) = if let Some(token) = &request.cursor {
13632
13676
  let cursor = state
13633
13677
  .bounded_query_cursors
@@ -13665,7 +13709,8 @@ async fn execute_bounded_query(
13665
13709
  // this surface's contract, so the matching set — not the collection, and
13666
13710
  // not an index bucket — is what gets sorted, and the limit is applied to
13667
13711
  // that ordered set. Nothing below this line knows which execution ran.
13668
- let matches = bounded_query_records(&state, &request, execution)?;
13712
+ let (matches, access) = bounded_query_records(&state, &request, execution)?;
13713
+ executed = Some((access, matches.len()));
13669
13714
  // Result materialization happens outside the state lock, so it is a cost
13670
13715
  // this request pays alone rather than one it imposes on every other
13671
13716
  // operation. PR36 measures it separately for exactly that reason.
@@ -13724,6 +13769,12 @@ async fn execute_bounded_query(
13724
13769
  } else {
13725
13770
  None
13726
13771
  };
13772
+ if let Some(observation) = observation {
13773
+ match executed {
13774
+ Some((access, matched)) => observation.finish_executed(access, matched, page.len()),
13775
+ None => observation.finish_continuation(page.len()),
13776
+ }
13777
+ }
13727
13778
  // The semantic stage transforms exactly this page. It runs after the
13728
13779
  // deterministic cursor is issued because continuation is by deterministic
13729
13780
  // position: the page may return fewer records than `limit` and still not
@@ -13814,6 +13865,15 @@ fn query_execution_diagnostics_router() -> Router<AppState> {
13814
13865
  get(query_execution_diagnostics),
13815
13866
  );
13816
13867
  }
13868
+ // Query-shape observations name collections and fields, so they are a
13869
+ // separate switch from the counters and exist only when the observer was
13870
+ // opted into. Counts and structure only; never a value.
13871
+ if ObserverConfig::from_environment().ok().flatten().is_some() {
13872
+ router = router.route(
13873
+ "/internal/query-shape-observations",
13874
+ get(query_shape_observations),
13875
+ );
13876
+ }
13817
13877
  // Phase attribution is a separate switch from the execution counters,
13818
13878
  // because it is a separate cost: the counters are a handful of atomic adds
13819
13879
  // per query, while attribution reads the clock at every instrumented
@@ -13833,6 +13893,15 @@ fn query_execution_diagnostics_router() -> Router<AppState> {
13833
13893
  router
13834
13894
  }
13835
13895
 
13896
+ /// The observer's retained shapes and counters. Diagnostic evidence about
13897
+ /// workload structure, not proof that any change would help.
13898
+ async fn query_shape_observations(State(state): State<AppState>) -> Json<Value> {
13899
+ match state.query_shapes.as_deref() {
13900
+ Some(observer) => Json(json!({ "enabled": true, "observations": observer.snapshot() })),
13901
+ None => Json(json!({ "enabled": false })),
13902
+ }
13903
+ }
13904
+
13836
13905
  /// Phase attribution: where elapsed time went, as counts and nanoseconds.
13837
13906
  ///
13838
13907
  /// Test instrumentation, mounted only under `FELTDB_WORKLOAD_DIAGNOSTICS=1`.