Skip to content

feat(status): say how old the printer's report is, not just what it said - #7

Merged
impuls42 merged 1 commit into
mainfrom
feat/status-staleness
Aug 2, 2026
Merged

feat(status): say how old the printer's report is, not just what it said#7
impuls42 merged 1 commit into
mainfrom
feat/status-staleness

Conversation

@impuls42

@impuls42 impuls42 commented Aug 2, 2026

Copy link
Copy Markdown
Member

Consumer half of sengine-cloud/labelfab#12 — pairs with sengine-cloud/labelfab#13.

Why

The retained status is mostly a memory. The D30 is only reachable while it is being printed to, so the agent remembers what it last heard and republishes it rather than going quiet. That is the right trade — the alternative is waking a sleeping printer to keep a status page tidy — but it means media ok on the Machines page is usually a reading from the last job, and until now it looked exactly like a reading from a minute ago.

This module already refuses to render silence as health. Rendering a three-day-old observation as a current one is the same claim by another route.

What it looks like

CONNECTED   fw 2.1.2 · 100% (4.17V) · media ok · Q223P4C31420105 · seen 6h ago

The age comes from device_seen_at, which labelfab#13 adds to the retained status. It annotates the reading rather than overruling it — a printer that had tape six hours ago is far likelier to still have tape than to have anything else — so the status→member mapping is completely untouched. No stale→WARNING rule: that would be inventing policy this issue did not ask for.

_ago never raises. It runs inside a Django worker on a payload produced by another process, so a surprise there is a 500 on the Machines page rather than a red test. An absent, empty or unparseable timestamp renders no age at all — which also means an agent too old to publish the field looks exactly as it does today.

Two details verified, not assumed

This package supports Python 3.9, whose fromisoformat rejects the trailing Z that pydantic emits for UTC. Confirmed against a real interpreter rather than from memory:

$ docker run --rm python:3.9-slim python -c "datetime.fromisoformat('2026-08-02T09:14:03Z')"
3.9 rejects Z: Invalid isoformat string: '2026-08-02T09:14:03Z'

So the offset is normalised by hand. CI only runs 3.12, where this would have passed silently and failed in production. The whole status suite was also run under 3.9.25 in Docker — 36 passed.

A naive timestamp is read as UTC rather than local time, which would otherwise shift the age by whole hours in a way nobody would spot.

Verified

48 tests pass on 3.12, 36 status tests pass on a real 3.9.25, ruff clean. classify_status takes an injectable now, so the ages are asserted against a fixed instant instead of wall time; the driver keeps calling it positionally and is unchanged.

The retained status is mostly a memory. The D30 is only reachable while it is being
printed to, so the agent remembers what it last heard and republishes it rather than
going quiet (labelfab#12). That is the right trade -- the alternative is waking a
sleeping printer to keep a status page tidy -- but it means "media ok" on the Machines
page is usually a reading from the last job, and until now it looked exactly like a
reading from a minute ago.

That is the same mistake the tri-state media_ok exists to prevent, one layer along:
this module already refuses to render silence as health, and rendering a three-day-old
observation as a current one is the same claim by another route.

labelfab now publishes device_seen_at, so the age is shown:

  CONNECTED  fw 2.1.2 · 100% (4.17V) · media ok · Q223P4C31420105 · seen 6h ago

It annotates the reading rather than overruling it -- a printer that had tape six hours
ago is far likelier to still have tape than to have anything else -- so the status
mapping is untouched. No stale-to-WARNING rule; that would be inventing policy.

_ago never raises. It runs in a Django worker on a payload produced by another process,
so a surprise there is a 500 on the Machines page rather than a red test: an absent,
empty or unparseable timestamp renders no age at all, which also keeps an agent too old
to publish the field looking exactly as it did before.

Two details that are easy to get wrong and were verified rather than assumed. The
package supports Python 3.9, whose fromisoformat rejects the trailing Z that pydantic
emits for UTC -- confirmed against a real 3.9.25 interpreter, where it raises
ValueError -- so the offset is normalised by hand. And a naive timestamp is read as
UTC rather than local, which would otherwise shift the age by whole hours in a way
nobody would notice.

classify_status takes an injectable now, so the ages are asserted against a fixed
instant instead of wall time.
@impuls42
impuls42 merged commit f0d4d37 into main Aug 2, 2026
1 check passed
@impuls42
impuls42 deleted the feat/status-staleness branch August 2, 2026 14:30
@impuls42

impuls42 commented Aug 2, 2026

Copy link
Copy Markdown
Member Author

Both nits taken, and the pairing dependency is documented.

seen is now annotated object — deliberately not str | None, since it comes out of json.loads on a payload this process did not produce and can be any JSON type; a narrow annotation would just move the lie into the signature. Under 10s renders seen just now, which is the common case given the page is usually looked at right after a print.

On the stale-LWT pairing: correct that this side cannot detect it, and correct that the fix belongs upstream. The producer PR now re-arms the will on every reconnect (labelfab@09c6487), which bounds it by the reconnect interval rather than the process lifetime — but that does not close the case you described, because the broker holds the will from CONNECT and nothing can change it for a live session. Neither of your two suggestions closes it either: on_disconnect fires with no connection to publish on, and a SIGKILL runs no callback at all.

What actually bounds it is recovery, not prevention: the unit is Restart=always/RestartSec=5, and a restarted agent seeds _last_status from the spool — which does hold the fault — and republishes on connect. So the stale-healthy row is seconds wide, not indefinite. That is now pinned by a test upstream (test_a_stale_will_is_corrected_by_the_restart_that_follows_it) rather than left as an argument, and noted in both READMEs and both PR descriptions.

49 tests on 3.12, 37 on a real 3.9.25, ruff clean.

impuls42 added a commit that referenced this pull request Aug 2, 2026
…nside it (#8)

Follow-up to #7, which merged before these review points were addressed.
Same three changes, rebased onto the merged main.

## The window the age cannot cover

Review raised a case this module cannot defend against, and it is worth
writing down rather than leaving to be rediscovered.

The agent's MQTT will is fixed when its connection opens and the broker
holds it, so a fault learned *during* that session never reaches it.
Agent connects while the tape is fine → a print an hour later finds the
tape gone and correctly publishes `media_ok: false` → agent is killed →
the broker publishes the older, *healthier* reading over the newer one,
and this page renders `DISCONNECTED` with a stale-healthy detail line.

Nothing here can detect it: retained MQTT v3.1.1 messages carry no
publish timestamp. So the README note says where the recovery actually
comes from rather than pretending to a fix — the agent restarts
(`Restart=always`, `RestartSec=5`), seeds from its spool, which *does*
hold the fault, and republishes. Seconds wide, not indefinite.

Worth being precise about what does **not** close it, since both were
suggested: `on_disconnect` fires with no connection to publish on, and a
SIGKILL runs no callback at all. Re-arming the will on reconnect (done
in sengine-cloud/labelfab#13) bounds it by the reconnect interval rather
than the process lifetime, but cannot touch a live session. The recovery
path is now pinned by a test upstream rather than left as an argument.

## Two nits

`_ago`'s `seen` is annotated `object`, deliberately not `str | None` —
it comes out of `json.loads` on a payload this process did not produce
and can be any JSON type, so a narrow annotation would move the lie into
the signature rather than remove it.

Under 10 seconds renders `seen just now` instead of `seen 0s ago`. That
is the common case, since the page is usually looked at right after
printing something.

## Verified

49 tests on 3.12, 37 on a real 3.9.25 in Docker, ruff clean.
impuls42 added a commit that referenced this pull request Aug 2, 2026
The Machines page now says how old the printer's report is, not just what it said
(#7, #8). Pairs with labelfab 0.3.0, which publishes device_seen_at; against an older
agent that omits the field the page renders exactly as it did before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant