@helyx/bot 0.4.9 → 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 +33 -0
- package/dist/cloud-control-dispatcher.js +2 -1
- package/dist/control-server.d.ts +5 -0
- package/dist/control-server.js +104 -13
- package/dist/events.d.ts +64 -1
- package/dist/events.js +699 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +69 -19
- package/dist/interactions.js +79 -16
- package/dist/management-module.js +42 -10
- package/dist/message-classification.d.ts +5 -0
- package/dist/message-classification.js +75 -0
- package/dist/modules.js +9 -0
- package/dist/numeric-write-behind-proxy.d.ts +62 -0
- package/dist/numeric-write-behind-proxy.js +313 -0
- package/dist/numeric-write-behind-redis.d.ts +123 -0
- package/dist/numeric-write-behind-redis.js +1281 -0
- package/dist/numeric-write-behind.d.ts +5 -0
- package/dist/numeric-write-behind.js +37 -0
- package/dist/shard-contracts.d.ts +486 -0
- package/dist/shard-contracts.js +245 -1
- package/dist/shard-supervisor.d.ts +2 -1
- package/dist/shard-supervisor.js +208 -1
- package/dist/shard-worker.d.ts +1 -0
- package/dist/shard-worker.js +42 -5
- package/package.json +9 -9
package/dist/shard-contracts.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export const SHARD_CONTRACT_VERSION = 1;
|
|
3
|
-
|
|
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;
|
|
4
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;
|
|
@@ -356,6 +359,7 @@ export const shardControlOperationSchema = z.discriminatedUnion("kind", [
|
|
|
356
359
|
actionId: resourceType,
|
|
357
360
|
expectedRevision: z.number().int().min(0),
|
|
358
361
|
value: managedResourceObject,
|
|
362
|
+
actorAuthenticatedAt: dateTime.optional(),
|
|
359
363
|
...mutationContext.shape,
|
|
360
364
|
})
|
|
361
365
|
.strict(),
|
|
@@ -568,11 +572,246 @@ export const shardScheduledTaskResponseSchema = z
|
|
|
568
572
|
result: scheduledTaskResult,
|
|
569
573
|
})
|
|
570
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
|
+
]);
|
|
571
808
|
export const supervisorMessageSchema = z.discriminatedUnion("type", [
|
|
572
809
|
shardControlRequestSchema,
|
|
573
810
|
shardCloudResponseSchema,
|
|
574
811
|
shardOperationsHealthResponseSchema,
|
|
575
812
|
shardScheduledTaskRequestSchema,
|
|
813
|
+
shardNumericResponseSchema,
|
|
814
|
+
shardNumericCommittedSchema,
|
|
576
815
|
z
|
|
577
816
|
.object({
|
|
578
817
|
version: z.literal(SHARD_CONTRACT_VERSION),
|
|
@@ -599,6 +838,8 @@ export const workerMessageSchema = z.discriminatedUnion("type", [
|
|
|
599
838
|
shardCloudRequestSchema,
|
|
600
839
|
shardOperationsHealthRequestSchema,
|
|
601
840
|
shardScheduledTaskResponseSchema,
|
|
841
|
+
shardNumericRequestSchema,
|
|
842
|
+
shardNumericCommittedResponseSchema,
|
|
602
843
|
z
|
|
603
844
|
.object({
|
|
604
845
|
version: z.literal(SHARD_CONTRACT_VERSION),
|
|
@@ -759,6 +1000,9 @@ export async function executeShardControlOperation(handler, operation) {
|
|
|
759
1000
|
value: operation.value,
|
|
760
1001
|
actorUserId: operation.actorUserId,
|
|
761
1002
|
correlationId: operation.correlationId,
|
|
1003
|
+
...(operation.actorAuthenticatedAt
|
|
1004
|
+
? { actorAuthenticatedAt: operation.actorAuthenticatedAt }
|
|
1005
|
+
: {}),
|
|
762
1006
|
});
|
|
763
1007
|
case "support.notification.send": {
|
|
764
1008
|
return handler.sendSupportNotification({
|
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
@@ -10,6 +10,7 @@ export declare function createStartupWorkerHeartbeatMaintenance(heartbeat: () =>
|
|
|
10
10
|
};
|
|
11
11
|
export declare function deferScheduledRoutingFailures(route: () => Promise<ScheduledTaskExecutionResult>): Promise<ScheduledTaskExecutionResult>;
|
|
12
12
|
export declare function startShardSupervisor(environment: BotEnvironment, processEnvironment: NodeJS.ProcessEnv, workerEntry: string): Promise<BotHandle>;
|
|
13
|
+
export declare function splitNumericCommittedBatch(batch: NumericWriteBehindCommittedBatch, index: number): NumericWriteBehindCommittedBatch;
|
|
13
14
|
export declare function buildShardWorkerEnvironment(source: NodeJS.ProcessEnv, identity: ShardIdentity, restartCount: number): NodeJS.ProcessEnv;
|
|
14
15
|
export declare function shardRuntimeCompositionHash(modules: readonly ModuleDefinition[], internalGuildId?: string): string;
|
|
15
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;
|
|
@@ -50,6 +54,14 @@ export async function deferScheduledRoutingFailures(route) {
|
|
|
50
54
|
};
|
|
51
55
|
}
|
|
52
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
|
+
}
|
|
53
65
|
export async function startShardSupervisor(environment, processEnvironment, workerEntry) {
|
|
54
66
|
if (environment.HELYX_SHARD_MODE === "off")
|
|
55
67
|
throw new Error("Shard supervisor requires sharding to be enabled");
|
|
@@ -72,6 +84,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
72
84
|
let privacyDatabase;
|
|
73
85
|
let controlServer;
|
|
74
86
|
let coordinator;
|
|
87
|
+
let numericWriteBehind;
|
|
88
|
+
let numericRegistrations = [];
|
|
75
89
|
let leaderLease;
|
|
76
90
|
let cloudConnector;
|
|
77
91
|
let cloudStarted = false;
|
|
@@ -88,6 +102,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
88
102
|
const healthByShard = new Map();
|
|
89
103
|
const pendingControl = new Map();
|
|
90
104
|
const pendingScheduled = new Map();
|
|
105
|
+
const pendingNumericCommitted = new Map();
|
|
91
106
|
const restartHistory = new Map();
|
|
92
107
|
const restartCounts = new Map();
|
|
93
108
|
const workers = new Map();
|
|
@@ -160,6 +175,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
160
175
|
...(cloudManagementModule ? [cloudManagementModule] : []),
|
|
161
176
|
...featureModules,
|
|
162
177
|
];
|
|
178
|
+
numericRegistrations = numericWriteBehindRegistrations(publicModules);
|
|
163
179
|
scheduledModules = publicModules;
|
|
164
180
|
const operationsModule = environment.DISCORD_INTERNAL_GUILD_ID
|
|
165
181
|
? createDeveloperOperationsModule({
|
|
@@ -194,6 +210,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
194
210
|
privacyDatabase = await createPrivacyDatabase(environment, database);
|
|
195
211
|
assertStartupLeaseHealthy();
|
|
196
212
|
const services = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
|
|
213
|
+
if (numericWriteBehind)
|
|
214
|
+
services.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
|
|
197
215
|
privacyService = new BotControlService(database, publicModules, unavailableResources, new ServiceRegistry(services), privacyDatabase
|
|
198
216
|
? new PostgresPrivacyProviderExecutor({
|
|
199
217
|
readPool: database,
|
|
@@ -233,6 +251,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
233
251
|
});
|
|
234
252
|
supervisorId = coordinator.supervisorId;
|
|
235
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);
|
|
236
269
|
deploymentOrder = await coordinator.assertGeneration(generationDescriptor, deploymentOrder);
|
|
237
270
|
leaderLease = await coordinator.acquireLeader(generationDescriptor, deploymentOrder);
|
|
238
271
|
startStartupLeaseMaintenance();
|
|
@@ -458,6 +491,135 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
458
491
|
?.send(heartbeat)
|
|
459
492
|
.catch(() => undefined);
|
|
460
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
|
+
}
|
|
461
623
|
async function handleWorkerMessage(shardId, raw) {
|
|
462
624
|
if (serializedBytes(raw) > SHARD_CONTROL_MAX_BYTES)
|
|
463
625
|
return;
|
|
@@ -495,6 +657,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
495
657
|
pending.resolve(message.data);
|
|
496
658
|
return;
|
|
497
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
|
+
}
|
|
498
675
|
if (message.data.type === "helyx.cloud.request") {
|
|
499
676
|
const response = leaderLease
|
|
500
677
|
? await executeLeaderCloud(message.data)
|
|
@@ -1014,6 +1191,23 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
1014
1191
|
if (shard.process)
|
|
1015
1192
|
shard.kill();
|
|
1016
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
|
+
}
|
|
1017
1211
|
await cloudConnector?.stop().catch(() => undefined);
|
|
1018
1212
|
if (coordinator) {
|
|
1019
1213
|
const activeCoordinator = coordinator;
|
|
@@ -1027,6 +1221,18 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
1027
1221
|
logger.info({ event: "shard.supervisor_stopped", reason }, "Shard supervisor stopped");
|
|
1028
1222
|
}
|
|
1029
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
|
+
}
|
|
1030
1236
|
async function runLeaderStartup(database, environment, modulePackages, modules, logger) {
|
|
1031
1237
|
await runCoreMigrations(database);
|
|
1032
1238
|
await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
|
|
@@ -1138,6 +1344,7 @@ export function buildShardWorkerEnvironment(source, identity, restartCount) {
|
|
|
1138
1344
|
"DISCORD_INTERNAL_GUILD_ID",
|
|
1139
1345
|
"HELYX_SUPPORT_STAFF_CHANNEL_ID",
|
|
1140
1346
|
"HELYX_MODULE_PACKAGES",
|
|
1347
|
+
"HELYX_MESSAGE_CONTENT_ENABLED",
|
|
1141
1348
|
"HELYX_MODULE_CATALOGUE_PATH",
|
|
1142
1349
|
"HELYX_CLOUD_CONNECT_ENABLED",
|
|
1143
1350
|
"HELYX_OWNER_USER_IDS",
|
package/dist/shard-worker.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
|
|
|
37
37
|
HELYX_SUPPORT_STAFF_CHANNEL_ID: string | undefined;
|
|
38
38
|
HELYX_BOT_CONTROL_PORT: number;
|
|
39
39
|
HELYX_PRIVACY_ENABLED: boolean;
|
|
40
|
+
HELYX_MESSAGE_CONTENT_ENABLED: boolean;
|
|
40
41
|
HELYX_CLOUD_CONNECT_ENABLED: boolean;
|
|
41
42
|
HELYX_CLOUD_ENROLMENT_TOKEN: string | undefined;
|
|
42
43
|
HELYX_CLOUD_ENROLMENT_FILE: string;
|