Operator error alerting (#7) - #29
Merged
Merged
Conversation
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>
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.
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
internal/alert.Reporter(no-op by default), anEmailerthat 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.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.s problem and stays silent; a crash, timeout, missing or invalidresult.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.executeand in the delivery and sweep loops; each reports and carries on.filemill alert-testsends one test alert, to prove the channel before relying on it.Fixes found along the way
t 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.store.Openmarked every running jobinterrupted, sosubmitorjobs getrun while the worker was mid-job made the delivery loop send a premature reply. Only the starting continuous worker does that now.Verification
go build,go vetandgo 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
restartemail 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
alert_recipientis set in the gitignoredconfig/email.yaml;config/email.yaml.exampledocuments it and the throttle settings.run --oncereintroducing the interrupt hazard. A machine running an earlier build of this branch should be rebuilt.🤖 Generated with Claude Code