etlantic-sqlmodel 0.40.0__tar.gz → 0.41.0__tar.gz
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.
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/PKG-INFO +6 -6
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/README.md +4 -4
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/pyproject.toml +2 -2
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/__init__.py +1 -1
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/__init__.py +10 -0
- etlantic_sqlmodel-0.41.0/src/etlantic_sqlmodel/control_plane/durable_stores.py +319 -0
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/models.py +21 -1
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/registry_backup.py +103 -22
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/registry_stores.py +96 -67
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/stores.py +15 -7
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/migrations/__init__.py +1 -1
- etlantic_sqlmodel-0.41.0/src/etlantic_sqlmodel/migrations/versions/002_durable_cp3.py +33 -0
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/.gitignore +0 -0
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/registry_search.py +0 -0
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/session.py +0 -0
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/migrations/versions/001_registry_cp2.py +0 -0
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/migrations/versions/__init__.py +0 -0
- {etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: etlantic-sqlmodel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.41.0
|
|
4
4
|
Summary: ContractModel ↔ SQLModel bridge for ETLantic.
|
|
5
5
|
Project-URL: Homepage, https://github.com/eddiethedean/etlantic
|
|
6
6
|
Project-URL: Documentation, https://github.com/eddiethedean/etlantic/tree/main/docs
|
|
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
19
|
Classifier: Typing :: Typed
|
|
20
20
|
Requires-Python: >=3.11
|
|
21
|
-
Requires-Dist: etlantic<0.
|
|
21
|
+
Requires-Dist: etlantic<0.42,>=0.41.0
|
|
22
22
|
Requires-Dist: sqlmodel<1,>=0.0.22
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
|
|
@@ -28,13 +28,13 @@ Optional bridge between ETLantic `Data` contracts and
|
|
|
28
28
|
[SQLModel](https://sqlmodel.tiangolo.com/) table models, plus optional CP1
|
|
29
29
|
control-plane reference stores. Install when you need `contract_to_sqlmodel`
|
|
30
30
|
helpers or SQLModel-backed definition/submission stores for local CP1 demos.
|
|
31
|
-
Package version is **0.
|
|
31
|
+
Package version is **0.41.0** — pin with core.
|
|
32
32
|
|
|
33
33
|
## Install
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
pip install 'etlantic-sqlmodel==0.
|
|
37
|
-
# pip install 'etlantic==0.
|
|
36
|
+
pip install 'etlantic-sqlmodel==0.41.0'
|
|
37
|
+
# pip install 'etlantic==0.41.0'
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
## Schema bridge
|
|
@@ -93,6 +93,6 @@ by a durable database URL. They are not a production multi-tenant claim.
|
|
|
93
93
|
|
|
94
94
|
## Links
|
|
95
95
|
|
|
96
|
-
[Optional packages](https://etlantic.readthedocs.io/en/v0.
|
|
96
|
+
[Optional packages](https://etlantic.readthedocs.io/en/v0.41.0/10_REFERENCE/OPTIONAL_PACKAGES/) ·
|
|
97
97
|
[Source](https://github.com/eddiethedean/etlantic/tree/main/packages/etlantic-sqlmodel) ·
|
|
98
98
|
[Issues](https://github.com/eddiethedean/etlantic/issues)
|
|
@@ -4,13 +4,13 @@ Optional bridge between ETLantic `Data` contracts and
|
|
|
4
4
|
[SQLModel](https://sqlmodel.tiangolo.com/) table models, plus optional CP1
|
|
5
5
|
control-plane reference stores. Install when you need `contract_to_sqlmodel`
|
|
6
6
|
helpers or SQLModel-backed definition/submission stores for local CP1 demos.
|
|
7
|
-
Package version is **0.
|
|
7
|
+
Package version is **0.41.0** — pin with core.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
pip install 'etlantic-sqlmodel==0.
|
|
13
|
-
# pip install 'etlantic==0.
|
|
12
|
+
pip install 'etlantic-sqlmodel==0.41.0'
|
|
13
|
+
# pip install 'etlantic==0.41.0'
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
## Schema bridge
|
|
@@ -69,6 +69,6 @@ by a durable database URL. They are not a production multi-tenant claim.
|
|
|
69
69
|
|
|
70
70
|
## Links
|
|
71
71
|
|
|
72
|
-
[Optional packages](https://etlantic.readthedocs.io/en/v0.
|
|
72
|
+
[Optional packages](https://etlantic.readthedocs.io/en/v0.41.0/10_REFERENCE/OPTIONAL_PACKAGES/) ·
|
|
73
73
|
[Source](https://github.com/eddiethedean/etlantic/tree/main/packages/etlantic-sqlmodel) ·
|
|
74
74
|
[Issues](https://github.com/eddiethedean/etlantic/issues)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "etlantic-sqlmodel"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.41.0"
|
|
4
4
|
description = "ContractModel ↔ SQLModel bridge for ETLantic."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -17,7 +17,7 @@ classifiers = [
|
|
|
17
17
|
"Typing :: Typed",
|
|
18
18
|
]
|
|
19
19
|
dependencies = [
|
|
20
|
-
"etlantic>=0.
|
|
20
|
+
"etlantic>=0.41.0,<0.42",
|
|
21
21
|
"sqlmodel>=0.0.22,<1",
|
|
22
22
|
]
|
|
23
23
|
|
|
@@ -9,9 +9,15 @@ tests/demos — production must apply versioned migrations under
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
from etlantic_sqlmodel.control_plane.durable_stores import (
|
|
13
|
+
DURABLE_TABLES,
|
|
14
|
+
SQLModelDurableWorkStore,
|
|
15
|
+
create_durable_tables,
|
|
16
|
+
)
|
|
12
17
|
from etlantic_sqlmodel.control_plane.models import (
|
|
13
18
|
AliasRow,
|
|
14
19
|
DefinitionRow,
|
|
20
|
+
DurableSnapshotRow,
|
|
15
21
|
EnvironmentRow,
|
|
16
22
|
EventRow,
|
|
17
23
|
LogicalIdentityRow,
|
|
@@ -55,16 +61,19 @@ from etlantic_sqlmodel.control_plane.stores import (
|
|
|
55
61
|
|
|
56
62
|
__all__ = [
|
|
57
63
|
"BACKUP_SCHEMA",
|
|
64
|
+
"DURABLE_TABLES",
|
|
58
65
|
"REGISTRY_TABLES",
|
|
59
66
|
"AliasRow",
|
|
60
67
|
"BackupTranscript",
|
|
61
68
|
"DefinitionRow",
|
|
69
|
+
"DurableSnapshotRow",
|
|
62
70
|
"EnvironmentRow",
|
|
63
71
|
"EventRow",
|
|
64
72
|
"LogicalIdentityRow",
|
|
65
73
|
"PromotionRow",
|
|
66
74
|
"RevisionRow",
|
|
67
75
|
"SQLModelDefinitionRepository",
|
|
76
|
+
"SQLModelDurableWorkStore",
|
|
68
77
|
"SQLModelSubmissionStore",
|
|
69
78
|
"SecurityDomainRow",
|
|
70
79
|
"SqlModelEventStore",
|
|
@@ -78,6 +87,7 @@ __all__ = [
|
|
|
78
87
|
"backup_round_trip",
|
|
79
88
|
"collect_revision_hits",
|
|
80
89
|
"create_control_plane_tables",
|
|
90
|
+
"create_durable_tables",
|
|
81
91
|
"create_registry_tables",
|
|
82
92
|
"create_sqlite_engine",
|
|
83
93
|
"dump_registry_sqlite",
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""SQLModel-backed DurableWorkStore (CP3 / 041-P).
|
|
2
|
+
|
|
3
|
+
Reference provider: each mutating call loads the durable snapshot inside a
|
|
4
|
+
database transaction, applies MemoryDurableWorkStore semantics, and writes the
|
|
5
|
+
snapshot back with an optimistic ``payload_version`` check before commit.
|
|
6
|
+
|
|
7
|
+
Production note: apply versioned migrations — do not rely on
|
|
8
|
+
``create_durable_tables`` as the sole schema path.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from dataclasses import asdict
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from typing import Any, TypeVar
|
|
18
|
+
|
|
19
|
+
from sqlalchemy.engine import Engine
|
|
20
|
+
|
|
21
|
+
from etlantic.control_plane.durable_memory import MemoryDurableWorkStore
|
|
22
|
+
from etlantic.control_plane.durable_models import (
|
|
23
|
+
AttemptRecord,
|
|
24
|
+
BaselineAcknowledgement,
|
|
25
|
+
CheckpointRecord,
|
|
26
|
+
DiffRecord,
|
|
27
|
+
EffectRecord,
|
|
28
|
+
LeaseRecord,
|
|
29
|
+
OutboxRecord,
|
|
30
|
+
PreviewWorkspace,
|
|
31
|
+
ShadowRunRecord,
|
|
32
|
+
StateDiagnostic,
|
|
33
|
+
SubmissionRecord,
|
|
34
|
+
)
|
|
35
|
+
from etlantic.control_plane.errors import ControlPlaneError
|
|
36
|
+
from etlantic.control_plane.models import ControlPlaneContext
|
|
37
|
+
from etlantic_sqlmodel.control_plane.models import DurableSnapshotRow
|
|
38
|
+
from etlantic_sqlmodel.control_plane.session import session_scope
|
|
39
|
+
from sqlmodel import Session, SQLModel, select
|
|
40
|
+
|
|
41
|
+
T = TypeVar("T")
|
|
42
|
+
|
|
43
|
+
DURABLE_TABLES = (DurableSnapshotRow,)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_durable_tables(engine: Engine) -> None:
|
|
47
|
+
"""Create CP3 durable tables (tests/demos only)."""
|
|
48
|
+
SQLModel.metadata.create_all(
|
|
49
|
+
engine,
|
|
50
|
+
tables=[cls.__table__ for cls in DURABLE_TABLES], # type: ignore[list-item]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _utcnow_iso() -> str:
|
|
55
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _encode_key(parts: tuple[str, ...]) -> str:
|
|
59
|
+
return json.dumps(list(parts), separators=(",", ":"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _decode_key(text: str) -> tuple[str, ...]:
|
|
63
|
+
raw = json.loads(text)
|
|
64
|
+
if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
|
|
65
|
+
raise ValueError("durable snapshot key must be a JSON string array")
|
|
66
|
+
return tuple(raw)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _dump_store(store: MemoryDurableWorkStore) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"admission_limit": store.admission_limit,
|
|
72
|
+
"submissions": {
|
|
73
|
+
_encode_key(k): asdict(v) for k, v in store._submissions.items()
|
|
74
|
+
},
|
|
75
|
+
"idempotency": {_encode_key(k): v for k, v in store._idempotency.items()},
|
|
76
|
+
"outbox": {_encode_key(k): asdict(v) for k, v in store._outbox.items()},
|
|
77
|
+
"leases": {_encode_key(k): asdict(v) for k, v in store._leases.items()},
|
|
78
|
+
"attempts": {_encode_key(k): asdict(v) for k, v in store._attempts.items()},
|
|
79
|
+
"checkpoints": {
|
|
80
|
+
_encode_key(k): asdict(v) for k, v in store._checkpoints.items()
|
|
81
|
+
},
|
|
82
|
+
"effects": {_encode_key(k): asdict(v) for k, v in store._effects.items()},
|
|
83
|
+
"previews": {_encode_key(k): asdict(v) for k, v in store._previews.items()},
|
|
84
|
+
"diffs": {_encode_key(k): asdict(v) for k, v in store._diffs.items()},
|
|
85
|
+
"shadows": {_encode_key(k): asdict(v) for k, v in store._shadows.items()},
|
|
86
|
+
"baselines": {_encode_key(k): asdict(v) for k, v in store._baselines.items()},
|
|
87
|
+
"diagnostics": [asdict(d) for d in store._diagnostics],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load_store(payload: Mapping[str, Any]) -> MemoryDurableWorkStore:
|
|
92
|
+
store = MemoryDurableWorkStore(admission_limit=payload.get("admission_limit"))
|
|
93
|
+
store._submissions = {
|
|
94
|
+
_decode_key(k): SubmissionRecord(**v) # type: ignore[arg-type]
|
|
95
|
+
for k, v in dict(payload.get("submissions") or {}).items()
|
|
96
|
+
}
|
|
97
|
+
store._idempotency = {
|
|
98
|
+
_decode_key(k): v for k, v in dict(payload.get("idempotency") or {}).items()
|
|
99
|
+
}
|
|
100
|
+
store._outbox = {
|
|
101
|
+
_decode_key(k): OutboxRecord(**v) # type: ignore[arg-type]
|
|
102
|
+
for k, v in dict(payload.get("outbox") or {}).items()
|
|
103
|
+
}
|
|
104
|
+
store._leases = {
|
|
105
|
+
_decode_key(k): LeaseRecord(**v) # type: ignore[arg-type]
|
|
106
|
+
for k, v in dict(payload.get("leases") or {}).items()
|
|
107
|
+
}
|
|
108
|
+
store._attempts = {
|
|
109
|
+
_decode_key(k): AttemptRecord(**v) # type: ignore[arg-type]
|
|
110
|
+
for k, v in dict(payload.get("attempts") or {}).items()
|
|
111
|
+
}
|
|
112
|
+
store._checkpoints = {
|
|
113
|
+
_decode_key(k): CheckpointRecord(**v) # type: ignore[arg-type]
|
|
114
|
+
for k, v in dict(payload.get("checkpoints") or {}).items()
|
|
115
|
+
}
|
|
116
|
+
store._effects = {
|
|
117
|
+
_decode_key(k): EffectRecord(**v) # type: ignore[arg-type]
|
|
118
|
+
for k, v in dict(payload.get("effects") or {}).items()
|
|
119
|
+
}
|
|
120
|
+
store._previews = {
|
|
121
|
+
_decode_key(k): PreviewWorkspace(**v) # type: ignore[arg-type]
|
|
122
|
+
for k, v in dict(payload.get("previews") or {}).items()
|
|
123
|
+
}
|
|
124
|
+
store._diffs = {
|
|
125
|
+
_decode_key(k): DiffRecord(**v) # type: ignore[arg-type]
|
|
126
|
+
for k, v in dict(payload.get("diffs") or {}).items()
|
|
127
|
+
}
|
|
128
|
+
store._shadows = {
|
|
129
|
+
_decode_key(k): ShadowRunRecord(**v) # type: ignore[arg-type]
|
|
130
|
+
for k, v in dict(payload.get("shadows") or {}).items()
|
|
131
|
+
}
|
|
132
|
+
store._baselines = {
|
|
133
|
+
_decode_key(k): BaselineAcknowledgement(**v) # type: ignore[arg-type]
|
|
134
|
+
for k, v in dict(payload.get("baselines") or {}).items()
|
|
135
|
+
}
|
|
136
|
+
store._diagnostics = [
|
|
137
|
+
StateDiagnostic(**d) # type: ignore[arg-type]
|
|
138
|
+
for d in list(payload.get("diagnostics") or [])
|
|
139
|
+
]
|
|
140
|
+
return store
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SQLModelDurableWorkStore:
|
|
144
|
+
"""Transactional DurableWorkStore backed by a SQL snapshot row."""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
engine: Engine,
|
|
149
|
+
*,
|
|
150
|
+
admission_limit: int | None = None,
|
|
151
|
+
store_id: str = "default",
|
|
152
|
+
) -> None:
|
|
153
|
+
self.engine = engine
|
|
154
|
+
self.admission_limit = admission_limit
|
|
155
|
+
self.store_id = store_id
|
|
156
|
+
|
|
157
|
+
def _txn(self, fn: Callable[[MemoryDurableWorkStore], T]) -> T:
|
|
158
|
+
with session_scope(self.engine) as session:
|
|
159
|
+
mem, version = self._read(session, for_update=True)
|
|
160
|
+
if self.admission_limit is not None:
|
|
161
|
+
mem.admission_limit = self.admission_limit
|
|
162
|
+
result = fn(mem)
|
|
163
|
+
self._write(session, mem, expected_version=version)
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def _read_only(self, fn: Callable[[MemoryDurableWorkStore], T]) -> T:
|
|
167
|
+
with session_scope(self.engine) as session:
|
|
168
|
+
mem, _version = self._read(session, for_update=False)
|
|
169
|
+
if self.admission_limit is not None:
|
|
170
|
+
mem.admission_limit = self.admission_limit
|
|
171
|
+
return fn(mem)
|
|
172
|
+
|
|
173
|
+
def _read(
|
|
174
|
+
self, session: Session, *, for_update: bool
|
|
175
|
+
) -> tuple[MemoryDurableWorkStore, int]:
|
|
176
|
+
stmt = select(DurableSnapshotRow).where(
|
|
177
|
+
DurableSnapshotRow.store_id == self.store_id
|
|
178
|
+
)
|
|
179
|
+
if for_update:
|
|
180
|
+
stmt = stmt.with_for_update()
|
|
181
|
+
row = session.exec(stmt).first()
|
|
182
|
+
if row is None:
|
|
183
|
+
return MemoryDurableWorkStore(admission_limit=self.admission_limit), 0
|
|
184
|
+
return _load_store(json.loads(row.payload_json or "{}")), int(
|
|
185
|
+
row.payload_version or 0
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _write(
|
|
189
|
+
self,
|
|
190
|
+
session: Session,
|
|
191
|
+
store: MemoryDurableWorkStore,
|
|
192
|
+
*,
|
|
193
|
+
expected_version: int,
|
|
194
|
+
) -> None:
|
|
195
|
+
payload = json.dumps(_dump_store(store), sort_keys=True)
|
|
196
|
+
row = session.exec(
|
|
197
|
+
select(DurableSnapshotRow)
|
|
198
|
+
.where(DurableSnapshotRow.store_id == self.store_id)
|
|
199
|
+
.with_for_update()
|
|
200
|
+
).first()
|
|
201
|
+
if row is None:
|
|
202
|
+
if expected_version != 0:
|
|
203
|
+
raise ControlPlaneError.conflict(
|
|
204
|
+
"Durable snapshot version conflict (missing row)"
|
|
205
|
+
)
|
|
206
|
+
session.add(
|
|
207
|
+
DurableSnapshotRow(
|
|
208
|
+
store_id=self.store_id,
|
|
209
|
+
payload_json=payload,
|
|
210
|
+
payload_version=1,
|
|
211
|
+
updated_at=_utcnow_iso(),
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return
|
|
215
|
+
current = int(row.payload_version or 0)
|
|
216
|
+
if current != expected_version:
|
|
217
|
+
raise ControlPlaneError.conflict(
|
|
218
|
+
"Durable snapshot version conflict (stale write)"
|
|
219
|
+
)
|
|
220
|
+
row.payload_json = payload
|
|
221
|
+
row.payload_version = current + 1
|
|
222
|
+
row.updated_at = _utcnow_iso()
|
|
223
|
+
session.add(row)
|
|
224
|
+
|
|
225
|
+
def accept(self, ctx: ControlPlaneContext, **kwargs: Any):
|
|
226
|
+
return self._txn(lambda m: m.accept(ctx, **kwargs))
|
|
227
|
+
|
|
228
|
+
def pending_outbox(self, ctx: ControlPlaneContext, *, limit: int = 100):
|
|
229
|
+
return self._read_only(lambda m: m.pending_outbox(ctx, limit=limit))
|
|
230
|
+
|
|
231
|
+
def mark_published(self, ctx: ControlPlaneContext, outbox_id: str):
|
|
232
|
+
return self._txn(lambda m: m.mark_published(ctx, outbox_id))
|
|
233
|
+
|
|
234
|
+
def cancel_submission(self, ctx: ControlPlaneContext, submission_id: str):
|
|
235
|
+
return self._txn(lambda m: m.cancel_submission(ctx, submission_id))
|
|
236
|
+
|
|
237
|
+
def acquire_lease(
|
|
238
|
+
self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any
|
|
239
|
+
):
|
|
240
|
+
return self._txn(lambda m: m.acquire_lease(ctx, submission_id, **kwargs))
|
|
241
|
+
|
|
242
|
+
def heartbeat(self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any):
|
|
243
|
+
return self._txn(lambda m: m.heartbeat(ctx, submission_id, **kwargs))
|
|
244
|
+
|
|
245
|
+
def release_lease(
|
|
246
|
+
self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any
|
|
247
|
+
):
|
|
248
|
+
return self._txn(lambda m: m.release_lease(ctx, submission_id, **kwargs))
|
|
249
|
+
|
|
250
|
+
def start_attempt(
|
|
251
|
+
self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any
|
|
252
|
+
):
|
|
253
|
+
return self._txn(lambda m: m.start_attempt(ctx, submission_id, **kwargs))
|
|
254
|
+
|
|
255
|
+
def finish_attempt(self, ctx: ControlPlaneContext, attempt_id: str, **kwargs: Any):
|
|
256
|
+
return self._txn(lambda m: m.finish_attempt(ctx, attempt_id, **kwargs))
|
|
257
|
+
|
|
258
|
+
def compare_and_swap_checkpoint(
|
|
259
|
+
self, ctx: ControlPlaneContext, checkpoint_id: str, **kwargs: Any
|
|
260
|
+
):
|
|
261
|
+
return self._txn(
|
|
262
|
+
lambda m: m.compare_and_swap_checkpoint(ctx, checkpoint_id, **kwargs)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def explain_transition(
|
|
266
|
+
self, ctx: ControlPlaneContext, checkpoint_id: str, **kwargs: Any
|
|
267
|
+
):
|
|
268
|
+
return self._read_only(
|
|
269
|
+
lambda m: m.explain_transition(ctx, checkpoint_id, **kwargs)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def diagnose_checkpoint(
|
|
273
|
+
self, ctx: ControlPlaneContext, checkpoint_id: str, **kwargs: Any
|
|
274
|
+
):
|
|
275
|
+
return self._txn(lambda m: m.diagnose_checkpoint(ctx, checkpoint_id, **kwargs))
|
|
276
|
+
|
|
277
|
+
def acknowledge_baseline(self, ctx: ControlPlaneContext, **kwargs: Any):
|
|
278
|
+
return self._txn(lambda m: m.acknowledge_baseline(ctx, **kwargs))
|
|
279
|
+
|
|
280
|
+
def record_effect(self, ctx: ControlPlaneContext, effect: EffectRecord):
|
|
281
|
+
return self._txn(lambda m: m.record_effect(ctx, effect))
|
|
282
|
+
|
|
283
|
+
def replay(self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any):
|
|
284
|
+
return self._read_only(lambda m: m.replay(ctx, submission_id, **kwargs))
|
|
285
|
+
|
|
286
|
+
def plan_resume(self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any):
|
|
287
|
+
return self._read_only(lambda m: m.plan_resume(ctx, submission_id, **kwargs))
|
|
288
|
+
|
|
289
|
+
def plan_repair(self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any):
|
|
290
|
+
return self._read_only(lambda m: m.plan_repair(ctx, submission_id, **kwargs))
|
|
291
|
+
|
|
292
|
+
def plan_backfill(
|
|
293
|
+
self, ctx: ControlPlaneContext, submission_id: str, **kwargs: Any
|
|
294
|
+
):
|
|
295
|
+
return self._read_only(lambda m: m.plan_backfill(ctx, submission_id, **kwargs))
|
|
296
|
+
|
|
297
|
+
def create_preview(self, ctx: ControlPlaneContext, preview: PreviewWorkspace):
|
|
298
|
+
return self._txn(lambda m: m.create_preview(ctx, preview))
|
|
299
|
+
|
|
300
|
+
def mark_preview_stale(
|
|
301
|
+
self, ctx: ControlPlaneContext, preview_id: str, **kwargs: Any
|
|
302
|
+
):
|
|
303
|
+
return self._txn(lambda m: m.mark_preview_stale(ctx, preview_id, **kwargs))
|
|
304
|
+
|
|
305
|
+
def record_preview_diff(self, ctx: ControlPlaneContext, diff: DiffRecord):
|
|
306
|
+
return self._txn(lambda m: m.record_preview_diff(ctx, diff))
|
|
307
|
+
|
|
308
|
+
def authorize_shadow_run(self, ctx: ControlPlaneContext, shadow: ShadowRunRecord):
|
|
309
|
+
return self._txn(lambda m: m.authorize_shadow_run(ctx, shadow))
|
|
310
|
+
|
|
311
|
+
def cleanup_expired_previews(self, ctx: ControlPlaneContext):
|
|
312
|
+
return self._txn(lambda m: m.cleanup_expired_previews(ctx))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
__all__ = [
|
|
316
|
+
"DURABLE_TABLES",
|
|
317
|
+
"SQLModelDurableWorkStore",
|
|
318
|
+
"create_durable_tables",
|
|
319
|
+
]
|
{etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/models.py
RENAMED
|
@@ -6,7 +6,7 @@ pipeline contracts.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from sqlalchemy import Column, String, UniqueConstraint
|
|
9
|
+
from sqlalchemy import Column, Integer, String, UniqueConstraint
|
|
10
10
|
|
|
11
11
|
from sqlmodel import Field, SQLModel
|
|
12
12
|
|
|
@@ -266,9 +266,29 @@ class SecurityDomainRow(SQLModel, table=True):
|
|
|
266
266
|
metadata_json: str = "{}"
|
|
267
267
|
|
|
268
268
|
|
|
269
|
+
class DurableSnapshotRow(SQLModel, table=True):
|
|
270
|
+
"""Transactional CP3 durable-work snapshot (reference provider).
|
|
271
|
+
|
|
272
|
+
One logical store per engine; mutations load/save under a DB transaction so
|
|
273
|
+
accept+outbox and fencing CAS share commit boundaries.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
__tablename__ = "cp_durable_snapshot"
|
|
277
|
+
__table_args__ = (UniqueConstraint("store_id", name="uq_cp_durable_snapshot"),)
|
|
278
|
+
|
|
279
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
280
|
+
store_id: str = Field(
|
|
281
|
+
sa_column=Column(String(), index=True, nullable=False), default="default"
|
|
282
|
+
)
|
|
283
|
+
payload_json: str = "{}"
|
|
284
|
+
payload_version: int = Field(default=0, sa_column=Column(Integer(), nullable=False))
|
|
285
|
+
updated_at: str | None = None
|
|
286
|
+
|
|
287
|
+
|
|
269
288
|
__all__ = [
|
|
270
289
|
"AliasRow",
|
|
271
290
|
"DefinitionRow",
|
|
291
|
+
"DurableSnapshotRow",
|
|
272
292
|
"EnvironmentRow",
|
|
273
293
|
"EventRow",
|
|
274
294
|
"LogicalIdentityRow",
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
from collections.abc import Mapping
|
|
12
|
-
from dataclasses import dataclass
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
13
|
from datetime import UTC, datetime
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any
|
|
@@ -21,6 +21,7 @@ from etlantic.control_plane import (
|
|
|
21
21
|
ControlPlaneContext,
|
|
22
22
|
EnvironmentRecord,
|
|
23
23
|
EnvironmentRef,
|
|
24
|
+
LifecycleState,
|
|
24
25
|
LogicalIdentity,
|
|
25
26
|
Principal,
|
|
26
27
|
PromotionRecord,
|
|
@@ -32,7 +33,9 @@ from etlantic.control_plane import (
|
|
|
32
33
|
WorkspaceRecord,
|
|
33
34
|
WorkspaceRef,
|
|
34
35
|
content_fingerprint,
|
|
36
|
+
redact_control_plane_payload,
|
|
35
37
|
)
|
|
38
|
+
from etlantic.control_plane.registry_memory import safe_registry_content
|
|
36
39
|
from etlantic_sqlmodel.control_plane.models import (
|
|
37
40
|
AliasRow,
|
|
38
41
|
EnvironmentRow,
|
|
@@ -147,23 +150,26 @@ def dump_registry_sqlite(engine: Engine) -> BackupTranscript:
|
|
|
147
150
|
}
|
|
148
151
|
for row in session.exec(select(WorkspaceRow)).all()
|
|
149
152
|
]
|
|
150
|
-
revisions = [
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
153
|
+
revisions = []
|
|
154
|
+
for row in session.exec(select(RevisionRow)).all():
|
|
155
|
+
content = safe_registry_content(json.loads(row.content_json or "{}"))
|
|
156
|
+
provenance = redact_control_plane_payload(
|
|
157
|
+
json.loads(row.provenance_json) if row.provenance_json else {}
|
|
158
|
+
)
|
|
159
|
+
revisions.append(
|
|
160
|
+
{
|
|
161
|
+
"tenant_id": row.tenant_id,
|
|
162
|
+
"workspace_id": row.workspace_id,
|
|
163
|
+
"logical_id": row.logical_id,
|
|
164
|
+
"revision_id": row.revision_id,
|
|
165
|
+
"content_fingerprint": content_fingerprint(content),
|
|
166
|
+
"content": content,
|
|
167
|
+
"created_at": row.created_at,
|
|
168
|
+
"kind": row.kind,
|
|
169
|
+
"signature_placeholder": row.signature_placeholder,
|
|
170
|
+
"provenance": provenance if isinstance(provenance, dict) else {},
|
|
171
|
+
}
|
|
172
|
+
)
|
|
167
173
|
logicals = [
|
|
168
174
|
{
|
|
169
175
|
"tenant_id": row.tenant_id,
|
|
@@ -286,7 +292,13 @@ def load_registry_sqlite(
|
|
|
286
292
|
workspace_id="__backup__",
|
|
287
293
|
domain_id=domain_id,
|
|
288
294
|
)
|
|
289
|
-
provider.tenants.put(
|
|
295
|
+
provider.tenants.put(
|
|
296
|
+
ctx,
|
|
297
|
+
replace(
|
|
298
|
+
TenantRecord.from_dict(tenant),
|
|
299
|
+
lifecycle=LifecycleState.ACTIVE,
|
|
300
|
+
),
|
|
301
|
+
)
|
|
290
302
|
|
|
291
303
|
for workspace in transcript.workspaces:
|
|
292
304
|
tenant_id = str(workspace["tenant_id"])
|
|
@@ -304,7 +316,13 @@ def load_registry_sqlite(
|
|
|
304
316
|
workspace_id=workspace_id,
|
|
305
317
|
domain_id=domain_id,
|
|
306
318
|
)
|
|
307
|
-
provider.workspaces.put(
|
|
319
|
+
provider.workspaces.put(
|
|
320
|
+
ctx,
|
|
321
|
+
replace(
|
|
322
|
+
WorkspaceRecord.from_dict(workspace),
|
|
323
|
+
lifecycle=LifecycleState.ACTIVE,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
308
326
|
|
|
309
327
|
for environment in transcript.environments:
|
|
310
328
|
tenant_id = str(environment["tenant_id"])
|
|
@@ -318,13 +336,14 @@ def load_registry_sqlite(
|
|
|
318
336
|
),
|
|
319
337
|
"backup-domain",
|
|
320
338
|
)
|
|
339
|
+
environment_record = EnvironmentRecord.from_dict(environment)
|
|
321
340
|
provider.put_environment(
|
|
322
341
|
_admin_ctx(
|
|
323
342
|
tenant_id=tenant_id,
|
|
324
343
|
workspace_id=workspace_id,
|
|
325
344
|
domain_id=domain_id,
|
|
326
345
|
),
|
|
327
|
-
|
|
346
|
+
replace(environment_record, lifecycle=LifecycleState.ACTIVE),
|
|
328
347
|
)
|
|
329
348
|
|
|
330
349
|
for logical in transcript.logicals:
|
|
@@ -416,10 +435,72 @@ def load_registry_sqlite(
|
|
|
416
435
|
from_environment=record.from_environment,
|
|
417
436
|
to_environment=record.to_environment,
|
|
418
437
|
created_at=record.created_at,
|
|
419
|
-
metadata_json=json.dumps(
|
|
438
|
+
metadata_json=json.dumps(
|
|
439
|
+
redact_control_plane_payload(dict(record.metadata)),
|
|
440
|
+
sort_keys=True,
|
|
441
|
+
),
|
|
420
442
|
)
|
|
421
443
|
)
|
|
422
444
|
|
|
445
|
+
# Apply archived/suspended lifecycle states only after dependent rows exist.
|
|
446
|
+
for environment in transcript.environments:
|
|
447
|
+
record = EnvironmentRecord.from_dict(environment)
|
|
448
|
+
if record.lifecycle == LifecycleState.ACTIVE:
|
|
449
|
+
continue
|
|
450
|
+
workspace_id = record.workspace_id or "__backup__"
|
|
451
|
+
domain_id = next(
|
|
452
|
+
(
|
|
453
|
+
str(tenant["security_domain_id"])
|
|
454
|
+
for tenant in transcript.tenants
|
|
455
|
+
if tenant.get("tenant_id") == record.tenant_id
|
|
456
|
+
and tenant.get("security_domain_id")
|
|
457
|
+
),
|
|
458
|
+
"backup-domain",
|
|
459
|
+
)
|
|
460
|
+
provider.put_environment(
|
|
461
|
+
_admin_ctx(
|
|
462
|
+
tenant_id=record.tenant_id,
|
|
463
|
+
workspace_id=workspace_id,
|
|
464
|
+
domain_id=domain_id,
|
|
465
|
+
),
|
|
466
|
+
record,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
for workspace in transcript.workspaces:
|
|
470
|
+
record = WorkspaceRecord.from_dict(workspace)
|
|
471
|
+
if record.lifecycle != LifecycleState.ACTIVE:
|
|
472
|
+
domain_id = next(
|
|
473
|
+
(
|
|
474
|
+
str(tenant["security_domain_id"])
|
|
475
|
+
for tenant in transcript.tenants
|
|
476
|
+
if tenant.get("tenant_id") == record.tenant_id
|
|
477
|
+
and tenant.get("security_domain_id")
|
|
478
|
+
),
|
|
479
|
+
"backup-domain",
|
|
480
|
+
)
|
|
481
|
+
provider.workspaces.set_lifecycle(
|
|
482
|
+
_admin_ctx(
|
|
483
|
+
tenant_id=record.tenant_id,
|
|
484
|
+
workspace_id=record.workspace_id,
|
|
485
|
+
domain_id=domain_id,
|
|
486
|
+
),
|
|
487
|
+
record.workspace_id,
|
|
488
|
+
record.lifecycle,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
for tenant in transcript.tenants:
|
|
492
|
+
record = TenantRecord.from_dict(tenant)
|
|
493
|
+
if record.lifecycle != LifecycleState.ACTIVE:
|
|
494
|
+
provider.tenants.set_lifecycle(
|
|
495
|
+
_admin_ctx(
|
|
496
|
+
tenant_id=record.tenant_id,
|
|
497
|
+
workspace_id="__backup__",
|
|
498
|
+
domain_id=record.security_domain_id or "backup-domain",
|
|
499
|
+
),
|
|
500
|
+
record.tenant_id,
|
|
501
|
+
record.lifecycle,
|
|
502
|
+
)
|
|
503
|
+
|
|
423
504
|
return provider
|
|
424
505
|
|
|
425
506
|
|
|
@@ -32,6 +32,8 @@ from etlantic.control_plane import (
|
|
|
32
32
|
content_fingerprint,
|
|
33
33
|
redact_control_plane_payload,
|
|
34
34
|
)
|
|
35
|
+
from etlantic.control_plane.redaction import redact_control_plane_text
|
|
36
|
+
from etlantic.control_plane.registry_memory import safe_registry_content
|
|
35
37
|
from etlantic_sqlmodel.control_plane.models import (
|
|
36
38
|
AliasRow,
|
|
37
39
|
EnvironmentRow,
|
|
@@ -192,58 +194,33 @@ class SqlModelTenantDirectory:
|
|
|
192
194
|
def get(self, ctx: ControlPlaneContext, tenant_id: str) -> TenantRecord:
|
|
193
195
|
with session_scope(self.engine) as session:
|
|
194
196
|
row = self._by_id(session, tenant_id)
|
|
195
|
-
if row is None:
|
|
197
|
+
if row is None or ctx.tenant.tenant_id != tenant_id:
|
|
196
198
|
raise ControlPlaneError.not_found(
|
|
197
199
|
"Tenant not found",
|
|
198
200
|
extensions={"tenant_id": tenant_id},
|
|
199
201
|
)
|
|
200
202
|
record = _tenant_from_row(row)
|
|
201
|
-
in_scope = ctx.tenant.tenant_id == tenant_id
|
|
202
|
-
admin_scope = (
|
|
203
|
-
record.security_domain_id is not None
|
|
204
|
-
and record.security_domain_id == ctx.security_domain.domain_id
|
|
205
|
-
)
|
|
206
|
-
if not in_scope and not admin_scope:
|
|
207
|
-
raise ControlPlaneError.not_found(
|
|
208
|
-
"Tenant not found",
|
|
209
|
-
extensions={"tenant_id": tenant_id},
|
|
210
|
-
)
|
|
211
203
|
_require_active(record.lifecycle, resource=f"tenant:{tenant_id}")
|
|
212
204
|
return deepcopy(record)
|
|
213
205
|
|
|
214
206
|
def put(self, ctx: ControlPlaneContext, record: TenantRecord) -> None:
|
|
215
207
|
with session_scope(self.engine) as session:
|
|
208
|
+
if ctx.tenant.tenant_id != record.tenant_id:
|
|
209
|
+
raise ControlPlaneError.not_found(
|
|
210
|
+
"Tenant not found",
|
|
211
|
+
extensions={"tenant_id": record.tenant_id},
|
|
212
|
+
)
|
|
216
213
|
existing = self._by_id(session, record.tenant_id)
|
|
217
214
|
if existing is not None:
|
|
218
215
|
_require_active(
|
|
219
216
|
LifecycleState(existing.lifecycle),
|
|
220
217
|
resource=f"tenant:{record.tenant_id}",
|
|
221
218
|
)
|
|
222
|
-
existing_in_scope = ctx.tenant.tenant_id == record.tenant_id
|
|
223
|
-
existing_admin_scope = (
|
|
224
|
-
existing.security_domain_id is not None
|
|
225
|
-
and existing.security_domain_id == ctx.security_domain.domain_id
|
|
226
|
-
)
|
|
227
|
-
if not existing_in_scope and not existing_admin_scope:
|
|
228
|
-
raise ControlPlaneError.not_found(
|
|
229
|
-
"Tenant not found",
|
|
230
|
-
extensions={"tenant_id": record.tenant_id},
|
|
231
|
-
)
|
|
232
219
|
if existing.security_domain_id != record.security_domain_id:
|
|
233
220
|
raise ControlPlaneError.conflict(
|
|
234
221
|
"Tenant security domain is immutable through directory put",
|
|
235
222
|
extensions={"tenant_id": record.tenant_id},
|
|
236
223
|
)
|
|
237
|
-
in_scope = ctx.tenant.tenant_id == record.tenant_id
|
|
238
|
-
admin_scope = (
|
|
239
|
-
record.security_domain_id is not None
|
|
240
|
-
and record.security_domain_id == ctx.security_domain.domain_id
|
|
241
|
-
)
|
|
242
|
-
if not in_scope and not admin_scope:
|
|
243
|
-
raise ControlPlaneError.not_found(
|
|
244
|
-
"Tenant not found",
|
|
245
|
-
extensions={"tenant_id": record.tenant_id},
|
|
246
|
-
)
|
|
247
224
|
now = _utcnow_iso()
|
|
248
225
|
if existing is None:
|
|
249
226
|
session.add(
|
|
@@ -267,16 +244,10 @@ class SqlModelTenantDirectory:
|
|
|
267
244
|
|
|
268
245
|
def list(self, ctx: ControlPlaneContext) -> Sequence[TenantRecord]:
|
|
269
246
|
with session_scope(self.engine) as session:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if record.tenant_id == ctx.tenant.tenant_id or (
|
|
275
|
-
record.security_domain_id is not None
|
|
276
|
-
and record.security_domain_id == ctx.security_domain.domain_id
|
|
277
|
-
):
|
|
278
|
-
out.append(deepcopy(record))
|
|
279
|
-
return sorted(out, key=lambda r: r.tenant_id)
|
|
247
|
+
row = self._by_id(session, ctx.tenant.tenant_id)
|
|
248
|
+
if row is None:
|
|
249
|
+
return ()
|
|
250
|
+
return (deepcopy(_tenant_from_row(row)),)
|
|
280
251
|
|
|
281
252
|
def set_lifecycle(
|
|
282
253
|
self,
|
|
@@ -286,10 +257,7 @@ class SqlModelTenantDirectory:
|
|
|
286
257
|
) -> TenantRecord:
|
|
287
258
|
with session_scope(self.engine) as session:
|
|
288
259
|
row = self._by_id(session, tenant_id)
|
|
289
|
-
if row is None or
|
|
290
|
-
ctx.tenant.tenant_id != tenant_id
|
|
291
|
-
and row.security_domain_id != ctx.security_domain.domain_id
|
|
292
|
-
):
|
|
260
|
+
if row is None or ctx.tenant.tenant_id != tenant_id:
|
|
293
261
|
raise ControlPlaneError.not_found(
|
|
294
262
|
"Tenant not found",
|
|
295
263
|
extensions={"tenant_id": tenant_id},
|
|
@@ -327,6 +295,11 @@ class SqlModelWorkspaceDirectory:
|
|
|
327
295
|
_require_active(tenant.lifecycle, resource=f"tenant:{tenant_id}")
|
|
328
296
|
|
|
329
297
|
def get(self, ctx: ControlPlaneContext, workspace_id: str) -> WorkspaceRecord:
|
|
298
|
+
if workspace_id != ctx.workspace.workspace_id:
|
|
299
|
+
raise ControlPlaneError.not_found(
|
|
300
|
+
"Workspace not found",
|
|
301
|
+
extensions={"workspace_id": workspace_id},
|
|
302
|
+
)
|
|
330
303
|
with session_scope(self.engine) as session:
|
|
331
304
|
self._assert_tenant_active(ctx.tenant.tenant_id)
|
|
332
305
|
row = self._by_key(session, ctx.tenant.tenant_id, workspace_id)
|
|
@@ -343,7 +316,10 @@ class SqlModelWorkspaceDirectory:
|
|
|
343
316
|
return deepcopy(record)
|
|
344
317
|
|
|
345
318
|
def put(self, ctx: ControlPlaneContext, record: WorkspaceRecord) -> None:
|
|
346
|
-
if
|
|
319
|
+
if (
|
|
320
|
+
record.tenant_id != ctx.tenant.tenant_id
|
|
321
|
+
or record.workspace_id != ctx.workspace.workspace_id
|
|
322
|
+
):
|
|
347
323
|
raise ControlPlaneError.not_found(
|
|
348
324
|
"Workspace not found",
|
|
349
325
|
extensions={"workspace_id": record.workspace_id},
|
|
@@ -394,6 +370,11 @@ class SqlModelWorkspaceDirectory:
|
|
|
394
370
|
workspace_id: str,
|
|
395
371
|
state: LifecycleState,
|
|
396
372
|
) -> WorkspaceRecord:
|
|
373
|
+
if workspace_id != ctx.workspace.workspace_id:
|
|
374
|
+
raise ControlPlaneError.not_found(
|
|
375
|
+
"Workspace not found",
|
|
376
|
+
extensions={"workspace_id": workspace_id},
|
|
377
|
+
)
|
|
397
378
|
with session_scope(self.engine) as session:
|
|
398
379
|
self._assert_tenant_active(ctx.tenant.tenant_id)
|
|
399
380
|
row = self._by_key(session, ctx.tenant.tenant_id, workspace_id)
|
|
@@ -549,6 +530,7 @@ class SqlModelRevisionRegistry:
|
|
|
549
530
|
"recorded": revision.content_fingerprint,
|
|
550
531
|
},
|
|
551
532
|
)
|
|
533
|
+
safe_content = safe_registry_content(revision.content)
|
|
552
534
|
try:
|
|
553
535
|
with session_scope(self.engine) as session:
|
|
554
536
|
self._assert_scope_active(ctx)
|
|
@@ -597,15 +579,17 @@ class SqlModelRevisionRegistry:
|
|
|
597
579
|
workspace_id=revision.workspace_id,
|
|
598
580
|
logical_id=revision.logical_id,
|
|
599
581
|
revision_id=revision.revision_id,
|
|
600
|
-
content_fingerprint=
|
|
601
|
-
content_json=json.dumps(
|
|
582
|
+
content_fingerprint=content_fingerprint(safe_content),
|
|
583
|
+
content_json=json.dumps(safe_content, sort_keys=True),
|
|
602
584
|
created_at=revision.created_at or _utcnow_iso(),
|
|
603
585
|
kind=revision.kind,
|
|
604
|
-
signature_placeholder=
|
|
586
|
+
signature_placeholder=(
|
|
587
|
+
redact_control_plane_text(revision.signature_placeholder)
|
|
588
|
+
if revision.signature_placeholder is not None
|
|
589
|
+
else None
|
|
590
|
+
),
|
|
605
591
|
provenance_json=(
|
|
606
|
-
|
|
607
|
-
dict(revision.provenance_placeholder), sort_keys=True
|
|
608
|
-
)
|
|
592
|
+
_meta(revision.provenance_placeholder)
|
|
609
593
|
if revision.provenance_placeholder is not None
|
|
610
594
|
else None
|
|
611
595
|
),
|
|
@@ -653,7 +637,7 @@ class SqlModelRevisionRegistry:
|
|
|
653
637
|
key=lambda r: r.revision_id,
|
|
654
638
|
)
|
|
655
639
|
|
|
656
|
-
def put_alias(self, ctx: ControlPlaneContext, alias: AliasRecord) ->
|
|
640
|
+
def put_alias(self, ctx: ControlPlaneContext, alias: AliasRecord) -> AliasRecord:
|
|
657
641
|
if (
|
|
658
642
|
alias.tenant_id != ctx.tenant.tenant_id
|
|
659
643
|
or alias.workspace_id != ctx.workspace.workspace_id
|
|
@@ -706,6 +690,12 @@ class SqlModelRevisionRegistry:
|
|
|
706
690
|
existing.created_at = created
|
|
707
691
|
existing.metadata_json = _meta(alias.metadata)
|
|
708
692
|
session.add(existing)
|
|
693
|
+
session.flush()
|
|
694
|
+
row = self._alias_row(
|
|
695
|
+
session, ctx.tenant.tenant_id, ctx.workspace.workspace_id, alias.alias
|
|
696
|
+
)
|
|
697
|
+
assert row is not None
|
|
698
|
+
return deepcopy(_alias_from_row(row))
|
|
709
699
|
|
|
710
700
|
def resolve_alias(self, ctx: ControlPlaneContext, alias: str) -> RegistryRevision:
|
|
711
701
|
with session_scope(self.engine) as session:
|
|
@@ -757,11 +747,12 @@ class SqlModelRevisionRegistry:
|
|
|
757
747
|
)
|
|
758
748
|
source = _revision_from_row(source_row)
|
|
759
749
|
source_snapshot = deepcopy(source)
|
|
760
|
-
|
|
750
|
+
requested_body = (
|
|
761
751
|
deepcopy(dict(content))
|
|
762
752
|
if content is not None
|
|
763
753
|
else deepcopy(dict(source.content))
|
|
764
754
|
)
|
|
755
|
+
body = safe_registry_content(requested_body)
|
|
765
756
|
new_revision_id = f"rev-{uuid.uuid4().hex[:16]}"
|
|
766
757
|
created = _utcnow_iso()
|
|
767
758
|
new_rev = RegistryRevision(
|
|
@@ -819,7 +810,7 @@ class SqlModelRevisionRegistry:
|
|
|
819
810
|
from_environment=from_environment,
|
|
820
811
|
to_environment=to_environment,
|
|
821
812
|
created_at=created,
|
|
822
|
-
metadata=
|
|
813
|
+
metadata=json.loads(_meta(metadata)),
|
|
823
814
|
)
|
|
824
815
|
session.add(
|
|
825
816
|
PromotionRow(
|
|
@@ -948,6 +939,15 @@ class SqlModelRegistryProvider:
|
|
|
948
939
|
_require_active(
|
|
949
940
|
tenant.lifecycle, resource=f"tenant:{ctx.tenant.tenant_id}"
|
|
950
941
|
)
|
|
942
|
+
assert self.workspaces is not None
|
|
943
|
+
workspace = self.workspaces.peek(
|
|
944
|
+
ctx.tenant.tenant_id, ctx.workspace.workspace_id
|
|
945
|
+
)
|
|
946
|
+
if workspace is not None:
|
|
947
|
+
_require_active(
|
|
948
|
+
workspace.lifecycle,
|
|
949
|
+
resource=f"workspace:{ctx.workspace.workspace_id}",
|
|
950
|
+
)
|
|
951
951
|
now = _utcnow_iso()
|
|
952
952
|
existing = session.exec(
|
|
953
953
|
select(EnvironmentRow).where(
|
|
@@ -955,6 +955,20 @@ class SqlModelRegistryProvider:
|
|
|
955
955
|
EnvironmentRow.environment_id == record.environment_id,
|
|
956
956
|
)
|
|
957
957
|
).first()
|
|
958
|
+
if (
|
|
959
|
+
existing is not None
|
|
960
|
+
and existing.workspace_id is not None
|
|
961
|
+
and existing.workspace_id != ctx.workspace.workspace_id
|
|
962
|
+
):
|
|
963
|
+
raise ControlPlaneError.not_found(
|
|
964
|
+
"Environment not found",
|
|
965
|
+
extensions={"environment_id": record.environment_id},
|
|
966
|
+
)
|
|
967
|
+
if existing is not None:
|
|
968
|
+
_require_active(
|
|
969
|
+
LifecycleState(existing.lifecycle),
|
|
970
|
+
resource=f"environment:{record.environment_id}",
|
|
971
|
+
)
|
|
958
972
|
if existing is None:
|
|
959
973
|
session.add(
|
|
960
974
|
EnvironmentRow(
|
|
@@ -988,29 +1002,44 @@ class SqlModelRegistryProvider:
|
|
|
988
1002
|
_require_active(
|
|
989
1003
|
tenant.lifecycle, resource=f"tenant:{ctx.tenant.tenant_id}"
|
|
990
1004
|
)
|
|
1005
|
+
assert self.workspaces is not None
|
|
1006
|
+
workspace = self.workspaces.peek(
|
|
1007
|
+
ctx.tenant.tenant_id, ctx.workspace.workspace_id
|
|
1008
|
+
)
|
|
1009
|
+
if workspace is not None:
|
|
1010
|
+
_require_active(
|
|
1011
|
+
workspace.lifecycle,
|
|
1012
|
+
resource=f"workspace:{ctx.workspace.workspace_id}",
|
|
1013
|
+
)
|
|
991
1014
|
row = session.exec(
|
|
992
1015
|
select(EnvironmentRow).where(
|
|
993
1016
|
EnvironmentRow.tenant_id == ctx.tenant.tenant_id,
|
|
994
1017
|
EnvironmentRow.environment_id == environment_id,
|
|
995
1018
|
)
|
|
996
1019
|
).first()
|
|
997
|
-
if row is None
|
|
1020
|
+
if row is None or (
|
|
1021
|
+
row.workspace_id is not None
|
|
1022
|
+
and row.workspace_id != ctx.workspace.workspace_id
|
|
1023
|
+
):
|
|
998
1024
|
raise ControlPlaneError.not_found(
|
|
999
1025
|
"Environment not found",
|
|
1000
1026
|
extensions={"environment_id": environment_id},
|
|
1001
1027
|
)
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
metadata=_load_meta(row.metadata_json),
|
|
1012
|
-
)
|
|
1028
|
+
record = EnvironmentRecord(
|
|
1029
|
+
tenant_id=row.tenant_id,
|
|
1030
|
+
environment_id=row.environment_id,
|
|
1031
|
+
name=row.name,
|
|
1032
|
+
workspace_id=row.workspace_id,
|
|
1033
|
+
lifecycle=LifecycleState(row.lifecycle),
|
|
1034
|
+
created_at=row.created_at,
|
|
1035
|
+
updated_at=row.updated_at,
|
|
1036
|
+
metadata=_load_meta(row.metadata_json),
|
|
1013
1037
|
)
|
|
1038
|
+
_require_active(
|
|
1039
|
+
record.lifecycle,
|
|
1040
|
+
resource=f"environment:{environment_id}",
|
|
1041
|
+
)
|
|
1042
|
+
return deepcopy(record)
|
|
1014
1043
|
|
|
1015
1044
|
def put_security_domain(
|
|
1016
1045
|
self,
|
{etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/stores.py
RENAMED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import uuid
|
|
7
7
|
from collections.abc import Callable, Mapping, Sequence
|
|
8
|
+
from copy import deepcopy
|
|
8
9
|
from datetime import UTC, datetime
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -16,6 +17,7 @@ from etlantic.control_plane import (
|
|
|
16
17
|
ControlPlaneContext,
|
|
17
18
|
ControlPlaneError,
|
|
18
19
|
ControlPlaneEvent,
|
|
20
|
+
redact_control_plane_payload,
|
|
19
21
|
)
|
|
20
22
|
from etlantic_sqlmodel.control_plane.models import (
|
|
21
23
|
DefinitionRow,
|
|
@@ -130,6 +132,9 @@ class SQLModelSubmissionStore:
|
|
|
130
132
|
) -> AcceptResult:
|
|
131
133
|
from sqlalchemy.exc import IntegrityError
|
|
132
134
|
|
|
135
|
+
safe_payload = redact_control_plane_payload(deepcopy(dict(payload)))
|
|
136
|
+
if not isinstance(safe_payload, dict):
|
|
137
|
+
safe_payload = {}
|
|
133
138
|
try:
|
|
134
139
|
with session_scope(self._engine) as session:
|
|
135
140
|
existing = self._by_idem(
|
|
@@ -137,7 +142,7 @@ class SQLModelSubmissionStore:
|
|
|
137
142
|
)
|
|
138
143
|
if existing is not None:
|
|
139
144
|
prior = json.loads(existing.payload_json)
|
|
140
|
-
if prior !=
|
|
145
|
+
if prior != safe_payload:
|
|
141
146
|
raise ControlPlaneError.conflict(
|
|
142
147
|
"Idempotency key reuse with a different payload",
|
|
143
148
|
extensions={"idempotency_key": idempotency_key},
|
|
@@ -162,12 +167,12 @@ class SQLModelSubmissionStore:
|
|
|
162
167
|
status="accepted",
|
|
163
168
|
resource_type=resource_type,
|
|
164
169
|
resource_id=run_id,
|
|
165
|
-
payload_json=json.dumps(
|
|
170
|
+
payload_json=json.dumps(safe_payload, sort_keys=True),
|
|
166
171
|
run_status="accepted",
|
|
167
172
|
updated_at=created,
|
|
168
173
|
definition_id=(
|
|
169
|
-
str(
|
|
170
|
-
if
|
|
174
|
+
str(safe_payload["definition_id"])
|
|
175
|
+
if safe_payload.get("definition_id") is not None
|
|
171
176
|
else None
|
|
172
177
|
),
|
|
173
178
|
)
|
|
@@ -185,7 +190,7 @@ class SQLModelSubmissionStore:
|
|
|
185
190
|
extensions={"idempotency_key": idempotency_key},
|
|
186
191
|
) from exc
|
|
187
192
|
prior = json.loads(winner.payload_json)
|
|
188
|
-
if prior !=
|
|
193
|
+
if prior != safe_payload:
|
|
189
194
|
raise ControlPlaneError.conflict(
|
|
190
195
|
"Idempotency key reuse with a different payload",
|
|
191
196
|
extensions={"idempotency_key": idempotency_key},
|
|
@@ -311,6 +316,9 @@ class SqlModelEventStore:
|
|
|
311
316
|
) -> ControlPlaneEvent:
|
|
312
317
|
import hashlib
|
|
313
318
|
|
|
319
|
+
safe_payload = redact_control_plane_payload(deepcopy(dict(payload or {})))
|
|
320
|
+
if not isinstance(safe_payload, dict):
|
|
321
|
+
safe_payload = {}
|
|
314
322
|
with session_scope(self._engine) as session:
|
|
315
323
|
statement = (
|
|
316
324
|
select(EventRow)
|
|
@@ -339,7 +347,7 @@ class SqlModelEventStore:
|
|
|
339
347
|
cursor=cursor,
|
|
340
348
|
kind=kind,
|
|
341
349
|
created_at=created,
|
|
342
|
-
payload_json=json.dumps(
|
|
350
|
+
payload_json=json.dumps(safe_payload, sort_keys=True),
|
|
343
351
|
correlation_id=correlation_id,
|
|
344
352
|
)
|
|
345
353
|
session.add(row)
|
|
@@ -350,7 +358,7 @@ class SqlModelEventStore:
|
|
|
350
358
|
cursor=cursor,
|
|
351
359
|
kind=kind,
|
|
352
360
|
created_at=created,
|
|
353
|
-
payload=
|
|
361
|
+
payload=safe_payload,
|
|
354
362
|
correlation_id=correlation_id,
|
|
355
363
|
scope={
|
|
356
364
|
"tenant_id": ctx.tenant.tenant_id,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""002 — CP3 durable work snapshot tables.
|
|
2
|
+
|
|
3
|
+
Production note: apply via ``etlantic_sqlmodel.migrations.upgrade``.
|
|
4
|
+
``create_all`` is for tests/demos only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from sqlalchemy.engine import Engine
|
|
10
|
+
|
|
11
|
+
from etlantic_sqlmodel.control_plane.models import DurableSnapshotRow
|
|
12
|
+
from sqlmodel import SQLModel
|
|
13
|
+
|
|
14
|
+
TABLES = (DurableSnapshotRow,)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def upgrade(engine: Engine) -> None:
|
|
18
|
+
"""Create CP3 durable tables."""
|
|
19
|
+
SQLModel.metadata.create_all(
|
|
20
|
+
engine,
|
|
21
|
+
tables=[cls.__table__ for cls in TABLES], # type: ignore[list-item]
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def downgrade(engine: Engine) -> None:
|
|
26
|
+
"""Drop CP3 durable tables."""
|
|
27
|
+
SQLModel.metadata.drop_all(
|
|
28
|
+
engine,
|
|
29
|
+
tables=[cls.__table__ for cls in reversed(TABLES)], # type: ignore[list-item]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["downgrade", "upgrade"]
|
|
File without changes
|
|
File without changes
|
{etlantic_sqlmodel-0.40.0 → etlantic_sqlmodel-0.41.0}/src/etlantic_sqlmodel/control_plane/session.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|