fix(email): make waiting rows read-only, and time the grace from when due
Codex review round 3 on #1273. The first finding reverses a round-1 fix of mine, correctly. Retry no longer sends. Round 1 flagged that retry was a no-op for waiting rows and offered two remedies: give them a send-now action, or stop showing them Retry. I took the first, and round 3 showed why it is the wrong half -- processEmailQueue claims nothing before invoking the transport, so a flush overlapping the scheduled pass has both of them sending the same email. Saving 60 seconds is not worth a duplicate landing in a customer's inbox, and a claim protocol would need a status no query watches plus a reaper for rows abandoned mid-send. So retry is a reset again, as it was on main. Waiting rows now carry no actions at all, which is the other half of that round-1 remedy and closes a worse hole the shared table opened: Dismiss DELETEs the queue row. Those emails have not failed and still go out once the processor recovers, so clicking the tidy-up icon on a health warning silently cancelled a customer's mail. The section is diagnostic; what a waiting row needs is the processor fixed, which the panel above it now says. The grace window runs from when a row became DUE, not from when it was queued. A split-payment invoice created three days ago and scheduled until a minute ago has had one minute of the processor's attention, and measuring from created_at reported every scheduled mail as unworked the instant it came due -- which is most of what this panel would then have been showing. A truncated scan can no longer read as an all-clear. The scan is bounded, so a queue larger than the budget whose head is all future-scheduled can hide a due row past the last page read; the response now says so and the UI withholds the green check. The test fixtures were wrong in a way worth keeping: scheduled_at also defaults to CURRENT_TIMESTAMP, so back-dating created_at alone built rows that cannot exist in production -- old, but scheduled for the moment the fixture ran. The helper now back-dates both, as the database would have. 3 more tests; the two that pin new behaviour fail before this commit, and the reverted flush is pinned by asserting the transport is NOT invoked.
This commit is contained in:
@@ -120,7 +120,8 @@
|
||||
"col": {
|
||||
"attempts": "Versuche"
|
||||
},
|
||||
"emptyButUnworked": "Noch ist nichts überfällig, es sendet aber auch nichts — siehe Prozessor oben. Alles ab jetzt Eingereihte bleibt hier liegen."
|
||||
"emptyButUnworked": "Noch ist nichts überfällig, es sendet aber auch nichts — siehe Prozessor oben. Alles ab jetzt Eingereihte bleibt hier liegen.",
|
||||
"truncated": "Die Warteschlange ist zu groß, um sie vollständig zu prüfen — in den gelesenen Zeilen war nichts überfällig, das ist aber keine Entwarnung."
|
||||
},
|
||||
"processor": {
|
||||
"title": "E-Mail-Warteschlangen-Prozessor",
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
"col": {
|
||||
"attempts": "Attempts"
|
||||
},
|
||||
"emptyButUnworked": "Nothing is overdue yet, but nothing is sending either — see the processor above. Anything queued from now on will sit here."
|
||||
"emptyButUnworked": "Nothing is overdue yet, but nothing is sending either — see the processor above. Anything queued from now on will sit here.",
|
||||
"truncated": "The pending queue is too large to check in full — nothing overdue was found in the rows read, but this is not an all-clear."
|
||||
},
|
||||
"processor": {
|
||||
"title": "Email queue processor",
|
||||
|
||||
@@ -44,6 +44,10 @@ export const SystemHealthPage: React.FC = () => {
|
||||
const stuckEmails = data?.stuckEmails ?? [];
|
||||
const waitingEmails = data?.waitingEmails ?? [];
|
||||
const processor = data?.processor;
|
||||
// The endpoint stops reading after a bounded number of pending rows. Past
|
||||
// that, an empty waiting list means "nothing found yet", not "nothing" — so
|
||||
// it must not turn into a green check.
|
||||
const scanTruncated = data?.scanTruncated ?? false;
|
||||
|
||||
// The processor is only "fine" when it has been started AND its last pass
|
||||
// didn't bail. A started-but-erroring processor is the case that used to
|
||||
@@ -56,7 +60,14 @@ export const SystemHealthPage: React.FC = () => {
|
||||
? 'degraded'
|
||||
: 'ok';
|
||||
|
||||
const emailTable = (rows: StuckEmail[], showError: boolean) => (
|
||||
/**
|
||||
* `actions` is off for waiting rows, and deliberately so. Retry would
|
||||
* rewrite a row that is already pending / retry_count 0 / unscheduled to the
|
||||
* state it is in, and Dismiss would permanently delete an email that has not
|
||||
* failed and will still go out once the processor recovers — a click on a
|
||||
* health warning silently cancelling a customer's mail.
|
||||
*/
|
||||
const emailTable = (rows: StuckEmail[], showError: boolean, actions = true) => (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -70,7 +81,9 @@ export const SystemHealthPage: React.FC = () => {
|
||||
: t('systemHealth.waitingEmails.col.attempts', 'Attempts')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.queued', 'Queued')}</th>
|
||||
<th className="px-3 py-2 text-right">{t('systemHealth.stuckEmails.col.actions', 'Actions')}</th>
|
||||
{actions && (
|
||||
<th className="px-3 py-2 text-right">{t('systemHealth.stuckEmails.col.actions', 'Actions')}</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -95,22 +108,24 @@ export const SystemHealthPage: React.FC = () => {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="outline" size="sm"
|
||||
isLoading={retryMutation.isPending && retryMutation.variables === m.id}
|
||||
onClick={() => retryMutation.mutate(m.id)}
|
||||
leftIcon={<RefreshCw className="w-3.5 h-3.5" />}>
|
||||
{t('systemHealth.retry', 'Retry')}
|
||||
</Button>
|
||||
<button type="button"
|
||||
aria-label={t('systemHealth.dismiss', 'Dismiss') as string}
|
||||
onClick={() => dismissMutation.mutate(m.id)}
|
||||
className="p-1.5 text-neutral-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
{actions && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="outline" size="sm"
|
||||
isLoading={retryMutation.isPending && retryMutation.variables === m.id}
|
||||
onClick={() => retryMutation.mutate(m.id)}
|
||||
leftIcon={<RefreshCw className="w-3.5 h-3.5" />}>
|
||||
{t('systemHealth.retry', 'Retry')}
|
||||
</Button>
|
||||
<button type="button"
|
||||
aria-label={t('systemHealth.dismiss', 'Dismiss') as string}
|
||||
onClick={() => dismissMutation.mutate(m.id)}
|
||||
className="p-1.5 text-neutral-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -190,7 +205,7 @@ export const SystemHealthPage: React.FC = () => {
|
||||
// queue. A processor that stopped a minute ago has no waiting rows
|
||||
// yet either — the grace window has not elapsed — and a green check
|
||||
// there is the same false all-clear this page exists to remove.
|
||||
processorState === 'ok' ? (
|
||||
processorState === 'ok' && !scanTruncated ? (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400 py-6">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
{t('systemHealth.waitingEmails.empty', 'Nothing waiting — the queue is being worked.')}
|
||||
@@ -198,8 +213,11 @@ export const SystemHealthPage: React.FC = () => {
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-400 py-6">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
{t('systemHealth.waitingEmails.emptyButUnworked',
|
||||
'Nothing is overdue yet, but nothing is sending either — see the processor above. Anything queued from now on will sit here.')}
|
||||
{scanTruncated
|
||||
? t('systemHealth.waitingEmails.truncated',
|
||||
'The pending queue is too large to check in full — nothing overdue was found in the rows read, but this is not an all-clear.')
|
||||
: t('systemHealth.waitingEmails.emptyButUnworked',
|
||||
'Nothing is overdue yet, but nothing is sending either — see the processor above. Anything queued from now on will sit here.')}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -208,7 +226,7 @@ export const SystemHealthPage: React.FC = () => {
|
||||
{t('systemHealth.waitingEmails.description',
|
||||
'Queued more than 10 minutes ago, due now, and still unsent. These have not failed — nothing has tried to send them.')}
|
||||
</p>
|
||||
{emailTable(waitingEmails, false)}
|
||||
{emailTable(waitingEmails, false, false)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -231,7 +249,7 @@ export const SystemHealthPage: React.FC = () => {
|
||||
allowed when the whole queue is clear. With mail waiting or a
|
||||
processor that is not working, this section is still empty but
|
||||
the system is not fine. */}
|
||||
{waitingEmails.length === 0 && processorState === 'ok'
|
||||
{waitingEmails.length === 0 && processorState === 'ok' && !scanTruncated
|
||||
? t('systemHealth.stuckEmails.empty', 'No stuck or failed emails — all clear.')
|
||||
: t('systemHealth.stuckEmails.emptyNotAllClear', 'Nothing has failed — but see above.')}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,10 @@ export interface SystemHealthFailures {
|
||||
/** Due, under the retry cap, and still unsent — nobody picked them up. */
|
||||
waitingEmails: StuckEmail[];
|
||||
processor: EmailProcessorStatus;
|
||||
counts: { stuckEmails: number; waitingEmails: number };
|
||||
/** The pending queue was larger than the endpoint reads — an empty
|
||||
* `waitingEmails` then means "nothing found yet", not "nothing". */
|
||||
scanTruncated?: boolean;
|
||||
counts: { stuckEmails: number; waitingEmails: number; pendingScanned?: number };
|
||||
}
|
||||
|
||||
export const systemHealthService = {
|
||||
|
||||
Reference in New Issue
Block a user