@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.
- package/CHANGELOG.md +66 -0
- package/dist/cloud-control-dispatcher.js +2 -1
- package/dist/control-server.d.ts +43 -3
- package/dist/control-server.js +228 -18
- package/dist/events.d.ts +92 -1
- package/dist/events.js +1483 -5
- package/dist/guilds.d.ts +20 -1
- package/dist/guilds.js +149 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +89 -23
- package/dist/interactions.js +101 -22
- package/dist/management-module.d.ts +1 -1
- package/dist/management-module.js +42 -10
- package/dist/message-classification.d.ts +5 -0
- package/dist/message-classification.js +75 -0
- package/dist/modules.js +9 -0
- package/dist/numeric-write-behind-proxy.d.ts +62 -0
- package/dist/numeric-write-behind-proxy.js +313 -0
- package/dist/numeric-write-behind-redis.d.ts +123 -0
- package/dist/numeric-write-behind-redis.js +1281 -0
- package/dist/numeric-write-behind.d.ts +5 -0
- package/dist/numeric-write-behind.js +37 -0
- package/dist/shard-contracts.d.ts +810 -0
- package/dist/shard-contracts.js +292 -3
- package/dist/shard-router.d.ts +3 -0
- package/dist/shard-router.js +42 -0
- package/dist/shard-supervisor.d.ts +4 -1
- package/dist/shard-supervisor.js +262 -7
- package/dist/shard-worker.d.ts +7 -0
- package/dist/shard-worker.js +64 -10
- package/dist/verification-service.d.ts +50 -0
- package/dist/verification-service.js +166 -0
- package/package.json +10 -9
|
@@ -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
|