@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.
- package/CHANGELOG.md +50 -0
- package/dist/cloud-control-dispatcher.js +2 -1
- package/dist/control-server.d.ts +5 -0
- package/dist/control-server.js +106 -14
- package/dist/discord-owned-message-service.js +9 -1
- package/dist/events.d.ts +64 -1
- package/dist/events.js +709 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +69 -19
- package/dist/interactions.js +167 -28
- package/dist/management-module.js +42 -10
- package/dist/message-classification.d.ts +5 -0
- package/dist/message-classification.js +75 -0
- package/dist/modules.js +9 -0
- package/dist/numeric-write-behind-proxy.d.ts +62 -0
- package/dist/numeric-write-behind-proxy.js +313 -0
- package/dist/numeric-write-behind-redis.d.ts +123 -0
- package/dist/numeric-write-behind-redis.js +1281 -0
- package/dist/numeric-write-behind.d.ts +5 -0
- package/dist/numeric-write-behind.js +37 -0
- package/dist/scheduled-task-runner.js +11 -5
- package/dist/shard-contracts.d.ts +490 -4
- package/dist/shard-contracts.js +247 -2
- package/dist/shard-supervisor.d.ts +5 -1
- package/dist/shard-supervisor.js +227 -12
- package/dist/shard-worker.d.ts +1 -0
- package/dist/shard-worker.js +51 -7
- package/package.json +9 -9
package/dist/shard-contracts.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const SHARD_CONTRACT_VERSION = 1;
|
|
3
|
-
|
|
4
|
-
|
|
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;
|
package/dist/shard-supervisor.js
CHANGED
|
@@ -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
|
-
|
|
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",
|