Skip to content

Commit 17652cb

Browse files
committed
Batch catalog metadata and publish reusable snapshots on wire 2.2
1 parent 69b1a7b commit 17652cb

33 files changed

Lines changed: 2230 additions & 199 deletions

‎docs/catalog_publication.md‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Batched catalog contents and reusable publications
2+
3+
VGI wire revision **2.2.0** retains the `vgi.v2` route and replaces
4+
`catalog_contents` with six catalog-wide Arrow IPC streams: schemas, tables,
5+
views, functions, macros and indexes. Each stream can contain multiple batches.
6+
Null means an empty category. Function and macro subtype fields remain intact.
7+
Existing schema-level RPCs retain their previous collection encoding.
8+
9+
Function metadata stores `arguments`, `output_schema` and
10+
`parameter_default_values` as `dictionary<int32, binary>` columns. Repeated
11+
serialized schemas and typed defaults share dictionary entries by their byte
12+
content. Python callers continue to use bytes for schemas and an optional
13+
`RecordBatch` for defaults. Dictionary replacements across record batches are
14+
supported; values remain opaque binary data rather than text.
15+
Single-object and schema-level function RPCs use these same updated rows.
16+
17+
`SchemaInfo` no longer contains attachment data on the wire. Existing Python
18+
authors may still pass `attach_opaque_data=` to its constructor during migration;
19+
the argument is discarded. Use `CatalogAttachResult.attach_opaque_data` for
20+
subsequent requests. Worker functions still receive their attachment state.
21+
22+
Every minor wire revision is an explicit contract boundary. The server checks
23+
major and minor exactly, so clients and workers must both use 2.2. A patch bump
24+
does not permit schema changes. Serving 2.1 and 2.2 during migration requires
25+
separate deployment URLs or an explicit compatibility implementation.
26+
27+
## Enable publication
28+
29+
Publication is opt-in through `CatalogInterface.catalog_snapshot_policy`.
30+
Ordinary delivery and the existing frozen-catalog response cache remain the
31+
default. Published delivery uses the existing vgi-rpc `ExternalRef` mechanism;
32+
clients resolve the external object on all transports, including subprocess.
33+
34+
Install `vgi-python[external]` on publishing workers and clients that resolve
35+
publications; add `http` when serving or connecting over HTTP, for example
36+
`pip install 'vgi-python[http,external]'`. The `external` extra supplies the RPC
37+
HTTP downloader, retry and compression dependencies. Supply your own configured
38+
S3 client and its SDK when using the S3 repository.
39+
40+
The catalog implementation uses APIs available in published **vgi-rpc 0.50.0**.
41+
An isolated check against that release passed 157 functional catalog, client,
42+
serialization, binary dictionary and publication tests. The package retains its
43+
declared RPC minimum of 0.52.0 for Identity.v2 support; catalog publication does
44+
not introduce a dependency on unpublished RPC APIs.
45+
46+
```python
47+
from vgi.catalog.snapshot import (
48+
CatalogSnapshotPolicy,
49+
S3CatalogPublicationRepository,
50+
SnapshotIdentity,
51+
)
52+
from vgi_rpc.external import Compression
53+
54+
# s3 is your configured boto3-compatible S3/R2 client. The client must support
55+
# conditional PutObject with IfNoneMatch and IfMatch.
56+
repository = S3CatalogPublicationRepository(
57+
s3,
58+
bucket="catalog-snapshots",
59+
profile="production-r2-gzip",
60+
url_ttl_seconds=3600,
61+
retention_seconds=86400,
62+
)
63+
64+
class MyCatalog(ExistingCatalog):
65+
catalog_snapshot_policy = CatalogSnapshotPolicy(
66+
delivery="published",
67+
repository=repository,
68+
identity=lambda request: SnapshotIdentity(
69+
catalog_namespace="my-catalog",
70+
source_revision=DEPLOYMENT_REVISION,
71+
catalog_version=request.catalog_version,
72+
# Omit this for the safe attachment-local default. Sharing requires
73+
# an explicit guarantee that metadata is identical for this scope.
74+
visibility_scope="public-default-options",
75+
),
76+
compression=Compression(algorithm="gzip"),
77+
)
78+
```
79+
80+
The identity hook must be cheap and must change `source_revision` whenever
81+
metadata changes. A dynamic catalog can use a schema generation rather than a
82+
deployment revision. Include options, resolved implementation/data versions,
83+
tenant or principal, and any other metadata visibility differences in its scope.
84+
Do not put credentials into names or scope strings. Omitting `visibility_scope`
85+
isolates publications by a hash of the attachment envelope. Catalog version zero
86+
still requires a meaningful source revision.
87+
88+
An optional `validator(request) -> str | None` can answer a matching ETag before
89+
repository access or inventory construction. Every request unwraps and authorizes
90+
its attachment first, even on a cache hit. The framework also checks that version
91+
and source identity remain unchanged while it builds a publication.
92+
93+
## Publication lifecycle
94+
95+
The framework writes a complete unary RPC envelope with one `result: binary`
96+
row. That binary contains the catalog response and its six nested streams.
97+
The stored object is compressed according to the policy and includes a mandatory
98+
SHA-256 over the raw envelope. Before committing its manifest, the framework
99+
fetches the actual HTTP URL and validates the digest, envelope, nested schemas,
100+
all rows/batches, schema membership, subtype fields and metadata equality.
101+
Raw and stored byte limits apply before upload and during readback, including
102+
when the caller supplies a custom fetch policy.
103+
104+
The repository stores small trusted manifests separately from immutable objects.
105+
Keys include routing name, major/minor wire revision, generated canonical catalog
106+
contract hash, codec revision, namespace, source revision, visibility scope,
107+
catalog version, and storage/compression profile. A new worker process looks up
108+
the manifest before collecting metadata, then validates it with one HTTP fetch.
109+
A warm hit performs no inventory build, serialization, compression, upload or
110+
object GET. A client receiving the pointer still downloads the result as needed.
111+
112+
`S3CatalogPublicationRepository` uses conditional manifest writes and tombstones
113+
for invalidation. It can re-sign a retained object without rebuilding it.
114+
Configure object lifecycle rules to retain objects for at least the declared
115+
retention interval; keep manifest retention separate. Signed URLs in manifests
116+
are credentials, so the manifest store must be access controlled. Newly changed
117+
retrieval URLs are validated before serving them.
118+
119+
`MemoryCatalogPublicationRepository` supports a supplied RPC uploader for local
120+
use. It requires an explicit URL lifetime or stable-location declaration.
121+
It does not provide persistence across process restarts. A process coordinates
122+
one build per key; conditional repository commit makes separate publishers
123+
converge but can permit duplicate initial uploads across processes. Storage
124+
lifecycle rules should collect unused losing uploads.
125+
126+
The manager bounds its in-memory manifest cache. URL or object-retention expiry
127+
causes renewal or rebuilding. Recovery of a corrupt or missing object
128+
conditionally invalidates that manifest and attempts a fresh publication.
129+
Publication errors fail the RPC by default. Set
130+
`on_publication_failure="ordinary"` to explicitly permit a newly validated
131+
ordinary full response when publication fails.
132+
133+
Workers expose `warm_catalog_contents(attach_opaque_data)`,
134+
`import_catalog_publication(attach_opaque_data, manifest)` and
135+
`invalidate_catalog_publication(attach_opaque_data, manifest_id)` for authorized
136+
operational use. Import uses the same HTTP validation as recovery. A failed client
137+
download does not automatically notify the worker; use conditional invalidation
138+
to repair a known bad publication without deleting a concurrent replacement.
139+
140+
## Validation
141+
142+
Run the focused Python checks with the project environment on `PATH`:
143+
144+
```sh
145+
uv run pytest tests/catalog/test_catalog_contents.py \
146+
tests/catalog/test_catalog_publication.py \
147+
tests/catalog/test_client_load_catalog.py tests/catalog/test_serialization.py
148+
```
149+
150+
The publication tests use real HTTP object retrieval with uncompressed, gzip and
151+
zstd results. They exercise restart recovery, singleflight, default attachment
152+
isolation, corrupt publication, explicit fallback, authentication before cached
153+
delivery, caps, generation changes, manifest import and conditional invalidation.
154+
The S3 fixture verifies discovery and conditional writes across repository
155+
instances. Configure a real S3/R2 bucket and deployment CORS before production
156+
rollout; the in-memory conditional object fixture does not validate a vendor's
157+
credentials, CORS settings or lifecycle configuration.
158+
159+
The installable native/browser fixture is:
160+
161+
```sh
162+
python -m vgi._test_fixtures.catalog_publication
163+
```
164+
165+
It accepts ordinary fixture worker options, including `--http`. Only
166+
`contents_probe` uses publication; other standard fixture catalogs retain their
167+
normal behavior. Set `VGI_CATALOG_PUBLICATION_PORT` to expose the local object
168+
server at a predictable port; `/stats` reports uploads and object GETs. Its CORS
169+
headers and preflight support permit browser conformance tests. This fixture is
170+
intended for local testing and its object storage lasts only for the worker's
171+
lifetime.

‎pyproject.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ dependencies = [
5858
haybarn = ["haybarn==1.5.5rc1"]
5959
duckdb = ["duckdb==1.5.5"]
6060
http = ["vgi-rpc[http]"]
61+
# Publishing and resolving immutable external catalog results needs the RPC
62+
# download/compression dependencies on every transport, including subprocess.
63+
external = ["vgi-rpc[external]"]
6164
iroh = ["vgi-rpc[http,iroh]"]
6265
# transport="launch": vgi_rpc.launcher imports filelock unconditionally at
6366
# module level, which lives behind vgi-rpc's own `cli` extra (alongside
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env python3
2+
"""Run the installable catalog publication integration fixture."""
3+
4+
from vgi._test_fixtures.catalog_publication import main
5+
6+
if __name__ == "__main__":
7+
main()

‎tests/catalog/test_catalog_contents.py‎

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from __future__ import annotations
66

77
import re
8+
from dataclasses import asdict
89
from typing import Any
910

11+
import pyarrow as pa
1012
import pytest
1113

1214
from vgi._test_fixtures.catalog import InMemoryCatalog, SchemaData
@@ -32,8 +34,9 @@
3234
SchemaInfo,
3335
SchemaObjectType,
3436
)
37+
from vgi.catalog.contents_codec import CATEGORY_TYPES, decode_inventory
3538
from vgi.meta_worker import MetaWorker
36-
from vgi.protocol import CatalogAttachRequest, CatalogContentsResponse, SchemaContents
39+
from vgi.protocol import CatalogAttachRequest, CatalogContentsResponse, SchemaContents, _item_ipc_bytes
3740
from vgi.worker import Worker, catalog_contents_digest
3841

3942
_KINDS = (
@@ -74,6 +77,17 @@ def _roundtrip(response: CatalogContentsResponse) -> CatalogContentsResponse:
7477
return CatalogContentsResponse.deserialize_from_bytes(response.serialize_to_bytes())
7578

7679

80+
def _entries(response: CatalogContentsResponse) -> list[SchemaContents]:
81+
return [
82+
SchemaContents(
83+
path=list(info.schema.path),
84+
schema=_item_ipc_bytes(info.schema),
85+
**{kind: [_item_ipc_bytes(item) for item in getattr(info, kind)] for kind in _KINDS},
86+
)
87+
for info in decode_inventory(response)
88+
]
89+
90+
7791
# ---------------------------------------------------------------------------
7892
# Wire shape
7993
# ---------------------------------------------------------------------------
@@ -82,12 +96,18 @@ def _roundtrip(response: CatalogContentsResponse) -> CatalogContentsResponse:
8296
class TestShape:
8397
"""The typed response: a struct per schema, items byte-identical to the per-schema RPCs."""
8498

85-
def test_schemas_is_a_list_of_structs(self) -> None:
86-
"""``schemas`` is ``list<struct>`` with ``path`` first, not a list of IPC blobs."""
87-
field = CatalogContentsResponse.ARROW_SCHEMA.field("schemas")
88-
assert field.type.value_type.num_fields == 10
89-
assert [f.name for f in field.type.value_type] == ["path", "schema", *_KINDS]
90-
assert CatalogContentsResponse.ARROW_SCHEMA.names == ["catalog_version", "etag", "not_modified", "schemas"]
99+
def test_categories_are_nullable_binary_streams(self) -> None:
100+
"""Every category is one nullable typed collection stream."""
101+
assert CatalogContentsResponse.ARROW_SCHEMA.names == [
102+
"catalog_version",
103+
"etag",
104+
"not_modified",
105+
*CATEGORY_TYPES,
106+
]
107+
for category in CATEGORY_TYPES:
108+
field = CatalogContentsResponse.ARROW_SCHEMA.field(category)
109+
assert field.nullable and field.type == pa.binary()
110+
assert "attach_opaque_data" not in SchemaInfo.ARROW_SCHEMA.names
91111

92112
def test_items_match_the_per_schema_rpcs(self) -> None:
93113
"""Every kind of every schema is byte-identical to its per-schema RPC answer."""
@@ -96,15 +116,15 @@ def test_items_match_the_per_schema_rpcs(self) -> None:
96116
response = _roundtrip(worker.catalog_contents(attach))
97117
assert response.etag is None
98118
assert not response.not_modified
99-
assert [entry.path for entry in response.schemas] == [["main"], ["extra"]]
119+
assert [entry.path for entry in _entries(response)] == [["main"], ["extra"]]
100120
schema_items = worker.catalog_schemas(attach).items
101-
assert [entry.schema for entry in response.schemas] == schema_items
102-
for entry in response.schemas:
121+
assert [entry.schema for entry in _entries(response)] == schema_items
122+
for entry in _entries(response):
103123
assert SchemaInfo.deserialize_from_bytes(entry.schema).path == entry.path
104124
expected = _per_schema_items(worker, attach, entry.path)
105125
for kind in _KINDS:
106126
assert getattr(entry, kind) == expected[kind], (entry.path, kind)
107-
main = response.schemas[0]
127+
main = _entries(response)[0]
108128
# Every seeded kind is present in the fixture.
109129
for kind in _KINDS[:-1]:
110130
assert getattr(main, kind), kind
@@ -126,8 +146,8 @@ class NestedWorker(ContentsProbeWorker):
126146

127147
worker = NestedWorker()
128148
response = _roundtrip(worker.catalog_contents(_attach(worker, CATALOG_PROBE)))
129-
assert [entry.path for entry in response.schemas] == [["a"], ["a", "b"], ["a", "b", "c"]]
130-
for entry in response.schemas:
149+
assert [entry.path for entry in _entries(response)] == [["a"], ["a", "b"], ["a", "b", "c"]]
150+
for entry in _entries(response):
131151
assert SchemaInfo.deserialize_from_bytes(entry.schema).path == entry.path
132152

133153
@pytest.mark.parametrize(
@@ -186,14 +206,14 @@ def test_cheap_validator_short_circuits_without_building(self, monkeypatch: pyte
186206
full = worker.catalog_contents(attach)
187207
assert full.etag == "gen-1"
188208
assert not full.not_modified
189-
assert [entry.path for entry in full.schemas] == [["main"]]
209+
assert [entry.path for entry in _entries(full)] == [["main"]]
190210

191211
with monkeypatch.context() as m:
192212
_forbid_build(m)
193213
hit = _roundtrip(worker.catalog_contents(attach, if_none_match="gen-1"))
194214
assert hit.not_modified
195215
assert hit.etag == "gen-1"
196-
assert hit.schemas == []
216+
assert hit.schemas is None
197217
assert hit.catalog_version == 1
198218

199219
# A different etag gets the full contents.
@@ -215,7 +235,7 @@ def test_ddl_changes_the_validator(self) -> None:
215235
assert not response.not_modified
216236
assert response.etag == "gen-2"
217237
assert response.catalog_version == 2
218-
assert [entry.path for entry in response.schemas] == [["main"], ["other"]]
238+
assert [entry.path for entry in _entries(response)] == [["main"], ["other"]]
219239

220240
def test_no_etag_ignores_if_none_match(self) -> None:
221241
"""A catalog without an etag always answers in full, with etag null."""
@@ -224,7 +244,7 @@ def test_no_etag_ignores_if_none_match(self) -> None:
224244
response = worker.catalog_contents(attach, if_none_match="anything")
225245
assert response.etag is None
226246
assert not response.not_modified
227-
assert [entry.path for entry in response.schemas] == [["main"]]
247+
assert [entry.path for entry in _entries(response)] == [["main"]]
228248
# contents_memory reports version 0: exercises the client's version-0 rule.
229249
assert response.catalog_version == 0
230250

@@ -245,7 +265,7 @@ class BuiltWorker(ContentsProbeWorker):
245265
attach = _attach(worker, CATALOG_PROBE)
246266
response = worker.catalog_contents(attach, if_none_match="v7")
247267
assert response.not_modified
248-
assert response.schemas == []
268+
assert response.schemas is None
249269
assert response.etag == "v7"
250270

251271
@pytest.mark.parametrize(
@@ -317,11 +337,11 @@ def test_hash_is_deterministic_and_revalidates(self) -> None:
317337
second = worker.catalog_contents(attach)
318338
assert second.etag == first.etag
319339
assert second.schemas == first.schemas
320-
assert catalog_contents_digest(first.schemas) == first.etag
340+
assert catalog_contents_digest(first) == first.etag
321341

322342
hit = worker.catalog_contents(attach, if_none_match=first.etag)
323343
assert hit.not_modified
324-
assert hit.schemas == []
344+
assert hit.schemas is None
325345
assert hit.etag == first.etag
326346

327347
cat = worker._get_catalog()
@@ -330,7 +350,7 @@ def test_hash_is_deterministic_and_revalidates(self) -> None:
330350
changed = worker.catalog_contents(attach, if_none_match=first.etag)
331351
assert not changed.not_modified
332352
assert changed.etag != first.etag
333-
assert [entry.path for entry in changed.schemas] == [["main"], ["other"]]
353+
assert [entry.path for entry in _entries(changed)] == [["main"], ["other"]]
334354

335355
def test_off_by_default(self) -> None:
336356
"""Without the opt-in a catalog gets no etag."""
@@ -357,9 +377,9 @@ class OwnWorker(ContentsProbeWorker):
357377

358378
def test_digest_separates_fields(self) -> None:
359379
"""Moving bytes between fields changes the digest."""
360-
a = [SchemaContents(path=["s"], schema=b"x", tables=[b"ab"])]
361-
b = [SchemaContents(path=["s"], schema=b"x", tables=[b"a", b"b"])]
362-
c = [SchemaContents(path=["s"], schema=b"x", views=[b"ab"])]
380+
a = CatalogContentsResponse(catalog_version=1, schemas=b"x", tables=b"ab")
381+
b = CatalogContentsResponse(catalog_version=1, schemas=b"xa", tables=b"b")
382+
c = CatalogContentsResponse(catalog_version=1, schemas=b"x", views=b"ab")
363383
assert len({catalog_contents_digest(a), catalog_contents_digest(b), catalog_contents_digest(c)}) == 3
364384

365385

@@ -397,12 +417,7 @@ def test_cached_bytes_equal_a_fresh_encoding(self) -> None:
397417
"""The pre-serialized response is byte-identical to encoding it normally."""
398418
worker = ContentsProbeWorker()
399419
cached = worker.catalog_contents(_attach(worker, CATALOG_PROBE))
400-
fresh = CatalogContentsResponse(
401-
catalog_version=cached.catalog_version,
402-
etag=cached.etag,
403-
not_modified=cached.not_modified,
404-
schemas=list(cached.schemas),
405-
)
420+
fresh = CatalogContentsResponse(**asdict(cached))
406421
assert cached.serialize_to_bytes() == fresh.serialize_to_bytes()
407422
assert _roundtrip(cached) == fresh
408423

@@ -436,7 +451,7 @@ class HashedWorker(ContentsProbeWorker):
436451
assert full.etag is not None
437452
hit = worker.catalog_contents(attach, if_none_match=full.etag)
438453
assert hit.not_modified
439-
assert hit.schemas == []
454+
assert hit.schemas is None
440455
assert worker.catalog_contents(attach, if_none_match="stale") is full
441456
assert len(calls) == 1
442457

0 commit comments

Comments
 (0)