terrine 0.1.0.dev0__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.
- terrine/__init__.py +120 -0
- terrine/_time.py +21 -0
- terrine/adapters/__init__.py +3 -0
- terrine/adapters/increment.py +365 -0
- terrine/adapters/opentelemetry.py +63 -0
- terrine/agent/__init__.py +6 -0
- terrine/agent/definitions.py +143 -0
- terrine/agent/design_schema.py +220 -0
- terrine/agent/policy.py +144 -0
- terrine/agent/schemas.py +285 -0
- terrine/agent/tools.py +956 -0
- terrine/backends/__init__.py +3 -0
- terrine/backends/capabilities.py +117 -0
- terrine/backends/dialect.py +55 -0
- terrine/backends/extrema.py +40 -0
- terrine/backends/joins.py +75 -0
- terrine/backends/numeric.py +657 -0
- terrine/backends/sequences.py +40 -0
- terrine/backends/timestamps.py +421 -0
- terrine/compile/__init__.py +3 -0
- terrine/compile/_asof.py +165 -0
- terrine/compile/_predicates.py +110 -0
- terrine/compile/anchors.py +492 -0
- terrine/compile/columns.py +1353 -0
- terrine/compile/cost.py +110 -0
- terrine/compile/dispatch.py +148 -0
- terrine/compile/layers.py +242 -0
- terrine/compile/materialize.py +91 -0
- terrine/compile/provenance.py +120 -0
- terrine/compile/ranges.py +91 -0
- terrine/compile/retention.py +127 -0
- terrine/compile/shapes/__init__.py +3 -0
- terrine/compile/shapes/_observations.py +502 -0
- terrine/compile/shapes/calendar_comparison.py +370 -0
- terrine/compile/shapes/cohorts.py +285 -0
- terrine/compile/shapes/event_panel.py +46 -0
- terrine/compile/shapes/event_sequence.py +498 -0
- terrine/compile/shapes/life_table.py +244 -0
- terrine/compile/shapes/ltv.py +287 -0
- terrine/compile/shapes/matched_t0.py +75 -0
- terrine/compile/shapes/sequential.py +320 -0
- terrine/compile/shapes/state_panel.py +460 -0
- terrine/compile/shapes/time_to_event.py +521 -0
- terrine/data.py +1311 -0
- terrine/dataset/__init__.py +5 -0
- terrine/dataset/export.py +463 -0
- terrine/dataset/manifest.py +254 -0
- terrine/dataset/reference.py +582 -0
- terrine/dataset/splits.py +373 -0
- terrine/dataset/training.py +114 -0
- terrine/dataset/transforms.py +182 -0
- terrine/errors.py +98 -0
- terrine/execution/__init__.py +5 -0
- terrine/execution/_bigquery.py +318 -0
- terrine/execution/_metadata.py +47 -0
- terrine/execution/_native.py +574 -0
- terrine/execution/_postgres.py +468 -0
- terrine/execution/_publish.py +299 -0
- terrine/execution/_snowflake.py +684 -0
- terrine/execution/backend.py +349 -0
- terrine/execution/gates.py +90 -0
- terrine/execution/inputs.py +923 -0
- terrine/export/__init__.py +31 -0
- terrine/export/diff_diff.py +177 -0
- terrine/export/lifelines.py +219 -0
- terrine/export/preflight.py +243 -0
- terrine/export/surrogate.py +168 -0
- terrine/frame.py +1042 -0
- terrine/model.py +397 -0
- terrine/orchestration/__init__.py +3 -0
- terrine/orchestration/airflow.py +115 -0
- terrine/orchestration/dagster.py +103 -0
- terrine/py.typed +0 -0
- terrine/recipes.py +295 -0
- terrine/runtime/__init__.py +70 -0
- terrine/runtime/api.py +803 -0
- terrine/runtime/arrow.py +98 -0
- terrine/runtime/backends/base.py +70 -0
- terrine/runtime/backends/duckdb.py +645 -0
- terrine/runtime/changes.py +71 -0
- terrine/runtime/cli.py +108 -0
- terrine/runtime/control.py +355 -0
- terrine/runtime/cost.py +211 -0
- terrine/runtime/events.py +194 -0
- terrine/runtime/execute.py +932 -0
- terrine/runtime/gc.py +43 -0
- terrine/runtime/graph.py +200 -0
- terrine/runtime/layers.py +61 -0
- terrine/runtime/leases.py +165 -0
- terrine/runtime/materialize.py +53 -0
- terrine/runtime/owner.py +57 -0
- terrine/runtime/planner.py +441 -0
- terrine/runtime/planning.py +394 -0
- terrine/runtime/saved_plan.py +337 -0
- terrine/runtime/snapshot.py +541 -0
- terrine/runtime/state.py +585 -0
- terrine/runtime/status.py +360 -0
- terrine/runtime/synchronization.py +15 -0
- terrine/semantic/__init__.py +3 -0
- terrine/semantic/bind.py +309 -0
- terrine/semantic/io.py +116 -0
- terrine/semantic/models.py +342 -0
- terrine/semantic/rules.py +113 -0
- terrine/semantic/sql.py +185 -0
- terrine/semantic/time.py +42 -0
- terrine/simulate/__init__.py +24 -0
- terrine/simulate/baseline.yaml +57 -0
- terrine/simulate/generate.py +182 -0
- terrine/simulate/scale.py +234 -0
- terrine/simulate/world.py +75 -0
- terrine/spec/__init__.py +96 -0
- terrine/spec/anchors.py +107 -0
- terrine/spec/dataset.py +260 -0
- terrine/spec/design.py +361 -0
- terrine/spec/expressions.py +440 -0
- terrine/spec/io.py +829 -0
- terrine/spec/panels.py +359 -0
- terrine/spec/sequence.py +165 -0
- terrine/spec/state_panel.py +246 -0
- terrine/spec/trajectory.py +144 -0
- terrine/spec/transforms.py +260 -0
- terrine/spec/validate.py +758 -0
- terrine/temporal/__init__.py +3 -0
- terrine/temporal/aggregate.py +688 -0
- terrine/temporal/attributes.py +389 -0
- terrine/temporal/daily.py +481 -0
- terrine/temporal/first.py +239 -0
- terrine/temporal/lookup.py +194 -0
- terrine/temporal/nonadditive.py +466 -0
- terrine/temporal/prefix.py +104 -0
- terrine/temporal/ranges.py +122 -0
- terrine/temporal/readiness.py +41 -0
- terrine/temporal/retention.py +225 -0
- terrine/temporal/sequence.py +900 -0
- terrine/temporal/sessionize.py +135 -0
- terrine/temporal/sketches.py +329 -0
- terrine/windows.py +54 -0
- terrine-0.1.0.dev0.dist-info/METADATA +164 -0
- terrine-0.1.0.dev0.dist-info/RECORD +144 -0
- terrine-0.1.0.dev0.dist-info/WHEEL +4 -0
- terrine-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- terrine-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- terrine-0.1.0.dev0.dist-info/licenses/NOTICE +4 -0
- terrine-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.txt +219 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
__all__ = ["Change", "ChangeSet", "affected_by_change", "window_intersects"]
|
|
8
|
+
|
|
9
|
+
ChangeKind = Literal[
|
|
10
|
+
"insert", "update", "delete", "restatement", "readiness", "definition", "roster"
|
|
11
|
+
]
|
|
12
|
+
ChangeEvidence = Literal["revision", "change_feed", "checksum", "operator", "conservative_rebuild"]
|
|
13
|
+
TimeRange = tuple[datetime, datetime]
|
|
14
|
+
KeyRange = tuple[str, str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Change:
|
|
19
|
+
kind: ChangeKind
|
|
20
|
+
source: str
|
|
21
|
+
before_key_range: KeyRange | None = None
|
|
22
|
+
after_key_range: KeyRange | None = None
|
|
23
|
+
before_time_range: TimeRange | None = None
|
|
24
|
+
after_time_range: TimeRange | None = None
|
|
25
|
+
revision: str | None = None
|
|
26
|
+
tombstone: bool = False
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if not self.source:
|
|
30
|
+
raise ValueError("change source must not be empty")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ChangeSet:
|
|
35
|
+
changes: tuple[Change, ...]
|
|
36
|
+
evidence: ChangeEvidence
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_readiness_advance(cls, source: str, before: datetime, after: datetime) -> ChangeSet:
|
|
40
|
+
if after == before:
|
|
41
|
+
return cls((), "revision")
|
|
42
|
+
if after < before:
|
|
43
|
+
return cls(
|
|
44
|
+
(Change("readiness", source, before_time_range=(after, before)),), "revision"
|
|
45
|
+
)
|
|
46
|
+
# Revalidate completeness-dependent outputs, without asserting row mutation.
|
|
47
|
+
return cls((Change("readiness", source, after_time_range=(before, after)),), "revision")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def window_intersects(support: TimeRange, window: TimeRange) -> bool:
|
|
51
|
+
left, right = support
|
|
52
|
+
start, end = window
|
|
53
|
+
if left.tzinfo is None or right.tzinfo is None or start.tzinfo is None or end.tzinfo is None:
|
|
54
|
+
raise ValueError("time supports must be timezone-aware")
|
|
55
|
+
if right < left or end < start:
|
|
56
|
+
raise ValueError("time ranges must be ordered")
|
|
57
|
+
return left < end and start < right
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def affected_by_change(change: Change, window: TimeRange) -> bool:
|
|
61
|
+
supports = (change.before_time_range, change.after_time_range)
|
|
62
|
+
if any(support is None for support in supports if change.kind == "update"):
|
|
63
|
+
return True
|
|
64
|
+
if change.kind == "delete":
|
|
65
|
+
if change.before_time_range is None:
|
|
66
|
+
return True
|
|
67
|
+
return window_intersects(change.before_time_range, window)
|
|
68
|
+
exact = tuple(support for support in supports if support is not None)
|
|
69
|
+
if not exact:
|
|
70
|
+
return True
|
|
71
|
+
return any(window_intersects(support, window) for support in exact)
|
terrine/runtime/cli.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import asdict, is_dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from terrine.errors import TerrineError
|
|
12
|
+
from terrine.runtime.control import ControlClient, ControlEndpoint
|
|
13
|
+
from terrine.runtime.saved_plan import DestinationRef
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(prog="terrine")
|
|
18
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
|
|
20
|
+
plan = commands.add_parser("plan", help="validate and canonicalize a saved refresh plan")
|
|
21
|
+
plan.add_argument("plan_file", type=Path)
|
|
22
|
+
|
|
23
|
+
run = commands.add_parser("run", help="execute a saved refresh plan through its owner")
|
|
24
|
+
run.add_argument("plan_file", type=Path, nargs="?")
|
|
25
|
+
run.add_argument("--request-key")
|
|
26
|
+
run.add_argument("--lookup-request-key")
|
|
27
|
+
run.add_argument("--destination-database")
|
|
28
|
+
run.add_argument("--destination-schema")
|
|
29
|
+
run.add_argument("--destination-dataset")
|
|
30
|
+
status = commands.add_parser("status", help="read an owner's durable run status")
|
|
31
|
+
status.add_argument("run_id")
|
|
32
|
+
|
|
33
|
+
events = commands.add_parser("events", help="read an owner's bounded run event page")
|
|
34
|
+
events.add_argument("run_id")
|
|
35
|
+
events.add_argument("--after")
|
|
36
|
+
events.add_argument("--limit", type=int, default=100)
|
|
37
|
+
|
|
38
|
+
cancel = commands.add_parser("cancel", help="request cancellation through the owner")
|
|
39
|
+
cancel.add_argument("run_id")
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _json(value: Any) -> str:
|
|
44
|
+
if is_dataclass(value):
|
|
45
|
+
value = asdict(value)
|
|
46
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _client() -> ControlClient:
|
|
50
|
+
endpoint = os.environ.get("TERRINE_RUNTIME_SOCKET")
|
|
51
|
+
namespace = os.environ.get("TERRINE_RUNTIME_NAMESPACE")
|
|
52
|
+
if not endpoint or not namespace:
|
|
53
|
+
raise ValueError("TERRINE_RUNTIME_SOCKET and TERRINE_RUNTIME_NAMESPACE are required")
|
|
54
|
+
return ControlClient(ControlEndpoint(Path(endpoint)), namespace=namespace)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv: list[str] | None = None) -> int:
|
|
58
|
+
args = _parser().parse_args(argv)
|
|
59
|
+
try:
|
|
60
|
+
client = _client()
|
|
61
|
+
if args.command == "plan":
|
|
62
|
+
serialized = client.plan(args.plan_file.read_bytes())
|
|
63
|
+
print(serialized)
|
|
64
|
+
elif args.command == "run":
|
|
65
|
+
destination_values = (
|
|
66
|
+
args.destination_database,
|
|
67
|
+
args.destination_schema,
|
|
68
|
+
args.destination_dataset,
|
|
69
|
+
)
|
|
70
|
+
if args.lookup_request_key and args.request_key:
|
|
71
|
+
raise ValueError("lookup and execution request keys are mutually exclusive")
|
|
72
|
+
if (args.request_key or args.lookup_request_key) and not all(destination_values):
|
|
73
|
+
raise ValueError("a request key requires all destination fields")
|
|
74
|
+
if any(destination_values) and not all(destination_values):
|
|
75
|
+
raise ValueError("all destination fields must be supplied together")
|
|
76
|
+
destination = DestinationRef(*destination_values) if all(destination_values) else None
|
|
77
|
+
if args.lookup_request_key:
|
|
78
|
+
if args.plan_file is not None:
|
|
79
|
+
raise ValueError("request-key lookup does not accept a plan file")
|
|
80
|
+
receipt = client.find_run_by_request_key(
|
|
81
|
+
args.lookup_request_key, destination=destination
|
|
82
|
+
)
|
|
83
|
+
print(_json(receipt))
|
|
84
|
+
else:
|
|
85
|
+
if args.plan_file is None:
|
|
86
|
+
raise ValueError("run requires a plan file")
|
|
87
|
+
receipt = client.run(
|
|
88
|
+
args.plan_file.read_bytes(),
|
|
89
|
+
request_key=args.request_key,
|
|
90
|
+
destination=destination,
|
|
91
|
+
)
|
|
92
|
+
print(_json(receipt))
|
|
93
|
+
elif args.command == "status":
|
|
94
|
+
print(_json(client.get_run(args.run_id)))
|
|
95
|
+
elif args.command == "events":
|
|
96
|
+
page = client.read_run_events(args.run_id, after=args.after, limit=args.limit)
|
|
97
|
+
print(_json(page))
|
|
98
|
+
else:
|
|
99
|
+
print(_json(client.cancel_run(args.run_id)))
|
|
100
|
+
except (OSError, ValueError, TerrineError) as error:
|
|
101
|
+
code = getattr(error, "code", "terrine.runtime.cli_error")
|
|
102
|
+
print(json.dumps({"code": code}, sort_keys=True), file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import socketserver
|
|
8
|
+
import stat
|
|
9
|
+
import threading
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any, overload
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from terrine.runtime.planning import PlanInvocation, PlanRequest
|
|
17
|
+
|
|
18
|
+
from terrine.errors import RefusalSpec, TerrineError
|
|
19
|
+
from terrine.runtime.events import (
|
|
20
|
+
EventPage,
|
|
21
|
+
RunEvent,
|
|
22
|
+
read_run_events,
|
|
23
|
+
)
|
|
24
|
+
from terrine.runtime.execute import RunReceipt, _ensure_run_tables
|
|
25
|
+
from terrine.runtime.state import RuntimeStore
|
|
26
|
+
from terrine.runtime.status import (
|
|
27
|
+
CancelReceipt,
|
|
28
|
+
RunStatus,
|
|
29
|
+
_recover_interrupted_runs,
|
|
30
|
+
cancel_run,
|
|
31
|
+
get_run,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = ["ControlEndpoint", "ControlServer", "ControlClient"]
|
|
35
|
+
|
|
36
|
+
_MAX_CONTROL_MESSAGE_BYTES = 16_777_216
|
|
37
|
+
_MESSAGE_TOO_LARGE = RefusalSpec(
|
|
38
|
+
"terrine.runtime.control_message_too_large",
|
|
39
|
+
"Owner control message exceeds the configured limit.",
|
|
40
|
+
)
|
|
41
|
+
_CONTROL_UNAVAILABLE = RefusalSpec(
|
|
42
|
+
"terrine.runtime.control_unavailable", "Owner control request was refused."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class ControlEndpoint:
|
|
48
|
+
path: Path
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
object.__setattr__(self, "path", Path(os.path.abspath(Path(self.path).expanduser())))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ControlServer:
|
|
55
|
+
"""Owner-local Unix socket that serves control requests on the owner's connection."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
store: RuntimeStore,
|
|
60
|
+
*,
|
|
61
|
+
namespace: str,
|
|
62
|
+
endpoint: ControlEndpoint,
|
|
63
|
+
executor: Any | None = None,
|
|
64
|
+
plan_planner: Any | None = None,
|
|
65
|
+
resolver: Any | None = None,
|
|
66
|
+
snapshot_authority: Any | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
if store.namespace != namespace:
|
|
69
|
+
raise ValueError("control namespace does not match runtime store")
|
|
70
|
+
self.store = store
|
|
71
|
+
self.namespace = namespace
|
|
72
|
+
self.endpoint = endpoint
|
|
73
|
+
self.executor = executor
|
|
74
|
+
self.plan_planner = plan_planner
|
|
75
|
+
self.resolver = resolver
|
|
76
|
+
self.snapshot_authority = snapshot_authority
|
|
77
|
+
self._server: socketserver.UnixStreamServer | None = None
|
|
78
|
+
self._thread: threading.Thread | None = None
|
|
79
|
+
self._endpoint_identity: tuple[int, int] | None = None
|
|
80
|
+
|
|
81
|
+
def start(self) -> None:
|
|
82
|
+
if self._server is not None:
|
|
83
|
+
return
|
|
84
|
+
self.endpoint.path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
self.store.initialize()
|
|
86
|
+
_ensure_run_tables(self.store.con, self.store._schema)
|
|
87
|
+
_recover_interrupted_runs(self.store.con, namespace=self.namespace)
|
|
88
|
+
server = self
|
|
89
|
+
|
|
90
|
+
class Handler(socketserver.StreamRequestHandler):
|
|
91
|
+
def handle(self) -> None:
|
|
92
|
+
self.connection.settimeout(2)
|
|
93
|
+
line = self.rfile.readline(_MAX_CONTROL_MESSAGE_BYTES + 2)
|
|
94
|
+
if not line:
|
|
95
|
+
return
|
|
96
|
+
if len(line) > _MAX_CONTROL_MESSAGE_BYTES + 1:
|
|
97
|
+
response = json.dumps({"ok": False, "code": _MESSAGE_TOO_LARGE.code}).encode()
|
|
98
|
+
self.wfile.write(response + b"\n")
|
|
99
|
+
return
|
|
100
|
+
con = server.store.con.cursor()
|
|
101
|
+
try:
|
|
102
|
+
request = json.loads(line)
|
|
103
|
+
self.connection.settimeout(
|
|
104
|
+
None if request.get("operation") in {"cancel", "run", "plan"} else 2
|
|
105
|
+
)
|
|
106
|
+
response = server._dispatch(request, con=con)
|
|
107
|
+
response_bytes = json.dumps(
|
|
108
|
+
{"ok": True, "value": response}, default=str
|
|
109
|
+
).encode()
|
|
110
|
+
if len(response_bytes) > _MAX_CONTROL_MESSAGE_BYTES:
|
|
111
|
+
response_bytes = json.dumps(
|
|
112
|
+
{"ok": False, "code": _MESSAGE_TOO_LARGE.code}
|
|
113
|
+
).encode()
|
|
114
|
+
self.wfile.write(response_bytes + b"\n")
|
|
115
|
+
except Exception as error:
|
|
116
|
+
code = getattr(error, "code", _CONTROL_UNAVAILABLE.code)
|
|
117
|
+
self.wfile.write(json.dumps({"ok": False, "code": code}).encode() + b"\n")
|
|
118
|
+
finally:
|
|
119
|
+
con.close()
|
|
120
|
+
|
|
121
|
+
class OwnerUnixStreamServer(socketserver.ThreadingUnixStreamServer):
|
|
122
|
+
daemon_threads = True
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
mode = self.endpoint.path.lstat().st_mode
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
mode = None
|
|
128
|
+
if mode is not None:
|
|
129
|
+
if not stat.S_ISSOCK(mode):
|
|
130
|
+
raise FileExistsError("control endpoint exists and is not a socket")
|
|
131
|
+
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
132
|
+
try:
|
|
133
|
+
probe.connect(str(self.endpoint.path))
|
|
134
|
+
except OSError as error:
|
|
135
|
+
if error.errno != errno.ECONNREFUSED:
|
|
136
|
+
raise
|
|
137
|
+
self.endpoint.path.unlink()
|
|
138
|
+
else:
|
|
139
|
+
raise FileExistsError("control endpoint is already active")
|
|
140
|
+
finally:
|
|
141
|
+
probe.close()
|
|
142
|
+
self._server = OwnerUnixStreamServer(str(self.endpoint.path), Handler)
|
|
143
|
+
os.chmod(self.endpoint.path, 0o600)
|
|
144
|
+
socket_stat = self.endpoint.path.stat()
|
|
145
|
+
self._endpoint_identity = (socket_stat.st_dev, socket_stat.st_ino)
|
|
146
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
147
|
+
self._thread.start()
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
if self._server is None:
|
|
151
|
+
return
|
|
152
|
+
server, self._server = self._server, None
|
|
153
|
+
server.shutdown()
|
|
154
|
+
server.server_close()
|
|
155
|
+
if self._thread is not None:
|
|
156
|
+
self._thread.join()
|
|
157
|
+
self._thread = None
|
|
158
|
+
try:
|
|
159
|
+
current = self.endpoint.path.lstat()
|
|
160
|
+
except FileNotFoundError:
|
|
161
|
+
current = None
|
|
162
|
+
if (
|
|
163
|
+
current is not None
|
|
164
|
+
and self._endpoint_identity == (current.st_dev, current.st_ino)
|
|
165
|
+
and stat.S_ISSOCK(current.st_mode)
|
|
166
|
+
):
|
|
167
|
+
self.endpoint.path.unlink()
|
|
168
|
+
self._endpoint_identity = None
|
|
169
|
+
|
|
170
|
+
def _dispatch(self, request: dict[str, Any], *, con: Any) -> Any:
|
|
171
|
+
if request.get("namespace") != self.namespace:
|
|
172
|
+
raise ValueError("control namespace mismatch")
|
|
173
|
+
_recover_interrupted_runs(con, namespace=self.namespace)
|
|
174
|
+
operation = request.get("operation")
|
|
175
|
+
run_id = request.get("run_id")
|
|
176
|
+
if operation == "plan":
|
|
177
|
+
if "plan_request" in request:
|
|
178
|
+
if self.plan_planner is None:
|
|
179
|
+
raise ValueError("owner fresh-planning service is unavailable")
|
|
180
|
+
from terrine.runtime.planning import PlanRequest
|
|
181
|
+
|
|
182
|
+
planning_request = PlanRequest.from_dict(request["plan_request"])
|
|
183
|
+
invocation = self.plan_planner.plan(
|
|
184
|
+
planning_request, con=con, namespace=self.namespace
|
|
185
|
+
)
|
|
186
|
+
return invocation.to_dict()
|
|
187
|
+
from terrine.runtime.planner import RefreshPlan
|
|
188
|
+
|
|
189
|
+
plan = RefreshPlan.deserialize(request["serialized"])
|
|
190
|
+
if plan.namespace != self.namespace:
|
|
191
|
+
raise ValueError("plan namespace does not match owner")
|
|
192
|
+
return plan.serialize().decode("utf-8")
|
|
193
|
+
if operation == "run":
|
|
194
|
+
from terrine.runtime.api import run_serialized_plan
|
|
195
|
+
from terrine.runtime.saved_plan import DestinationRef
|
|
196
|
+
|
|
197
|
+
destination_data = request.get("destination")
|
|
198
|
+
destination = (
|
|
199
|
+
DestinationRef(**destination_data) if destination_data is not None else None
|
|
200
|
+
)
|
|
201
|
+
receipt = run_serialized_plan(
|
|
202
|
+
request["serialized"],
|
|
203
|
+
con=con,
|
|
204
|
+
namespace=self.namespace,
|
|
205
|
+
request_key=request.get("request_key"),
|
|
206
|
+
destination=destination,
|
|
207
|
+
executor=self.executor,
|
|
208
|
+
resolver=self.resolver,
|
|
209
|
+
snapshot_authority=self.snapshot_authority,
|
|
210
|
+
)
|
|
211
|
+
return asdict(receipt)
|
|
212
|
+
|
|
213
|
+
if operation == "status":
|
|
214
|
+
if not isinstance(run_id, str) or not run_id:
|
|
215
|
+
raise ValueError("run ID is required")
|
|
216
|
+
return asdict(get_run(run_id, con=con, namespace=self.namespace))
|
|
217
|
+
if operation == "events":
|
|
218
|
+
if not isinstance(run_id, str) or not run_id:
|
|
219
|
+
raise ValueError("run ID is required")
|
|
220
|
+
page = read_run_events(
|
|
221
|
+
run_id,
|
|
222
|
+
con=con,
|
|
223
|
+
namespace=self.namespace,
|
|
224
|
+
after=request.get("after"),
|
|
225
|
+
limit=request.get("limit", 100),
|
|
226
|
+
)
|
|
227
|
+
return {
|
|
228
|
+
"events": [asdict(event) for event in page.events],
|
|
229
|
+
"next_cursor": page.next_cursor,
|
|
230
|
+
"has_more": page.has_more,
|
|
231
|
+
"cursor_gap": page.cursor_gap,
|
|
232
|
+
}
|
|
233
|
+
if operation == "cancel":
|
|
234
|
+
if not isinstance(run_id, str) or not run_id:
|
|
235
|
+
raise ValueError("run ID is required")
|
|
236
|
+
result = cancel_run(run_id, con=con, namespace=self.namespace)
|
|
237
|
+
return {
|
|
238
|
+
"run_id": result.run_id,
|
|
239
|
+
"disposition": result.disposition,
|
|
240
|
+
"status": asdict(result.status),
|
|
241
|
+
}
|
|
242
|
+
if operation == "request_key":
|
|
243
|
+
from terrine.runtime.saved_plan import DestinationRef
|
|
244
|
+
|
|
245
|
+
destination = request.get("destination")
|
|
246
|
+
if not isinstance(destination, dict):
|
|
247
|
+
raise ValueError("destination reference is required")
|
|
248
|
+
target = DestinationRef(**destination)
|
|
249
|
+
from terrine.runtime.api import find_run_by_request_key
|
|
250
|
+
|
|
251
|
+
receipt = find_run_by_request_key(
|
|
252
|
+
request["request_key"],
|
|
253
|
+
destination=target,
|
|
254
|
+
con=con,
|
|
255
|
+
namespace=self.namespace,
|
|
256
|
+
)
|
|
257
|
+
return asdict(receipt) if receipt is not None else None
|
|
258
|
+
raise ValueError("unsupported control operation")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ControlClient:
|
|
262
|
+
def __init__(self, endpoint: ControlEndpoint, *, namespace: str) -> None:
|
|
263
|
+
self.endpoint = endpoint
|
|
264
|
+
self.namespace = namespace
|
|
265
|
+
|
|
266
|
+
def _request(self, operation: str, **payload: Any) -> Any:
|
|
267
|
+
request = json.dumps({"namespace": self.namespace, "operation": operation, **payload})
|
|
268
|
+
encoded = request.encode()
|
|
269
|
+
if len(encoded) > _MAX_CONTROL_MESSAGE_BYTES:
|
|
270
|
+
raise _MESSAGE_TOO_LARGE.error()
|
|
271
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
272
|
+
client.settimeout(None if operation in {"cancel", "run", "plan"} else 2)
|
|
273
|
+
client.connect(str(self.endpoint.path))
|
|
274
|
+
client.sendall(encoded + b"\n")
|
|
275
|
+
with client.makefile("rb") as response_stream:
|
|
276
|
+
line = response_stream.readline(_MAX_CONTROL_MESSAGE_BYTES + 2)
|
|
277
|
+
if not line.endswith(b"\n") or len(line) > _MAX_CONTROL_MESSAGE_BYTES + 1:
|
|
278
|
+
raise ConnectionError("owner control response exceeded its limit")
|
|
279
|
+
response = json.loads(line)
|
|
280
|
+
if not response.get("ok"):
|
|
281
|
+
raise TerrineError(
|
|
282
|
+
_CONTROL_UNAVAILABLE.template,
|
|
283
|
+
code=response.get("code", _CONTROL_UNAVAILABLE.code),
|
|
284
|
+
context={},
|
|
285
|
+
)
|
|
286
|
+
return response["value"]
|
|
287
|
+
|
|
288
|
+
@overload
|
|
289
|
+
def plan(self, serialized: bytes | str) -> str: ...
|
|
290
|
+
|
|
291
|
+
@overload
|
|
292
|
+
def plan(self, serialized: PlanRequest) -> PlanInvocation: ...
|
|
293
|
+
|
|
294
|
+
def plan(self, serialized: bytes | str | PlanRequest) -> str | PlanInvocation:
|
|
295
|
+
from terrine.runtime.planning import PlanInvocation, PlanRequest
|
|
296
|
+
|
|
297
|
+
if isinstance(serialized, PlanRequest):
|
|
298
|
+
value = self._request("plan", plan_request=serialized.to_dict())
|
|
299
|
+
return PlanInvocation.from_dict(value)
|
|
300
|
+
payload = serialized.decode("utf-8") if isinstance(serialized, bytes) else serialized
|
|
301
|
+
return self._request("plan", serialized=payload)
|
|
302
|
+
|
|
303
|
+
def run(
|
|
304
|
+
self,
|
|
305
|
+
serialized: bytes | str,
|
|
306
|
+
*,
|
|
307
|
+
request_key: str | None = None,
|
|
308
|
+
destination: Any | None = None,
|
|
309
|
+
) -> RunReceipt:
|
|
310
|
+
payload = serialized.decode("utf-8") if isinstance(serialized, bytes) else serialized
|
|
311
|
+
value = self._request(
|
|
312
|
+
"run",
|
|
313
|
+
serialized=payload,
|
|
314
|
+
request_key=request_key,
|
|
315
|
+
destination=asdict(destination) if destination is not None else None,
|
|
316
|
+
)
|
|
317
|
+
return RunReceipt(value["run_id"], value["state"], value["published_generation"])
|
|
318
|
+
|
|
319
|
+
def get_run(self, run_id: str) -> RunStatus:
|
|
320
|
+
return _status(self._request("status", run_id=run_id))
|
|
321
|
+
|
|
322
|
+
def read_run_events(
|
|
323
|
+
self, run_id: str, *, after: str | None = None, limit: int = 100
|
|
324
|
+
) -> EventPage:
|
|
325
|
+
value = self._request("events", run_id=run_id, after=after, limit=limit)
|
|
326
|
+
events = tuple(
|
|
327
|
+
RunEvent(
|
|
328
|
+
event["run_id"],
|
|
329
|
+
event["sequence"],
|
|
330
|
+
datetime.fromisoformat(event["at"]),
|
|
331
|
+
event["kind"],
|
|
332
|
+
event["payload"],
|
|
333
|
+
)
|
|
334
|
+
for event in value["events"]
|
|
335
|
+
)
|
|
336
|
+
return EventPage(events, value["next_cursor"], value["has_more"], value["cursor_gap"])
|
|
337
|
+
|
|
338
|
+
def cancel_run(self, run_id: str) -> CancelReceipt:
|
|
339
|
+
value = self._request("cancel", run_id=run_id)
|
|
340
|
+
return CancelReceipt(run_id, value["disposition"], _status(value["status"]))
|
|
341
|
+
|
|
342
|
+
def find_run_by_request_key(self, request_key: str, *, destination: Any) -> RunReceipt | None:
|
|
343
|
+
value = self._request(
|
|
344
|
+
"request_key", request_key=request_key, destination=asdict(destination)
|
|
345
|
+
)
|
|
346
|
+
if value is None:
|
|
347
|
+
return None
|
|
348
|
+
return RunReceipt(value["run_id"], value["state"], value["published_generation"])
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _status(value: dict[str, Any]) -> RunStatus:
|
|
352
|
+
for field in ("updated_at", "heartbeat_at", "lease_expires_at"):
|
|
353
|
+
if value[field] is not None and isinstance(value[field], str):
|
|
354
|
+
value[field] = datetime.fromisoformat(value[field])
|
|
355
|
+
return RunStatus(**value)
|