@@ -136,6 +136,27 @@ export const bucketTag = (key: string): string =>
136136const deltaKey = ( tag : string , key : string ) : string =>
137137 `meter:d:{${ tag } }:${ key } ` ;
138138const baseKey = ( tag : string , key : string ) : string => `meter:b:{${ tag } }:${ key } ` ;
139+ /**
140+ * Which settle a counter's base came from — see `seqKey`. Kept beside the base
141+ * rather than in it, so the base holds amounts and nothing else: a bookkeeping
142+ * field inside it would read back as a counter path of its own.
143+ */
144+ const baseSeqKey = ( tag : string , key : string ) : string =>
145+ `meter:bq:{${ tag } }:${ key } ` ;
146+ /**
147+ * Hands out the ordering token a settle carries, one sequence per bucket.
148+ *
149+ * A settle replaces the base with what the store returned, so of two settles
150+ * for the same counter the one that finished writing last is the one holding
151+ * the newer view — and that is the only thing the base has to be ordered by.
152+ * The token is taken the moment the write comes back, so the order the tokens
153+ * are in is the order the writes completed in.
154+ *
155+ * Deliberately without a TTL: it orders every settle the bucket will ever do,
156+ * and one that started over would hand out tokens the stamps already written
157+ * are ahead of, leaving those bases in place until it caught up again.
158+ */
159+ const seqKey = ( tag : string ) : string => `meter:q:{${ tag } }` ;
139160const dirtyKey = ( tag : string ) : string => `meter:dirty:{${ tag } }` ;
140161const pendingKey = ( tag : string , nonce : string ) : string =>
141162 `meter:p:{${ tag } }:${ nonce } ` ;
@@ -398,30 +419,37 @@ return { 1, redis.call('HGETALL', KEYS[2]) }
398419` ;
399420
400421/**
401- * KEYS: base, pending, pending index, delta, tracked set. ARGV: nonce, ttl, new
402- * total (or '') , member, then the authoritative path/value pairs.
422+ * KEYS: base, pending, pending index, delta, tracked set, base sequence . ARGV:
423+ * nonce, ttl, sequence , member, then the authoritative path/value pairs.
403424 *
404425 * Replaces the base with what the KV store now holds, which is how the base
405- * picks up other deployments' contributions without a separate read. The total
406- * may not move backwards: two flushes settling out of order would otherwise
407- * briefly under-report, and this total decides whether someone may spend.
426+ * picks up other deployments' contributions without a separate read. Two
427+ * flushes settling out of order must not leave the older view in place, so a
428+ * settle only replaces a base laid down by a settle that finished before it —
429+ * ordered by the sequence each took when its write came back.
430+ *
431+ * Ordering by sequence rather than by which view holds the larger total is what
432+ * lets a counter go down at all: an amount can be corrected downwards, and
433+ * comparing totals reads that correction as the stale view it must refuse,
434+ * pinning the base to the pre-correction number for as long as it lives.
408435 *
409436 * The counter leaves the tracked set here, but only if nothing has started a
410437 * fresh delta for it in the meantime — checking and removing in the same step
411438 * is what stops an increment that landed mid-settle from being forgotten.
412439 */
413440const SETTLE_SCRIPT = `
414- local current = redis.call('HGET ', KEYS[1], 'total' )
441+ local current = redis.call('GET ', KEYS[6] )
415442local replace = true
416- if ARGV[3] ~= '' and current then
417- if tonumber(current) > tonumber(ARGV[3]) then replace = false end
443+ if current and tonumber( current) > tonumber(ARGV[3]) then
444+ replace = false
418445end
419446if replace then
420447 redis.call('DEL', KEYS[1])
421448 for i = 5, #ARGV, 2 do
422449 redis.call('HSET', KEYS[1], ARGV[i], ARGV[i + 1])
423450 end
424451 redis.call('PEXPIRE', KEYS[1], ARGV[2])
452+ redis.call('SET', KEYS[6], ARGV[3], 'PX', ARGV[2])
425453end
426454redis.call('DEL', KEYS[2])
427455redis.call('HDEL', KEYS[3], ARGV[1])
@@ -505,6 +533,7 @@ type ScriptRunner = {
505533 meterRetire ( ...args : string [ ] ) : Promise < number > ;
506534 meterReconcile ( ...args : string [ ] ) : Promise < number > ;
507535 meterSeed ( ...args : string [ ] ) : Promise < string [ ] > ;
536+ incr ( key : string ) : Promise < number > ;
508537} ;
509538
510539// -- MeteringBufferStore ----------------------------------------------
@@ -650,6 +679,35 @@ export class MeteringBufferStore extends PuterStore {
650679 return this . stores . kv . get ( { key, consistentRead : true } ) ;
651680 }
652681
682+ /**
683+ * Forget the cached view of what the store holds for a counter, so the next
684+ * read seeds it from the store again.
685+ *
686+ * For a counter corrected outside this buffer's own accounting — an
687+ * adjustment applied to the record itself rather than metered onto it —
688+ * where the cached view would otherwise keep answering with what it had
689+ * until the correction settles. Buffered increments are deliberately left
690+ * alone: they are amounts the store hasn't seen yet, and the seeded view is
691+ * what they are added to.
692+ *
693+ * Never throws. The correction is in the store either way; failing the call
694+ * that made it over a cache that is about to be replaced anyway would be
695+ * the worse outcome.
696+ */
697+ async forgetBase ( key : string ) : Promise < void > {
698+ const tag = bucketTag ( key ) ;
699+ try {
700+ await this . clients . redis . del (
701+ baseKey ( tag , key ) ,
702+ baseSeqKey ( tag , key ) ,
703+ ) ;
704+ } catch ( e ) {
705+ console . warn (
706+ `[metering] cached base not dropped for ${ key } : ${ ( e as Error ) . message } ` ,
707+ ) ;
708+ }
709+ }
710+
653711 // -- Internals: client & scripts ----------------------------------
654712
655713 get #redis( ) : ScriptRunner {
@@ -682,7 +740,7 @@ export class MeteringBufferStore extends PuterStore {
682740 lua : RECLAIM_SCRIPT ,
683741 } ) ;
684742 client . defineCommand ( 'meterSettle' , {
685- numberOfKeys : 5 ,
743+ numberOfKeys : 6 ,
686744 lua : SETTLE_SCRIPT ,
687745 } ) ;
688746 client . defineCommand ( 'meterRetire' , {
@@ -1113,16 +1171,22 @@ export class MeteringBufferStore extends PuterStore {
11131171 return ;
11141172 }
11151173
1174+ // Taken here rather than before the writes: what the base has to be
1175+ // ordered by is which settle came away with the newer view of the
1176+ // store, and that is decided by the write that just returned.
1177+ const seq = await this . #redis. incr ( seqKey ( tag ) ) ;
1178+
11161179 const flat = flattenAmounts ( settled ) ;
11171180 await this . #redis. meterSettle (
11181181 baseKey ( tag , key ) ,
11191182 pendingKey ( tag , nonce ) ,
11201183 pendingIndexKey ( tag ) ,
11211184 deltaKey ( tag , key ) ,
11221185 trackedKey ( tag ) ,
1186+ baseSeqKey ( tag , key ) ,
11231187 nonce ,
11241188 String ( BUFFER_TTL_MS ) ,
1125- flat [ 'total' ] === undefined ? '' : String ( flat [ 'total' ] ) ,
1189+ String ( seq ) ,
11261190 key ,
11271191 ...toScriptArgs ( flat ) ,
11281192 ) ;
0 commit comments