smart-data-engine-sdk 0.1.0__py3-none-any.whl

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 (73) hide show
  1. sde/__init__.py +318 -0
  2. sde/_cutover_project.py +179 -0
  3. sde/_local_state.py +188 -0
  4. sde/_operator_deadline.py +50 -0
  5. sde/_usage.py +314 -0
  6. sde/bulk.py +79 -0
  7. sde/canonical.py +141 -0
  8. sde/capabilities.py +62 -0
  9. sde/cutover.py +286 -0
  10. sde/engines/__init__.py +0 -0
  11. sde/engines/_clickhouse_connection.py +224 -0
  12. sde/engines/_index_build.py +294 -0
  13. sde/engines/_operator.py +394 -0
  14. sde/engines/_staging.py +222 -0
  15. sde/engines/_storage.py +22 -0
  16. sde/engines/_write_fences.py +271 -0
  17. sde/engines/clickhouse.py +1115 -0
  18. sde/engines/orderbook.py +457 -0
  19. sde/engines/postgres.py +967 -0
  20. sde/entity.py +170 -0
  21. sde/errors.py +103 -0
  22. sde/explain.py +300 -0
  23. sde/frozen_verification.py +152 -0
  24. sde/generation.py +131 -0
  25. sde/groups.py +97 -0
  26. sde/hashing.py +242 -0
  27. sde/index_build.py +313 -0
  28. sde/index_operator.py +347 -0
  29. sde/infer.py +461 -0
  30. sde/inspection.py +62 -0
  31. sde/internal.py +90 -0
  32. sde/layout.py +669 -0
  33. sde/local_cutover.py +801 -0
  34. sde/logging.py +143 -0
  35. sde/migration.py +856 -0
  36. sde/model.py +482 -0
  37. sde/physical.py +531 -0
  38. sde/placement.py +1010 -0
  39. sde/provisioning.py +63 -0
  40. sde/py.typed +0 -0
  41. sde/query.py +521 -0
  42. sde/routing.py +85 -0
  43. sde/schema.py +466 -0
  44. sde/session.py +993 -0
  45. sde/shapes.py +153 -0
  46. sde/staging.py +264 -0
  47. sde/staging_operator.py +393 -0
  48. sde/telemetry.py +1087 -0
  49. sde/testing/__init__.py +14 -0
  50. sde/testing/loader.py +175 -0
  51. sde/testing/memory.py +331 -0
  52. sde/types.py +228 -0
  53. sde/verification.py +220 -0
  54. sde/watermark.py +222 -0
  55. sde/write_fence.py +283 -0
  56. sde_demo/__init__.py +1 -0
  57. sde_demo/__main__.py +183 -0
  58. sde_demo/diagnostics.py +92 -0
  59. sde_demo/model.py +75 -0
  60. sde_demo/project.py +312 -0
  61. sde_demo/py.typed +0 -0
  62. sde_demo/query_count.py +301 -0
  63. sde_demo/resources.py +969 -0
  64. sde_demo/runtime.py +419 -0
  65. sde_demo/verification.py +242 -0
  66. sde_operator/__init__.py +1 -0
  67. sde_operator/__main__.py +210 -0
  68. smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
  69. smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
  70. smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
  71. smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  72. smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
  73. smart_data_engine_sdk-0.1.0.dist-info/licenses/NOTICE +13 -0
sde/telemetry.py ADDED
@@ -0,0 +1,1087 @@
1
+ """Measuring what the application actually does, without ever seeing what it does it to.
2
+
3
+ This is the input the placement decision is made from, so its shape matters more than its precision.
4
+ Three constraints shaped everything here.
5
+
6
+ **It carries no values.** A record is keyed by an operation shape, which is assembled from the
7
+ structure of a call and never sees its arguments. There is no code path by which a customer's row
8
+ reaches a telemetry record, which is why this file can be read by a client and believed.
9
+
10
+ **It cannot cost anything.** Routing already has a one percent budget for the whole library, and
11
+ recording happens on the same path. So: no locks on the hot path per record, no string formatting,
12
+ no stack walking except once per shape, and a histogram rather than a list of samples.
13
+
14
+ **It cannot fail the caller.** Every entry point is wrapped in :func:`~sde.internal.guard`. A bug in
15
+ an aggregation counter must not take down somebody's request - and the failure is counted, so it is
16
+ not invisible either.
17
+
18
+ The histogram deserves a word, because it is the one deliberate loss of precision. Latency lands in
19
+ exponential buckets, so a percentile read out of it is approximate - within one bucket width, which
20
+ is a factor of two at the extremes. That is ample for the decision it feeds: the planner cares
21
+ whether a group's reads are microseconds or milliseconds, not whether p99 is 412 or 431
22
+ microseconds. Keeping exact samples would mean either unbounded memory or reservoir sampling, and
23
+ reservoir sampling gets the tail wrong in exactly the region the planner looks at.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ import sys
30
+ import threading
31
+ from collections import deque
32
+ from collections.abc import Callable, Collection, Mapping, Sequence
33
+ from dataclasses import dataclass, field, replace
34
+ from dataclasses import fields as dataclass_fields
35
+ from typing import Any
36
+
37
+ from .groups import Group, colocation_groups
38
+ from .internal import guard
39
+ from .logging import log
40
+ from .model import LogicalModel
41
+ from .shapes import WRITE_KINDS, OperationShape, enumerate_shapes
42
+
43
+ __all__ = [
44
+ "MEASURED_FIELDS",
45
+ "CopyFreshness",
46
+ "FanOutStats",
47
+ "GroupFeatures",
48
+ "Histogram",
49
+ "Recorder",
50
+ "ShapeStats",
51
+ "Window",
52
+ "has_time_dimension",
53
+ ]
54
+
55
+ # 1 µs to about 17 s, doubling. Twenty-five buckets is enough to tell a cache hit from a full scan,
56
+ # which is the distinction the planner actually acts on.
57
+ BUCKET_COUNT = 25
58
+ BUCKET_BASE_NS = 1_000
59
+
60
+
61
+ class Histogram:
62
+ """Exponential-bucket histogram. Fixed memory, O(1) record, approximate percentiles."""
63
+
64
+ __slots__ = ("buckets", "count", "total")
65
+
66
+ def __init__(self) -> None:
67
+ self.buckets = [0] * BUCKET_COUNT
68
+ self.count = 0
69
+ self.total = 0
70
+
71
+ def record(self, nanoseconds: int) -> None:
72
+ """Put one duration in its bucket. Integer arithmetic only, and that is the point.
73
+
74
+ This used to read ``int(math.log2(nanoseconds / BUCKET_BASE_NS)) + 1``, which is the same
75
+ function and the wrong way to compute it once a second language has to agree. ``log2`` is
76
+ not required by IEEE 754 to be correctly rounded, so two libm implementations may differ in
77
+ the last bit - and one bit at a power-of-two boundary is a different bucket, which is a
78
+ different p99 for identical traffic. The bit length of the integer quotient is exact
79
+ everywhere, and it is cheaper on a path that runs per operation.
80
+
81
+ Verified rather than asserted: the two forms were compared over every value below 40 000,
82
+ every power-of-two boundary and its neighbours, and 400 000 random durations up to 10^13 ns.
83
+ Zero disagreements.
84
+
85
+ **And the vectors cannot see the difference, which is the point of saying so here.** An
86
+ earlier version of this note claimed ``telemetry/002`` pins it. Measured: replacing this
87
+ with the logarithm form passes every vector, because glibc's ``log2`` and V8's are both
88
+ exact at a power of two - so the two runtimes we have agree, and the hazard is a *third*
89
+ libm that does not. A property no output can distinguish on the machines available is not
90
+ one a vector can hold, so it is held statically instead: see
91
+ ``test_the_bucket_index_never_reaches_for_a_logarithm``.
92
+ """
93
+ self.count += 1
94
+ self.total += nanoseconds
95
+ if nanoseconds < BUCKET_BASE_NS:
96
+ self.buckets[0] += 1
97
+ return
98
+ index: int = min(BUCKET_COUNT - 1, (nanoseconds // BUCKET_BASE_NS).bit_length())
99
+ self.buckets[index] += 1
100
+
101
+ def percentile_ms(self, fraction: float) -> float | None:
102
+ """Approximate percentile in milliseconds, or None if nothing was recorded.
103
+
104
+ Returns the *upper* edge of the bucket the percentile falls in. Rounding up rather than
105
+ interpolating is deliberate: a placement decision made on an optimistic latency figure is
106
+ the wrong kind of wrong.
107
+ """
108
+ if self.count == 0:
109
+ return None
110
+ target = fraction * self.count
111
+ seen = 0
112
+ for index, hits in enumerate(self.buckets):
113
+ seen += hits
114
+ if seen >= target:
115
+ upper_ns: int = BUCKET_BASE_NS * (2**index)
116
+ return upper_ns / 1_000_000
117
+ return None
118
+
119
+ def merge(self, other: Histogram) -> None:
120
+ for index, hits in enumerate(other.buckets):
121
+ self.buckets[index] += hits
122
+ self.count += other.count
123
+ self.total += other.total
124
+
125
+
126
+ @dataclass
127
+ class ShapeStats:
128
+ """What was observed for one operation shape. No values, by construction."""
129
+
130
+ shape_id: str
131
+ group: str
132
+ entity: str
133
+ kind: str
134
+ calls: int = 0
135
+ rows: int = 0
136
+ errors: int = 0
137
+ latency: Histogram = field(default_factory=Histogram)
138
+ call_site: str | None = None
139
+ filtered: dict[tuple[tuple[str, ...], str], int] = field(default_factory=dict)
140
+ """Calls by what they filtered on - the fields compared by equality and the field a range
141
+ bounded (``""`` for none) - names only, never values.
142
+
143
+ A range read over ``at`` with ``where={"station": ...}`` and one without it are the same shape
144
+ (the shape is enumerated from the model and routed by identifier), and they want different key
145
+ orders; an aggregate over a time range and one over the whole table are the same shape too.
146
+ Only an operation that takes a ``where`` reports this, so writes and point reads leave it empty
147
+ rather than claiming they filtered on nothing."""
148
+
149
+ def record(
150
+ self,
151
+ nanoseconds: int,
152
+ rows: int,
153
+ failed: bool,
154
+ predicates: tuple[tuple[str, ...], str] | None = None,
155
+ ) -> None:
156
+ self.calls += 1
157
+ self.rows += rows
158
+ if failed:
159
+ self.errors += 1
160
+ self.latency.record(nanoseconds)
161
+ if predicates is not None:
162
+ self.filtered[predicates] = self.filtered.get(predicates, 0) + 1
163
+
164
+
165
+ @dataclass
166
+ class FanOutStats:
167
+ """What was observed writing one row to one derived copy. No values, by construction.
168
+
169
+ Deliberately **not** a :class:`ShapeStats`, and that is the load-bearing decision. A fan-out is
170
+ not an operation the application asked for - it is the library keeping a copy current - so
171
+ recording it as a shape would add a write to the very counters a placement is scored on:
172
+ ``read_write_ratio`` would move because a copy exists, and a group with one copy would look
173
+ twice as write-heavy as the same group without one. The set of shape kinds that count as writes
174
+ already had four copies once (:data:`sde.shapes.WRITE_KINDS`); this is the same failure
175
+ arriving from the other side.
176
+
177
+ It is also not in :class:`GroupFeatures`, for a reason worth stating: a copy's freshness does
178
+ not score a placement. It reports the health of a copy the placement already made. Putting it
179
+ in the feature vector would bump the frozen scoring model to add an input nothing scores.
180
+ """
181
+
182
+ group: str
183
+ materialization: str
184
+ writes: int = 0
185
+ failures: int = 0
186
+ """Rows that did not reach the copy. Absence, not lateness - see :class:`CopyFreshness`."""
187
+ latency: Histogram = field(default_factory=Histogram)
188
+
189
+ def record(self, nanoseconds: int, failed: bool) -> None:
190
+ self.writes += 1
191
+ if failed:
192
+ self.failures += 1
193
+ # Recorded either way. A failed fan-out took time too, and dropping it would make the
194
+ # measured window look better precisely when the copy is in trouble.
195
+ self.latency.record(nanoseconds)
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class CopyFreshness:
200
+ """How far behind one derived copy is, measured. Requirement 5.2.
201
+
202
+ **What "behind" means here was settled by looking at the mechanism rather than at the word.**
203
+ A derived copy in this library is maintained by the fan-out in
204
+ :meth:`sde.session.Session.save` - a write in the client's own process, to the copy, straight
205
+ after the source. There is no asynchronous replication anywhere, so there is no queue to fall
206
+ behind in. Two things can therefore be true of a copy, and only two:
207
+
208
+ - it is **late** by at most the duration of that one write, which is what ``lag_p50_ms`` and
209
+ ``lag_p99_ms`` measure;
210
+ - or the write **failed**, and the row is absent rather than late. That is ``failures``, and it
211
+ is the number a lag figure would hide: a copy missing a thousand rows can have an excellent
212
+ p99.
213
+
214
+ Both are reported because they are different problems with different fixes, and a client told
215
+ only the first would read "0.9 ms behind" off a copy that is missing yesterday.
216
+
217
+ Inside a write transaction the fan-out is deferred to commit, so the window measured there
218
+ starts when the row was queued - inside the transaction - rather than at the commit. That
219
+ **overstates** the staleness by the rest of the transaction, and overstating a staleness bound
220
+ is the safe direction: the number is used to ask whether a copy is inside the budget a map
221
+ declared, and a bound that flatters is a bound nobody can rely on.
222
+ """
223
+
224
+ group: str
225
+ materialization: str
226
+ writes: int
227
+ failures: int
228
+ lag_p50_ms: float | None
229
+ lag_p99_ms: float | None
230
+
231
+ @property
232
+ def complete(self) -> bool:
233
+ """Whether every write reached the copy in this window."""
234
+ return self.failures == 0
235
+
236
+ def as_record(self) -> dict[str, Any]:
237
+ return {
238
+ "group": self.group,
239
+ "materialization": self.materialization,
240
+ "writes": self.writes,
241
+ "failures": self.failures,
242
+ "lag_p50_ms": self.lag_p50_ms,
243
+ "lag_p99_ms": self.lag_p99_ms,
244
+ "complete": self.complete,
245
+ }
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class GroupFeatures:
250
+ """The contract between telemetry and the planner.
251
+
252
+ ``None`` means *unknown*, which is not zero and is treated differently by the planner. Anything
253
+ unknown also appears in ``missing``, so a reader never has to infer absence from a null.
254
+ """
255
+
256
+ calls: int = 0
257
+ """How many operations were observed. A count, never a value.
258
+
259
+ Present because a planner comparing two groups has to know which one carries the traffic:
260
+ without it, an idle group and the group serving every request are equally important, and one
261
+ dead group can outvote the one that matters. It is also evidence about the features themselves -
262
+ a read/write ratio derived from twelve calls is arithmetic, not a measurement, and the planner
263
+ should be able to see that rather than being told a confident number.
264
+ """
265
+
266
+ read_write_ratio: float | None = None
267
+ shape_mix: Mapping[str, float] = field(default_factory=dict)
268
+ latency_p50_ms: float | None = None
269
+ latency_p99_ms: float | None = None
270
+ result_cardinality_p50: float | None = None
271
+ result_cardinality_p99: float | None = None
272
+ total_bytes: int | None = None
273
+ daily_growth_bytes: int | None = None
274
+ index_to_table_ratio: float | None = None
275
+ pk_access_share: float | None = None
276
+ has_time_dimension: bool = False
277
+ time_filtered_share: float | None = None
278
+ distinct_shapes: int = 0
279
+ write_burstiness: float | None = None
280
+ error_share: float | None = None
281
+ missing: frozenset[str] = frozenset()
282
+ complete: bool = True
283
+
284
+ def as_record(self) -> dict[str, Any]:
285
+ """The feature vector as the document that crosses the boundary to the control plane.
286
+
287
+ **A field with no value is omitted, and ``missing`` is what says so.** Emitting a null
288
+ would work too - the reader treats absent and null alike - but omitting is the honest
289
+ spelling of "not measured", and it keeps this method from having an opinion about what a
290
+ null means.
291
+
292
+ The field list is **derived** from this dataclass rather than written out. That is
293
+ requirement 6.7 from the writing side: a field added to the library joins this document
294
+ instead of being silently dropped, which is the mirror of the defect the control plane's
295
+ reader had - a hand-written list, against a dataclass where every field has a default, so
296
+ the next field would have been recorded as measured.
297
+
298
+ No float is formatted here and none is rounded. Every number in this document is either a
299
+ ratio of two integers or a bucket edge divided by a million, so two languages compute the
300
+ same IEEE 754 double - see :meth:`Window.as_record` for why that matters and where the
301
+ limit of the claim is.
302
+ """
303
+ body: dict[str, Any] = {}
304
+ for name in MEASURED_FIELDS:
305
+ value = getattr(self, name)
306
+ if value is None:
307
+ continue
308
+ body[name] = dict(value) if isinstance(value, Mapping) else value
309
+ body["missing"] = sorted(self.missing)
310
+ body["complete"] = self.complete
311
+ return body
312
+
313
+
314
+ MEASURED_FIELDS: tuple[str, ...] = tuple(
315
+ spec.name
316
+ for spec in dataclass_fields(GroupFeatures)
317
+ if spec.name not in ("missing", "complete")
318
+ )
319
+ """Every field of :class:`GroupFeatures` that carries a measurement.
320
+
321
+ Derived from the dataclass, and the two names excluded are bookkeeping *about* the measurement
322
+ rather than part of it. Both this module and the control plane's reader work from this same source,
323
+ so a field added to the feature vector cannot be written by one side and ignored by the other.
324
+ """
325
+
326
+ SECOND_NS = 1_000_000_000
327
+ """The bucket a write's rows are counted in for ``write_burstiness``."""
328
+
329
+ FILTERED_KINDS: frozenset[str] = frozenset({"aggregate", "full_scan", "range_read"})
330
+ """The kinds of read that take a ``where`` and so report what they filtered on (``filtered_on``)."""
331
+
332
+ GROWTH_MIN_NS = 3_600_000_000_000
333
+ """The shortest span a daily growth is projected from.
334
+
335
+ A day projected from a few seconds of writes is a number with no basis: a demonstration writing a
336
+ hundred rows in two seconds would claim gigabytes a day. An hour is where the projection multiplies
337
+ a measurement by 24 rather than by thousands."""
338
+
339
+ DAY_NS = 86_400_000_000_000
340
+ """The unit growth is projected to, and how long the recorder keeps a group's storage samples."""
341
+
342
+
343
+ @dataclass(frozen=True)
344
+ class StorageSample:
345
+ """One group's bytes on its source materialisation at one moment, from the engine's catalogue.
346
+
347
+ Numbers only. ``total_bytes`` is everything the group's tables occupy, indexes included;
348
+ ``secondary_index_bytes`` is the part of it that is indexes other than the one enforcing the
349
+ key - the part a physical design adds and can remove. ``at_ns`` is the recorder's clock.
350
+ """
351
+
352
+ group: str
353
+ at_ns: int
354
+ total_bytes: int
355
+ secondary_index_bytes: int
356
+
357
+
358
+ @dataclass(frozen=True)
359
+ class StorageSize:
360
+ """One group's size on its source materialisation, as the engine's catalogue gave it."""
361
+
362
+ group: str
363
+ materialization: str
364
+ engine: str
365
+ total_bytes: int
366
+ secondary_index_bytes: int
367
+
368
+
369
+ STORAGE_UNAVAILABLE = ("failed", "missing_table", "refused", "unsupported")
370
+ """Why a group's size stayed unknown: its engine's adapter has no catalogue to read (the orderbook
371
+ engine), a table the map names does not exist, the catalogue refused the login (ClickHouse without
372
+ the ``system.parts`` grant) or the read failed otherwise."""
373
+
374
+
375
+ @dataclass(frozen=True)
376
+ class StorageMeasurement:
377
+ """What :meth:`sde.session.Session.measure_storage` measured, and why the rest stayed unknown.
378
+
379
+ ``unavailable`` maps a group to one of :data:`STORAGE_UNAVAILABLE`. A class, never the engine's
380
+ message: the message can name objects the application may not want in a log it forwards."""
381
+
382
+ sizes: tuple[StorageSize, ...]
383
+ unavailable: Mapping[str, str]
384
+
385
+
386
+ @dataclass(frozen=True)
387
+ class Window:
388
+ """One aggregation period, ready to send.
389
+
390
+ ``complete`` is false when the library could not reach the control plane for part of the period,
391
+ or when the buffer dropped windows. A window that is not complete is still sent - the planner
392
+ needs to know traffic existed - but it may not be used to justify a migration.
393
+ """
394
+
395
+ model_version: str
396
+ started_ns: int
397
+ ended_ns: int
398
+ shapes: Sequence[ShapeStats]
399
+ complete: bool = True
400
+ dropped_windows: int = 0
401
+ fanned: Sequence[FanOutStats] = ()
402
+ """What the fan-out to each derived copy did. Empty when the group has no copy, which is the
403
+ ordinary case - a copy exists during a migration and while a derived materialisation is in the
404
+ map, not otherwise."""
405
+ storage: Sequence[StorageSample] = ()
406
+ """The storage samples taken during this window - between the roll that opened it and the one
407
+ that closed it, by the recorder's own state rather than by comparing clock readings, so a sample
408
+ taken at the instant of a roll belongs to exactly one window."""
409
+ storage_history: Sequence[StorageSample] = ()
410
+ """Every storage sample the recorder kept when this window closed: this window's and earlier
411
+ ones up to a day old, which a daily growth is projected against."""
412
+ write_seconds: Mapping[str, Mapping[int, int]] = field(default_factory=dict)
413
+ """Rows written by successful writes, per group, per whole second of the window from its start.
414
+
415
+ What ``write_burstiness`` is computed from, and nothing else: a count per second, not a row."""
416
+
417
+ def copies(self, group: str) -> tuple[CopyFreshness, ...]:
418
+ """How far behind each of this group's derived copies ran, sorted by materialisation.
419
+
420
+ Read out of the histogram rather than stored, like every other percentile here. A stored
421
+ percentile is a second copy of a fact that changes when the samples do.
422
+ """
423
+ return tuple(
424
+ CopyFreshness(
425
+ group=stats.group,
426
+ materialization=stats.materialization,
427
+ writes=stats.writes,
428
+ failures=stats.failures,
429
+ lag_p50_ms=stats.latency.percentile_ms(0.50),
430
+ lag_p99_ms=stats.latency.percentile_ms(0.99),
431
+ )
432
+ for stats in sorted(
433
+ (s for s in self.fanned if s.group == group),
434
+ key=lambda s: s.materialization,
435
+ )
436
+ )
437
+
438
+ def features(
439
+ self,
440
+ group: str,
441
+ *,
442
+ has_time_dimension: bool = False,
443
+ time_fields: Mapping[str, Collection[str]] | None = None,
444
+ range_fields: Mapping[str, str] | None = None,
445
+ ) -> GroupFeatures:
446
+ """Fold this window's records for one group into the planner's feature vector.
447
+
448
+ ``time_fields`` names, per entity, the fields of a time type (:func:`time_fields`), which is
449
+ what ``time_filtered_share`` is counted against. Without it that share stays unknown: which
450
+ fields are times is a fact about the model, and the window does not guess it.
451
+ ``range_fields`` maps a range read's shape identifier to the field its shape ranges over,
452
+ which settles a range read whose filters were not reported.
453
+ """
454
+ records = [s for s in self.shapes if s.group == group]
455
+ if not records:
456
+ # `no_traffic` is the *reason*, and the unknown fields are named too - by the same
457
+ # derivation as the branch below, because two branches of one function computing
458
+ # `missing` by two different rules is the defect this set exists to prevent. An empty
459
+ # window really did not measure a read/write ratio, and saying only "no traffic" leaves
460
+ # a reader to work out which fields that implies.
461
+ return _with_missing(
462
+ GroupFeatures(complete=self.complete, distinct_shapes=0), also={"no_traffic"}
463
+ )
464
+
465
+ writes = sum(s.calls for s in records if s.kind in WRITE_KINDS)
466
+ reads = sum(s.calls for s in records if s.kind not in WRITE_KINDS)
467
+ calls = writes + reads
468
+
469
+ latency = Histogram()
470
+ for record in records:
471
+ latency.merge(record.latency)
472
+
473
+ mix: dict[str, float] = {}
474
+ for record in records:
475
+ mix[record.kind] = mix.get(record.kind, 0.0) + record.calls
476
+ mix = {kind: hits / calls for kind, hits in sorted(mix.items())} if calls else {}
477
+
478
+ # A failed call counts in the latency histogram and **not** here, and the two answers have
479
+ # different reasons rather than one convention. A failure took time, so dropping it from
480
+ # the histogram would flatter a window precisely when the engine is in trouble. It returned
481
+ # no rows because it failed rather than because the data is sparse, so averaging that zero
482
+ # in understates how many rows a read of this shape returns - and `error_share` already
483
+ # carries the failure rate, so smearing it into a second feature is one fact in two places.
484
+ # A shape whose every call failed contributes nothing rather than a zero. `telemetry/008`.
485
+ read_records = [
486
+ s for s in records if s.kind not in WRITE_KINDS and s.calls > s.errors
487
+ ]
488
+ cardinalities = sorted(s.rows / (s.calls - s.errors) for s in read_records)
489
+
490
+ pk_calls = sum(s.calls for s in records if s.kind == "point_read")
491
+ errors = sum(s.errors for s in records)
492
+
493
+ # A call filtered on time when its filter bounded a time field by a range or compared one by
494
+ # equality. Names only - the fields `filtered_on` already reports - so no argument of any
495
+ # call is needed. The denominator is every call of the group, as for `pk_access_share`.
496
+ #
497
+ # A read that takes a `where` and did not report its filters - a recorder fed by another
498
+ # producer, or a hand-written window - leaves the share unknown rather than counting it as
499
+ # not filtered on time: zero would claim to know. The exception is a range read, whose
500
+ # shape names the field it bounded; and in an entity with no time field nothing could have
501
+ # filtered on time, reported or not.
502
+ time_filtered: float | None = None
503
+ if time_fields is not None and calls:
504
+ filtered = 0
505
+ known = True
506
+ for record in records:
507
+ times = frozenset(time_fields.get(record.entity, ()))
508
+ for (equal, ranged), hits in record.filtered.items():
509
+ if ranged in times or not times.isdisjoint(equal):
510
+ filtered += hits
511
+ unreported = record.calls - sum(record.filtered.values())
512
+ if unreported <= 0 or record.kind not in FILTERED_KINDS or not times:
513
+ continue
514
+ bounded = (range_fields or {}).get(record.shape_id)
515
+ if record.kind == "range_read" and bounded is not None:
516
+ filtered += unreported if bounded in times else 0
517
+ else:
518
+ known = False
519
+ time_filtered = filtered / calls if known else None
520
+
521
+ total_bytes, index_ratio, growth = self._storage_features(group)
522
+
523
+ measured = GroupFeatures(
524
+ calls=calls,
525
+ read_write_ratio=(reads / writes) if writes else None,
526
+ shape_mix=mix,
527
+ latency_p50_ms=latency.percentile_ms(0.5),
528
+ latency_p99_ms=latency.percentile_ms(0.99),
529
+ result_cardinality_p50=_at(cardinalities, 0.5),
530
+ result_cardinality_p99=_at(cardinalities, 0.99),
531
+ total_bytes=total_bytes,
532
+ daily_growth_bytes=growth,
533
+ index_to_table_ratio=index_ratio,
534
+ pk_access_share=(pk_calls / calls) if calls else None,
535
+ has_time_dimension=has_time_dimension,
536
+ time_filtered_share=time_filtered,
537
+ distinct_shapes=len(records),
538
+ write_burstiness=self._burstiness(group),
539
+ error_share=(errors / calls) if calls else None,
540
+ complete=self.complete,
541
+ )
542
+ return _with_missing(measured)
543
+
544
+ def _storage_features(self, group: str) -> tuple[int | None, float | None, int | None]:
545
+ """``total_bytes``, ``index_to_table_ratio`` and ``daily_growth_bytes`` from the samples.
546
+
547
+ The size is the latest sample taken **in this window** - a sample from an earlier window is
548
+ an earlier size, and reporting it as this window's would date a measurement wrongly. Growth
549
+ reaches back to the oldest sample kept (up to a day) and is projected to a day only from a
550
+ span of at least :data:`GROWTH_MIN_NS`, truncated toward zero, and may be negative:
551
+ ClickHouse merges shrink parts. Every result is an integer or a ratio of two integers.
552
+ """
553
+ latest: StorageSample | None = None
554
+ for sample in self.storage:
555
+ if sample.group == group and (latest is None or sample.at_ns >= latest.at_ns):
556
+ latest = sample
557
+ if latest is None:
558
+ return None, None, None
559
+ rest = latest.total_bytes - latest.secondary_index_bytes
560
+ ratio = latest.secondary_index_bytes / rest if rest > 0 else None
561
+ history = [sample for sample in self.storage_history if sample.group == group]
562
+ oldest = min(history or [latest], key=lambda sample: sample.at_ns)
563
+ elapsed = latest.at_ns - oldest.at_ns
564
+ growth: int | None = None
565
+ if elapsed >= GROWTH_MIN_NS:
566
+ delta = latest.total_bytes - oldest.total_bytes
567
+ projected = abs(delta) * DAY_NS // elapsed
568
+ growth = projected if delta >= 0 else -projected
569
+ return latest.total_bytes, ratio, growth
570
+
571
+ def _burstiness(self, group: str) -> float | None:
572
+ """How many times the busiest second's rows exceed the window's mean rate.
573
+
574
+ ``M * S / W``: the busiest second's rows, the window's whole seconds - at least one, and at
575
+ least as many as the seconds its writes landed in - and every row written. One is perfectly
576
+ even; a window that wrote everything in one of its sixty seconds reads 60.
577
+ """
578
+ seconds = self.write_seconds.get(group) or {}
579
+ written = sum(seconds.values())
580
+ if written <= 0:
581
+ return None
582
+ duration = -(-(self.ended_ns - self.started_ns) // SECOND_NS)
583
+ span = max(1, duration, max(seconds) + 1)
584
+ return max(seconds.values()) * span / written
585
+
586
+
587
+
588
+
589
+ def shape_records(self, model: LogicalModel, group: str) -> list[dict[str, Any]]:
590
+ """What each operation shape of one group measured, in the model's enumeration order.
591
+
592
+ The evidence a physical design can point at. A group's features say that 62% of its calls
593
+ were range reads; only this says *which field* they ranged over, which is the fact a key
594
+ order or a partition is chosen from. Still no values: a shape is the structure of a call,
595
+ and the fields it names are the model's field names (digests, when the client hashes them).
596
+
597
+ The descriptor - entity, kind, fields, target - is taken from the model's own enumeration
598
+ by identifier, never from the recorder: the recorder is given an identifier, and a document
599
+ restating a classification the library was told rather than one it derived would let two
600
+ libraries agree on a shape neither enumerates. ``target`` appears only on a relation walk,
601
+ for the reason ``copies`` is absent rather than empty.
602
+
603
+ Every number is an integer count or a bucket edge divided by a million, so this section is
604
+ comparable across languages on the same terms as the rest of the document.
605
+ """
606
+ recorded = {s.shape_id: s for s in self.shapes if s.group == group}
607
+ out: list[dict[str, Any]] = []
608
+ for shape in _enumerated(model, recorded):
609
+ stats = recorded[shape.id]
610
+ entry: dict[str, Any] = {
611
+ "id": shape.id,
612
+ "entity": shape.entity,
613
+ "kind": shape.kind,
614
+ "fields": list(shape.fields),
615
+ }
616
+ if shape.target is not None:
617
+ entry["target"] = shape.target
618
+ entry.update(
619
+ calls=stats.calls,
620
+ errors=stats.errors,
621
+ rows=stats.rows,
622
+ latency_p50_ms=stats.latency.percentile_ms(0.5),
623
+ latency_p99_ms=stats.latency.percentile_ms(0.99),
624
+ )
625
+ if stats.filtered:
626
+ # What the calls filtered on: one entry per combination of equality fields and
627
+ # bounded field, in code point order. An entry with no `equal` fields and no
628
+ # `range` is the calls that filtered on nothing.
629
+ entry["filtered_on"] = [
630
+ {"equal": list(equal), **({"range": ranged} if ranged else {}), "calls": calls}
631
+ for (equal, ranged), calls in sorted(stats.filtered.items())
632
+ ]
633
+ out.append(entry)
634
+ return out
635
+
636
+ def as_record(self, model: LogicalModel) -> dict[str, Any]:
637
+ """This window as the document the control plane reads. Numbers, never rows.
638
+
639
+ **The artefact this library existed without.** Everything above measured traffic and
640
+ nothing turned a window into the file we are handed - so the walkthrough's step 8, "the
641
+ library measures traffic and hands us a window", was a literal dictionary typed into the
642
+ example, and every client in every language would have written their own. Two clients of
643
+ one model would then produce two documents from identical traffic, and the planner would
644
+ score whichever one it was given. Same shape of gap as the two before it: both halves
645
+ worked and nothing joined them.
646
+
647
+ The model is required and it is checked. Only one fact is read from it - whether a group
648
+ carries a time dimension, which is decided by declared *type* and never by a field's name -
649
+ but a window serialised against the wrong model would attach that fact to the wrong groups
650
+ and claim `has_time_dimension: false` for a group that has one. False is a claim; a
651
+ measurement this library cannot make has to be absent, which is what everything else in
652
+ here is careful about.
653
+
654
+ **This document is deliberately not canonical, and that needs saying because §1 of the
655
+ format contract rejects floating point outright.** Its reason is that a float's textual
656
+ form differs between languages, and almost every number here is a float. This is not
657
+ signed, not hashed and never compared for equality, so the rule it breaks does not apply -
658
+ but the thing that makes the family checkable at all is narrower and worth stating: every
659
+ number in this document is either **a ratio of two integers** or **a bucket edge divided by
660
+ a million**, and IEEE 754 requires division to be correctly rounded. So two languages
661
+ compute the same double from the same traffic even where they would print it differently,
662
+ and the `telemetry/` vectors compare numbers rather than bytes for exactly that reason.
663
+ """
664
+ if model.version != self.model_version:
665
+ raise ValueError(
666
+ f"this window measured model version {self.model_version} and it is being "
667
+ f"serialised against {model.version}. Only one fact is read from the model here - "
668
+ f"whether a group has a time dimension - and reading it from another model would "
669
+ f"attach it to the wrong groups. Keep the model the recorder was created for, or "
670
+ f"drop the window: a window measured against a model that no longer exists cannot "
671
+ f"be scored against the one that does."
672
+ )
673
+ by_name = {group.name: group for group in colocation_groups(model)}
674
+ named = sorted({stats.group for stats in self.shapes} | {s.group for s in self.fanned})
675
+ unknown = [name for name in named if name not in by_name]
676
+ if unknown:
677
+ raise ValueError(
678
+ f"this window has records for groups this model does not have: {unknown}. The "
679
+ f"recorder is given a model version and the groups come from the operations it "
680
+ f"observed, so this is a session routing a model other than the one measured."
681
+ )
682
+
683
+ ranges = {
684
+ shape.id: shape.fields[0]
685
+ for shape in enumerate_shapes(model)
686
+ if shape.kind == "range_read" and shape.fields
687
+ }
688
+ groups: dict[str, Any] = {}
689
+ for name in named:
690
+ record = self.features(
691
+ name,
692
+ has_time_dimension=has_time_dimension(model, by_name[name]),
693
+ time_fields=time_fields(model, by_name[name]),
694
+ range_fields=ranges,
695
+ ).as_record()
696
+ shapes = self.shape_records(model, name)
697
+ if shapes:
698
+ # Absent rather than empty, like `copies`: a group in this document saw traffic,
699
+ # so an empty list would only arise from a group listed for its copies alone.
700
+ record["shapes"] = shapes
701
+ copies = [copy.as_record() for copy in self.copies(name)]
702
+ if copies:
703
+ # Absent rather than empty when the group has no derived copy, for the reason
704
+ # `also_write` is absent rather than empty in a map: absent says "not doing this"
705
+ # and an empty list says "considered and found none", which is a stronger claim.
706
+ record["copies"] = copies
707
+ groups[name] = record
708
+
709
+ return {
710
+ "model_version": self.model_version,
711
+ "complete": self.complete,
712
+ "dropped_windows": self.dropped_windows,
713
+ "groups": groups,
714
+ }
715
+
716
+
717
+ def _enumerated(model: LogicalModel, recorded: Mapping[str, ShapeStats]) -> list[OperationShape]:
718
+ """The model's shapes that were recorded, in enumeration order - or a refusal naming the rest.
719
+
720
+ A recorder fed by a :class:`~sde.session.Session` only ever sees enumerated identifiers, and the
721
+ model version is a digest over the model, so a window serialised against the model it measured
722
+ cannot name a shape that model lacks. A recorder driven directly can, and a document describing
723
+ an identifier by guesswork would hand the planner a field nobody declared.
724
+ """
725
+ enumerated = [shape for shape in enumerate_shapes(model) if shape.id in recorded]
726
+ known = {shape.id for shape in enumerated}
727
+ unknown = sorted(identifier for identifier in recorded if identifier not in known)
728
+ if unknown:
729
+ raise ValueError(
730
+ f"this window has records for shapes this model does not enumerate: {unknown}. A shape "
731
+ f"is described from the model's own enumeration, never from what the recorder was "
732
+ f"told, so a record for an identifier the model does not produce has no fields to "
733
+ f"report."
734
+ )
735
+ return enumerated
736
+
737
+
738
+ def _with_missing(
739
+ measured: GroupFeatures, *, also: Collection[str] = ()
740
+ ) -> GroupFeatures:
741
+ """The same features with ``missing`` **derived** from which values came out unknown.
742
+
743
+ Derived rather than listed alongside them, so the two cannot disagree - and they did.
744
+ ``time_filtered_share`` is a field this library never measures and it was left null and absent
745
+ from this set, which breaks the one promise the set makes: that a reader never has to infer
746
+ absence from a null. Four other fields were named by hand and would have stayed the only ones.
747
+
748
+ What stays unknown and why, because a derivation hides the reasons. The sizes
749
+ (``total_bytes``, ``index_to_table_ratio``) come from a storage sample taken in the window -
750
+ :meth:`Recorder.record_storage`, fed by :meth:`sde.session.Session.measure_storage` - and are
751
+ unknown without one, or when the engine's catalogue could not be read. ``daily_growth_bytes``
752
+ needs samples at least an hour apart. ``write_burstiness`` needs rows written in the window.
753
+ ``time_filtered_share`` needs the caller to say which fields are times. Zero bytes and unknown
754
+ bytes lead a planner to opposite conclusions, which is the whole reason for naming them rather
755
+ than defaulting them.
756
+
757
+ ``also`` carries reasons that are not field names - ``no_traffic`` is the only one - because a
758
+ reader of the window needs to know the difference between "this group was idle" and "this
759
+ field is not measurable".
760
+ """
761
+ return replace(
762
+ measured,
763
+ missing=frozenset(also)
764
+ | frozenset(name for name in MEASURED_FIELDS if getattr(measured, name) is None),
765
+ )
766
+
767
+
768
+ def _at(ordered: list[float], fraction: float) -> float | None:
769
+ """Nearest-rank: the smallest sample at least ``fraction`` of the data is not above.
770
+
771
+ **The same rank rule as** :meth:`Histogram.percentile_ms`, and it did not used to be. This
772
+ function selected ``ordered[int(len * fraction)]`` - the floor - while the histogram takes the
773
+ first bucket whose cumulative count reaches ``fraction * count``, which is the ceiling. The two
774
+ agree on every sample set with an odd count, and every case in ``telemetry/`` had one, so one
775
+ window document carried two percentile conventions and nothing could see it: a third
776
+ implementation swapped one for the other and the whole family stayed green.
777
+
778
+ They differ exactly when ``fraction * len`` is an integer. Two samples at p50 is that case:
779
+ ``[3.0, 300.0]`` reported 300 here and the *lower* bucket in the histogram, for the same
780
+ reason applied twice in opposite directions. ``telemetry/007`` pins it.
781
+ """
782
+ if not ordered:
783
+ return None
784
+ rank = max(1, math.ceil(fraction * len(ordered))) - 1
785
+ return ordered[min(len(ordered) - 1, rank)]
786
+
787
+
788
+ # Types that make a field a time dimension. Recognised by **type**, never by name - see below.
789
+ _TIME_TYPES = frozenset({"date", "timestamp", "timestamptz"})
790
+
791
+
792
+ def has_time_dimension(model: LogicalModel, group: Group) -> bool:
793
+ """Does any entity in this group carry a time dimension?
794
+
795
+ Decided by the declared type and never by the field's name. Two reasons, and the second is the
796
+ one that made it a rule rather than a preference.
797
+
798
+ A name is not evidence: `created_at` typed as a string is a string, and treating it as a
799
+ timestamp would have the planner recommend time partitioning on a column no engine can
800
+ range-scan usefully.
801
+
802
+ And a client may hash identifier names (requirement 11.3) so that we never see them. A
803
+ derivation that reads names would silently produce different answers with hashing on and off -
804
+ which is exactly the property the conformance vector for hashed models exists to forbid.
805
+ Deciding by type also means models written in other natural languages work identically, which is
806
+ a free consequence of getting this right for the other reason.
807
+ """
808
+ for member in group.members:
809
+ spec = model.entity(member)
810
+ for spec_field in spec.fields:
811
+ if spec_field.type in _TIME_TYPES:
812
+ return True
813
+ return False
814
+
815
+
816
+ def time_fields(model: LogicalModel, group: Group) -> dict[str, frozenset[str]]:
817
+ """Each entity of this group and its fields of a time type - by type, never by name.
818
+
819
+ What ``time_filtered_share`` counts against, for the reasons :func:`has_time_dimension` gives:
820
+ a `created_at` typed as a string is not a time, and a hashed model must answer the same.
821
+ """
822
+ return {
823
+ member: frozenset(
824
+ spec_field.name
825
+ for spec_field in model.entity(member).fields
826
+ if spec_field.type in _TIME_TYPES
827
+ )
828
+ for member in group.members
829
+ }
830
+
831
+
832
+ class Recorder:
833
+ """Accumulates records, rolls windows, and drops telemetry rather than anything else.
834
+
835
+ Thread safety is a single lock taken only when a shape is first seen or a window rolls - not on
836
+ every record. Counters for an existing shape are updated without it: two threads racing on the
837
+ same shape can lose a call from the count, and that is the right trade. Telemetry informs a
838
+ placement decision made over days; a lock on the hot path would cost every operation forever.
839
+ """
840
+
841
+ def __init__(
842
+ self,
843
+ model_version: str,
844
+ *,
845
+ max_windows: int = 64,
846
+ clock: Callable[[], int] | None = None,
847
+ ) -> None:
848
+ """``clock`` returns nanoseconds and defaults to a monotonic one; tests and the conformance
849
+ vectors pass their own, because a write's second and a sample's age are read from it."""
850
+ self._model_version = model_version
851
+ self._clock: Callable[[], int] = clock or _now
852
+ self._lock = threading.Lock()
853
+ self._current: dict[str, ShapeStats] = {}
854
+ self._fanned: dict[tuple[str, str], FanOutStats] = {}
855
+ self._writes: dict[str, dict[int, int]] = {}
856
+ self._storage: dict[str, list[StorageSample]] = {}
857
+ self._storage_window: list[StorageSample] = []
858
+ self._started_ns = self._clock()
859
+ self._windows: deque[Window] = deque(maxlen=max_windows)
860
+ self._dropped = 0
861
+ self._incomplete = False
862
+
863
+ # --- recording ---------------------------------------------------------------------------
864
+
865
+ def record(
866
+ self,
867
+ *,
868
+ shape_id: str,
869
+ group: str,
870
+ entity: str,
871
+ kind: str,
872
+ nanoseconds: int,
873
+ rows: int = 0,
874
+ failed: bool = False,
875
+ equal: Sequence[str] | None = None,
876
+ ranged: str | None = None,
877
+ ) -> None:
878
+ """Record one operation. Never raises, never blocks on the common path.
879
+
880
+ For an operation that takes a ``where``, ``equal`` names the fields it compared by
881
+ equality and ``ranged`` the field a range bounded; ``equal`` is ``None`` for an operation
882
+ that does not filter at all, and then nothing about filters is recorded.
883
+ """
884
+ guard(
885
+ "telemetry.record",
886
+ lambda: self._record(
887
+ shape_id,
888
+ group,
889
+ entity,
890
+ kind,
891
+ nanoseconds,
892
+ rows,
893
+ failed,
894
+ None if equal is None else (tuple(sorted(set(equal))), ranged or ""),
895
+ ),
896
+ )
897
+
898
+ def _record(
899
+ self,
900
+ shape_id: str,
901
+ group: str,
902
+ entity: str,
903
+ kind: str,
904
+ nanoseconds: int,
905
+ rows: int,
906
+ failed: bool,
907
+ predicates: tuple[tuple[str, ...], str] | None = None,
908
+ ) -> None:
909
+ stats = self._current.get(shape_id)
910
+ if stats is None:
911
+ with self._lock:
912
+ stats = self._current.get(shape_id)
913
+ if stats is None:
914
+ stats = ShapeStats(
915
+ shape_id=shape_id, group=group, entity=entity, kind=kind,
916
+ call_site=_call_site(),
917
+ )
918
+ self._current[shape_id] = stats
919
+ stats.record(nanoseconds, rows, failed, predicates)
920
+ if rows > 0 and not failed and kind in WRITE_KINDS:
921
+ # The second of the window this write's rows landed in - one clock read per write and no
922
+ # lock, the same trade as the counters above: a write racing a roll may be counted in
923
+ # the window it did not finish in, which moves a burstiness estimate, never a row.
924
+ second = max(0, (self._clock() - self._started_ns) // SECOND_NS)
925
+ seconds = self._writes.get(group)
926
+ if seconds is None:
927
+ with self._lock:
928
+ seconds = self._writes.setdefault(group, {})
929
+ seconds[second] = seconds.get(second, 0) + rows
930
+
931
+ def record_fan_out(
932
+ self, *, group: str, materialization: str, nanoseconds: int, failed: bool = False
933
+ ) -> None:
934
+ """Record one write to one derived copy. Never raises, never blocks on the common path.
935
+
936
+ A separate entry point from :meth:`record` rather than a shape kind, because a fan-out is
937
+ not an operation the application asked for - see :class:`FanOutStats`.
938
+ """
939
+ guard(
940
+ "telemetry.record_fan_out",
941
+ lambda: self._record_fan_out(group, materialization, nanoseconds, failed),
942
+ )
943
+
944
+ def _record_fan_out(
945
+ self, group: str, materialization: str, nanoseconds: int, failed: bool
946
+ ) -> None:
947
+ key = (group, materialization)
948
+ stats = self._fanned.get(key)
949
+ if stats is None:
950
+ with self._lock:
951
+ stats = self._fanned.get(key)
952
+ if stats is None:
953
+ stats = FanOutStats(group=group, materialization=materialization)
954
+ self._fanned[key] = stats
955
+ stats.record(nanoseconds, failed)
956
+
957
+ def record_storage(self, *, group: str, total_bytes: int, secondary_index_bytes: int) -> None:
958
+ """Record one group's size, as the engine's catalogue reported it. Never raises.
959
+
960
+ :meth:`sde.session.Session.measure_storage` calls this for every group it could measure. A
961
+ sample is kept for a day, across windows, because growth is a property of time rather than
962
+ of one window; a sample that is not two non-negative integers with the index part inside
963
+ the total is dropped and logged, never guessed into shape.
964
+ """
965
+ guard(
966
+ "telemetry.record_storage",
967
+ lambda: self._record_storage(group, total_bytes, secondary_index_bytes),
968
+ )
969
+
970
+ def _record_storage(self, group: str, total_bytes: int, secondary_index_bytes: int) -> None:
971
+ if (
972
+ not isinstance(group, str)
973
+ or not group
974
+ or type(total_bytes) is not int
975
+ or type(secondary_index_bytes) is not int
976
+ or total_bytes < 0
977
+ or not 0 <= secondary_index_bytes <= total_bytes
978
+ ):
979
+ log("sde.telemetry.storage_rejected")
980
+ return
981
+ sample = StorageSample(group, self._clock(), total_bytes, secondary_index_bytes)
982
+ with self._lock:
983
+ kept = [s for s in self._storage.get(group, ()) if s.at_ns >= sample.at_ns - DAY_NS]
984
+ kept.append(sample)
985
+ self._storage[group] = kept
986
+ self._storage_window.append(sample)
987
+
988
+ # --- windows -----------------------------------------------------------------------------
989
+
990
+ def roll(self) -> Window | None:
991
+ """Close the current period and queue it.
992
+
993
+ Returns the window, or None if nothing was recorded in it.
994
+ """
995
+ return guard("telemetry.roll", self._roll)
996
+
997
+ def _roll(self) -> Window | None:
998
+ with self._lock:
999
+ if not self._current and not self._fanned:
1000
+ # `not self._current` alone would drop a window holding only fan-out records. That
1001
+ # cannot arise from an application write - a write records a shape and then fans
1002
+ # out - but it can from a backfill replaying rows, and a window silently discarded
1003
+ # is the shape of gap that makes a client's copy look healthier than it is.
1004
+ return None
1005
+ window = Window(
1006
+ model_version=self._model_version,
1007
+ started_ns=self._started_ns,
1008
+ ended_ns=self._clock(),
1009
+ shapes=tuple(self._current.values()),
1010
+ complete=not self._incomplete,
1011
+ dropped_windows=self._dropped,
1012
+ fanned=tuple(self._fanned.values()),
1013
+ storage=tuple(self._storage_window),
1014
+ storage_history=tuple(sample for kept in self._storage.values() for sample in kept),
1015
+ write_seconds={group: dict(seconds) for group, seconds in self._writes.items()},
1016
+ )
1017
+ self._current = {}
1018
+ self._fanned = {}
1019
+ self._writes = {}
1020
+ self._storage_window = []
1021
+ self._started_ns = self._clock()
1022
+ self._incomplete = False
1023
+
1024
+ # A full buffer drops the oldest window and says so in the next one. Telemetry is the
1025
+ # thing that gets lost when we run out of room - never a write, never an operation.
1026
+ if len(self._windows) == self._windows.maxlen:
1027
+ self._dropped += 1
1028
+ log("sde.telemetry.dropped", dropped=self._dropped)
1029
+ self._windows.append(window)
1030
+ log(
1031
+ "sde.telemetry.window_closed",
1032
+ model_version=window.model_version,
1033
+ shapes=len(window.shapes),
1034
+ complete=window.complete,
1035
+ )
1036
+ return window
1037
+
1038
+ def pending(self) -> tuple[Window, ...]:
1039
+ with self._lock:
1040
+ return tuple(self._windows)
1041
+
1042
+ def mark_incomplete(self) -> None:
1043
+ """Called by the application when part of the current period was not recorded.
1044
+
1045
+ The flag travels to us in :attr:`Window.complete` and the planner reads it, because a
1046
+ window missing a slice of the traffic must not be scored as though it were the whole
1047
+ period: an application that was down for six of the last twenty-four hours looks
1048
+ idle-and-cheap on figures nobody marked as partial.
1049
+
1050
+ Nothing here decides when that happened. The application collects these windows and hands
1051
+ them to us; only it knows whether a collection was skipped.
1052
+ """
1053
+ self._incomplete = True
1054
+
1055
+ def acknowledge(self, count: int) -> None:
1056
+ """Drop the oldest ``count`` windows once the application has taken them.
1057
+
1058
+ The delivering party is the client's own process, and there is no other. This library
1059
+ never opens a connection to us - the buffer is read with :meth:`pending`, written wherever
1060
+ the application writes it, and that file is what we are handed.
1061
+ """
1062
+ with self._lock:
1063
+ for _ in range(min(count, len(self._windows))):
1064
+ self._windows.popleft()
1065
+
1066
+
1067
+ def _now() -> int:
1068
+ return int(__import__("time").perf_counter_ns())
1069
+
1070
+
1071
+ def _call_site() -> str | None:
1072
+ """Where in the client's code this shape is used. Walked once per shape, never per call.
1073
+
1074
+ Best effort by design: a frame walk is not free and a missing call site costs a little
1075
+ diagnostic value, while a frame walk on every operation would cost the latency budget.
1076
+ """
1077
+
1078
+ def walk() -> str | None:
1079
+ frame: Any = sys._getframe(1)
1080
+ while frame is not None:
1081
+ name = frame.f_globals.get("__name__", "")
1082
+ if not name.startswith("sde"):
1083
+ return f"{frame.f_code.co_filename}:{frame.f_lineno}"
1084
+ frame = frame.f_back
1085
+ return None
1086
+
1087
+ return guard("telemetry.call_site", walk)