Repository navigation
fix(registry): preset/model-endpoint mutation integrity (rename, default, delete) #588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
eec4067
21cb82f
52699a9
a9c2ca4
ffb89b3
61258c3
532b330
da9269c
1d0ed20
7e693c1
1477003
b0ffc08
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,11 +12,20 @@ | |
|
|
||
| from core.config.model_endpoints import ModelEndpointRow | ||
| from core.ports.model_endpoint_repo import ModelEndpointRepository | ||
| from core.utils.exceptions import NotFoundError | ||
|
|
||
| if TYPE_CHECKING: | ||
| import asyncpg | ||
|
|
||
| _ALLOWED_UPDATE_FIELDS = frozenset({"endpoint", "model_name", "batch_size", "timeout", "extra", "is_default"}) | ||
| # 'is_default' is deliberately excluded: a bare ``UPDATE ... SET is_default = true`` | ||
| # cannot clear the previous default in the same statement, so it would leave two | ||
| # is_default=true rows for one model_type — and load_all() then resolves the | ||
| # 'default' alias to whichever endpoint sorts last by name, not the one the caller | ||
| # picked. (Verified against the live admin API: a single PUT of {"is_default": true} | ||
| # on a non-default endpoint yielded two defaults for the type.) Promotion must go | ||
| # through set_default / delete_and_promote_default, which clear-then-set inside one | ||
| # transaction; ModelEndpointService.update_model_endpoint routes is_default there. | ||
| _ALLOWED_UPDATE_FIELDS = frozenset({"endpoint", "model_name", "batch_size", "timeout", "extra"}) | ||
|
|
||
|
|
||
| class PgModelEndpointRepository(ModelEndpointRepository): | ||
|
|
@@ -45,22 +54,34 @@ def _to_model(row: asyncpg.Record) -> ModelEndpointRow: | |
| ) | ||
|
|
||
| async def create(self, row: ModelEndpointRow) -> ModelEndpointRow: | ||
| rec = await self.pool.fetchrow( | ||
| """ | ||
| INSERT INTO model_endpoints | ||
| (name, model_type, endpoint, model_name, batch_size, timeout, extra, is_default) | ||
| VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) | ||
| RETURNING * | ||
| """, | ||
| row.name, | ||
| row.model_type, | ||
| row.endpoint, | ||
| row.model_name, | ||
| row.batch_size, | ||
| row.timeout, | ||
| row.extra, | ||
| row.is_default, | ||
| ) | ||
| # A bare INSERT with is_default=true cannot clear the previous default, so | ||
| # POST /model-endpoints/ {"is_default": true} would leave two is_default=true | ||
| # rows for one model_type (same hazard the update path avoids by routing | ||
| # through set_default). Demote any existing default in the SAME transaction | ||
| # as the insert so the new endpoint becomes the sole default atomically. | ||
| async with self.pool.acquire() as conn: | ||
| async with conn.transaction(): | ||
| if row.is_default: | ||
| await conn.execute( | ||
| "UPDATE model_endpoints SET is_default = false, updated_at = now() WHERE model_type = $1", | ||
| row.model_type, | ||
| ) | ||
| rec = await conn.fetchrow( | ||
| """ | ||
| INSERT INTO model_endpoints | ||
| (name, model_type, endpoint, model_name, batch_size, timeout, extra, is_default) | ||
| VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) | ||
| RETURNING * | ||
| """, | ||
| row.name, | ||
| row.model_type, | ||
| row.endpoint, | ||
| row.model_name, | ||
| row.batch_size, | ||
| row.timeout, | ||
| row.extra, | ||
| row.is_default, | ||
| ) | ||
| return self._to_model(rec) | ||
|
|
||
| async def get(self, name: str, model_type: str) -> ModelEndpointRow | None: | ||
|
|
@@ -119,8 +140,25 @@ async def delete(self, name: str, model_type: str) -> bool: | |
| return result == "DELETE 1" | ||
|
|
||
| async def set_default(self, model_type: str, name: str) -> None: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug (found in review, fixed here): Same "never leave a type defaultless" invariant that Concretely — type
Low probability (admin-only path, ms-scale window) but high, silent impact. Fix:
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in commit 7e693c1.
Validated locally: |
||
| """Promote ``name`` to the default for ``model_type``, atomically. | ||
|
|
||
| Locks the type's rows (FOR UPDATE) and confirms ``name`` still exists | ||
| *inside* the transaction before clearing the old default. Without the | ||
| lock + existence check, a concurrent delete of ``name`` between the | ||
| caller's existence check and this transaction would make the second | ||
| UPDATE match 0 rows AFTER the first already cleared the previous default | ||
| — leaving the type with no default at all. Same invariant | ||
| ``delete_and_promote_default`` protects. Raises ``NotFoundError`` if the | ||
| target endpoint is gone. | ||
| """ | ||
| async with self.pool.acquire() as conn: | ||
| async with conn.transaction(): | ||
| rows = await conn.fetch( | ||
| "SELECT name FROM model_endpoints WHERE model_type = $1 FOR UPDATE", | ||
| model_type, | ||
| ) | ||
| if name not in {r["name"] for r in rows}: | ||
| raise NotFoundError(f"Endpoint '{name}' of type '{model_type}' not found.") | ||
| await conn.execute( | ||
| "UPDATE model_endpoints SET is_default = false, updated_at = now() WHERE model_type = $1", | ||
| model_type, | ||
|
|
@@ -132,5 +170,56 @@ async def set_default(self, model_type: str, name: str) -> None: | |
| model_type, | ||
| ) | ||
|
|
||
| async def delete_and_promote_default(self, name: str, model_type: str) -> tuple[str, str | None]: | ||
| """Delete an endpoint and, if it was the default, promote a survivor to | ||
| default — all atomically and decided under a row lock. | ||
|
|
||
| Locking and deciding inside one transaction means concurrent deletes of the | ||
| same model type can't both pass a stale last-endpoint check or promote an | ||
| already-deleted survivor, so the type is never left with no endpoint or no | ||
| default. Returns ``(status, promoted_name)`` where ``status`` is | ||
| ``"not_found" | "last" | "ok"`` and ``promoted_name`` is set only when a | ||
| deleted default was replaced. | ||
| """ | ||
| # Lock every row of this model_type (FOR UPDATE) so concurrent deletes of | ||
| # the same type serialize, then make the last-endpoint guard and survivor | ||
| # choice from the LOCKED, current state — not a stale snapshot. Otherwise two | ||
| # concurrent deletes could each pass a snapshot count check and remove the | ||
| # last row, or promote a survivor that another tx just deleted, leaving the | ||
| # type with no endpoint / no default. Returns (status, promoted_name): | ||
| # status is "not_found" | "last" | "ok"; promoted_name is set only when the | ||
| # deleted row was the default and a survivor was promoted. | ||
|
andyne13 marked this conversation as resolved.
Resolved
|
||
| async with self.pool.acquire() as conn: | ||
| async with conn.transaction(): | ||
| rows = await conn.fetch( | ||
| "SELECT name, is_default FROM model_endpoints WHERE model_type = $1 ORDER BY name FOR UPDATE", | ||
| model_type, | ||
| ) | ||
| names = [r["name"] for r in rows] | ||
| if name not in names: | ||
| return ("not_found", None) | ||
| if len(names) <= 1: | ||
| return ("last", None) | ||
| was_default = next(r["is_default"] for r in rows if r["name"] == name) | ||
| await conn.execute( | ||
| "DELETE FROM model_endpoints WHERE name = $1 AND model_type = $2", | ||
| name, | ||
| model_type, | ||
| ) | ||
| promoted: str | None = None | ||
| if was_default: | ||
| promoted = next(n for n in names if n != name) # deterministic: first by name | ||
| await conn.execute( | ||
| "UPDATE model_endpoints SET is_default = false, updated_at = now() WHERE model_type = $1", | ||
| model_type, | ||
| ) | ||
| await conn.execute( | ||
| "UPDATE model_endpoints SET is_default = true, updated_at = now() " | ||
| "WHERE name = $1 AND model_type = $2", | ||
| promoted, | ||
| model_type, | ||
| ) | ||
| return ("ok", promoted) | ||
|
|
||
|
|
||
| __all__ = ["PgModelEndpointRepository"] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
| from typing import TYPE_CHECKING | ||
|
|
||
| from core.ports.preset_repo import PresetRepository | ||
| from core.utils.exceptions import NotFoundError | ||
|
|
||
| if TYPE_CHECKING: | ||
| import asyncpg | ||
|
|
@@ -79,18 +80,39 @@ async def upsert(self, name: str, preset_type: str, config: dict) -> dict: | |
| return self._row_to_dict(rec) | ||
|
|
||
| async def rename(self, old_name: str, new_name: str, preset_type: str, config: dict) -> dict: | ||
| """Rename a preset to ``new_name`` (also updating its config) and repoint | ||
| every referencing partition, atomically. | ||
|
|
||
| Raises :class:`NotFoundError` if ``old_name`` no longer exists; a collision | ||
| with an existing ``new_name`` is rejected by the ``(name, preset_type)`` | ||
| unique constraint. Returns the updated preset row as a dict. | ||
| """ | ||
| # Rename in place with a plain UPDATE (not an upsert): the | ||
| # (name, preset_type) unique constraint then rejects a collision with an | ||
| # existing target name instead of an upsert silently overwriting it, and | ||
| # the row keeps its identity/created_at. Partitions reference a preset by | ||
| # its name string (partitions.{col}) with no FK, so referencing partitions | ||
| # are repointed in the same transaction or they would orphan. | ||
| col = _preset_column(preset_type) | ||
| async with self.pool.acquire() as conn: | ||
| async with conn.transaction(): | ||
| await conn.execute( | ||
| "DELETE FROM pipeline_presets WHERE name = $1 AND preset_type = $2", | ||
| rec = await conn.fetchrow( | ||
| "UPDATE pipeline_presets SET name = $2, config = $3::jsonb, updated_at = now() " | ||
| "WHERE name = $1 AND preset_type = $4 RETURNING *", | ||
| old_name, | ||
| new_name, | ||
| config, | ||
| preset_type, | ||
| ) | ||
|
Comment on lines
97
to
106
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The rename looks much safer now, but I think there's still one small consistency edge case.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid edge — a partition write committing old_name concurrently with the rename can still leave a stale reference. The robust fix (a shared advisory lock spanning preset rename AND the partition write path) is cross-cutting and touches the partition-write hot path, so we're deferring it to a focused follow-up after the pre-release rather than expanding this PR. Leaving this thread open to track it. |
||
| rec = await conn.fetchrow( | ||
| _UPSERT_PRESET_SQL, | ||
| if rec is None: | ||
| # old_name vanished between the service's existence check and | ||
| # this UPDATE (concurrent delete) — fail cleanly instead of | ||
| # blowing up in _row_to_dict(None). | ||
| raise NotFoundError(f"Preset '{old_name}' of type '{preset_type}' not found.") | ||
| await conn.execute( | ||
| f"UPDATE partitions SET {col} = $1 WHERE {col} = $2", | ||
| new_name, | ||
| preset_type, | ||
| config, | ||
| old_name, | ||
| ) | ||
| return self._row_to_dict(rec) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.