Skip to content

feat: clear retained state when a topic moves or goes away - #64

Merged
stakach merged 4 commits into
masterfrom
feat/retained-cleanup-wip
Aug 11, 2026
Merged

feat: clear retained state when a topic moves or goes away#64
stakach merged 4 commits into
masterfrom
feat/retained-cleanup-wip

Conversation

@stakach

@stakach stakach commented Aug 11, 2026

Copy link
Copy Markdown
Member

Everything source publishes is retained, so a topic holds the current value of a status indefinitely. Nothing cleared those values when a topic stopped being valid, which meant a renamed, reindexed or destroyed Module left its last value sitting at the old path forever — consumers went on reading state for something that no longer publishes there.

54 examples, 0 failures, over repeated full runs against the broker.

What happens now

When a Module is renamed, reindexed or destroyed — or its Driver is destroyed — the keys it currently publishes are collected and queued for removal, and the queue is drained ahead of new messages.

Order matters here. A rename removes the old topic and republishes under the new one; if a publish won that race the deletion would take the new value straight back out again. select makes a non-blocking pass over its branches in order, so listing the delete queue first is what gives deletions priority. A state resync also waits for the queue to drain, so it cannot republish a key that is about to be removed.

Clearing is a zero length retained publish, which is the only thing that actually removes a retained value. A JSON null would simply be retained in its place and consumers would keep seeing a value.

Collection is best effort by design, as agreed. The statuses come from the Module's stored state, the same source resync_state reads, and another service may already have cleared it by the time a delete reaches us. Whatever is still there gets removed; anything already gone was never ours to find.

The keys are collected before the mapping is rewritten, while the old topics can still be resolved. That ordering is the whole trick — afterwards there is no way to know what the old topic was.

InfluxDB gets nothing, deliberately. A time series has no current value to remove, and a renamed Module's history belongs under the tags it was recorded with.

Two bugs found on the way

  • deletes_pending? asked Channel#empty?, which blocks forever on an empty channel rather than answering. Every caller stopped dead, including the resync gate it exists for. Outstanding deletions are counted now.
  • The first attempt drained the queue manually with a non-blocking select. Reordering the main select does the same job without the extra construct.

The first commit's message says the branch does not work. It was fixed in cc96efb; the message is left as it was written rather than rewritten after the fact.

Testing

  • Rename and reindex round trips against the broker, each asserting both ends: the old topic cleared and the value present at the new one. Clearing without republishing loses the state, republishing without clearing leaves a ghost, and a spec checking one end would pass for either failure.
  • Mutation tested — disabling remove_retained_state fails both, on the old topic still holding its value. Worth doing, since two specs earlier in this work passed for the wrong reason.
  • Queue priority and drain reporting are deterministic unit specs rather than broker round trips, so they are fast and cannot flake.
  • MockBrokerManager lets a router's deletions reach a real publisher; shared broker and subscriber helpers moved to spec_helper.

Not covered

  • The best effort destroy path where storage has already been cleared by another service. The code rescues and treats an empty status list as nothing to do, but no spec drives that race.
  • driver_router builds a second Router::Module purely to reuse remove_retained_state. It works, but the method would sit better somewhere neutral.

The branch is still named wip, which it no longer is.

🤖 Generated with Claude Code

stakach and others added 3 commits August 11, 2026 11:18
Not working — the publisher's consume loop hangs. Parked on a branch so
master stays green.

The design, as agreed: on a rename, reindex or destroy, collect the keys
the Module currently publishes and queue them for removal; drain that
queue ahead of new messages, so a rename cannot have its republished value
deleted by a late arriving deletion; and make a state resync wait for the
queue to drain.

What is here and looks right:

- Publisher gains a delete_queue beside message_queue, plus an overridable
  delete. MqttPublisher implements it as a zero length retained publish,
  which is the only thing that actually removes a retained value — a JSON
  null would simply be retained in its place
- InfluxPublisher does nothing for it. A time series has no current value
  to remove, and a renamed Module's history belongs under its old tags
- Module and Driver routers collect the affected keys *before* the mapping
  is rewritten, while the old topics can still be resolved. Statuses come
  from RedisStorage, the same source resync_state reads, so this is best
  effort by design: whatever another service already cleared was never
  ours to find
- StatusEvents#resync_state waits on the queue draining
- 'clears the retained value for a key' passes end to end against the
  broker, so the mechanism itself works

What does not:

- 'drains queued deletions before queued messages' hangs, in the unit spec
  as well as the end to end one, so the fault is in consume_messages
  rather than anything MQTT. Suspect the interaction between the
  non-blocking drain, the three way select and closing both channels in
  stop. Guarding the closed channel in drain_deletion was not enough

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two separate faults, the second hidden behind the first.

The manual non-blocking drain is gone. `select` makes a non-blocking pass
over its branches in order, so simply listing the delete queue first gives
deletions priority when both are ready — no draining needed. Verified:
two deletions and three messages queued messages-first come back
delete, delete, publish, publish, publish.

That alone did not stop the hang, because the hang was not there.
`deletes_pending?` asked `Channel#empty?`, which blocks forever on an
empty channel rather than answering. Every caller stopped dead, including
the resync gate it exists for. Outstanding deletions are now counted, so
asking costs nothing and cannot block.

Found by reproducing the consume loop in a standalone program. The
docker suite takes four minutes a cycle and told me only that something
hung; the reproduction took seconds and named the line.

52 examples, 0 failures, over two consecutive runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the round trip the earlier specs only tested in halves: the old
topic cleared *and* the value present at the new one.

Both the module name and the index are topic segments, so changing either
moves every status the Module publishes. Because everything is retained,
the old topic keeps serving its last value indefinitely unless it is
cleared — a consumer would go on reading state for a path nothing
publishes to any more. Each example asserts both ends, since clearing
without republishing loses the state and republishing without clearing
leaves a ghost.

The statuses come from the Module's stored state, so the specs seed
RedisStorage the way the service reads it.

Verified by mutation: disabling remove_retained_state fails both, on the
old topic still holding its value. The shared broker and subscriber
helpers moved to spec_helper so both spec files can reach them.

54 examples, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Hey, your PR is titled Clear retained state when a topic moves or goes away.
This is not a valid conventional commit.
Since we use well-formed PR titles for our changelogs, not fixing this can introduce problems :'(

Ameba runs in CI but not in ./test, so this slipped through when the
router specs landed on master.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@stakach
stakach force-pushed the feat/retained-cleanup-wip branch from 999fa6e to 9079a3b Compare August 11, 2026 01:24
@stakach stakach changed the title Clear retained state when a topic moves or goes away feat: clear retained state when a topic moves or goes away Aug 11, 2026
@stakach
stakach merged commit 0d5af63 into master Aug 11, 2026
9 of 11 checks passed
@stakach
stakach deleted the feat/retained-cleanup-wip branch August 11, 2026 01:33
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