@helyx/bot 0.4.9 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1281 @@
1
+ import {} from "@helyx/sdk";
2
+ import {} from "@helyx/platform";
3
+ import { randomUUID } from "node:crypto";
4
+ import { createClient } from "redis";
5
+ const REDIS_FLUSH_INTERVAL_MS = 30_000;
6
+ const REDIS_BATCH_SIZE = 100;
7
+ const REDIS_CAPACITY = 10_000;
8
+ const REDIS_CLAIM_LEASE_MS = 30_000;
9
+ const REDIS_RETRY_BASE_MS = 250;
10
+ const REDIS_RETRY_MAX_MS = 30_000;
11
+ const REDIS_BARRIER_LEASE_MS = 60_000;
12
+ const REDIS_SUPPRESSION_DRAIN_TIMEOUT_MS = 5_000;
13
+ const REDIS_SUPPRESSION_POLL_MS = 25;
14
+ // Stay inside the database receipt's 30-day retention window.
15
+ const REDIS_RETRY_RETENTION_MS = 29 * 24 * 60 * 60 * 1_000;
16
+ const SAFE_NAMESPACE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
17
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
18
+ export class RedisNumericWriteBehindService {
19
+ #redis;
20
+ #prefix;
21
+ #writer;
22
+ #registrations = new Map();
23
+ #subscribers = new Map();
24
+ #subjectSuppressions = new Map();
25
+ #guildSuppressions = new Map();
26
+ #flushIntervalMs;
27
+ #capacity;
28
+ #onError;
29
+ #supervisorId;
30
+ #shardLeaseKeyPrefix;
31
+ #barrierLeaseMs;
32
+ #suppressionDrainTimeoutMs;
33
+ #suppressionPollMs;
34
+ #timer;
35
+ #healthTimer;
36
+ #flushTail = Promise.resolve();
37
+ #started = false;
38
+ #accepting = true;
39
+ #health = emptyHealth();
40
+ constructor(options) {
41
+ if (!SAFE_NAMESPACE.test(options.namespace))
42
+ throw new Error("Numeric write-behind Redis namespace is invalid");
43
+ if (!Number.isSafeInteger(options.capacity ?? REDIS_CAPACITY) ||
44
+ (options.capacity ?? REDIS_CAPACITY) < 1 ||
45
+ (options.capacity ?? REDIS_CAPACITY) > 100_000)
46
+ throw new Error("Numeric write-behind Redis capacity is invalid");
47
+ this.#redis =
48
+ options.client ??
49
+ createClient({ url: options.redisUrl });
50
+ this.#prefix = `helyx:numeric-write-behind:v1:${options.namespace}`;
51
+ this.#writer = options.writer;
52
+ this.#flushIntervalMs = options.flushIntervalMs ?? REDIS_FLUSH_INTERVAL_MS;
53
+ this.#capacity = options.capacity ?? REDIS_CAPACITY;
54
+ this.#onError = options.onError;
55
+ this.#supervisorId = options.supervisorId;
56
+ this.#shardLeaseKeyPrefix = options.shardLeaseKeyPrefix;
57
+ this.#barrierLeaseMs = options.barrierLeaseMs ?? REDIS_BARRIER_LEASE_MS;
58
+ this.#suppressionDrainTimeoutMs =
59
+ options.suppressionDrainTimeoutMs ?? REDIS_SUPPRESSION_DRAIN_TIMEOUT_MS;
60
+ this.#suppressionPollMs =
61
+ options.suppressionPollMs ?? REDIS_SUPPRESSION_POLL_MS;
62
+ if (!Number.isSafeInteger(this.#barrierLeaseMs) ||
63
+ this.#barrierLeaseMs < 1_000 ||
64
+ this.#barrierLeaseMs > 10 * 60_000)
65
+ throw new Error("Numeric write-behind barrier lease is invalid");
66
+ if (!Number.isSafeInteger(this.#suppressionDrainTimeoutMs) ||
67
+ this.#suppressionDrainTimeoutMs < 1 ||
68
+ this.#suppressionDrainTimeoutMs > 60_000 ||
69
+ !Number.isSafeInteger(this.#suppressionPollMs) ||
70
+ this.#suppressionPollMs < 1 ||
71
+ this.#suppressionPollMs > 1_000)
72
+ throw new Error("Numeric write-behind suppression wait is invalid");
73
+ if ((this.#supervisorId === undefined) !==
74
+ (this.#shardLeaseKeyPrefix === undefined))
75
+ throw new Error("Redis numeric shard fencing configuration is incomplete");
76
+ for (const registration of options.registrations) {
77
+ const key = scopeKey(registration);
78
+ if (this.#registrations.has(key))
79
+ throw new Error("Duplicate numeric write-behind Redis registration");
80
+ this.#registrations.set(key, registration);
81
+ }
82
+ }
83
+ async start() {
84
+ if (this.#started)
85
+ return;
86
+ await this.#redis.connect();
87
+ this.#started = true;
88
+ this.#timer = setInterval(() => {
89
+ void this.#serial(() => this.#drainAll(false)).catch((error) => this.#onError?.(toError(error)));
90
+ }, this.#flushIntervalMs);
91
+ this.#timer.unref();
92
+ const refreshHealth = () => {
93
+ void this.#refreshHealth().catch((error) => this.#onError?.(toError(error)));
94
+ };
95
+ refreshHealth();
96
+ this.#healthTimer = setInterval(refreshHealth, Math.min(this.#flushIntervalMs, 5_000));
97
+ this.#healthTimer.unref();
98
+ }
99
+ async enqueue(input) {
100
+ if (this.#supervisorId)
101
+ return { outcome: "unavailable" };
102
+ return this.#enqueue(input);
103
+ }
104
+ enqueueFenced(input, fence) {
105
+ return this.#enqueue(input, fence);
106
+ }
107
+ async #enqueue(input, fence) {
108
+ this.#registration(input);
109
+ if (!this.#started)
110
+ return { outcome: "unavailable" };
111
+ if (!this.#accepting)
112
+ return { outcome: "paused" };
113
+ try {
114
+ const result = await this.#redis.eval(ENQUEUE_SCRIPT, {
115
+ keys: [...keysFor(this.#prefix, input), fence?.leaseKey ?? ""],
116
+ arguments: [
117
+ memberKey(input.guildId, input.subjectId),
118
+ input.guildId,
119
+ input.subjectId,
120
+ String(input.occurredAt.getTime()),
121
+ String(input.cooldownSeconds * 1_000),
122
+ JSON.stringify(encodeEntry(input, fence?.shardId)),
123
+ String(this.#capacity),
124
+ fence?.leaseValue ?? "",
125
+ String(Date.now()),
126
+ ],
127
+ });
128
+ const outcomes = [
129
+ "accepted",
130
+ "cooldown",
131
+ "suppressed",
132
+ "paused",
133
+ "capacity",
134
+ "unavailable",
135
+ ];
136
+ const outcome = outcomes[Number(result) - 1];
137
+ if (!outcome)
138
+ throw new Error("Redis enqueue returned an invalid outcome");
139
+ if (outcome !== "accepted")
140
+ this.#health = {
141
+ ...this.#health,
142
+ rejectedCount: this.#health.rejectedCount + 1,
143
+ };
144
+ return { outcome };
145
+ }
146
+ catch (error) {
147
+ this.#onError?.(toError(error));
148
+ return { outcome: "unavailable" };
149
+ }
150
+ }
151
+ async pendingMemberSnapshot(input) {
152
+ this.#assertAvailable(input);
153
+ const raw = await this.#redis.eval(SNAPSHOT_SCRIPT, {
154
+ keys: keysFor(this.#prefix, input),
155
+ arguments: [input.guildId, input.subjectId],
156
+ });
157
+ const entries = parseEntries(raw);
158
+ const increments = {};
159
+ const maxima = {};
160
+ let oldest = null;
161
+ for (const entry of entries) {
162
+ for (const [field, value] of Object.entries(entry.increments))
163
+ increments[field] = (increments[field] ?? 0) + value;
164
+ for (const [field, value] of Object.entries(entry.maxima)) {
165
+ const candidate = new Date(value);
166
+ const current = maxima[field];
167
+ if (!current || candidate > current)
168
+ maxima[field] = candidate;
169
+ }
170
+ oldest =
171
+ oldest === null ? entry.acceptedAt : Math.min(oldest, entry.acceptedAt);
172
+ }
173
+ return {
174
+ increments,
175
+ maxima,
176
+ oldestAcceptedAt: oldest === null ? null : new Date(oldest),
177
+ };
178
+ }
179
+ flushMember(input) {
180
+ return this.#flush(input, input.guildId, input.subjectId, true);
181
+ }
182
+ flushGuild(input) {
183
+ return this.#flush(input, input.guildId, "", true);
184
+ }
185
+ flushSubject(input) {
186
+ return this.#flush(input, "", input.subjectId, true);
187
+ }
188
+ flushModule(input) {
189
+ return this.#flush(input, "", "", true);
190
+ }
191
+ async runMemberBarrier(input, operation) {
192
+ const barrierId = await this.#acquireBarrier(input, "member", input.guildId, input.subjectId);
193
+ const stopRenewal = this.#startBarrierRenewal(input, "member", input.guildId, input.subjectId, barrierId);
194
+ try {
195
+ await this.flushMember(input);
196
+ return await operation();
197
+ }
198
+ finally {
199
+ stopRenewal();
200
+ await this.#releaseBarrier(input, "member", input.guildId, input.subjectId, barrierId);
201
+ }
202
+ }
203
+ async runGuildBarrier(input, operation) {
204
+ const barrierId = await this.#acquireBarrier(input, "guild", input.guildId, "");
205
+ const stopRenewal = this.#startBarrierRenewal(input, "guild", input.guildId, "", barrierId);
206
+ try {
207
+ await this.flushGuild(input);
208
+ return await operation();
209
+ }
210
+ finally {
211
+ stopRenewal();
212
+ await this.#releaseBarrier(input, "guild", input.guildId, "", barrierId);
213
+ }
214
+ }
215
+ async runSubjectBarrier(input, operation) {
216
+ const barrierId = await this.#acquireBarrier(input, "subject", "", input.subjectId);
217
+ const stopRenewal = this.#startBarrierRenewal(input, "subject", "", input.subjectId, barrierId);
218
+ try {
219
+ await this.flushSubject(input);
220
+ return await operation();
221
+ }
222
+ finally {
223
+ stopRenewal();
224
+ await this.#releaseBarrier(input, "subject", "", input.subjectId, barrierId);
225
+ }
226
+ }
227
+ async suppressSubject(input) {
228
+ this.#assertAvailable(input);
229
+ const suppressionId = randomUUID();
230
+ const result = await this.#redis.eval(SUPPRESS_SUBJECT_SCRIPT, {
231
+ keys: keysFor(this.#prefix, input),
232
+ arguments: [
233
+ input.subjectId,
234
+ String(input.cutoff.getTime()),
235
+ suppressionId,
236
+ ],
237
+ });
238
+ const discarded = Number(result);
239
+ if (!Number.isSafeInteger(discarded) || discarded < 0)
240
+ throw new Error("Redis suppression returned an invalid count");
241
+ this.#subjectSuppressions.set(subjectSuppressionKey(input, input.subjectId), { suppressionId, cutoff: new Date(input.cutoff) });
242
+ await this.#waitForSuppression(input, SUPPRESSION_STATUS_SCRIPT, [
243
+ input.subjectId,
244
+ String(input.cutoff.getTime()),
245
+ ]);
246
+ return { suppressionId, discarded };
247
+ }
248
+ async suppressGuild(input) {
249
+ this.#assertAvailable(input);
250
+ const suppressionId = randomUUID();
251
+ const discarded = Number(await this.#redis.eval(SUPPRESS_GUILD_SCRIPT, {
252
+ keys: keysFor(this.#prefix, input),
253
+ arguments: [input.guildId, suppressionId],
254
+ }));
255
+ if (!Number.isSafeInteger(discarded) || discarded < 0)
256
+ throw new Error("Redis guild suppression returned an invalid count");
257
+ this.#guildSuppressions.set(guildSuppressionKey(input, input.guildId), suppressionId);
258
+ await this.#waitForSuppression(input, GUILD_SUPPRESSION_STATUS_SCRIPT, [
259
+ input.guildId,
260
+ ]);
261
+ return { suppressionId, discarded };
262
+ }
263
+ async releaseGuild(input) {
264
+ this.#assertAvailable(input);
265
+ const released = Number(await this.#redis.eval(RELEASE_GUILD_SCRIPT, {
266
+ keys: keysFor(this.#prefix, input),
267
+ arguments: [input.guildId, input.suppressionId],
268
+ })) === 1;
269
+ if (released)
270
+ this.#guildSuppressions.delete(guildSuppressionKey(input, input.guildId));
271
+ return { released };
272
+ }
273
+ async releaseSubject(input) {
274
+ this.#assertAvailable(input);
275
+ const result = await this.#redis.eval(RELEASE_SUBJECT_SCRIPT, {
276
+ keys: keysFor(this.#prefix, input),
277
+ arguments: [input.subjectId, input.suppressionId],
278
+ });
279
+ const released = Number(result) === 1;
280
+ if (released)
281
+ this.#subjectSuppressions.delete(subjectSuppressionKey(input, input.subjectId));
282
+ return { released };
283
+ }
284
+ async clearMemberCooldown(input) {
285
+ this.#assertAvailable(input);
286
+ await this.#redis.eval(CLEAR_COOLDOWN_SCRIPT, {
287
+ keys: keysFor(this.#prefix, input),
288
+ arguments: [memberKey(input.guildId, input.subjectId), input.subjectId],
289
+ });
290
+ }
291
+ async clearSubjectCooldown(input) {
292
+ this.#assertAvailable(input);
293
+ await this.#redis.eval(CLEAR_SUBJECT_COOLDOWNS_SCRIPT, {
294
+ keys: keysFor(this.#prefix, input),
295
+ arguments: [input.subjectId],
296
+ });
297
+ }
298
+ subscribeCommitted(input, handler) {
299
+ this.#registration(input);
300
+ const key = scopeKey(input);
301
+ const subscribers = this.#subscribers.get(key) ?? new Set();
302
+ subscribers.add(handler);
303
+ this.#subscribers.set(key, subscribers);
304
+ return () => {
305
+ subscribers.delete(handler);
306
+ if (subscribers.size === 0)
307
+ this.#subscribers.delete(key);
308
+ };
309
+ }
310
+ health() {
311
+ return this.#health;
312
+ }
313
+ acquireBarrier(input) {
314
+ return this.#acquireBarrier(input.scope, input.barrierType, input.guildId ?? "", input.subjectId ?? "").then((barrierId) => ({ barrierId }));
315
+ }
316
+ releaseBarrier(input) {
317
+ return this.#releaseBarrier(input.scope, input.barrierType, input.guildId ?? "", input.subjectId ?? "", input.barrierId);
318
+ }
319
+ renewBarrier(input) {
320
+ return this.#renewBarrier(input.scope, input.barrierType, input.guildId ?? "", input.subjectId ?? "", input.barrierId);
321
+ }
322
+ async stop(timeoutMs = 5_000) {
323
+ this.#accepting = false;
324
+ this.#health = { ...this.#health, accepting: false };
325
+ if (this.#timer)
326
+ clearInterval(this.#timer);
327
+ if (this.#healthTimer)
328
+ clearInterval(this.#healthTimer);
329
+ if (!this.#started)
330
+ return { drained: true };
331
+ let timeout;
332
+ const drain = this.#serial(() => this.#drainAll(true));
333
+ let drainError;
334
+ const completed = drain.then(() => true, (error) => {
335
+ drainError = error;
336
+ return true;
337
+ });
338
+ const finished = await Promise.race([
339
+ completed,
340
+ new Promise((resolve) => {
341
+ timeout = setTimeout(() => resolve(false), timeoutMs);
342
+ timeout.unref();
343
+ }),
344
+ ]);
345
+ if (timeout)
346
+ clearTimeout(timeout);
347
+ if (finished) {
348
+ await this.#redis.quit();
349
+ this.#started = false;
350
+ if (drainError)
351
+ throw toError(drainError);
352
+ }
353
+ else {
354
+ void completed
355
+ .then(() => this.#redis.quit().finally(() => {
356
+ this.#started = false;
357
+ }))
358
+ .catch((error) => this.#onError?.(toError(error)));
359
+ }
360
+ return { drained: finished };
361
+ }
362
+ #flush(scope, guildId, subjectId, force) {
363
+ this.#assertAvailable(scope);
364
+ return this.#serial(() => this.#drainRegistration(this.#registration(scope), guildId, subjectId, force));
365
+ }
366
+ #serial(run) {
367
+ const result = this.#flushTail.then(run, run);
368
+ this.#flushTail = result.catch(() => undefined);
369
+ return result;
370
+ }
371
+ async #drainAll(force) {
372
+ for (const registration of this.#registrations.values())
373
+ await this.#drainRegistration(registration, "", "", force);
374
+ await this.#refreshHealth().catch((error) => this.#onError?.(toError(error)));
375
+ }
376
+ async #refreshHealth() {
377
+ let pendingEntries = 0;
378
+ let retryBatches = 0;
379
+ let quarantinedBatches = 0;
380
+ let suppressedGuilds = 0;
381
+ let suppressedMembers = 0;
382
+ let suppressedSubjects = 0;
383
+ let oldestAcceptedAt = null;
384
+ for (const registration of this.#registrations.values()) {
385
+ const raw = await this.#redis.eval(HEALTH_SCRIPT, {
386
+ keys: keysFor(this.#prefix, registration),
387
+ arguments: [],
388
+ });
389
+ if (typeof raw !== "string")
390
+ throw new Error("Redis numeric health response was invalid");
391
+ const parsed = JSON.parse(raw);
392
+ if (!isRedisHealthSnapshot(parsed))
393
+ throw new Error("Redis numeric health response was invalid");
394
+ pendingEntries += parsed.pendingEntries;
395
+ retryBatches += parsed.retryBatches;
396
+ quarantinedBatches += parsed.quarantinedBatches;
397
+ suppressedGuilds += parsed.suppressedGuilds;
398
+ suppressedMembers += parsed.suppressedMembers;
399
+ suppressedSubjects += parsed.suppressedSubjects;
400
+ if (parsed.oldestAcceptedAt !== null)
401
+ oldestAcceptedAt =
402
+ oldestAcceptedAt === null
403
+ ? parsed.oldestAcceptedAt
404
+ : Math.min(oldestAcceptedAt, parsed.oldestAcceptedAt);
405
+ }
406
+ this.#health = {
407
+ ...this.#health,
408
+ accepting: this.#accepting,
409
+ pendingEntries,
410
+ retryBatches,
411
+ quarantinedBatches,
412
+ oldestPendingAgeMs: oldestAcceptedAt === null
413
+ ? null
414
+ : Math.max(0, Date.now() - oldestAcceptedAt),
415
+ suppressedGuilds,
416
+ suppressedMembers,
417
+ suppressedSubjects,
418
+ };
419
+ }
420
+ async #drainRegistration(registration, guildId, subjectId, force) {
421
+ while (true) {
422
+ const proposedOperationId = randomUUID();
423
+ const raw = await this.#redis.eval(CLAIM_SCRIPT, {
424
+ keys: keysFor(this.#prefix, registration),
425
+ arguments: [
426
+ String(Date.now()),
427
+ String(REDIS_CLAIM_LEASE_MS),
428
+ proposedOperationId,
429
+ String(REDIS_BATCH_SIZE),
430
+ guildId,
431
+ subjectId,
432
+ force ? "1" : "0",
433
+ this.#supervisorId ?? "",
434
+ this.#shardLeaseKeyPrefix ?? "",
435
+ String(REDIS_RETRY_RETENTION_MS),
436
+ ],
437
+ });
438
+ if (raw === null)
439
+ return;
440
+ const parsed = parseClaim(raw);
441
+ if ("empty" in parsed) {
442
+ this.#health = {
443
+ ...this.#health,
444
+ quarantinedBatches: parsed.quarantined,
445
+ };
446
+ return;
447
+ }
448
+ const claim = parsed;
449
+ const startedAt = Date.now();
450
+ this.#health = {
451
+ ...this.#health,
452
+ inFlightEntries: claim.entries.length,
453
+ };
454
+ try {
455
+ const output = await this.#writer.mutateNumericBatch({
456
+ moduleId: registration.moduleId,
457
+ collection: registration.collection,
458
+ operationId: claim.operationId,
459
+ uniqueBy: registration.uniqueBy,
460
+ mutations: claim.entries.map(decodeMutation),
461
+ select: registration.select,
462
+ });
463
+ if (output.results.length !== claim.entries.length)
464
+ throw new Error("Redis numeric writer result count was invalid");
465
+ const committed = {
466
+ moduleId: registration.moduleId,
467
+ collection: registration.collection,
468
+ operationId: claim.operationId,
469
+ replayed: output.replayed,
470
+ mutations: claim.entries.map(decodeMutation),
471
+ results: output.results,
472
+ };
473
+ for (const subscriber of this.#subscribers.get(scopeKey(registration)) ?? [])
474
+ await subscriber(committed);
475
+ const acknowledged = await this.#redis.eval(ACK_SCRIPT, {
476
+ keys: keysFor(this.#prefix, registration),
477
+ arguments: [claim.operationId],
478
+ });
479
+ if (Number(acknowledged) !== 1)
480
+ throw new Error("Redis numeric claim ownership changed");
481
+ }
482
+ catch (error) {
483
+ const attempt = claim.attempt + 1;
484
+ const retryAt = Date.now() +
485
+ Math.min(REDIS_RETRY_MAX_MS, REDIS_RETRY_BASE_MS * 2 ** Math.min(claim.attempt, 16));
486
+ await this.#redis.eval(RETRY_SCRIPT, {
487
+ keys: keysFor(this.#prefix, registration),
488
+ arguments: [claim.operationId, String(attempt), String(retryAt)],
489
+ });
490
+ this.#health = {
491
+ ...this.#health,
492
+ retryCount: this.#health.retryCount + 1,
493
+ inFlightEntries: 0,
494
+ lastFlushDurationMs: Math.max(0, Date.now() - startedAt),
495
+ };
496
+ this.#onError?.(toError(error));
497
+ if (force)
498
+ throw error;
499
+ return;
500
+ }
501
+ this.#health = {
502
+ ...this.#health,
503
+ inFlightEntries: 0,
504
+ lastFlushDurationMs: Math.max(0, Date.now() - startedAt),
505
+ };
506
+ }
507
+ }
508
+ async #acquireBarrier(scope, kind, guildId, subjectId) {
509
+ this.#assertAvailable(scope);
510
+ const barrierId = randomUUID();
511
+ const result = await this.#redis.eval(ACQUIRE_BARRIER_SCRIPT, {
512
+ keys: keysFor(this.#prefix, scope),
513
+ arguments: [
514
+ kind,
515
+ guildId,
516
+ subjectId,
517
+ barrierId,
518
+ String(Date.now()),
519
+ String(Date.now() + this.#barrierLeaseMs),
520
+ ],
521
+ });
522
+ if (Number(result) === 2)
523
+ throw new Error("Numeric write-behind member is suppressed");
524
+ if (Number(result) !== 1)
525
+ throw new Error("Numeric write-behind barrier is already held");
526
+ return barrierId;
527
+ }
528
+ async #releaseBarrier(scope, kind, guildId, subjectId, barrierId) {
529
+ const result = await this.#redis.eval(RELEASE_BARRIER_SCRIPT, {
530
+ keys: keysFor(this.#prefix, scope),
531
+ arguments: [kind, guildId, subjectId, barrierId, String(Date.now())],
532
+ });
533
+ if (Number(result) !== 1)
534
+ throw new Error("Numeric write-behind barrier ownership changed");
535
+ }
536
+ async #renewBarrier(scope, kind, guildId, subjectId, barrierId) {
537
+ const now = Date.now();
538
+ const result = await this.#redis.eval(RENEW_BARRIER_SCRIPT, {
539
+ keys: keysFor(this.#prefix, scope),
540
+ arguments: [
541
+ kind,
542
+ guildId,
543
+ subjectId,
544
+ barrierId,
545
+ String(now),
546
+ String(now + this.#barrierLeaseMs),
547
+ ],
548
+ });
549
+ if (Number(result) !== 1)
550
+ throw new Error("Numeric write-behind barrier lease expired");
551
+ }
552
+ #startBarrierRenewal(scope, kind, guildId, subjectId, barrierId) {
553
+ const timer = setInterval(() => {
554
+ void this.#renewBarrier(scope, kind, guildId, subjectId, barrierId).catch((error) => this.#onError?.(toError(error)));
555
+ }, Math.max(250, Math.floor(this.#barrierLeaseMs / 3)));
556
+ timer.unref();
557
+ return () => clearInterval(timer);
558
+ }
559
+ async #waitForSuppression(scope, script, arguments_) {
560
+ const deadline = Date.now() + this.#suppressionDrainTimeoutMs;
561
+ while (true) {
562
+ const active = Number(await this.#redis.eval(script, {
563
+ keys: keysFor(this.#prefix, scope),
564
+ arguments: arguments_,
565
+ }));
566
+ if (!Number.isSafeInteger(active) || active < 0)
567
+ throw new Error("Redis suppression status was invalid");
568
+ if (active === 0)
569
+ return;
570
+ if (Date.now() >= deadline)
571
+ throw new Error("Numeric write-behind suppression is waiting for active writes");
572
+ await delay(Math.min(this.#suppressionPollMs, Math.max(1, deadline - Date.now())));
573
+ }
574
+ }
575
+ #registration(scope) {
576
+ const registration = this.#registrations.get(scopeKey(scope));
577
+ if (!registration)
578
+ throw new Error("Unknown numeric write-behind Redis registration");
579
+ return registration;
580
+ }
581
+ #assertAvailable(scope) {
582
+ this.#registration(scope);
583
+ if (!this.#started)
584
+ throw new Error("Numeric write-behind Redis service is unavailable");
585
+ }
586
+ }
587
+ export async function executeRedisNumericOperation(service, operation, fence) {
588
+ const scoped = {
589
+ moduleId: operation.moduleId,
590
+ collection: operation.collection,
591
+ };
592
+ switch (operation.kind) {
593
+ case "enqueue": {
594
+ const input = {
595
+ ...scoped,
596
+ guildId: operation.guildId,
597
+ subjectId: operation.subjectId,
598
+ occurredAt: new Date(operation.occurredAt),
599
+ cooldownSeconds: operation.cooldownSeconds,
600
+ identity: decodeWireRecord(operation.identity),
601
+ insert: decodeWireRecord(operation.insert),
602
+ increments: operation.increments,
603
+ ...(operation.maxima
604
+ ? {
605
+ maxima: Object.fromEntries(Object.entries(operation.maxima).map(([field, value]) => [
606
+ field,
607
+ new Date(value),
608
+ ])),
609
+ }
610
+ : {}),
611
+ };
612
+ if (!fence)
613
+ return service.enqueue(input);
614
+ const lease = {
615
+ generation: fence.identity.generation,
616
+ deploymentOrder: fence.identity.deploymentOrder,
617
+ supervisorId: fence.identity.supervisorId,
618
+ shardId: fence.identity.shardId,
619
+ fencingToken: fence.identity.fencingToken,
620
+ };
621
+ return service.enqueueFenced(input, {
622
+ shardId: fence.identity.shardId,
623
+ leaseKey: `${fence.shardLeaseKeyPrefix}${String(fence.identity.shardId)}`,
624
+ leaseValue: JSON.stringify(lease),
625
+ });
626
+ }
627
+ case "pending.member":
628
+ return service.pendingMemberSnapshot({ ...scoped, ...operation });
629
+ case "flush.member":
630
+ await service.flushMember({ ...scoped, ...operation });
631
+ return null;
632
+ case "flush.guild":
633
+ await service.flushGuild({ ...scoped, guildId: operation.guildId });
634
+ return null;
635
+ case "flush.subject":
636
+ await service.flushSubject({
637
+ ...scoped,
638
+ subjectId: operation.subjectId,
639
+ });
640
+ return null;
641
+ case "flush.module":
642
+ await service.flushModule(scoped);
643
+ return null;
644
+ case "barrier.acquire":
645
+ return service.acquireBarrier({
646
+ scope: scoped,
647
+ barrierType: operation.barrierType,
648
+ ...(operation.guildId ? { guildId: operation.guildId } : {}),
649
+ ...(operation.subjectId ? { subjectId: operation.subjectId } : {}),
650
+ });
651
+ case "barrier.release":
652
+ await service.releaseBarrier({
653
+ scope: scoped,
654
+ barrierType: operation.barrierType,
655
+ ...(operation.guildId ? { guildId: operation.guildId } : {}),
656
+ ...(operation.subjectId ? { subjectId: operation.subjectId } : {}),
657
+ barrierId: operation.barrierId,
658
+ });
659
+ return null;
660
+ case "barrier.renew":
661
+ await service.renewBarrier({
662
+ scope: scoped,
663
+ barrierType: operation.barrierType,
664
+ ...(operation.guildId ? { guildId: operation.guildId } : {}),
665
+ ...(operation.subjectId ? { subjectId: operation.subjectId } : {}),
666
+ barrierId: operation.barrierId,
667
+ });
668
+ return null;
669
+ case "suppress.subject":
670
+ return service.suppressSubject({
671
+ ...scoped,
672
+ subjectId: operation.subjectId,
673
+ cutoff: new Date(operation.cutoff),
674
+ });
675
+ case "suppress.guild":
676
+ return service.suppressGuild({
677
+ ...scoped,
678
+ guildId: operation.guildId,
679
+ });
680
+ case "release.guild":
681
+ return service.releaseGuild({
682
+ ...scoped,
683
+ guildId: operation.guildId,
684
+ suppressionId: operation.suppressionId,
685
+ });
686
+ case "release.subject":
687
+ return service.releaseSubject({
688
+ ...scoped,
689
+ subjectId: operation.subjectId,
690
+ suppressionId: operation.suppressionId,
691
+ });
692
+ case "cooldown.member.clear":
693
+ await service.clearMemberCooldown({ ...scoped, ...operation });
694
+ return null;
695
+ case "cooldown.subject.clear":
696
+ await service.clearSubjectCooldown({
697
+ ...scoped,
698
+ subjectId: operation.subjectId,
699
+ });
700
+ return null;
701
+ }
702
+ }
703
+ function keysFor(prefix, scope) {
704
+ const root = `${prefix}:${scope.moduleId}:${scope.collection}`;
705
+ return [
706
+ `${root}:pending`,
707
+ `${root}:pending-index`,
708
+ `${root}:staged`,
709
+ `${root}:claims`,
710
+ `${root}:claim-retry`,
711
+ `${root}:cooldown:`,
712
+ `${root}:subject-cooldowns:`,
713
+ `${root}:suppressed-subjects`,
714
+ `${root}:paused-guilds`,
715
+ `${root}:paused-members`,
716
+ `${root}:paused-subjects`,
717
+ `${root}:subject-claims:`,
718
+ `${root}:suppressed-guilds`,
719
+ `${root}:guild-claims:`,
720
+ `${root}:quarantine`,
721
+ ];
722
+ }
723
+ function encodeEntry(input, shardId) {
724
+ return {
725
+ guildId: input.guildId,
726
+ subjectId: input.subjectId,
727
+ acceptedAt: input.occurredAt.getTime(),
728
+ ...(shardId === undefined ? {} : { shardId }),
729
+ identity: encodeRecord(input.identity),
730
+ insert: encodeRecord(input.insert),
731
+ increments: { ...input.increments },
732
+ maxima: Object.fromEntries(Object.entries(input.maxima ?? {}).map(([field, value]) => [
733
+ field,
734
+ value.toISOString(),
735
+ ])),
736
+ };
737
+ }
738
+ function encodeRecord(input) {
739
+ return Object.fromEntries(Object.entries(input).map(([field, value]) => [
740
+ field,
741
+ value instanceof Date
742
+ ? { type: "date", value: value.toISOString() }
743
+ : { type: typeof value, value },
744
+ ]));
745
+ }
746
+ function decodeMutation(entry) {
747
+ return {
748
+ identity: decodeRecord(entry.identity),
749
+ insert: decodeRecord(entry.insert),
750
+ increments: entry.increments,
751
+ ...(Object.keys(entry.maxima).length > 0
752
+ ? {
753
+ maxima: Object.fromEntries(Object.entries(entry.maxima).map(([field, value]) => [
754
+ field,
755
+ new Date(value),
756
+ ])),
757
+ }
758
+ : {}),
759
+ };
760
+ }
761
+ function decodeRecord(input) {
762
+ return Object.fromEntries(Object.entries(input).map(([field, value]) => [
763
+ field,
764
+ value.type === "date" ? new Date(value.value) : value.value,
765
+ ]));
766
+ }
767
+ function decodeWireRecord(input) {
768
+ return decodeRecord(input);
769
+ }
770
+ function parseClaim(raw) {
771
+ if (typeof raw !== "string")
772
+ throw new Error("Redis numeric claim was invalid");
773
+ const parsed = JSON.parse(raw);
774
+ if (typeof parsed === "object" &&
775
+ parsed !== null &&
776
+ "empty" in parsed &&
777
+ parsed.empty === true &&
778
+ "quarantined" in parsed &&
779
+ Number.isSafeInteger(parsed.quarantined) &&
780
+ parsed.quarantined >= 0)
781
+ return { empty: true, quarantined: parsed.quarantined };
782
+ if (typeof parsed !== "object" ||
783
+ parsed === null ||
784
+ !("operationId" in parsed) ||
785
+ typeof parsed.operationId !== "string" ||
786
+ !UUID.test(parsed.operationId) ||
787
+ !("entries" in parsed) ||
788
+ !Array.isArray(parsed.entries) ||
789
+ parsed.entries.length < 1 ||
790
+ parsed.entries.length > REDIS_BATCH_SIZE ||
791
+ !("attempt" in parsed) ||
792
+ !Number.isSafeInteger(parsed.attempt) ||
793
+ parsed.attempt < 0 ||
794
+ !("expiresAt" in parsed) ||
795
+ !Number.isFinite(parsed.expiresAt) ||
796
+ parsed.expiresAt <= 0)
797
+ throw new Error("Redis numeric claim was invalid");
798
+ return parsed;
799
+ }
800
+ function parseEntries(raw) {
801
+ if (typeof raw !== "string")
802
+ throw new Error("Redis numeric snapshot was invalid");
803
+ const parsed = JSON.parse(raw);
804
+ if (!Array.isArray(parsed))
805
+ throw new Error("Redis numeric snapshot was invalid");
806
+ return parsed;
807
+ }
808
+ function isRedisHealthSnapshot(value) {
809
+ if (typeof value !== "object" || value === null)
810
+ return false;
811
+ const snapshot = value;
812
+ const nonnegativeInteger = (field) => Number.isSafeInteger(snapshot[field]) && snapshot[field] >= 0;
813
+ return (nonnegativeInteger("pendingEntries") &&
814
+ nonnegativeInteger("retryBatches") &&
815
+ nonnegativeInteger("quarantinedBatches") &&
816
+ (snapshot["oldestAcceptedAt"] === null ||
817
+ (Number.isSafeInteger(snapshot["oldestAcceptedAt"]) &&
818
+ snapshot["oldestAcceptedAt"] >= 0)) &&
819
+ nonnegativeInteger("suppressedGuilds") &&
820
+ nonnegativeInteger("suppressedMembers") &&
821
+ nonnegativeInteger("suppressedSubjects"));
822
+ }
823
+ function scopeKey(scope) {
824
+ return `${scope.moduleId}\u0000${scope.collection}`;
825
+ }
826
+ function subjectSuppressionKey(scope, subjectId) {
827
+ return `${scopeKey(scope)}\u0000${subjectId}`;
828
+ }
829
+ function guildSuppressionKey(scope, guildId) {
830
+ return `${scopeKey(scope)}\u0000${guildId}`;
831
+ }
832
+ function memberKey(guildId, subjectId) {
833
+ return `${guildId}\u0000${subjectId}`;
834
+ }
835
+ function emptyHealth() {
836
+ return {
837
+ accepting: true,
838
+ pendingEntries: 0,
839
+ retryBatches: 0,
840
+ quarantinedBatches: 0,
841
+ inFlightEntries: 0,
842
+ oldestPendingAgeMs: null,
843
+ lastFlushDurationMs: null,
844
+ retryCount: 0,
845
+ rejectedCount: 0,
846
+ suppressedGuilds: 0,
847
+ suppressedMembers: 0,
848
+ suppressedSubjects: 0,
849
+ };
850
+ }
851
+ function toError(error) {
852
+ return error instanceof Error
853
+ ? error
854
+ : new Error("Unknown Redis numeric error");
855
+ }
856
+ function delay(milliseconds) {
857
+ return new Promise((resolve) => {
858
+ const timer = setTimeout(resolve, milliseconds);
859
+ timer.unref();
860
+ });
861
+ }
862
+ const MERGE_LUA = `
863
+ local function merge(existing_json, incoming_json)
864
+ if not existing_json then return incoming_json end
865
+ local existing = cjson.decode(existing_json)
866
+ local incoming = cjson.decode(incoming_json)
867
+ for field, value in pairs(incoming.increments) do
868
+ existing.increments[field] = (existing.increments[field] or 0) + value
869
+ end
870
+ for field, value in pairs(incoming.maxima) do
871
+ if not existing.maxima[field] or value > existing.maxima[field] then
872
+ existing.maxima[field] = value
873
+ end
874
+ end
875
+ if incoming.acceptedAt < existing.acceptedAt then
876
+ existing.acceptedAt = incoming.acceptedAt
877
+ end
878
+ return cjson.encode(existing)
879
+ end
880
+ `;
881
+ const BARRIER_HELPERS_LUA = `
882
+ local function barrier_active(hash, key, now)
883
+ local payload = redis.call('HGET', hash, key)
884
+ if not payload then return false end
885
+ local valid, parsed = pcall(cjson.decode, payload)
886
+ if not valid or not parsed.expiresAt or tonumber(parsed.expiresAt) <= now then
887
+ redis.call('HDEL', hash, key)
888
+ return false
889
+ end
890
+ return true
891
+ end
892
+ local function member_blocked(guild_id, subject_id, now)
893
+ return barrier_active(KEYS[9], guild_id, now) or
894
+ barrier_active(KEYS[10], guild_id .. string.char(0) .. subject_id, now) or
895
+ barrier_active(KEYS[11], subject_id, now)
896
+ end
897
+ `;
898
+ const ENQUEUE_SCRIPT = `${MERGE_LUA}
899
+ ${BARRIER_HELPERS_LUA}
900
+ if KEYS[16] ~= '' and redis.call('GET', KEYS[16]) ~= ARGV[8] then return 6 end
901
+ local member_key = ARGV[1]
902
+ local guild_id = ARGV[2]
903
+ local subject_id = ARGV[3]
904
+ local occurred_at = tonumber(ARGV[4])
905
+ local cooldown_ms = tonumber(ARGV[5])
906
+ local payload = ARGV[6]
907
+ local capacity = tonumber(ARGV[7])
908
+ if redis.call('HEXISTS', KEYS[13], guild_id) == 1 then return 3 end
909
+ local suppression = redis.call('HGET', KEYS[8], subject_id)
910
+ if suppression then
911
+ local parsed = cjson.decode(suppression)
912
+ if occurred_at <= tonumber(parsed.cutoff) then return 3 end
913
+ end
914
+ if redis.call('EXISTS', KEYS[6] .. member_key) == 1 then return 2 end
915
+ local paused = member_blocked(guild_id, subject_id, tonumber(ARGV[9]))
916
+ if not paused then
917
+ local staged = redis.call('HGET', KEYS[3], member_key)
918
+ if staged then
919
+ local staged_entry = cjson.decode(staged)
920
+ redis.call('HSET', KEYS[1], member_key, merge(redis.call('HGET', KEYS[1], member_key), staged))
921
+ redis.call('ZADD', KEYS[2], staged_entry.acceptedAt, member_key)
922
+ redis.call('HDEL', KEYS[3], member_key)
923
+ end
924
+ end
925
+ local target = paused and KEYS[3] or KEYS[1]
926
+ if redis.call('HEXISTS', target, member_key) == 0 then
927
+ local retained = redis.call('HLEN', KEYS[1]) + redis.call('HLEN', KEYS[3])
928
+ local claims = (redis.call('HLEN', KEYS[4]) + redis.call('HLEN', KEYS[15])) * 100
929
+ if retained + claims >= capacity then return 5 end
930
+ end
931
+ redis.call('HSET', target, member_key, merge(redis.call('HGET', target, member_key), payload))
932
+ if not paused then redis.call('ZADD', KEYS[2], occurred_at, member_key) end
933
+ redis.call('SET', KEYS[6] .. member_key, '1', 'PX', cooldown_ms, 'NX')
934
+ redis.call('SADD', KEYS[7] .. subject_id, member_key)
935
+ return 1
936
+ `;
937
+ const CLAIM_SCRIPT = `${MERGE_LUA}
938
+ ${BARRIER_HELPERS_LUA}
939
+ local now = tonumber(ARGV[1])
940
+ local lease_ms = tonumber(ARGV[2])
941
+ local proposed_id = ARGV[3]
942
+ local limit = tonumber(ARGV[4])
943
+ local guild_filter = ARGV[5]
944
+ local subject_filter = ARGV[6]
945
+ local force = ARGV[7] == '1'
946
+ local owner = ARGV[8]
947
+ local lease_prefix = ARGV[9]
948
+ local retry_retention_ms = tonumber(ARGV[10])
949
+ local staged = redis.call('HGETALL', KEYS[3])
950
+ for index = 1, #staged, 2 do
951
+ local member_key = staged[index]
952
+ local payload = staged[index + 1]
953
+ local entry = cjson.decode(payload)
954
+ if not member_blocked(entry.guildId, entry.subjectId, now) then
955
+ redis.call('HSET', KEYS[1], member_key, merge(redis.call('HGET', KEYS[1], member_key), payload))
956
+ redis.call('ZADD', KEYS[2], entry.acceptedAt, member_key)
957
+ redis.call('HDEL', KEYS[3], member_key)
958
+ end
959
+ end
960
+ local due = redis.call('ZRANGEBYSCORE', KEYS[5], '-inf', force and '+inf' or now, 'LIMIT', 0, 100)
961
+ for _, operation_id in ipairs(due) do
962
+ local payload = redis.call('HGET', KEYS[4], operation_id)
963
+ if payload then
964
+ local claim = cjson.decode(payload)
965
+ if not claim.expiresAt then
966
+ claim.expiresAt = now + retry_retention_ms
967
+ redis.call('HSET', KEYS[4], operation_id, cjson.encode(claim))
968
+ payload = cjson.encode(claim)
969
+ end
970
+ if tonumber(claim.expiresAt) <= now then
971
+ for _, expired_entry in ipairs(claim.entries) do
972
+ redis.call('SREM', KEYS[12] .. expired_entry.subjectId, operation_id)
973
+ redis.call('SREM', KEYS[14] .. expired_entry.guildId, operation_id)
974
+ end
975
+ redis.call('HSET', KEYS[15], operation_id, cjson.encode(claim))
976
+ redis.call('HDEL', KEYS[4], operation_id)
977
+ redis.call('ZREM', KEYS[5], operation_id)
978
+ payload = nil
979
+ end
980
+ end
981
+ if payload then
982
+ local claim = cjson.decode(payload)
983
+ local entry = claim.entries[1]
984
+ local eligible = owner == ''
985
+ local lease_key = ''
986
+ local lease_value = ''
987
+ if not eligible and entry and entry.shardId then
988
+ lease_key = lease_prefix .. tostring(entry.shardId)
989
+ lease_value = redis.call('GET', lease_key)
990
+ if lease_value then
991
+ local lease = cjson.decode(lease_value)
992
+ eligible = lease.supervisorId == owner
993
+ end
994
+ end
995
+ if eligible then
996
+ for _, claimed_entry in ipairs(claim.entries) do
997
+ redis.call('SADD', KEYS[12] .. claimed_entry.subjectId, operation_id)
998
+ redis.call('SADD', KEYS[14] .. claimed_entry.guildId, operation_id)
999
+ end
1000
+ claim.leaseKey = lease_key
1001
+ claim.leaseValue = lease_value
1002
+ local encoded = cjson.encode(claim)
1003
+ redis.call('HSET', KEYS[4], operation_id, encoded)
1004
+ redis.call('ZADD', KEYS[5], now + lease_ms, operation_id)
1005
+ return encoded
1006
+ end
1007
+ else
1008
+ redis.call('ZREM', KEYS[5], operation_id)
1009
+ end
1010
+ end
1011
+ local candidates = redis.call('ZRANGE', KEYS[2], 0, -1)
1012
+ local entries = {}
1013
+ local claimed_shard = nil
1014
+ local claimed_lease_key = ''
1015
+ local claimed_lease_value = ''
1016
+ for _, member_key in ipairs(candidates) do
1017
+ if #entries >= limit then break end
1018
+ local payload = redis.call('HGET', KEYS[1], member_key)
1019
+ if payload then
1020
+ local entry = cjson.decode(payload)
1021
+ local eligible = owner == ''
1022
+ local lease_key = ''
1023
+ local lease_value = ''
1024
+ if not eligible and entry.shardId and (not claimed_shard or claimed_shard == entry.shardId) then
1025
+ lease_key = lease_prefix .. tostring(entry.shardId)
1026
+ lease_value = redis.call('GET', lease_key)
1027
+ if lease_value then
1028
+ local lease = cjson.decode(lease_value)
1029
+ eligible = lease.supervisorId == owner
1030
+ end
1031
+ end
1032
+ if eligible and (not claimed_shard or not entry.shardId or claimed_shard == entry.shardId) and
1033
+ (guild_filter == '' or entry.guildId == guild_filter) and
1034
+ (subject_filter == '' or entry.subjectId == subject_filter) then
1035
+ if entry.shardId then
1036
+ claimed_shard = entry.shardId
1037
+ claimed_lease_key = lease_key
1038
+ claimed_lease_value = lease_value
1039
+ end
1040
+ table.insert(entries, entry)
1041
+ redis.call('HDEL', KEYS[1], member_key)
1042
+ redis.call('ZREM', KEYS[2], member_key)
1043
+ end
1044
+ end
1045
+ end
1046
+ if #entries == 0 then
1047
+ return cjson.encode({empty = true, quarantined = redis.call('HLEN', KEYS[15])})
1048
+ end
1049
+ local claim = {
1050
+ operationId = proposed_id,
1051
+ entries = entries,
1052
+ attempt = 0,
1053
+ expiresAt = now + retry_retention_ms,
1054
+ leaseKey = claimed_lease_key,
1055
+ leaseValue = claimed_lease_value
1056
+ }
1057
+ local encoded = cjson.encode(claim)
1058
+ redis.call('HSET', KEYS[4], proposed_id, encoded)
1059
+ redis.call('ZADD', KEYS[5], now + lease_ms, proposed_id)
1060
+ for _, claimed_entry in ipairs(entries) do
1061
+ redis.call('SADD', KEYS[12] .. claimed_entry.subjectId, proposed_id)
1062
+ redis.call('SADD', KEYS[14] .. claimed_entry.guildId, proposed_id)
1063
+ end
1064
+ return encoded
1065
+ `;
1066
+ const ACK_SCRIPT = `
1067
+ local payload = redis.call('HGET', KEYS[4], ARGV[1])
1068
+ if not payload then return 0 end
1069
+ local claim = cjson.decode(payload)
1070
+ if claim.leaseKey and claim.leaseKey ~= '' and
1071
+ redis.call('GET', claim.leaseKey) ~= claim.leaseValue then return 0 end
1072
+ for _, entry in ipairs(claim.entries) do
1073
+ redis.call('SREM', KEYS[12] .. entry.subjectId, ARGV[1])
1074
+ redis.call('SREM', KEYS[14] .. entry.guildId, ARGV[1])
1075
+ end
1076
+ redis.call('HDEL', KEYS[4], ARGV[1])
1077
+ redis.call('ZREM', KEYS[5], ARGV[1])
1078
+ return 1
1079
+ `;
1080
+ const RETRY_SCRIPT = `
1081
+ local payload = redis.call('HGET', KEYS[4], ARGV[1])
1082
+ if not payload then return 0 end
1083
+ local claim = cjson.decode(payload)
1084
+ claim.attempt = tonumber(ARGV[2])
1085
+ redis.call('HSET', KEYS[4], ARGV[1], cjson.encode(claim))
1086
+ local retry_at = tonumber(ARGV[3])
1087
+ if claim.expiresAt and retry_at > tonumber(claim.expiresAt) then retry_at = tonumber(claim.expiresAt) end
1088
+ redis.call('ZADD', KEYS[5], retry_at, ARGV[1])
1089
+ return 1
1090
+ `;
1091
+ const HEALTH_SCRIPT = `
1092
+ local oldest = redis.call('ZRANGE', KEYS[2], 0, 0, 'WITHSCORES')
1093
+ return cjson.encode({
1094
+ pendingEntries = redis.call('HLEN', KEYS[1]) + redis.call('HLEN', KEYS[3]),
1095
+ retryBatches = redis.call('HLEN', KEYS[4]),
1096
+ quarantinedBatches = redis.call('HLEN', KEYS[15]),
1097
+ oldestAcceptedAt = oldest[2] and tonumber(oldest[2]) or cjson.null,
1098
+ suppressedGuilds = redis.call('HLEN', KEYS[13]),
1099
+ suppressedMembers = redis.call('HLEN', KEYS[10]),
1100
+ suppressedSubjects = redis.call('HLEN', KEYS[8])
1101
+ })
1102
+ `;
1103
+ const SNAPSHOT_SCRIPT = `
1104
+ local guild_id = ARGV[1]
1105
+ local subject_id = ARGV[2]
1106
+ local member_key = guild_id .. string.char(0) .. subject_id
1107
+ local result = {}
1108
+ local pending = redis.call('HGET', KEYS[1], member_key)
1109
+ if pending then table.insert(result, cjson.decode(pending)) end
1110
+ local staged = redis.call('HGET', KEYS[3], member_key)
1111
+ if staged then table.insert(result, cjson.decode(staged)) end
1112
+ for _, encoded in ipairs(redis.call('HVALS', KEYS[4])) do
1113
+ local claim = cjson.decode(encoded)
1114
+ for _, entry in ipairs(claim.entries) do
1115
+ if entry.guildId == guild_id and entry.subjectId == subject_id then
1116
+ table.insert(result, entry)
1117
+ end
1118
+ end
1119
+ end
1120
+ if #result == 0 then return '[]' end
1121
+ return cjson.encode(result)
1122
+ `;
1123
+ const ACQUIRE_BARRIER_SCRIPT = `
1124
+ if ARGV[1] == 'member' and
1125
+ (redis.call('HEXISTS', KEYS[13], ARGV[2]) == 1 or
1126
+ redis.call('HEXISTS', KEYS[8], ARGV[3]) == 1) then return 2 end
1127
+ local hash = ARGV[1] == 'guild' and KEYS[9] or (ARGV[1] == 'subject' and KEYS[11] or KEYS[10])
1128
+ local key = ARGV[1] == 'guild' and ARGV[2] or (ARGV[1] == 'subject' and ARGV[3] or (ARGV[2] .. string.char(0) .. ARGV[3]))
1129
+ local current = redis.call('HGET', hash, key)
1130
+ if current then
1131
+ local valid, parsed = pcall(cjson.decode, current)
1132
+ if valid and parsed.expiresAt and tonumber(parsed.expiresAt) > tonumber(ARGV[5]) then return 0 end
1133
+ end
1134
+ redis.call('HSET', hash, key, cjson.encode({id = ARGV[4], expiresAt = tonumber(ARGV[6])}))
1135
+ return 1
1136
+ `;
1137
+ const RELEASE_BARRIER_SCRIPT = `${MERGE_LUA}
1138
+ ${BARRIER_HELPERS_LUA}
1139
+ local hash = ARGV[1] == 'guild' and KEYS[9] or (ARGV[1] == 'subject' and KEYS[11] or KEYS[10])
1140
+ local barrier_key = ARGV[1] == 'guild' and ARGV[2] or (ARGV[1] == 'subject' and ARGV[3] or (ARGV[2] .. string.char(0) .. ARGV[3]))
1141
+ local current = redis.call('HGET', hash, barrier_key)
1142
+ if not current then return 0 end
1143
+ local valid, parsed = pcall(cjson.decode, current)
1144
+ if not valid or parsed.id ~= ARGV[4] then return 0 end
1145
+ redis.call('HDEL', hash, barrier_key)
1146
+ local staged = redis.call('HGETALL', KEYS[3])
1147
+ for index = 1, #staged, 2 do
1148
+ local member_key = staged[index]
1149
+ local payload = staged[index + 1]
1150
+ local entry = cjson.decode(payload)
1151
+ local matches = (ARGV[1] == 'guild' and entry.guildId == ARGV[2]) or
1152
+ (ARGV[1] == 'subject' and entry.subjectId == ARGV[3]) or
1153
+ (ARGV[1] == 'member' and entry.guildId == ARGV[2] and entry.subjectId == ARGV[3])
1154
+ if matches and not member_blocked(entry.guildId, entry.subjectId, tonumber(ARGV[5])) then
1155
+ redis.call('HSET', KEYS[1], member_key, merge(redis.call('HGET', KEYS[1], member_key), payload))
1156
+ redis.call('ZADD', KEYS[2], entry.acceptedAt, member_key)
1157
+ redis.call('HDEL', KEYS[3], member_key)
1158
+ end
1159
+ end
1160
+ return 1
1161
+ `;
1162
+ const RENEW_BARRIER_SCRIPT = `
1163
+ local hash = ARGV[1] == 'guild' and KEYS[9] or (ARGV[1] == 'subject' and KEYS[11] or KEYS[10])
1164
+ local key = ARGV[1] == 'guild' and ARGV[2] or (ARGV[1] == 'subject' and ARGV[3] or (ARGV[2] .. string.char(0) .. ARGV[3]))
1165
+ local current = redis.call('HGET', hash, key)
1166
+ if not current then return 0 end
1167
+ local valid, parsed = pcall(cjson.decode, current)
1168
+ if not valid or parsed.id ~= ARGV[4] or tonumber(parsed.expiresAt) <= tonumber(ARGV[5]) then return 0 end
1169
+ parsed.expiresAt = tonumber(ARGV[6])
1170
+ redis.call('HSET', hash, key, cjson.encode(parsed))
1171
+ return 1
1172
+ `;
1173
+ const SUPPRESS_SUBJECT_SCRIPT = `
1174
+ local subject_id = ARGV[1]
1175
+ local cutoff = tonumber(ARGV[2])
1176
+ local suppression_id = ARGV[3]
1177
+ local current = redis.call('HGET', KEYS[8], subject_id)
1178
+ if current then
1179
+ local parsed = cjson.decode(current)
1180
+ if tonumber(parsed.cutoff) > cutoff then cutoff = tonumber(parsed.cutoff) end
1181
+ end
1182
+ redis.call('HSET', KEYS[8], subject_id, cjson.encode({cutoff = cutoff, id = suppression_id}))
1183
+ local discarded = 0
1184
+ for _, hash in ipairs({KEYS[1], KEYS[3]}) do
1185
+ local values = redis.call('HGETALL', hash)
1186
+ for index = 1, #values, 2 do
1187
+ local entry = cjson.decode(values[index + 1])
1188
+ if entry.subjectId == subject_id and tonumber(entry.acceptedAt) <= cutoff then
1189
+ redis.call('HDEL', hash, values[index])
1190
+ redis.call('ZREM', KEYS[2], values[index])
1191
+ discarded = discarded + 1
1192
+ end
1193
+ end
1194
+ end
1195
+ for _, member_key in ipairs(redis.call('SMEMBERS', KEYS[7] .. subject_id)) do
1196
+ redis.call('DEL', KEYS[6] .. member_key)
1197
+ end
1198
+ redis.call('DEL', KEYS[7] .. subject_id)
1199
+ return discarded
1200
+ `;
1201
+ const SUPPRESSION_STATUS_SCRIPT = `
1202
+ local subject_id = ARGV[1]
1203
+ local cutoff = tonumber(ARGV[2])
1204
+ local active = 0
1205
+ for _, operation_id in ipairs(redis.call('SMEMBERS', KEYS[12] .. subject_id)) do
1206
+ local encoded = redis.call('HGET', KEYS[4], operation_id)
1207
+ if not encoded then
1208
+ redis.call('SREM', KEYS[12] .. subject_id, operation_id)
1209
+ else
1210
+ local claim = cjson.decode(encoded)
1211
+ local matched = false
1212
+ for _, entry in ipairs(claim.entries) do
1213
+ if entry.subjectId == subject_id and tonumber(entry.acceptedAt) <= cutoff then matched = true end
1214
+ end
1215
+ if matched then active = active + 1 end
1216
+ end
1217
+ end
1218
+ return active
1219
+ `;
1220
+ const RELEASE_SUBJECT_SCRIPT = `
1221
+ local current = redis.call('HGET', KEYS[8], ARGV[1])
1222
+ if not current then return 0 end
1223
+ local parsed = cjson.decode(current)
1224
+ if parsed.id ~= ARGV[2] then return 0 end
1225
+ redis.call('HDEL', KEYS[8], ARGV[1])
1226
+ return 1
1227
+ `;
1228
+ const SUPPRESS_GUILD_SCRIPT = `
1229
+ local guild_id = ARGV[1]
1230
+ local suppression_id = ARGV[2]
1231
+ redis.call('HSET', KEYS[13], guild_id, suppression_id)
1232
+ local discarded = 0
1233
+ for _, hash in ipairs({KEYS[1], KEYS[3]}) do
1234
+ local values = redis.call('HGETALL', hash)
1235
+ for index = 1, #values, 2 do
1236
+ local entry = cjson.decode(values[index + 1])
1237
+ if entry.guildId == guild_id then
1238
+ redis.call('HDEL', hash, values[index])
1239
+ redis.call('ZREM', KEYS[2], values[index])
1240
+ discarded = discarded + 1
1241
+ end
1242
+ end
1243
+ end
1244
+ return discarded
1245
+ `;
1246
+ const GUILD_SUPPRESSION_STATUS_SCRIPT = `
1247
+ local guild_id = ARGV[1]
1248
+ local active = 0
1249
+ for _, operation_id in ipairs(redis.call('SMEMBERS', KEYS[14] .. guild_id)) do
1250
+ local encoded = redis.call('HGET', KEYS[4], operation_id)
1251
+ if not encoded then
1252
+ redis.call('SREM', KEYS[14] .. guild_id, operation_id)
1253
+ else
1254
+ local claim = cjson.decode(encoded)
1255
+ local matched = false
1256
+ for _, entry in ipairs(claim.entries) do
1257
+ if entry.guildId == guild_id then matched = true end
1258
+ end
1259
+ if matched then active = active + 1 end
1260
+ end
1261
+ end
1262
+ return active
1263
+ `;
1264
+ const RELEASE_GUILD_SCRIPT = `
1265
+ if redis.call('HGET', KEYS[13], ARGV[1]) ~= ARGV[2] then return 0 end
1266
+ redis.call('HDEL', KEYS[13], ARGV[1])
1267
+ return 1
1268
+ `;
1269
+ const CLEAR_COOLDOWN_SCRIPT = `
1270
+ redis.call('DEL', KEYS[6] .. ARGV[1])
1271
+ redis.call('SREM', KEYS[7] .. ARGV[2], ARGV[1])
1272
+ return 1
1273
+ `;
1274
+ const CLEAR_SUBJECT_COOLDOWNS_SCRIPT = `
1275
+ for _, member_key in ipairs(redis.call('SMEMBERS', KEYS[7] .. ARGV[1])) do
1276
+ redis.call('DEL', KEYS[6] .. member_key)
1277
+ end
1278
+ redis.call('DEL', KEYS[7] .. ARGV[1])
1279
+ return 1
1280
+ `;
1281
+ //# sourceMappingURL=numeric-write-behind-redis.js.map