Skip to content

Commit 213575a

Browse files
authored
Merge pull request #804 from morluto/agent/fix-schema-registry-transactions
Preserve schema registry transaction invariants
2 parents fdc52ff + ef62021 commit 213575a

2 files changed

Lines changed: 156 additions & 12 deletions

File tree

src/jacobian/schema_registry.py

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,11 @@ def _register_canonical(
131131

132132
registration = (name, version, canonical_schema)
133133
transaction_identity = self.store.transaction_identity
134-
registrations = self._registrations
135-
if transaction_identity is not None:
136-
registrations = self._pending.setdefault(
137-
transaction_identity,
138-
_PendingRegistrations(),
139-
).registrations
134+
if transaction_identity is None:
135+
registrations = self._registrations
136+
else:
137+
pending = self._pending.get(transaction_identity)
138+
registrations = pending.registrations if pending is not None else {}
140139
cached_uri = registrations.get(registration)
141140
if cached_uri is not None:
142141
return cached_uri
@@ -155,11 +154,16 @@ def _register_canonical(
155154
version=version,
156155
definition=schema,
157156
)
158-
registrations[registration] = schema_uri
159157
if transaction_identity is None:
158+
self._registrations[registration] = schema_uri
160159
self._schema_bytes[schema_uri] = canonical_schema
161160
else:
162-
self._pending[transaction_identity].schemas[schema_uri] = canonical_schema
161+
pending = self._pending.setdefault(
162+
transaction_identity,
163+
_PendingRegistrations(),
164+
)
165+
pending.registrations[registration] = schema_uri
166+
pending.schemas[schema_uri] = canonical_schema
163167
return schema_uri
164168

165169
def register_model(
@@ -179,10 +183,21 @@ def register_model(
179183
"""
180184

181185
self._reconcile_pending()
186+
canonical_schema = _model_schema_bytes(model)
187+
schema = cast(dict[str, Any], loads_strict_json(canonical_schema))
188+
self._ensure_model_contract_available(
189+
self.store.descriptor_uri(
190+
kind="schema",
191+
name=name,
192+
version=version,
193+
definition=schema,
194+
),
195+
model,
196+
)
182197
schema_uri = self._register_canonical(
183198
name=name,
184199
version=version,
185-
canonical_schema=_model_schema_bytes(model),
200+
canonical_schema=canonical_schema,
186201
)
187202
self._bind_model_contract(schema_uri, model)
188203
if producer_only:
@@ -214,16 +229,35 @@ def _bind_model_contract(
214229
schema_uri: str,
215230
model: type[BaseModel],
216231
) -> None:
232+
self._ensure_model_contract_available(schema_uri, model)
217233
transaction_identity = self.store.transaction_identity
218-
model_contracts = self._model_contracts
219234
if transaction_identity is not None:
220235
model_contracts = self._pending[transaction_identity].model_contracts
221-
registered = model_contracts.get(schema_uri)
236+
else:
237+
model_contracts = self._model_contracts
238+
model_contracts[schema_uri] = model
239+
240+
def _ensure_model_contract_available(
241+
self,
242+
schema_uri: str,
243+
model: type[BaseModel],
244+
) -> None:
245+
committed = self._model_contracts.get(schema_uri)
246+
if committed is not None and committed is not model:
247+
raise SchemaRegistryError(
248+
"one schema URI cannot use multiple model-backed contracts"
249+
)
250+
transaction_identity = self.store.transaction_identity
251+
if transaction_identity is None:
252+
return
253+
pending = self._pending.get(transaction_identity)
254+
registered = (
255+
pending.model_contracts.get(schema_uri) if pending is not None else None
256+
)
222257
if registered is not None and registered is not model:
223258
raise SchemaRegistryError(
224259
"one schema URI cannot use multiple model-backed contracts"
225260
)
226-
model_contracts[schema_uri] = model
227261

228262
def resolve(self, schema_uri: str) -> dict[str, Any]:
229263
"""Load a previously registered schema definition."""
@@ -263,6 +297,9 @@ def _reconcile_pending(self) -> None:
263297
for transaction_identity, pending in tuple(self._pending.items()):
264298
if transaction_identity == active_identity:
265299
continue
300+
if not pending.schemas:
301+
del self._pending[transaction_identity]
302+
continue
266303
witness_uri = next(iter(pending.schemas))
267304
try:
268305
self.store.get_descriptor(witness_uri, expected_kind="schema")

tests/component/schemas/test_schema_registry.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,3 +326,110 @@ def unexpected_blob_write(_data: bytes) -> str:
326326

327327
with pytest.raises(SchemaValidationError, match="pair must be ordered"):
328328
runtime_registry.validate(schema_uri, {"first": 2, "second": 1})
329+
330+
331+
def test_failed_transactional_registration_does_not_leave_empty_pending_state(
332+
tmp_path: Path,
333+
monkeypatch: pytest.MonkeyPatch,
334+
) -> None:
335+
store = ArtifactRepository(tmp_path)
336+
registry = SchemaRegistry(store)
337+
register_descriptor = store.register_descriptor
338+
339+
def fail_first_registration(
340+
*,
341+
kind: str,
342+
name: str,
343+
version: str,
344+
definition: dict[str, Any],
345+
) -> str:
346+
if name == "will-fail":
347+
raise RuntimeError("simulated descriptor failure")
348+
return register_descriptor(
349+
kind=kind,
350+
name=name,
351+
version=version,
352+
definition=definition,
353+
)
354+
355+
monkeypatch.setattr(store, "register_descriptor", fail_first_registration)
356+
357+
with (
358+
store.transaction(),
359+
pytest.raises(
360+
RuntimeError,
361+
match="simulated descriptor failure",
362+
),
363+
):
364+
registry.register(
365+
name="will-fail",
366+
version="1",
367+
schema={"type": "object"},
368+
)
369+
370+
assert registry.register(
371+
name="still-usable",
372+
version="1",
373+
schema={"type": "object"},
374+
).startswith("artifact://sha256/")
375+
376+
377+
def test_transaction_cannot_replace_committed_model_contract(tmp_path: Path) -> None:
378+
store = ArtifactRepository(tmp_path)
379+
registry = SchemaRegistry(store)
380+
schema_uri = registry.register_model(
381+
name="shared-model-contract",
382+
version="1",
383+
model=_CachedSchemaModel,
384+
)
385+
386+
with store.transaction():
387+
with pytest.raises(SchemaRegistryError, match="one schema URI"):
388+
registry.register_model(
389+
name="shared-model-contract",
390+
version="1",
391+
model=_EquivalentCachedSchemaModel,
392+
)
393+
assert registry._pending == {}
394+
395+
assert registry._model_contracts[schema_uri] is _CachedSchemaModel
396+
397+
398+
def test_transaction_can_reattach_the_same_model_contract(tmp_path: Path) -> None:
399+
store = ArtifactRepository(tmp_path)
400+
registry = SchemaRegistry(store)
401+
schema_uri = registry.register_model(
402+
name="same-model-contract",
403+
version="1",
404+
model=_CachedSchemaModel,
405+
)
406+
407+
with store.transaction():
408+
assert (
409+
registry.register_model(
410+
name="same-model-contract",
411+
version="1",
412+
model=_CachedSchemaModel,
413+
)
414+
== schema_uri
415+
)
416+
417+
assert registry._model_contracts[schema_uri] is _CachedSchemaModel
418+
419+
420+
def test_transaction_cannot_bind_two_models_to_the_same_schema(tmp_path: Path) -> None:
421+
store = ArtifactRepository(tmp_path)
422+
registry = SchemaRegistry(store)
423+
424+
with store.transaction():
425+
registry.register_model(
426+
name="intra-transaction-model-conflict",
427+
version="1",
428+
model=_CachedSchemaModel,
429+
)
430+
with pytest.raises(SchemaRegistryError, match="one schema URI"):
431+
registry.register_model(
432+
name="intra-transaction-model-conflict",
433+
version="1",
434+
model=_EquivalentCachedSchemaModel,
435+
)

0 commit comments

Comments
 (0)