feat: clear retained state when a topic moves or goes away - #64
Merged
Conversation
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>
|
Hey, your PR is titled |
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
force-pushed
the
feat/retained-cleanup-wip
branch
from
August 11, 2026 01:24
999fa6e to
9079a3b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
selectmakes 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
nullwould 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_statereads, 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?askedChannel#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.select. Reordering the mainselectdoes 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
remove_retained_statefails both, on the old topic still holding its value. Worth doing, since two specs earlier in this work passed for the wrong reason.MockBrokerManagerlets a router's deletions reach a real publisher; shared broker and subscriber helpers moved tospec_helper.Not covered
driver_routerbuilds a secondRouter::Modulepurely to reuseremove_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