@helyx/bot 0.4.9 → 0.4.13
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 +66 -0
- package/dist/cloud-control-dispatcher.js +2 -1
- package/dist/control-server.d.ts +43 -3
- package/dist/control-server.js +228 -18
- package/dist/events.d.ts +92 -1
- package/dist/events.js +1483 -5
- package/dist/guilds.d.ts +20 -1
- package/dist/guilds.js +149 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.js +89 -23
- package/dist/interactions.js +101 -22
- package/dist/management-module.d.ts +1 -1
- 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 +810 -0
- package/dist/shard-contracts.js +292 -3
- package/dist/shard-router.d.ts +3 -0
- package/dist/shard-router.js +42 -0
- package/dist/shard-supervisor.d.ts +4 -1
- package/dist/shard-supervisor.js +262 -7
- package/dist/shard-worker.d.ts +7 -0
- package/dist/shard-worker.js +64 -10
- package/dist/verification-service.d.ts +50 -0
- package/dist/verification-service.js +166 -0
- package/package.json +10 -9
package/dist/shard-supervisor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { safeError, ServiceRegistry } from "@helyx/core";
|
|
2
|
-
import { checkDatabaseConnection, createDatabasePool, InteractionSessionRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
|
|
2
|
+
import { checkDatabaseConnection, createDatabasePool, AuditRepository, 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,22 +16,38 @@ 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;
|
|
32
|
+
const LEASE_RENEW_ATTEMPTS = 2;
|
|
28
33
|
const RETENTION_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
|
|
29
34
|
const STARTUP_WAIT_MS = 60_000;
|
|
30
35
|
const WORKER_SHUTDOWN_MS = 5_000;
|
|
31
36
|
const MAX_RESTARTS_PER_WINDOW = 5;
|
|
32
37
|
const RESTART_WINDOW_MS = 10 * 60 * 1_000;
|
|
33
|
-
export const STEADY_STATE_LEASE_LOSS_BOUND_MS = LEASE_RENEW_INTERVAL_MS +
|
|
38
|
+
export const STEADY_STATE_LEASE_LOSS_BOUND_MS = LEASE_RENEW_INTERVAL_MS +
|
|
39
|
+
LEASE_VALIDATION_TIMEOUT_MS * LEASE_RENEW_ATTEMPTS +
|
|
40
|
+
WORKER_SHUTDOWN_MS;
|
|
41
|
+
const FATAL_SUPERVISOR_SHUTDOWN_REASONS = new Set([
|
|
42
|
+
"generation-lease-lost",
|
|
43
|
+
"leader-lease-lost",
|
|
44
|
+
"leader-promotion-failed",
|
|
45
|
+
"rebalance-failed",
|
|
46
|
+
"coordination-unavailable",
|
|
47
|
+
]);
|
|
48
|
+
export function supervisorShutdownExitCode(reason) {
|
|
49
|
+
return FATAL_SUPERVISOR_SHUTDOWN_REASONS.has(reason) ? 1 : undefined;
|
|
50
|
+
}
|
|
34
51
|
export function createStartupWorkerHeartbeatMaintenance(heartbeat, intervalMs = STARTUP_LEASE_RENEW_INTERVAL_MS) {
|
|
35
52
|
const timer = setInterval(heartbeat, intervalMs);
|
|
36
53
|
timer.unref();
|
|
@@ -50,6 +67,14 @@ export async function deferScheduledRoutingFailures(route) {
|
|
|
50
67
|
};
|
|
51
68
|
}
|
|
52
69
|
}
|
|
70
|
+
function sameNumericIdentity(left, right) {
|
|
71
|
+
return (left.generation === right.generation &&
|
|
72
|
+
left.deploymentOrder === right.deploymentOrder &&
|
|
73
|
+
left.supervisorId === right.supervisorId &&
|
|
74
|
+
left.shardId === right.shardId &&
|
|
75
|
+
left.shardCount === right.shardCount &&
|
|
76
|
+
left.fencingToken === right.fencingToken);
|
|
77
|
+
}
|
|
53
78
|
export async function startShardSupervisor(environment, processEnvironment, workerEntry) {
|
|
54
79
|
if (environment.HELYX_SHARD_MODE === "off")
|
|
55
80
|
throw new Error("Shard supervisor requires sharding to be enabled");
|
|
@@ -72,6 +97,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
72
97
|
let privacyDatabase;
|
|
73
98
|
let controlServer;
|
|
74
99
|
let coordinator;
|
|
100
|
+
let numericWriteBehind;
|
|
101
|
+
let numericRegistrations = [];
|
|
75
102
|
let leaderLease;
|
|
76
103
|
let cloudConnector;
|
|
77
104
|
let cloudStarted = false;
|
|
@@ -88,6 +115,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
88
115
|
const healthByShard = new Map();
|
|
89
116
|
const pendingControl = new Map();
|
|
90
117
|
const pendingScheduled = new Map();
|
|
118
|
+
const pendingNumericCommitted = new Map();
|
|
91
119
|
const restartHistory = new Map();
|
|
92
120
|
const restartCounts = new Map();
|
|
93
121
|
const workers = new Map();
|
|
@@ -135,6 +163,11 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
135
163
|
});
|
|
136
164
|
const commandClient = new Client({ intents: [] });
|
|
137
165
|
const unavailableResources = {
|
|
166
|
+
listGuildPresence: (guildIds) => Promise.resolve(guildIds.map((guildId) => ({
|
|
167
|
+
guildId,
|
|
168
|
+
state: "unknown",
|
|
169
|
+
observedAt: new Date().toISOString(),
|
|
170
|
+
}))),
|
|
138
171
|
getGuildResources: () => Promise.reject(new Error("Guild resources are worker-owned")),
|
|
139
172
|
sendToChannel: () => Promise.reject(new Error("Guild resources are worker-owned")),
|
|
140
173
|
deleteChannelMessage: () => Promise.reject(new Error("Guild resources are worker-owned")),
|
|
@@ -160,6 +193,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
160
193
|
...(cloudManagementModule ? [cloudManagementModule] : []),
|
|
161
194
|
...featureModules,
|
|
162
195
|
];
|
|
196
|
+
numericRegistrations = numericWriteBehindRegistrations(publicModules);
|
|
163
197
|
scheduledModules = publicModules;
|
|
164
198
|
const operationsModule = environment.DISCORD_INTERNAL_GUILD_ID
|
|
165
199
|
? createDeveloperOperationsModule({
|
|
@@ -194,6 +228,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
194
228
|
privacyDatabase = await createPrivacyDatabase(environment, database);
|
|
195
229
|
assertStartupLeaseHealthy();
|
|
196
230
|
const services = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
|
|
231
|
+
if (numericWriteBehind)
|
|
232
|
+
services.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
|
|
197
233
|
privacyService = new BotControlService(database, publicModules, unavailableResources, new ServiceRegistry(services), privacyDatabase
|
|
198
234
|
? new PostgresPrivacyProviderExecutor({
|
|
199
235
|
readPool: database,
|
|
@@ -233,6 +269,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
233
269
|
});
|
|
234
270
|
supervisorId = coordinator.supervisorId;
|
|
235
271
|
await coordinator.start((targetShardId, request) => dispatchLocal(targetShardId, request), (request) => executeLeaderControl(request), (request) => executeLeaderCloud(request), (targetShardId, request) => dispatchLocalScheduled(targetShardId, request));
|
|
272
|
+
const numericPlatformServices = createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules));
|
|
273
|
+
numericWriteBehind = new RedisNumericWriteBehindService({
|
|
274
|
+
redisUrl: environment.HELYX_SHARD_REDIS_URL,
|
|
275
|
+
namespace: environment.HELYX_SHARD_REDIS_NAMESPACE,
|
|
276
|
+
writer: numericPlatformServices.get(HELYX_SERVICE_NAMES.records),
|
|
277
|
+
registrations: numericRegistrations,
|
|
278
|
+
supervisorId,
|
|
279
|
+
shardLeaseKeyPrefix: `helyx:shard:v1:${environment.HELYX_SHARD_REDIS_NAMESPACE}:lease:shard:`,
|
|
280
|
+
onError: (error) => logger.error({
|
|
281
|
+
event: "numeric_write_behind.redis_failed",
|
|
282
|
+
error: safeError(error),
|
|
283
|
+
}, "Persistent numeric write-behind operation failed"),
|
|
284
|
+
});
|
|
285
|
+
await numericWriteBehind.start();
|
|
286
|
+
installNumericCommitRouting(numericWriteBehind);
|
|
236
287
|
deploymentOrder = await coordinator.assertGeneration(generationDescriptor, deploymentOrder);
|
|
237
288
|
leaderLease = await coordinator.acquireLeader(generationDescriptor, deploymentOrder);
|
|
238
289
|
startStartupLeaseMaintenance();
|
|
@@ -458,6 +509,135 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
458
509
|
?.send(heartbeat)
|
|
459
510
|
.catch(() => undefined);
|
|
460
511
|
}
|
|
512
|
+
function installNumericCommitRouting(service) {
|
|
513
|
+
for (const registration of numericRegistrations) {
|
|
514
|
+
service.subscribeCommitted(registration, async (batch) => {
|
|
515
|
+
for (let index = 0; index < batch.mutations.length; index += 1) {
|
|
516
|
+
const mutation = batch.mutations[index];
|
|
517
|
+
const result = batch.results[index];
|
|
518
|
+
const guildId = mutation?.identity.guild_id;
|
|
519
|
+
if (typeof guildId !== "string" || !result)
|
|
520
|
+
continue;
|
|
521
|
+
const committedItem = splitNumericCommittedBatch(batch, index);
|
|
522
|
+
const shardId = guildIdToShardId(guildId, shardCount);
|
|
523
|
+
const lease = localLeases.get(shardId);
|
|
524
|
+
const worker = workers.get(shardId);
|
|
525
|
+
if (!lease || !worker?.process)
|
|
526
|
+
throw new Error("Committed numeric batch owner is not local or available");
|
|
527
|
+
const identity = {
|
|
528
|
+
version: 1,
|
|
529
|
+
mode: shardMode,
|
|
530
|
+
scope: environment.HELYX_SHARD_SCOPE,
|
|
531
|
+
generation: lease.generation,
|
|
532
|
+
deploymentOrder: lease.deploymentOrder,
|
|
533
|
+
supervisorId: lease.supervisorId,
|
|
534
|
+
shardId,
|
|
535
|
+
shardCount,
|
|
536
|
+
fencingToken: lease.fencingToken,
|
|
537
|
+
};
|
|
538
|
+
const notification = {
|
|
539
|
+
version: 1,
|
|
540
|
+
type: "helyx.numeric.committed",
|
|
541
|
+
requestId: randomUUID(),
|
|
542
|
+
deadlineAt: new Date(Date.now() + SHARD_CONTROL_TIMEOUT_MS).toISOString(),
|
|
543
|
+
identity,
|
|
544
|
+
scope: {
|
|
545
|
+
moduleId: batch.moduleId,
|
|
546
|
+
collection: batch.collection,
|
|
547
|
+
},
|
|
548
|
+
payload: encodeNumericIpcValue(committedItem),
|
|
549
|
+
};
|
|
550
|
+
assertShardMessageSize(notification);
|
|
551
|
+
const response = await dispatchNumericCommitted(shardId, identity, notification);
|
|
552
|
+
if (!response.ok)
|
|
553
|
+
throw new Error(`Committed numeric batch was not applied: ${response.safeCode}`);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function dispatchNumericCommitted(shardId, identity, notification) {
|
|
559
|
+
const worker = workers.get(shardId);
|
|
560
|
+
if (!worker?.process)
|
|
561
|
+
return Promise.reject(new Error("Committed numeric batch owner is unavailable"));
|
|
562
|
+
return new Promise((resolve, reject) => {
|
|
563
|
+
const timer = setTimeout(() => {
|
|
564
|
+
pendingNumericCommitted.delete(notification.requestId);
|
|
565
|
+
reject(new Error("Committed numeric batch acknowledgement timed out"));
|
|
566
|
+
}, SHARD_CONTROL_TIMEOUT_MS);
|
|
567
|
+
timer.unref();
|
|
568
|
+
pendingNumericCommitted.set(notification.requestId, {
|
|
569
|
+
shardId,
|
|
570
|
+
identity,
|
|
571
|
+
resolve,
|
|
572
|
+
timer,
|
|
573
|
+
});
|
|
574
|
+
void worker.send(notification).catch((error) => {
|
|
575
|
+
clearTimeout(timer);
|
|
576
|
+
pendingNumericCommitted.delete(notification.requestId);
|
|
577
|
+
reject(error instanceof Error
|
|
578
|
+
? error
|
|
579
|
+
: new Error("Committed numeric delivery failed"));
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
async function handleNumericRequest(shardId, request) {
|
|
584
|
+
const lease = localLeases.get(shardId);
|
|
585
|
+
const numericService = numericWriteBehind;
|
|
586
|
+
const guildId = "guildId" in request.operation ? request.operation.guildId : undefined;
|
|
587
|
+
const valid = numericService !== undefined &&
|
|
588
|
+
lease !== undefined &&
|
|
589
|
+
request.identity.shardId === shardId &&
|
|
590
|
+
request.identity.shardCount === shardCount &&
|
|
591
|
+
request.identity.generation === lease.generation &&
|
|
592
|
+
request.identity.supervisorId === lease.supervisorId &&
|
|
593
|
+
request.identity.fencingToken === lease.fencingToken &&
|
|
594
|
+
request.identity.deploymentOrder === lease.deploymentOrder &&
|
|
595
|
+
request.identity.deploymentOrder !== undefined &&
|
|
596
|
+
new Date(request.deadlineAt).getTime() > Date.now() &&
|
|
597
|
+
(!guildId || guildIdToShardId(guildId, shardCount) === shardId);
|
|
598
|
+
let response;
|
|
599
|
+
if (!valid) {
|
|
600
|
+
response = {
|
|
601
|
+
version: 1,
|
|
602
|
+
type: "helyx.numeric.response",
|
|
603
|
+
requestId: request.requestId,
|
|
604
|
+
ok: false,
|
|
605
|
+
safeCode: "stale_owner",
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
try {
|
|
610
|
+
if (!numericService)
|
|
611
|
+
throw new Error("Numeric write-behind service is unavailable");
|
|
612
|
+
response = {
|
|
613
|
+
version: 1,
|
|
614
|
+
type: "helyx.numeric.response",
|
|
615
|
+
requestId: request.requestId,
|
|
616
|
+
ok: true,
|
|
617
|
+
result: encodeNumericIpcValue(await executeRedisNumericOperation(numericService, request.operation, {
|
|
618
|
+
identity: request.identity,
|
|
619
|
+
shardLeaseKeyPrefix: `helyx:shard:v1:${environment.HELYX_SHARD_REDIS_NAMESPACE}:lease:shard:`,
|
|
620
|
+
})),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
logger.error({
|
|
625
|
+
event: "numeric_write_behind.ipc_failed",
|
|
626
|
+
shardId,
|
|
627
|
+
error: safeError(error),
|
|
628
|
+
}, "Numeric write-behind IPC operation failed");
|
|
629
|
+
response = {
|
|
630
|
+
version: 1,
|
|
631
|
+
type: "helyx.numeric.response",
|
|
632
|
+
requestId: request.requestId,
|
|
633
|
+
ok: false,
|
|
634
|
+
safeCode: "operation_failed",
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
assertShardMessageSize(response);
|
|
639
|
+
await workers.get(shardId)?.send(response);
|
|
640
|
+
}
|
|
461
641
|
async function handleWorkerMessage(shardId, raw) {
|
|
462
642
|
if (serializedBytes(raw) > SHARD_CONTROL_MAX_BYTES)
|
|
463
643
|
return;
|
|
@@ -495,6 +675,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
495
675
|
pending.resolve(message.data);
|
|
496
676
|
return;
|
|
497
677
|
}
|
|
678
|
+
if (message.data.type === "helyx.numeric.committed.response") {
|
|
679
|
+
const pending = pendingNumericCommitted.get(message.data.requestId);
|
|
680
|
+
if (!pending ||
|
|
681
|
+
pending.shardId !== shardId ||
|
|
682
|
+
!sameNumericIdentity(pending.identity, message.data.identity))
|
|
683
|
+
return;
|
|
684
|
+
pendingNumericCommitted.delete(message.data.requestId);
|
|
685
|
+
clearTimeout(pending.timer);
|
|
686
|
+
pending.resolve(message.data);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (message.data.type === "helyx.numeric.request") {
|
|
690
|
+
await handleNumericRequest(shardId, message.data);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
498
693
|
if (message.data.type === "helyx.cloud.request") {
|
|
499
694
|
const response = leaderLease
|
|
500
695
|
? await executeLeaderCloud(message.data)
|
|
@@ -706,8 +901,19 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
706
901
|
try {
|
|
707
902
|
if (!coordinator)
|
|
708
903
|
return;
|
|
709
|
-
|
|
710
|
-
|
|
904
|
+
let renewal;
|
|
905
|
+
try {
|
|
906
|
+
renewal = await retryOwnershipRenewal(() => renewSteadyStateOwnership(coordinator, generationDescriptor, deploymentOrder, localLeases, leaderLease));
|
|
907
|
+
}
|
|
908
|
+
catch (error) {
|
|
909
|
+
logger.error({
|
|
910
|
+
event: "shard.coordination_renewal_failed",
|
|
911
|
+
error: safeError(error),
|
|
912
|
+
}, "Shard coordination could not be renewed after a bounded retry");
|
|
913
|
+
await shutdown("coordination-unavailable");
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (!renewal.generationRenewed) {
|
|
711
917
|
logger.error({ event: "shard.generation_lease_lost" }, "Shard generation lease was lost; stopping this supervisor safely");
|
|
712
918
|
await shutdown("generation-lease-lost");
|
|
713
919
|
return;
|
|
@@ -987,6 +1193,9 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
987
1193
|
if (shuttingDown)
|
|
988
1194
|
return;
|
|
989
1195
|
shuttingDown = true;
|
|
1196
|
+
const shutdownExitCode = supervisorShutdownExitCode(reason);
|
|
1197
|
+
if (shutdownExitCode !== undefined)
|
|
1198
|
+
process.exitCode = shutdownExitCode;
|
|
990
1199
|
if (leaseTimer)
|
|
991
1200
|
clearInterval(leaseTimer);
|
|
992
1201
|
if (startupLeaseTimer)
|
|
@@ -1014,6 +1223,23 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
1014
1223
|
if (shard.process)
|
|
1015
1224
|
shard.kill();
|
|
1016
1225
|
}));
|
|
1226
|
+
if (numericWriteBehind) {
|
|
1227
|
+
try {
|
|
1228
|
+
const { drained } = await numericWriteBehind.stop();
|
|
1229
|
+
if (!drained) {
|
|
1230
|
+
process.exitCode = 1;
|
|
1231
|
+
logger.error({ event: "numeric_write_behind.shutdown_not_drained", reason }, "Numeric write-behind exceeded its bounded shutdown drain");
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
catch (error) {
|
|
1235
|
+
process.exitCode = 1;
|
|
1236
|
+
logger.error({
|
|
1237
|
+
event: "numeric_write_behind.shutdown_failed",
|
|
1238
|
+
reason,
|
|
1239
|
+
error: safeError(error),
|
|
1240
|
+
}, "Numeric write-behind shutdown failed");
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1017
1243
|
await cloudConnector?.stop().catch(() => undefined);
|
|
1018
1244
|
if (coordinator) {
|
|
1019
1245
|
const activeCoordinator = coordinator;
|
|
@@ -1027,6 +1253,18 @@ export async function startShardSupervisor(environment, processEnvironment, work
|
|
|
1027
1253
|
logger.info({ event: "shard.supervisor_stopped", reason }, "Shard supervisor stopped");
|
|
1028
1254
|
}
|
|
1029
1255
|
}
|
|
1256
|
+
export function splitNumericCommittedBatch(batch, index) {
|
|
1257
|
+
const mutation = batch.mutations[index];
|
|
1258
|
+
const result = batch.results[index];
|
|
1259
|
+
if (!Number.isSafeInteger(index) || index < 0 || !mutation || !result)
|
|
1260
|
+
throw new Error("Numeric committed batch item is unavailable");
|
|
1261
|
+
return {
|
|
1262
|
+
...batch,
|
|
1263
|
+
itemIndexes: [batch.itemIndexes?.[index] ?? index],
|
|
1264
|
+
mutations: [mutation],
|
|
1265
|
+
results: [result],
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1030
1268
|
async function runLeaderStartup(database, environment, modulePackages, modules, logger) {
|
|
1031
1269
|
await runCoreMigrations(database);
|
|
1032
1270
|
await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
|
|
@@ -1138,6 +1376,7 @@ export function buildShardWorkerEnvironment(source, identity, restartCount) {
|
|
|
1138
1376
|
"DISCORD_INTERNAL_GUILD_ID",
|
|
1139
1377
|
"HELYX_SUPPORT_STAFF_CHANNEL_ID",
|
|
1140
1378
|
"HELYX_MODULE_PACKAGES",
|
|
1379
|
+
"HELYX_MESSAGE_CONTENT_ENABLED",
|
|
1141
1380
|
"HELYX_MODULE_CATALOGUE_PATH",
|
|
1142
1381
|
"HELYX_CLOUD_CONNECT_ENABLED",
|
|
1143
1382
|
"HELYX_OWNER_USER_IDS",
|
|
@@ -1243,15 +1482,17 @@ function failedCloud(requestId, message) {
|
|
|
1243
1482
|
}
|
|
1244
1483
|
async function cleanupExpiredCoreRecords(database, logger) {
|
|
1245
1484
|
try {
|
|
1246
|
-
const [sessions, rateLimits] = await Promise.all([
|
|
1485
|
+
const [sessions, rateLimits, verificationAudit] = await Promise.all([
|
|
1247
1486
|
new InteractionSessionRepository(database).deleteExpired(),
|
|
1248
1487
|
new RateLimitRepository(database).deleteExpired(),
|
|
1488
|
+
new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
|
|
1249
1489
|
]);
|
|
1250
|
-
if (sessions > 0 || rateLimits > 0)
|
|
1490
|
+
if (sessions > 0 || rateLimits > 0 || verificationAudit > 0)
|
|
1251
1491
|
logger.info({
|
|
1252
1492
|
event: "database.retention_cleaned",
|
|
1253
1493
|
interactionSessions: sessions,
|
|
1254
1494
|
rateLimitWindows: rateLimits,
|
|
1495
|
+
verificationAuditEvents: verificationAudit,
|
|
1255
1496
|
}, "Expired core records removed");
|
|
1256
1497
|
}
|
|
1257
1498
|
catch (error) {
|
|
@@ -1302,6 +1543,20 @@ async function waitForDeath(shard, timeoutMs) {
|
|
|
1302
1543
|
export function boundedRespawnDelay(attempt) {
|
|
1303
1544
|
return Math.min(30_000, 1_000 * 2 ** Math.max(0, attempt - 1));
|
|
1304
1545
|
}
|
|
1546
|
+
export async function retryOwnershipRenewal(renew, attempts = LEASE_RENEW_ATTEMPTS) {
|
|
1547
|
+
if (!Number.isSafeInteger(attempts) || attempts < 1)
|
|
1548
|
+
throw new Error("Ownership renewal attempts must be a positive integer");
|
|
1549
|
+
let lastError;
|
|
1550
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1551
|
+
try {
|
|
1552
|
+
return await renew();
|
|
1553
|
+
}
|
|
1554
|
+
catch (error) {
|
|
1555
|
+
lastError = error;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
throw lastError;
|
|
1559
|
+
}
|
|
1305
1560
|
export function selectFencedShardHealth(input) {
|
|
1306
1561
|
const liveByShard = new Map(input.liveLeases.map((lease) => [lease.shardId, lease]));
|
|
1307
1562
|
const selected = new Map();
|
package/dist/shard-worker.d.ts
CHANGED
|
@@ -35,8 +35,12 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
|
|
|
35
35
|
HELYX_MODULE_PACKAGES: string[];
|
|
36
36
|
HELYX_MODULE_CATALOGUE_PATH: string | undefined;
|
|
37
37
|
HELYX_SUPPORT_STAFF_CHANNEL_ID: string | undefined;
|
|
38
|
+
HELYX_VERIFICATION_ENABLED: boolean;
|
|
39
|
+
HELYX_VERIFICATION_SIGNING_PUBLIC_KEYS: Record<string, string>;
|
|
38
40
|
HELYX_BOT_CONTROL_PORT: number;
|
|
41
|
+
HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS: number;
|
|
39
42
|
HELYX_PRIVACY_ENABLED: boolean;
|
|
43
|
+
HELYX_MESSAGE_CONTENT_ENABLED: boolean;
|
|
40
44
|
HELYX_CLOUD_CONNECT_ENABLED: boolean;
|
|
41
45
|
HELYX_CLOUD_ENROLMENT_TOKEN: string | undefined;
|
|
42
46
|
HELYX_CLOUD_ENROLMENT_FILE: string;
|
|
@@ -51,6 +55,9 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
|
|
|
51
55
|
NODE_ENV: "development" | "test" | "production";
|
|
52
56
|
HELYX_BOT_CONTROL_SECRET?: string | undefined;
|
|
53
57
|
HELYX_SUPPORT_SERVICE_SECRET?: string | undefined;
|
|
58
|
+
HELYX_VERIFICATION_INSTALLATION_ID?: string | undefined;
|
|
59
|
+
HELYX_VERIFICATION_URL?: string | undefined;
|
|
60
|
+
HELYX_VERIFICATION_INTERNAL_SECRET?: string | undefined;
|
|
54
61
|
HELYX_PRIVACY_EXECUTOR_DATABASE_URL?: string | undefined;
|
|
55
62
|
HELYX_PRIVACY_EXECUTOR_DATABASE_SSL?: "disable" | "require" | "verify-full" | undefined;
|
|
56
63
|
HELYX_SHARD_COUNT?: number | undefined;
|
package/dist/shard-worker.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseBotEnvironment } from "@helyx/config";
|
|
2
2
|
import { safeError, ServiceRegistry } from "@helyx/core";
|
|
3
|
-
import { checkDatabaseConnection, createDatabasePool } from "@helyx/database";
|
|
3
|
+
import { checkDatabaseConnection, createDatabasePool, VerificationSetupRepository, } from "@helyx/database";
|
|
4
4
|
import { createRuntime } from "@helyx/framework";
|
|
5
5
|
import { createPlatformServices } from "@helyx/platform";
|
|
6
6
|
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
@@ -9,8 +9,9 @@ import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
|
9
9
|
import pino from "pino";
|
|
10
10
|
import { createCloudManagementModule } from "./cloud-management-module.js";
|
|
11
11
|
import { BotControlService, DiscordGuildResourceDirectory, mapBotControlError, } from "./control-server.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { HttpVerificationService } from "./verification-service.js";
|
|
13
|
+
import { DiscordMessageDeliveryService, DiscordMemberEnforcementService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordVerificationSetupService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
|
|
14
|
+
import { installGuildTracking, } from "./guilds.js";
|
|
14
15
|
import { installInteractions, } from "./interactions.js";
|
|
15
16
|
import { createManagementModule } from "./management-module.js";
|
|
16
17
|
import { DiscordModuleLoggingService } from "./module-logging.js";
|
|
@@ -25,6 +26,8 @@ import { assertShardMessageSize, controlOperationGuildId, executeShardControlOpe
|
|
|
25
26
|
import { gatewayIntentsForModules, readBotVersion, } from "./index.js";
|
|
26
27
|
import { createSupportModule } from "./support-module.js";
|
|
27
28
|
import { createLocalScheduledTaskRoute, } from "./scheduled-task-runner.js";
|
|
29
|
+
import { createLocalNumericWriteBehind } from "./numeric-write-behind.js";
|
|
30
|
+
import { WorkerNumericWriteBehindProxy } from "./numeric-write-behind-proxy.js";
|
|
28
31
|
const HEALTH_INTERVAL_MS = 5_000;
|
|
29
32
|
export const WORKER_ORPHAN_TERMINATION_BOUND_MS = SHARD_LEASE_HEARTBEAT_TIMEOUT_MS + 5_000;
|
|
30
33
|
export function createLeaseHeartbeatWatchdog(onExpired, timeoutMs = SHARD_LEASE_HEARTBEAT_TIMEOUT_MS) {
|
|
@@ -71,16 +74,19 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
71
74
|
let shuttingDown = false;
|
|
72
75
|
let runtimeStarted = false;
|
|
73
76
|
let moduleEvents;
|
|
77
|
+
let guildTracking;
|
|
74
78
|
let client;
|
|
75
79
|
let cloudProxy;
|
|
76
80
|
let shardHealthProvider;
|
|
77
81
|
let leaseHeartbeatWatchdog;
|
|
78
82
|
let forcedLeaseExit;
|
|
83
|
+
let numericWriteBehind;
|
|
79
84
|
async function shutdown(reason = "programmatic") {
|
|
80
85
|
if (shuttingDown)
|
|
81
86
|
return;
|
|
82
87
|
shuttingDown = true;
|
|
83
88
|
status = "stopped";
|
|
89
|
+
guildTracking?.stop();
|
|
84
90
|
if (healthTimer)
|
|
85
91
|
clearInterval(healthTimer);
|
|
86
92
|
leaseHeartbeatWatchdog?.stop();
|
|
@@ -92,6 +98,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
92
98
|
await moduleEvents?.stop().catch(() => undefined);
|
|
93
99
|
if (runtimeStarted)
|
|
94
100
|
await runtime?.stop().catch(() => undefined);
|
|
101
|
+
await numericWriteBehind?.stop();
|
|
95
102
|
await client?.destroy();
|
|
96
103
|
eventLoopDelay.disable();
|
|
97
104
|
await database.end();
|
|
@@ -104,7 +111,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
104
111
|
const modulePackages = await loadConfiguredModulePackages(moduleSpecifiers);
|
|
105
112
|
const featureModules = modulePackages.map(({ definition }) => definition);
|
|
106
113
|
client = new Client({
|
|
107
|
-
intents: gatewayIntentsForModules(featureModules),
|
|
114
|
+
intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED),
|
|
108
115
|
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
|
109
116
|
allowedMentions: { parse: [] },
|
|
110
117
|
shards: [identity.shardId],
|
|
@@ -156,14 +163,39 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
156
163
|
...publicModules,
|
|
157
164
|
...(operationsModule ? [operationsModule] : []),
|
|
158
165
|
];
|
|
159
|
-
const platformServices = new Map(
|
|
166
|
+
const platformServices = new Map();
|
|
167
|
+
const serviceAccess = new ServiceRegistry(platformServices);
|
|
168
|
+
for (const [name, service] of createPlatformServices(database, publicModules, {
|
|
169
|
+
...platformServiceOptions(environment, publicModules),
|
|
170
|
+
activationServices: serviceAccess,
|
|
171
|
+
})) {
|
|
172
|
+
platformServices.set(name, service);
|
|
173
|
+
}
|
|
174
|
+
if (environment.HELYX_VERIFICATION_ENABLED &&
|
|
175
|
+
environment.HELYX_VERIFICATION_URL &&
|
|
176
|
+
environment.HELYX_VERIFICATION_INTERNAL_SECRET) {
|
|
177
|
+
platformServices.set(HELYX_SERVICE_NAMES.verification, new HttpVerificationService({
|
|
178
|
+
baseUrl: environment.HELYX_VERIFICATION_URL,
|
|
179
|
+
internalSecret: environment.HELYX_VERIFICATION_INTERNAL_SECRET,
|
|
180
|
+
installationId: environment.HELYX_VERIFICATION_INSTALLATION_ID,
|
|
181
|
+
publicSigningKeys: environment.HELYX_VERIFICATION_SIGNING_PUBLIC_KEYS,
|
|
182
|
+
}));
|
|
183
|
+
platformServices.set(HELYX_SERVICE_NAMES.verificationMembers, new DiscordVerificationMemberService(client));
|
|
184
|
+
platformServices.set(HELYX_SERVICE_NAMES.verificationSetup, new DiscordVerificationSetupService(client, new VerificationSetupRepository(database)));
|
|
185
|
+
}
|
|
186
|
+
numericWriteBehind =
|
|
187
|
+
identity.scope === "coordinated"
|
|
188
|
+
? new WorkerNumericWriteBehindProxy(identity)
|
|
189
|
+
: createLocalNumericWriteBehind(platformServices, publicModules);
|
|
190
|
+
platformServices.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
|
|
160
191
|
platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
|
|
192
|
+
platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
|
|
161
193
|
platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
|
|
194
|
+
platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
|
|
162
195
|
platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
|
|
163
196
|
platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
|
|
164
197
|
if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging"))
|
|
165
198
|
platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, logger));
|
|
166
|
-
const serviceAccess = new ServiceRegistry(platformServices);
|
|
167
199
|
const scheduledTaskRoute = createLocalScheduledTaskRoute({
|
|
168
200
|
modules: publicModules,
|
|
169
201
|
services: serviceAccess,
|
|
@@ -177,13 +209,15 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
177
209
|
...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
|
|
178
210
|
...(operationsModule ? [operationsModule.manifest.id] : []),
|
|
179
211
|
]);
|
|
180
|
-
const
|
|
212
|
+
const shardOwnership = {
|
|
181
213
|
generation: identity.generation,
|
|
182
214
|
deploymentOrder: BigInt(identity.deploymentOrder ?? 1),
|
|
183
215
|
supervisorId: identity.supervisorId,
|
|
184
216
|
shardId: identity.shardId,
|
|
185
217
|
fencingToken: BigInt(identity.fencingToken),
|
|
186
|
-
}
|
|
218
|
+
};
|
|
219
|
+
const prepareGuilds = installGuildTracking(client, database, publicModules, logger, enabledCoreModuleIds, shardOwnership, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, identity.shardCount);
|
|
220
|
+
guildTracking = prepareGuilds;
|
|
187
221
|
const commandsReady = installInteractions(client, modules, shardWorkerCommandRegistration({
|
|
188
222
|
...(environment.DISCORD_INTERNAL_GUILD_ID
|
|
189
223
|
? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
|
|
@@ -201,7 +235,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
|
|
|
201
235
|
guildId: environment.DISCORD_INTERNAL_GUILD_ID,
|
|
202
236
|
channelId: environment.HELYX_SUPPORT_STAFF_CHANNEL_ID,
|
|
203
237
|
}
|
|
204
|
-
: undefined);
|
|
238
|
+
: undefined, shardOwnership, identity.shardCount);
|
|
205
239
|
process.on("message", (raw) => {
|
|
206
240
|
const parsed = supervisorMessageSchema.safeParse(raw);
|
|
207
241
|
if (!parsed.success)
|
|
@@ -436,7 +470,27 @@ async function handleControl(identity, service, request, logger) {
|
|
|
436
470
|
};
|
|
437
471
|
}
|
|
438
472
|
}
|
|
439
|
-
|
|
473
|
+
try {
|
|
474
|
+
assertShardMessageSize(response);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
logger.error({
|
|
478
|
+
event: "shard.control_response_oversized",
|
|
479
|
+
requestId: request.requestId,
|
|
480
|
+
operation: request.operation.kind,
|
|
481
|
+
guildId,
|
|
482
|
+
error: safeError(error),
|
|
483
|
+
}, "Shard control response exceeded the transport limit");
|
|
484
|
+
response = {
|
|
485
|
+
version: 1,
|
|
486
|
+
type: "helyx.control.response",
|
|
487
|
+
requestId: request.requestId,
|
|
488
|
+
ok: false,
|
|
489
|
+
status: 502,
|
|
490
|
+
message: "The owning shard returned too much data",
|
|
491
|
+
};
|
|
492
|
+
assertShardMessageSize(response);
|
|
493
|
+
}
|
|
440
494
|
process.send?.(response);
|
|
441
495
|
}
|
|
442
496
|
function health(identity, client, status, eventLoopDelayNanoseconds, queueDepth) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ActionReceipt, type ChallengeCreateRequest, type ChallengeCreateResponse, type DashboardAttemptPage, type DashboardAttemptQuery, type DashboardSafeAttemptDetail, type SignedAssessmentEnvelope, type VerificationInstallationClaims } from "@helyx/verification-contracts";
|
|
2
|
+
import type { VerificationResultContext, VerificationService } from "@helyx/sdk";
|
|
3
|
+
export interface HttpVerificationServiceOptions {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
internalSecret: string;
|
|
6
|
+
publicSigningKeys: Readonly<Record<string, string>>;
|
|
7
|
+
installationId: string;
|
|
8
|
+
fetchImplementation?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
export declare class HttpVerificationService implements VerificationService {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(options: HttpVerificationServiceOptions);
|
|
13
|
+
getInstallationContext(): Promise<{
|
|
14
|
+
installation: VerificationInstallationClaims;
|
|
15
|
+
}>;
|
|
16
|
+
createChallenge(input: ChallengeCreateRequest): Promise<ChallengeCreateResponse>;
|
|
17
|
+
cancelChallenges(input: {
|
|
18
|
+
installationId: string;
|
|
19
|
+
guildId: string;
|
|
20
|
+
userId?: string;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
cancelled: number;
|
|
23
|
+
}>;
|
|
24
|
+
listAttempts(input: DashboardAttemptQuery & {
|
|
25
|
+
installationId: string;
|
|
26
|
+
guildId: string;
|
|
27
|
+
}): Promise<DashboardAttemptPage>;
|
|
28
|
+
getAttempt(input: {
|
|
29
|
+
installationId: string;
|
|
30
|
+
guildId: string;
|
|
31
|
+
attemptId: string;
|
|
32
|
+
}): Promise<DashboardSafeAttemptDetail | null>;
|
|
33
|
+
decideAttempt(input: {
|
|
34
|
+
installationId: string;
|
|
35
|
+
guildId: string;
|
|
36
|
+
attemptId: string;
|
|
37
|
+
expectedVersion: number;
|
|
38
|
+
actorUserId: string;
|
|
39
|
+
decision: "approve" | "deny" | "retry" | "dismiss_relationship";
|
|
40
|
+
reason: string;
|
|
41
|
+
}): Promise<DashboardSafeAttemptDetail>;
|
|
42
|
+
registerResultHandler(handler: (context: VerificationResultContext) => Promise<ActionReceipt>): () => void;
|
|
43
|
+
acceptResult(envelopeInput: SignedAssessmentEnvelope): Promise<ActionReceipt>;
|
|
44
|
+
health(): Promise<{
|
|
45
|
+
status: "healthy" | "degraded" | "unavailable";
|
|
46
|
+
provider: "available" | "degraded" | "unavailable";
|
|
47
|
+
allowanceRemaining?: number;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=verification-service.d.ts.map
|