Skip to content

Stop removed monitors in parallel outside the loader mutex - #190

Draft
Areson wants to merge 2 commits into
Areson/heartbeat-reader-leakfrom
Areson/parallel-monitor-stop
Draft

Areson wants to merge 2 commits into
Areson/heartbeat-reader-leakfrom
Areson/parallel-monitor-stop

Conversation

@Areson

@Areson Areson commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Stack: PR 4 of 5. Stacked on #189. Review only this PR's commits, a394527 and de3d8d7.

Why

Loader.Load stops removed and changed monitors one at a time while it holds the loader mutex. Monitor.Stop has no deadline. Each stop waits for the level collector's next 1 s tick, 0.5 s on average. So removing many monitors at once holds the mutex for n × 0.5 s, and a monitor that never stops holds it forever. Every reload, start, and stop waits on that mutex.

What

  • Stop removed monitors in parallel, in background goroutines, outside the loader mutex.
  • Wait for them no longer than the new config.monitor-loader.stop-timeout (default 3s).
  • Report a monitor that misses the timeout with event monitor-stop-timeout and status error:stop.
  • Route POST /monitors/start and /monitors/stop through the loader. A stop that misses the timeout returns 202.
  • Fix several start/stop races in Monitor that the new concurrency test found.

How

  • The loader tracks in-flight stops by monitor ID. A monitor with the same ID (changed, re-added, or restarted) is marked start-pending. It starts only after the old one stops, so two monitors with one ID never run at once.
  • A stuck monitor keeps stopping in the background. Its error:stop status clears when it stops.
  • Loader.Stop or Unload on a monitor that never started no longer panics. Loader.Monitor returns nil for an unknown ID instead of panicking.
  • Monitor.Start and Stop are now serialized. Each runLoop gets its own stop and done channels, and Stop waits for runLoop. This fixes three races:
    • Stop right after Start panicked when it closed a nil channel.
    • Stop then Start could leave two runLoops on one monitor. The next Stop then hung forever in wg.Wait.
    • Stop during startup could start subsystems on a closed DB.
  • Retry sleeps return early on stop.
  • The plan-loading retry loop no longer returns success on error.

Risk

Medium to high. This changes the monitor lifecycle and the loader, which every Blip deployment uses. The API behavior of /monitors/start and /monitors/stop also changes: a start can now be pending, and a slow stop returns 202.

Testing

A local benchmark ran 400 monitors in one process against 4 MySQL 8.0 servers.

  • An unload of all 400 monitors on the kill path finished in 1.0 s. Before, it couldn't finish within the caller's 5 s.

Bigger picture

Part of a stack of 5 fixes found by the same benchmark. Each stop still waits for the level collector's tick; #191 removes that wait.

🤖 Generated with Claude Code

@Areson
Areson added this pull request to stack #192 October 21, 2026 21:52
@Areson
Areson force-pushed the Areson/parallel-monitor-stop branch from d6a23e0 to 8fd762f Compare October 21, 2026 22:43
@Areson
Areson force-pushed the Areson/parallel-monitor-stop branch from 8fd762f to 43ef39e Compare October 22, 2026 14:54
@Areson
Areson force-pushed the Areson/parallel-monitor-stop branch from 43ef39e to e1eb533 Compare October 22, 2026 15:51
@Areson
Areson force-pushed the Areson/parallel-monitor-stop branch 2 times, most recently from ee4db51 to f0e1912 Compare October 22, 2026 17:28
Ian Oberst and others added 2 commits October 22, 2026 12:13
Load unloaded removed and changed monitors one at a time while holding the
loader mutex, and Monitor.Stop has no deadline. Each stop waits for the
level collector's next 1s tick (about 0.5s on average), so removing many
monitors at once held the mutex for n x 0.5s, and a monitor that never
stops would hold it forever. Every reload, start, and stop waits on that
mutex.

Loader:
- Take unloaded and stopped monitors out of the loader state under the
  mutex, then stop them in background goroutines, in parallel.
- Load, Stop, and Unload wait for those stops without the mutex, but no
  longer than config.monitor-loader.stop-timeout (default 3s).
- A monitor that misses the timeout is reported with event
  monitor-stop-timeout and monitor status error:stop, and keeps stopping
  in the background. Its status is cleared when it stops.
- Track in-flight stops by monitor ID. A monitor with the same ID (a
  changed or re-added monitor, or a restart) is marked start-pending and
  starts only when the old one has stopped, so two monitors with the same
  ID never run at the same time.
- Don't call Monitor.Stop on a monitor that never started (it panicked).
- Monitor returns nil for an unknown ID instead of panicking.

API: POST /monitors/start and /monitors/stop go through the loader instead
of calling Monitor.Start and Monitor.Stop directly, so they can't restart
a monitor the loader is stopping or unloading. A stop that misses the
timeout returns 202.

Monitor: fix start/stop races found by the new concurrency test:
- Stop right after Start, before runLoop made runChan, panicked closing a
  nil channel.
- Stop didn't wait for runLoop to return, and runLoop read the
  runLoopChan field, so Stop then Start could leave two runLoops running
  one monitor. The next Stop then blocked forever in wg.Wait.
- Stop during startup could leave subsystems started on a closed DB.
Start and Stop are now serialized, each runLoop gets its own stop and done
channels, Stop waits for runLoop, runLoop stops its last subsystems before
returning, and retry sleeps return early on stop. The plan-loading retry
loop no longer returns success on error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A changed monitor whose old instance is still stopping starts when the
old instance stops, right after the loader removes the old monitor's
status and counters. Test that the new monitor's retry sink counter is
reported and incremented once it starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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