Skip to content

aggregator(alerts): getSupabaseClient and getAlertsStore both memoize a transient null forever — the alerts ledger stays unavailable until restart #3732

Closed

Description

@dezren39

aggregator(alerts): getSupabaseClient and getAlertsStore both memoize a transient null forever — the alerts ledger stays "unavailable" until restart

Evidence

Two stacked "resolved once" latches in the alert-throat landing (#3676),
both in pm-stack/aggregator/src/ (main d3225b99):

Layer 1 — supabase.ts:48-68:

let clientPromise: Promise<SupabaseClient | null> | null = null;

export function getSupabaseClient(): Promise<SupabaseClient | null> {
  if (!clientPromise) {
    clientPromise = (async () => {
      const token = await resolveBwsSecret("supabase.access-token-muse-cli");
      if (!token) return null;
      ...
    })().catch(() => null);
  }
  return clientPromise;
}

Layer 2 — alerts.ts:482-489:

let storePromise: Promise<AlertsStore | null> | null = null;

export function getAlertsStore(): Promise<AlertsStore | null> {
  if (!storePromise) {
    storePromise = getSupabaseClient().then((db) => (db ? new AlertsStore(db) : null));
  }
  return storePromise;
}

resolveBwsSecret (src/secrets.ts) fails soft to null on any
transient problem (bws unreachable, token file briefly unreadable, 30s
timeout) — by design, and it caches that null for only 5 minutes so
the failure heals. Neither consumer honors that design: the first
null is memoized in a module-level promise that is never cleared, at
BOTH layers independently. Fixing only the client layer would not heal
the store, whose settled null promise never re-asks the client; and
the store layer is the only path the alerts surface uses, so the whole
alert throat (intake ledger, dedupe, delivery state, ack) reports
itself unavailable for the process lifetime after one transient hiccup
at first use.

This is the same defect shape as #3729 (push.ts
ensurePushConfigured), filed separately because the fix sites and the
affected surface differ: there, push delivery; here, the ledger/store
every alerts route depends on.

Verified (laptop probes, verbatim files)

Probe: ~/git/fleet/probe-2026-11-01-laneB-supalatch/ on the laptop.

  1. supa/ — the repo's real supabase.ts (sha256 7a0969f7…, identical
    on sandbox main d3225b99 and the laptop clone) with its
    ./secrets.ts import stubbed fail-once-then-succeed
    (probe-supabase.log):
1st getSupabaseClient (bws transiently down): null resolveCalls=1
2nd getSupabaseClient (bws recovered):        null resolveCalls=1
RESULT: LATCH CONFIRMED — null client memoized forever, secret never re-read
  1. alerts/ — the repo's real alerts.ts (sha256 fb07caf8…) with
    only its ./supabase.ts and ./push.ts imports stubbed; the stub
    client is unavailable on the first ask and available thereafter
    (probe-alerts.log):
1st getAlertsStore (client unavailable): null clientCalls=1
2nd getAlertsStore (client available):   null clientCalls=1
RESULT: LATCH CONFIRMED — storePromise memoizes the null store; the client is never re-asked

Why it matters

The alerts surface is deliberately fail-soft ("callers fail soft — the
alerts surface reports itself unavailable instead of taking the
dashboard down", supabase.ts header). A transient secret-store failure
around the first alerts request after a restart turns that soft
degradation into a hard, silent, permanent one: sentinels keep posting,
but intake/dedupe/delivery bookkeeping has no store until someone
restarts the aggregator. Nothing distinguishes "no token configured"
from "failed once at startup" in the outcome.

Acceptance criteria

  • A null result is not memoized at either layer: after a
    transient failure, a later getSupabaseClient() / getAlertsStore()
    retries resolution and can return a live client/store without a
    process restart.
  • A successful resolution is still memoized (no per-request bws
    spawns), and concurrent first callers still share one in-flight
    resolution.
  • Regression tests cover fail-then-recover at both layers
    (client: first token null, then a token → null then a client;
    store: first client null, then a client → null then a store).
  • Genuinely unconfigured (no token reachable at all) still fails
    soft: callers get null, no throw, the dashboard stays up.

Distinct from: #3729 (the push.ts VAPID configure latch — same shape,
different module and surface), #3685 (closed; delivery marking),
#3693 (subscriptions GET key leak).

Activity

  1. developing-today commented on Nov 1, 2026

    @developing-today
    Contributor
  2. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/highImportant and time-sensitive
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    app/aggregatorPackage/app: aggregator (pm-stack in this repo)
    effort/sSmall: expected under 2 hours of work
    on Nov 1, 2026
  3. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator (lane B), new-issue-creator-code-b
    • Session / ID: new-issue-creator-code-b run, Sun 2026-11-01 08:20 CST
    • Found via: filed this run
    • Task: Triage the stacked null-memoization latches in pm-stack/aggregator/src/supabase.ts (getSupabaseClient) and src/alerts.ts (getAlertsStore)
    • Expectation: effort/s — stop memoizing null at both layers while keeping success memoized and in-flight sharing; regression tests fail-then-recover at both layers; done when the alerts store recovers without a process restart
    • Links: sibling latch in push.ts filed as aggregator(push): ensurePushConfigured memoizes a failed VAPID configure forever — one transient bws failure disables Web Push for the process lifetime #3729; evidence/probe: laptop ~/git/fleet/probe-2026-11-01-laneB-supalatch/ (verbatim supabase.ts sha256 7a0969f7, alerts.ts sha256 fb07caf8)
  4. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · adopted as Phase 1 of design #3879

  5. 49 remaining items

  6. added
    queue/queuedHas a queued task in the Supabase agent queue
    and removed
    work/assignedQueue worker lifecycle state
    on Nov 4, 2026
  7. dezren39 commented on Nov 4, 2026

    @dezren39
    MemberAuthor

    Triaged: OpenChamber scheduled lane completed task 7407; fix task 9750 is queued.

  8. added
    queue/claimedQueue worker lifecycle state
    work/assignedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 5, 2026
  9. dezren39 commented on Nov 5, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  10. dezren39 commented on Nov 5, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/aggregatorPackage/app: aggregator (pm-stack in this repo)app/pm-stackpm-stack (Pad/Grist) in developing-today/codeeffort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/highImportant and time-sensitivesource/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions