/* * TwinEngine — operato-twin 커널 인스턴스를 things-factory 위에서 호스팅. * integration-base 의 ScenarioEngine 패턴 미러: 메모리 인스턴스 맵 + 백그라운드 tick + pubsub 상태 발행. * * 커널(zero-dep, ESM+CJS 이중배포)은 CJS require 로 로드(Node 20 CommonJS 소비 — require(ESM) 회피). (vocabulary-guard: allow — Node 런타임 버전 표기) * 커널은 결정적 논리 연산 코어 — 이 host 가 전송(pubsub)·영속(TypeORM)·스케줄(tick)·복구(replay)만 얇게 래핑. * (설계 SoT: operato-twin/design/integration/things-factory-host.md) */ import { twinLog, twinWarn, twinError } from './log.js' import { hydratedDate } from './read-time.js' import { pubsub, getRepository, Domain } from '@things-factory/shell' /* 저널을 자를 조건은 SQL 이 안다 — 관용구만 쓴다(원시 SQL 은 5개 드라이버에서 갈라진다). */ import { And, IsNull, LessThanOrEqual, MoreThan } from 'typeorm' import { cacheService } from '@things-factory/cache-service' import type { ReducerCheckpoint } from '@operato/twin-kernel' import type { VocabularyElement } from '@operato/ops-contract' import type { OeeCheckpoint } from './oee-accumulator.js' import { TwinEvent } from '../service/twin-event/twin-event.js' import { twinEventKeys } from '../service/twin-event/twin-event-keys.js' import { writeSubjectRows, pruneSubjectRows } from '../service/twin-subject/twin-subject-writer.js' import { itemResolverFor } from '../service/twin-subject/item-resolver.js' import { planLiveContinuity, planWarmStart, unwrapState } from './warm-start.js' import { applyDeclarationLayers } from './local-declarations.js' import { isOfDomain, parseRuntimeKey, runtimeKey } from './runtime-key.js' import { TWIN_STATE_TOPIC, twinStatePayload } from './twin-state-channel.js' import { refuseOverwriteByAnotherProducer } from './producer-guard.js' import { refusingModel } from './model-refusal.js' import { routeCommand } from './command-routing.js' import { TwinInstance } from '../service/twin-instance/twin-instance.js' /* 자극의 집은 원본이다(ADR-0029) — 그 행을 읽고 쓴다. */ import { TwinReference } from '../service/reference/twin-reference.js' import { TwinStructure } from '../service/twin-structure/twin-structure.js' import { TwinSpace } from '../service/twin-space/twin-space.js' import { utcOffsetOf } from '../service/reference/reference-master.js' import { TwinSpaceRepresentation } from '../service/twin-space/twin-space-representation.js' import { TwinSpaceArea } from '../service/twin-space/twin-space-area.js' import { TwinArea } from '../service/twin-space/twin-area.js' import { masterToTwin, type ReferenceMaster } from '../service/reference/reference-master.js' import { resolveIngestSpace } from '../service/reference/ingest-space.js' import { type IngestWarning, describeWarnings, unresolvedReference, projectionFailed, structureAdopted, generationWithoutTimeZone } from '../service/reference/reference-master.js' import { projectStructure } from '../service/twin-model/project-structure.js' import { buildTravelEstimator, chainEstimators } from './travel-estimator.js' import { buildMeasuredEstimator } from './measured-estimator.js' import { buildYieldEstimator } from './measured-yield.js' import { planStimulus, withStimulus } from './declared-stimulus.js' import { readRestartPolicy, type RestartPolicy } from './restart-policy.js' import { describeModelBasis, type ModelBasis } from './model-basis.js' import { computeTwinKpi } from './kpi-query.js' import { createHash } from 'node:crypto' import { buildEntityDeltas } from '@things-factory/headless-twin/dist-shared/entity-delta.js' import { orderFoldPolicyOf } from '@operato/ops-contract' import { foldForCheckpoint } from './checkpoint-fold.js' import { fleetLoad, judgeCycle, loadSummary, newLoadMeter, recordFork, recordPhase, slowTickMessage, type LoadMeter, type LoadPhase } from './load-meter.js' import { loopLag } from './loop-lag.js' import { touchedItemKeys } from '@things-factory/headless-twin/dist-shared/touched-items.js' import { diffStructures, type StructureDiff } from './structure-diff.js' import { OeeAccumulator, withLiveOee } from './oee-accumulator.js' import { withLiveAttentions } from './live-attentions.js' import { digestAttentions, mergeLensAttentions } from './attention-digest.js' import { liveFeedStateOf } from './live-feed-registry.js' import { ingestHealth, newIngestLedger, recordIngest, recordReadFailure, clearCursorStall, clearReadFailure, recordRecovered, recordCursorStall, recordWithheld, recordIngestSource, recordDuplicates, rollIngestWindow, recordVocabulary, /* 별칭 — 같은 이름의 정적 메서드와 헷갈리지 않게(그 메서드가 이것을 부른다). */ recordJournalWrite as recordLedgerWrite, type IngestHealthView, type IngestLedger, type IngestWindow } from './ingest-health.js' import { FactDeduper } from './ingest-dedupe.js' import { EMS_PROPERTY } from '@operato/ops-contract' import type { SubscriptionMessage, TwinRuntime as TwinRuntimeType } from '@operato/twin-kernel' import type { TwinKernel, TwinModelDef, StructureShift, CanonicalEnvelope } from '@operato/ops-contract' /* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */ const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments } = require('@operato/twin-kernel') const { readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/ops-contract') const KERNELS: Record = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel } /** * 시각축을 구할 때 한 번에 띄우는 트윈 수. * * 트윈마다 왕복이 둘이라, 공간에 트윈이 수백이면 동시 왕복도 수백이 된다 — 커넥션 풀이 마르면 이 * 함수 하나 때문에 다른 질의가 줄을 선다. 20이면 왕복 40개로 지연은 감추면서 풀은 남는다. */ const TIME_RANGE_FANOUT = 20 /** * DB 가 준 시각을 epoch ms 로 — **모양이 드라이버마다 다르다.** * * 집계(MIN/MAX)의 반환은 드라이버가 정한다: sqlite 는 문자열, pg·mysql·mssql 은 `Date`. 엔티티 * 하이드레이션을 건너뛰면 TypeORM 의 날짜 변환도 함께 건너뛰므로 받는 쪽에서 한 번 정규화한다. * * ── 시간대를 잃지 않는다 (시험이 잡았다) ──────────────────────────────────── * 처음에 `Date.parse(String(v))` 로 두었더니 **9시간이 밀렸다.** sqlite 는 UTC 로 저장한 값을 * `2026-01-15 09:00:00.000` 처럼 **시간대 표시 없이** 돌려주는데, 그 형태를 `Date.parse` 는 **로컬 * 시각**으로 읽는다(여기가 KST 라 UTC 00:00 이 됐다). 원래 코드가 이 함정을 피한 것은 TypeORM 이 * 하이드레이션에서 먼저 `Date` 로 바꿔 줬기 때문이다 — 그 단계를 건너뛴 대가를 여기서 치른다. * * 그래서 시간대 표시가 **없는 문자열은 UTC 로 읽는다**(TypeORM 이 UTC 로 적으므로). 표시가 있으면 * 그대로 믿고, `Date` 는 손대지 않는다. * * 읽을 수 없는 값은 **없음(`null`)** 이다 — 0 으로 떨어뜨리면 1970년이 시각축의 시작이 된다. */ function parseTime(v: unknown): number | null { if (v == null) return null if (v instanceof Date) return Number.isFinite(v.getTime()) ? v.getTime() : null const s = String(v).trim() if (!s) return null /* 끝에 `Z` 나 `±hh:mm` 이 붙어 있으면 시간대를 아는 문자열이다 — 그대로 믿는다. */ const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/.test(s) const t = Date.parse(zoned ? s : `${s.replace(' ', 'T')}Z`) return Number.isFinite(t) ? t : null } /** * 종류 문자열 → 커널. **모르는 값이면 오류를 낸다.** * * 예전에는 표를 찾고 없으면 WmsKernel 로 떨어졌다. `kind` 는 검증 없는 자유 문자열(`@Arg('kind') kind: string`) * 이라 오타 하나·대소문자 하나로 야드/생산 트윈이 **오류 없이 창고 커널로 돌았다.** 오류가 없으니 화면에는 * 트윈이 정상으로 보이고, 안에서 도는 규칙만 다른 도메인의 것이다. 예측 경로가 가장 나쁘다 — * 야드의 미래를 창고 규칙으로 실행해 놓고 숫자만 뜬다. * * 틀린 공장을 알리지 않고 띄우는 것보다 뜨지 않는 편이 낫다. */ function kernelFor(kind: string): any { const K = KERNELS[kind] if (!K) throw new Error(`unknown twin kind "${kind}" — expected one of ${Object.keys(KERNELS).join(' | ')}`) return K } /** `observe()` 를 갖지 않은 커널을 만난 적이 있는가 — 경고를 한 번만 낸다(틱마다 쏟지 않는다). */ let observeUnavailableWarned = false /** * **이 커널의 진실이 원본에서 온다고 선언한다** — 그리고 선언이 닿지 않았으면 말한다. * * ── 무엇이 틀렸나 (2026-08-21) ────────────────────────────────────────────── * 예전에는 `kernel.observe?.()` 였다. 옵셔널 호출이라 **메서드가 없으면 알리지 않고 아무것도 하지 않는다.** * 설치된 커널 0.7.39 에는 그 메서드가 없으므로, 지금 배포본에서는 미러가 관측 구동으로 선언되지 * 않는다 — 그런데 코드를 읽으면 선언한 것처럼 보인다. * * 그 차이가 실제 거동을 가른다: 관측 구동은 원본의 빈틈을 받아들이고 세지만, 시뮬로 오인된 미러는 * **멈춘다.** 즉 「관용이 켜진 줄 알았는데 안 켜져 있다」가 알리지 않고 지나가고, 장애가 났을 때 원인이 * 커널에 있는 것처럼 보인다 — 원인은 버전이다. * * 그래서 옵셔널 호출을 없애고, 없으면 **한 번 경고한다.** 커널이 배포되면 이 경고는 사라진다. */ /** * 저장된 기록을 **도는 동안과 같은 코드로** 처리하게 한다 (2026-08-27). * * ── 무엇이 틀렸나 ─────────────────────────────────────────────────────────── * 도는 동안 사건을 처리하는 것은 도메인 커널이다(`startLive` 가 `new Kernel(...)` 을 세운다). 그런데 * 저장된 기록으로 과거 시점 상태를 만드는 경로는 커널 종류를 넘기지 않아서 도메인 규칙 없이 * 처리했다. 그 결과 「어제 오후 3시에 이 발전소가 어땠나」를 물으면 발전량과 계량이 늘 비어 있었다 — * 받아서 저장까지 해 둔 값인데 읽는 쪽이 건너뛴 것이다. * * 종류는 모델에 없다(등록부가 든다). 그래서 호스트가 말해 준다. */ function replayOptions(reg: { kind?: string; model?: any } | undefined, domainId: string): any { return { kind: reg?.kind, productionSpec: (reg?.model as any)?.productionSpec, tenantId: domainId } } function declareObserved(kernel: any, what: string): void { if (typeof kernel?.observe === 'function') { kernel.observe() return } if (observeUnavailableWarned) return observeUnavailableWarned = true twinWarn( `[twin-engine] ${what}: kernel has no observe() — this mirror is not declared observation-driven. ` + 'Source gaps will stop it instead of being tolerated and counted. Deploy a kernel that provides observe().' ) } /* 라이브 처리량 계측(모니터) — 유입/브로드캐스팅/저널률·백로그. 실 동기 인터페이스 부하를 읽는 일급 지표. */ interface TwinMetrics { ingestedTotal: number // 누적 유입 이벤트 broadcastTotal: number // 누적 브로드캐스팅 횟수 journaledTotal: number // 누적 저널 기록 이벤트 ingestRate: number // 최근 유입률(ev/s) broadcastRate: number // 최근 브로드캐스팅률(회/s) journalRate: number // 최근 저널률(ev/s) /** * **지금 대기 중인** 저널 건수 — 아직 DB 로 나가지 않은 것. * * ── 무엇이 틀렸었나 (2026-08-21) ────────────────────────────────────────── * 예전에는 「마지막 flush 에서 쓴 건수」를 여기 넣었다. 그런데 200ms 마다 모아 한 문으로 넣는 것은 * **정상 동작**이다(그렇게 만든 것이 어제 고친 성능 작업이다). 그래서 정상적으로 10건을 쓴 직후 * 화면에 「밀림 10」 경고가 떴고, 다음 flush 까지 그 값이 남았다 — 큐는 이미 0 인데도. * * 운영자에게 그것은 「저장이 10건 밀려 있다」로 읽힌다. 거짓 경보는 참 경보를 죽인다. * * 이제 이 값은 **지금 남아 있는 것**이다: 다 나갔으면 0 이고, 정말로 쌓이는 중이면 그 수가 자란다. */ backlog: number // 창(window) 집계용 내부 누적 _accIngest: number _accBroadcast: number _accJournal: number _windowStartMs: number } /* * 재기동 정책은 **선언이고 기본값이 없다** — 정의·판정은 `engine/restart-policy.ts` 가 든다(ADR-0029 §2). * * 예전에는 여기에 `DEFAULT_REALITY_MODE = 'sim-experiment'` 가 있었고, 미선언이 알리지 않고 **저널 초기화**로 * 떨어졌다. 그 관용은 값이 하나 어긋나는 순간 이력을 지우는 길이 된다 — 그래서 없앴다. */ export type { RestartPolicy } from './restart-policy.js' /* 인메모리 라이브 런타임 홀더(영속 엔티티 TwinInstance 와 구분). */ interface InstanceRuntime { /** 부하 계기판 — 작업별 소요. 시뮬·라이브 모두 붙는다(누가 루프를 점유하는지 보려면 둘 다 필요하다). */ load?: LoadMeter /** 현실 출처 선언(§0 프레임 ①). 부팅 거동(reset/resume/resync)의 근거. */ restartPolicy?: RestartPolicy id: string domainId: string domain?: any // 라이브 바인딩(data 채널)용 Domain 객체(subdomain 필터). start 시 비동기 해석. runtime?: TwinRuntimeType // sim: 커널 런타임(tick/subscribe/dispatch). live 는 projector 사용. kernel?: any // sim: ForecastTwin(fork/getSnapshot/tick/scenario). live 미사용. mode?: 'sim' | 'live' // 상태 드라이버 — sim=커널 tick / live=projector 미러(face2-inbound-live). 미지정=sim. /** 이 트윈이 선 현장 — 같은 현장의 트윈끼리 서로의 설비 상태를 읽을 수 있게 하는 열쇠. */ spaceId?: string /** * 이 트윈의 모델 — **대상별 사건 목록에 품목 줄을 적을 때** 쓴다 (2026-08-27). * * 개체 식별자에서 선언된 품목을 찾는 판정이 그 트윈의 선언을 본다(§`item-resolver`). 사건마다 * 등록부를 읽으면 쓰기마다 DB 조회가 하나 늘어나므로, 세울 때 받은 모델을 들고 있는다. */ model?: TwinModelDef /** * live 의 상태 접근 어댑터 — 이제 **관측 모드 커널**을 가리킨다(`kernel` 과 같은 것). * 이름은 소비처 호환으로 남았고, P3 에서 커널 어휘로 정리하면 사라진다. */ projector?: any oee?: OeeAccumulator // live: OEE 계산 층(이벤트 누적 → equipment payload 보강). sim 은 커널이 계산. /** * 같은 사실이 두 번 오는 것을 거르는 자리(§`FactDeduper`). * * 트윈마다 하나다 — 정체는 트윈 안에서만 뜻이 있고, 트윈 하나가 되풀이 받는 것을 거르는 것이 목적이다. * 프로세스가 새로 뜨면 비어 있다(그 사실은 `FactDeduper` 주석에 적어 두었다). */ deduper?: FactDeduper unsub: () => void /** * 방금 인입한 봉투들 — 커널이 그것을 재방출할 때 **저널에 두 번 적지 않기** 위한 표시. * 라이브만 쓴다(sim 은 커널이 스스로 낸 것만 흘린다). 약한 참조라 따로 비울 것이 없다. */ applying?: WeakSet timer?: any /** 엔티티별 마지막 발행 시그니처 — 값이 바뀐 엔티티만 재발행(무변화 반복 push 방지). */ entitySigs?: Map /** live 브로드캐스팅 병합(ingest-scale §4.4) — ingest 마다 브로드캐스팅하지 않고 dirty 표시 → coalescer tick 이 주기 브로드캐스팅. */ dirty?: boolean /** * 이 창에 **건드린 물품 태그** — 브로드캐스팅이 그것만 만든다. * * `undefined` 는 「말할 수 없다」다(낯선 사건이 하나라도 왔다) → 전부 만든다. 빈 집합은 「물품은 * 하나도 건드리지 않았다」이고, 그 둘은 다른 사실이다. */ dirtyItems?: Set /** 기동 이후 이 트윈이 발행한 건수 — 무엇이 대기열을 채우는지 값으로 말한다. */ publishedTotal?: number /** 다음 브로드캐스팅에서 전부 만들어야 하나 — 주기마다 한 번은 그물로 전부 만든다. */ fullBroadcastDue?: boolean /** 전부 만들기까지 남은 창 수. */ fullBroadcastCountdown?: number /** live 저널 결선 — ingest 이벤트를 모았다가 coalescer tick 에서 배치 기록(이벤트마다 DB write 금지). */ pendingJournal?: any[] /** live 저널 revision 카운터(sim 의 delta revision 대응 — 시간여행·히스토리 노브 앵커). */ revision?: number /** live 처리량 계측(모니터, ④-1). */ metrics?: TwinMetrics } /** * 트윈이 **스스로 멈춘 이유** — 문장이 아니라 코드다. * * 서버가 만든 영어 문장을 그대로 실으면 한국어·일본어 화면에 그 영어가 뜬다. 이 시스템은 거절 메시지에서 * 같은 결론에 이르렀다(코드+파라미터를 내고 화면이 옮긴다). `reason` 만은 원문 그대로다 — 커널·런타임이 * 낸 오류 문구는 옮길 수 있는 어휘가 아니고, 옮기려 들면 원인을 잃는다. */ export interface StopNote { code: 'starved' | 'tick-failed' params: Record } export class TwinEngine { /* * 기동 중인 런타임 — **키는 `runtimeKey(domainId, instanceId)`** 다(`runtime-key.ts` 에 이유). * * 예전엔 `instanceId` 하나로 키를 잡았다. 그런데 정체성은 `(domain, instanceId)` 이고 DB 유일성도 * 그쪽이라, 두 테넌트가 같은 id 를 쓰면(레퍼런스 경로가 소스 이름을 id 로 쓴다) 한 자리를 다퉜다. * **엔진 밖에서 이 맵을 직접 색인하지 않는다** — `owns`·`runtime`·`kernel` 같은 접근자를 쓴다. */ static instances: Record = {} /** 라이브 런타임이 없을 때(복구 후 미기동) 저널에서 재구성한 상태 캐시. */ /** 웜스타트 씨앗 — `instances` 와 **같은 키**(겹치면 남의 스냅샷으로 재고가 섞인다). */ static recovered: Record = {} static TICK_MS = 1000 /* * ── 굶김 안전망 (2026-08-14) ────────────────────────────────────────────── * 시뮬 틱은 **메인 이벤트 루프**에서 돈다. 그래서 한 트윈의 틱이 길어지면 그 시간만큼 호스트 전체가 * 멈춘다 — HTTP·구독·다른 트윈의 틱까지. 실측으로 `order-check` 의 틱 하나가 34.9초였고, 그 사이 * 구독자가 아무것도 빼내지 못해 pubsub 이 넘쳐 프로세스가 죽었다. * * 브로드캐스팅 반복은 걷어냈지만(`flushLiveBroadcasts` 로 병합) **커널 틱 자체는 여전히 메인 루프에 있다.** * 근본 해결은 분산이고 그것은 이연됐다 — 그때까지의 안전망이 이 셋이다. * * 판정을 예산(500ms)이 아니라 **굶김 문턱**으로 따로 둔다: 조금 느린 트윈은 계기판이 말하게 두고 * (경고), 호스트를 굶기는 트윈만 멈춘다. 한 번으로 멈추지 않는다 — 웜스타트 직후의 첫 틱은 원래 * 무겁다(복구한 상태를 처음 계산한다). **연속**으로 이어질 때가 구조적으로 느린 것이다. */ /* * ── 틱을 **한 순간에 몰지 않는다** (2026-08-21 실측) ──────────────────────── * 트윈마다 `setInterval(TICK_MS)` 를 부팅 때 나란히 세우면, 스무 개가 **같은 밀리초에** 깨어난다. * 각자의 틱이 짧아도(실측: 물품 2,400 개에서 0.23ms) 그 순간에는 스무 개가 한 줄로 붙어 실행되고, * 그 사이 도착한 HTTP 요청은 전부 뒤에서 기다린다. 초당 한 번의 정체가 「누를 때마다 걸린다」로 * 나타나는 자리다. * * 그래서 첫 발화만 간격 안에서 **고르게 흩는다** — 총 작업량은 같고 한 순간의 최대치만 낮아진다. * 트윈이 간격보다 많아지면 다시 겹치므로, 그때는 이 흩기가 아니라 분산이 답이다(이연됨). */ /** 부팅 때 트윈 하나를 되살린 뒤 루프를 비워 주는 시간(ms) — 그 사이 도착한 요청이 처리된다. */ static BOOT_YIELD_MS = 25 private static tickPhase = 0 /** * 흩어진 첫 발화 뒤 주기 틱. **핸들은 항상 진짜 타이머**다 — 정지하는 쪽이 `clearInterval(inst.timer)` * 하나로 끝내야 하므로, 첫 발화 전에는 그 `setTimeout` 을, 이후에는 `setInterval` 을 같은 자리에 둔다 * (감싼 객체를 주면 `clearInterval` 이 아무 일도 하지 않고 트윈이 멈추지 않는다 — 들어온 것이 없는 결함이 된다). */ private static startTickTimer(fn: () => void, hold: (t: any) => void): any { const spread = Math.max(1, Math.round(this.TICK_MS / 20)) const offset = (this.tickPhase = (this.tickPhase + spread) % this.TICK_MS) const first = setTimeout(() => { const interval = setInterval(fn, this.TICK_MS) if (typeof interval?.unref === 'function') interval.unref() hold(interval) // 정지 경로가 지울 대상을 바꿔 준다 fn() }, offset) if (typeof first?.unref === 'function') first.unref() return first } /** 굶김 문턱 — 틱 간격의 배수(1초 간격이면 5초). 이 시간만큼 호스트가 멈춘다. */ static STARVE_FACTOR = 5 /** 연속 몇 번이면 멈추나 — 3번이면 15초를 굶긴 셈이고, 그건 우연이 아니다. */ static STARVE_STREAK = 3 private static starveStreak = new Map() /** * 왜 멈췄나 — **화면이 그대로 말할 수 있게.** 스스로 멈춘 트윈이 이유 없이 「정지」로만 보이면 * 사람은 자기가 멈춘 줄 안다. 메모리에만 둔다(재기동하면 사라진다 — 그때는 「모른다」가 사실이다). */ private static stopNotes = new Map() /* 최신 스냅샷 영속(warm-start) — 재기동 시 저널 fold-from-0 replay 대신 마지막 라이브 스냅샷으로 복원. * 프레임워크 공통 cache-service(@things-factory/cache-service) 재사용(재발명 금지, framework-leverage §4). * 저널은 그대로 진실(시간여행/history replay 무변경) — 캐시는 display-only 웜스타트 최적화. */ static SNAPSHOT_CACHE_ID = 'twin-snapshot' /* * ── 재개점 **사슬** — 과거를 물었을 때 목표 직전에서 계산 위해 (2026-08-18) ── * * 최신 재개점 하나로는 시간여행을 도울 수 없다: 그것은 언제나 목표보다 **뒤**에 있다. 그래서 지점을 * 여러 개 남긴다. 다만 그것들은 각각 상태 전체를 들고 있어 무겁다 — 한 행에 몰아 넣으면 거대한 * JSON 이 되므로 **지점마다 따로 두고 색인을 둔다**(cache-service 는 해시 키 조회라 열거가 안 된다). * * 간격은 리비전 눈금으로 잡는다(`CHAIN_STRIDE`): 눈금을 넘을 때만 한 지점을 남기므로, 저널이 빠르게 * 자라는 트윈에서도 지점 수가 폭발하지 않는다. 오래된 것부터 버리고 최근 `CHAIN_KEEP` 개만 든다 — * 과거로 깊이 갈수록 지점이 없어 0부터 계산하는 것은 **알려진 한계**다(무한 보관보다 정직하다). */ static CHAIN_CACHE_ID = 'twin-fold-chain' static CHAIN_INDEX_CACHE_ID = 'twin-fold-chain-index' static CHAIN_STRIDE = 5000 // 리비전 눈금 — 이 간격을 넘을 때만 한 지점을 남긴다 static CHAIN_KEEP = 5 // 최근 몇 지점을 들고 있나(그보다 과거는 0부터 계산한다) static SNAPSHOT_TTL_S = 7 * 24 * 3600 // 7일 — 정상 다운타임 생존, 만료 시 저널 replay 폴백 /** * 멈춘 트윈의 마지막 상태를 두는 기간 — 사실상 만료 없음 (2026-08-27). * * 캐시 계층에 「만료 없음」을 뜻하는 값이 없어서 큰 수를 쓴다(`ICacheService` 는 초 단위 TTL 하나만 * 받는다). 100년이면 이 시스템의 수명보다 길다. */ static SNAPSHOT_KEEP_S = 100 * 365 * 24 * 3600 static CHECKPOINT_MS = 20000 // 체크포인트 주기(핫 브로드캐스트 경로와 분리, O(state) 스로틀) /** * **저널 보존 — 지우는 것은 선언이 있을 때만 한다.** * * ── 왜 필요한가 (2026-08-22 실측) ───────────────────────────────────────── * 개발 저널이 시간당 **581,794행 · 0.85GB** 로 자랐다(하루 20GB). 데모 MES 트윈 셋이 전체의 **97%** * 를 만들고 지우는 것이 없었다. 오늘 고친 것들은 「그 크기에서도 질의가 빠르게」이고, **크기 자체를 * 줄이는 것은 아무것도 없었다.** 그러면 며칠마다 같은 자리로 돌아온다. * * ── 그런데 저널을 지우는 것은 사실을 잃는 일이다 ────────────────────────── * 그래서 세 규율을 지킨다. * * ① **선언이 없으면 아무것도 지우지 않는다.** 기본값은 없음이다 — 알리지 않고 지우는 편이 알리지 않고 쌓는 * 것보다 나쁘다. 지우는 것은 사람이 정한다. * ② **체크포인트가 대신할 수 있는 만큼만.** 스냅샷이 없거나 그 리비전을 넘는 자리는 건드리지 않는다. * 주석이 「만료 시 저널 replay 폴백」이라고 적어 둔 그대로 — 스냅샷이 사라지면 저널이 **유일한** * 복구 수단이므로, 둘을 함께 잃으면 그 트윈의 상태는 되돌릴 수 없다. * ③ **지운 것을 말한다.** 몇 건을 어느 시각까지 지웠는지 로그에 남긴다. 알리지 않고 줄어든 저널은 * 「없었던 일」과 구별되지 않는다. * * 그리고 보존 기간은 **스냅샷 TTL(7일)보다 짧을 수 없다.** 더 짧으면 스냅샷이 살아 있는데 그것이 * 가리키는 앞쪽 저널이 없는 구간이 생기고, 시간여행·계보 추적이 그 구간에서 알리지 않고 빈다. */ static JOURNAL_RETENTION_DAYS?: number = undefined /** * **도메인마다 다른 보존 기간을 앱이 답한다** — 엔진은 그 값이 어디서 오는지 모른다. * * 보존 기간은 테넌트의 정책이다: 한 고객은 30일이 필요하고 다른 고객은 7일이면 된다. 그런데 엔진이 * `Setting` 을 읽게 만들면 **엔진이 화면 관심사를 알게 된다** — 방향이 거꾸로다(엔진은 커널의 호스트이고 * 그 위에 어떤 화면이 있는지 몰라야 한다). * * 그래서 시임을 둔다. 앱이 이 함수를 심고, 그 안에서 `Setting` 이든 다른 무엇이든 읽는다. 심지 않으면 * `JOURNAL_RETENTION_DAYS`(설정 파일에서 온 프로세스 기본값)가 답한다. * * `undefined` 를 돌려주면 **그 도메인은 지우지 않는다** — 「모른다」를 「지워도 된다」로 읽지 않는다. */ static retentionDaysOf?: (domainId: string) => Promise static RETENTION_SWEEP_MS = 10 * 60 * 1000 // 10분마다 한 번 — 지우는 일은 급하지 않다 private static retentionTimer?: any private static checkpointTimer?: any /** 최신 스냅샷을 cache-service 에 체크포인트(도메인+instanceId 키). display-only·비차단·오류흡수. */ static async persistSnapshot( domainId: string, instanceId: string, /** * 만료를 두지 않는다 — **트윈을 멈출 때만** 참이다 (2026-08-27). * * 도는 트윈은 주기적으로 다시 쓰므로 7일 만료가 걸리지 않는다. 멈춘 트윈은 아무도 쓰지 않아서 * 7일 뒤 만료되고, 그러면 그 트윈의 마지막 상태를 아는 것이 아무것도 없다. 멈추는 순간이 그 * 상태가 메모리에 있는 마지막 순간이므로 그때 만료 없이 적는다. * * 만료된 뒤에도 그 트윈은 보관된 구간에서 다시 세워진다(보관 기간만큼이므로 유한하다). 다만 그 * 구간 앞의 상태는 알 수 없다. 멈추는 순간에 적어 두면 그 손실이 없다. */ keepForever = false ): Promise { const inst = this.instances[runtimeKey(domainId, instanceId)] if (!inst) return /* * **봉투가 아니라 상태를 저장한다.** `snapshot()` 은 시뮬에서 `runtime.resync()` 봉투를 주는데, * 그것을 다시 `{revision, state}` 로 감싸 넣어 왔다 → 꺼낸 값에 축이 하나도 없어 웜스타트가 * 알리지 않고 넘어갔다(저널에 수천 건이 있어도 트윈이 빈 채로 떴다). */ /* 끝난 오더를 접고 나서 상태를 뜬다 — 접힌 쪽이 이 체크포인트와 같은 때에 적힌다(ADR-0092 · `checkpoint-fold.ts`). */ foldForCheckpoint(inst) const state = unwrapState(this.snapshot(domainId, instanceId)) if (!state) return /* * ── **아무것도 듣지 못한 것을 「비었다」로 적지 않는다** (2026-08-24 실측) ──── * * 미러가 재기동하면 관측 축(재고·오더·작업)을 들고 오지 않는다(`startLive` 가 되찾은 상태를 * 버린다 — 「다음 계측이 정정한다」는 전제). 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그 * 정정이 오지 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채다. * * 그 상태에서 이 함수가 돌면 **빈 상태를 좋은 스냅샷 위에 덮는다.** 그래서 손실이 영구화됐다: * * 저장돼 있던 것 rev 221,884 · nowTime 2026-04-15 · items 741 · orders 2,780 · tasks 6,353 * 덮으려던 것 같은 키 · nowTime 2026-01-01 · 전부 0 * * 리비전으로는 막을 수 없다 — 저널 줄 번호는 관측이 없어도 계속 자란다. 막는 기준은 **「들은 것이 * 있나」**다. 유입이 한 건도 없었다면 이 빈 상태는 **원본이 「비었다」고 말한 것이 아니라 우리가 * 아무것도 못 들은 것**이고, 그 둘을 같은 값으로 적으면 「모름」이 「없음」이 된다. * * 원본이 실제로 「다 비었다」고 말한 경우는 막지 않는다 — 그때는 유입이 있었으므로 이 문을 지난다. * * 그리고 **거절을 말한다**: 알리지 않고 거절하면 왜 체크포인트가 낡아 가는지 아무도 모른다. */ if (!(inst.metrics?.ingestedTotal > 0)) { const observed = (s: any) => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0) if (observed(state) === 0) { const prev = await this.loadSnapshot(domainId, instanceId).catch(() => null) const had = observed(prev?.state) if (had > 0) { twinWarn( `[twin-engine] "${instanceId}": checkpoint refused — this twin has heard nothing since start and its live ` + `state is empty, while the stored snapshot holds ${had} observed fact(s) (revision ${prev?.revision}). ` + 'Writing the empty state would destroy the only recoverable copy. The journal still holds the truth.' ) return } } } const revision = inst.revision ?? state.revision ?? 0 /* 구조 리비전도 함께 — 읽는 쪽이 "이 상태가 지금의 공장인가" 를 가릴 수 있어야 한다. */ const { structureRev } = await this.tipOf(domainId, instanceId).catch(() => ({ structureRev: null }) as any) /* * ── **이어 계산할 씨앗을 함께 적는다** (2026-08-24) ────────────────────────── * * 이 함수는 오랫동안 `{revision, state, structureRev}` 만 적었다. 그래서 **재개점을 읽는 쪽은 다 * 있는데 쓰는 쪽이 없었다**: 조회 경로(`recover`)는 `fold` 가 있으면 꼬리만 계산하고, 없으면 저널을 * 0부터 계산한다. 실측으로 저장된 스냅샷 34건 전부 `fold` 가 비어 있었고, 저널은 2,960만 줄이었다 — * 그래서 재기동·조회마다 처음부터 다시 계산했다. 규모 기준(엔티티 10만·품목 100만)에서 이것은 * 느린 것이 아니라 **못 하는 것**이다. * * 씨앗은 상태가 대신할 수 없다: 리듀서는 소비처가 보는 값 말고도 든다(부모를 기다리는 담김·집계 * 중인 수량·담을 줄 몰라 세어 둔 사건). 상태만 되돌리고 뒤를 계산하면 0부터 계산한 결과와 **알리지 않고** * 달라진다. 그 동치는 커널 시험이 증명한다(`observed-checkpoint.test.ts`). * * 관측 구동이 아니면 씨앗이 없다 — 시뮬은 리듀서를 갖지 않고, 그 상태의 권위는 커널 자신이다. * 그때는 `fold` 를 **넣지 않는다**(빈 씨앗을 넣으면 읽는 쪽이 「이어 계산할 수 있다」고 잘못 본다). */ const reducer: ReducerCheckpoint | undefined = inst.kernel?.observedCheckpoint?.() const fold = reducer && inst.oee ? { reducer, oee: inst.oee.serialize() } : undefined await cacheService.setInCache( this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision, state, structureRev, ...(fold ? { fold } : {}) }, keepForever ? this.SNAPSHOT_KEEP_S : this.SNAPSHOT_TTL_S ) } /** * 계산의 **재개점** — 리듀서 내부 상태 전부 + 가동 누적기. * * 스냅샷(`state`)은 소비처가 보는 값이라 이어 계산의 씨앗이 되지 못한다(보류된 담김·집합·반영 못 한 * 사건 집계가 없다 — 그 상태로 뒤를 계산하면 0부터 계산한 결과와 오류 없이 달라진다). 그래서 씨앗은 따로 든다. */ private static readonly FOLD_NOTE = 'reducer + oee checkpoint — the seed for folding only the tail' /** * 재기동에 쓸 **웜스타트 씨앗**을 만든다 — 상태 + 이어 계산할 재개점. * * ── 왜 이 자리가 생겼나 (2026-08-24) ────────────────────────────────────── * 두 호출부가 같은 일을 조금씩 다르게 하고 있었고(겹포장을 한쪽만 벗겼다), 둘 다 **재개점을 버리고** * 상태만 들고 갔다. 그래서 저장된 재개점을 읽는 쪽이 다 있는데도 재기동은 매번 저널을 처음부터 * 계산했다(실측: 저널 2,960만 줄). * * 여기서 하는 일 셋: * ① 겹포장을 벗긴다 — 옛 형식으로 저장된 값이 한 번은 반드시 나온다 * ② **그 공장이 아직 그 공장인지** 심판한다 — 아니면 씨앗을 버린다(아래) * ③ 씨앗이 저널 끝보다 앞서 있으면 **그 꼬리만 계산해** 끝까지 밀어 둔다 * * ②가 필요한 이유: 재개점은 그때의 보드 위에서 만들어진 것이다. 그 뒤 구조가 바뀌었다면(자리가 * 빠졌다·설비가 옮겨졌다) 되세운 리듀서는 **지금 없는 자리와 설비를 든다** — 없는 냉장실이 화면에 * 나오고 그 자리의 판정이 계속 돌아간다. 오류 없이 틀리므로 눈에 띄지 않는다. * * ③이 필요한 이유: 스냅샷은 체크포인트 주기로 쓰이므로 마지막 주기 이후의 사실은 저널에만 있다. * 그것을 빼고 되세우면 그만큼이 오류 없이 사라진다 — 「모름」을 「없음」으로 적는 것과 같은 부류다. * 계산하는 구간은 **그 틈뿐**이고(저널 전체가 아니다), `recover` 가 그 자리에서 새 재개점을 남겨 준다. */ private static async warmSeedFor( domainId: string, instanceId: string ): Promise<{ revision: number; state: any; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } | null> { let cached = await this.loadSnapshot(domainId, instanceId).catch(() => null) if (!cached?.state) return null const seedOf = (c: typeof cached) => (c?.fold?.reducer ? { fold: c.fold } : {}) if (!cached.fold?.reducer) return { revision: cached.revision, state: unwrapState(cached.state) } const tip = await this.tipOf(domainId, instanceId).catch(() => null) if (tip && (cached.structureRev ?? null) !== tip.structureRev) { twinLog( `[twin-engine] "${instanceId}": the stored fold seed is from structure ${cached.structureRev} but the factory is now ` + `at ${tip.structureRev} — starting without it (the journal is folded from the beginning instead).` ) return { revision: cached.revision, state: unwrapState(cached.state) } } /* * ── **묻지 못했으면 그 사실을 말한다** (2026-08-28) ───────────────────────── * * 여기가 `if (tip && …)` 이었다. 위에서 `tipOf` 가 실패하면 `null` 이 되고, 그러면 이 갈래가 * **아무 말 없이 건너뛴다** — 저장본 뒤의 사건을 반영하지 못한 채 뜨고, 로그에 아무 흔적이 없다. * 실측(2026-08-28 10:34): 저장본 3,372,228 · 저널 머리 3,374,894 인데 계산했다는 줄도, 못 했다는 * 줄도 없었다. */ if (!tip) { twinWarn( `[twin-engine] "${instanceId}": could not read the journal tip — starting from the stored seed at revision ` + `${cached.revision ?? 0} without folding what came after it. Some recent facts may be missing from state.` ) return { revision: cached.revision, ...seedOf(cached), state: unwrapState(cached.state) } } if ((cached.revision ?? 0) < tip.revision) { /* * 틈을 계산한다 — `recover` 가 이 씨앗으로 **꼬리만** 계산하고, 끝 지점의 새 재개점을 남긴다. * 아직 이 트윈의 런타임이 없으므로 `recover` 는 메모리 대신 저널 경로를 탄다(그것이 여기의 전제다). */ const gap = tip.revision - (cached.revision ?? 0) let failed: string | undefined await this.recover(domainId, instanceId).catch(err => { failed = err?.message ?? String(err) twinWarn(`[twin-engine] "${instanceId}": could not fold the ${gap} event(s) after the checkpoint — ${failed}`) }) const advanced = await this.loadSnapshot(domainId, instanceId).catch(() => null) if (advanced?.state && (advanced.revision ?? 0) > (cached.revision ?? 0)) { twinLog(`[twin-engine] "${instanceId}": folded ${gap} event(s) after the checkpoint → revision ${advanced.revision}.`) cached = advanced } else if (!failed) { /* * **아무 일도 하지 않은 것을 말한다.** 계산이 오류 없이 끝났는데 재개점이 나아가지 않았다는 뜻이고, * 그러면 그 사건들의 결과가 상태에 없다. 예전에는 이 갈래가 조건에 걸리지 않아 침묵했다. */ twinWarn( `[twin-engine] "${instanceId}": folding the ${gap} event(s) after the checkpoint left the seed at revision ` + `${cached.revision ?? 0} — state starts without those facts. The screens may show them as absent.` ) } } return { revision: cached.revision, state: unwrapState(cached.state), ...seedOf(cached) } } /** 체크포인트된 최신 스냅샷 로드(없으면 null). getFromCache 는 CacheStore 엔티티를 반환 → 페이로드는 .value. */ static async loadSnapshot( domainId: string, instanceId: string ): Promise<{ revision: number; state: any; structureRev?: number | null; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } | null> { const entry = await cacheService.getFromCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }) return (entry as any)?.value ?? null } /** * 계산한 상태를 스냅샷으로 남긴다 — **라이브가 아니어도.** * * `persistSnapshot` 은 기동 중인 인스턴스에서만 뜬다(메모리가 진실이므로 옳다). 그런데 **멈춘** * 트윈을 조회할 때마다 저널을 전량 다시 계산하고 있었다(23,731건짜리 트윈에서 4.6초). 그 폴드의 결과를 * 남겨 두면 **처음 한 번만 느리다.** * * `structureRev` 를 함께 적는다: 이벤트가 하나도 안 늘어도 구조를 갈아치우면(재프로비저닝) 그 * 상태는 낡은 것이다. 리비전만 보면 새 설비가 없는 옛 상태를 "지금" 으로 내게 된다. */ static async saveFoldedSnapshot( domainId: string, instanceId: string, value: { revision: number; state: any; structureRev?: number | null; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } ): Promise { if (!value?.state) return await cacheService .setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, value, this.SNAPSHOT_TTL_S) .catch((err: any) => twinError(`[twin-engine] snapshot save fail "${instanceId}"`, err?.message ?? err)) } /** 사슬 색인 — 어떤 리비전 지점을 들고 있나(최신순 아님, 오름차순). */ private static async chainIndex(domainId: string, instanceId: string): Promise { const entry = await cacheService.getFromCache(this.CHAIN_INDEX_CACHE_ID, { domainId, instanceId }).catch(() => null) const revs = (entry as any)?.value?.revisions return Array.isArray(revs) ? revs.filter((r: any) => Number.isFinite(r)).sort((a: number, b: number) => a - b) : [] } /** * 목표 이전의 **가장 가까운 지점**을 고른다 — 없으면 `null`(0부터 계산한다). * * 시각으로 물었으면 그 지점의 마지막 사실 시각이 목표 이내여야 한다(리비전만 보면 목표보다 뒤의 * 사실이 씨앗에 섞인다). 구조가 바뀐 트윈에서는 쓰지 않는다 — 마디를 건너뛴 씨앗은 그 경계의 * 판정을 잃는다(그 경우는 0부터 계산하는 것이 옳다). */ private static async chainSeedFor( domainId: string, instanceId: string, target: { revision?: number; timeMs?: number } ): Promise<{ revision: number; eventTime?: string; structureRev?: number | null; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } | null> { const revs = await this.chainIndex(domainId, instanceId) if (!revs.length) return null for (const revision of [...revs].reverse()) { if (target.revision != null && revision > target.revision) continue const entry = await cacheService.getFromCache(this.CHAIN_CACHE_ID, { domainId, instanceId, revision }).catch(() => null) const value = (entry as any)?.value if (!value?.fold?.reducer) continue if (target.timeMs != null) { const t = value.eventTime ? Date.parse(String(value.eventTime)) : NaN /* 시각을 모르는 지점은 쓰지 않는다 — 목표 이내인지 가릴 수 없다(짐작하지 않는다). */ if (!Number.isFinite(t) || t > target.timeMs) continue } return value } return null } /** 눈금을 넘었으면 한 지점을 남긴다 — 오래된 것은 버린다(색인도 함께 줄인다). */ private static async keepChainPoint( domainId: string, instanceId: string, /* * **보기(state)는 담지 않는다** (2026-08-18 실측으로 고침). * * 처음에는 상태까지 담았더니 지점 하나가 **11.4 MB** 였다(27만 건 트윈). 사슬이 필요한 것은 씨앗 * (재개점)뿐이고, 소비처가 보는 값은 그 씨앗에서 다시 만들어진다 — 같은 사실을 두 번 저장하지 않는다. */ value: { revision: number; eventTime?: string; structureRev?: number | null; fold: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } ): Promise { const revs = await this.chainIndex(domainId, instanceId) const newest = revs.length ? revs[revs.length - 1] : -Infinity if (value.revision - newest < this.CHAIN_STRIDE) return // 아직 눈금을 넘지 않았다 const next = [...revs, value.revision].slice(-this.CHAIN_KEEP) const dropped = revs.filter(r => !next.includes(r)) await cacheService.setInCache(this.CHAIN_CACHE_ID, { domainId, instanceId, revision: value.revision }, value, this.SNAPSHOT_TTL_S) await cacheService.setInCache(this.CHAIN_INDEX_CACHE_ID, { domainId, instanceId }, { revisions: next }, this.SNAPSHOT_TTL_S) /* * 버린 지점은 **색인에서만** 빠진다 — 공용 캐시에 삭제 API 가 없다(`ICacheService` 는 get·set· * clearStaleCache 뿐이다). 그래서 값은 TTL(7일)까지 남는다: 아무도 닿지 못하지만 자리는 차지한다. * 지금 규모에서 감당할 수 있는 낭비이고, 공용 모듈에 삭제를 여는 것은 다른 소비처까지 함께 볼 * 일이라 여기서 몰래 하지 않는다 — **알려진 한계로 적어 둔다.** */ if (dropped.length) console.info(`[twin-engine] "${instanceId}" fold chain dropped ${dropped.join(', ')} from the index (values expire with the cache TTL).`) } /** 이 트윈 저널의 끝 리비전 · 최신 구조 리비전 — 스냅샷이 지금의 사실인지 가리는 두 값. */ private static async tipOf(domainId: string, instanceId: string): Promise<{ revision: number; structureRev: number | null }> { /* * ── **관계로 묻지 않는다** (2026-08-28) ───────────────────────────────────── * * `findOne({ where: { domain: { id } } })` 는 관계를 가진 엔티티에서 두 단계 질의가 되고 **안쪽에 * 상한이 없다** — 조건에 맞는 행을 전부 만들어 놓고 밖에서 한 줄을 고른다. 이 파일이 같은 것을 * 실측해 적어 두었다(§`twinEvents`: 0.024초 대 12초, 저널 375만 행). * * 이 함수는 **부팅마다 트윈마다** 불린다. 승화푸드 저널이 337만 행이므로 그 한 번이 부팅을 * 붙잡는다. 관계를 컬럼으로 물으면 감싸지 않고 색인(`domain, instance, revision`)의 첫 줄로 끝난다. */ const [tip, newest] = await Promise.all([ getRepository(TwinEvent) .createQueryBuilder('e') .select('e.revision', 'revision') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .orderBy('e.revision', 'DESC') .limit(1) .getRawOne<{ revision: unknown }>(), getRepository(TwinStructure) .createQueryBuilder('s') .select('s.rev', 'rev') .where('s.domain = :domainId', { domainId }) .andWhere('s.instanceId = :instanceId', { instanceId }) .orderBy('s.rev', 'DESC') .limit(1) .getRawOne<{ rev: unknown }>() ]) const revision = Number(tip?.revision) const structureRev = Number(newest?.rev) return { revision: Number.isFinite(revision) ? revision : 0, structureRev: Number.isFinite(structureRev) ? structureRev : null } } /** * **저널을 보존 기간까지만 둔다.** * * 지우는 조건은 하나다 — `createdAt` 이 보존 기간보다 오래됐다(**행이 쓰인 실제 시각**이 기준이다). * * **도는 트윈**에서만 조건이 하나 더 붙는다: `revision` 이 체크포인트 리비전 이하여야 한다. 도는 * 트윈은 메모리에서 이어 집계하므로, 체크포인트가 아직 반영하지 못한 사건을 지우면 그 구간이 * 어디에도 없어진다. 멈춘 트윈에는 이어 집계할 메모리가 없어서 이 조건이 없다. * * ── 왜 `eventTime` 이 아니라 `createdAt` 인가 (2026-08-22 실측으로 고침) ──── * 처음에 `eventTime` 으로 적었다. 그것은 **트윈의 시계**다 — 시뮬레이션은 자기 시계로 사건을 찍고, * 그 시계는 실제 시각과 무관하게 앞서거나 뒤선다. 실측: * * order-check 가장 늦은 eventTime = 2027-06-08 ← 실제 시각보다 10개월 앞 * hatio-mx2 2026-01-01 ~ 지금 ← 7개월치를 한 번에 지우게 된다 * * 즉 「7일」이 트윈마다 다른 뜻이 된다: 시계가 앞선 트윈은 영원히 지워지지 않고, 과거부터 찍은 트윈은 * 거의 전부가 한 번에 지워진다. **보존은 저장 나이의 문제**이고 저장 나이는 실제 시계다. * * `createdAt` 은 그 행이 DB 에 쓰인 시각이고 1,599만 행 전부 채워져 있다(확인함). 도메인 시각을 * 정책에 쓰지 않는다 — 그 둘을 섞으면 시뮬과 미러에서 같은 설정이 다르게 동작한다. * * ── 왜 체크포인트를 전제 조건으로 두지 않나 (2026-08-27) ─────────────────── * 예전에는 체크포인트가 없으면 그 트윈을 건드리지 않았다. 「저널이 유일한 복구 수단」이라는 전제 * 때문이었고, 그 전제가 틀렸다. 트윈이 자기 저널과 어떤 관계인지는 `restartPolicy` 가 선언한다 — * `resync` 는 연결된 시스템에서 다시 읽고, `reset` 은 씨앗부터 돌리고, `resume` 은 보관된 구간에서 * 세운다. 셋 다 보관 기간 밖의 사건을 요구하지 않는다. * * 보존 기간을 7일로 선언한 것이 곧 「7일 전 상태로는 세우지 않는다」는 뜻이다. 그 전제를 지키느라 * 지우지 않으면, 멈춘 트윈의 저널이 영구히 남는다 — 실측으로 세 트윈에 2,900만 행이 그렇게 남았다. * * 「지울 수 없었다」는 사실은 그대로 센다(도는데 체크포인트가 아직 없는 경우) — 알리지 않고 넘기면 * 「보존이 도는데 왜 안 줄어드나」가 된다. * * `createdAt` 이 빈 옛 행은 **지우지 않는다**(시각을 모르는 것을 「오래됐다」로 읽지 않는다). * * 드라이버 다섯을 다 지나야 하므로 raw SQL 을 쓰지 않는다 — 조건 삭제는 쿼리빌더가 이식한다. */ static async pruneJournal(domainId: string): Promise<{ deleted: number; instances: number }> { /* 도메인의 정책이 먼저다(§`retentionDaysOf`). 없으면 프로세스 기본값. 둘 다 없으면 지우지 않는다. */ const perDomain = this.retentionDaysOf ? await this.retentionDaysOf(domainId).catch(() => undefined) : undefined const days = perDomain ?? this.JOURNAL_RETENTION_DAYS if (!days || days <= 0) return { deleted: 0, instances: 0 } /* * ── 보존 기간을 스냅샷 TTL 로 올리지 않는다 (2026-08-27) ──────────────────── * * 여기가 `max(보존 기간, 스냅샷 TTL)` 이었다. 둘 다 7일이라 상향은 없었지만, 그 식은 두 값을 * 묶어 놓아서 하나를 줄이면 다른 하나가 따라오게 만든다. 두 값은 다른 것을 정한다. * * 보존 기간 이력을 며칠 두나 * 스냅샷 TTL 빠르게 세우기 위한 저장본이 며칠 사나 * * 저장본이 만료되어도 트윈은 보관된 구간에서 세워진다. 그래서 보존 기간이 더 짧아도 성립한다. */ const effectiveDays = days const cutoff = new Date(Date.now() - effectiveDays * 86400 * 1000) const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } }) let deleted = 0 let instances = 0 for (const r of rows) { /* * ── 체크포인트는 **상한**이지 전제 조건이 아니다 (2026-08-27 사용자 지시로 고침) ── * * 여기가 체크포인트가 없으면 건너뛰었다. 그 규칙은 「저널이 유일한 복구 수단」이라는 전제에서 * 나왔는데, 그 전제가 틀렸다. 트윈이 자기 저널과 어떤 관계인지는 `restartPolicy` 가 선언한다. * * resync 연결된 시스템에서 다시 읽는다 저널은 복구 수단이 아니다 * reset 씨앗부터 다시 돌린다 저널은 복구 수단이 아니다 * resume 저널을 이어 세운다 보관된 구간에서 세운다 * * 셋 다 보관 기간 밖의 사건을 요구하지 않는다. 보관 기간 7일이라는 선언이 곧 「7일 전 상태로는 * 세우지 않는다」는 뜻이다. 그래서 정리는 체크포인트 없이도 지운다. * * 체크포인트가 하는 일은 하나 남는다. 체크포인트가 **있으면** 그 리비전을 상한으로 쓴다 — 다음 * 기동이 그 지점에서 이어 집계하므로, 그 뒤의 사건을 지우면 그 구간이 어디에도 없어진다. * * **없으면 상한도 없다.** 이어 집계할 지점이 없으니 지킬 것도 없고, 보관 기간 조건만으로 지운다. * 없다는 이유로 건너뛰면 멈춘 트윈은 영구히 줄지 않는다(실측: 세 트윈에 2,900만 행). */ const snap = await this.loadSnapshot(domainId, r.instanceId).catch(() => null) const at = Number(snap?.revision ?? 0) const upTo = at > 0 ? at : Number.MAX_SAFE_INTEGER /* * ── 나눠서 지운다 (2026-08-25 실측으로 고침) ──────────────────────────────── * * 여기가 **한 문장으로 통째** 지웠다. 지울 것이 며칠치면 그 한 문장이 수백만 행이 되고, sqlite 는 * 쓰기가 하나이므로 그동안 호스트가 아무 일도 못 한다. 실측: 한 번의 지우기가 74초·79초였고, * 그 뒤에 한 줄짜리 `domains` 조회가 67초로 찍혔다 — 그 질의의 문제가 아니라 줄을 선 값이다. * 개발 환경에서 보관 기간 7일에 13일치가 쌓여 있었고, 그 밀린 몫이 한 번에 나왔다. * * 그래서 **한 번에 지우는 수를 묶고**, 배치 사이에 루프를 비워 준다. 그리고 한 창에서 쓰는 * 시간에도 상한을 둔다 — 남은 것은 다음 창이 이어서 지운다. 지우는 총량은 같고, 그 사이에 * 사람의 요청이 처리된다. * * `DELETE … LIMIT` 은 드라이버마다 다르므로 쓰지 않는다(sqlite 는 기본 빌드에서 지원하지 않는다). * 대신 **경계 리비전을 먼저 찾아** 그 아래만 지운다 — 다섯 드라이버에서 같은 뜻이 되는 방법이다. */ let n = 0 const startedMs = Date.now() for (;;) { /* * ── 경계를 **시각**으로 찾는다 (2026-08-27 실측으로 고침) ────────────────── * * 여기가 `ORDER BY revision` 이었다. 그러면 계획기가 리비전 색인을 고르고, 지울 대상인지는 * 행마다 `created_at` 을 열어 확인한다. 지울 것이 적으면 그 트윈의 저널을 끝까지 걸어야 * 「한 배치를 못 채웠다」를 알 수 있다 — 1,029만 행에서 그것이 2초 예산을 다 썼고 44건만 * 지우고 멈췄다. * * 시각순으로 물으면 `(domain, instance, created_at)` 색인을 그대로 쓴다. 실측 계획: * * ORDER BY revision SEARCH USING INDEX ix_twin_event_0 ← created_at 을 행마다 확인 * ORDER BY createdAt SEARCH USING COVERING INDEX ix_twin_event_8 ← 테이블을 열지 않는다 * * 지우기는 정렬이 없어 색인만 있으면 바로 그것을 쓴다(실측 확인). */ const edge = await getRepository(TwinEvent) .createQueryBuilder('e') .select('e.createdAt', 'createdAt') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId: r.instanceId }) .andWhere('e.createdAt IS NOT NULL') .andWhere('e.createdAt < :cutoff', { cutoff }) .orderBy('e.createdAt', 'ASC') .offset(this.JOURNAL_PRUNE_BATCH - 1) .limit(1) .getRawOne<{ createdAt: unknown }>() /* * 경계가 없으면 남은 것이 한 배치 안이다 — 그때는 보관 기간 경계까지 지우고 끝난다. * * 경계가 있으면 그 시각 **이하**까지 지운다(`<=`). 같은 시각의 행이 여럿이면 한 배치가 조금 * 커지지만, `<` 로 두면 그 시각의 행들이 남아 다음 배치가 같은 경계를 다시 찾는다(제자리걸음). */ /* * 원시 결과의 컬럼 값은 **드라이버에게 해석시킨다**(§`hydratedDate`). sqlite 가 주는 값에는 * 시간대가 없어서 그대로 `new Date()` 로 읽으면 지역 시간으로 해석된다 — 경계가 그만큼 이르게 * 밀리고, 그러면 **한 건도 지우지 못해 정리가 그대로 멈춘다**(0건이면 반복을 끝낸다). */ const batchCutoff = hydratedDate(getRepository(TwinEvent), 'createdAt', edge?.createdAt) const del = getRepository(TwinEvent) .createQueryBuilder() .delete() .from(TwinEvent) .where('domain_id = :domainId', { domainId }) .andWhere('instance_id = :instanceId', { instanceId: r.instanceId }) /* 체크포인트가 아직 반영하지 못한 사건은 지우지 않는다(도는 트윈에서만 상한이 걸린다). */ .andWhere('revision <= :upTo', { upTo }) .andWhere('created_at IS NOT NULL') const res = await (batchCutoff ? del.andWhere('created_at <= :batchCutoff', { batchCutoff }) : del.andWhere('created_at < :cutoff', { cutoff }) ).execute() const batch = res.affected ?? 0 n += batch if (!batch) break if (Date.now() - startedMs >= this.JOURNAL_PRUNE_BUDGET_MS) { twinLog( `[twin-engine] journal prune paused "${r.instanceId}" — 이번 창에서 ${n}건까지 지웠다. ` + '남은 것은 다음 창이 이어서 지운다(호스트가 그 사이에 요청을 처리한다).' ) break } /* 루프를 비워 준다 — 이 한 줄이 없으면 배치로 나눈 뜻이 없다(같은 틱에서 계속 지운다). */ await new Promise(resolve => setImmediate(resolve)) } if (n > 0) { deleted += n instances++ /* **지운 것을 말한다** — 알리지 않고 줄어든 저널은 「없었던 일」과 구별되지 않는다. */ twinLog( `[twin-engine] journal pruned "${r.instanceId}" — ${n}건 (${cutoff.toISOString()} 이전` + `${upTo < Number.MAX_SAFE_INTEGER ? ` · revision ≤ ${upTo} 까지만, 그 뒤는 체크포인트가 대신하지 못한다` : ''}).` ) } /* * ── 조회용 파생 표도 같은 창에서 줄인다 (2026-08-27) ──────────────────────── * * 이 표는 **버릴 수 있는 값**이다(사건에서 파생된 것이므로). 지우면 그 구간의 이력 조회가 저널로 * 넘어가 느려지고, 사실은 사라지지 않는다. * * 그래서 체크포인트 상한을 보지 않는다 — 그 상한은 「사실을 잃지 않기 위한 것」이고 여기에는 잃을 * 사실이 없다. 보관 기간만 본다. */ const subjectPruned = await pruneSubjectRows( domainId, r.instanceId, cutoff, this.JOURNAL_PRUNE_BUDGET_MS, this.JOURNAL_PRUNE_BATCH ).catch(() => 0) if (subjectPruned > 0) { twinLog(`[twin-subject] pruned "${r.instanceId}" — ${subjectPruned}줄 (${cutoff.toISOString()} 이전).`) } } return { deleted, instances } } /** * 한 번의 지우기가 다루는 행 수 — 쓰기 잠금을 이 만큼만 잡는다. * * ── 20,000 이 왜 컸나 (2026-08-28 실측) ──────────────────────────────────── * 앞 버전은 20,000 이었고 근거는 「한 문장으로 수백만 행을 지우면 74초였다」였다. 그것은 **더 나쁜 * 쪽과 견준 수**이고, 20,000 자체를 잰 것이 아니었다. 실제로 재 보니 이렇다(서버 기록): * * hatio-mx1 20,123줄 5.6초 * hatio-mx2 20,130줄 5.2초 * hatio-us 20,139줄 4.2초 * 한 창의 합계 약 15초 * * sqlite 는 연결이 하나다. 그래서 그 4~5초 동안 **화면의 모든 질의가 줄을 선다** — 실측으로 첫 * 화면 조회가 12.2초였다(정상 상태에서는 40밀리초다). * * 그리고 예산(`JOURNAL_PRUNE_BUDGET_MS` = 2초)이 **문장이 끝난 뒤에** 검사되므로, 한 문장이 예산을 * 2~3배 넘긴다. 배치를 나눈 뜻이 그만큼 없어진다. * * ── 2,000 의 근거 ────────────────────────────────────────────────────────── * 위 실측이 줄당 약 0.26밀리초다(5.2초 / 20,130줄). 2,000줄이면 **한 문장이 약 0.5초**다. 화면이 * 한 번 멈추는 길이가 5초에서 0.5초로 줄고, 예산 검사도 뜻을 갖는다(한 창에 문장 넷). * * 총량은 같고 나눠서 치른다 — 창마다 지우는 수가 줄지만 지우는 일은 급하지 않다(창이 10분마다 온다). * * 값을 바꾸려면 다시 재고 이 문장을 고칠 것. */ static JOURNAL_PRUNE_BATCH = 2_000 /** * 한 창에서 지우는 데 쓰는 시간 상한(ms) — 남은 것은 다음 창이 이어서 지운다. * * 밀린 몫이 며칠치면 한 창에서 다 지울 수 없다. 다 지우려 들면 그동안 호스트가 멈추고, 그것이 바로 * 고치려는 증상이다. 총량은 같고 나눠서 치른다. * * ── 2초에서 5초로 올린 이유 (2026-08-28) ─────────────────────────────────── * 배치를 20,000 → 2,000 으로 줄였다(§`JOURNAL_PRUNE_BATCH`). 예산을 그대로 두면 **창마다 지우는 * 양이 2.5배 줄어** 밀린 몫이 빠지지 않는다. * * 예산을 5초로 두면 한 창에 문장 열 개(각 0.5초)가 돌아 **지우는 양은 전과 같고**, 잠금은 열 번 * 나뉘어 그 사이에 화면 질의가 처리된다. 고치려던 것은 총 시간이 아니라 **한 번에 멈추는 길이**다. * * 전 한 문장 5.2초 · 그동안 모든 조회가 줄을 선다 * 후 문장 열 개 × 0.5초 · 사이마다 루프를 비워 준다 */ static JOURNAL_PRUNE_BUDGET_MS = 5_000 /** 보존 정리 주기 기동(1회) — 선언이 없으면 아무것도 하지 않는다. */ static startRetentionLoop(domainId: string): void { if (this.retentionTimer) return /* * 걸지 않는 조건: 프로세스 기본값도 없고 **도메인에 물을 길도 없을** 때다. 시임이 심겨 있으면 * 기본값이 없어도 걸어야 한다 — 그 도메인이 자기 값을 가질 수 있다. */ if (!this.JOURNAL_RETENTION_DAYS && !this.retentionDaysOf) return this.retentionTimer = setInterval(() => { this.pruneJournal(domainId).catch(err => twinError('[twin-engine] journal prune failed', err?.message ?? err)) }, this.RETENTION_SWEEP_MS) if (typeof this.retentionTimer?.unref === 'function') this.retentionTimer.unref() /* * 두 상태를 **다른 문장으로** 말한다. * * 앞서는 한 문장에 `${일수 ?? '(도메인 정책)'}일` 로 끼워 넣었고, 기본값이 없는 배포에서 * 「(도메인 정책)일」이 찍혔다(2026-09-09 실측). 뜻이 없는 문장이고, 읽는 사람이 **며칠인지 * 적혀 있는데 못 읽는 것**으로 오해한다. * * 수가 들어갈 자리에 수가 아닌 것을 끼우면 그 자리는 값이 아니라 구멍이다. 상태가 둘이면 * 문장도 둘이다. */ twinLog( this.JOURNAL_RETENTION_DAYS ? `[twin-engine] journal retention on — ${this.JOURNAL_RETENTION_DAYS}일 · ${this.RETENTION_SWEEP_MS / 60000}분마다` : `[twin-engine] journal retention on — 보존 기간은 도메인마다 정한다(프로세스 기본값 없음) · ` + `${this.RETENTION_SWEEP_MS / 60000}분마다` ) } /** 체크포인트 루프 기동(1회) — 라이브 인스턴스들의 최신 스냅샷을 주기 영속. */ static startCheckpointLoop(): void { if (this.checkpointTimer) return this.checkpointTimer = setInterval(() => { for (const [key, inst] of Object.entries(this.instances)) { const { instanceId } = parseRuntimeKey(key) this.persistSnapshot(inst.domainId, instanceId).catch(err => twinError(`[twin-engine] snapshot checkpoint fail "${instanceId}"`, err?.message ?? err) ) } }, this.CHECKPOINT_MS) if (typeof this.checkpointTimer?.unref === 'function') this.checkpointTimer.unref() } /** * 부팅 훅 — DB 에 status='running' 으로 남은 인스턴스를 저널에서 상태 재구성(DB→replay). * 라이브 tick 재개(sim 결정적 re-run / live 인제스트 지속)는 후속. 여기선 상태 복원 + 노출. */ static async bootstrap(): Promise { /* 부팅부터 잰다 — 사람이 가장 답답한 구간이 여기이고, 그 구간을 재지 않으면 값으로 말할 수 없다. */ loopLag.start() try { const repo = getRepository(TwinInstance) const rows = await repo.find({ where: { status: 'running' } }) for (const row of rows) { if (!row.domainId || !row.instanceId) continue // 웜스타트: 캐시된 최신 스냅샷 우선(O(1) + attentions/OEE 등 라이브 파생상태 보존). // 없으면 저널 fold-from-0 replay(진실 폴백 — replay 는 라이브 파생상태를 못 담으므로 캐시가 더 충실). const cached = await this.warmSeedFor(row.domainId, row.instanceId).catch(() => null) if (cached?.state) { this.recovered[runtimeKey(row.domainId, row.instanceId)] = cached /* * **「웜스타트했다」고 말하지 않는다** — 여기서는 상태를 **찾아 둔 것**뿐이다. * * 실제 주입은 기동 때 일어나고(`warmStart` 가 그때 무엇을 심었는지 말한다), 미러(live) 트윈은 * 그 씨앗을 아예 쓰지 않는다(`startLive` 가 버린다). 그런데 이 줄이 「warm-started」라고 말해 * 로그만 읽으면 심긴 줄 알게 된다 — 실제로 그렇게 읽고 재기동 뒤 지속시간이 사라진 것을 * 데이터 문제로 오진할 뻔했다. */ twinLog(`[twin-engine] found cached state for "${row.instanceId}" → revision ${cached.revision} (seeded at start).`) continue } const state = await this.recover(row.domainId, row.instanceId).catch(() => null) if (state) { this.recovered[runtimeKey(row.domainId, row.instanceId)] = { revision: state.revision, state } twinLog(`[twin-engine] recovered "${row.instanceId}" from journal → revision ${state.revision}.`) } } /* * 상태만 되찾는 것으로는 **도는 트윈이 되지 않는다** — 런타임까지 되살린다(아래). * * 되살리기 사이에 **루프를 한 번 비워 준다** (2026-08-21). 웜스타트 하나가 물품 수천 개를 접으므로 * 스무 개를 연달아 하면 그 시간 내내 HTTP 가 서지 않는다 — 서버는 이미 `Server ready` 를 찍은 * 뒤라서, 사람에게는 「열렸는데 아무 반응이 없는 화면」으로 보인다. 총 시간은 같고, 그 사이에 * 도착한 요청이 처리될 틈만 생긴다. */ for (const row of rows) { await this.resumeRow(row) await new Promise(resolve => setTimeout(resolve, this.BOOT_YIELD_MS)) } this.startCheckpointLoop() // 이후 기동되는 라이브 인스턴스의 최신 스냅샷을 주기 영속 /* * 보존 정리 — **선언이 있을 때만** 돈다(§`JOURNAL_RETENTION_DAYS`). 도메인마다 한 번 건다. * 체크포인트 루프 뒤에 두는 이유: 지울 수 있는 경계가 스냅샷이므로, 스냅샷을 남기는 쪽이 먼저 * 돌아야 첫 정리가 실제로 지울 것을 갖는다. */ for (const domainId of new Set(rows.map(r => r.domainId).filter(Boolean) as string[])) { this.startRetentionLoop(domainId) } } catch (err) { twinError('[twin-engine] recovery scan failed', err) } } /** * 부팅 때 **도는 트윈을 실제로 되살린다** — 「도는 중」이 사실이 되게. * * ── 무엇이 거짓말이었나 (2026-08-14) ──────────────────────────────────────── * `bootstrap()` 은 상태만 되찾아 `recovered` 에 담았고, 커널을 세우는 것은 **명시 mutation 뿐**이었다 * (이 파일 위쪽 주석이 「향후」라고 적어 둔 그 자리다). 그래서 서버를 한 번 재기동하면 등록부는 * `running` 이라 말하는데 **아무 커널도 돌지 않았다** — 화면은 도는 트윈을, 실제로는 멈춘 트윈을. * 미러 트윈에서는 더 나쁘다: 계측이 알리지 않고 끊기고, 사람은 「값이 안 변하네」로 알게 된다. * * ── 모드를 지어내지 않는다 ────────────────────────────────────────────────── * 미러였던 트윈을 시뮬로 되살리면 **없던 움직임을 만들어 낸다**(관측 트윈이 스스로 물건을 옮긴다). * 그래서 선언된 `restartPolicy` 그대로 되살린다 — 미러는 관측 구동으로, 시뮬은 시뮬로. * * ── 되살릴 수 없으면 그렇게 적는다 ────────────────────────────────────────── * 실패를 삼키면 등록부가 계속 `running` 이라 말한다 — 우리가 고치려던 그 거짓말이다. 그래서 실패한 * 행은 `stopped` 로 적고 이유를 남긴다. 「멈췄다」는 사실이고, 「도는 중」은 사실이 아니었다. */ private static async resumeRow(row: TwinInstance): Promise { const { domainId, instanceId } = row if (!domainId || !instanceId) return if (this.instances[runtimeKey(domainId, instanceId)]) return // 이미 세워졌다(데모 시드 등) /* * 벤치 사본은 되살리지 않는다 — 그것은 **누군가 지켜보던 실험**이고, 무인으로 되살아나면 호스트를 * 그대로 두들긴다(격리 사본의 뜻은 「운영과 섞이지 않는다」이지 「영원히 돈다」가 아니다). * 실험은 재기동을 넘기지 못했으므로 등록부도 그렇게 적는다. */ if (row.purpose === 'bench') { await this.markStopped(row, 'bench copy — an experiment does not survive a restart unattended') return } try { /* * **선언된 모드로 되살리는 판단은 한 곳에 있다**(`startFromRegistry`) — 2026-08-20. * * 예전에는 이 자리에서 `resync` 를 갈라 `startLive` 를 불렀다. 그래서 부팅으로 살아난 미러는 * 관측 구동이었지만 **사람이 화면에서 시작한 미러는 시뮬로 떴다**(그 문에는 갈림이 없었다). * 같은 선언이 두 결과를 내지 않도록 갈림을 문 안으로 옮겼다. */ await this.startFromRegistry(domainId, instanceId) if (row.restartPolicy === 'resync') { /* 계측을 나르는 피드는 커넥터의 것이다 — 레퍼런스 계층이 부팅 훅에서 다시 붙인다 (`resumeReferenceLiveFeeds`). 여기서 어댑터를 아는 것은 계층을 거꾸로 잇는 것이다. */ twinLog(`[twin-engine] resumed mirror "${instanceId}" — feed reattach is the reference layer's job.`) } else { twinLog(`[twin-engine] resumed ${row.restartPolicy} "${instanceId}".`) } } catch (err: any) { await this.markStopped(row, err?.message ?? 'resume failed') } } /** 되살리지 못한 행을 정직하게 적는다 — 「도는 중」이라 말하는 채로 두지 않는다. */ private static async markStopped(row: TwinInstance, why: string): Promise { twinWarn(`[twin-engine] "${row.instanceId}" not resumed (${why}) — registry says stopped now.`) try { await getRepository(TwinInstance).update({ id: row.id }, { status: 'stopped' }) } catch (e: any) { twinError(`[twin-engine] could not mark "${row.instanceId}" stopped — registry now lies about it.`, e?.message) } } /** * 웜스타트 — 기동하는 커널에 **직전 관측 상태**를 주입한다. * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * `loadTwinModel` 는 **구조만** 싣는다(자리·설비). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한 * 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고 * 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인). * `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데, * 기동 순간 그걸 **버리고** 있었다. 반만 연결돼 있던 장치를 잇는다. * * ── 반쪽이던 복구를 마무리한다 (2026-08-05) ───────────────────────────────── * 재고는 살아났는데 **진행 중 주문이 통째로 사라진** 화면이 남아 있었다. 씨앗이 자리·물품·설비 * 셋만 넘겼기 때문이다. 그 근거는 "스냅샷 오더에 progress 밖에 없다" 였고, 그때는 맞았다 — * 그러나 커널 `OrderState` 가 원값(`requested`·`fulfilled`·`lines`)을 되찾은 뒤에도 이 자리만 * 그대로 남았다. 오류를 내지 않는 종류라 오래 버텼다. * * 지금은 **관측 스냅샷 전체**를 넘긴다(오더·작업·사람·자산 포함). 시뮬은 커널 `snapshot()`, * 라이브는 `StateProjector` 가 그 일곱 축을 모두 담는다. 무엇을 심을 수 있는지 판정하는 규칙 * (이행 완료 오더 제외·고아 작업 제외)은 커널에 있고, 호스트가 미리 골라내지 않는다. * * ── 남은 한계는 숨기지 않고 센다 ──────────────────────────────────────────── * · 원값이 없는 오더(progress 만 있는 것)는 남은 수량을 알 수 없어 주입하지 않는다. **지어내지 않는 * 대신 몇 건인지 말한다** — 세지 않으면 "주문이 없다" 와 "주문을 못 심었다" 가 화면에서 같아진다. * · 진행 중 개별 작업의 내부 상태는 관측만으로 완전히 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김). * * ── 벤치는 시드하지 않는다 ────────────────────────────────────────────────── * 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 주입하면 측정이 오염된다. */ private static warmStart(domainId: string, id: string, kernel: TwinKernel, purpose?: string): void { const hydrate = (kernel as any).hydrateObserved const plan = planWarmStart(this.recovered[runtimeKey(domainId, id)]?.state, purpose, typeof hydrate === 'function') if (plan.action === 'skip') { if (plan.reason === 'bench') { twinLog(`[twin-engine] "${id}" is a bench twin — starting empty on purpose (seeding would skew the measurement).`) } else if (plan.reason === 'unsupported') { twinWarn( `[twin-engine] kernel for "${id}" cannot be warm-started (no hydrateObserved) — it starts with structure only, so held stock will read as zero.` ) } return } hydrate.call(kernel, plan.seed) const restored = [ `${plan.itemCount} item(s)`, `${plan.equipmentCount} equipment`, `${plan.orderCount} open order(s)`, ...(plan.foldedOrderCount ? [`${plan.foldedOrderCount} folded finished order(s)`] : []), `${plan.taskCount} task(s)`, ...(plan.personCount ? [`${plan.personCount} person(s)`] : []), ...(plan.assetCount ? [`${plan.assetCount} asset(s)`] : []), /* 확인해 둔 신호를 이어받았다는 사실도 말한다 — 잃으면 확인 처리가 다시 빨개지는 것으로 보인다. */ ...(plan.ackedCount ? [`${plan.ackedCount} acknowledged attention(s)`] : []), /* 「언제부터인가」도 말한다 — 잃으면 지속된 조건이 모두 「방금」으로 보인다. */ ...(plan.attentionSinceCount ? [`${plan.attentionSinceCount} attention start time(s)`] : []) ].join(', ') twinLog(`[twin-engine] warm-started "${id}" — restored ${restored}.`) /* 뺀 것은 알리지 않고 넘기지 않는다 — 지어내지 않았다는 사실 자체를 말해야 화면의 빈칸이 읽힌다. */ if (plan.ordersWithoutDemand > 0) { twinWarn( `[twin-engine] "${id}": ${plan.ordersWithoutDemand} order(s) could not be restored — they carry progress only, ` + 'with no requested/fulfilled counts, so the remaining demand is unknown. They are left out rather than guessed.' ) } } /** 트윈 인스턴스 시작 — 커널 생성 + 모델 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */ /** * 공정 명세를 커널에 싣는다 — **시뮬레이션의 시간을 데이터가 말하게 하는 마지막 한 칸.** * * `model.operations`(마스터 인제스트가 통과시킨 ISA-95 OperationsSegment 명세)를 커널이 소비한다. * 없으면 커널 기본 상수로 굴러가고, 커널 `specCoverage()` 가 무엇을 기본값으로 썼는지 보고한다. * * 커널이 아직 이 API 를 갖지 않은 버전이면(발행 이전) **알리지 않고 넘어가지 않고 경고한다** — 명세를 * 선언했는데 반영되지 않는 상태를 모르고 지나가면, 예측이 상수로 돌아간 것을 아무도 알 수 없다. */ private static applyOperations(kernel: any, model: any, id: string): void { /* * 현장이 정한 공정 시간 — 명세 행과 **따로** 싣는다(`declareDurations`). * * 행이 없는 종류에도 시간을 줄 수 있어야 한다: 실측으로 창고·야드 트윈에는 공정 명세 행이 0개였고, * 그 트윈들의 시간은 전부 커널 상수에서 왔다. 행을 지어 만드는 길은 막았다(`label`·`intent` 를 * 지어내면 능력 계산까지 오염된다) — 그래서 시간만 받는 창구가 커널에 있다. */ const declared = model?.localDurations as Record | undefined if (declared && Object.keys(declared).length) { if (typeof kernel?.declareDurations !== 'function') { twinWarn( `[twin-engine] "${id}": this facility declared ${Object.keys(declared).length} operation duration(s) but the kernel cannot consume them ` + '(declareDurations missing — kernel needs publishing). The simulation keeps running on built-in constants, and specCoverage() will keep reporting "default".' ) } else { try { kernel.declareDurations(declared) } catch (err: any) { /* 커널이 거절한 값은 알리지 않고 넘기지 않는다 — 화면은 「넣었습니다」라고 말한 값이다. */ twinWarn(`[twin-engine] "${id}": declared operation duration rejected by the kernel — ${err?.message ?? err}`) } } } /* 공정 모수(수율·셋업)도 같은 규율로 싣는다 — 커널이 그 창구를 갖지 않으면 그 사실을 말한다. */ const params = model?.localParams as Record> | undefined if (params && Object.keys(params).length) { if (typeof kernel?.declareParameters !== 'function') { twinWarn( `[twin-engine] "${id}": this facility declared operation parameters for ${Object.keys(params).length} operation(s) but the kernel cannot consume them ` + '(declareParameters missing — kernel needs publishing). Yield and setup keep running on built-in constants.' ) } else { try { kernel.declareParameters(params) } catch (err: any) { twinWarn(`[twin-engine] "${id}": declared operation parameter rejected by the kernel — ${err?.message ?? err}`) } } } const ops = model?.operations if (!ops?.length) return if (typeof kernel?.loadOperations !== 'function') { twinWarn(`[twin-engine] "${id}": model declares ${ops.length} operation spec(s) but the kernel cannot consume them (loadOperations missing — kernel needs publishing). Simulation will use built-in default durations.`) return } kernel.loadOperations(ops) } /** * 소요시간 추정기 주입 — **상수를 데이터로 바꾸는 두 번째·세 번째 층.** * * 커널 `durationOf` 의 우선순위는 추정기 > 명세 > 상수다. 그 추정기 자리에 두 가지를 사슬로 넣는다: * ① **실측**(저널의 작업 종류별 작업시간 p50) — 그 현장에서 실제로 얼마 걸렸나. 가장 강한 근거. * ② **거리 × 속도**(model.layout + 설비 속도 속성) — 이동은 거리에 비례한다. 커널은 좌표를 모르므로 * 호스트가 계산해 넣는다. * 둘 다 못 만들면 주입하지 않는다 — 커널이 명세·상수로 굴러가고 `specCoverage()` 가 그 사실을 남긴다. * * 실측은 DB 조회라 비동기다. 그래서 이 함수는 **await 하지 않는 쪽에서도 안전**하도록 실패를 삼키되, * 무엇을 왜 못 넣었는지는 로그로 남긴다(들어온 것이 없는 무효화 금지). */ static async installEstimators(kernel: any, domainId: string, instanceId: string, model: any): Promise { if (!kernel || typeof kernel !== 'object') return const travel = buildTravelEstimator({ layout: model?.layout, equipment: model?.equipment, unit: model?.unit }) const measured = await this.measuredEstimator(domainId, instanceId) const chained = chainEstimators([measured?.estimator, travel.estimator]) if (!chained) { /* 왜 못 넣었는지 한 번만 알린다 — 이동시간이 상수로 남은 이유를 모르고 지나가지 않게. */ if (travel.reasons.length) twinWarn(`[twin-engine] "${instanceId}": no duration estimator installed — ${travel.reasons.join('; ')}`) return } kernel.durationEstimator = chained /* * **양품률도 이력에서 배운다** (2026-08-19) — 소요와 같은 자리에서 붙인다. * * 커널이 그 시임을 갖지 않은 버전이면(발행 이전) 알리지 않고 넘어가지 않고 말한다: 수율이 상수로 남은 * 이유를 모르고 지나가면, 화면의 불량 판정이 그 현장의 사실이 아니라 우리 상수의 결과다. */ const yields = await this.measuredYield(domainId, instanceId) if (yields?.estimator) { if (typeof kernel.yieldOf !== 'function') { twinWarn( `[twin-engine] "${instanceId}": learned yield for ${Object.keys(yields.learned).length} operation kind(s) but the kernel cannot consume it ` + '(yieldOf missing — kernel needs publishing). Yield keeps running on the declared value or the built-in constant.' ) } else { kernel.yieldEstimator = yields.estimator twinLog( `[twin-engine] "${instanceId}": measured yield installed — ${Object.entries(yields.learned) .map(([k, v]) => `${k}=${Math.round(v * 1000) / 10}%(${yields.samples[k].good + yields.samples[k].scrap}건)`) .join(', ')}${yields.skipped.length ? ` · 표본 부족으로 뺀 종류: ${yields.skipped.map(s => `${s.kind}(${s.judged})`).join(', ')}` : ''}` ) } } else if (yields?.skipped.length) { /* 배운 것이 없고 버린 것만 있으면 그 사실도 말한다 — 「이력이 없다」와 「표본이 모자라다」는 다르다. */ twinLog( `[twin-engine] "${instanceId}": no measured yield yet — samples below ${yields.minSamples}: ${yields.skipped .map(s => `${s.kind}(${s.judged})`) .join(', ')}` ) } const learned = Object.keys(measured?.learned ?? {}) const spread = Object.keys(measured?.spreads ?? {}) twinLog( `[twin-engine] "${instanceId}": duration estimator installed — measured kinds: ${learned.length ? learned.join(',') : 'none'}` + ` (with observed spread: ${spread.length ? spread.join(',') : 'none'})` + `${travel.estimator ? `, travel from distance (speeds: ${Object.keys(travel.speedsByKind).join(',')})` : `, travel not derived (${travel.reasons.join('; ')})`}` ) } /** * 실측 추정기 — **예측 요청마다 저널을 다시 계산하지 않는다.** * * 예측 커널은 요청마다 새로 세워지고(미러 예측·백테스트), 화면은 시각을 긁으면 계속 재예측한다. * 거기에 KPI 조회를 그대로 달면 요청당 저널 스캔이 하나씩 붙는다 — 실측은 분 단위로 바뀌지 않으므로 * 짧은 TTL 로 재사용한다. 캐시는 인스턴스별이고, 만료 전에는 같은 값을 쓴다(예측 간 일관성도 얻는다). */ private static measuredCache = new Map< string, { at: number value: ReturnType | undefined /** 같은 폴드에서 나온 양품률 — **저널을 두 번 계산하지 않는다**(소요와 수율은 같은 창의 같은 사실이다). */ yields?: ReturnType } >() private static readonly MEASURED_TTL_MS = 60_000 /** * 캐시 항목 상한 — **라이프사이클이 놓친 것까지 막는 두 번째 방어.** * * 지움은 `forgetInstance` 가 한다(그것이 첫 번째 방어이고 정확한 쪽이다). 그런데 트윈이 우리 API 를 * 지나지 않고 사라지는 길이 있다: 도메인(테넌트)째로 지워지거나 DB 를 직접 손대는 경우다. 그때 * 남은 항목을 지울 주인이 없으므로 상한이 필요하다. * * 넘치면 **가장 오래 손대지 않은 것**부터 버린다(Map 의 삽입 순서가 곧 그 순서다 — 값을 쓸 때마다 * 다시 넣으므로). 버리는 것이 손해가 아닌 이유: 이 값은 캐시이고, 없으면 다시 계산한다. * * 수를 크게 잡는다 — 트윈 규모는 늘 크고, 항목 하나는 작업 종류별 소요 몇 줄이다. 상한이 작으면 * 정상 규모에서 서로 밀어내며 캐시가 무의미해진다(그게 더 나쁘다: 알리지 않고 느려진다). */ private static readonly MEASURED_MAX = 5_000 /** 이 트윈이 이력에서 배운 양품률 — 소요와 **같은 폴드·같은 캐시**에서 온다(저널을 두 번 계산하지 않는다). */ private static async measuredYield(domainId: string, instanceId: string) { await this.measuredEstimator(domainId, instanceId) return this.measuredCache.get(runtimeKey(domainId, instanceId))?.yields } private static async measuredEstimator(domainId: string, instanceId: string) { /* 키는 `runtimeKey` 하나로 — 손으로 조립하면 지우는 쪽과 어긋나 못 지우는 항목이 생긴다. */ const key = runtimeKey(domainId, instanceId) const hit = this.measuredCache.get(key) if (hit && Date.now() - hit.at < this.MEASURED_TTL_MS) return hit.value let value: ReturnType | undefined let yields: ReturnType | undefined try { /* 작업 종류별 실측 — 창은 넉넉히(하루) 두고 표본이 모자란 종류는 추정기가 스스로 뺀다. */ const kpi: any = await computeTwinKpi({ domainId, instanceId, windowMinutes: 24 * 60, groupBy: 'taskKind' }) value = buildMeasuredEstimator(kpi?.groups?.items, {}) /* 같은 그룹에서 양품률도 배운다 — 한 번 계산한 저널을 둘이 나눠 쓴다. */ yields = buildYieldEstimator(kpi?.groups?.items, {}) } catch (err) { twinWarn(`[twin-engine] "${instanceId}": measured duration lookup failed — falling back to declared/default durations.`, (err as any)?.message) } /* 다시 넣어 **최근 쓴 것**으로 만든다 — 삽입 순서가 곧 버릴 순서이므로 이 한 줄이 LRU 를 만든다. */ this.measuredCache.delete(key) this.measuredCache.set(key, { at: Date.now(), value, yields }) if (this.measuredCache.size > this.MEASURED_MAX) { const oldest = this.measuredCache.keys().next() if (!oldest.done) this.measuredCache.delete(oldest.value) } return value } /** * **원본이 선언한 자극을 싣는다** — 재기동해도 살아 있게 (2026-08-19). * * ── 무엇이 났나 ──────────────────────────────────────────────────────────── * 데모의 시나리오는 시드 코드가 메모리에만 실었다. 그래서 트윈을 재기동하면 자극이 사라지고 구조만 * 서 있는 트윈이 남았다(작업 0·오더 0) — 「살아 있는 데모」가 **첫 재기동까지만** 살았다. * * 자극의 집은 **원본**이다(`TwinReference.connectionConfig.scenario`, ADR-0029 · * `plans/simulator-as-source.md` §4): 무엇이 들어오고 무슨 주문이 나는지는 시뮬레이터가 정하는 사실이고 * 트윈은 반영한다. 트윈에 새 축을 만들면 ADR-0029 가 걷어내야 할 표면이 하나 늘어난다. * * 판정은 순수 함수가 한다(`planStimulus`) — 미러에 싣지 않고, 검사를 통과하지 못한 선언은 태우지 않고 * (그 이력이 있다: 잘못된 선언 하나가 다음 틱에서 서버를 내렸다), 못 실은 이유는 **말한다.** */ private static async installStimulus(domainId: string, instanceId: string, inst: InstanceRuntime): Promise { let config: any try { const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } }) config = ref?.connectionConfig } catch (err: any) { twinWarn(`[twin-engine] "${instanceId}": could not read the declared stimulus — ${err?.message ?? err}`) return } const plan = planStimulus(config, { hasScenarioEngine: !!inst.runtime?.scenario, mode: inst.mode }, validateScenario as any) if (plan.action === 'skip') { /* 선언이 없는 것은 정상이므로 알리지 않고 지난다. 나머지 셋은 **말한다** — 선언했는데 안 실린 상태다. */ if (plan.reason !== 'none') { twinWarn( `[twin-engine] "${instanceId}": a stimulus is declared on its source but was not loaded (${plan.reason}${plan.detail ? `: ${plan.detail}` : ''}).` + (plan.reason === 'observed' ? ' This twin runs on observation — we do not manufacture arrivals for it.' : '') ) } return } try { /* 자극을 싣고 시작하는 데 든 시간 — 실측에서 큰 트윈은 이 구간이 29~72초였다. */ const tStim = performance.now() inst.runtime!.scenario.load(plan.scenario) inst.runtime!.scenario.start() const stimMs = performance.now() - tStim recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'stimulus', stimMs) const kinds = (plan.scenario.generators ?? []).map((g: any) => g?.kind).filter(Boolean) twinLog( `[twin-engine] "${instanceId}": stimulus from its source started in ${Math.round(stimMs)}ms — ${kinds.length ? kinds.join(', ') : 'no generators'}.` ) } catch (err: any) { twinWarn(`[twin-engine] "${instanceId}": the declared stimulus was rejected at load — ${err?.message ?? err}`) } } /** * 자극을 **원본에 선언한다** — 프로비저닝·데모 시드가 부르는 문. * * 트윈이 아니라 원본에 적는 이유는 위와 같다(ADR-0029). 원본 행이 없으면 만들지 않는다 — 어떤 원본에서 * 온 트윈인지 모르는 채 자극을 지어 붙이면, 그 자극이 어디서 왔는지 아무도 되짚을 수 없다. */ static async declareStimulus(domainId: string, instanceId: string, scenario: any): Promise { const repo = getRepository(TwinReference) const ref = await repo.findOne({ where: { domain: { id: domainId }, source: instanceId } }) if (!ref) { twinWarn(`[twin-engine] "${instanceId}": no source reference — a stimulus has nowhere to be declared.`) return false } ref.connectionConfig = withStimulus(ref.connectionConfig, scenario) await repo.save(ref) return true } /** * 이 트윈이 **이력에서 시간을 배운 작업 종류들** — 재기동에도 남는 근거. * * ── 왜 필요한가 (2026-08-18 실측) ──────────────────────────────────────── * 커널의 자기보고(`specCoverage()`)는 **작업이 새로 생길 때** 채워진다. 그래서 재기동 직후에는 이력이 * 풍부한 트윈에서도 목록이 비고, 「이 트윈이 무슨 공정을 돌리나」 화면이 빈칸이 된다 — 값을 채울 곳을 * 보여야 하는 화면이 정작 그때 아무 말도 못 한다. * * 그 빈칸을 이력로 메운다: 저널에서 배운 종류는 **추정기가 이미 답할 수 있는 종류**이므로, 지어내는 * 것이 아니라 있는 사실을 꺼내는 것이다. 같은 캐시(60초)를 쓰므로 조회마다 저널을 다시 계산하지 않는다. */ static async measuredOperationKinds(domainId: string, instanceId: string): Promise { const measured = await this.measuredEstimator(domainId, instanceId) return Object.keys((measured as any)?.learned ?? {}) } /** * 지금 이 트윈이 어떤 모델로 굴러가는가 — 예측 출력 보정이 **자기가 배운 모델**에만 적용되도록 * 비교하는 지문. 재료는 호스트가 아는 것(실측·속도·선언 명세)이라 커널 발행 상태와 무관하다. * 실패하면 undefined — 그때는 비교를 하지 않는다(모르는 것을 근거로 보정을 끊지 않는다). */ static async modelBasis(domainId: string, instanceId: string): Promise { try { const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) const model: any = reg?.model const measured = await this.measuredEstimator(domainId, instanceId) const travel = buildTravelEstimator({ layout: model?.layout, equipment: model?.equipment, unit: model?.unit }) return { measured: measured?.learned, speeds: travel.speedsByKind, declared: model?.operations } } catch { return undefined } } /** * **이 트윈이 하루 몇 대를 낼 수 있는가** — 실행해 보지 않고 답한다. * * 계산은 커널이 자기 상태에서 한다(`kernel.capacity`). 여기서 하는 일은 **기준 주를 정해 주는 * 것**뿐이다: 공휴일이 없는 평상주여야 한다 — 공휴일은 연간 가용량을 따로 깎지, 이 공장의 평상시 * 상한을 정하지 않는다. 커널이 임의로 고르게 두면 그 주에 공휴일이 끼었을 때 상한이 알리지 않고 낮아진다. * * 트윈이 기동 중이 아니면 `undefined` 다 — 0 이 아니다. 기동하지 않은 트윈의 상한을 0 이라고 답하면 화면은 * "이 공장은 아무것도 못 만든다" 고 말한다. */ static async capacity( domainId: string, target: { instanceId?: string; spaceId?: string }, unitsPerDay: number, sampleWeekStartMs: number ): Promise<{ instanceId: string; kind?: string; analysis?: any; reason?: 'not-running' | 'no-operations' }[]> { /* 대상은 **등록부**가 정한다 — 기동 중인 런타임만 훑으면 "안 떠 있어서 안 보이는 것" 과 "없는 것" 이 구별되지 않는다. 화면은 그 둘을 다르게 말해야 한다. */ const where: any = { domain: { id: domainId } } if (target.instanceId) where.instanceId = target.instanceId else where.spaceId = target.spaceId const registered = await getRepository(TwinInstance).find({ where }) return registered.map(reg => { const rt = this.instances[runtimeKey(domainId, reg.instanceId)] const kernel: any = rt?.kernel // 키에 도메인이 있으므로 남의 런타임을 집을 수 없다 if (typeof kernel?.capacity !== 'function') return { instanceId: reg.instanceId, kind: reg.kind, reason: 'not-running' as const } const analysis = kernel.capacity({ unitsPerDay, sampleWeekStartMs }) /* 공정을 선언하지 않은 트윈(대개 YMS·WMS)은 **판정 대상이 아니다.** 0 으로 채워 넣으면 "야드가 병목" 이라는 없는 사실이 생긴다 — 잴 것이 없다고 말한다. */ if (!analysis?.operations?.length) return { instanceId: reg.instanceId, kind: reg.kind, reason: 'no-operations' as const } return { instanceId: reg.instanceId, kind: reg.kind, analysis } }) } /** * **생산 능력 보고서** — ISA-95 Part 4 `OperationsCapability`. 구간의 양을 종류로 구분한다 * (약정·가용·달성불가). 용량(`capacity`)은 비율을 내고 이것은 **구간의 양**을 낸다. * * 대상 선정·거절 사유는 `capacity` 와 **같은 규율**이다: 등록부가 대상을 정하고(안 기동 중인 것과 * 없는 것을 구별한다), 공정을 선언하지 않은 트윈은 판정 대상이 아니라고 말한다(0 으로 채우면 * "야드가 병목" 이라는 없는 사실이 생긴다). */ static async operationsCapability( domainId: string, target: { instanceId?: string; spaceId?: string }, unitsPerDay: number, sampleWeekStartMs: number, window: { fromTime: string; toTime: string } ): Promise<{ instanceId: string; kind?: string; report?: any; reason?: 'not-running' | 'no-operations' }[]> { const where: any = { domain: { id: domainId } } if (target.instanceId) where.instanceId = target.instanceId else where.spaceId = target.spaceId const registered = await getRepository(TwinInstance).find({ where }) return registered.map(reg => { const rt = this.instances[runtimeKey(domainId, reg.instanceId)] const kernel: any = rt?.kernel if (typeof kernel?.operationsCapability !== 'function') { return { instanceId: reg.instanceId, kind: reg.kind, reason: 'not-running' as const } } const report = kernel.operationsCapability({ unitsPerDay, sampleWeekStartMs, window }) if (!report?.perOperation?.length) return { instanceId: reg.instanceId, kind: reg.kind, reason: 'no-operations' as const } return { instanceId: reg.instanceId, kind: reg.kind, report } }) } /** * 미러 기동의 연속성 씨앗 — 이어받은 것은 **말한다**(알리지 않고 잇지 않는다). * * 커널이 그 문을 열어 두지 않았으면 그 사실도 말한다: 그 트윈은 재기동마다 열린 구간을 잃는다. */ private static seedLiveContinuity(domainId: string, id: string, kernel: any): void { const cached = this.recovered[runtimeKey(domainId, id)]?.state if (!cached) return const plan = planLiveContinuity(cached) if (!plan.ackedCount && !plan.attentionSinceCount && !plan.energy && !plan.foldedOrders) return if (typeof kernel.hydrateContinuity !== 'function') { twinWarn( `[twin-engine] kernel for "${id}" cannot carry continuity (no hydrateContinuity) — the open demand window, ` + 'its peak and the attention start times are lost on every restart.' ) return } try { kernel.hydrateContinuity(plan.seed) } catch (err: any) { /* 이어받기가 실패해도 미러는 돌아야 한다 — 다만 무엇을 잃었는지 말한다. */ twinWarn(`[twin-engine] continuity seed failed for "${id}" — starting without it: ${err?.message ?? err}`) return } const carried = [ ...(plan.energy ? ['energy accumulation (open window, totalizer baselines, peak)'] : []), ...(plan.ackedCount ? [`${plan.ackedCount} acknowledged attention(s)`] : []), ...(plan.attentionSinceCount ? [`${plan.attentionSinceCount} attention start time(s)`] : []) ].join(', ') twinLog(`[twin-engine] mirror "${id}" carried over ${carried} — observation axes come from the source.`) } static start(id: string, domainId: string, kind: string, model: TwinModelDef, restartPolicy?: RestartPolicy, purpose?: string, resumeFrom?: number): InstanceRuntime { const key = runtimeKey(domainId, id) if (this.instances[key]) return this.instances[key] const Kernel = kernelFor(kind) const kernel: TwinKernel = new Kernel(domainId, undefined, this.productionSpecOf(model)) /* 커널은 도메인으로 선다 — 어느 트윈인지는 여기서만 알려 줄 수 있다(§`factScope`). */ ;(kernel as any).scopeId = id refusingModel(id, () => kernel.loadTwinModel(model)) // 구조만. 상태는 아래 웜스타트가 주입한다. this.applyOperations(kernel, model, id) // 시간·수율 명세(있으면) — 없으면 커널 기본값 /* 추정기는 DB 조회를 포함해 비동기 — 기동을 막지 않고 붙는다(붙기 전 작업은 명세·상수로 산출). */ this.installEstimators(kernel, domainId, id, model).catch(err => twinWarn('[twin-engine] estimator install failed', err?.message)) /* * **웜스타트에 든 시간을 값으로 남긴다** (2026-08-22). 실측으로 부팅이 620~717초였고 트윈 하나에 * 15~93초였는데, 어느 작업이 그 시간을 쓰는지 답할 계기가 없었다. 로그에도 밀리초까지 적어 * 부팅 로그만으로 구간을 읽을 수 있게 한다. */ const tWarm = performance.now() this.warmStart(domainId, id, kernel, purpose) const warmMs = performance.now() - tWarm if (warmMs >= 1000) twinLog(`[twin-engine] "${id}": warm start took ${Math.round(warmMs)}ms.`) /* * **번호를 이어 센다** — 저널에 이미 있는 번호와 겹치지 않게. * * 저널이 비어 있으면(0) 아무 일도 안 한다. 비어 있지 않은데 0부터 다시 세면 같은 트윈에 같은 * 리비전이 둘 생기고, 재생 순서가 뒤섞이며, 시간여행이 엉뚱한 시점을 답한다 — 오류는 안 난다. */ if (resumeFrom && typeof (kernel as any).resumeRevision === 'function') (kernel as any).resumeRevision(resumeFrom) /* * ── 이 트윈의 시계를 **지금**에 맞춘다 (2026-08-15) ──────────────────────── * 커널의 기본 기준점은 상수(2026-01-01)다. 그대로 두면 시뮬 트윈의 저널 시각이 **재기동마다 * 되감겨** 며칠 치 사건이 전부 같은 분에 몰린다 — 「언제 있었던 일인가」를 되짚을 수 없고, * 실 시각으로 창을 자르는 성과·이력 질의에는 그 트윈이 아예 보이지 않는다. * * 재기동하면 그만큼 시계가 앞으로 뛰는데, 그것이 사실이다: 그 사이 이 트윈은 돌지 않았고 * 저널의 빈 구간이 그 사실을 말한다. */ kernel.setClockOrigin?.(Date.now()) const runtime: TwinRuntimeType = new TwinRuntime(kernel) /* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */ /* * **모드를 적어 둔다** (2026-08-18 실측으로 고침). * * 시뮬 경로는 `mode` 를 비워 두었고(「미지정=sim」이라는 규약에 기대), 라이브만 적었다. 그래서 * `modeOf()` 가 **도는 시뮬레이션에 undefined** 를 답했고, 선언·철회의 답이 「저장했다」로 나갔다 — * 실제로는 도는 커널이 옛 수를 그대로 쓰고 재기동 때 받는데, 그 사실이 답에서 사라진 것이다. * 규약에 기대는 대신 사실을 적는다: 「돌고 있나」를 묻는 쪽이 그 답을 받아야 한다. */ const inst: InstanceRuntime = { id, domainId, mode: 'sim', runtime, kernel, /* 정책은 부르는 쪽이 선언한다 — 여기서 고르면 같은 트윈이 부르는 자리에 따라 다르게 재기동한다. */ restartPolicy: readRestartPolicy(restartPolicy, `start("${id}")`), spaceId: (model as any)?.spaceId, model, /* * **시뮬도 계기를 든다** (2026-08-20). * * 예전에는 `startLive` 에서만 만들었다. 그래서 도는 트윈 22개가 시뮬인 서버에서 「초당 몇 행을 * 쓰나」·「밀린 게 있나」를 물을 자리가 **아예 없었다** — 저널 쓰기를 배치로 고친 뒤에도 그 효과와 * 회귀를 숫자로 볼 수 없다. 세지 않는 개선은 다음 사람이 되돌려도 아무도 모른다. * * 유입(`ingested`)은 시뮬에 뜻이 없다(원본에서 받는 것이 아니라 자기가 낸다) — 그 칸은 0 으로 * 남고, 그것이 사실이다(「받은 것이 없다」). */ metrics: this.newMetrics(), unsub: () => {} } this.instances[key] = inst /* 다시 세웠으므로 지난 정지 이유는 사실이 아니다 — 남겨 두면 도는 트윈이 「굶겨서 멈췄다」고 말한다. */ this.stopNotes.delete(key) delete this.recovered[key] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다. /* 원본이 선언한 자극 — 기동을 막지 않는다(추정기와 같은 규율). 못 실었으면 그 사실을 말한다. */ this.installStimulus(domainId, id, inst).catch(err => twinWarn(`[twin-engine] "${id}": stimulus install failed — ${err?.message ?? err}`)) /* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */ getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {}) /* State 채널: runtime.subscribe(snapshot→delta→clock) → pubsub 브로드캐스팅(구독 리졸버가 instanceId 필터). */ const sub = runtime.subscribe((msg: SubscriptionMessage) => { this.publishGuarded( TWIN_STATE_TOPIC, twinStatePayload(domainId, { instanceId: id, kind: msg.kind, revision: (msg as any).revision, payload: msg }), `twin-state:${id}` ) /* 영속 + 라이브 바인딩 브리지: delta 를 저널 버퍼에 담고 + 엔티티별 data(tag:) publish → 보드 컴포넌트 라이브. */ if (msg.kind === 'delta') { /* * ── 쓰기도 **모아서** 한 번 (2026-08-20 실측으로 잡음) ──────────────────── * * 여기서 델타마다 `persist()` 를 불렀다 — 행 하나당 `save()` 하나, 그리고 그 안에서 * `structureRevOf` 를 await(구조 행이 없는 트윈은 캐시 미스가 기억되지 않아 **델타마다 * SELECT** 였다). 데모 자극을 켠 트윈 23개에서 그 결과가 이랬다: 프로세스 CPU 81~112%, * 아무 일도 하지 않는 질의가 8~14초. 프로파일은 `JSON.parse`·대형 GC·스레드풀을 가리키고 * 계기는 틱의 몫이 16% 라고 말했다 — **틱이 아니라 쓰기였다.** * * 바로 위 주석이 **브로드캐스팅**에서 같은 함정을 적어 두었다(틱 하나가 35초). 브로드캐스팅은 모으도록 * 고쳤고 쓰기는 단건으로 남아 있었다. 같은 규율로 들인다 — ADR-0030 이 *"기록 경로가 둘인 * 것은 시뮬만 옆문으로 들어오기 때문"* 이라 예고한 그 자리다. * * **리비전은 커널의 것을 그대로 든다**(라이브는 flush 때 호스트가 부여한다). 시뮬의 저널은 * 커널 리비전으로 번호가 정해지므로 여기서 다시 번호를 매기면 시간여행이 어긋난다. */ ;(inst.pendingJournal ?? (inst.pendingJournal = [])).push({ event: msg.event, revision: (msg as any).revision }) /* * ── 브로드캐스팅은 **모아서** 한 번 (2026-08-14 실측으로 잡음) ──────────────────── * 여기서 곧바로 브로드캐스팅하고 있었다. 그런데 커널은 한 번의 tick 에서 사실을 **여러 개** 낸다 * (예약·배치·완료…). 그래서 tick 하나가 전 상태 투영을 수백 번 반복했다 — `order-check` * 트윈에서 **틱 하나가 35초**를 먹고(단계 합은 32ms 였다: 시간은 반복 횟수에 있었다) 그 * 35초 동안 이벤트 루프가 막혀 구독자가 아무것도 빼내지 못했다. 밀린 push 가 1024를 넘는 * 순간 pubsub 이 오류를 내고, 그 예외가 타이머 콜백을 타고 올라와 **호스트가 죽었다.** * * 라이브는 이미 dirty 표시 + 주기 flush 로 이 문제를 풀어 두었다(BROADCAST_COALESCE_MS). * 시뮬만 그 규율 밖에 있었다 — 같은 규율로 들인다(최신-상태 채널이라 중간 상태를 모두 * 보낼 이유가 없다: 200ms 마다 마지막 것 하나면 화면은 같다). */ inst.dirty = true /* 이 사실이 건드린 물품만 다음 브로드캐스팅에서 만든다(모르면 전부). */ this.markItemsDirty(inst, [msg.event]) this.ensureBroadcastCoalescer() } }) inst.unsub = () => sub.unsubscribe() /* 위에서 잰 웜스타트 시간을 계기에 적는다 — 계기는 인스턴스가 생긴 뒤에야 있다. */ recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'warmStart', warmMs) /* 복구 앵커: 레지스트리에 model/kind/status/restartPolicy 영속(재부팅 시 이게 있어야 replay·선언 거동 가능). */ this.register(domainId, id, kind, model, 'running', inst.restartPolicy).catch(err => twinError('twin register fail', err)) /* 워커 tick — 스켈레톤은 setInterval(메인 루프). 긴 시뮬 오프-루프(worker thread)는 스케일 하드닝(향후, §host 경계). */ inst.timer = this.startTickTimer( () => this.tickGuarded(domainId, id, runtime), t => (inst.timer = t) ) return inst } /** * live-모드 기동 — **관측 모드 커널**로 실 이벤트를 미러한다(통합 P2). * * 예전에는 `StateProjector` 를 세웠다. 그래서 라이브에는 **커널이 없었고**, 그 하나 때문에 우회가 * 줄줄이 생겼다: 예측하려면 임시 커널을 세워야 했고(`buildForecastKernel`), 주목 신호를 호스트가 * 덧붙여야 했고(`withLiveAttentions`), AI 예측 도구는 미러에서 "찾을 수 없다" 로 끝났다. * * 이제 라이브 인스턴스도 **커널이다.** 같은 규칙(`ObservedReducer`)으로 이벤트를 계산하고, 주목 신호를 * 스스로 내고, 그 자리에서 `fork` 해 예측한다. 구동만 다르다 — sim 은 `tick`, live 는 `apply`. * * 실 이벤트원 = reference 어댑터 openLiveFeed → face2-adapter.ingest → CanonicalEnvelope → ingestLive(). */ /** * 모델이 실은 **생산 정의**(레시피·라우트·바인딩)를 꺼낸다 — 커널의 정의-구동 모드 입구. * * ── 없을 때 무엇이 일어났나 ────────────────────────────────────────────── * 커널에는 정의-구동 MES 경로가 있는데 **호스트가 그것을 한 번도 넘기지 않았다.** 그래서 모든 MES * 트윈이 **하드코딩된 레거시 흐름**으로 돌았다 — 현장 레시피를 아무리 정성껏 적어도 커널은 그것을 * 보지 못하고 토이 부품으로 토이 제품을 만들었다. 선언과 실행이 갈라져 있던 자리다. * * 모델에 없으면 `undefined` — 레거시 경로 그대로다(기존 트윈의 거동을 바꾸지 않는다). */ /** * 생산 선언을 꺼낸다 — 커널 생성자에 넘긴다. * * **옛 이름(`mesSpec`)은 읽지 않는다.** 그것은 계약에 선언조차 없던 필드였고(MES 커널 생성자 인자 * 이름이 그대로 굳은 것), 일반 기제에 한 시스템 이름이 붙어 있었기 때문에 창고 트윈이 이 자리를 * 쓰지 못했다. 별명으로 남겨 두면 그 혼동이 계속되므로 하나로 통일했다. * * 옛 이름만 가진 모델이 있으면 **알리지 않고 생산 선언을 잃는 대신 분명히 멈춘다** — 그 트윈은 공정이 * 없는 채로 돌게 되고(라인이 서 있는 창고), 원인을 찾기 어렵다. */ private static productionSpecOf(model: TwinModelDef | undefined): any { const legacy = (model as any)?.mesSpec if (legacy && !(model as any)?.productionSpec) { throw new Error( 'the twin model carries the retired `mesSpec` field — rename it to `productionSpec` ' + '(same shape; the name was tied to one kernel while the declaration is ISA-95 operations + BOM)' ) } return (model as any)?.productionSpec } /** * 현장(공간)의 **시각 기준**을 모델에 얹는다 — 커널이 교대의 `HH:MM` 을 읽을 기준. * * **테넌트가 아니라 공간이 권위다.** 한 테넌트가 Rosarito(태평양)와 한국 공장을 함께 가질 수 있고, * 테넌트 단위(`Domain.timezone`)로 두면 둘 중 하나는 반드시 틀린다. 그래서 인스턴스가 연결된 공간의 * `timezone` 을 읽는다. 공간이 말하지 않으면 **테넌트로 내려가지 않는다** — 잘못된 입자로 답하는 것이 * 모르는 것보다 나쁘다(그때는 커널이 UTC 로 읽고, 그 기본값은 계약에 밝혀져 있다). * * 커널은 zero-dep 이라 시간대 데이터베이스를 갖지 않으므로 **분 오프셋**으로 풀어 넘긴다. * * ── 이름도 함께 얹는다 (2026-09-07) ──────────────────────────────────────── * 오프셋 하나만 얹으면 **그 값이 이 순간의 것으로 모델에 굳는다.** 트윈은 몇 주씩 돌고, 자료에 * 이미 `America/Tijuana`(PST −480 / PDT −420)가 있다 — 여름에 뜬 트윈이 11월까지 돌면 그 뒤 모든 * 시각이 한 시간 틀린다. * * 지난 구간을 접을 때가 더 나쁘다. 1년치를 교대별로 접으면 그 1년을 한 오프셋으로 읽는데 그 안에 * 전환이 두 번 있다. 어긋난 시각이 오류를 내지 않고 **다른 교대로 세어진다.** * * 그래서 이름(`timezone`)을 함께 얹어, 순간마다 다시 풀 수 있게 한다. 커널은 이 값을 쓰지 않는다 — * 여전히 오프셋을 호출마다 받는다. 푸는 것은 호스트의 몫이고 그것이 계약이 적어 둔 분담이다. * * `utcOffsetMinutes` 도 계속 얹는다. 아직 이름을 안 보는 소비처가 그 값으로 돌고, 이름이 없는 공간에 * 대해서는 그것이 유일한 답이다. * * ⚠ 이것으로도 완전하지 않다. 정확한 답은 호스트가 표준대로 **절대 구간**을 계산해 넣는 것이다. */ static async withSpaceTimeBase(model: TwinModelDef, domainId: string, spaceId?: string): Promise { const sid = spaceId ?? (model as any)?.spaceId if (!sid) return model const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } }).catch(() => null) const offset = utcOffsetOf(space?.timezone) if (offset === undefined) { if (space?.timezone) twinWarn(`[twin-engine] space "${sid}" declares time zone "${space.timezone}" but it is not a known IANA zone — times will be read as UTC.`) return model } /* 이름이 IANA 로 읽히는 것을 위에서 이미 확인했다 — 오프셋이 나왔다는 것이 그 증거다. */ return { ...model, utcOffsetMinutes: offset, timezone: space!.timezone } } static startLive(id: string, domainId: string, kind: string, model: TwinModelDef): InstanceRuntime { const key = runtimeKey(domainId, id) if (this.instances[key]) return this.instances[key] const Kernel = kernelFor(kind) const kernel: any = new Kernel(domainId, undefined, this.productionSpecOf(model)) /* 커널은 도메인으로 선다 — 어느 트윈인지는 여기서만 알려 줄 수 있다(§`factScope`). */ ;(kernel as any).scopeId = id refusingModel(id, () => kernel.loadTwinModel(model)) /* **세우는 쪽이 아는 사실은 세울 때 말한다.** 예전에는 첫 이벤트가 도착해야 커널이 스스로를 관측 구동으로 여겼고, 그래서 아직 아무것도 못 받은 미러는 시뮬레이션 취급을 받았다. */ declareObserved(kernel, `live twin '${id}'`) this.applyOperations(kernel, model, id) // 명세는 라이브에도 실린다(예측 자격이 sim 과 같아진다) /* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다. * (P3 에서 소비처를 커널 어휘로 바꾸면 사라진다.) */ const projector = { apply: (e: CanonicalEnvelope) => kernel.apply(e), snapshot: () => kernel.getSnapshot() } const oeeAcc = new OeeAccumulator() /* * **현장의 조업 달력을 얹는다** — 이것을 안 부르면 계획 조업 시간이 창 전체가 된다(§`OeeAccumulator`). * 자리를 만들고 부르지 않는 것이 이 저장소에서 가장 자주 나는 결함이라, 세우는 자리에서 바로 붙인다. */ oeeAcc.setWorkCalendar(model as any) const inst: InstanceRuntime = { id, domainId, mode: 'live', restartPolicy: 'resync', kernel, projector, oee: oeeAcc, spaceId: (model as any)?.spaceId, model, unsub: () => {} } /* * ── 커널이 **판정으로 낸 사실**도 저널에 남는다 (2026-08-14 실측으로 잡음) ──── * * 라이브는 인입 봉투만 저널에 적고 있었다(`ingestLive`). 그런데 커널은 관측을 계산하다가 **자기 사실**을 * 낸다 — 에너지의 수요 구간 마감·피크 경신·감축 제안이 그렇다(`emitOp`). 그것을 구독하는 곳이 * 없어서 그 사실들이 **커널 안에서 사라졌다**: 표본 48건이 저널에 쌓였는데 구간 마감은 0건이었고, * 저널을 읽는 성과 화면의 전력 타일은 영원히 나오지 않았다. * * sim 은 `runtime.subscribe` 로 같은 일을 한다 — 라이브에만 그 배선이 없었다. * * **인입의 재방출은 걸러낸다**: `apply()` 는 받은 봉투를 구독자에게 그대로 흘린다(호스트가 두 모드에서 * 같은 배선을 쓰게 하려고). 그것까지 적으면 인입이 저널에 두 번 들어간다 — 그래서 방금 넣은 것과 * **같은 객체**인지 보고 건너뛴다(id 비교는 어댑터가 id 를 어떻게 만드는지에 기대게 되어 약하다). */ const applying = new WeakSet() inst.applying = applying inst.unsub = kernel.onEvent?.((e: CanonicalEnvelope) => { if (e && typeof e === 'object' && applying.has(e as object)) return // 인입의 재방출 — 저널은 인입에서 한 번만 ;(inst.pendingJournal ?? (inst.pendingJournal = [])).push(e) /* 커널이 낸 사실도 상태를 바꾼다(피크·마감) — 다음 브로드캐스팅 주기에 실린다. */ inst.dirty = true this.markItemsDirty(inst, [e]) this.ensureBroadcastCoalescer() }) ?? (() => {}) /* 추정기(실측·거리)도 라이브에 붙인다 — 예측이 상수로 계산되지 않게. 기동을 막지 않는다. */ this.installEstimators(kernel, domainId, id, model).catch(err => twinWarn('[twin-engine] estimator install failed', err?.message)) inst.metrics = this.newMetrics() this.instances[key] = inst /* 미러에도 부른다 — 선언이 있으면 「미러에는 싣지 않는다」고 말해야 한다(들어온 것이 없는 무시 금지). */ this.installStimulus(domainId, id, inst).catch(err => twinWarn(`[twin-engine] "${id}": stimulus check failed — ${err?.message ?? err}`)) /* * **원천이 되풀어 주지 않는 것만 잇는다** (2026-08-18 실측으로 붙임). * * 미러는 오랫동안 아무것도 이어받지 않았다 — 진실이 원천에 있으니 옳은 판단이었지만, 원천이 **애초에 * 다시 말해 주지 않는 축**까지 함께 버렸다: 열린 15분 구간의 누적·적산 기준점·관측 이후 최대, 그리고 * 확인해 둔 신호와 조건이 언제부터인지. 그래서 재기동하면 오류 없이 값이 작아졌다(그 구간의 전력량과 * 피크가 부팅 이후로만 잡히고, 세 시간째 지속된 경보가 「0초째」가 됐다 — 요금이 걸린 수다). * * 관측 축(재고·위치·설비)은 **여전히 심지 않는다** — 다음 계측이 정정하고, 심으면 떠난 물건이 * 되살아난다. 무엇을 넘길지는 `planLiveContinuity` 가 고르고, 어떻게 흡수할지는 커널이 정한다. */ /* * ── **재개점에서 미러를 되세운다** (2026-08-24) ──────────────────────────── * * 이 자리에서 미러는 오랫동안 되찾은 상태를 **버렸다**. 전제는 「진실은 원천에 있으니 다음 계측이 * 정정한다」였고 라이브 피드에서는 옳았다. 그런데 원본이 **커서 증분**으로 말하는 현장에서는 그 * 정정이 오지 않는다: 커서가 따라잡힌 뒤 원본이 변하지 않으면 미러는 영구히 빈 채로 남는다. * 그리고 그 빈 채로 화면이 「이상 없음」을 보였다 — 사실이 사라지는 동안 화면이 안심시킨 것이다. * * 되돌리는 것은 **상태가 아니라 재개점**이다. 상태만 심으면 그 뒤를 이어 계산한 결과가 0부터 계산한 * 결과와 오류 없이 달라진다(리듀서는 보류된 담김·집계 중인 수량도 든다). 그 동치는 커널 시험이 * 증명한다(`observed-checkpoint.test.ts` — 재개점 + 꼬리 == 0부터 계산하기). * * 씨앗은 **그 공장이 아직 그 공장일 때만** 오고, 마지막 체크포인트 이후의 사실은 이미 계산되어 들어 * 있다(`warmSeedFor`). 씨앗이 없으면 전과 같이 빈 채로 시작한다 — 지어내지 않는다. */ const seed = this.recovered[key]?.fold?.reducer if (seed) { if (typeof kernel.restoreObserved !== 'function') { twinWarn( `[twin-engine] "${id}": a fold seed is stored but this kernel cannot take it (no restoreObserved) — ` + 'the mirror starts empty and waits for the source to re-tell everything. Upgrade the kernel.' ) } else { try { kernel.restoreObserved(seed) const st = kernel.getSnapshot?.() twinLog( `[twin-engine] mirror "${id}" resumed from the stored fold seed at revision ${this.recovered[key]?.revision} — ` + `items ${st?.items?.length ?? 0} · orders ${st?.orders?.length ?? 0} · tasks ${st?.tasks?.length ?? 0} ` + '(the journal is not folded from the beginning).' ) if (this.recovered[key]?.fold?.oee) inst.oee?.restore(this.recovered[key].fold.oee) } catch (err: any) { /* 되세우기가 실패해도 미러는 돌아야 한다 — 다만 무엇을 잃었는지 말한다. */ twinWarn(`[twin-engine] "${id}": could not resume from the stored fold seed — starting empty: ${err?.message ?? err}`) } } } this.seedLiveContinuity(domainId, id, kernel) delete this.recovered[key] /* 라이브 바인딩(data 채널) subdomain 필터용 Domain 1회 해석(sim 과 동일). */ getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {}) /* * 저널 revision 카운터 시드 — 기존 저널 최대치에서 이어붙임(재기동 시 revision 충돌 방지). * * ── **커널에도 같은 번호를 알려 준다** (2026-08-23 실측) ──────────────────── * 예전에는 이 자리에서 `inst.revision`(저널 줄 번호)만 이어받고 **커널은 0 부터 세게 두었다.** * 그래서 체크포인트가 뜻이 다른 두 수를 나란히 적었다. * * 바깥 revision 12,972 저널에 적힌 줄 번호 (이 자리에서 이어받는다) * state.revision 6,478 커널이 처리한 봉투 수 (0 부터 셌다) * * 포천 미러에서 확정했다: 차이 6,494 는 그 트윈을 **다시 세운 시각**(09:11)에 저널이 이미 갖고 * 있던 줄 수와 정확히 같았다(줄 6494 = 08:23:59, 줄 6495 = 09:11:13). 산수가 맞았다. * * 결함은 두 수가 다르다는 것 자체가 아니라 **비대칭**이다: 재기동 경로(`start` → `resumeRevision`)는 * 커널에 번호를 알려 주는데 **이 경로(미러)는 알려 주지 않았다.** 그래서 같은 저장물을 읽는 소비처가 * 같은 이름의 두 수를 같은 축으로 견주게 되고, 실제로 그렇게 읽혔다. * * 저널이 비어 있으면(0) 아무 일도 하지 않는다 — 새 트윈은 0 부터 세는 것이 맞다. 그리고 이 조회는 * 비동기라 그 사이에 이벤트가 몇 건 들어와 있을 수 있는데, 그때 뒤로 되돌리는 것은 커널이 거절한다 * (겹치는 번호를 막는 그 판정이다). 거절은 삼키지 않고 남긴다. */ /* * 관계로 묻지 않는다 — `findOne({ where: { domain: { id } } })` 은 두 단계 질의가 되고 안쪽에 * 상한이 없어 그 트윈의 저널을 **전부 만든 뒤** 한 줄을 고른다(§`tipOf` 의 실측). 여기는 부팅 * 경로이므로 그 비용이 기동을 붙잡는다. 색인의 끝 한 줄만 읽는다. */ getRepository(TwinEvent) .createQueryBuilder('e') .select('MAX(e.revision)', 'max') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId: id }) .getRawOne<{ max: unknown }>() .then(top => { const head = Number(top?.max) || 0 inst.revision = head if (!head || typeof (kernel as any).resumeRevision !== 'function') return try { ;(kernel as any).resumeRevision(head) } catch (err: any) { /* 뒤로 갈 수 없다는 거절 — 이 조회가 도착하기 전에 이미 그만큼 처리했다는 뜻이다. */ twinWarn(`[twin-engine] "${id}": journal head ${head} is behind the kernel — ${err?.message ?? err}`) } }) .catch(() => (inst.revision = 0)) this.register(domainId, id, kind, model, 'running', 'resync').catch(err => twinError('twin register fail', err)) return inst } /** * live 이벤트 인제스트 — projector 구동 + data(tag) 브로드캐스팅(폐루프의 인바운드 도착 지점, command-routing §8.2). * reference 어댑터가 낸 records → 커널 face2-adapter.ingest → CanonicalEnvelope 를 여기로 밀어넣는다. * (State 채널 델타/저널 결선은 후속 — 스켈레톤은 data(tag) 미러 중심.) * * ── 넣은 수를 **답한다** (2026-08-20) ──────────────────────────────────────── * 트윈이 라이브로 돌지 않으면 여기서 봉투를 버린다. 그것 자체는 맞다(넣을 커널이 없다). 문제는 * **알리지 않고** 버린 것이었다: 트윈이 멈춘 뒤에도 피드는 남아 레코드를 나르고, 유입 장부는 그것을 * 「통과」로 셌다. 화면은 멈춘 트윈 옆에 「150 통과 · 100%」라고 적었다 — 사실이 사라지는 동안 * 화면이 안심시킨 것이다. * * 그래서 **넣은 수를 돌려준다.** 부르는 쪽이 제시 수와 견주어 버려진 수를 장부에 적는다. 반환을 * 무시하는 호출부는 그대로 동작한다(전과 같다). */ /** * 유입 결과를 수로 낸다 — 받은 수 · 중복으로 버린 수 · 보낸 수. * * `ingestLive` 는 받은 수만 돌려주는데, 웹훅으로 받을 때는 **몇 건이 중복이었는지도 답해야** 한다. * 밀어 주는 쪽이 그 수를 보고 자기가 되풀이 보내고 있다는 것을 알 수 있어야 하고, 그것을 모르면 * 우리 응답이 「전부 새 것이었다」로 읽힌다. */ static ingestLiveResult( domainId: string, id: string, envelopes: CanonicalEnvelope[], source = 'poll' ): { offered: number; applied: number; duplicates: number } { const before = this.ingestLedgers[runtimeKey(domainId, id)]?.duplicates ?? 0 const offered = envelopes.length const applied = this.ingestLive(domainId, id, envelopes, source) const after = this.ingestLedgers[runtimeKey(domainId, id)]?.duplicates ?? 0 return { offered, applied, duplicates: Math.max(0, after - before) } } /** * **마스터데이터를 받는다** — 변하지 않는 속성을 상태에 세운다. 저널에 적지 않는다. * * ── 왜 저널에 적지 않나 ──────────────────────────────────────────────────── * 유통기한·로트번호 같은 값은 그 물건의 생애 동안 같다. 그래서 「그때는 얼마였나」라는 물음이 * 성립하지 않는다 — 시각축이 없는 값이므로 사건이 아니다. 되세울 근거는 중간 저장본이 든다 * (리듀서가 그 값을 저장본에 담는다). * * ── 이 문이 없어서 무엇이 났나 (2026-08-28 실측) ─────────────────────────── * 표준은 그 값을 `ObjectEvent(action=ADD)` 에만 실을 수 있게 한다. 그래서 전량을 읽는 원본이 * 재고를 다시 말할 때마다 **들어오지 않은 것을 「들어왔다」** 고 했고, 사건 단위 값이라 자리별로 * 묶을 수도 없어 낱개로 나갔다 — 재기동 한 번에 2,665건이 「읽은 시각」으로 쌓였다. * * 돌려주는 수는 **상태에 반영된 건수**다. 라이브로 돌지 않는 트윈은 0 을 낸다 — 그 차이를 부르는 * 쪽이 알아야 한다(버려진 것을 통과로 세면 멈춘 트윈이 정상으로 보인다). * * 설계: `operato-twin/design/plans/master-data.md` */ static applyMasterData(domainId: string, id: string, elements: readonly VocabularyElement[]): number { if (!elements.length) return 0 const inst = this.instances[runtimeKey(domainId, id)] if (inst?.mode !== 'live' || !inst.kernel) return 0 const kernel = inst.kernel as any if (typeof kernel.applyMasterData !== 'function') { twinWarn( `[twin-engine] kernel for "${id}" cannot take master data (no applyMasterData) — ` + 'lot attributes such as expiry will stay empty for this twin.' ) return 0 } const n = kernel.applyMasterData(elements) as number /* * **돌았다는 것을 로그로 남긴다** (2026-08-28). * * 이 값은 지난 기록에 적히지 않으므로(시각축이 없다) 세지 않으면 이 통로가 돌았는지 아무 데도 * 남지 않는다. 처음에는 유입 장부에 수를 더했는데 **그것이 편법이었다**: 유입 장부는 「원본에서 * 사실이 얼마나 들어오고 거부되고 버려지나」를 답하는 자리이고, 마스터데이터는 사실의 흐름이 아니라 * 선언의 동기다. 그리고 그 수를 읽는 화면이 없었다 — 내가 확인하려고 만든 값이었다. * * 확인은 **로그의 일**이다. 정상 상태에서는 이 줄이 안 나오는 것이 맞다 — 값이 그대로면 원본이 * 다시 말하지 않는다. 나오면 그때가 무언가 바뀐 때다. */ if (n > 0) twinLog(`[twin-engine] "${id}": 마스터데이터 ${n}건을 상태에 세웠다(지난 기록에는 적지 않는다).`) /* 상태가 바뀌었으니 다음 창에서 화면으로 흘러야 한다 — 관측 반영과 같은 규율이다. */ inst.dirty = true this.ensureBroadcastCoalescer() return n } static ingestLive(domainId: string, id: string, envelopes: CanonicalEnvelope[], source = 'poll'): number { const inst = this.instances[runtimeKey(domainId, id)] if (inst?.mode !== 'live' || !inst.projector) return 0 const tIngest = performance.now() /* * ── 같은 사실을 두 번 받으면 한 번만 반영한다 (2026-08-26) ───────────────── * * 이 자리가 유입 경계다 — 커넥터가 물어서 받은 것 · 시나리오가 넣는 것 · 부하 도구가 넣는 것이 * 모두 여기를 지난다(웹훅으로 받는 길이 생기면 그것도 여기로 온다). 그래서 중복을 여기서 한 번 * 거른다. * * 지금까지는 커넥터마다 각자 막고 있었다. 그래서 잊으면 트윈이 그대로 두 번 셌다 — 태양광 * 커넥터가 한 주기에 76건을 되풀이 보냈고, 커넥터를 고쳐서 2건이 됐다. 다음 커넥터가 같은 것을 * 잊으면 또 난다. * * 절대값으로 말하는 사실은 두 번 반영해도 결과가 같지만(잔량 · 상태 · 적산), 세는 값은 아니다: * 구간의 표본 수 · 평균 부하 · OEE 카운터 · 유입 계수. 그리고 지난 기록에 같은 사실이 두 줄로 * 남으면, 나중에 그 기록으로 다시 계산할 때 또 두 번 센다. * * **정확히 같은 것만 버린다.** 늦게 도착한 옛 사실은 버리지 않는다 — 그것이 왔다는 것도 사실이고, * 상태에 반영하지 않는 판단은 커널이 이미 한다. */ const { fresh, duplicates } = (inst.deduper ??= new FactDeduper()).filter(envelopes) envelopes = fresh const ledgerKey = runtimeKey(domainId, id) const ledger = this.ingestLedgers[ledgerKey] ?? (this.ingestLedgers[ledgerKey] = newIngestLedger()) if (duplicates) recordDuplicates(ledger, duplicates, Date.now()) if (envelopes.length) recordIngestSource(ledger, source, envelopes.length, Date.now()) /* 인입 봉투를 표시해 두고 넣는다 — 커널이 그것을 재방출해도 저널에 두 번 적히지 않게(위 구독 주석). */ for (const e of envelopes) { if (e && typeof e === 'object') inst.applying?.add(e as object) inst.projector.apply(e) inst.oee?.apply(e) // 관측(projector) + 계산(OEE 누적) } recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'ingest', performance.now() - tIngest) // 저널 결선(라이브도 sim 처럼 이벤트 영속) — 단 이벤트마다 DB write 하면 부하폭발이라 모아뒀다가 // coalescer tick 에서 배치 기록(브로드캐스팅과 동일 주기). 히스토리 노브·타임라인·비즈니스 원장이 이 저널을 읽는다. ;(inst.pendingJournal ?? (inst.pendingJournal = [])).push(...envelopes) if (inst.metrics) { inst.metrics.ingestedTotal += envelopes.length; inst.metrics._accIngest += envelopes.length } // 계측(④-1) // 브로드캐스팅 병합(ingest-scale §1.1/§4.4) — apply 는 O(1)·싸다. 그러나 브로드캐스팅(snapshot O(state))은 비싸(대규모 5ms+) // 이벤트마다 브로드캐스팅하면 폭발 → dirty 만 찍고 coalescer tick 이 주기 브로드캐스팅(브로드캐스팅률 ≠ 인제스트률). inst.dirty = true this.markItemsDirty(inst, envelopes) this.ensureBroadcastCoalescer() return envelopes.length } /** * 구간 성과 브로드캐스팅은 **없앴다**(2026-08-06). 카드가 `twinKpi` 를 직접 묻는다. * * 왜: 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려면 브로드캐스팅으로는 태그가 트윈당 1,200개가 되고, * **모델에 카드를 하나도 안 놓아도** 30초마다 트윈마다 저널을 계산했다. 질의로 바꾸니 보고 있는 카드 * 수만큼만 들고, 같은 (대상·창·축) 은 클라이언트가 하나로 합친다. * * 덤으로 질의만 할 수 있는 것이 둘 생겼다 — **과거 시각**(`toTime`)과 **공간 단위 합산**(여러 트윈을 * 한 번에 계산). 브로드캐스팅 루프는 트윈별이라 둘 다 못 했다. * * 축을 나눠도 폴드 비용이 같다는 실측이 근거다(`test/kpi-query-bench.test.ts`). */ /** * 몇 창마다 한 번은 **전부** 만드나 — 사건 없이 값이 바뀌는 자리에 대한 그물. * * 보장이 아니라 그물이다(위 `publishEntityData` 주석). **세는 단위는 창이지만 뜻은 시간**이므로, * 기본 주기를 200밀리초에서 1초로 늘린 날 이 수도 함께 줄였다(25창 → 5창). 그러지 않으면 그물이 * 5초에서 25초로 벌어져, 시각만으로 바뀌는 값(유효 기간 만료 같은 것)이 그만큼 늦게 보인다. */ static FULL_BROADCAST_EVERY = 5 /** * **한 실행 차례에 발행할 수 있는 건수** — 이만큼 발행하면 루프를 비워 준다 (2026-08-27). * * ── 왜 상한이 필요한가 (실측으로 서버가 두 번 죽었다) ────────────────────── * 구독의 대기열에는 버퍼가 없다(`pubsub.subscribe` 가 `new Repeater(fn)` 을 버퍼 없이 만든다). * 발행하는 순간에 「다음 값을 달라」는 요청이 이미 걸려 있어야 바로 전달되고, 아니면 대기열에 * 쌓인다. 상한은 1024 이고 넘으면 예외가 난다(`@repeaterjs/repeater` 의 `MAX_QUEUE_LENGTH`). * * 값을 꺼내는 쪽은 비동기다. 그래서 **동기 반복문이 도는 중에는 한 건도 꺼내지 못한다.** 라이브러리로 * 직접 재 본 값이다 — 소비자를 붙여 두고 2,000건을 동기로 발행하면 소비자가 꺼낸 것은 1건이었고 * 나머지가 쌓여 예외가 났다. * * 그 예외는 `publish()` 호출자에게 오지 않는다. `EventTarget` 이 리스너 예외를 uncaughtException 으로 * 보내므로 try/catch 로는 잡을 수 없다(2026-08-14 에 그 가드를 붙였는데 그래서 무력했다). 막는 방법은 * **한 차례에 상한을 넘기지 않는 것** 하나다. * * 1024 보다 넉넉히 작게 둔다 — 소비자가 한 청크를 다 꺼내지 못해도 다음 청크까지 여유가 남는다. */ static PUBLISH_CHUNK = 200 /** 전부 만든 횟수 — 범위를 좁히지 못한 창이 얼마나 되는지 값으로 남는다. */ static broadcastFullPasses = 0 /** * 브로드캐스팅을 만든 횟수 전부 — **전부 만든 횟수의 분모.** * * 분자만 내면 「전부 만들기 1,200회」가 많은 것인지 적은 것인지 읽을 수 없다. 좁히기가 듣고 있으면 * 이 값의 `1/FULL_BROADCAST_EVERY` 쯤이 전부 만든 횟수이고, 두 값이 비슷하면 범위를 거의 못 좁힌 * 것이다(원인은 대개 「모른다」로 떨어지는 사건이다). */ static broadcastPasses = 0 /** * 이 창에 건드린 물품을 모은다 — **말할 수 없으면 범위를 버린다(전부 만든다).** * * `events` 를 주지 않으면 「무엇이 바뀌었는지 모른다」다(구조 전환처럼 상태 전반이 달라지는 자리). * 한 창에서 한 번 「모른다」가 되면 그 창은 끝까지 모르는 채로 둔다 — 뒤에 온 사건으로 범위를 * 되살리면 앞 사건이 건드린 것을 빠뜨린다. */ static markItemsDirty(inst: InstanceRuntime, events?: any[]): void { if (inst.dirtyItems === undefined) return // 이 창은 이미 「모른다」 if (!events) { inst.dirtyItems = undefined return } for (const e of events) { const touched = touchedItemKeys(e) if (!touched) { inst.dirtyItems = undefined return } for (const epc of touched) inst.dirtyItems.add(epc) } } /** 브로드캐스팅 병합 주기(ms) — 브로드캐스팅률 상한. 인제스트가 아무리 빨라도 이 주기로만 브로드캐스팅. */ /* * ── 200 → 1000 (2026-08-25 · 사용자 결정) ────────────────────────────────── * 트윈 **둘**에서 이미 주기를 스스로 늘리고 있었다(한 번 보내는 데 270밀리초). 200밀리초는 상태가 * 작을 때의 값이고, 지금 승화푸드 하나가 엔티티 17,600개다(물품 6,494 · 오더 4,794 · 작업 6,353). * * 한 번 보내는 값은 상태 크기에 비례하므로 주기를 늘리는 것으로 그 값이 줄지는 않는다. 대신 **단위 * 시간에 그 값을 치르는 횟수**가 5분의 1이 된다. 그 사이 호스트가 HTTP·구독을 처리한다. * * 화면이 늦게 갱신되는 것은 최대 1초다. 최신-상태 채널이라 밀린 것을 쌓아 보내지 않고 마지막 하나만 * 가므로, 늦어질 뿐 내용은 같다. * * **비용을 줄인 것이 아니다.** 줄이는 것은 변경분만 만드는 일이고(바뀐 것만 다시 만드는 일) 그것은 별 * 작업이다 — 계획: `design/plans/live-broadcast-cost.md`. */ static BROADCAST_COALESCE_MS = 1000 /** * ── 브로드캐스팅 주기는 **재 본 비용에 맞춘다** (2026-08-21 실측) ──────────────────── * 한 번의 브로드캐스팅은 상태 크기에 비례한다(실측: 물품 2,400 개인 트윈 하나가 4.5ms — 상태 투영 1.7ms, * payload 만들기 1.8ms, 시그니처 1.0ms). 트윈이 스무 개면 200ms 마다 90ms 가 브로드캐스팅에 들어가고, * 그 시간에는 HTTP 도 구독도 서지 못한다. * * 그래서 한 창에서 브로드캐스팅에 쓴 시간이 주기의 일정 몫을 넘으면 **주기를 늘린다**. 화면은 조금 늦게 * 갱신되고(최신-상태 채널이라 값은 마지막 것 하나뿐이므로 내용은 같다), 늘렸다는 것은 계기판이 * 말한다(`broadcastCoalesceMs`). 여유가 생기면 원래 주기로 되돌린다. * * 이것은 브로드캐스팅 비용을 **줄이는 것이 아니다** — 비용을 줄이는 것은 변경분만 만드는 일이고 그것은 별 * 작업이다. 여기서는 그때까지 호스트가 굶지 않게 상한을 둔다. */ /* 기본이 1초가 되었으므로 늘릴 여지를 함께 올린다 — 상한이 기본과 같으면 늘릴 곳이 없다. */ static BROADCAST_MAX_COALESCE_MS = 2000 /** 주기의 몇 몫까지 브로드캐스팅에 써도 되는가 — 넘으면 주기를 늘린다(절반이면 나머지 절반은 남긴다). */ static BROADCAST_LOAD_RATIO = 0.3 /** 지금 쓰고 있는 주기(ms) — 계기판이 이 값을 읽는다. 늘어난 채로 있으면 그것이 사실이다. */ static broadcastPeriodMs = 200 /** 주기를 늘린 횟수 — 알리지 않고 늦추지 않는다. */ static broadcastBackoffs = 0 private static broadcastTimer?: any /** live 브로드캐스팅 coalescer — dirty 인 live 인스턴스만 주기적으로 1회 브로드캐스팅(entitySigs 로 변경 엔티티만). */ private static ensureBroadcastCoalescer(): void { if (this.broadcastTimer) return this.broadcastPeriodMs = this.BROADCAST_COALESCE_MS this.broadcastTimer = setInterval(() => this.flushLiveBroadcasts(), this.broadcastPeriodMs) if (typeof this.broadcastTimer.unref === 'function') this.broadcastTimer.unref() // 종료 비차단 } /** * 브로드캐스팅에 쓴 시간을 보고 주기를 정한다 — **늘리는 것도 줄이는 것도 값에 근거한다.** * * 한 번의 flush 가 주기의 `BROADCAST_LOAD_RATIO` 를 넘게 쓰면 주기를 두 배로(상한까지), 그 몫의 * 절반 아래로 내려오면 절반으로(원래 주기까지) 되돌린다. 문턱을 두 개 두는 이유는 하나면 경계에서 * 늘리고 줄이기를 반복하기 때문이다. */ private static adjustBroadcastPeriod(flushMs: number): void { const period = this.broadcastPeriodMs const high = period * this.BROADCAST_LOAD_RATIO const low = high / 2 let next = period if (flushMs > high && period < this.BROADCAST_MAX_COALESCE_MS) { next = Math.min(this.BROADCAST_MAX_COALESCE_MS, period * 2) this.broadcastBackoffs++ twinWarn( `[twin-engine] broadcast period ${period}ms → ${next}ms — one flush took ${Math.round(flushMs)}ms across ${Object.keys(this.instances).length} twin(s)` ) } else if (flushMs < low && period > this.BROADCAST_COALESCE_MS) { next = Math.max(this.BROADCAST_COALESCE_MS, Math.round(period / 2)) } if (next === period) return this.broadcastPeriodMs = next if (this.broadcastTimer) clearInterval(this.broadcastTimer) this.broadcastTimer = setInterval(() => this.flushLiveBroadcasts(), next) if (typeof this.broadcastTimer.unref === 'function') this.broadcastTimer.unref() } /** * dirty 인스턴스 브로드캐스팅 flush(주기 tick 또는 명시 호출) — **시뮬과 라이브 둘 다.** * * 예전에는 라이브만 봤다(`mode !== 'live'` 면 건너뜀). 시뮬은 delta 마다 곧바로 브로드캐스팅했고, 그것이 * 한 tick 에서 수백 번 반복되며 이벤트 루프를 막았다(위 `start()` 주석의 35초 틱). 브로드캐스팅을 모으는 * 규율은 모드의 성질이 아니라 **채널의 성질**이다 — 최신-상태 채널이면 중간 상태는 보낼 값이 없다. */ /** * 브로드캐스팅 한 창 — **겹쳐 돌지 않는다** (2026-08-27). * * 발행이 청크로 나뉘어 사이에 루프를 비워 주므로 한 창이 주기를 넘길 수 있다. 그때 다음 타이머가 * 겹쳐 들어오면 같은 태그가 두 순서로 나가고 시그니처 표가 엉킨다. 그래서 앞 창이 끝날 때까지 * 다음 타이머는 그대로 돌아간다 — **창을 버리는 것이 아니다.** 다음 타이머가 이어서 한다. * * 겹친 횟수를 센다. 계속 겹치면 주기보다 한 창이 오래 걸리고 있다는 뜻이고, 그것은 * `adjustBroadcastPeriod` 가 주기를 올려 스스로 풀어야 하는 사실이다. */ private static flushInFlight = false static flushOverruns = 0 static flushLiveBroadcasts(): void { if (this.flushInFlight) { this.flushOverruns++ return } this.flushInFlight = true void this.flushBroadcastWindow() .catch(err => twinError('[twin-engine] broadcast window failed', err?.message ?? err)) .finally(() => { this.flushInFlight = false }) } private static async flushBroadcastWindow(): Promise { const now = Date.now() const flushStart = performance.now() for (const inst of Object.values(this.instances)) { const isLive = inst.mode === 'live' /* * 처리량 계측(④-1) — 창(≥1s)마다 유입·브로드캐스팅·저널률 갱신. dirty 무관(유휴면 0으로 수렴). * * **두 구동을 함께 센다** (2026-08-20). 예전에는 `isLive ? … : undefined` 로 시뮬을 잘라 냈다. * 그래서 도는 트윈 대부분이 시뮬인 서버에서 「초당 몇 행을 쓰나」에 답할 자리가 없었다 — 저널 쓰기를 * 배치로 고친 뒤에도 그 효과를 숫자로 볼 수 없었다. 유입(`ingestRate`)은 시뮬에서 0 으로 수렴하고, * 그것이 사실이다(원본에서 받는 것이 없다). */ const m = inst.metrics if (m) { const dt = (now - m._windowStartMs) / 1000 if (dt >= 1) { m.ingestRate = Math.round(m._accIngest / dt) m.broadcastRate = Math.round((m._accBroadcast / dt) * 10) / 10 m.journalRate = Math.round(m._accJournal / dt) m._accIngest = 0; m._accBroadcast = 0; m._accJournal = 0; m._windowStartMs = now } } if (!inst.dirty) continue inst.dirty = false /* 주기마다 한 번은 범위를 버리고 전부 만든다 — 사건 없이 값이 바뀌는 자리에 대한 그물. */ const left = (inst.fullBroadcastCountdown ?? 0) - 1 if (left <= 0) { inst.fullBroadcastDue = true inst.fullBroadcastCountdown = this.FULL_BROADCAST_EVERY } else { inst.fullBroadcastCountdown = left } // ① 엔티티 data(tag) 브로드캐스팅 — 보드 컴포넌트 라이브 렌더. await this.publishEntityData(inst) if (m) { m.broadcastTotal++; m._accBroadcast++ } /* ③ 저널 배치 기록 — **두 구동이 같은 문을 쓴다**(시뮬도 여기서 흘린다, §7.1). */ this.flushJournal(inst) /* 여기서부터는 **라이브만**이다: 시뮬의 state 채널은 자기 콜백이 델타마다 보내므로(리비전이 커널의 것이다) 여기서 또 보내면 같은 신호가 두 번 간다. 저널은 위에서 이미 두 구동 몫을 흘렸다. */ if (!isLive) continue // ② State 채널 브로드캐스팅 — "바뀌었다"는 가벼운 신호만(kind+revision). 맵 구독(subscribeTwinState)은 이 신호에 // scheduleRefresh(250ms 디바운스)→pollLive 로 되물어봄. 스냅샷(O(state))은 보는 사람이 물을 때만 1회 계산. // (여기서 payload 로 스냅샷을 실으면 아무도 안 읽는데 tick 마다 통째로 떠서 순수 낭비 — 신호만 보낸다.) this.publishGuarded( TWIN_STATE_TOPIC, twinStatePayload(inst.domainId, { instanceId: inst.id, kind: 'delta', revision: inst.revision ?? 0 }), `twin-state:${inst.id}` ) } /* 가동 중인 인스턴스가 하나도 없으면 tick 을 멈춘다(예전엔 「라이브가 없으면」이었는데, 이제 시뮬도 이 flush 에 기대므로 그 조건이면 시뮬 브로드캐스팅이 멈춘 채 남는다). */ if (!Object.keys(this.instances).length && this.broadcastTimer) { clearInterval(this.broadcastTimer) this.broadcastTimer = undefined this.broadcastPeriodMs = this.BROADCAST_COALESCE_MS return } /* 이번 flush 가 얼마를 썼는지로 다음 주기를 정한다 — 브로드캐스팅이 루프를 다 쓰지 못하게. */ this.adjustBroadcastPeriod(performance.now() - flushStart) } /** * 저널 행 한 줄 — **기록 경로가 둘이라(라이브 벌크·심 단건) 행 모양은 반드시 한 곳에서 만든다.** * 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데 * 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다. */ /** 저널의 시각 축은 날짜다 — 못 읽는 값은 비운다(0 이나 지금으로 위장하지 않는다). */ private static toEventTimeValue(v: any): Date | undefined { if (v instanceof Date) return v const t = typeof v === 'string' ? Date.parse(v) : NaN return Number.isNaN(t) ? undefined : new Date(t) } private static journalRow(repo: any, domainId: string, instanceId: string, e: any, revision: number, structureRev?: number) { return repo.create({ domain: { id: domainId } as any, instanceId, eventType: e?.eventType, /* 커널이 커맨드에서 이어 준 값 — 이것이 감사 기록과 저널을 잇는 다리다. */ correlationId: e?.correlationId, revision, /* **이 사실이 일어난 공장**을 함께 찍는다 — 이것이 없으면 나중에 구조가 바뀌었을 때 이 행을 새 공장에 대고 계산하게 되고, 그때 없던 설비에서 일이 있었던 것처럼 보인다. */ ...(structureRev === undefined ? {} : { structureRev }), /* 커널은 ISO 문자열을 준다. 컬럼은 날짜다 — **여기서 옮긴다.** 문자열을 그대로 넣으면 드라이버가 제 형식으로 정규화하지 않아, 나중에 날짜로 거는 질의에 안 걸린다. */ eventTime: this.toEventTimeValue(e?.eventTime), ...twinEventKeys(e), payload: e }) } /** * 이 트윈이 지금 어느 구조로 도는가 — 이벤트에 찍을 번호. 한 번 읽고 캐시한다(쓰기마다 조회 금지). * * 없으면 `undefined` 다 — 0 이 아니다. 구조 리비전이 생기기 전에 만들어진 트윈은 아직 리비전이 * 없고, 그 사실을 0 이라는 **유효해 보이는 번호**로 위장하면 안 된다. */ /** 구조 리비전 캐시 — `null` 은 **없다는 것을 알고 있다**는 뜻이다(모름과 구별한다, §7.1). */ private static structureRevCache: Record = {} static async structureRevOf(domainId: string, instanceId: string): Promise { const key = runtimeKey(domainId, instanceId) const cached = this.structureRevCache[key] /* * **없다는 것도 답이다** — 예전에는 찾지 못하면 캐시하지 않았다(`if (latest) …`). 그래서 구조 행이 * 없는 트윈은 **부를 때마다 SELECT** 했고, 그 경로가 델타마다 불리고 있었다(§7.1). `null` 로 기억한다. */ if (cached !== undefined) return cached === null ? undefined : cached const latest = await getRepository(TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } }) this.structureRevCache[key] = latest ? latest.rev : null return latest?.rev } /** 계기 한 벌 — **두 구동이 같은 것을 든다**(한쪽만 들면 그 구동은 물어도 답이 없다). */ private static newMetrics(): TwinMetrics { return { ingestedTotal: 0, broadcastTotal: 0, journaledTotal: 0, ingestRate: 0, broadcastRate: 0, journalRate: 0, backlog: 0, _accIngest: 0, _accBroadcast: 0, _accJournal: 0, _windowStartMs: Date.now() } } /** * 모아 둔 저널을 **한 번에** 쓴다 — 두 구동이 같은 문을 쓴다 (§7.1). * * ── 왜 한 함수인가 ───────────────────────────────────────────────────────── * 쓰는 자리가 둘이면(주기 flush · 정지) 한쪽만 고쳐지고, 그 어긋남은 **사실이 오류 없이 사라지는** * 모양으로 나타난다. 그래서 흘리는 규칙을 여기 한 곳에 둔다. * * ── 리비전을 누가 매기나 ─────────────────────────────────────────────────── * · 라이브 — 원천은 리비전을 주지 않으므로 **호스트가** 이어 붙인다(저널 high-water 에서 시드). * · 시뮬 — **커널의 리비전**이 실려 온다(저널이 그 번호로 정렬되고 시간여행이 그것을 딛는다). * 그래서 버퍼는 두 모양을 함께 든다: 봉투만 있으면 라이브, `{ event, revision }` 이면 시뮬이다. */ private static flushJournal(inst: InstanceRuntime): Promise { const batch = inst.pendingJournal if (!batch?.length) return Promise.resolve() inst.pendingJournal = [] const m = inst.metrics /* 쓴 건수는 누적에, **지금 남은 것**은 backlog 에. 이 자리에서 배치 크기를 backlog 로 적으면 정상적인 배치 저장이 화면에서 경고로 보인다(2026-08-21 교정). */ if (m) { m.journaledTotal += batch.length; m._accJournal += batch.length; m.backlog = inst.pendingJournal?.length ?? 0 } /* 시뮬은 자기 리비전을 들고 온다 — 그대로 쓴다. 라이브는 여기서 이어 붙인다. */ const carried = batch.filter((b: any) => b && typeof b === 'object' && 'event' in b && typeof b.revision === 'number') const plain = batch.filter((b: any) => !(b && typeof b === 'object' && 'event' in b && typeof b.revision === 'number')) const tJournal = performance.now() const done = () => recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'journal', performance.now() - tJournal) const jobs: Promise[] = [] if (carried.length) jobs.push(this.persistCarried(inst.domainId, inst.id, carried)) if (plain.length && inst.revision != null) { /* * ── 마감된 구간 사실은 한 번만 적는다 (2026-08-30) ─────────────────────── * * 이미 있는 것을 걸러낸 뒤에 번호를 매긴다. 걸러내기 전에 매기면 **쓰지 않은 번호가 비고**, * 그 구멍이 저널의 리비전 연속성을 끊는다. */ /* * 번호는 **먼저 잡는다**(동기). 걸러낸 뒤에 잡으면 두 flush 가 겹칠 때 같은 번호를 두 번 쓴다. * 걸러진 만큼 번호가 비지만, 그 구멍은 해롭지 않다 — 리비전은 같은 시각을 가리는 데만 쓰고 * 이어짐을 요구하지 않는다(저널 머리는 최대값이다). */ const start = inst.revision inst.revision = start + plain.length jobs.push( this.dropAlreadyWritten(inst.domainId, inst.id, plain).then(fresh => fresh.length ? this.persistBatch(inst.domainId, inst.id, fresh, start) : undefined ) ) } if (!jobs.length) return Promise.resolve() /* * **약속을 돌려준다** — 주기 flush 는 기다리지 않지만(핫 경로) **정지는 기다려야 한다.** 기다리지 * 않으면 곧바로 이어지는 저널 재생이 아직 안 쓰인 구간을 못 보고, 웜스타트가 「없던 일」로 시작한다 * (실제로 그렇게 깨졌다: 확보분을 든 오더가 되살아나지 못했다). */ const written = carried.length + plain.length return Promise.all(jobs) .then(() => { done() /* * **적힌 뒤에 적는다** — 실패한 배치를 「남았다」고 세면 추이가 없는 사실을 있다고 말한다. * 창(10분)에 쌓이므로 재기동 뒤에도 「지난 여섯 시간 얼마나 적었나」를 답할 수 있다. */ this.recordJournalWrite(inst.domainId, inst.id, written) }) .catch(err => twinError('twin journal flush fail', err)) } /** * 커널이 매긴 리비전을 그대로 들고 벌크 저장 — 시뮬 경로. * * 예전에는 이 경로가 **델타마다 한 행씩** 저장했다(그리고 행마다 구조 리비전을 물었다). 규모에서 그것이 * 호스트를 먹었다(§7.1 실측). 여기서 구조 리비전은 **한 번만** 묻는다. */ /** * **마감된 구간 사실은 한 번만 적는다** — 같은 사실이 두 번 오면 한 사실이다 (2026-08-30). * * ── 왜 쓰는 자리에서 막나 ────────────────────────────────────────────────── * 커널이 그 사실들의 정체성을 내용에서 만든다(종류·대상·시작·끝). 그런데 저널의 기본키는 새로 * 만드는 uuid 라, **id 가 글자 하나까지 같아도 행이 따로 쌓였다.** * * 실측(2026-08-30): 발전 단가가 행 7개·서로 다른 id 4개였다. 8월 27일 단가가 세 벌 있었고, * 그 구간의 수익이 세 번 세어진다. * * 커넥터가 커서로 막는 것이 첫 방어선이고 그쪽도 고쳤다. 그런데 **커넥터가 한 번 실수하면 저널이 * 부풀고 그 위의 모든 합이 틀린다.** 정체성은 커널이 선언한 사실이므로, 어느 문으로 들어오든 * 지켜져야 한다 — 채우기 경로에만 있으면 그 보증은 「어느 문으로 왔느냐」에 걸린다. * * ── 값이 싼 이유 ─────────────────────────────────────────────────────────── * 마감된 구간 사실만 본다(시점의 관측은 그대로 지난다 — 반복이 뜻을 가진다). 그것들은 드물어서 * (한 시간에 한 줄) 한 묶음에 몇 개뿐이고, 그 구간의 그 종류만 색인으로 읽는다. */ private static readonly PERIOD_FACT_TYPES = new Set([ 'energy.usage.period', 'energy.generation.period', 'energy.generation.price', 'energy.tariff.basis', 'energy.bill' ]) static async dropAlreadyWritten(domainId: string, instanceId: string, envelopes: any[]): Promise { const periodFacts = envelopes.filter(e => this.PERIOD_FACT_TYPES.has(String(e?.eventType)) && typeof e?.eventId === 'string') if (!periodFacts.length) return envelopes const times = periodFacts.map(e => Date.parse(String(e.eventTime))).filter(n => Number.isFinite(n)) if (!times.length) return envelopes const types = [...new Set(periodFacts.map(e => String(e.eventType)))] const existing = await getRepository(TwinEvent) .createQueryBuilder('e') .select(['e.payload AS "payload"']) .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .andWhere('e.eventType IN (:...types)', { types }) .andWhere('e.eventTime BETWEEN :fromAt AND :toAt', { fromAt: new Date(Math.min(...times)), toAt: new Date(Math.max(...times)) }) .getRawMany<{ payload: unknown }>() .catch(() => [] as { payload: unknown }[]) const seen = new Set() for (const row of existing) { let p: any = row.payload if (typeof p === 'string') { try { p = JSON.parse(p) } catch { p = undefined } } if (typeof p?.eventId === 'string' && p.eventId) seen.add(p.eventId) } /* 한 묶음 안에 같은 것이 둘 있어도 하나만 적는다. */ const kept: any[] = [] for (const e of envelopes) { if (!this.PERIOD_FACT_TYPES.has(String(e?.eventType)) || typeof e?.eventId !== 'string') { kept.push(e) continue } if (seen.has(e.eventId)) continue seen.add(e.eventId) kept.push(e) } return kept } static async persistCarried(domainId: string, instanceId: string, items: { event: any; revision: number }[]): Promise { const repo = getRepository(TwinEvent) const structureRev = await this.structureRevOf(domainId, instanceId) const rows = items.map(it => this.journalRow(repo, domainId, instanceId, it.event, it.revision, structureRev)) await this.insertRows(repo, rows) await this.writeSubjects(domainId, instanceId, items.map(it => ({ envelope: it.event, revision: it.revision }))) } /** * **대상별 사건 목록에 함께 적는다** — 조회용 파생 표 (2026-08-27). * * 저널을 쓰는 두 문(`persistBatch` · `persistCarried`) 뒤에서 부른다. 다른 자리에서 나중에 적으면 * 「저널에는 있고 그 표에는 없는 사건」이 생기고, 그것은 화면에서 오류 없이 빠진 사건이 된다. * * **저널이 먼저다.** 이 표에 적다가 실패해도 저널 쓰기를 되돌리지 않는다 — 사실의 원본은 저널이고 * 이 표는 다시 만들 수 있다. 실패는 세어 남긴다(§`subjectWriteFailures`). * * 설계: `operato-twin/design/plans/read-models.md` */ private static async writeSubjects( domainId: string, instanceId: string, entries: { envelope: any; revision: number }[] ): Promise { if (!entries.length) return const reg = this.instances[runtimeKey(domainId, instanceId)] const itemOf = itemResolverFor(domainId, instanceId, reg?.model) await writeSubjectRows(domainId, instanceId, entries, itemOf).catch(err => twinWarn(`[twin-subject] "${instanceId}" 대상 목록 쓰기 실패 — ${err?.message ?? err}`) ) } /** * 저널 행을 넣는다 — **넣기만 한다**(2026-08-20). * * 예전에는 `save()` 였다. 그런데 `save` 는 넣은 뒤 생성 컬럼을 읽으려고 **행마다 SELECT 를 한 번 더** * 한다(시험 로그에서 그 질의가 그대로 보였다: `SELECT … FROM twin_events WHERE id = ?`). 저널은 * append-only 이고 부르는 쪽은 돌려받은 엔티티를 쓰지 않으므로 그 왕복이 순수 낭비다. * * 묶음은 **나눠서** 넣는다: 한 문에 열이 열다섯인 행을 수천 개 실으면 드라이버의 파라미터 한계에 * 걸린다(pg 는 65,535개). 500행이면 어느 드라이버에서도 안전하다. */ private static async insertRows(repo: any, rows: any[]): Promise { const CHUNK = 500 /* * ── 한 트랜잭션으로 감싸 보았고, **되돌렸다** (2026-08-22 실측) ───────────── * 「청크마다 커밋하면 fsync 가 그만큼 늘어난다」는 이유로 전체를 한 트랜잭션에 감쌌다. 쓰기 자체는 * 실제로 빨라졌다 — 느린 질의 목록에서 이 INSERT 가 사라졌다. **그런데 서버가 더 느려졌다.** * * 감싸기 전 느린 질의 최대 32.3초 · ROLLBACK 없음 * 감싼 뒤 느린 질의 최대 50.2초 · ROLLBACK 36.3초 등장 * * 기전은 드라이버다. TypeORM 의 sqlite 드라이버는 연결을 **하나**만 든다(풀 없음). 트랜잭션이 * 열려 있는 동안 그 유일한 연결은 이 묶음의 것이므로, **묶음이 끝날 때까지 앱의 모든 질의가 * 기다린다.** 청크마다 커밋하면 그 사이에 다른 질의가 끼어들 자리가 생긴다 — fsync 를 더 치르는 * 대신 **머리 막힘이 짧아진다.** * * 즉 여기서는 「커밋 수를 줄이는 것」이 목적이 아니다. 목적은 **한 번에 오래 붙잡지 않는 것**이다. * 저널 묶음의 원자성은 그 대가를 치를 만큼의 값이 아니다: 저널은 append-only 이고 리비전이 * 이어지므로, 절반만 들어간 묶음은 다음 기동의 replay 가 그 지점부터 이어받는다. * * 연결 풀이 있는 드라이버(postgres·mysql)에서는 판단이 달라질 수 있다 — 그때는 이 주석을 근거로 * 다시 재고 정하라. **드라이버마다 다른 결론이 나는 자리다.** */ for (let i = 0; i < rows.length; i += CHUNK) await repo.insert(rows.slice(i, i + CHUNK)) } /** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */ static async persistBatch(domainId: string, instanceId: string, envelopes: any[], startRevision: number): Promise { const repo = getRepository(TwinEvent) const structureRev = await this.structureRevOf(domainId, instanceId) const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1, structureRev)) await this.insertRows(repo, rows) await this.writeSubjects( domainId, instanceId, envelopes.map((e, i) => ({ envelope: e, revision: startRevision + i + 1 })) ) } /** 레지스트리 upsert(도메인+instanceId 유니크). status 인자로 provision(stopped)/start(running) 공용. */ static async register( domainId: string, instanceId: string, kind: string, model: TwinModelDef, status: 'running' | 'stopped' = 'running', restartPolicy?: RestartPolicy, origin?: any, /* * 사람이 부르는 이름과 **누가 했나** — 업무키를 나눠 둔 대가로 이름을 함께 실어야 한다. * 사람 없는 경로(부팅 자동 프로비저닝·복구)는 `actor` 를 주지 않는다 — 아무 사용자를 적으면 * 감사 기록이 거짓이 된다. */ meta?: { name?: string; description?: string; actor?: { id: string } } ): Promise { const repo = getRepository(TwinInstance) const existing = await repo.findOne({ where: { domain: { id: domainId }, instanceId } }) await repo.save( repo.create({ ...(existing ?? {}), domain: { id: domainId } as any, instanceId, kind, model, // 인스턴스↔공간 1급 링크(space #3) — model.spaceId/areaId 를 컬럼으로 승격(dual-write). model JSON 도 유지(커널·복구용). spaceId: (model as any)?.spaceId ?? existing?.spaceId, areaId: (model as any)?.areaId ?? existing?.areaId, /* 재기동 정책(ADR-0029 §2) — 명시값 우선, 없으면 기존값 보존(재프로비전이 선언을 지우지 않게). **둘 다 없으면 오류를 낸다**: 예전에는 여기서 기본값을 각인했는데, 그 기본값이 「저널 초기화」라서 선언을 빠뜨린 프로비저닝이 알리지 않고 이력을 지우는 트윈을 만들었다. 선언은 부르는 쪽의 몫이다. */ restartPolicy: restartPolicy ?? readRestartPolicy( existing?.restartPolicy, `register("${instanceId}") did not declare a restart policy and the stored row has none` ), /* 용도도 각인한다. 벤치로 뒤집는 것은 `setPurpose` 하나뿐이므로 기존값을 반드시 보존한다 — 여기서 덮으면 재프로비전이 벤치 사본을 운영 트윈으로 되돌린다. */ purpose: existing?.purpose ?? 'operational', /* **다시 읽는 방법**을 각인한다. 명시값 우선, 없으면 기존값 보존 — 원천을 모르는 호출이 한 번 지나가면서 이미 알던 것을 지워 버리면, 그 트윈은 다시 읽을 수 없는 트윈이 된다. */ origin: origin ?? existing?.origin ?? null, /* 이름은 **덮지 않고 채운다** — 사용자가 고친 이름을 재프로비전이 되돌리면 안 된다. */ name: existing?.name ?? meta?.name ?? null, description: existing?.description ?? meta?.description ?? null, /* 만든 사람은 처음 한 번만. 고친 사람은 사람이 한 경우에만 갱신한다. */ ...(existing ? {} : meta?.actor ? { creator: { id: meta.actor.id } as any } : {}), ...(meta?.actor ? { updater: { id: meta.actor.id } as any } : {}), status }) ) } /** * ── **트윈은 설비의 마스터가 아니다** (사용자 결정 2026-08-30) ───────────── * * 인원·설비·자산의 마스터는 `@things-factory/ops-master` 다. 트윈은 그것을 **당겨 와** 모델에 * 반영하고, 여기 남는 것은 그 결과의 캐시다. * * 그래서 이 함수가 하는 일에 선이 있다. * * 트윈 안에서 사람이 더한 설비 가정·시뮬레이션의 사실이다 — 트윈이 든다 (지금 이 함수) * 현장에 실제로 있는 설비 마스터의 사실이다 — 트윈이 만들지 않는다 * * 이 함수는 **트윈 제어 명령**(`resource.add`) 뒤에만 불린다. 관측에서 설비가 생기는 경로가 아니다. * 관측으로 모르는 설비가 들어오면 그것은 **마스터에 없는 것**이고, 트윈이 조용히 만들어 주면 두 * 곳이 서로 다른 설비 목록을 갖게 된다. 그때는 만들지 말고 「모르는 설비가 왔다」로 세어야 한다. * * 당겨 오는 길이 붙으면 이 주석의 위쪽 절반만 남는다. * * ── 원래 설명 ───────────────────────────────────────────────────────────── * 라이브 구조 변이(resource.add 등)를 저장된 model 에 반영 — 런타임 커널의 무버를 registry model.equipment 에 동기. * 이게 없으면 런타임엔 추가돼도(상태·저널엔 반영) 저장 model 는 원본이라 프로비저닝 편집기·재기동(loadTwinModel)이 * 추가분을 잃는다. 기존 model.equipment 항목은 보존(homeLocation 유지)하고 새 id 만 append(추가 시점 location=homeLocation). * 좌표(layout)만 다루는 register 와 달리 equipment 집합을 갱신하나, 재프로비전(purge)이 아니라 in-place 갱신이라 * 저널은 보존된다(추가는 이미 equipment 델타로 저널됨 → replay 는 id-keyed upsert 라 이중계산 없음). */ static async syncBoardEquipment(domainId: string, instanceId: string): Promise { const inst = this.instances[runtimeKey(domainId, instanceId)] const snap = inst?.kernel?.getSnapshot?.() if (!snap?.equipment) return const repo = getRepository(TwinInstance) const reg = await repo.findOne({ where: { domain: { id: domainId }, instanceId } }) if (!reg?.model) return const model = reg.model as TwinModelDef const known = new Set((model.equipment ?? []).map(m => m.id)) const added = snap.equipment .filter((m: any) => !known.has(m.id)) .map((m: any) => ({ id: m.id, kind: m.kind, homeLocation: m.location })) if (!added.length) return model.equipment = [...(model.equipment ?? []), ...added] reg.model = model await repo.save(reg) } /** * 프로비저닝 — 레지스트리에 구조(model)를 등록만 하고 기동하지 않음(status='stopped'). * 재프로비전 시 구조 시그니처가 바뀌면(노드·무버 집합/속성 변경) 기존 저널을 purge 한다(ADR-0015): * model 는 replay 의 마스터라 구조가 바뀌면 과거 이벤트의 전제가 깨진다. 좌표(layout)만 바뀌면 저널 보존. */ /** * 실행 중이면 구조를 교체하지 않는다 — **판정 문장을 한 곳에 둔다.** * * 인제스트는 전량 교체라, 이벤트를 계산하고 있는 커널 밑에서 바닥을 바꾸는 셈이 된다. 지금은 거절이 * 유일한 답이지만 최종형은 아니다(`adoptStructure` 배선). 두 곳에서 같은 문장으로 거절해야 * 나중에 이 규칙을 걷어낼 때 걷어낼 것이 하나로 보인다. */ private static assertNotRunning(domainId: string, instanceId: string): void { if (!this.instances[runtimeKey(domainId, instanceId)]) return /* * **거절도 번역돼야 한다.** 이 문장은 전달되어 리졸버의 `catch` 를 지나 화면 토스트에 그대로 * 떴다 — 다섯 언어 제품에서 영어 한 줄이 사용자에게 보였다(2026-08-14 실측). * * 그래서 코드와 파라미터를 예외에 실어 보낸다(`ImportSpaceRefusal` 과 같은 규약: 영어 문장은 * canonical 이고 로그·폴백으로만 쓰인다). 이유가 코드로 오면 화면이 옮길 수 있다. */ const err: any = new Error(`instance "${instanceId}" is running — stop before re-provisioning`) err.code = 'twin-running' err.params = { instanceId } throw err } /** * 돌면서 공장을 전환한다 — **미러 전용.** * * 미러가 비추는 현실은 안 멈춘다. 설비 한 대가 늘었다고 트윈을 멈췄다 세우면 그 사이의 사실을 * 잃고, 라이브에서 유실은 곧 거짓이다. 그래서 미러는 **동작 중에** 새 구조를 받는다. * * 시뮬레이션은 여기 오지 않는다. 조건이 바뀐 실험은 다른 실험이므로 멈추고 다시 세우는 것이 * 옳다 — 커널이 그렇게 거절한다(`FlowEngine.adoptStructure`). * * 세 가지가 **함께** 일어나야 한다. 하나라도 빠지면 층이 어긋난다: * ① 리비전 — 이 시점 이후의 이벤트가 새 번호를 달고 다닌다(재생이 마디를 나눌 수 있게) * ② 저장 model·구조 행 — 조회가 새 구조를 본다 * ③ 커널 — 지금 계산에 들어가는 이벤트가 새 구조 위에서 계산된다 */ static async adoptStructure( domainId: string, instanceId: string, model: TwinModelDef, comment?: string, origin?: any ): Promise<{ rev: number } & StructureShift> { const inst = this.instances[runtimeKey(domainId, instanceId)] if (!inst) throw new Error(`instance "${instanceId}" is not running — provision it instead of adopting`) if (inst.mode !== 'live') throw new Error(`instance "${instanceId}" is a simulation — stop it and re-provision (a changed experiment is a different experiment)`) const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!reg) throw new Error(`instance "${instanceId}" is running but not registered — refusing to adopt into an unknown twin`) const rev = await this.recordStructure(domainId, instanceId, model, comment) await this.register(domainId, instanceId, reg.kind, model, 'running', undefined, origin) /* 커널을 **마지막에** 전환한다 — 저장이 실패하면 메모리만 새 구조가 되어, 재기동하면 알리지 않고 옛 구조로 돌아간다(고치기 어려운 어긋남이다). */ const shift: StructureShift = inst.kernel.adoptStructure(model) /* * **공정 명세·현장이 정한 시간도 함께 싣는다** — 커널의 구조 전환은 자리·설비를 갈지만, 공정 시간은 * 호스트가 실어 주는 것이므로(`model.localDurations`) 여기서 다시 부르지 않으면 돌고 있는 미러는 * 재기동할 때까지 옛 시간으로 돈다(화면은 「넣었습니다」라고 말한 값이다). */ this.applyOperations(inst.kernel, model, instanceId) /* * **투영 행도 새 구조를 따른다** (2026-08-18 실측으로 붙임). * * 행(`twin_locations`·`twin_equipment`)은 구조의 캐시다. 그런데 이 경로는 커널만 갈고 행은 그대로 * 두었다 — 돌고 있는 미러에 값을 선언하면 커널은 새 값으로 계산하는데 **항목 패널은 옛 행을 보여 * 준다**(넣은 값이 화면에서 보이지 않는다). 구조가 바뀐 순간이 곧 캐시를 다시 그릴 순간이다. * * 실패는 흡수한다: 투영이 막혀도(예: 모델에 중복 id) 커널은 이미 새 구조로 돌고 있으므로 그 사실을 * 되돌리지 않는다 — 다만 알리지 않고 넘기지 않고 말한다. */ await projectStructure(domainId, instanceId, model, instanceId).catch((err: any) => twinWarn(`[twin-engine] "${instanceId}" adopted a new structure but its projected rows were not refreshed — ${err?.message ?? err}`) ) /* * **구조가 바뀐 순간이 상태가 바뀐 순간이다** — 그러니 브로드캐스팅한다. * * ── 무엇이 났나 (2026-08-18) ──────────────────────────────────────────── * 구조 전환은 커널만 갈고 알리지 않고 끝났다. 그런데 화면이 보는 것 상당수가 구조에서 파생된다 — * 계약 대비 판정, 주목 신호, 자리 색. 현장이 계약을 고쳐 선언한 순간 조건이 성립하는데도, 상태 * 브로드캐스팅이 없어서 지도 레일은 **다음 계측 표본이 올 때까지** 옛 화면을 들고 있었다(그 사이 헤더 * 배지는 4초 폴링으로 먼저 알아, 「배지엔 있고 목록엔 없는」 어긋난 화면이 실제로 보였다). * * 새 브로드캐스팅 경로를 만들지 않는다: dirty 를 세워 **이미 있는 병합 규율**(200ms)에 얹는다. 구조 전환은 * 드물지만, 여러 트윈에 잇달아 들어올 수 있고(현장 일괄 선언) 그때도 브로드캐스팅률 상한은 지켜야 한다. */ inst.dirty = true /* 구조가 갈렸으면 무엇이 달라졌는지 사건으로 말할 수 없다 — 전부 다시 만든다. */ this.markItemsDirty(inst) this.ensureBroadcastCoalescer() return { rev, ...shift } } static async provision( domainId: string, instanceId: string, kind: string, model: TwinModelDef, /** * **재기동 정책은 생성 시점의 선언이다**(ADR-0029 §2) — 트윈은 정책 없이 존재할 수 없다. * * 예전에는 이 자리가 없었고 `register` 가 기본값(`sim-experiment` = 저널 초기화)을 각인했다. 그래서 * 선언을 빠뜨린 프로비저닝이 **재기동마다 이력을 지우는 트윈**을 알리지 않고 만들었다. 이제 만드는 쪽이 * 말해야 한다: 이 트윈이 자기 과거를 어떻게 대하는지는 만드는 사람이 아는 사실이다. */ restartPolicy: RestartPolicy, comment?: string, origin?: any, /** 이름·저자 — 업무키를 나눠 둔 대가로 이름을 함께 남긴다(`register` 의 `meta` 그대로). */ meta?: { name?: string; description?: string; actor?: { id: string } } ): Promise { this.assertNotRunning(domainId, instanceId) /* 종류를 **저장하기 전에** 검증한다. 나중에 기동할 때 걸리면 원인이 프로비저닝에서 멀어지고, 그 사이 레지스트리에는 돌릴 수 없는 트윈이 앉아 있다. */ kernelFor(kind) const repo = getRepository(TwinInstance) const existing = await repo.findOne({ where: { domain: { id: domainId }, instanceId } }) /* * **구조가 바뀌어도 역사를 지우지 않는다.** * * 예전에는 서명이 다르면 그 트윈의 저널을 통째로 지웠다. 안 지우면 옛 이벤트를 새 공장에 대고 * 계산하게 되어 이력이 거짓말을 했기 때문이다 — 부스가 둘이던 시절의 사실을 여섯 개짜리 공장에 * 계산하면 그때 없던 부스에서 일이 있었던 것처럼 보인다. 선택지가 **역사를 잃거나 거짓말을 * 하거나** 둘뿐이었고, 그래서 공장을 고칠 때마다 이력을 버려야 했다. * * 이제 셋째 길로 간다: 바뀐 구조를 **새 리비전**으로 남기고, 앞으로 쓰이는 이벤트가 그 번호를 * 달고 다닌다. 재생은 구조가 바뀌는 지점에서 전환한 뒤 이어서 계산한다(`replaySegments`). */ await this.recordStructure(domainId, instanceId, model, comment) await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : (existing?.status as any) ?? 'stopped', restartPolicy, origin, meta) } /** * 이 구조를 리비전으로 남기고 그 번호를 돌려준다 — **바뀌었을 때만** 새 번호가 생긴다. * * 프로비저닝은 부팅마다 다시 도는데, 그때마다 리비전이 늘면 이력이 뜻 없는 마디로 잘게 쪼개진다. * 그래서 **구조 서명이 같으면 있던 리비전을 그대로 쓴다.** */ static async recordStructure(domainId: string, instanceId: string, model: TwinModelDef, comment?: string): Promise { const repo = getRepository(TwinStructure) const signature = this.structureFingerprint(model) const latest = await repo.findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } }) /* * **저장된 지문이 아니라 저장된 공장과 비교한다.** * * 지문 계산 방식을 한 번이라도 바꾸면(원문 → 해시로 바꿨다) 저장된 문자열은 전부 옛 방식의 * 것이라 무엇과도 안 맞는다. 그러면 **아무것도 안 바뀐 트윈들이 전부 "구조가 바뀌었다"** 로 * 기록된다 — 실제로 그렇게 됐고, 재기동 한 번에 리비전이 통째로 하나씩 늘었다. * * 그래서 안 맞을 때 한 번 더 묻는다: 저장된 **모델**로 지문을 다시 계산하면 같은가? 같다면 * 공장은 그대로이고 지문 표기만 낡은 것이니, 리비전을 만들지 않고 표기만 고친다. */ if (latest && latest.signature !== signature && this.structureFingerprint(latest.model as TwinModelDef) === signature) { latest.signature = signature await repo.save(latest) } if (latest?.signature === signature) { this.structureRevCache[runtimeKey(domainId, instanceId)] = latest.rev return latest.rev } const rev = (latest?.rev ?? 0) + 1 await repo.save(repo.create({ domain: { id: domainId } as any, instanceId, rev, signature, model: model as any, ...(comment ? { comment } : {}) })) this.structureRevCache[runtimeKey(domainId, instanceId)] = rev /* 구조가 바뀌면 재구성 캐시는 옛 공장의 것이다 — 버린다(지우는 건 캐시뿐, 사실은 남는다). */ delete this.recovered[runtimeKey(domainId, instanceId)] if (latest) console.info(`[twin-engine] "${instanceId}" structure changed → revision ${rev} (history kept; older events stay under revision ${latest.rev}).`) return rev } /** * 이 트윈이 거쳐 온 구조들 — **무엇이 언제 달라졌나.** * * 리비전은 그 시절 공장을 통째로 담지만(재생에 필요하다), 사람이 보고 싶은 것은 통째가 아니라 * 차이다. 모델은 빼고 **차이 요약만** 내보낸다 — 62KB 짜리 보드를 화면에 실어 보낼 이유가 없다. */ static async structureHistory( domainId: string, instanceId: string ): Promise<{ rev: number; createdAt?: Date; comment?: string; events: number; diff: StructureDiff }[]> { const revs = await getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'ASC' } }) if (!revs.length) return [] /* 리비전마다 그 아래에서 몇 건이 일어났나 — 이력의 무게를 보여준다(짧게 스쳐간 구조인지). */ const counts = new Map() for (const row of await getRepository(TwinEvent) .createQueryBuilder('e') .select('e.structureRev', 'rev') .addSelect('COUNT(1)', 'n') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .groupBy('e.structureRev') .getRawMany()) counts.set(row.rev === null || row.rev === undefined ? null : Number(row.rev), Number(row.n)) /* 번호 없는 옛 행은 **가장 오래된 리비전**의 몫이다 — 재생이 그렇게 접으므로 세는 것도 같아야 한다. */ const oldest = revs[0].rev const legacy = counts.get(null) ?? 0 return revs.map((r, i) => ({ rev: r.rev, createdAt: r.createdAt, ...(r.comment ? { comment: r.comment } : {}), events: (counts.get(r.rev) ?? 0) + (r.rev === oldest ? legacy : 0), diff: diffStructures(i === 0 ? undefined : (revs[i - 1].model as any), r.model as any) })) } /** 지금 쓰이는 구조 리비전 — 이벤트에 찍을 번호. 아직 없으면 기록하며 만든다. */ static async currentStructureRev(domainId: string, instanceId: string, model?: TwinModelDef): Promise { const latest = await getRepository(TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } }) if (latest) return latest.rev return model ? this.recordStructure(domainId, instanceId, model) : undefined } /** 구조 동일성 지문 — 좌표(layout) 등 뷰 관심사는 제외하고 커널이 보는 위상·용량·무버만. */ /** * 지문을 **64자로 줄인다** — 컬럼이 varchar(64) 다. * * 원문 지문은 자리·설비를 전부 이어 붙인 문자열이라 큰 공장에서는 수만 자가 된다. sqlite 는 * 길이를 강제하지 않아 그냥 들어가지만 **Postgres 는 거기서 터진다** — 개발에서는 멀쩡하고 * 운영에서만 죽는 종류의 실패다. 비교에만 쓰는 값이므로 해시로 충분하다. */ private static structureFingerprint(model: TwinModelDef): string { return createHash('sha256').update(this.structureSignature(model)).digest('hex') } private static structureSignature(model: TwinModelDef): string { const locations = [...(model.locations ?? [])] .map(n => `${n.id}:${n.type}:${n.capacity}`) .sort() .join('|') const equipment = [...(model.equipment ?? [])] .map(m => `${m.id}:${m.kind}:${m.homeLocation}`) .sort() .join('|') return `N[${locations}]M[${equipment}]` } /** 레지스트리 model 로 기동(프로비전된 인스턴스 start). model 인자 없이 저장된 구조로 재기동. */ static async startFromRegistry(domainId: string, instanceId: string, restartPolicy?: RestartPolicy): Promise { const key = runtimeKey(domainId, instanceId) if (this.instances[key]) return this.instances[key] const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`) /* * ── 선언된 `restartPolicy` 가 **기동 방식을 정한다** (2026-08-20) ────────── * * 예전에는 이 함수가 정책을 **읽어서 넘기기만** 했고, 기동은 언제나 시뮬 경로였다. 그래서 미러로 * 선언한 트윈을 화면에서 시작하면 **시뮬로 떴다** — 그 트윈은 유입을 받지 못한다(`ingestLive` 는 * 라이브가 아니면 0 을 돌려준다). 오류는 나지 않고, 화면에는 「running」이라 적힌다. * * 부팅 경로(`resumeRow`)에는 그 갈림이 있었는데 여기에는 없었다. 즉 **부팅으로 살아난 미러는 * 받고, 사람이 시작한 미러는 받지 못했다.** 같은 선언이 두 결과를 내는 것은 결함이다. * * 갈림을 여기 한 곳에 둔다 — 부팅도 이 문을 지난다. * * ── `reset` 은 선언대로 **저널을 비운다** (2026-08-20) ─────────────────── * 「seed 재현」은 백지에서 다시 시작한다는 뜻이다. 예전에는 그 선언이 구현되지 않아 `reset` 트윈이 * 사실상 이어졌고, 그래서 「이 트윈의 이력은 왜 재기동을 넘겨 남아 있나」를 아무도 설명할 수 없었다. * * 이것은 **사실을 지우는 동작**이라 순서를 지켜 켰다: 먼저 사람이 각 트윈의 정책을 다시 선언할 문을 * 만들고(`setTwinRestartPolicy`), 이력을 두고 볼 트윈을 `resume` 으로 옮긴 뒤에 켠다. 그 순서를 * 뒤집으면 선언을 지킨 대가로 남의 이력을 지운다. */ const policy = restartPolicy ?? readRestartPolicy(reg.restartPolicy, `twin "${instanceId}"`) if (policy === 'resync') { /* 미러는 **관측 구동**으로 세운다 — 시뮬로 세우면 없던 움직임을 스스로 만든다. 시각 기준은 공간이 갖는다(부팅 경로와 같은 규칙). */ return this.startLive(instanceId, domainId, reg.kind, await this.withSpaceTimeBase(reg.model as TwinModelDef, domainId)) } if (policy === 'reset') { /* * 씨앗 재현 — 저널을 비우고 리비전 0 부터 다시 센다. 복구본도 함께 버린다(`resetJournal` 이 한다): * 남겨 두면 백지로 시작한 트윈에 옛 상태가 되살아나 「씨앗 재현」이 아니게 된다. * * **벤치 사본은 예외다.** 그것은 누군가 지켜보는 실험이고, 그 실험의 기록을 기동이 지우면 실험이 * 사라진다(부팅은 벤치를 되살리지도 않는다 — `resumeRow`). */ if (reg.purpose !== 'bench') await this.resetJournal(domainId, instanceId) } /* * 웜스타트 재료를 **여기서 확실히 확보한다.** * `start()` 는 동기라 스스로 캐시를 읽을 수 없어서 `recovered` 에 미리 담겨 있기를 기대하는데, * 그건 `bootstrap()` 이 먼저 돌았을 때만 참이다. 부팅 순서에 기대면 어떤 날은 재고가 살아나고 * 어떤 날은 알리지 않고 빈 채로 뜬다 — 재현되지 않는 결함이 가장 나쁘다. * 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서). */ if (!this.recovered[key] && reg.purpose !== 'bench') { const cached = await this.warmSeedFor(domainId, instanceId).catch(() => null) if (cached?.state) { this.recovered[key] = cached } else { const state = await this.recover(domainId, instanceId).catch(() => null) if (state) this.recovered[key] = { revision: state.revision, state } } } /* * 저널에 남아 있는 마지막 번호 — **모드와 무관하게** 이것을 이어 센다. * * 저널을 초기화하고 기동하는 정책(`reset`)이라면 이 값이 0이라 아무 영향이 없다. 규칙을 * 모드별로 구분하지 않는 이유: "저널이 비어 있지 않으면 그 뒤부터" 하나면 어느 모드에서도 * 겹칠 수 없고, 모드가 늘어도 이 자리를 다시 손볼 일이 없다. */ const last = await getRepository(TwinEvent) .createQueryBuilder('e') .select('MAX(e.revision)', 'max') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .getRawOne() return this.start( instanceId, domainId, /* 커널 종류·현실 선언은 레지스트리에 반드시 있다(둘 다 NOT NULL). 예전에는 `?? 'wms'` 로 메웠는데, 그건 YMS/MES 트윈을 **알리지 않고 WMS 로 부팅**시키는 길이었다 — 오류 없이 다른 공장이 뜬다. */ reg.kind, reg.model as TwinModelDef, /* 저장된 값은 위에서 **엄격히** 읽었다 — 모르는 값을 기본값으로 메우면 그 트윈이 알리지 않고 다르게 재기동한다. */ policy, reg.purpose, Number(last?.max ?? 0) || 0 ) } /** * 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 restartPolicy 에 따라 재기동 방식을 구분한다. * 부팅 경로를 한 곳에 모아 "런타임 ≠ 현실" 범주오류를 코드로 강제한다. * - 'resync' : 현실=외부 실물 → startLive(재동기). 이벤트는 어댑터 ingest 로 유입, 커널 tick 없음. * - 'resume' : 생성 타임라인 지속 → 저널 보존 + resume(현실 이어감). * ⚠ GATE(§8 ②③): 커널 상태 직렬화/재개가 아직 없어 진짜 resume 불가 → 실 필요·실부하 시 구현. * 그때까지는 저널을 보존하되 revision 충돌을 피하려 fresh 로 기동하고, 선언과 구현의 간극을 크게 경고한다. * - 'reset' : seed 재현 → resetJournal + fresh 기동(백지 시작이 버그가 아니라 선언된 거동). * 반환: 기동된 InstanceRuntime. */ static async bootDeclared(domainId: string, instanceId: string, mode: RestartPolicy): Promise { if (mode === 'resync') { const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`) /* * ── **미러도 씨앗을 여기서 확보한다** (2026-08-24) ──────────────────────── * * 이 줄이 없어서 미러는 저장된 체크포인트를 **한 번도 읽지 않았다.** `startLive` 는 동기라 스스로 * 캐시를 읽을 수 없고 `recovered` 에 담겨 있기를 기대하는데, 그것을 담는 곳은 `bootstrap` 과 * `start` 뿐이었다 — 그리고 미러의 실제 기동 경로는 **여기**다. 그래서 「읽는 쪽·쓰는 쪽이 다 * 있는데 아무 일도 일어나지 않는」 상태가 됐다. * * 이것은 `start` 가 이미 배운 교훈과 같은 자리다: 부팅 순서에 기대면 어떤 날은 상태가 살아나고 * 어떤 날은 알리지 않고 빈 채로 뜬다 — **재현되지 않는 결함이 가장 나쁘다.** 그래서 순서에 기대지 않고 * 이 자리에서 확보한다(이미 담겨 있으면 그것을 쓴다). */ const key = runtimeKey(domainId, instanceId) if (!this.recovered[key]) { const seed = await this.warmSeedFor(domainId, instanceId).catch(() => null) if (seed?.state) this.recovered[key] = seed } return this.startLive(instanceId, domainId, reg.kind, reg.model as TwinModelDef) } if (mode === 'resume') { /* * **이어지는 현실** — 저널을 지우지 않는다. * * 예전에는 여기서 저널을 지우고 fresh 로 띄웠다. resume 기계장치가 없다고 봤기 때문인데, * 실제로 없던 것은 **번호 이어 세기 하나**였다: 상태 복원은 이미 `startFromRegistry` 가 * 저널 재생(또는 체크포인트)으로 하고 있었고, 막힌 것은 커널이 리비전을 0부터 다시 세어 * 기존 행과 겹치는 문제였다. 그 하나를 `resumeRevision` 으로 풀었으므로 이제 이어 간다. * * 아직 이어지지 않는 것(정직하게): 시나리오 생성기의 난수 흐름은 재기동에 이어지지 않는다 — * 같은 씨앗에서 다시 시작하므로 자극의 패턴이 재기동 지점에서 한 번 끊긴다. 쌓인 사실과 * 상태는 이어지고, 앞으로 일어날 일의 무작위 순서만 새로 시작한다. */ return this.startFromRegistry(domainId, instanceId, 'resume') } // `reset`: seed 재현 — 저널 초기화 후 revision 0 부터 재실행. await this.resetJournal(domainId, instanceId) return this.startFromRegistry(domainId, instanceId, 'reset') } /** * 저널 초기화 — 인스턴스 이벤트 저널 purge(레지스트리·상태는 유지). * 데모 fresh-start 용: 매 부팅 seed 부터 새로 실행하는 데모에서, 복구본 폐기 후 revision 0 부터 재실행하면 * 옛 런과 revision 이 겹쳐 저널이 오염된다 → start 전에 호출해 현재 런만 남긴다. */ static async resetJournal(domainId: string, instanceId: string): Promise { await getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId }) delete this.recovered[runtimeKey(domainId, instanceId)] /* * ── **체크포인트도 함께 무효로 만든다** (2026-08-20) ────────────────────── * * 저널만 지우면 씨앗 재현이 되지 않는다: 웜스타트는 체크포인트를 **먼저** 보고, 그것이 남아 있으면 * 백지로 시작한 트윈에 옛 상태가 되살아난다(저널은 비었는데 재고가 있는 트윈이 된다). * 시간여행이 딛는 사슬 캐시도 같은 이유로 무효다 — 지워진 리비전을 가리키게 된다. * * 공통 캐시 서비스에는 삭제가 없다(get/set/clearStale 뿐). 그래서 **「없음」을 적는다** — 값을 * 지어내는 것이 아니라 「체크포인트가 없다」는 사실을 적는 것이고, 읽는 쪽은 이미 `cached?.state` 로 * 그것을 가려낸다. 공통 모듈에 삭제를 새로 뚫는 것은 이 한 자리를 위해 하기에는 큰 변경이다. */ await cacheService .setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision: 0, state: null }, this.SNAPSHOT_TTL_S) .catch(err => twinWarn(`[twin-engine] "${instanceId}" checkpoint not invalidated — a seed run may inherit old state:`, err?.message ?? err)) await cacheService .setInCache(this.CHAIN_INDEX_CACHE_ID, { domainId, instanceId }, { revisions: [] }, this.SNAPSHOT_TTL_S) .catch(err => twinWarn(`[twin-engine] "${instanceId}" chain index not cleared — time travel may point at deleted revisions:`, err?.message ?? err)) } /** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */ static async remove(domainId: string, instanceId: string): Promise { await this.stop(domainId, instanceId) this.forgetInstance(domainId, instanceId) await getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId }) await getRepository(TwinInstance).delete({ domain: { id: domainId } as any, instanceId }) } /** * 이 트윈에 대해 **메모리에 남은 것을 전부 잊는다** — 지움의 한 자리. * * 캐시가 라이프사이클과 어긋나 있었다: 저널·등록부는 지우면서 인스턴스별 캐시는 남겨, 삭제된 트윈의 * 항목이 **서버 재기동까지 살아 있었다.** 항목 하나가 하루치 KPI 그룹을 물고 있으므로 잊지 않으면 * 지운 트윈이 계속 메모리를 차지한다. TTL 은 값이 낡는 것을 막을 뿐 **없어진 주인을 지우지 않는다.** * * 잊을 것을 여기 모아 두는 이유: 캐시를 새로 만드는 사람이 지우는 자리를 찾아 헤매지 않게 한다. * 새 인스턴스별 캐시를 만들면 **이 함수에 한 줄을 더한다** — 그러지 않으면 같은 누수가 다시 생긴다. */ private static forgetInstance(domainId: string, instanceId: string): void { const key = runtimeKey(domainId, instanceId) delete this.recovered[key] this.measuredCache.delete(key) delete this.structureRevCache[key] } /** 관리 목록 — domain 의 등록 인스턴스 전체(라이브 여부·최신 저널 revision·노드/무버 수 포함). */ static async list(domainId: string): Promise { const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } }) /* * **현장 이름을 함께 낸다** — 목록이 `spaceId` 만 내보내서 화면이 이름을 따로 조회하고 있었다. * 그 조회(`twinSpaces`)는 공간마다 카운트 쿼리를 도는 N+1 이라 느리고, 그 사이 화면은 **id 를 * 이름처럼** 보여 준다(2026-08-13 관측: 「현장 crew-probe-site」). 한 번에 읽어 map 으로 붙이면 * 왕복도 줄고 그 깜빡임도 사라진다. 이름이 없으면 **비운다** — id 를 이름으로 베끼지 않는다. */ const spaceNameOf = new Map( (await getRepository(TwinSpace).find({ where: { domain: { id: domainId } } })).map(s => [s.spaceId, s.name]) ) /* * **최신 리비전은 트윈마다 인덱스 끝에서 한 행씩 읽는다.** * * ── 그룹 질의로 바꾸었다가 되돌렸다 (2026-08-22 실측) ───────────────────── * 예전 주석은 이랬다: 「트윈마다 `findOne(order revision DESC)` 을 돌고 있었다 — 13개면 질의 * 13번이고, 트윈이 늘면 그대로 자란다. 한 번의 그룹 질의로 바꾼다.」 * * **질의 개수를 줄였지만 일의 양을 늘렸다.** * * findOne × 13 `(domain, instance, revision)` 인덱스 **끝에서 한 행** — 각각 O(1) * GROUP BY 한 번 그 도메인의 저널을 **전수 집계** — O(전체) * * 저널이 작을 때는 이득이었고, 커지면서 손해가 됐다. 실측(저널 1,134만 행): 이 그룹 질의가 * **44.7초**였다. 그리고 sqlite 드라이버는 연결이 하나이므로 그동안 앱의 모든 질의가 그 뒤에 섰다 — * 같은 순간 29행짜리 `twin_instances` 조회도 44.7초로 찍혔다. 이 목록은 「트윈 관리·현장 구성· * 엔티티 패널이 모두 읽는 자리」여서, 화면을 열 때마다 서버 전체가 그만큼 멈췄다. * * 트윈 수는 수십이고 저널은 천만이다. **작은 것을 여러 번 읽는 편이 큰 것을 한 번 훑는 것보다 싸다.** * 질의 개수가 트윈 수에 비례해 자라는 것은 사실이지만, 각 질의가 인덱스 끝 한 행이므로 그 성장은 * 감당된다 — 그리고 병렬로 묻는다. * * **다시 그룹 질의로 바꾸지 말 것.** 바꾸려면 이 숫자를 먼저 다시 재라. */ const tipOfInstance = new Map() await Promise.all( rows.map(async r => { try { /* * **집계 하나로 묻는다 — 행을 실어 오지 않는다.** * * `findOne(order revision DESC)` 로 두었더니 두 가지가 틀렸다. ① 관계를 가진 엔티티의 정렬 * 조회는 TypeORM 이 `DISTINCT` 로 감싸므로 `select` 로 컬럼을 좁히면 `distinctAlias. * TwinEvent_id` 가 없다고 터진다(실측: 모든 트윈에서 SQLITE_ERROR). ② `select` 를 떼면 * **`payload` 까지 실어 온다** — 이 값 하나를 알려고 큰 TEXT 를 읽는다. * * 그래서 단일 집계를 쓴다. `(domain, instance, revision)` 인덱스에서 instance 가 정해지면 * `MAX` 는 그 구간의 끝이므로 sqlite 가 끝을 집는다(실측 0.007초 · 도메인 전체 GROUP BY 는 * 14.5초). 이 파일의 다른 자리도 같은 규율이다(§`timeRange` · 기동 시 최종 리비전). */ const row = await getRepository(TwinEvent) .createQueryBuilder('e') .select('MAX(e.revision)', 'revision') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId: r.instanceId }) .getRawOne<{ revision: unknown }>() /* 행이 없으면 `null` 이다 — 그때는 비워 둔다(0 은 "아무 일도 없었다" 는 주장이다). */ if (row?.revision != null) tipOfInstance.set(String(r.instanceId), Number(row.revision) || 0) } catch (err: any) { /* 한 트윈의 조회가 실패해도 **0 으로 메우지 않는다** — 리비전 0 은 "아직 아무 일도 없었다" 는 사실 주장이다. 비워 두면 아래에서 undefined 로 남고, 화면은 그것을 "모름" 으로 그린다. 그리고 그 트윈만 모름이 된다 — 예전에는 집계 하나가 실패하면 전부 모름이었다. */ twinError(`[twin-engine] latest revision lookup failed "${r.instanceId}"`, err?.message ?? err) } }) ) const out: any[] = [] for (const r of rows) { const model = (r.model as TwinModelDef) ?? { locations: [], equipment: [] } out.push({ instanceId: r.instanceId, /* * **이름을 함께 낸다.** 업무키(`instanceId`)만 내보내고 있어서 목록이 `crew-two` 만 보였다 — * 업무키를 나눠 둔 설계에서는 이름을 같이 실어야 그 나눔이 값을 한다. * 없으면 `undefined` 다(id 를 베끼지 않는다 — 화면이 이름 없음을 알아볼 수 있어야 한다). */ name: r.name ?? undefined, description: r.description ?? undefined, kind: r.kind, status: r.status, spaceId: r.spaceId, // co-location: 같은 spaceId 인스턴스들이 한 현장 공유 — 현장뷰 집약 키(G7) /* 사람이 읽는 현장 이름 — 없으면 `undefined`(화면이 "이름 없음" 을 알아볼 수 있어야 한다). */ spaceName: (r.spaceId ? spaceNameOf.get(r.spaceId) : undefined) || undefined, restartPolicy: r.restartPolicy, // 재기동 정책(ADR-0029 §2) — resync/resume/reset. 선언이므로 비어 있을 수 없다. purpose: r.purpose, // 운영 vs 벤치 사본(1급 구별 — 이름 접두사 아님). 저장 시 각인되므로 비지 않는다. copyOf: r.copyOf ?? undefined, running: !!this.instances[runtimeKey(domainId, r.instanceId)], /* * **계측이 들어오고 있나** — 「도는 중」과 다른 사실이다. * * 미러 트윈은 피드가 떨어져도 커널이 돌기만 하면 「도는 중」이었다. 그래서 아무것도 받지 않는 * 트윈과 흐르는 트윈이 화면에서 똑같이 보였다(재기동 뒤 실제로 그랬다). 시뮬·정지 트윈에는 * 「해당 없음」이다 — 없어야 하는 것을 끊겼다고 부르지 않는다. */ /* 스스로 멈춘 이유 — 있으면 낸다. 재기동 뒤에는 없다(그때는 「모른다」가 사실이다). */ stopNote: this.stopNotes.get(runtimeKey(domainId, r.instanceId)) || undefined, liveFeed: liveFeedStateOf({ restartPolicy: r.restartPolicy, running: !!this.instances[runtimeKey(domainId, r.instanceId)], instanceId: r.instanceId }), revision: tipOfInstance.get(r.instanceId) ?? 0, /* * **리더를 거쳐 센다** — 보드 키를 직접 읽으면 옛 세대 모델이 0 으로 보인다. * * 커널은 자리·설비 배열의 **옛 세대 키까지 흡수해** 읽어 주는데, 이 목록은 새 이름만 직접 * 세고 있었다. 그래서 옛 모델 12개가 화면에 **자리 0 · 설비 0** * 으로 떴다 — 오류 없이, 그냥 빈 공장처럼. 세는 규칙이 두 벌이면 이런 식으로 어긋난다. */ locationCount: readBoardLocations(model).length, equipmentCount: readBoardEquipment(model).length }) } return out } /** 운영 공간 목록(벤치 소스 선택기) — 이름·규모 포함, 벤치 사본 제외. */ static async listSpaces(domainId: string): Promise<{ spaceId: string; name: string; instances: number; locations: number; equipment: number }[]> { const nameOf = new Map((await getRepository(TwinSpace).find({ where: { domain: { id: domainId } } })).map(s => [s.spaceId, s.name])) const insts = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } }) const agg = new Map() for (const r of insts) { /* 벤치 제외는 **선언으로만** 판단한다. 예전에는 spaceId 이름(`bench-`·`loadtest*`)으로도 추측했는데, 그러면 그렇게 이름 지은 진짜 운영 공간이 목록에서 오류 없이 사라진다. purpose 가 정본이다. */ if (!r.spaceId || r.purpose === 'bench') continue const model: any = r.model ?? {} const a = agg.get(r.spaceId) ?? { spaceId: r.spaceId, name: nameOf.get(r.spaceId) ?? r.spaceId, instances: 0, locations: 0, equipment: 0 } a.instances++ /* 목록과 **같은 규칙**으로 센다 — 여기만 직접 세면 공간 요약과 인스턴스 목록이 다른 수를 말한다. */ a.locations += readBoardLocations(model).length a.equipment += readBoardEquipment(model).length agg.set(r.spaceId, a) } return [...agg.values()] } /** 인스턴스 목적 표식(운영/벤치) 설정 — 벤치 사본을 1급 속성으로 구별(이름 접두사 아님). */ static async setPurpose(domainId: string, instanceId: string, purpose: 'operational' | 'bench', copyOf?: string): Promise { await getRepository(TwinInstance).update({ domain: { id: domainId } as any, instanceId }, { purpose, copyOf: copyOf ?? null } as any) } /** * 공간 구조 판독 — 완전복제(벤치 사본)의 소스. 저장물이 model(위상)·TwinSpace.content(치수·geo·표현)·TwinArea(영역)로 * 분산돼 있으므로 셋을 모아 돌려준다. 호출측이 ReferenceMaster 로 재구성 → 재식별 → ingestMaster. */ static async spaceStructure(domainId: string, spaceId: string): Promise<{ name?: string /** 설계 좌표계 범위(미터)와 지오레퍼런스 — 사본이 원본과 같은 자리에 서려면 함께 가야 한다. */ frame: { extentX?: number; extentY?: number; geo?: any; primaryRepresentationId?: string } areas: any[] instances: { instanceId: string; kind?: string; model: any }[] } | null> { const sp = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId } }) const areas = sp ? (await getRepository(TwinArea).find({ where: { domain: { id: domainId } as any, space: { id: sp.id } as any } })).map(a => ({ id: a.areaId, type: a.type, name: a.name, parentId: a.parentId, layout: a.layout })) : [] const insts = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, spaceId } }) if (!insts.length) return null return { name: sp?.name, frame: { extentX: sp?.extentX, extentY: sp?.extentY, geo: sp?.geo, primaryRepresentationId: sp?.primaryRepresentationId }, areas, instances: insts.map(i => ({ instanceId: i.instanceId, kind: i.kind, model: i.model })) } } /** 공간(+표현·영역) 삭제 — 벤치 정리 등. 호출측이 바인딩 인스턴스를 먼저 제거하는 전제(가드 없음). deleteTwinSpace 리졸버와 동형. */ static async deleteSpace(domainId: string, spaceId: string): Promise { const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId } }) if (space) { const reps = await getRepository(TwinSpaceRepresentation).find({ where: { domain: { id: domainId }, space: { id: space.id } } }) for (const rep of reps) await getRepository(TwinSpaceArea).delete({ domain: { id: domainId } as any, representation: { id: rep.id } as any }) await getRepository(TwinSpaceRepresentation).delete({ domain: { id: domainId } as any, space: { id: space.id } as any }) await getRepository(TwinArea).delete({ domain: { id: domainId } as any, space: { id: space.id } as any }) } await getRepository(TwinSpace).delete({ domain: { id: domainId } as any, spaceId }) } /** * Face2 마스터 인제스트(ADR-0018) — 레퍼런스 시스템(실 또는 가상)의 마스터를 읽어 트윈을 생성. * 엔티티를 손배선/발명하지 않고 마스터에서 반영: 공간(Space) upsert + 인스턴스 provision(미기동). * 노드타입은 커널 카탈로그로 검증(warning). start 는 호출측(bootstrap/mutation)이 결정. * * `into.spaceId` — **이 현장에 더한다.** 한 현실을 여러 렌즈(WMS·MES·YMS)가 비추므로 새 트윈이 * 기존 공간으로 들어갈 수 있다. 아래 병합은 원래 그 경우를 위해 있었는데(N:1) 만드는 흐름에서 * 공간을 고를 방법이 없었다 — 마스터가 파생한 id 만 쓰였다. 판정은 `resolveIngestSpace`(순수). */ static async ingestMaster( domainId: string, master: ReferenceMaster, /** 만들어지는 트윈의 재기동 정책 — 원본이 정하는 것이 아니라 **만드는 쪽의 선언**이다(ADR-0029 §2). */ restartPolicy: RestartPolicy, into?: { spaceId?: string }, /** 누가 인제스트했나 — 사람이 없는 경로(부팅)는 주지 않는다(감사 기록을 지어내지 않는다). */ actor?: { id: string } ): Promise<{ instanceId: string; spaceId: string; warnings: IngestWarning[] }> { /* * **거절할 것이면 아무것도 쓰기 전에 거절한다.** * * 가동 중 재프로비저닝은 `provision` 이 막는다. 그런데 그 호출은 이 함수의 **맨 끝**이라, * 그때까지 공간·표현·구역(TwinArea)은 이미 저장된 뒤였다. 사용자에게는 "인제스트 실패" 로 * 보이는데 실제로는 절반이 적용된 상태 — 실패라고 말하면서 데이터를 바꾸는 것이 가장 나쁘다. * * 그래서 판정을 앞으로 옮긴다. 아래 `provision` 의 같은 판정은 그대로 둔다 — 이건 편의를 위한 * 앞선 거절이고, 저기가 진짜 방벽이다(다른 경로가 생겨도 지켜져야 한다). * * **미러는 여기서 거절하지 않는다.** 미러가 비추는 현실은 안 멈추므로 동작 중에 전환한다 * (아래 `adoptStructure`). 거절은 시뮬레이션에만 남는다 — 조건이 바뀐 실험은 다른 실험이다. */ const running = this.instances[runtimeKey(domainId, master.source)] if (running && running.mode !== 'live') this.assertNotRunning(domainId, master.source) const { model, spaceContent, warnings } = masterToTwin(master, DOMAIN_CATALOG) /* * ── 현장 선언을 다시 얹는다 (2026-08-18) ────────────────────────────────── * * 여기가 「원천에서 다시 그리는」 자리다. 요금 단가·계약 정보처럼 **현장이 정한 수**는 원천이 모르므로 * 다시 그리면 그대로 사라진다 — 금액이 사라지고, 사용자는 자기가 넣은 값이 어디로 갔는지 알 수 없다. * 그래서 겹으로 보관한 선언을 매번 다시 얹는다(`engine/local-declarations`). * * 대상이 사라졌으면(구조가 바뀌었다) 그 사실을 경고로 낸다 — 갈 곳 없는 선언을 알리지 않고 버리지 않는다. */ const prior = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId: master.source } }) /* * A twin another producer made is not rebuilt here (engine/producer-guard.ts). Refused before anything * below writes — `prior` is saved a few lines on, and refusing after that would leave it half-changed. */ if (prior) { const taken = refuseOverwriteByAnotherProducer(master.source, prior.origin, (master as any).origin) if (taken) { const err: any = new Error(taken.message) err.code = taken.code err.params = taken.params throw err } } /* * 겹이 **둘**이다 — 현장(공간)의 것과 이 트윈의 것. 순서가 규율이다: 현장 먼저, 트윈이 나중. * 요금처럼 현장이 정한 수는 그 현장의 트윈 전부가 같이 읽어야 하고, 한 트윈만 다르게 두는 실험은 * 좁은 겹이 이겨야 한다(넓은 값을 고치면 나머지 트윈이 함께 흔들린다). */ const priorSpaceId = prior?.spaceId ?? (model as any)?.spaceId const spaceRow = priorSpaceId ? await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: priorSpaceId } }) : null if (prior?.localDeclarations || spaceRow?.localDeclarations) { const layered = applyDeclarationLayers(model as any, spaceRow?.localDeclarations as any, prior?.localDeclarations as any) Object.assign(model as any, layered.model) /* * 겹을 **다시 저장한다** — 여기서 얹은 것은 원천에서 새로 그린 모델이므로, 그때 우리가 덮은 값이 * 곧 지금의 원천 값이다(`replaced`). 저장하지 않으면 그 기억이 낡아, 철회가 옛 원천 값으로 * 되돌린다 — 원천이 그동안 값을 고쳤어도 알 수 없다. */ if (prior) { prior.localDeclarations = layered.instanceLayerApplied as any await getRepository(TwinInstance).save(prior) } if (spaceRow) { spaceRow.localDeclarations = layered.spaceLayerApplied as any await getRepository(TwinSpace).save(spaceRow) } /* * 갈 곳 없는 선언은 경고로 낸다 — 다만 **현장 겹의 것은 뺀다**: 한 현장에는 여러 종류의 트윈이 * 살고(창고·야드·에너지), 요금 자리가 없는 트윈에 요금이 얹히지 않는 것은 정상이다. */ for (const t of layered.missingTargets.filter(x => x.scope === 'instance')) { warnings.push(unresolvedReference('localDeclaration.target', `${t.target}:${t.id}`)) } } const repo = getRepository(TwinSpace) /* 합칠 공간을 골랐으면 **있는지 먼저 확인한다** — 없는 곳에 알리지 않고 새 공간을 만들면 사용자는 합쳤다고 믿고 화면은 따로 논다. 판정 함수가 그 경우 거절한다. */ const wanted = (into?.spaceId ?? '').trim() const wantedExists = wanted ? (await repo.count({ where: { domain: { id: domainId }, spaceId: wanted } })) > 0 : false const choice = resolveIngestSpace(model.spaceId, wanted, wantedExists) const spaceId: string = choice.spaceId model.spaceId = spaceId // 보드(커널 소비)도 같은 공간을 가리켜야 한다 const existing = await repo.findOne({ where: { domain: { id: domainId }, spaceId } }) if (choice.joined) { /* 같은 id 의 구역·랜드마크는 합집합으로 합쳐진다 — 구분할 수 없는 것을 고르지 않고 무엇이 합쳐졌는지 말한다(정당한 경우가 많으므로 막지 않는다). */ } /* * 공유 공간(여러 트윈이 한 spaceId, N:1) — **먼저 쓴 값이 이긴다.** * * 마지막 인제스트가 통째로 덮어써 표현·구역이 유실되던 문제 보정. 설계 좌표계 범위와 * 지오레퍼런스는 공간의 성질이라 트윈마다 다를 수 없다 — 두 번째 트윈이 들어오면서 바꾸면 * 먼저 놓인 구역들이 통째로 어긋난다. 그래서 이미 있으면 그대로 둔다. */ const cur: any = spaceContent const timezone = existing?.timezone || master.space.timezone const savedSpace = await repo.save( repo.create({ ...(existing ?? {}), domain: { id: domainId } as any, spaceId, name: existing?.name || master.siteName, ...(timezone ? { timezone } : {}), extentX: existing?.extentX ?? cur.width, extentY: existing?.extentY ?? cur.depth, geo: existing?.geo ?? cur.geo, /* * 대표 표현 포인터는 **표현을 저장한 뒤** 실제 행 id 로 채운다(아래). 여기서 마스터의 논리 * id(`r-map` 따위)를 넣으면 표현 행은 생성 uuid 를 받으므로 포인터가 **처음부터 허공을 * 가리킨다** — 실제로 13개 현장이 그 상태였고 정합성 점검이 잡았다(2026-08-13). 화면은 * 행의 `isPrimary` 로 ★를 그려서 증상이 보이지 않았다(사실이 두 벌이면 이렇게 들어온 것이 없다). */ primaryRepresentationId: existing?.primaryRepresentationId ?? null }) ) /* * 표현을 twin_space_representations 테이블에 저장한다 — 뷰(twin-map-page 등)는 content 가 아니라 테이블 표현을 소싱하므로, * 저장하지 않으면 인제스트 사이트에 "대표 표현" 이 없어 지도로 시작하지 못한다. rep.areas 가 있으면 TwinSpaceArea 도 함께 저장. * 최초 인제스트만(기존 표현 있으면 사용자 편집 보존). */ const repRepo = getRepository(TwinSpaceRepresentation) const areaRepo = getRepository(TwinSpaceArea) if ((await repRepo.count({ where: { space: { id: savedSpace.id } } })) === 0) { const reps: any[] = (spaceContent as any).representations ?? [] for (let i = 0; i < reps.length; i++) { const rep = reps[i] const config = rep.type === 'map' ? { center: rep.center, zoom: rep.zoom } : rep.type === 'board' ? { boardId: rep.boardId } : rep.type === 'image' ? { imageUrl: rep.imageUrl, width: rep.width, height: rep.height } : {} const savedRep = await repRepo.save(repRepo.create({ domain: { id: domainId } as any, space: { id: savedSpace.id } as any, type: rep.type, name: rep.name, seq: i, isPrimary: !!rep.primary || rep.id === (spaceContent as any).primaryId, config })) for (const area of (rep.areas ?? []) as any[]) { await areaRepo.save(areaRepo.create({ domain: { id: domainId } as any, representation: { id: savedRep.id } as any, name: area.name, type: area.type, geometry: area.geometry, drillTo: area.drillTo ?? null, binding: area.binding ?? null, style: area.style ?? null })) } } /* * 대표를 **저장된 행 id** 로 가리킨다 — 마스터의 논리 id 는 저장 키가 아니다. 고른 순서: * ① 마스터가 primary 로 표시한 표현 ② 없으면 첫 표현 ③ 표현이 없으면 비운다(지어내지 않는다). */ const saved = await repRepo.find({ where: { domain: { id: domainId } as any, space: { id: savedSpace.id } as any }, order: { seq: 'ASC' } }) const primary = saved.find(r => r.isPrimary) ?? saved[0] if (primary && savedSpace.primaryRepresentationId !== primary.id) { await repo.save(repo.create({ ...savedSpace, primaryRepresentationId: primary.id })) /* 하나만 대표다 — 행 플래그도 그 하나로 맞춘다(둘이 켜져 있으면 화면이 ★를 두 번 그린다). */ for (const r of saved) if (!!r.isPrimary !== (r.id === primary.id)) await repRepo.save(repRepo.create({ ...r, isPrimary: r.id === primary.id })) } } /* * area 단일화(space #2, P1) — 논리 구역을 TwinArea(정규·표현무관·공유)에 dual-write(upsert by areaId). * P3: content.areas 저장 폐기 → TwinArea 가 유일 권위. 소스 = masterToTwin 산출 spaceContent.areas(cur). * (원천 diff 제거[master 에서 빠진 area 삭제]는 사용자저작 area 보호 위해 per-area provenance 필요 → 후속.) */ const twinAreaRepo = getRepository(TwinArea) for (const a of ((spaceContent as any).areas ?? []) as any[]) { if (!a?.id) continue const existingArea = await twinAreaRepo.findOne({ where: { domain: { id: domainId }, space: { id: savedSpace.id }, areaId: a.id } }) await twinAreaRepo.save( twinAreaRepo.create({ ...(existingArea ?? {}), domain: { id: domainId } as any, space: { id: savedSpace.id } as any, areaId: a.id, name: a.name ?? a.id, type: a.type, parentId: a.parentId ?? null, layout: a.x != null || a.w != null ? { x: a.x, y: a.y, w: a.w, h: a.h } : null }) ) } if (running) { /* 실행 중인 미러 — 멈추지 않고 전환한다. 무엇이 사라졌는지는 **경고로 말한다** (멈췄다 세우는 마디가 없으므로, 말하지 않으면 자리 하나가 알리지 않고 없어진다). */ const shift = await this.adoptStructure(domainId, master.source, model, undefined, master.origin) warnings.push(structureAdopted(shift.rev, shift)) } else { /* 사이트 이름이 곧 트윈의 이름이다 — 마스터가 이미 말했으므로 지어내지 않고 그대로 싣는다. */ await this.provision(domainId, master.source, master.system, model, restartPolicy, undefined, master.origin, { name: master.siteName, description: (master as any).description, actor }) } /* * 구조 투영 — model(커널 입력 문서)를 **표준 엔티티 행**으로 푼다(ADR-0032). * * 인제스트가 유일한 쓰기 경로다. 여기서 실패해도 인제스트 자체는 성공으로 둔다 — * 행은 **원본에서 언제든 다시 그릴 수 있는 캐시**이고, 트윈 자체는 model 로 이미 동작한다. * 다만 **알리지 않고 넘기지 않는다**: 못 이은 참조는 인제스트 경고로 올라간다. */ try { const projected = await projectStructure(domainId, master.source, model, master.source) for (const ref of projected.unresolvedHome) warnings.push(unresolvedReference('equipment.homeLocation', ref)) for (const ref of projected.unresolvedArea) warnings.push(unresolvedReference('location.area', ref)) /* * 발전 형상은 **시각**을 읽는다 — 시간대를 모르면 UTC 로 읽히고, 현장 정오가 아닌 시각에 피크가 선다. * 시간대를 지어내지 않고 그 조합을 말한다(공간에 선언하면 사라진다). */ if ((model as any)?.utcOffsetMinutes === undefined) { for (const e of ((model as any)?.equipment ?? []) as any[]) { const hasProfile = ((e?.properties ?? []) as any[]).some(p => String(p?.id) === EMS_PROPERTY.genDailyProfile && String(p?.value ?? '').trim()) if (hasProfile) warnings.push(generationWithoutTimeZone(String((model as any)?.spaceId ?? ''), String(e.id))) } } } catch (err: any) { twinError(`[twin-engine] structure projection failed for "${master.source}":`, err?.message) warnings.push(projectionFailed(err?.message ?? 'unknown')) } if (warnings.length) twinWarn(`[twin-engine] ingest "${master.source}" warnings: ${describeWarnings(warnings)}`) return { instanceId: master.source, spaceId, warnings } } /** 단건 상세 — 프로비저닝 에디터가 편집할 model(구조+layout) 포함. */ static async detail(domainId: string, instanceId: string): Promise { const r = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!r) return null return { instanceId: r.instanceId, kind: r.kind, status: r.status, running: !!this.instances[runtimeKey(domainId, r.instanceId)], restartPolicy: r.restartPolicy, // 현실 선언 — what-if 적용 가드용(mirror=예측 전용, 자극 주입 불가) model: r.model } } /** * 라이브 바인딩 브리지(P3, ADR-0018) — 트윈 엔티티 상태를 `data(tag=엔티티id)` 채널로 publish. * 보드 컴포넌트가 tag 로 구독(board-ui provider)해 `component.data` 로 라이브 갱신. delta 시에만(희소). * tag=엔티티 id(데모=단일 인스턴스). 멀티 인스턴스/보드 재사용 시 tag 네임스페이스는 후속. */ /** 브로드캐스팅 실패 로그 조절 — 창마다 한 줄(막힌 구독자는 초당 수십 번 실패한다). */ private static publishDrops = new Map() private static readonly PUBLISH_DROP_LOG_MS = 10_000 /** * 한 번의 브로드캐스팅 — **구독자 하나가 호스트를 죽이지 못하게.** * * ── 무엇이 죽였나 (2026-08-14) ───────────────────────────────────────────── * 밀린 push 가 1024를 넘으면 pubsub 이 오류를 낸다(`RepeaterOverflowError`). 그 브로드캐스팅은 타이머 콜백 * 안에서 일어나므로 예외가 잡히는 곳 없이 올라가 **프로세스가 끝났다** — 트윈 14개가 도는 호스트가 * 소비를 멈춘 구독자 하나 때문에 통째로. * * 못 보낸 것은 **사실로 남긴다**(횟수를 세고 창마다 한 줄 남긴다). 삼키면 「보냈는데 화면이 낡았다」가 * 되고, 그건 가장 찾기 어려운 부류다. */ /** * 브로드캐스팅 한 건 — **이 가드가 대기열 넘침을 막지는 못한다** (2026-08-27 측정으로 바로잡음). * * 2026-08-14 에 이 함수를 「방송이 호스트를 죽이지 못하게」라는 이름으로 붙였다. 그것이 사실이 아니다. * 대기열이 넘칠 때 나는 예외는 `EventTarget` 이 부른 리스너 안에서 난다. 런타임은 리스너 예외를 * 호출자에게 주지 않고 uncaughtException 으로 보낸다. 그래서 이 try/catch 에는 잡을 것이 없다. * 실제로 그 뒤 3주 동안 막힌 줄 알고 있었고, 2026-08-27 10:24 에 같은 예외로 다시 죽었다. * * 넘침을 막는 것은 **한 실행 차례에 발행하는 수를 상한 아래로 두는 것**이다(§`PUBLISH_CHUNK`). * * 그래도 이 가드를 남기는 이유는 둘이다. `publish` 가 직접 던지는 경우(직렬화 실패 등)를 잡고, * 못 보낸 태그의 시그니처를 되돌린다 — 남겨 두면 그 태그가 오류 없이 영원히 낡은 값을 보인다. */ private static publishGuarded(channel: 'data' | typeof TWIN_STATE_TOPIC, payload: any, what: string): boolean { try { pubsub.publish(channel as any, payload) return true } catch (err: any) { const d = this.publishDrops.get(what) ?? { count: 0, lastLogMs: 0 } d.count++ const now = Date.now() if (now - d.lastLogMs >= this.PUBLISH_DROP_LOG_MS) { d.lastLogMs = now twinWarn(`[twin-engine] broadcast dropped on "${what}" (${d.count} so far) — ${err?.message ?? err}`) } this.publishDrops.set(what, d) return false } } static async publishEntityData(inst: InstanceRuntime): Promise { const domain = inst.domain if (!domain) return /* 상태 출처 스왑 — sim: 커널 runtime, live: projector 미러(+OEE 계산 층 보강). 계약·payload 동일, 드라이버만 다름. */ const load = inst.load ?? (inst.load = newLoadMeter()) /* ① 상태 투영 — 상태 크기에 비례한다(대규모에서 가장 무거운 축). */ const tSnap = performance.now() const st: any = inst.mode === 'live' ? inst.oee ? withLiveOee(inst.projector?.snapshot?.(), inst.oee) // 관측 커널 + 계산(OEE) → equipment payload 에 oee 포함(sim 동형) : inst.projector?.snapshot?.() : inst.runtime?.resync?.()?.state recordPhase(load, 'snapshot', performance.now() - tSnap) if (!st) return /* * 변화한 엔티티만 발행 — 이전엔 delta 마다 전 엔티티(노드+무버+오더)를 값 변화와 무관하게 전량 재발행해 * 같은 데이터를 매초 반복 push 하고 있었다(오용). 최신-상태 채널이므로 무변화 재브로드캐스팅은 무의미하다. * 엔티티별 시그니처를 비교해 바뀐 것만 push. */ const sigs = inst.entitySigs ?? (inst.entitySigs = new Map()) const seen = new Set() /* payload 매핑은 순수 함수(buildEntityDeltas)로 분리 — 여기선 시그니처 dedup + 발행만. * 변화한 엔티티만 발행(최신-상태 채널이라 무변화 재브로드캐스팅 무의미). */ /* ② payload 만들기 — 엔티티 수에 비례. ③ 시그니처 비교 + 발행 — 바뀐 것 수에 비례. */ const tDelta = performance.now() /* * ── 물품은 **건드린 것만** 만든다 (2026-08-21 실측) ────────────────────── * 만드는 payload 의 대부분이 물품이다(엔티티 3,611 중 2,400). 바뀐 것이 하나여도 전부 만들어 * 문자열로 바꾼 뒤 「같다」를 확인하고 버렸다. * * 범위는 이 창에 들어온 사건에서 모았다(`touchedItemKeys`). **말할 수 없는 사건이 하나라도 있으면 * 범위는 없고 전부 만든다** — 낯선 어휘가 오면 알리지 않고 빠뜨리는 대신 비싸게 안전한 쪽으로 떨어진다. * * 그리고 주기마다 한 번은 **무조건 전부** 만든다(`FULL_BROADCAST_EVERY`). 커널이 사건 없이 물품을 * 바꾸는 자리가 생기면 그 값이 화면에 남을 수 있는데, 그 창을 몇 초로 묶는 그물이다. **보장이 아니라 * 그물이다** — 사건 없이 바뀌는 자리를 찾으면 그것을 고치는 것이 답이고 이 그물은 시간을 벌 뿐이다. */ const full = inst.fullBroadcastDue === true || !inst.dirtyItems /* 「최근 T 완료」의 T 는 모델의 선언이다(ADR-0092) — 커널이 불러올 때 이미 거절했으므로 여기 오는 값은 옳다. */ const fold = orderFoldPolicyOf((inst.model as any)?.orderFold) const deltas = buildEntityDeltas(st, inst.id, full ? undefined : { items: inst.dirtyItems! }, 'policy' in fold ? fold.policy : undefined) this.broadcastPasses++ if (full) this.broadcastFullPasses++ /* 이번 창의 범위는 여기서 닫는다 — 다음 창은 다시 모은다. */ inst.dirtyItems = new Set() inst.fullBroadcastDue = false recordPhase(load, 'deltas', performance.now() - tDelta) const tPub = performance.now() /* * ── **한 실행 차례에 상한까지만 발행한다** (2026-08-27 실측으로 고침) ──────── * * 이 반복문이 동기였다. 구독의 대기열에는 버퍼가 없고, 발행하는 순간에 「다음 값을 달라」는 요청이 * 걸려 있지 않으면 쌓인다. 값을 꺼내는 쪽은 비동기라서 **동기 반복문이 도는 중에는 한 건도 꺼내지 * 못한다.** 그래서 N 건을 발행하면 첫 건만 전달되고 나머지가 쌓였고, 1024 를 넘는 순간 예외가 나서 * 프로세스가 내려갔다(2026-08-27 10:24 · 2026-08-14 에도 같은 예외). * * `PUBLISH_CHUNK` 마다 루프를 비워 준다. 그 틈에 소비자가 대기열을 꺼내 간다. * * **건너뛰지 않는다.** 이 창의 발행은 끝까지 한다 — 중간에 그만두면 그 태그가 낡은 값으로 남는다. * 이 함수가 주기보다 오래 걸리면 다음 타이머는 그대로 돌아가고(§`flushLiveBroadcasts`), * `adjustBroadcastPeriod` 가 주기를 올린다. */ let published = 0 let sinceYield = 0 for (const { tag, data } of deltas) { seen.add(tag) const sig = JSON.stringify(data) if (sigs.get(tag) === sig) continue // 무변화 → 발행 생략 sigs.set(tag, sig) /* * **못 보냈으면 보낸 것으로 적지 않는다.** 시그니처를 남겨 두면 다음 주기에 「무변화」로 건너뛰고, * 그 태그는 영원히 낡은 값을 보여 준다(오류 없이). 되돌려 두면 다음 주기가 다시 시도한다. */ if (!this.publishGuarded('data', { data: { domain, tag, data } }, `data:${inst.id}`)) sigs.delete(tag) published++ if (++sinceYield >= this.PUBLISH_CHUNK) { sinceYield = 0 await new Promise(resolve => setImmediate(resolve)) } } recordPhase(load, 'publish', performance.now() - tPub) /* * ── **몇 건을 발행했는지 남긴다** (2026-08-27) ────────────────────────────── * * 10:24 에 서버가 죽었을 때 그 창이 몇 건을 발행했는지 알 수 없었다. 그 수가 없으면 「대기열이 * 넘쳤다」는 사실만 있고 무엇이 넘겼는지 판단할 수 없어, 원인을 추측으로 고르게 된다. * * 상한을 넘긴 창만 남긴다 — 매 창을 남기면 로그가 그 자체로 부하가 된다. */ inst.publishedTotal = (inst.publishedTotal ?? 0) + published if (published >= this.PUBLISH_CHUNK) { twinLog( `[twin-engine] "${inst.id}" 이번 창에서 ${published}건 발행 ` + `(전체 ${deltas.length}건 중 · ${full ? '전량' : '좁힘'} · 청크 ${this.PUBLISH_CHUNK})` ) } /* * 사라진 엔티티의 시그니처 정리(맵 무한 성장 방지) — **전부 만든 창에서만.** * 범위를 좁힌 창의 `seen` 에는 만들지 않은 엔티티가 없으므로, 그때 정리하면 살아 있는 태그의 * 시그니처를 지운다. 낡은 값이 나가는 것은 아니지만(다음 창에 다시 만들어 보낸다) 같은 값을 * 되풀어 보내게 되어, 줄이려던 것을 되돌린다. */ if (full && sigs.size > seen.size) for (const tag of sigs.keys()) if (!seen.has(tag)) sigs.delete(tag) } /* * 단건 저장(`persist`)은 **없앴다** (2026-08-20, §7.1). * * 시뮬이 델타마다 이것을 불러 행 하나씩 썼고, 그것이 규모에서 호스트를 먹었다. 남겨 두면 다음 사람이 * 다시 부를 자리가 되므로 지운다 — 쓰는 문은 `flushJournal` 하나다(`persistBatch`·`persistCarried`). */ /** * 재부팅 복구 / 시간여행 — DB 저널을 replay 해 상태 재구성. * model 는 레지스트리(TwinInstance)에서, 이벤트는 TwinEvent(revision ASC)에서. * 커서 두 축(runtime-state-model §4): **시각(untilTime, 사용자 모국어·공간 공통축)** 우선, 없으면 리비전(untilRevision, 렌즈 내부). * - untilTime 주면 `eventTime ≤ T` 인 이벤트만(그 시점 watermark) — 공동배치 여러 트윈을 하나의 시각 T로 통일 해소. * - 둘 다 없으면 최신(라이브 인메모리 진실). * 이벤트는 항상 revision ASC(인과 순서)로 적용 — 시각 필터는 컷오프 집합만 정한다. */ static async recover(domainId: string, instanceId: string, untilRevision?: number, untilTime?: string): Promise { // 라이브 최신(시간여행 아님) + 인메모리 인스턴스 → 라이브 커널 스냅샷을 직접 사용. // replay(StateProjector)는 attentions·ack 등 라이브 전용 파생 상태를 담지 못하므로, 최신은 커널 진실을 쓴다. if (untilRevision == null && untilTime == null) { const live = this.instances[runtimeKey(domainId, instanceId)] if (live?.kernel?.getSnapshot) return live.kernel.getSnapshot() // live 모드는 kernel 이 없고 projector 미러 → 인메모리 최신 스냅샷(projector+OEE+attentions) 직접 사용. // (저널이 있어도 최신은 인메모리가 진실 — replay 는 시간여행/복구 전용.) if (live?.mode === 'live' && live.projector) return this.snapshot(domainId, instanceId) } /* * **끝에 있는 스냅샷이면 계산하지 않는다.** * * 멈춘 트윈을 조회할 때마다 저널을 전량 계산하고 있었다 — 트윈을 하나도 안 돌려도 모델 조회가 * 4.6~7.7초였던 이유다(crew-probe 23,731건). 스냅샷은 같은 폴드의 결과이므로, 그 뒤로 이벤트도 * 구조 변경도 없다면 **다시 계산해 봐야 같은 값**이다. * * 쓰지 않는 조건을 좁게 잡는다: 지금을 물었을 때만(시간여행은 그 시점까지 계산해야 한다), 리비전이 * 끝과 같을 때만, 구조 리비전까지 같을 때만. 하나라도 어긋나면 계산한다 — 캐시가 사실을 이기지 않는다. */ const asOfNowRead = untilRevision == null && untilTime == null const tip = asOfNowRead ? await this.tipOf(domainId, instanceId).catch(() => null) : null /* 한 번만 읽는다 — 끝에 있으면 그대로 쓰고, 아니면 아래에서 **이어 계산하는 씨앗**으로 쓴다. */ const cachedForResume = asOfNowRead && tip ? await this.loadSnapshot(domainId, instanceId).catch(() => null) : null if (cachedForResume) { const state = unwrapState(cachedForResume.state) if (state && (cachedForResume.revision ?? 0) === tip!.revision && (cachedForResume.structureRev ?? null) === tip!.structureRev) return state } const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!reg?.model) throw new Error(`twin instance "${instanceId}" not registered (no model to replay)`) /* * ── 재개점에서 **이어서 계산한다** (2026-08-18) ──────────────────────────── * * 우리는 이미 계산한 결과를 남기고 있다(`saveFoldedSnapshot`). 그 지점의 **재개점**(리듀서 내부 상태 * 전부 + 가동 누적기)이 함께 있으면, 그 뒤에 일어난 것만 계산해도 같은 답이 나온다 — 그 동치는 * 커널 시험이 증명한다(0부터 계산하기 == 재개점 + 꼬리). * * 쓰는 조건을 좁게 잡는다: **지금을 물었을 때만**(시간여행은 목표 이전 재개점이 필요한데 지금은 * 최신 하나만 남긴다 — 사슬은 다음 단계다), 구조가 그대로일 때만(구조가 바뀌면 그 경계에서 갈라 * 계산해야 한다), 재개점이 저널 끝보다 앞설 때만. 하나라도 어긋나면 0부터 계산한다 — **캐시가 사실을 * 이기지 않는다.** */ /* * 씨앗은 둘 중 하나다: **지금**을 물으면 최신 재개점, **과거**를 물으면 사슬에서 목표 직전 지점. * * 과거 씨앗은 구조가 한 번도 바뀌지 않은 트윈에서만 쓴다 — 구조가 갈린 저널은 마디마다 갈아 계산해야 * 하고, 마디를 건너뛴 씨앗은 그 경계의 판정을 잃는다(그때는 0부터 계산하는 것이 옳다). */ const structureCount = asOfNowRead ? 0 : await getRepository(TwinStructure).count({ where: { domain: { id: domainId }, instanceId } }).catch(() => 99) const pastSeed = !asOfNowRead && structureCount <= 1 ? await this.chainSeedFor(domainId, instanceId, { ...(untilRevision != null ? { revision: untilRevision } : {}), ...(untilTime != null && !Number.isNaN(Date.parse(untilTime)) ? { timeMs: Date.parse(untilTime) } : {}) }).catch(() => null) : null const resume = asOfNowRead && tip && cachedForResume && cachedForResume.fold && (cachedForResume.structureRev ?? null) === tip.structureRev ? cachedForResume : pastSeed /* * **자를 것을 알면서 다 읽어 오지 않는다** (2026-08-18 실측). * * 저널을 전량 읽어 온 뒤 JS 에서 걸렀다. 27만 건짜리 트윈(order-check)에서 한 시간 전 상태를 물으면 * **7.4초**가 걸렸고, 그 대부분이 「필요 없는 행을 읽어 오는 시간」이었다. 시각·리비전 상한은 SQL 이 * 아는 조건이므로 거기서 자른다(TypeORM 관용구만 — 원시 SQL 은 5개 드라이버에서 갈라진다). * * 시각 상한에는 **경계를 그대로** 쓴다(`<=`): 그 시각에 일어난 사실은 그 시각의 화면에 있어야 한다. * 시각이 없는 레거시 행은 보수적으로 포함해 왔는데, SQL 로 자르면 그 행들이 빠진다 — 그래서 시각 * 조건일 때는 **시각이 비어 있는 행도 함께** 가져와 예전과 같은 결과를 낸다. */ const cutoffMs = untilTime != null ? Date.parse(untilTime) : NaN const useTime = untilTime != null && !Number.isNaN(cutoffMs) const baseWhere = { domain: { id: domainId }, instanceId } /* 이어 계산할 때는 **그 뒤만** 읽는다 — 재개점까지의 사실은 이미 씨앗 안에 있다. */ const from = resume ? (resume.revision ?? 0) : undefined /* * journal-fold: 재생은 사실을 하나씩 계산하는 것이므로 그 구간의 행이 필요하다. 커서(`from`)와 시각 * 상한이 구간을 자르고, 체크포인트가 앞쪽을 씨앗으로 대신한다 — 표 전체를 읽지 않는다. */ const rows = await getRepository(TwinEvent).find({ where: useTime ? from ? /* 씨앗 뒤 · 목표 이내 — 시각이 빈 옛 행은 씨앗 뒤에 있는 것만 포함한다(보수적 포함을 유지). */ [ { ...baseWhere, revision: MoreThan(from), eventTime: LessThanOrEqual(new Date(cutoffMs)) }, { ...baseWhere, revision: MoreThan(from), eventTime: IsNull() } ] : [ { ...baseWhere, eventTime: LessThanOrEqual(new Date(cutoffMs)) }, { ...baseWhere, eventTime: IsNull() } ] : untilRevision != null ? from ? { ...baseWhere, revision: And(MoreThan(from), LessThanOrEqual(untilRevision)) } : { ...baseWhere, revision: LessThanOrEqual(untilRevision) } : from ? { ...baseWhere, revision: MoreThan(from) } : baseWhere, order: { revision: 'ASC' } }) const wanted = rows /* * **그때의 공장으로 계산한다.** * * 예전에는 모든 이벤트를 지금 등록된 보드 하나로 계산했다. 구조가 바뀐 적 없으면 맞지만, 바뀐 * 뒤에는 옛 사실을 새 공장에 대고 계산하게 되어 — 그때 없던 설비에서 일이 있었던 것처럼 보인다. * (그래서 예전 프로비저닝은 구조가 바뀌면 저널을 아예 지웠다. 역사를 잃거나 거짓말을 하거나.) * * 이제 이벤트가 자기 구조를 달고 오므로, 리비전이 바뀌는 지점에서 구조를 갈아타며 이어서 계산한다. * 리비전이 하나뿐이거나(대다수) 아예 없으면(구조 이력 이전) 예전과 똑같이 한 번에 계산한다. */ const structures = await getRepository(TwinStructure).find({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'ASC' } }) const boardOf = new Map(structures.map(x => [x.rev, x.model as TwinModelDef])) /* 컬럼이 생기기 전 행은 리비전이 비어 있다 — **가장 오래된 구조**에 속한다(0 으로 채우지 않는다). */ const oldest = structures[0]?.rev const revOf = (r: { structureRev?: number }): number | undefined => r.structureRev ?? oldest const segments: { model: TwinModelDef; events: any[] }[] = [] for (const r of wanted) { if (!r.payload) continue const rev = revOf(r) const model = (rev !== undefined && boardOf.get(rev)) || (reg.model as TwinModelDef) const last = segments[segments.length - 1] if (last && last.model === model) last.events.push(r.payload) else segments.push({ model, events: [r.payload] }) } /* * **가장 새 구조가 지금의 공장이다** — 그 아래에서 아직 아무 일도 없었더라도. * * 재프로비저닝 직후가 정확히 그 상태다: 부스를 넷 늘렸는데 새 이벤트는 아직 하나도 없다. 이때 * 마지막 이벤트의 구조로만 계산하면 화면은 **옛 공장**을 보여준다 — 방금 늘린 것이 안 보인다. * 그래서 마디의 끝에 지금 구조를 한 번 더 얹는다(이벤트 없는 마디). * * **다만 "지금" 을 물었을 때만이다.** 과거 시점을 물었는데 최신 구조를 얹으면, 그 시점에 없던 * 설비가 화면에 서고 — 이 작업이 막으려던 바로 그 거짓말이 된다. */ const asOfNow = untilRevision == null && untilTime == null const newest = structures[structures.length - 1]?.model as TwinModelDef | undefined if (asOfNow && newest && segments[segments.length - 1]?.model !== newest) segments.push({ model: newest, events: [] }) /* * 계산한 상태에 **주의 신호와 시각을 채운다.** * * 라이브·시뮬은 커널이 신호를 스스로 내지만, 저널을 계산하는 이 경로는 프로젝터 상태만 낸다 — 신호도 * `nowTime` 도 없다. 그래서 **과거를 다시 계산하면 주의 레일이 텅 비었고**(지도는 `snap.attentions` 를 * 읽는다), 기동돼 있지 않은 트윈을 보는 화면도 같았다. 신호는 상태에서 계산되는 것이므로 * 여기서 같은 공식(`deriveAttentions`)으로 채우면 된다 — 두 벌을 두지 않는다. * * `nowTime` 이 먼저다: "늦었나" 판정이 그 값을 본다. 다시 계산한 시점의 정직한 "지금" 은 물어본 시각 * (`untilTime`)이고, 없으면 마지막으로 적용한 이벤트의 시각이다. 둘 다 없으면 채우지 않는다 — * 벽시계를 끼워 넣으면 과거 화면이 "지금 기준으로 늦었다" 고 말하게 된다. */ const lastEventTime = rows.length ? (rows[rows.length - 1]?.eventTime?.toISOString() ?? '') : '' /* * **가동 이력도 되살린다** — 다시 계산한 화면에 설비 계측이 비어 있던 것. * * OEE 는 원 시스템이 누적을 보내 주지 않아 호스트가 상태 전이를 적분해 만든다(그래서 커널이 아니라 * 여기 있다). 그런데 그 누적기는 **라이브에서만** 돌았고, 저널을 계산하는 경로는 그 계산을 하지 않았다 — * 과거를 다시 계산하면 모든 설비가 "가동 이력이 전혀 없음" 으로 보였다. * * 없는 것은 데이터가 아니라 계산이다: 입력(`equipment.status` 전이·`quality.output`)은 저널에 다 * 있다. 그래서 **같은 누적기에 같은 이벤트를 태운다** — 규칙을 두 벌 만들지 않는다. * * 시간여행에서도 맞다: 여기 태우는 것은 이미 잘라 낸(`wanted`) 이벤트뿐이므로, 그 시점까지의 * 가동 이력이 나온다(그 뒤에 일어난 고장이 과거 화면에 섞이지 않는다). */ const oee = new OeeAccumulator() /* * 히스토리도 같은 달력으로 읽는다 — 라이브와 다른 기준으로 세면 두 화면이 다른 수를 보인다. * 여기서 쓸 모델은 `newest` 다(이 스코프의 마지막 구조). 구간마다의 `model` 은 위 루프 안에만 * 있고, 달력은 구조 개정마다 갈리는 값이 아니다. */ oee.setWorkCalendar(newest as any) /* 재개점이 있으면 그 위에 꼬리만 얹는다 — 없으면 꼬리분만 세어 가용률이 알리지 않고 작아진다. */ if (resume?.fold?.oee) oee.restore(resume.fold.oee) for (const r of wanted) { try { oee.apply(r.payload as any) } catch { /* 한 건이 이상해도 나머지 계측을 버리지 않는다 — 누적기는 모르는 이벤트를 무시하는 계약이다. */ } } const withNow = (st: any): any => { if (!st || typeof st !== 'object') return st const nowTime = st.nowTime || untilTime || lastEventTime || undefined const based = nowTime ? { ...st, nowTime } : st /* 계측 시점은 그 화면의 "지금" 이다 — 벽시계로 재면 과거 화면의 가용률이 시간이 갈수록 떨어진다. */ const nowMs = nowTime ? Date.parse(String(nowTime)) : Number.NaN const withMetrics = withLiveOee(based, oee, Number.isFinite(nowMs) ? nowMs : undefined) return withLiveAttentions(withMetrics) } /* 계산한 결과를 남긴다 — **지금을 물었을 때만**(시간여행 결과를 "지금" 으로 저장하면 거짓이 된다). */ /* * 계산한 지점의 리비전 — 사슬에 적을 이름이다. 꼬리를 계산했으면 그 꼬리의 끝, 아무것도 안 읽었으면 * 씨앗의 자리 그대로다(모르면 0). */ const foldedTo = rows.length ? (rows[rows.length - 1]?.revision ?? 0) : (from ?? 0) const keep = (st: any, fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint }): any => { /* 재개점을 함께 남긴다 — 상태만 남기면 다음 번에 또 0부터 계산해야 한다(그것이 이 작업의 요점이다). **구조가 갈린 폴드에는 재개점을 붙이지 않는다**: 마디를 건너뛴 씨앗은 그 경계의 판정을 잃는다. */ if (asOfNowRead && tip && st) { void this.saveFoldedSnapshot(domainId, instanceId, { revision: tip.revision, state: st, structureRev: tip.structureRev, fold }) } /* * 사슬에는 **과거를 계산했을 때도** 한 지점을 남긴다 (2026-08-18 실측으로 고침). * * 처음에는 「지금 읽기」에서만 남겼다. 그런데 도는 트윈의 지금 읽기는 커널 스냅샷으로 즉시 답하고 * 폴드에 닿지 않는다 — 그래서 사슬이 **영원히 비어 있었다**(실측: 지점 0개, 시간여행 3.7s 그대로). * * 지점은 「지금」을 주장하지 않는다: 자기 리비전과 시각을 달고 있으므로 과거 폴드의 결과를 남겨도 * 거짓이 아니다(그것이 스냅샷과 다른 점이다). 그래서 첫 과거 조회가 다음 과거 조회를 빠르게 한다. */ if (fold && st) { const at = asOfNowRead && tip ? tip.revision : foldedTo if (at > 0) void this.keepChainPoint(domainId, instanceId, { revision: at, ...(lastEventTime ? { eventTime: lastEventTime } : {}), structureRev: (asOfNowRead && tip ? tip.structureRev : null) ?? null, fold }).catch(() => {}) } return st } /* * 씨앗이 있으면 **이어서 계산한다** — 한 구조 안에서만(구조가 갈리면 아래 마디 경로가 맡는다). * 새 재개점도 함께 남긴다: 다음 번에 또 꼬리만 계산할 수 있어야 이 지름길이 계속 산다. */ if (resume?.fold?.reducer && segments.length <= 1) { const model = (segments[0]?.model ?? (asOfNow && newest) ?? reg.model) as TwinModelDef const out = refusingModel(instanceId, () => replayFrom(model, resume.fold.reducer, segments[0]?.events ?? [], replayOptions(reg, domainId)) ) return keep(withNow(out.state), { reducer: out.checkpoint, oee: oee.serialize() }) } if (!segments.length) { const model = ((asOfNow && newest) || reg.model) as TwinModelDef const out = refusingModel(instanceId, () => replayWithCheckpoint(model, [], replayOptions(reg, domainId))) return keep(withNow(out.state), { reducer: out.checkpoint, oee: oee.serialize() }) } if (segments.length === 1) { const out = refusingModel(instanceId, () => replayWithCheckpoint(segments[0].model, segments[0].events, replayOptions(reg, domainId)) ) return keep(withNow(out.state), { reducer: out.checkpoint, oee: oee.serialize() }) } /* 커널 계약도 `model` 이다(0.6.14) — 경계에서 어휘를 되돌려 담던 브릿지가 사라졌다. */ const { state, shifts } = refusingModel(instanceId, () => replaySegments(segments, replayOptions(reg, domainId))) /* 경계에서 사라진 것을 알리지 않고 넘기지 않는다 — 수가 줄어든 이유를 어딘가에는 남겨야 한다. */ for (const sh of shifts) if (sh.equipmentDropped || sh.locationsDropped || sh.personsDropped || sh.assetsDropped) console.info(`[twin-engine] "${instanceId}" replay crossed a structure change — dropped ${sh.equipmentDropped} equipment, ${sh.locationsDropped} locations, ${sh.personsDropped} persons, ${sh.assetsDropped} assets that no longer exist.`) return keep(withNow(state)) } /** * 공간(공동배치) 시각 범위 — 스크러버 앵커(runtime-state-model §4·§6). 그 공간 전 인스턴스 저널의 min/max eventTime. * 반환 {minTime, maxTime}(ISO) — 이벤트/시각 없으면 null. 클라 히스토리 스크러버가 이 범위를 시각축으로 그린다. * * ── 두 수를 구하려고 저널을 다 읽지 않는다 (2026-08-20 실측으로 잡음) ──────── * 여기서 인스턴스마다 `find()` 로 **행을 전부 엔티티로 하이드레이션**한 다음 JS 에서 min/max 를 * 골랐다. 그런데 이 함수는 화면 상단의 컨텍스트 띠가 **4초마다** 부른다. 실측한 개발 서버에서 * `order-check` 한 트윈이 275,882행 · `payload` 131MB 였다 — 4초마다 그 JSON 을 전부 파싱한 것이다. * * 그 결과가 이랬다: 프로세스 CPU 81~152%, 아무 일도 하지 않는 질의가 8~14초. JS 프로파일의 상위가 * TypeORM 의 `RelationIdLoader`·`RawSqlResultsToEntityTransformer`·`stringToSimpleJson`(= `payload` * 파싱)이고 GC 가 16.7% 였다. **트윈 틱도 저널 쓰기도 아니라 이 읽기였다.** * * 집계는 DB 가 한다. `(domain, instanceId, eventTime)` 인덱스가 이미 있어(`ix_twin_event_1`) * 인덱스의 **양 끝을 집는다** — 행을 하나도 실어 오지 않는다. * * ── 왜 MIN 과 MAX 를 한 문장에 넣지 않나 (실측) ───────────────────────────── * 처음에 `SELECT MIN(...), MAX(...) ... WHERE instanceId IN (...)` 한 방으로 두었더니 **21ms** 였다. * 집계가 **둘이면** 옵티마이저의 「인덱스 끝을 집는」 최적화가 걸리지 않아 인덱스 구간을 훑는다 — * 즉 비용이 여전히 **행 수에 비례**한다. 트윈마다 단일 집계로 나눠 물으면 **0.32ms**(트윈 3개 · 6왕복) * 이고, 비용이 **트윈 수에 비례**한다. 규모 기준(엔티티 10만)에서는 이 차이가 본질이다. * * 왕복은 트윈당 둘이지만 **함께 띄운다** — 원격 DB 에서 직렬로 돌면 왕복 지연이 그대로 쌓인다. * * 드라이버 다섯을 다 지나가야 하므로 raw SQL 을 쓰지 않는다(쿼리빌더의 MIN/MAX 는 이식된다). * 돌려주는 값의 **모양은 드라이버마다 다르다**(문자열·Date) — 받은 뒤에 한 번 정규화한다(`parseTime`). */ static async timeRange(domainId: string, spaceId: string): Promise<{ minTime: string | null; maxTime: string | null }> { const regs = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, spaceId } }) const ids = regs.map(r => r.instanceId) if (!ids.length) return { minTime: null, maxTime: null } /** * 한 트윈 저널의 시각 양 끝 하나 — 단일 집계라 인덱스 끝을 집는다. * * 집계식을 **조립하지 않고 표에 리터럴로 적는다.** `` `${agg}(e.eventTime)` `` 로 만들면 이 자리가 * 집계를 쓰는지 훑는 검사(`journal-read-discipline`)가 찾지 못한다 — 실제로 그렇게 빨개졌다. * 번역 키에서 겪은 것과 같은 규율이다: 검사가 일하게 두는 편이 낫다. */ const AGG_SELECT = { MIN: 'MIN(e.eventTime)', MAX: 'MAX(e.eventTime)' } as const const edge = async (instanceId: string, agg: 'MIN' | 'MAX'): Promise => { const row = await getRepository(TwinEvent) .createQueryBuilder('e') .select(AGG_SELECT[agg], 't') .where('e.domain = :domainId', { domainId }) .andWhere('e.instanceId = :instanceId', { instanceId }) .getRawOne<{ t: unknown }>() return parseTime(row?.t) } let min = Infinity let max = -Infinity /* * 한 번에 띄우는 수를 묶는다 — 공간에 트윈이 수백이면 왕복 수백 개를 동시에 보내 커넥션 풀을 * 말려 버린다(이 함수 하나 때문에 다른 질의가 기다리게 된다). */ for (let i = 0; i < ids.length; i += TIME_RANGE_FANOUT) { const batch = ids.slice(i, i + TIME_RANGE_FANOUT) const edges = await Promise.all(batch.flatMap(id => [edge(id, 'MIN'), edge(id, 'MAX')])) for (let k = 0; k < edges.length; k += 2) { const lo = edges[k] const hi = edges[k + 1] if (lo !== null && lo < min) min = lo if (hi !== null && hi > max) max = hi } } if (min === Infinity) return { minTime: null, maxTime: null } return { minTime: new Date(min).toISOString(), maxTime: new Date(max).toISOString() } } /** 정지 훅 — 인스턴스 정지 시 부수자원 정리(예: reference-live 피드 unsubscribe). 순환 임포트 없이 결선. */ static stopHooks: ((id: string) => void)[] = [] static onStop(fn: (id: string) => void): void { this.stopHooks.push(fn) } static async stop(domainId: string, id: string): Promise { const i = this.instances[runtimeKey(domainId, id)] if (i) { /* * **끄기 전에 남긴다** — 지금이 메모리가 진실인 마지막 순간이다. * * 남기지 않으면 다음 조회가 저널을 전량 다시 계산한다(그 값은 어차피 방금 메모리에 있던 것이다). * 체크포인트 루프가 20초마다 뜨지만 그 사이에 멈추면 그 구간이 통째로 다시 계산된다. */ await this.persistSnapshot(domainId, id, true).catch(err => twinError(`[twin-engine] snapshot on stop fail "${id}"`, err?.message ?? err) ) /* * **모아 둔 저널도 지금 흘린다** — 주기 flush 사이(최대 `BROADCAST_COALESCE_MS`)에 멈추면 그 구간의 * 사실이 사라진다. 스냅샷만 남기면 상태는 살아도 **왜 그렇게 됐는지**가 빈다(저널이 답하는 것이다). * 쓰기는 이 함수를 기다리지 않지만(비차단) 버퍼는 여기서 비워지므로 다음 기동이 두 번 적지 않는다. */ await this.flushJournal(i) clearInterval(i.timer) i.unsub() delete this.instances[runtimeKey(domainId, id)] await getRepository(TwinInstance) .update({ domain: { id: i.domainId }, instanceId: id }, { status: 'stopped' }) .catch(err => twinError('twin deregister fail', err)) } for (const hook of this.stopHooks) { try { hook(id) } catch { /* 훅 격리 */ } } } /* * tick 한 번 — **한 트윈의 예외가 서버를 내리지 못하게** 감싼다. * * 잘못된 시나리오가 실렸을 때 다음 tick 에서 `rate.meanPerHour` 를 읽다 터졌고, 타이머 콜백의 * 예외는 아무도 받지 않아 **uncaught exception 으로 프로세스가 내려갔다.** 한 테넌트의 잘못된 * 선언이 모든 테넌트를 멈춘 셈이다. 문 앞에서 막는 것이 1차 방벽이고(`validateScenario`), * 이것이 2차 방벽이다. * * **알리지 않고 삼키지 않는다.** 실행을 멈추고 그 사실을 남긴다 — 예외를 무시하고 계속 tick 하면 * 같은 오류가 매 주기 쏟아지고, 그 트윈은 "도는 것처럼 보이면서" 아무것도 진행하지 않는다. */ /** * 같은 현장 트윈들의 **설비 상태**를 커널에 넘긴다 — 커널이 요청할 때만. * * ── 경계 (2026-08-14) ────────────────────────────────────────────────────── * 호스트가 아는 것은 **누가 같은 현장에 있는가** 하나다. 무엇을 읽고 그것을 어떻게 부하로 바꿀지는 * 커널이 정한다(계수는 트윈의 모델이 선언한다). 호스트가 kW 를 계산해 주면 트윈마다 다른 규칙이 * 생기고, 그 규칙은 어디에도 적혀 있지 않게 된다. * * 상태만 넘긴다. 스냅샷을 통째로 넘기면 상태 크기에 비례한 비용을 매 tick 치르게 된다 — * 설비 목록은 그 트윈의 커널이 이미 들고 있는 것이라 훑는 값이 싸다. */ private static feedPeerEquipment(inst: InstanceRuntime): void { const kernel: any = inst.kernel if (typeof kernel?.observePeerEquipment !== 'function') return // 이웃을 읽지 않는 커널 if (!inst.spaceId) { kernel.observePeerEquipment([]) // 현장이 없으면 이웃도 없다(옛 상태가 남지 않게 비운다) return } const peers: { id: string; status?: string }[] = [] for (const other of Object.values(this.instances)) { if (other === inst || other.domainId !== inst.domainId || other.spaceId !== inst.spaceId) continue const eq: Map | undefined = (other.kernel as any)?.equipment if (!eq || typeof eq.forEach !== 'function') continue eq.forEach(e => peers.push({ id: String(e?.id ?? ''), status: String(e?.status ?? '') })) } kernel.observePeerEquipment(peers) } private static tickGuarded(domainId: string, id: string, runtime: TwinRuntimeType): void { const inst = this.instances[runtimeKey(domainId, id)] const load = inst && (inst.load ?? (inst.load = newLoadMeter())) const t0 = performance.now() try { /* 이웃의 설비 상태를 먼저 싣는다 — 커널이 이번 tick 에서 부하를 만들 때 읽는다. */ if (inst) this.feedPeerEquipment(inst) runtime.tick(this.TICK_MS) /* 재고 말한다 — 시뮬 틱은 메인 이벤트 루프에서 수행된다. 예산(간격의 절반)을 넘기면 HTTP·구독이 함께 느려지는데, 예전에는 그것을 볼 계기판이 없어 "터지면 알고 느려지면 몰랐다". */ if (load) { const took = performance.now() - t0 recordPhase(load, 'tick', took) const j = judgeCycle(load, took, this.TICK_MS, Date.now()) if (j.warn) twinWarn(slowTickMessage(id, took, j.budgetMs, load)) this.guardStarvation(domainId, id, took) } } catch (err: any) { /* * 멈추는 길은 **하나**다(`stop`) — 예전에는 타이머만 껐다. 그러면 등록부는 계속 `running` 이라 * 말하고(아무 틱도 없는 채로), 부팅 재개는 그 거짓을 근거로 다시 세운다. 스냅샷 보존·피드 정리· * 등록부 갱신이 모두 `stop` 안에 있다. */ this.stopWithNote( domainId, id, { code: 'tick-failed', params: { reason: String(err?.message ?? err) } }, `[twin-engine] "${id}" tick failed — this twin is stopped (other twins keep running). ` + `Fix the declaration and start it again. Reason: ${err?.message ?? err}` ) } } /** * 호스트를 굶기는 트윈을 멈춘다 — **도는 척하는 것보다 멈춘 것이 낫다.** * * 굶김 문턱을 넘는 틱이 연속 `STARVE_STREAK` 번이면 그 트윈은 구조적으로 무겁다(우연이 아니다). * 문턱 아래로 한 번만 내려와도 연속을 끊는다 — 무거운 순간 하나로 트윈을 내리지 않는다. */ private static guardStarvation(domainId: string, id: string, tookMs: number): void { const key = runtimeKey(domainId, id) const starveMs = this.TICK_MS * this.STARVE_FACTOR if (!(tookMs >= starveMs)) { this.starveStreak.delete(key) return } const streak = (this.starveStreak.get(key) ?? 0) + 1 this.starveStreak.set(key, streak) if (streak < this.STARVE_STREAK) return this.starveStreak.delete(key) const secs = Math.round(tookMs) / 1000 this.stopWithNote( domainId, id, { code: 'starved', params: { streak, thresholdSec: starveMs / 1000, lastSec: secs } }, `[twin-engine] "${id}" stopped — ${streak} consecutive ticks over ${starveMs / 1000}s (last ${secs}s). ` + `A simulation tick runs on the main loop, so this twin was stalling HTTP, subscriptions and every other twin. ` + `Its state is checkpointed; start it again after making it lighter (fewer orders/resources) or wait for off-loop ticking.` ) } /** 멈추고 **이유를 남긴다** — 이유 없는 「정지」는 사람이 자기가 멈춘 것으로 읽는다. */ private static stopWithNote(domainId: string, id: string, note: StopNote, log: string): void { twinError(log) this.stopNotes.set(runtimeKey(domainId, id), note) this.stop(domainId, id).catch(err => twinError(`[twin-engine] stop after guard failed "${id}"`, err?.message ?? err)) } /** 이 트윈이 스스로 멈춘 이유(있으면) — 화면이 옮겨 말한다. */ static stopNoteOf(domainId: string, id: string): StopNote | undefined { return this.stopNotes.get(runtimeKey(domainId, id)) } /** * 이 트윈이 **지금 실행 중인가** — 판정의 집 하나. * * "스냅샷이 있나" 로는 답이 되지 않는다: 웜스타트가 되살린 상태가 캐시에 남아 있으면 멈춘 트윈도 * 스냅샷을 낸다. 그것을 관측 중으로 읽으면 화면이 **멈춘 트윈의 작업을 0 건이라고 단언**한다 * (실제로 그렇게 나왔다 — 관측이 없는 것과 0 건은 다르다는 이 프로젝트의 규율을 화면이 어겼다). * * 목록이 쓰는 것과 **같은 술어**다(`list()` 의 `running`) — 두 벌이면 어긋난다. */ static isRunning(domainId: string, id: string): boolean { return !!this.instances[runtimeKey(domainId, id)] } static runtime(domainId: string, id: string): TwinRuntimeType | undefined { return this.instances[runtimeKey(domainId, id)]?.runtime } /* * 커맨드 실행 — **판정은 `routeCommand`(순수)가 하고 여기서는 실행만** 한다. * 판정을 코드 한가운데 두면 이 부류를 테스트로 못 잡는다(엔진은 DB 를 물고 있어 단위 테스트가 * 불러올 수 없다). 예전에는 리졸버가 `inst.runtime.dispatch` 를 곧바로 불러 미러에서 터졌다. */ static async dispatchCommand(domainId: string, instanceId: string, command: any): Promise<{ accepted: boolean; error?: string; errorCode?: string; errorParams?: Record }> { const inst = this.instances[runtimeKey(domainId, instanceId)] const type = String(command?.type ?? '') const route = routeCommand( inst && { mode: inst.mode, hasRuntime: !!inst.runtime, hasKernelDispatch: typeof (inst.kernel as any)?.dispatch === 'function' }, !!inst && inst.domainId === domainId, type ) if (route.target === 'reject') { return { accepted: false, errorCode: route.errorCode, errorParams: route.errorParams, error: route.errorCode } } const cmd = { ...command, tenantId: domainId } // tenantId 는 호출자 도메인으로 각인(감사·무결성) if (route.target === 'runtime') return inst!.runtime!.dispatch(cmd) /* * 미러 — 관측 커널로 보내고, **커맨드가 낸 사실을 저널 큐에 실어** 코얼레서가 번호를 부여하게 한다. * 미러에는 State 구독 배관이 없어(시뮬은 그 경로로 저널링) 커널 방출이 아무 데도 닿지 않는다. * 여기서 DB 를 따로 읽어 번호를 매기면 인메모리 카운터와 어긋나 리비전이 겹친다(겹침은 오류를 * 내지 않고 재생 순서만 알리지 않고 뒤섞는다). */ const kernel: any = inst!.kernel const emitted: any[] = [] const off = kernel.onEvent?.((e: any) => emitted.push(e)) let ack: any try { ack = kernel.dispatch(cmd) } finally { off?.() } if (ack?.accepted && emitted.length) { inst!.pendingJournal = [...(inst!.pendingJournal ?? []), ...emitted] inst!.dirty = true // 코얼레서가 이번 주기에 비우고 브로드캐스팅까지 하게 한다 } return ack ?? { accepted: false, errorCode: 'unknown-command', error: 'unknown-command' } } /** * 도메인 소유 게이트 — 인스턴스가 이 도메인 소유일 때만 true(테넌트 격리). * 없거나 다른 도메인이면 false. 노출 리졸버(per-id metrics/attentions/state/stop/forecast)가 * this.instances[id] 를 만지기 전에 반드시 이걸로 확인해야 한다. 인스턴스 id 는 추측 가능하므로 * 검증 없이 접근하면 크로스테넌트 읽기/정지가 가능해진다. */ /** * 이 런타임의 **관측 모드** — `'live'`(외부 실물을 미러) 또는 `'sim'`(커널이 실행한다). 기동 중이 아니면 없다. * * 소비처가 `instances[id].mode` 를 직접 읽던 자리를 대신한다. 레지스트리를 밖에 열면 도메인 확인이 * 자리마다 제각각이 되고, 실제로 그렇게 됐다(테넌트 격리 전수 확인, 2026-08-06). */ static modeOf(domainId: string, id: string): 'live' | 'sim' | undefined { return this.instances[runtimeKey(domainId, id)]?.mode } static owns(domainId: string, id: string): boolean { return !!this.instances[runtimeKey(domainId, id)] } /** * 이 트윈의 **중복 판정 기억을 미리 채운다** — 저널에서 읽은 정체로. * * 기억은 프로세스 안에만 있어서 재기동하면 비워진다(§`FactDeduper`). 그 뒤 backfill 을 돌리면 * 이미 저널에 있는 사실도 「처음 본 것」이 되어 다시 앉는다. 실측으로 39건이 통째로 다시 앉았다 * (2026-09-06, `mes-line-a`). * * 안 도는 트윈에는 채울 곳이 없다 — 0을 낸다(거짓이 아니라 사실이다). */ static warmDeduper(domainId: string, id: string, identities: readonly string[]): number { const inst = this.instances[runtimeKey(domainId, id)] if (!inst || !identities.length) return 0 return (inst.deduper ??= new FactDeduper()).remember(identities) } /** * 이 프로세스가 지금 들고 있는 트윈들 — 테넌트로 좁혀서. * * 주기 작업(규칙 판정 등)이 「무엇을 돌아야 하나」를 물을 자리다. 등록부(DB)를 읽으면 **다른 프로세스가 * 든 트윈까지 나온다** — 그것을 돌면 같은 조치가 두 벌 올라간다. 도는 것은 든 쪽이 돈다. * * `owns()` 와 같은 술어를 쓴다(`this.instances`). 두 벌이면 어긋난다. */ static ownedTwins(domainId?: string): { domainId: string; instanceId: string }[] { return Object.entries(this.instances) .map(([key, inst]) => ({ domainId: inst.domainId, instanceId: parseRuntimeKey(key).instanceId })) .filter(t => !domainId || t.domainId === domainId) } /** * 이 트윈이 이 테넌트 것인가 — **떠 있든 아니든.** * * `owns()` 는 **기동 중인** 런타임만 안다. 그것을 이력 조회의 관문으로 쓰면, 꺼진 트윈의 저널을 * 읽으려 할 때 "이 테넌트에 없다" 는 답이 돌아온다 — 두 가지가 틀렸다. 첫째, 저널은 트윈이 꺼져 * 있을 때 **가장 필요한 것**이다(그게 이력의 존재 이유다). 둘째, 그 문장은 남의 것이라는 뜻이라 * 사용자가 권한 문제로 오해한다. 실제로는 그냥 실행 중이 아닐 뿐이다. * * 소유는 **등록부**가 안다. 이력·집계처럼 런타임과 무관한 질문은 이쪽에 묻는다. */ static async ownsRegistered(domainId: string, instanceId: string): Promise { if (this.owns(domainId, instanceId)) return true return !!(await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })) } /** * **이 공간의 인스턴스들** — 한 현실을 여러 렌즈가 비추므로, 공간을 물으면 그 렌즈 전부를 답한다. * * 화면이 이 규칙을 손으로 짜지 않게 서버가 답한다. 예측 화면은 같은 확장을 **한 파일에서 두 번** * 복제하고 있었다(전체 목록을 받아 클라이언트에서 걸렀다) — 규칙이 흩어지면 한쪽만 고쳐진다. * * 떠 있지 않은 것도 포함한다: 공간에 무엇이 있는지는 기동 여부와 다른 사실이다. */ static async instanceIdsOfSpace(domainId: string, spaceId: string): Promise { if (!spaceId) return [] const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, spaceId } }) return rows.map(r => r.instanceId).filter((x): x is string => !!x) } /** * **이 공간에서 봐야 할 것** — 공간의 모든 렌즈에서 신호를 모은다. * * 합집합이 그대로 뜻이 있는 유일한 렌즈다(예측은 합칠 수 없다 — P50 두 개를 더할 수 없다). * 정렬·잘라내기는 화면과 **같은 규칙**을 쓴다(`digestAttentions`) — 서버가 고른 상위 N 이 화면이 * 고를 N 과 달라지면 그 어긋남은 아무 데서도 오류로 드러나지 않는다. * * 신호마다 어느 트윈에서 왔는지(`instanceId`)를 붙인다. 공간에서 보면 같은 자리 id 가 렌즈마다 * 다른 것을 가리킬 수 있고, 조치는 결국 그 트윈에 보내야 한다. */ static async attentionsOfSpace(domainId: string, spaceId: string, limit?: number): Promise<{ attentions: any[]; attentionTotal: number; severityByLocation: Record }> { const ids = await this.instanceIdsOfSpace(domainId, spaceId) const lenses = ids /* 떠 있지 않은 트윈은 **지금** 신호가 없다(그 시절 신호는 시간여행이 답한다). */ .filter(id => this.owns(domainId, id)) .map(id => { const kernel: any = this.kernel(domainId, id) const snap = kernel?.getSnapshot?.() ?? this.snapshot(domainId, id) return { instanceId: id, attentions: snap?.attentions ?? [] } }) /* 모으는 규칙(태깅·급한 순서·자리 색)은 순수 함수가 들고 있다 — 여기서 손으로 계산하지 않는다. */ return mergeLensAttentions(lenses, limit) } /** 라이브 커널(ForecastTwin) — forecast/divergence 예측 연산용. */ static kernel(domainId: string, id: string): any { return this.instances[runtimeKey(domainId, id)]?.kernel } /** 라이브 처리량 계측 스냅샷(모니터, ④-1) — 내부 누적(_acc*) 제외한 공개 지표. live 아니면 null. */ static metrics(domainId: string, id: string): any { const m = this.instances[runtimeKey(domainId, id)]?.metrics if (!m) return null return { instanceId: id, ingestedTotal: m.ingestedTotal, broadcastTotal: m.broadcastTotal, journaledTotal: m.journaledTotal, ingestRate: m.ingestRate, broadcastRate: m.broadcastRate, journalRate: m.journalRate, backlog: m.backlog, broadcastCoalesceMs: this.broadcastPeriodMs, /* 작업별 부하 — 무거운 것부터. 잰 적이 없으면 null(0 으로 채우면 "빠르다" 로 읽힌다). */ load: loadSummary(this.instances[runtimeKey(domainId, id)]?.load, this.TICK_MS) } } /** * 부하 계기판만 따로 — **시뮬 트윈도 포함한다.** * * `metrics()` 는 라이브 지표(`inst.metrics`)가 있어야 무언가를 돌려준다. 그런데 이벤트 루프를 점유하는 * 것은 주로 **시뮬 틱**이고, 시뮬 인스턴스에는 그 지표가 없어 통째로 안 보였다. */ static load(domainId: string, id: string): any { const inst = this.instances[runtimeKey(domainId, id)] if (!inst) return null return { instanceId: id, mode: inst.mode ?? 'sim', domainId: inst.domainId, domainLabel: inst.domain?.subdomain, restartPolicy: inst.restartPolicy, tickMs: this.TICK_MS, running: !!inst.timer || inst.mode === 'live', ...(loadSummary(inst.load, this.TICK_MS) ?? { recent: null, total: null, forksCreated: 0, overBudget: 0, budgetMs: this.TICK_MS * 0.5, loadRatio: null }) } } /** * **한 곳에서 보는 전체 부하** — 몇 개가 실행 중이고, 누가 루프를 점유하고 있나. * * 시뮬 트윈도 포함한다(`metrics()` 는 라이브 지표가 있어야 답해서 시뮬이 통째로 안 보였다). */ static fleet(domainId?: string): any { const keys = Object.keys(this.instances).filter(key => !domainId || isOfDomain(key, domainId)) const rows = keys.map(key => { const at = parseRuntimeKey(key) const inst = this.instances[key] /* 도메인을 함께 넘긴다 — 운영자가 먼저 묻는 것은 "어느 테넌트가 루프를 먹나" 다. 읽을 수 있는 이름(subdomain)은 라이브 인스턴스에만 붙어 있으므로, 없으면 리졸버가 채운다. */ return { instanceId: at.instanceId, mode: inst.mode ?? 'sim', domainId: at.domainId, domainLabel: inst.domain?.subdomain, meter: inst.load } }) return { tickMs: this.TICK_MS, broadcastCoalesceMs: this.broadcastPeriodMs, /* 브로드캐스팅 주기를 늘린 횟수 — 늘어난 주기만 보면 「원래 그런 값」으로 읽힌다. */ broadcastBackoffs: this.broadcastBackoffs, /* 물품 범위를 좁히지 못해 전부 만든 횟수 — 좁히기가 실제로 듣고 있는지를 이 값으로 본다. */ broadcastPasses: this.broadcastPasses, broadcastFullPasses: this.broadcastFullPasses, broadcastFullEvery: this.FULL_BROADCAST_EVERY, /* **루프가 실제로 얼마나 막혔나** — 트윈 부하와 나란히 놓고 원인을 가린다(loop-lag.ts 주석). */ loopLag: loopLag.view(), ...fleetLoad(rows, this.TICK_MS), /* 줄마다 상세 — 화면이 펼쳐 볼 수 있게. 최근 부하 순서는 fleetLoad 가 정한다. */ details: keys.map(key => { const at = parseRuntimeKey(key); return this.load(at.domainId, at.instanceId) }).filter(Boolean) } } /** 이 트윈에서 fork 가 만들어졌다 — 예측 한 번이 수십 개를 만든다. */ static countForks(domainId: string, id: string, count: number): void { const inst = this.instances[runtimeKey(domainId, id)] if (inst) recordFork(inst.load ?? (inst.load = newLoadMeter()), count) } /** 엔진 밖(예측 질의 등)에서 무거운 작업을 잰다 — 같은 계기판에 모인다. */ static recordLoad(domainId: string, id: string, phase: LoadPhase, tookMs: number): void { const inst = this.instances[runtimeKey(domainId, id)] if (inst) recordPhase(inst.load ?? (inst.load = newLoadMeter()), phase, tookMs) } /* * ── 동기화 건강 장부 ─────────────────────────────────────────────────────── * * **런타임이 아니라 엔진이 든다.** 인스턴스에 붙이면 트윈을 재기동할 때 사라지는데, 사람이 알고 싶은 * 것은 바로 그 순간이다 — 「고쳐서 다시 띄웠는데 이제 통과하나」. 프로세스가 사는 동안 이어진다. * * 지금은 메모리만이다. 창이 닫힐 때 한 행씩 영속하는 것은 `onWindowClosed` 한 자리에 붙는다. */ private static ingestLedgers: Record = {} /** 창이 닫힐 때 부를 곳 — 영속 계층이 부팅에서 한 번 등록한다(커널 층은 그 계층을 모른다). */ private static onWindowClosed?: (at: { domainId: string; instanceId: string }, closed: IngestWindow) => void /** * 닫힌 창을 받을 곳을 등록한다 — **영속(B)이 붙는 유일한 문.** * * 이 패키지는 저장 계층을 모른다. 등록하는 쪽이 자기 방식으로 쓴다. 훅이 오류를 내도 장부는 계속 굴러간다 * (`rollIngestWindow` 가 감싼다) — 영속을 지키려고 관측을 멈추지 않는다. */ static onIngestWindowClosed( fn: (at: { domainId: string; instanceId: string }, closed: IngestWindow) => void ): void { this.onWindowClosed = fn } /** * **모든 트윈의 창을 굴린다** — 보는 사람이 없어도 창이 닫히게. * * ── 무엇이 있었나 (2026-09-05 실측) ──────────────────────────────────────── * 창은 「다음 사건이 왔을 때 이전 창을 닫는」 모양으로만 굴렀다. 그러면 사실이 드문 트윈은 창이 * 계속 열려 있고, 표에 아무것도 안 남는다. 두 수가 그것을 정확히 말한다. * * mes-miratek 사실 46건 · 62분에 흩어짐 창 1개 * mes-line-a 사실 11건 창 48개 * * **사실 수와 창 수가 거꾸로다.** 창을 만든 것이 유입이 아니라 **누가 물어본 것**이다 — * `ingestHealthOf()` 가 굴리므로, 점검표·화면이 계속 묻는 트윈만 기록이 쌓였다. * * 그래서 **아무도 안 보는 트윈일수록 기록이 없다.** 사실이 조용히 사라지는 것을 잡으려고 만든 * 장부가 정확히 그 반대로 돌고 있었다. 실제로 137봉투가 장부 없이 지나갔다(인티그레이션 레인 실측). * * ── 왜 주기인가 ──────────────────────────────────────────────────────────── * 창이 닫히는 이유는 **시간이 지난 것**이다. 사건도 질의도 그것을 알려 주는 신호일 뿐이고, 둘 다 * 안 올 수 있다. 시간은 늘 지나므로 주기가 그 사실에 맞는 신호다. * * 조용한 트윈이 자기 침묵을 기록하게 되는 것이 이 변경의 값이다 — `expected-quiet` 판정이 그 * 기록 위에 선다. */ static rollAllIngestWindows(nowMs: number = Date.now()): number { let closed = 0 for (const key of Object.keys(this.ingestLedgers)) { const ledger = this.ingestLedgers[key] if (!ledger) continue /* * 키가 깨져 있으면 그 트윈만 건너뛴다 — 하나 때문에 나머지 장부가 안 굴러서는 안 된다. * 훅의 실패도 `rollIngestWindow` 가 감싼다(영속을 지키려고 관측을 멈추지 않는다). */ let at: { domainId: string; instanceId: string } try { at = parseRuntimeKey(key) } catch { continue } rollIngestWindow(ledger, nowMs, undefined, w => { closed++ this.onWindowClosed?.(at, w) }) } return closed } /** * 한 번의 인제스트 결과를 장부에 적는다. * * `offered` 는 **제시된 레코드 수**다(거부 여부 무관). 통과율을 서로 다른 두 계수기에서 나눠 계산하면 * 분모와 분자가 다른 것을 세게 되므로, 한자리에서 본 수를 그대로 넘긴다. */ /** * 이 트윈의 저널이 지금 몇 번까지 적혔나 — **접근자로 낸다.** * * 레지스트리를 밖에서 직접 색인하지 않게 하는 것이 이 저장소의 규율이다(§`engine-registry-access`). * 지난 기록을 채우는 길이 다음 번호를 알아야 해서 열었다. * * 트윈이 도는 중이 아니면 0 이다 — 그때는 저널만 있고 이어 붙일 번호를 커널이 들고 있지 않다. */ /** * 마감된 구간 사실의 **이름을 지을 때 쓸 범위와 선언** — 유입 문에 넘긴다. * * ── 왜 호스트가 주나 (2026-08-30) ──────────────────────────────────────── * 커널은 `new Kernel(domainId, …)` 으로 서므로 **자기가 어느 트윈인지 모른다.** 그런데 설비 번호는 * 트윈 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이 * 된다. 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳐, 여러 현장을 함께 계산하는 성과 화면에서 * 한쪽 발전량이 사라진다. * * 선언된 정체성은 **커널이 답한다**(§`FlowEngine.equipmentIdentity`) — 판정을 두 곳에 두지 않는다. * 트윈이 돌고 있지 않으면 선언을 물을 곳이 없으므로 범위만 넘긴다(그때도 겹치지는 않는다). */ static factScope( domainId: string, instanceId: string ): { scopeId: string; identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined } { const inst = this.instances[runtimeKey(domainId, instanceId)] const kernel: any = inst?.kernel if (typeof kernel?.equipmentIdentity !== 'function') return { scopeId: instanceId } return { scopeId: instanceId, identityOf: (kind, localId) => (kind === 'equipment' ? kernel.equipmentIdentity(localId) : undefined) } } static journalHead(domainId: string, instanceId: string): number { const inst = this.instances[runtimeKey(domainId, instanceId)] return Number(inst?.revision ?? 0) } /** * 저널에 그만큼 적었으니 번호를 밀어 준다. * * 밀어 주지 않으면 다음에 라이브가 **같은 번호를 다시 쓴다** — 같은 시각의 두 사실을 가릴 수 없게 된다. */ static advanceJournalHead(domainId: string, instanceId: string, by: number): void { const inst = this.instances[runtimeKey(domainId, instanceId)] if (!inst || !(by > 0)) return inst.revision = Number(inst.revision ?? 0) + by } static recordIngestResult( domainId: string, instanceId: string, offered: number, rejected: { record?: unknown; errors?: string[] }[] | undefined, nowMs = Date.now(), /** * 매핑은 통과했는데 **커널이 받지 않은** 수(`ingestLive` 의 반환과 견주어 얻는다). * * 「통과」로 세지 않는다 — 트윈이 멈춘 사이 사실이 사라지는데 화면이 100% 라고 말하게 된다. */ undelivered = 0, /** * **저널에 안 간 어휘 요소의 수** — 로트 번호·소비기한처럼 시각축이 없는 값. * * 사라진 것이 아니라 저널로 가지 않는 것이다. 그런데 그 수를 빼 주는 자리가 없으면 * 「보낸 것 − 앉은 것」이 안 맞고 **그 차이를 사라진 것으로 읽게 된다**(2026-09-05 실측: * 커서 20 · 저널 17 · 어휘 1 을 사람이 손으로 세서 「둘이 빈다」를 찾았다). */ vocabulary = 0 ): void { const key = runtimeKey(domainId, instanceId) const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger()) recordIngest( ledger, offered, rejected, nowMs, closed => this.onWindowClosed?.({ domainId, instanceId }, closed), undelivered ) /* 창을 이미 굴린 뒤라 순서가 중요하다 — 먼저 적으면 한 유입이 두 창에 걸친다. */ recordVocabulary(ledger, vocabulary) } /** * **원본에 닿지 못했다**를 적는다 — 「받은 것이 없다」와 가른다(§`recordReadFailure`). * * 이 문이 없던 동안 실 원본이 끊겨도 트윈의 조회 가능한 상태에 그 사실이 없었다. 화면이 볼 수 있는 * 것은 「새 사실이 없다」뿐이었고 그것은 「연결된 시스템에서 들어온 것이 없다」와 구별되지 않는다 — 실증 중에 원본이 * 끊기면 사용자가 원인을 찾을 수 없다. * * 로그로는 말하고 있었다(어댑터가 재시도를 경고한다). 그러나 **로그는 사람이 볼 때만 값이 있다** — * 화면이 말하려면 상태에 있어야 한다. */ static recordIngestReadFailure( domainId: string, instanceId: string, reason: string, nowMs = Date.now(), stream?: string, nextRetryMs?: number ): void { const key = runtimeKey(domainId, instanceId) const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger()) recordReadFailure(ledger, reason, nowMs, stream, nextRetryMs) } /** * **끊겼다가 돌아왔다** — 실패 기록을 지우고 그 사실을 남긴다 (2026-08-27). * * `clearIngestReadFailure` 와 나누어 둔다: 지우기만 하면 「끊긴 적이 있었다」가 화면에서 사라진다. */ static recordIngestRecovered( domainId: string, instanceId: string, afterFailures: number, downMs: number, nowMs = Date.now() ): void { const key = runtimeKey(domainId, instanceId) const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger()) recordRecovered(ledger, afterFailures, downMs, nowMs) } /** * 읽기가 성공했다 — 단절 기록을 지운다. * * **빈 읽기도 성공이다.** 원본이 「새 것이 없다」고 답한 것은 닿았다는 뜻이므로, 그때도 부른다. * 그 둘을 같게 두면 들어온 것이 없는 연결이 끊긴 원본으로 보인다. */ static clearIngestReadFailure(domainId: string, instanceId: string): void { const ledger = this.ingestLedgers[runtimeKey(domainId, instanceId)] if (ledger) clearReadFailure(ledger) } /** * **읽었는데 창을 넘길 수 없다**를 적는다 — 위와 **조치가 반대인** 사실이다(§`recordCursorStall`). * * 이 문이 없던 동안 이 사실이 `recordIngestReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지 * 못한다」고 말했는데 원본은 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다(한 시각에 한 * 페이지보다 많은 행). 사람은 원본을 의심하고 기다리는데 **기다림으로는 영원히 풀리지 않는다.** * * 조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다. */ static recordIngestCursorStall(domainId: string, instanceId: string, reason: string, nowMs = Date.now(), stream?: string): void { const key = runtimeKey(domainId, instanceId) const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger()) recordCursorStall(ledger, reason, nowMs, stream) } /** * **원본에 있는데 세우지 않은 것을 적는다** — 이유와 수(§`IngestLedger.withheld`). * * 어댑터가 주기마다 불러도 된다: 장부가 이유로 묶어 **마지막 수로 덮는다**(누적하지 않는다). * `count: 0` 은 「그 이유가 풀렸다」로 그 줄을 지운다 — 알리지 않고 그치면 낡은 수가 남는다. */ static recordIngestWithheld(domainId: string, instanceId: string, reason: string, count: number, nowMs = Date.now()): void { const key = runtimeKey(domainId, instanceId) const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger()) if (!(count > 0)) { if (ledger.withheld) { ledger.withheld = ledger.withheld.filter(w => w.reason !== reason) if (!ledger.withheld.length) delete ledger.withheld } return } recordWithheld(ledger, reason, count, nowMs) } /** 창을 넘겼다 — 정체 기록을 지운다(풀린 정체가 화면에 남아 있으면 그것도 거짓이다). */ static clearIngestCursorStall(domainId: string, instanceId: string): void { const ledger = this.ingestLedgers[runtimeKey(domainId, instanceId)] if (ledger) clearCursorStall(ledger) } /** * 저널에 **적은 것**을 같은 장부에 남긴다 — 유입과 같은 10분 창에. * * ── 왜 유입 장부에 넣나 ──────────────────────────────────────────────────── * 새 장부를 만들면 「닫힌 창만 최근」·「0 과 없음을 가른다」·영속을 두 벌 지켜야 하고, 그중 한 벌만 * 고쳐지는 것이 보통이다. 유입 창은 그 규율이 이미 들어 있고 닫힐 때 행으로 남는다. * * ── 이 값이 없으면 무엇을 못 보나 ────────────────────────────────────────── * 계기(`journalRate`)는 **지금**만 답한다. 프로세스를 다시 띄우면 0 에서 시작하므로 「어제 이 시각에도 * 이랬나」·「고친 뒤로 줄었나」를 물을 자리가 없다. 2026-08-20 에 고친 것이 바로 쓰기 경로이므로, * 되돌아가는 것을 볼 수 있어야 한다. */ static recordJournalWrite(domainId: string, instanceId: string, rows: number, nowMs = Date.now()): void { const key = runtimeKey(domainId, instanceId) const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger()) recordLedgerWrite(ledger, rows, nowMs, closed => this.onWindowClosed?.({ domainId, instanceId }, closed)) } /** * 「이 트윈이 현장과 맞춰지고 있나」 — 한눈 판정 + 추이 + 사유 + 표본. * * 조회할 때 창을 한 번 굴린다: 유입이 멈추면 다음 인제스트가 없어 창이 영원히 닫히지 않는데, 그러면 * 「최근」이 옛것으로 남는다. 타이머를 두지 않는 이유는 트윈마다 타이머를 걸면 그 타이머들이 다시 * 메인 루프에 얹히기 때문이다(오늘 확인한 그 부하를 이 기능이 다시 만들 이유가 없다). */ /** * 도메인의 트윈마다 **한 줄 요약** — 목록 화면과 미니 추이용. * * ── 왜 상세와 따로인가 ───────────────────────────────────────────────────── * `ingestHealthOf` 는 사유 문구와 **레코드 원문 표본**까지 낸다. 목록에 트윈이 스무 개면 그 원문이 * 스무 벌 실려 조회가 무거워지고, 화면은 어차피 그것을 그리지 않는다. 그래서 여기서는 **숫자만** 낸다. * * 추이는 창당 숫자 넷이라 스파크라인 하나에 충분하고 가볍다. * * ── 등록된 트윈을 기준으로 훑는다 ────────────────────────────────────────── * 도는 트윈만 훑으면 「고치려고 멈춰 둔 트윈」이 목록에서 사라진다 — 사람이 방금 멈춘 그것을 보려고 * 목록을 여는데 없으면 결함으로 읽는다. 그래서 호출부(목록 화면)가 아는 instanceId 들을 받는다. */ static ingestHealthBrief( domainId: string, instanceIds: string[] ): { instanceId: string; verdict: string; acceptedRatio: number | null; trend: { offered: number; rejected: number; undelivered: number; rows: number }[]; lastAt: string | null }[] { return instanceIds.map(instanceId => { const full = this.ingestHealthOf(domainId, instanceId) return { instanceId, verdict: full.verdict, /* 최근 **닫힌** 창의 통과율. 창이 안 닫혔으면 비운다 — 0 이나 1 로 채우지 않는다. */ acceptedRatio: full.recent?.acceptedRatio ?? null, /* * 스파크라인용 — 창마다 셋. **미전달을 빼놓으면 스파크라인이 거짓을 그린다**: 버려진 것을 * 통과로 세면 트윈이 멈춘 구간에서도 선이 100% 에 붙는다. */ /* 적힌 행도 함께 — 시뮬 트윈은 유입이 0 이라 이 값 없이는 추이가 빈 선으로 보인다. */ trend: full.trend.map(w => ({ offered: w.offered, rejected: w.rejected, undelivered: w.undelivered, rows: w.rows })), lastAt: full.lastAt } }) } /** * 한 트윈의 동기화 건강. * * ── 조회가 장부를 전진시킨다(의도) ───────────────────────────────────────── * 창은 **다음 유입이 있을 때** 닫힌다. 그래서 피드가 죽으면 마지막 창이 열린 채로 남아 영원히 영속되지 * 않고, 추이에도 들어가지 않는다. 조회 시점에 한 번 굴려 주면 그 마지막 창이 닫히면서 영속 훅도 불린다 * — 즉 **죽은 피드의 마지막 구간을 잃지 않기 위해** 읽기가 굴린다. 부수효과지만 필요한 부수효과다. * * 시각은 **한 번만 읽어** 굴리기와 판정에 같은 값을 쓴다. 두 번 읽으면 그 사이에 창이 닫혀 판정이 * 굴리기 전 상태를 보는 일이 생긴다. */ static ingestHealthOf(domainId: string, instanceId: string): IngestHealthView { const key = runtimeKey(domainId, instanceId) const nowMs = Date.now() const ledger = this.ingestLedgers[key] if (ledger) { rollIngestWindow(ledger, nowMs, undefined, closed => this.onWindowClosed?.({ domainId, instanceId }, closed)) } const inst = this.instances[key] const feedState = liveFeedStateOf({ restartPolicy: inst?.restartPolicy, running: !!inst, instanceId }) return ingestHealth(ledger, feedState, undefined, nowMs) } /** * 도는 인스턴스 전체의 계측(모니터 대시보드용) — **시뮬과 미러를 함께**. * * 예전에는 시뮬에 계기가 없어 이 목록에서 알리지 않고 빠졌다(계기가 `null` 이라 걸러졌다). 도는 트윈 * 대부분이 시뮬인 서버에서 그 목록은 「부하가 거의 없다」로 보였다. */ static async allMetrics(domainId?: string): Promise { /* 도메인 없이 부르면 전 테넌트를 훑는다(내부 모니터용) — 키에서 도메인을 되돌려 각자에게 묻는다. */ const rows = Object.keys(this.instances) .filter(key => !domainId || isOfDomain(key, domainId)) .map(key => { const at = parseRuntimeKey(key) return this.metrics(at.domainId, at.instanceId) }) .filter(Boolean) if (!rows.length || !domainId) return rows // 표시 이름 부여 — 실행중 카드가 id 만 보이지 않도록 공간명을 실어준다(공간 카드와 동일). name=공간명 폴백 spaceId 폴백 instanceId. const insts = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } }) const spaceOf = new Map(insts.map(i => [i.instanceId, i.spaceId])) /* 용도 선언도 함께 실어 보낸다 — 화면이 공간 이름으로 벤치를 **추측하지 않게** 한다. 추측하면 `loadtest-` 로 이름 지은 운영 트윈에 벤치 딱지가 붙는다. */ const purposeOf = new Map(insts.map(i => [i.instanceId, i.purpose])) const nameOf = new Map((await getRepository(TwinSpace).find({ where: { domain: { id: domainId } } })).map(s => [s.spaceId, s.name])) for (const r of rows) { const sp = spaceOf.get(r.instanceId) r.spaceId = sp r.purpose = purposeOf.get(r.instanceId) r.name = (sp && nameOf.get(sp)) || sp || r.instanceId } return rows } /* * `buildForecastKernel` 은 **사라졌다**(통합 P3, 2026-08-01). * * 라이브가 `StateProjector` 였을 때는 커널이 없어서, 예측하려면 관측 스냅샷 + 저널의 오더 원값으로 * **일회용 커널을 세워야** 했다. 이제 라이브 인스턴스가 곧 커널이므로(`startLive`) 그 자리에서 * `fork` 하면 된다 — `TwinEngine.kernel(id)` 가 sim·live 둘 다 돌려준다. * * (오더 원값을 저널에서 되찾던 이유도 사라졌다 — 상태가 requested·fulfilled·lines 를 지킨다.) */ /** * 예측용 커널을 **임의 시각 T 기준**으로 재구성 — 과거-vantage 예측(백테스트)·"그때 서서 본 미래". * recover(untilTime)로 T 시점 상태를, T 이하 오더 관측을 모아 hydrate → monteCarloForecast 가 T 에서 앞으로 실행한다. * sim/live 무관(저널 기반 재구성). untilTime 생략 시 최신. 도메인 스코프(reg·journal 조회가 domainId). */ static async buildForecastKernelAt(domainId: string, instanceId: string, untilTime?: string): Promise { const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }) if (!reg?.model) return null const Kernel = kernelFor(reg.kind) const k = new Kernel(domainId, undefined, this.productionSpecOf(reg.model as TwinModelDef)) refusingModel(instanceId, () => k.loadTwinModel(reg.model as TwinModelDef)) this.applyOperations(k, reg.model, instanceId) // 예측도 같은 명세로 굴러야 한다(화면과 다른 숫자 금지) await this.installEstimators(k, domainId, instanceId, reg.model) // 예측은 기다린다 — 실측을 놓치면 예측이 상수로 계산된다 const state = await this.recover(domainId, instanceId, undefined, untilTime).catch(() => null) if (!state) return null const cutoff = untilTime != null ? Date.parse(untilTime) : Infinity /* * journal-fold: 오더의 마지막 상태는 그 오더에 일어난 사실들을 계산해야 나온다. * * **없어질 조건** — 지금 이 읽기에는 상한이 없고 `eventType` 에 인덱스도 없다. 그래서 저널이 큰 * 트윈에서는 이 한 번이 그 트윈의 저널 전체 주사다. 오더 상태를 따로 계산해 둔 표(투영)를 두거나, * `(domain, instanceId, eventType, revision)` 인덱스를 붙이고 커서를 넣으면 이 예외는 사라진다. * 시각 상한(`cutoff`)을 SQL 로 내려도 절반은 준다. */ const rows = await getRepository(TwinEvent).find({ where: { domain: { id: domainId }, instanceId, eventType: OP_EVENT.order }, order: { revision: 'ASC' } }) const latest = new Map() for (const r of rows) { if (Number.isFinite(cutoff) && (r.eventTime?.getTime() ?? NaN) > cutoff) continue const d = (r.payload as any)?.data if (d?.orderId) latest.set(d.orderId, d) } k.hydrateObserved(state, [...latest.values()]) return k } /** 현재 전체 스냅샷 — 라이브 우선, 없으면 저널 복구 캐시. */ static snapshot(domainId: string, id: string): any { const inst = this.instances[runtimeKey(domainId, id)] if (inst?.mode === 'live' && inst.projector) { /* live: **커널이 주목 신호를 스스로 낸다**(관측 모드) — 호스트가 덧붙이던 withLiveAttentions 는 * 필요 없다. OEE 만 호스트가 채운다: 원 시스템이 시간 누적을 보내 주지 않아 상태 전이를 적분해 * 만드는 값이라 커널이 대신할 수 없다(적합성 하네스의 유일한 예외). */ const st = inst.projector.snapshot() return inst.oee ? withLiveOee(st, inst.oee) : st } return inst?.runtime?.resync() ?? this.recovered[runtimeKey(domainId, id)] } }