Files
Donkie 9b6af119c6 Fix commit-before-notify races in write paths (flaky postgres deletes)
Several DB write functions mutated and then sent their websocket notification (or
just returned) without committing, relying on the request session's teardown
commit. On async postgres that commit races the HTTP response, so a fast
follow-up read could still see the pre-write state — e.g. `DELETE /vendor/{id}`
returns 200 but an immediate `GET` still returns 200. Latent since 2023 (a6d527a);
the async-timing dependency bump widened the window enough to intermittently fail
`test (postgres)` in CI.

Commit inline before notifying, matching the functions that already did
(filament.delete, vendor/spool create/update) and the commit-first invariant from
the earlier websocket-ordering fix:
- vendor.delete, spool.delete
- setting.update, setting.delete
- vendor/filament/spool.clear_extra_field
- spool.rename_location

(use/measure already commit via their wrappers; reads are unaffected.)

Verified via `SPOOLMAN_CONTAINER_ENGINE=podman poe itest`: full 4-DB suite green
plus 7/7 repeat postgres runs with zero flakes (was ~66% failing before).
2026-07-06 15:53:40 +02:00

75 lines
2.5 KiB
Python

"""Helper functions for interacting with vendor database objects."""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import EventType, SettingEvent, SettingKV
from spoolman.database import models
from spoolman.exceptions import ItemNotFoundError
from spoolman.settings import SettingDefinition
from spoolman.ws import websocket_manager
SETTING_MAX_LENGTH = 2**16 - 1
async def update(
*,
db: AsyncSession,
definition: SettingDefinition,
value: str,
) -> None:
"""Set a setting in the database."""
if len(value) > SETTING_MAX_LENGTH:
raise ValueError(f"Setting value is too big, max size is {SETTING_MAX_LENGTH} characters.")
setting = models.Setting(
key=definition.key,
value=value,
last_updated=datetime.utcnow().replace(microsecond=0),
)
await db.merge(setting)
# Commit before notifying so the setting is durable and visible to subsequent
# requests; post-commit notification must be the last, infallible step.
await db.commit()
await setting_changed(definition, value, EventType.UPDATED)
async def get(db: AsyncSession, definition: SettingDefinition) -> models.Setting:
"""Get a specific setting from the database."""
setting = await db.get(models.Setting, definition.key)
if setting is None:
raise ItemNotFoundError(f"Setting with key {definition.key} has not been set.")
return setting
async def get_all(db: AsyncSession) -> list[models.Setting]:
"""Get all set settings in the database."""
stmt = select(models.Setting)
rows = await db.execute(stmt)
return list(rows.scalars().all())
async def delete(db: AsyncSession, definition: SettingDefinition) -> None:
"""Delete a setting from the database."""
setting = await get(db, definition)
await db.delete(setting)
# Commit before notifying so the deletion is durable and visible to subsequent
# requests; post-commit notification must be the last, infallible step.
await db.commit()
await setting_changed(definition, None, EventType.DELETED)
async def setting_changed(definition: SettingDefinition, set_value: str | None, typ: EventType) -> None:
"""Notify websocket clients that a setting has changed."""
await websocket_manager.send(
("setting", str(definition.key)),
SettingEvent(
type=typ,
resource="setting",
date=datetime.utcnow(),
payload=SettingKV.from_db(definition, set_value),
),
)