Skip to content

Commit 74cdf2d

Browse files
authored
fix(news): unify digest publication acceptance (#7599)
* fix(news): unify digest publication acceptance Keep canonical and durable digest snapshots behind one acceptance gate so a narrower candidate cannot land first. * fix(news): close digest publication review gaps * fix(news): avoid false digest failure after build
1 parent b05f3bb commit 74cdf2d

9 files changed

Lines changed: 735 additions & 74 deletions

File tree

docker/redis-rest-proxy.mjs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,27 @@ const DIGEST_LASTGOOD_PUBLISH_SCRIPT = [
118118
'local candidate = nil',
119119
'if okCandidate then candidate = countData(candidateData) end',
120120
'if not candidate or candidate.categories < 1 or candidate.items < 1 then return -1 end',
121+
'local canonicalRaw = nil',
122+
"if KEYS[3] then canonicalRaw = redis.call('GET', KEYS[3]) end",
123+
'local function rejectNarrower()',
124+
" if KEYS[3] and not canonicalRaw then redis.call('SET', KEYS[3], '\"__WM_NEG__\"', 'EX', ARGV[9]) end",
125+
' return 0',
126+
'end',
127+
'local function isNarrower(nextData, currentData)',
128+
' return nextData.categories < currentData.categories or nextData.items < currentData.items',
129+
'end',
130+
'local function isLiveCanonicalClock(value)',
131+
" if type(value) ~= 'string' then return false end",
132+
" local year, month, day, hour, minute, second = string.match(value, '^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)%.%d%d%dZ$')",
133+
' if not year then return false end',
134+
' year, month, day = tonumber(year), tonumber(month), tonumber(day)',
135+
' hour, minute, second = tonumber(hour), tonumber(minute), tonumber(second)',
136+
' if month < 1 or month > 12 or hour > 23 or minute > 59 or second > 59 then return false end',
137+
' local leap = year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0)',
138+
' local monthDays = { 31, leap and 29 or 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }',
139+
' if day < 1 or day > monthDays[month] then return false end',
140+
' return value >= ARGV[7] and value <= ARGV[8]',
141+
'end',
121142
"local currentRaw = redis.call('GET', KEYS[1])",
122143
'if currentRaw then',
123144
' local okCurrent, snapshot = pcall(cjson.decode, currentRaw)',
@@ -126,7 +147,18 @@ const DIGEST_LASTGOOD_PUBLISH_SCRIPT = [
126147
' if current then',
127148
' local delta = tonumber(ARGV[1]) - (tonumber(snapshot.acceptedAt) or 0)',
128149
' local live = delta >= 0 and delta <= tonumber(ARGV[2])',
129-
' if live and (candidate.categories < current.categories or candidate.items < current.items) then return 0 end',
150+
' if live and isNarrower(candidate, current) then return rejectNarrower() end',
151+
' end',
152+
' end',
153+
'end',
154+
'if KEYS[3] then',
155+
' if canonicalRaw then',
156+
' local okCanonical, canonicalData = pcall(cjson.decode, canonicalRaw)',
157+
" if okCanonical and type(canonicalData) == 'table' then",
158+
' local currentCanonical = countData(canonicalData)',
159+
' local live = isLiveCanonicalClock(canonicalData.generatedAt)',
160+
' local usable = currentCanonical and currentCanonical.categories >= 1 and currentCanonical.items >= 1',
161+
' if live and usable and isNarrower(candidate, currentCanonical) then return rejectNarrower() end',
130162
' end',
131163
' end',
132164
'end',
@@ -140,6 +172,7 @@ const DIGEST_LASTGOOD_PUBLISH_SCRIPT = [
140172
" .. ',\"itemCount\":' .. string.format('%.0f', candidate.items)",
141173
' .. \',"data":\' .. ARGV[5] .. \'}\'',
142174
"redis.call('SET', KEYS[1], stored, 'EX', ARGV[4])",
175+
"if KEYS[3] then redis.call('SET', KEYS[3], ARGV[5], 'EX', ARGV[6]) end",
143176
'return 1',
144177
].join('\n');
145178

server/_shared/redis.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,7 @@ export async function getCachedEnvelopeJson(key: string, raw = false): Promise<u
252252
export async function setCachedJson(key: string, value: unknown, ttlSeconds: number, raw = false): Promise<boolean> {
253253
if (process.env.LOCAL_API_MODE === 'tauri-sidecar') {
254254
const { sidecarCacheSet } = await import('./sidecar-cache');
255-
sidecarCacheSet(key, value, ttlSeconds);
256-
return true;
255+
return sidecarCacheSet(key, value, ttlSeconds);
257256
}
258257

259258
const url = process.env.UPSTASH_REDIS_REST_URL;
@@ -448,8 +447,7 @@ export async function prependCachedJsonList(
448447
const retained = Array.isArray(existing)
449448
? existing.filter((item) => JSON.stringify(item) !== encoded)
450449
: [];
451-
sidecarCacheSet(key, [value, ...retained].slice(0, boundedLimit), boundedTtlSeconds);
452-
return true;
450+
return sidecarCacheSet(key, [value, ...retained].slice(0, boundedLimit), boundedTtlSeconds);
453451
} catch (err) {
454452
// sentry-coverage-ok: this helper returns false to its caller, and a
455453
// history write must never fail the current response it will inform.
@@ -987,15 +985,19 @@ export interface UsageHook {
987985
* `opts.cacheFailures: false` similarly makes nulls, no-store payloads, and
988986
* thrown fetches non-cacheable. `opts.inflightKey` lets callers share positive
989987
* cache entries while isolating provider-local work and failures.
988+
* `opts.onPositiveResult` keeps the in-flight slot open for a caller-owned
989+
* commit phase after a valid fetch result.
990990
* `opts.isCallerLocalError` identifies admission failures that belong only to
991991
* the fetch leader. These errors do not arm shared cache backoff, and an
992992
* in-flight follower re-enters admission under its own request instead of
993993
* inheriting any preceding leader's failure.
994994
*/
995-
type CachedFetchWithMetaOpts = CachedFetchOpts & {
995+
type CachedFetchWithMetaOpts<T extends object = object> = CachedFetchOpts & {
996996
usage?: UsageHook;
997997
shouldFetch?: () => boolean;
998998
cacheFailures?: boolean;
999+
cachePositiveResult?: boolean;
1000+
onPositiveResult?: (result: T) => Promise<void>;
9991001
inflightKey?: string;
10001002
isCallerLocalError?: (error: unknown) => boolean;
10011003
};
@@ -1005,7 +1007,7 @@ export async function cachedFetchJsonWithMeta<T extends object>(
10051007
ttlSeconds: number,
10061008
fetcher: () => Promise<T | null>,
10071009
negativeTtlSeconds = 120,
1008-
opts?: CachedFetchWithMetaOpts,
1010+
opts?: CachedFetchWithMetaOpts<T>,
10091011
): Promise<{ data: T | null; source: 'cache' | 'fresh' | 'skipped'; leader: boolean }> {
10101012
return cachedFetchJsonCore(key, ttlSeconds, fetcher, negativeTtlSeconds, opts, 'cachedFetchJsonWithMeta');
10111013
}
@@ -1023,7 +1025,7 @@ async function cachedFetchJsonCore<T extends object>(
10231025
ttlSeconds: number,
10241026
fetcher: () => Promise<T | null>,
10251027
negativeTtlSeconds: number,
1026-
opts: CachedFetchWithMetaOpts | undefined,
1028+
opts: CachedFetchWithMetaOpts<T> | undefined,
10271029
callerName: 'cachedFetchJson' | 'cachedFetchJsonWithMeta',
10281030
): Promise<{ data: T | null; source: 'cache' | 'fresh' | 'skipped'; leader: boolean }> {
10291031
const cached = await readCachedJson(key);
@@ -1093,12 +1095,16 @@ async function cachedFetchJsonCore<T extends object>(
10931095
}
10941096
} else {
10951097
upstreamStatus = 200;
1096-
const wrote = await setCachedJson(key, result, ttlSeconds);
1098+
const wrote = opts?.cachePositiveResult === false
1099+
? true
1100+
: await setCachedJson(key, result, ttlSeconds);
10971101
// See cachedFetchJson(): this short in-process bridge is only for
10981102
// remote Redis outages, not local sidecar cache writes.
1099-
if (hadCacheReadError || (!wrote && hasRemoteRedisConfig())) {
1103+
if (opts?.cachePositiveResult !== false
1104+
&& (hadCacheReadError || (!wrote && hasRemoteRedisConfig()))) {
11001105
armLocalPositiveFallback(key, result, ttlSeconds);
11011106
}
1107+
if (opts?.cachePositiveResult === false) await opts.onPositiveResult?.(result);
11021108
}
11031109
} else {
11041110
upstreamStatus = 0;

server/_shared/sidecar-cache.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,10 @@ export function sidecarCacheGet(key: string): unknown | null {
7777
export function sidecarCacheSetIfAbsent(key: string, value: unknown, ttlSeconds: number): boolean {
7878
const existing = store.get(key);
7979
if (existing && existing.expiresAt > Date.now()) return false;
80-
sidecarCacheSet(key, value, ttlSeconds);
81-
return true;
80+
return sidecarCacheSet(key, value, ttlSeconds);
8281
}
8382

84-
export function sidecarCacheSet(key: string, value: unknown, ttlSeconds: number): void {
83+
export function sidecarCacheSet(key: string, value: unknown, ttlSeconds: number): boolean {
8584
const clamped = Math.max(MIN_TTL_S, Math.min(MAX_TTL_S, ttlSeconds));
8685
const json = JSON.stringify(value);
8786
// Rough byte estimate: JS strings are UTF-16 (2 bytes per code unit).
@@ -90,7 +89,7 @@ export function sidecarCacheSet(key: string, value: unknown, ttlSeconds: number)
9089

9190
if (size > MAX_SINGLE_VALUE_BYTES) {
9291
console.warn(`[sidecar-cache] rejecting key "${key}": ${(size / 1024 / 1024).toFixed(1)} MB exceeds 2 MB limit`);
93-
return;
92+
return false;
9493
}
9594

9695
// Remove old entry if exists
@@ -113,6 +112,7 @@ export function sidecarCacheSet(key: string, value: unknown, ttlSeconds: number)
113112
totalBytes += size;
114113

115114
startSweepIfNeeded();
115+
return true;
116116
}
117117

118118
export function sidecarCacheStats(): { entries: number; bytes: number; hits: number; misses: number } {

server/worldmonitor/news/v1/_lastgood-store.ts

Lines changed: 114 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
1919
import { getUsageScope } from '../../../_shared/usage';
2020
import {
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.
3838
const DIGEST_NEGATIVE_SENTINEL = '__WM_NEG__';
39+
const DIGEST_CACHE_TTL_S = 900;
40+
const DIGEST_REJECTION_TTL_S = 120;
3941

4042
export 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+
9097
export 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

316408
export const __testing__ = {
317409
activeAttempts,
318410
recentFailedAttempts,
319411
failureCooldowns,
412+
deferDigestAttempt,
320413
measureServableRichness,
321414
};

0 commit comments

Comments
 (0)