Drop the filament->spool websocket fan-out

filament.update re-broadcast every spool of the filament (archived included)
after commit, so a filament shared by hundreds of spools turned a single PATCH
into hundreds of ORM loads and websocket frames inside one request. This is the
unbounded fan-out flagged in review; the same reasoning is why no equivalent was
ever added for vendor.update.

Subscribers now treat a filament event as invalidating the spools that reference
it. In client_v2 the cache is already normalized -- spools hold a filamentId and
resolve their filament through filamentById -- so nearly everything a spool
displays was live regardless. The exception is a spool with no initial_weight of
its own, whose initial/remaining weights the server derived from filament.weight.
The cache now recomputes exactly those, mirroring Spool.from_db's formula
including its clamp at 0, which needs used_weight and the initial_weight override
carried on the cached spool.

Reverts the behavior added in 6ade405 (unreleased). Its integration test is
repurposed to pin the decision from both sides: no spool event reaches a /spool
subscriber, and the filament event subscribers rely on is still emitted.
This commit is contained in:
Donkie committed 2026-07-28 20:51:36 +02:00
1 parent b369cf049e
commit 2924850317
5 files changed
+84 -38

No files matched your search

+2
View File
@@ -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,
+25 -1
View File
@@ -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<string, unknown> | undefined;
if (v) this.upsertVendor(mapVendor(v));
}
+6 -1
View File
@@ -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. */
+9 -17
View File
@@ -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)
@@ -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