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.
- sde/__init__.py +318 -0
- sde/_cutover_project.py +179 -0
- sde/_local_state.py +188 -0
- sde/_operator_deadline.py +50 -0
- sde/_usage.py +314 -0
- sde/bulk.py +79 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/cutover.py +286 -0
- sde/engines/__init__.py +0 -0
- sde/engines/_clickhouse_connection.py +224 -0
- sde/engines/_index_build.py +294 -0
- sde/engines/_operator.py +394 -0
- sde/engines/_staging.py +222 -0
- sde/engines/_storage.py +22 -0
- sde/engines/_write_fences.py +271 -0
- sde/engines/clickhouse.py +1115 -0
- sde/engines/orderbook.py +457 -0
- sde/engines/postgres.py +967 -0
- sde/entity.py +170 -0
- sde/errors.py +103 -0
- sde/explain.py +300 -0
- sde/frozen_verification.py +152 -0
- sde/generation.py +131 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/index_build.py +313 -0
- sde/index_operator.py +347 -0
- sde/infer.py +461 -0
- sde/inspection.py +62 -0
- sde/internal.py +90 -0
- sde/layout.py +669 -0
- sde/local_cutover.py +801 -0
- sde/logging.py +143 -0
- sde/migration.py +856 -0
- sde/model.py +482 -0
- sde/physical.py +531 -0
- sde/placement.py +1010 -0
- sde/provisioning.py +63 -0
- sde/py.typed +0 -0
- sde/query.py +521 -0
- sde/routing.py +85 -0
- sde/schema.py +466 -0
- sde/session.py +993 -0
- sde/shapes.py +153 -0
- sde/staging.py +264 -0
- sde/staging_operator.py +393 -0
- sde/telemetry.py +1087 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +331 -0
- sde/types.py +228 -0
- sde/verification.py +220 -0
- sde/watermark.py +222 -0
- sde/write_fence.py +283 -0
- sde_demo/__init__.py +1 -0
- sde_demo/__main__.py +183 -0
- sde_demo/diagnostics.py +92 -0
- sde_demo/model.py +75 -0
- sde_demo/project.py +312 -0
- sde_demo/py.typed +0 -0
- sde_demo/query_count.py +301 -0
- sde_demo/resources.py +969 -0
- sde_demo/runtime.py +419 -0
- sde_demo/verification.py +242 -0
- sde_operator/__init__.py +1 -0
- sde_operator/__main__.py +210 -0
- smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
- smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
- smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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)
|