/* * **놓친 보고를 backfill 한다** — 유입 자리에서 잇는 길(§`settle-effects.ts`)의 짝. * * ── 왜 필요한가 (2026-09-02, 실제로 하나 놓쳤다) ────────────────────────────── * 유입 자리에서만 이으면, **우리가 못 듣고 있던 동안 온 보고는 영원히 사라진다.** * * 10:13 MES 가 WO-20260902-005 를 현장에 냄. order.status 가 트윈에 도착 * 19:21 잇는 배선을 산출물에 넣음 * → 그 보고는 저널에 남았는데 조치에는 안 이어졌다. 다시 오지 않는다 * * 같은 일이 트윈이 내려가 있는 사이, 커넥터를 고치는 사이, 프로세스가 죽었다 서는 사이에 난다. * 관측은 되풀이해 주지 않는다. * * ── 왜 저널을 읽나 ─────────────────────────────────────────────────────────── * 저널에 `orderId` 가 **컬럼으로** 있고 인덱스도 있다(`ix_twin_event_4 (domain, instanceId, orderId)` * — 「이 오더가 어디까지 갔나」). 그래서 「우리가 낸 지시들의 마지막 보고」를 값싸게 물을 수 있다. * JSON 안을 뒤지지 않으므로 드라이버 다섯에서 같이 돈다. * * ── 왜 방향이 반대인가 ─────────────────────────────────────────────────────── * 유입 쪽은 **보고에서 조치를 찾는다**(보고가 많고 조치가 적다 — 그래서 식별자를 모아 한 번 묻는다). * 이쪽은 **조치에서 보고를 찾는다.** 효과를 기다리는 조치는 몇 건이고, 그 몇 건의 식별자로 저널을 * 물으면 끝난다. 저널 전체를 주사하지 않는다. */ import { OP_EVENT } from '@operato/ops-contract' import { getRepository } from '@things-factory/shell' import { twinWarn } from '../../engine/log.js' import { TwinEvent } from '../twin-event/twin-event.js' import type { CommandStore, StoredCommand } from './command-dispatcher.js' import { matchEffects, type OrderObservation } from './effect-match.js' export interface CatchUpResult { /** 효과를 기다리던 조치 수. */ awaiting: number /** 이번에 효과를 적은 조치 수. */ recorded: number /** 저널에 보고가 아직 없던 조치 수 — 저쪽이 아직 아무 말도 안 한 것이다. */ silent: number } /** * 이 도메인에서 놓친 보고를 한 번 backfill 한다. * * **던지지 않는다.** 주기에서 부르고, 여기서 터지면 그 주기가 멈춘다. 그리고 이 일은 다음 바퀴에 * 다시 해도 같은 결과가 나온다 — 급할 것이 없다. */ export async function catchUpEffects(domainId: string, store: CommandStore): Promise { const none: CatchUpResult = { awaiting: 0, recorded: 0, silent: 0 } if (!store.awaitingEffect || !store.recordEffects) { twinWarn( '[actuation] 놓친 보고를 backfill 할 수 없다 — 저장소에 awaitingEffect 나 recordEffects 가 없다. ' + '못 듣고 있던 동안 온 보고는 영원히 이어지지 않는다.' ) return none } try { const commands = await store.awaitingEffect(domainId) if (!commands.length) return none /* 트윈마다 묶는다 — 저널 질의가 트윈 단위로 인덱스를 탄다. */ const byTwin = new Map() for (const c of commands) { if (!c.dispatchRef) continue const list = byTwin.get(c.instanceId) ?? [] list.push(c) byTwin.set(c.instanceId, list) } let recorded = 0 let silent = 0 for (const [instanceId, list] of byTwin) { const observations = await latestOrderReports( domainId, instanceId, list.map(c => c.dispatchRef!) ) const seen = new Set(observations.map(o => o.orderId)) silent += list.filter(c => !seen.has(c.dispatchRef!)).length const { updates } = matchEffects({ observations, byRef: new Map( list.map(c => [c.dispatchRef!, { id: c.id, dispatchRef: c.dispatchRef!, ...(c.effect ? { effect: c.effect } : {}) }]) ) }) /* * **이미 적힌 것과 같으면 적지 않는다.** 주기마다 부르므로, 안 걸러 내면 같은 보고로 `seen` 이 * 영원히 는다 — 「몇 번 보고됐나」가 「몇 번 backfill 했나」가 된다. */ const changed = updates.filter(u => { const before = list.find(c => c.id === u.commandId)?.effect return !before || before.status !== u.status || before.at !== u.at }) if (changed.length) { await store.recordEffects(domainId, changed) recorded += changed.length } } return { awaiting: commands.length, recorded, silent } } catch (e: any) { twinWarn(`[actuation] 놓친 보고를 backfill하다 실패했다 — ${String(e?.message ?? e).slice(0, 200)}`) return none } } /** * 이 트윈에서 그 오더들의 **마지막** 상태 보고를 읽는다. * * ── 왜 오더마다 따로 묻나 ────────────────────────────────────────────────── * 한 번에 묶어 물으면 그 오더들의 보고를 **전부** 받게 된다. 오더 하나가 수백 번 보고될 수 있고 * (초안 → 나감 → 진행 → 완료, 그리고 수량이 바뀔 때마다), 그러면 상한 없이 저널을 읽는 자리가 된다. * * 그래서 오더마다 `take: 1` 로 최신 하나만 읽는다. 질의 수가 조치 수만큼 늘지만, **효과를 기다리는 * 조치는 몇 건**이다(넘긴 것만, 그것도 식별자가 있는 것만). 몇 건에 몇 질의는 값이 아니고, 상한 없이 * 원장을 읽는 것은 값이다. * * `revision` 내림차순이 「나중」의 정본이다 — 시각이 같은 보고가 여럿 올 수 있고(같은 밀리초에 둘이 * 실제로 있었다), 그때 무엇이 나중인지는 원장의 순서가 답한다. */ async function latestOrderReports( domainId: string, instanceId: string, refs: readonly string[] ): Promise { const out: OrderObservation[] = [] const repo = getRepository(TwinEvent) for (const orderId of new Set(refs)) { const rows = await repo.find({ where: { domain: { id: domainId }, instanceId, eventType: OP_EVENT.order, orderId }, order: { revision: 'DESC' }, take: 1 } as any) const row: any = rows[0] const data = row?.payload?.data if (!data) continue /* 봉투에 적힌 시각을 쓴다 — 저장 시각이 아니다. 없으면 판정이 거른다. */ out.push({ orderId, status: String(data.status ?? ''), at: row.payload?.eventTime }) } return out }