view-scheduler 0.4.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.
- view_scheduler/__init__.py +34 -0
- view_scheduler/advice.py +162 -0
- view_scheduler/cli.py +71 -0
- view_scheduler/client.py +222 -0
- view_scheduler/config.py +29 -0
- view_scheduler/gate.py +278 -0
- view_scheduler/history.py +145 -0
- view_scheduler/models.py +36 -0
- view_scheduler/obs.py +38 -0
- view_scheduler/recommend.py +174 -0
- view_scheduler/report.py +159 -0
- view_scheduler/run.py +51 -0
- view_scheduler/walker.py +140 -0
- view_scheduler-0.4.0.dist-info/METADATA +230 -0
- view_scheduler-0.4.0.dist-info/RECORD +19 -0
- view_scheduler-0.4.0.dist-info/WHEEL +5 -0
- view_scheduler-0.4.0.dist-info/entry_points.txt +2 -0
- view_scheduler-0.4.0.dist-info/licenses/LICENSE +21 -0
- view_scheduler-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""View materialization scheduler.
|
|
2
|
+
|
|
3
|
+
Two tools for a Unity Catalog view:
|
|
4
|
+
|
|
5
|
+
* `advise` — suggest the time each day the view can run with every upstream
|
|
6
|
+
job already finished. Computed from history. ADVISORY.
|
|
7
|
+
* `can_refresh` — a runtime GATE. Right now, has every dependency of this
|
|
8
|
+
view refreshed recently enough? READY or BLOCKED.
|
|
9
|
+
|
|
10
|
+
READ ONLY. This package never writes, alters, optimizes, vacuums or drops a
|
|
11
|
+
user table. Metadata and history reads only.
|
|
12
|
+
|
|
13
|
+
`advise` predicts. `can_refresh` checks. Use the first to set the schedule,
|
|
14
|
+
the second to guard the run. See README.md.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .advice import Advice, DependencyRun, advise
|
|
18
|
+
from .client import Workspace
|
|
19
|
+
from .config import Config
|
|
20
|
+
from .gate import Dependency, GateResult, ViewResult, can_refresh
|
|
21
|
+
from .run import analyze
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"advise",
|
|
25
|
+
"Advice",
|
|
26
|
+
"analyze",
|
|
27
|
+
"can_refresh",
|
|
28
|
+
"Config",
|
|
29
|
+
"Dependency",
|
|
30
|
+
"DependencyRun",
|
|
31
|
+
"GateResult",
|
|
32
|
+
"ViewResult",
|
|
33
|
+
"Workspace",
|
|
34
|
+
]
|
view_scheduler/advice.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Advise when a view can run, as an object you can read or log.
|
|
2
|
+
|
|
3
|
+
`advise()` is the easy front door to the analyzer. It prints a plain report and
|
|
4
|
+
returns an `Advice` that carries the same numbers as fields.
|
|
5
|
+
|
|
6
|
+
from view_scheduler import advise
|
|
7
|
+
|
|
8
|
+
advice = advise("sales.gold.customer_360") # WAREHOUSE_ID from the env
|
|
9
|
+
print(advice.safe_time) # 06:40 or None
|
|
10
|
+
|
|
11
|
+
`analyze()` is the low-level call. It returns a tuple of raw pieces. Use it
|
|
12
|
+
only when you need the per-table history objects.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import date, datetime, time
|
|
19
|
+
|
|
20
|
+
from . import report
|
|
21
|
+
from .client import Workspace, workspace_from_env
|
|
22
|
+
from .config import DEFAULT, Config
|
|
23
|
+
from .models import Skipped
|
|
24
|
+
from .recommend import Recommendation
|
|
25
|
+
from .run import collect
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class DependencyRun:
|
|
30
|
+
"""One upstream job, and what its history looked like."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
runs: str # "daily", "Mondays", "never", ...
|
|
34
|
+
usual: time | None # its usual finish time
|
|
35
|
+
latest: time | None # its latest finish time
|
|
36
|
+
days: int # days it ran in the period
|
|
37
|
+
missed: int # days it did not run
|
|
38
|
+
note: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Advice:
|
|
43
|
+
"""When a view can safely run. One object, ready to read or log."""
|
|
44
|
+
|
|
45
|
+
view: str
|
|
46
|
+
safe_time: time | None
|
|
47
|
+
earlier_time: time | None
|
|
48
|
+
next_day: bool
|
|
49
|
+
earlier_next_day: bool
|
|
50
|
+
days_used: int
|
|
51
|
+
period_days: int
|
|
52
|
+
timezone: str
|
|
53
|
+
dependencies: tuple[DependencyRun, ...] = ()
|
|
54
|
+
unreadable: tuple[Skipped, ...] = ()
|
|
55
|
+
finished_by_day: tuple[tuple[date, time], ...] = ()
|
|
56
|
+
warnings: tuple[str, ...] = ()
|
|
57
|
+
text: str = field(default="")
|
|
58
|
+
|
|
59
|
+
def render(self) -> str:
|
|
60
|
+
return self.text
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
"""JSON-ready. Safe to log from a job."""
|
|
64
|
+
return {
|
|
65
|
+
"view": self.view,
|
|
66
|
+
"safe_time": _clock(self.safe_time),
|
|
67
|
+
"earlier_time": _clock(self.earlier_time),
|
|
68
|
+
"next_day": self.next_day,
|
|
69
|
+
"days_used": self.days_used,
|
|
70
|
+
"period_days": self.period_days,
|
|
71
|
+
"timezone": self.timezone,
|
|
72
|
+
"dependencies": [
|
|
73
|
+
{
|
|
74
|
+
"name": row.name,
|
|
75
|
+
"runs": row.runs,
|
|
76
|
+
"usual": _clock(row.usual),
|
|
77
|
+
"latest": _clock(row.latest),
|
|
78
|
+
"days": row.days,
|
|
79
|
+
"missed": row.missed,
|
|
80
|
+
}
|
|
81
|
+
for row in self.dependencies
|
|
82
|
+
],
|
|
83
|
+
"unreadable": [
|
|
84
|
+
{"name": item.name, "reason": item.reason}
|
|
85
|
+
for item in self.unreadable
|
|
86
|
+
],
|
|
87
|
+
"finished_by_day": [
|
|
88
|
+
{"day": day.isoformat(), "time": moment.strftime("%H:%M")}
|
|
89
|
+
for day, moment in self.finished_by_day
|
|
90
|
+
],
|
|
91
|
+
"warnings": list(self.warnings),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def __str__(self) -> str:
|
|
95
|
+
return self.text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def advise(
|
|
99
|
+
view: str,
|
|
100
|
+
ws: Workspace | None = None,
|
|
101
|
+
config: Config | None = None,
|
|
102
|
+
*,
|
|
103
|
+
today: date | None = None,
|
|
104
|
+
quiet: bool = False,
|
|
105
|
+
) -> Advice:
|
|
106
|
+
"""Suggest the time each day this view can run with every input ready.
|
|
107
|
+
|
|
108
|
+
`ws` defaults to a Workspace built from the WAREHOUSE_ID environment
|
|
109
|
+
variable. Pass `today` to make a test reproducible. `quiet=True` suppresses
|
|
110
|
+
the printed report; the result is returned either way.
|
|
111
|
+
"""
|
|
112
|
+
settings = config or DEFAULT
|
|
113
|
+
workspace = ws or workspace_from_env()
|
|
114
|
+
moment = today or datetime.now(settings.tz).date()
|
|
115
|
+
|
|
116
|
+
result, _, skipped = collect(view, workspace, settings, moment)
|
|
117
|
+
advice = _build(view, result, skipped, settings)
|
|
118
|
+
|
|
119
|
+
if not quiet:
|
|
120
|
+
print(advice)
|
|
121
|
+
return advice
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build(view: str, result: Recommendation, skipped: list[Skipped],
|
|
125
|
+
config: Config) -> Advice:
|
|
126
|
+
rows = tuple(
|
|
127
|
+
DependencyRun(
|
|
128
|
+
name=row.name,
|
|
129
|
+
runs=row.refreshes,
|
|
130
|
+
usual=row.usual,
|
|
131
|
+
latest=row.worst,
|
|
132
|
+
days=row.observed_days,
|
|
133
|
+
missed=row.missed,
|
|
134
|
+
)
|
|
135
|
+
for row in sorted(result.rows, key=lambda r: r.name)
|
|
136
|
+
)
|
|
137
|
+
earlier = (result.earlier
|
|
138
|
+
if result.earlier and result.earlier != result.safe else None)
|
|
139
|
+
finished = tuple(
|
|
140
|
+
(day, moment)
|
|
141
|
+
for day, moment in sorted(result.all_clear.items(),
|
|
142
|
+
key=lambda kv: kv[1].time(), reverse=True)
|
|
143
|
+
)
|
|
144
|
+
return Advice(
|
|
145
|
+
view=view,
|
|
146
|
+
safe_time=result.safe,
|
|
147
|
+
earlier_time=earlier,
|
|
148
|
+
next_day=bool(result.safe and result.safe_next_day),
|
|
149
|
+
earlier_next_day=bool(earlier and result.earlier_next_day),
|
|
150
|
+
days_used=result.n_days,
|
|
151
|
+
period_days=result.span_days,
|
|
152
|
+
timezone=config.timezone,
|
|
153
|
+
dependencies=rows,
|
|
154
|
+
unreadable=tuple(skipped),
|
|
155
|
+
finished_by_day=finished,
|
|
156
|
+
warnings=tuple(report.warnings(result, skipped)),
|
|
157
|
+
text=report.render(view, result, skipped, config.timezone),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _clock(value: time | None) -> str | None:
|
|
162
|
+
return value.strftime("%H:%M") if value else None
|
view_scheduler/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Command line entry point: `view-scheduler <catalog.schema.view>`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import obs
|
|
11
|
+
from .advice import advise
|
|
12
|
+
from .client import Workspace
|
|
13
|
+
from .config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="view-scheduler",
|
|
19
|
+
description="Suggest the time each day a Unity Catalog view can run with "
|
|
20
|
+
"every upstream job already finished.",
|
|
21
|
+
epilog="ADVISORY: this suggests a time from history. It does not check "
|
|
22
|
+
"today. For a runtime check, use can_refresh() from the library.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("view", help="fully qualified view, e.g. catalog.schema.view")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--warehouse-id", default=os.environ.get("WAREHOUSE_ID"),
|
|
27
|
+
help="SQL warehouse to run history reads against "
|
|
28
|
+
"(default: $WAREHOUSE_ID)")
|
|
29
|
+
# The three analysis settings. The gate's fresh_hours is library-only.
|
|
30
|
+
parser.add_argument("--days", type=int, default=Config.days_history,
|
|
31
|
+
metavar="N", help="days of history to read")
|
|
32
|
+
parser.add_argument("--buffer", type=int, default=Config.buffer_min,
|
|
33
|
+
metavar="MIN", help="safety gap added to the safe time")
|
|
34
|
+
parser.add_argument("--timezone", default=Config.timezone,
|
|
35
|
+
help="timezone the advice is expressed in")
|
|
36
|
+
parser.add_argument("--json", action="store_true",
|
|
37
|
+
help="print the machine-readable JSON instead of the report")
|
|
38
|
+
parser.add_argument("--quiet", action="store_true",
|
|
39
|
+
help="suppress the per-call log; print only the report")
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv: list[str] | None = None) -> int:
|
|
44
|
+
args = build_parser().parse_args(argv)
|
|
45
|
+
|
|
46
|
+
if not args.warehouse_id:
|
|
47
|
+
# Fail on the missing input rather than deep inside the first SQL call.
|
|
48
|
+
print("error: no warehouse. Pass --warehouse-id or set $WAREHOUSE_ID.",
|
|
49
|
+
file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
|
|
52
|
+
obs.setup(level=40 if args.quiet else 20)
|
|
53
|
+
config = Config(days_history=args.days, buffer_min=args.buffer,
|
|
54
|
+
timezone=args.timezone)
|
|
55
|
+
workspace = Workspace(warehouse_id=args.warehouse_id)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
advice = advise(args.view, workspace, config, quiet=True)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
if args.json:
|
|
64
|
+
print(json.dumps(advice.to_dict(), indent=2))
|
|
65
|
+
else:
|
|
66
|
+
print("\n" + str(advice) + "\n")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
raise SystemExit(main())
|
view_scheduler/client.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""I/O layer. Read-only: metadata lookups, DESCRIBE HISTORY, event log queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from databricks.sdk import WorkspaceClient
|
|
12
|
+
from databricks.sdk.service.sql import ExecuteStatementRequestOnWaitTimeout
|
|
13
|
+
|
|
14
|
+
from . import obs
|
|
15
|
+
from .history import qualifying
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def quote_name(full_name: str) -> str:
|
|
19
|
+
"""Backtick each part of a dotted Unity Catalog name.
|
|
20
|
+
|
|
21
|
+
A hyphen, a space, or a reserved word is a syntax error unless the name is
|
|
22
|
+
quoted, and an unquoted name is an injection surface. An internal backtick
|
|
23
|
+
is doubled so it cannot close the identifier early.
|
|
24
|
+
"""
|
|
25
|
+
parts = [part.strip() for part in str(full_name).split(".")]
|
|
26
|
+
if not parts or any(not part for part in parts):
|
|
27
|
+
raise ValueError(f"not a Unity Catalog name: {full_name!r}")
|
|
28
|
+
return ".".join("`" + part.replace("`", "``") + "`" for part in parts)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Workspace:
|
|
32
|
+
"""Thin wrapper over the SDK. One method per external call we make."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: WorkspaceClient | None = None,
|
|
35
|
+
warehouse_id: str | None = None):
|
|
36
|
+
self.w = client or WorkspaceClient()
|
|
37
|
+
self.warehouse_id = warehouse_id
|
|
38
|
+
|
|
39
|
+
# --- Unity Catalog -------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def get_table(self, full_name: str) -> dict:
|
|
42
|
+
"""Raw GET for one table's metadata, including view_dependencies."""
|
|
43
|
+
path = quote(full_name, safe=".")
|
|
44
|
+
with obs.call("uc.tables.get", full_name):
|
|
45
|
+
return self.w.api_client.do(
|
|
46
|
+
"GET", f"/api/2.1/unity-catalog/tables/{path}")
|
|
47
|
+
|
|
48
|
+
# --- SQL -----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def sql(self, statement: str, target: str) -> list[dict[str, Any]]:
|
|
51
|
+
"""Run a read-only statement. Return every row as a dict of strings.
|
|
52
|
+
|
|
53
|
+
Follows result chunking to the end, and refuses a truncated result, so a
|
|
54
|
+
caller never silently sees only the first page of a large result.
|
|
55
|
+
"""
|
|
56
|
+
if self.warehouse_id is None:
|
|
57
|
+
raise ValueError("warehouse_id is required for SQL calls")
|
|
58
|
+
|
|
59
|
+
with obs.call("sql", target):
|
|
60
|
+
resp = self.w.statement_execution.execute_statement(
|
|
61
|
+
warehouse_id=self.warehouse_id,
|
|
62
|
+
statement=statement,
|
|
63
|
+
wait_timeout="50s",
|
|
64
|
+
on_wait_timeout=ExecuteStatementRequestOnWaitTimeout.CANCEL,
|
|
65
|
+
)
|
|
66
|
+
state = resp.status.state.value if resp.status and resp.status.state else "?"
|
|
67
|
+
if state != "SUCCEEDED":
|
|
68
|
+
message = (resp.status.error.message
|
|
69
|
+
if resp.status and resp.status.error else state)
|
|
70
|
+
raise RuntimeError(message)
|
|
71
|
+
|
|
72
|
+
if resp.manifest and resp.manifest.truncated:
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"the warehouse truncated the result for {target}; "
|
|
75
|
+
f"the answer would be incomplete")
|
|
76
|
+
|
|
77
|
+
columns = [c.name for c in resp.manifest.schema.columns]
|
|
78
|
+
rows = list((resp.result.data_array if resp.result else None) or [])
|
|
79
|
+
chunk = resp.result.next_chunk_index if resp.result else None
|
|
80
|
+
while chunk is not None:
|
|
81
|
+
page = self.w.statement_execution.get_statement_result_chunk_n(
|
|
82
|
+
resp.statement_id, chunk)
|
|
83
|
+
rows.extend(page.data_array or [])
|
|
84
|
+
chunk = page.next_chunk_index
|
|
85
|
+
|
|
86
|
+
return [dict(zip(columns, row)) for row in rows]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def workspace_from_env() -> "Workspace":
|
|
90
|
+
"""Build a Workspace from $WAREHOUSE_ID, or say exactly what is missing."""
|
|
91
|
+
warehouse = os.environ.get("WAREHOUSE_ID")
|
|
92
|
+
if not warehouse:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"no warehouse: pass Workspace(warehouse_id='...') "
|
|
95
|
+
"or set the WAREHOUSE_ID environment variable")
|
|
96
|
+
return Workspace(warehouse_id=warehouse)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def failure_category(exc: Exception) -> str:
|
|
100
|
+
"""Classify an SDK/SQL error as 'permission', 'not_found', 'mv_no_history', or 'other'."""
|
|
101
|
+
text = str(exc).lower()
|
|
102
|
+
if ("permission" in text or "does not have" in text or "403" in text
|
|
103
|
+
or "401" in text or "unauthorized" in text):
|
|
104
|
+
return "permission"
|
|
105
|
+
if "does not exist" in text or "not found" in text or "404" in text:
|
|
106
|
+
return "not_found"
|
|
107
|
+
if "expect_table_not_view" in text:
|
|
108
|
+
return "mv_no_history"
|
|
109
|
+
return "other"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_json_field(value: Any) -> dict | None:
|
|
113
|
+
"""Parse a JSON string or pass through a dict. None if absent or unparseable."""
|
|
114
|
+
if value is None:
|
|
115
|
+
return None
|
|
116
|
+
if isinstance(value, dict):
|
|
117
|
+
return value
|
|
118
|
+
try:
|
|
119
|
+
parsed = json.loads(value)
|
|
120
|
+
except (TypeError, ValueError):
|
|
121
|
+
return None
|
|
122
|
+
return parsed if isinstance(parsed, dict) else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- history readers ---------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def _from_epoch(value):
|
|
128
|
+
"""Seconds since the epoch → an aware UTC datetime.
|
|
129
|
+
|
|
130
|
+
Timestamps are read as epochs rather than as the API's rendered string
|
|
131
|
+
because a Spark TIMESTAMP is an instant, and `unix_timestamp` returns that
|
|
132
|
+
instant regardless of the warehouse's session timezone. The rendered string
|
|
133
|
+
carries a "Z" suffix whose accuracy depends on that session setting, which
|
|
134
|
+
is warehouse configuration this tool does not control. Verified identical to
|
|
135
|
+
the string path on a UTC warehouse; correct on any other.
|
|
136
|
+
"""
|
|
137
|
+
from datetime import timezone
|
|
138
|
+
return datetime.fromtimestamp(int(value), tz=timezone.utc)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def read_history(ws: "Workspace", table: str, *,
|
|
142
|
+
with_metrics: bool = True) -> list:
|
|
143
|
+
"""Every commit for a physical Delta table, newest first.
|
|
144
|
+
|
|
145
|
+
`with_metrics=False` drops the wide operationMetrics column. The gate only
|
|
146
|
+
classifies commits, so it never needs it; analyze() does.
|
|
147
|
+
"""
|
|
148
|
+
from .models import Commit
|
|
149
|
+
metrics = ", operationMetrics" if with_metrics else ""
|
|
150
|
+
rows = ws.sql(
|
|
151
|
+
"SELECT version, unix_timestamp(timestamp) AS ts_epoch, operation, "
|
|
152
|
+
f"operationParameters{metrics} "
|
|
153
|
+
f"FROM (DESCRIBE HISTORY {quote_name(table)}) "
|
|
154
|
+
"ORDER BY version DESC",
|
|
155
|
+
target=table)
|
|
156
|
+
return [
|
|
157
|
+
Commit(
|
|
158
|
+
version=int(r.get("version") or 0),
|
|
159
|
+
timestamp=_from_epoch(r["ts_epoch"]),
|
|
160
|
+
operation=str(r.get("operation") or ""),
|
|
161
|
+
params=parse_json_field(r.get("operationParameters")) or {},
|
|
162
|
+
metrics=(parse_json_field(r.get("operationMetrics"))
|
|
163
|
+
if with_metrics else None),
|
|
164
|
+
)
|
|
165
|
+
for r in rows
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def read_mv_refreshes(ws: "Workspace", mv: str) -> list:
|
|
170
|
+
"""Completed MV refreshes from the pipeline event log.
|
|
171
|
+
|
|
172
|
+
DESCRIBE HISTORY is rejected on a materialized view, so this is the only
|
|
173
|
+
way to see when it last refreshed.
|
|
174
|
+
"""
|
|
175
|
+
from .models import Commit
|
|
176
|
+
statement = (
|
|
177
|
+
"SELECT unix_timestamp(timestamp) AS ts_epoch "
|
|
178
|
+
f"FROM event_log(TABLE({quote_name(mv)})) "
|
|
179
|
+
"WHERE event_type = 'update_progress' "
|
|
180
|
+
"AND get_json_object(details, '$.update_progress.state') = 'COMPLETED'"
|
|
181
|
+
)
|
|
182
|
+
rows = ws.sql(statement, target=mv)
|
|
183
|
+
return [
|
|
184
|
+
Commit(version=i, timestamp=_from_epoch(r["ts_epoch"]),
|
|
185
|
+
operation="MV REFRESH", params={}, metrics=None)
|
|
186
|
+
for i, r in enumerate(rows)
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def read_commits(ws: "Workspace", table: str, *,
|
|
191
|
+
with_metrics: bool = True) -> list:
|
|
192
|
+
"""History for any physical object: a table, streaming table, or MV.
|
|
193
|
+
|
|
194
|
+
DESCRIBE HISTORY covers tables and streaming tables. A materialized view
|
|
195
|
+
rejects it, so fall back to the pipeline event log.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
return read_history(ws, table, with_metrics=with_metrics)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
if "expect_table_not_view" in str(exc).lower():
|
|
201
|
+
with obs.call("mv.event_log", table):
|
|
202
|
+
return read_mv_refreshes(ws, table)
|
|
203
|
+
raise
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def read_latest_commit(ws: "Workspace", table: str, *,
|
|
207
|
+
not_after: int | None = None) -> datetime | None:
|
|
208
|
+
"""The newest qualifying write for a table or an MV, or None.
|
|
209
|
+
|
|
210
|
+
This is the gate's one question. A write dated after `not_after` (epoch
|
|
211
|
+
seconds) is ignored, so a clock-skewed future commit cannot make a stale
|
|
212
|
+
table look fresh.
|
|
213
|
+
"""
|
|
214
|
+
newest: datetime | None = None
|
|
215
|
+
for commit in read_commits(ws, table, with_metrics=False):
|
|
216
|
+
if not qualifying(commit):
|
|
217
|
+
continue
|
|
218
|
+
if not_after is not None and commit.timestamp.timestamp() > not_after:
|
|
219
|
+
continue
|
|
220
|
+
if newest is None or commit.timestamp > newest:
|
|
221
|
+
newest = commit.timestamp
|
|
222
|
+
return newest
|
view_scheduler/config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Configuration. Keep it small — ask before adding a field."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Config:
|
|
11
|
+
|
|
12
|
+
days_history: int = 30
|
|
13
|
+
buffer_min: int = 15
|
|
14
|
+
timezone: str = "Asia/Kolkata"
|
|
15
|
+
#: Gate only. A dependency is fresh when it wrote within this many hours.
|
|
16
|
+
fresh_hours: int = 24
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
if self.fresh_hours <= 0:
|
|
20
|
+
raise ValueError(f"fresh_hours must be positive, got {self.fresh_hours}")
|
|
21
|
+
if self.days_history <= 0:
|
|
22
|
+
raise ValueError(f"days_history must be positive, got {self.days_history}")
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def tz(self) -> ZoneInfo:
|
|
26
|
+
return ZoneInfo(self.timezone)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DEFAULT = Config()
|