@llblab/pi-telegram 0.21.1 → 0.22.0

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/lib/bus-leader.ts CHANGED
@@ -7,7 +7,10 @@
7
7
 
8
8
  import * as Sync from "./sync.ts";
9
9
  import * as ThreadReconciler from "./thread-reconciler.ts";
10
- import type { TelegramApiCallOptions } from "./telegram-api.ts";
10
+ import {
11
+ isTelegramApiCommitUnknownError,
12
+ type TelegramApiCallOptions,
13
+ } from "./telegram-api.ts";
11
14
  import type { TelegramTarget } from "./target.ts";
12
15
  import * as Threads from "./threads.ts";
13
16
  import {
@@ -65,8 +68,7 @@ export interface TelegramBusLeaderTargetProvisionerDeps<TContext> {
65
68
  ) => Promise<TResponse>;
66
69
  getCurrentLeaderEpoch?: () => number | string | undefined;
67
70
  getThreadReconciliationMachineState?: () =>
68
- | ThreadReconciler.ThreadReconciliationMachineState
69
- | undefined;
71
+ ThreadReconciler.ThreadReconciliationMachineState | undefined;
70
72
  recordThreadReconciliationPlan?: (
71
73
  plan: ThreadReconciler.ThreadReconciliationPlan,
72
74
  ) => void;
@@ -221,6 +223,7 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
221
223
  provisionFollowerTarget: createTelegramBusFollowerTargetProvisioner({
222
224
  ...provisionerPorts,
223
225
  }),
226
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
224
227
  reconcileFollowerBindings:
225
228
  createTelegramBusFollowerBindingRealityReconciler({
226
229
  topicTargetStore: deps.topicTargetStore,
@@ -273,6 +276,7 @@ export type TelegramBusFollowerMessageOwnershipRecorder = (
273
276
 
274
277
  export interface TelegramBusLeaderRuntimeDeps<TContext> {
275
278
  socketPath: TelegramBusSocketPathSource;
279
+ commitEndpointPublication?: (commit: () => void) => boolean;
276
280
  followerRegistry: TelegramBusFollowerRegistry;
277
281
  authSecret?: string;
278
282
  startPolling: (ctx: TContext) => void | Promise<void>;
@@ -287,6 +291,7 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
287
291
  provisionFollowerTarget?: (
288
292
  registration: TelegramBusInstanceRegistration,
289
293
  ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
294
+ getCurrentLeaderEpoch?: () => number | string | undefined;
290
295
  reconcileFollowerBindings?: () => Promise<unknown> | unknown;
291
296
  provisionLeaderTarget?: (ctx: TContext) => Promise<void> | void;
292
297
  getNowMs?: () => number;
@@ -308,8 +313,7 @@ export function createTelegramBusFollowerBindingRealityReconciler(
308
313
  ): () => Promise<number> {
309
314
  const getNowMs = deps.getNowMs ?? Date.now;
310
315
  const recentBindingGraceMs =
311
- deps.recentBindingGraceMs ??
312
- TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS;
316
+ deps.recentBindingGraceMs ?? TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS;
313
317
  return async () => {
314
318
  await deps.topicTargetStore.load();
315
319
  const nowMs = getNowMs();
@@ -339,11 +343,13 @@ export function createTelegramBusFollowerBindingRealityReconciler(
339
343
  (record.owner
340
344
  ? Threads.getTelegramThreadOwnerKey(record.owner)
341
345
  : undefined);
342
- if (profileKey) deps.topicTargetStore.forgetIdentityByProfileKey(profileKey);
346
+ if (profileKey)
347
+ deps.topicTargetStore.forgetIdentityByProfileKey(profileKey);
343
348
  }
344
349
  if (removed === 0) return 0;
345
- const cursorRealigned =
346
- Threads.reconcileTelegramFreshAllocationCursor(deps.topicTargetStore);
350
+ const cursorRealigned = Threads.reconcileTelegramFreshAllocationCursor(
351
+ deps.topicTargetStore,
352
+ );
347
353
  await deps.topicTargetStore.persist();
348
354
  deps.recordRuntimeEvent(
349
355
  "bus",
@@ -443,7 +449,9 @@ export function createTelegramBusFollowerTargetProvisioner(
443
449
  const getNowMs = deps.getNowMs ?? Date.now;
444
450
  const pendingRegistrations = new Map<
445
451
  string,
446
- Promise<(TelegramTarget & { slot?: string; threadName?: string }) | undefined>
452
+ Promise<
453
+ (TelegramTarget & { slot?: string; threadName?: string }) | undefined
454
+ >
447
455
  >();
448
456
  return async (registration) => {
449
457
  const registrationStartedAtMs = Date.now();
@@ -574,125 +582,133 @@ export function createTelegramBusFollowerTargetProvisioner(
574
582
  const runRegistration = async (): Promise<
575
583
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
576
584
  > => {
577
- let result = reconnectRecord
578
- ? { target: reconnectRecord.target, reused: true, record: reconnectRecord }
579
- : recoverableTarget
580
- ? await recoverRequestedTarget()
581
- : await provisionTarget();
582
- if (reconnectRecord) {
583
- const nowMs = getNowMs();
584
- const transferredRecord = deps.topicTargetStore.upsert({
585
- ...reconnectRecord,
585
+ let result = reconnectRecord
586
+ ? {
587
+ target: reconnectRecord.target,
588
+ reused: true,
589
+ record: reconnectRecord,
590
+ }
591
+ : recoverableTarget
592
+ ? await recoverRequestedTarget()
593
+ : await provisionTarget();
594
+ if (reconnectRecord) {
595
+ const nowMs = getNowMs();
596
+ const transferredRecord = deps.topicTargetStore.upsert({
597
+ ...reconnectRecord,
598
+ instanceId: registration.instanceId,
599
+ updatedAtMs: nowMs,
600
+ lastSyncObservedAtMs: nowMs,
601
+ lastReconcileAction:
602
+ reconnectRecord.instanceId === registration.instanceId
603
+ ? "follower-register-reuse"
604
+ : "follower-session-handoff",
605
+ });
606
+ await deps.topicTargetStore.persist();
607
+ result = {
608
+ target: transferredRecord.target,
609
+ reused: true,
610
+ record: transferredRecord,
611
+ };
612
+ }
613
+ deps.setSyncState(
614
+ Sync.markTelegramSyncSliceFresh(
615
+ deps.getSyncState(),
616
+ "target-bindings",
617
+ {
618
+ nowMs: getNowMs(),
619
+ action: "follower-register",
620
+ },
621
+ ),
622
+ );
623
+ const connectedAnnouncement = result.reused
624
+ ? undefined
625
+ : createTelegramBusInstanceLifecycleAnnouncement({
626
+ target: result.target,
627
+ threadName: result.record.threadName,
628
+ slot: result.record.slot,
629
+ state: "connected",
630
+ });
631
+ recordSlowTelegramBusFollowerRegistrationStep(deps, {
632
+ phase: "follower-register-critical",
633
+ elapsedMs: Date.now() - registrationStartedAtMs,
586
634
  instanceId: registration.instanceId,
587
- updatedAtMs: nowMs,
588
- lastSyncObservedAtMs: nowMs,
589
- lastReconcileAction:
590
- reconnectRecord.instanceId === registration.instanceId
591
- ? "follower-register-reuse"
592
- : "follower-session-handoff",
635
+ target: result.target,
636
+ reused: result.reused,
593
637
  });
594
- await deps.topicTargetStore.persist();
595
- result = {
596
- target: transferredRecord.target,
597
- reused: true,
598
- record: transferredRecord,
599
- };
600
- }
601
- deps.setSyncState(
602
- Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
603
- nowMs: getNowMs(),
604
- action: "follower-register",
605
- }),
606
- );
607
- const connectedAnnouncement = result.reused
608
- ? undefined
609
- : createTelegramBusInstanceLifecycleAnnouncement({
610
- target: result.target,
611
- threadName: result.record.threadName,
612
- slot: result.record.slot,
613
- state: "connected",
614
- });
615
- recordSlowTelegramBusFollowerRegistrationStep(deps, {
616
- phase: "follower-register-critical",
617
- elapsedMs: Date.now() - registrationStartedAtMs,
618
- instanceId: registration.instanceId,
619
- target: result.target,
620
- reused: result.reused,
621
- });
622
- scheduleTelegramBusLeaderBackgroundTask(async () => {
623
- const backgroundStartedAtMs = Date.now();
624
- if (connectedAnnouncement) {
638
+ scheduleTelegramBusLeaderBackgroundTask(async () => {
639
+ const backgroundStartedAtMs = Date.now();
640
+ if (connectedAnnouncement) {
641
+ try {
642
+ await deps.callApi("sendMessage", {
643
+ chat_id: connectedAnnouncement.target.chatId,
644
+ message_thread_id: connectedAnnouncement.target.threadId,
645
+ text: connectedAnnouncement.text,
646
+ parse_mode: connectedAnnouncement.parseMode,
647
+ });
648
+ } catch (error) {
649
+ deps.recordRuntimeEvent("telegram", error, {
650
+ phase: "follower-topic-announce",
651
+ instanceId: registration.instanceId,
652
+ chatId: result.target.chatId,
653
+ threadId: result.target.threadId,
654
+ });
655
+ }
656
+ }
625
657
  try {
626
- await deps.callApi("sendMessage", {
627
- chat_id: connectedAnnouncement.target.chatId,
628
- message_thread_id: connectedAnnouncement.target.threadId,
629
- text: connectedAnnouncement.text,
630
- parse_mode: connectedAnnouncement.parseMode,
631
- });
658
+ await ThreadReconciler.applyThreadReconciliationPlan(
659
+ ThreadReconciler.planThreadReconciliation({
660
+ nowMs: getNowMs(),
661
+ currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
662
+ records: recordsBeforeProvision,
663
+ pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
664
+ replacedBindings: [
665
+ {
666
+ instanceId: registration.instanceId,
667
+ replacementTarget: result.target,
668
+ },
669
+ ],
670
+ }),
671
+ {
672
+ callApi: deps.callApi,
673
+ markStaleByTarget(target, syncStatus, lastSyncError) {
674
+ return deps.topicTargetStore.markStaleByTarget(
675
+ target,
676
+ syncStatus,
677
+ lastSyncError,
678
+ );
679
+ },
680
+ persist() {
681
+ return deps.topicTargetStore.persist();
682
+ },
683
+ removePendingProvisionById(id) {
684
+ return deps.topicTargetStore.removePendingProvision(id);
685
+ },
686
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
687
+ recordRuntimeEvent: deps.recordRuntimeEvent,
688
+ },
689
+ );
690
+ await deps.topicTargetStore.persist();
632
691
  } catch (error) {
633
692
  deps.recordRuntimeEvent("telegram", error, {
634
- phase: "follower-topic-announce",
693
+ phase: "follower-register-background-reconcile",
635
694
  instanceId: registration.instanceId,
636
695
  chatId: result.target.chatId,
637
696
  threadId: result.target.threadId,
638
697
  });
639
698
  }
640
- }
641
- try {
642
- await ThreadReconciler.applyThreadReconciliationPlan(
643
- ThreadReconciler.planThreadReconciliation({
644
- nowMs: getNowMs(),
645
- currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
646
- records: recordsBeforeProvision,
647
- pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
648
- replacedBindings: [
649
- {
650
- instanceId: registration.instanceId,
651
- replacementTarget: result.target,
652
- },
653
- ],
654
- }),
655
- {
656
- callApi: deps.callApi,
657
- markStaleByTarget(target, syncStatus, lastSyncError) {
658
- return deps.topicTargetStore.markStaleByTarget(
659
- target,
660
- syncStatus,
661
- lastSyncError,
662
- );
663
- },
664
- persist() {
665
- return deps.topicTargetStore.persist();
666
- },
667
- removePendingProvisionById(id) {
668
- return deps.topicTargetStore.removePendingProvision(id);
669
- },
670
- getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
671
- recordRuntimeEvent: deps.recordRuntimeEvent,
672
- },
673
- );
674
- await deps.topicTargetStore.persist();
675
- } catch (error) {
676
- deps.recordRuntimeEvent("telegram", error, {
677
- phase: "follower-register-background-reconcile",
699
+ recordSlowTelegramBusFollowerRegistrationStep(deps, {
700
+ phase: "follower-register-background",
701
+ elapsedMs: Date.now() - backgroundStartedAtMs,
678
702
  instanceId: registration.instanceId,
679
- chatId: result.target.chatId,
680
- threadId: result.target.threadId,
703
+ target: result.target,
704
+ reused: result.reused,
681
705
  });
682
- }
683
- recordSlowTelegramBusFollowerRegistrationStep(deps, {
684
- phase: "follower-register-background",
685
- elapsedMs: Date.now() - backgroundStartedAtMs,
686
- instanceId: registration.instanceId,
687
- target: result.target,
688
- reused: result.reused,
689
706
  });
690
- });
691
- return {
692
- ...result.target,
693
- slot: result.record.slot,
694
- threadName: result.record.threadName,
695
- };
707
+ return {
708
+ ...result.target,
709
+ slot: result.record.slot,
710
+ threadName: result.record.threadName,
711
+ };
696
712
  };
697
713
  const registrationPromise = runRegistration().finally(() => {
698
714
  pendingRegistrations.delete(registrationKey);
@@ -722,6 +738,12 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
722
738
  ): (ctx: TContext) => Promise<void> {
723
739
  const getNowMs = deps.getNowMs ?? Date.now;
724
740
  return async (ctx) => {
741
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
742
+ if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
743
+ throw new Error(
744
+ "Telegram leader target provisioning requires ownership.",
745
+ );
746
+ }
725
747
  deps.onProvisioningStart?.();
726
748
  let ownTarget: Threads.TelegramOwnTopicProvisionResult | undefined;
727
749
  try {
@@ -743,6 +765,12 @@ export function createTelegramBusLeaderTargetProvisioner<TContext>(
743
765
  } finally {
744
766
  deps.onProvisioningEnd?.();
745
767
  }
768
+ if (
769
+ deps.getCurrentLeaderEpoch &&
770
+ deps.getCurrentLeaderEpoch() !== leaderEpoch
771
+ ) {
772
+ throw new Error("Telegram leader target provisioning lost ownership.");
773
+ }
746
774
  if (!ownTarget) return;
747
775
  deps.setLeaderTarget({
748
776
  target: ownTarget.target,
@@ -848,6 +876,7 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
848
876
  provisionFollowerTarget?: (
849
877
  registration: TelegramBusInstanceRegistration,
850
878
  ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
879
+ getCurrentLeaderEpoch?: () => number | string | undefined;
851
880
  }): (
852
881
  envelope: TelegramBusEnvelope,
853
882
  ) => Promise<TelegramBusEnvelope> | TelegramBusEnvelope {
@@ -868,7 +897,8 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
868
897
  const followerSocketPath =
869
898
  follower?.busSocketPath ??
870
899
  getTelegramBusFollowerSocketPath(envelope.recipientInstanceId);
871
- if (follower) deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
900
+ if (follower)
901
+ deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
872
902
  try {
873
903
  const response = await sendTelegramBusLocalEnvelope({
874
904
  socketPath: followerSocketPath,
@@ -880,7 +910,8 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
880
910
  }),
881
911
  });
882
912
  if (response?.kind === "bus.ack" && response.ok) {
883
- if (follower) deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
913
+ if (follower)
914
+ deps.followerRegistry.heartbeat(follower.instanceId, getNowMs());
884
915
  return { kind: "bus.ack", requestId: envelope.requestId, ok: true };
885
916
  }
886
917
  const message =
@@ -913,9 +944,23 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
913
944
  switch (envelope.kind) {
914
945
  case "follower.register": {
915
946
  try {
947
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
948
+ if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
949
+ throw new Error(
950
+ "Telegram follower registration requires leader ownership.",
951
+ );
952
+ }
916
953
  const target = await deps.provisionFollowerTarget?.(
917
954
  envelope.registration,
918
955
  );
956
+ if (
957
+ deps.getCurrentLeaderEpoch &&
958
+ deps.getCurrentLeaderEpoch() !== leaderEpoch
959
+ ) {
960
+ throw new Error(
961
+ "Telegram follower registration lost leader ownership.",
962
+ );
963
+ }
919
964
  const registeredTarget = target ?? envelope.registration.target;
920
965
  deps.followerRegistry.register({
921
966
  ...envelope.registration,
@@ -941,6 +986,18 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
941
986
  }
942
987
  }
943
988
  case "follower.heartbeat": {
989
+ const current = deps.followerRegistry.get(envelope.instanceId);
990
+ if (
991
+ current?.registrationGeneration &&
992
+ envelope.registrationGeneration !== current.registrationGeneration
993
+ ) {
994
+ return {
995
+ kind: "bus.ack",
996
+ requestId: envelope.requestId,
997
+ ok: false,
998
+ message: "Stale Telegram bus follower registration generation.",
999
+ };
1000
+ }
944
1001
  const follower = deps.followerRegistry.heartbeat(
945
1002
  envelope.instanceId,
946
1003
  getNowMs(),
@@ -985,14 +1042,15 @@ function asInteger(value: unknown): number | undefined {
985
1042
  return Number.isInteger(parsed) ? parsed : undefined;
986
1043
  }
987
1044
 
988
- function getFollowerApiMethodAndBody(envelope: Extract<TelegramBusEnvelope, { kind: "follower.callApi" }>): {
1045
+ function getFollowerApiMethodAndBody(
1046
+ envelope: Extract<TelegramBusEnvelope, { kind: "follower.callApi" }>,
1047
+ ): {
989
1048
  apiMethod: string;
990
1049
  body?: Record<string, unknown>;
991
1050
  } {
992
1051
  if (envelope.method === "call" || envelope.method === "callMultipart") {
993
1052
  return {
994
- apiMethod:
995
- typeof envelope.args[0] === "string" ? envelope.args[0] : "",
1053
+ apiMethod: typeof envelope.args[0] === "string" ? envelope.args[0] : "",
996
1054
  body: asRecord(envelope.args[1]),
997
1055
  };
998
1056
  }
@@ -1026,7 +1084,8 @@ function recordFollowerApiMessageOwnership(input: {
1026
1084
  }
1027
1085
  const chatId = asInteger(body?.chat_id) ?? input.follower.target?.chatId;
1028
1086
  if (chatId === undefined) return;
1029
- const threadId = asInteger(body?.message_thread_id) ?? input.follower.target?.threadId;
1087
+ const threadId =
1088
+ asInteger(body?.message_thread_id) ?? input.follower.target?.threadId;
1030
1089
  const target = threadId !== undefined ? { chatId, threadId } : { chatId };
1031
1090
  for (const messageId of getSentMessageIds(input.result)) {
1032
1091
  input.record({
@@ -1061,6 +1120,17 @@ async function handleFollowerApiCall(
1061
1120
  message: "Unknown Telegram bus follower instance.",
1062
1121
  };
1063
1122
  }
1123
+ if (
1124
+ follower.registrationGeneration &&
1125
+ envelope.registrationGeneration !== follower.registrationGeneration
1126
+ ) {
1127
+ return {
1128
+ kind: "bus.ack",
1129
+ requestId: envelope.requestId,
1130
+ ok: false,
1131
+ message: "Stale Telegram bus follower registration generation.",
1132
+ };
1133
+ }
1064
1134
  deps.followerRegistry.heartbeat(envelope.instanceId, deps.getNowMs());
1065
1135
  if (
1066
1136
  deps.authorizeFollowerApiCall &&
@@ -1108,6 +1178,14 @@ async function handleFollowerApiCall(
1108
1178
  error instanceof Error
1109
1179
  ? error.message
1110
1180
  : "Telegram bus API call failed.",
1181
+ ...(isTelegramApiCommitUnknownError(error)
1182
+ ? {
1183
+ error: {
1184
+ code: "commit-unknown" as const,
1185
+ method: error.method,
1186
+ },
1187
+ }
1188
+ : {}),
1111
1189
  };
1112
1190
  }
1113
1191
  }
@@ -1237,6 +1315,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
1237
1315
  };
1238
1316
  const localServer = createTelegramBusLocalServer({
1239
1317
  socketPath: deps.socketPath,
1318
+ commitEndpointPublication: deps.commitEndpointPublication,
1240
1319
  recordTransportEvent(phase, details) {
1241
1320
  deps.recordRuntimeEvent?.("bus", `Telegram bus ${phase}`, {
1242
1321
  phase: `leader-${phase}`,
@@ -1251,6 +1330,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
1251
1330
  authorizeFollowerApiCall: deps.authorizeFollowerApiCall,
1252
1331
  recordFollowerMessageOwnership: deps.recordFollowerMessageOwnership,
1253
1332
  provisionFollowerTarget: deps.provisionFollowerTarget,
1333
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
1254
1334
  }),
1255
1335
  });
1256
1336
  return {