@helyx/bot 0.4.8 → 0.4.11

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.
@@ -1,7 +1,10 @@
1
1
  import { z } from "zod";
2
2
  export const SHARD_CONTRACT_VERSION = 1;
3
- export const SHARD_CONTROL_MAX_BYTES = 64 * 1_024;
4
- export const SHARD_CONTROL_TIMEOUT_MS = 10_000;
3
+ // Guild dashboard state includes the bounded module catalogue as well as the
4
+ // guild's Discord resources. Keep this aligned with the cloud-control message
5
+ // ceiling so hosted and self-hosted transports accept the same valid state.
6
+ export const SHARD_CONTROL_MAX_BYTES = 256 * 1_024;
7
+ export const SHARD_CONTROL_TIMEOUT_MS = 30_000;
5
8
  export const SHARD_HEALTH_STALE_MS = 30_000;
6
9
  export const SHARD_LEASE_HEARTBEAT_TIMEOUT_MS = 20_000;
7
10
  const snowflake = z.string().regex(/^\d{17,20}$/u);
@@ -43,6 +46,7 @@ const permissionState = z
43
46
  })
44
47
  .strict();
45
48
  const configurationValue = z.record(z.string().min(1).max(100), z.union([
49
+ z.null(),
46
50
  z.string().max(4_000),
47
51
  z.boolean(),
48
52
  z.number().finite(),
@@ -355,6 +359,7 @@ export const shardControlOperationSchema = z.discriminatedUnion("kind", [
355
359
  actionId: resourceType,
356
360
  expectedRevision: z.number().int().min(0),
357
361
  value: managedResourceObject,
362
+ actorAuthenticatedAt: dateTime.optional(),
358
363
  ...mutationContext.shape,
359
364
  })
360
365
  .strict(),
@@ -567,11 +572,246 @@ export const shardScheduledTaskResponseSchema = z
567
572
  result: scheduledTaskResult,
568
573
  })
569
574
  .strict();
575
+ const numericWireValueSchema = z.discriminatedUnion("type", [
576
+ z
577
+ .object({ type: z.literal("string"), value: z.string().max(4_000) })
578
+ .strict(),
579
+ z.object({ type: z.literal("boolean"), value: z.boolean() }).strict(),
580
+ z.object({ type: z.literal("number"), value: z.number().finite() }).strict(),
581
+ z.object({ type: z.literal("date"), value: dateTime }).strict(),
582
+ ]);
583
+ const numericWireRecordSchema = z
584
+ .record(z.string().regex(/^[a-z][a-z0-9_]*$/u), numericWireValueSchema)
585
+ .refine((value) => Object.keys(value).length <= 50);
586
+ const numericScopeSchema = z.object({
587
+ moduleId,
588
+ collection: z.string().regex(/^[a-z][a-z0-9_]*$/u),
589
+ });
590
+ const numericMemberScopeSchema = {
591
+ ...numericScopeSchema.shape,
592
+ guildId: snowflake,
593
+ subjectId: safeIdentifier,
594
+ };
595
+ const numericOperationSchema = z
596
+ .discriminatedUnion("kind", [
597
+ z
598
+ .object({
599
+ kind: z.literal("enqueue"),
600
+ ...numericMemberScopeSchema,
601
+ occurredAt: dateTime,
602
+ cooldownSeconds: z.number().int().min(1).max(86_400),
603
+ identity: numericWireRecordSchema,
604
+ insert: numericWireRecordSchema,
605
+ increments: z
606
+ .record(z.string().regex(/^[a-z][a-z0-9_]*$/u), z.number().int().positive())
607
+ .refine((value) => {
608
+ const length = Object.keys(value).length;
609
+ return length >= 1 && length <= 20;
610
+ }),
611
+ maxima: z
612
+ .record(z.string().regex(/^[a-z][a-z0-9_]*$/u), dateTime)
613
+ .refine((value) => Object.keys(value).length <= 20)
614
+ .optional(),
615
+ })
616
+ .strict(),
617
+ z
618
+ .object({
619
+ kind: z.literal("pending.member"),
620
+ ...numericMemberScopeSchema,
621
+ })
622
+ .strict(),
623
+ z
624
+ .object({ kind: z.literal("flush.member"), ...numericMemberScopeSchema })
625
+ .strict(),
626
+ z
627
+ .object({
628
+ kind: z.literal("flush.guild"),
629
+ ...numericScopeSchema.shape,
630
+ guildId: snowflake,
631
+ })
632
+ .strict(),
633
+ z
634
+ .object({
635
+ kind: z.literal("flush.subject"),
636
+ ...numericScopeSchema.shape,
637
+ subjectId: safeIdentifier,
638
+ })
639
+ .strict(),
640
+ z
641
+ .object({ kind: z.literal("flush.module"), ...numericScopeSchema.shape })
642
+ .strict(),
643
+ z
644
+ .object({
645
+ kind: z.literal("barrier.acquire"),
646
+ ...numericScopeSchema.shape,
647
+ barrierType: z.enum({
648
+ member: "member",
649
+ guild: "guild",
650
+ subject: "subject",
651
+ }),
652
+ guildId: snowflake.optional(),
653
+ subjectId: safeIdentifier.optional(),
654
+ })
655
+ .strict(),
656
+ z
657
+ .object({
658
+ kind: z.literal("barrier.release"),
659
+ ...numericScopeSchema.shape,
660
+ barrierType: z.enum({
661
+ member: "member",
662
+ guild: "guild",
663
+ subject: "subject",
664
+ }),
665
+ guildId: snowflake.optional(),
666
+ subjectId: safeIdentifier.optional(),
667
+ barrierId: z.uuid(),
668
+ })
669
+ .strict(),
670
+ z
671
+ .object({
672
+ kind: z.literal("barrier.renew"),
673
+ ...numericScopeSchema.shape,
674
+ barrierType: z.enum({
675
+ member: "member",
676
+ guild: "guild",
677
+ subject: "subject",
678
+ }),
679
+ guildId: snowflake.optional(),
680
+ subjectId: safeIdentifier.optional(),
681
+ barrierId: z.uuid(),
682
+ })
683
+ .strict(),
684
+ z
685
+ .object({
686
+ kind: z.literal("suppress.subject"),
687
+ ...numericScopeSchema.shape,
688
+ subjectId: safeIdentifier,
689
+ cutoff: dateTime,
690
+ })
691
+ .strict(),
692
+ z
693
+ .object({
694
+ kind: z.literal("suppress.guild"),
695
+ ...numericScopeSchema.shape,
696
+ guildId: snowflake,
697
+ })
698
+ .strict(),
699
+ z
700
+ .object({
701
+ kind: z.literal("release.guild"),
702
+ ...numericScopeSchema.shape,
703
+ guildId: snowflake,
704
+ suppressionId: z.uuid(),
705
+ })
706
+ .strict(),
707
+ z
708
+ .object({
709
+ kind: z.literal("release.subject"),
710
+ ...numericScopeSchema.shape,
711
+ subjectId: safeIdentifier,
712
+ suppressionId: z.uuid(),
713
+ })
714
+ .strict(),
715
+ z
716
+ .object({
717
+ kind: z.literal("cooldown.member.clear"),
718
+ ...numericMemberScopeSchema,
719
+ })
720
+ .strict(),
721
+ z
722
+ .object({
723
+ kind: z.literal("cooldown.subject.clear"),
724
+ ...numericScopeSchema.shape,
725
+ subjectId: safeIdentifier,
726
+ })
727
+ .strict(),
728
+ ])
729
+ .superRefine((operation, context) => {
730
+ if (operation.kind !== "barrier.acquire" &&
731
+ operation.kind !== "barrier.release" &&
732
+ operation.kind !== "barrier.renew")
733
+ return;
734
+ const valid = operation.barrierType === "member"
735
+ ? operation.guildId !== undefined && operation.subjectId !== undefined
736
+ : operation.barrierType === "guild"
737
+ ? operation.guildId !== undefined && operation.subjectId === undefined
738
+ : operation.guildId === undefined &&
739
+ operation.subjectId !== undefined;
740
+ if (!valid)
741
+ context.addIssue({
742
+ code: "custom",
743
+ message: "Numeric barrier scope does not match its barrier type",
744
+ });
745
+ });
746
+ export const shardNumericRequestSchema = z
747
+ .object({
748
+ version: z.literal(SHARD_CONTRACT_VERSION),
749
+ type: z.literal("helyx.numeric.request"),
750
+ requestId: z.uuid(),
751
+ deadlineAt: dateTime,
752
+ identity: shardIdentitySchema,
753
+ operation: numericOperationSchema,
754
+ })
755
+ .strict();
756
+ export const shardNumericResponseSchema = z.discriminatedUnion("ok", [
757
+ z
758
+ .object({
759
+ version: z.literal(SHARD_CONTRACT_VERSION),
760
+ type: z.literal("helyx.numeric.response"),
761
+ requestId: z.uuid(),
762
+ ok: z.literal(true),
763
+ result: z.unknown(),
764
+ })
765
+ .strict(),
766
+ z
767
+ .object({
768
+ version: z.literal(SHARD_CONTRACT_VERSION),
769
+ type: z.literal("helyx.numeric.response"),
770
+ requestId: z.uuid(),
771
+ ok: z.literal(false),
772
+ safeCode: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/u),
773
+ })
774
+ .strict(),
775
+ ]);
776
+ export const shardNumericCommittedSchema = z
777
+ .object({
778
+ version: z.literal(SHARD_CONTRACT_VERSION),
779
+ type: z.literal("helyx.numeric.committed"),
780
+ requestId: z.uuid(),
781
+ deadlineAt: dateTime,
782
+ identity: shardIdentitySchema,
783
+ scope: numericScopeSchema.strict(),
784
+ payload: z.unknown(),
785
+ })
786
+ .strict();
787
+ export const shardNumericCommittedResponseSchema = z.discriminatedUnion("ok", [
788
+ z
789
+ .object({
790
+ version: z.literal(SHARD_CONTRACT_VERSION),
791
+ type: z.literal("helyx.numeric.committed.response"),
792
+ requestId: z.uuid(),
793
+ identity: shardIdentitySchema,
794
+ ok: z.literal(true),
795
+ })
796
+ .strict(),
797
+ z
798
+ .object({
799
+ version: z.literal(SHARD_CONTRACT_VERSION),
800
+ type: z.literal("helyx.numeric.committed.response"),
801
+ requestId: z.uuid(),
802
+ identity: shardIdentitySchema,
803
+ ok: z.literal(false),
804
+ safeCode: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/u),
805
+ })
806
+ .strict(),
807
+ ]);
570
808
  export const supervisorMessageSchema = z.discriminatedUnion("type", [
571
809
  shardControlRequestSchema,
572
810
  shardCloudResponseSchema,
573
811
  shardOperationsHealthResponseSchema,
574
812
  shardScheduledTaskRequestSchema,
813
+ shardNumericResponseSchema,
814
+ shardNumericCommittedSchema,
575
815
  z
576
816
  .object({
577
817
  version: z.literal(SHARD_CONTRACT_VERSION),
@@ -598,6 +838,8 @@ export const workerMessageSchema = z.discriminatedUnion("type", [
598
838
  shardCloudRequestSchema,
599
839
  shardOperationsHealthRequestSchema,
600
840
  shardScheduledTaskResponseSchema,
841
+ shardNumericRequestSchema,
842
+ shardNumericCommittedResponseSchema,
601
843
  z
602
844
  .object({
603
845
  version: z.literal(SHARD_CONTRACT_VERSION),
@@ -758,6 +1000,9 @@ export async function executeShardControlOperation(handler, operation) {
758
1000
  value: operation.value,
759
1001
  actorUserId: operation.actorUserId,
760
1002
  correlationId: operation.correlationId,
1003
+ ...(operation.actorAuthenticatedAt
1004
+ ? { actorAuthenticatedAt: operation.actorAuthenticatedAt }
1005
+ : {}),
761
1006
  });
762
1007
  case "support.notification.send": {
763
1008
  return handler.sendSupportNotification({
@@ -1,12 +1,16 @@
1
1
  import type { BotEnvironment } from "@helyx/config";
2
2
  import { type DatabasePool } from "@helyx/database";
3
- import type { ModuleDefinition, ScheduledTaskExecutionResult } from "@helyx/sdk";
3
+ import type { ModuleDefinition, NumericWriteBehindCommittedBatch, ScheduledTaskExecutionResult } from "@helyx/sdk";
4
4
  import { type ShardCloudRequest, type ShardIdentity, type ShardProcessHealth } from "./shard-contracts.js";
5
5
  import { type ShardCoordinator, type ShardGenerationDescriptor, type ShardLeaderLease, type ShardLease } from "./shard-coordinator.js";
6
6
  import type { BotHandle } from "./index.js";
7
7
  export declare const STEADY_STATE_LEASE_LOSS_BOUND_MS: number;
8
+ export declare function createStartupWorkerHeartbeatMaintenance(heartbeat: () => void, intervalMs?: number): {
9
+ stop(): void;
10
+ };
8
11
  export declare function deferScheduledRoutingFailures(route: () => Promise<ScheduledTaskExecutionResult>): Promise<ScheduledTaskExecutionResult>;
9
12
  export declare function startShardSupervisor(environment: BotEnvironment, processEnvironment: NodeJS.ProcessEnv, workerEntry: string): Promise<BotHandle>;
13
+ export declare function splitNumericCommittedBatch(batch: NumericWriteBehindCommittedBatch, index: number): NumericWriteBehindCommittedBatch;
10
14
  export declare function buildShardWorkerEnvironment(source: NodeJS.ProcessEnv, identity: ShardIdentity, restartCount: number): NodeJS.ProcessEnv;
11
15
  export declare function shardRuntimeCompositionHash(modules: readonly ModuleDefinition[], internalGuildId?: string): string;
12
16
  export declare function boundedRespawnDelay(attempt: number): number;
@@ -1,6 +1,7 @@
1
1
  import { safeError, ServiceRegistry } from "@helyx/core";
2
2
  import { checkDatabaseConnection, createDatabasePool, InteractionSessionRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
3
3
  import { createPlatformServices, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
4
+ import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
4
5
  import { Client, ShardingManager } from "discord.js";
5
6
  import { createHash, randomUUID } from "node:crypto";
6
7
  import { createRequire } from "node:module";
@@ -15,13 +16,16 @@ import { buildApplicationCommandDefinitions } from "./interactions.js";
15
16
  import { resolveModulePackageSpecifiers } from "./module-configuration.js";
16
17
  import { deriveSecureRandomSelectionKey, loadConfiguredModulePackages, platformServiceOptions, secureRandomSelectionWorkerKeyName, } from "./modules.js";
17
18
  import { createPermissionsModule } from "./permissions-module.js";
18
- import { SHARD_CONTROL_MAX_BYTES, SHARD_HEALTH_STALE_MS, assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, gatewayBotInformationSchema, guildIdToShardId, shardCloudRequestSchema, shardHealthSnapshotSchema, workerMessageSchema, } from "./shard-contracts.js";
19
+ import { SHARD_CONTROL_MAX_BYTES, SHARD_CONTROL_TIMEOUT_MS, SHARD_HEALTH_STALE_MS, assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, gatewayBotInformationSchema, guildIdToShardId, shardCloudRequestSchema, shardHealthSnapshotSchema, workerMessageSchema, } from "./shard-contracts.js";
19
20
  import { RedisShardCoordinator, } from "./shard-coordinator.js";
20
21
  import { ShardControlRouter } from "./shard-router.js";
21
22
  import { createDeveloperOperationsModule } from "./developer-operations-module.js";
22
23
  import { SupervisorCloudDiscordDirectory } from "./supervisor-cloud-directory.js";
23
24
  import { createSupportModule } from "./support-module.js";
24
25
  import { createScheduledTaskRunner, schedulerOperationalHealth, } from "./scheduled-task-runner.js";
26
+ import { numericWriteBehindRegistrations } from "./numeric-write-behind.js";
27
+ import { executeRedisNumericOperation, RedisNumericWriteBehindService, } from "./numeric-write-behind-redis.js";
28
+ import { encodeNumericIpcValue } from "./numeric-write-behind-proxy.js";
25
29
  const LEASE_RENEW_INTERVAL_MS = 10_000;
26
30
  const STARTUP_LEASE_RENEW_INTERVAL_MS = 5_000;
27
31
  const LEASE_VALIDATION_TIMEOUT_MS = 1_000;
@@ -31,6 +35,13 @@ const WORKER_SHUTDOWN_MS = 5_000;
31
35
  const MAX_RESTARTS_PER_WINDOW = 5;
32
36
  const RESTART_WINDOW_MS = 10 * 60 * 1_000;
33
37
  export const STEADY_STATE_LEASE_LOSS_BOUND_MS = LEASE_RENEW_INTERVAL_MS + LEASE_VALIDATION_TIMEOUT_MS + WORKER_SHUTDOWN_MS;
38
+ export function createStartupWorkerHeartbeatMaintenance(heartbeat, intervalMs = STARTUP_LEASE_RENEW_INTERVAL_MS) {
39
+ const timer = setInterval(heartbeat, intervalMs);
40
+ timer.unref();
41
+ return {
42
+ stop: () => clearInterval(timer),
43
+ };
44
+ }
34
45
  export async function deferScheduledRoutingFailures(route) {
35
46
  try {
36
47
  return await route();
@@ -43,6 +54,14 @@ export async function deferScheduledRoutingFailures(route) {
43
54
  };
44
55
  }
45
56
  }
57
+ function sameNumericIdentity(left, right) {
58
+ return (left.generation === right.generation &&
59
+ left.deploymentOrder === right.deploymentOrder &&
60
+ left.supervisorId === right.supervisorId &&
61
+ left.shardId === right.shardId &&
62
+ left.shardCount === right.shardCount &&
63
+ left.fencingToken === right.fencingToken);
64
+ }
46
65
  export async function startShardSupervisor(environment, processEnvironment, workerEntry) {
47
66
  if (environment.HELYX_SHARD_MODE === "off")
48
67
  throw new Error("Shard supervisor requires sharding to be enabled");
@@ -65,6 +84,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
65
84
  let privacyDatabase;
66
85
  let controlServer;
67
86
  let coordinator;
87
+ let numericWriteBehind;
88
+ let numericRegistrations = [];
68
89
  let leaderLease;
69
90
  let cloudConnector;
70
91
  let cloudStarted = false;
@@ -73,6 +94,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
73
94
  let retentionTimer;
74
95
  let leaseTimer;
75
96
  let startupLeaseTimer;
97
+ let startupWorkerHeartbeats;
76
98
  let startupLeaseRefresh;
77
99
  let startupLeaseError;
78
100
  let shuttingDown = false;
@@ -80,6 +102,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
80
102
  const healthByShard = new Map();
81
103
  const pendingControl = new Map();
82
104
  const pendingScheduled = new Map();
105
+ const pendingNumericCommitted = new Map();
83
106
  const restartHistory = new Map();
84
107
  const restartCounts = new Map();
85
108
  const workers = new Map();
@@ -152,6 +175,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
152
175
  ...(cloudManagementModule ? [cloudManagementModule] : []),
153
176
  ...featureModules,
154
177
  ];
178
+ numericRegistrations = numericWriteBehindRegistrations(publicModules);
155
179
  scheduledModules = publicModules;
156
180
  const operationsModule = environment.DISCORD_INTERNAL_GUILD_ID
157
181
  ? createDeveloperOperationsModule({
@@ -186,6 +210,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
186
210
  privacyDatabase = await createPrivacyDatabase(environment, database);
187
211
  assertStartupLeaseHealthy();
188
212
  const services = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
213
+ if (numericWriteBehind)
214
+ services.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
189
215
  privacyService = new BotControlService(database, publicModules, unavailableResources, new ServiceRegistry(services), privacyDatabase
190
216
  ? new PostgresPrivacyProviderExecutor({
191
217
  readPool: database,
@@ -225,6 +251,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
225
251
  });
226
252
  supervisorId = coordinator.supervisorId;
227
253
  await coordinator.start((targetShardId, request) => dispatchLocal(targetShardId, request), (request) => executeLeaderControl(request), (request) => executeLeaderCloud(request), (targetShardId, request) => dispatchLocalScheduled(targetShardId, request));
254
+ const numericPlatformServices = createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules));
255
+ numericWriteBehind = new RedisNumericWriteBehindService({
256
+ redisUrl: environment.HELYX_SHARD_REDIS_URL,
257
+ namespace: environment.HELYX_SHARD_REDIS_NAMESPACE,
258
+ writer: numericPlatformServices.get(HELYX_SERVICE_NAMES.records),
259
+ registrations: numericRegistrations,
260
+ supervisorId,
261
+ shardLeaseKeyPrefix: `helyx:shard:v1:${environment.HELYX_SHARD_REDIS_NAMESPACE}:lease:shard:`,
262
+ onError: (error) => logger.error({
263
+ event: "numeric_write_behind.redis_failed",
264
+ error: safeError(error),
265
+ }, "Persistent numeric write-behind operation failed"),
266
+ });
267
+ await numericWriteBehind.start();
268
+ installNumericCommitRouting(numericWriteBehind);
228
269
  deploymentOrder = await coordinator.assertGeneration(generationDescriptor, deploymentOrder);
229
270
  leaderLease = await coordinator.acquireLeader(generationDescriptor, deploymentOrder);
230
271
  startStartupLeaseMaintenance();
@@ -297,6 +338,11 @@ export async function startShardSupervisor(environment, processEnvironment, work
297
338
  execArgv: process.execArgv,
298
339
  });
299
340
  await reserveInitialSessionStarts(claimedIds.length, gateway);
341
+ if (coordinator) {
342
+ startupWorkerHeartbeats = createStartupWorkerHeartbeatMaintenance(() => {
343
+ void Promise.all([...localLeases].map(([shardId, lease]) => sendLeaseHeartbeat(shardId, lease)));
344
+ });
345
+ }
300
346
  await spawnInIdentifyBuckets(claimedIds, gateway.session_start_limit.max_concurrency);
301
347
  workersRoutable = true;
302
348
  if (leaderLease)
@@ -317,8 +363,11 @@ export async function startShardSupervisor(environment, processEnvironment, work
317
363
  await stopStartupLeaseMaintenance();
318
364
  if (coordinator)
319
365
  await withTimeout(renewStartupOwnership(coordinator, generationDescriptor, deploymentOrder, leaderLease), LEASE_VALIDATION_TIMEOUT_MS);
366
+ await Promise.all([...localLeases].map(([shardId, lease]) => sendLeaseHeartbeat(shardId, lease)));
320
367
  leaseTimer = setInterval(() => void maintainLeases(), LEASE_RENEW_INTERVAL_MS);
321
368
  leaseTimer.unref();
369
+ startupWorkerHeartbeats?.stop();
370
+ startupWorkerHeartbeats = undefined;
322
371
  process.once("SIGINT", () => void shutdown("SIGINT"));
323
372
  process.once("SIGTERM", () => void shutdown("SIGTERM"));
324
373
  logger.info({
@@ -414,17 +463,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
414
463
  };
415
464
  shard.env = buildShardWorkerEnvironment(processEnvironment, identity, restartCounts.get(shardId) ?? 0);
416
465
  healthByShard.set(shardId, startingHealth(shardId, restartCounts.get(shardId) ?? 0));
417
- const launchHeartbeatTimer = coordinator
418
- ? setInterval(() => void sendLeaseHeartbeat(shardId, lease), STARTUP_LEASE_RENEW_INTERVAL_MS)
419
- : undefined;
420
- launchHeartbeatTimer?.unref();
421
- try {
422
- await spawnWithShutdownGuard(() => shuttingDown, () => shard.spawn(30_000), () => shard.kill());
423
- }
424
- finally {
425
- if (launchHeartbeatTimer)
426
- clearInterval(launchHeartbeatTimer);
427
- }
466
+ await spawnWithShutdownGuard(() => shuttingDown, () => shard.spawn(30_000), () => shard.kill());
428
467
  await sendLeaseHeartbeat(shardId, lease);
429
468
  }
430
469
  async function sendLeaseHeartbeat(shardId, lease) {
@@ -452,6 +491,135 @@ export async function startShardSupervisor(environment, processEnvironment, work
452
491
  ?.send(heartbeat)
453
492
  .catch(() => undefined);
454
493
  }
494
+ function installNumericCommitRouting(service) {
495
+ for (const registration of numericRegistrations) {
496
+ service.subscribeCommitted(registration, async (batch) => {
497
+ for (let index = 0; index < batch.mutations.length; index += 1) {
498
+ const mutation = batch.mutations[index];
499
+ const result = batch.results[index];
500
+ const guildId = mutation?.identity.guild_id;
501
+ if (typeof guildId !== "string" || !result)
502
+ continue;
503
+ const committedItem = splitNumericCommittedBatch(batch, index);
504
+ const shardId = guildIdToShardId(guildId, shardCount);
505
+ const lease = localLeases.get(shardId);
506
+ const worker = workers.get(shardId);
507
+ if (!lease || !worker?.process)
508
+ throw new Error("Committed numeric batch owner is not local or available");
509
+ const identity = {
510
+ version: 1,
511
+ mode: shardMode,
512
+ scope: environment.HELYX_SHARD_SCOPE,
513
+ generation: lease.generation,
514
+ deploymentOrder: lease.deploymentOrder,
515
+ supervisorId: lease.supervisorId,
516
+ shardId,
517
+ shardCount,
518
+ fencingToken: lease.fencingToken,
519
+ };
520
+ const notification = {
521
+ version: 1,
522
+ type: "helyx.numeric.committed",
523
+ requestId: randomUUID(),
524
+ deadlineAt: new Date(Date.now() + SHARD_CONTROL_TIMEOUT_MS).toISOString(),
525
+ identity,
526
+ scope: {
527
+ moduleId: batch.moduleId,
528
+ collection: batch.collection,
529
+ },
530
+ payload: encodeNumericIpcValue(committedItem),
531
+ };
532
+ assertShardMessageSize(notification);
533
+ const response = await dispatchNumericCommitted(shardId, identity, notification);
534
+ if (!response.ok)
535
+ throw new Error(`Committed numeric batch was not applied: ${response.safeCode}`);
536
+ }
537
+ });
538
+ }
539
+ }
540
+ function dispatchNumericCommitted(shardId, identity, notification) {
541
+ const worker = workers.get(shardId);
542
+ if (!worker?.process)
543
+ return Promise.reject(new Error("Committed numeric batch owner is unavailable"));
544
+ return new Promise((resolve, reject) => {
545
+ const timer = setTimeout(() => {
546
+ pendingNumericCommitted.delete(notification.requestId);
547
+ reject(new Error("Committed numeric batch acknowledgement timed out"));
548
+ }, SHARD_CONTROL_TIMEOUT_MS);
549
+ timer.unref();
550
+ pendingNumericCommitted.set(notification.requestId, {
551
+ shardId,
552
+ identity,
553
+ resolve,
554
+ timer,
555
+ });
556
+ void worker.send(notification).catch((error) => {
557
+ clearTimeout(timer);
558
+ pendingNumericCommitted.delete(notification.requestId);
559
+ reject(error instanceof Error
560
+ ? error
561
+ : new Error("Committed numeric delivery failed"));
562
+ });
563
+ });
564
+ }
565
+ async function handleNumericRequest(shardId, request) {
566
+ const lease = localLeases.get(shardId);
567
+ const numericService = numericWriteBehind;
568
+ const guildId = "guildId" in request.operation ? request.operation.guildId : undefined;
569
+ const valid = numericService !== undefined &&
570
+ lease !== undefined &&
571
+ request.identity.shardId === shardId &&
572
+ request.identity.shardCount === shardCount &&
573
+ request.identity.generation === lease.generation &&
574
+ request.identity.supervisorId === lease.supervisorId &&
575
+ request.identity.fencingToken === lease.fencingToken &&
576
+ request.identity.deploymentOrder === lease.deploymentOrder &&
577
+ request.identity.deploymentOrder !== undefined &&
578
+ new Date(request.deadlineAt).getTime() > Date.now() &&
579
+ (!guildId || guildIdToShardId(guildId, shardCount) === shardId);
580
+ let response;
581
+ if (!valid) {
582
+ response = {
583
+ version: 1,
584
+ type: "helyx.numeric.response",
585
+ requestId: request.requestId,
586
+ ok: false,
587
+ safeCode: "stale_owner",
588
+ };
589
+ }
590
+ else {
591
+ try {
592
+ if (!numericService)
593
+ throw new Error("Numeric write-behind service is unavailable");
594
+ response = {
595
+ version: 1,
596
+ type: "helyx.numeric.response",
597
+ requestId: request.requestId,
598
+ ok: true,
599
+ result: encodeNumericIpcValue(await executeRedisNumericOperation(numericService, request.operation, {
600
+ identity: request.identity,
601
+ shardLeaseKeyPrefix: `helyx:shard:v1:${environment.HELYX_SHARD_REDIS_NAMESPACE}:lease:shard:`,
602
+ })),
603
+ };
604
+ }
605
+ catch (error) {
606
+ logger.error({
607
+ event: "numeric_write_behind.ipc_failed",
608
+ shardId,
609
+ error: safeError(error),
610
+ }, "Numeric write-behind IPC operation failed");
611
+ response = {
612
+ version: 1,
613
+ type: "helyx.numeric.response",
614
+ requestId: request.requestId,
615
+ ok: false,
616
+ safeCode: "operation_failed",
617
+ };
618
+ }
619
+ }
620
+ assertShardMessageSize(response);
621
+ await workers.get(shardId)?.send(response);
622
+ }
455
623
  async function handleWorkerMessage(shardId, raw) {
456
624
  if (serializedBytes(raw) > SHARD_CONTROL_MAX_BYTES)
457
625
  return;
@@ -489,6 +657,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
489
657
  pending.resolve(message.data);
490
658
  return;
491
659
  }
660
+ if (message.data.type === "helyx.numeric.committed.response") {
661
+ const pending = pendingNumericCommitted.get(message.data.requestId);
662
+ if (!pending ||
663
+ pending.shardId !== shardId ||
664
+ !sameNumericIdentity(pending.identity, message.data.identity))
665
+ return;
666
+ pendingNumericCommitted.delete(message.data.requestId);
667
+ clearTimeout(pending.timer);
668
+ pending.resolve(message.data);
669
+ return;
670
+ }
671
+ if (message.data.type === "helyx.numeric.request") {
672
+ await handleNumericRequest(shardId, message.data);
673
+ return;
674
+ }
492
675
  if (message.data.type === "helyx.cloud.request") {
493
676
  const response = leaderLease
494
677
  ? await executeLeaderCloud(message.data)
@@ -985,6 +1168,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
985
1168
  clearInterval(leaseTimer);
986
1169
  if (startupLeaseTimer)
987
1170
  clearInterval(startupLeaseTimer);
1171
+ startupWorkerHeartbeats?.stop();
1172
+ startupWorkerHeartbeats = undefined;
988
1173
  if (retentionTimer)
989
1174
  clearInterval(retentionTimer);
990
1175
  workersRoutable = false;
@@ -1006,6 +1191,23 @@ export async function startShardSupervisor(environment, processEnvironment, work
1006
1191
  if (shard.process)
1007
1192
  shard.kill();
1008
1193
  }));
1194
+ if (numericWriteBehind) {
1195
+ try {
1196
+ const { drained } = await numericWriteBehind.stop();
1197
+ if (!drained) {
1198
+ process.exitCode = 1;
1199
+ logger.error({ event: "numeric_write_behind.shutdown_not_drained", reason }, "Numeric write-behind exceeded its bounded shutdown drain");
1200
+ }
1201
+ }
1202
+ catch (error) {
1203
+ process.exitCode = 1;
1204
+ logger.error({
1205
+ event: "numeric_write_behind.shutdown_failed",
1206
+ reason,
1207
+ error: safeError(error),
1208
+ }, "Numeric write-behind shutdown failed");
1209
+ }
1210
+ }
1009
1211
  await cloudConnector?.stop().catch(() => undefined);
1010
1212
  if (coordinator) {
1011
1213
  const activeCoordinator = coordinator;
@@ -1019,6 +1221,18 @@ export async function startShardSupervisor(environment, processEnvironment, work
1019
1221
  logger.info({ event: "shard.supervisor_stopped", reason }, "Shard supervisor stopped");
1020
1222
  }
1021
1223
  }
1224
+ export function splitNumericCommittedBatch(batch, index) {
1225
+ const mutation = batch.mutations[index];
1226
+ const result = batch.results[index];
1227
+ if (!Number.isSafeInteger(index) || index < 0 || !mutation || !result)
1228
+ throw new Error("Numeric committed batch item is unavailable");
1229
+ return {
1230
+ ...batch,
1231
+ itemIndexes: [batch.itemIndexes?.[index] ?? index],
1232
+ mutations: [mutation],
1233
+ results: [result],
1234
+ };
1235
+ }
1022
1236
  async function runLeaderStartup(database, environment, modulePackages, modules, logger) {
1023
1237
  await runCoreMigrations(database);
1024
1238
  await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
@@ -1130,6 +1344,7 @@ export function buildShardWorkerEnvironment(source, identity, restartCount) {
1130
1344
  "DISCORD_INTERNAL_GUILD_ID",
1131
1345
  "HELYX_SUPPORT_STAFF_CHANNEL_ID",
1132
1346
  "HELYX_MODULE_PACKAGES",
1347
+ "HELYX_MESSAGE_CONTENT_ENABLED",
1133
1348
  "HELYX_MODULE_CATALOGUE_PATH",
1134
1349
  "HELYX_CLOUD_CONNECT_ENABLED",
1135
1350
  "HELYX_OWNER_USER_IDS",