@@ -28,15 +28,24 @@ const logger = createLogger('ReportingUsageCache')
2828 * small against a year-long allowance while turning a per-event scan into one per window.
2929 *
3030 * A sum is held both in Redis, shared by every process, and in each process that reads it. It
31- * reflects the ledger as of the moment its sum began, so a served sum can omit usage written over
32- * the sum's own duration, plus up to this TTL and its jitter in Redis, plus up to this TTL again
33- * in the reading process.
31+ * reflects the ledger as of the moment its sum began, and its Redis expiry is anchored to that
32+ * moment, so it is served from Redis for at most max(this TTL + jitter, the sum's duration +
33+ * {@link MIN_SHARED_TTL_MS}) after it began (plus any reconnect delay for a resent write), plus
34+ * up to this TTL again in the reading process.
3435 */
3536export const REPORTING_USAGE_CACHE_TTL_MS = 30_000
3637
3738/** Redis expiry is jittered by up to this much, so payers summed together do not expire together. */
3839const SHARED_TTL_JITTER_MS = 5_000
3940
41+ /**
42+ * The shortest time a sum is kept in Redis. A sum that ran longer than the TTL would otherwise
43+ * expire on arrival, and under the database pressure that makes sums slow, every process would
44+ * then run the same slow sum again. Five seconds shares it with the processes waiting on it while
45+ * adding little staleness next to the time the sum itself took.
46+ */
47+ const MIN_SHARED_TTL_MS = 5_000
48+
4049/**
4150 * How long a read waits on a connected Redis before summing the ledger instead, so a socket that
4251 * has silently stopped answering costs one sum rather than the shared client's long timeouts.
@@ -95,17 +104,27 @@ function warnSharedWriteFailed(error: unknown): void {
95104}
96105
97106/**
98- * Fire-and-forget: a read never waits on, or fails because of, the shared write. The write only
99- * lands when no sum is stored (`NX`), so a slow, older sum can never replace a fresher one or
100- * extend its expiry.
107+ * Fire-and-forget: a read never waits on, or fails because of, the shared write. The expiry is
108+ * anchored to when the sum began: the remaining lifetime is computed here and written with `PX`,
109+ * which every Redis version accepts, floored at {@link MIN_SHARED_TTL_MS}. A write the client
110+ * resends after a reconnect re-applies that same relative lifetime from the resend, so it can
111+ * extend the expiry by at most the reconnect delay. The write only lands when no sum is stored
112+ * (`NX`), so an older sum can never replace a fresher one or extend its expiry.
101113 */
102- function writeSharedReportingUsageCost ( key : string , cost : number ) : void {
114+ function writeSharedReportingUsageCost ( key : string , cost : number , sumStartedAt : number ) : void {
103115 try {
104116 const redis = readyRedisClient ( )
105117 if ( ! redis ) return
106- const ttlMs = REPORTING_USAGE_CACHE_TTL_MS + randomInt ( 0 , SHARED_TTL_JITTER_MS )
118+ const remainingMs =
119+ sumStartedAt + REPORTING_USAGE_CACHE_TTL_MS + randomInt ( 0 , SHARED_TTL_JITTER_MS ) - Date . now ( )
107120 redis
108- . set ( sharedReportingUsageKey ( key ) , String ( cost ) , 'PX' , ttlMs , 'NX' )
121+ . set (
122+ sharedReportingUsageKey ( key ) ,
123+ String ( cost ) ,
124+ 'PX' ,
125+ Math . max ( remainingMs , MIN_SHARED_TTL_MS ) ,
126+ 'NX'
127+ )
109128 . catch ( warnSharedWriteFailed )
110129 } catch ( error ) {
111130 warnSharedWriteFailed ( error )
@@ -124,8 +143,9 @@ async function sumReportingUsageCost(
124143) : Promise < number > {
125144 const shared = await readSharedReportingUsageCost ( key )
126145 if ( shared !== undefined ) return shared
146+ const sumStartedAt = Date . now ( )
127147 const cost = await getBillingPeriodUsageCost ( entity , period )
128- writeSharedReportingUsageCost ( key , cost )
148+ writeSharedReportingUsageCost ( key , cost , sumStartedAt )
129149 return cost
130150}
131151
0 commit comments