/* * **빠진 구간을 언제 다시 받나** — 세 자리에서 부른다. * * ── 왜 세 자리인가 (인티그레이션 레인의 측정 권고, 2026-09-05) ────────────── * ``` * 1 재기동 직후 한 번 우리가 내려가 있는 동안 그쪽이 보낸 것을 못 받았다 * 2 구멍을 본 그 자리 번호가 비었다는 것을 아는 순간이 가장 이르다 * 3 10분 그물 위 둘이 다 놓치는 경우 — 그쪽이 아예 안 보낸 구간 * ``` * * **30초가 아니다.** 그 권고에 근거가 있었다 — backfill 은 원본에 조회를 걸고 저널을 읽는다. * 30초마다 돌면 얻는 것 없이 두 쪽을 계속 친다. 놓친 구간은 10분 뒤에 받아도 사실이 달라지지 않는다. * * 규칙 주기(`rule-loop.ts`, 30초)에 얹지 않는 이유가 그것이다. 얹으면 간격이 그쪽에 묶이고, 나중에 * 규칙 주기를 바꾸면 이쪽이 소리 없이 따라간다. * * ── 겹쳐 돌지 않는다 ──────────────────────────────────────────────────────── * 한 바퀴가 간격보다 오래 걸리면 다음 발화가 겹친다. 겹치면 두 바퀴가 같은 구간을 받고, 그 순간 * 중복 판정이 두 벌의 기억을 갖는다. * * ── 도는 트윈만 본다 ──────────────────────────────────────────────────────── * 멈춘 트윈에는 사실을 넣을 커널이 없다. `runFill` 이 그것을 다시 확인하지만 여기서 미리 걸러 * 헛조회를 안 만든다. */ import { twinError, twinLog, twinWarn } from '../../engine/log.js' import { TwinEngine } from '../../engine/twin-engine.js' import { runFill, type FillRunResult } from './reference-fill-run.js' /* 간격은 가벼운 파일에 있다 — 테스트가 붙게(§`fill-cadence.ts`). 부르는 쪽이 한 곳에서 가져가게 다시 내보낸다. */ export { FILL_DEBOUNCE_MS, FILL_PERIOD_MS } from './fill-cadence.js' import { FILL_DEBOUNCE_MS, FILL_PERIOD_MS } from './fill-cadence.js' /** 어느 단위를 언제 마지막으로 받았나 — `domainId::instanceId::scope`. */ const lastRun = new Map() let timer: any let running = false function recently(key: string, now: number): boolean { const last = lastRun.get(key) return last !== undefined && now - last < FILL_DEBOUNCE_MS } /** * 그 단위를 한 번 받는다 — **커서부터 이어서.** * * `from` 을 주지 않는다. 밑에서부터 다시 받는 것은 사람이 정하는 일이고(`fillTwinScope(from:)`), * 주기가 스스로 할 일이 아니다 — 그것을 주기로 돌리면 매번 원본의 전 구간을 훑는다. */ export async function fillOnce(domainId: string, instanceId: string, scope: string): Promise { const key = `${domainId}::${instanceId}::${scope}` const now = Date.now() if (recently(key, now)) return undefined lastRun.set(key, now) const out = await runFill({ domainId, instanceId, scope }) /* * 받은 것이 있을 때만 말한다. 「받을 것이 없었다」를 10분마다 적으면 로그가 그 줄로 차고, 그러면 * 진짜 받은 날을 못 본다. */ if (out.applied) twinLog(`[twin-fill] "${instanceId}" · ${scope} — 빠진 구간에서 ${out.applied}건을 받았습니다`) return out } /** * **구멍을 본 자리에서 부른다** — 기다리지 않는다. * * 훅의 응답이 이것 때문에 늦으면 보내는 쪽이 되풀이하고, 그러면 같은 봉투가 두 번 온다. 조치를 * 잇는 자리(`settleEffects`)와 같은 규율이다. */ export function requestFill(domainId: string, instanceId: string, scope: string): void { void fillOnce(domainId, instanceId, scope).catch(err => twinWarn(`[twin-fill] "${instanceId}" · ${scope} 를 받지 못했습니다 — ${err?.message ?? err}`) ) } /** * 도는 트윈을 한 바퀴 돈다. * * **어느 단위를 받을지는 커서가 말한다.** 커서에 적힌 흐름이 그동안 받아 온 것들이고, 그것이 * 없으면 아직 아무것도 안 받은 연결이라 받을 구간도 없다. */ export async function fillAllOnce(): Promise<{ tried: number; applied: number }> { let tried = 0 let applied = 0 for (const { domainId, instanceId } of TwinEngine.ownedTwins()) { for (const scope of await scopesOf(domainId, instanceId)) { tried++ const out = await fillOnce(domainId, instanceId, scope) applied += out?.applied ?? 0 } } return { tried, applied } } /** * 그 연결이 받아 온 단위들 — **커서에 적힌 것.** * * 커서 열쇠는 `instanceId::scope` 다(§`pushCursorKey`). 여기서 그 규칙을 다시 적지 않고 앞부분을 * 떼어 낸다 — 두 곳이 같은 규칙을 가지면 어긋나는 날 이 주기가 엉뚱한 단위를 받는다. */ async function scopesOf(domainId: string, instanceId: string): Promise { const { getRepository } = await import('@things-factory/shell') const { TwinReference } = await import('./twin-reference.js') const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } }) const streams = (ref as any)?.liveCursor?.streams if (!streams || typeof streams !== 'object') return [] const prefix = `${instanceId}::` return Object.keys(streams) .filter(k => k.startsWith(prefix)) .map(k => k.slice(prefix.length)) .filter(s => s) } /** * 주기를 세운다 — 두 번 불러도 하나만 선다. * * **기동에서 한 번 먼저 돈다**(§1). 우리가 내려가 있는 동안 그쪽이 보낸 것을 그때 받는다. 첫 바퀴를 * 10분 뒤로 미루면 그 10분 동안 화면이 빈 구간을 그대로 보인다. */ export function startFillLoop(periodMs = FILL_PERIOD_MS): void { if (timer) return const turn = () => { if (running) return running = true fillAllOnce() .then(({ tried, applied }) => { if (applied) twinLog(`[twin-fill] 한 바퀴 — ${tried}개 단위에서 ${applied}건을 받았습니다`) }) .catch(err => twinError(`[twin-fill] 주기가 실패했습니다 — ${err?.message ?? err}`)) .finally(() => { running = false }) } timer = setInterval(turn, periodMs) /* * 기동 직후 한 번. 조금 늦춰서 돈다 — 커널이 되살아나기 전에 부르면 도는 트윈이 0개라 아무것도 * 안 받고, 그러면 그 한 번이 없는 것과 같다. */ setTimeout(turn, 15_000).unref?.() } /** 주기를 내린다 — 테스트가 재기동을 넣을 수 있게. */ export function stopFillLoop(): void { if (timer) clearInterval(timer) timer = undefined running = false lastRun.clear() }