From e063fb66d4a764835e1197b4536df0d138b5a81c Mon Sep 17 00:00:00 2001 From: Angel Beltran Date: Wed, 5 Aug 2026 19:18:04 -0600 Subject: [PATCH] amazon: add a Ready() signal for ProcessEvents' LISTEN registration Tests (and any other caller) previously had no way to know when the Postgres LISTEN behind the reactive event-processing loop had actually registered, forcing a guessed sleep before relying on it. Mocks now exposes Ready() <-chan struct{}, closed once listenForNotifications successfully issues LISTEN. Purely additive - ProcessEvents' signature is unchanged. Updates the integration tests to wait on Ready() instead of a flat sleep, which also cut TestProcessEvents_ReactsToNotification's runtime from ~0.25s to ~0.06s with no flakes across repeated runs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY --- domains/amazon/mock.go | 19 +++++++++++++-- domains/amazon/mock_test.go | 19 +++++++-------- .../work-summary-Claude-2026-08-05-1913.md | 23 +++++++++++++++++++ 3 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 work-summaries/work-summary-Claude-2026-08-05-1913.md diff --git a/domains/amazon/mock.go b/domains/amazon/mock.go index 24260db..5d47ad2 100644 --- a/domains/amazon/mock.go +++ b/domains/amazon/mock.go @@ -7,6 +7,7 @@ import ( "ruben/inventory2/domains/accounts" "ruben/inventory2/domains/raw_events" "ruben/inventory2/logging" + "sync" "time" "github.com/jackc/pgx/v5" @@ -18,6 +19,9 @@ type ( log *logging.Logger db *pgxpool.Pool listener MockEventListener + + ready chan struct{} + readyOnce sync.Once } MockEventListener interface { @@ -31,8 +35,9 @@ const ( func NewMocks(log *logging.Logger, db *pgxpool.Pool) *Mocks { return &Mocks{ - log: log, - db: db, + log: log, + db: db, + ready: make(chan struct{}), } } @@ -41,6 +46,14 @@ func (m *Mocks) SetListener(l MockEventListener) *Mocks { return m } +// Ready returns a channel that's closed once ProcessEvents has registered +// its Postgres LISTEN and is actively watching for notifications. Callers +// that need to know the reactive path is live - tests in particular - +// should wait on this instead of guessing with a sleep. +func (m *Mocks) Ready() <-chan struct{} { + return m.ready +} + func (m *Mocks) ProcessEvents(ctx context.Context) error { notifCh, errCh := m.listenForNotifications(ctx) @@ -83,6 +96,8 @@ func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <- return nil, errCh } + m.readyOnce.Do(func() { close(m.ready) }) + ch := make(chan struct{}) go func() (err error) { diff --git a/domains/amazon/mock_test.go b/domains/amazon/mock_test.go index 3fcf80e..87ab0aa 100644 --- a/domains/amazon/mock_test.go +++ b/domains/amazon/mock_test.go @@ -184,14 +184,11 @@ func TestProcessEvents_ReactsToNotification(t *testing.T) { errCh <- m.ProcessEvents(ctx) }() - // ProcessEvents registers its Postgres LISTEN synchronously before - // entering its main loop, but that registration still races against - // this goroutine actually getting scheduled - the production code - // exposes no "listening now" signal a caller (or a test) can wait - // on. A short sleep is the only lever available from outside; see - // the accompanying report for why that's a real testability/design - // gap, not just a test-flakiness workaround. - time.Sleep(200 * time.Millisecond) + select { + case <-m.Ready(): + case <-time.After(5 * time.Second): + t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s") + } insertRawAmazonEvent(t, pool, shopID, "evt-1") @@ -234,7 +231,11 @@ func TestProcessEvents_ShutsDownOnContextCancel(t *testing.T) { errCh <- m.ProcessEvents(ctx) }() - time.Sleep(50 * time.Millisecond) // let it reach its first select + select { + case <-m.Ready(): + case <-time.After(5 * time.Second): + t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s") + } cancel() select { diff --git a/work-summaries/work-summary-Claude-2026-08-05-1913.md b/work-summaries/work-summary-Claude-2026-08-05-1913.md new file mode 100644 index 0000000..683e0d3 --- /dev/null +++ b/work-summaries/work-summary-Claude-2026-08-05-1913.md @@ -0,0 +1,23 @@ +# Work Summary — 2026-08-05 19:13 + +## Task +First of the four `domains/amazon` design insights, addressed one at a time per user request: no "now listening" readiness signal from `(*Mocks).ProcessEvents`. + +## Change +`domains/amazon/mock.go`: +- `Mocks` gained `ready chan struct{}` (initialized in `NewMocks`) and a `readyOnce sync.Once` guard. +- New exported method `Ready() <-chan struct{}` - closed once `listenForNotifications` successfully issues `LISTEN` on Postgres, i.e. the moment the reactive path is actually live. +- `listenForNotifications` calls `m.readyOnce.Do(func() { close(m.ready) })` right after the `LISTEN` exec succeeds (and before spawning the `WaitForNotification` goroutine). +- `ProcessEvents(ctx) error`'s signature is unchanged - this is purely additive, so `main.go`'s existing call site needed no changes. + +`domains/amazon/mock_test.go`: +- `TestProcessEvents_ReactsToNotification` and `TestProcessEvents_ShutsDownOnContextCancel` now `select` on `m.Ready()` (bounded by a 5s timeout as a safety net) instead of a flat `time.Sleep(200 * time.Millisecond)` / `time.Sleep(50 * time.Millisecond)` before proceeding. + +## Verification +- `go build ./...` / `go vet ./...` clean. +- `go test ./domains/amazon/... -v -race`: all 4 pass. +- `TestProcessEvents_ReactsToNotification` dropped from ~0.25s to ~0.06-0.07s per run (no longer paying for an arbitrary sleep) - and 10 consecutive runs (`-count=1` each) were all clean, no flakes. +- `make test`: full suite still green, no regressions. + +## Notes +This closes insight #1 from `work-summaries/work-summary-Claude-2026-08-04-2318.md`. The other three (fire-and-forget listener notifications, hardcoded poll interval, LISTEN-connection errors being fatal to the whole app) are still open, to be addressed one at a time per the user's request - not done in this pass.