-
Notifications
You must be signed in to change notification settings - Fork 1k
feat(labeler): prolonged-error publisher notice via reconciliation cron (W10.5 follow-up) #2088
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ascorbic
merged 2 commits into
feat/plugin-registry-labelling-service
from
feat/labeler-prolonged-error
Jul 17, 2026
+926
−4
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
36 changes: 36 additions & 0 deletions
36
apps/labeler/migrations/0010_assessment_error_escalations.sql
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| -- Prolonged-error escalation tracking (plan W10.5 follow-up, ratified | ||
| -- 2026-07-17). Drives the two-stage escalation ladder the reconciliation cron | ||
| -- applies to an assessment stuck in the terminal `error` state (the labeler | ||
| -- failed to assess, transient retries exhausted) that no newer run has | ||
| -- superseded: at 24h an operator alert (an `operational_events` row) so | ||
| -- operators can triage an infra-vs-publisher cause, then at 72h the publisher | ||
| -- notice if the error is still the live run. | ||
| -- | ||
| -- One row per escalated assessment makes each stage fire-once across the | ||
| -- 5-minute cron ticks: the cron upserts the row, raises the operator alert once | ||
| -- when `operator_alerted_at_epoch_ms` is null, and sends the publisher notice | ||
| -- once past 72h when `publisher_notified_at_epoch_ms` is null. MUTABLE — the two | ||
| -- mark columns flip from null to stamped — so no immutability trigger, mirroring | ||
| -- the `notifications`/`notification_outbox` mutable-outbox tables. | ||
| -- | ||
| -- Timestamp columns queries order/compare on carry an integer `*_epoch_ms` | ||
| -- sibling (RFC 3339 strings compare incorrectly across timezone offsets in SQL), | ||
| -- matching 0003-0008. | ||
|
|
||
| CREATE TABLE assessment_error_escalations ( | ||
| assessment_id TEXT PRIMARY KEY REFERENCES assessments(id), | ||
| subject_uri TEXT NOT NULL, | ||
| subject_cid TEXT NOT NULL, | ||
| operator_alerted_at_epoch_ms INTEGER, | ||
| publisher_notified_at_epoch_ms INTEGER, | ||
| created_at TEXT NOT NULL, | ||
| created_at_epoch_ms INTEGER NOT NULL | ||
| ); | ||
|
|
||
| -- The escalation scan filters `state = 'error' AND completed_at_epoch_ms <= ?` | ||
| -- and orders by `completed_at_epoch_ms`; the existing `idx_assessments_state_created` | ||
| -- covers `(state, created_at_epoch_ms)`, not `completed_at`. Partial on the error | ||
| -- state keeps this compact (errors are a small minority) and matches the cron's | ||
| -- filter and order exactly, so a 5-minute tick seeks instead of full-scanning. | ||
| CREATE INDEX idx_assessments_error_completed | ||
| ON assessments(completed_at_epoch_ms) WHERE state = 'error'; | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| /** | ||
| * Persistence for the prolonged-error escalation ladder (plan W10.5 follow-up). | ||
| * `assessment_error_escalations` (migration 0010) tracks, per errored | ||
| * assessment, whether the 24h operator alert and the 72h publisher notice have | ||
| * already fired — the tracking table that makes each stage idempotent across the | ||
| * 5-minute reconciliation cron ticks. | ||
| * | ||
| * The escalation query deliberately does NOT reuse the pointer-based | ||
| * `isSuperseded` (an `error` run never moves the `current_assessments` pointer, | ||
| * spec §10): supersession here means simply "a newer run exists for the same | ||
| * `(uri, cid)`", detected by `created_at_epoch_ms`. | ||
| */ | ||
|
|
||
| import { PROLONGED_ERROR_OPERATOR_THRESHOLD_MS, PROLONGED_ERROR_SCAN_BATCH } from "./constants.js"; | ||
|
|
||
| export interface EscalatableError { | ||
| id: string; | ||
| uri: string; | ||
| cid: string; | ||
| completedAtEpochMs: number; | ||
| } | ||
|
|
||
| export interface AssessmentErrorEscalation { | ||
| assessmentId: string; | ||
| subjectUri: string; | ||
| subjectCid: string; | ||
| operatorAlertedAtEpochMs: number | null; | ||
| publisherNotifiedAtEpochMs: number | null; | ||
| createdAtEpochMs: number; | ||
| } | ||
|
|
||
| interface EscalatableErrorRow { | ||
| id: string; | ||
| uri: string; | ||
| cid: string; | ||
| completed_at_epoch_ms: number; | ||
| } | ||
|
|
||
| interface EscalationRow { | ||
| assessment_id: string; | ||
| subject_uri: string; | ||
| subject_cid: string; | ||
| operator_alerted_at_epoch_ms: number | null; | ||
| publisher_notified_at_epoch_ms: number | null; | ||
| created_at_epoch_ms: number; | ||
| } | ||
|
|
||
| /** | ||
| * Terminal `error` assessments whose `completed_at` is at least the operator | ||
| * threshold (24h) old and that no newer run has superseded — the candidate set | ||
| * for the escalation ladder. Skips subjects tombstoned at the source (a deleted | ||
| * release warrants no publisher chase) and rows whose escalation is already | ||
| * complete (both marks stamped), so a fully-escalated row leaves the | ||
| * `completed_at`-ordered window and newer errors behind it are reached during a | ||
| * backlog. Capped at {@link PROLONGED_ERROR_SCAN_BATCH}; a full page is logged so | ||
| * a persistent backlog is visible. | ||
| */ | ||
| export async function findEscalatableErrors( | ||
| db: D1Database, | ||
| now: Date, | ||
| ): Promise<EscalatableError[]> { | ||
| const operatorBefore = now.getTime() - PROLONGED_ERROR_OPERATOR_THRESHOLD_MS; | ||
| const rows = await db | ||
| .prepare( | ||
| `SELECT a.id, a.uri, a.cid, a.completed_at_epoch_ms | ||
| FROM assessments a | ||
| JOIN subjects s ON s.uri = a.uri AND s.cid = a.cid | ||
| LEFT JOIN assessment_error_escalations e ON e.assessment_id = a.id | ||
| WHERE a.state = 'error' | ||
| AND a.completed_at_epoch_ms IS NOT NULL | ||
| AND a.completed_at_epoch_ms <= ? | ||
| AND s.deleted_at IS NULL | ||
| AND ( | ||
| e.assessment_id IS NULL | ||
| OR e.operator_alerted_at_epoch_ms IS NULL | ||
| OR e.publisher_notified_at_epoch_ms IS NULL | ||
| ) | ||
| AND NOT EXISTS ( | ||
| SELECT 1 FROM assessments b | ||
| WHERE b.uri = a.uri AND b.cid = a.cid AND b.created_at_epoch_ms > a.created_at_epoch_ms | ||
| ) | ||
| ORDER BY a.completed_at_epoch_ms ASC | ||
| LIMIT ?`, | ||
| ) | ||
| .bind(operatorBefore, PROLONGED_ERROR_SCAN_BATCH) | ||
| .all<EscalatableErrorRow>(); | ||
| const results = rows.results ?? []; | ||
| if (results.length >= PROLONGED_ERROR_SCAN_BATCH) | ||
| console.error("[labeler] prolonged-error escalation scan hit the batch cap", { | ||
| cap: PROLONGED_ERROR_SCAN_BATCH, | ||
| }); | ||
| return results.map((row) => ({ | ||
| id: row.id, | ||
| uri: row.uri, | ||
| cid: row.cid, | ||
| completedAtEpochMs: row.completed_at_epoch_ms, | ||
| })); | ||
| } | ||
|
|
||
| /** Guarantee a tracking row exists for an errored assessment, leaving both mark | ||
| * columns null. Idempotent: a repeated cron tick keeps the first row's marks. */ | ||
| export async function ensureEscalationRow( | ||
| db: D1Database, | ||
| input: { assessmentId: string; subjectUri: string; subjectCid: string; now: Date }, | ||
| ): Promise<void> { | ||
| await db | ||
| .prepare( | ||
| `INSERT INTO assessment_error_escalations | ||
| (assessment_id, subject_uri, subject_cid, created_at, created_at_epoch_ms) | ||
| VALUES (?, ?, ?, ?, ?) | ||
| ON CONFLICT(assessment_id) DO NOTHING`, | ||
| ) | ||
| .bind( | ||
| input.assessmentId, | ||
| input.subjectUri, | ||
| input.subjectCid, | ||
| input.now.toISOString(), | ||
| input.now.getTime(), | ||
| ) | ||
| .run(); | ||
| } | ||
|
|
||
| export async function getEscalation( | ||
| db: D1Database, | ||
| assessmentId: string, | ||
| ): Promise<AssessmentErrorEscalation | null> { | ||
| const row = await db | ||
| .prepare( | ||
| `SELECT assessment_id, subject_uri, subject_cid, | ||
| operator_alerted_at_epoch_ms, publisher_notified_at_epoch_ms, created_at_epoch_ms | ||
| FROM assessment_error_escalations WHERE assessment_id = ?`, | ||
| ) | ||
| .bind(assessmentId) | ||
| .first<EscalationRow>(); | ||
| return row | ||
| ? { | ||
| assessmentId: row.assessment_id, | ||
| subjectUri: row.subject_uri, | ||
| subjectCid: row.subject_cid, | ||
| operatorAlertedAtEpochMs: row.operator_alerted_at_epoch_ms, | ||
| publisherNotifiedAtEpochMs: row.publisher_notified_at_epoch_ms, | ||
| createdAtEpochMs: row.created_at_epoch_ms, | ||
| } | ||
| : null; | ||
| } | ||
|
|
||
| /** | ||
| * The statement that stamps `operator_alerted_at`, guarded on it still being | ||
| * null. Returned rather than executed so the caller can batch it atomically with | ||
| * the operational-event insert — a crash can then never leave the alert raised | ||
| * without the mark (which would re-alert on the next tick). | ||
| */ | ||
| export function buildMarkOperatorAlerted( | ||
| db: D1Database, | ||
| assessmentId: string, | ||
| now: Date, | ||
| ): D1PreparedStatement { | ||
| return db | ||
| .prepare( | ||
| `UPDATE assessment_error_escalations | ||
| SET operator_alerted_at_epoch_ms = ? | ||
| WHERE assessment_id = ? AND operator_alerted_at_epoch_ms IS NULL`, | ||
| ) | ||
| .bind(now.getTime(), assessmentId); | ||
| } | ||
|
|
||
| /** Stamp `publisher_notified_at`, guarded on it still being null. Runs after the | ||
| * notice trigger returns; the notice's own `(issuance, id)` dedup makes a crash | ||
| * between send and mark self-heal (the next tick re-sends nothing, then marks). */ | ||
| export async function markPublisherNotified( | ||
| db: D1Database, | ||
| assessmentId: string, | ||
| now: Date, | ||
| ): Promise<void> { | ||
| await db | ||
| .prepare( | ||
| `UPDATE assessment_error_escalations | ||
| SET publisher_notified_at_epoch_ms = ? | ||
| WHERE assessment_id = ? AND publisher_notified_at_epoch_ms IS NULL`, | ||
| ) | ||
| .bind(now.getTime(), assessmentId) | ||
| .run(); | ||
| } |
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
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
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
Oops, something went wrong.
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[needs fixing] The
findEscalatableErrorsquery filtersassessments.state = 'error' AND assessments.completed_at_epoch_ms <= ?and orders bycompleted_at_epoch_ms ASC. The only existing assessment index isidx_assessments_state_createdon(state, created_at_epoch_ms DESC), so SQLite can equality-matchstatebut must still scan all errored rows and sort bycompleted_at. As theerrorbacklog grows, a 5-minute cron tick becomes increasingly expensive.That violates the repo’s index discipline (see
0006_assessment_checksum_index.sqlfor the same rationale). Add a partial index oncompleted_at_epoch_msscoped toerrorrows: