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.
Files changed (144) hide show
  1. terrine/__init__.py +120 -0
  2. terrine/_time.py +21 -0
  3. terrine/adapters/__init__.py +3 -0
  4. terrine/adapters/increment.py +365 -0
  5. terrine/adapters/opentelemetry.py +63 -0
  6. terrine/agent/__init__.py +6 -0
  7. terrine/agent/definitions.py +143 -0
  8. terrine/agent/design_schema.py +220 -0
  9. terrine/agent/policy.py +144 -0
  10. terrine/agent/schemas.py +285 -0
  11. terrine/agent/tools.py +956 -0
  12. terrine/backends/__init__.py +3 -0
  13. terrine/backends/capabilities.py +117 -0
  14. terrine/backends/dialect.py +55 -0
  15. terrine/backends/extrema.py +40 -0
  16. terrine/backends/joins.py +75 -0
  17. terrine/backends/numeric.py +657 -0
  18. terrine/backends/sequences.py +40 -0
  19. terrine/backends/timestamps.py +421 -0
  20. terrine/compile/__init__.py +3 -0
  21. terrine/compile/_asof.py +165 -0
  22. terrine/compile/_predicates.py +110 -0
  23. terrine/compile/anchors.py +492 -0
  24. terrine/compile/columns.py +1353 -0
  25. terrine/compile/cost.py +110 -0
  26. terrine/compile/dispatch.py +148 -0
  27. terrine/compile/layers.py +242 -0
  28. terrine/compile/materialize.py +91 -0
  29. terrine/compile/provenance.py +120 -0
  30. terrine/compile/ranges.py +91 -0
  31. terrine/compile/retention.py +127 -0
  32. terrine/compile/shapes/__init__.py +3 -0
  33. terrine/compile/shapes/_observations.py +502 -0
  34. terrine/compile/shapes/calendar_comparison.py +370 -0
  35. terrine/compile/shapes/cohorts.py +285 -0
  36. terrine/compile/shapes/event_panel.py +46 -0
  37. terrine/compile/shapes/event_sequence.py +498 -0
  38. terrine/compile/shapes/life_table.py +244 -0
  39. terrine/compile/shapes/ltv.py +287 -0
  40. terrine/compile/shapes/matched_t0.py +75 -0
  41. terrine/compile/shapes/sequential.py +320 -0
  42. terrine/compile/shapes/state_panel.py +460 -0
  43. terrine/compile/shapes/time_to_event.py +521 -0
  44. terrine/data.py +1311 -0
  45. terrine/dataset/__init__.py +5 -0
  46. terrine/dataset/export.py +463 -0
  47. terrine/dataset/manifest.py +254 -0
  48. terrine/dataset/reference.py +582 -0
  49. terrine/dataset/splits.py +373 -0
  50. terrine/dataset/training.py +114 -0
  51. terrine/dataset/transforms.py +182 -0
  52. terrine/errors.py +98 -0
  53. terrine/execution/__init__.py +5 -0
  54. terrine/execution/_bigquery.py +318 -0
  55. terrine/execution/_metadata.py +47 -0
  56. terrine/execution/_native.py +574 -0
  57. terrine/execution/_postgres.py +468 -0
  58. terrine/execution/_publish.py +299 -0
  59. terrine/execution/_snowflake.py +684 -0
  60. terrine/execution/backend.py +349 -0
  61. terrine/execution/gates.py +90 -0
  62. terrine/execution/inputs.py +923 -0
  63. terrine/export/__init__.py +31 -0
  64. terrine/export/diff_diff.py +177 -0
  65. terrine/export/lifelines.py +219 -0
  66. terrine/export/preflight.py +243 -0
  67. terrine/export/surrogate.py +168 -0
  68. terrine/frame.py +1042 -0
  69. terrine/model.py +397 -0
  70. terrine/orchestration/__init__.py +3 -0
  71. terrine/orchestration/airflow.py +115 -0
  72. terrine/orchestration/dagster.py +103 -0
  73. terrine/py.typed +0 -0
  74. terrine/recipes.py +295 -0
  75. terrine/runtime/__init__.py +70 -0
  76. terrine/runtime/api.py +803 -0
  77. terrine/runtime/arrow.py +98 -0
  78. terrine/runtime/backends/base.py +70 -0
  79. terrine/runtime/backends/duckdb.py +645 -0
  80. terrine/runtime/changes.py +71 -0
  81. terrine/runtime/cli.py +108 -0
  82. terrine/runtime/control.py +355 -0
  83. terrine/runtime/cost.py +211 -0
  84. terrine/runtime/events.py +194 -0
  85. terrine/runtime/execute.py +932 -0
  86. terrine/runtime/gc.py +43 -0
  87. terrine/runtime/graph.py +200 -0
  88. terrine/runtime/layers.py +61 -0
  89. terrine/runtime/leases.py +165 -0
  90. terrine/runtime/materialize.py +53 -0
  91. terrine/runtime/owner.py +57 -0
  92. terrine/runtime/planner.py +441 -0
  93. terrine/runtime/planning.py +394 -0
  94. terrine/runtime/saved_plan.py +337 -0
  95. terrine/runtime/snapshot.py +541 -0
  96. terrine/runtime/state.py +585 -0
  97. terrine/runtime/status.py +360 -0
  98. terrine/runtime/synchronization.py +15 -0
  99. terrine/semantic/__init__.py +3 -0
  100. terrine/semantic/bind.py +309 -0
  101. terrine/semantic/io.py +116 -0
  102. terrine/semantic/models.py +342 -0
  103. terrine/semantic/rules.py +113 -0
  104. terrine/semantic/sql.py +185 -0
  105. terrine/semantic/time.py +42 -0
  106. terrine/simulate/__init__.py +24 -0
  107. terrine/simulate/baseline.yaml +57 -0
  108. terrine/simulate/generate.py +182 -0
  109. terrine/simulate/scale.py +234 -0
  110. terrine/simulate/world.py +75 -0
  111. terrine/spec/__init__.py +96 -0
  112. terrine/spec/anchors.py +107 -0
  113. terrine/spec/dataset.py +260 -0
  114. terrine/spec/design.py +361 -0
  115. terrine/spec/expressions.py +440 -0
  116. terrine/spec/io.py +829 -0
  117. terrine/spec/panels.py +359 -0
  118. terrine/spec/sequence.py +165 -0
  119. terrine/spec/state_panel.py +246 -0
  120. terrine/spec/trajectory.py +144 -0
  121. terrine/spec/transforms.py +260 -0
  122. terrine/spec/validate.py +758 -0
  123. terrine/temporal/__init__.py +3 -0
  124. terrine/temporal/aggregate.py +688 -0
  125. terrine/temporal/attributes.py +389 -0
  126. terrine/temporal/daily.py +481 -0
  127. terrine/temporal/first.py +239 -0
  128. terrine/temporal/lookup.py +194 -0
  129. terrine/temporal/nonadditive.py +466 -0
  130. terrine/temporal/prefix.py +104 -0
  131. terrine/temporal/ranges.py +122 -0
  132. terrine/temporal/readiness.py +41 -0
  133. terrine/temporal/retention.py +225 -0
  134. terrine/temporal/sequence.py +900 -0
  135. terrine/temporal/sessionize.py +135 -0
  136. terrine/temporal/sketches.py +329 -0
  137. terrine/windows.py +54 -0
  138. terrine-0.1.0.dev0.dist-info/METADATA +164 -0
  139. terrine-0.1.0.dev0.dist-info/RECORD +144 -0
  140. terrine-0.1.0.dev0.dist-info/WHEEL +4 -0
  141. terrine-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  142. terrine-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  143. terrine-0.1.0.dev0.dist-info/licenses/NOTICE +4 -0
  144. 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)