Skip to content

Watchers: Enforce one active watcher per instrument - #43

Merged
wasimxyz merged 1 commit into
stagingfrom
wa/one-watcher-per-instrument
Apr 28, 2026
Merged

Watchers: Enforce one active watcher per instrument#43
wasimxyz merged 1 commit into
stagingfrom
wa/one-watcher-per-instrument

Conversation

@wasimxyz

@wasimxyz wasimxyz commented Apr 28, 2026

Copy link
Copy Markdown
Member

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 CONFLICT from POST /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

  • DB invariant (web-app/drizzle/0010_enforce_active_watcher_unique.sql, web-app/lib/db/schema.ts): replaced the existing partial non-unique idx_watchers_instrument_id with a partial unique index uq_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.
  • API (web-app/app/api/v1/watchers/register/route.ts): pre-checks for an existing active watcher and returns 409 CONFLICT with error.details.existing_watcher_id (and hostname) so callers can route the operator to the right deregister action.
  • CLI (watcher/src/data_hub_watcher/cli.py): catches the new 409 on data-hub-watcher init and prints a multi-line message with both the web-UI path and the DELETE /api/v1/watchers/<id> API equivalent, including the existing watcher's id.
  • Docs:
    • docs/api.md documents the 409 response on register and the DELETE /api/v1/watchers/:watcherId endpoint.
    • docs/watcher.md notes the 1:1 invariant in the init flow description.
    • docs/guides/installing-a-watcher.md adds an "Instrument already has an active watcher" troubleshooting section covering both the UI and API deregister paths.
  • Tests:
    • watcher/tests/integration/test_watcher_registration.py adds test_register_watcher_active_conflict_409 and test_register_watcher_succeeds_after_deregister.
    • web-app/tests/integration/watchers.test.ts adds 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/register now returns 409 CONFLICT instead of 201 CREATED when the target instrument already has an active watcher. Any external caller that previously assumed it could register a second watcher per instrument must first call DELETE /api/v1/watchers/:watcherId against the existing one. The 409 body includes error.details.existing_watcher_id to make this mechanical.
  • The migration's pre-flight backfill will soft-delete duplicate active watchers in any environment that has them (likely staging/prod). Heartbeats, events, and runs are preserved; the affected rows simply move to the "Deregistered" view.

Driveby changes

None — every file in the diff is in service of the one-watcher-per-instrument invariant.

Testing

  • make check-all passes (lint, format, type-check across web-app and watcher)
  • pnpm --filter web-app test:integration — new watcher 409 / re-register cases pass
  • uv run --package data-hub-watcher pytest watcher/tests/integration/test_watcher_registration.py — new 409 and post-deregister cases pass
  • Manually verify migration 0010 against a staging DB snapshot that contains duplicate active watchers; confirm the unique index is created and only one row per instrument has deleted_at IS NULL
  • Manually run data-hub-watcher init against an instrument that already has an active watcher and confirm the CLI prints the new 409 message with the existing watcher id and deregister instructions
  • Deregister via the web UI, re-run data-hub-watcher init, and confirm registration succeeds with a fresh watcher id
  • Confirm the watchers list UI still surfaces the soft-deleted rows under the "Deregistered" tab and that heartbeats/events for those rows remain accessible

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.
@wasimxyz wasimxyz self-assigned this Apr 28, 2026
@vercel

vercel Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
data-hub Ready Ready Preview, Comment Apr 28, 2026 10:31pm

Request Review

@wasimxyz
wasimxyz merged commit 7306d06 into staging Apr 28, 2026
6 checks passed
@wasimxyz
wasimxyz deleted the wa/one-watcher-per-instrument branch April 28, 2026 22:47
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