@helyx/bot 0.4.35 → 0.4.37
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 +12 -0
- package/dist/cloud-management-module.js +2 -1
- package/dist/control-server.js +6 -9
- package/dist/developer-operations-module.js +2 -1
- package/dist/events.js +11 -3
- package/dist/internal-module-manifest.d.ts +13 -0
- package/dist/internal-module-manifest.js +14 -0
- package/dist/management-module.js +2 -1
- package/dist/numeric-barriers.d.ts +11 -0
- package/dist/numeric-barriers.js +14 -0
- package/dist/numeric-write-behind-proxy.d.ts +4 -4
- package/dist/numeric-write-behind-proxy.js +45 -24
- package/dist/numeric-write-behind-redis.d.ts +4 -4
- package/dist/numeric-write-behind-redis.js +132 -70
- package/dist/oauth-module.js +2 -1
- package/dist/permissions-module.js +2 -1
- package/dist/renewable-lease.d.ts +16 -0
- package/dist/renewable-lease.js +66 -0
- package/dist/scheduled-task-runner.js +8 -1
- package/dist/shard-supervisor.d.ts +5 -0
- package/dist/shard-supervisor.js +56 -4
- package/dist/support-module.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.37
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Publish the launch-readiness bot from a clean build and update the embedded self-host catalogue to the corrected runtime package.
|
|
8
|
+
|
|
9
|
+
## 0.4.36
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Harden startup cleanup, lease ownership, database transaction handling, worker shutdown, and stale-work fencing across the hosted and self-hosted runtime.
|
|
14
|
+
|
|
3
15
|
## 0.4.35
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineModule, } from "@helyx/sdk";
|
|
2
2
|
import { Events } from "discord.js";
|
|
3
3
|
import { ClientCloudDiscordDirectory, CloudConnectorUserError, } from "./cloud-connector.js";
|
|
4
|
+
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
4
5
|
const CONNECT_MODAL_ID = "helyx.cloud.connect";
|
|
5
6
|
const CONNECT_CODE_FIELD_ID = "helyx.cloud.connect.code";
|
|
6
7
|
const DISCONNECT_MODAL_ID = "helyx.cloud.disconnect";
|
|
@@ -72,7 +73,7 @@ export function createCloudManagementModule(input) {
|
|
|
72
73
|
};
|
|
73
74
|
return defineModule({
|
|
74
75
|
manifest: {
|
|
75
|
-
|
|
76
|
+
...INTERNAL_MODULE_MANIFEST_FIELDS,
|
|
76
77
|
releaseStatus: "live",
|
|
77
78
|
id: "helyx.cloud-connect",
|
|
78
79
|
name: "Helyx Cloud Connect",
|
package/dist/control-server.js
CHANGED
|
@@ -7,6 +7,7 @@ import Fastify, {} from "fastify";
|
|
|
7
7
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
8
8
|
import { z, ZodError } from "zod";
|
|
9
9
|
import { addChannelMessageReaction as addDiscordChannelMessageReaction, deleteChannelMessage as deleteDiscordChannelMessage, editChannelMessage as editDiscordChannelMessage, normalizeDiscordPermission, sendToChannel as sendDiscordMessageToChannel, } from "./interactions.js";
|
|
10
|
+
import { runNestedGuildBarriers } from "./numeric-barriers.js";
|
|
10
11
|
import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
|
|
11
12
|
import { dashboardCategoryLabel } from "./dashboard-category.js";
|
|
12
13
|
import { assertVersion, dashboardActionPermissionId, declaredManagedResourcePermission, fromDashboardPolicy, managedResourcePermissionId, permissionPoliciesEqual, permissionPolicyKey, safeOptionalVersion, safeVersion, toDashboardPolicy, } from "./control-permissions.js";
|
|
@@ -663,15 +664,11 @@ export class BotControlService {
|
|
|
663
664
|
await setEnabled();
|
|
664
665
|
}
|
|
665
666
|
else {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
}
|
|
672
|
-
await writeBehind.runGuildBarrier(scope, () => runBoundary(index + 1));
|
|
673
|
-
};
|
|
674
|
-
await runBoundary(0);
|
|
667
|
+
await runNestedGuildBarriers({
|
|
668
|
+
scopes,
|
|
669
|
+
writeBehind,
|
|
670
|
+
operation: setEnabled,
|
|
671
|
+
});
|
|
675
672
|
for (const scope of scopes)
|
|
676
673
|
await writeBehind.flushGuild(scope);
|
|
677
674
|
for (const scope of scopes)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineModule, } from "@helyx/sdk";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { SHARD_CONTROL_TIMEOUT_MS, assertShardMessageSize, shardOperationsHealthResponseSchema, } from "./shard-contracts.js";
|
|
4
|
+
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
4
5
|
const DEVELOPER_MODULE_ID = "helyx.developer-operations";
|
|
5
6
|
const DEVELOPER_MODULE_NAME = "Helyx Developer Operations";
|
|
6
7
|
const DEVELOPER_MODULE_VERSION = "0.2.0";
|
|
@@ -9,7 +10,7 @@ export function createDeveloperOperationsModule(input) {
|
|
|
9
10
|
const approvedRoles = new Set(input.approvedRoleIds);
|
|
10
11
|
return defineModule({
|
|
11
12
|
manifest: {
|
|
12
|
-
|
|
13
|
+
...INTERNAL_MODULE_MANIFEST_FIELDS,
|
|
13
14
|
releaseStatus: "live",
|
|
14
15
|
id: DEVELOPER_MODULE_ID,
|
|
15
16
|
name: DEVELOPER_MODULE_NAME,
|
package/dist/events.js
CHANGED
|
@@ -300,8 +300,11 @@ export function installModuleEvents(client, modules, logger, services) {
|
|
|
300
300
|
async function handlePrivilegedMessageCreate(message, contributions) {
|
|
301
301
|
if (!message.guildId || !message.channelId)
|
|
302
302
|
return;
|
|
303
|
+
const installations = availabilityAwareInstallations(services);
|
|
303
304
|
for (const { moduleId, contribution } of contributions) {
|
|
304
305
|
try {
|
|
306
|
+
if (!(await installations.isModuleEnabled(message.guildId, moduleId)))
|
|
307
|
+
continue;
|
|
305
308
|
await contribution.execute({
|
|
306
309
|
serverId: message.guildId,
|
|
307
310
|
channelId: message.channelId,
|
|
@@ -827,13 +830,18 @@ class BoundedGuildEventQueue {
|
|
|
827
830
|
this.#accepting = false;
|
|
828
831
|
if (this.#active === 0 && this.#pending.length === 0)
|
|
829
832
|
return;
|
|
830
|
-
|
|
831
|
-
|
|
833
|
+
const drain = new Promise((resolve) => this.#drainWaiters.add(resolve));
|
|
834
|
+
const drainedBeforeDeadline = await Promise.race([
|
|
835
|
+
drain.then(() => true),
|
|
832
836
|
new Promise((resolve) => {
|
|
833
|
-
const timer = setTimeout(resolve, 5_000);
|
|
837
|
+
const timer = setTimeout(() => resolve(false), 5_000);
|
|
834
838
|
timer.unref();
|
|
835
839
|
}),
|
|
836
840
|
]);
|
|
841
|
+
if (!drainedBeforeDeadline) {
|
|
842
|
+
this.logger.warn({ event: "module.event_stop_deadline_exceeded" }, "Module gateway event queue is still draining after its shutdown deadline");
|
|
843
|
+
await drain;
|
|
844
|
+
}
|
|
837
845
|
this.#pending.length = 0;
|
|
838
846
|
}
|
|
839
847
|
#pump() {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const INTERNAL_MODULE_MANIFEST_FIELDS: {
|
|
2
|
+
readonly manifestVersion: "0.3";
|
|
3
|
+
readonly moduleKind: "internal_dependency";
|
|
4
|
+
readonly customerSelectable: false;
|
|
5
|
+
readonly countsAsInstalledModule: false;
|
|
6
|
+
readonly healthExposure: "delegated";
|
|
7
|
+
readonly privacy: {
|
|
8
|
+
containsPersonalData: false;
|
|
9
|
+
providerVersion: number;
|
|
10
|
+
collections: never[];
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=internal-module-manifest.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const noOwnedPersonalData = {
|
|
2
|
+
containsPersonalData: false,
|
|
3
|
+
providerVersion: 1,
|
|
4
|
+
collections: [],
|
|
5
|
+
};
|
|
6
|
+
export const INTERNAL_MODULE_MANIFEST_FIELDS = {
|
|
7
|
+
manifestVersion: "0.3",
|
|
8
|
+
moduleKind: "internal_dependency",
|
|
9
|
+
customerSelectable: false,
|
|
10
|
+
countsAsInstalledModule: false,
|
|
11
|
+
healthExposure: "delegated",
|
|
12
|
+
privacy: noOwnedPersonalData,
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=internal-module-manifest.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isCustomerSelectableModule, } from "@helyx/module-manifest";
|
|
2
2
|
import { defineModule, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
|
|
3
|
+
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
3
4
|
const MODULE_SELECT_ID = "helyx.management.module";
|
|
4
5
|
const SETTING_SELECT_ID = "helyx.management.setting";
|
|
5
6
|
const VALUE_SELECT_ID = "helyx.management.value";
|
|
@@ -31,7 +32,7 @@ export function createManagementModule(configurableModules, resources) {
|
|
|
31
32
|
const byId = new Map(modules.map((module) => [module.manifest.id, module]));
|
|
32
33
|
return defineModule({
|
|
33
34
|
manifest: {
|
|
34
|
-
|
|
35
|
+
...INTERNAL_MODULE_MANIFEST_FIELDS,
|
|
35
36
|
releaseStatus: "live",
|
|
36
37
|
id: "helyx.management",
|
|
37
38
|
name: "Helyx management",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { NumericWriteBehindScope, NumericWriteBehindService } from "@helyx/sdk";
|
|
2
|
+
type GuildScope = NumericWriteBehindScope & {
|
|
3
|
+
guildId: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function runNestedGuildBarriers(input: {
|
|
6
|
+
scopes: readonly GuildScope[];
|
|
7
|
+
writeBehind: NumericWriteBehindService;
|
|
8
|
+
operation(): Promise<unknown>;
|
|
9
|
+
}): Promise<void>;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=numeric-barriers.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function runNestedGuildBarriers(input) {
|
|
2
|
+
const run = async (index, inheritedSignal) => {
|
|
3
|
+
inheritedSignal?.throwIfAborted();
|
|
4
|
+
const scope = input.scopes[index];
|
|
5
|
+
if (!scope) {
|
|
6
|
+
await input.operation();
|
|
7
|
+
inheritedSignal?.throwIfAborted();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
await input.writeBehind.runGuildBarrier(scope, (signal) => run(index + 1, inheritedSignal ? AbortSignal.any([inheritedSignal, signal]) : signal));
|
|
11
|
+
};
|
|
12
|
+
await run(0);
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=numeric-barriers.js.map
|
|
@@ -19,13 +19,13 @@ export declare class WorkerNumericWriteBehindProxy implements NumericWriteBehind
|
|
|
19
19
|
subjectId: string;
|
|
20
20
|
}): Promise<void>;
|
|
21
21
|
flushModule(input: NumericWriteBehindScope): Promise<void>;
|
|
22
|
-
runMemberBarrier<Result>(input: NumericWriteBehindMemberScope, operation: () => Promise<Result>): Promise<Result>;
|
|
22
|
+
runMemberBarrier<Result>(input: NumericWriteBehindMemberScope, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
|
|
23
23
|
runGuildBarrier<Result>(input: NumericWriteBehindScope & {
|
|
24
24
|
guildId: string;
|
|
25
|
-
}, operation: () => Promise<Result>): Promise<Result>;
|
|
25
|
+
}, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
|
|
26
26
|
runSubjectBarrier<Result>(input: NumericWriteBehindScope & {
|
|
27
27
|
subjectId: string;
|
|
28
|
-
}, operation: () => Promise<Result>): Promise<Result>;
|
|
28
|
+
}, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
|
|
29
29
|
suppressSubject(input: NumericWriteBehindScope & {
|
|
30
30
|
subjectId: string;
|
|
31
31
|
cutoff: Date;
|
|
@@ -55,7 +55,7 @@ export declare class WorkerNumericWriteBehindProxy implements NumericWriteBehind
|
|
|
55
55
|
clearSubjectCooldown(input: NumericWriteBehindScope & {
|
|
56
56
|
subjectId: string;
|
|
57
57
|
}): Promise<void>;
|
|
58
|
-
subscribeCommitted(input: NumericWriteBehindScope, handler: (batch: NumericWriteBehindCommittedBatch) => void | Promise<void>): () => void;
|
|
58
|
+
subscribeCommitted(input: NumericWriteBehindScope, handler: (batch: NumericWriteBehindCommittedBatch, signal: AbortSignal) => void | Promise<void>): () => void;
|
|
59
59
|
stop(): void;
|
|
60
60
|
}
|
|
61
61
|
export declare function encodeNumericIpcValue(input: unknown): unknown;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {} from "@helyx/sdk";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { assertShardMessageSize, SHARD_CONTROL_TIMEOUT_MS, shardNumericCommittedSchema, shardNumericResponseSchema, } from "./shard-contracts.js";
|
|
4
|
+
import { RenewableLease } from "./renewable-lease.js";
|
|
4
5
|
export class WorkerNumericWriteBehindProxy {
|
|
5
6
|
#identity;
|
|
6
7
|
#pending = new Map();
|
|
@@ -157,37 +158,44 @@ export class WorkerNumericWriteBehindProxy {
|
|
|
157
158
|
...(input.guildId ? { guildId: input.guildId } : {}),
|
|
158
159
|
...(input.subjectId ? { subjectId: input.subjectId } : {}),
|
|
159
160
|
}));
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
const renewal = new RenewableLease({
|
|
162
|
+
intervalMs: 20_000,
|
|
163
|
+
renew: () => this.#call({
|
|
163
164
|
kind: "barrier.renew",
|
|
164
165
|
...scope(input),
|
|
165
166
|
barrierType,
|
|
166
167
|
...(input.guildId ? { guildId: input.guildId } : {}),
|
|
167
168
|
...(input.subjectId ? { subjectId: input.subjectId } : {}),
|
|
168
169
|
barrierId: acquired.barrierId,
|
|
169
|
-
}).
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
170
|
+
}).then(() => undefined),
|
|
171
|
+
});
|
|
172
|
+
renewal.start();
|
|
173
|
+
const failures = [];
|
|
174
|
+
let result;
|
|
175
|
+
let completed = false;
|
|
176
|
+
try {
|
|
177
|
+
result = await renewal.run(async (signal) => {
|
|
178
|
+
if (barrierType === "member")
|
|
179
|
+
await this.flushMember(input);
|
|
180
|
+
else if (barrierType === "guild")
|
|
181
|
+
await this.flushGuild(input);
|
|
182
|
+
else
|
|
183
|
+
await this.flushSubject(input);
|
|
184
|
+
signal.throwIfAborted();
|
|
185
|
+
return operation(signal);
|
|
174
186
|
});
|
|
175
|
-
|
|
176
|
-
|
|
187
|
+
completed = true;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
failures.push(error);
|
|
191
|
+
}
|
|
177
192
|
try {
|
|
178
|
-
|
|
179
|
-
await this.flushMember(input);
|
|
180
|
-
else if (barrierType === "guild")
|
|
181
|
-
await this.flushGuild(input);
|
|
182
|
-
else
|
|
183
|
-
await this.flushSubject(input);
|
|
184
|
-
const result = await operation();
|
|
185
|
-
if (renewalFailure)
|
|
186
|
-
throw renewalFailure;
|
|
187
|
-
return result;
|
|
193
|
+
await renewal.stop();
|
|
188
194
|
}
|
|
189
|
-
|
|
190
|
-
|
|
195
|
+
catch (error) {
|
|
196
|
+
failures.push(error);
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
191
199
|
await this.#call({
|
|
192
200
|
kind: "barrier.release",
|
|
193
201
|
...scope(input),
|
|
@@ -197,6 +205,16 @@ export class WorkerNumericWriteBehindProxy {
|
|
|
197
205
|
barrierId: acquired.barrierId,
|
|
198
206
|
});
|
|
199
207
|
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
failures.push(error);
|
|
210
|
+
}
|
|
211
|
+
if (failures.length === 1)
|
|
212
|
+
throw failures[0];
|
|
213
|
+
if (failures.length > 1)
|
|
214
|
+
throw new AggregateError(failures, "Numeric barrier cleanup failed");
|
|
215
|
+
if (!completed)
|
|
216
|
+
throw new Error("Numeric barrier operation did not complete");
|
|
217
|
+
return result;
|
|
200
218
|
}
|
|
201
219
|
async #handleCommitted(committed) {
|
|
202
220
|
let response;
|
|
@@ -204,8 +222,11 @@ export class WorkerNumericWriteBehindProxy {
|
|
|
204
222
|
if (new Date(committed.deadlineAt).getTime() <= Date.now())
|
|
205
223
|
throw new Error("Numeric committed notification expired");
|
|
206
224
|
const batch = decodeIpcValue(committed.payload);
|
|
207
|
-
|
|
208
|
-
|
|
225
|
+
const signal = AbortSignal.timeout(Math.max(1, new Date(committed.deadlineAt).getTime() - Date.now()));
|
|
226
|
+
for (const subscriber of this.#subscribers.get(scopeKey(committed.scope)) ?? []) {
|
|
227
|
+
signal.throwIfAborted();
|
|
228
|
+
await subscriber(batch, signal);
|
|
229
|
+
}
|
|
209
230
|
response = {
|
|
210
231
|
version: 1,
|
|
211
232
|
type: "helyx.numeric.committed.response",
|
|
@@ -51,13 +51,13 @@ export declare class RedisNumericWriteBehindService implements NumericWriteBehin
|
|
|
51
51
|
subjectId: string;
|
|
52
52
|
}): Promise<void>;
|
|
53
53
|
flushModule(input: NumericWriteBehindScope): Promise<void>;
|
|
54
|
-
runMemberBarrier<Result>(input: NumericWriteBehindMemberScope, operation: () => Promise<Result>): Promise<Result>;
|
|
54
|
+
runMemberBarrier<Result>(input: NumericWriteBehindMemberScope, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
|
|
55
55
|
runGuildBarrier<Result>(input: NumericWriteBehindScope & {
|
|
56
56
|
guildId: string;
|
|
57
|
-
}, operation: () => Promise<Result>): Promise<Result>;
|
|
57
|
+
}, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
|
|
58
58
|
runSubjectBarrier<Result>(input: NumericWriteBehindScope & {
|
|
59
59
|
subjectId: string;
|
|
60
|
-
}, operation: () => Promise<Result>): Promise<Result>;
|
|
60
|
+
}, operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
|
|
61
61
|
suppressSubject(input: NumericWriteBehindScope & {
|
|
62
62
|
subjectId: string;
|
|
63
63
|
cutoff: Date;
|
|
@@ -87,7 +87,7 @@ export declare class RedisNumericWriteBehindService implements NumericWriteBehin
|
|
|
87
87
|
clearSubjectCooldown(input: NumericWriteBehindScope & {
|
|
88
88
|
subjectId: string;
|
|
89
89
|
}): Promise<void>;
|
|
90
|
-
subscribeCommitted(input: NumericWriteBehindScope, handler: (batch: NumericWriteBehindCommittedBatch) => void | Promise<void>): () => void;
|
|
90
|
+
subscribeCommitted(input: NumericWriteBehindScope, handler: (batch: NumericWriteBehindCommittedBatch, signal: AbortSignal) => void | Promise<void>): () => void;
|
|
91
91
|
health(): NumericWriteBehindHealth;
|
|
92
92
|
acquireBarrier(input: {
|
|
93
93
|
scope: NumericWriteBehindScope;
|
|
@@ -2,6 +2,7 @@ import {} from "@helyx/sdk";
|
|
|
2
2
|
import {} from "@helyx/platform";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { createClient } from "redis";
|
|
5
|
+
import { RenewableLease } from "./renewable-lease.js";
|
|
5
6
|
const REDIS_FLUSH_INTERVAL_MS = 30_000;
|
|
6
7
|
const REDIS_BATCH_SIZE = 100;
|
|
7
8
|
const REDIS_CAPACITY = 10_000;
|
|
@@ -189,40 +190,13 @@ export class RedisNumericWriteBehindService {
|
|
|
189
190
|
return this.#flush(input, "", "", true);
|
|
190
191
|
}
|
|
191
192
|
async runMemberBarrier(input, operation) {
|
|
192
|
-
|
|
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
|
-
}
|
|
193
|
+
return this.#runBarrier(input, "member", input.guildId, input.subjectId, () => this.flushMember(input), operation);
|
|
202
194
|
}
|
|
203
195
|
async runGuildBarrier(input, operation) {
|
|
204
|
-
|
|
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
|
-
}
|
|
196
|
+
return this.#runBarrier(input, "guild", input.guildId, "", () => this.flushGuild(input), operation);
|
|
214
197
|
}
|
|
215
198
|
async runSubjectBarrier(input, operation) {
|
|
216
|
-
|
|
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
|
-
}
|
|
199
|
+
return this.#runBarrier(input, "subject", "", input.subjectId, () => this.flushSubject(input), operation);
|
|
226
200
|
}
|
|
227
201
|
async suppressSubject(input) {
|
|
228
202
|
this.#assertAvailable(input);
|
|
@@ -420,16 +394,17 @@ export class RedisNumericWriteBehindService {
|
|
|
420
394
|
async #drainRegistration(registration, guildId, subjectId, force) {
|
|
421
395
|
while (true) {
|
|
422
396
|
const proposedOperationId = randomUUID();
|
|
397
|
+
const proposedClaimToken = randomUUID();
|
|
423
398
|
const raw = await this.#redis.eval(CLAIM_SCRIPT, {
|
|
424
399
|
keys: keysFor(this.#prefix, registration),
|
|
425
400
|
arguments: [
|
|
426
401
|
String(Date.now()),
|
|
427
402
|
String(REDIS_CLAIM_LEASE_MS),
|
|
428
403
|
proposedOperationId,
|
|
404
|
+
proposedClaimToken,
|
|
429
405
|
String(REDIS_BATCH_SIZE),
|
|
430
406
|
guildId,
|
|
431
407
|
subjectId,
|
|
432
|
-
force ? "1" : "0",
|
|
433
408
|
this.#supervisorId ?? "",
|
|
434
409
|
this.#shardLeaseKeyPrefix ?? "",
|
|
435
410
|
String(REDIS_RETRY_RETENTION_MS),
|
|
@@ -451,33 +426,45 @@ export class RedisNumericWriteBehindService {
|
|
|
451
426
|
...this.#health,
|
|
452
427
|
inFlightEntries: claim.entries.length,
|
|
453
428
|
};
|
|
429
|
+
const renewal = new RenewableLease({
|
|
430
|
+
intervalMs: Math.max(250, Math.floor(REDIS_CLAIM_LEASE_MS / 3)),
|
|
431
|
+
renew: () => this.#renewClaim(registration, claim),
|
|
432
|
+
onError: (error) => this.#onError?.(error),
|
|
433
|
+
});
|
|
434
|
+
renewal.start();
|
|
454
435
|
try {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
436
|
+
await renewal.run(async (signal) => {
|
|
437
|
+
const output = await this.#writer.mutateNumericBatch({
|
|
438
|
+
moduleId: registration.moduleId,
|
|
439
|
+
collection: registration.collection,
|
|
440
|
+
operationId: claim.operationId,
|
|
441
|
+
uniqueBy: registration.uniqueBy,
|
|
442
|
+
mutations: claim.entries.map(decodeMutation),
|
|
443
|
+
select: registration.select,
|
|
444
|
+
});
|
|
445
|
+
signal.throwIfAborted();
|
|
446
|
+
if (output.results.length !== claim.entries.length)
|
|
447
|
+
throw new Error("Redis numeric writer result count was invalid");
|
|
448
|
+
const committed = {
|
|
449
|
+
moduleId: registration.moduleId,
|
|
450
|
+
collection: registration.collection,
|
|
451
|
+
operationId: claim.operationId,
|
|
452
|
+
replayed: output.replayed,
|
|
453
|
+
mutations: claim.entries.map(decodeMutation),
|
|
454
|
+
results: output.results,
|
|
455
|
+
};
|
|
456
|
+
for (const subscriber of this.#subscribers.get(scopeKey(registration)) ?? []) {
|
|
457
|
+
signal.throwIfAborted();
|
|
458
|
+
await subscriber(committed, signal);
|
|
459
|
+
}
|
|
460
|
+
signal.throwIfAborted();
|
|
461
|
+
const acknowledged = await this.#redis.eval(ACK_SCRIPT, {
|
|
462
|
+
keys: keysFor(this.#prefix, registration),
|
|
463
|
+
arguments: [claim.operationId, claim.claimToken],
|
|
464
|
+
});
|
|
465
|
+
if (Number(acknowledged) !== 1)
|
|
466
|
+
throw new Error("Redis numeric claim ownership changed");
|
|
478
467
|
});
|
|
479
|
-
if (Number(acknowledged) !== 1)
|
|
480
|
-
throw new Error("Redis numeric claim ownership changed");
|
|
481
468
|
}
|
|
482
469
|
catch (error) {
|
|
483
470
|
const attempt = claim.attempt + 1;
|
|
@@ -485,7 +472,12 @@ export class RedisNumericWriteBehindService {
|
|
|
485
472
|
Math.min(REDIS_RETRY_MAX_MS, REDIS_RETRY_BASE_MS * 2 ** Math.min(claim.attempt, 16));
|
|
486
473
|
await this.#redis.eval(RETRY_SCRIPT, {
|
|
487
474
|
keys: keysFor(this.#prefix, registration),
|
|
488
|
-
arguments: [
|
|
475
|
+
arguments: [
|
|
476
|
+
claim.operationId,
|
|
477
|
+
claim.claimToken,
|
|
478
|
+
String(attempt),
|
|
479
|
+
String(retryAt),
|
|
480
|
+
],
|
|
489
481
|
});
|
|
490
482
|
this.#health = {
|
|
491
483
|
...this.#health,
|
|
@@ -498,6 +490,9 @@ export class RedisNumericWriteBehindService {
|
|
|
498
490
|
throw error;
|
|
499
491
|
return;
|
|
500
492
|
}
|
|
493
|
+
finally {
|
|
494
|
+
await renewal.stop();
|
|
495
|
+
}
|
|
501
496
|
this.#health = {
|
|
502
497
|
...this.#health,
|
|
503
498
|
inFlightEntries: 0,
|
|
@@ -505,6 +500,20 @@ export class RedisNumericWriteBehindService {
|
|
|
505
500
|
};
|
|
506
501
|
}
|
|
507
502
|
}
|
|
503
|
+
async #renewClaim(registration, claim) {
|
|
504
|
+
const now = Date.now();
|
|
505
|
+
const renewed = await this.#redis.eval(RENEW_CLAIM_SCRIPT, {
|
|
506
|
+
keys: keysFor(this.#prefix, registration),
|
|
507
|
+
arguments: [
|
|
508
|
+
claim.operationId,
|
|
509
|
+
claim.claimToken,
|
|
510
|
+
String(now),
|
|
511
|
+
String(now + REDIS_CLAIM_LEASE_MS),
|
|
512
|
+
],
|
|
513
|
+
});
|
|
514
|
+
if (Number(renewed) !== 1)
|
|
515
|
+
throw new Error("Redis numeric claim lease expired");
|
|
516
|
+
}
|
|
508
517
|
async #acquireBarrier(scope, kind, guildId, subjectId) {
|
|
509
518
|
this.#assertAvailable(scope);
|
|
510
519
|
const barrierId = randomUUID();
|
|
@@ -550,12 +559,47 @@ export class RedisNumericWriteBehindService {
|
|
|
550
559
|
if (Number(result) !== 1)
|
|
551
560
|
throw new Error("Numeric write-behind barrier lease expired");
|
|
552
561
|
}
|
|
553
|
-
#
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
562
|
+
async #runBarrier(scope, kind, guildId, subjectId, flush, operation) {
|
|
563
|
+
const barrierId = await this.#acquireBarrier(scope, kind, guildId, subjectId);
|
|
564
|
+
const renewal = new RenewableLease({
|
|
565
|
+
intervalMs: Math.max(250, Math.floor(this.#barrierLeaseMs / 3)),
|
|
566
|
+
renew: () => this.#renewBarrier(scope, kind, guildId, subjectId, barrierId),
|
|
567
|
+
onError: (error) => this.#onError?.(error),
|
|
568
|
+
});
|
|
569
|
+
renewal.start();
|
|
570
|
+
const failures = [];
|
|
571
|
+
let result;
|
|
572
|
+
let completed = false;
|
|
573
|
+
try {
|
|
574
|
+
result = await renewal.run(async (signal) => {
|
|
575
|
+
await flush();
|
|
576
|
+
signal.throwIfAborted();
|
|
577
|
+
return operation(signal);
|
|
578
|
+
});
|
|
579
|
+
completed = true;
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
failures.push(error);
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
await renewal.stop();
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
failures.push(error);
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
await this.#releaseBarrier(scope, kind, guildId, subjectId, barrierId);
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
failures.push(error);
|
|
595
|
+
}
|
|
596
|
+
if (failures.length === 1)
|
|
597
|
+
throw failures[0];
|
|
598
|
+
if (failures.length > 1)
|
|
599
|
+
throw new AggregateError(failures, "Numeric barrier cleanup failed");
|
|
600
|
+
if (!completed)
|
|
601
|
+
throw new Error("Numeric barrier operation did not complete");
|
|
602
|
+
return result;
|
|
559
603
|
}
|
|
560
604
|
async #waitForSuppression(scope, script, arguments_) {
|
|
561
605
|
const deadline = Date.now() + this.#suppressionDrainTimeoutMs;
|
|
@@ -785,6 +829,9 @@ function parseClaim(raw) {
|
|
|
785
829
|
!("operationId" in parsed) ||
|
|
786
830
|
typeof parsed.operationId !== "string" ||
|
|
787
831
|
!UUID.test(parsed.operationId) ||
|
|
832
|
+
!("claimToken" in parsed) ||
|
|
833
|
+
typeof parsed.claimToken !== "string" ||
|
|
834
|
+
!UUID.test(parsed.claimToken) ||
|
|
788
835
|
!("entries" in parsed) ||
|
|
789
836
|
!Array.isArray(parsed.entries) ||
|
|
790
837
|
parsed.entries.length < 1 ||
|
|
@@ -940,10 +987,10 @@ ${BARRIER_HELPERS_LUA}
|
|
|
940
987
|
local now = tonumber(ARGV[1])
|
|
941
988
|
local lease_ms = tonumber(ARGV[2])
|
|
942
989
|
local proposed_id = ARGV[3]
|
|
943
|
-
local
|
|
944
|
-
local
|
|
945
|
-
local
|
|
946
|
-
local
|
|
990
|
+
local claim_token = ARGV[4]
|
|
991
|
+
local limit = tonumber(ARGV[5])
|
|
992
|
+
local guild_filter = ARGV[6]
|
|
993
|
+
local subject_filter = ARGV[7]
|
|
947
994
|
local owner = ARGV[8]
|
|
948
995
|
local lease_prefix = ARGV[9]
|
|
949
996
|
local retry_retention_ms = tonumber(ARGV[10])
|
|
@@ -958,7 +1005,7 @@ for index = 1, #staged, 2 do
|
|
|
958
1005
|
redis.call('HDEL', KEYS[3], member_key)
|
|
959
1006
|
end
|
|
960
1007
|
end
|
|
961
|
-
local due = redis.call('ZRANGEBYSCORE', KEYS[5], '-inf',
|
|
1008
|
+
local due = redis.call('ZRANGEBYSCORE', KEYS[5], '-inf', now, 'LIMIT', 0, 100)
|
|
962
1009
|
for _, operation_id in ipairs(due) do
|
|
963
1010
|
local payload = redis.call('HGET', KEYS[4], operation_id)
|
|
964
1011
|
if payload then
|
|
@@ -1000,6 +1047,7 @@ for _, operation_id in ipairs(due) do
|
|
|
1000
1047
|
end
|
|
1001
1048
|
claim.leaseKey = lease_key
|
|
1002
1049
|
claim.leaseValue = lease_value
|
|
1050
|
+
claim.claimToken = claim_token
|
|
1003
1051
|
local encoded = cjson.encode(claim)
|
|
1004
1052
|
redis.call('HSET', KEYS[4], operation_id, encoded)
|
|
1005
1053
|
redis.call('ZADD', KEYS[5], now + lease_ms, operation_id)
|
|
@@ -1049,6 +1097,7 @@ if #entries == 0 then
|
|
|
1049
1097
|
end
|
|
1050
1098
|
local claim = {
|
|
1051
1099
|
operationId = proposed_id,
|
|
1100
|
+
claimToken = claim_token,
|
|
1052
1101
|
entries = entries,
|
|
1053
1102
|
attempt = 0,
|
|
1054
1103
|
expiresAt = now + retry_retention_ms,
|
|
@@ -1068,6 +1117,7 @@ const ACK_SCRIPT = `
|
|
|
1068
1117
|
local payload = redis.call('HGET', KEYS[4], ARGV[1])
|
|
1069
1118
|
if not payload then return 0 end
|
|
1070
1119
|
local claim = cjson.decode(payload)
|
|
1120
|
+
if claim.claimToken ~= ARGV[2] then return 0 end
|
|
1071
1121
|
if claim.leaseKey and claim.leaseKey ~= '' and
|
|
1072
1122
|
redis.call('GET', claim.leaseKey) ~= claim.leaseValue then return 0 end
|
|
1073
1123
|
for _, entry in ipairs(claim.entries) do
|
|
@@ -1082,13 +1132,25 @@ const RETRY_SCRIPT = `
|
|
|
1082
1132
|
local payload = redis.call('HGET', KEYS[4], ARGV[1])
|
|
1083
1133
|
if not payload then return 0 end
|
|
1084
1134
|
local claim = cjson.decode(payload)
|
|
1085
|
-
claim.
|
|
1135
|
+
if claim.claimToken ~= ARGV[2] then return 0 end
|
|
1136
|
+
claim.attempt = tonumber(ARGV[3])
|
|
1086
1137
|
redis.call('HSET', KEYS[4], ARGV[1], cjson.encode(claim))
|
|
1087
|
-
local retry_at = tonumber(ARGV[
|
|
1138
|
+
local retry_at = tonumber(ARGV[4])
|
|
1088
1139
|
if claim.expiresAt and retry_at > tonumber(claim.expiresAt) then retry_at = tonumber(claim.expiresAt) end
|
|
1089
1140
|
redis.call('ZADD', KEYS[5], retry_at, ARGV[1])
|
|
1090
1141
|
return 1
|
|
1091
1142
|
`;
|
|
1143
|
+
const RENEW_CLAIM_SCRIPT = `
|
|
1144
|
+
local payload = redis.call('HGET', KEYS[4], ARGV[1])
|
|
1145
|
+
if not payload then return 0 end
|
|
1146
|
+
local claim = cjson.decode(payload)
|
|
1147
|
+
if claim.claimToken ~= ARGV[2] then return 0 end
|
|
1148
|
+
if claim.leaseKey and claim.leaseKey ~= '' and
|
|
1149
|
+
redis.call('GET', claim.leaseKey) ~= claim.leaseValue then return 0 end
|
|
1150
|
+
if tonumber(redis.call('ZSCORE', KEYS[5], ARGV[1]) or 0) <= tonumber(ARGV[3]) then return 0 end
|
|
1151
|
+
redis.call('ZADD', KEYS[5], ARGV[4], ARGV[1])
|
|
1152
|
+
return 1
|
|
1153
|
+
`;
|
|
1092
1154
|
const HEALTH_SCRIPT = `
|
|
1093
1155
|
local oldest = redis.call('ZRANGE', KEYS[2], 0, 0, 'WITHSCORES')
|
|
1094
1156
|
return cjson.encode({
|
package/dist/oauth-module.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
2
3
|
const MODULE_ID = "helyx.oauth";
|
|
3
4
|
const MODAL_ID = "helyx.oauth.revoke";
|
|
4
5
|
const SERVICE_FIELD_ID = "helyx.oauth.revoke.service";
|
|
@@ -15,7 +16,7 @@ const everyonePolicy = {
|
|
|
15
16
|
export function createOAuthModule() {
|
|
16
17
|
return defineModule({
|
|
17
18
|
manifest: {
|
|
18
|
-
|
|
19
|
+
...INTERNAL_MODULE_MANIFEST_FIELDS,
|
|
19
20
|
releaseStatus: "live",
|
|
20
21
|
id: MODULE_ID,
|
|
21
22
|
name: "Helyx OAuth",
|
|
@@ -2,6 +2,7 @@ import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
|
2
2
|
import { isCustomerSelectableModule } from "@helyx/module-manifest";
|
|
3
3
|
import { PermissionFlagsBits } from "discord.js";
|
|
4
4
|
import { canMutatePermissionPolicy, permissionMutationDenialMessage, } from "@helyx/core";
|
|
5
|
+
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
5
6
|
const MODULE_SELECT_ID = "helyx.permissions.module";
|
|
6
7
|
const TARGET_SELECT_ID = "helyx.permissions.target";
|
|
7
8
|
const MODULE_PREVIOUS_ID = "helyx.permissions.module.previous";
|
|
@@ -26,7 +27,7 @@ export function createPermissionsModule(configurableModules) {
|
|
|
26
27
|
.sort((left, right) => left.manifest.name.localeCompare(right.manifest.name, "en-GB"));
|
|
27
28
|
return defineModule({
|
|
28
29
|
manifest: {
|
|
29
|
-
|
|
30
|
+
...INTERNAL_MODULE_MANIFEST_FIELDS,
|
|
30
31
|
releaseStatus: "live",
|
|
31
32
|
id: "helyx.permissions",
|
|
32
33
|
name: "Permissions",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface RenewableLeaseOptions {
|
|
2
|
+
intervalMs: number;
|
|
3
|
+
renew(): Promise<void>;
|
|
4
|
+
onError?(error: Error): void;
|
|
5
|
+
}
|
|
6
|
+
export declare class RenewableLease {
|
|
7
|
+
#private;
|
|
8
|
+
private readonly options;
|
|
9
|
+
constructor(options: RenewableLeaseOptions);
|
|
10
|
+
start(): void;
|
|
11
|
+
assertOwned(): void;
|
|
12
|
+
run<Result>(operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
|
|
13
|
+
stop(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=renewable-lease.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export class RenewableLease {
|
|
2
|
+
options;
|
|
3
|
+
#controller = new AbortController();
|
|
4
|
+
#lost;
|
|
5
|
+
#resolveLost;
|
|
6
|
+
#failure;
|
|
7
|
+
#renewing;
|
|
8
|
+
#timer;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
let resolveLost;
|
|
12
|
+
this.#lost = new Promise((resolve) => {
|
|
13
|
+
resolveLost = resolve;
|
|
14
|
+
});
|
|
15
|
+
this.#resolveLost = resolveLost;
|
|
16
|
+
}
|
|
17
|
+
start() {
|
|
18
|
+
if (this.#timer || this.#failure)
|
|
19
|
+
return;
|
|
20
|
+
this.#timer = setInterval(() => this.#tick(), this.options.intervalMs);
|
|
21
|
+
this.#timer.unref();
|
|
22
|
+
}
|
|
23
|
+
assertOwned() {
|
|
24
|
+
if (this.#failure)
|
|
25
|
+
throw this.#failure;
|
|
26
|
+
}
|
|
27
|
+
async run(operation) {
|
|
28
|
+
this.assertOwned();
|
|
29
|
+
const operationOutcome = Promise.resolve()
|
|
30
|
+
.then(() => operation(this.#controller.signal))
|
|
31
|
+
.then((value) => ({ kind: "completed", value }), (error) => ({ kind: "failed", error }));
|
|
32
|
+
const outcome = await Promise.race([
|
|
33
|
+
operationOutcome,
|
|
34
|
+
this.#lost.then((error) => ({ kind: "lease-lost", error })),
|
|
35
|
+
]);
|
|
36
|
+
if (outcome.kind === "lease-lost") {
|
|
37
|
+
await operationOutcome;
|
|
38
|
+
throw outcome.error;
|
|
39
|
+
}
|
|
40
|
+
if (outcome.kind === "failed")
|
|
41
|
+
throw outcome.error;
|
|
42
|
+
this.assertOwned();
|
|
43
|
+
return outcome.value;
|
|
44
|
+
}
|
|
45
|
+
async stop() {
|
|
46
|
+
if (this.#timer)
|
|
47
|
+
clearInterval(this.#timer);
|
|
48
|
+
this.#timer = undefined;
|
|
49
|
+
await this.#renewing;
|
|
50
|
+
}
|
|
51
|
+
#tick() {
|
|
52
|
+
if (this.#renewing || this.#failure)
|
|
53
|
+
return;
|
|
54
|
+
const renewing = this.options.renew().catch((error) => {
|
|
55
|
+
const failure = error instanceof Error ? error : new Error("Lease renewal failed");
|
|
56
|
+
this.#failure = failure;
|
|
57
|
+
this.#controller.abort(failure);
|
|
58
|
+
this.#resolveLost(failure);
|
|
59
|
+
this.options.onError?.(failure);
|
|
60
|
+
});
|
|
61
|
+
this.#renewing = renewing.finally(() => {
|
|
62
|
+
this.#renewing = undefined;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=renewable-lease.js.map
|
|
@@ -58,7 +58,14 @@ export class ScheduledTaskRunner {
|
|
|
58
58
|
const active = this.#active;
|
|
59
59
|
if (!active)
|
|
60
60
|
return;
|
|
61
|
-
await Promise.race([
|
|
61
|
+
const drainedBeforeDeadline = await Promise.race([
|
|
62
|
+
active.then(() => true),
|
|
63
|
+
wait(this.#stopTimeoutMs).then(() => false),
|
|
64
|
+
]);
|
|
65
|
+
if (!drainedBeforeDeadline) {
|
|
66
|
+
this.#logger.warn({ event: "scheduler.stop_deadline_exceeded" }, "Scheduled task runner is still draining after its shutdown deadline");
|
|
67
|
+
await active;
|
|
68
|
+
}
|
|
62
69
|
}
|
|
63
70
|
async runOnce() {
|
|
64
71
|
if (this.#stopping)
|
|
@@ -9,6 +9,11 @@ export declare function supervisorShutdownExitCode(reason: string): number | und
|
|
|
9
9
|
export declare function createStartupWorkerHeartbeatMaintenance(heartbeat: () => void, intervalMs?: number): {
|
|
10
10
|
stop(): void;
|
|
11
11
|
};
|
|
12
|
+
export declare function relinquishRestartCircuitOwnership(input: {
|
|
13
|
+
shardId: number;
|
|
14
|
+
localLeases: Map<number, ShardLease>;
|
|
15
|
+
release(lease: ShardLease): Promise<boolean>;
|
|
16
|
+
}): Promise<boolean>;
|
|
12
17
|
export declare function deferScheduledRoutingFailures(route: () => Promise<ScheduledTaskExecutionResult>): Promise<ScheduledTaskExecutionResult>;
|
|
13
18
|
export declare function startShardSupervisor(environment: BotEnvironment, processEnvironment: NodeJS.ProcessEnv, workerEntry: string): Promise<BotHandle>;
|
|
14
19
|
export declare function splitNumericCommittedBatch(batch: NumericWriteBehindCommittedBatch, index: number): NumericWriteBehindCommittedBatch;
|
package/dist/shard-supervisor.js
CHANGED
|
@@ -57,6 +57,13 @@ export function createStartupWorkerHeartbeatMaintenance(heartbeat, intervalMs =
|
|
|
57
57
|
stop: () => clearInterval(timer),
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
export function relinquishRestartCircuitOwnership(input) {
|
|
61
|
+
const lease = input.localLeases.get(input.shardId);
|
|
62
|
+
if (!lease)
|
|
63
|
+
return Promise.resolve(false);
|
|
64
|
+
input.localLeases.delete(input.shardId);
|
|
65
|
+
return input.release(lease);
|
|
66
|
+
}
|
|
60
67
|
export async function deferScheduledRoutingFailures(route) {
|
|
61
68
|
try {
|
|
62
69
|
return await route();
|
|
@@ -120,6 +127,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
120
127
|
const pendingNumericCommitted = new Map();
|
|
121
128
|
const restartHistory = new Map();
|
|
122
129
|
const restartCounts = new Map();
|
|
130
|
+
const openRestartCircuits = new Set();
|
|
123
131
|
const workers = new Map();
|
|
124
132
|
let sessionBudget;
|
|
125
133
|
let databaseHealthy = false;
|
|
@@ -533,8 +541,9 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
533
541
|
}
|
|
534
542
|
function installNumericCommitRouting(service) {
|
|
535
543
|
for (const registration of numericRegistrations) {
|
|
536
|
-
service.subscribeCommitted(registration, async (batch) => {
|
|
544
|
+
service.subscribeCommitted(registration, async (batch, signal) => {
|
|
537
545
|
for (let index = 0; index < batch.mutations.length; index += 1) {
|
|
546
|
+
signal.throwIfAborted();
|
|
538
547
|
const mutation = batch.mutations[index];
|
|
539
548
|
const result = batch.results[index];
|
|
540
549
|
const guildId = mutation?.identity.guild_id;
|
|
@@ -570,32 +579,53 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
570
579
|
payload: encodeNumericIpcValue(committedItem),
|
|
571
580
|
};
|
|
572
581
|
assertShardMessageSize(notification);
|
|
573
|
-
const response = await dispatchNumericCommitted(shardId, identity, notification);
|
|
582
|
+
const response = await dispatchNumericCommitted(shardId, identity, notification, signal);
|
|
574
583
|
if (!response.ok)
|
|
575
584
|
throw new Error(`Committed numeric batch was not applied: ${response.safeCode}`);
|
|
576
585
|
}
|
|
577
586
|
});
|
|
578
587
|
}
|
|
579
588
|
}
|
|
580
|
-
function dispatchNumericCommitted(shardId, identity, notification) {
|
|
589
|
+
function dispatchNumericCommitted(shardId, identity, notification, signal) {
|
|
581
590
|
const worker = workers.get(shardId);
|
|
582
591
|
if (!worker?.process)
|
|
583
592
|
return Promise.reject(new Error("Committed numeric batch owner is unavailable"));
|
|
584
593
|
return new Promise((resolve, reject) => {
|
|
594
|
+
const onAbort = () => {
|
|
595
|
+
const pending = pendingNumericCommitted.get(notification.requestId);
|
|
596
|
+
if (!pending)
|
|
597
|
+
return;
|
|
598
|
+
pendingNumericCommitted.delete(notification.requestId);
|
|
599
|
+
clearTimeout(pending.timer);
|
|
600
|
+
pending.removeAbortListener();
|
|
601
|
+
reject(signal.reason instanceof Error
|
|
602
|
+
? signal.reason
|
|
603
|
+
: new Error("Numeric claim ownership was lost"));
|
|
604
|
+
};
|
|
585
605
|
const timer = setTimeout(() => {
|
|
606
|
+
const pending = pendingNumericCommitted.get(notification.requestId);
|
|
586
607
|
pendingNumericCommitted.delete(notification.requestId);
|
|
608
|
+
pending?.removeAbortListener();
|
|
587
609
|
reject(new Error("Committed numeric batch acknowledgement timed out"));
|
|
588
610
|
}, SHARD_CONTROL_TIMEOUT_MS);
|
|
589
611
|
timer.unref();
|
|
612
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
590
613
|
pendingNumericCommitted.set(notification.requestId, {
|
|
591
614
|
shardId,
|
|
592
615
|
identity,
|
|
593
616
|
resolve,
|
|
594
617
|
timer,
|
|
618
|
+
removeAbortListener: () => signal.removeEventListener("abort", onAbort),
|
|
595
619
|
});
|
|
620
|
+
if (signal.aborted) {
|
|
621
|
+
onAbort();
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
596
624
|
void worker.send(notification).catch((error) => {
|
|
597
625
|
clearTimeout(timer);
|
|
626
|
+
const pending = pendingNumericCommitted.get(notification.requestId);
|
|
598
627
|
pendingNumericCommitted.delete(notification.requestId);
|
|
628
|
+
pending?.removeAbortListener();
|
|
599
629
|
reject(error instanceof Error
|
|
600
630
|
? error
|
|
601
631
|
: new Error("Committed numeric delivery failed"));
|
|
@@ -720,6 +750,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
720
750
|
return;
|
|
721
751
|
pendingNumericCommitted.delete(message.data.requestId);
|
|
722
752
|
clearTimeout(pending.timer);
|
|
753
|
+
pending.removeAbortListener();
|
|
723
754
|
pending.resolve(message.data);
|
|
724
755
|
return;
|
|
725
756
|
}
|
|
@@ -909,6 +940,25 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
909
940
|
const now = Date.now();
|
|
910
941
|
const recent = (restartHistory.get(shardId) ?? []).filter((time) => now - time < RESTART_WINDOW_MS);
|
|
911
942
|
if (recent.length >= MAX_RESTARTS_PER_WINDOW) {
|
|
943
|
+
openRestartCircuits.add(shardId);
|
|
944
|
+
const circuitCoordinator = coordinator;
|
|
945
|
+
if (circuitCoordinator)
|
|
946
|
+
void relinquishRestartCircuitOwnership({
|
|
947
|
+
shardId,
|
|
948
|
+
localLeases,
|
|
949
|
+
release: (lease) => circuitCoordinator.releaseShard(lease),
|
|
950
|
+
})
|
|
951
|
+
.then((released) => {
|
|
952
|
+
if (!released)
|
|
953
|
+
logger.warn({ event: "shard.circuit_lease_release_stale", shardId }, "Shard restart circuit lease was already reassigned");
|
|
954
|
+
})
|
|
955
|
+
.catch((error) => logger.warn({
|
|
956
|
+
event: "shard.circuit_lease_release_failed",
|
|
957
|
+
shardId,
|
|
958
|
+
error: safeError(error),
|
|
959
|
+
}, "Shard restart circuit lease will expire without renewal"));
|
|
960
|
+
else
|
|
961
|
+
localLeases.delete(shardId);
|
|
912
962
|
logger.error({ event: "shard.circuit_open", shardId, restartCount: recent.length }, "Shard worker restart circuit opened");
|
|
913
963
|
return;
|
|
914
964
|
}
|
|
@@ -998,7 +1048,9 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
998
1048
|
try {
|
|
999
1049
|
const occupied = new Set((await withTimeout(coordinator.getShardLeases(shardCount), LEASE_VALIDATION_TIMEOUT_MS).catch(() => [])).map(({ shardId }) => shardId));
|
|
1000
1050
|
const candidates = Array.from({ length: shardCount }, (_, shardId) => shardId)
|
|
1001
|
-
.filter((shardId) => !localLeases.has(shardId) &&
|
|
1051
|
+
.filter((shardId) => !localLeases.has(shardId) &&
|
|
1052
|
+
!occupied.has(shardId) &&
|
|
1053
|
+
!openRestartCircuits.has(shardId))
|
|
1002
1054
|
.slice(0, capacity);
|
|
1003
1055
|
const results = await Promise.all(candidates.map(async (shardId) => {
|
|
1004
1056
|
const lease = await withTimeout(coordinator.claimShard(generationDescriptor, shardId, deploymentOrder), LEASE_VALIDATION_TIMEOUT_MS).catch(() => undefined);
|
package/dist/support-module.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
|
|
3
4
|
const MODAL_ID = "helyx.support.bug";
|
|
4
5
|
const MODULE_FIELD_ID = "helyx.support.bug.module";
|
|
5
6
|
const SUMMARY_FIELD_ID = "helyx.support.bug.summary";
|
|
@@ -11,7 +12,7 @@ const HOUR_SECONDS = 60 * 60;
|
|
|
11
12
|
export function createSupportModule(transport) {
|
|
12
13
|
return defineModule({
|
|
13
14
|
manifest: {
|
|
14
|
-
|
|
15
|
+
...INTERNAL_MODULE_MANIFEST_FIELDS,
|
|
15
16
|
releaseStatus: "live",
|
|
16
17
|
id: MODULE_ID,
|
|
17
18
|
name: "Helyx Support",
|