Skip to content

Commit 13e70b4

Browse files
committed
fix: metering buffer improvements for manual editing
1 parent 0527bcd commit 13e70b4

4 files changed

Lines changed: 185 additions & 15 deletions

File tree

src/backend/services/metering/MeteringService.test.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import type { Actor } from '../../core/actor.ts';
1212
import { SYSTEM_ACTOR } from '../../core/actor.ts';
1313
import { PuterServer } from '../../server.ts';
14+
import { bucketTag } from '../../stores/metering/MeteringBufferStore.ts';
1415
import { setupTestServer } from '../../testUtil.ts';
1516
import {
1617
DEFAULT_FREE_SUBSCRIPTION,
@@ -1245,6 +1246,43 @@ describe('MeteringService', () => {
12451246
);
12461247
});
12471248

1249+
it('stays set once the adjustment has been written onward', async () => {
1250+
await target.incrementUsage(actor, 'kv:read', 1, 10_000);
1251+
await server.stores.meteringBuffer.flushCycle();
1252+
1253+
await target.setActorCurrentMonthUsageTotal(actor, 0);
1254+
// The adjustment is buffered like any other amount, so the read
1255+
// that matters is the one after it has settled — a correction that
1256+
// only holds until then is a correction nobody keeps.
1257+
await server.stores.meteringBuffer.flushCycle();
1258+
1259+
const { usage } =
1260+
await target.getActorCurrentMonthUsageDetails(actor);
1261+
expect(usage.total).toBe(0);
1262+
expect(usage.allowanceUsed).toBe(0);
1263+
});
1264+
1265+
it('repairs a cached view that has drifted from the record', async () => {
1266+
await target.incrementUsage(actor, 'kv:read', 1, 10_000);
1267+
await server.stores.meteringBuffer.flushCycle();
1268+
1269+
// Whatever the drift came from, re-applying the total the record
1270+
// already holds is the support-facing repair for it, so it has to
1271+
// take even though there is nothing to write.
1272+
const key = `${METRICS_PREFIX}:actor:${actor.user.uuid}:${new Date().toISOString().slice(0, 7)}`;
1273+
await server.clients.redis.hset(
1274+
`meter:b:{${bucketTag(key)}}:${key}`,
1275+
'total',
1276+
'999999',
1277+
);
1278+
1279+
await target.setActorCurrentMonthUsageTotal(actor, 10_000);
1280+
1281+
const { usage } =
1282+
await target.getActorCurrentMonthUsageDetails(actor);
1283+
expect(usage.total).toBe(10_000);
1284+
});
1285+
12481286
it('rejects a negative total', async () => {
12491287
await expect(
12501288
target.setActorCurrentMonthUsageTotal(actor, -1),
@@ -1448,9 +1486,7 @@ describe('MeteringService', () => {
14481486
});
14491487

14501488
const allowed = await target.getAllowedUsage(actor);
1451-
expect(allowed.remaining).toBe(
1452-
sub.monthUsageAllowance - 1_000_000,
1453-
);
1489+
expect(allowed.remaining).toBe(sub.monthUsageAllowance - 1_000_000);
14541490
});
14551491

14561492
it('folds the legacy baseline in exactly once under concurrent increments', async () => {

src/backend/services/metering/MeteringService.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -944,7 +944,15 @@ export class MeteringService extends PuterService {
944944
subscription.monthUsageAllowance,
945945
);
946946

947+
// The record already reads as asked, so there is nothing to write — but
948+
// an adjustment is also how a cached view that has drifted from the
949+
// record gets repaired, and answering "already correct" from the record
950+
// while readers keep being told something else is how that drift
951+
// survives being corrected at all. Drop the view either way.
952+
await this.stores.meteringBuffer.forgetBase(actorUsageKey);
953+
947954
if (delta === 0 && allowanceUsedDelta === 0) {
955+
this.invalidateActorCredits(userId);
948956
return (current as UsageByType) || ({ total: 0 } as UsageByType);
949957
}
950958

src/backend/stores/metering/MeteringBufferStore.test.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,13 +503,14 @@ describe('MeteringBufferStore', () => {
503503
).toBe('10');
504504
});
505505

506-
it('does not let the base total move backwards', async () => {
506+
it('adopts the view of a re-driven claim it wrote onward', async () => {
507507
await target.incr({ key, pathAndAmountMap: { total: 100 } });
508508
await target.flushCycle();
509509

510510
const tag = bucketTag(key);
511511
const base = `meter:b:{${tag}}:${key}`;
512-
// A settle carrying an older, smaller view must not win.
512+
// A claim another deployment left behind, re-driven here: its
513+
// amounts reach the store, so the base takes what came back.
513514
await server.clients.redis.hset(
514515
`meter:p:{${tag}}:stalenonce`,
515516
'total',
@@ -527,6 +528,67 @@ describe('MeteringBufferStore', () => {
527528
);
528529
});
529530

531+
it('does not let a settle replace a base laid down after it', async () => {
532+
await target.incr({ key, pathAndAmountMap: { total: 100 } });
533+
await target.flushCycle();
534+
535+
const tag = bucketTag(key);
536+
const base = `meter:b:{${tag}}:${key}`;
537+
// Stand in for a settle that finished later: this one's view of the
538+
// store is the older of the two however large its total is.
539+
await server.clients.redis.set(
540+
`meter:bq:{${tag}}:${key}`,
541+
'1000000000',
542+
);
543+
544+
await target.incr({ key, pathAndAmountMap: { total: 5 } });
545+
await target.flushCycle();
546+
547+
expect(await storedTotal(key)).toBe(105);
548+
expect(Number(await server.clients.redis.hget(base, 'total'))).toBe(
549+
100,
550+
);
551+
});
552+
553+
it('seeds a forgotten base from the store, keeping what is buffered', async () => {
554+
await target.incr({ key, pathAndAmountMap: { total: 100 } });
555+
await target.flushCycle();
556+
await target.incr({ key, pathAndAmountMap: { total: 5 } });
557+
558+
const tag = bucketTag(key);
559+
// Stand in for a cached view that no longer matches the record.
560+
await server.clients.redis.hset(
561+
`meter:b:{${tag}}:${key}`,
562+
'total',
563+
'999',
564+
);
565+
await target.forgetBase(key);
566+
567+
const { res } = await target.get({ key });
568+
expect((res as { total: number }).total).toBe(105);
569+
});
570+
571+
it('lets a correction take the base down', async () => {
572+
// The counter is authoritative in both directions: an amount can be
573+
// corrected downwards, and reads have to follow it down rather than
574+
// answer with the number the correction replaced.
575+
await target.incr({
576+
key,
577+
pathAndAmountMap: { total: 100, allowanceUsed: 100 },
578+
});
579+
await target.flushCycle();
580+
581+
await target.incr({
582+
key,
583+
pathAndAmountMap: { total: -100, allowanceUsed: -100 },
584+
});
585+
await target.flushCycle();
586+
587+
expect(await storedTotal(key)).toBe(0);
588+
const { res } = await target.get({ key });
589+
expect(res).toEqual({ total: 0, allowanceUsed: 0 });
590+
});
591+
530592
it('flushes many counters in one cycle', async () => {
531593
const keys = Array.from(
532594
{ length: 25 },

src/backend/stores/metering/MeteringBufferStore.ts

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,27 @@ export const bucketTag = (key: string): string =>
136136
const deltaKey = (tag: string, key: string): string =>
137137
`meter:d:{${tag}}:${key}`;
138138
const 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}}`;
139160
const dirtyKey = (tag: string): string => `meter:dirty:{${tag}}`;
140161
const 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
*/
413440
const SETTLE_SCRIPT = `
414-
local current = redis.call('HGET', KEYS[1], 'total')
441+
local current = redis.call('GET', KEYS[6])
415442
local 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
418445
end
419446
if 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])
425453
end
426454
redis.call('DEL', KEYS[2])
427455
redis.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

Comments
 (0)