Watchers: Enforce one active watcher per instrument - #43
Merged
Conversation
Adds a partial unique index on `watchers (instrument_id) WHERE deleted_at IS NULL` and rejects re-registrations for instruments that already have an active watcher with a 409 Conflict, returning the existing `watcher_id` in `error.details` so the CLI can point operators at the deregister flow. Existing soft-deleted (deregistered) rows and their heartbeats, events, and instrument_runs FKs are preserved — the Deregistered tab is unchanged. The drizzle migration soft-deletes any pre-existing duplicate active rows (keeping the most recent) so the unique index can be created cleanly.
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
Summary
Each instrument can now have at most one active (non-deregistered) watcher at a time. The invariant is enforced at the database level via a partial unique index, surfaced as a friendly
409 CONFLICTfromPOST /api/v1/watchers/register, and translated by the CLI into an actionable error that points operators at the deregister flow. Heartbeats, events, and runs from previously-registered watchers remain visible after deregistration so the audit trail is preserved.Changes
web-app/drizzle/0010_enforce_active_watcher_unique.sql,web-app/lib/db/schema.ts): replaced the existing partial non-uniqueidx_watchers_instrument_idwith a partial unique indexuq_watchers_active_instrument_id ON watchers (instrument_id) WHERE deleted_at IS NULL. The migration backfills any existing duplicates by keeping the most recently created active row per instrument and soft-deleting the rest.web-app/app/api/v1/watchers/register/route.ts): pre-checks for an existing active watcher and returns409 CONFLICTwitherror.details.existing_watcher_id(andhostname) so callers can route the operator to the right deregister action.watcher/src/data_hub_watcher/cli.py): catches the new 409 ondata-hub-watcher initand prints a multi-line message with both the web-UI path and theDELETE /api/v1/watchers/<id>API equivalent, including the existing watcher's id.docs/api.mddocuments the 409 response onregisterand theDELETE /api/v1/watchers/:watcherIdendpoint.docs/watcher.mdnotes the 1:1 invariant in theinitflow description.docs/guides/installing-a-watcher.mdadds an "Instrument already has an active watcher" troubleshooting section covering both the UI and API deregister paths.watcher/tests/integration/test_watcher_registration.pyaddstest_register_watcher_active_conflict_409andtest_register_watcher_succeeds_after_deregister.web-app/tests/integration/watchers.test.tsadds vitest cases for the 409 on duplicate registration, registration succeeding for a different instrument, and re-registration succeeding after the prior watcher is deregistered.Breaking changes
POST /api/v1/watchers/registernow returns409 CONFLICTinstead of201 CREATEDwhen the target instrument already has an active watcher. Any external caller that previously assumed it could register a second watcher per instrument must first callDELETE /api/v1/watchers/:watcherIdagainst the existing one. The 409 body includeserror.details.existing_watcher_idto make this mechanical.Driveby changes
None — every file in the diff is in service of the one-watcher-per-instrument invariant.
Testing
make check-allpasses (lint, format, type-check across web-app and watcher)pnpm --filter web-app test:integration— new watcher 409 / re-register cases passuv run --package data-hub-watcher pytest watcher/tests/integration/test_watcher_registration.py— new 409 and post-deregister cases pass0010against a staging DB snapshot that contains duplicate active watchers; confirm the unique index is created and only one row per instrument hasdeleted_at IS NULLdata-hub-watcher initagainst an instrument that already has an active watcher and confirm the CLI prints the new 409 message with the existing watcher id and deregister instructionsdata-hub-watcher init, and confirm registration succeeds with a fresh watcher id