diff --git a/client_v2/src/lib/api/map.ts b/client_v2/src/lib/api/map.ts index 2cbdb603..efd65edc 100644 --- a/client_v2/src/lib/api/map.ts +++ b/client_v2/src/lib/api/map.ts @@ -64,6 +64,8 @@ export function mapSpool(s: Json): Spool { unused: (s.used_weight ?? 0) === 0, remaining: s.remaining_weight ?? 0, initial: s.initial_weight ?? f.weight ?? 0, + initialOverride: s.initial_weight ?? undefined, + usedWeight: s.used_weight ?? 0, location: s.location ?? '', lot: s.lot_nr ?? '', price: s.price ?? undefined, diff --git a/client_v2/src/lib/stores/inventory.svelte.ts b/client_v2/src/lib/stores/inventory.svelte.ts index 1b67b81d..7768610c 100644 --- a/client_v2/src/lib/stores/inventory.svelte.ts +++ b/client_v2/src/lib/stores/inventory.svelte.ts @@ -83,6 +83,28 @@ class Inventory { if (v) this.upsertVendor({ ...v, ...patch }); } + /** + * Recompute the weights of cached spools that inherit them from `f`. + * + * The server does not re-broadcast a filament's spools when the filament changes + * (that fan-out is unbounded — see spoolman/database/filament.py `update`), so a + * filament event is our only signal. Everything else a spool shows about its + * filament resolves through `filamentById` and is already live; the exception is + * a spool with no `initial_weight` of its own, whose initial/remaining weights the + * server derived from `filament.weight`. Mirror `Spool.from_db`'s formula — + * including its clamp at 0 — so an over-used spool doesn't go negative. + */ + private reweighSpoolsOfFilament(f: Filament) { + let changed = false; + const next = this.spools.map((s) => { + if (s.filamentId !== f.id || s.initialOverride !== undefined) return s; + if (s.initial === f.weight) return s; + changed = true; + return { ...s, initial: f.weight, remaining: Math.max(f.weight - s.usedWeight, 0) }; + }); + if (changed) this.spools = next; + } + // --- live events (raw API payload → cache) ------------------------------ ingest(event: LiveEvent) { @@ -104,7 +126,9 @@ class Inventory { const id = String(event.id); if (event.type === 'deleted') this.filaments = this.filaments.filter((f) => f.id !== id); else if (payload) { - this.upsertFilament(mapFilament(payload)); + const f = mapFilament(payload); + this.upsertFilament(f); + this.reweighSpoolsOfFilament(f); const v = payload.vendor as Record | undefined; if (v) this.upsertVendor(mapVendor(v)); } diff --git a/client_v2/src/lib/types.ts b/client_v2/src/lib/types.ts index 16f85b4a..e2d35172 100644 --- a/client_v2/src/lib/types.ts +++ b/client_v2/src/lib/types.ts @@ -56,8 +56,13 @@ export interface Spool { unused: boolean; /** Remaining filament weight in grams. */ remaining: number; - /** Initial (net) weight in grams. */ + /** Initial (net) weight in grams; falls back to the filament's weight. */ initial: number; + /** Per-spool initial-weight override; undefined means `initial` came from the + * filament and must be recomputed when the filament's weight changes. */ + initialOverride?: number; + /** Filament drawn from this spool so far, in grams. */ + usedWeight: number; location: string; lot: string; /** Per-spool price override; undefined falls back to the filament's price. */ diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index f059b08d..a3006445 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -190,27 +190,19 @@ async def update( else: setattr(filament, k, v) await db.commit() + # A spool's response embeds its filament and carries values derived from it + # (remaining_length, and the initial_weight/price fall-backs), so this edit changed + # every one of this filament's spool payloads without touching a spool row. + # We deliberately do NOT re-broadcast those spools: a filament shared by hundreds of + # spools would turn one PATCH into hundreds of ORM loads and websocket frames inside + # the request. Subscribers are expected to treat a filament event as invalidating the + # spools that reference it and refetch what they still have on screen — see + # client_v2/src/lib/stores/inventory.svelte.ts. The same reasoning applies to vendor + # updates, which is why there is no vendor-side fan-out either. await filament_changed(filament, EventType.UPDATED) - # A spool's response carries fields derived from its filament (remaining_length, - # and the initial_weight/price fall-backs to the filament's values). Those change - # when the filament changes, yet no spool row was touched — so subscribers to the - # affected spools would keep stale derived values until each spool is next edited. - # Re-broadcast the filament's spools so websocket clients refresh them. - await _notify_filament_spools(db, filament_id) return filament -async def _notify_filament_spools(db: AsyncSession, filament_id: int) -> None: - """Emit a spool 'updated' event for every spool belonging to a filament.""" - # Local import: spool.py imports this module at load time, so importing it at the - # top here would be a circular import. - from spoolman.database import spool # noqa: PLC0415 - - spools, _ = await spool.find(db=db, filament_id=filament_id, allow_archived=True) - for s in spools: - await spool.spool_changed(s, EventType.UPDATED) - - async def delete(db: AsyncSession, filament_id: int) -> None: """Delete a filament object.""" filament = await get_by_id(db, filament_id) diff --git a/tests_integration/tests/filament/test_notify_spools.py b/tests_integration/tests/filament/test_notify_spools.py index e4c7ef8f..4d559b79 100644 --- a/tests_integration/tests/filament/test_notify_spools.py +++ b/tests_integration/tests/filament/test_notify_spools.py @@ -1,13 +1,18 @@ -"""Integration tests: a filament update re-broadcasts its spools over websockets. +"""Integration tests: a filament update emits only a filament event, not a spool fan-out. Spool responses carry fields derived from the filament (e.g. remaining_length, and the -initial_weight/price fall-backs). Editing the filament changes those derived values -without touching any spool row, so the server must emit a spool 'updated' event for -every spool of the filament — otherwise websocket subscribers keep stale values until -the spool itself is next edited. +initial_weight/price fall-backs), so editing a filament does change those derived values +without touching any spool row. Re-broadcasting every affected spool was tried and +deliberately dropped: a filament shared by hundreds of spools turned one PATCH into +hundreds of ORM loads and websocket frames inside the request. Subscribers are instead +expected to treat a filament event as invalidating the spools that reference it. + +These tests pin that contract from both sides so the fan-out isn't reintroduced by +accident. """ import asyncio +import contextlib import json from typing import Any @@ -19,18 +24,21 @@ from ..conftest import URL WS_URL = URL.replace("http://", "ws://", 1) +# How long to wait for an event we expect NOT to arrive. Long enough that a +# re-introduced fan-out would reliably land inside it, short enough to keep the +# suite quick across all four database backends. +QUIET_PERIOD_S = 2.0 + @pytest.mark.asyncio -async def test_filament_update_notifies_spools(random_filament: dict[str, Any]) -> None: - """Patching a filament's density emits a spool 'updated' event with fresh derived fields.""" - # Setup: one spool for this filament. +async def test_filament_update_does_not_fan_out_to_spools(random_filament: dict[str, Any]) -> None: + """Patching a filament emits no spool events, however many spools reference it.""" result = httpx.post( f"{URL}/api/v1/spool", json={"filament_id": random_filament["id"], "remaining_weight": 500}, ) result.raise_for_status() spool = result.json() - original_length = spool["remaining_length"] try: async with connect(f"{WS_URL}/api/v1/spool") as ws: @@ -38,19 +46,34 @@ async def test_filament_update_notifies_spools(random_filament: dict[str, Any]) # but give the server a beat before triggering the event. await asyncio.sleep(0.2) - new_density = random_filament["density"] * 2 httpx.patch( f"{URL}/api/v1/filament/{random_filament['id']}", - json={"density": new_density}, + json={"density": random_filament["density"] * 2}, ).raise_for_status() - event = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) - - assert event["type"] == "updated" - assert event["resource"] == "spool" - assert event["payload"]["id"] == spool["id"] - # The embedded filament and the derived length must reflect the new density. - assert event["payload"]["filament"]["density"] == new_density - assert event["payload"]["remaining_length"] != original_length + # Nothing should reach a /spool subscriber. + with contextlib.suppress(TimeoutError): + event = json.loads(await asyncio.wait_for(ws.recv(), timeout=QUIET_PERIOD_S)) + pytest.fail(f"filament update fanned out to a spool event: {event}") finally: httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status() + + +@pytest.mark.asyncio +async def test_filament_update_emits_filament_event(random_filament: dict[str, Any]) -> None: + """The filament event subscribers rely on for invalidation is still emitted.""" + async with connect(f"{WS_URL}/api/v1/filament") as ws: + await asyncio.sleep(0.2) + + new_density = random_filament["density"] * 2 + httpx.patch( + f"{URL}/api/v1/filament/{random_filament['id']}", + json={"density": new_density}, + ).raise_for_status() + + event = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) + + assert event["type"] == "updated" + assert event["resource"] == "filament" + assert event["payload"]["id"] == random_filament["id"] + assert event["payload"]["density"] == new_density