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 ADDED
@@ -0,0 +1,3 @@
1
+ """Memcove — a lakehouse-backed memory service for LLM agents, exposed over MCP."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -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)
@@ -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