mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-20 00:59:47 -05:00
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:
1 parent
b369cf049e
commit
2924850317
5 files changed
+84
-38
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user