/* * **남은 것을 줍는 그물** — 승인은 됐는데 안 나간 것, 실패해서 다시 해 볼 것. * * ── 왜 필요한가 (2026-09-04) ──────────────────────────────────────────────── * 승인 직후에 한 번 보내는 것만 있었다(§`dispatchAfterApproval`). 정상 경로는 그것으로 282ms 에 * 끝난다 — 실측이다. 그런데 두 자리가 비어 있었다. * * 커밋과 발신 사이에 프로세스가 죽으면 그 조치는 `approved` 로 남고 스스로 안 나간다 * 실패한 것을 다시 보내려면 사람이 시켜야 했다 * * 2026-09-02 에 뒤엣것을 겪었다: 저쪽 인증 정보가 채워진 뒤 사람이 손으로 다시 넘겨서 나갔다. * * ── 왜 새 타이머를 세우지 않나 ────────────────────────────────────────────── * 규칙 주기(`rule-loop`)에 얹는다. 그 파일이 이미 그 이유를 적어 두었다 — 「타이머를 둘 두면 * 내리고 세울 자리가 둘이 된다」. 놓친 보고를 backfill 하는 것(`catchUpEffects`)도 같은 자리에 있다. * * 30초면 충분하다. **아무도 안 기다린다** — 정상 경로가 이미 끝냈고, 이 그물은 남은 것만 본다. * * ── 판단은 여기 없다 ──────────────────────────────────────────────────────── * 「지금 넘길 것인가, 다음은 언제인가」는 순수 함수가 답한다(§`retryDecisionOf`). 이 파일은 * 표를 읽고 그 답대로 하고 결과를 적는 배선이다. 그렇게 갈라 둔 이유는 물러섬 규칙을 30분 * 기다리지 않고 값으로 잴 수 있어야 하기 때문이다. * * ── 던지지 않는다 ─────────────────────────────────────────────────────────── * 주기 안에서 돈다. 한 조치가 터져도 다음 조치를 봐야 하고, 이 그물이 터져도 규칙과 backfill 은 * 돌아야 한다. 조치를 못 넘기는 것과 트윈이 멈추는 것은 다른 사고다. */ import { nextCommandState, type ApprovedCommand } from '@operato/ops-contract' import { twinWarn } from '../../engine/log.js' import { dispatch, type CommandAck, type CommandStore, type StoredCommand } from './command-dispatcher.js' import { retryDecisionOf } from './retry-plan.js' /* * **의존을 인자로 받는다** — 이 파일이 커넥터 등록소·연결 표를 직접 부르지 않는다. * * 규칙 실행이 이미 그 모양이다(`runRulesOnce({ store, rulesOf, attentionsOf … })`). 그렇게 두는 * 값이 둘이다: 배선을 시험이 굴릴 수 있고(직접 부르면 시험이 DB·모듈 부팅을 끌어온다 — 실제로 * 그렇게 났다), 그리고 이 파일이 무엇에 의존하는지가 서명에 적힌다. */ /** 한 바퀴에 볼 최대 수 — 대기 줄이 커도 한 주기를 독차지하지 않게. */ export const SWEEP_LIMIT = 50 export interface SweepReport { /** 표에서 읽은 수. */ seen: number /** 실제로 넘긴 수. */ dispatched: number /** 넘기지 않은 이유별 수 — 목록이 그대로 보인다. */ skipped: Record } /** * 이 테넌트의 대기 줄을 한 바퀴 훑는다. * * 저장소가 대기 줄을 못 내면 **아무것도 하지 않는다** — 그 능력이 없는 구현도 그대로 돌아야 * 한다(옛 거동). */ export interface SweepDeps { store: CommandStore /** 이 조치를 저쪽에 낸다 — 부르는 쪽이 커넥터 배선을 안다. */ /* * `dispatch` 가 승인 기록을 확인한 뒤의 커맨드를 넘겨 준다(`ApprovedCommand`) — 그 타입을 그대로 * 받는다. 우리가 `StoredCommand` 로 넓히면 「승인 없이 나갈 수 있는 서명」이 된다. */ actuate: (command: ApprovedCommand) => Promise /** 이 연결이 두 번 보내도 지시가 하나인가 — **선언만 참으로 읽는다.** */ idempotent: (instanceId: string) => Promise /** * 우리 쪽 설정만으로 답할 수 있는 준비 상태. 저쪽을 두드리지 않는 답만 준다. * * 없으면 앞점검을 하지 않는다(그냥 넘긴다) — 모르는 것을 거절로 접지 않는 규율이다. */ localReadiness?: (instanceId: string) => Promise<{ ready?: boolean; reason?: string; code?: string } | undefined> nowMs?: () => number } export async function sweepPendingDispatch(domainId: string, deps: SweepDeps): Promise { const { store } = deps const now = deps.nowMs ?? (() => Date.now()) const report: SweepReport = { seen: 0, dispatched: 0, skipped: {} } if (!store.pendingDispatch) return report const pending = await store.pendingDispatch(domainId).catch(() => [] as readonly StoredCommand[]) report.seen = pending.length const note = (why: string) => { report.skipped[why] = (report.skipped[why] ?? 0) + 1 } for (const command of pending.slice(0, SWEEP_LIMIT)) { try { const decision = retryDecisionOf({ state: command.state, dispatchable: nextCommandState(command.state, 'dispatch') !== undefined, retry: command.retry, attempts: command.attempts, nextAttemptAt: command.nextAttemptAt, idempotent: await deps.idempotent(command.instanceId).catch(() => false), nowMs: now() }) if (!decision.take) { note(decision.skip ?? 'unknown') continue } /* * **넘기기 전에 우리 설정을 먼저 본다** — 아끼려는 것이 아니라 **이유를 지금 값으로 * 적으려는** 것이다(인티그레이션 레인이 제 첫 근거를 깼다: 그쪽 어댑터는 이미 `fetch` 전에 * 되돌아오므로 아낄 요청이 없다). * * 건너뛰면서 아무것도 안 적으면 **옛 `error` 가 남는다.** 저쪽 인증 정보 때문에 실패해 앉은 * 뒤 우리 쪽 설정이 지워지면, 목록에 계속 저쪽 얘기가 보이고 사람이 남의 서버를 보러 간다. * * 모르면(`undefined`) 그냥 넘긴다 — 모르는 것을 거절로 접지 않는다. */ const readiness = await deps.localReadiness?.(command.instanceId).catch(() => undefined) if (readiness && readiness.ready === false) { await noteBlocked(store, domainId, command, decision.nextAttemptAt, readiness.reason, readiness.code) note('not-ready') continue } const settled = await dispatch(store, domainId, command.id, c => deps.actuate(c)) await recordAttempt(store, domainId, settled, decision.attempt ?? 1, decision.nextAttemptAt) if (settled.state === 'failed') { note('failed') twinWarn(`[twin-sweep] 조치 ${command.id} 를 넘기지 못했다: ${settled.error ?? '(사유 없음)'}`) } else { report.dispatched++ } } catch (e: any) { /* 한 조치가 터져도 다음을 본다 — 가장 흔한 원인은 그 사이에 상태가 바뀐 것이다. */ note('threw') twinWarn(`[twin-sweep] 조치 ${command.id} 에서 예외 — ${String(e?.message ?? e)}`) } } return report } /** * 못 나가는 이유를 **지금 값으로** 적는다 — 넘기지 않았으므로 상태는 그대로다. * * **바뀌었을 때만 쓴다.** 10분마다 같은 이유를 다시 쓰면 `updatedAt` 이 계속 튀고, 목록의 * 「언제부터 이랬나」가 사라진다(§`catch-up-effects` 에서 같은 규율을 지켰다). */ async function noteBlocked( store: CommandStore, domainId: string, command: StoredCommand, nextAttemptAt: string | undefined, reason?: string, code?: string ): Promise { const sentence = reason ?? code ?? '연결 설정이 조치를 낼 준비가 되지 않았습니다' if (command.error === sentence && command.nextAttemptAt === nextAttemptAt) return await store .save(domainId, { ...command, error: sentence, ...(nextAttemptAt ? { nextAttemptAt } : {}) }) .catch(err => twinWarn(`[twin-sweep] 조치 ${command.id} 의 사유를 적지 못했다 — ${String(err?.message ?? err)}`)) } /** 시도 횟수와 다음 시각을 적는다 — `dispatch` 가 쓴 상태를 덮지 않고 이 둘만 더한다. */ async function recordAttempt( store: CommandStore, domainId: string, settled: StoredCommand, attempt: number, nextAttemptAt: string | undefined ): Promise { await store .save(domainId, { ...settled, attempts: attempt, ...(nextAttemptAt ? { nextAttemptAt } : {}) }) .catch(err => twinWarn(`[twin-sweep] 조치 ${settled.id} 의 시도 기록을 적지 못했다 — ${String(err?.message ?? err)}`)) }