Skip to content

Operator error alerting (#7) - #29

Merged
brocla merged 25 commits into
mainfrom
alerting-plan
Sep 11, 2026
Merged

Operator error alerting (#7)#29
brocla merged 25 commits into
mainfrom
alerting-plan

Conversation

@brocla

@brocla brocla commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Implements issue #7: FileMill emails the operator when it fails systemically, so an unattended worker no longer fails silently. Built in the five phases of ERROR-ALERTING-PLAN.md, which is updated here to match what was actually built.

What it does

  • New leaf package internal/alert. Reporter (no-op by default), an Emailer that queues alerts and drains them on its own goroutine, and a throttle: 15 minutes per category, 10 an hour, 20 a day over a rolling 24h. The daily cap is sized for the Mailgun Free plan, whose 100 daily sends are shared with replies. The send that fills the cap says so.
  • Throttle state lives in SQLite (alert_state, alert_sends), so a crash-looping worker can`t spend the whole budget. If the database fails, the throttle falls back to an in-memory copy rather than going silent, since a sick database is exactly what is worth alerting about.
  • Alerts on systemic failures only. A transformer that rejects its input through the contract is the senders problem and stays silent; a crash, timeout, missing or invalid result.json, or a result contradicting its exit code is ours. Also: intake 500s, a Mailgun route that stores instead of forwarding, replies or Drive publishes failing for 5 minutes, a reply that cant be marked delivered, orphaned Drive files, failing job claims, both retention sweeps, and a restart after a crash.
  • Crashes are reported by the next process. The supervisor passes the previous exit code and rapid-restart count to each relaunch, folded together with the count of jobs left running.
  • Panic recovery in execute and in the delivery and sweep loops; each reports and carries on.
  • filemill alert-test sends one test alert, to prove the channel before relying on it.

Fixes found along the way

  • Reply resend storm (refs Reliability: at-least-once delivery can send a duplicate reply (#6) #6): when a reply was sent but couldnt be marked delivered, it was re-sent every second, which would flood the sender and spend the days Mailgun budget in under two minutes. Only the mark is retried now. Reliability: at-least-once delivery can send a duplicate reply (#6) #6`s remaining crash window is documented and accepted.
  • CLI commands could interrupt a live job. store.Open marked every running job interrupted, so submit or jobs get run while the worker was mid-job made the delivery loop send a premature reply. Only the starting continuous worker does that now.
  • Two misleading sender-facing messages: a result claiming success with a nonzero exit no longer reports the transformer`s success text, and an interrupted job now asks the sender to send the file again instead of leaving the line blank.

Verification

go build, go vet and go test ./... pass. Tests are isolation tests with fakes: the throttle tests use a fake clock, mailer and ledger, so they neither sleep nor touch SQLite or the network; execute's tests run the test binary itself as the transformer.

Verified in production against a build of this branch: a test send, one restart email from a killed worker with four further restarts suppressed, and 21 probe jobs producing 2 emails and 19 suppressions, the second email carrying the count held back. The bad-Mailgun-domain check was skipped deliberately: it delays real senders` replies, and that path is covered by tests and by the alerts above.

Notes

  • Alerting is off until alert_recipient is set in the gitignored config/email.yaml; config/email.yaml.example documents it and the throttle settings.
  • The last four commits fix correctness issues found in a review after that production run, including run --once reintroducing the interrupt hazard. A machine running an earlier build of this branch should be rebuilt.

🤖 Generated with Claude Code

Kevin Brown and others added 25 commits September 11, 2026 04:59
The July draft predates the supervisor, boot start, both retention sweeps,
sheets-link delivery and non-blocking delivery. The revision adds their
alert sites, persists throttle state so a crash-loop can't bypass it, and
reports crashes from the restarted process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The account is on Mailgun's Free plan: 100 sends a day, shared with
replies, hard-rejected past the limit. The hourly cap alone would allow
240 alerts a day, enough to lock out every reply to senders. A persisted
rolling 24h cap of 20 keeps alerts to a fifth of the daily budget, even
across a crash-loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 of the error-alerting plan (#7). Emailer is a Reporter that queues
alerts on a buffered channel drained by Run, and throttles them before they
reach a Mailer: a 15-minute cooldown per category, 10 sends an hour and 20 in
a rolling 24h across categories. The 20th send in the window says the daily
cap was reached. Suppressed alerts are logged and counted into the next email
in their category.

Throttle state lives behind a Ledger so it survives a worker restart. Nothing
is wired in yet, so there is no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
store.Open creates alert_state and alert_sends, and Store implements
alert.Ledger over them. alert_sends is pruned to 24h on each write. Times use
a fixed-width format so the window comparisons, done as strings in SQL, order
correctly at sub-second precision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LastAlert drops its redundant ok flag, AlertSendsSince returns send times
instead of a count, and alert_state.last_sent_at is nullable so a category
capped before its first send can still hold a suppressed count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sick database is the likeliest cause of the failures worth alerting on
(worker-claim, intake), so dropping alerts when the Ledger failed silenced
exactly those. The Emailer now keeps an in-memory copy of the throttle state,
synced from the Ledger on each good read, and after the first Ledger error
throttles from it for the rest of the process. It never switches back: a
Ledger whose writes failed would read back too few sends. Each email sent in
this mode says the throttle's state failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2 of the error-alerting plan (#7). execute now reports job-systemic for
any failure the transformer didn't make through the contract: a timeout, a
crash, a missing or invalid result.json, a command that won't start, a
transformer no longer configured, or success:true with a nonzero exit. A valid
success:false stays silent whatever the exit code, and so does a transformer
killed because the worker itself is shutting down, which would otherwise read
as a crash on every restart that lands mid-job. Job statuses and messages are
unchanged.

A recover around each job fails it and reports panic with the stack, and Run
reports worker-claim once claims have failed for a minute, once per outage.
App.SetReporter takes the reporter; it defaults to alert.Nop, and nothing
calls it yet, so there is no behavior change.

The tests run the test binary itself as the transformer, so they need no
Python or scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A valid result.json with success:true and a nonzero exit is failed as
systemic, but the job kept the transformer's own message. The reply then told
the sender their report was ready ("Report ready") with nothing attached, and
the alert's subject read "job failed: Report ready". The job's message now
says what happened; the transformer's text moves to the alert's detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When Mailgun accepted a reply but MarkEmailDelivered failed, the submission
stayed pending and the delivery loop sent the reply again on every 1-second
tick. That floods the sender with duplicates and spends the Mailgun Free
plan's 100 sends a day in under two minutes. Service now remembers such a
submission in memory, and later ticks retry the mark alone. The memory dies
with the process, so each restart before the mark succeeds sends one
duplicate.

The remaining at-least-once window from #6 (a crash between send and mark, or
a send that timed out on our side after Mailgun accepted it) is accepted and
documented on deliver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…plan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 3 of the error-alerting plan (#7), app side. The job sweep reports
sweep-jobs once per sweep when workspaces past retention can't be deleted,
naming each job. App hands the alert Ledger calls to its store, as it does
for the other store methods, so main can give the Emailer the App.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 3 of the error-alerting plan (#7), Mailgun side.
- send takes its subject verbatim; deliver adds "Re:". SendAlert sends a plain
  message from REPLY_FROM with no threading or attachments, and satisfies
  alert.Mailer.
- Delivery errors carry their alert category. A submission reports delivery or
  publish once it has failed for 5 minutes, delivery-mark at once, and each
  only once per outage. Every orphaned Drive file reports publish-orphan with
  its file ID.
- The webhook reports intake on a 500 and route-config on the store(notify=)
  warning. The Drive sweep reports sweep-drive for files it can't delete.
- email.yaml gains alert_recipient and the throttle settings. A Service with
  no reporter stays silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In continuous mode with Mailgun configured and alert_recipient set, run
builds an alert Emailer over the Mailgun service and the App's ledger and
sets it as the reporter on both. It runs on its own context, stopped after
the job loop and webhook server have finished, so an alert raised during
shutdown still goes out. Without alert_recipient, or in --once mode, nothing
changes. email.yaml.example documents the new keys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sends one test alert to alert_recipient through the Mailgun service, so the
channel and its spam placement are checked before anything relies on it. It
bypasses the throttle, since the operator asked for exactly this message, but
is recorded in the ledger because it spends one of the day's Mailgun sends,
which keeps the daily cap exact. A ledger failure doesn't stop the send.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 of the error-alerting plan (#7). Each pass of the delivery loop, the
Drive retention sweep and the job sweep now recovers a panic, logs it and
reports panic with the stack, and the loop carries on. Before, a panic in any
of them ended the process, or with the supervisor restarting it, a crash-loop
if the panic repeats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 of the error-alerting plan (#7). A crashed process can't report its
own death, so the supervisor now passes FILEMILL_PREVIOUS_EXIT and
FILEMILL_RAPID_RESTARTS to each relaunch, and run reports one restart alert
from them, marked as a crash-loop at 4 rapid restarts. It folds in the jobs
the last worker left running, which also reveal an unclean stop with no
supervisor at all.

store.Open no longer marks running jobs interrupted. Every CLI command opens
the database, and submit, jobs get or alert-test run while the worker was
mid-job marked its live job interrupted, which the delivery loop treats as
finished. The starting worker does it instead, once it holds the webhook
port and just before the job loop. An interrupted job's message now asks the
sender to send the file again; its reply line used to be empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…plan

Verified against a branch build (v0.2.1-20-g0aa8f96) running in production:
one restart email from a killed worker with four further restarts suppressed,
and 21 alert_probe jobs producing 2 emails and 19 suppressions, the second
email carrying the count held back. The bad-Mailgun-domain check is skipped
deliberately: it delays real replies and the send path is covered elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The call sat outside the `if !once` block, so `filemill run --once` — the
README quickstart command — marked a live worker`s running job interrupted.
The delivery loop treats interrupted as terminal, so the sender would get a
premature reply with outputs missing. That is the hazard this branch moved
out of store.Open, one layer up. Only the continuous worker does it, which by
then holds the webhook port; --once binds nothing and cannot tell whether the
real worker is mid-job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RecordAlertSent zeroed the count before the send was attempted, so a failed
send threw away the backlog it was meant to report and no later email ever
mentioned it. Recording the send still happens first, to keep the daily cap
exact, but clearing the count is now a separate Ledger call made only after
an email actually goes out. A failed send instead adds itself to the backlog,
since nobody saw it either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iles

Two fixes in the delivery loop`s reporting:
- The per-outage reported flag was a bool, so once a submission had reported
  publish or delivery, a later mark failure was silently swallowed. That is
  the one category with no grace period, because every restart before it
  heals sends the sender another duplicate. The flag now holds the category
  that was reported, and a different one reports on its own.
- Orphan alerts name their Drive file id in the summary. Only the first
  orphan per cooldown is emailed, and the rest are logged by summary alone,
  so the id has to be there: it is all anyone has to delete the file by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brocla
brocla merged commit b517115 into main Sep 11, 2026
1 check passed
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