@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 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
- manifestVersion: "0.1",
76
+ ...INTERNAL_MODULE_MANIFEST_FIELDS,
76
77
  releaseStatus: "live",
77
78
  id: "helyx.cloud-connect",
78
79
  name: "Helyx Cloud Connect",
@@ -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
- const runBoundary = async (index) => {
667
- const scope = scopes[index];
668
- if (!scope) {
669
- await setEnabled();
670
- return;
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
- manifestVersion: "0.1",
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
- await Promise.race([
831
- new Promise((resolve) => this.#drainWaiters.add(resolve)),
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
- manifestVersion: "0.1",
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
- let renewalFailure;
161
- const renew = setInterval(() => {
162
- void this.#call({
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
- }).catch((error) => {
170
- renewalFailure =
171
- error instanceof Error
172
- ? error
173
- : new Error("Numeric barrier renewal failed");
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
- }, 20_000);
176
- renew.unref();
187
+ completed = true;
188
+ }
189
+ catch (error) {
190
+ failures.push(error);
191
+ }
177
192
  try {
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
- const result = await operation();
185
- if (renewalFailure)
186
- throw renewalFailure;
187
- return result;
193
+ await renewal.stop();
188
194
  }
189
- finally {
190
- clearInterval(renew);
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
- for (const subscriber of this.#subscribers.get(scopeKey(committed.scope)) ?? [])
208
- await subscriber(batch);
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
- 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
- }
193
+ return this.#runBarrier(input, "member", input.guildId, input.subjectId, () => this.flushMember(input), operation);
202
194
  }
203
195
  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
- }
196
+ return this.#runBarrier(input, "guild", input.guildId, "", () => this.flushGuild(input), operation);
214
197
  }
215
198
  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
- }
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
- 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],
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: [claim.operationId, String(attempt), String(retryAt)],
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
- #startBarrierRenewal(scope, kind, guildId, subjectId, barrierId) {
554
- const timer = setInterval(() => {
555
- void this.#renewBarrier(scope, kind, guildId, subjectId, barrierId).catch((error) => this.#onError?.(toError(error)));
556
- }, Math.max(250, Math.floor(this.#barrierLeaseMs / 3)));
557
- timer.unref();
558
- return () => clearInterval(timer);
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 limit = tonumber(ARGV[4])
944
- local guild_filter = ARGV[5]
945
- local subject_filter = ARGV[6]
946
- local force = ARGV[7] == '1'
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', force and '+inf' or now, 'LIMIT', 0, 100)
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.attempt = tonumber(ARGV[2])
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[3])
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({
@@ -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
- manifestVersion: "0.1",
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
- manifestVersion: "0.1",
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([active, wait(this.#stopTimeoutMs)]);
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;
@@ -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) && !occupied.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);
@@ -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
- manifestVersion: "0.1",
15
+ ...INTERNAL_MODULE_MANIFEST_FIELDS,
15
16
  releaseStatus: "live",
16
17
  id: MODULE_ID,
17
18
  name: "Helyx Support",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",