memcove 0.3.4__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.
- memcove/__init__.py +3 -0
- memcove/core/__init__.py +0 -0
- memcove/core/arrow_io.py +35 -0
- memcove/core/audit.py +27 -0
- memcove/core/catalog.py +183 -0
- memcove/core/config.py +121 -0
- memcove/core/errors.py +40 -0
- memcove/core/models.py +92 -0
- memcove/core/naming.py +20 -0
- memcove/core/registry.py +344 -0
- memcove/core/sql_guard.py +174 -0
- memcove/core/storage.py +100 -0
- memcove/core/tenancy.py +88 -0
- memcove/core/trino_client.py +97 -0
- memcove/data_plane/__init__.py +0 -0
- memcove/data_plane/flight_server.py +141 -0
- memcove/data_plane/tickets.py +108 -0
- memcove/reconciler.py +202 -0
- memcove/server.py +455 -0
- memcove/tools/__init__.py +0 -0
- memcove/tools/artifacts.py +72 -0
- memcove/tools/derive.py +92 -0
- memcove/tools/discovery.py +42 -0
- memcove/tools/ingest.py +120 -0
- memcove/tools/objects.py +175 -0
- memcove/tools/query.py +28 -0
- memcove-0.3.4.dist-info/METADATA +150 -0
- memcove-0.3.4.dist-info/RECORD +31 -0
- memcove-0.3.4.dist-info/WHEEL +4 -0
- memcove-0.3.4.dist-info/entry_points.txt +4 -0
- memcove-0.3.4.dist-info/licenses/LICENSE +202 -0
memcove/__init__.py
ADDED
memcove/core/__init__.py
ADDED
|
File without changes
|
memcove/core/arrow_io.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Decode inline payloads into Arrow tables for the ingest write path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
|
|
8
|
+
import pyarrow as pa
|
|
9
|
+
import pyarrow.ipc as ipc
|
|
10
|
+
|
|
11
|
+
from memcove.core.errors import IngestError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def from_json_records(records: list[dict]) -> pa.Table:
|
|
15
|
+
"""Build an Arrow table from a list of JSON row objects."""
|
|
16
|
+
if not records:
|
|
17
|
+
raise IngestError("inline json_records payload is empty")
|
|
18
|
+
try:
|
|
19
|
+
return pa.Table.from_pylist(records)
|
|
20
|
+
except Exception as exc: # noqa: BLE001
|
|
21
|
+
raise IngestError(f"could not build table from json records: {exc}") from exc
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def from_arrow_ipc_b64(data_b64: str) -> pa.Table:
|
|
25
|
+
"""Decode a base64 Arrow IPC stream into a table."""
|
|
26
|
+
try:
|
|
27
|
+
raw = base64.b64decode(data_b64)
|
|
28
|
+
with ipc.open_stream(io.BytesIO(raw)) as reader:
|
|
29
|
+
return reader.read_all()
|
|
30
|
+
except Exception as exc: # noqa: BLE001
|
|
31
|
+
raise IngestError(f"could not decode arrow_ipc payload: {exc}") from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def estimate_bytes(table: pa.Table) -> int:
|
|
35
|
+
return table.nbytes
|
memcove/core/audit.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Structured audit log for guarded data operations.
|
|
2
|
+
|
|
3
|
+
Every accepted read/derive/export emits one JSON line on the ``memcove.audit``
|
|
4
|
+
logger: which tenant ran what (rewritten) SQL and how much it returned. Operators
|
|
5
|
+
route this logger wherever their audit sink lives. Auditing must never break a
|
|
6
|
+
request, so serialization failures are swallowed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
import orjson
|
|
14
|
+
|
|
15
|
+
_log = logging.getLogger("memcove.audit")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def audit(event: str, **fields) -> None:
|
|
19
|
+
"""Emit a single structured audit record."""
|
|
20
|
+
try:
|
|
21
|
+
_log.info(
|
|
22
|
+
orjson.dumps(
|
|
23
|
+
{"event": event, **fields}, default=str, option=orjson.OPT_SORT_KEYS
|
|
24
|
+
).decode()
|
|
25
|
+
)
|
|
26
|
+
except Exception: # noqa: BLE001 - auditing is best-effort, never fatal
|
|
27
|
+
_log.info("audit event=%s (unserializable fields)", event)
|
memcove/core/catalog.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""PyIceberg REST-catalog client — the write / ingest path.
|
|
2
|
+
|
|
3
|
+
Ingested data (inline, s3 parquet, or uploaded parquet) becomes a PyArrow
|
|
4
|
+
table and is written here. Reads and derivations go through Trino instead
|
|
5
|
+
(see ``trino_client.py``); both speak to the same REST catalog + MinIO.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
|
|
13
|
+
import pyarrow as pa
|
|
14
|
+
from pyiceberg.catalog import Catalog, load_catalog
|
|
15
|
+
|
|
16
|
+
from memcove.core.config import get_settings
|
|
17
|
+
from memcove.core.errors import ObjectExistsError, ObjectNotFoundError, SchemaMismatchError
|
|
18
|
+
|
|
19
|
+
WriteMode = str # "create" | "replace" | "append"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _norm_type(t: pa.DataType) -> str:
|
|
23
|
+
"""Canonical type string for schema comparison.
|
|
24
|
+
|
|
25
|
+
Iceberg forces several arrow types onto a canonical storage form, and
|
|
26
|
+
``Schema.as_arrow()`` reads them back in that form. Comparing the raw
|
|
27
|
+
``str(type)`` of an incoming write against the read-back schema would then
|
|
28
|
+
false-reject a genuinely same-shape replace/append. Fold every conversion
|
|
29
|
+
Iceberg makes so both sides converge:
|
|
30
|
+
|
|
31
|
+
* ``large_string`` / ``large_binary`` / ``large_list`` <- string/binary/list
|
|
32
|
+
(an arrow encoding detail Iceberg picks on read-back).
|
|
33
|
+
* ``timestamp[ns|ms|s]`` -> ``timestamp[us]`` (Iceberg timestamps are microsecond).
|
|
34
|
+
* ``int8`` / ``int16`` -> ``int32`` (Iceberg has no integer narrower than 32-bit).
|
|
35
|
+
|
|
36
|
+
Applied to both sides, so a real retype (e.g. ``int64`` -> ``string``) still fails.
|
|
37
|
+
Regex-based so the folding reaches into nested ``list``/``struct`` types too.
|
|
38
|
+
"""
|
|
39
|
+
s = str(t).replace("large_", "")
|
|
40
|
+
s = re.sub(r"timestamp\[(ns|ms|s)", "timestamp[us", s)
|
|
41
|
+
s = re.sub(r"\bint(8|16)\b", "int32", s)
|
|
42
|
+
return s
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _assert_schema_compatible(
|
|
46
|
+
existing: pa.Schema, incoming: pa.Schema, *, op: str, label: str
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Reject a replace/append whose schema differs from the existing object.
|
|
49
|
+
|
|
50
|
+
Compatibility is deterministic: the set of column names must match and each
|
|
51
|
+
column's Arrow type must be equal (nullability and large-vs-not encoding are
|
|
52
|
+
ignored — Iceberg matches by name). A changed shape is rejected rather than
|
|
53
|
+
silently evolved, so downstream SQL never breaks. To change shape, ``forget()``
|
|
54
|
+
then ``create()``.
|
|
55
|
+
"""
|
|
56
|
+
existing_map = {f.name: _norm_type(f.type) for f in existing}
|
|
57
|
+
incoming_map = {f.name: _norm_type(f.type) for f in incoming}
|
|
58
|
+
if existing_map == incoming_map:
|
|
59
|
+
return
|
|
60
|
+
added = sorted(set(incoming_map) - set(existing_map))
|
|
61
|
+
removed = sorted(set(existing_map) - set(incoming_map))
|
|
62
|
+
retyped = sorted(
|
|
63
|
+
f"{n} ({existing_map[n]} -> {incoming_map[n]})"
|
|
64
|
+
for n in set(existing_map) & set(incoming_map)
|
|
65
|
+
if existing_map[n] != incoming_map[n]
|
|
66
|
+
)
|
|
67
|
+
parts = []
|
|
68
|
+
if added:
|
|
69
|
+
parts.append(f"added={added}")
|
|
70
|
+
if removed:
|
|
71
|
+
parts.append(f"removed={removed}")
|
|
72
|
+
if retyped:
|
|
73
|
+
parts.append(f"retyped={retyped}")
|
|
74
|
+
raise SchemaMismatchError(
|
|
75
|
+
f"{op} of '{label}' is schema-incompatible with the existing object "
|
|
76
|
+
f"({'; '.join(parts)}); forget() then create() to change an object's shape"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@lru_cache
|
|
81
|
+
def get_catalog() -> Catalog:
|
|
82
|
+
s = get_settings()
|
|
83
|
+
return load_catalog(
|
|
84
|
+
s.iceberg_catalog_name,
|
|
85
|
+
**{
|
|
86
|
+
"type": "rest",
|
|
87
|
+
"uri": s.iceberg_rest_uri,
|
|
88
|
+
"warehouse": s.iceberg_warehouse,
|
|
89
|
+
"s3.endpoint": s.s3_endpoint,
|
|
90
|
+
"s3.access-key-id": s.s3_access_key,
|
|
91
|
+
"s3.secret-access-key": s.s3_secret_key,
|
|
92
|
+
"s3.region": s.s3_region,
|
|
93
|
+
"s3.path-style-access": str(s.s3_path_style).lower(),
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def ensure_namespace(namespace: str) -> None:
|
|
99
|
+
get_catalog().create_namespace_if_not_exists((namespace,))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def table_exists(namespace: str, label: str) -> bool:
|
|
103
|
+
return get_catalog().table_exists(f"{namespace}.{label}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def list_labels(namespace: str) -> list[str]:
|
|
107
|
+
try:
|
|
108
|
+
idents = get_catalog().list_tables((namespace,))
|
|
109
|
+
except Exception: # noqa: BLE001 - namespace may not exist yet
|
|
110
|
+
return []
|
|
111
|
+
return [ident[-1] for ident in idents]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def list_namespaces() -> list[str]:
|
|
115
|
+
"""All tenant namespaces known to the catalog (single-component names)."""
|
|
116
|
+
return [ns[0] for ns in get_catalog().list_namespaces() if ns]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def drop_table(namespace: str, label: str) -> None:
|
|
120
|
+
cat = get_catalog()
|
|
121
|
+
ident = f"{namespace}.{label}"
|
|
122
|
+
if not cat.table_exists(ident):
|
|
123
|
+
raise ObjectNotFoundError(f"object '{label}' does not exist")
|
|
124
|
+
cat.drop_table(ident)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_arrow(
|
|
128
|
+
namespace: str, label: str, table: pa.Table, mode: WriteMode = "create"
|
|
129
|
+
) -> int:
|
|
130
|
+
"""Create/replace/append an Iceberg table from an Arrow table.
|
|
131
|
+
|
|
132
|
+
Returns the number of rows written in this call.
|
|
133
|
+
"""
|
|
134
|
+
cat = get_catalog()
|
|
135
|
+
ensure_namespace(namespace)
|
|
136
|
+
ident = f"{namespace}.{label}"
|
|
137
|
+
exists = cat.table_exists(ident)
|
|
138
|
+
|
|
139
|
+
if mode == "create":
|
|
140
|
+
if exists:
|
|
141
|
+
raise ObjectExistsError(
|
|
142
|
+
f"object '{label}' already exists (use mode=replace or mode=append)"
|
|
143
|
+
)
|
|
144
|
+
iceberg_table = cat.create_table(ident, schema=table.schema)
|
|
145
|
+
iceberg_table.append(table)
|
|
146
|
+
elif mode == "replace":
|
|
147
|
+
if exists:
|
|
148
|
+
# Atomic full-table replace: overwrite() commits delete+append in a
|
|
149
|
+
# single catalog transaction, so a concurrent reader sees the old rows
|
|
150
|
+
# or the new rows, never a missing table (the old drop-then-create had a
|
|
151
|
+
# window where a crash lost the data and a reader saw the table vanish).
|
|
152
|
+
iceberg_table = cat.load_table(ident)
|
|
153
|
+
_assert_schema_compatible(
|
|
154
|
+
iceberg_table.schema().as_arrow(), table.schema, op="replace", label=label
|
|
155
|
+
)
|
|
156
|
+
iceberg_table.overwrite(table)
|
|
157
|
+
else:
|
|
158
|
+
iceberg_table = cat.create_table(ident, schema=table.schema)
|
|
159
|
+
iceberg_table.append(table)
|
|
160
|
+
elif mode == "append":
|
|
161
|
+
if exists:
|
|
162
|
+
iceberg_table = cat.load_table(ident)
|
|
163
|
+
_assert_schema_compatible(
|
|
164
|
+
iceberg_table.schema().as_arrow(), table.schema, op="append", label=label
|
|
165
|
+
)
|
|
166
|
+
iceberg_table.append(table)
|
|
167
|
+
else:
|
|
168
|
+
iceberg_table = cat.create_table(ident, schema=table.schema)
|
|
169
|
+
iceberg_table.append(table)
|
|
170
|
+
else:
|
|
171
|
+
raise ValueError(f"unknown write mode: {mode!r}")
|
|
172
|
+
|
|
173
|
+
return table.num_rows
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load_schema(namespace: str, label: str) -> list[tuple[str, str]]:
|
|
177
|
+
"""Return [(column_name, iceberg_type), ...] for an object."""
|
|
178
|
+
cat = get_catalog()
|
|
179
|
+
ident = f"{namespace}.{label}"
|
|
180
|
+
if not cat.table_exists(ident):
|
|
181
|
+
raise ObjectNotFoundError(f"object '{label}' does not exist")
|
|
182
|
+
tbl = cat.load_table(ident)
|
|
183
|
+
return [(f.name, str(f.field_type)) for f in tbl.schema().fields]
|
memcove/core/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Runtime configuration, loaded from environment (see .env.example)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
model_config = SettingsConfigDict(
|
|
12
|
+
env_prefix="MEMCOVE_",
|
|
13
|
+
env_file=".env",
|
|
14
|
+
env_file_encoding="utf-8",
|
|
15
|
+
extra="ignore",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# MCP server
|
|
19
|
+
host: str = "0.0.0.0"
|
|
20
|
+
port: int = 8090
|
|
21
|
+
|
|
22
|
+
# Iceberg REST catalog
|
|
23
|
+
iceberg_rest_uri: str = "http://localhost:8181"
|
|
24
|
+
iceberg_warehouse: str = "s3://warehouse/"
|
|
25
|
+
iceberg_catalog_name: str = "memcove"
|
|
26
|
+
|
|
27
|
+
# Object store
|
|
28
|
+
s3_endpoint: str = "http://localhost:9000"
|
|
29
|
+
s3_region: str = "us-east-1"
|
|
30
|
+
s3_access_key: str = "minio"
|
|
31
|
+
s3_secret_key: str = "minio12345"
|
|
32
|
+
s3_path_style: bool = True
|
|
33
|
+
warehouse_bucket: str = "warehouse"
|
|
34
|
+
staging_bucket: str = "memcove-staging"
|
|
35
|
+
artifacts_bucket: str = "memcove-artifacts"
|
|
36
|
+
|
|
37
|
+
# Trino
|
|
38
|
+
trino_host: str = "localhost"
|
|
39
|
+
trino_port: int = 8080
|
|
40
|
+
trino_user: str = "memcove" # service principal; connect identity when not impersonating
|
|
41
|
+
trino_catalog: str = "iceberg"
|
|
42
|
+
trino_http_scheme: str = "http" # "https" for TLS-fronted Trino in real deployments
|
|
43
|
+
# When true, each request connects to Trino AS the caller's tenant so the operator's
|
|
44
|
+
# own Trino access control applies per tenant (defense-in-depth beneath the SQL guard).
|
|
45
|
+
# Requires the service principal to hold impersonation rights + a configured grant
|
|
46
|
+
# backend. Off by default so local/dev works with a single identity.
|
|
47
|
+
trino_impersonation: bool = False
|
|
48
|
+
# Session properties applied to every data connection (generic passthrough so the
|
|
49
|
+
# operator sets whatever their Trino version supports — resource caps, etc.), e.g.
|
|
50
|
+
# {"query_max_run_time":"60s","query_max_scan_physical_bytes":"10GB"}.
|
|
51
|
+
trino_session_properties: dict[str, str] = {}
|
|
52
|
+
|
|
53
|
+
# Arrow Flight streaming data plane (M3)
|
|
54
|
+
flight_host: str = "0.0.0.0" # bind address
|
|
55
|
+
flight_port: int = 8815
|
|
56
|
+
flight_advertise_uri: str = "grpc://localhost:8815" # what clients are told to dial
|
|
57
|
+
# HMAC secret for signing Flight tickets/descriptors so a client cannot forge one
|
|
58
|
+
# to read/write another tenant. MUST be overridden in any real deployment.
|
|
59
|
+
flight_ticket_secret: str = "dev-insecure-change-me"
|
|
60
|
+
flight_ticket_ttl_seconds: int = 300 # signed tickets expire after this many seconds
|
|
61
|
+
|
|
62
|
+
# Postgres registry
|
|
63
|
+
pg_dsn: str = "postgresql://memcove:memcove@localhost:5433/memcove"
|
|
64
|
+
# Connection pool: the registry opens a fresh connection per op otherwise, and the
|
|
65
|
+
# reconciler + synchronous read-repair add per-op churn. min_size connections are
|
|
66
|
+
# kept warm; the pool grows to max_size under load. pool_timeout bounds how long a
|
|
67
|
+
# caller waits for a free connection before raising (a subclass of OperationalError,
|
|
68
|
+
# so a saturated/unreachable registry is still swallowed by the guarded-write path).
|
|
69
|
+
# Kept at 10s (not psycopg_pool's 30s default) so an unreachable registry fails
|
|
70
|
+
# closer to the old connect-per-call fast-fail; registry ops are milliseconds, so a
|
|
71
|
+
# >10s wait for a free connection only happens during a real outage, not under load.
|
|
72
|
+
pg_pool_min_size: int = 1
|
|
73
|
+
pg_pool_max_size: int = 10
|
|
74
|
+
pg_pool_timeout: float = 10.0
|
|
75
|
+
|
|
76
|
+
# Reconciler / read-repair (write-atomicity self-healing). The reconciler diffs the
|
|
77
|
+
# Iceberg catalog against the Postgres registry to backfill missing rows and drop
|
|
78
|
+
# dangling ones. Deletion is fail-safe: an empty/failed namespace listing deletes
|
|
79
|
+
# nothing, a row must be absent across this many consecutive sweeps before deletion,
|
|
80
|
+
# and a sweep that would delete more than the cap ratio of a namespace aborts + alerts.
|
|
81
|
+
reconcile_min_absent_sweeps: int = 2
|
|
82
|
+
reconcile_deletion_cap_ratio: float = 0.25
|
|
83
|
+
# The ratio cap only applies once a sweep would delete more than this many rows,
|
|
84
|
+
# so small namespaces can still clean up a single dangling row (1 of 2 rows is 50%
|
|
85
|
+
# but is not a mass deletion). The cap exists to stop a wipe, not routine cleanup.
|
|
86
|
+
reconcile_deletion_cap_min: int = 3
|
|
87
|
+
|
|
88
|
+
# Guardrails
|
|
89
|
+
preview_row_cap: int = 1000
|
|
90
|
+
inline_bytes_cap: int = 8 * 1024 * 1024
|
|
91
|
+
export_row_cap: int = 5_000_000
|
|
92
|
+
presign_ttl_seconds: int = 3600
|
|
93
|
+
|
|
94
|
+
# Tenancy. Default: trust a tenant header set by the auth proxy (dev/simple).
|
|
95
|
+
tenant_header: str = "x-memcove-tenant"
|
|
96
|
+
default_tenant: str = "default"
|
|
97
|
+
|
|
98
|
+
# Provisioning map (optional). When tenant_subject_header is set, the tenant is
|
|
99
|
+
# resolved by mapping the proxy-provided identity (subject, else a matching group)
|
|
100
|
+
# through tenant_map -> internal tenant id, instead of trusting a raw tenant value.
|
|
101
|
+
# This is the seam for "don't feed a raw OIDC sub straight through".
|
|
102
|
+
# Provisioning mode is fail-closed: when tenant_subject_header is set, an identity
|
|
103
|
+
# absent from tenant_map is rejected (never falls through to the raw tenant header).
|
|
104
|
+
tenant_subject_header: str = "" # e.g. "x-auth-subject"; empty = direct tenant header
|
|
105
|
+
tenant_group_header: str = "" # e.g. "x-auth-groups" (comma-separated)
|
|
106
|
+
tenant_map: dict[str, str] = {} # subject/group -> internal tenant id
|
|
107
|
+
|
|
108
|
+
# Shared read-only reference plane (the gateway): schemas every tenant may SELECT
|
|
109
|
+
# from but none may write. These are NOT rewritten to the caller's namespace by the
|
|
110
|
+
# SQL guard; they resolve to themselves. Per-domain schemas contain blast radius.
|
|
111
|
+
shared_schemas: list[str] = ["ref_market"]
|
|
112
|
+
|
|
113
|
+
# Ingest allowlist: agent-supplied `s3_parquet` URIs must start with one of these
|
|
114
|
+
# prefixes. Empty list = agent s3_parquet ingest is DISABLED (fail closed) to avoid
|
|
115
|
+
# a confused-deputy read of any bucket the service credential can reach.
|
|
116
|
+
allowed_s3_ingest_prefixes: list[str] = []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@lru_cache
|
|
120
|
+
def get_settings() -> Settings:
|
|
121
|
+
return Settings()
|
memcove/core/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Typed errors surfaced to MCP callers as structured messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MemcoveError(Exception):
|
|
7
|
+
"""Base class for all Memcove errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SqlGuardError(MemcoveError):
|
|
11
|
+
"""Raised when submitted SQL violates the safety policy."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ObjectNotFoundError(MemcoveError):
|
|
15
|
+
"""Raised when a referenced label does not exist for the tenant."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ObjectExistsError(MemcoveError):
|
|
19
|
+
"""Raised when creating an object whose label already exists."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SchemaMismatchError(MemcoveError):
|
|
23
|
+
"""Raised when incoming data's schema is incompatible with an existing object.
|
|
24
|
+
|
|
25
|
+
A ``replace`` or ``append`` must keep the object's shape; changing columns or
|
|
26
|
+
types is rejected so downstream SQL never breaks silently. To change shape,
|
|
27
|
+
``forget()`` the object then ``create()`` it fresh.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IngestError(MemcoveError):
|
|
32
|
+
"""Raised when an ingest source cannot be read or written."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TenancyError(MemcoveError):
|
|
36
|
+
"""Raised when a tenant cannot be resolved."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TicketError(MemcoveError):
|
|
40
|
+
"""Raised when a Flight ticket/descriptor fails signature or expiry checks."""
|
memcove/core/models.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Domain models for the Memcove control plane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SourceKind(str, Enum):
|
|
13
|
+
"""How an object's data first entered Memcove."""
|
|
14
|
+
|
|
15
|
+
INLINE = "inline"
|
|
16
|
+
S3_PARQUET = "s3_parquet"
|
|
17
|
+
UPLOAD = "upload_handle"
|
|
18
|
+
STREAM = "stream"
|
|
19
|
+
DERIVED = "derived"
|
|
20
|
+
# A row the reconciler (or synchronous read-repair) backfilled for an Iceberg
|
|
21
|
+
# table that had no registry row — e.g. after a crash between the data write and
|
|
22
|
+
# the registry write. Marks that user metadata (tags/producing_sql/lineage) was
|
|
23
|
+
# lost and should be re-supplied by re-running the producing operation.
|
|
24
|
+
RECONCILED = "reconciled"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ColumnSchema(BaseModel):
|
|
28
|
+
name: str
|
|
29
|
+
type: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Lineage(BaseModel):
|
|
33
|
+
"""Provenance of a derived object."""
|
|
34
|
+
|
|
35
|
+
parents: list[str] = Field(default_factory=list) # parent labels
|
|
36
|
+
producing_sql: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MemoryObject(BaseModel):
|
|
40
|
+
"""A labeled, namespaced reference to an Iceberg table.
|
|
41
|
+
|
|
42
|
+
Identity is ``<tenant>.<label>`` which maps to the Iceberg table
|
|
43
|
+
``<catalog>.<tenant>.<label>``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
tenant: str
|
|
47
|
+
label: str
|
|
48
|
+
table_ident: str # fully-qualified iceberg identifier, e.g. memcove.t_acme.events
|
|
49
|
+
source: SourceKind
|
|
50
|
+
source_ref: str | None = None # e.g. the s3 uri or upload handle
|
|
51
|
+
schema_: list[ColumnSchema] = Field(default_factory=list, alias="schema")
|
|
52
|
+
row_count: int | None = None
|
|
53
|
+
size_bytes: int | None = None
|
|
54
|
+
tags: list[str] = Field(default_factory=list)
|
|
55
|
+
lineage: Lineage = Field(default_factory=Lineage)
|
|
56
|
+
created_at: datetime | None = None
|
|
57
|
+
updated_at: datetime | None = None
|
|
58
|
+
# True when the data write committed but the registry metadata write failed. The
|
|
59
|
+
# object is queryable, but tags/producing_sql/lineage may be missing until a
|
|
60
|
+
# reconciler sweep or a re-run repairs them. Built from values in hand, so the
|
|
61
|
+
# response does not depend on re-reading the registry that just failed.
|
|
62
|
+
metadata_pending: bool = False
|
|
63
|
+
|
|
64
|
+
model_config = {"populate_by_name": True}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PreviewResult(BaseModel):
|
|
68
|
+
"""A capped tabular result handed back through the control plane."""
|
|
69
|
+
|
|
70
|
+
columns: list[str]
|
|
71
|
+
rows: list[list[Any]]
|
|
72
|
+
row_count: int # number of rows in this preview
|
|
73
|
+
truncated: bool # True if more rows exist beyond the cap
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ArtifactRef(BaseModel):
|
|
77
|
+
"""A materialized export living in object storage."""
|
|
78
|
+
|
|
79
|
+
uri: str # s3:// location
|
|
80
|
+
presigned_url: str # time-limited GET URL
|
|
81
|
+
format: str # parquet | csv | json
|
|
82
|
+
row_count: int
|
|
83
|
+
size_bytes: int
|
|
84
|
+
expires_in_seconds: int
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class UploadTicket(BaseModel):
|
|
88
|
+
"""A presigned slot for out-of-band parquet upload."""
|
|
89
|
+
|
|
90
|
+
upload_handle: str # staging key the client passes back to ingest_object
|
|
91
|
+
presigned_url: str # presigned PUT URL
|
|
92
|
+
expires_in_seconds: int
|
memcove/core/naming.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Validation for object labels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from memcove.core.errors import MemcoveError
|
|
8
|
+
|
|
9
|
+
_LABEL_RE = re.compile(r"^[a-z][a-z0-9_]{0,127}$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_label(label: str) -> str:
|
|
13
|
+
"""Ensure a label is a safe, lowercase Iceberg table name."""
|
|
14
|
+
candidate = (label or "").strip().lower()
|
|
15
|
+
if not _LABEL_RE.match(candidate):
|
|
16
|
+
raise MemcoveError(
|
|
17
|
+
f"invalid label {label!r}: use lowercase letters, digits and underscores, "
|
|
18
|
+
"starting with a letter (max 128 chars)"
|
|
19
|
+
)
|
|
20
|
+
return candidate
|