1010 * same seam the queue pages and the waiting-run module use — with the
1111 * ClickHouse depth series as fallback.
1212 * - queue existence: ONE Postgres `TaskQueue` point-read.
13- * - error recurrence: ClickHouse `error_occurrences_v1` only.
13+ * - error recurrence: ClickHouse `errors_v1` for WHETHER it recurred (millisecond
14+ * `last_seen`), plus the per-minute `error_occurrences_v1` rollup for the count.
1415 * - health: the existing health report (loader + interpreter), unchanged.
1516 *
1617 * Readers THROW on failure rather than swallowing it, because `checkWatch` turns a
@@ -69,16 +70,32 @@ export async function watchQueueExists(environmentId: string, queueName: string)
6970/** How far back the ClickHouse depth fallback looks when the live counter is down. */
7071const DEPTH_FALLBACK_MINUTES = 10 ;
7172const DEPTH_FALLBACK_BUCKET_SECONDS = 60 ;
73+ /**
74+ * How far behind `now` the newest analytics bucket may END and still be read as
75+ * "the queue right now". One bucket of slack: anything older leaves a gap the
76+ * rollup hasn't covered, and runs queued in that gap would be invisible.
77+ */
78+ const DEPTH_FRESH_TOLERANCE_MS = DEPTH_FALLBACK_BUCKET_SECONDS * 1000 ;
7279
7380function formatClickhouseDateTime ( date : Date ) : string {
7481 return date . toISOString ( ) . slice ( 0 , 19 ) . replace ( "T" , " " ) ;
7582}
7683
84+ /** ClickHouse renders DateTime without a zone; the column is UTC. */
85+ function parseClickhouseDateTime ( value : string ) : Date {
86+ return new Date ( `${ value . replace ( " " , "T" ) } Z` ) ;
87+ }
88+
7789/**
7890 * Current pending count for one queue. The live run-queue counter is the truth
7991 * ("is it drained RIGHT NOW"); ClickHouse is the fallback and reports the most
80- * recent bucket's PEAK depth, which can only over-report — so a fallback zero
81- * still means "nothing was queued in that bucket", never a false drain.
92+ * recent bucket's PEAK depth, which can only over-report within that bucket.
93+ *
94+ * The fallback carries `current`, and it is false unless the newest bucket
95+ * actually reaches the present: a rollup that's minutes behind may hold an empty
96+ * bucket while runs piled up after it, and reading that as "drained" is the one
97+ * mistake this watch must never make. `checkBacklogDrain` turns a stale zero into
98+ * `unavailable`.
8299 */
83100export async function readWatchQueueDepth (
84101 environment : AuthenticatedEnvironment ,
@@ -87,7 +104,7 @@ export async function readWatchQueueDepth(
87104) : Promise < WatchQueueDepth | null > {
88105 const live = await engine . lengthOfQueue ( environment , queueName ) . catch ( ( ) => null ) ;
89106 if ( typeof live === "number" && Number . isFinite ( live ) ) {
90- return { depth : live , source : "live_queue" } ;
107+ return { depth : live , source : "live_queue" , current : true , asOf : now } ;
91108 }
92109
93110 const clickhouse = await clickhouseFactory . getClickhouseForOrganization (
@@ -114,18 +131,66 @@ export async function readWatchQueueDepth(
114131
115132 // Newest bucket wins — the closest thing the rollup has to "now".
116133 const newest = rows . reduce ( ( best , row ) => ( row . bucket > best . bucket ? row : best ) , rows [ 0 ] ! ) ;
117- return { depth : newest . depth , source : "queue_metrics" } ;
134+ const bucketEnd = new Date ( parseClickhouseDateTime ( newest . bucket ) . getTime ( ) + bucketMs ) ;
135+ const current = bucketEnd . getTime ( ) >= now . getTime ( ) - DEPTH_FRESH_TOLERANCE_MS ;
136+
137+ return { depth : newest . depth , source : "queue_metrics" , current, asOf : bucketEnd } ;
138+ }
139+
140+ const MINUTE_MS = 60_000 ;
141+
142+ type OrganizationClickhouse = Awaited <
143+ ReturnType < typeof clickhouseFactory . getClickhouseForOrganization >
144+ > ;
145+
146+ /**
147+ * The fingerprint's most recent occurrence, at MILLISECOND precision, from the
148+ * `errors_v1` aggregate (`max(last_seen)` over every task that produced it).
149+ *
150+ * This is what makes "has it come back?" answerable exactly. The per-minute
151+ * `error_occurrences_v1` rollup can only place an error in a minute, and the
152+ * minute a watch is created in holds BOTH the error that prompted the watch and
153+ * any recurrence seconds later — so the rollup alone can neither confirm nor deny
154+ * a recurrence in that first minute.
155+ */
156+ async function readErrorLastSeen (
157+ clickhouse : OrganizationClickhouse ,
158+ environment : AuthenticatedEnvironment ,
159+ fingerprint : string
160+ ) : Promise < Date | null > {
161+ const builder = clickhouse . errors . activeErrorsSinceQueryBuilder ( ) ;
162+ builder . where ( "organization_id = {organizationId: String}" , {
163+ organizationId : environment . organizationId ,
164+ } ) ;
165+ builder . where ( "project_id = {projectId: String}" , { projectId : environment . projectId } ) ;
166+ builder . where ( "environment_id = {environmentId: String}" , { environmentId : environment . id } ) ;
167+ builder . where ( "error_fingerprint = {fingerprint: String}" , { fingerprint } ) ;
168+ builder . groupBy ( "environment_id, task_identifier, error_fingerprint" ) ;
169+
170+ const [ error , rows ] = await builder . execute ( ) ;
171+ if ( error ) throw error ;
172+ if ( ! rows || rows . length === 0 ) return null ;
173+
174+ let lastSeenMs = 0 ;
175+ for ( const row of rows ) {
176+ const ms = Number ( row . last_seen ) ;
177+ if ( Number . isFinite ( ms ) && ms > lastSeenMs ) lastSeenMs = ms ;
178+ }
179+
180+ return lastSeenMs > 0 ? new Date ( lastSeenMs ) : null ;
118181}
119182
120183/**
121- * The first occurrence of an error fingerprint after `since`, from the per-minute
122- * `error_occurrences_v1` rollup.
184+ * What we know about an error fingerprint relative to `since`, from two reads that
185+ * each answer what only they can:
123186 *
124- * Buckets are filtered with `minute > since`, i.e. STRICTLY after, so an
125- * occurrence in the same minute the watch was created can't be read as a
126- * recurrence. That under-counts by at most the creation minute — the conservative
127- * direction, since a watch must never fire on the error that prompted it.
128- * `occurredAt` is therefore minute-granular by construction.
187+ * - `errors_v1` decides WHETHER it recurred, to the millisecond. An occurrence
188+ * 40 seconds after the watch was created is a recurrence, and rounding the
189+ * window up to the next minute used to lose it entirely.
190+ * - `error_occurrences_v1` supplies HOW MANY and, for minutes after the creation
191+ * minute, when. Its creation-minute bucket can't be split between the original
192+ * error and a recurrence, so those occurrences only make `countSince` a lower
193+ * bound (`countApproximate`) — never a claim.
129194 */
130195export async function readWatchErrorRecurrence (
131196 environment : AuthenticatedEnvironment ,
@@ -137,31 +202,66 @@ export async function readWatchErrorRecurrence(
137202 "logs"
138203 ) ;
139204
205+ const lastSeenAt = await readErrorLastSeen ( clickhouse , environment , fingerprint ) ;
206+ // Never seen in this environment at all.
207+ if ( ! lastSeenAt ) return null ;
208+
209+ const notRecurred : WatchErrorRecurrence = {
210+ occurredAt : null ,
211+ occurredAtPrecision : null ,
212+ countSince : 0 ,
213+ countApproximate : false ,
214+ lastSeenAt,
215+ } ;
216+ if ( lastSeenAt . getTime ( ) <= since . getTime ( ) ) return notRecurred ;
217+
218+ // Something landed after `since`. The rollup fills in the count and the minute.
219+ const sinceMinuteMs = Math . floor ( since . getTime ( ) / MINUTE_MS ) * MINUTE_MS ;
140220 const queryBuilder = clickhouse . errors . createOccurrencesQueryBuilder ( "INTERVAL 1 MINUTE" ) ;
141221 queryBuilder . where ( "organization_id = {organizationId: String}" , {
142222 organizationId : environment . organizationId ,
143223 } ) ;
144224 queryBuilder . where ( "project_id = {projectId: String}" , { projectId : environment . projectId } ) ;
145225 queryBuilder . where ( "environment_id = {environmentId: String}" , { environmentId : environment . id } ) ;
146226 queryBuilder . where ( "error_fingerprint = {fingerprint: String}" , { fingerprint } ) ;
147- queryBuilder . where ( "minute > toStartOfMinute(fromUnixTimestamp64Milli({sinceMs: Int64}))" , {
227+ // The creation minute is INCLUDED — its occurrences are what the old
228+ // `minute > since` filter dropped.
229+ queryBuilder . where ( "minute >= toStartOfMinute(fromUnixTimestamp64Milli({sinceMs: Int64}))" , {
148230 sinceMs : since . getTime ( ) ,
149231 } ) ;
150232 queryBuilder . groupBy ( "error_fingerprint, bucket_epoch" ) ;
151233 queryBuilder . orderBy ( "bucket_epoch ASC" ) ;
152234
153235 const [ error , rows ] = await queryBuilder . execute ( ) ;
154236 if ( error ) throw error ;
155- if ( ! rows || rows . length === 0 ) return null ;
156237
157- let earliest = rows [ 0 ] ! . bucket_epoch ;
158- let countSince = 0 ;
159- for ( const row of rows ) {
160- if ( row . bucket_epoch < earliest ) earliest = row . bucket_epoch ;
161- countSince += row . count ;
238+ let earliestAfterMs : number | null = null ;
239+ let countAfter = 0 ;
240+ let creationMinuteCount = 0 ;
241+
242+ for ( const row of rows ?? [ ] ) {
243+ const bucketMs = row . bucket_epoch * 1000 ;
244+ if ( bucketMs <= sinceMinuteMs ) {
245+ creationMinuteCount += row . count ;
246+ continue ;
247+ }
248+ countAfter += row . count ;
249+ if ( earliestAfterMs === null || bucketMs < earliestAfterMs ) earliestAfterMs = bucketMs ;
162250 }
163251
164- return { occurredAt : new Date ( earliest * 1000 ) , countSince } ;
252+ // The earliest time we can PROVE an occurrence at: a bucket that starts after
253+ // the creation minute, or — when the only evidence is in that minute, or the
254+ // rollup hasn't caught up — the exact `last_seen`.
255+ const useBucket = earliestAfterMs !== null && earliestAfterMs < lastSeenAt . getTime ( ) ;
256+
257+ return {
258+ occurredAt : useBucket ? new Date ( earliestAfterMs ! ) : lastSeenAt ,
259+ occurredAtPrecision : useBucket ? "minute" : "exact" ,
260+ // At least the one `errors_v1` proved, even if the rollup lags behind it.
261+ countSince : Math . max ( 1 , countAfter ) ,
262+ countApproximate : creationMinuteCount > 0 ,
263+ lastSeenAt,
264+ } ;
165265}
166266
167267const HEALTH_SEVERITIES = new Set < string > ( [ "ok" , "warn" , "crit" ] ) ;
0 commit comments