Confirm subscriptions before ready; bound TCP connects#448
Merged
Conversation
CI failed on one or two jobs from time to time, and the same jobs passed on a re-run. I pulled the failure logs from the last 200 runs. They show three separate causes, and this change fixes all three. The pub/sub tests timed out on every real Redis backend, in runs 29753363584, 29753344632, 28542779700, and 28188728646. The cause is a race in the library, not in the tests. `PubSub.subscribe()` only sends the SUBSCRIBE command; it does not wait for the reply from the server. We set the `ready` event immediately, so a publish on another connection could win the race against the subscription, and the subscriber lost those events. The new `confirm_subscriptions()` helper consumes the confirmation messages from the server before it sets the `ready` event. The worker's cancellation listener gets the same guarantee inside its poll loop, so worker shutdown stays responsive. A CLI test hit its 30s timeout in run 28913775528. The stack shows an unbounded TCP connect inside `Worker._remove_heartbeat` during cleanup. Our pools set no `socket_connect_timeout`, so a stalled connect could hang forever. The new `CONNECT_TIMEOUT` bounds connects to 10s on all the pools. Blocking reads stay unbounded, as before. Two tests got fixture errors in run 28071483236, with a sync `redis.exceptions.TimeoutError`. redis-py 8 gives the sync client a 5s read timeout by default, and a `FLUSHALL` on a busy CI container can exceed that. The `sync_redis()` test helper now uses `socket_timeout=30`, and `wait_for_redis()` also retries on `TimeoutError`. I verified the fix with pytest-flakefinder: 80 runs on Redis 7.4, and 40 runs on Redis 6.2 at 0.2 CPU, all green. The core suite and the CLI suite both pass at 100% coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chrisguidry
force-pushed
the
deflake-pubsub-ack
branch
from
July 20, 2026 16:01
06d56c7 to
522271a
Compare
❌ 1 Tests Failed:
View the top 2 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
The re-run sequence for PR #448 surfaced two more flakes in attempt 4 of run 29757754279. `cancel_task()` decided between "our cancellation" and "an external cancellation" by the message on the CancelledError. CPython drops that message when the awaited future completes in the same event-loop tick as the cancellation (python/cpython#91048). In `tests/cli/test_snapshot.py::test_snapshot_with_stats_flag_mixed_tasks`, the worker's cleanup set `session.stopping` and then cancelled the heartbeat task, so the two raced, the message was lost, and the helper re-raised our own cleanup cancellation. That abort also skipped the drain of the active tasks, which leaked two `progress:*` keys and failed the TTL check. The helper now checks `task.cancelled()` on the awaited task, which is reliable on every supported Python version, and the message-based `is_our_cancellation()` helper is gone. `tests/test_execution_state.py::test_full_lifecycle_integration` scheduled a task 50ms in the future and then asserted that its state was SCHEDULED. On a slow runner, more than 50ms passed before the server evaluated the schedule, so the task enqueued immediately as QUEUED. The test now schedules 2 seconds out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Attempt 1 of run 29760321411 failed `test_worker_joining_doesnt_steal_renewed_lease` on the Redis 8 Cluster job: the task ran twice. The test gave the workers a 200ms `redelivery_timeout`, and renewal runs at a quarter of that, so one renewal 150ms late lets a competing worker's XAUTOCLAIM steal the task. CI runners stall longer than that. `test_workers_with_same_redelivery_timeout` already moved to a 1s timeout for the same reason, so this follows that precedent for the two tests where a competing worker can steal: the joining test and the multi-worker test. Their task sleeps grow past the timeout so the tests still prove that renewal prevents the steal. The single-worker tests keep their 200ms timeouts; a self-steal needs the same event loop to stall and resume in a much narrower way, and those tests have not flaked. The suite passes on the cluster backend at 0.2 CPU. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Attempt 2 of run 29760321411 failed `test_running_worker_resows_severed_automatic_perpetual` on Windows: the re-seed never re-ran the task within 5 seconds. The captured log shows why: the test severed the chain with `docket.cancel()`, whose pub/sub signal reached the worker while the first execution was still in its post-completion bookkeeping. The worker logged "Cancelling running task" and the execution never logged its completion line, so the cancel raced state this test asserts on. The test's own comment says it wants "no scheduled entry, nothing in flight", which is the state the fallback drop leaves. It now produces that state directly on the Redis keys: remove the parked entry from the queue, delete the parked data, and clear `known`/`stream_id` from the runs hash. No pub/sub signal, no race with the live worker, and the re-seed loop heals the chain deterministically. The test passes 50 of 50 runs on the memory backend at 0.2 CPU. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
CI failed on one or two jobs from time to time, and the same jobs passed on a re-run. I pulled the failure logs from the last 200 runs. They show three separate causes, and this change fixes all three. The re-run sequence for this PR then surfaced two more flakes, and this change fixes those too — seven in total.
The pub/sub acknowledgment race
The pub/sub tests timed out on every real Redis backend, in runs 29753363584, 29753344632, 28542779700, and 28188728646. The cause is a race in the library, not in the tests.
PubSub.subscribe()only sends the SUBSCRIBE command; it does not wait for the reply from the server. We set thereadyevent immediately, so a publish on another connection could win the race against the subscription, and the subscriber lost those events.The new
confirm_subscriptions()helper consumes the confirmation messages from the server before it sets thereadyevent. Today'slisten()loop already received those confirmation frames and discarded them, so the message flow does not change. The worker's cancellation listener gets the same guarantee inside its 0.1s poll loop, not with a blocking wait, so worker shutdown stays responsive.Unbounded TCP connects
A CLI test hit its 30s timeout in run 28913775528. The stack shows an unbounded TCP connect inside
Worker._remove_heartbeatduring cleanup. Our pools set nosocket_connect_timeout, so a stalled connect could hang forever. The newCONNECT_TIMEOUTbounds connects to 10s on all the pools. Blocking reads stay unbounded, as before.The sync test client's 5s read timeout
Two tests got fixture errors in run 28071483236, with a sync
redis.exceptions.TimeoutError. redis-py 8 gives the sync client a 5s read timeout by default, and aFLUSHALLon a busy CI container can exceed that. Thesync_redis()test helper now usessocket_timeout=30, andwait_for_redis()also retries onTimeoutError.The lost cancel message in
cancel_task()Attempt 4 of run 29757754279 failed
test_snapshot_with_stats_flag_mixed_taskswith a bareCancelledErrorand a TTL leak.cancel_task()decided between "our cancellation" and "an external cancellation" by the message on theCancelledError. CPython drops that message when the awaited future completes in the same event-loop tick as the cancellation (python/cpython#91048). The worker's cleanup setsession.stoppingand then cancelled the heartbeat task, so the two raced, the message was lost, and the helper re-raised our own cleanup cancellation. That abort also skipped the drain of the active tasks, which leaked twoprogress:*keys. The helper now checkstask.cancelled()on the awaited task, which is reliable on every supported Python version.A 50ms margin in the lifecycle test
The same attempt failed
test_full_lifecycle_integration, which scheduled a task 50ms in the future and then asserted that its state was SCHEDULED. On a slow runner, more than 50ms passed before the server evaluated the schedule, so the task enqueued immediately as QUEUED. The test now schedules 2 seconds out.A 200ms lease in the steal tests
Attempt 1 of run 29760321411 failed
test_worker_joining_doesnt_steal_renewed_leaseon the Redis 8 Cluster job: the task ran twice. The test gave the workers a 200msredelivery_timeout, and renewal runs at a quarter of that, so one renewal 150ms late lets a competing worker's XAUTOCLAIM steal the task.test_workers_with_same_redelivery_timeoutalready moved to a 1s timeout for the same reason; the two tests where a competing worker can steal now follow that precedent, with task sleeps that still exceed the timeout so the tests keep their meaning.A cancel() that races the worker in the re-seed test
Attempt 2 of the same run failed
test_running_worker_resows_severed_automatic_perpetualon Windows: the re-seed never re-ran the task within 5 seconds. The test severed the perpetual chain withdocket.cancel(), whose pub/sub signal reached the worker while the first execution was still in its post-completion bookkeeping — the log shows "Cancelling running task" and no completion line. The test now severs the chain directly on the Redis keys, which is the exact state its comment describes ("no scheduled entry, nothing in flight") with no signal racing the live worker.Verification
I verified the pub/sub fix with pytest-flakefinder: 80 runs on Redis 7.4, and 40 runs on Redis 6.2 at 0.2 CPU, all green. The core suite and the CLI suite both pass at 100% coverage. The pub/sub flake fired about weekly, so the real proof is a quiet couple of weeks in CI; I am re-running this PR's CI until it is green five times in a row.
Session context
Session summary: Chris asked me to find the tests that flake in CI and to fix them for good. I searched the last 200 GitHub Actions runs and pulled the failed-step logs from every failed run. Three flakes explained all the one-or-two-job failures; the broad multi-job failures were real bugs on development branches. The dominant flake was the pub/sub subscribe-acknowledgment race. I confirmed in redis-py's source that
PubSub.execute_commandonly sends the command, and in burner-redis's source that it queues confirmation dicts at subscribe time, so one design works on all backends.Major decisions:
subscribe()generators are safe: the wait is one round trip on a dedicated connection, a dead connection raises, and cancellation propagates. But a blocking wait in the worker's cancellation listener could hang worker shutdown, because the worker'sTaskGroupwaits for the listener to seesession.stopping. So the listener keeps its 0.1s poll loop and folds the acknowledgment into it.cancel_task()fix and the lifecycle-test margin fix in the second commit. The count restarted after that commit.loqbaselines for three files grew by the size of the additions, per the project's baseline workflow.🤖 Generated with Claude Code