smart-data-engine-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sde/__init__.py +318 -0
- sde/_cutover_project.py +179 -0
- sde/_local_state.py +188 -0
- sde/_operator_deadline.py +50 -0
- sde/_usage.py +314 -0
- sde/bulk.py +79 -0
- sde/canonical.py +141 -0
- sde/capabilities.py +62 -0
- sde/cutover.py +286 -0
- sde/engines/__init__.py +0 -0
- sde/engines/_clickhouse_connection.py +224 -0
- sde/engines/_index_build.py +294 -0
- sde/engines/_operator.py +394 -0
- sde/engines/_staging.py +222 -0
- sde/engines/_storage.py +22 -0
- sde/engines/_write_fences.py +271 -0
- sde/engines/clickhouse.py +1115 -0
- sde/engines/orderbook.py +457 -0
- sde/engines/postgres.py +967 -0
- sde/entity.py +170 -0
- sde/errors.py +103 -0
- sde/explain.py +300 -0
- sde/frozen_verification.py +152 -0
- sde/generation.py +131 -0
- sde/groups.py +97 -0
- sde/hashing.py +242 -0
- sde/index_build.py +313 -0
- sde/index_operator.py +347 -0
- sde/infer.py +461 -0
- sde/inspection.py +62 -0
- sde/internal.py +90 -0
- sde/layout.py +669 -0
- sde/local_cutover.py +801 -0
- sde/logging.py +143 -0
- sde/migration.py +856 -0
- sde/model.py +482 -0
- sde/physical.py +531 -0
- sde/placement.py +1010 -0
- sde/provisioning.py +63 -0
- sde/py.typed +0 -0
- sde/query.py +521 -0
- sde/routing.py +85 -0
- sde/schema.py +466 -0
- sde/session.py +993 -0
- sde/shapes.py +153 -0
- sde/staging.py +264 -0
- sde/staging_operator.py +393 -0
- sde/telemetry.py +1087 -0
- sde/testing/__init__.py +14 -0
- sde/testing/loader.py +175 -0
- sde/testing/memory.py +331 -0
- sde/types.py +228 -0
- sde/verification.py +220 -0
- sde/watermark.py +222 -0
- sde/write_fence.py +283 -0
- sde_demo/__init__.py +1 -0
- sde_demo/__main__.py +183 -0
- sde_demo/diagnostics.py +92 -0
- sde_demo/model.py +75 -0
- sde_demo/project.py +312 -0
- sde_demo/py.typed +0 -0
- sde_demo/query_count.py +301 -0
- sde_demo/resources.py +969 -0
- sde_demo/runtime.py +419 -0
- sde_demo/verification.py +242 -0
- sde_operator/__init__.py +1 -0
- sde_operator/__main__.py +210 -0
- smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
- smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
- smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
- smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- smart_data_engine_sdk-0.1.0.dist-info/licenses/NOTICE +13 -0
sde/shapes.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Operation shapes: the finite set of things an application can ask of a model.
|
|
2
|
+
|
|
3
|
+
This is where the entity API pays for itself twice over.
|
|
4
|
+
|
|
5
|
+
First, security. A shape is built from an API call, so there is nowhere for a literal to come from.
|
|
6
|
+
Contrast the SQL route, where you receive a string containing values and have to strip them out with
|
|
7
|
+
a parser you hope covers every dialect corner - and where one missed case means a customer's data in
|
|
8
|
+
our telemetry. Here the value never enters the shape, because the shape is assembled from the
|
|
9
|
+
operation's structure and never sees the arguments.
|
|
10
|
+
|
|
11
|
+
Second, planning. Because the API is finite, the set of shapes is *enumerable from the model*. The
|
|
12
|
+
planner can compute a routing decision for every shape ahead of time and put the answers in the
|
|
13
|
+
placement map, which is what lets the library look routing up instead of deciding it. A library that
|
|
14
|
+
decides is a library whose decisions have to be tested in four languages.
|
|
15
|
+
|
|
16
|
+
The enumeration below is deliberately conservative. It covers what the thin slice can execute and
|
|
17
|
+
nothing more: adding a shape kind is cheap, while shipping a shape the runtime cannot honour means
|
|
18
|
+
the map promises a route for an operation that then fails.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any, Final
|
|
25
|
+
|
|
26
|
+
from .canonical import digest16
|
|
27
|
+
from .groups import Group, colocation_groups, group_of
|
|
28
|
+
from .model import LogicalModel
|
|
29
|
+
|
|
30
|
+
__all__ = ["SHAPE_KINDS", "WRITE_KINDS", "OperationShape", "enumerate_shapes"]
|
|
31
|
+
|
|
32
|
+
SHAPE_KINDS: Final[tuple[str, ...]] = (
|
|
33
|
+
"point_read",
|
|
34
|
+
"range_read",
|
|
35
|
+
"aggregate",
|
|
36
|
+
"full_scan",
|
|
37
|
+
"relation_walk",
|
|
38
|
+
"write",
|
|
39
|
+
"bulk_write",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
WRITE_KINDS: Final[frozenset[str]] = frozenset({"write", "bulk_write"})
|
|
43
|
+
"""Which of those kinds are writes. One definition, and it decides three separate things.
|
|
44
|
+
|
|
45
|
+
Here rather than in each module that asks, because there were four copies of this set and two of
|
|
46
|
+
them were inside this package - one in ``routing``, deciding whether an operation goes to the
|
|
47
|
+
source, and one in ``telemetry``, deciding whether an operation counts as a write in the features a
|
|
48
|
+
placement is scored on. Two copies of a set in one process is how the same operation comes to be a
|
|
49
|
+
write for routing and a read for scoring, and nothing would have raised.
|
|
50
|
+
|
|
51
|
+
Public because a producer of routing tables needs it. A write shape is never routed - the library
|
|
52
|
+
sends writes to the source unconditionally - so an entry for one is a line in a signed document
|
|
53
|
+
that nothing reads, and the control plane cannot avoid writing one without knowing this set.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
# Types over which a range predicate is meaningful. Ranges over strings and uuids are legal SQL and
|
|
57
|
+
# almost never what anybody means, so they are not enumerated; if telemetry ever shows one, that is
|
|
58
|
+
# a signal to revisit this list rather than to widen it speculatively.
|
|
59
|
+
_ORDERED_PREFIXES: Final[tuple[str, ...]] = (
|
|
60
|
+
"int32",
|
|
61
|
+
"int64",
|
|
62
|
+
"float32",
|
|
63
|
+
"float64",
|
|
64
|
+
"decimal",
|
|
65
|
+
"date",
|
|
66
|
+
"timestamp",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class OperationShape:
|
|
72
|
+
"""One kind of operation, without any of its values."""
|
|
73
|
+
|
|
74
|
+
group: str
|
|
75
|
+
kind: str
|
|
76
|
+
entity: str
|
|
77
|
+
fields: tuple[str, ...]
|
|
78
|
+
target: str | None = None
|
|
79
|
+
|
|
80
|
+
# Computed once, in __post_init__, and stored. It used to be a property, which meant a SHA-256
|
|
81
|
+
# over a freshly built and canonically encoded dict on every access - and routing reads it up to
|
|
82
|
+
# three times per operation. The overhead test measured 41 microseconds median to resolve one
|
|
83
|
+
# route, sixteen percent of a PostgreSQL round trip, against a budget of one percent. The same
|
|
84
|
+
# mistake as building a key four times per write, which the engine paid for once already.
|
|
85
|
+
id: str = field(init=False, repr=False, compare=False)
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
# object.__setattr__ because the dataclass is frozen. The alternative - a memo keyed by the
|
|
89
|
+
# shape - would put a dictionary lookup back on the hot path to avoid a hash, which is the
|
|
90
|
+
# wrong trade when shapes are enumerated once per model and live for the process.
|
|
91
|
+
object.__setattr__(self, "id", digest16(self.as_ir()))
|
|
92
|
+
|
|
93
|
+
def as_ir(self) -> dict[str, Any]:
|
|
94
|
+
# Sorted fields, explicit nulls: the shape is hashed, so its encoding has to be as stable as
|
|
95
|
+
# the model's.
|
|
96
|
+
return {
|
|
97
|
+
"group": self.group,
|
|
98
|
+
"kind": self.kind,
|
|
99
|
+
"entity": self.entity,
|
|
100
|
+
"fields": list(self.fields),
|
|
101
|
+
"target": self.target,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_ordered(neutral_type: str) -> bool:
|
|
106
|
+
return neutral_type.startswith(_ORDERED_PREFIXES)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def enumerate_shapes(model: LogicalModel) -> tuple[OperationShape, ...]:
|
|
110
|
+
"""Every shape the model admits, in a deterministic order.
|
|
111
|
+
|
|
112
|
+
Ordering is by ``(group, entity, kind, fields, target)`` rather than by identifier, so that a
|
|
113
|
+
human reading a placement map or a diff between two of them sees related shapes together instead
|
|
114
|
+
of scattered by hash.
|
|
115
|
+
"""
|
|
116
|
+
groups: tuple[Group, ...] = colocation_groups(model)
|
|
117
|
+
shapes: list[OperationShape] = []
|
|
118
|
+
|
|
119
|
+
for spec in model.entities:
|
|
120
|
+
group = group_of(groups, spec.name).name
|
|
121
|
+
|
|
122
|
+
shapes.append(
|
|
123
|
+
OperationShape(
|
|
124
|
+
group=group, kind="point_read", entity=spec.name, fields=tuple(sorted(spec.key))
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
shapes.append(OperationShape(group=group, kind="write", entity=spec.name, fields=()))
|
|
128
|
+
shapes.append(OperationShape(group=group, kind="bulk_write", entity=spec.name, fields=()))
|
|
129
|
+
shapes.append(OperationShape(group=group, kind="full_scan", entity=spec.name, fields=()))
|
|
130
|
+
shapes.append(OperationShape(group=group, kind="aggregate", entity=spec.name, fields=()))
|
|
131
|
+
|
|
132
|
+
for spec_field in spec.fields:
|
|
133
|
+
if _is_ordered(spec_field.type):
|
|
134
|
+
shapes.append(
|
|
135
|
+
OperationShape(
|
|
136
|
+
group=group, kind="range_read", entity=spec.name, fields=(spec_field.name,)
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
for relation in model.relations:
|
|
141
|
+
group = group_of(groups, relation.source).name
|
|
142
|
+
shapes.append(
|
|
143
|
+
OperationShape(
|
|
144
|
+
group=group,
|
|
145
|
+
kind="relation_walk",
|
|
146
|
+
entity=relation.source,
|
|
147
|
+
fields=(relation.name,),
|
|
148
|
+
target=relation.target,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
shapes.sort(key=lambda s: (s.group, s.entity, s.kind, s.fields, s.target or ""))
|
|
153
|
+
return tuple(shapes)
|
sde/staging.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Load the signed authorization to prepare one fresh copy while retaining its source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .canonical import CanonicalError, canonical_bytes
|
|
13
|
+
from .cutover import _hex, _record, _signature
|
|
14
|
+
from .errors import MapError, MigrationRefused
|
|
15
|
+
from .generation import GENERATIONS_SINCE, MAX_EPOCH, check_map_project, json_numbers
|
|
16
|
+
from .groups import colocation_groups
|
|
17
|
+
from .layout import group_columns
|
|
18
|
+
from .model import LogicalModel
|
|
19
|
+
from .placement import PlacementMap, _verify_signature, load_map
|
|
20
|
+
|
|
21
|
+
STAGING_PROTOCOL = 1
|
|
22
|
+
"""A move: the fresh copy is prepared in another engine binding than the source."""
|
|
23
|
+
STAGING_RELAYOUT_PROTOCOL = 2
|
|
24
|
+
"""A relayout: the fresh copy is prepared in the source's own engine binding, under fresh table
|
|
25
|
+
names, so a new physical design takes over by the same staging and cutover as a move.
|
|
26
|
+
|
|
27
|
+
A protocol of its own rather than protocol 1 with a refusal lifted, because lifting a refusal is a
|
|
28
|
+
change of format: a packet a released library refuses would become one a newer library executes,
|
|
29
|
+
under the same number. Here an older operator refuses a relayout by its protocol, by name, and both
|
|
30
|
+
protocols stay strict - protocol 1 still refuses a copy in the source's binding, protocol 2 refuses
|
|
31
|
+
one anywhere else."""
|
|
32
|
+
_FIELDS = {
|
|
33
|
+
"kind",
|
|
34
|
+
"protocol",
|
|
35
|
+
"stage_id",
|
|
36
|
+
"project_id",
|
|
37
|
+
"group",
|
|
38
|
+
"current",
|
|
39
|
+
"prepared",
|
|
40
|
+
"signature",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def staging_table_name(stage_id: str, position: int) -> str:
|
|
45
|
+
"""Portable physical name: fresh stage id plus a one-based, code-point ordered entity index."""
|
|
46
|
+
_hex(stage_id, 32, "stage_id")
|
|
47
|
+
if type(position) is not int or not 1 <= position <= 999999:
|
|
48
|
+
raise MigrationRefused("staging entity position must be an integer from 1 through 999999")
|
|
49
|
+
return f"sde_m_{stage_id}_{position:06d}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class StagingPlan:
|
|
54
|
+
stage_id: str
|
|
55
|
+
project_id: str
|
|
56
|
+
group: str
|
|
57
|
+
current: PlacementMap
|
|
58
|
+
prepared: PlacementMap
|
|
59
|
+
verified_with: str | None
|
|
60
|
+
fingerprint: str | None = field(default=None, init=False)
|
|
61
|
+
_document: bytes = field(default=b"", init=False, repr=False)
|
|
62
|
+
|
|
63
|
+
def _loaded(self) -> None:
|
|
64
|
+
if self.fingerprint is None or not self._document:
|
|
65
|
+
raise MigrationRefused("staging requires an immutable loaded authorization")
|
|
66
|
+
|
|
67
|
+
def as_record(self) -> dict[str, Any]:
|
|
68
|
+
self._loaded()
|
|
69
|
+
value: dict[str, Any] = json.loads(self._document)
|
|
70
|
+
return value
|
|
71
|
+
|
|
72
|
+
def prepared_payload(self) -> bytes:
|
|
73
|
+
return canonical_bytes(self.as_record()["prepared"])
|
|
74
|
+
|
|
75
|
+
def check_current(self, current: PlacementMap) -> None:
|
|
76
|
+
self._loaded()
|
|
77
|
+
check_map_project(current, self.project_id)
|
|
78
|
+
if not current.signed or current.fingerprint != self.current.fingerprint:
|
|
79
|
+
raise MigrationRefused("staging authorization does not name the signed current map")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, init=False)
|
|
83
|
+
class StagingReceipt:
|
|
84
|
+
_payload: bytes
|
|
85
|
+
|
|
86
|
+
def __init__(self, record: Mapping[str, Any]) -> None:
|
|
87
|
+
object.__setattr__(
|
|
88
|
+
self,
|
|
89
|
+
"_payload",
|
|
90
|
+
json.dumps(
|
|
91
|
+
record, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
92
|
+
).encode("utf-8"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def as_record(self) -> dict[str, Any]:
|
|
96
|
+
record: dict[str, Any] = json.loads(self._payload)
|
|
97
|
+
return record
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load(
|
|
101
|
+
raw: Mapping[str, Any],
|
|
102
|
+
model: LogicalModel,
|
|
103
|
+
project_id: str,
|
|
104
|
+
public_key: bytes | Mapping[str, bytes],
|
|
105
|
+
) -> StagingPlan:
|
|
106
|
+
body = _record(json_numbers(deepcopy(raw)), "staging authorization")
|
|
107
|
+
if set(body) != _FIELDS:
|
|
108
|
+
raise MigrationRefused("staging authorization has missing or unknown fields")
|
|
109
|
+
if (
|
|
110
|
+
type(body["protocol"]) is not int
|
|
111
|
+
or body["protocol"] not in (STAGING_PROTOCOL, STAGING_RELAYOUT_PROTOCOL)
|
|
112
|
+
or body["kind"] != "sde-stage"
|
|
113
|
+
):
|
|
114
|
+
raise MigrationRefused("unsupported staging authorization kind or protocol")
|
|
115
|
+
protocol: int = body["protocol"]
|
|
116
|
+
identity = _hex(body["stage_id"], 32, "stage_id")
|
|
117
|
+
local = _hex(body["project_id"], 32, "project_id")
|
|
118
|
+
if local != project_id:
|
|
119
|
+
raise MigrationRefused("staging authorization belongs to another local project")
|
|
120
|
+
group = body["group"]
|
|
121
|
+
if not isinstance(group, str) or not group:
|
|
122
|
+
raise MigrationRefused("staging group must be a nonempty string")
|
|
123
|
+
_signature(body)
|
|
124
|
+
verified = _verify_signature(body, public_key)
|
|
125
|
+
maps = []
|
|
126
|
+
for name in ("current", "prepared"):
|
|
127
|
+
document = _record(body[name], name)
|
|
128
|
+
_signature(document)
|
|
129
|
+
for raw_group in _record(document.get("groups"), "groups").values():
|
|
130
|
+
value = _record(raw_group, "group")
|
|
131
|
+
copies = value.get("derived", [])
|
|
132
|
+
if not isinstance(copies, list):
|
|
133
|
+
raise MigrationRefused("staging derived copies must be an array")
|
|
134
|
+
for raw_material in (value.get("source"), *copies):
|
|
135
|
+
raw_layout = _record(
|
|
136
|
+
_record(raw_material, "materialization").get("layout"), "layout"
|
|
137
|
+
)
|
|
138
|
+
if raw_layout.get("auto"):
|
|
139
|
+
raise MigrationRefused("staging maps need explicit physical layouts")
|
|
140
|
+
parsed = load_map(document, model=model, public_key=public_key, require_signature=True)
|
|
141
|
+
# Contract 4 introduced the generations this protocol rests on; 5 adds physical design
|
|
142
|
+
# and keeps them. Anything a newer library would read is refused by load_map already.
|
|
143
|
+
if parsed.contract < GENERATIONS_SINCE:
|
|
144
|
+
raise MigrationRefused(
|
|
145
|
+
f"staging protocol {protocol} requires map contract {GENERATIONS_SINCE} or later"
|
|
146
|
+
)
|
|
147
|
+
check_map_project(parsed, project_id)
|
|
148
|
+
for placement in parsed.groups.values():
|
|
149
|
+
for material in placement.all():
|
|
150
|
+
if not material.layout.tables or not material.layout.columns:
|
|
151
|
+
raise MigrationRefused("staging maps need explicit physical layouts")
|
|
152
|
+
maps.append(parsed)
|
|
153
|
+
current, prepared = maps
|
|
154
|
+
# The prepared map may raise the contract, because the fresh copy is the one place a physical
|
|
155
|
+
# design can first appear - a ClickHouse copy partitioned by month under a contract-4 current
|
|
156
|
+
# map. It may not lower it: a lower number would tell an older library it may ignore keys that
|
|
157
|
+
# the current map already relies on.
|
|
158
|
+
if prepared.contract < current.contract:
|
|
159
|
+
raise MigrationRefused("staging cannot lower the placement map contract")
|
|
160
|
+
if current.map_version >= prepared.map_version:
|
|
161
|
+
raise MigrationRefused("staging must allocate a newer prepared map")
|
|
162
|
+
if group not in current.groups or set(current.groups) != set(prepared.groups):
|
|
163
|
+
raise MigrationRefused("staging cannot add or remove colocation groups")
|
|
164
|
+
old, new = current.groups[group], prepared.groups[group]
|
|
165
|
+
if old.derived or old.also_write:
|
|
166
|
+
raise MigrationRefused("staging begins with a source-only group")
|
|
167
|
+
if len(new.derived) != 1 or new.also_write != new.derived:
|
|
168
|
+
raise MigrationRefused("staging prepares exactly one maintained copy")
|
|
169
|
+
epoch = old.write_epoch
|
|
170
|
+
assert epoch is not None
|
|
171
|
+
if epoch > MAX_EPOCH - 2 or new.write_epoch != epoch:
|
|
172
|
+
raise MigrationRefused(
|
|
173
|
+
"staging retains the source generation and needs two spare generations"
|
|
174
|
+
)
|
|
175
|
+
same = new.derived[0].engine == old.source.engine
|
|
176
|
+
if protocol == STAGING_PROTOCOL and same:
|
|
177
|
+
raise MigrationRefused("staging target must use another engine binding")
|
|
178
|
+
if protocol == STAGING_RELAYOUT_PROTOCOL and not same:
|
|
179
|
+
# The copy's tables are fresh stage names, and the map refuses a copy in the source's
|
|
180
|
+
# engine that reuses a source table - so a relayout cannot be the source under a new name.
|
|
181
|
+
raise MigrationRefused(
|
|
182
|
+
"a relayout (staging protocol 2) prepares its copy in the source's own engine binding"
|
|
183
|
+
)
|
|
184
|
+
current_raw, prepared_raw = body["current"], body["prepared"]
|
|
185
|
+
old_group, new_group = current_raw["groups"][group], prepared_raw["groups"][group]
|
|
186
|
+
if set(old_group) != {"source", "write_epoch"} or set(new_group) != {
|
|
187
|
+
"source",
|
|
188
|
+
"write_epoch",
|
|
189
|
+
"derived",
|
|
190
|
+
"also_write",
|
|
191
|
+
}:
|
|
192
|
+
raise MigrationRefused("staging group shape is not source-only to one maintained copy")
|
|
193
|
+
if canonical_bytes(old_group["source"]) != canonical_bytes(new_group["source"]):
|
|
194
|
+
raise MigrationRefused("staging cannot change the existing source")
|
|
195
|
+
stable = {
|
|
196
|
+
key: value
|
|
197
|
+
for key, value in current_raw.items()
|
|
198
|
+
if key not in {"signature", "map_version", "groups", "contract"}
|
|
199
|
+
}
|
|
200
|
+
after = {
|
|
201
|
+
key: value
|
|
202
|
+
for key, value in prepared_raw.items()
|
|
203
|
+
if key not in {"signature", "map_version", "groups", "contract"}
|
|
204
|
+
}
|
|
205
|
+
if canonical_bytes(stable) != canonical_bytes(after) or dict(current.routing) != dict(
|
|
206
|
+
prepared.routing
|
|
207
|
+
):
|
|
208
|
+
raise MigrationRefused("staging cannot change routing or other map attributes")
|
|
209
|
+
for other in current.groups:
|
|
210
|
+
if other != group and canonical_bytes(current_raw["groups"][other]) != canonical_bytes(
|
|
211
|
+
prepared_raw["groups"][other]
|
|
212
|
+
):
|
|
213
|
+
raise MigrationRefused("staging cannot change an unaffected group")
|
|
214
|
+
members = next(value for value in colocation_groups(model) if value.name == group)
|
|
215
|
+
expected = {
|
|
216
|
+
entity: staging_table_name(identity, position)
|
|
217
|
+
for position, entity in enumerate(sorted(members.members), start=1)
|
|
218
|
+
}
|
|
219
|
+
if dict(new.derived[0].layout.tables) != expected:
|
|
220
|
+
raise MigrationRefused(
|
|
221
|
+
"staging needs fresh physical names bound to its stage id and entity order"
|
|
222
|
+
)
|
|
223
|
+
columns = group_columns(model, members)
|
|
224
|
+
for material in (old.source, new.derived[0]):
|
|
225
|
+
if set(material.layout.tables) != set(columns) or set(material.layout.columns) != set(
|
|
226
|
+
columns
|
|
227
|
+
):
|
|
228
|
+
raise MigrationRefused("staging layouts must cover exactly the group's entities")
|
|
229
|
+
if any(
|
|
230
|
+
set(material.layout.columns[entity]) != set(fields)
|
|
231
|
+
for entity, fields in columns.items()
|
|
232
|
+
):
|
|
233
|
+
raise MigrationRefused("staging layout columns must match the logical group")
|
|
234
|
+
used = {
|
|
235
|
+
table
|
|
236
|
+
for placed in current.groups.values()
|
|
237
|
+
for material in placed.all()
|
|
238
|
+
for table in material.layout.tables.values()
|
|
239
|
+
}
|
|
240
|
+
if used & set(expected.values()):
|
|
241
|
+
raise MigrationRefused("staging cannot reuse a current physical name")
|
|
242
|
+
plan = StagingPlan(identity, local, group, current, prepared, verified)
|
|
243
|
+
object.__setattr__(plan, "_document", canonical_bytes(body))
|
|
244
|
+
object.__setattr__(
|
|
245
|
+
plan,
|
|
246
|
+
"fingerprint",
|
|
247
|
+
hashlib.sha256(
|
|
248
|
+
canonical_bytes({key: value for key, value in body.items() if key != "signature"})
|
|
249
|
+
).hexdigest(),
|
|
250
|
+
)
|
|
251
|
+
return plan
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def load_staging_plan(
|
|
255
|
+
raw: Mapping[str, Any],
|
|
256
|
+
*,
|
|
257
|
+
model: LogicalModel,
|
|
258
|
+
project_id: str,
|
|
259
|
+
public_key: bytes | Mapping[str, bytes],
|
|
260
|
+
) -> StagingPlan:
|
|
261
|
+
try:
|
|
262
|
+
return _load(raw, model, project_id, public_key)
|
|
263
|
+
except (MapError, CanonicalError, ValueError, TypeError, KeyError) as exc:
|
|
264
|
+
raise MigrationRefused(f"staging authorization refused: {exc}") from exc
|