@@ -15,7 +15,7 @@ import {
1515 runRedisTransaction ,
1616 setCachedJson ,
1717} from '../../../_shared/redis' ;
18- import { REVOKED_URLS_KEY } from '../../../_shared/digest-revocations' ;
18+ import { REVOKED_URLS_KEY , readRevokedUrlSet } from '../../../_shared/digest-revocations' ;
1919import { getUsageScope } from '../../../_shared/usage' ;
2020import {
2121 ATTEMPT_META_TTL_S ,
@@ -36,6 +36,8 @@ import {
3636// Private wire value used by cachedFetchJsonWithMeta. This write is kept here
3737// because the attempt row and sentinel must become visible atomically.
3838const DIGEST_NEGATIVE_SENTINEL = '__WM_NEG__' ;
39+ const DIGEST_CACHE_TTL_S = 900 ;
40+ const DIGEST_REJECTION_TTL_S = 120 ;
3941
4042export interface FailedDigestAttempt {
4143 readonly at : string ;
@@ -87,6 +89,11 @@ export function shouldStartDigestAttempt(digestCacheKey: string, now = Date.now(
8789 return false ;
8890}
8991
92+ export function deferDigestAttempt ( digestCacheKey : string , ttlSeconds : number , now = Date . now ( ) ) : void {
93+ boundLocalRecoveryMaps ( now ) ;
94+ failureCooldowns . set ( digestCacheKey , now + ttlSeconds * 1000 ) ;
95+ }
96+
9097export function beginDigestAttempt ( variant : string , lang : string , at : string ) : AttemptSlot {
9198 // A shared promise rejection can wake a follower before the leader's outer
9299 // catch runs. The thrown/timeout identity is therefore prepared at start;
@@ -120,7 +127,8 @@ export function measureServableRichness(
120127 const categories = Object . values ( data . categories ?? { } ) ;
121128 let itemCount = 0 ;
122129 for ( const bucket of categories ) {
123- for ( const item of bucket ?. items ?? [ ] ) {
130+ const items = Array . isArray ( bucket ?. items ) ? bucket . items : [ ] ;
131+ for ( const item of items ) {
124132 const link = item && typeof item === 'object' && 'link' in item
125133 ? ( item as { link ?: unknown } ) . link
126134 : undefined ;
@@ -262,60 +270,145 @@ export async function publishAcceptedSnapshot(
262270 variant : string ,
263271 lang : string ,
264272 data : ListFeedDigestResponse ,
265- ) : Promise < void > {
266- if ( ! isEligibleScope ( variant , lang ) || ! isAcceptableDigest ( data ) ) return ;
273+ canonicalDigestKey ?: string ,
274+ ) : Promise < 'accepted' | 'rejected' | 'unavailable' > {
275+ if ( ! isEligibleScope ( variant , lang ) || ! isAcceptableDigest ( data ) ) return 'rejected' ;
267276 const now = Date . now ( ) ;
268277 const generatedAtMs = Date . parse ( data . generatedAt ?? '' ) ;
269278 const acceptedAt = Number . isFinite ( generatedAtMs ) ? generatedAtMs : now ;
270279 try {
271280 if ( process . env . LOCAL_API_MODE === 'tauri-sidecar' ) {
281+ const revoked = await readRevokedUrlSet ( ) ;
282+ if ( ! revoked . readable ) {
283+ console . warn ( `[digest-publication] source unavailable variant=${ variant } lang=${ lang } ` ) ;
284+ return 'unavailable' ;
285+ }
286+ const candidateRichness = measureServableRichness ( data , revoked . urls ) ;
287+ if ( candidateRichness . categoryCount < 1 || candidateRichness . itemCount < 1 ) {
288+ console . log ( `[digest-publication] candidate rejected after revocations variant=${ variant } lang=${ lang } ` ) ;
289+ return 'rejected' ;
290+ }
272291 const read = await readAcceptedSnapshot < ListFeedDigestResponse > ( variant , lang ) ;
273- if ( ! read . readable ) return ;
292+ if ( ! read . readable ) {
293+ console . warn ( `[digest-publication] publish unavailable variant=${ variant } lang=${ lang } ` ) ;
294+ return 'unavailable' ;
295+ }
274296 const current = read . snapshot ;
275- const { categoryCount, itemCount } = measureServableRichness ( data , new Set ( ) ) ;
276- const decision = shouldReplaceAccepted ( current , { categoryCount, itemCount } , now ) ;
277- if ( ! decision . replace ) return ;
278- const meta : AcceptedSnapshotMeta = { acceptedAt, categoryCount, itemCount } ;
279- await setCachedJson ( lastGoodKey ( variant , lang ) , { ...meta , data } , LASTGOOD_TTL_S ) ;
280- return ;
297+ const currentCanonical = canonicalDigestKey
298+ ? await readCachedJson ( canonicalDigestKey )
299+ : { status : 'miss' as const } ;
300+ if ( currentCanonical . status === 'error' ) {
301+ console . warn ( `[digest-publication] canonical read unavailable variant=${ variant } lang=${ lang } ` ) ;
302+ return 'unavailable' ;
303+ }
304+ const canonicalValue = currentCanonical . status === 'hit'
305+ && currentCanonical . value && typeof currentCanonical . value === 'object'
306+ ? currentCanonical . value as ListFeedDigestResponse
307+ : null ;
308+ const canonicalGeneratedAt = canonicalValue ? Date . parse ( canonicalValue . generatedAt ?? '' ) : NaN ;
309+ const canonicalRichness = canonicalValue
310+ ? measureServableRichness ( canonicalValue , revoked . urls )
311+ : null ;
312+ const canonicalMeta = canonicalRichness
313+ && canonicalRichness . categoryCount >= 1
314+ && canonicalRichness . itemCount >= 1
315+ && Number . isFinite ( canonicalGeneratedAt )
316+ ? {
317+ acceptedAt : canonicalGeneratedAt ,
318+ ...canonicalRichness ,
319+ }
320+ : null ;
321+ const decision = shouldReplaceAccepted ( current , candidateRichness , now ) ;
322+ const canonicalDecision = canonicalMeta
323+ ? shouldReplaceAccepted ( canonicalMeta , candidateRichness , now )
324+ : null ;
325+ if ( ! decision . replace || canonicalDecision && ! canonicalDecision . replace ) {
326+ if ( ! decision . replace && canonicalDigestKey && currentCanonical . status === 'miss' ) {
327+ const cooldownWritten = await setCachedJson (
328+ canonicalDigestKey ,
329+ DIGEST_NEGATIVE_SENTINEL ,
330+ DIGEST_REJECTION_TTL_S ,
331+ ) ;
332+ if ( ! cooldownWritten ) {
333+ console . warn ( `[digest-publication] publish unavailable variant=${ variant } lang=${ lang } ` ) ;
334+ return 'unavailable' ;
335+ }
336+ }
337+ console . log ( `[digest-publication] candidate rejected by acceptance gate variant=${ variant } lang=${ lang } ` ) ;
338+ return 'rejected' ;
339+ }
340+ const meta : AcceptedSnapshotMeta = { acceptedAt, ...candidateRichness } ;
341+ const durableWritten = await setCachedJson ( lastGoodKey ( variant , lang ) , { ...meta , data } , LASTGOOD_TTL_S ) ;
342+ if ( ! durableWritten ) {
343+ console . warn ( `[digest-publication] publish unavailable variant=${ variant } lang=${ lang } ` ) ;
344+ return 'unavailable' ;
345+ }
346+ if ( canonicalDigestKey ) {
347+ const canonicalWritten = await setCachedJson ( canonicalDigestKey , data , DIGEST_CACHE_TTL_S ) ;
348+ if ( ! canonicalWritten ) {
349+ console . warn ( `[digest-publication] publish unavailable variant=${ variant } lang=${ lang } ` ) ;
350+ return 'unavailable' ;
351+ }
352+ }
353+ return 'accepted' ;
281354 }
282355
283356 // ARGV[5] is the digest body ALONE, and the script splices it into the
284357 // stored JSON verbatim. Sending the wrapped `{ acceptedAt, data }` and
285358 // letting Lua rebuild it meant a cjson decode/encode round trip, which
286359 // silently rewrote every empty array in the body as `{}`.
287- const results = await runRedisPipeline ( [ [
288- 'EVAL' ,
289- DIGEST_LASTGOOD_PUBLISH_SCRIPT ,
290- '2' ,
291- lastGoodKey ( variant , lang ) ,
292- REVOKED_URLS_KEY ,
360+ const keys = canonicalDigestKey
361+ ? [ lastGoodKey ( variant , lang ) , REVOKED_URLS_KEY , canonicalDigestKey ]
362+ : [ lastGoodKey ( variant , lang ) , REVOKED_URLS_KEY ] ;
363+ const args = [
293364 String ( now ) ,
294365 String ( LASTGOOD_MAX_AGE_MS ) ,
295366 String ( acceptedAt ) ,
296367 String ( LASTGOOD_TTL_S ) ,
297368 JSON . stringify ( data ) ,
369+ ...( canonicalDigestKey ? [
370+ String ( DIGEST_CACHE_TTL_S ) ,
371+ new Date ( now - LASTGOOD_MAX_AGE_MS ) . toISOString ( ) ,
372+ new Date ( now ) . toISOString ( ) ,
373+ String ( DIGEST_REJECTION_TTL_S ) ,
374+ ] : [ ] ) ,
375+ ] ;
376+ const results = await runRedisPipeline ( [ [
377+ 'EVAL' ,
378+ DIGEST_LASTGOOD_PUBLISH_SCRIPT ,
379+ String ( keys . length ) ,
380+ ...keys ,
381+ ...args ,
298382 ] ] ) ;
299383 const outcome = results [ 0 ] ;
300384 if ( ! outcome || outcome . error ) {
301- console . warn ( `[digest-lastgood] guarded publish unavailable variant=${ variant } lang=${ lang } ` ) ;
385+ console . warn ( `[digest-publication] publish unavailable variant=${ variant } lang=${ lang } ` ) ;
386+ return 'unavailable' ;
302387 } else if ( outcome . result === 0 ) {
303- console . log ( `[digest-lastgood] kept live snapshot (not-narrower) variant=${ variant } lang=${ lang } ` ) ;
388+ console . log ( `[digest-publication] candidate rejected by acceptance gate variant=${ variant } lang=${ lang } ` ) ;
389+ return 'rejected' ;
304390 } else if ( outcome . result === - 1 ) {
305- console . log ( `[digest-lastgood] candidate rejected after revocations variant=${ variant } lang=${ lang } ` ) ;
391+ console . log ( `[digest-publication] candidate rejected after revocations variant=${ variant } lang=${ lang } ` ) ;
392+ return 'rejected' ;
393+ } else if ( outcome . result === 1 ) {
394+ return 'accepted' ;
306395 }
396+ console . warn ( `[digest-publication] publish unavailable variant=${ variant } lang=${ lang } ` ) ;
397+ return 'unavailable' ;
307398 } catch ( err ) {
308- console . warn ( '[digest-lastgood ] publish failed:' , err ) ;
399+ console . warn ( '[digest-publication ] publish failed:' , err ) ;
309400 captureSilentError ( err , {
310401 tags : { surface : 'news' , component : 'digest-lastgood' , stage : 'publish' , variant, lang } ,
311402 fingerprint : [ 'digest-lastgood' , 'publish-failed' ] ,
312403 } ) ;
404+ return 'unavailable' ;
313405 }
314406}
315407
316408export const __testing__ = {
317409 activeAttempts,
318410 recentFailedAttempts,
319411 failureCooldowns,
412+ deferDigestAttempt,
320413 measureServableRichness,
321414} ;
0 commit comments