@feltdb/core 0.12.0 → 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.
- package/dist/agent-state.d.ts +489 -0
- package/dist/agent-state.d.ts.map +1 -0
- package/dist/agent-state.js +993 -0
- package/dist/build-identity.json +19 -0
- package/dist/create/build-identity.json +19 -0
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +11 -0
- package/dist/create/server-source/crates/feltdb/src/query_performance.rs +4 -1
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +854 -155
- package/dist/create/server-source/crates/feltdb/tests/generalized_version_control_substrate_completion.rs +31 -13
- package/dist/create/server-source/crates/feltdb/tests/state_ref_authority.rs +918 -0
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +15 -2
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +4 -0
- package/dist/create/server-source/crates/feltdb-server/benches/query_shape_observer.rs +136 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +3 -0
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +1 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +74 -5
- package/dist/create/server-source/crates/feltdb-server/src/query_shape.rs +1137 -0
- package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.expected.json +300 -0
- package/dist/create/server-source/crates/feltdb-server/tests/fixtures/query-shape-trace.json +25 -0
- package/dist/create/server-source/crates/feltdb-server/tests/query_shape_trace.rs +128 -0
- package/dist/index-core.d.ts +1 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +1 -0
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/GlobalSearch.d.ts.map +1 -1
- package/dist/studio/components/index.js +1 -1
- package/dist/studio/{components-BHTKlUgT.js → components-Bivm2lX6.js} +2 -1
- package/dist/studio/control-plane/Agents.d.ts.map +1 -1
- package/dist/studio/control-plane/Data.d.ts.map +1 -1
- package/dist/studio/control-plane/Operations.d.ts.map +1 -1
- package/dist/studio/control-plane/Overview.d.ts.map +1 -1
- package/dist/studio/control-plane/Schema.d.ts.map +1 -1
- package/dist/studio/control-plane/State.d.ts +11 -0
- package/dist/studio/control-plane/State.d.ts.map +1 -1
- package/dist/studio/control-plane/index.d.ts +1 -1
- package/dist/studio/control-plane/index.d.ts.map +1 -1
- package/dist/studio/control-plane/index.js +2 -2
- package/dist/studio/control-plane/primitives.d.ts +14 -2
- package/dist/studio/control-plane/primitives.d.ts.map +1 -1
- package/dist/studio/control-plane-CLm69XwX.js +4121 -0
- package/dist/studio/index.js +266 -235
- package/dist/studio/studio.css +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-DYfWAV0E.js → feltdb_wasm-DACcD-zs.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BejThWID.wasm +0 -0
- package/dist/studio-app/assets/index-CiC8VY-a.css +1 -0
- package/dist/studio-app/assets/index-CoZaxnlm.js +30 -0
- package/dist/studio-app/index.html +2 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +2 -2
- package/dist/studio/control-plane-DP8ljTDO.js +0 -3207
- package/dist/studio-app/assets/feltdb_wasm_bg-DHiOUhoQ.wasm +0 -0
- package/dist/studio-app/assets/index-6f6KVx5R.js +0 -30
- package/dist/studio-app/assets/index-BrsF-kWL.css +0 -1
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
//! A bounded, opt-in, value-free observer of bounded-query *shapes*.
|
|
2
|
+
//!
|
|
3
|
+
//! This is diagnostic evidence about how a workload is structured. It is never
|
|
4
|
+
//! authoritative: nothing in the query path reads it, no query result, error or
|
|
5
|
+
//! ordering depends on it, and a failure inside it degrades observation, not the
|
|
6
|
+
//! application operation. See `docs/architecture/query-shape-observer.md`.
|
|
7
|
+
//!
|
|
8
|
+
//! # What a shape is
|
|
9
|
+
//!
|
|
10
|
+
//! A shape is the structure of a `POST /query` request with every value removed:
|
|
11
|
+
//! the collection, the multiset of `(field, operator)` predicates, the ordered
|
|
12
|
+
//! `(field, direction)` sort, and a coarse limit class. The input type of this
|
|
13
|
+
//! module ([`ShapeInput`]) has no place to put a predicate value, a cursor, a
|
|
14
|
+
//! principal or a tenant, so a value cannot be recorded by construction rather
|
|
15
|
+
//! than by discipline.
|
|
16
|
+
//!
|
|
17
|
+
//! # Bounds
|
|
18
|
+
//!
|
|
19
|
+
//! * Per operation, outside the lock: normalization is O(P log P + O) with
|
|
20
|
+
//! `P <= MAX_PREDICATES` and `O <= MAX_ORDER_FIELDS`; anything larger is
|
|
21
|
+
//! classified [`Unsupported`], not truncated.
|
|
22
|
+
//! * Per operation, inside the lock: one hash lookup and a handful of integer
|
|
23
|
+
//! additions. The one exception is admitting a *new* shape into a full table,
|
|
24
|
+
//! which scans at most `capacity <= MAX_CAPACITY` entries to find a victim.
|
|
25
|
+
//! * Memory: at most [`ObserverConfig::memory_bound_bytes`], fixed at
|
|
26
|
+
//! construction. Nothing grows with request count.
|
|
27
|
+
//!
|
|
28
|
+
//! The mutex is taken only after the application work has finished, for the
|
|
29
|
+
//! duration of those additions. It is never held across, or inside, any part of
|
|
30
|
+
//! query execution, and the state lock the database owns is untouched.
|
|
31
|
+
|
|
32
|
+
use serde::Serialize;
|
|
33
|
+
use serde_json::json;
|
|
34
|
+
use sha2::{Digest, Sha256};
|
|
35
|
+
use std::collections::{BTreeSet, HashMap};
|
|
36
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
37
|
+
use std::sync::Mutex;
|
|
38
|
+
use std::time::Instant;
|
|
39
|
+
|
|
40
|
+
/// Version of the normalized representation. Bumped whenever [`Shape::canonical`]
|
|
41
|
+
/// would produce a different string for the same request.
|
|
42
|
+
pub const SHAPE_VERSION: u32 = 1;
|
|
43
|
+
pub const DEFAULT_CAPACITY: usize = 128;
|
|
44
|
+
pub const MAX_CAPACITY: usize = 1024;
|
|
45
|
+
pub const MAX_PREDICATES: usize = 16;
|
|
46
|
+
pub const MAX_ORDER_FIELDS: usize = 4;
|
|
47
|
+
/// Longest collection or field name that is retained. Longer is unsupported.
|
|
48
|
+
pub const MAX_IDENTIFIER_BYTES: usize = 128;
|
|
49
|
+
/// Log2-microsecond latency buckets; the last bucket is open-ended.
|
|
50
|
+
pub const LATENCY_BUCKETS: usize = 24;
|
|
51
|
+
/// Per-slot cost of the hash table beyond the key and value themselves.
|
|
52
|
+
const HASH_ENTRY_OVERHEAD: usize = 64;
|
|
53
|
+
|
|
54
|
+
pub const ENABLE_VARIABLE: &str = "FELTDB_QUERY_SHAPE_OBSERVER";
|
|
55
|
+
pub const CAPACITY_VARIABLE: &str = "FELTDB_QUERY_SHAPE_CAPACITY";
|
|
56
|
+
pub const EXCLUDE_COLLECTIONS_VARIABLE: &str = "FELTDB_QUERY_SHAPE_EXCLUDE_COLLECTIONS";
|
|
57
|
+
pub const EXCLUDE_FIELDS_VARIABLE: &str = "FELTDB_QUERY_SHAPE_EXCLUDE_FIELDS";
|
|
58
|
+
|
|
59
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
|
60
|
+
#[serde(rename_all = "lowercase")]
|
|
61
|
+
pub enum Operator {
|
|
62
|
+
Eq,
|
|
63
|
+
Neq,
|
|
64
|
+
Lt,
|
|
65
|
+
Lte,
|
|
66
|
+
Gt,
|
|
67
|
+
Gte,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl Operator {
|
|
71
|
+
fn parse(value: &str) -> Option<Self> {
|
|
72
|
+
Some(match value {
|
|
73
|
+
"eq" => Self::Eq,
|
|
74
|
+
"neq" => Self::Neq,
|
|
75
|
+
"lt" => Self::Lt,
|
|
76
|
+
"lte" => Self::Lte,
|
|
77
|
+
"gt" => Self::Gt,
|
|
78
|
+
"gte" => Self::Gte,
|
|
79
|
+
_ => return None,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
|
85
|
+
#[serde(rename_all = "lowercase")]
|
|
86
|
+
pub enum Direction {
|
|
87
|
+
Asc,
|
|
88
|
+
Desc,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl Direction {
|
|
92
|
+
fn parse(value: &str) -> Option<Self> {
|
|
93
|
+
match value {
|
|
94
|
+
"asc" => Some(Self::Asc),
|
|
95
|
+
"desc" => Some(Self::Desc),
|
|
96
|
+
_ => None,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// The request limit, reduced to the class that matters for grouping.
|
|
102
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
|
103
|
+
pub enum LimitClass {
|
|
104
|
+
#[serde(rename = "1")]
|
|
105
|
+
One,
|
|
106
|
+
#[serde(rename = "2-10")]
|
|
107
|
+
UpTo10,
|
|
108
|
+
#[serde(rename = "11-100")]
|
|
109
|
+
UpTo100,
|
|
110
|
+
#[serde(rename = "101-1000")]
|
|
111
|
+
UpTo1000,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
impl LimitClass {
|
|
115
|
+
fn of(limit: usize) -> Option<Self> {
|
|
116
|
+
match limit {
|
|
117
|
+
1 => Some(Self::One),
|
|
118
|
+
2..=10 => Some(Self::UpTo10),
|
|
119
|
+
11..=100 => Some(Self::UpTo100),
|
|
120
|
+
101..=1000 => Some(Self::UpTo1000),
|
|
121
|
+
_ => None,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Why a query is not retained as a shape. Closed set; no free text.
|
|
127
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
|
128
|
+
#[serde(rename_all = "snake_case")]
|
|
129
|
+
pub enum Unsupported {
|
|
130
|
+
/// The request carries a semantic clause; its structure is not modelled.
|
|
131
|
+
SemanticClause,
|
|
132
|
+
UnknownOperator,
|
|
133
|
+
UnknownDirection,
|
|
134
|
+
LimitOutOfRange,
|
|
135
|
+
TooManyPredicates,
|
|
136
|
+
TooManyOrderFields,
|
|
137
|
+
IdentifierTooLong,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
impl Unsupported {
|
|
141
|
+
const ALL: [Unsupported; 7] = [
|
|
142
|
+
Self::SemanticClause,
|
|
143
|
+
Self::UnknownOperator,
|
|
144
|
+
Self::UnknownDirection,
|
|
145
|
+
Self::LimitOutOfRange,
|
|
146
|
+
Self::TooManyPredicates,
|
|
147
|
+
Self::TooManyOrderFields,
|
|
148
|
+
Self::IdentifierTooLong,
|
|
149
|
+
];
|
|
150
|
+
fn index(self) -> usize {
|
|
151
|
+
Self::ALL
|
|
152
|
+
.iter()
|
|
153
|
+
.position(|value| *value == self)
|
|
154
|
+
.unwrap_or(0)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Why a query was deliberately not observed. Closed set; no names.
|
|
159
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
160
|
+
pub enum Skipped {
|
|
161
|
+
/// An internal FeltDB collection. Never application workload.
|
|
162
|
+
Reserved,
|
|
163
|
+
/// A collection or field the operator excluded.
|
|
164
|
+
Excluded,
|
|
165
|
+
Unsupported(Unsupported),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// The only information the observer accepts about a query.
|
|
169
|
+
///
|
|
170
|
+
/// There is deliberately no field for a value, a cursor, a principal or a
|
|
171
|
+
/// tenant. Predicates and sort keys are borrowed names and operator/direction
|
|
172
|
+
/// words exactly as the request spelled them.
|
|
173
|
+
pub struct ShapeInput<'a> {
|
|
174
|
+
pub collection: &'a str,
|
|
175
|
+
/// `(field, operator)` per ANDed condition.
|
|
176
|
+
pub predicates: &'a [(&'a str, &'a str)],
|
|
177
|
+
/// `(field, direction)` in request order.
|
|
178
|
+
pub order_by: &'a [(&'a str, &'a str)],
|
|
179
|
+
pub limit: usize,
|
|
180
|
+
pub has_semantic_clause: bool,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
|
184
|
+
pub struct Shape {
|
|
185
|
+
pub version: u32,
|
|
186
|
+
pub collection: String,
|
|
187
|
+
/// Sorted: a conjunction is order-independent, so equivalent requests must
|
|
188
|
+
/// normalize identically. Duplicates are kept; `a=1 AND a=2` is not `a=1`.
|
|
189
|
+
pub predicates: Vec<(String, Operator)>,
|
|
190
|
+
/// Request order: sort order is semantically relevant.
|
|
191
|
+
pub order_by: Vec<(String, Direction)>,
|
|
192
|
+
pub limit: LimitClass,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
impl Shape {
|
|
196
|
+
/// The unambiguous, deterministic string that defines shape identity.
|
|
197
|
+
/// JSON escaping makes names containing separators unable to collide.
|
|
198
|
+
pub fn canonical(&self) -> String {
|
|
199
|
+
json!([
|
|
200
|
+
self.version,
|
|
201
|
+
self.collection,
|
|
202
|
+
self.predicates
|
|
203
|
+
.iter()
|
|
204
|
+
.map(|(field, operator)| json!([field, operator]))
|
|
205
|
+
.collect::<Vec<_>>(),
|
|
206
|
+
self.order_by
|
|
207
|
+
.iter()
|
|
208
|
+
.map(|(field, direction)| json!([field, direction]))
|
|
209
|
+
.collect::<Vec<_>>(),
|
|
210
|
+
self.limit,
|
|
211
|
+
])
|
|
212
|
+
.to_string()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// Bytes this shape occupies, counted from its contents. An estimate of
|
|
216
|
+
/// retained memory, not an allocator measurement.
|
|
217
|
+
fn approx_bytes(&self) -> usize {
|
|
218
|
+
let names = self.collection.len()
|
|
219
|
+
+ self
|
|
220
|
+
.predicates
|
|
221
|
+
.iter()
|
|
222
|
+
.map(|(field, _)| field.len() + 48)
|
|
223
|
+
.sum::<usize>()
|
|
224
|
+
+ self
|
|
225
|
+
.order_by
|
|
226
|
+
.iter()
|
|
227
|
+
.map(|(field, _)| field.len() + 48)
|
|
228
|
+
.sum::<usize>();
|
|
229
|
+
std::mem::size_of::<Self>() + names
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// A short stable identifier derived from [`Shape::canonical`].
|
|
233
|
+
pub fn id(&self) -> String {
|
|
234
|
+
let digest = Sha256::digest(self.canonical().as_bytes());
|
|
235
|
+
digest[..8]
|
|
236
|
+
.iter()
|
|
237
|
+
.map(|byte| format!("{byte:02x}"))
|
|
238
|
+
.collect()
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
243
|
+
pub struct ObserverConfig {
|
|
244
|
+
pub capacity: usize,
|
|
245
|
+
pub exclude_collections: BTreeSet<String>,
|
|
246
|
+
/// `(collection, field)`; `None` collection applies to every collection.
|
|
247
|
+
pub exclude_fields: BTreeSet<(Option<String>, String)>,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
impl ObserverConfig {
|
|
251
|
+
pub fn with_capacity(capacity: usize) -> Self {
|
|
252
|
+
Self {
|
|
253
|
+
capacity: capacity.clamp(1, MAX_CAPACITY),
|
|
254
|
+
..Self::default()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// Resolve configuration. `Ok(None)` is the default: observation disabled.
|
|
259
|
+
///
|
|
260
|
+
/// A malformed value is an error rather than a silent default. An operator
|
|
261
|
+
/// who mistyped an exclusion list must not get an observer that quietly
|
|
262
|
+
/// ignores it.
|
|
263
|
+
pub fn from_environment() -> Result<Option<Self>, String> {
|
|
264
|
+
Self::from_values(
|
|
265
|
+
std::env::var(ENABLE_VARIABLE).ok().as_deref(),
|
|
266
|
+
std::env::var(CAPACITY_VARIABLE).ok().as_deref(),
|
|
267
|
+
std::env::var(EXCLUDE_COLLECTIONS_VARIABLE).ok().as_deref(),
|
|
268
|
+
std::env::var(EXCLUDE_FIELDS_VARIABLE).ok().as_deref(),
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
pub fn from_values(
|
|
273
|
+
enable: Option<&str>,
|
|
274
|
+
capacity: Option<&str>,
|
|
275
|
+
exclude_collections: Option<&str>,
|
|
276
|
+
exclude_fields: Option<&str>,
|
|
277
|
+
) -> Result<Option<Self>, String> {
|
|
278
|
+
if enable != Some("1") {
|
|
279
|
+
return Ok(None);
|
|
280
|
+
}
|
|
281
|
+
let capacity = match capacity.map(str::trim).filter(|value| !value.is_empty()) {
|
|
282
|
+
None => DEFAULT_CAPACITY,
|
|
283
|
+
Some(value) => match value.parse::<usize>() {
|
|
284
|
+
Ok(parsed) if (1..=MAX_CAPACITY).contains(&parsed) => parsed,
|
|
285
|
+
_ => {
|
|
286
|
+
return Err(format!(
|
|
287
|
+
"{CAPACITY_VARIABLE} must be an integer from 1 to {MAX_CAPACITY}"
|
|
288
|
+
))
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
let mut config = Self::with_capacity(capacity);
|
|
293
|
+
for entry in list(exclude_collections) {
|
|
294
|
+
config.exclude_collections.insert(entry.to_string());
|
|
295
|
+
}
|
|
296
|
+
for entry in list(exclude_fields) {
|
|
297
|
+
// `collection.field` or `*.field`, split at the first dot.
|
|
298
|
+
let Some((collection, field)) = entry.split_once('.') else {
|
|
299
|
+
return Err(format!(
|
|
300
|
+
"{EXCLUDE_FIELDS_VARIABLE} entry {entry:?} must be written as collection.field or *.field"
|
|
301
|
+
));
|
|
302
|
+
};
|
|
303
|
+
if collection.is_empty() || field.is_empty() {
|
|
304
|
+
return Err(format!(
|
|
305
|
+
"{EXCLUDE_FIELDS_VARIABLE} entry {entry:?} must name a collection and a field"
|
|
306
|
+
));
|
|
307
|
+
}
|
|
308
|
+
let collection = (collection != "*").then(|| collection.to_string());
|
|
309
|
+
config
|
|
310
|
+
.exclude_fields
|
|
311
|
+
.insert((collection, field.to_string()));
|
|
312
|
+
}
|
|
313
|
+
Ok(Some(config))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/// Upper bound on the bytes the retained table can occupy.
|
|
317
|
+
///
|
|
318
|
+
/// An entry holds its shape and the canonical string that keys it. Both are
|
|
319
|
+
/// bounded by the identifier, predicate and sort limits.
|
|
320
|
+
pub fn memory_bound_bytes(&self) -> usize {
|
|
321
|
+
// Each name is at most MAX_IDENTIFIER_BYTES of heap plus a 24-byte String
|
|
322
|
+
// header, an operator or direction and allocator slack (48 covers both).
|
|
323
|
+
let names = (1 + MAX_PREDICATES + MAX_ORDER_FIELDS) * (MAX_IDENTIFIER_BYTES + 48);
|
|
324
|
+
let per_entry = std::mem::size_of::<Shape>()
|
|
325
|
+
+ names
|
|
326
|
+
+ std::mem::size_of::<Entry>()
|
|
327
|
+
+ HASH_ENTRY_OVERHEAD;
|
|
328
|
+
self.capacity * per_entry
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
fn list(value: Option<&str>) -> impl Iterator<Item = &str> {
|
|
333
|
+
value
|
|
334
|
+
.unwrap_or("")
|
|
335
|
+
.split(',')
|
|
336
|
+
.map(str::trim)
|
|
337
|
+
.filter(|entry| !entry.is_empty())
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/// Collections that belong to FeltDB, not to the application.
|
|
341
|
+
///
|
|
342
|
+
/// Anything with a leading underscore is reserved (`_feltdb.*`, `_retention`),
|
|
343
|
+
/// and `state` is the internal revision capability. They are excluded by
|
|
344
|
+
/// default and not configurable: observing them would attribute FeltDB's own
|
|
345
|
+
/// activity to the application workload.
|
|
346
|
+
pub fn is_reserved_collection(collection: &str) -> bool {
|
|
347
|
+
collection.starts_with('_') || collection == "state"
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/// Pure normalization with no policy beyond structural support.
|
|
351
|
+
pub fn normalize(input: &ShapeInput<'_>) -> Result<Shape, Unsupported> {
|
|
352
|
+
if input.has_semantic_clause {
|
|
353
|
+
return Err(Unsupported::SemanticClause);
|
|
354
|
+
}
|
|
355
|
+
if input.predicates.len() > MAX_PREDICATES {
|
|
356
|
+
return Err(Unsupported::TooManyPredicates);
|
|
357
|
+
}
|
|
358
|
+
if input.order_by.len() > MAX_ORDER_FIELDS {
|
|
359
|
+
return Err(Unsupported::TooManyOrderFields);
|
|
360
|
+
}
|
|
361
|
+
let too_long = |name: &str| name.len() > MAX_IDENTIFIER_BYTES;
|
|
362
|
+
if too_long(input.collection)
|
|
363
|
+
|| input.predicates.iter().any(|(field, _)| too_long(field))
|
|
364
|
+
|| input.order_by.iter().any(|(field, _)| too_long(field))
|
|
365
|
+
{
|
|
366
|
+
return Err(Unsupported::IdentifierTooLong);
|
|
367
|
+
}
|
|
368
|
+
let limit = LimitClass::of(input.limit).ok_or(Unsupported::LimitOutOfRange)?;
|
|
369
|
+
let mut predicates = Vec::with_capacity(input.predicates.len());
|
|
370
|
+
for (field, operator) in input.predicates {
|
|
371
|
+
let operator = Operator::parse(operator).ok_or(Unsupported::UnknownOperator)?;
|
|
372
|
+
predicates.push((field.to_string(), operator));
|
|
373
|
+
}
|
|
374
|
+
predicates.sort();
|
|
375
|
+
let mut order_by = Vec::with_capacity(input.order_by.len());
|
|
376
|
+
for (field, direction) in input.order_by {
|
|
377
|
+
let direction = Direction::parse(direction).ok_or(Unsupported::UnknownDirection)?;
|
|
378
|
+
order_by.push((field.to_string(), direction));
|
|
379
|
+
}
|
|
380
|
+
Ok(Shape {
|
|
381
|
+
version: SHAPE_VERSION,
|
|
382
|
+
collection: input.collection.to_string(),
|
|
383
|
+
predicates,
|
|
384
|
+
order_by,
|
|
385
|
+
limit,
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Which access path the real execution took. Only what the path reports.
|
|
390
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
391
|
+
pub enum Access {
|
|
392
|
+
Indexed,
|
|
393
|
+
Scan,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
|
397
|
+
pub struct LatencyStats {
|
|
398
|
+
pub count: u64,
|
|
399
|
+
pub sum_nanos: u64,
|
|
400
|
+
pub max_nanos: u64,
|
|
401
|
+
/// Bucket `i` counts latencies in `[2^i, 2^(i+1))` microseconds (bucket 0
|
|
402
|
+
/// also holds sub-microsecond); the last bucket is open-ended.
|
|
403
|
+
pub log2_micros: [u64; LATENCY_BUCKETS],
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
impl LatencyStats {
|
|
407
|
+
fn record(&mut self, nanos: u64) {
|
|
408
|
+
self.count += 1;
|
|
409
|
+
self.sum_nanos = self.sum_nanos.saturating_add(nanos);
|
|
410
|
+
self.max_nanos = self.max_nanos.max(nanos);
|
|
411
|
+
let micros = (nanos / 1_000).max(1);
|
|
412
|
+
let bucket = (63 - micros.leading_zeros() as usize).min(LATENCY_BUCKETS - 1);
|
|
413
|
+
self.log2_micros[bucket] += 1;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
|
418
|
+
pub struct ShapeStats {
|
|
419
|
+
/// Fresh executions of the query (page one): `ok + errors`.
|
|
420
|
+
pub executions: u64,
|
|
421
|
+
pub ok: u64,
|
|
422
|
+
/// Failures after the request was validated and observation began.
|
|
423
|
+
pub errors: u64,
|
|
424
|
+
/// Cursor continuations. These replay a stored result; they execute nothing.
|
|
425
|
+
pub continuations: u64,
|
|
426
|
+
pub indexed: u64,
|
|
427
|
+
pub scans: u64,
|
|
428
|
+
/// Records matching the conjunction before the limit, over successful
|
|
429
|
+
/// executions that reported it.
|
|
430
|
+
pub matched_sum: u64,
|
|
431
|
+
pub matched_max: u64,
|
|
432
|
+
/// Records returned in the page, over successful executions.
|
|
433
|
+
pub returned_sum: u64,
|
|
434
|
+
/// Handler time from validated request to response, fresh executions only.
|
|
435
|
+
pub latency: LatencyStats,
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[derive(Debug, Clone)]
|
|
439
|
+
struct Entry {
|
|
440
|
+
first_seq: u64,
|
|
441
|
+
stats: ShapeStats,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
impl Entry {
|
|
445
|
+
fn weight(&self) -> u64 {
|
|
446
|
+
self.stats.executions + self.stats.continuations
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#[derive(Default)]
|
|
451
|
+
struct Table {
|
|
452
|
+
/// Keyed by the shape itself, so the hit path allocates no canonical string.
|
|
453
|
+
entries: HashMap<Shape, Entry>,
|
|
454
|
+
next_seq: u64,
|
|
455
|
+
evictions: u64,
|
|
456
|
+
evicted_executions: u64,
|
|
457
|
+
reserved: u64,
|
|
458
|
+
excluded: u64,
|
|
459
|
+
unsupported: [u64; Unsupported::ALL.len()],
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/// What the observer can report. Counts and names of structure only.
|
|
463
|
+
#[derive(Debug, Clone, Serialize)]
|
|
464
|
+
pub struct ObserverSnapshot {
|
|
465
|
+
pub shape_version: u32,
|
|
466
|
+
pub capacity: usize,
|
|
467
|
+
pub memory_bound_bytes: usize,
|
|
468
|
+
pub memory_estimate_bytes: usize,
|
|
469
|
+
pub retained_shapes: usize,
|
|
470
|
+
pub evictions: u64,
|
|
471
|
+
/// Executions that had been counted for shapes since evicted.
|
|
472
|
+
pub evicted_executions: u64,
|
|
473
|
+
pub reserved: u64,
|
|
474
|
+
pub excluded: u64,
|
|
475
|
+
pub unsupported: Vec<(Unsupported, u64)>,
|
|
476
|
+
/// Observer-internal failures (a poisoned lock). Never application errors.
|
|
477
|
+
pub observer_failures: u64,
|
|
478
|
+
/// Ordered by executions descending, then canonical shape ascending.
|
|
479
|
+
pub shapes: Vec<ShapeReport>,
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#[derive(Debug, Clone, Serialize)]
|
|
483
|
+
pub struct ShapeReport {
|
|
484
|
+
pub id: String,
|
|
485
|
+
pub shape: Shape,
|
|
486
|
+
pub first_seen_sequence: u64,
|
|
487
|
+
pub stats: ShapeStats,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
impl ObserverSnapshot {
|
|
491
|
+
/// The snapshot with every wall-clock measurement zeroed, so two runs of the
|
|
492
|
+
/// same trace compare equal. Everything else is a pure function of the
|
|
493
|
+
/// ordered trace and the configuration.
|
|
494
|
+
pub fn without_timing(mut self) -> Self {
|
|
495
|
+
for report in &mut self.shapes {
|
|
496
|
+
report.stats.latency = LatencyStats::default();
|
|
497
|
+
}
|
|
498
|
+
self
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
pub struct QueryShapeObserver {
|
|
503
|
+
config: ObserverConfig,
|
|
504
|
+
table: Mutex<Table>,
|
|
505
|
+
failures: AtomicU64,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
impl QueryShapeObserver {
|
|
509
|
+
pub fn new(mut config: ObserverConfig) -> Self {
|
|
510
|
+
config.capacity = config.capacity.clamp(1, MAX_CAPACITY);
|
|
511
|
+
Self {
|
|
512
|
+
config,
|
|
513
|
+
table: Mutex::new(Table::default()),
|
|
514
|
+
failures: AtomicU64::new(0),
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
pub fn config(&self) -> &ObserverConfig {
|
|
519
|
+
&self.config
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
fn is_excluded(&self, input: &ShapeInput<'_>) -> bool {
|
|
523
|
+
if self.config.exclude_collections.contains(input.collection) {
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
if self.config.exclude_fields.is_empty() {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
let hit = |field: &str| {
|
|
530
|
+
self.config
|
|
531
|
+
.exclude_fields
|
|
532
|
+
.contains(&(None, field.to_string()))
|
|
533
|
+
|| self
|
|
534
|
+
.config
|
|
535
|
+
.exclude_fields
|
|
536
|
+
.contains(&(Some(input.collection.to_string()), field.to_string()))
|
|
537
|
+
};
|
|
538
|
+
input.predicates.iter().any(|(field, _)| hit(field))
|
|
539
|
+
|| input.order_by.iter().any(|(field, _)| hit(field))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/// Decide whether and how to observe a validated query.
|
|
543
|
+
///
|
|
544
|
+
/// Exclusions are applied before any shape is built, so an excluded name is
|
|
545
|
+
/// never copied, hashed or stored. The result is always an [`Observation`];
|
|
546
|
+
/// it records nothing unless the query was admitted.
|
|
547
|
+
pub fn begin(&self, input: &ShapeInput<'_>) -> Observation<'_> {
|
|
548
|
+
let skipped = if is_reserved_collection(input.collection) {
|
|
549
|
+
Some(Skipped::Reserved)
|
|
550
|
+
} else if self.is_excluded(input) {
|
|
551
|
+
Some(Skipped::Excluded)
|
|
552
|
+
} else {
|
|
553
|
+
None
|
|
554
|
+
};
|
|
555
|
+
let outcome = match skipped {
|
|
556
|
+
Some(skipped) => Err(skipped),
|
|
557
|
+
None => normalize(input).map_err(Skipped::Unsupported),
|
|
558
|
+
};
|
|
559
|
+
match outcome {
|
|
560
|
+
Ok(shape) => Observation {
|
|
561
|
+
observer: self,
|
|
562
|
+
shape: Some(shape),
|
|
563
|
+
started: Some(Instant::now()),
|
|
564
|
+
done: false,
|
|
565
|
+
},
|
|
566
|
+
Err(skipped) => {
|
|
567
|
+
self.count_skipped(skipped);
|
|
568
|
+
Observation {
|
|
569
|
+
observer: self,
|
|
570
|
+
shape: None,
|
|
571
|
+
started: None,
|
|
572
|
+
done: true,
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
fn with_table(&self, update: impl FnOnce(&mut Table)) {
|
|
579
|
+
match self.table.lock() {
|
|
580
|
+
Ok(mut table) => update(&mut table),
|
|
581
|
+
Err(_) => {
|
|
582
|
+
self.failures.fetch_add(1, Ordering::Relaxed);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
fn count_skipped(&self, skipped: Skipped) {
|
|
588
|
+
self.with_table(|table| match skipped {
|
|
589
|
+
Skipped::Reserved => table.reserved += 1,
|
|
590
|
+
Skipped::Excluded => table.excluded += 1,
|
|
591
|
+
Skipped::Unsupported(reason) => table.unsupported[reason.index()] += 1,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
fn record(&self, shape: Shape, update: impl FnOnce(&mut ShapeStats)) {
|
|
596
|
+
let capacity = self.config.capacity;
|
|
597
|
+
self.with_table(|table| {
|
|
598
|
+
if let Some(entry) = table.entries.get_mut(&shape) {
|
|
599
|
+
update(&mut entry.stats);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if table.entries.len() >= capacity {
|
|
603
|
+
// Deterministic victim: lowest weight, then oldest admission.
|
|
604
|
+
// `first_seq` is unique, so there are no further ties.
|
|
605
|
+
let victim = table
|
|
606
|
+
.entries
|
|
607
|
+
.iter()
|
|
608
|
+
.min_by_key(|(_, entry)| (entry.weight(), entry.first_seq))
|
|
609
|
+
.map(|(shape, _)| shape.clone());
|
|
610
|
+
if let Some(victim) = victim {
|
|
611
|
+
if let Some(removed) = table.entries.remove(&victim) {
|
|
612
|
+
table.evictions += 1;
|
|
613
|
+
table.evicted_executions += removed.stats.executions;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
let first_seq = table.next_seq;
|
|
618
|
+
table.next_seq += 1;
|
|
619
|
+
let mut stats = ShapeStats::default();
|
|
620
|
+
update(&mut stats);
|
|
621
|
+
table.entries.insert(shape, Entry { first_seq, stats });
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
pub fn snapshot(&self) -> ObserverSnapshot {
|
|
626
|
+
let failures = self.failures.load(Ordering::Relaxed);
|
|
627
|
+
let mut shapes = Vec::new();
|
|
628
|
+
let (mut evictions, mut evicted_executions, mut reserved, mut excluded) = (0, 0, 0, 0);
|
|
629
|
+
let mut unsupported = [0u64; Unsupported::ALL.len()];
|
|
630
|
+
let mut estimate = std::mem::size_of::<Table>();
|
|
631
|
+
if let Ok(table) = self.table.lock() {
|
|
632
|
+
evictions = table.evictions;
|
|
633
|
+
evicted_executions = table.evicted_executions;
|
|
634
|
+
reserved = table.reserved;
|
|
635
|
+
excluded = table.excluded;
|
|
636
|
+
unsupported = table.unsupported;
|
|
637
|
+
for (shape, entry) in &table.entries {
|
|
638
|
+
estimate +=
|
|
639
|
+
shape.approx_bytes() + std::mem::size_of::<Entry>() + HASH_ENTRY_OVERHEAD;
|
|
640
|
+
shapes.push((shape.canonical(), shape.clone(), entry.clone()));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
shapes.sort_by(|(left_key, _, left), (right_key, _, right)| {
|
|
644
|
+
right
|
|
645
|
+
.stats
|
|
646
|
+
.executions
|
|
647
|
+
.cmp(&left.stats.executions)
|
|
648
|
+
.then_with(|| left_key.cmp(right_key))
|
|
649
|
+
});
|
|
650
|
+
ObserverSnapshot {
|
|
651
|
+
shape_version: SHAPE_VERSION,
|
|
652
|
+
capacity: self.config.capacity,
|
|
653
|
+
memory_bound_bytes: self.config.memory_bound_bytes(),
|
|
654
|
+
memory_estimate_bytes: estimate,
|
|
655
|
+
retained_shapes: shapes.len(),
|
|
656
|
+
evictions,
|
|
657
|
+
evicted_executions,
|
|
658
|
+
reserved,
|
|
659
|
+
excluded,
|
|
660
|
+
unsupported: Unsupported::ALL
|
|
661
|
+
.iter()
|
|
662
|
+
.map(|reason| (*reason, unsupported[reason.index()]))
|
|
663
|
+
.collect(),
|
|
664
|
+
observer_failures: failures,
|
|
665
|
+
shapes: shapes
|
|
666
|
+
.into_iter()
|
|
667
|
+
.map(|(_, shape, entry)| ShapeReport {
|
|
668
|
+
id: shape.id(),
|
|
669
|
+
shape,
|
|
670
|
+
first_seen_sequence: entry.first_seq,
|
|
671
|
+
stats: entry.stats,
|
|
672
|
+
})
|
|
673
|
+
.collect(),
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/// One in-flight observation of a validated query.
|
|
679
|
+
///
|
|
680
|
+
/// Dropping it without finishing records an error outcome: every path out of
|
|
681
|
+
/// the handler, including `?` early returns and cancellation, is accounted for
|
|
682
|
+
/// without the handler enumerating them.
|
|
683
|
+
pub struct Observation<'a> {
|
|
684
|
+
observer: &'a QueryShapeObserver,
|
|
685
|
+
shape: Option<Shape>,
|
|
686
|
+
started: Option<Instant>,
|
|
687
|
+
done: bool,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
impl Observation<'_> {
|
|
691
|
+
/// Whether this query was admitted into observation.
|
|
692
|
+
pub fn is_recorded(&self) -> bool {
|
|
693
|
+
self.shape.is_some()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/// A fresh execution completed. `matched` is the conjunction's match count
|
|
697
|
+
/// before the limit; `returned` is the page size.
|
|
698
|
+
pub fn finish_executed(mut self, access: Access, matched: usize, returned: usize) {
|
|
699
|
+
self.done = true;
|
|
700
|
+
let nanos = self.elapsed_nanos();
|
|
701
|
+
if let Some(shape) = self.shape.take() {
|
|
702
|
+
self.observer.record(shape, |stats| {
|
|
703
|
+
stats.executions += 1;
|
|
704
|
+
stats.ok += 1;
|
|
705
|
+
match access {
|
|
706
|
+
Access::Indexed => stats.indexed += 1,
|
|
707
|
+
Access::Scan => stats.scans += 1,
|
|
708
|
+
}
|
|
709
|
+
stats.matched_sum = stats.matched_sum.saturating_add(matched as u64);
|
|
710
|
+
stats.matched_max = stats.matched_max.max(matched as u64);
|
|
711
|
+
stats.returned_sum = stats.returned_sum.saturating_add(returned as u64);
|
|
712
|
+
stats.latency.record(nanos);
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/// A cursor continuation was served from a stored result.
|
|
718
|
+
pub fn finish_continuation(mut self, returned: usize) {
|
|
719
|
+
self.done = true;
|
|
720
|
+
if let Some(shape) = self.shape.take() {
|
|
721
|
+
self.observer.record(shape, |stats| {
|
|
722
|
+
stats.continuations += 1;
|
|
723
|
+
stats.returned_sum = stats.returned_sum.saturating_add(returned as u64);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
fn elapsed_nanos(&self) -> u64 {
|
|
729
|
+
self.started
|
|
730
|
+
.map(|started| started.elapsed().as_nanos().min(u64::MAX as u128) as u64)
|
|
731
|
+
.unwrap_or(0)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
impl Drop for Observation<'_> {
|
|
736
|
+
fn drop(&mut self) {
|
|
737
|
+
if self.done {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
let nanos = self.elapsed_nanos();
|
|
741
|
+
if let Some(shape) = self.shape.take() {
|
|
742
|
+
self.observer.record(shape, |stats| {
|
|
743
|
+
stats.executions += 1;
|
|
744
|
+
stats.errors += 1;
|
|
745
|
+
stats.latency.record(nanos);
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
#[cfg(test)]
|
|
752
|
+
mod tests {
|
|
753
|
+
use super::*;
|
|
754
|
+
|
|
755
|
+
fn input<'a>(
|
|
756
|
+
collection: &'a str,
|
|
757
|
+
predicates: &'a [(&'a str, &'a str)],
|
|
758
|
+
order_by: &'a [(&'a str, &'a str)],
|
|
759
|
+
limit: usize,
|
|
760
|
+
) -> ShapeInput<'a> {
|
|
761
|
+
ShapeInput {
|
|
762
|
+
collection,
|
|
763
|
+
predicates,
|
|
764
|
+
order_by,
|
|
765
|
+
limit,
|
|
766
|
+
has_semantic_clause: false,
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
fn observer(capacity: usize) -> QueryShapeObserver {
|
|
771
|
+
QueryShapeObserver::new(ObserverConfig::with_capacity(capacity))
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
#[test]
|
|
775
|
+
fn disabled_is_the_default() {
|
|
776
|
+
assert_eq!(
|
|
777
|
+
ObserverConfig::from_values(None, None, None, None),
|
|
778
|
+
Ok(None)
|
|
779
|
+
);
|
|
780
|
+
assert_eq!(
|
|
781
|
+
ObserverConfig::from_values(Some("0"), None, None, None),
|
|
782
|
+
Ok(None)
|
|
783
|
+
);
|
|
784
|
+
assert_eq!(
|
|
785
|
+
ObserverConfig::from_values(Some("true"), Some("9"), None, None),
|
|
786
|
+
Ok(None),
|
|
787
|
+
"only the literal 1 enables observation"
|
|
788
|
+
);
|
|
789
|
+
let enabled = ObserverConfig::from_values(Some("1"), None, None, None)
|
|
790
|
+
.unwrap()
|
|
791
|
+
.unwrap();
|
|
792
|
+
assert_eq!(enabled.capacity, DEFAULT_CAPACITY);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
#[test]
|
|
796
|
+
fn malformed_configuration_is_an_error_not_a_default() {
|
|
797
|
+
for capacity in ["0", "-1", "abc", "1025"] {
|
|
798
|
+
assert!(
|
|
799
|
+
ObserverConfig::from_values(Some("1"), Some(capacity), None, None).is_err(),
|
|
800
|
+
"capacity {capacity}"
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
for field in ["nodot", ".field", "coll."] {
|
|
804
|
+
assert!(
|
|
805
|
+
ObserverConfig::from_values(Some("1"), None, None, Some(field)).is_err(),
|
|
806
|
+
"field {field}"
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
// A disabled observer ignores malformed settings: it reads nothing.
|
|
810
|
+
assert_eq!(
|
|
811
|
+
ObserverConfig::from_values(None, Some("abc"), None, Some("nodot")),
|
|
812
|
+
Ok(None)
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
#[test]
|
|
817
|
+
fn equivalent_structures_share_one_shape() {
|
|
818
|
+
let a = normalize(&input(
|
|
819
|
+
"orders",
|
|
820
|
+
&[("status", "eq"), ("tenant", "eq")],
|
|
821
|
+
&[("createdAt", "desc")],
|
|
822
|
+
50,
|
|
823
|
+
))
|
|
824
|
+
.unwrap();
|
|
825
|
+
let b = normalize(&input(
|
|
826
|
+
"orders",
|
|
827
|
+
&[("tenant", "eq"), ("status", "eq")],
|
|
828
|
+
&[("createdAt", "desc")],
|
|
829
|
+
99,
|
|
830
|
+
))
|
|
831
|
+
.unwrap();
|
|
832
|
+
assert_eq!(
|
|
833
|
+
a, b,
|
|
834
|
+
"condition order and limit within a class do not matter"
|
|
835
|
+
);
|
|
836
|
+
assert_eq!(a.canonical(), b.canonical());
|
|
837
|
+
assert_eq!(a.id(), b.id());
|
|
838
|
+
assert_eq!(a.id().len(), 16);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
#[test]
|
|
842
|
+
fn semantically_relevant_differences_produce_distinct_shapes() {
|
|
843
|
+
let base = normalize(&input("o", &[("a", "eq")], &[("x", "asc")], 10)).unwrap();
|
|
844
|
+
let variants = [
|
|
845
|
+
normalize(&input("p", &[("a", "eq")], &[("x", "asc")], 10)),
|
|
846
|
+
normalize(&input("o", &[("b", "eq")], &[("x", "asc")], 10)),
|
|
847
|
+
normalize(&input("o", &[("a", "gt")], &[("x", "asc")], 10)),
|
|
848
|
+
normalize(&input("o", &[("a", "eq")], &[("x", "desc")], 10)),
|
|
849
|
+
normalize(&input("o", &[("a", "eq")], &[("y", "asc")], 10)),
|
|
850
|
+
normalize(&input(
|
|
851
|
+
"o",
|
|
852
|
+
&[("a", "eq")],
|
|
853
|
+
&[("x", "asc"), ("y", "asc")],
|
|
854
|
+
10,
|
|
855
|
+
)),
|
|
856
|
+
normalize(&input("o", &[("a", "eq")], &[("x", "asc")], 11)),
|
|
857
|
+
normalize(&input(
|
|
858
|
+
"o",
|
|
859
|
+
&[("a", "eq"), ("a", "eq")],
|
|
860
|
+
&[("x", "asc")],
|
|
861
|
+
10,
|
|
862
|
+
)),
|
|
863
|
+
];
|
|
864
|
+
let mut ids = BTreeSet::from([base.id()]);
|
|
865
|
+
for variant in variants {
|
|
866
|
+
assert!(ids.insert(variant.unwrap().id()));
|
|
867
|
+
}
|
|
868
|
+
// Sort order is meaningful: (x, y) is not (y, x).
|
|
869
|
+
let xy = normalize(&input("o", &[], &[("x", "asc"), ("y", "asc")], 1)).unwrap();
|
|
870
|
+
let yx = normalize(&input("o", &[], &[("y", "asc"), ("x", "asc")], 1)).unwrap();
|
|
871
|
+
assert_ne!(xy.id(), yx.id());
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
#[test]
|
|
875
|
+
fn names_containing_separators_cannot_collide() {
|
|
876
|
+
let one = normalize(&input("c", &[("a\",\"b", "eq")], &[], 1)).unwrap();
|
|
877
|
+
let two = normalize(&input("c", &[("a", "eq"), ("b", "eq")], &[], 1)).unwrap();
|
|
878
|
+
assert_ne!(one.canonical(), two.canonical());
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
#[test]
|
|
882
|
+
fn unsupported_forms_are_classified_not_guessed() {
|
|
883
|
+
let mut semantic = input("o", &[], &[], 1);
|
|
884
|
+
semantic.has_semantic_clause = true;
|
|
885
|
+
assert_eq!(normalize(&semantic), Err(Unsupported::SemanticClause));
|
|
886
|
+
assert_eq!(
|
|
887
|
+
normalize(&input("o", &[("a", "like")], &[], 1)),
|
|
888
|
+
Err(Unsupported::UnknownOperator)
|
|
889
|
+
);
|
|
890
|
+
assert_eq!(
|
|
891
|
+
normalize(&input("o", &[], &[("a", "up")], 1)),
|
|
892
|
+
Err(Unsupported::UnknownDirection)
|
|
893
|
+
);
|
|
894
|
+
assert_eq!(
|
|
895
|
+
normalize(&input("o", &[], &[], 0)),
|
|
896
|
+
Err(Unsupported::LimitOutOfRange)
|
|
897
|
+
);
|
|
898
|
+
assert_eq!(
|
|
899
|
+
normalize(&input("o", &[], &[], 1001)),
|
|
900
|
+
Err(Unsupported::LimitOutOfRange)
|
|
901
|
+
);
|
|
902
|
+
let many = vec![("a", "eq"); MAX_PREDICATES + 1];
|
|
903
|
+
assert_eq!(
|
|
904
|
+
normalize(&input("o", &many, &[], 1)),
|
|
905
|
+
Err(Unsupported::TooManyPredicates)
|
|
906
|
+
);
|
|
907
|
+
let sorts = vec![("a", "asc"); MAX_ORDER_FIELDS + 1];
|
|
908
|
+
assert_eq!(
|
|
909
|
+
normalize(&input("o", &[], &sorts, 1)),
|
|
910
|
+
Err(Unsupported::TooManyOrderFields)
|
|
911
|
+
);
|
|
912
|
+
let long = "x".repeat(MAX_IDENTIFIER_BYTES + 1);
|
|
913
|
+
assert_eq!(
|
|
914
|
+
normalize(&input("o", &[(long.as_str(), "eq")], &[], 1)),
|
|
915
|
+
Err(Unsupported::IdentifierTooLong)
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
#[test]
|
|
920
|
+
fn unsupported_queries_are_counted_by_reason_and_never_retained() {
|
|
921
|
+
let observer = observer(4);
|
|
922
|
+
let mut semantic = input("o", &[("a", "eq")], &[], 1);
|
|
923
|
+
semantic.has_semantic_clause = true;
|
|
924
|
+
let observation = observer.begin(&semantic);
|
|
925
|
+
assert!(!observation.is_recorded());
|
|
926
|
+
observation.finish_executed(Access::Scan, 1, 1);
|
|
927
|
+
let snapshot = observer.snapshot();
|
|
928
|
+
assert!(snapshot.shapes.is_empty());
|
|
929
|
+
assert!(snapshot
|
|
930
|
+
.unsupported
|
|
931
|
+
.contains(&(Unsupported::SemanticClause, 1)));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
#[test]
|
|
935
|
+
fn reserved_collections_are_excluded_by_default() {
|
|
936
|
+
let observer = observer(4);
|
|
937
|
+
for name in ["_feltdb.Proposal", "_feltdb_x", "_retention", "state"] {
|
|
938
|
+
assert!(is_reserved_collection(name), "{name}");
|
|
939
|
+
observer
|
|
940
|
+
.begin(&input(name, &[("a", "eq")], &[], 1))
|
|
941
|
+
.finish_executed(Access::Scan, 0, 0);
|
|
942
|
+
}
|
|
943
|
+
assert!(!is_reserved_collection("orders"));
|
|
944
|
+
let snapshot = observer.snapshot();
|
|
945
|
+
assert_eq!(snapshot.reserved, 4);
|
|
946
|
+
assert!(snapshot.shapes.is_empty());
|
|
947
|
+
let rendered = serde_json::to_string(&snapshot).unwrap();
|
|
948
|
+
assert!(
|
|
949
|
+
!rendered.contains("feltdb"),
|
|
950
|
+
"reserved names must not be retained"
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
#[test]
|
|
955
|
+
fn excluded_collections_and_fields_never_reach_retained_data() {
|
|
956
|
+
let mut config = ObserverConfig::with_capacity(8);
|
|
957
|
+
config.exclude_collections.insert("secrets".into());
|
|
958
|
+
config.exclude_fields.insert((None, "ssn".into()));
|
|
959
|
+
config
|
|
960
|
+
.exclude_fields
|
|
961
|
+
.insert((Some("orders".into()), "cardNumber".into()));
|
|
962
|
+
let observer = QueryShapeObserver::new(config);
|
|
963
|
+
type Case<'a> = (&'a str, &'a [(&'a str, &'a str)], &'a [(&'a str, &'a str)]);
|
|
964
|
+
let cases: [Case<'_>; 5] = [
|
|
965
|
+
("secrets", &[("a", "eq")], &[]),
|
|
966
|
+
("people", &[("ssn", "eq")], &[]),
|
|
967
|
+
("people", &[("a", "eq")], &[("ssn", "asc")]),
|
|
968
|
+
("orders", &[("cardNumber", "eq")], &[]),
|
|
969
|
+
("orders", &[("a", "eq"), ("cardNumber", "gt")], &[]),
|
|
970
|
+
];
|
|
971
|
+
for (collection, predicates, order) in cases {
|
|
972
|
+
observer
|
|
973
|
+
.begin(&input(collection, predicates, order, 1))
|
|
974
|
+
.finish_executed(Access::Scan, 0, 0);
|
|
975
|
+
}
|
|
976
|
+
// Not excluded: the same field name on another collection.
|
|
977
|
+
observer
|
|
978
|
+
.begin(&input("other", &[("cardNumber", "eq")], &[], 1))
|
|
979
|
+
.finish_executed(Access::Scan, 0, 0);
|
|
980
|
+
let snapshot = observer.snapshot();
|
|
981
|
+
assert_eq!(snapshot.excluded, 5);
|
|
982
|
+
assert_eq!(snapshot.shapes.len(), 1);
|
|
983
|
+
let rendered = serde_json::to_string(&snapshot).unwrap();
|
|
984
|
+
for leaked in ["secrets", "ssn", "people"] {
|
|
985
|
+
assert!(!rendered.contains(leaked), "{leaked} leaked: {rendered}");
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
#[test]
|
|
990
|
+
fn statistics_aggregate_by_shape_and_never_hold_values() {
|
|
991
|
+
let observer = observer(8);
|
|
992
|
+
let predicates = [("status", "eq")];
|
|
993
|
+
let order = [("createdAt", "desc")];
|
|
994
|
+
for matched in [3usize, 7] {
|
|
995
|
+
observer
|
|
996
|
+
.begin(&input("orders", &predicates, &order, 50))
|
|
997
|
+
.finish_executed(Access::Indexed, matched, matched.min(5));
|
|
998
|
+
}
|
|
999
|
+
observer
|
|
1000
|
+
.begin(&input("orders", &predicates, &order, 50))
|
|
1001
|
+
.finish_executed(Access::Scan, 10, 5);
|
|
1002
|
+
observer
|
|
1003
|
+
.begin(&input("orders", &predicates, &order, 50))
|
|
1004
|
+
.finish_continuation(5);
|
|
1005
|
+
// Dropped without finishing: an error after validation.
|
|
1006
|
+
drop(observer.begin(&input("orders", &predicates, &order, 50)));
|
|
1007
|
+
let snapshot = observer.snapshot();
|
|
1008
|
+
assert_eq!(snapshot.shapes.len(), 1);
|
|
1009
|
+
let stats = &snapshot.shapes[0].stats;
|
|
1010
|
+
assert_eq!(stats.executions, 4);
|
|
1011
|
+
assert_eq!((stats.ok, stats.errors), (3, 1));
|
|
1012
|
+
assert_eq!(stats.continuations, 1);
|
|
1013
|
+
assert_eq!((stats.indexed, stats.scans), (2, 1));
|
|
1014
|
+
assert_eq!((stats.matched_sum, stats.matched_max), (20, 10));
|
|
1015
|
+
assert_eq!(stats.returned_sum, 3 + 5 + 5 + 5);
|
|
1016
|
+
assert_eq!(stats.latency.count, 4);
|
|
1017
|
+
assert_eq!(stats.latency.log2_micros.iter().sum::<u64>(), 4);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
#[test]
|
|
1021
|
+
fn capacity_and_eviction_are_deterministic() {
|
|
1022
|
+
let run = || {
|
|
1023
|
+
let observer = observer(3);
|
|
1024
|
+
for round in 0..4 {
|
|
1025
|
+
for collection in ["a", "b", "c", "d", "e", "f"] {
|
|
1026
|
+
if round == 0 || collection != "f" {
|
|
1027
|
+
observer
|
|
1028
|
+
.begin(&input(collection, &[("x", "eq")], &[], 1))
|
|
1029
|
+
.finish_executed(Access::Scan, 1, 1);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
observer.snapshot().without_timing()
|
|
1034
|
+
};
|
|
1035
|
+
let first = run();
|
|
1036
|
+
let second = run();
|
|
1037
|
+
assert_eq!(first.retained_shapes, 3);
|
|
1038
|
+
assert!(first.retained_shapes <= first.capacity);
|
|
1039
|
+
assert!(first.evictions > 0);
|
|
1040
|
+
assert_eq!(
|
|
1041
|
+
serde_json::to_string(&first).unwrap(),
|
|
1042
|
+
serde_json::to_string(&second).unwrap(),
|
|
1043
|
+
"same trace, same retained shapes and counters"
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
#[test]
|
|
1048
|
+
fn eviction_prefers_the_lightest_then_the_oldest_shape() {
|
|
1049
|
+
let observer = observer(2);
|
|
1050
|
+
let heavy = input("heavy", &[("x", "eq")], &[], 1);
|
|
1051
|
+
for _ in 0..5 {
|
|
1052
|
+
observer.begin(&heavy).finish_executed(Access::Scan, 0, 0);
|
|
1053
|
+
}
|
|
1054
|
+
observer
|
|
1055
|
+
.begin(&input("old", &[("x", "eq")], &[], 1))
|
|
1056
|
+
.finish_executed(Access::Scan, 0, 0);
|
|
1057
|
+
observer
|
|
1058
|
+
.begin(&input("new", &[("x", "eq")], &[], 1))
|
|
1059
|
+
.finish_executed(Access::Scan, 0, 0);
|
|
1060
|
+
let snapshot = observer.snapshot();
|
|
1061
|
+
let names: Vec<_> = snapshot
|
|
1062
|
+
.shapes
|
|
1063
|
+
.iter()
|
|
1064
|
+
.map(|report| report.shape.collection.as_str())
|
|
1065
|
+
.collect();
|
|
1066
|
+
assert_eq!(names, ["heavy", "new"]);
|
|
1067
|
+
assert_eq!(snapshot.evictions, 1);
|
|
1068
|
+
assert_eq!(snapshot.evicted_executions, 1);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
#[test]
|
|
1072
|
+
fn memory_stays_within_the_declared_bound() {
|
|
1073
|
+
let observer = observer(MAX_CAPACITY);
|
|
1074
|
+
let long = "n".repeat(MAX_IDENTIFIER_BYTES);
|
|
1075
|
+
let fields: Vec<String> = (0..MAX_PREDICATES)
|
|
1076
|
+
.map(|i| format!("{i:02}{long}")[..MAX_IDENTIFIER_BYTES].to_string())
|
|
1077
|
+
.collect();
|
|
1078
|
+
for index in 0..(MAX_CAPACITY * 2) {
|
|
1079
|
+
let name = format!("c{index}");
|
|
1080
|
+
let predicates: Vec<(&str, &str)> =
|
|
1081
|
+
fields.iter().map(|field| (field.as_str(), "eq")).collect();
|
|
1082
|
+
observer
|
|
1083
|
+
.begin(&input(&name, &predicates, &[], 1))
|
|
1084
|
+
.finish_executed(Access::Scan, 0, 0);
|
|
1085
|
+
}
|
|
1086
|
+
let snapshot = observer.snapshot();
|
|
1087
|
+
assert_eq!(snapshot.retained_shapes, MAX_CAPACITY);
|
|
1088
|
+
assert!(
|
|
1089
|
+
snapshot.memory_estimate_bytes <= snapshot.memory_bound_bytes,
|
|
1090
|
+
"{} > {}",
|
|
1091
|
+
snapshot.memory_estimate_bytes,
|
|
1092
|
+
snapshot.memory_bound_bytes
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
#[test]
|
|
1097
|
+
fn a_poisoned_lock_degrades_observation_without_panicking() {
|
|
1098
|
+
let observer = observer(4);
|
|
1099
|
+
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
1100
|
+
let _guard = observer.table.lock().unwrap();
|
|
1101
|
+
panic!("poison the table");
|
|
1102
|
+
}));
|
|
1103
|
+
observer
|
|
1104
|
+
.begin(&input("o", &[("a", "eq")], &[], 1))
|
|
1105
|
+
.finish_executed(Access::Scan, 1, 1);
|
|
1106
|
+
drop(observer.begin(&input("o", &[("a", "eq")], &[], 1)));
|
|
1107
|
+
observer.begin(&input("_x", &[], &[], 1));
|
|
1108
|
+
let snapshot = observer.snapshot();
|
|
1109
|
+
assert!(snapshot.observer_failures >= 3);
|
|
1110
|
+
assert!(snapshot.shapes.is_empty());
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
#[test]
|
|
1114
|
+
fn concurrent_recording_loses_nothing_within_capacity() {
|
|
1115
|
+
let observer = std::sync::Arc::new(observer(16));
|
|
1116
|
+
let handles: Vec<_> = (0..8)
|
|
1117
|
+
.map(|worker| {
|
|
1118
|
+
let observer = observer.clone();
|
|
1119
|
+
std::thread::spawn(move || {
|
|
1120
|
+
for _ in 0..500 {
|
|
1121
|
+
let name = format!("c{}", worker % 4);
|
|
1122
|
+
observer
|
|
1123
|
+
.begin(&input(&name, &[("a", "eq")], &[], 1))
|
|
1124
|
+
.finish_executed(Access::Indexed, 1, 1);
|
|
1125
|
+
}
|
|
1126
|
+
})
|
|
1127
|
+
})
|
|
1128
|
+
.collect();
|
|
1129
|
+
for handle in handles {
|
|
1130
|
+
handle.join().unwrap();
|
|
1131
|
+
}
|
|
1132
|
+
let snapshot = observer.snapshot();
|
|
1133
|
+
let total: u64 = snapshot.shapes.iter().map(|s| s.stats.executions).sum();
|
|
1134
|
+
assert_eq!(total, 4000);
|
|
1135
|
+
assert_eq!(snapshot.shapes.len(), 4);
|
|
1136
|
+
}
|
|
1137
|
+
}
|