dactopus 0.2.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.
- dactopus/__init__.py +1 -0
- dactopus/build.py +221 -0
- dactopus/checks.py +671 -0
- dactopus/cli.py +182 -0
- dactopus/clickhouse.py +32 -0
- dactopus/config.py +353 -0
- dactopus/ga4.py +253 -0
- dactopus/library.py +210 -0
- dactopus/report.py +129 -0
- dactopus/serve.py +59 -0
- dactopus/shopify.py +475 -0
- dactopus-0.2.0.dist-info/METADATA +286 -0
- dactopus-0.2.0.dist-info/RECORD +16 -0
- dactopus-0.2.0.dist-info/WHEEL +4 -0
- dactopus-0.2.0.dist-info/entry_points.txt +8 -0
- dactopus-0.2.0.dist-info/licenses/LICENSE +202 -0
dactopus/checks.py
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""Checks over the input and the canonical tables, and their findings.
|
|
2
|
+
|
|
3
|
+
A check is a function of a ClickHouse client and the configuration that
|
|
4
|
+
returns findings. Checks register through the entry point group
|
|
5
|
+
``dactopus.checks``; the entry point's name is the check's name. The
|
|
6
|
+
built-in checks register the same way, in pyproject.toml.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import statistics
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import UTC, date, datetime, timedelta
|
|
17
|
+
from importlib.metadata import entry_points
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
from zoneinfo import ZoneInfo
|
|
20
|
+
|
|
21
|
+
from clickhouse_connect.driver.client import Client
|
|
22
|
+
from clickhouse_connect.driver.exceptions import ClickHouseError
|
|
23
|
+
|
|
24
|
+
from . import library
|
|
25
|
+
from .build import misdated, target
|
|
26
|
+
from .config import Config
|
|
27
|
+
|
|
28
|
+
Severity = Literal["pass", "skip", "warn", "fail", "error"]
|
|
29
|
+
|
|
30
|
+
# From the least to the most serious.
|
|
31
|
+
SEVERITIES: tuple[Severity, ...] = ("pass", "skip", "warn", "fail", "error")
|
|
32
|
+
|
|
33
|
+
ENTRY_POINT_GROUP = "dactopus.checks"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Finding:
|
|
38
|
+
"""One result of a check, in the same shape for built-in and plugin checks."""
|
|
39
|
+
|
|
40
|
+
check: str
|
|
41
|
+
# The table, column or test the finding is about.
|
|
42
|
+
object: str
|
|
43
|
+
severity: Severity
|
|
44
|
+
observed: str
|
|
45
|
+
expected: str
|
|
46
|
+
# One sentence a store owner can read.
|
|
47
|
+
sentence: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
Check = Callable[[Client, Config], list[Finding]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_all(client: Client, config: Config) -> list[Finding]:
|
|
54
|
+
"""Run every registered check; a check that breaks becomes an error finding."""
|
|
55
|
+
findings: list[Finding] = []
|
|
56
|
+
for entry in sorted(entry_points(group=ENTRY_POINT_GROUP), key=lambda e: e.name):
|
|
57
|
+
try:
|
|
58
|
+
check: Check = entry.load()
|
|
59
|
+
findings.extend(check(client, config))
|
|
60
|
+
except Exception as exc: # noqa: BLE001 - a plugin's fault must not hide the others
|
|
61
|
+
findings.append(
|
|
62
|
+
Finding(
|
|
63
|
+
check=entry.name,
|
|
64
|
+
object=entry.value,
|
|
65
|
+
severity="error",
|
|
66
|
+
observed=f"{type(exc).__name__}: {exc}",
|
|
67
|
+
expected="the check runs",
|
|
68
|
+
sentence=f"The check {entry.name} could not run: {exc}",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return findings
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def failed(findings: list[Finding]) -> bool:
|
|
75
|
+
"""Whether the run should exit non-zero: no findings, or a fail or an error among them.
|
|
76
|
+
|
|
77
|
+
The built-in checks always find something; none means they are not
|
|
78
|
+
installed.
|
|
79
|
+
"""
|
|
80
|
+
return not findings or any(finding.severity in ("fail", "error") for finding in findings)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def now() -> datetime:
|
|
84
|
+
return datetime.now(UTC)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# dbt results: target/run_results.json with manifest.json for what each node is.
|
|
88
|
+
|
|
89
|
+
_STATUS: dict[str, Severity] = {
|
|
90
|
+
"pass": "pass",
|
|
91
|
+
"success": "pass",
|
|
92
|
+
"warn": "warn",
|
|
93
|
+
"fail": "fail",
|
|
94
|
+
"error": "error",
|
|
95
|
+
"skipped": "skip",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_REF = re.compile(r"""ref\(\s*['"]([^'"]+)['"]\s*\)""")
|
|
99
|
+
|
|
100
|
+
# The status of exposures and saved queries in `dbt build`: nothing ran,
|
|
101
|
+
# so there is nothing to report (the source of dbt 1.11.16, 2026-10-22).
|
|
102
|
+
_NOT_RUN = "no-op"
|
|
103
|
+
|
|
104
|
+
# What dbt says of an incremental model with `on_schema_change: fail` whose
|
|
105
|
+
# columns differ from the table's (dbt-core 1.11.16 with dbt-clickhouse
|
|
106
|
+
# 1.10.3, 2026-10-23: `status: error`, and the nodes after it skipped).
|
|
107
|
+
_SCHEMA_CHANGE = "The source and target schemas on this incremental model are out of sync"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def dbt_results(client: Client, config: Config) -> list[Finding]:
|
|
111
|
+
"""The models, tests and unit tests of the last dbt run of each package."""
|
|
112
|
+
findings: list[Finding] = []
|
|
113
|
+
for package in config.packages():
|
|
114
|
+
where = target(config, package)
|
|
115
|
+
try:
|
|
116
|
+
results = json.loads((where / "run_results.json").read_text())
|
|
117
|
+
manifest = json.loads((where / "manifest.json").read_text())
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
findings.append(
|
|
120
|
+
Finding(
|
|
121
|
+
check="dbt",
|
|
122
|
+
object=package,
|
|
123
|
+
severity="error",
|
|
124
|
+
observed="no dbt results",
|
|
125
|
+
expected="a dbt run of the package",
|
|
126
|
+
sentence=f"The {package} package has not been built yet: run `dactopus run`.",
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
continue
|
|
130
|
+
for result in results["results"]:
|
|
131
|
+
if result["status"] != _NOT_RUN:
|
|
132
|
+
findings.append(_dbt_finding(result, manifest, package))
|
|
133
|
+
return findings
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _dbt_finding(result: dict[str, Any], manifest: dict[str, Any], package: str) -> Finding:
|
|
137
|
+
unique_id = result["unique_id"]
|
|
138
|
+
kind = unique_id.split(".", 1)[0]
|
|
139
|
+
status = result["status"]
|
|
140
|
+
severity = _STATUS.get(status, "error")
|
|
141
|
+
message = result.get("message") or status
|
|
142
|
+
failures = result.get("failures")
|
|
143
|
+
observed = status if failures is None else f"{failures} rows"
|
|
144
|
+
if kind == "model":
|
|
145
|
+
table = manifest["nodes"][unique_id]["name"]
|
|
146
|
+
if severity == "error" and _SCHEMA_CHANGE in message:
|
|
147
|
+
sentence = (
|
|
148
|
+
f"{table} no longer has the columns its model builds (a release of the "
|
|
149
|
+
"models library changed them, or the table was changed by hand); run "
|
|
150
|
+
f"`dactopus run` once with `--full-refresh {package}` to rebuild it "
|
|
151
|
+
"from the input."
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
sentence = {
|
|
155
|
+
"pass": f"{table} was built.",
|
|
156
|
+
"skip": f"{table} was not built because an earlier step failed.",
|
|
157
|
+
}.get(severity, f"{table} could not be built: {message}")
|
|
158
|
+
return Finding("dbt", table, severity, status, "built", sentence)
|
|
159
|
+
if kind == "unit_test":
|
|
160
|
+
unit = manifest["unit_tests"][unique_id]
|
|
161
|
+
where = f"the unit test {unit['name']} of {unit['model']}"
|
|
162
|
+
sentence = {
|
|
163
|
+
"pass": f"{_cap(where)} passes.",
|
|
164
|
+
"skip": f"{_cap(where)} did not run because an earlier step failed.",
|
|
165
|
+
"fail": f"{_cap(where)} fails: the package's logic, not the data; report it.",
|
|
166
|
+
}.get(severity, f"{_cap(where)} could not run: {message}")
|
|
167
|
+
return Finding("dbt", unit["model"], severity, status, "pass", sentence)
|
|
168
|
+
if kind == "test":
|
|
169
|
+
node = manifest["nodes"][unique_id]
|
|
170
|
+
return _test_finding(node, manifest, severity, status, message, failures, observed)
|
|
171
|
+
return Finding("dbt", unique_id, severity, status, "pass", f"{unique_id}: {message}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _test_finding(
|
|
175
|
+
node: dict[str, Any],
|
|
176
|
+
manifest: dict[str, Any],
|
|
177
|
+
severity: Severity,
|
|
178
|
+
status: str,
|
|
179
|
+
message: str,
|
|
180
|
+
failures: int | None,
|
|
181
|
+
observed: str,
|
|
182
|
+
) -> Finding:
|
|
183
|
+
nodes = manifest["nodes"]
|
|
184
|
+
metadata = node.get("test_metadata")
|
|
185
|
+
column = node.get("column_name")
|
|
186
|
+
n = failures or 0
|
|
187
|
+
if metadata is None:
|
|
188
|
+
# A singular test: a query in the package's tests/ that must return no rows.
|
|
189
|
+
models = [nodes[d]["name"] for d in node["depends_on"]["nodes"] if d.startswith("model.")]
|
|
190
|
+
table = models[0] if models else node["name"]
|
|
191
|
+
what = node["name"].replace("_", " ")
|
|
192
|
+
where = f"the check {what} on {', '.join(models) or table}"
|
|
193
|
+
ok = f"{_cap(where)} passes."
|
|
194
|
+
bad = f"{_n(n, 'row')} of {table} {_verb(n, 'fails', 'fail')} the check {what}."
|
|
195
|
+
obj = table
|
|
196
|
+
else:
|
|
197
|
+
kind = metadata["name"]
|
|
198
|
+
kwargs = metadata.get("kwargs", {})
|
|
199
|
+
attached = node.get("attached_node")
|
|
200
|
+
table = nodes[attached]["name"] if attached else "a table"
|
|
201
|
+
obj = f"{table}.{column}" if column else table
|
|
202
|
+
where = f"the {kind} test on {obj}"
|
|
203
|
+
if kind == "not_null":
|
|
204
|
+
ok = f"{column} is set in every row of {table}."
|
|
205
|
+
bad = f"{column} is missing in {_n(n, 'row')} of {table}."
|
|
206
|
+
elif kind == "unique":
|
|
207
|
+
ok = f"No {column} repeats in {table}."
|
|
208
|
+
bad = (
|
|
209
|
+
f"{_n(n, 'value')} of {column} {_verb(n, 'appears', 'appear')} in more "
|
|
210
|
+
f"than one row of {table}."
|
|
211
|
+
)
|
|
212
|
+
elif kind == "accepted_values":
|
|
213
|
+
values = ", ".join(str(v) for v in kwargs.get("values", []))
|
|
214
|
+
ok = f"Every {column} of {table} is one of {values}."
|
|
215
|
+
bad = (
|
|
216
|
+
f"{_n(n, 'value')} of {column} in {table} {_verb(n, 'is', 'are')} not among "
|
|
217
|
+
f"{values}."
|
|
218
|
+
)
|
|
219
|
+
elif kind == "relationships":
|
|
220
|
+
match = _REF.search(str(kwargs.get("to", "")))
|
|
221
|
+
to = match.group(1) if match else str(kwargs.get("to"))
|
|
222
|
+
field = kwargs.get("field", column)
|
|
223
|
+
ok = f"Every {column} of {table} exists as {field} in {to}."
|
|
224
|
+
bad = (
|
|
225
|
+
f"{_n(n, 'row')} of {table} {_verb(n, 'has', 'have')} no matching {field} in {to}."
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
ok = f"{_cap(where)} passes."
|
|
229
|
+
bad = f"{_n(n, 'row')} {_verb(n, 'fails', 'fail')} {where}."
|
|
230
|
+
sentence = {
|
|
231
|
+
"pass": ok,
|
|
232
|
+
"warn": bad,
|
|
233
|
+
"fail": bad,
|
|
234
|
+
"skip": f"{_cap(where)} did not run because an earlier step failed.",
|
|
235
|
+
}.get(severity, f"{_cap(where)} could not run: {message}")
|
|
236
|
+
return Finding("dbt", obj, severity, observed, "0 rows", sentence)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _cap(text: str) -> str:
|
|
240
|
+
return text[:1].upper() + text[1:]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _n(count: int, noun: str) -> str:
|
|
244
|
+
return f"{count:,} {noun}{'' if count == 1 else 's'}"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _verb(count: int, singular: str, plural: str) -> str:
|
|
248
|
+
return singular if count == 1 else plural
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# Input freshness: target/sources.json of `dbt source freshness`; the package
|
|
252
|
+
# sets the thresholds in its sources.yml.
|
|
253
|
+
|
|
254
|
+
_FRESHNESS: dict[str, Severity] = {"pass": "pass", "warn": "warn", "error": "error"}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def freshness(client: Client, config: Config) -> list[Finding]:
|
|
258
|
+
"""How old the latest input row of each package is, against the package's thresholds."""
|
|
259
|
+
findings: list[Finding] = []
|
|
260
|
+
for package in config.packages():
|
|
261
|
+
path = target(config, package) / "sources.json"
|
|
262
|
+
try:
|
|
263
|
+
results = json.loads(path.read_text())["results"]
|
|
264
|
+
except FileNotFoundError:
|
|
265
|
+
findings.append(
|
|
266
|
+
Finding(
|
|
267
|
+
check="freshness",
|
|
268
|
+
object=package,
|
|
269
|
+
severity="error",
|
|
270
|
+
observed="no freshness results",
|
|
271
|
+
expected="a freshness run of the package",
|
|
272
|
+
sentence=f"The freshness of the {package} input is unknown: "
|
|
273
|
+
"run `dactopus run`.",
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
continue
|
|
277
|
+
if not results:
|
|
278
|
+
# dbt 1.11.16 writes an empty list when the source table is missing
|
|
279
|
+
# (2026-10-22).
|
|
280
|
+
findings.append(
|
|
281
|
+
Finding(
|
|
282
|
+
check="freshness",
|
|
283
|
+
object=package,
|
|
284
|
+
severity="error",
|
|
285
|
+
observed="no source table",
|
|
286
|
+
expected="the input table of the package",
|
|
287
|
+
sentence=f"The freshness of the {package} input could not be measured: "
|
|
288
|
+
"its input table is missing; see the dbt output and run `dactopus run`.",
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
for result in results:
|
|
292
|
+
findings.append(_freshness_finding(package, result))
|
|
293
|
+
return findings
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _freshness_finding(package: str, result: dict[str, Any]) -> Finding:
|
|
297
|
+
table = result["unique_id"].rsplit(".", 1)[-1]
|
|
298
|
+
status = result["status"]
|
|
299
|
+
severity = _FRESHNESS.get(status, "error")
|
|
300
|
+
criteria = result.get("criteria") or {}
|
|
301
|
+
warn_after = _period(criteria.get("warn_after"))
|
|
302
|
+
error_after = _period(criteria.get("error_after"))
|
|
303
|
+
expected = f"within {warn_after or 'any time'}"
|
|
304
|
+
if error_after:
|
|
305
|
+
expected += f", {error_after} at most"
|
|
306
|
+
if severity == "error" and status != "error":
|
|
307
|
+
return Finding(
|
|
308
|
+
"freshness",
|
|
309
|
+
table,
|
|
310
|
+
"error",
|
|
311
|
+
status,
|
|
312
|
+
expected,
|
|
313
|
+
f"The freshness of {table} ({package}) could not be measured: "
|
|
314
|
+
f"{result.get('error') or status}",
|
|
315
|
+
)
|
|
316
|
+
latest = datetime.fromisoformat(result["max_loaded_at"])
|
|
317
|
+
age = ago(result["max_loaded_at_time_ago_in_s"])
|
|
318
|
+
observed = f"latest {latest:%Y-%m-%d %H:%M} UTC, {age} ago"
|
|
319
|
+
if severity == "pass":
|
|
320
|
+
sentence = (
|
|
321
|
+
f"The {package} input is current: the latest row of {table} is {age} old "
|
|
322
|
+
f"({latest:%Y-%m-%d})."
|
|
323
|
+
)
|
|
324
|
+
elif severity == "warn":
|
|
325
|
+
sentence = (
|
|
326
|
+
f"No new rows of {table} ({package}) for {age}, the latest from "
|
|
327
|
+
f"{latest:%Y-%m-%d}; the package expects them within {warn_after}."
|
|
328
|
+
)
|
|
329
|
+
else:
|
|
330
|
+
sentence = (
|
|
331
|
+
f"No new rows of {table} ({package}) for {age}, the latest from "
|
|
332
|
+
f"{latest:%Y-%m-%d}; the package allows {error_after} at most. "
|
|
333
|
+
"Check that the source still exports and the load runs."
|
|
334
|
+
)
|
|
335
|
+
return Finding("freshness", table, severity, observed, expected, sentence)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _period(spec: dict[str, Any] | None) -> str | None:
|
|
339
|
+
if not spec or spec.get("count") is None:
|
|
340
|
+
return None
|
|
341
|
+
count, period = spec["count"], spec["period"]
|
|
342
|
+
return f"{count} {period}{'s' if count != 1 else ''}"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def ago(seconds: float) -> str:
|
|
346
|
+
"""An age in words: a minute, 5 minutes, 3 hours, 2 days."""
|
|
347
|
+
minutes = seconds / 60
|
|
348
|
+
if minutes < 2:
|
|
349
|
+
return "a minute"
|
|
350
|
+
if minutes < 120:
|
|
351
|
+
return f"{minutes:.0f} minutes"
|
|
352
|
+
hours = minutes / 60
|
|
353
|
+
if hours < 48:
|
|
354
|
+
return f"{hours:.0f} hours"
|
|
355
|
+
return f"{hours / 24:.0f} days"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# Rows per day: the tables each package declares as daily in its
|
|
359
|
+
# deployment.yml, counted by their date column, over the last 7 days that
|
|
360
|
+
# are over against the 4 weeks before. A day without rows is a finding only
|
|
361
|
+
# in a table the package marks as a gap: a day without events or orders is
|
|
362
|
+
# a gap, a day without refunds is not.
|
|
363
|
+
|
|
364
|
+
WEEK = 7
|
|
365
|
+
PAST_WEEKS = 4
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@dataclass(frozen=True)
|
|
369
|
+
class Volume:
|
|
370
|
+
"""Rows per day of one table over its last 7 days that are over, with past weeks."""
|
|
371
|
+
|
|
372
|
+
package: str
|
|
373
|
+
table: str
|
|
374
|
+
first: date | None
|
|
375
|
+
# The day the week ends at; None when the table has no day that is over.
|
|
376
|
+
latest: date | None
|
|
377
|
+
# The last 7 days ending at latest, 0 for a day without rows.
|
|
378
|
+
days: dict[date, int]
|
|
379
|
+
# Per day of the week: the median of the same weekday over the 4 weeks
|
|
380
|
+
# before, None when the table has no history that far.
|
|
381
|
+
typical: dict[date, int | None]
|
|
382
|
+
# Why the table could not be counted, when it could not.
|
|
383
|
+
error: str | None = None
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def gaps(self) -> list[date]:
|
|
387
|
+
"""Days without rows where the same weekday had rows, or no history to say."""
|
|
388
|
+
return [day for day, rows in self.days.items() if rows == 0 and self.typical[day] != 0]
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def quiet(self) -> list[date]:
|
|
392
|
+
"""Days without rows, as the same weekday in the weeks before: a small shop's usual."""
|
|
393
|
+
return [day for day, rows in self.days.items() if rows == 0 and self.typical[day] == 0]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def volumes(client: Client, config: Config, at: datetime) -> list[Volume]:
|
|
397
|
+
"""Rows per day of each counted table of the configured packages."""
|
|
398
|
+
found: list[Volume] = []
|
|
399
|
+
database = config.clickhouse.database
|
|
400
|
+
for package in config.packages():
|
|
401
|
+
declared = _deployment(config, package)
|
|
402
|
+
last = _last_day(config, declared, at)
|
|
403
|
+
for counted in declared.daily:
|
|
404
|
+
table = counted.table
|
|
405
|
+
try:
|
|
406
|
+
found.append(_volume(client, database, package, table, counted.date, last))
|
|
407
|
+
except ClickHouseError as exc:
|
|
408
|
+
found.append(Volume(package, table, None, None, {}, {}, _first_line(exc)))
|
|
409
|
+
return found
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _deployment(config: Config, package: str) -> library.Deployment:
|
|
413
|
+
return library.deployment(config.models, library.resolve(config.models), package)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _last_day(config: Config, declared: library.Deployment, at: datetime) -> date | None:
|
|
417
|
+
"""Yesterday in store.timezone; None for a package whose days are all over.
|
|
418
|
+
|
|
419
|
+
A package without a time zone variable has days that end where its
|
|
420
|
+
source says (GA4 is read in daily tables, each a day that is over). A
|
|
421
|
+
package with one is built in store.timezone, and its day is over at
|
|
422
|
+
midnight there.
|
|
423
|
+
"""
|
|
424
|
+
if "time_zone" not in declared.vars:
|
|
425
|
+
return None
|
|
426
|
+
return at.astimezone(ZoneInfo(config.store.timezone)).date() - timedelta(days=1)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _first_line(exc: Exception) -> str:
|
|
430
|
+
return str(exc).splitlines()[0]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _volume(
|
|
434
|
+
client: Client, database: str, package: str, table: str, column: str, last: date | None
|
|
435
|
+
) -> Volume:
|
|
436
|
+
qualified = f"`{database}`.`{table}`"
|
|
437
|
+
rows, first, latest = client.query(
|
|
438
|
+
f"SELECT count(), min(`{column}`), max(`{column}`) FROM {qualified}"
|
|
439
|
+
).result_rows[0]
|
|
440
|
+
if not rows:
|
|
441
|
+
return Volume(package, table, None, None, {}, {})
|
|
442
|
+
end = latest if last is None else last
|
|
443
|
+
if end < first:
|
|
444
|
+
# Rows only for a day that is not over yet.
|
|
445
|
+
return Volume(package, table, first, None, {}, {})
|
|
446
|
+
span = WEEK * (PAST_WEEKS + 1)
|
|
447
|
+
counted = dict(
|
|
448
|
+
client.query(
|
|
449
|
+
f"SELECT `{column}` AS day, count() FROM {qualified} "
|
|
450
|
+
f"WHERE `{column}` > {{end:Date}} - INTERVAL {span} DAY "
|
|
451
|
+
f"AND `{column}` <= {{end:Date}} GROUP BY day",
|
|
452
|
+
parameters={"end": end},
|
|
453
|
+
).result_rows
|
|
454
|
+
)
|
|
455
|
+
window = [end - timedelta(days=i) for i in range(WEEK - 1, -1, -1)]
|
|
456
|
+
days = {day: int(counted.get(day, 0)) for day in window if day >= first}
|
|
457
|
+
typical: dict[date, int | None] = {}
|
|
458
|
+
for day in days:
|
|
459
|
+
past = [day - timedelta(weeks=w) for w in range(1, PAST_WEEKS + 1)]
|
|
460
|
+
past = [p for p in past if p >= first]
|
|
461
|
+
typical[day] = int(statistics.median(counted.get(p, 0) for p in past)) if past else None
|
|
462
|
+
return Volume(package, table, first, end, days, typical)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def rows_per_day(client: Client, config: Config) -> list[Finding]:
|
|
466
|
+
"""A day without rows in the last 7 days that are over of a table where that is a gap."""
|
|
467
|
+
findings: list[Finding] = []
|
|
468
|
+
at = now()
|
|
469
|
+
for package in config.packages():
|
|
470
|
+
declared = _deployment(config, package)
|
|
471
|
+
last = _last_day(config, declared, at)
|
|
472
|
+
for counted in declared.daily:
|
|
473
|
+
if not counted.gap:
|
|
474
|
+
continue
|
|
475
|
+
table, column = counted.table, counted.date
|
|
476
|
+
try:
|
|
477
|
+
volume = _volume(client, config.clickhouse.database, package, table, column, last)
|
|
478
|
+
except ClickHouseError as exc:
|
|
479
|
+
findings.append(
|
|
480
|
+
Finding(
|
|
481
|
+
"rows per day",
|
|
482
|
+
table,
|
|
483
|
+
"error",
|
|
484
|
+
_first_line(exc),
|
|
485
|
+
"rows every day",
|
|
486
|
+
f"{table} ({package}) could not be counted; build it with `dactopus run`.",
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
continue
|
|
490
|
+
if volume.first is None:
|
|
491
|
+
findings.append(
|
|
492
|
+
Finding(
|
|
493
|
+
"rows per day",
|
|
494
|
+
table,
|
|
495
|
+
"warn",
|
|
496
|
+
"no rows",
|
|
497
|
+
"rows every day",
|
|
498
|
+
f"{table} ({package}) has no rows yet.",
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
continue
|
|
502
|
+
if volume.latest is None:
|
|
503
|
+
findings.append(
|
|
504
|
+
Finding(
|
|
505
|
+
"rows per day",
|
|
506
|
+
table,
|
|
507
|
+
"pass",
|
|
508
|
+
"rows only for today",
|
|
509
|
+
"rows every day",
|
|
510
|
+
f"{table} ({package}) has rows only for today so far; a day is "
|
|
511
|
+
"counted once it is over.",
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
continue
|
|
515
|
+
gaps = volume.gaps
|
|
516
|
+
first_day = min(volume.days)
|
|
517
|
+
if gaps:
|
|
518
|
+
others = [rows for rows in volume.days.values() if rows]
|
|
519
|
+
sentence = f"{table} ({package}) has no rows for {_days(gaps)}"
|
|
520
|
+
if others:
|
|
521
|
+
spread = (
|
|
522
|
+
f"{min(others):,}"
|
|
523
|
+
if min(others) == max(others)
|
|
524
|
+
else f"{min(others):,} to {max(others):,}"
|
|
525
|
+
)
|
|
526
|
+
sentence += f"; the other days of that week have {spread}"
|
|
527
|
+
findings.append(
|
|
528
|
+
Finding(
|
|
529
|
+
"rows per day",
|
|
530
|
+
table,
|
|
531
|
+
"warn",
|
|
532
|
+
f"no rows on {', '.join(day.isoformat() for day in gaps)}",
|
|
533
|
+
"rows every day",
|
|
534
|
+
f"{sentence}.",
|
|
535
|
+
)
|
|
536
|
+
)
|
|
537
|
+
elif volume.quiet:
|
|
538
|
+
quiet = volume.quiet
|
|
539
|
+
findings.append(
|
|
540
|
+
Finding(
|
|
541
|
+
"rows per day",
|
|
542
|
+
table,
|
|
543
|
+
"pass",
|
|
544
|
+
f"no rows on {', '.join(day.isoformat() for day in quiet)}, as before",
|
|
545
|
+
"rows every day the same weekday had rows",
|
|
546
|
+
f"{table} ({package}) has no rows for {_days(quiet)}, as on the same "
|
|
547
|
+
f"weekday in the {PAST_WEEKS} weeks before.",
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
else:
|
|
551
|
+
findings.append(
|
|
552
|
+
Finding(
|
|
553
|
+
"rows per day",
|
|
554
|
+
table,
|
|
555
|
+
"pass",
|
|
556
|
+
f"rows on every day from {first_day} to {volume.latest}",
|
|
557
|
+
"rows every day",
|
|
558
|
+
f"{table} ({package}) has rows on every day from {first_day} "
|
|
559
|
+
f"to {volume.latest}.",
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
return findings
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _days(days: list[date]) -> str:
|
|
566
|
+
if len(days) == 1:
|
|
567
|
+
return days[0].isoformat()
|
|
568
|
+
return ", ".join(day.isoformat() for day in days[:-1]) + f" and {days[-1].isoformat()}"
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# Time zone: the daily tables with a time column, counted for rows whose
|
|
572
|
+
# date is not the day of their time in store.timezone. A package with a
|
|
573
|
+
# time zone variable is built in that zone, so such a row means tables
|
|
574
|
+
# built in another one. A package without one has the days of its source
|
|
575
|
+
# (GA4: the property's zone), counted over the days rows_per_day reads.
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def time_zone(client: Client, config: Config) -> list[Finding]:
|
|
579
|
+
"""Whether the days of each daily table are those of store.timezone."""
|
|
580
|
+
findings: list[Finding] = []
|
|
581
|
+
zone = config.store.timezone
|
|
582
|
+
expected = f"every row on its day in {zone}"
|
|
583
|
+
for package in config.packages():
|
|
584
|
+
declared = _deployment(config, package)
|
|
585
|
+
built_in_zone = "time_zone" in declared.vars
|
|
586
|
+
for counted in declared.daily:
|
|
587
|
+
if counted.time is None:
|
|
588
|
+
continue
|
|
589
|
+
table = counted.table
|
|
590
|
+
days = None if built_in_zone else WEEK * (PAST_WEEKS + 1)
|
|
591
|
+
try:
|
|
592
|
+
off = misdated(client, config.clickhouse.database, counted, zone, days)
|
|
593
|
+
except ClickHouseError as exc:
|
|
594
|
+
findings.append(
|
|
595
|
+
Finding(
|
|
596
|
+
"time zone",
|
|
597
|
+
table,
|
|
598
|
+
"error",
|
|
599
|
+
_first_line(exc),
|
|
600
|
+
expected,
|
|
601
|
+
f"The days of {table} ({package}) could not be counted; check that "
|
|
602
|
+
"the ClickHouse user can read it and run again.",
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
continue
|
|
606
|
+
if not off.of:
|
|
607
|
+
findings.append(
|
|
608
|
+
Finding(
|
|
609
|
+
"time zone",
|
|
610
|
+
table,
|
|
611
|
+
"skip",
|
|
612
|
+
"no rows",
|
|
613
|
+
expected,
|
|
614
|
+
f"{table} ({package}) has no rows to check the days of.",
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
continue
|
|
618
|
+
observed = f"{off.rows:,} of {off.of:,} rows on another day"
|
|
619
|
+
if days is not None:
|
|
620
|
+
observed += f", from {off.first} to {off.last}"
|
|
621
|
+
if not off.rows:
|
|
622
|
+
findings.append(
|
|
623
|
+
Finding(
|
|
624
|
+
"time zone",
|
|
625
|
+
table,
|
|
626
|
+
"pass",
|
|
627
|
+
observed,
|
|
628
|
+
expected,
|
|
629
|
+
f"Every row of {table} ({package}) is on its day in {zone}.",
|
|
630
|
+
)
|
|
631
|
+
)
|
|
632
|
+
elif built_in_zone:
|
|
633
|
+
findings.append(
|
|
634
|
+
Finding(
|
|
635
|
+
"time zone",
|
|
636
|
+
table,
|
|
637
|
+
"fail",
|
|
638
|
+
observed,
|
|
639
|
+
expected,
|
|
640
|
+
f"{_n(off.rows, 'row')} of {table} ({package}) "
|
|
641
|
+
f"{_verb(off.rows, 'is', 'are')} on another day than in {zone}: the "
|
|
642
|
+
"table was built in another time zone. Run `dactopus run "
|
|
643
|
+
f"--full-refresh {package}` once to rebuild it.",
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
else:
|
|
647
|
+
findings.append(
|
|
648
|
+
Finding(
|
|
649
|
+
"time zone",
|
|
650
|
+
table,
|
|
651
|
+
"warn",
|
|
652
|
+
observed,
|
|
653
|
+
expected,
|
|
654
|
+
f"{_share(off.rows, off.of)} of the rows of {table} ({package}) from "
|
|
655
|
+
f"{off.first} to {off.last} are on another day in {zone}: the source "
|
|
656
|
+
'counts its days in another time zone, so "today" in an agent\'s '
|
|
657
|
+
"question and days compared with other packages are off by the "
|
|
658
|
+
"difference between the zones.",
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
return findings
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _share(rows: int, of: int) -> str:
|
|
665
|
+
"""The share of rows in of, never 0.0% when a row is counted nor 100.0% when one is not."""
|
|
666
|
+
share = rows / of
|
|
667
|
+
if share < 0.001:
|
|
668
|
+
return "under 0.1%"
|
|
669
|
+
if share > 0.999 and rows < of:
|
|
670
|
+
return "over 99.9%"
|
|
671
|
+
return f"{share:.1%}"
|