@things-factory/headless-twin 10.0.12 โ†’ 10.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +57 -2
  2. package/dist-server/engine/canonical-ingest.js +43 -2
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/command-routing.js +1 -1
  5. package/dist-server/engine/command-routing.js.map +1 -1
  6. package/dist-server/engine/index.d.ts +3 -0
  7. package/dist-server/engine/index.js +4 -0
  8. package/dist-server/engine/index.js.map +1 -1
  9. package/dist-server/engine/ingest-health.d.ts +376 -0
  10. package/dist-server/engine/ingest-health.js +482 -0
  11. package/dist-server/engine/ingest-health.js.map +1 -0
  12. package/dist-server/engine/integration-coverage.d.ts +76 -0
  13. package/dist-server/engine/integration-coverage.js +73 -0
  14. package/dist-server/engine/integration-coverage.js.map +1 -0
  15. package/dist-server/engine/integration-probes.d.ts +65 -0
  16. package/dist-server/engine/integration-probes.js +100 -0
  17. package/dist-server/engine/integration-probes.js.map +1 -0
  18. package/dist-server/engine/integration-runner.d.ts +45 -0
  19. package/dist-server/engine/integration-runner.js +59 -0
  20. package/dist-server/engine/integration-runner.js.map +1 -0
  21. package/dist-server/engine/integration-target-profile.d.ts +57 -0
  22. package/dist-server/engine/integration-target-profile.js +79 -0
  23. package/dist-server/engine/integration-target-profile.js.map +1 -0
  24. package/dist-server/engine/kpi-fold.d.ts +27 -0
  25. package/dist-server/engine/kpi-fold.js +47 -4
  26. package/dist-server/engine/kpi-fold.js.map +1 -1
  27. package/dist-server/engine/kpi-query.js +128 -14
  28. package/dist-server/engine/kpi-query.js.map +1 -1
  29. package/dist-server/engine/live-feed-registry.js +2 -16
  30. package/dist-server/engine/live-feed-registry.js.map +1 -1
  31. package/dist-server/engine/load-meter.d.ts +1 -1
  32. package/dist-server/engine/load-meter.js +12 -3
  33. package/dist-server/engine/load-meter.js.map +1 -1
  34. package/dist-server/engine/log.d.ts +18 -0
  35. package/dist-server/engine/log.js +80 -0
  36. package/dist-server/engine/log.js.map +1 -0
  37. package/dist-server/engine/loop-lag.d.ts +54 -0
  38. package/dist-server/engine/loop-lag.js +87 -0
  39. package/dist-server/engine/loop-lag.js.map +1 -0
  40. package/dist-server/engine/model-gap.d.ts +108 -0
  41. package/dist-server/engine/model-gap.js +95 -0
  42. package/dist-server/engine/model-gap.js.map +1 -0
  43. package/dist-server/engine/restart-policy.d.ts +3 -3
  44. package/dist-server/engine/restart-policy.js +4 -4
  45. package/dist-server/engine/restart-policy.js.map +1 -1
  46. package/dist-server/engine/runtime-key.js +1 -1
  47. package/dist-server/engine/runtime-key.js.map +1 -1
  48. package/dist-server/engine/stage-path.d.ts +83 -0
  49. package/dist-server/engine/stage-path.js +118 -0
  50. package/dist-server/engine/stage-path.js.map +1 -0
  51. package/dist-server/engine/twin-engine.d.ts +386 -16
  52. package/dist-server/engine/twin-engine.js +1217 -172
  53. package/dist-server/engine/twin-engine.js.map +1 -1
  54. package/dist-server/index.js +34 -2
  55. package/dist-server/index.js.map +1 -1
  56. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.d.ts +5 -0
  57. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js +58 -0
  58. package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +1 -0
  59. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.d.ts +5 -0
  60. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js +55 -0
  61. package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +1 -0
  62. package/dist-server/migrations/index.js +7 -1
  63. package/dist-server/migrations/index.js.map +1 -1
  64. package/dist-server/service/index.d.ts +5 -2
  65. package/dist-server/service/index.js +18 -7
  66. package/dist-server/service/index.js.map +1 -1
  67. package/dist-server/service/reference/discovery-result.d.ts +1 -1
  68. package/dist-server/service/reference/discovery-result.js +1 -1
  69. package/dist-server/service/reference/discovery-result.js.map +1 -1
  70. package/dist-server/service/reference/index.d.ts +4 -1
  71. package/dist-server/service/reference/index.js +6 -1
  72. package/dist-server/service/reference/index.js.map +1 -1
  73. package/dist-server/service/reference/reference-adapter.d.ts +210 -3
  74. package/dist-server/service/reference/reference-adapter.js +28 -4
  75. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  76. package/dist-server/service/reference/reference-assessment.d.ts +78 -0
  77. package/dist-server/service/reference/reference-assessment.js +156 -0
  78. package/dist-server/service/reference/reference-assessment.js.map +1 -0
  79. package/dist-server/service/reference/reference-live.d.ts +12 -1
  80. package/dist-server/service/reference/reference-live.js +134 -11
  81. package/dist-server/service/reference/reference-live.js.map +1 -1
  82. package/dist-server/service/reference/reference-master.d.ts +59 -3
  83. package/dist-server/service/reference/reference-master.js +34 -4
  84. package/dist-server/service/reference/reference-master.js.map +1 -1
  85. package/dist-server/service/reference/reference-probe.d.ts +92 -0
  86. package/dist-server/service/reference/reference-probe.js +186 -0
  87. package/dist-server/service/reference/reference-probe.js.map +1 -0
  88. package/dist-server/service/reference/reference-progress-subscription.d.ts +17 -0
  89. package/dist-server/service/reference/reference-progress-subscription.js +94 -0
  90. package/dist-server/service/reference/reference-progress-subscription.js.map +1 -0
  91. package/dist-server/service/reference/reference-progress.d.ts +38 -0
  92. package/dist-server/service/reference/reference-progress.js +71 -0
  93. package/dist-server/service/reference/reference-progress.js.map +1 -0
  94. package/dist-server/service/reference/reference-resolver.js +210 -43
  95. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  96. package/dist-server/service/reference/twin-reference.d.ts +32 -0
  97. package/dist-server/service/reference/twin-reference.js +10 -0
  98. package/dist-server/service/reference/twin-reference.js.map +1 -1
  99. package/dist-server/service/twin-control/twin-control-mutation.js +4 -3
  100. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  101. package/dist-server/service/twin-event/journal-count.d.ts +21 -0
  102. package/dist-server/service/twin-event/journal-count.js +26 -0
  103. package/dist-server/service/twin-event/journal-count.js.map +1 -0
  104. package/dist-server/service/twin-event/twin-event-keys.d.ts +9 -0
  105. package/dist-server/service/twin-event/twin-event-keys.js +15 -18
  106. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  107. package/dist-server/service/twin-event/twin-event-type.d.ts +3 -1
  108. package/dist-server/service/twin-event/twin-event-type.js +18 -1
  109. package/dist-server/service/twin-event/twin-event-type.js.map +1 -1
  110. package/dist-server/service/twin-event/twin-event.d.ts +1 -0
  111. package/dist-server/service/twin-event/twin-event.js +9 -1
  112. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  113. package/dist-server/service/twin-forecast/twin-forecast-query.js +26 -2
  114. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  115. package/dist-server/service/twin-ingest-window/index.d.ts +5 -0
  116. package/dist-server/service/twin-ingest-window/index.js +10 -0
  117. package/dist-server/service/twin-ingest-window/index.js.map +1 -0
  118. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.d.ts +12 -0
  119. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js +123 -0
  120. package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +1 -0
  121. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.d.ts +1 -0
  122. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js +51 -0
  123. package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +1 -0
  124. package/dist-server/service/twin-ingest-window/twin-ingest-window.d.ts +16 -0
  125. package/dist-server/service/twin-ingest-window/twin-ingest-window.js +108 -0
  126. package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +1 -0
  127. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  128. package/dist-server/service/twin-journal/twin-journal-query.js +58 -5
  129. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  130. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +14 -0
  131. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +72 -2
  132. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  133. package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
  134. package/dist-server/service/twin-metrics/twin-metrics-query.js +78 -3
  135. package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
  136. package/dist-server/service/twin-model/axis-journal-evidence.d.ts +2 -0
  137. package/dist-server/service/twin-model/axis-journal-evidence.js +89 -0
  138. package/dist-server/service/twin-model/axis-journal-evidence.js.map +1 -0
  139. package/dist-server/service/twin-model/epcis-coverage.d.ts +1 -1
  140. package/dist-server/service/twin-model/epcis-coverage.js +46 -7
  141. package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
  142. package/dist-server/service/twin-model/iec61850-coverage.d.ts +1 -1
  143. package/dist-server/service/twin-model/iec61850-coverage.js +18 -7
  144. package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -1
  145. package/dist-server/service/twin-model/isa95-coverage.d.ts +14 -0
  146. package/dist-server/service/twin-model/isa95-coverage.js +108 -14
  147. package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
  148. package/dist-server/service/twin-model/item-ref.d.ts +24 -0
  149. package/dist-server/service/twin-model/item-ref.js +88 -0
  150. package/dist-server/service/twin-model/item-ref.js.map +1 -0
  151. package/dist-server/service/twin-model/name-index.d.ts +36 -0
  152. package/dist-server/service/twin-model/name-index.js +116 -0
  153. package/dist-server/service/twin-model/name-index.js.map +1 -0
  154. package/dist-server/service/twin-model/project-structure.js +1 -1
  155. package/dist-server/service/twin-model/project-structure.js.map +1 -1
  156. package/dist-server/service/twin-model/status-tally.d.ts +9 -0
  157. package/dist-server/service/twin-model/status-tally.js +37 -0
  158. package/dist-server/service/twin-model/status-tally.js.map +1 -0
  159. package/dist-server/service/twin-model/twin-lineage-query.js +40 -12
  160. package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
  161. package/dist-server/service/twin-model/twin-model-item-query.js +38 -39
  162. package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
  163. package/dist-server/service/twin-model/twin-model-query.js +156 -4
  164. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  165. package/dist-server/service/twin-model/twin-model-tree-query.js +7 -0
  166. package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
  167. package/dist-server/service/twin-readiness/index.d.ts +2 -0
  168. package/dist-server/service/twin-readiness/index.js +6 -0
  169. package/dist-server/service/twin-readiness/index.js.map +1 -0
  170. package/dist-server/service/twin-readiness/twin-readiness-query.d.ts +3 -0
  171. package/dist-server/service/twin-readiness/twin-readiness-query.js +103 -0
  172. package/dist-server/service/twin-readiness/twin-readiness-query.js.map +1 -0
  173. package/dist-server/service/twin-space/twin-space-resolver.d.ts +2 -2
  174. package/dist-server/service/twin-space/twin-space-resolver.js +5 -4
  175. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  176. package/dist-shared/entity-delta.d.ts +23 -3
  177. package/dist-shared/entity-delta.js +12 -8
  178. package/dist-shared/entity-delta.js.map +1 -1
  179. package/dist-shared/kpi-broadcast.js +1 -1
  180. package/dist-shared/kpi-broadcast.js.map +1 -1
  181. package/dist-shared/touched-items.d.ts +7 -0
  182. package/dist-shared/touched-items.js +80 -0
  183. package/dist-shared/touched-items.js.map +1 -0
  184. package/package.json +7 -7
  185. package/server/engine/canonical-ingest.ts +92 -7
  186. package/server/engine/command-routing.ts +1 -1
  187. package/server/engine/index.ts +4 -0
  188. package/server/engine/ingest-health.ts +782 -0
  189. package/server/engine/integration-coverage.ts +147 -0
  190. package/server/engine/integration-probes.ts +144 -0
  191. package/server/engine/integration-runner.ts +95 -0
  192. package/server/engine/integration-target-profile.ts +103 -0
  193. package/server/engine/kpi-fold.ts +72 -4
  194. package/server/engine/kpi-query.ts +129 -15
  195. package/server/engine/live-feed-registry.ts +2 -1
  196. package/server/engine/load-meter.ts +12 -3
  197. package/server/engine/log.ts +72 -0
  198. package/server/engine/loop-lag.ts +120 -0
  199. package/server/engine/model-gap.ts +168 -0
  200. package/server/engine/restart-policy.ts +4 -4
  201. package/server/engine/runtime-key.ts +1 -1
  202. package/server/engine/stage-path.ts +172 -0
  203. package/server/engine/twin-engine.ts +1334 -168
  204. package/server/index.ts +35 -2
  205. package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +54 -0
  206. package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +53 -0
  207. package/server/migrations/index.ts +7 -1
  208. package/server/service/index.ts +11 -0
  209. package/server/service/reference/discovery-result.ts +1 -1
  210. package/server/service/reference/index.ts +6 -1
  211. package/server/service/reference/reference-adapter.ts +220 -5
  212. package/server/service/reference/reference-assessment.ts +250 -0
  213. package/server/service/reference/reference-live.ts +143 -11
  214. package/server/service/reference/reference-master.ts +76 -6
  215. package/server/service/reference/reference-probe.ts +264 -0
  216. package/server/service/reference/reference-progress-subscription.ts +73 -0
  217. package/server/service/reference/reference-progress.ts +95 -0
  218. package/server/service/reference/reference-resolver.ts +200 -12
  219. package/server/service/reference/twin-reference.ts +39 -1
  220. package/server/service/twin-control/twin-control-mutation.ts +4 -3
  221. package/server/service/twin-event/journal-count.ts +53 -0
  222. package/server/service/twin-event/twin-event-keys.ts +16 -1
  223. package/server/service/twin-event/twin-event-type.ts +26 -2
  224. package/server/service/twin-event/twin-event.ts +7 -0
  225. package/server/service/twin-forecast/twin-forecast-query.ts +29 -2
  226. package/server/service/twin-ingest-window/index.ts +7 -0
  227. package/server/service/twin-ingest-window/twin-ingest-window-query.ts +125 -0
  228. package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +57 -0
  229. package/server/service/twin-ingest-window/twin-ingest-window.ts +113 -0
  230. package/server/service/twin-instance/twin-instance.ts +1 -1
  231. package/server/service/twin-journal/twin-journal-query.ts +59 -5
  232. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +77 -3
  233. package/server/service/twin-metrics/twin-metrics-query.ts +81 -3
  234. package/server/service/twin-model/axis-journal-evidence.ts +57 -0
  235. package/server/service/twin-model/epcis-coverage.ts +29 -8
  236. package/server/service/twin-model/iec61850-coverage.ts +2 -8
  237. package/server/service/twin-model/isa95-coverage.ts +107 -14
  238. package/server/service/twin-model/item-ref.ts +80 -0
  239. package/server/service/twin-model/name-index.ts +94 -0
  240. package/server/service/twin-model/project-structure.ts +1 -1
  241. package/server/service/twin-model/status-tally.ts +37 -0
  242. package/server/service/twin-model/twin-lineage-query.ts +38 -9
  243. package/server/service/twin-model/twin-model-item-query.ts +26 -27
  244. package/server/service/twin-model/twin-model-query.ts +155 -4
  245. package/server/service/twin-model/twin-model-tree-query.ts +7 -0
  246. package/server/service/twin-readiness/index.ts +3 -0
  247. package/server/service/twin-readiness/twin-readiness-query.ts +96 -0
  248. package/server/service/twin-space/twin-space-resolver.ts +5 -4
  249. package/shared/entity-delta.ts +31 -7
  250. package/shared/kpi-broadcast.ts +1 -1
  251. package/shared/touched-items.ts +73 -0
  252. package/test/axis-journal-evidence.test.ts +71 -0
  253. package/test/axis-read.test.ts +101 -1
  254. package/test/boot-resume.test.ts +92 -26
  255. package/test/broadcast-cost-baseline.test.ts +200 -0
  256. package/test/broadcast-period.test.ts +58 -0
  257. package/test/canonical-ingest-vocabularies.test.ts +36 -1
  258. package/test/canonical-quantity-door.test.ts +61 -0
  259. package/test/checkpoint-refuses-empty.test.ts +103 -0
  260. package/test/command-routing.test.ts +1 -1
  261. package/test/cursor-stall-not-read-failure.test.ts +126 -0
  262. package/test/declared-location-types.test.ts +89 -0
  263. package/test/discovery-result.test.ts +1 -1
  264. package/test/duration-estimators.test.ts +1 -1
  265. package/test/entity-delta.test.ts +2 -2
  266. package/test/ingest-bench.test.ts +3 -3
  267. package/test/ingest-health-engine.test.ts +248 -0
  268. package/test/ingest-health-wiring.test.ts +119 -0
  269. package/test/ingest-health.test.ts +306 -0
  270. package/test/ingest-history.test.ts +247 -0
  271. package/test/integration-probes.test.ts +103 -0
  272. package/test/integration-runner.test.ts +95 -0
  273. package/test/item-ref.test.ts +78 -0
  274. package/test/journal-read-discipline.test.ts +177 -0
  275. package/test/journal-retention.test.ts +133 -0
  276. package/test/journal-sort-axis.test.ts +142 -0
  277. package/test/journal-write-door.test.ts +110 -0
  278. package/test/journal-write-trend.test.ts +115 -0
  279. package/test/kernel-kind-guard.test.ts +4 -4
  280. package/test/kpi-fold.test.ts +90 -3
  281. package/test/kpi-query-bench.test.ts +2 -2
  282. package/test/lineage-survives-restart.test.ts +17 -1
  283. package/test/live-cursor-wiring.test.ts +87 -0
  284. package/test/live-feed-registry.test.ts +9 -1
  285. package/test/live-kernel-facts.test.ts +7 -7
  286. package/test/live-mirror-parity.test.ts +6 -0
  287. package/test/load-meter.test.ts +29 -15
  288. package/test/local-declarations.test.ts +1 -1
  289. package/test/log-stamp.test.ts +59 -0
  290. package/test/loop-lag.test.ts +82 -0
  291. package/test/mirror-resumes-from-checkpoint.test.ts +192 -0
  292. package/test/model-gap.test.ts +153 -0
  293. package/test/oee-accumulator.test.ts +4 -0
  294. package/test/operational-vocabulary.test.ts +4 -4
  295. package/test/read-failure-visible.test.ts +145 -0
  296. package/test/reference-grounding.test.ts +70 -0
  297. package/test/resolve-ts-siblings.mjs +52 -0
  298. package/test/restart-policy.test.ts +7 -7
  299. package/test/revision-axis.test.ts +104 -0
  300. package/test/runtime-key.test.ts +2 -2
  301. package/test/scale-twin-bench.test.ts +2 -2
  302. package/test/spec-coverage.test.ts +1 -1
  303. package/test/stage-path.test.ts +95 -0
  304. package/test/standard-coverage.test.ts +15 -3
  305. package/test/status-tally.test.ts +55 -0
  306. package/test/structure-revision-db.test.ts +4 -0
  307. package/test/tenant-registry-db.test.ts +1 -1
  308. package/test/time-range.test.ts +152 -0
  309. package/test/touched-items.test.ts +61 -0
  310. package/test/twin-event-keys.test.ts +10 -2
  311. package/test/twin-model-item-db.test.ts +2 -0
  312. package/test/warm-start-seam.test.ts +4 -0
  313. package/test/yield-loop.test.ts +57 -2
  314. package/tsconfig.shared.tsbuildinfo +1 -1
  315. package/tsconfig.tsbuildinfo +1 -1
@@ -7,6 +7,7 @@
7
7
  * (์„ค๊ณ„ SoT: operato-twin/design/integration/things-factory-host.md)
8
8
  */
9
9
 
10
+ import { twinLog, twinWarn, twinError } from './log.js'
10
11
  import { pubsub, getRepository, Domain } from '@things-factory/shell'
11
12
  /* ์ €๋„์„ ์ž๋ฅผ ์กฐ๊ฑด์€ SQL ์ด ์•ˆ๋‹ค โ€” ๊ด€์šฉ๊ตฌ๋งŒ ์“ด๋‹ค(์›์‹œ SQL ์€ 5๊ฐœ ๋“œ๋ผ์ด๋ฒ„์—์„œ ๊ฐˆ๋ผ์ง„๋‹ค). */
12
13
  import { And, IsNull, LessThanOrEqual, MoreThan } from 'typeorm'
@@ -44,12 +45,29 @@ import { createHash } from 'node:crypto'
44
45
 
45
46
  import { buildEntityDeltas } from '@things-factory/headless-twin/dist-shared/entity-delta.js'
46
47
  import { fleetLoad, judgeCycle, loadSummary, newLoadMeter, recordFork, recordPhase, slowTickMessage, type LoadMeter, type LoadPhase } from './load-meter.js'
48
+ import { loopLag } from './loop-lag.js'
49
+ import { touchedItemKeys } from '@things-factory/headless-twin/dist-shared/touched-items.js'
47
50
  import { diffStructures, type StructureDiff } from './structure-diff.js'
48
51
  import { OeeAccumulator, withLiveOee } from './oee-accumulator.js'
49
52
  import { withLiveAttentions } from './live-attentions.js'
50
53
  import { digestAttentions, mergeLensAttentions } from './attention-digest.js'
51
54
 
52
55
  import { liveFeedStateOf } from './live-feed-registry.js'
56
+ import {
57
+ ingestHealth,
58
+ newIngestLedger,
59
+ recordIngest,
60
+ recordReadFailure,
61
+ clearCursorStall,
62
+ clearReadFailure,
63
+ recordCursorStall,
64
+ rollIngestWindow,
65
+ /* ๋ณ„์นญ โ€” ๊ฐ™์€ ์ด๋ฆ„์˜ ์ •์  ๋ฉ”์„œ๋“œ์™€ ํ—ท๊ฐˆ๋ฆฌ์ง€ ์•Š๊ฒŒ(๊ทธ ๋ฉ”์„œ๋“œ๊ฐ€ ์ด๊ฒƒ์„ ๋ถ€๋ฅธ๋‹ค). */
66
+ recordJournalWrite as recordLedgerWrite,
67
+ type IngestHealthView,
68
+ type IngestLedger,
69
+ type IngestWindow
70
+ } from './ingest-health.js'
53
71
  import { EMS_PROPERTY } from '@operato/twin-kernel'
54
72
  import type { TwinKernel, TwinModelDef, StructureShift, SubscriptionMessage, TwinRuntime as TwinRuntimeType, CanonicalEnvelope } from '@operato/twin-kernel'
55
73
 
@@ -59,7 +77,44 @@ const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector,
59
77
  const KERNELS: Record<string, any> = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel }
60
78
 
61
79
  /**
62
- * ์ข…๋ฅ˜ ๋ฌธ์ž์—ด โ†’ ์ปค๋„. **๋ชจ๋ฅด๋Š” ๊ฐ’์ด๋ฉด ๋˜์ง„๋‹ค.**
80
+ * ์‹œ๊ฐ์ถ•์„ ๊ตฌํ•  ๋•Œ ํ•œ ๋ฒˆ์— ๋„์šฐ๋Š” ํŠธ์œˆ ์ˆ˜.
81
+ *
82
+ * ํŠธ์œˆ๋งˆ๋‹ค ์™•๋ณต์ด ๋‘˜์ด๋ผ, ๊ณต๊ฐ„์— ํŠธ์œˆ์ด ์ˆ˜๋ฐฑ์ด๋ฉด ๋™์‹œ ์™•๋ณต๋„ ์ˆ˜๋ฐฑ์ด ๋œ๋‹ค โ€” ์ปค๋„ฅ์…˜ ํ’€์ด ๋งˆ๋ฅด๋ฉด ์ด
83
+ * ํ•จ์ˆ˜ ํ•˜๋‚˜ ๋•Œ๋ฌธ์— ๋‹ค๋ฅธ ์งˆ์˜๊ฐ€ ์ค„์„ ์„ ๋‹ค. 20์ด๋ฉด ์™•๋ณต 40๊ฐœ๋กœ ์ง€์—ฐ์€ ๊ฐ์ถ”๋ฉด์„œ ํ’€์€ ๋‚จ๋Š”๋‹ค.
84
+ */
85
+ const TIME_RANGE_FANOUT = 20
86
+
87
+ /**
88
+ * DB ๊ฐ€ ์ค€ ์‹œ๊ฐ์„ epoch ms ๋กœ โ€” **๋ชจ์–‘์ด ๋“œ๋ผ์ด๋ฒ„๋งˆ๋‹ค ๋‹ค๋ฅด๋‹ค.**
89
+ *
90
+ * ์ง‘๊ณ„(MIN/MAX)์˜ ๋ฐ˜ํ™˜์€ ๋“œ๋ผ์ด๋ฒ„๊ฐ€ ์ •ํ•œ๋‹ค: sqlite ๋Š” ๋ฌธ์ž์—ด, pgยทmysqlยทmssql ์€ `Date`. ์—”ํ‹ฐํ‹ฐ
91
+ * ํ•˜์ด๋“œ๋ ˆ์ด์…˜์„ ๊ฑด๋„ˆ๋›ฐ๋ฉด TypeORM ์˜ ๋‚ ์งœ ๋ณ€ํ™˜๋„ ํ•จ๊ป˜ ๊ฑด๋„ˆ๋›ฐ๋ฏ€๋กœ ๋ฐ›๋Š” ์ชฝ์—์„œ ํ•œ ๋ฒˆ ์ •๊ทœํ™”ํ•œ๋‹ค.
92
+ *
93
+ * โ”€โ”€ ์‹œ๊ฐ„๋Œ€๋ฅผ ์žƒ์ง€ ์•Š๋Š”๋‹ค (์‹œํ—˜์ด ์žก์•˜๋‹ค) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
94
+ * ์ฒ˜์Œ์— `Date.parse(String(v))` ๋กœ ๋‘์—ˆ๋”๋‹ˆ **9์‹œ๊ฐ„์ด ๋ฐ€๋ ธ๋‹ค.** sqlite ๋Š” UTC ๋กœ ์ €์žฅํ•œ ๊ฐ’์„
95
+ * `2026-01-15 09:00:00.000` ์ฒ˜๋Ÿผ **์‹œ๊ฐ„๋Œ€ ํ‘œ์‹œ ์—†์ด** ๋Œ๋ ค์ฃผ๋Š”๋ฐ, ๊ทธ ํ˜•ํƒœ๋ฅผ `Date.parse` ๋Š” **๋กœ์ปฌ
96
+ * ์‹œ๊ฐ**์œผ๋กœ ์ฝ๋Š”๋‹ค(์—ฌ๊ธฐ๊ฐ€ KST ๋ผ UTC 00:00 ์ด ๋๋‹ค). ์›๋ž˜ ์ฝ”๋“œ๊ฐ€ ์ด ํ•จ์ •์„ ํ”ผํ•œ ๊ฒƒ์€ TypeORM ์ด
97
+ * ํ•˜์ด๋“œ๋ ˆ์ด์…˜์—์„œ ๋จผ์ € `Date` ๋กœ ๋ฐ”๊ฟ” ์คฌ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค โ€” ๊ทธ ๋‹จ๊ณ„๋ฅผ ๊ฑด๋„ˆ๋›ด ๋Œ€๊ฐ€๋ฅผ ์—ฌ๊ธฐ์„œ ์น˜๋ฅธ๋‹ค.
98
+ *
99
+ * ๊ทธ๋ž˜์„œ ์‹œ๊ฐ„๋Œ€ ํ‘œ์‹œ๊ฐ€ **์—†๋Š” ๋ฌธ์ž์—ด์€ UTC ๋กœ ์ฝ๋Š”๋‹ค**(TypeORM ์ด UTC ๋กœ ์ ์œผ๋ฏ€๋กœ). ํ‘œ์‹œ๊ฐ€ ์žˆ์œผ๋ฉด
100
+ * ๊ทธ๋Œ€๋กœ ๋ฏฟ๊ณ , `Date` ๋Š” ์†๋Œ€์ง€ ์•Š๋Š”๋‹ค.
101
+ *
102
+ * ์ฝ์„ ์ˆ˜ ์—†๋Š” ๊ฐ’์€ **์—†์Œ(`null`)** ์ด๋‹ค โ€” 0 ์œผ๋กœ ๋–จ์–ด๋œจ๋ฆฌ๋ฉด 1970๋…„์ด ์‹œ๊ฐ์ถ•์˜ ์‹œ์ž‘์ด ๋œ๋‹ค.
103
+ */
104
+ function parseTime(v: unknown): number | null {
105
+ if (v == null) return null
106
+ if (v instanceof Date) return Number.isFinite(v.getTime()) ? v.getTime() : null
107
+
108
+ const s = String(v).trim()
109
+ if (!s) return null
110
+ /* ๋์— `Z` ๋‚˜ `ยฑhh:mm` ์ด ๋ถ™์–ด ์žˆ์œผ๋ฉด ์‹œ๊ฐ„๋Œ€๋ฅผ ์•„๋Š” ๋ฌธ์ž์—ด์ด๋‹ค โ€” ๊ทธ๋Œ€๋กœ ๋ฏฟ๋Š”๋‹ค. */
111
+ const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/.test(s)
112
+ const t = Date.parse(zoned ? s : `${s.replace(' ', 'T')}Z`)
113
+ return Number.isFinite(t) ? t : null
114
+ }
115
+
116
+ /**
117
+ * ์ข…๋ฅ˜ ๋ฌธ์ž์—ด โ†’ ์ปค๋„. **๋ชจ๋ฅด๋Š” ๊ฐ’์ด๋ฉด ์˜ค๋ฅ˜๋ฅผ ๋‚ธ๋‹ค.**
63
118
  *
64
119
  * ์˜ˆ์ „์—๋Š” ํ‘œ๋ฅผ ์ฐพ๊ณ  ์—†์œผ๋ฉด WmsKernel ๋กœ ๋–จ์–ด์กŒ๋‹ค. `kind` ๋Š” ๊ฒ€์ฆ ์—†๋Š” ์ž์œ  ๋ฌธ์ž์—ด(`@Arg('kind') kind: string`)
65
120
  * ์ด๋ผ ์˜คํƒ€ ํ•˜๋‚˜ยท๋Œ€์†Œ๋ฌธ์ž ํ•˜๋‚˜๋กœ ์•ผ๋“œ/์ƒ์‚ฐ ํŠธ์œˆ์ด **์กฐ์šฉํžˆ ์ฐฝ๊ณ  ์ปค๋„๋กœ ๋Œ์•˜๋‹ค.** ์˜ค๋ฅ˜๊ฐ€ ์—†์œผ๋‹ˆ ํ™”๋ฉด์—๋Š”
@@ -74,15 +129,57 @@ function kernelFor(kind: string): any {
74
129
  return K
75
130
  }
76
131
 
77
- /* ๋ผ์ด๋ธŒ ์ฒ˜๋ฆฌ๋Ÿ‰ ๊ณ„์ธก(๋ชจ๋‹ˆํ„ฐ) โ€” ์œ ์ž…/๋ฐฉ์†ก/์ €๋„๋ฅ ยท๋ฐฑ๋กœ๊ทธ. ์‹ค ๋™๊ธฐ ์ธํ„ฐํŽ˜์ด์Šค ๋ถ€ํ•˜๋ฅผ ์ฝ๋Š” ์ผ๊ธ‰ ์ง€ํ‘œ. */
132
+ /** `observe()` ๋ฅผ ๊ฐ–์ง€ ์•Š์€ ์ปค๋„์„ ๋งŒ๋‚œ ์ ์ด ์žˆ๋Š”๊ฐ€ โ€” ๊ฒฝ๊ณ ๋ฅผ ํ•œ ๋ฒˆ๋งŒ ๋‚ธ๋‹ค(ํ‹ฑ๋งˆ๋‹ค ์Ÿ์ง€ ์•Š๋Š”๋‹ค). */
133
+ let observeUnavailableWarned = false
134
+
135
+ /**
136
+ * **์ด ์ปค๋„์˜ ์ง„์‹ค์ด ์›๋ณธ์—์„œ ์˜จ๋‹ค๊ณ  ์„ ์–ธํ•œ๋‹ค** โ€” ๊ทธ๋ฆฌ๊ณ  ์„ ์–ธ์ด ๋‹ฟ์ง€ ์•Š์•˜์œผ๋ฉด ๋งํ•œ๋‹ค.
137
+ *
138
+ * โ”€โ”€ ๋ฌด์—‡์ด ํ‹€๋ ธ๋‚˜ (2026-08-21) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
139
+ * ์˜ˆ์ „์—๋Š” `kernel.observe?.()` ์˜€๋‹ค. ์˜ต์…”๋„ ํ˜ธ์ถœ์ด๋ผ **๋ฉ”์„œ๋“œ๊ฐ€ ์—†์œผ๋ฉด ์กฐ์šฉํžˆ ์•„๋ฌด๊ฒƒ๋„ ํ•˜์ง€ ์•Š๋Š”๋‹ค.**
140
+ * ์„ค์น˜๋œ ์ปค๋„ 0.7.39 ์—๋Š” ๊ทธ ๋ฉ”์„œ๋“œ๊ฐ€ ์—†์œผ๋ฏ€๋กœ, ์ง€๊ธˆ ๋ฐฐํฌ๋ณธ์—์„œ๋Š” ๋ฏธ๋Ÿฌ๊ฐ€ ๊ด€์ธก ๊ตฌ๋™์œผ๋กœ ์„ ์–ธ๋˜์ง€
141
+ * ์•Š๋Š”๋‹ค โ€” ๊ทธ๋Ÿฐ๋ฐ ์ฝ”๋“œ๋ฅผ ์ฝ์œผ๋ฉด ์„ ์–ธํ•œ ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ธ๋‹ค.
142
+ *
143
+ * ๊ทธ ์ฐจ์ด๊ฐ€ ์‹ค์ œ ๊ฑฐ๋™์„ ๊ฐ€๋ฅธ๋‹ค: ๊ด€์ธก ๊ตฌ๋™์€ ์›๋ณธ์˜ ๋นˆํ‹ˆ์„ ๋ฐ›์•„๋“ค์ด๊ณ  ์„ธ์ง€๋งŒ, ์‹œ๋ฎฌ๋กœ ์˜ค์ธ๋œ ๋ฏธ๋Ÿฌ๋Š”
144
+ * **๋ฉˆ์ถ˜๋‹ค.** ์ฆ‰ ใ€Œ๊ด€์šฉ์ด ์ผœ์ง„ ์ค„ ์•Œ์•˜๋Š”๋ฐ ์•ˆ ์ผœ์ ธ ์žˆ๋‹คใ€๊ฐ€ ์กฐ์šฉํžˆ ์ง€๋‚˜๊ฐ€๊ณ , ์žฅ์• ๊ฐ€ ๋‚ฌ์„ ๋•Œ ์›์ธ์ด
145
+ * ์ปค๋„์— ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ธ๋‹ค โ€” ์›์ธ์€ ๋ฒ„์ „์ด๋‹ค.
146
+ *
147
+ * ๊ทธ๋ž˜์„œ ์˜ต์…”๋„ ํ˜ธ์ถœ์„ ์—†์• ๊ณ , ์—†์œผ๋ฉด **ํ•œ ๋ฒˆ ๊ฒฝ๊ณ ํ•œ๋‹ค.** ์ปค๋„์ด ๋ฐฐํฌ๋˜๋ฉด ์ด ๊ฒฝ๊ณ ๋Š” ์‚ฌ๋ผ์ง„๋‹ค.
148
+ */
149
+ function declareObserved(kernel: any, what: string): void {
150
+ if (typeof kernel?.observe === 'function') {
151
+ kernel.observe()
152
+ return
153
+ }
154
+ if (observeUnavailableWarned) return
155
+ observeUnavailableWarned = true
156
+ twinWarn(
157
+ `[twin-engine] ${what}: kernel has no observe() โ€” this mirror is not declared observation-driven. ` +
158
+ 'Source gaps will stop it instead of being tolerated and counted. Deploy a kernel that provides observe().'
159
+ )
160
+ }
161
+
162
+ /* ๋ผ์ด๋ธŒ ์ฒ˜๋ฆฌ๋Ÿ‰ ๊ณ„์ธก(๋ชจ๋‹ˆํ„ฐ) โ€” ์œ ์ž…/๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…/์ €๋„๋ฅ ยท๋ฐฑ๋กœ๊ทธ. ์‹ค ๋™๊ธฐ ์ธํ„ฐํŽ˜์ด์Šค ๋ถ€ํ•˜๋ฅผ ์ฝ๋Š” ์ผ๊ธ‰ ์ง€ํ‘œ. */
78
163
  interface TwinMetrics {
79
164
  ingestedTotal: number // ๋ˆ„์  ์œ ์ž… ์ด๋ฒคํŠธ
80
- broadcastTotal: number // ๋ˆ„์  ๋ฐฉ์†ก ํšŸ์ˆ˜
165
+ broadcastTotal: number // ๋ˆ„์  ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ํšŸ์ˆ˜
81
166
  journaledTotal: number // ๋ˆ„์  ์ €๋„ ๊ธฐ๋ก ์ด๋ฒคํŠธ
82
167
  ingestRate: number // ์ตœ๊ทผ ์œ ์ž…๋ฅ (ev/s)
83
- broadcastRate: number // ์ตœ๊ทผ ๋ฐฉ์†ก๋ฅ (ํšŒ/s)
168
+ broadcastRate: number // ์ตœ๊ทผ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…๋ฅ (ํšŒ/s)
84
169
  journalRate: number // ์ตœ๊ทผ ์ €๋„๋ฅ (ev/s)
85
- backlog: number // ๋งˆ์ง€๋ง‰ flush ์‹œ ๋Œ€๊ธฐ ์ €๋„ ํฌ๊ธฐ(๋ฐฑํ”„๋ ˆ์…” ์‹ ํ˜ธ)
170
+ /**
171
+ * **์ง€๊ธˆ ๋Œ€๊ธฐ ์ค‘์ธ** ์ €๋„ ๊ฑด์ˆ˜ โ€” ์•„์ง DB ๋กœ ๋‚˜๊ฐ€์ง€ ์•Š์€ ๊ฒƒ.
172
+ *
173
+ * โ”€โ”€ ๋ฌด์—‡์ด ํ‹€๋ ธ์—ˆ๋‚˜ (2026-08-21) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
174
+ * ์˜ˆ์ „์—๋Š” ใ€Œ๋งˆ์ง€๋ง‰ flush ์—์„œ ์“ด ๊ฑด์ˆ˜ใ€๋ฅผ ์—ฌ๊ธฐ ๋„ฃ์—ˆ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ 200ms ๋งˆ๋‹ค ๋ชจ์•„ ํ•œ ๋ฌธ์œผ๋กœ ๋„ฃ๋Š” ๊ฒƒ์€
175
+ * **์ •์ƒ ๋™์ž‘**์ด๋‹ค(๊ทธ๋ ‡๊ฒŒ ๋งŒ๋“  ๊ฒƒ์ด ์–ด์ œ ๊ณ ์นœ ์„ฑ๋Šฅ ์ž‘์—…์ด๋‹ค). ๊ทธ๋ž˜์„œ ์ •์ƒ์ ์œผ๋กœ 10๊ฑด์„ ์“ด ์งํ›„
176
+ * ํ™”๋ฉด์— ใ€Œ๋ฐ€๋ฆผ 10ใ€ ๊ฒฝ๊ณ ๊ฐ€ ๋–ด๊ณ , ๋‹ค์Œ flush ๊นŒ์ง€ ๊ทธ ๊ฐ’์ด ๋‚จ์•˜๋‹ค โ€” ํ๋Š” ์ด๋ฏธ 0 ์ธ๋ฐ๋„.
177
+ *
178
+ * ์šด์˜์ž์—๊ฒŒ ๊ทธ๊ฒƒ์€ ใ€Œ์ €์žฅ์ด 10๊ฑด ๋ฐ€๋ ค ์žˆ๋‹คใ€๋กœ ์ฝํžŒ๋‹ค. ๊ฑฐ์ง“ ๊ฒฝ๋ณด๋Š” ์ฐธ ๊ฒฝ๋ณด๋ฅผ ์ฃฝ์ธ๋‹ค.
179
+ *
180
+ * ์ด์ œ ์ด ๊ฐ’์€ **์ง€๊ธˆ ๋‚จ์•„ ์žˆ๋Š” ๊ฒƒ**์ด๋‹ค: ๋‹ค ๋‚˜๊ฐ”์œผ๋ฉด 0 ์ด๊ณ , ์ •๋ง๋กœ ์Œ“์ด๋Š” ์ค‘์ด๋ฉด ๊ทธ ์ˆ˜๊ฐ€ ์ž๋ž€๋‹ค.
181
+ */
182
+ backlog: number
86
183
  // ์ฐฝ(window) ์ง‘๊ณ„์šฉ ๋‚ด๋ถ€ ๋ˆ„์ 
87
184
  _accIngest: number
88
185
  _accBroadcast: number
@@ -127,8 +224,19 @@ interface InstanceRuntime {
127
224
  timer?: any
128
225
  /** ์—”ํ‹ฐํ‹ฐ๋ณ„ ๋งˆ์ง€๋ง‰ ๋ฐœํ–‰ ์‹œ๊ทธ๋‹ˆ์ฒ˜ โ€” ๊ฐ’์ด ๋ฐ”๋€ ์—”ํ‹ฐํ‹ฐ๋งŒ ์žฌ๋ฐœํ–‰(๋ฌด๋ณ€ํ™” ๋ฐ˜๋ณต push ๋ฐฉ์ง€). */
129
226
  entitySigs?: Map<string, string>
130
- /** live ๋ฐฉ์†ก ๋ณ‘ํ•ฉ(ingest-scale ยง4.4) โ€” ingest ๋งˆ๋‹ค ๋ฐฉ์†กํ•˜์ง€ ์•Š๊ณ  dirty ํ‘œ์‹œ โ†’ coalescer tick ์ด ์ฃผ๊ธฐ ๋ฐฉ์†ก. */
227
+ /** live ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ๋ณ‘ํ•ฉ(ingest-scale ยง4.4) โ€” ingest ๋งˆ๋‹ค ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…ํ•˜์ง€ ์•Š๊ณ  dirty ํ‘œ์‹œ โ†’ coalescer tick ์ด ์ฃผ๊ธฐ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…. */
131
228
  dirty?: boolean
229
+ /**
230
+ * ์ด ์ฐฝ์— **๊ฑด๋“œ๋ฆฐ ๋ฌผํ’ˆ ํƒœ๊ทธ** โ€” ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์ด ๊ทธ๊ฒƒ๋งŒ ๋งŒ๋“ ๋‹ค.
231
+ *
232
+ * `undefined` ๋Š” ใ€Œ๋งํ•  ์ˆ˜ ์—†๋‹คใ€๋‹ค(๋‚ฏ์„  ์‚ฌ๊ฑด์ด ํ•˜๋‚˜๋ผ๋„ ์™”๋‹ค) โ†’ ์ „๋ถ€ ๋งŒ๋“ ๋‹ค. ๋นˆ ์ง‘ํ•ฉ์€ ใ€Œ๋ฌผํ’ˆ์€
233
+ * ํ•˜๋‚˜๋„ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์•˜๋‹คใ€์ด๊ณ , ๊ทธ ๋‘˜์€ ๋‹ค๋ฅธ ์‚ฌ์‹ค์ด๋‹ค.
234
+ */
235
+ dirtyItems?: Set<string>
236
+ /** ๋‹ค์Œ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์—์„œ ์ „๋ถ€ ๋งŒ๋“ค์–ด์•ผ ํ•˜๋‚˜ โ€” ์ฃผ๊ธฐ๋งˆ๋‹ค ํ•œ ๋ฒˆ์€ ๊ทธ๋ฌผ๋กœ ์ „๋ถ€ ๋งŒ๋“ ๋‹ค. */
237
+ fullBroadcastDue?: boolean
238
+ /** ์ „๋ถ€ ๋งŒ๋“ค๊ธฐ๊นŒ์ง€ ๋‚จ์€ ์ฐฝ ์ˆ˜. */
239
+ fullBroadcastCountdown?: number
132
240
  /** live ์ €๋„ ๊ฒฐ์„  โ€” ingest ์ด๋ฒคํŠธ๋ฅผ ๋ชจ์•˜๋‹ค๊ฐ€ coalescer tick ์—์„œ ๋ฐฐ์น˜ ๊ธฐ๋ก(์ด๋ฒคํŠธ๋งˆ๋‹ค DB write ๊ธˆ์ง€). */
133
241
  pendingJournal?: any[]
134
242
  /** live ์ €๋„ revision ์นด์šดํ„ฐ(sim ์˜ delta revision ๋Œ€์‘ โ€” ์‹œ๊ฐ„์—ฌํ–‰ยทํžˆ์Šคํ† ๋ฆฌ ๋…ธ๋ธŒ ์•ต์ปค). */
@@ -169,13 +277,44 @@ export class TwinEngine {
169
277
  * ๋ฉˆ์ถ˜๋‹ค โ€” HTTPยท๊ตฌ๋…ยท๋‹ค๋ฅธ ํŠธ์œˆ์˜ ํ‹ฑ๊นŒ์ง€. ์‹ค์ธก์œผ๋กœ `order-check` ์˜ ํ‹ฑ ํ•˜๋‚˜๊ฐ€ 34.9์ดˆ์˜€๊ณ , ๊ทธ ์‚ฌ์ด
170
278
  * ๊ตฌ๋…์ž๊ฐ€ ์•„๋ฌด๊ฒƒ๋„ ๋นผ๋‚ด์ง€ ๋ชปํ•ด pubsub ์ด ๋„˜์ณ ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ฃฝ์—ˆ๋‹ค.
171
279
  *
172
- * ๋ฐฉ์†ก ๋ฐ˜๋ณต์€ ๊ฑท์–ด๋ƒˆ์ง€๋งŒ(`flushLiveBroadcasts` ๋กœ ๋ณ‘ํ•ฉ) **์ปค๋„ ํ‹ฑ ์ž์ฒด๋Š” ์—ฌ์ „ํžˆ ๋ฉ”์ธ ๋ฃจํ”„์— ์žˆ๋‹ค.**
280
+ * ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ๋ฐ˜๋ณต์€ ๊ฑท์–ด๋ƒˆ์ง€๋งŒ(`flushLiveBroadcasts` ๋กœ ๋ณ‘ํ•ฉ) **์ปค๋„ ํ‹ฑ ์ž์ฒด๋Š” ์—ฌ์ „ํžˆ ๋ฉ”์ธ ๋ฃจํ”„์— ์žˆ๋‹ค.**
173
281
  * ๊ทผ๋ณธ ํ•ด๊ฒฐ์€ ๋ถ„์‚ฐ์ด๊ณ  ๊ทธ๊ฒƒ์€ ์ด์—ฐ๋๋‹ค โ€” ๊ทธ๋•Œ๊นŒ์ง€์˜ ์•ˆ์ „๋ง์ด ์ด ์…‹์ด๋‹ค.
174
282
  *
175
283
  * ํŒ์ •์„ ์˜ˆ์‚ฐ(500ms)์ด ์•„๋‹ˆ๋ผ **๊ตถ๊น€ ๋ฌธํ„ฑ**์œผ๋กœ ๋”ฐ๋กœ ๋‘”๋‹ค: ์กฐ๊ธˆ ๋А๋ฆฐ ํŠธ์œˆ์€ ๊ณ„๊ธฐํŒ์ด ๋งํ•˜๊ฒŒ ๋‘๊ณ 
176
284
  * (๊ฒฝ๊ณ ), ํ˜ธ์ŠคํŠธ๋ฅผ ๊ตถ๊ธฐ๋Š” ํŠธ์œˆ๋งŒ ๋ฉˆ์ถ˜๋‹ค. ํ•œ ๋ฒˆ์œผ๋กœ ๋ฉˆ์ถ”์ง€ ์•Š๋Š”๋‹ค โ€” ์›œ์Šคํƒ€ํŠธ ์งํ›„์˜ ์ฒซ ํ‹ฑ์€ ์›๋ž˜
177
285
  * ๋ฌด๊ฒ๋‹ค(๋ณต๊ตฌํ•œ ์ƒํƒœ๋ฅผ ์ฒ˜์Œ ์ ‘๋Š”๋‹ค). **์—ฐ์†**์œผ๋กœ ์ด์–ด์งˆ ๋•Œ๊ฐ€ ๊ตฌ์กฐ์ ์œผ๋กœ ๋А๋ฆฐ ๊ฒƒ์ด๋‹ค.
178
286
  */
287
+ /*
288
+ * โ”€โ”€ ํ‹ฑ์„ **ํ•œ ์ˆœ๊ฐ„์— ๋ชฐ์ง€ ์•Š๋Š”๋‹ค** (2026-08-21 ์‹ค์ธก) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
289
+ * ํŠธ์œˆ๋งˆ๋‹ค `setInterval(TICK_MS)` ๋ฅผ ๋ถ€ํŒ… ๋•Œ ๋‚˜๋ž€ํžˆ ์„ธ์šฐ๋ฉด, ์Šค๋ฌด ๊ฐœ๊ฐ€ **๊ฐ™์€ ๋ฐ€๋ฆฌ์ดˆ์—** ๊นจ์–ด๋‚œ๋‹ค.
290
+ * ๊ฐ์ž์˜ ํ‹ฑ์ด ์งง์•„๋„(์‹ค์ธก: ๋ฌผํ’ˆ 2,400 ๊ฐœ์—์„œ 0.23ms) ๊ทธ ์ˆœ๊ฐ„์—๋Š” ์Šค๋ฌด ๊ฐœ๊ฐ€ ํ•œ ์ค„๋กœ ๋ถ™์–ด ์‹คํ–‰๋˜๊ณ ,
291
+ * ๊ทธ ์‚ฌ์ด ๋„์ฐฉํ•œ HTTP ์š”์ฒญ์€ ์ „๋ถ€ ๋’ค์—์„œ ๊ธฐ๋‹ค๋ฆฐ๋‹ค. ์ดˆ๋‹น ํ•œ ๋ฒˆ์˜ ์ •์ฒด๊ฐ€ ใ€Œ๋ˆ„๋ฅผ ๋•Œ๋งˆ๋‹ค ๊ฑธ๋ฆฐ๋‹คใ€๋กœ
292
+ * ๋‚˜ํƒ€๋‚˜๋Š” ์ž๋ฆฌ๋‹ค.
293
+ *
294
+ * ๊ทธ๋ž˜์„œ ์ฒซ ๋ฐœํ™”๋งŒ ๊ฐ„๊ฒฉ ์•ˆ์—์„œ **๊ณ ๋ฅด๊ฒŒ ํฉ๋Š”๋‹ค** โ€” ์ด ์ž‘์—…๋Ÿ‰์€ ๊ฐ™๊ณ  ํ•œ ์ˆœ๊ฐ„์˜ ์ตœ๋Œ€์น˜๋งŒ ๋‚ฎ์•„์ง„๋‹ค.
295
+ * ํŠธ์œˆ์ด ๊ฐ„๊ฒฉ๋ณด๋‹ค ๋งŽ์•„์ง€๋ฉด ๋‹ค์‹œ ๊ฒน์น˜๋ฏ€๋กœ, ๊ทธ๋•Œ๋Š” ์ด ํฉ๊ธฐ๊ฐ€ ์•„๋‹ˆ๋ผ ๋ถ„์‚ฐ์ด ๋‹ต์ด๋‹ค(์ด์—ฐ๋จ).
296
+ */
297
+ /** ๋ถ€ํŒ… ๋•Œ ํŠธ์œˆ ํ•˜๋‚˜๋ฅผ ๋˜์‚ด๋ฆฐ ๋’ค ๋ฃจํ”„๋ฅผ ๋น„์›Œ ์ฃผ๋Š” ์‹œ๊ฐ„(ms) โ€” ๊ทธ ์‚ฌ์ด ๋„์ฐฉํ•œ ์š”์ฒญ์ด ์ฒ˜๋ฆฌ๋œ๋‹ค. */
298
+ static BOOT_YIELD_MS = 25
299
+ private static tickPhase = 0
300
+ /**
301
+ * ํฉ์–ด์ง„ ์ฒซ ๋ฐœํ™” ๋’ค ์ฃผ๊ธฐ ํ‹ฑ. **ํ•ธ๋“ค์€ ํ•ญ์ƒ ์ง„์งœ ํƒ€์ด๋จธ**๋‹ค โ€” ์ •์ง€ํ•˜๋Š” ์ชฝ์ด `clearInterval(inst.timer)`
302
+ * ํ•˜๋‚˜๋กœ ๋๋‚ด์•ผ ํ•˜๋ฏ€๋กœ, ์ฒซ ๋ฐœํ™” ์ „์—๋Š” ๊ทธ `setTimeout` ์„, ์ดํ›„์—๋Š” `setInterval` ์„ ๊ฐ™์€ ์ž๋ฆฌ์— ๋‘”๋‹ค
303
+ * (๊ฐ์‹ผ ๊ฐ์ฒด๋ฅผ ์ฃผ๋ฉด `clearInterval` ์ด ์•„๋ฌด ์ผ๋„ ํ•˜์ง€ ์•Š๊ณ  ํŠธ์œˆ์ด ๋ฉˆ์ถ”์ง€ ์•Š๋Š”๋‹ค โ€” ์กฐ์šฉํ•œ ๊ฒฐํ•จ์ด ๋œ๋‹ค).
304
+ */
305
+ private static startTickTimer(fn: () => void, hold: (t: any) => void): any {
306
+ const spread = Math.max(1, Math.round(this.TICK_MS / 20))
307
+ const offset = (this.tickPhase = (this.tickPhase + spread) % this.TICK_MS)
308
+ const first = setTimeout(() => {
309
+ const interval = setInterval(fn, this.TICK_MS)
310
+ if (typeof interval?.unref === 'function') interval.unref()
311
+ hold(interval) // ์ •์ง€ ๊ฒฝ๋กœ๊ฐ€ ์ง€์šธ ๋Œ€์ƒ์„ ๋ฐ”๊ฟ” ์ค€๋‹ค
312
+ fn()
313
+ }, offset)
314
+ if (typeof first?.unref === 'function') first.unref()
315
+ return first
316
+ }
317
+
179
318
  /** ๊ตถ๊น€ ๋ฌธํ„ฑ โ€” ํ‹ฑ ๊ฐ„๊ฒฉ์˜ ๋ฐฐ์ˆ˜(1์ดˆ ๊ฐ„๊ฒฉ์ด๋ฉด 5์ดˆ). ์ด ์‹œ๊ฐ„๋งŒํผ ํ˜ธ์ŠคํŠธ๊ฐ€ ๋ฉˆ์ถ˜๋‹ค. */
180
319
  static STARVE_FACTOR = 5
181
320
  /** ์—ฐ์† ๋ช‡ ๋ฒˆ์ด๋ฉด ๋ฉˆ์ถ”๋‚˜ โ€” 3๋ฒˆ์ด๋ฉด 15์ดˆ๋ฅผ ๊ตถ๊ธด ์…ˆ์ด๊ณ , ๊ทธ๊ฑด ์šฐ์—ฐ์ด ์•„๋‹ˆ๋‹ค. */
@@ -208,6 +347,44 @@ export class TwinEngine {
208
347
  static CHAIN_KEEP = 5 // ์ตœ๊ทผ ๋ช‡ ์ง€์ ์„ ๋“ค๊ณ  ์žˆ๋‚˜(๊ทธ๋ณด๋‹ค ๊ณผ๊ฑฐ๋Š” 0๋ถ€ํ„ฐ ์ ‘๋Š”๋‹ค)
209
348
  static SNAPSHOT_TTL_S = 7 * 24 * 3600 // 7์ผ โ€” ์ •์ƒ ๋‹ค์šดํƒ€์ž„ ์ƒ์กด, ๋งŒ๋ฃŒ ์‹œ ์ €๋„ replay ํด๋ฐฑ
210
349
  static CHECKPOINT_MS = 20000 // ์ฒดํฌํฌ์ธํŠธ ์ฃผ๊ธฐ(ํ•ซ ๋ธŒ๋กœ๋“œ์บ์ŠคํŠธ ๊ฒฝ๋กœ์™€ ๋ถ„๋ฆฌ, O(state) ์Šค๋กœํ‹€)
350
+ /**
351
+ * **์ €๋„ ๋ณด์กด โ€” ์ง€์šฐ๋Š” ๊ฒƒ์€ ์„ ์–ธ์ด ์žˆ์„ ๋•Œ๋งŒ ํ•œ๋‹ค.**
352
+ *
353
+ * โ”€โ”€ ์™œ ํ•„์š”ํ•œ๊ฐ€ (2026-08-22 ์‹ค์ธก) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
354
+ * ๊ฐœ๋ฐœ ์ €๋„์ด ์‹œ๊ฐ„๋‹น **581,794ํ–‰ ยท 0.85GB** ๋กœ ์ž๋ž๋‹ค(ํ•˜๋ฃจ 20GB). ๋ฐ๋ชจ MES ํŠธ์œˆ ์…‹์ด ์ „์ฒด์˜ **97%**
355
+ * ๋ฅผ ๋งŒ๋“ค๊ณ  ์ง€์šฐ๋Š” ๊ฒƒ์ด ์—†์—ˆ๋‹ค. ์˜ค๋Š˜ ๊ณ ์นœ ๊ฒƒ๋“ค์€ ใ€Œ๊ทธ ํฌ๊ธฐ์—์„œ๋„ ์งˆ์˜๊ฐ€ ๋น ๋ฅด๊ฒŒใ€์ด๊ณ , **ํฌ๊ธฐ ์ž์ฒด๋ฅผ
356
+ * ์ค„์ด๋Š” ๊ฒƒ์€ ์•„๋ฌด๊ฒƒ๋„ ์—†์—ˆ๋‹ค.** ๊ทธ๋Ÿฌ๋ฉด ๋ฉฐ์น ๋งˆ๋‹ค ๊ฐ™์€ ์ž๋ฆฌ๋กœ ๋Œ์•„์˜จ๋‹ค.
357
+ *
358
+ * โ”€โ”€ ๊ทธ๋Ÿฐ๋ฐ ์ €๋„์„ ์ง€์šฐ๋Š” ๊ฒƒ์€ ์‚ฌ์‹ค์„ ์žƒ๋Š” ์ผ์ด๋‹ค โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
359
+ * ๊ทธ๋ž˜์„œ ์„ธ ๊ทœ์œจ์„ ์ง€ํ‚จ๋‹ค.
360
+ *
361
+ * โ‘  **์„ ์–ธ์ด ์—†์œผ๋ฉด ์•„๋ฌด๊ฒƒ๋„ ์ง€์šฐ์ง€ ์•Š๋Š”๋‹ค.** ๊ธฐ๋ณธ๊ฐ’์€ ์—†์Œ์ด๋‹ค โ€” ์กฐ์šฉํžˆ ์ง€์šฐ๋Š” ํŽธ์ด ์กฐ์šฉํžˆ ์Œ“๋Š”
362
+ * ๊ฒƒ๋ณด๋‹ค ๋‚˜์˜๋‹ค. ์ง€์šฐ๋Š” ๊ฒƒ์€ ์‚ฌ๋žŒ์ด ์ •ํ•œ๋‹ค.
363
+ * โ‘ก **์ฒดํฌํฌ์ธํŠธ๊ฐ€ ๋Œ€์‹ ํ•  ์ˆ˜ ์žˆ๋Š” ๋งŒํผ๋งŒ.** ์Šค๋ƒ…์ƒท์ด ์—†๊ฑฐ๋‚˜ ๊ทธ ๋ฆฌ๋น„์ „์„ ๋„˜๋Š” ์ž๋ฆฌ๋Š” ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค.
364
+ * ์ฃผ์„์ด ใ€Œ๋งŒ๋ฃŒ ์‹œ ์ €๋„ replay ํด๋ฐฑใ€์ด๋ผ๊ณ  ์ ์–ด ๋‘” ๊ทธ๋Œ€๋กœ โ€” ์Šค๋ƒ…์ƒท์ด ์‚ฌ๋ผ์ง€๋ฉด ์ €๋„์ด **์œ ์ผํ•œ**
365
+ * ๋ณต๊ตฌ ์ˆ˜๋‹จ์ด๋ฏ€๋กœ, ๋‘˜์„ ํ•จ๊ป˜ ์žƒ์œผ๋ฉด ๊ทธ ํŠธ์œˆ์˜ ์ƒํƒœ๋Š” ๋˜๋Œ๋ฆด ์ˆ˜ ์—†๋‹ค.
366
+ * โ‘ข **์ง€์šด ๊ฒƒ์„ ๋งํ•œ๋‹ค.** ๋ช‡ ๊ฑด์„ ์–ด๋А ์‹œ๊ฐ๊นŒ์ง€ ์ง€์› ๋Š”์ง€ ๋กœ๊ทธ์— ๋‚จ๊ธด๋‹ค. ์กฐ์šฉํžˆ ์ค„์–ด๋“  ์ €๋„์€
367
+ * ใ€Œ์—†์—ˆ๋˜ ์ผใ€๊ณผ ๊ตฌ๋ณ„๋˜์ง€ ์•Š๋Š”๋‹ค.
368
+ *
369
+ * ๊ทธ๋ฆฌ๊ณ  ๋ณด์กด ๊ธฐ๊ฐ„์€ **์Šค๋ƒ…์ƒท TTL(7์ผ)๋ณด๋‹ค ์งง์„ ์ˆ˜ ์—†๋‹ค.** ๋” ์งง์œผ๋ฉด ์Šค๋ƒ…์ƒท์ด ์‚ด์•„ ์žˆ๋Š”๋ฐ ๊ทธ๊ฒƒ์ด
370
+ * ๊ฐ€๋ฆฌํ‚ค๋Š” ์•ž์ชฝ ์ €๋„์ด ์—†๋Š” ๊ตฌ๊ฐ„์ด ์ƒ๊ธฐ๊ณ , ์‹œ๊ฐ„์—ฌํ–‰ยท๊ณ„๋ณด ์ถ”์ ์ด ๊ทธ ๊ตฌ๊ฐ„์—์„œ ์กฐ์šฉํžˆ ๋นˆ๋‹ค.
371
+ */
372
+ static JOURNAL_RETENTION_DAYS?: number = undefined
373
+ /**
374
+ * **๋„๋ฉ”์ธ๋งˆ๋‹ค ๋‹ค๋ฅธ ๋ณด์กด ๊ธฐ๊ฐ„์„ ์•ฑ์ด ๋‹ตํ•œ๋‹ค** โ€” ์—”์ง„์€ ๊ทธ ๊ฐ’์ด ์–ด๋””์„œ ์˜ค๋Š”์ง€ ๋ชจ๋ฅธ๋‹ค.
375
+ *
376
+ * ๋ณด์กด ๊ธฐ๊ฐ„์€ ํ…Œ๋„ŒํŠธ์˜ ์ •์ฑ…์ด๋‹ค: ํ•œ ๊ณ ๊ฐ์€ 30์ผ์ด ํ•„์š”ํ•˜๊ณ  ๋‹ค๋ฅธ ๊ณ ๊ฐ์€ 7์ผ์ด๋ฉด ๋œ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ์—”์ง„์ด
377
+ * `Setting` ์„ ์ฝ๊ฒŒ ๋งŒ๋“ค๋ฉด **์—”์ง„์ด ํ™”๋ฉด ๊ด€์‹ฌ์‚ฌ๋ฅผ ์•Œ๊ฒŒ ๋œ๋‹ค** โ€” ๋ฐฉํ–ฅ์ด ๊ฑฐ๊พธ๋กœ๋‹ค(์—”์ง„์€ ์ปค๋„์˜ ํ˜ธ์ŠคํŠธ์ด๊ณ 
378
+ * ๊ทธ ์œ„์— ์–ด๋–ค ํ™”๋ฉด์ด ์žˆ๋Š”์ง€ ๋ชฐ๋ผ์•ผ ํ•œ๋‹ค).
379
+ *
380
+ * ๊ทธ๋ž˜์„œ ์‹œ์ž„์„ ๋‘”๋‹ค. ์•ฑ์ด ์ด ํ•จ์ˆ˜๋ฅผ ์‹ฌ๊ณ , ๊ทธ ์•ˆ์—์„œ `Setting` ์ด๋“  ๋‹ค๋ฅธ ๋ฌด์—‡์ด๋“  ์ฝ๋Š”๋‹ค. ์‹ฌ์ง€ ์•Š์œผ๋ฉด
381
+ * `JOURNAL_RETENTION_DAYS`(์„ค์ • ํŒŒ์ผ์—์„œ ์˜จ ํ”„๋กœ์„ธ์Šค ๊ธฐ๋ณธ๊ฐ’)๊ฐ€ ๋‹ตํ•œ๋‹ค.
382
+ *
383
+ * `undefined` ๋ฅผ ๋Œ๋ ค์ฃผ๋ฉด **๊ทธ ๋„๋ฉ”์ธ์€ ์ง€์šฐ์ง€ ์•Š๋Š”๋‹ค** โ€” ใ€Œ๋ชจ๋ฅธ๋‹คใ€๋ฅผ ใ€Œ์ง€์›Œ๋„ ๋œ๋‹คใ€๋กœ ์ฝ์ง€ ์•Š๋Š”๋‹ค.
384
+ */
385
+ static retentionDaysOf?: (domainId: string) => Promise<number | undefined>
386
+ static RETENTION_SWEEP_MS = 10 * 60 * 1000 // 10๋ถ„๋งˆ๋‹ค ํ•œ ๋ฒˆ โ€” ์ง€์šฐ๋Š” ์ผ์€ ๊ธ‰ํ•˜์ง€ ์•Š๋‹ค
387
+ private static retentionTimer?: any
211
388
  private static checkpointTimer?: any
212
389
 
213
390
  /** ์ตœ์‹  ์Šค๋ƒ…์ƒท์„ cache-service ์— ์ฒดํฌํฌ์ธํŠธ(๋„๋ฉ”์ธ+instanceId ํ‚ค). display-onlyยท๋น„์ฐจ๋‹จยท์˜ค๋ฅ˜ํก์ˆ˜. */
@@ -221,10 +398,68 @@ export class TwinEngine {
221
398
  */
222
399
  const state = unwrapState(this.snapshot(domainId, instanceId))
223
400
  if (!state) return
401
+ /*
402
+ * โ”€โ”€ **์•„๋ฌด๊ฒƒ๋„ ๋“ฃ์ง€ ๋ชปํ•œ ๊ฒƒ์„ ใ€Œ๋น„์—ˆ๋‹คใ€๋กœ ์ ์ง€ ์•Š๋Š”๋‹ค** (2026-08-24 ์‹ค์ธก) โ”€โ”€โ”€โ”€
403
+ *
404
+ * ๋ฏธ๋Ÿฌ๊ฐ€ ์žฌ๊ธฐ๋™ํ•˜๋ฉด ๊ด€์ธก ์ถ•(์žฌ๊ณ ยท์˜ค๋”ยท์ž‘์—…)์„ ๋“ค๊ณ  ์˜ค์ง€ ์•Š๋Š”๋‹ค(`startLive` ๊ฐ€ ๋˜์ฐพ์€ ์ƒํƒœ๋ฅผ
405
+ * ๋ฒ„๋ฆฐ๋‹ค โ€” ใ€Œ๋‹ค์Œ ๊ณ„์ธก์ด ์ •์ •ํ•œ๋‹คใ€๋Š” ์ „์ œ). ๊ทธ๋Ÿฐ๋ฐ ์›๋ณธ์ด **์ปค์„œ ์ฆ๋ถ„**์œผ๋กœ ๋งํ•˜๋Š” ํ˜„์žฅ์—์„œ๋Š” ๊ทธ
406
+ * ์ •์ •์ด ์˜ค์ง€ ์•Š๋Š”๋‹ค: ์ปค์„œ๊ฐ€ ๋”ฐ๋ผ์žกํžŒ ๋’ค ์›๋ณธ์ด ๋ณ€ํ•˜์ง€ ์•Š์œผ๋ฉด ๋ฏธ๋Ÿฌ๋Š” ์˜๊ตฌํžˆ ๋นˆ ์ฑ„๋‹ค.
407
+ *
408
+ * ๊ทธ ์ƒํƒœ์—์„œ ์ด ํ•จ์ˆ˜๊ฐ€ ๋Œ๋ฉด **๋นˆ ์ƒํƒœ๋ฅผ ์ข‹์€ ์Šค๋ƒ…์ƒท ์œ„์— ๋ฎ๋Š”๋‹ค.** ๊ทธ๋ž˜์„œ ์†์‹ค์ด ์˜๊ตฌํ™”๋๋‹ค:
409
+ *
410
+ * ์ €์žฅ๋ผ ์žˆ๋˜ ๊ฒƒ rev 221,884 ยท nowTime 2026-04-15 ยท items 741 ยท orders 2,780 ยท tasks 6,353
411
+ * ๋ฎ์œผ๋ ค๋˜ ๊ฒƒ ๊ฐ™์€ ํ‚ค ยท nowTime 2026-01-01 ยท ์ „๋ถ€ 0
412
+ *
413
+ * ๋ฆฌ๋น„์ „์œผ๋กœ๋Š” ๋ง‰์„ ์ˆ˜ ์—†๋‹ค โ€” ์ €๋„ ์ค„ ๋ฒˆํ˜ธ๋Š” ๊ด€์ธก์ด ์—†์–ด๋„ ๊ณ„์† ์ž๋ž€๋‹ค. ๋ง‰๋Š” ๊ธฐ์ค€์€ **ใ€Œ๋“ค์€ ๊ฒƒ์ด
414
+ * ์žˆ๋‚˜ใ€**๋‹ค. ์œ ์ž…์ด ํ•œ ๊ฑด๋„ ์—†์—ˆ๋‹ค๋ฉด ์ด ๋นˆ ์ƒํƒœ๋Š” **์›๋ณธ์ด ใ€Œ๋น„์—ˆ๋‹คใ€๊ณ  ๋งํ•œ ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ์šฐ๋ฆฌ๊ฐ€
415
+ * ์•„๋ฌด๊ฒƒ๋„ ๋ชป ๋“ค์€ ๊ฒƒ**์ด๊ณ , ๊ทธ ๋‘˜์„ ๊ฐ™์€ ๊ฐ’์œผ๋กœ ์ ์œผ๋ฉด ใ€Œ๋ชจ๋ฆ„ใ€์ด ใ€Œ์—†์Œใ€์ด ๋œ๋‹ค.
416
+ *
417
+ * ์›๋ณธ์ด ์‹ค์ œ๋กœ ใ€Œ๋‹ค ๋น„์—ˆ๋‹คใ€๊ณ  ๋งํ•œ ๊ฒฝ์šฐ๋Š” ๋ง‰์ง€ ์•Š๋Š”๋‹ค โ€” ๊ทธ๋•Œ๋Š” ์œ ์ž…์ด ์žˆ์—ˆ์œผ๋ฏ€๋กœ ์ด ๋ฌธ์„ ์ง€๋‚œ๋‹ค.
418
+ *
419
+ * ๊ทธ๋ฆฌ๊ณ  **๊ฑฐ์ ˆ์„ ๋งํ•œ๋‹ค**: ์กฐ์šฉํžˆ ๊ฑฐ์ ˆํ•˜๋ฉด ์™œ ์ฒดํฌํฌ์ธํŠธ๊ฐ€ ๋‚ก์•„ ๊ฐ€๋Š”์ง€ ์•„๋ฌด๋„ ๋ชจ๋ฅธ๋‹ค.
420
+ */
421
+ if (!(inst.metrics?.ingestedTotal > 0)) {
422
+ const observed = (s: any) => (s?.items?.length ?? 0) + (s?.orders?.length ?? 0) + (s?.tasks?.length ?? 0)
423
+ if (observed(state) === 0) {
424
+ const prev = await this.loadSnapshot(domainId, instanceId).catch(() => null)
425
+ const had = observed(prev?.state)
426
+ if (had > 0) {
427
+ twinWarn(
428
+ `[twin-engine] "${instanceId}": checkpoint refused โ€” this twin has heard nothing since start and its live ` +
429
+ `state is empty, while the stored snapshot holds ${had} observed fact(s) (revision ${prev?.revision}). ` +
430
+ 'Writing the empty state would destroy the only recoverable copy. The journal still holds the truth.'
431
+ )
432
+ return
433
+ }
434
+ }
435
+ }
224
436
  const revision = inst.revision ?? state.revision ?? 0
225
437
  /* ๊ตฌ์กฐ ๋ฆฌ๋น„์ „๋„ ํ•จ๊ป˜ โ€” ์ฝ๋Š” ์ชฝ์ด "์ด ์ƒํƒœ๊ฐ€ ์ง€๊ธˆ์˜ ๊ณต์žฅ์ธ๊ฐ€" ๋ฅผ ๊ฐ€๋ฆด ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. */
226
438
  const { structureRev } = await this.tipOf(domainId, instanceId).catch(() => ({ structureRev: null }) as any)
227
- await cacheService.setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision, state, structureRev }, this.SNAPSHOT_TTL_S)
439
+ /*
440
+ * โ”€โ”€ **์ด์–ด ์ ‘์„ ์”จ์•—์„ ํ•จ๊ป˜ ์ ๋Š”๋‹ค** (2026-08-24) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
441
+ *
442
+ * ์ด ํ•จ์ˆ˜๋Š” ์˜ค๋žซ๋™์•ˆ `{revision, state, structureRev}` ๋งŒ ์ ์—ˆ๋‹ค. ๊ทธ๋ž˜์„œ **์žฌ๊ฐœ์ ์„ ์ฝ๋Š” ์ชฝ์€ ๋‹ค
443
+ * ์žˆ๋Š”๋ฐ ์“ฐ๋Š” ์ชฝ์ด ์—†์—ˆ๋‹ค**: ์กฐํšŒ ๊ฒฝ๋กœ(`recover`)๋Š” `fold` ๊ฐ€ ์žˆ์œผ๋ฉด ๊ผฌ๋ฆฌ๋งŒ ์ ‘๊ณ , ์—†์œผ๋ฉด ์ €๋„์„
444
+ * 0๋ถ€ํ„ฐ ์ ‘๋Š”๋‹ค. ์‹ค์ธก์œผ๋กœ ์ €์žฅ๋œ ์Šค๋ƒ…์ƒท 34๊ฑด ์ „๋ถ€ `fold` ๊ฐ€ ๋น„์–ด ์žˆ์—ˆ๊ณ , ์ €๋„์€ 2,960๋งŒ ์ค„์ด์—ˆ๋‹ค โ€”
445
+ * ๊ทธ๋ž˜์„œ ์žฌ๊ธฐ๋™ยท์กฐํšŒ๋งˆ๋‹ค ์ฒ˜์Œ๋ถ€ํ„ฐ ๋‹ค์‹œ ์ ‘์—ˆ๋‹ค. ๊ทœ๋ชจ ๊ธฐ์ค€(์—”ํ‹ฐํ‹ฐ 10๋งŒยทํ’ˆ๋ชฉ 100๋งŒ)์—์„œ ์ด๊ฒƒ์€
446
+ * ๋А๋ฆฐ ๊ฒƒ์ด ์•„๋‹ˆ๋ผ **๋ชป ํ•˜๋Š” ๊ฒƒ**์ด๋‹ค.
447
+ *
448
+ * ์”จ์•—์€ ์ƒํƒœ๊ฐ€ ๋Œ€์‹ ํ•  ์ˆ˜ ์—†๋‹ค: ๋ฆฌ๋“€์„œ๋Š” ์†Œ๋น„์ฒ˜๊ฐ€ ๋ณด๋Š” ๊ฐ’ ๋ง๊ณ ๋„ ๋“ ๋‹ค(๋ถ€๋ชจ๋ฅผ ๊ธฐ๋‹ค๋ฆฌ๋Š” ๋‹ด๊น€ยท์ง‘๊ณ„
449
+ * ์ค‘์ธ ์ˆ˜๋Ÿ‰ยท๋‹ด์„ ์ค„ ๋ชฐ๋ผ ์„ธ์–ด ๋‘” ์‚ฌ๊ฑด). ์ƒํƒœ๋งŒ ๋˜๋Œ๋ฆฌ๊ณ  ๋’ค๋ฅผ ์ ‘์œผ๋ฉด 0๋ถ€ํ„ฐ ์ ‘์€ ๊ฒฐ๊ณผ์™€ **์กฐ์šฉํžˆ**
450
+ * ๋‹ฌ๋ผ์ง„๋‹ค. ๊ทธ ๋™์น˜๋Š” ์ปค๋„ ์‹œํ—˜์ด ์ฆ๋ช…ํ•œ๋‹ค(`observed-checkpoint.test.ts`).
451
+ *
452
+ * ๊ด€์ธก ๊ตฌ๋™์ด ์•„๋‹ˆ๋ฉด ์”จ์•—์ด ์—†๋‹ค โ€” ์‹œ๋ฎฌ์€ ๋ฆฌ๋“€์„œ๋ฅผ ๊ฐ–์ง€ ์•Š๊ณ , ๊ทธ ์ƒํƒœ์˜ ๊ถŒ์œ„๋Š” ์ปค๋„ ์ž์‹ ์ด๋‹ค.
453
+ * ๊ทธ๋•Œ๋Š” `fold` ๋ฅผ **๋„ฃ์ง€ ์•Š๋Š”๋‹ค**(๋นˆ ์”จ์•—์„ ๋„ฃ์œผ๋ฉด ์ฝ๋Š” ์ชฝ์ด ใ€Œ์ด์–ด ์ ‘์„ ์ˆ˜ ์žˆ๋‹คใ€๊ณ  ์ž˜๋ชป ๋ณธ๋‹ค).
454
+ */
455
+ const reducer: ReducerCheckpoint | undefined = inst.kernel?.observedCheckpoint?.()
456
+ const fold = reducer && inst.oee ? { reducer, oee: inst.oee.serialize() } : undefined
457
+ await cacheService.setInCache(
458
+ this.SNAPSHOT_CACHE_ID,
459
+ { domainId, instanceId },
460
+ { revision, state, structureRev, ...(fold ? { fold } : {}) },
461
+ this.SNAPSHOT_TTL_S
462
+ )
228
463
  }
229
464
 
230
465
  /**
@@ -235,6 +470,64 @@ export class TwinEngine {
235
470
  */
236
471
  private static readonly FOLD_NOTE = 'reducer + oee checkpoint โ€” the seed for folding only the tail'
237
472
 
473
+ /**
474
+ * ์žฌ๊ธฐ๋™์— ์“ธ **์›œ์Šคํƒ€ํŠธ ์”จ์•—**์„ ๋งŒ๋“ ๋‹ค โ€” ์ƒํƒœ + ์ด์–ด ์ ‘์„ ์žฌ๊ฐœ์ .
475
+ *
476
+ * โ”€โ”€ ์™œ ์ด ์ž๋ฆฌ๊ฐ€ ์ƒ๊ฒผ๋‚˜ (2026-08-24) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
477
+ * ๋‘ ํ˜ธ์ถœ๋ถ€๊ฐ€ ๊ฐ™์€ ์ผ์„ ์กฐ๊ธˆ์”ฉ ๋‹ค๋ฅด๊ฒŒ ํ•˜๊ณ  ์žˆ์—ˆ๊ณ (๊ฒนํฌ์žฅ์„ ํ•œ์ชฝ๋งŒ ๋ฒ—๊ฒผ๋‹ค), ๋‘˜ ๋‹ค **์žฌ๊ฐœ์ ์„ ๋ฒ„๋ฆฌ๊ณ **
478
+ * ์ƒํƒœ๋งŒ ๋“ค๊ณ  ๊ฐ”๋‹ค. ๊ทธ๋ž˜์„œ ์ €์žฅ๋œ ์žฌ๊ฐœ์ ์„ ์ฝ๋Š” ์ชฝ์ด ๋‹ค ์žˆ๋Š”๋ฐ๋„ ์žฌ๊ธฐ๋™์€ ๋งค๋ฒˆ ์ €๋„์„ ์ฒ˜์Œ๋ถ€ํ„ฐ
479
+ * ์ ‘์—ˆ๋‹ค(์‹ค์ธก: ์ €๋„ 2,960๋งŒ ์ค„).
480
+ *
481
+ * ์—ฌ๊ธฐ์„œ ํ•˜๋Š” ์ผ ์…‹:
482
+ * โ‘  ๊ฒนํฌ์žฅ์„ ๋ฒ—๊ธด๋‹ค โ€” ์˜› ํ˜•์‹์œผ๋กœ ์ €์žฅ๋œ ๊ฐ’์ด ํ•œ ๋ฒˆ์€ ๋ฐ˜๋“œ์‹œ ๋‚˜์˜จ๋‹ค
483
+ * โ‘ก **๊ทธ ๊ณต์žฅ์ด ์•„์ง ๊ทธ ๊ณต์žฅ์ธ์ง€** ์‹ฌํŒํ•œ๋‹ค โ€” ์•„๋‹ˆ๋ฉด ์”จ์•—์„ ๋ฒ„๋ฆฐ๋‹ค(์•„๋ž˜)
484
+ * โ‘ข ์”จ์•—์ด ์ €๋„ ๋๋ณด๋‹ค ์•ž์„œ ์žˆ์œผ๋ฉด **๊ทธ ๊ผฌ๋ฆฌ๋งŒ ์ ‘์–ด** ๋๊นŒ์ง€ ๋ฐ€์–ด ๋‘”๋‹ค
485
+ *
486
+ * โ‘ก๊ฐ€ ํ•„์š”ํ•œ ์ด์œ : ์žฌ๊ฐœ์ ์€ ๊ทธ๋•Œ์˜ ๋ณด๋“œ ์œ„์—์„œ ๋งŒ๋“ค์–ด์ง„ ๊ฒƒ์ด๋‹ค. ๊ทธ ๋’ค ๊ตฌ์กฐ๊ฐ€ ๋ฐ”๋€Œ์—ˆ๋‹ค๋ฉด(์ž๋ฆฌ๊ฐ€
487
+ * ๋น ์กŒ๋‹คยท์„ค๋น„๊ฐ€ ์˜ฎ๊ฒจ์กŒ๋‹ค) ๋˜์„ธ์šด ๋ฆฌ๋“€์„œ๋Š” **์ง€๊ธˆ ์—†๋Š” ์ž๋ฆฌ์™€ ์„ค๋น„๋ฅผ ๋“ ๋‹ค** โ€” ์—†๋Š” ๋ƒ‰์žฅ์‹ค์ด ํ™”๋ฉด์—
488
+ * ๋‚˜์˜ค๊ณ  ๊ทธ ์ž๋ฆฌ์˜ ํŒ์ •์ด ๊ณ„์† ๋Œ์•„๊ฐ„๋‹ค. ์˜ค๋ฅ˜ ์—†์ด ํ‹€๋ฆฌ๋ฏ€๋กœ ๋ˆˆ์— ๋„์ง€ ์•Š๋Š”๋‹ค.
489
+ *
490
+ * โ‘ข์ด ํ•„์š”ํ•œ ์ด์œ : ์Šค๋ƒ…์ƒท์€ ์ฒดํฌํฌ์ธํŠธ ์ฃผ๊ธฐ๋กœ ์“ฐ์ด๋ฏ€๋กœ ๋งˆ์ง€๋ง‰ ์ฃผ๊ธฐ ์ดํ›„์˜ ์‚ฌ์‹ค์€ ์ €๋„์—๋งŒ ์žˆ๋‹ค.
491
+ * ๊ทธ๊ฒƒ์„ ๋นผ๊ณ  ๋˜์„ธ์šฐ๋ฉด ๊ทธ๋งŒํผ์ด ์กฐ์šฉํžˆ ์‚ฌ๋ผ์ง„๋‹ค โ€” ใ€Œ๋ชจ๋ฆ„ใ€์„ ใ€Œ์—†์Œใ€์œผ๋กœ ์ ๋Š” ๊ฒƒ๊ณผ ๊ฐ™์€ ๋ถ€๋ฅ˜๋‹ค.
492
+ * ์ ‘๋Š” ๊ตฌ๊ฐ„์€ **๊ทธ ํ‹ˆ๋ฟ**์ด๊ณ (์ €๋„ ์ „์ฒด๊ฐ€ ์•„๋‹ˆ๋‹ค), `recover` ๊ฐ€ ๊ทธ ์ž๋ฆฌ์—์„œ ์ƒˆ ์žฌ๊ฐœ์ ์„ ๋‚จ๊ฒจ ์ค€๋‹ค.
493
+ */
494
+ private static async warmSeedFor(
495
+ domainId: string,
496
+ instanceId: string
497
+ ): Promise<{ revision: number; state: any; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint } } | null> {
498
+ let cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
499
+ if (!cached?.state) return null
500
+
501
+ const seedOf = (c: typeof cached) => (c?.fold?.reducer ? { fold: c.fold } : {})
502
+ if (!cached.fold?.reducer) return { revision: cached.revision, state: unwrapState(cached.state) }
503
+
504
+ const tip = await this.tipOf(domainId, instanceId).catch(() => null)
505
+ if (tip && (cached.structureRev ?? null) !== tip.structureRev) {
506
+ twinLog(
507
+ `[twin-engine] "${instanceId}": the stored fold seed is from structure ${cached.structureRev} but the factory is now ` +
508
+ `at ${tip.structureRev} โ€” starting without it (the journal is folded from the beginning instead).`
509
+ )
510
+ return { revision: cached.revision, state: unwrapState(cached.state) }
511
+ }
512
+
513
+ if (tip && (cached.revision ?? 0) < tip.revision) {
514
+ /*
515
+ * ํ‹ˆ์„ ์ ‘๋Š”๋‹ค โ€” `recover` ๊ฐ€ ์ด ์”จ์•—์œผ๋กœ **๊ผฌ๋ฆฌ๋งŒ** ์ ‘๊ณ , ๋ ์ง€์ ์˜ ์ƒˆ ์žฌ๊ฐœ์ ์„ ๋‚จ๊ธด๋‹ค.
516
+ * ์•„์ง ์ด ํŠธ์œˆ์˜ ๋Ÿฐํƒ€์ž„์ด ์—†์œผ๋ฏ€๋กœ `recover` ๋Š” ๋ฉ”๋ชจ๋ฆฌ ๋Œ€์‹  ์ €๋„ ๊ฒฝ๋กœ๋ฅผ ํƒ„๋‹ค(๊ทธ๊ฒƒ์ด ์—ฌ๊ธฐ์˜ ์ „์ œ๋‹ค).
517
+ */
518
+ const gap = tip.revision - (cached.revision ?? 0)
519
+ await this.recover(domainId, instanceId).catch(err =>
520
+ twinWarn(`[twin-engine] "${instanceId}": could not fold the ${gap} event(s) after the checkpoint โ€” ${err?.message ?? err}`)
521
+ )
522
+ const advanced = await this.loadSnapshot(domainId, instanceId).catch(() => null)
523
+ if (advanced?.state && (advanced.revision ?? 0) > (cached.revision ?? 0)) {
524
+ twinLog(`[twin-engine] "${instanceId}": folded ${gap} event(s) after the checkpoint โ†’ revision ${advanced.revision}.`)
525
+ cached = advanced
526
+ }
527
+ }
528
+ return { revision: cached.revision, state: unwrapState(cached.state), ...seedOf(cached) }
529
+ }
530
+
238
531
  /** ์ฒดํฌํฌ์ธํŠธ๋œ ์ตœ์‹  ์Šค๋ƒ…์ƒท ๋กœ๋“œ(์—†์œผ๋ฉด null). getFromCache ๋Š” CacheStore ์—”ํ‹ฐํ‹ฐ๋ฅผ ๋ฐ˜ํ™˜ โ†’ ํŽ˜์ด๋กœ๋“œ๋Š” .value. */
239
532
  static async loadSnapshot(
240
533
  domainId: string,
@@ -262,7 +555,7 @@ export class TwinEngine {
262
555
  if (!value?.state) return
263
556
  await cacheService
264
557
  .setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, value, this.SNAPSHOT_TTL_S)
265
- .catch((err: any) => console.error(`[twin-engine] snapshot save fail "${instanceId}"`, err?.message ?? err))
558
+ .catch((err: any) => twinError(`[twin-engine] snapshot save fail "${instanceId}"`, err?.message ?? err))
266
559
  }
267
560
 
268
561
 
@@ -339,6 +632,113 @@ export class TwinEngine {
339
632
  return { revision: tip?.revision ?? 0, structureRev: newest?.rev ?? null }
340
633
  }
341
634
 
635
+ /**
636
+ * **์ €๋„์„ ๋ณด์กด ๊ธฐ๊ฐ„๊นŒ์ง€๋งŒ ๋‘”๋‹ค** โ€” ์ฒดํฌํฌ์ธํŠธ๊ฐ€ ๋Œ€์‹ ํ•  ์ˆ˜ ์žˆ๋Š” ๋งŒํผ๋งŒ ์ง€์šด๋‹ค.
637
+ *
638
+ * ํ•œ ์ธ์Šคํ„ด์Šค์—์„œ ์ง€์šฐ๋Š” ์กฐ๊ฑด์€ **๋‘˜ ๋‹ค** ๋งŒ์กฑํ•ด์•ผ ํ•œ๋‹ค.
639
+ *
640
+ * ยท `createdAt` ์ด ๋ณด์กด ๊ธฐ๊ฐ„๋ณด๋‹ค ์˜ค๋ž˜๋๋‹ค โ€” **ํ–‰์ด ์“ฐ์ธ ์‹ค์ œ ์‹œ๊ฐ**์ด ๊ธฐ์ค€์ด๋‹ค
641
+ * ยท `revision` ์ด **์ฒดํฌํฌ์ธํŠธ ๋ฆฌ๋น„์ „ ์ดํ•˜**๋‹ค โ€” ๊ทธ ์•ž์€ ์Šค๋ƒ…์ƒท์ด ๋Œ€์‹ ํ•œ๋‹ค
642
+ *
643
+ * โ”€โ”€ ์™œ `eventTime` ์ด ์•„๋‹ˆ๋ผ `createdAt` ์ธ๊ฐ€ (2026-08-22 ์‹ค์ธก์œผ๋กœ ๊ณ ์นจ) โ”€โ”€โ”€โ”€
644
+ * ์ฒ˜์Œ์— `eventTime` ์œผ๋กœ ์ ์—ˆ๋‹ค. ๊ทธ๊ฒƒ์€ **ํŠธ์œˆ์˜ ์‹œ๊ณ„**๋‹ค โ€” ์‹œ๋ฎฌ๋ ˆ์ด์…˜์€ ์ž๊ธฐ ์‹œ๊ณ„๋กœ ์‚ฌ๊ฑด์„ ์ฐ๊ณ ,
645
+ * ๊ทธ ์‹œ๊ณ„๋Š” ์‹ค์ œ ์‹œ๊ฐ๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ ์•ž์„œ๊ฑฐ๋‚˜ ๋’ค์„ ๋‹ค. ์‹ค์ธก:
646
+ *
647
+ * order-check ๊ฐ€์žฅ ๋Šฆ์€ eventTime = 2027-06-08 โ† ์‹ค์ œ ์‹œ๊ฐ๋ณด๋‹ค 10๊ฐœ์›” ์•ž
648
+ * hatio-mx2 2026-01-01 ~ ์ง€๊ธˆ โ† 7๊ฐœ์›”์น˜๋ฅผ ํ•œ ๋ฒˆ์— ์ง€์šฐ๊ฒŒ ๋œ๋‹ค
649
+ *
650
+ * ์ฆ‰ ใ€Œ7์ผใ€์ด ํŠธ์œˆ๋งˆ๋‹ค ๋‹ค๋ฅธ ๋œป์ด ๋œ๋‹ค: ์‹œ๊ณ„๊ฐ€ ์•ž์„  ํŠธ์œˆ์€ ์˜์›ํžˆ ์ง€์›Œ์ง€์ง€ ์•Š๊ณ , ๊ณผ๊ฑฐ๋ถ€ํ„ฐ ์ฐ์€ ํŠธ์œˆ์€
651
+ * ๊ฑฐ์˜ ์ „๋ถ€๊ฐ€ ํ•œ ๋ฒˆ์— ์ง€์›Œ์ง„๋‹ค. **๋ณด์กด์€ ์ €์žฅ ๋‚˜์ด์˜ ๋ฌธ์ œ**์ด๊ณ  ์ €์žฅ ๋‚˜์ด๋Š” ์‹ค์ œ ์‹œ๊ณ„๋‹ค.
652
+ *
653
+ * `createdAt` ์€ ๊ทธ ํ–‰์ด DB ์— ์“ฐ์ธ ์‹œ๊ฐ์ด๊ณ  1,599๋งŒ ํ–‰ ์ „๋ถ€ ์ฑ„์›Œ์ ธ ์žˆ๋‹ค(ํ™•์ธํ•จ). ๋„๋ฉ”์ธ ์‹œ๊ฐ์„
654
+ * ์ •์ฑ…์— ์“ฐ์ง€ ์•Š๋Š”๋‹ค โ€” ๊ทธ ๋‘˜์„ ์„ž์œผ๋ฉด ์‹œ๋ฎฌ๊ณผ ๋ฏธ๋Ÿฌ์—์„œ ๊ฐ™์€ ์„ค์ •์ด ๋‹ค๋ฅด๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.
655
+ *
656
+ * ์Šค๋ƒ…์ƒท์ด ์—†์œผ๋ฉด **๊ทธ ์ธ์Šคํ„ด์Šค๋Š” ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค.** ์ €๋„์ด ์œ ์ผํ•œ ๋ณต๊ตฌ ์ˆ˜๋‹จ์ธ ์ƒํƒœ์ด๋ฏ€๋กœ, ์ง€์šฐ๋ฉด
657
+ * ๊ทธ ํŠธ์œˆ์˜ ์ƒํƒœ๋ฅผ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†๋‹ค. ใ€Œ์ง€์šธ ์ˆ˜ ์—†์—ˆ๋‹คใ€๋Š” ์‚ฌ์‹ค๋„ ํ•จ๊ป˜ ์„ผ๋‹ค โ€” ์กฐ์šฉํžˆ ๋„˜๊ธฐ๋ฉด ใ€Œ๋ณด์กด์ด
658
+ * ๋„๋Š”๋ฐ ์™œ ์•ˆ ์ค„์–ด๋“œ๋‚˜ใ€๊ฐ€ ๋œ๋‹ค.
659
+ *
660
+ * `createdAt` ์ด ๋นˆ ์˜› ํ–‰์€ **์ง€์šฐ์ง€ ์•Š๋Š”๋‹ค**(์‹œ๊ฐ์„ ๋ชจ๋ฅด๋Š” ๊ฒƒ์„ ใ€Œ์˜ค๋ž˜๋๋‹คใ€๋กœ ์ฝ์ง€ ์•Š๋Š”๋‹ค).
661
+ *
662
+ * ๋“œ๋ผ์ด๋ฒ„ ๋‹ค์„ฏ์„ ๋‹ค ์ง€๋‚˜์•ผ ํ•˜๋ฏ€๋กœ raw SQL ์„ ์“ฐ์ง€ ์•Š๋Š”๋‹ค โ€” ์กฐ๊ฑด ์‚ญ์ œ๋Š” ์ฟผ๋ฆฌ๋นŒ๋”๊ฐ€ ์ด์‹ํ•œ๋‹ค.
663
+ */
664
+ static async pruneJournal(domainId: string): Promise<{ deleted: number; instances: number; skipped: string[] }> {
665
+ /* ๋„๋ฉ”์ธ์˜ ์ •์ฑ…์ด ๋จผ์ €๋‹ค(ยง`retentionDaysOf`). ์—†์œผ๋ฉด ํ”„๋กœ์„ธ์Šค ๊ธฐ๋ณธ๊ฐ’. ๋‘˜ ๋‹ค ์—†์œผ๋ฉด ์ง€์šฐ์ง€ ์•Š๋Š”๋‹ค. */
666
+ const perDomain = this.retentionDaysOf ? await this.retentionDaysOf(domainId).catch(() => undefined) : undefined
667
+ const days = perDomain ?? this.JOURNAL_RETENTION_DAYS
668
+ if (!days || days <= 0) return { deleted: 0, instances: 0, skipped: [] }
669
+
670
+ /* ๋ณด์กด ๊ธฐ๊ฐ„์€ ์Šค๋ƒ…์ƒท TTL ๋ณด๋‹ค ์งง์„ ์ˆ˜ ์—†๋‹ค โ€” ์งง์œผ๋ฉด ์Šค๋ƒ…์ƒท์ด ๊ฐ€๋ฆฌํ‚ค๋Š” ์•ž์ชฝ์ด ๋น„๋Š” ๊ตฌ๊ฐ„์ด ์ƒ๊ธด๋‹ค. */
671
+ const minDays = this.SNAPSHOT_TTL_S / 86400
672
+ const effectiveDays = Math.max(days, minDays)
673
+ if (effectiveDays !== days) {
674
+ twinWarn(
675
+ `[twin-engine] journal retention ${days}์ผ์€ ์Šค๋ƒ…์ƒท TTL(${minDays}์ผ)๋ณด๋‹ค ์งง๋‹ค โ€” ${effectiveDays}์ผ๋กœ ์˜ฌ๋ฆฐ๋‹ค ` +
676
+ '(๋” ์งง์œผ๋ฉด ์Šค๋ƒ…์ƒท์ด ์‚ด์•„ ์žˆ๋Š”๋ฐ ๊ทธ๊ฒƒ์ด ๊ฐ€๋ฆฌํ‚ค๋Š” ์•ž์ชฝ ์ €๋„์ด ์—†๋Š” ๊ตฌ๊ฐ„์ด ์ƒ๊ธด๋‹ค)'
677
+ )
678
+ }
679
+ const cutoff = new Date(Date.now() - effectiveDays * 86400 * 1000)
680
+
681
+ const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId } } })
682
+ let deleted = 0
683
+ let instances = 0
684
+ const skipped: string[] = []
685
+
686
+ for (const r of rows) {
687
+ const snap = await this.loadSnapshot(domainId, r.instanceId).catch(() => null)
688
+ const upTo = Number(snap?.revision ?? 0)
689
+ if (!upTo) {
690
+ /* ์Šค๋ƒ…์ƒท์ด ์—†๋‹ค โ€” ์ €๋„์ด ์œ ์ผํ•œ ๋ณต๊ตฌ ์ˆ˜๋‹จ์ด๋ฏ€๋กœ ์†๋Œ€์ง€ ์•Š๋Š”๋‹ค. */
691
+ skipped.push(r.instanceId)
692
+ continue
693
+ }
694
+ const res = await getRepository(TwinEvent)
695
+ .createQueryBuilder()
696
+ .delete()
697
+ .from(TwinEvent)
698
+ .where('domain_id = :domainId', { domainId })
699
+ .andWhere('instance_id = :instanceId', { instanceId: r.instanceId })
700
+ .andWhere('revision <= :upTo', { upTo })
701
+ .andWhere('created_at IS NOT NULL')
702
+ .andWhere('created_at < :cutoff', { cutoff })
703
+ .execute()
704
+ const n = res.affected ?? 0
705
+ if (n > 0) {
706
+ deleted += n
707
+ instances++
708
+ /* **์ง€์šด ๊ฒƒ์„ ๋งํ•œ๋‹ค** โ€” ์กฐ์šฉํžˆ ์ค„์–ด๋“  ์ €๋„์€ ใ€Œ์—†์—ˆ๋˜ ์ผใ€๊ณผ ๊ตฌ๋ณ„๋˜์ง€ ์•Š๋Š”๋‹ค. */
709
+ twinLog(
710
+ `[twin-engine] journal pruned "${r.instanceId}" โ€” ${n}๊ฑด (revision โ‰ค ${upTo} ยท ${cutoff.toISOString()} ์ด์ „). ` +
711
+ '๊ทธ ์•ž์€ ์ฒดํฌํฌ์ธํŠธ๊ฐ€ ๋Œ€์‹ ํ•œ๋‹ค.'
712
+ )
713
+ }
714
+ }
715
+ if (skipped.length) {
716
+ twinLog(
717
+ `[twin-engine] journal prune skipped ${skipped.length} instance(s) with no checkpoint โ€” ` +
718
+ `์ €๋„์ด ์œ ์ผํ•œ ๋ณต๊ตฌ ์ˆ˜๋‹จ์ด๋ผ ์†๋Œ€์ง€ ์•Š์•˜๋‹ค: ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ' โ€ฆ' : ''}`
719
+ )
720
+ }
721
+ return { deleted, instances, skipped }
722
+ }
723
+
724
+ /** ๋ณด์กด ์ •๋ฆฌ ์ฃผ๊ธฐ ๊ธฐ๋™(1ํšŒ) โ€” ์„ ์–ธ์ด ์—†์œผ๋ฉด ์•„๋ฌด๊ฒƒ๋„ ํ•˜์ง€ ์•Š๋Š”๋‹ค. */
725
+ static startRetentionLoop(domainId: string): void {
726
+ if (this.retentionTimer) return
727
+ /*
728
+ * ๊ฑธ์ง€ ์•Š๋Š” ์กฐ๊ฑด: ํ”„๋กœ์„ธ์Šค ๊ธฐ๋ณธ๊ฐ’๋„ ์—†๊ณ  **๋„๋ฉ”์ธ์— ๋ฌผ์„ ๊ธธ๋„ ์—†์„** ๋•Œ๋‹ค. ์‹œ์ž„์ด ์‹ฌ๊ฒจ ์žˆ์œผ๋ฉด
729
+ * ๊ธฐ๋ณธ๊ฐ’์ด ์—†์–ด๋„ ๊ฑธ์–ด์•ผ ํ•œ๋‹ค โ€” ๊ทธ ๋„๋ฉ”์ธ์ด ์ž๊ธฐ ๊ฐ’์„ ๊ฐ€์งˆ ์ˆ˜ ์žˆ๋‹ค.
730
+ */
731
+ if (!this.JOURNAL_RETENTION_DAYS && !this.retentionDaysOf) return
732
+ this.retentionTimer = setInterval(() => {
733
+ this.pruneJournal(domainId).catch(err => twinError('[twin-engine] journal prune failed', err?.message ?? err))
734
+ }, this.RETENTION_SWEEP_MS)
735
+ if (typeof this.retentionTimer?.unref === 'function') this.retentionTimer.unref()
736
+ twinLog(
737
+ `[twin-engine] journal retention on โ€” ${this.JOURNAL_RETENTION_DAYS ?? '(๋„๋ฉ”์ธ ์ •์ฑ…)'}์ผ ยท ` +
738
+ `${this.RETENTION_SWEEP_MS / 60000}๋ถ„๋งˆ๋‹ค`
739
+ )
740
+ }
741
+
342
742
  /** ์ฒดํฌํฌ์ธํŠธ ๋ฃจํ”„ ๊ธฐ๋™(1ํšŒ) โ€” ๋ผ์ด๋ธŒ ์ธ์Šคํ„ด์Šค๋“ค์˜ ์ตœ์‹  ์Šค๋ƒ…์ƒท์„ ์ฃผ๊ธฐ ์˜์†. */
343
743
  static startCheckpointLoop(): void {
344
744
  if (this.checkpointTimer) return
@@ -346,7 +746,7 @@ export class TwinEngine {
346
746
  for (const [key, inst] of Object.entries(this.instances)) {
347
747
  const { instanceId } = parseRuntimeKey(key)
348
748
  this.persistSnapshot(inst.domainId, instanceId).catch(err =>
349
- console.error(`[twin-engine] snapshot checkpoint fail "${instanceId}"`, err?.message ?? err)
749
+ twinError(`[twin-engine] snapshot checkpoint fail "${instanceId}"`, err?.message ?? err)
350
750
  )
351
751
  }
352
752
  }, this.CHECKPOINT_MS)
@@ -358,6 +758,8 @@ export class TwinEngine {
358
758
  * ๋ผ์ด๋ธŒ tick ์žฌ๊ฐœ(sim ๊ฒฐ์ •์  re-run / live ์ธ์ œ์ŠคํŠธ ์ง€์†)๋Š” ํ›„์†. ์—ฌ๊ธฐ์„  ์ƒํƒœ ๋ณต์› + ๋…ธ์ถœ.
359
759
  */
360
760
  static async bootstrap(): Promise<void> {
761
+ /* ๋ถ€ํŒ…๋ถ€ํ„ฐ ์žฐ๋‹ค โ€” ์‚ฌ๋žŒ์ด ๊ฐ€์žฅ ๋‹ต๋‹ตํ•œ ๊ตฌ๊ฐ„์ด ์—ฌ๊ธฐ์ด๊ณ , ๊ทธ ๊ตฌ๊ฐ„์„ ์žฌ์ง€ ์•Š์œผ๋ฉด ๊ฐ’์œผ๋กœ ๋งํ•  ์ˆ˜ ์—†๋‹ค. */
762
+ loopLag.start()
361
763
  try {
362
764
  const repo = getRepository(TwinInstance)
363
765
  const rows = await repo.find({ where: { status: 'running' } })
@@ -365,10 +767,9 @@ export class TwinEngine {
365
767
  if (!row.domainId || !row.instanceId) continue
366
768
  // ์›œ์Šคํƒ€ํŠธ: ์บ์‹œ๋œ ์ตœ์‹  ์Šค๋ƒ…์ƒท ์šฐ์„ (O(1) + attentions/OEE ๋“ฑ ๋ผ์ด๋ธŒ ํŒŒ์ƒ์ƒํƒœ ๋ณด์กด).
367
769
  // ์—†์œผ๋ฉด ์ €๋„ fold-from-0 replay(์ง„์‹ค ํด๋ฐฑ โ€” replay ๋Š” ๋ผ์ด๋ธŒ ํŒŒ์ƒ์ƒํƒœ๋ฅผ ๋ชป ๋‹ด์œผ๋ฏ€๋กœ ์บ์‹œ๊ฐ€ ๋” ์ถฉ์‹ค).
368
- const cached = await this.loadSnapshot(row.domainId, row.instanceId).catch(() => null)
770
+ const cached = await this.warmSeedFor(row.domainId, row.instanceId).catch(() => null)
369
771
  if (cached?.state) {
370
- /* ์˜ˆ์ „์— ๊ฒนํฌ์žฅ์œผ๋กœ ์ €์žฅ๋œ ๊ฐ’์ด ๋‚จ์•„ ์žˆ์„ ์ˆ˜ ์žˆ๋‹ค โ€” ์ฝ๋Š” ์ชฝ์—์„œ๋„ ๋ฒ—๊ธด๋‹ค(ํ•œ ๋ฒˆ์€ ๋ฐ˜๋“œ์‹œ ๋งŒ๋‚œ๋‹ค). */
371
- this.recovered[runtimeKey(row.domainId, row.instanceId)] = { revision: cached.revision, state: unwrapState(cached.state) }
772
+ this.recovered[runtimeKey(row.domainId, row.instanceId)] = cached
372
773
  /*
373
774
  * **ใ€Œ์›œ์Šคํƒ€ํŠธํ–ˆ๋‹คใ€๊ณ  ๋งํ•˜์ง€ ์•Š๋Š”๋‹ค** โ€” ์—ฌ๊ธฐ์„œ๋Š” ์ƒํƒœ๋ฅผ **์ฐพ์•„ ๋‘” ๊ฒƒ**๋ฟ์ด๋‹ค.
374
775
  *
@@ -377,20 +778,38 @@ export class TwinEngine {
377
778
  * ๋กœ๊ทธ๋งŒ ์ฝ์œผ๋ฉด ์‹ฌ๊ธด ์ค„ ์•Œ๊ฒŒ ๋œ๋‹ค โ€” ์‹ค์ œ๋กœ ๊ทธ๋ ‡๊ฒŒ ์ฝ๊ณ  ์žฌ๊ธฐ๋™ ๋’ค ์ง€์†์‹œ๊ฐ„์ด ์‚ฌ๋ผ์ง„ ๊ฒƒ์„
378
779
  * ๋ฐ์ดํ„ฐ ๋ฌธ์ œ๋กœ ์˜ค์ง„ํ•  ๋ป”ํ–ˆ๋‹ค.
379
780
  */
380
- console.log(`[twin-engine] found cached state for "${row.instanceId}" โ†’ revision ${cached.revision} (seeded at start).`)
781
+ twinLog(`[twin-engine] found cached state for "${row.instanceId}" โ†’ revision ${cached.revision} (seeded at start).`)
381
782
  continue
382
783
  }
383
784
  const state = await this.recover(row.domainId, row.instanceId).catch(() => null)
384
785
  if (state) {
385
786
  this.recovered[runtimeKey(row.domainId, row.instanceId)] = { revision: state.revision, state }
386
- console.log(`[twin-engine] recovered "${row.instanceId}" from journal โ†’ revision ${state.revision}.`)
787
+ twinLog(`[twin-engine] recovered "${row.instanceId}" from journal โ†’ revision ${state.revision}.`)
387
788
  }
388
789
  }
389
- /* ์ƒํƒœ๋งŒ ๋˜์ฐพ๋Š” ๊ฒƒ์œผ๋กœ๋Š” **๋„๋Š” ํŠธ์œˆ์ด ๋˜์ง€ ์•Š๋Š”๋‹ค** โ€” ๋Ÿฐํƒ€์ž„๊นŒ์ง€ ๋˜์‚ด๋ฆฐ๋‹ค(์•„๋ž˜). */
390
- for (const row of rows) await this.resumeRow(row)
790
+ /*
791
+ * ์ƒํƒœ๋งŒ ๋˜์ฐพ๋Š” ๊ฒƒ์œผ๋กœ๋Š” **๋„๋Š” ํŠธ์œˆ์ด ๋˜์ง€ ์•Š๋Š”๋‹ค** โ€” ๋Ÿฐํƒ€์ž„๊นŒ์ง€ ๋˜์‚ด๋ฆฐ๋‹ค(์•„๋ž˜).
792
+ *
793
+ * ๋˜์‚ด๋ฆฌ๊ธฐ ์‚ฌ์ด์— **๋ฃจํ”„๋ฅผ ํ•œ ๋ฒˆ ๋น„์›Œ ์ค€๋‹ค** (2026-08-21). ์›œ์Šคํƒ€ํŠธ ํ•˜๋‚˜๊ฐ€ ๋ฌผํ’ˆ ์ˆ˜์ฒœ ๊ฐœ๋ฅผ ์ ‘์œผ๋ฏ€๋กœ
794
+ * ์Šค๋ฌด ๊ฐœ๋ฅผ ์—ฐ๋‹ฌ์•„ ํ•˜๋ฉด ๊ทธ ์‹œ๊ฐ„ ๋‚ด๋‚ด HTTP ๊ฐ€ ์„œ์ง€ ์•Š๋Š”๋‹ค โ€” ์„œ๋ฒ„๋Š” ์ด๋ฏธ `Server ready` ๋ฅผ ์ฐ์€
795
+ * ๋’ค๋ผ์„œ, ์‚ฌ๋žŒ์—๊ฒŒ๋Š” ใ€Œ์—ด๋ ธ๋Š”๋ฐ ์•„๋ฌด ๋ฐ˜์‘์ด ์—†๋Š” ํ™”๋ฉดใ€์œผ๋กœ ๋ณด์ธ๋‹ค. ์ด ์‹œ๊ฐ„์€ ๊ฐ™๊ณ , ๊ทธ ์‚ฌ์ด์—
796
+ * ๋„์ฐฉํ•œ ์š”์ฒญ์ด ์ฒ˜๋ฆฌ๋  ํ‹ˆ๋งŒ ์ƒ๊ธด๋‹ค.
797
+ */
798
+ for (const row of rows) {
799
+ await this.resumeRow(row)
800
+ await new Promise(resolve => setTimeout(resolve, this.BOOT_YIELD_MS))
801
+ }
391
802
  this.startCheckpointLoop() // ์ดํ›„ ๊ธฐ๋™๋˜๋Š” ๋ผ์ด๋ธŒ ์ธ์Šคํ„ด์Šค์˜ ์ตœ์‹  ์Šค๋ƒ…์ƒท์„ ์ฃผ๊ธฐ ์˜์†
803
+ /*
804
+ * ๋ณด์กด ์ •๋ฆฌ โ€” **์„ ์–ธ์ด ์žˆ์„ ๋•Œ๋งŒ** ๋ˆ๋‹ค(ยง`JOURNAL_RETENTION_DAYS`). ๋„๋ฉ”์ธ๋งˆ๋‹ค ํ•œ ๋ฒˆ ๊ฑด๋‹ค.
805
+ * ์ฒดํฌํฌ์ธํŠธ ๋ฃจํ”„ ๋’ค์— ๋‘๋Š” ์ด์œ : ์ง€์šธ ์ˆ˜ ์žˆ๋Š” ๊ฒฝ๊ณ„๊ฐ€ ์Šค๋ƒ…์ƒท์ด๋ฏ€๋กœ, ์Šค๋ƒ…์ƒท์„ ๋‚จ๊ธฐ๋Š” ์ชฝ์ด ๋จผ์ €
806
+ * ๋Œ์•„์•ผ ์ฒซ ์ •๋ฆฌ๊ฐ€ ์‹ค์ œ๋กœ ์ง€์šธ ๊ฒƒ์„ ๊ฐ–๋Š”๋‹ค.
807
+ */
808
+ for (const domainId of new Set(rows.map(r => r.domainId).filter(Boolean) as string[])) {
809
+ this.startRetentionLoop(domainId)
810
+ }
392
811
  } catch (err) {
393
- console.error('[twin-engine] recovery scan failed', err)
812
+ twinError('[twin-engine] recovery scan failed', err)
394
813
  }
395
814
  }
396
815
 
@@ -427,16 +846,20 @@ export class TwinEngine {
427
846
  }
428
847
 
429
848
  try {
849
+ /*
850
+ * **์„ ์–ธ๋œ ๋ชจ๋“œ๋กœ ๋˜์‚ด๋ฆฌ๋Š” ํŒ๋‹จ์€ ํ•œ ๊ณณ์— ์žˆ๋‹ค**(`startFromRegistry`) โ€” 2026-08-20.
851
+ *
852
+ * ์˜ˆ์ „์—๋Š” ์ด ์ž๋ฆฌ์—์„œ `resync` ๋ฅผ ๊ฐˆ๋ผ `startLive` ๋ฅผ ๋ถˆ๋ €๋‹ค. ๊ทธ๋ž˜์„œ ๋ถ€ํŒ…์œผ๋กœ ์‚ด์•„๋‚œ ๋ฏธ๋Ÿฌ๋Š”
853
+ * ๊ด€์ธก ๊ตฌ๋™์ด์—ˆ์ง€๋งŒ **์‚ฌ๋žŒ์ด ํ™”๋ฉด์—์„œ ์‹œ์ž‘ํ•œ ๋ฏธ๋Ÿฌ๋Š” ์‹œ๋ฎฌ๋กœ ๋–ด๋‹ค**(๊ทธ ๋ฌธ์—๋Š” ๊ฐˆ๋ฆผ์ด ์—†์—ˆ๋‹ค).
854
+ * ๊ฐ™์€ ์„ ์–ธ์ด ๋‘ ๊ฒฐ๊ณผ๋ฅผ ๋‚ด์ง€ ์•Š๋„๋ก ๊ฐˆ๋ฆผ์„ ๋ฌธ ์•ˆ์œผ๋กœ ์˜ฎ๊ฒผ๋‹ค.
855
+ */
856
+ await this.startFromRegistry(domainId, instanceId)
430
857
  if (row.restartPolicy === 'resync') {
431
- if (!row.model) throw new Error('no model')
432
- /* ์‹œ๊ฐ ๊ธฐ์ค€์€ **๊ณต๊ฐ„**์ด ๊ฐ–๋Š”๋‹ค โ€” ๊ต๋Œ€์˜ HH:MM ์„ ์–ด๋А ๊ธฐ์ค€์œผ๋กœ ์ฝ๋‚˜(๋ผ์ด๋ธŒ ๊ธฐ๋™๊ณผ ๊ฐ™์€ ๊ทœ์น™). */
433
- this.startLive(instanceId, domainId, row.kind, await this.withSpaceTimeBase(row.model as TwinModelDef, domainId))
434
858
  /* ๊ณ„์ธก์„ ๋‚˜๋ฅด๋Š” ํ”ผ๋“œ๋Š” ์ปค๋„ฅํ„ฐ์˜ ๊ฒƒ์ด๋‹ค โ€” ๋ ˆํผ๋Ÿฐ์Šค ๊ณ„์ธต์ด ๋ถ€ํŒ… ํ›…์—์„œ ๋‹ค์‹œ ๋ถ™์ธ๋‹ค
435
859
  (`resumeReferenceLiveFeeds`). ์—ฌ๊ธฐ์„œ ์–ด๋Œ‘ํ„ฐ๋ฅผ ์•„๋Š” ๊ฒƒ์€ ๊ณ„์ธต์„ ๊ฑฐ๊พธ๋กœ ์ž‡๋Š” ๊ฒƒ์ด๋‹ค. */
436
- console.log(`[twin-engine] resumed mirror "${instanceId}" โ€” feed reattach is the reference layer's job.`)
860
+ twinLog(`[twin-engine] resumed mirror "${instanceId}" โ€” feed reattach is the reference layer's job.`)
437
861
  } else {
438
- await this.startFromRegistry(domainId, instanceId)
439
- console.log(`[twin-engine] resumed ${row.restartPolicy} "${instanceId}".`)
862
+ twinLog(`[twin-engine] resumed ${row.restartPolicy} "${instanceId}".`)
440
863
  }
441
864
  } catch (err: any) {
442
865
  await this.markStopped(row, err?.message ?? 'resume failed')
@@ -445,11 +868,11 @@ export class TwinEngine {
445
868
 
446
869
  /** ๋˜์‚ด๋ฆฌ์ง€ ๋ชปํ•œ ํ–‰์„ ์ •์งํ•˜๊ฒŒ ์ ๋Š”๋‹ค โ€” ใ€Œ๋„๋Š” ์ค‘ใ€์ด๋ผ ๋งํ•˜๋Š” ์ฑ„๋กœ ๋‘์ง€ ์•Š๋Š”๋‹ค. */
447
870
  private static async markStopped(row: TwinInstance, why: string): Promise<void> {
448
- console.warn(`[twin-engine] "${row.instanceId}" not resumed (${why}) โ€” registry says stopped now.`)
871
+ twinWarn(`[twin-engine] "${row.instanceId}" not resumed (${why}) โ€” registry says stopped now.`)
449
872
  try {
450
873
  await getRepository(TwinInstance).update({ id: row.id }, { status: 'stopped' })
451
874
  } catch (e: any) {
452
- console.error(`[twin-engine] could not mark "${row.instanceId}" stopped โ€” registry now lies about it.`, e?.message)
875
+ twinError(`[twin-engine] could not mark "${row.instanceId}" stopped โ€” registry now lies about it.`, e?.message)
453
876
  }
454
877
  }
455
878
 
@@ -487,9 +910,9 @@ export class TwinEngine {
487
910
 
488
911
  if (plan.action === 'skip') {
489
912
  if (plan.reason === 'bench') {
490
- console.log(`[twin-engine] "${id}" is a bench twin โ€” starting empty on purpose (seeding would skew the measurement).`)
913
+ twinLog(`[twin-engine] "${id}" is a bench twin โ€” starting empty on purpose (seeding would skew the measurement).`)
491
914
  } else if (plan.reason === 'unsupported') {
492
- console.warn(
915
+ twinWarn(
493
916
  `[twin-engine] kernel for "${id}" cannot be warm-started (no hydrateObserved) โ€” it starts with structure only, so held stock will read as zero.`
494
917
  )
495
918
  }
@@ -509,10 +932,10 @@ export class TwinEngine {
509
932
  /* ใ€Œ์–ธ์ œ๋ถ€ํ„ฐ์ธ๊ฐ€ใ€๋„ ๋งํ•œ๋‹ค โ€” ์žƒ์œผ๋ฉด ์ง€์†๋œ ์กฐ๊ฑด์ด ๋ชจ๋‘ ใ€Œ๋ฐฉ๊ธˆใ€์œผ๋กœ ๋ณด์ธ๋‹ค. */
510
933
  ...(plan.attentionSinceCount ? [`${plan.attentionSinceCount} attention start time(s)`] : [])
511
934
  ].join(', ')
512
- console.log(`[twin-engine] warm-started "${id}" โ€” restored ${restored}.`)
935
+ twinLog(`[twin-engine] warm-started "${id}" โ€” restored ${restored}.`)
513
936
  /* ๋บ€ ๊ฒƒ์€ ์กฐ์šฉํžˆ ๋„˜๊ธฐ์ง€ ์•Š๋Š”๋‹ค โ€” ์ง€์–ด๋‚ด์ง€ ์•Š์•˜๋‹ค๋Š” ์‚ฌ์‹ค ์ž์ฒด๋ฅผ ๋งํ•ด์•ผ ํ™”๋ฉด์˜ ๋นˆ์นธ์ด ์ฝํžŒ๋‹ค. */
514
937
  if (plan.ordersWithoutDemand > 0) {
515
- console.warn(
938
+ twinWarn(
516
939
  `[twin-engine] "${id}": ${plan.ordersWithoutDemand} order(s) could not be restored โ€” they carry progress only, ` +
517
940
  'with no requested/fulfilled counts, so the remaining demand is unknown. They are left out rather than guessed.'
518
941
  )
@@ -540,7 +963,7 @@ export class TwinEngine {
540
963
  const declared = model?.localDurations as Record<string, string> | undefined
541
964
  if (declared && Object.keys(declared).length) {
542
965
  if (typeof kernel?.declareDurations !== 'function') {
543
- console.warn(
966
+ twinWarn(
544
967
  `[twin-engine] "${id}": this facility declared ${Object.keys(declared).length} operation duration(s) but the kernel cannot consume them ` +
545
968
  '(declareDurations missing โ€” kernel needs publishing). The simulation keeps running on built-in constants, and specCoverage() will keep reporting "default".'
546
969
  )
@@ -549,7 +972,7 @@ export class TwinEngine {
549
972
  kernel.declareDurations(declared)
550
973
  } catch (err: any) {
551
974
  /* ์ปค๋„์ด ๊ฑฐ์ ˆํ•œ ๊ฐ’์€ ์กฐ์šฉํžˆ ๋„˜๊ธฐ์ง€ ์•Š๋Š”๋‹ค โ€” ํ™”๋ฉด์€ ใ€Œ๋„ฃ์—ˆ์Šต๋‹ˆ๋‹คใ€๋ผ๊ณ  ๋งํ•œ ๊ฐ’์ด๋‹ค. */
552
- console.warn(`[twin-engine] "${id}": declared operation duration rejected by the kernel โ€” ${err?.message ?? err}`)
975
+ twinWarn(`[twin-engine] "${id}": declared operation duration rejected by the kernel โ€” ${err?.message ?? err}`)
553
976
  }
554
977
  }
555
978
  }
@@ -557,7 +980,7 @@ export class TwinEngine {
557
980
  const params = model?.localParams as Record<string, Record<string, string>> | undefined
558
981
  if (params && Object.keys(params).length) {
559
982
  if (typeof kernel?.declareParameters !== 'function') {
560
- console.warn(
983
+ twinWarn(
561
984
  `[twin-engine] "${id}": this facility declared operation parameters for ${Object.keys(params).length} operation(s) but the kernel cannot consume them ` +
562
985
  '(declareParameters missing โ€” kernel needs publishing). Yield and setup keep running on built-in constants.'
563
986
  )
@@ -565,14 +988,14 @@ export class TwinEngine {
565
988
  try {
566
989
  kernel.declareParameters(params)
567
990
  } catch (err: any) {
568
- console.warn(`[twin-engine] "${id}": declared operation parameter rejected by the kernel โ€” ${err?.message ?? err}`)
991
+ twinWarn(`[twin-engine] "${id}": declared operation parameter rejected by the kernel โ€” ${err?.message ?? err}`)
569
992
  }
570
993
  }
571
994
  }
572
995
  const ops = model?.operations
573
996
  if (!ops?.length) return
574
997
  if (typeof kernel?.loadOperations !== 'function') {
575
- console.warn(`[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.`)
998
+ 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.`)
576
999
  return
577
1000
  }
578
1001
  kernel.loadOperations(ops)
@@ -597,7 +1020,7 @@ export class TwinEngine {
597
1020
  const chained = chainEstimators([measured?.estimator, travel.estimator])
598
1021
  if (!chained) {
599
1022
  /* ์™œ ๋ชป ๋„ฃ์—ˆ๋Š”์ง€ ํ•œ ๋ฒˆ๋งŒ ์•Œ๋ฆฐ๋‹ค โ€” ์ด๋™์‹œ๊ฐ„์ด ์ƒ์ˆ˜๋กœ ๋‚จ์€ ์ด์œ ๋ฅผ ๋ชจ๋ฅด๊ณ  ์ง€๋‚˜๊ฐ€์ง€ ์•Š๊ฒŒ. */
600
- if (travel.reasons.length) console.warn(`[twin-engine] "${instanceId}": no duration estimator installed โ€” ${travel.reasons.join('; ')}`)
1023
+ if (travel.reasons.length) twinWarn(`[twin-engine] "${instanceId}": no duration estimator installed โ€” ${travel.reasons.join('; ')}`)
601
1024
  return
602
1025
  }
603
1026
  kernel.durationEstimator = chained
@@ -610,13 +1033,13 @@ export class TwinEngine {
610
1033
  const yields = await this.measuredYield(domainId, instanceId)
611
1034
  if (yields?.estimator) {
612
1035
  if (typeof kernel.yieldOf !== 'function') {
613
- console.warn(
1036
+ twinWarn(
614
1037
  `[twin-engine] "${instanceId}": learned yield for ${Object.keys(yields.learned).length} operation kind(s) but the kernel cannot consume it ` +
615
1038
  '(yieldOf missing โ€” kernel needs publishing). Yield keeps running on the declared value or the built-in constant.'
616
1039
  )
617
1040
  } else {
618
1041
  kernel.yieldEstimator = yields.estimator
619
- console.log(
1042
+ twinLog(
620
1043
  `[twin-engine] "${instanceId}": measured yield installed โ€” ${Object.entries(yields.learned)
621
1044
  .map(([k, v]) => `${k}=${Math.round(v * 1000) / 10}%(${yields.samples[k].good + yields.samples[k].scrap}๊ฑด)`)
622
1045
  .join(', ')}${yields.skipped.length ? ` ยท ํ‘œ๋ณธ ๋ถ€์กฑ์œผ๋กœ ๋บ€ ์ข…๋ฅ˜: ${yields.skipped.map(s => `${s.kind}(${s.judged})`).join(', ')}` : ''}`
@@ -624,7 +1047,7 @@ export class TwinEngine {
624
1047
  }
625
1048
  } else if (yields?.skipped.length) {
626
1049
  /* ๋ฐฐ์šด ๊ฒƒ์ด ์—†๊ณ  ๋ฒ„๋ฆฐ ๊ฒƒ๋งŒ ์žˆ์œผ๋ฉด ๊ทธ ์‚ฌ์‹ค๋„ ๋งํ•œ๋‹ค โ€” ใ€Œ์ด๋ ฅ์ด ์—†๋‹คใ€์™€ ใ€Œํ‘œ๋ณธ์ด ๋ชจ์ž๋ผ๋‹คใ€๋Š” ๋‹ค๋ฅด๋‹ค. */
627
- console.log(
1050
+ twinLog(
628
1051
  `[twin-engine] "${instanceId}": no measured yield yet โ€” samples below ${yields.minSamples}: ${yields.skipped
629
1052
  .map(s => `${s.kind}(${s.judged})`)
630
1053
  .join(', ')}`
@@ -632,7 +1055,7 @@ export class TwinEngine {
632
1055
  }
633
1056
  const learned = Object.keys(measured?.learned ?? {})
634
1057
  const spread = Object.keys(measured?.spreads ?? {})
635
- console.log(
1058
+ twinLog(
636
1059
  `[twin-engine] "${instanceId}": duration estimator installed โ€” measured kinds: ${learned.length ? learned.join(',') : 'none'}` +
637
1060
  ` (with observed spread: ${spread.length ? spread.join(',') : 'none'})` +
638
1061
  `${travel.estimator ? `, travel from distance (speeds: ${Object.keys(travel.speedsByKind).join(',')})` : `, travel not derived (${travel.reasons.join('; ')})`}`
@@ -691,7 +1114,7 @@ export class TwinEngine {
691
1114
  /* ๊ฐ™์€ ๊ทธ๋ฃน์—์„œ ์–‘ํ’ˆ๋ฅ ๋„ ๋ฐฐ์šด๋‹ค โ€” ํ•œ ๋ฒˆ ์ ‘์€ ์ €๋„์„ ๋‘˜์ด ๋‚˜๋ˆ  ์“ด๋‹ค. */
692
1115
  yields = buildYieldEstimator(kpi?.groups?.items, {})
693
1116
  } catch (err) {
694
- console.warn(`[twin-engine] "${instanceId}": measured duration lookup failed โ€” falling back to declared/default durations.`, (err as any)?.message)
1117
+ twinWarn(`[twin-engine] "${instanceId}": measured duration lookup failed โ€” falling back to declared/default durations.`, (err as any)?.message)
695
1118
  }
696
1119
  /* ๋‹ค์‹œ ๋„ฃ์–ด **์ตœ๊ทผ ์“ด ๊ฒƒ**์œผ๋กœ ๋งŒ๋“ ๋‹ค โ€” ์‚ฝ์ž… ์ˆœ์„œ๊ฐ€ ๊ณง ๋ฒ„๋ฆด ์ˆœ์„œ์ด๋ฏ€๋กœ ์ด ํ•œ ์ค„์ด LRU ๋ฅผ ๋งŒ๋“ ๋‹ค. */
697
1120
  this.measuredCache.delete(key)
@@ -723,14 +1146,14 @@ export class TwinEngine {
723
1146
  const ref = await getRepository(TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } })
724
1147
  config = ref?.connectionConfig
725
1148
  } catch (err: any) {
726
- console.warn(`[twin-engine] "${instanceId}": could not read the declared stimulus โ€” ${err?.message ?? err}`)
1149
+ twinWarn(`[twin-engine] "${instanceId}": could not read the declared stimulus โ€” ${err?.message ?? err}`)
727
1150
  return
728
1151
  }
729
1152
  const plan = planStimulus(config, { hasScenarioEngine: !!inst.runtime?.scenario, mode: inst.mode }, validateScenario as any)
730
1153
  if (plan.action === 'skip') {
731
1154
  /* ์„ ์–ธ์ด ์—†๋Š” ๊ฒƒ์€ ์ •์ƒ์ด๋ฏ€๋กœ ์กฐ์šฉํžˆ ์ง€๋‚œ๋‹ค. ๋‚˜๋จธ์ง€ ์…‹์€ **๋งํ•œ๋‹ค** โ€” ์„ ์–ธํ–ˆ๋Š”๋ฐ ์•ˆ ์‹ค๋ฆฐ ์ƒํƒœ๋‹ค. */
732
1155
  if (plan.reason !== 'none') {
733
- console.warn(
1156
+ twinWarn(
734
1157
  `[twin-engine] "${instanceId}": a stimulus is declared on its source but was not loaded (${plan.reason}${plan.detail ? `: ${plan.detail}` : ''}).` +
735
1158
  (plan.reason === 'observed' ? ' This twin runs on observation โ€” we do not manufacture arrivals for it.' : '')
736
1159
  )
@@ -738,12 +1161,18 @@ export class TwinEngine {
738
1161
  return
739
1162
  }
740
1163
  try {
1164
+ /* ์ž๊ทน์„ ์‹ฃ๊ณ  ์‹œ์ž‘ํ•˜๋Š” ๋ฐ ๋“  ์‹œ๊ฐ„ โ€” ์‹ค์ธก์—์„œ ํฐ ํŠธ์œˆ์€ ์ด ๊ตฌ๊ฐ„์ด 29~72์ดˆ์˜€๋‹ค. */
1165
+ const tStim = performance.now()
741
1166
  inst.runtime!.scenario.load(plan.scenario)
742
1167
  inst.runtime!.scenario.start()
1168
+ const stimMs = performance.now() - tStim
1169
+ recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'stimulus', stimMs)
743
1170
  const kinds = (plan.scenario.generators ?? []).map((g: any) => g?.kind).filter(Boolean)
744
- console.log(`[twin-engine] "${instanceId}": stimulus from its source started โ€” ${kinds.length ? kinds.join(', ') : 'no generators'}.`)
1171
+ twinLog(
1172
+ `[twin-engine] "${instanceId}": stimulus from its source started in ${Math.round(stimMs)}ms โ€” ${kinds.length ? kinds.join(', ') : 'no generators'}.`
1173
+ )
745
1174
  } catch (err: any) {
746
- console.warn(`[twin-engine] "${instanceId}": the declared stimulus was rejected at load โ€” ${err?.message ?? err}`)
1175
+ twinWarn(`[twin-engine] "${instanceId}": the declared stimulus was rejected at load โ€” ${err?.message ?? err}`)
747
1176
  }
748
1177
  }
749
1178
 
@@ -757,7 +1186,7 @@ export class TwinEngine {
757
1186
  const repo = getRepository(TwinReference)
758
1187
  const ref = await repo.findOne({ where: { domain: { id: domainId }, source: instanceId } })
759
1188
  if (!ref) {
760
- console.warn(`[twin-engine] "${instanceId}": no source reference โ€” a stimulus has nowhere to be declared.`)
1189
+ twinWarn(`[twin-engine] "${instanceId}": no source reference โ€” a stimulus has nowhere to be declared.`)
761
1190
  return false
762
1191
  }
763
1192
  ref.connectionConfig = withStimulus(ref.connectionConfig, scenario)
@@ -877,7 +1306,7 @@ export class TwinEngine {
877
1306
  const plan = planLiveContinuity(cached)
878
1307
  if (!plan.ackedCount && !plan.attentionSinceCount && !plan.energy) return
879
1308
  if (typeof kernel.hydrateContinuity !== 'function') {
880
- console.warn(
1309
+ twinWarn(
881
1310
  `[twin-engine] kernel for "${id}" cannot carry continuity (no hydrateContinuity) โ€” the open demand window, ` +
882
1311
  'its peak and the attention start times are lost on every restart.'
883
1312
  )
@@ -887,7 +1316,7 @@ export class TwinEngine {
887
1316
  kernel.hydrateContinuity(plan.seed)
888
1317
  } catch (err: any) {
889
1318
  /* ์ด์–ด๋ฐ›๊ธฐ๊ฐ€ ์‹คํŒจํ•ด๋„ ๋ฏธ๋Ÿฌ๋Š” ๋Œ์•„์•ผ ํ•œ๋‹ค โ€” ๋‹ค๋งŒ ๋ฌด์—‡์„ ์žƒ์—ˆ๋Š”์ง€ ๋งํ•œ๋‹ค. */
890
- console.warn(`[twin-engine] continuity seed failed for "${id}" โ€” starting without it: ${err?.message ?? err}`)
1319
+ twinWarn(`[twin-engine] continuity seed failed for "${id}" โ€” starting without it: ${err?.message ?? err}`)
891
1320
  return
892
1321
  }
893
1322
  const carried = [
@@ -895,7 +1324,7 @@ export class TwinEngine {
895
1324
  ...(plan.ackedCount ? [`${plan.ackedCount} acknowledged attention(s)`] : []),
896
1325
  ...(plan.attentionSinceCount ? [`${plan.attentionSinceCount} attention start time(s)`] : [])
897
1326
  ].join(', ')
898
- console.log(`[twin-engine] mirror "${id}" carried over ${carried} โ€” observation axes come from the source.`)
1327
+ twinLog(`[twin-engine] mirror "${id}" carried over ${carried} โ€” observation axes come from the source.`)
899
1328
  }
900
1329
 
901
1330
  static start(id: string, domainId: string, kind: string, model: TwinModelDef, restartPolicy?: RestartPolicy, purpose?: string, resumeFrom?: number): InstanceRuntime {
@@ -907,8 +1336,16 @@ export class TwinEngine {
907
1336
  kernel.loadTwinModel(model) // ๊ตฌ์กฐ๋งŒ. ์ƒํƒœ๋Š” ์•„๋ž˜ ์›œ์Šคํƒ€ํŠธ๊ฐ€ ์ฃผ์ž…ํ•œ๋‹ค.
908
1337
  this.applyOperations(kernel, model, id) // ์‹œ๊ฐ„ยท์ˆ˜์œจ ๋ช…์„ธ(์žˆ์œผ๋ฉด) โ€” ์—†์œผ๋ฉด ์ปค๋„ ๊ธฐ๋ณธ๊ฐ’
909
1338
  /* ์ถ”์ •๊ธฐ๋Š” DB ์กฐํšŒ๋ฅผ ํฌํ•จํ•ด ๋น„๋™๊ธฐ โ€” ๊ธฐ๋™์„ ๋ง‰์ง€ ์•Š๊ณ  ๋ถ™๋Š”๋‹ค(๋ถ™๊ธฐ ์ „ ์ž‘์—…์€ ๋ช…์„ธยท์ƒ์ˆ˜๋กœ ์‚ฐ์ถœ). */
910
- this.installEstimators(kernel, domainId, id, model).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
1339
+ this.installEstimators(kernel, domainId, id, model).catch(err => twinWarn('[twin-engine] estimator install failed', err?.message))
1340
+ /*
1341
+ * **์›œ์Šคํƒ€ํŠธ์— ๋“  ์‹œ๊ฐ„์„ ๊ฐ’์œผ๋กœ ๋‚จ๊ธด๋‹ค** (2026-08-22). ์‹ค์ธก์œผ๋กœ ๋ถ€ํŒ…์ด 620~717์ดˆ์˜€๊ณ  ํŠธ์œˆ ํ•˜๋‚˜์—
1342
+ * 15~93์ดˆ์˜€๋Š”๋ฐ, ์–ด๋А ์ž‘์—…์ด ๊ทธ ์‹œ๊ฐ„์„ ์“ฐ๋Š”์ง€ ๋‹ตํ•  ๊ณ„๊ธฐ๊ฐ€ ์—†์—ˆ๋‹ค. ๋กœ๊ทธ์—๋„ ๋ฐ€๋ฆฌ์ดˆ๊นŒ์ง€ ์ ์–ด
1343
+ * ๋ถ€ํŒ… ๋กœ๊ทธ๋งŒ์œผ๋กœ ๊ตฌ๊ฐ„์„ ์ฝ์„ ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค.
1344
+ */
1345
+ const tWarm = performance.now()
911
1346
  this.warmStart(domainId, id, kernel, purpose)
1347
+ const warmMs = performance.now() - tWarm
1348
+ if (warmMs >= 1000) twinLog(`[twin-engine] "${id}": warm start took ${Math.round(warmMs)}ms.`)
912
1349
  /*
913
1350
  * **๋ฒˆํ˜ธ๋ฅผ ์ด์–ด ์„ผ๋‹ค** โ€” ์ €๋„์— ์ด๋ฏธ ์žˆ๋Š” ๋ฒˆํ˜ธ์™€ ๊ฒน์น˜์ง€ ์•Š๊ฒŒ.
914
1351
  *
@@ -946,6 +1383,17 @@ export class TwinEngine {
946
1383
  /* ์ •์ฑ…์€ ๋ถ€๋ฅด๋Š” ์ชฝ์ด ์„ ์–ธํ•œ๋‹ค โ€” ์—ฌ๊ธฐ์„œ ๊ณ ๋ฅด๋ฉด ๊ฐ™์€ ํŠธ์œˆ์ด ๋ถ€๋ฅด๋Š” ์ž๋ฆฌ์— ๋”ฐ๋ผ ๋‹ค๋ฅด๊ฒŒ ์žฌ๊ธฐ๋™ํ•œ๋‹ค. */
947
1384
  restartPolicy: readRestartPolicy(restartPolicy, `start("${id}")`),
948
1385
  spaceId: (model as any)?.spaceId,
1386
+ /*
1387
+ * **์‹œ๋ฎฌ๋„ ๊ณ„๊ธฐ๋ฅผ ๋“ ๋‹ค** (2026-08-20).
1388
+ *
1389
+ * ์˜ˆ์ „์—๋Š” `startLive` ์—์„œ๋งŒ ๋งŒ๋“ค์—ˆ๋‹ค. ๊ทธ๋ž˜์„œ ๋„๋Š” ํŠธ์œˆ 22๊ฐœ๊ฐ€ ์‹œ๋ฎฌ์ธ ์„œ๋ฒ„์—์„œ ใ€Œ์ดˆ๋‹น ๋ช‡ ํ–‰์„
1390
+ * ์“ฐ๋‚˜ใ€ยทใ€Œ๋ฐ€๋ฆฐ ๊ฒŒ ์žˆ๋‚˜ใ€๋ฅผ ๋ฌผ์„ ์ž๋ฆฌ๊ฐ€ **์•„์˜ˆ ์—†์—ˆ๋‹ค** โ€” ์ €๋„ ์“ฐ๊ธฐ๋ฅผ ๋ฐฐ์น˜๋กœ ๊ณ ์นœ ๋’ค์—๋„ ๊ทธ ํšจ๊ณผ์™€
1391
+ * ํšŒ๊ท€๋ฅผ ์ˆซ์ž๋กœ ๋ณผ ์ˆ˜ ์—†๋‹ค. ์„ธ์ง€ ์•Š๋Š” ๊ฐœ์„ ์€ ๋‹ค์Œ ์‚ฌ๋žŒ์ด ๋˜๋Œ๋ ค๋„ ์•„๋ฌด๋„ ๋ชจ๋ฅธ๋‹ค.
1392
+ *
1393
+ * ์œ ์ž…(`ingested`)์€ ์‹œ๋ฎฌ์— ๋œป์ด ์—†๋‹ค(์›๋ณธ์—์„œ ๋ฐ›๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ์ž๊ธฐ๊ฐ€ ๋‚ธ๋‹ค) โ€” ๊ทธ ์นธ์€ 0 ์œผ๋กœ
1394
+ * ๋‚จ๊ณ , ๊ทธ๊ฒƒ์ด ์‚ฌ์‹ค์ด๋‹ค(ใ€Œ๋ฐ›์€ ๊ฒƒ์ด ์—†๋‹คใ€).
1395
+ */
1396
+ metrics: this.newMetrics(),
949
1397
  unsub: () => {}
950
1398
  }
951
1399
  this.instances[key] = inst
@@ -953,44 +1401,68 @@ export class TwinEngine {
953
1401
  this.stopNotes.delete(key)
954
1402
  delete this.recovered[key] // ์›œ์Šคํƒ€ํŠธ๋กœ ์ปค๋„์— ์˜ฎ๊ฒจ ์‹ฌ์—ˆ๋‹ค โ€” ์ด์ œ ๋ผ์ด๋ธŒ๊ฐ€ ์ง„์‹ค์ด๋‹ค.
955
1403
  /* ์›๋ณธ์ด ์„ ์–ธํ•œ ์ž๊ทน โ€” ๊ธฐ๋™์„ ๋ง‰์ง€ ์•Š๋Š”๋‹ค(์ถ”์ •๊ธฐ์™€ ๊ฐ™์€ ๊ทœ์œจ). ๋ชป ์‹ค์—ˆ์œผ๋ฉด ๊ทธ ์‚ฌ์‹ค์„ ๋งํ•œ๋‹ค. */
956
- this.installStimulus(domainId, id, inst).catch(err => console.warn(`[twin-engine] "${id}": stimulus install failed โ€” ${err?.message ?? err}`))
1404
+ this.installStimulus(domainId, id, inst).catch(err => twinWarn(`[twin-engine] "${id}": stimulus install failed โ€” ${err?.message ?? err}`))
957
1405
 
958
1406
  /* ๋ผ์ด๋ธŒ ๋ฐ”์ธ๋”ฉ(P3): data ์ฑ„๋„ ํ•„ํ„ฐ๊ฐ€ subdomain ์„ ๋ณด๋ฏ€๋กœ Domain ๊ฐ์ฒด๋ฅผ 1ํšŒ ํ•ด์„ํ•ด ๋‘”๋‹ค. */
959
1407
  getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
960
1408
 
961
- /* State ์ฑ„๋„: runtime.subscribe(snapshotโ†’deltaโ†’clock) โ†’ pubsub ๋ฐฉ์†ก(๊ตฌ๋… ๋ฆฌ์กธ๋ฒ„๊ฐ€ instanceId ํ•„ํ„ฐ). */
1409
+ /* State ์ฑ„๋„: runtime.subscribe(snapshotโ†’deltaโ†’clock) โ†’ pubsub ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…(๊ตฌ๋… ๋ฆฌ์กธ๋ฒ„๊ฐ€ instanceId ํ•„ํ„ฐ). */
962
1410
  const sub = runtime.subscribe((msg: SubscriptionMessage) => {
963
1411
  this.publishGuarded(
964
1412
  'twin-state',
965
1413
  { twinState: { instanceId: id, kind: msg.kind, revision: (msg as any).revision, payload: msg } },
966
1414
  `twin-state:${id}`
967
1415
  )
968
- /* ์˜์† + ๋ผ์ด๋ธŒ ๋ฐ”์ธ๋”ฉ ๋ธŒ๋ฆฌ์ง€: delta ๋งˆ๋‹ค ์ €๋„ ์ €์žฅ + ์—”ํ‹ฐํ‹ฐ๋ณ„ data(tag:) publish โ†’ ๋ณด๋“œ ์ปดํฌ๋„ŒํŠธ ๋ผ์ด๋ธŒ. */
1416
+ /* ์˜์† + ๋ผ์ด๋ธŒ ๋ฐ”์ธ๋”ฉ ๋ธŒ๋ฆฌ์ง€: delta ๋ฅผ ์ €๋„ ๋ฒ„ํผ์— ๋‹ด๊ณ  + ์—”ํ‹ฐํ‹ฐ๋ณ„ data(tag:) publish โ†’ ๋ณด๋“œ ์ปดํฌ๋„ŒํŠธ ๋ผ์ด๋ธŒ. */
969
1417
  if (msg.kind === 'delta') {
970
- this.persist(domainId, id, msg).catch(err => console.error('twin persist fail', err))
971
1418
  /*
972
- * โ”€โ”€ ๋ฐฉ์†ก์€ **๋ชจ์•„์„œ** ํ•œ ๋ฒˆ (2026-08-14 ์‹ค์ธก์œผ๋กœ ์žก์Œ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
973
- * ์—ฌ๊ธฐ์„œ ๊ณง๋ฐ”๋กœ ๋ฐฉ์†กํ•˜๊ณ  ์žˆ์—ˆ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ์ปค๋„์€ ํ•œ ๋ฒˆ์˜ tick ์—์„œ ์‚ฌ์‹ค์„ **์—ฌ๋Ÿฌ ๊ฐœ** ๋‚ธ๋‹ค
1419
+ * โ”€โ”€ ์“ฐ๊ธฐ๋„ **๋ชจ์•„์„œ** ํ•œ ๋ฒˆ (2026-08-20 ์‹ค์ธก์œผ๋กœ ์žก์Œ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1420
+ *
1421
+ * ์—ฌ๊ธฐ์„œ ๋ธํƒ€๋งˆ๋‹ค `persist()` ๋ฅผ ๋ถˆ๋ €๋‹ค โ€” ํ–‰ ํ•˜๋‚˜๋‹น `save()` ํ•˜๋‚˜, ๊ทธ๋ฆฌ๊ณ  ๊ทธ ์•ˆ์—์„œ
1422
+ * `structureRevOf` ๋ฅผ await(๊ตฌ์กฐ ํ–‰์ด ์—†๋Š” ํŠธ์œˆ์€ ์บ์‹œ ๋ฏธ์Šค๊ฐ€ ๊ธฐ์–ต๋˜์ง€ ์•Š์•„ **๋ธํƒ€๋งˆ๋‹ค
1423
+ * SELECT** ์˜€๋‹ค). ๋ฐ๋ชจ ์ž๊ทน์„ ์ผ  ํŠธ์œˆ 23๊ฐœ์—์„œ ๊ทธ ๊ฒฐ๊ณผ๊ฐ€ ์ด๋žฌ๋‹ค: ํ”„๋กœ์„ธ์Šค CPU 81~112%,
1424
+ * ์•„๋ฌด ์ผ๋„ ํ•˜์ง€ ์•Š๋Š” ์งˆ์˜๊ฐ€ 8~14์ดˆ. ํ”„๋กœํŒŒ์ผ์€ `JSON.parse`ยท๋Œ€ํ˜• GCยท์Šค๋ ˆ๋“œํ’€์„ ๊ฐ€๋ฆฌํ‚ค๊ณ 
1425
+ * ๊ณ„๊ธฐ๋Š” ํ‹ฑ์˜ ๋ชซ์ด 16% ๋ผ๊ณ  ๋งํ–ˆ๋‹ค โ€” **ํ‹ฑ์ด ์•„๋‹ˆ๋ผ ์“ฐ๊ธฐ์˜€๋‹ค.**
1426
+ *
1427
+ * ๋ฐ”๋กœ ์œ„ ์ฃผ์„์ด **๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…**์—์„œ ๊ฐ™์€ ํ•จ์ •์„ ์ ์–ด ๋‘์—ˆ๋‹ค(ํ‹ฑ ํ•˜๋‚˜๊ฐ€ 35์ดˆ). ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์€ ๋ชจ์œผ๋„๋ก
1428
+ * ๊ณ ์ณค๊ณ  ์“ฐ๊ธฐ๋Š” ๋‹จ๊ฑด์œผ๋กœ ๋‚จ์•„ ์žˆ์—ˆ๋‹ค. ๊ฐ™์€ ๊ทœ์œจ๋กœ ๋“ค์ธ๋‹ค โ€” ADR-0030 ์ด *"๊ธฐ๋ก ๊ฒฝ๋กœ๊ฐ€ ๋‘˜์ธ
1429
+ * ๊ฒƒ์€ ์‹œ๋ฎฌ๋งŒ ์˜†๋ฌธ์œผ๋กœ ๋“ค์–ด์˜ค๊ธฐ ๋•Œ๋ฌธ"* ์ด๋ผ ์˜ˆ๊ณ ํ•œ ๊ทธ ์ž๋ฆฌ๋‹ค.
1430
+ *
1431
+ * **๋ฆฌ๋น„์ „์€ ์ปค๋„์˜ ๊ฒƒ์„ ๊ทธ๋Œ€๋กœ ๋“ ๋‹ค**(๋ผ์ด๋ธŒ๋Š” flush ๋•Œ ํ˜ธ์ŠคํŠธ๊ฐ€ ๋ถ€์—ฌํ•œ๋‹ค). ์‹œ๋ฎฌ์˜ ์ €๋„์€
1432
+ * ์ปค๋„ ๋ฆฌ๋น„์ „์œผ๋กœ ์ ‘ํžˆ๋ฏ€๋กœ ์—ฌ๊ธฐ์„œ ๋‹ค์‹œ ๋ฒˆํ˜ธ๋ฅผ ๋งค๊ธฐ๋ฉด ์‹œ๊ฐ„์—ฌํ–‰์ด ์–ด๊ธ‹๋‚œ๋‹ค.
1433
+ */
1434
+ ;(inst.pendingJournal ?? (inst.pendingJournal = [])).push({ event: msg.event, revision: (msg as any).revision })
1435
+ /*
1436
+ * โ”€โ”€ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์€ **๋ชจ์•„์„œ** ํ•œ ๋ฒˆ (2026-08-14 ์‹ค์ธก์œผ๋กœ ์žก์Œ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1437
+ * ์—ฌ๊ธฐ์„œ ๊ณง๋ฐ”๋กœ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…ํ•˜๊ณ  ์žˆ์—ˆ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ์ปค๋„์€ ํ•œ ๋ฒˆ์˜ tick ์—์„œ ์‚ฌ์‹ค์„ **์—ฌ๋Ÿฌ ๊ฐœ** ๋‚ธ๋‹ค
974
1438
  * (์˜ˆ์•ฝยท๋ฐฐ์น˜ยท์™„๋ฃŒโ€ฆ). ๊ทธ๋ž˜์„œ tick ํ•˜๋‚˜๊ฐ€ ์ „ ์ƒํƒœ ํˆฌ์˜์„ ์ˆ˜๋ฐฑ ๋ฒˆ ๋ฐ˜๋ณตํ–ˆ๋‹ค โ€” `order-check`
975
1439
  * ํŠธ์œˆ์—์„œ **ํ‹ฑ ํ•˜๋‚˜๊ฐ€ 35์ดˆ**๋ฅผ ๋จน๊ณ (๋‹จ๊ณ„ ํ•ฉ์€ 32ms ์˜€๋‹ค: ์‹œ๊ฐ„์€ ๋ฐ˜๋ณต ํšŸ์ˆ˜์— ์žˆ์—ˆ๋‹ค) ๊ทธ
976
1440
  * 35์ดˆ ๋™์•ˆ ์ด๋ฒคํŠธ ๋ฃจํ”„๊ฐ€ ๋ง‰ํ˜€ ๊ตฌ๋…์ž๊ฐ€ ์•„๋ฌด๊ฒƒ๋„ ๋นผ๋‚ด์ง€ ๋ชปํ–ˆ๋‹ค. ๋ฐ€๋ฆฐ push ๊ฐ€ 1024๋ฅผ ๋„˜๋Š”
977
- * ์ˆœ๊ฐ„ pubsub ์ด ๋˜์ง€๊ณ , ๊ทธ ์˜ˆ์™ธ๊ฐ€ ํƒ€์ด๋จธ ์ฝœ๋ฐฑ์„ ํƒ€๊ณ  ์˜ฌ๋ผ์™€ **ํ˜ธ์ŠคํŠธ๊ฐ€ ์ฃฝ์—ˆ๋‹ค.**
1441
+ * ์ˆœ๊ฐ„ pubsub ์ด ์˜ค๋ฅ˜๋ฅผ ๋‚ด๊ณ , ๊ทธ ์˜ˆ์™ธ๊ฐ€ ํƒ€์ด๋จธ ์ฝœ๋ฐฑ์„ ํƒ€๊ณ  ์˜ฌ๋ผ์™€ **ํ˜ธ์ŠคํŠธ๊ฐ€ ์ฃฝ์—ˆ๋‹ค.**
978
1442
  *
979
1443
  * ๋ผ์ด๋ธŒ๋Š” ์ด๋ฏธ dirty ํ‘œ์‹œ + ์ฃผ๊ธฐ flush ๋กœ ์ด ๋ฌธ์ œ๋ฅผ ํ’€์–ด ๋‘์—ˆ๋‹ค(BROADCAST_COALESCE_MS).
980
1444
  * ์‹œ๋ฎฌ๋งŒ ๊ทธ ๊ทœ์œจ ๋ฐ–์— ์žˆ์—ˆ๋‹ค โ€” ๊ฐ™์€ ๊ทœ์œจ๋กœ ๋“ค์ธ๋‹ค(์ตœ์‹ -์ƒํƒœ ์ฑ„๋„์ด๋ผ ์ค‘๊ฐ„ ์ƒํƒœ๋ฅผ ๋ชจ๋‘
981
1445
  * ๋ณด๋‚ผ ์ด์œ ๊ฐ€ ์—†๋‹ค: 200ms ๋งˆ๋‹ค ๋งˆ์ง€๋ง‰ ๊ฒƒ ํ•˜๋‚˜๋ฉด ํ™”๋ฉด์€ ๊ฐ™๋‹ค).
982
1446
  */
983
1447
  inst.dirty = true
1448
+ /* ์ด ์‚ฌ์‹ค์ด ๊ฑด๋“œ๋ฆฐ ๋ฌผํ’ˆ๋งŒ ๋‹ค์Œ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์—์„œ ๋งŒ๋“ ๋‹ค(๋ชจ๋ฅด๋ฉด ์ „๋ถ€). */
1449
+ this.markItemsDirty(inst, [msg.event])
984
1450
  this.ensureBroadcastCoalescer()
985
1451
  }
986
1452
  })
987
1453
  inst.unsub = () => sub.unsubscribe()
988
1454
 
1455
+ /* ์œ„์—์„œ ์žฐ ์›œ์Šคํƒ€ํŠธ ์‹œ๊ฐ„์„ ๊ณ„๊ธฐ์— ์ ๋Š”๋‹ค โ€” ๊ณ„๊ธฐ๋Š” ์ธ์Šคํ„ด์Šค๊ฐ€ ์ƒ๊ธด ๋’ค์—์•ผ ์žˆ๋‹ค. */
1456
+ recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'warmStart', warmMs)
1457
+
989
1458
  /* ๋ณต๊ตฌ ์•ต์ปค: ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ์— model/kind/status/restartPolicy ์˜์†(์žฌ๋ถ€ํŒ… ์‹œ ์ด๊ฒŒ ์žˆ์–ด์•ผ replayยท์„ ์–ธ ๊ฑฐ๋™ ๊ฐ€๋Šฅ). */
990
- this.register(domainId, id, kind, model, 'running', inst.restartPolicy).catch(err => console.error('twin register fail', err))
1459
+ this.register(domainId, id, kind, model, 'running', inst.restartPolicy).catch(err => twinError('twin register fail', err))
991
1460
 
992
1461
  /* ์›Œ์ปค tick โ€” ์Šค์ผˆ๋ ˆํ†ค์€ setInterval(๋ฉ”์ธ ๋ฃจํ”„). ๊ธด ์‹œ๋ฎฌ ์˜คํ”„-๋ฃจํ”„(worker thread)๋Š” ์Šค์ผ€์ผ ํ•˜๋“œ๋‹(ํ–ฅํ›„, ยงhost ๊ฒฝ๊ณ„). */
993
- inst.timer = setInterval(() => this.tickGuarded(domainId, id, runtime), this.TICK_MS)
1462
+ inst.timer = this.startTickTimer(
1463
+ () => this.tickGuarded(domainId, id, runtime),
1464
+ t => (inst.timer = t)
1465
+ )
994
1466
  return inst
995
1467
  }
996
1468
 
@@ -1054,7 +1526,7 @@ export class TwinEngine {
1054
1526
  const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } }).catch(() => null)
1055
1527
  const offset = utcOffsetOf(space?.timezone)
1056
1528
  if (offset === undefined) {
1057
- if (space?.timezone) console.warn(`[twin-engine] space "${sid}" declares time zone "${space.timezone}" but it is not a known IANA zone โ€” times will be read as UTC.`)
1529
+ 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.`)
1058
1530
  return model
1059
1531
  }
1060
1532
  return { ...model, utcOffsetMinutes: offset }
@@ -1068,7 +1540,7 @@ export class TwinEngine {
1068
1540
  kernel.loadTwinModel(model)
1069
1541
  /* **์„ธ์šฐ๋Š” ์ชฝ์ด ์•„๋Š” ์‚ฌ์‹ค์€ ์„ธ์šธ ๋•Œ ๋งํ•œ๋‹ค.** ์˜ˆ์ „์—๋Š” ์ฒซ ์ด๋ฒคํŠธ๊ฐ€ ๋„์ฐฉํ•ด์•ผ ์ปค๋„์ด ์Šค์Šค๋กœ๋ฅผ
1070
1542
  ๊ด€์ธก ๊ตฌ๋™์œผ๋กœ ์—ฌ๊ฒผ๊ณ , ๊ทธ๋ž˜์„œ ์•„์ง ์•„๋ฌด๊ฒƒ๋„ ๋ชป ๋ฐ›์€ ๋ฏธ๋Ÿฌ๋Š” ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์ทจ๊ธ‰์„ ๋ฐ›์•˜๋‹ค. */
1071
- kernel.observe?.()
1543
+ declareObserved(kernel, `live twin '${id}'`)
1072
1544
  this.applyOperations(kernel, model, id) // ๋ช…์„ธ๋Š” ๋ผ์ด๋ธŒ์—๋„ ์‹ค๋ฆฐ๋‹ค(์˜ˆ์ธก ์ž๊ฒฉ์ด sim ๊ณผ ๊ฐ™์•„์ง„๋‹ค)
1073
1545
  /* `projector` ํ•„๋“œ๋Š” ์˜› ์ด๋ฆ„์œผ๋กœ ๋‚จ๊ธด๋‹ค โ€” ์†Œ๋น„์ฒ˜๊ฐ€ `snapshot()` ์„ ๋ถ€๋ฅด๋ฏ€๋กœ ์–‡์€ ์–ด๋Œ‘ํ„ฐ๋กœ ์ž‡๋Š”๋‹ค.
1074
1546
  * (P3 ์—์„œ ์†Œ๋น„์ฒ˜๋ฅผ ์ปค๋„ ์–ดํœ˜๋กœ ๋ฐ”๊พธ๋ฉด ์‚ฌ๋ผ์ง„๋‹ค.) */
@@ -1093,16 +1565,17 @@ export class TwinEngine {
1093
1565
  inst.unsub = kernel.onEvent?.((e: CanonicalEnvelope) => {
1094
1566
  if (e && typeof e === 'object' && applying.has(e as object)) return // ์ธ์ž…์˜ ์žฌ๋ฐฉ์ถœ โ€” ์ €๋„์€ ์ธ์ž…์—์„œ ํ•œ ๋ฒˆ๋งŒ
1095
1567
  ;(inst.pendingJournal ?? (inst.pendingJournal = [])).push(e)
1096
- /* ์ปค๋„์ด ๋‚ธ ์‚ฌ์‹ค๋„ ์ƒํƒœ๋ฅผ ๋ฐ”๊พผ๋‹ค(ํ”ผํฌยท๋งˆ๊ฐ) โ€” ๋‹ค์Œ ๋ฐฉ์†ก ์ฃผ๊ธฐ์— ์‹ค๋ฆฐ๋‹ค. */
1568
+ /* ์ปค๋„์ด ๋‚ธ ์‚ฌ์‹ค๋„ ์ƒํƒœ๋ฅผ ๋ฐ”๊พผ๋‹ค(ํ”ผํฌยท๋งˆ๊ฐ) โ€” ๋‹ค์Œ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ์ฃผ๊ธฐ์— ์‹ค๋ฆฐ๋‹ค. */
1097
1569
  inst.dirty = true
1570
+ this.markItemsDirty(inst, [e])
1098
1571
  this.ensureBroadcastCoalescer()
1099
1572
  }) ?? (() => {})
1100
1573
  /* ์ถ”์ •๊ธฐ(์‹ค์ธกยท๊ฑฐ๋ฆฌ)๋„ ๋ผ์ด๋ธŒ์— ๋ถ™์ธ๋‹ค โ€” ์˜ˆ์ธก์ด ์ƒ์ˆ˜๋กœ ๊ณ„์‚ฐ๋˜์ง€ ์•Š๊ฒŒ. ๊ธฐ๋™์„ ๋ง‰์ง€ ์•Š๋Š”๋‹ค. */
1101
- this.installEstimators(kernel, domainId, id, model).catch(err => console.warn('[twin-engine] estimator install failed', err?.message))
1102
- inst.metrics = { ingestedTotal: 0, broadcastTotal: 0, journaledTotal: 0, ingestRate: 0, broadcastRate: 0, journalRate: 0, backlog: 0, _accIngest: 0, _accBroadcast: 0, _accJournal: 0, _windowStartMs: Date.now() }
1574
+ this.installEstimators(kernel, domainId, id, model).catch(err => twinWarn('[twin-engine] estimator install failed', err?.message))
1575
+ inst.metrics = this.newMetrics()
1103
1576
  this.instances[key] = inst
1104
1577
  /* ๋ฏธ๋Ÿฌ์—๋„ ๋ถ€๋ฅธ๋‹ค โ€” ์„ ์–ธ์ด ์žˆ์œผ๋ฉด ใ€Œ๋ฏธ๋Ÿฌ์—๋Š” ์‹ฃ์ง€ ์•Š๋Š”๋‹คใ€๊ณ  ๋งํ•ด์•ผ ํ•œ๋‹ค(์กฐ์šฉํ•œ ๋ฌด์‹œ ๊ธˆ์ง€). */
1105
- this.installStimulus(domainId, id, inst).catch(err => console.warn(`[twin-engine] "${id}": stimulus check failed โ€” ${err?.message ?? err}`))
1578
+ this.installStimulus(domainId, id, inst).catch(err => twinWarn(`[twin-engine] "${id}": stimulus check failed โ€” ${err?.message ?? err}`))
1106
1579
  /*
1107
1580
  * **์›์ฒœ์ด ๋˜ํ’€์–ด ์ฃผ์ง€ ์•Š๋Š” ๊ฒƒ๋งŒ ์ž‡๋Š”๋‹ค** (2026-08-18 ์‹ค์ธก์œผ๋กœ ๋ถ™์ž„).
1108
1581
  *
@@ -1114,27 +1587,104 @@ export class TwinEngine {
1114
1587
  * ๊ด€์ธก ์ถ•(์žฌ๊ณ ยท์œ„์น˜ยท์„ค๋น„)์€ **์—ฌ์ „ํžˆ ์‹ฌ์ง€ ์•Š๋Š”๋‹ค** โ€” ๋‹ค์Œ ๊ณ„์ธก์ด ์ •์ •ํ•˜๊ณ , ์‹ฌ์œผ๋ฉด ๋– ๋‚œ ๋ฌผ๊ฑด์ด
1115
1588
  * ๋˜์‚ด์•„๋‚œ๋‹ค. ๋ฌด์—‡์„ ๋„˜๊ธธ์ง€๋Š” `planLiveContinuity` ๊ฐ€ ๊ณ ๋ฅด๊ณ , ์–ด๋–ป๊ฒŒ ํก์ˆ˜ํ• ์ง€๋Š” ์ปค๋„์ด ์ •ํ•œ๋‹ค.
1116
1589
  */
1590
+ /*
1591
+ * โ”€โ”€ **์žฌ๊ฐœ์ ์—์„œ ๋ฏธ๋Ÿฌ๋ฅผ ๋˜์„ธ์šด๋‹ค** (2026-08-24) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1592
+ *
1593
+ * ์ด ์ž๋ฆฌ์—์„œ ๋ฏธ๋Ÿฌ๋Š” ์˜ค๋žซ๋™์•ˆ ๋˜์ฐพ์€ ์ƒํƒœ๋ฅผ **๋ฒ„๋ ธ๋‹ค**. ์ „์ œ๋Š” ใ€Œ์ง„์‹ค์€ ์›์ฒœ์— ์žˆ์œผ๋‹ˆ ๋‹ค์Œ ๊ณ„์ธก์ด
1594
+ * ์ •์ •ํ•œ๋‹คใ€์˜€๊ณ  ๋ผ์ด๋ธŒ ํ”ผ๋“œ์—์„œ๋Š” ์˜ณ์•˜๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ์›๋ณธ์ด **์ปค์„œ ์ฆ๋ถ„**์œผ๋กœ ๋งํ•˜๋Š” ํ˜„์žฅ์—์„œ๋Š” ๊ทธ
1595
+ * ์ •์ •์ด ์˜ค์ง€ ์•Š๋Š”๋‹ค: ์ปค์„œ๊ฐ€ ๋”ฐ๋ผ์žกํžŒ ๋’ค ์›๋ณธ์ด ๋ณ€ํ•˜์ง€ ์•Š์œผ๋ฉด ๋ฏธ๋Ÿฌ๋Š” ์˜๊ตฌํžˆ ๋นˆ ์ฑ„๋กœ ๋‚จ๋Š”๋‹ค.
1596
+ * ๊ทธ๋ฆฌ๊ณ  ๊ทธ ๋นˆ ์ฑ„๋กœ ํ™”๋ฉด์ด ใ€Œ์ด์ƒ ์—†์Œใ€์„ ๋ณด์˜€๋‹ค โ€” ์‚ฌ์‹ค์ด ์‚ฌ๋ผ์ง€๋Š” ๋™์•ˆ ํ™”๋ฉด์ด ์•ˆ์‹ฌ์‹œํ‚จ ๊ฒƒ์ด๋‹ค.
1597
+ *
1598
+ * ๋˜๋Œ๋ฆฌ๋Š” ๊ฒƒ์€ **์ƒํƒœ๊ฐ€ ์•„๋‹ˆ๋ผ ์žฌ๊ฐœ์ **์ด๋‹ค. ์ƒํƒœ๋งŒ ์‹ฌ์œผ๋ฉด ๊ทธ ๋’ค๋ฅผ ์ด์–ด ์ ‘์€ ๊ฒฐ๊ณผ๊ฐ€ 0๋ถ€ํ„ฐ ์ ‘์€
1599
+ * ๊ฒฐ๊ณผ์™€ ์กฐ์šฉํžˆ ๋‹ฌ๋ผ์ง„๋‹ค(๋ฆฌ๋“€์„œ๋Š” ๋ณด๋ฅ˜๋œ ๋‹ด๊น€ยท์ง‘๊ณ„ ์ค‘์ธ ์ˆ˜๋Ÿ‰๋„ ๋“ ๋‹ค). ๊ทธ ๋™์น˜๋Š” ์ปค๋„ ์‹œํ—˜์ด
1600
+ * ์ฆ๋ช…ํ•œ๋‹ค(`observed-checkpoint.test.ts` โ€” ์žฌ๊ฐœ์  + ๊ผฌ๋ฆฌ == 0๋ถ€ํ„ฐ ์ ‘๊ธฐ).
1601
+ *
1602
+ * ์”จ์•—์€ **๊ทธ ๊ณต์žฅ์ด ์•„์ง ๊ทธ ๊ณต์žฅ์ผ ๋•Œ๋งŒ** ์˜ค๊ณ , ๋งˆ์ง€๋ง‰ ์ฒดํฌํฌ์ธํŠธ ์ดํ›„์˜ ์‚ฌ์‹ค์€ ์ด๋ฏธ ์ ‘ํ˜€ ๋“ค์–ด
1603
+ * ์žˆ๋‹ค(`warmSeedFor`). ์”จ์•—์ด ์—†์œผ๋ฉด ์ „๊ณผ ๊ฐ™์ด ๋นˆ ์ฑ„๋กœ ์‹œ์ž‘ํ•œ๋‹ค โ€” ์ง€์–ด๋‚ด์ง€ ์•Š๋Š”๋‹ค.
1604
+ */
1605
+ const seed = this.recovered[key]?.fold?.reducer
1606
+ if (seed) {
1607
+ if (typeof kernel.restoreObserved !== 'function') {
1608
+ twinWarn(
1609
+ `[twin-engine] "${id}": a fold seed is stored but this kernel cannot take it (no restoreObserved) โ€” ` +
1610
+ 'the mirror starts empty and waits for the source to re-tell everything. Upgrade the kernel.'
1611
+ )
1612
+ } else {
1613
+ try {
1614
+ kernel.restoreObserved(seed)
1615
+ const st = kernel.getSnapshot?.()
1616
+ twinLog(
1617
+ `[twin-engine] mirror "${id}" resumed from the stored fold seed at revision ${this.recovered[key]?.revision} โ€” ` +
1618
+ `items ${st?.items?.length ?? 0} ยท orders ${st?.orders?.length ?? 0} ยท tasks ${st?.tasks?.length ?? 0} ` +
1619
+ '(the journal is not folded from the beginning).'
1620
+ )
1621
+ if (this.recovered[key]?.fold?.oee) inst.oee?.restore(this.recovered[key].fold.oee)
1622
+ } catch (err: any) {
1623
+ /* ๋˜์„ธ์šฐ๊ธฐ๊ฐ€ ์‹คํŒจํ•ด๋„ ๋ฏธ๋Ÿฌ๋Š” ๋Œ์•„์•ผ ํ•œ๋‹ค โ€” ๋‹ค๋งŒ ๋ฌด์—‡์„ ์žƒ์—ˆ๋Š”์ง€ ๋งํ•œ๋‹ค. */
1624
+ twinWarn(`[twin-engine] "${id}": could not resume from the stored fold seed โ€” starting empty: ${err?.message ?? err}`)
1625
+ }
1626
+ }
1627
+ }
1117
1628
  this.seedLiveContinuity(domainId, id, kernel)
1118
1629
  delete this.recovered[key]
1119
1630
  /* ๋ผ์ด๋ธŒ ๋ฐ”์ธ๋”ฉ(data ์ฑ„๋„) subdomain ํ•„ํ„ฐ์šฉ Domain 1ํšŒ ํ•ด์„(sim ๊ณผ ๋™์ผ). */
1120
1631
  getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
1121
- /* ์ €๋„ revision ์นด์šดํ„ฐ ์‹œ๋“œ โ€” ๊ธฐ์กด ์ €๋„ ์ตœ๋Œ€์น˜์—์„œ ์ด์–ด๋ถ™์ž„(์žฌ๊ธฐ๋™ ์‹œ revision ์ถฉ๋Œ ๋ฐฉ์ง€). ์ดํ›„ ์ธ๋ฉ”๋ชจ๋ฆฌ ์ฆ๊ฐ€. */
1632
+ /*
1633
+ * ์ €๋„ revision ์นด์šดํ„ฐ ์‹œ๋“œ โ€” ๊ธฐ์กด ์ €๋„ ์ตœ๋Œ€์น˜์—์„œ ์ด์–ด๋ถ™์ž„(์žฌ๊ธฐ๋™ ์‹œ revision ์ถฉ๋Œ ๋ฐฉ์ง€).
1634
+ *
1635
+ * โ”€โ”€ **์ปค๋„์—๋„ ๊ฐ™์€ ๋ฒˆํ˜ธ๋ฅผ ์•Œ๋ ค ์ค€๋‹ค** (2026-08-23 ์‹ค์ธก) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1636
+ * ์˜ˆ์ „์—๋Š” ์ด ์ž๋ฆฌ์—์„œ `inst.revision`(์ €๋„ ์ค„ ๋ฒˆํ˜ธ)๋งŒ ์ด์–ด๋ฐ›๊ณ  **์ปค๋„์€ 0 ๋ถ€ํ„ฐ ์„ธ๊ฒŒ ๋‘์—ˆ๋‹ค.**
1637
+ * ๊ทธ๋ž˜์„œ ์ฒดํฌํฌ์ธํŠธ๊ฐ€ ๋œป์ด ๋‹ค๋ฅธ ๋‘ ์ˆ˜๋ฅผ ๋‚˜๋ž€ํžˆ ์ ์—ˆ๋‹ค.
1638
+ *
1639
+ * ๋ฐ”๊นฅ revision 12,972 ์ €๋„์— ์ ํžŒ ์ค„ ๋ฒˆํ˜ธ (์ด ์ž๋ฆฌ์—์„œ ์ด์–ด๋ฐ›๋Š”๋‹ค)
1640
+ * state.revision 6,478 ์ปค๋„์ด ์ฒ˜๋ฆฌํ•œ ๋ด‰ํˆฌ ์ˆ˜ (0 ๋ถ€ํ„ฐ ์…Œ๋‹ค)
1641
+ *
1642
+ * ํฌ์ฒœ ๋ฏธ๋Ÿฌ์—์„œ ํ™•์ •ํ–ˆ๋‹ค: ์ฐจ์ด 6,494 ๋Š” ๊ทธ ํŠธ์œˆ์„ **๋‹ค์‹œ ์„ธ์šด ์‹œ๊ฐ**(09:11)์— ์ €๋„์ด ์ด๋ฏธ ๊ฐ–๊ณ 
1643
+ * ์žˆ๋˜ ์ค„ ์ˆ˜์™€ ์ •ํ™•ํžˆ ๊ฐ™์•˜๋‹ค(์ค„ 6494 = 08:23:59, ์ค„ 6495 = 09:11:13). ์‚ฐ์ˆ˜๊ฐ€ ๋งž์•˜๋‹ค.
1644
+ *
1645
+ * ๊ฒฐํ•จ์€ ๋‘ ์ˆ˜๊ฐ€ ๋‹ค๋ฅด๋‹ค๋Š” ๊ฒƒ ์ž์ฒด๊ฐ€ ์•„๋‹ˆ๋ผ **๋น„๋Œ€์นญ**์ด๋‹ค: ์žฌ๊ธฐ๋™ ๊ฒฝ๋กœ(`start` โ†’ `resumeRevision`)๋Š”
1646
+ * ์ปค๋„์— ๋ฒˆํ˜ธ๋ฅผ ์•Œ๋ ค ์ฃผ๋Š”๋ฐ **์ด ๊ฒฝ๋กœ(๋ฏธ๋Ÿฌ)๋Š” ์•Œ๋ ค ์ฃผ์ง€ ์•Š์•˜๋‹ค.** ๊ทธ๋ž˜์„œ ๊ฐ™์€ ์ €์žฅ๋ฌผ์„ ์ฝ๋Š” ์†Œ๋น„์ฒ˜๊ฐ€
1647
+ * ๊ฐ™์€ ์ด๋ฆ„์˜ ๋‘ ์ˆ˜๋ฅผ ๊ฐ™์€ ์ถ•์œผ๋กœ ๊ฒฌ์ฃผ๊ฒŒ ๋˜๊ณ , ์‹ค์ œ๋กœ ๊ทธ๋ ‡๊ฒŒ ์ฝํ˜”๋‹ค.
1648
+ *
1649
+ * ์ €๋„์ด ๋น„์–ด ์žˆ์œผ๋ฉด(0) ์•„๋ฌด ์ผ๋„ ํ•˜์ง€ ์•Š๋Š”๋‹ค โ€” ์ƒˆ ํŠธ์œˆ์€ 0 ๋ถ€ํ„ฐ ์„ธ๋Š” ๊ฒƒ์ด ๋งž๋‹ค. ๊ทธ๋ฆฌ๊ณ  ์ด ์กฐํšŒ๋Š”
1650
+ * ๋น„๋™๊ธฐ๋ผ ๊ทธ ์‚ฌ์ด์— ์ด๋ฒคํŠธ๊ฐ€ ๋ช‡ ๊ฑด ๋“ค์–ด์™€ ์žˆ์„ ์ˆ˜ ์žˆ๋Š”๋ฐ, ๊ทธ๋•Œ ๋’ค๋กœ ๋˜๋Œ๋ฆฌ๋Š” ๊ฒƒ์€ ์ปค๋„์ด ๊ฑฐ์ ˆํ•œ๋‹ค
1651
+ * (๊ฒน์น˜๋Š” ๋ฒˆํ˜ธ๋ฅผ ๋ง‰๋Š” ๊ทธ ํŒ์ •์ด๋‹ค). ๊ฑฐ์ ˆ์€ ์‚ผํ‚ค์ง€ ์•Š๊ณ  ๋‚จ๊ธด๋‹ค.
1652
+ */
1122
1653
  getRepository(TwinEvent)
1123
1654
  .findOne({ where: { domain: { id: domainId }, instanceId: id }, order: { revision: 'DESC' } })
1124
- .then(top => (inst.revision = top?.revision ?? 0))
1655
+ .then(top => {
1656
+ const head = top?.revision ?? 0
1657
+ inst.revision = head
1658
+ if (!head || typeof (kernel as any).resumeRevision !== 'function') return
1659
+ try {
1660
+ ;(kernel as any).resumeRevision(head)
1661
+ } catch (err: any) {
1662
+ /* ๋’ค๋กœ ๊ฐˆ ์ˆ˜ ์—†๋‹ค๋Š” ๊ฑฐ์ ˆ โ€” ์ด ์กฐํšŒ๊ฐ€ ๋„์ฐฉํ•˜๊ธฐ ์ „์— ์ด๋ฏธ ๊ทธ๋งŒํผ ์ฒ˜๋ฆฌํ–ˆ๋‹ค๋Š” ๋œป์ด๋‹ค. */
1663
+ twinWarn(`[twin-engine] "${id}": journal head ${head} is behind the kernel โ€” ${err?.message ?? err}`)
1664
+ }
1665
+ })
1125
1666
  .catch(() => (inst.revision = 0))
1126
- this.register(domainId, id, kind, model, 'running', 'resync').catch(err => console.error('twin register fail', err))
1667
+ this.register(domainId, id, kind, model, 'running', 'resync').catch(err => twinError('twin register fail', err))
1127
1668
  return inst
1128
1669
  }
1129
1670
 
1130
1671
  /**
1131
- * live ์ด๋ฒคํŠธ ์ธ์ œ์ŠคํŠธ โ€” projector ๊ตฌ๋™ + data(tag) ๋ฐฉ์†ก(ํ๋ฃจํ”„์˜ ์ธ๋ฐ”์šด๋“œ ๋„์ฐฉ ์ง€์ , command-routing ยง8.2).
1672
+ * live ์ด๋ฒคํŠธ ์ธ์ œ์ŠคํŠธ โ€” projector ๊ตฌ๋™ + data(tag) ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…(ํ๋ฃจํ”„์˜ ์ธ๋ฐ”์šด๋“œ ๋„์ฐฉ ์ง€์ , command-routing ยง8.2).
1132
1673
  * reference ์–ด๋Œ‘ํ„ฐ๊ฐ€ ๋‚ธ records โ†’ ์ปค๋„ face2-adapter.ingest โ†’ CanonicalEnvelope ๋ฅผ ์—ฌ๊ธฐ๋กœ ๋ฐ€์–ด๋„ฃ๋Š”๋‹ค.
1133
1674
  * (State ์ฑ„๋„ ๋ธํƒ€/์ €๋„ ๊ฒฐ์„ ์€ ํ›„์† โ€” ์Šค์ผˆ๋ ˆํ†ค์€ data(tag) ๋ฏธ๋Ÿฌ ์ค‘์‹ฌ.)
1675
+ *
1676
+ * โ”€โ”€ ๋„ฃ์€ ์ˆ˜๋ฅผ **๋‹ตํ•œ๋‹ค** (2026-08-20) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1677
+ * ํŠธ์œˆ์ด ๋ผ์ด๋ธŒ๋กœ ๋Œ์ง€ ์•Š์œผ๋ฉด ์—ฌ๊ธฐ์„œ ๋ด‰ํˆฌ๋ฅผ ๋ฒ„๋ฆฐ๋‹ค. ๊ทธ๊ฒƒ ์ž์ฒด๋Š” ๋งž๋‹ค(๋„ฃ์„ ์ปค๋„์ด ์—†๋‹ค). ๋ฌธ์ œ๋Š”
1678
+ * **์กฐ์šฉํžˆ** ๋ฒ„๋ฆฐ ๊ฒƒ์ด์—ˆ๋‹ค: ํŠธ์œˆ์ด ๋ฉˆ์ถ˜ ๋’ค์—๋„ ํ”ผ๋“œ๋Š” ๋‚จ์•„ ๋ ˆ์ฝ”๋“œ๋ฅผ ๋‚˜๋ฅด๊ณ , ์œ ์ž… ์žฅ๋ถ€๋Š” ๊ทธ๊ฒƒ์„
1679
+ * ใ€Œํ†ต๊ณผใ€๋กœ ์…Œ๋‹ค. ํ™”๋ฉด์€ ๋ฉˆ์ถ˜ ํŠธ์œˆ ์˜†์— ใ€Œ150 ํ†ต๊ณผ ยท 100%ใ€๋ผ๊ณ  ์ ์—ˆ๋‹ค โ€” ์‚ฌ์‹ค์ด ์‚ฌ๋ผ์ง€๋Š” ๋™์•ˆ
1680
+ * ํ™”๋ฉด์ด ์•ˆ์‹ฌ์‹œํ‚จ ๊ฒƒ์ด๋‹ค.
1681
+ *
1682
+ * ๊ทธ๋ž˜์„œ **๋„ฃ์€ ์ˆ˜๋ฅผ ๋Œ๋ ค์ค€๋‹ค.** ๋ถ€๋ฅด๋Š” ์ชฝ์ด ์ œ์‹œ ์ˆ˜์™€ ๊ฒฌ์ฃผ์–ด ๋ฒ„๋ ค์ง„ ์ˆ˜๋ฅผ ์žฅ๋ถ€์— ์ ๋Š”๋‹ค. ๋ฐ˜ํ™˜์„
1683
+ * ๋ฌด์‹œํ•˜๋Š” ํ˜ธ์ถœ๋ถ€๋Š” ๊ทธ๋Œ€๋กœ ๋™์ž‘ํ•œ๋‹ค(์ „๊ณผ ๊ฐ™๋‹ค).
1134
1684
  */
1135
- static ingestLive(domainId: string, id: string, envelopes: CanonicalEnvelope[]): void {
1685
+ static ingestLive(domainId: string, id: string, envelopes: CanonicalEnvelope[]): number {
1136
1686
  const inst = this.instances[runtimeKey(domainId, id)]
1137
- if (inst?.mode !== 'live' || !inst.projector) return
1687
+ if (inst?.mode !== 'live' || !inst.projector) return 0
1138
1688
  const tIngest = performance.now()
1139
1689
  /* ์ธ์ž… ๋ด‰ํˆฌ๋ฅผ ํ‘œ์‹œํ•ด ๋‘๊ณ  ๋„ฃ๋Š”๋‹ค โ€” ์ปค๋„์ด ๊ทธ๊ฒƒ์„ ์žฌ๋ฐฉ์ถœํ•ด๋„ ์ €๋„์— ๋‘ ๋ฒˆ ์ ํžˆ์ง€ ์•Š๊ฒŒ(์œ„ ๊ตฌ๋… ์ฃผ์„). */
1140
1690
  for (const e of envelopes) {
@@ -1144,51 +1694,151 @@ export class TwinEngine {
1144
1694
  }
1145
1695
  recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'ingest', performance.now() - tIngest)
1146
1696
  // ์ €๋„ ๊ฒฐ์„ (๋ผ์ด๋ธŒ๋„ sim ์ฒ˜๋Ÿผ ์ด๋ฒคํŠธ ์˜์†) โ€” ๋‹จ ์ด๋ฒคํŠธ๋งˆ๋‹ค DB write ํ•˜๋ฉด ๋ถ€ํ•˜ํญ๋ฐœ์ด๋ผ ๋ชจ์•„๋’€๋‹ค๊ฐ€
1147
- // coalescer tick ์—์„œ ๋ฐฐ์น˜ ๊ธฐ๋ก(๋ฐฉ์†ก๊ณผ ๋™์ผ ์ฃผ๊ธฐ). ํžˆ์Šคํ† ๋ฆฌ ๋…ธ๋ธŒยทํƒ€์ž„๋ผ์ธยท๋น„์ฆˆ๋‹ˆ์Šค ์›์žฅ์ด ์ด ์ €๋„์„ ์ฝ๋Š”๋‹ค.
1697
+ // coalescer tick ์—์„œ ๋ฐฐ์น˜ ๊ธฐ๋ก(๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…๊ณผ ๋™์ผ ์ฃผ๊ธฐ). ํžˆ์Šคํ† ๋ฆฌ ๋…ธ๋ธŒยทํƒ€์ž„๋ผ์ธยท๋น„์ฆˆ๋‹ˆ์Šค ์›์žฅ์ด ์ด ์ €๋„์„ ์ฝ๋Š”๋‹ค.
1148
1698
  ;(inst.pendingJournal ?? (inst.pendingJournal = [])).push(...envelopes)
1149
1699
  if (inst.metrics) { inst.metrics.ingestedTotal += envelopes.length; inst.metrics._accIngest += envelopes.length } // ๊ณ„์ธก(โ‘ฃ-1)
1150
- // ๋ฐฉ์†ก ๋ณ‘ํ•ฉ(ingest-scale ยง1.1/ยง4.4) โ€” apply ๋Š” O(1)ยท์‹ธ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ๋ฐฉ์†ก(snapshot O(state))์€ ๋น„์‹ธ(๋Œ€๊ทœ๋ชจ 5ms+)
1151
- // ์ด๋ฒคํŠธ๋งˆ๋‹ค ๋ฐฉ์†กํ•˜๋ฉด ํญ๋ฐœ โ†’ dirty ๋งŒ ์ฐ๊ณ  coalescer tick ์ด ์ฃผ๊ธฐ ๋ฐฉ์†ก(๋ฐฉ์†ก๋ฅ  โ‰  ์ธ์ œ์ŠคํŠธ๋ฅ ).
1700
+ // ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ๋ณ‘ํ•ฉ(ingest-scale ยง1.1/ยง4.4) โ€” apply ๋Š” O(1)ยท์‹ธ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…(snapshot O(state))์€ ๋น„์‹ธ(๋Œ€๊ทœ๋ชจ 5ms+)
1701
+ // ์ด๋ฒคํŠธ๋งˆ๋‹ค ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…ํ•˜๋ฉด ํญ๋ฐœ โ†’ dirty ๋งŒ ์ฐ๊ณ  coalescer tick ์ด ์ฃผ๊ธฐ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…(๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…๋ฅ  โ‰  ์ธ์ œ์ŠคํŠธ๋ฅ ).
1152
1702
  inst.dirty = true
1703
+ this.markItemsDirty(inst, envelopes)
1153
1704
  this.ensureBroadcastCoalescer()
1705
+ return envelopes.length
1154
1706
  }
1155
1707
 
1156
1708
  /**
1157
- * ๊ตฌ๊ฐ„ ์„ฑ๊ณผ ๋ฐฉ์†ก์€ **์—†์•ด๋‹ค**(2026-08-06). ์นด๋“œ๊ฐ€ `twinKpi` ๋ฅผ ์ง์ ‘ ๋ฌป๋Š”๋‹ค.
1709
+ * ๊ตฌ๊ฐ„ ์„ฑ๊ณผ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์€ **์—†์•ด๋‹ค**(2026-08-06). ์นด๋“œ๊ฐ€ `twinKpi` ๋ฅผ ์ง์ ‘ ๋ฌป๋Š”๋‹ค.
1158
1710
  *
1159
- * ์™œ: ์นด๋“œ๋ฅผ ์—ฌ๋Ÿฌ ๋‹จ๊ณ„(๊ณต๊ฐ„ยทํŠธ์œˆยท๊ตฌ์—ญยท์ž๋ฆฌยท์„ค๋น„)์— ๋ถ™์ด๋ ค๋ฉด ๋ฐฉ์†ก์œผ๋กœ๋Š” ํƒœ๊ทธ๊ฐ€ ํŠธ์œˆ๋‹น 1,200๊ฐœ๊ฐ€ ๋˜๊ณ ,
1711
+ * ์™œ: ์นด๋“œ๋ฅผ ์—ฌ๋Ÿฌ ๋‹จ๊ณ„(๊ณต๊ฐ„ยทํŠธ์œˆยท๊ตฌ์—ญยท์ž๋ฆฌยท์„ค๋น„)์— ๋ถ™์ด๋ ค๋ฉด ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์œผ๋กœ๋Š” ํƒœ๊ทธ๊ฐ€ ํŠธ์œˆ๋‹น 1,200๊ฐœ๊ฐ€ ๋˜๊ณ ,
1160
1712
  * **๋ชจ๋ธ์— ์นด๋“œ๋ฅผ ํ•˜๋‚˜๋„ ์•ˆ ๋†“์•„๋„** 30์ดˆ๋งˆ๋‹ค ํŠธ์œˆ๋งˆ๋‹ค ์ €๋„์„ ์ ‘์—ˆ๋‹ค. ์งˆ์˜๋กœ ๋ฐ”๊พธ๋‹ˆ ๋ณด๊ณ  ์žˆ๋Š” ์นด๋“œ
1161
1713
  * ์ˆ˜๋งŒํผ๋งŒ ๋“ค๊ณ , ๊ฐ™์€ (๋Œ€์ƒยท์ฐฝยท์ถ•) ์€ ํด๋ผ์ด์–ธํŠธ๊ฐ€ ํ•˜๋‚˜๋กœ ํ•ฉ์นœ๋‹ค.
1162
1714
  *
1163
1715
  * ๋ค์œผ๋กœ ์งˆ์˜๋งŒ ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ์ด ๋‘˜ ์ƒ๊ฒผ๋‹ค โ€” **๊ณผ๊ฑฐ ์‹œ๊ฐ**(`toTime`)๊ณผ **๊ณต๊ฐ„ ๋‹จ์œ„ ํ•ฉ์‚ฐ**(์—ฌ๋Ÿฌ ํŠธ์œˆ์„
1164
- * ํ•œ ๋ฒˆ์— ์ ‘๊ธฐ). ๋ฐฉ์†ก ๋ฃจํ”„๋Š” ํŠธ์œˆ๋ณ„์ด๋ผ ๋‘˜ ๋‹ค ๋ชป ํ–ˆ๋‹ค.
1716
+ * ํ•œ ๋ฒˆ์— ์ ‘๊ธฐ). ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ๋ฃจํ”„๋Š” ํŠธ์œˆ๋ณ„์ด๋ผ ๋‘˜ ๋‹ค ๋ชป ํ–ˆ๋‹ค.
1165
1717
  *
1166
1718
  * ์ถ•์„ ๋‚˜๋ˆ ๋„ ํด๋“œ ๋น„์šฉ์ด ๊ฐ™๋‹ค๋Š” ์‹ค์ธก์ด ๊ทผ๊ฑฐ๋‹ค(`test/kpi-query-bench.test.ts`).
1167
1719
  */
1168
- /** ๋ฐฉ์†ก ๋ณ‘ํ•ฉ ์ฃผ๊ธฐ(ms) โ€” ๋ฐฉ์†ก๋ฅ  ์ƒํ•œ. ์ธ์ œ์ŠคํŠธ๊ฐ€ ์•„๋ฌด๋ฆฌ ๋นจ๋ผ๋„ ์ด ์ฃผ๊ธฐ๋กœ๋งŒ ๋ฐฉ์†ก. */
1720
+ /**
1721
+ * ๋ช‡ ์ฐฝ๋งˆ๋‹ค ํ•œ ๋ฒˆ์€ **์ „๋ถ€** ๋งŒ๋“œ๋‚˜ โ€” ์‚ฌ๊ฑด ์—†์ด ๊ฐ’์ด ๋ฐ”๋€Œ๋Š” ์ž๋ฆฌ์— ๋Œ€ํ•œ ๊ทธ๋ฌผ.
1722
+ *
1723
+ * 25 ์ฐฝ์ด๋ฉด ๊ธฐ๋ณธ ์ฃผ๊ธฐ์—์„œ 5์ดˆ๋‹ค. ๋ณด์žฅ์ด ์•„๋‹ˆ๋ผ ๊ทธ๋ฌผ์ด๋‹ค(์œ„ `publishEntityData` ์ฃผ์„).
1724
+ */
1725
+ static FULL_BROADCAST_EVERY = 25
1726
+ /** ์ „๋ถ€ ๋งŒ๋“  ํšŸ์ˆ˜ โ€” ๋ฒ”์œ„๋ฅผ ์ขํžˆ์ง€ ๋ชปํ•œ ์ฐฝ์ด ์–ผ๋งˆ๋‚˜ ๋˜๋Š”์ง€ ๊ฐ’์œผ๋กœ ๋‚จ๋Š”๋‹ค. */
1727
+ static broadcastFullPasses = 0
1728
+ /**
1729
+ * ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์„ ๋งŒ๋“  ํšŸ์ˆ˜ ์ „๋ถ€ โ€” **์ „๋ถ€ ๋งŒ๋“  ํšŸ์ˆ˜์˜ ๋ถ„๋ชจ.**
1730
+ *
1731
+ * ๋ถ„์ž๋งŒ ๋‚ด๋ฉด ใ€Œ์ „๋ถ€ ๋งŒ๋“ค๊ธฐ 1,200ํšŒใ€๊ฐ€ ๋งŽ์€ ๊ฒƒ์ธ์ง€ ์ ์€ ๊ฒƒ์ธ์ง€ ์ฝ์„ ์ˆ˜ ์—†๋‹ค. ์ขํžˆ๊ธฐ๊ฐ€ ๋“ฃ๊ณ  ์žˆ์œผ๋ฉด
1732
+ * ์ด ๊ฐ’์˜ `1/FULL_BROADCAST_EVERY` ์ฏค์ด ์ „๋ถ€ ๋งŒ๋“  ํšŸ์ˆ˜์ด๊ณ , ๋‘ ๊ฐ’์ด ๋น„์Šทํ•˜๋ฉด ๋ฒ”์œ„๋ฅผ ๊ฑฐ์˜ ๋ชป ์ขํžŒ
1733
+ * ๊ฒƒ์ด๋‹ค(์›์ธ์€ ๋Œ€๊ฐœ ใ€Œ๋ชจ๋ฅธ๋‹คใ€๋กœ ๋–จ์–ด์ง€๋Š” ์‚ฌ๊ฑด์ด๋‹ค).
1734
+ */
1735
+ static broadcastPasses = 0
1736
+
1737
+ /**
1738
+ * ์ด ์ฐฝ์— ๊ฑด๋“œ๋ฆฐ ๋ฌผํ’ˆ์„ ๋ชจ์€๋‹ค โ€” **๋งํ•  ์ˆ˜ ์—†์œผ๋ฉด ๋ฒ”์œ„๋ฅผ ๋ฒ„๋ฆฐ๋‹ค(์ „๋ถ€ ๋งŒ๋“ ๋‹ค).**
1739
+ *
1740
+ * `events` ๋ฅผ ์ฃผ์ง€ ์•Š์œผ๋ฉด ใ€Œ๋ฌด์—‡์ด ๋ฐ”๋€Œ์—ˆ๋Š”์ง€ ๋ชจ๋ฅธ๋‹คใ€๋‹ค(๊ตฌ์กฐ ์ „ํ™˜์ฒ˜๋Ÿผ ์ƒํƒœ ์ „๋ฐ˜์ด ๋‹ฌ๋ผ์ง€๋Š” ์ž๋ฆฌ).
1741
+ * ํ•œ ์ฐฝ์—์„œ ํ•œ ๋ฒˆ ใ€Œ๋ชจ๋ฅธ๋‹คใ€๊ฐ€ ๋˜๋ฉด ๊ทธ ์ฐฝ์€ ๋๊นŒ์ง€ ๋ชจ๋ฅด๋Š” ์ฑ„๋กœ ๋‘”๋‹ค โ€” ๋’ค์— ์˜จ ์‚ฌ๊ฑด์œผ๋กœ ๋ฒ”์œ„๋ฅผ
1742
+ * ๋˜์‚ด๋ฆฌ๋ฉด ์•ž ์‚ฌ๊ฑด์ด ๊ฑด๋“œ๋ฆฐ ๊ฒƒ์„ ๋น ๋œจ๋ฆฐ๋‹ค.
1743
+ */
1744
+ static markItemsDirty(inst: InstanceRuntime, events?: any[]): void {
1745
+ if (inst.dirtyItems === undefined) return // ์ด ์ฐฝ์€ ์ด๋ฏธ ใ€Œ๋ชจ๋ฅธ๋‹คใ€
1746
+ if (!events) {
1747
+ inst.dirtyItems = undefined
1748
+ return
1749
+ }
1750
+ for (const e of events) {
1751
+ const touched = touchedItemKeys(e)
1752
+ if (!touched) {
1753
+ inst.dirtyItems = undefined
1754
+ return
1755
+ }
1756
+ for (const epc of touched) inst.dirtyItems.add(epc)
1757
+ }
1758
+ }
1759
+
1760
+ /** ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ๋ณ‘ํ•ฉ ์ฃผ๊ธฐ(ms) โ€” ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…๋ฅ  ์ƒํ•œ. ์ธ์ œ์ŠคํŠธ๊ฐ€ ์•„๋ฌด๋ฆฌ ๋นจ๋ผ๋„ ์ด ์ฃผ๊ธฐ๋กœ๋งŒ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…. */
1169
1761
  static BROADCAST_COALESCE_MS = 200
1762
+ /**
1763
+ * โ”€โ”€ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ์ฃผ๊ธฐ๋Š” **์žฌ ๋ณธ ๋น„์šฉ์— ๋งž์ถ˜๋‹ค** (2026-08-21 ์‹ค์ธก) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1764
+ * ํ•œ ๋ฒˆ์˜ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์€ ์ƒํƒœ ํฌ๊ธฐ์— ๋น„๋ก€ํ•œ๋‹ค(์‹ค์ธก: ๋ฌผํ’ˆ 2,400 ๊ฐœ์ธ ํŠธ์œˆ ํ•˜๋‚˜๊ฐ€ 4.5ms โ€” ์ƒํƒœ ํˆฌ์˜ 1.7ms,
1765
+ * payload ๋งŒ๋“ค๊ธฐ 1.8ms, ์‹œ๊ทธ๋‹ˆ์ฒ˜ 1.0ms). ํŠธ์œˆ์ด ์Šค๋ฌด ๊ฐœ๋ฉด 200ms ๋งˆ๋‹ค 90ms ๊ฐ€ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์— ๋“ค์–ด๊ฐ€๊ณ ,
1766
+ * ๊ทธ ์‹œ๊ฐ„์—๋Š” HTTP ๋„ ๊ตฌ๋…๋„ ์„œ์ง€ ๋ชปํ•œ๋‹ค.
1767
+ *
1768
+ * ๊ทธ๋ž˜์„œ ํ•œ ์ฐฝ์—์„œ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์— ์“ด ์‹œ๊ฐ„์ด ์ฃผ๊ธฐ์˜ ์ผ์ • ๋ชซ์„ ๋„˜์œผ๋ฉด **์ฃผ๊ธฐ๋ฅผ ๋Š˜๋ฆฐ๋‹ค**. ํ™”๋ฉด์€ ์กฐ๊ธˆ ๋Šฆ๊ฒŒ
1769
+ * ๊ฐฑ์‹ ๋˜๊ณ (์ตœ์‹ -์ƒํƒœ ์ฑ„๋„์ด๋ผ ๊ฐ’์€ ๋งˆ์ง€๋ง‰ ๊ฒƒ ํ•˜๋‚˜๋ฟ์ด๋ฏ€๋กœ ๋‚ด์šฉ์€ ๊ฐ™๋‹ค), ๋Š˜๋ ธ๋‹ค๋Š” ๊ฒƒ์€ ๊ณ„๊ธฐํŒ์ด
1770
+ * ๋งํ•œ๋‹ค(`broadcastCoalesceMs`). ์—ฌ์œ ๊ฐ€ ์ƒ๊ธฐ๋ฉด ์›๋ž˜ ์ฃผ๊ธฐ๋กœ ๋˜๋Œ๋ฆฐ๋‹ค.
1771
+ *
1772
+ * ์ด๊ฒƒ์€ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ๋น„์šฉ์„ **์ค„์ด๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋‹ค** โ€” ๋น„์šฉ์„ ์ค„์ด๋Š” ๊ฒƒ์€ ๋ณ€๊ฒฝ๋ถ„๋งŒ ๋งŒ๋“œ๋Š” ์ผ์ด๊ณ  ๊ทธ๊ฒƒ์€ ๋ณ„
1773
+ * ์ž‘์—…์ด๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ๊ทธ๋•Œ๊นŒ์ง€ ํ˜ธ์ŠคํŠธ๊ฐ€ ๊ตถ์ง€ ์•Š๊ฒŒ ์ƒํ•œ์„ ๋‘”๋‹ค.
1774
+ */
1775
+ static BROADCAST_MAX_COALESCE_MS = 1000
1776
+ /** ์ฃผ๊ธฐ์˜ ๋ช‡ ๋ชซ๊นŒ์ง€ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์— ์จ๋„ ๋˜๋Š”๊ฐ€ โ€” ๋„˜์œผ๋ฉด ์ฃผ๊ธฐ๋ฅผ ๋Š˜๋ฆฐ๋‹ค(์ ˆ๋ฐ˜์ด๋ฉด ๋‚˜๋จธ์ง€ ์ ˆ๋ฐ˜์€ ๋‚จ๊ธด๋‹ค). */
1777
+ static BROADCAST_LOAD_RATIO = 0.3
1778
+ /** ์ง€๊ธˆ ์“ฐ๊ณ  ์žˆ๋Š” ์ฃผ๊ธฐ(ms) โ€” ๊ณ„๊ธฐํŒ์ด ์ด ๊ฐ’์„ ์ฝ๋Š”๋‹ค. ๋Š˜์–ด๋‚œ ์ฑ„๋กœ ์žˆ์œผ๋ฉด ๊ทธ๊ฒƒ์ด ์‚ฌ์‹ค์ด๋‹ค. */
1779
+ static broadcastPeriodMs = 200
1780
+ /** ์ฃผ๊ธฐ๋ฅผ ๋Š˜๋ฆฐ ํšŸ์ˆ˜ โ€” ์กฐ์šฉํžˆ ๋Šฆ์ถ”์ง€ ์•Š๋Š”๋‹ค. */
1781
+ static broadcastBackoffs = 0
1170
1782
  private static broadcastTimer?: any
1171
1783
 
1172
- /** live ๋ฐฉ์†ก coalescer โ€” dirty ์ธ live ์ธ์Šคํ„ด์Šค๋งŒ ์ฃผ๊ธฐ์ ์œผ๋กœ 1ํšŒ ๋ฐฉ์†ก(entitySigs ๋กœ ๋ณ€๊ฒฝ ์—”ํ‹ฐํ‹ฐ๋งŒ). */
1784
+ /** live ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… coalescer โ€” dirty ์ธ live ์ธ์Šคํ„ด์Šค๋งŒ ์ฃผ๊ธฐ์ ์œผ๋กœ 1ํšŒ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…(entitySigs ๋กœ ๋ณ€๊ฒฝ ์—”ํ‹ฐํ‹ฐ๋งŒ). */
1173
1785
  private static ensureBroadcastCoalescer(): void {
1174
1786
  if (this.broadcastTimer) return
1175
- this.broadcastTimer = setInterval(() => this.flushLiveBroadcasts(), this.BROADCAST_COALESCE_MS)
1787
+ this.broadcastPeriodMs = this.BROADCAST_COALESCE_MS
1788
+ this.broadcastTimer = setInterval(() => this.flushLiveBroadcasts(), this.broadcastPeriodMs)
1176
1789
  if (typeof this.broadcastTimer.unref === 'function') this.broadcastTimer.unref() // ์ข…๋ฃŒ ๋น„์ฐจ๋‹จ
1177
1790
  }
1178
1791
 
1179
1792
  /**
1180
- * dirty ์ธ์Šคํ„ด์Šค ๋ฐฉ์†ก flush(์ฃผ๊ธฐ tick ๋˜๋Š” ๋ช…์‹œ ํ˜ธ์ถœ) โ€” **์‹œ๋ฎฌ๊ณผ ๋ผ์ด๋ธŒ ๋‘˜ ๋‹ค.**
1793
+ * ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์— ์“ด ์‹œ๊ฐ„์„ ๋ณด๊ณ  ์ฃผ๊ธฐ๋ฅผ ์ •ํ•œ๋‹ค โ€” **๋Š˜๋ฆฌ๋Š” ๊ฒƒ๋„ ์ค„์ด๋Š” ๊ฒƒ๋„ ๊ฐ’์— ๊ทผ๊ฑฐํ•œ๋‹ค.**
1794
+ *
1795
+ * ํ•œ ๋ฒˆ์˜ flush ๊ฐ€ ์ฃผ๊ธฐ์˜ `BROADCAST_LOAD_RATIO` ๋ฅผ ๋„˜๊ฒŒ ์“ฐ๋ฉด ์ฃผ๊ธฐ๋ฅผ ๋‘ ๋ฐฐ๋กœ(์ƒํ•œ๊นŒ์ง€), ๊ทธ ๋ชซ์˜
1796
+ * ์ ˆ๋ฐ˜ ์•„๋ž˜๋กœ ๋‚ด๋ ค์˜ค๋ฉด ์ ˆ๋ฐ˜์œผ๋กœ(์›๋ž˜ ์ฃผ๊ธฐ๊นŒ์ง€) ๋˜๋Œ๋ฆฐ๋‹ค. ๋ฌธํ„ฑ์„ ๋‘ ๊ฐœ ๋‘๋Š” ์ด์œ ๋Š” ํ•˜๋‚˜๋ฉด ๊ฒฝ๊ณ„์—์„œ
1797
+ * ๋Š˜๋ฆฌ๊ณ  ์ค„์ด๊ธฐ๋ฅผ ๋ฐ˜๋ณตํ•˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.
1798
+ */
1799
+ private static adjustBroadcastPeriod(flushMs: number): void {
1800
+ const period = this.broadcastPeriodMs
1801
+ const high = period * this.BROADCAST_LOAD_RATIO
1802
+ const low = high / 2
1803
+ let next = period
1804
+ if (flushMs > high && period < this.BROADCAST_MAX_COALESCE_MS) {
1805
+ next = Math.min(this.BROADCAST_MAX_COALESCE_MS, period * 2)
1806
+ this.broadcastBackoffs++
1807
+ twinWarn(
1808
+ `[twin-engine] broadcast period ${period}ms โ†’ ${next}ms โ€” one flush took ${Math.round(flushMs)}ms across ${Object.keys(this.instances).length} twin(s)`
1809
+ )
1810
+ } else if (flushMs < low && period > this.BROADCAST_COALESCE_MS) {
1811
+ next = Math.max(this.BROADCAST_COALESCE_MS, Math.round(period / 2))
1812
+ }
1813
+ if (next === period) return
1814
+
1815
+ this.broadcastPeriodMs = next
1816
+ if (this.broadcastTimer) clearInterval(this.broadcastTimer)
1817
+ this.broadcastTimer = setInterval(() => this.flushLiveBroadcasts(), next)
1818
+ if (typeof this.broadcastTimer.unref === 'function') this.broadcastTimer.unref()
1819
+ }
1820
+
1821
+ /**
1822
+ * dirty ์ธ์Šคํ„ด์Šค ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… flush(์ฃผ๊ธฐ tick ๋˜๋Š” ๋ช…์‹œ ํ˜ธ์ถœ) โ€” **์‹œ๋ฎฌ๊ณผ ๋ผ์ด๋ธŒ ๋‘˜ ๋‹ค.**
1181
1823
  *
1182
- * ์˜ˆ์ „์—๋Š” ๋ผ์ด๋ธŒ๋งŒ ๋ดค๋‹ค(`mode !== 'live'` ๋ฉด ๊ฑด๋„ˆ๋œ€). ์‹œ๋ฎฌ์€ delta ๋งˆ๋‹ค ๊ณง๋ฐ”๋กœ ๋ฐฉ์†กํ–ˆ๊ณ , ๊ทธ๊ฒƒ์ด
1183
- * ํ•œ tick ์—์„œ ์ˆ˜๋ฐฑ ๋ฒˆ ๋ฐ˜๋ณต๋˜๋ฉฐ ์ด๋ฒคํŠธ ๋ฃจํ”„๋ฅผ ๋ง‰์•˜๋‹ค(์œ„ `start()` ์ฃผ์„์˜ 35์ดˆ ํ‹ฑ). ๋ฐฉ์†ก์„ ๋ชจ์œผ๋Š”
1824
+ * ์˜ˆ์ „์—๋Š” ๋ผ์ด๋ธŒ๋งŒ ๋ดค๋‹ค(`mode !== 'live'` ๋ฉด ๊ฑด๋„ˆ๋œ€). ์‹œ๋ฎฌ์€ delta ๋งˆ๋‹ค ๊ณง๋ฐ”๋กœ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…ํ–ˆ๊ณ , ๊ทธ๊ฒƒ์ด
1825
+ * ํ•œ tick ์—์„œ ์ˆ˜๋ฐฑ ๋ฒˆ ๋ฐ˜๋ณต๋˜๋ฉฐ ์ด๋ฒคํŠธ ๋ฃจํ”„๋ฅผ ๋ง‰์•˜๋‹ค(์œ„ `start()` ์ฃผ์„์˜ 35์ดˆ ํ‹ฑ). ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์„ ๋ชจ์œผ๋Š”
1184
1826
  * ๊ทœ์œจ์€ ๋ชจ๋“œ์˜ ์„ฑ์งˆ์ด ์•„๋‹ˆ๋ผ **์ฑ„๋„์˜ ์„ฑ์งˆ**์ด๋‹ค โ€” ์ตœ์‹ -์ƒํƒœ ์ฑ„๋„์ด๋ฉด ์ค‘๊ฐ„ ์ƒํƒœ๋Š” ๋ณด๋‚ผ ๊ฐ’์ด ์—†๋‹ค.
1185
1827
  */
1186
1828
  static flushLiveBroadcasts(): void {
1187
1829
  const now = Date.now()
1830
+ const flushStart = performance.now()
1188
1831
  for (const inst of Object.values(this.instances)) {
1189
1832
  const isLive = inst.mode === 'live'
1190
- // ์ฒ˜๋ฆฌ๋Ÿ‰ ๊ณ„์ธก(โ‘ฃ-1) โ€” ์ฐฝ(โ‰ฅ1s)๋งˆ๋‹ค ์œ ์ž…/๋ฐฉ์†ก/์ €๋„๋ฅ  ๊ฐฑ์‹ . dirty ๋ฌด๊ด€(์œ ํœด๋ฉด 0์œผ๋กœ ์ˆ˜๋ ด). ๋ถ€ํ•˜๋ฅผ ์ฝ๋Š” ์‹ ํ˜ธ.
1191
- const m = isLive ? inst.metrics : undefined
1833
+ /*
1834
+ * ์ฒ˜๋ฆฌ๋Ÿ‰ ๊ณ„์ธก(โ‘ฃ-1) โ€” ์ฐฝ(โ‰ฅ1s)๋งˆ๋‹ค ์œ ์ž…ยท๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…ยท์ €๋„๋ฅ  ๊ฐฑ์‹ . dirty ๋ฌด๊ด€(์œ ํœด๋ฉด 0์œผ๋กœ ์ˆ˜๋ ด).
1835
+ *
1836
+ * **๋‘ ๊ตฌ๋™์„ ํ•จ๊ป˜ ์„ผ๋‹ค** (2026-08-20). ์˜ˆ์ „์—๋Š” `isLive ? โ€ฆ : undefined` ๋กœ ์‹œ๋ฎฌ์„ ์ž˜๋ผ ๋ƒˆ๋‹ค.
1837
+ * ๊ทธ๋ž˜์„œ ๋„๋Š” ํŠธ์œˆ ๋Œ€๋ถ€๋ถ„์ด ์‹œ๋ฎฌ์ธ ์„œ๋ฒ„์—์„œ ใ€Œ์ดˆ๋‹น ๋ช‡ ํ–‰์„ ์“ฐ๋‚˜ใ€์— ๋‹ตํ•  ์ž๋ฆฌ๊ฐ€ ์—†์—ˆ๋‹ค โ€” ์ €๋„ ์“ฐ๊ธฐ๋ฅผ
1838
+ * ๋ฐฐ์น˜๋กœ ๊ณ ์นœ ๋’ค์—๋„ ๊ทธ ํšจ๊ณผ๋ฅผ ์ˆซ์ž๋กœ ๋ณผ ์ˆ˜ ์—†์—ˆ๋‹ค. ์œ ์ž…(`ingestRate`)์€ ์‹œ๋ฎฌ์—์„œ 0 ์œผ๋กœ ์ˆ˜๋ ดํ•˜๊ณ ,
1839
+ * ๊ทธ๊ฒƒ์ด ์‚ฌ์‹ค์ด๋‹ค(์›๋ณธ์—์„œ ๋ฐ›๋Š” ๊ฒƒ์ด ์—†๋‹ค).
1840
+ */
1841
+ const m = inst.metrics
1192
1842
  if (m) {
1193
1843
  const dt = (now - m._windowStartMs) / 1000
1194
1844
  if (dt >= 1) {
@@ -1200,26 +1850,23 @@ export class TwinEngine {
1200
1850
  }
1201
1851
  if (!inst.dirty) continue
1202
1852
  inst.dirty = false
1203
- // โ‘  ์—”ํ‹ฐํ‹ฐ data(tag) ๋ฐฉ์†ก โ€” ๋ณด๋“œ ์ปดํฌ๋„ŒํŠธ ๋ผ์ด๋ธŒ ๋ Œ๋”.
1853
+ /* ์ฃผ๊ธฐ๋งˆ๋‹ค ํ•œ ๋ฒˆ์€ ๋ฒ”์œ„๋ฅผ ๋ฒ„๋ฆฌ๊ณ  ์ „๋ถ€ ๋งŒ๋“ ๋‹ค โ€” ์‚ฌ๊ฑด ์—†์ด ๊ฐ’์ด ๋ฐ”๋€Œ๋Š” ์ž๋ฆฌ์— ๋Œ€ํ•œ ๊ทธ๋ฌผ. */
1854
+ const left = (inst.fullBroadcastCountdown ?? 0) - 1
1855
+ if (left <= 0) {
1856
+ inst.fullBroadcastDue = true
1857
+ inst.fullBroadcastCountdown = this.FULL_BROADCAST_EVERY
1858
+ } else {
1859
+ inst.fullBroadcastCountdown = left
1860
+ }
1861
+ // โ‘  ์—”ํ‹ฐํ‹ฐ data(tag) ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… โ€” ๋ณด๋“œ ์ปดํฌ๋„ŒํŠธ ๋ผ์ด๋ธŒ ๋ Œ๋”.
1204
1862
  this.publishEntityData(inst)
1205
1863
  if (m) { m.broadcastTotal++; m._accBroadcast++ }
1206
- /* ์‹œ๋ฎฌ์˜ ์ €๋„ยทstate ์ฑ„๋„์€ ์ž๊ธฐ ์ฝœ๋ฐฑ์ด delta ๋งˆ๋‹ค ์ฒ˜๋ฆฌํ•œ๋‹ค(์‚ฌ์‹ค์€ ํ•˜๋‚˜๋„ ๋น ๋œจ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค).
1207
- ์—ฌ๊ธฐ์„œ ๋ชจ์œผ๋Š” ๊ฒƒ์€ **์—”ํ‹ฐํ‹ฐ ๋ฐฉ์†ก**๋ฟ์ด๋‹ค โ€” ํ™”๋ฉด์ด ์ฝ๋Š” ์ตœ์‹ -์ƒํƒœ ์ฑ„๋„. */
1864
+ /* โ‘ข ์ €๋„ ๋ฐฐ์น˜ ๊ธฐ๋ก โ€” **๋‘ ๊ตฌ๋™์ด ๊ฐ™์€ ๋ฌธ์„ ์“ด๋‹ค**(์‹œ๋ฎฌ๋„ ์—ฌ๊ธฐ์„œ ํ˜๋ฆฐ๋‹ค, ยง7.1). */
1865
+ this.flushJournal(inst)
1866
+ /* ์—ฌ๊ธฐ์„œ๋ถ€ํ„ฐ๋Š” **๋ผ์ด๋ธŒ๋งŒ**์ด๋‹ค: ์‹œ๋ฎฌ์˜ state ์ฑ„๋„์€ ์ž๊ธฐ ์ฝœ๋ฐฑ์ด ๋ธํƒ€๋งˆ๋‹ค ๋ณด๋‚ด๋ฏ€๋กœ(๋ฆฌ๋น„์ „์ด ์ปค๋„์˜
1867
+ ๊ฒƒ์ด๋‹ค) ์—ฌ๊ธฐ์„œ ๋˜ ๋ณด๋‚ด๋ฉด ๊ฐ™์€ ์‹ ํ˜ธ๊ฐ€ ๋‘ ๋ฒˆ ๊ฐ„๋‹ค. ์ €๋„์€ ์œ„์—์„œ ์ด๋ฏธ ๋‘ ๊ตฌ๋™ ๋ชซ์„ ํ˜๋ ธ๋‹ค. */
1208
1868
  if (!isLive) continue
1209
- // โ‘ข ์ €๋„ ๋ฐฐ์น˜ ๊ธฐ๋ก โ€” ๋ชจ์•„๋‘” ์ด๋ฒคํŠธ์— revision ๋ถ€์—ฌํ•ด ๋ฒŒํฌ ์ €์žฅ(์ด๋ฒคํŠธ๋งˆ๋‹ค write ์•„๋‹˜).
1210
- // revision ์นด์šดํ„ฐ๋Š” ์ธ๋ฉ”๋ชจ๋ฆฌ(startLive ์—์„œ ์ €๋„ high-water ๋กœ 1ํšŒ ์‹œ๋“œ) โ†’ tick ๋งˆ๋‹ค DB ์งˆ์˜ ์—†์Œ.
1211
- const batch = inst.pendingJournal
1212
- if (batch?.length && inst.revision != null) {
1213
- inst.pendingJournal = []
1214
- const start = inst.revision
1215
- inst.revision = start + batch.length
1216
- if (m) { m.journaledTotal += batch.length; m._accJournal += batch.length; m.backlog = batch.length }
1217
- const tJournal = performance.now()
1218
- this.persistBatch(inst.domainId, inst.id, batch, start)
1219
- .then(() => recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'journal', performance.now() - tJournal))
1220
- .catch(err => console.error('twin live journal fail', err))
1221
- }
1222
- // โ‘ก State ์ฑ„๋„ ๋ฐฉ์†ก โ€” "๋ฐ”๋€Œ์—ˆ๋‹ค"๋Š” ๊ฐ€๋ฒผ์šด ์‹ ํ˜ธ๋งŒ(kind+revision). ๋งต ๊ตฌ๋…(subscribeTwinState)์€ ์ด ์‹ ํ˜ธ์—
1869
+ // โ‘ก State ์ฑ„๋„ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… โ€” "๋ฐ”๋€Œ์—ˆ๋‹ค"๋Š” ๊ฐ€๋ฒผ์šด ์‹ ํ˜ธ๋งŒ(kind+revision). ๋งต ๊ตฌ๋…(subscribeTwinState)์€ ์ด ์‹ ํ˜ธ์—
1223
1870
  // scheduleRefresh(250ms ๋””๋ฐ”์šด์Šค)โ†’pollLive ๋กœ ๋˜๋ฌผ์–ด๋ด„. ์Šค๋ƒ…์ƒท(O(state))์€ ๋ณด๋Š” ์‚ฌ๋žŒ์ด ๋ฌผ์„ ๋•Œ๋งŒ 1ํšŒ ๊ณ„์‚ฐ.
1224
1871
  // (์—ฌ๊ธฐ์„œ payload ๋กœ ์Šค๋ƒ…์ƒท์„ ์‹ค์œผ๋ฉด ์•„๋ฌด๋„ ์•ˆ ์ฝ๋Š”๋ฐ tick ๋งˆ๋‹ค ํ†ต์งธ๋กœ ๋– ์„œ ์ˆœ์ˆ˜ ๋‚ญ๋น„ โ€” ์‹ ํ˜ธ๋งŒ ๋ณด๋‚ธ๋‹ค.)
1225
1872
  this.publishGuarded(
@@ -1228,12 +1875,16 @@ export class TwinEngine {
1228
1875
  `twin-state:${inst.id}`
1229
1876
  )
1230
1877
  }
1231
- /* ๋Œ๊ณ  ์žˆ๋Š” ์ธ์Šคํ„ด์Šค๊ฐ€ ํ•˜๋‚˜๋„ ์—†์œผ๋ฉด tick ์„ ๋ฉˆ์ถ˜๋‹ค(์˜ˆ์ „์—” ใ€Œ๋ผ์ด๋ธŒ๊ฐ€ ์—†์œผ๋ฉดใ€์ด์—ˆ๋Š”๋ฐ, ์ด์ œ
1232
- ์‹œ๋ฎฌ๋„ ์ด flush ์— ๊ธฐ๋Œ€๋ฏ€๋กœ ๊ทธ ์กฐ๊ฑด์ด๋ฉด ์‹œ๋ฎฌ ๋ฐฉ์†ก์ด ๋ฉˆ์ถ˜ ์ฑ„ ๋‚จ๋Š”๋‹ค). */
1878
+ /* ๊ฐ€๋™ ์ค‘์ธ ์ธ์Šคํ„ด์Šค๊ฐ€ ํ•˜๋‚˜๋„ ์—†์œผ๋ฉด tick ์„ ๋ฉˆ์ถ˜๋‹ค(์˜ˆ์ „์—” ใ€Œ๋ผ์ด๋ธŒ๊ฐ€ ์—†์œผ๋ฉดใ€์ด์—ˆ๋Š”๋ฐ, ์ด์ œ
1879
+ ์‹œ๋ฎฌ๋„ ์ด flush ์— ๊ธฐ๋Œ€๋ฏ€๋กœ ๊ทธ ์กฐ๊ฑด์ด๋ฉด ์‹œ๋ฎฌ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์ด ๋ฉˆ์ถ˜ ์ฑ„ ๋‚จ๋Š”๋‹ค). */
1233
1880
  if (!Object.keys(this.instances).length && this.broadcastTimer) {
1234
1881
  clearInterval(this.broadcastTimer)
1235
1882
  this.broadcastTimer = undefined
1883
+ this.broadcastPeriodMs = this.BROADCAST_COALESCE_MS
1884
+ return
1236
1885
  }
1886
+ /* ์ด๋ฒˆ flush ๊ฐ€ ์–ผ๋งˆ๋ฅผ ์ผ๋Š”์ง€๋กœ ๋‹ค์Œ ์ฃผ๊ธฐ๋ฅผ ์ •ํ•œ๋‹ค โ€” ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์ด ๋ฃจํ”„๋ฅผ ๋‹ค ์“ฐ์ง€ ๋ชปํ•˜๊ฒŒ. */
1887
+ this.adjustBroadcastPeriod(performance.now() - flushStart)
1237
1888
  }
1238
1889
 
1239
1890
  /**
@@ -1273,22 +1924,134 @@ export class TwinEngine {
1273
1924
  * ์—†์œผ๋ฉด `undefined` ๋‹ค โ€” 0 ์ด ์•„๋‹ˆ๋‹ค. ๊ตฌ์กฐ ๋ฆฌ๋น„์ „์ด ์ƒ๊ธฐ๊ธฐ ์ „์— ๋งŒ๋“ค์–ด์ง„ ํŠธ์œˆ์€ ์•„์ง ๋ฆฌ๋น„์ „์ด
1274
1925
  * ์—†๊ณ , ๊ทธ ์‚ฌ์‹ค์„ 0 ์ด๋ผ๋Š” **์œ ํšจํ•ด ๋ณด์ด๋Š” ๋ฒˆํ˜ธ**๋กœ ์œ„์žฅํ•˜๋ฉด ์•ˆ ๋œ๋‹ค.
1275
1926
  */
1276
- private static structureRevCache: Record<string, number> = {}
1927
+ /** ๊ตฌ์กฐ ๋ฆฌ๋น„์ „ ์บ์‹œ โ€” `null` ์€ **์—†๋‹ค๋Š” ๊ฒƒ์„ ์•Œ๊ณ  ์žˆ๋‹ค**๋Š” ๋œป์ด๋‹ค(๋ชจ๋ฆ„๊ณผ ๊ตฌ๋ณ„ํ•œ๋‹ค, ยง7.1). */
1928
+ private static structureRevCache: Record<string, number | null> = {}
1277
1929
  static async structureRevOf(domainId: string, instanceId: string): Promise<number | undefined> {
1278
1930
  const key = runtimeKey(domainId, instanceId)
1279
1931
  const cached = this.structureRevCache[key]
1280
- if (cached !== undefined) return cached
1932
+ /*
1933
+ * **์—†๋‹ค๋Š” ๊ฒƒ๋„ ๋‹ต์ด๋‹ค** โ€” ์˜ˆ์ „์—๋Š” ์ฐพ์ง€ ๋ชปํ•˜๋ฉด ์บ์‹œํ•˜์ง€ ์•Š์•˜๋‹ค(`if (latest) โ€ฆ`). ๊ทธ๋ž˜์„œ ๊ตฌ์กฐ ํ–‰์ด
1934
+ * ์—†๋Š” ํŠธ์œˆ์€ **๋ถ€๋ฅผ ๋•Œ๋งˆ๋‹ค SELECT** ํ–ˆ๊ณ , ๊ทธ ๊ฒฝ๋กœ๊ฐ€ ๋ธํƒ€๋งˆ๋‹ค ๋ถˆ๋ฆฌ๊ณ  ์žˆ์—ˆ๋‹ค(ยง7.1). `null` ๋กœ ๊ธฐ์–ตํ•œ๋‹ค.
1935
+ */
1936
+ if (cached !== undefined) return cached === null ? undefined : cached
1281
1937
  const latest = await getRepository(TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } })
1282
- if (latest) this.structureRevCache[key] = latest.rev
1938
+ this.structureRevCache[key] = latest ? latest.rev : null
1283
1939
  return latest?.rev
1284
1940
  }
1285
1941
 
1942
+ /** ๊ณ„๊ธฐ ํ•œ ๋ฒŒ โ€” **๋‘ ๊ตฌ๋™์ด ๊ฐ™์€ ๊ฒƒ์„ ๋“ ๋‹ค**(ํ•œ์ชฝ๋งŒ ๋“ค๋ฉด ๊ทธ ๊ตฌ๋™์€ ๋ฌผ์–ด๋„ ๋‹ต์ด ์—†๋‹ค). */
1943
+ private static newMetrics(): TwinMetrics {
1944
+ return { ingestedTotal: 0, broadcastTotal: 0, journaledTotal: 0, ingestRate: 0, broadcastRate: 0, journalRate: 0, backlog: 0, _accIngest: 0, _accBroadcast: 0, _accJournal: 0, _windowStartMs: Date.now() }
1945
+ }
1946
+
1947
+ /**
1948
+ * ๋ชจ์•„ ๋‘” ์ €๋„์„ **ํ•œ ๋ฒˆ์—** ์“ด๋‹ค โ€” ๋‘ ๊ตฌ๋™์ด ๊ฐ™์€ ๋ฌธ์„ ์“ด๋‹ค (ยง7.1).
1949
+ *
1950
+ * โ”€โ”€ ์™œ ํ•œ ํ•จ์ˆ˜์ธ๊ฐ€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1951
+ * ์“ฐ๋Š” ์ž๋ฆฌ๊ฐ€ ๋‘˜์ด๋ฉด(์ฃผ๊ธฐ flush ยท ์ •์ง€) ํ•œ์ชฝ๋งŒ ๊ณ ์ณ์ง€๊ณ , ๊ทธ ์–ด๊ธ‹๋‚จ์€ **์‚ฌ์‹ค์ด ์กฐ์šฉํžˆ ์‚ฌ๋ผ์ง€๋Š”**
1952
+ * ๋ชจ์–‘์œผ๋กœ ๋‚˜ํƒ€๋‚œ๋‹ค. ๊ทธ๋ž˜์„œ ํ˜๋ฆฌ๋Š” ๊ทœ์น™์„ ์—ฌ๊ธฐ ํ•œ ๊ณณ์— ๋‘”๋‹ค.
1953
+ *
1954
+ * โ”€โ”€ ๋ฆฌ๋น„์ „์„ ๋ˆ„๊ฐ€ ๋งค๊ธฐ๋‚˜ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1955
+ * ยท ๋ผ์ด๋ธŒ โ€” ์›์ฒœ์€ ๋ฆฌ๋น„์ „์„ ์ฃผ์ง€ ์•Š์œผ๋ฏ€๋กœ **ํ˜ธ์ŠคํŠธ๊ฐ€** ์ด์–ด ๋ถ™์ธ๋‹ค(์ €๋„ high-water ์—์„œ ์‹œ๋“œ).
1956
+ * ยท ์‹œ๋ฎฌ โ€” **์ปค๋„์˜ ๋ฆฌ๋น„์ „**์ด ์‹ค๋ ค ์˜จ๋‹ค(์ €๋„์ด ๊ทธ๊ฒƒ์œผ๋กœ ์ ‘ํžˆ๊ณ  ์‹œ๊ฐ„์—ฌํ–‰์ด ๊ทธ๊ฒƒ์„ ๋”›๋Š”๋‹ค).
1957
+ * ๊ทธ๋ž˜์„œ ๋ฒ„ํผ๋Š” ๋‘ ๋ชจ์–‘์„ ํ•จ๊ป˜ ๋“ ๋‹ค: ๋ด‰ํˆฌ๋งŒ ์žˆ์œผ๋ฉด ๋ผ์ด๋ธŒ, `{ event, revision }` ์ด๋ฉด ์‹œ๋ฎฌ์ด๋‹ค.
1958
+ */
1959
+ private static flushJournal(inst: InstanceRuntime): Promise<void> {
1960
+ const batch = inst.pendingJournal
1961
+ if (!batch?.length) return Promise.resolve()
1962
+ inst.pendingJournal = []
1963
+ const m = inst.metrics
1964
+ /* ์“ด ๊ฑด์ˆ˜๋Š” ๋ˆ„์ ์—, **์ง€๊ธˆ ๋‚จ์€ ๊ฒƒ**์€ backlog ์—. ์ด ์ž๋ฆฌ์—์„œ ๋ฐฐ์น˜ ํฌ๊ธฐ๋ฅผ backlog ๋กœ ์ ์œผ๋ฉด
1965
+ ์ •์ƒ์ ์ธ ๋ฐฐ์น˜ ์ €์žฅ์ด ํ™”๋ฉด์—์„œ ๊ฒฝ๊ณ ๋กœ ๋ณด์ธ๋‹ค(2026-08-21 ๊ต์ •). */
1966
+ if (m) { m.journaledTotal += batch.length; m._accJournal += batch.length; m.backlog = inst.pendingJournal?.length ?? 0 }
1967
+
1968
+ /* ์‹œ๋ฎฌ์€ ์ž๊ธฐ ๋ฆฌ๋น„์ „์„ ๋“ค๊ณ  ์˜จ๋‹ค โ€” ๊ทธ๋Œ€๋กœ ์“ด๋‹ค. ๋ผ์ด๋ธŒ๋Š” ์—ฌ๊ธฐ์„œ ์ด์–ด ๋ถ™์ธ๋‹ค. */
1969
+ const carried = batch.filter((b: any) => b && typeof b === 'object' && 'event' in b && typeof b.revision === 'number')
1970
+ const plain = batch.filter((b: any) => !(b && typeof b === 'object' && 'event' in b && typeof b.revision === 'number'))
1971
+
1972
+ const tJournal = performance.now()
1973
+ const done = () => recordPhase(inst.load ?? (inst.load = newLoadMeter()), 'journal', performance.now() - tJournal)
1974
+ const jobs: Promise<void>[] = []
1975
+
1976
+ if (carried.length) jobs.push(this.persistCarried(inst.domainId, inst.id, carried))
1977
+ if (plain.length && inst.revision != null) {
1978
+ const start = inst.revision
1979
+ inst.revision = start + plain.length
1980
+ jobs.push(this.persistBatch(inst.domainId, inst.id, plain, start))
1981
+ }
1982
+ if (!jobs.length) return Promise.resolve()
1983
+ /*
1984
+ * **์•ฝ์†์„ ๋Œ๋ ค์ค€๋‹ค** โ€” ์ฃผ๊ธฐ flush ๋Š” ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š์ง€๋งŒ(ํ•ซ ๊ฒฝ๋กœ) **์ •์ง€๋Š” ๊ธฐ๋‹ค๋ ค์•ผ ํ•œ๋‹ค.** ๊ธฐ๋‹ค๋ฆฌ์ง€
1985
+ * ์•Š์œผ๋ฉด ๊ณง๋ฐ”๋กœ ์ด์–ด์ง€๋Š” ์ €๋„ ์žฌ์ƒ์ด ์•„์ง ์•ˆ ์“ฐ์ธ ๊ตฌ๊ฐ„์„ ๋ชป ๋ณด๊ณ , ์›œ์Šคํƒ€ํŠธ๊ฐ€ ใ€Œ์—†๋˜ ์ผใ€๋กœ ์‹œ์ž‘ํ•œ๋‹ค
1986
+ * (์‹ค์ œ๋กœ ๊ทธ๋ ‡๊ฒŒ ๊นจ์กŒ๋‹ค: ํ™•๋ณด๋ถ„์„ ๋“  ์˜ค๋”๊ฐ€ ๋˜์‚ด์•„๋‚˜์ง€ ๋ชปํ–ˆ๋‹ค).
1987
+ */
1988
+ const written = carried.length + plain.length
1989
+ return Promise.all(jobs)
1990
+ .then(() => {
1991
+ done()
1992
+ /*
1993
+ * **์ ํžŒ ๋’ค์— ์ ๋Š”๋‹ค** โ€” ์‹คํŒจํ•œ ๋ฐฐ์น˜๋ฅผ ใ€Œ๋‚จ์•˜๋‹คใ€๊ณ  ์„ธ๋ฉด ์ถ”์ด๊ฐ€ ์—†๋Š” ์‚ฌ์‹ค์„ ์žˆ๋‹ค๊ณ  ๋งํ•œ๋‹ค.
1994
+ * ์ฐฝ(10๋ถ„)์— ์Œ“์ด๋ฏ€๋กœ ์žฌ๊ธฐ๋™ ๋’ค์—๋„ ใ€Œ์ง€๋‚œ ์—ฌ์„ฏ ์‹œ๊ฐ„ ์–ผ๋งˆ๋‚˜ ์ ์—ˆ๋‚˜ใ€๋ฅผ ๋‹ตํ•  ์ˆ˜ ์žˆ๋‹ค.
1995
+ */
1996
+ this.recordJournalWrite(inst.domainId, inst.id, written)
1997
+ })
1998
+ .catch(err => twinError('twin journal flush fail', err))
1999
+ }
2000
+
2001
+ /**
2002
+ * ์ปค๋„์ด ๋งค๊ธด ๋ฆฌ๋น„์ „์„ ๊ทธ๋Œ€๋กœ ๋“ค๊ณ  ๋ฒŒํฌ ์ €์žฅ โ€” ์‹œ๋ฎฌ ๊ฒฝ๋กœ.
2003
+ *
2004
+ * ์˜ˆ์ „์—๋Š” ์ด ๊ฒฝ๋กœ๊ฐ€ **๋ธํƒ€๋งˆ๋‹ค ํ•œ ํ–‰์”ฉ** ์ €์žฅํ–ˆ๋‹ค(๊ทธ๋ฆฌ๊ณ  ํ–‰๋งˆ๋‹ค ๊ตฌ์กฐ ๋ฆฌ๋น„์ „์„ ๋ฌผ์—ˆ๋‹ค). ๊ทœ๋ชจ์—์„œ ๊ทธ๊ฒƒ์ด
2005
+ * ํ˜ธ์ŠคํŠธ๋ฅผ ๋จน์—ˆ๋‹ค(ยง7.1 ์‹ค์ธก). ์—ฌ๊ธฐ์„œ ๊ตฌ์กฐ ๋ฆฌ๋น„์ „์€ **ํ•œ ๋ฒˆ๋งŒ** ๋ฌป๋Š”๋‹ค.
2006
+ */
2007
+ static async persistCarried(domainId: string, instanceId: string, items: { event: any; revision: number }[]): Promise<void> {
2008
+ const repo = getRepository(TwinEvent)
2009
+ const structureRev = await this.structureRevOf(domainId, instanceId)
2010
+ const rows = items.map(it => this.journalRow(repo, domainId, instanceId, it.event, it.revision, structureRev))
2011
+ await this.insertRows(repo, rows)
2012
+ }
2013
+
2014
+ /**
2015
+ * ์ €๋„ ํ–‰์„ ๋„ฃ๋Š”๋‹ค โ€” **๋„ฃ๊ธฐ๋งŒ ํ•œ๋‹ค**(2026-08-20).
2016
+ *
2017
+ * ์˜ˆ์ „์—๋Š” `save()` ์˜€๋‹ค. ๊ทธ๋Ÿฐ๋ฐ `save` ๋Š” ๋„ฃ์€ ๋’ค ์ƒ์„ฑ ์ปฌ๋Ÿผ์„ ์ฝ์œผ๋ ค๊ณ  **ํ–‰๋งˆ๋‹ค SELECT ๋ฅผ ํ•œ ๋ฒˆ ๋”**
2018
+ * ํ•œ๋‹ค(์‹œํ—˜ ๋กœ๊ทธ์—์„œ ๊ทธ ์งˆ์˜๊ฐ€ ๊ทธ๋Œ€๋กœ ๋ณด์˜€๋‹ค: `SELECT โ€ฆ FROM twin_events WHERE id = ?`). ์ €๋„์€
2019
+ * append-only ์ด๊ณ  ๋ถ€๋ฅด๋Š” ์ชฝ์€ ๋Œ๋ ค๋ฐ›์€ ์—”ํ‹ฐํ‹ฐ๋ฅผ ์“ฐ์ง€ ์•Š์œผ๋ฏ€๋กœ ๊ทธ ์™•๋ณต์ด ์ˆœ์ˆ˜ ๋‚ญ๋น„๋‹ค.
2020
+ *
2021
+ * ๋ฌถ์Œ์€ **๋‚˜๋ˆ ์„œ** ๋„ฃ๋Š”๋‹ค: ํ•œ ๋ฌธ์— ์—ด์ด ์—ด๋‹ค์„ฏ์ธ ํ–‰์„ ์ˆ˜์ฒœ ๊ฐœ ์‹ค์œผ๋ฉด ๋“œ๋ผ์ด๋ฒ„์˜ ํŒŒ๋ผ๋ฏธํ„ฐ ํ•œ๊ณ„์—
2022
+ * ๊ฑธ๋ฆฐ๋‹ค(pg ๋Š” 65,535๊ฐœ). 500ํ–‰์ด๋ฉด ์–ด๋А ๋“œ๋ผ์ด๋ฒ„์—์„œ๋„ ์•ˆ์ „ํ•˜๋‹ค.
2023
+ */
2024
+ private static async insertRows(repo: any, rows: any[]): Promise<void> {
2025
+ const CHUNK = 500
2026
+ /*
2027
+ * โ”€โ”€ ํ•œ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๊ฐ์‹ธ ๋ณด์•˜๊ณ , **๋˜๋Œ๋ ธ๋‹ค** (2026-08-22 ์‹ค์ธก) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2028
+ * ใ€Œ์ฒญํฌ๋งˆ๋‹ค ์ปค๋ฐ‹ํ•˜๋ฉด fsync ๊ฐ€ ๊ทธ๋งŒํผ ๋Š˜์–ด๋‚œ๋‹คใ€๋Š” ์ด์œ ๋กœ ์ „์ฒด๋ฅผ ํ•œ ํŠธ๋žœ์žญ์…˜์— ๊ฐ์ŒŒ๋‹ค. ์“ฐ๊ธฐ ์ž์ฒด๋Š”
2029
+ * ์‹ค์ œ๋กœ ๋นจ๋ผ์กŒ๋‹ค โ€” ๋А๋ฆฐ ์งˆ์˜ ๋ชฉ๋ก์—์„œ ์ด INSERT ๊ฐ€ ์‚ฌ๋ผ์กŒ๋‹ค. **๊ทธ๋Ÿฐ๋ฐ ์„œ๋ฒ„๊ฐ€ ๋” ๋А๋ ค์กŒ๋‹ค.**
2030
+ *
2031
+ * ๊ฐ์‹ธ๊ธฐ ์ „ ๋А๋ฆฐ ์งˆ์˜ ์ตœ๋Œ€ 32.3์ดˆ ยท ROLLBACK ์—†์Œ
2032
+ * ๊ฐ์‹ผ ๋’ค ๋А๋ฆฐ ์งˆ์˜ ์ตœ๋Œ€ 50.2์ดˆ ยท ROLLBACK 36.3์ดˆ ๋“ฑ์žฅ
2033
+ *
2034
+ * ๊ธฐ์ „์€ ๋“œ๋ผ์ด๋ฒ„๋‹ค. TypeORM ์˜ sqlite ๋“œ๋ผ์ด๋ฒ„๋Š” ์—ฐ๊ฒฐ์„ **ํ•˜๋‚˜**๋งŒ ๋“ ๋‹ค(ํ’€ ์—†์Œ). ํŠธ๋žœ์žญ์…˜์ด
2035
+ * ์—ด๋ ค ์žˆ๋Š” ๋™์•ˆ ๊ทธ ์œ ์ผํ•œ ์—ฐ๊ฒฐ์€ ์ด ๋ฌถ์Œ์˜ ๊ฒƒ์ด๋ฏ€๋กœ, **๋ฌถ์Œ์ด ๋๋‚  ๋•Œ๊นŒ์ง€ ์•ฑ์˜ ๋ชจ๋“  ์งˆ์˜๊ฐ€
2036
+ * ๊ธฐ๋‹ค๋ฆฐ๋‹ค.** ์ฒญํฌ๋งˆ๋‹ค ์ปค๋ฐ‹ํ•˜๋ฉด ๊ทธ ์‚ฌ์ด์— ๋‹ค๋ฅธ ์งˆ์˜๊ฐ€ ๋ผ์–ด๋“ค ์ž๋ฆฌ๊ฐ€ ์ƒ๊ธด๋‹ค โ€” fsync ๋ฅผ ๋” ์น˜๋ฅด๋Š”
2037
+ * ๋Œ€์‹  **๋จธ๋ฆฌ ๋ง‰ํž˜์ด ์งง์•„์ง„๋‹ค.**
2038
+ *
2039
+ * ์ฆ‰ ์—ฌ๊ธฐ์„œ๋Š” ใ€Œ์ปค๋ฐ‹ ์ˆ˜๋ฅผ ์ค„์ด๋Š” ๊ฒƒใ€์ด ๋ชฉ์ ์ด ์•„๋‹ˆ๋‹ค. ๋ชฉ์ ์€ **ํ•œ ๋ฒˆ์— ์˜ค๋ž˜ ๋ถ™์žก์ง€ ์•Š๋Š” ๊ฒƒ**์ด๋‹ค.
2040
+ * ์ €๋„ ๋ฌถ์Œ์˜ ์›์ž์„ฑ์€ ๊ทธ ๋Œ€๊ฐ€๋ฅผ ์น˜๋ฅผ ๋งŒํผ์˜ ๊ฐ’์ด ์•„๋‹ˆ๋‹ค: ์ €๋„์€ append-only ์ด๊ณ  ๋ฆฌ๋น„์ „์ด
2041
+ * ์ด์–ด์ง€๋ฏ€๋กœ, ์ ˆ๋ฐ˜๋งŒ ๋“ค์–ด๊ฐ„ ๋ฌถ์Œ์€ ๋‹ค์Œ ๊ธฐ๋™์˜ replay ๊ฐ€ ๊ทธ ์ง€์ ๋ถ€ํ„ฐ ์ด์–ด๋ฐ›๋Š”๋‹ค.
2042
+ *
2043
+ * ์—ฐ๊ฒฐ ํ’€์ด ์žˆ๋Š” ๋“œ๋ผ์ด๋ฒ„(postgresยทmysql)์—์„œ๋Š” ํŒ๋‹จ์ด ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ๋‹ค โ€” ๊ทธ๋•Œ๋Š” ์ด ์ฃผ์„์„ ๊ทผ๊ฑฐ๋กœ
2044
+ * ๋‹ค์‹œ ์žฌ๊ณ  ์ •ํ•˜๋ผ. **๋“œ๋ผ์ด๋ฒ„๋งˆ๋‹ค ๋‹ค๋ฅธ ๊ฒฐ๋ก ์ด ๋‚˜๋Š” ์ž๋ฆฌ๋‹ค.**
2045
+ */
2046
+ for (let i = 0; i < rows.length; i += CHUNK) await repo.insert(rows.slice(i, i + CHUNK))
2047
+ }
2048
+
1286
2049
  /** live ์ €๋„ ๋ฐฐ์น˜ ๊ธฐ๋ก โ€” ๋ชจ์•„๋‘” CanonicalEnvelope ๋“ค์— startRevision+1.. ์„ ๋ถ€์—ฌํ•ด ๋ฒŒํฌ ์ €์žฅ(coalescer tick ๋‹น 1ํšŒ). */
1287
2050
  static async persistBatch(domainId: string, instanceId: string, envelopes: any[], startRevision: number): Promise<void> {
1288
2051
  const repo = getRepository(TwinEvent)
1289
2052
  const structureRev = await this.structureRevOf(domainId, instanceId)
1290
2053
  const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1, structureRev))
1291
- await repo.save(rows, { chunk: 500 })
2054
+ await this.insertRows(repo, rows)
1292
2055
  }
1293
2056
 
1294
2057
  /** ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ upsert(๋„๋ฉ”์ธ+instanceId ์œ ๋‹ˆํฌ). status ์ธ์ž๋กœ provision(stopped)/start(running) ๊ณต์šฉ. */
@@ -1320,7 +2083,7 @@ export class TwinEngine {
1320
2083
  spaceId: (model as any)?.spaceId ?? existing?.spaceId,
1321
2084
  areaId: (model as any)?.areaId ?? existing?.areaId,
1322
2085
  /* ์žฌ๊ธฐ๋™ ์ •์ฑ…(ADR-0029 ยง2) โ€” ๋ช…์‹œ๊ฐ’ ์šฐ์„ , ์—†์œผ๋ฉด ๊ธฐ์กด๊ฐ’ ๋ณด์กด(์žฌํ”„๋กœ๋น„์ „์ด ์„ ์–ธ์„ ์ง€์šฐ์ง€ ์•Š๊ฒŒ).
1323
- **๋‘˜ ๋‹ค ์—†์œผ๋ฉด ๋˜์ง„๋‹ค**: ์˜ˆ์ „์—๋Š” ์—ฌ๊ธฐ์„œ ๊ธฐ๋ณธ๊ฐ’์„ ๊ฐ์ธํ–ˆ๋Š”๋ฐ, ๊ทธ ๊ธฐ๋ณธ๊ฐ’์ด ใ€Œ์ €๋„ ์ดˆ๊ธฐํ™”ใ€๋ผ์„œ
2086
+ **๋‘˜ ๋‹ค ์—†์œผ๋ฉด ์˜ค๋ฅ˜๋ฅผ ๋‚ธ๋‹ค**: ์˜ˆ์ „์—๋Š” ์—ฌ๊ธฐ์„œ ๊ธฐ๋ณธ๊ฐ’์„ ๊ฐ์ธํ–ˆ๋Š”๋ฐ, ๊ทธ ๊ธฐ๋ณธ๊ฐ’์ด ใ€Œ์ €๋„ ์ดˆ๊ธฐํ™”ใ€๋ผ์„œ
1324
2087
  ์„ ์–ธ์„ ๋น ๋œจ๋ฆฐ ํ”„๋กœ๋น„์ €๋‹์ด ์กฐ์šฉํžˆ ์ด๋ ฅ์„ ์ง€์šฐ๋Š” ํŠธ์œˆ์„ ๋งŒ๋“ค์—ˆ๋‹ค. ์„ ์–ธ์€ ๋ถ€๋ฅด๋Š” ์ชฝ์˜ ๋ชซ์ด๋‹ค. */
1325
2088
  restartPolicy:
1326
2089
  restartPolicy ??
@@ -1385,7 +2148,7 @@ export class TwinEngine {
1385
2148
  private static assertNotRunning(domainId: string, instanceId: string): void {
1386
2149
  if (!this.instances[runtimeKey(domainId, instanceId)]) return
1387
2150
  /*
1388
- * **๊ฑฐ์ ˆ๋„ ๋ฒˆ์—ญ๋ผ์•ผ ํ•œ๋‹ค.** ์ด ๋ฌธ์žฅ์€ ๋˜์ ธ์ ธ์„œ ๋ฆฌ์กธ๋ฒ„์˜ `catch` ๋ฅผ ์ง€๋‚˜ ํ™”๋ฉด ํ† ์ŠคํŠธ์— ๊ทธ๋Œ€๋กœ
2151
+ * **๊ฑฐ์ ˆ๋„ ๋ฒˆ์—ญ๋ผ์•ผ ํ•œ๋‹ค.** ์ด ๋ฌธ์žฅ์€ ์ „๋‹ฌ๋˜์–ด ๋ฆฌ์กธ๋ฒ„์˜ `catch` ๋ฅผ ์ง€๋‚˜ ํ™”๋ฉด ํ† ์ŠคํŠธ์— ๊ทธ๋Œ€๋กœ
1389
2152
  * ๋–ด๋‹ค โ€” ๋‹ค์„ฏ ์–ธ์–ด ์ œํ’ˆ์—์„œ ์˜์–ด ํ•œ ์ค„์ด ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์˜€๋‹ค(2026-08-14 ์‹ค์ธก).
1390
2153
  *
1391
2154
  * ๊ทธ๋ž˜์„œ ์ฝ”๋“œ์™€ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์˜ˆ์™ธ์— ์‹ค์–ด ๋ณด๋‚ธ๋‹ค(`ImportSpaceRefusal` ๊ณผ ๊ฐ™์€ ๊ทœ์•ฝ: ์˜์–ด ๋ฌธ์žฅ์€
@@ -1449,22 +2212,24 @@ export class TwinEngine {
1449
2212
  * ๋˜๋Œ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค โ€” ๋‹ค๋งŒ ์กฐ์šฉํžˆ ๋„˜๊ธฐ์ง€ ์•Š๊ณ  ๋งํ•œ๋‹ค.
1450
2213
  */
1451
2214
  await projectStructure(domainId, instanceId, model, instanceId).catch((err: any) =>
1452
- console.warn(`[twin-engine] "${instanceId}" adopted a new structure but its projected rows were not refreshed โ€” ${err?.message ?? err}`)
2215
+ twinWarn(`[twin-engine] "${instanceId}" adopted a new structure but its projected rows were not refreshed โ€” ${err?.message ?? err}`)
1453
2216
  )
1454
2217
 
1455
2218
  /*
1456
- * **๊ตฌ์กฐ๊ฐ€ ๋ฐ”๋€ ์ˆœ๊ฐ„์ด ์ƒํƒœ๊ฐ€ ๋ฐ”๋€ ์ˆœ๊ฐ„์ด๋‹ค** โ€” ๊ทธ๋Ÿฌ๋‹ˆ ๋ฐฉ์†กํ•œ๋‹ค.
2219
+ * **๊ตฌ์กฐ๊ฐ€ ๋ฐ”๋€ ์ˆœ๊ฐ„์ด ์ƒํƒœ๊ฐ€ ๋ฐ”๋€ ์ˆœ๊ฐ„์ด๋‹ค** โ€” ๊ทธ๋Ÿฌ๋‹ˆ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…ํ•œ๋‹ค.
1457
2220
  *
1458
2221
  * โ”€โ”€ ๋ฌด์—‡์ด ๋‚ฌ๋‚˜ (2026-08-18) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1459
2222
  * ๊ตฌ์กฐ ์ „ํ™˜์€ ์ปค๋„๋งŒ ๊ฐˆ๊ณ  ์กฐ์šฉํžˆ ๋๋‚ฌ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ํ™”๋ฉด์ด ๋ณด๋Š” ๊ฒƒ ์ƒ๋‹น์ˆ˜๊ฐ€ ๊ตฌ์กฐ์—์„œ ํŒŒ์ƒ๋œ๋‹ค โ€”
1460
2223
  * ๊ณ„์•ฝ ๋Œ€๋น„ ํŒ์ •, ์ฃผ๋ชฉ ์‹ ํ˜ธ, ์ž๋ฆฌ ์ƒ‰. ํ˜„์žฅ์ด ๊ณ„์•ฝ์„ ๊ณ ์ณ ์„ ์–ธํ•œ ์ˆœ๊ฐ„ ์กฐ๊ฑด์ด ์„ฑ๋ฆฝํ•˜๋Š”๋ฐ๋„, ์ƒํƒœ
1461
- * ๋ฐฉ์†ก์ด ์—†์–ด์„œ ์ง€๋„ ๋ ˆ์ผ์€ **๋‹ค์Œ ๊ณ„์ธก ํ‘œ๋ณธ์ด ์˜ฌ ๋•Œ๊นŒ์ง€** ์˜› ํ™”๋ฉด์„ ๋“ค๊ณ  ์žˆ์—ˆ๋‹ค(๊ทธ ์‚ฌ์ด ํ—ค๋”
2224
+ * ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์ด ์—†์–ด์„œ ์ง€๋„ ๋ ˆ์ผ์€ **๋‹ค์Œ ๊ณ„์ธก ํ‘œ๋ณธ์ด ์˜ฌ ๋•Œ๊นŒ์ง€** ์˜› ํ™”๋ฉด์„ ๋“ค๊ณ  ์žˆ์—ˆ๋‹ค(๊ทธ ์‚ฌ์ด ํ—ค๋”
1462
2225
  * ๋ฐฐ์ง€๋Š” 4์ดˆ ํด๋ง์œผ๋กœ ๋จผ์ € ์•Œ์•„, ใ€Œ๋ฐฐ์ง€์—” ์žˆ๊ณ  ๋ชฉ๋ก์—” ์—†๋Š”ใ€ ์–ด๊ธ‹๋‚œ ํ™”๋ฉด์ด ์‹ค์ œ๋กœ ๋ณด์˜€๋‹ค).
1463
2226
  *
1464
- * ์ƒˆ ๋ฐฉ์†ก ๊ฒฝ๋กœ๋ฅผ ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค: dirty ๋ฅผ ์„ธ์›Œ **์ด๋ฏธ ์žˆ๋Š” ๋ณ‘ํ•ฉ ๊ทœ์œจ**(200ms)์— ์–น๋Š”๋‹ค. ๊ตฌ์กฐ ์ „ํ™˜์€
1465
- * ๋“œ๋ฌผ์ง€๋งŒ, ์—ฌ๋Ÿฌ ํŠธ์œˆ์— ์ž‡๋‹ฌ์•„ ๋“ค์–ด์˜ฌ ์ˆ˜ ์žˆ๊ณ (ํ˜„์žฅ ์ผ๊ด„ ์„ ์–ธ) ๊ทธ๋•Œ๋„ ๋ฐฉ์†ก๋ฅ  ์ƒํ•œ์€ ์ง€์ผœ์•ผ ํ•œ๋‹ค.
2227
+ * ์ƒˆ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ๊ฒฝ๋กœ๋ฅผ ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค: dirty ๋ฅผ ์„ธ์›Œ **์ด๋ฏธ ์žˆ๋Š” ๋ณ‘ํ•ฉ ๊ทœ์œจ**(200ms)์— ์–น๋Š”๋‹ค. ๊ตฌ์กฐ ์ „ํ™˜์€
2228
+ * ๋“œ๋ฌผ์ง€๋งŒ, ์—ฌ๋Ÿฌ ํŠธ์œˆ์— ์ž‡๋‹ฌ์•„ ๋“ค์–ด์˜ฌ ์ˆ˜ ์žˆ๊ณ (ํ˜„์žฅ ์ผ๊ด„ ์„ ์–ธ) ๊ทธ๋•Œ๋„ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…๋ฅ  ์ƒํ•œ์€ ์ง€์ผœ์•ผ ํ•œ๋‹ค.
1466
2229
  */
1467
2230
  inst.dirty = true
2231
+ /* ๊ตฌ์กฐ๊ฐ€ ๊ฐˆ๋ ธ์œผ๋ฉด ๋ฌด์—‡์ด ๋‹ฌ๋ผ์กŒ๋Š”์ง€ ์‚ฌ๊ฑด์œผ๋กœ ๋งํ•  ์ˆ˜ ์—†๋‹ค โ€” ์ „๋ถ€ ๋‹ค์‹œ ๋งŒ๋“ ๋‹ค. */
2232
+ this.markItemsDirty(inst)
1468
2233
  this.ensureBroadcastCoalescer()
1469
2234
 
1470
2235
  return { rev, ...shift }
@@ -1626,6 +2391,43 @@ export class TwinEngine {
1626
2391
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
1627
2392
  if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`)
1628
2393
 
2394
+ /*
2395
+ * โ”€โ”€ ์„ ์–ธ๋œ `restartPolicy` ๊ฐ€ **๊ธฐ๋™ ๋ฐฉ์‹์„ ์ •ํ•œ๋‹ค** (2026-08-20) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2396
+ *
2397
+ * ์˜ˆ์ „์—๋Š” ์ด ํ•จ์ˆ˜๊ฐ€ ์ •์ฑ…์„ **์ฝ์–ด์„œ ๋„˜๊ธฐ๊ธฐ๋งŒ** ํ–ˆ๊ณ , ๊ธฐ๋™์€ ์–ธ์ œ๋‚˜ ์‹œ๋ฎฌ ๊ฒฝ๋กœ์˜€๋‹ค. ๊ทธ๋ž˜์„œ ๋ฏธ๋Ÿฌ๋กœ
2398
+ * ์„ ์–ธํ•œ ํŠธ์œˆ์„ ํ™”๋ฉด์—์„œ ์‹œ์ž‘ํ•˜๋ฉด **์‹œ๋ฎฌ๋กœ ๋–ด๋‹ค** โ€” ๊ทธ ํŠธ์œˆ์€ ์œ ์ž…์„ ๋ฐ›์ง€ ๋ชปํ•œ๋‹ค(`ingestLive` ๋Š”
2399
+ * ๋ผ์ด๋ธŒ๊ฐ€ ์•„๋‹ˆ๋ฉด 0 ์„ ๋Œ๋ ค์ค€๋‹ค). ์˜ค๋ฅ˜๋Š” ๋‚˜์ง€ ์•Š๊ณ , ํ™”๋ฉด์—๋Š” ใ€Œrunningใ€์ด๋ผ ์ ํžŒ๋‹ค.
2400
+ *
2401
+ * ๋ถ€ํŒ… ๊ฒฝ๋กœ(`resumeRow`)์—๋Š” ๊ทธ ๊ฐˆ๋ฆผ์ด ์žˆ์—ˆ๋Š”๋ฐ ์—ฌ๊ธฐ์—๋Š” ์—†์—ˆ๋‹ค. ์ฆ‰ **๋ถ€ํŒ…์œผ๋กœ ์‚ด์•„๋‚œ ๋ฏธ๋Ÿฌ๋Š”
2402
+ * ๋ฐ›๊ณ , ์‚ฌ๋žŒ์ด ์‹œ์ž‘ํ•œ ๋ฏธ๋Ÿฌ๋Š” ๋ฐ›์ง€ ๋ชปํ–ˆ๋‹ค.** ๊ฐ™์€ ์„ ์–ธ์ด ๋‘ ๊ฒฐ๊ณผ๋ฅผ ๋‚ด๋Š” ๊ฒƒ์€ ๊ฒฐํ•จ์ด๋‹ค.
2403
+ *
2404
+ * ๊ฐˆ๋ฆผ์„ ์—ฌ๊ธฐ ํ•œ ๊ณณ์— ๋‘”๋‹ค โ€” ๋ถ€ํŒ…๋„ ์ด ๋ฌธ์„ ์ง€๋‚œ๋‹ค.
2405
+ *
2406
+ * โ”€โ”€ `reset` ์€ ์„ ์–ธ๋Œ€๋กœ **์ €๋„์„ ๋น„์šด๋‹ค** (2026-08-20) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2407
+ * ใ€Œseed ์žฌํ˜„ใ€์€ ๋ฐฑ์ง€์—์„œ ๋‹ค์‹œ ์‹œ์ž‘ํ•œ๋‹ค๋Š” ๋œป์ด๋‹ค. ์˜ˆ์ „์—๋Š” ๊ทธ ์„ ์–ธ์ด ๊ตฌํ˜„๋˜์ง€ ์•Š์•„ `reset` ํŠธ์œˆ์ด
2408
+ * ์‚ฌ์‹ค์ƒ ์ด์–ด์กŒ๊ณ , ๊ทธ๋ž˜์„œ ใ€Œ์ด ํŠธ์œˆ์˜ ์ด๋ ฅ์€ ์™œ ์žฌ๊ธฐ๋™์„ ๋„˜๊ฒจ ๋‚จ์•„ ์žˆ๋‚˜ใ€๋ฅผ ์•„๋ฌด๋„ ์„ค๋ช…ํ•  ์ˆ˜ ์—†์—ˆ๋‹ค.
2409
+ *
2410
+ * ์ด๊ฒƒ์€ **์‚ฌ์‹ค์„ ์ง€์šฐ๋Š” ๋™์ž‘**์ด๋ผ ์ˆœ์„œ๋ฅผ ์ง€์ผœ ์ผฐ๋‹ค: ๋จผ์ € ์‚ฌ๋žŒ์ด ๊ฐ ํŠธ์œˆ์˜ ์ •์ฑ…์„ ๋‹ค์‹œ ์„ ์–ธํ•  ๋ฌธ์„
2411
+ * ๋งŒ๋“ค๊ณ (`setTwinRestartPolicy`), ์ด๋ ฅ์„ ๋‘๊ณ  ๋ณผ ํŠธ์œˆ์„ `resume` ์œผ๋กœ ์˜ฎ๊ธด ๋’ค์— ์ผ ๋‹ค. ๊ทธ ์ˆœ์„œ๋ฅผ
2412
+ * ๋’ค์ง‘์œผ๋ฉด ์„ ์–ธ์„ ์ง€ํ‚จ ๋Œ€๊ฐ€๋กœ ๋‚จ์˜ ์ด๋ ฅ์„ ์ง€์šด๋‹ค.
2413
+ */
2414
+ const policy = restartPolicy ?? readRestartPolicy(reg.restartPolicy, `twin "${instanceId}"`)
2415
+ if (policy === 'resync') {
2416
+ /* ๋ฏธ๋Ÿฌ๋Š” **๊ด€์ธก ๊ตฌ๋™**์œผ๋กœ ์„ธ์šด๋‹ค โ€” ์‹œ๋ฎฌ๋กœ ์„ธ์šฐ๋ฉด ์—†๋˜ ์›€์ง์ž„์„ ์Šค์Šค๋กœ ๋งŒ๋“ ๋‹ค.
2417
+ ์‹œ๊ฐ ๊ธฐ์ค€์€ ๊ณต๊ฐ„์ด ๊ฐ–๋Š”๋‹ค(๋ถ€ํŒ… ๊ฒฝ๋กœ์™€ ๊ฐ™์€ ๊ทœ์น™). */
2418
+ return this.startLive(instanceId, domainId, reg.kind, await this.withSpaceTimeBase(reg.model as TwinModelDef, domainId))
2419
+ }
2420
+ if (policy === 'reset') {
2421
+ /*
2422
+ * ์”จ์•— ์žฌํ˜„ โ€” ์ €๋„์„ ๋น„์šฐ๊ณ  ๋ฆฌ๋น„์ „ 0 ๋ถ€ํ„ฐ ๋‹ค์‹œ ์„ผ๋‹ค. ๋ณต๊ตฌ๋ณธ๋„ ํ•จ๊ป˜ ๋ฒ„๋ฆฐ๋‹ค(`resetJournal` ์ด ํ•œ๋‹ค):
2423
+ * ๋‚จ๊ฒจ ๋‘๋ฉด ๋ฐฑ์ง€๋กœ ์‹œ์ž‘ํ•œ ํŠธ์œˆ์— ์˜› ์ƒํƒœ๊ฐ€ ๋˜์‚ด์•„๋‚˜ ใ€Œ์”จ์•— ์žฌํ˜„ใ€์ด ์•„๋‹ˆ๊ฒŒ ๋œ๋‹ค.
2424
+ *
2425
+ * **๋ฒค์น˜ ์‚ฌ๋ณธ์€ ์˜ˆ์™ธ๋‹ค.** ๊ทธ๊ฒƒ์€ ๋ˆ„๊ตฐ๊ฐ€ ์ง€์ผœ๋ณด๋Š” ์‹คํ—˜์ด๊ณ , ๊ทธ ์‹คํ—˜์˜ ๊ธฐ๋ก์„ ๊ธฐ๋™์ด ์ง€์šฐ๋ฉด ์‹คํ—˜์ด
2426
+ * ์‚ฌ๋ผ์ง„๋‹ค(๋ถ€ํŒ…์€ ๋ฒค์น˜๋ฅผ ๋˜์‚ด๋ฆฌ์ง€๋„ ์•Š๋Š”๋‹ค โ€” `resumeRow`).
2427
+ */
2428
+ if (reg.purpose !== 'bench') await this.resetJournal(domainId, instanceId)
2429
+ }
2430
+
1629
2431
  /*
1630
2432
  * ์›œ์Šคํƒ€ํŠธ ์žฌ๋ฃŒ๋ฅผ **์—ฌ๊ธฐ์„œ ํ™•์‹คํžˆ ํ™•๋ณดํ•œ๋‹ค.**
1631
2433
  * `start()` ๋Š” ๋™๊ธฐ๋ผ ์Šค์Šค๋กœ ์บ์‹œ๋ฅผ ์ฝ์„ ์ˆ˜ ์—†์–ด์„œ `recovered` ์— ๋ฏธ๋ฆฌ ๋‹ด๊ฒจ ์žˆ๊ธฐ๋ฅผ ๊ธฐ๋Œ€ํ•˜๋Š”๋ฐ,
@@ -1634,9 +2436,9 @@ export class TwinEngine {
1634
2436
  * ์ฒดํฌํฌ์ธํŠธ ์บ์‹œ ์šฐ์„ (O(1) + ๋ผ์ด๋ธŒ ํŒŒ์ƒ์ƒํƒœ ๋ณด์กด), ์—†์œผ๋ฉด ์ €๋„ replay ํด๋ฐฑ(๋ถ€ํŒ…๊ณผ ๊ฐ™์€ ์ˆœ์„œ).
1635
2437
  */
1636
2438
  if (!this.recovered[key] && reg.purpose !== 'bench') {
1637
- const cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
2439
+ const cached = await this.warmSeedFor(domainId, instanceId).catch(() => null)
1638
2440
  if (cached?.state) {
1639
- this.recovered[key] = { revision: cached.revision, state: cached.state }
2441
+ this.recovered[key] = cached
1640
2442
  } else {
1641
2443
  const state = await this.recover(domainId, instanceId).catch(() => null)
1642
2444
  if (state) this.recovered[key] = { revision: state.revision, state }
@@ -1664,8 +2466,8 @@ export class TwinEngine {
1664
2466
  ๋ฉ”์› ๋Š”๋ฐ, ๊ทธ๊ฑด YMS/MES ํŠธ์œˆ์„ **์กฐ์šฉํžˆ WMS ๋กœ ๋ถ€ํŒ…**์‹œํ‚ค๋Š” ๊ธธ์ด์—ˆ๋‹ค โ€” ์˜ค๋ฅ˜ ์—†์ด ๋‹ค๋ฅธ ๊ณต์žฅ์ด ๋œฌ๋‹ค. */
1665
2467
  reg.kind,
1666
2468
  reg.model as TwinModelDef,
1667
- /* ์ €์žฅ๋œ ๊ฐ’์€ **์—„๊ฒฉํžˆ** ์ฝ๋Š”๋‹ค โ€” ๋ชจ๋ฅด๋Š” ๊ฐ’์„ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋ฉ”์šฐ๋ฉด ๊ทธ ํŠธ์œˆ์ด ์กฐ์šฉํžˆ ๋‹ค๋ฅด๊ฒŒ ์žฌ๊ธฐ๋™ํ•œ๋‹ค. */
1668
- restartPolicy ?? readRestartPolicy(reg.restartPolicy, `twin "${instanceId}"`),
2469
+ /* ์ €์žฅ๋œ ๊ฐ’์€ ์œ„์—์„œ **์—„๊ฒฉํžˆ** ์ฝ์—ˆ๋‹ค โ€” ๋ชจ๋ฅด๋Š” ๊ฐ’์„ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋ฉ”์šฐ๋ฉด ๊ทธ ํŠธ์œˆ์ด ์กฐ์šฉํžˆ ๋‹ค๋ฅด๊ฒŒ ์žฌ๊ธฐ๋™ํ•œ๋‹ค. */
2470
+ policy,
1669
2471
  reg.purpose,
1670
2472
  Number(last?.max ?? 0) || 0
1671
2473
  )
@@ -1685,6 +2487,23 @@ export class TwinEngine {
1685
2487
  if (mode === 'resync') {
1686
2488
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
1687
2489
  if (!reg?.model) throw new Error(`instance "${instanceId}" not provisioned (no model)`)
2490
+ /*
2491
+ * โ”€โ”€ **๋ฏธ๋Ÿฌ๋„ ์”จ์•—์„ ์—ฌ๊ธฐ์„œ ํ™•๋ณดํ•œ๋‹ค** (2026-08-24) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2492
+ *
2493
+ * ์ด ์ค„์ด ์—†์–ด์„œ ๋ฏธ๋Ÿฌ๋Š” ์ €์žฅ๋œ ์ฒดํฌํฌ์ธํŠธ๋ฅผ **ํ•œ ๋ฒˆ๋„ ์ฝ์ง€ ์•Š์•˜๋‹ค.** `startLive` ๋Š” ๋™๊ธฐ๋ผ ์Šค์Šค๋กœ
2494
+ * ์บ์‹œ๋ฅผ ์ฝ์„ ์ˆ˜ ์—†๊ณ  `recovered` ์— ๋‹ด๊ฒจ ์žˆ๊ธฐ๋ฅผ ๊ธฐ๋Œ€ํ•˜๋Š”๋ฐ, ๊ทธ๊ฒƒ์„ ๋‹ด๋Š” ๊ณณ์€ `bootstrap` ๊ณผ
2495
+ * `start` ๋ฟ์ด์—ˆ๋‹ค โ€” ๊ทธ๋ฆฌ๊ณ  ๋ฏธ๋Ÿฌ์˜ ์‹ค์ œ ๊ธฐ๋™ ๊ฒฝ๋กœ๋Š” **์—ฌ๊ธฐ**๋‹ค. ๊ทธ๋ž˜์„œ ใ€Œ์ฝ๋Š” ์ชฝยท์“ฐ๋Š” ์ชฝ์ด ๋‹ค
2496
+ * ์žˆ๋Š”๋ฐ ์•„๋ฌด ์ผ๋„ ์ผ์–ด๋‚˜์ง€ ์•Š๋Š”ใ€ ์ƒํƒœ๊ฐ€ ๋๋‹ค.
2497
+ *
2498
+ * ์ด๊ฒƒ์€ `start` ๊ฐ€ ์ด๋ฏธ ๋ฐฐ์šด ๊ตํ›ˆ๊ณผ ๊ฐ™์€ ์ž๋ฆฌ๋‹ค: ๋ถ€ํŒ… ์ˆœ์„œ์— ๊ธฐ๋Œ€๋ฉด ์–ด๋–ค ๋‚ ์€ ์ƒํƒœ๊ฐ€ ์‚ด์•„๋‚˜๊ณ 
2499
+ * ์–ด๋–ค ๋‚ ์€ ์กฐ์šฉํžˆ ๋นˆ ์ฑ„๋กœ ๋œฌ๋‹ค โ€” **์žฌํ˜„๋˜์ง€ ์•Š๋Š” ๊ฒฐํ•จ์ด ๊ฐ€์žฅ ๋‚˜์˜๋‹ค.** ๊ทธ๋ž˜์„œ ์ˆœ์„œ์— ๊ธฐ๋Œ€์ง€ ์•Š๊ณ 
2500
+ * ์ด ์ž๋ฆฌ์—์„œ ํ™•๋ณดํ•œ๋‹ค(์ด๋ฏธ ๋‹ด๊ฒจ ์žˆ์œผ๋ฉด ๊ทธ๊ฒƒ์„ ์“ด๋‹ค).
2501
+ */
2502
+ const key = runtimeKey(domainId, instanceId)
2503
+ if (!this.recovered[key]) {
2504
+ const seed = await this.warmSeedFor(domainId, instanceId).catch(() => null)
2505
+ if (seed?.state) this.recovered[key] = seed
2506
+ }
1688
2507
  return this.startLive(instanceId, domainId, reg.kind, reg.model as TwinModelDef)
1689
2508
  }
1690
2509
  if (mode === 'resume') {
@@ -1715,6 +2534,23 @@ export class TwinEngine {
1715
2534
  static async resetJournal(domainId: string, instanceId: string): Promise<void> {
1716
2535
  await getRepository(TwinEvent).delete({ domain: { id: domainId } as any, instanceId })
1717
2536
  delete this.recovered[runtimeKey(domainId, instanceId)]
2537
+ /*
2538
+ * โ”€โ”€ **์ฒดํฌํฌ์ธํŠธ๋„ ํ•จ๊ป˜ ๋ฌดํšจ๋กœ ๋งŒ๋“ ๋‹ค** (2026-08-20) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2539
+ *
2540
+ * ์ €๋„๋งŒ ์ง€์šฐ๋ฉด ์”จ์•— ์žฌํ˜„์ด ๋˜์ง€ ์•Š๋Š”๋‹ค: ์›œ์Šคํƒ€ํŠธ๋Š” ์ฒดํฌํฌ์ธํŠธ๋ฅผ **๋จผ์ €** ๋ณด๊ณ , ๊ทธ๊ฒƒ์ด ๋‚จ์•„ ์žˆ์œผ๋ฉด
2541
+ * ๋ฐฑ์ง€๋กœ ์‹œ์ž‘ํ•œ ํŠธ์œˆ์— ์˜› ์ƒํƒœ๊ฐ€ ๋˜์‚ด์•„๋‚œ๋‹ค(์ €๋„์€ ๋น„์—ˆ๋Š”๋ฐ ์žฌ๊ณ ๊ฐ€ ์žˆ๋Š” ํŠธ์œˆ์ด ๋œ๋‹ค).
2542
+ * ์‹œ๊ฐ„์—ฌํ–‰์ด ๋”›๋Š” ์‚ฌ์Šฌ ์บ์‹œ๋„ ๊ฐ™์€ ์ด์œ ๋กœ ๋ฌดํšจ๋‹ค โ€” ์ง€์›Œ์ง„ ๋ฆฌ๋น„์ „์„ ๊ฐ€๋ฆฌํ‚ค๊ฒŒ ๋œ๋‹ค.
2543
+ *
2544
+ * ๊ณตํ†ต ์บ์‹œ ์„œ๋น„์Šค์—๋Š” ์‚ญ์ œ๊ฐ€ ์—†๋‹ค(get/set/clearStale ๋ฟ). ๊ทธ๋ž˜์„œ **ใ€Œ์—†์Œใ€์„ ์ ๋Š”๋‹ค** โ€” ๊ฐ’์„
2545
+ * ์ง€์–ด๋‚ด๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ใ€Œ์ฒดํฌํฌ์ธํŠธ๊ฐ€ ์—†๋‹คใ€๋Š” ์‚ฌ์‹ค์„ ์ ๋Š” ๊ฒƒ์ด๊ณ , ์ฝ๋Š” ์ชฝ์€ ์ด๋ฏธ `cached?.state` ๋กœ
2546
+ * ๊ทธ๊ฒƒ์„ ๊ฐ€๋ ค๋‚ธ๋‹ค. ๊ณตํ†ต ๋ชจ๋“ˆ์— ์‚ญ์ œ๋ฅผ ์ƒˆ๋กœ ๋šซ๋Š” ๊ฒƒ์€ ์ด ํ•œ ์ž๋ฆฌ๋ฅผ ์œ„ํ•ด ํ•˜๊ธฐ์—๋Š” ํฐ ๋ณ€๊ฒฝ์ด๋‹ค.
2547
+ */
2548
+ await cacheService
2549
+ .setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision: 0, state: null }, this.SNAPSHOT_TTL_S)
2550
+ .catch(err => twinWarn(`[twin-engine] "${instanceId}" checkpoint not invalidated โ€” a seed run may inherit old state:`, err?.message ?? err))
2551
+ await cacheService
2552
+ .setInCache(this.CHAIN_INDEX_CACHE_ID, { domainId, instanceId }, { revisions: [] }, this.SNAPSHOT_TTL_S)
2553
+ .catch(err => twinWarn(`[twin-engine] "${instanceId}" chain index not cleared โ€” time travel may point at deleted revisions:`, err?.message ?? err))
1718
2554
  }
1719
2555
 
1720
2556
  /** ์‚ญ์ œ โ€” ์ •์ง€ + ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ ์‚ญ์ œ + ์ €๋„ purge(domain ์Šค์ฝ”ํ”„). */
@@ -1755,27 +2591,60 @@ export class TwinEngine {
1755
2591
  (await getRepository(TwinSpace).find({ where: { domain: { id: domainId } } })).map(s => [s.spaceId, s.name])
1756
2592
  )
1757
2593
  /*
1758
- * **์ตœ์‹  ๋ฆฌ๋น„์ „์€ ํ•œ ๋ฒˆ์— ๋ฌป๋Š”๋‹ค.**
2594
+ * **์ตœ์‹  ๋ฆฌ๋น„์ „์€ ํŠธ์œˆ๋งˆ๋‹ค ์ธ๋ฑ์Šค ๋์—์„œ ํ•œ ํ–‰์”ฉ ์ฝ๋Š”๋‹ค.**
2595
+ *
2596
+ * โ”€โ”€ ๊ทธ๋ฃน ์งˆ์˜๋กœ ๋ฐ”๊พธ์—ˆ๋‹ค๊ฐ€ ๋˜๋Œ๋ ธ๋‹ค (2026-08-22 ์‹ค์ธก) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2597
+ * ์˜ˆ์ „ ์ฃผ์„์€ ์ด๋žฌ๋‹ค: ใ€ŒํŠธ์œˆ๋งˆ๋‹ค `findOne(order revision DESC)` ์„ ๋Œ๊ณ  ์žˆ์—ˆ๋‹ค โ€” 13๊ฐœ๋ฉด ์งˆ์˜
2598
+ * 13๋ฒˆ์ด๊ณ , ํŠธ์œˆ์ด ๋Š˜๋ฉด ๊ทธ๋Œ€๋กœ ์ž๋ž€๋‹ค. ํ•œ ๋ฒˆ์˜ ๊ทธ๋ฃน ์งˆ์˜๋กœ ๋ฐ”๊พผ๋‹ค.ใ€
2599
+ *
2600
+ * **์งˆ์˜ ๊ฐœ์ˆ˜๋ฅผ ์ค„์˜€์ง€๋งŒ ์ผ์˜ ์–‘์„ ๋Š˜๋ ธ๋‹ค.**
1759
2601
  *
1760
- * ํŠธ์œˆ๋งˆ๋‹ค `findOne(order revision DESC)` ์„ ๋Œ๊ณ  ์žˆ์—ˆ๋‹ค โ€” 13๊ฐœ๋ฉด ์งˆ์˜ 13๋ฒˆ์ด๊ณ , ํŠธ์œˆ์ด ๋Š˜๋ฉด
1761
- * ๊ทธ๋Œ€๋กœ ์ž๋ž€๋‹ค. ์ด ๋ชฉ๋ก์€ ํŠธ์œˆ ๊ด€๋ฆฌยทํ˜„์žฅ ๊ตฌ์„ฑยท์—”ํ‹ฐํ‹ฐ ํŒจ๋„์ด ๋ชจ๋‘ ์ฝ๋Š” ์ž๋ฆฌ๋‹ค.
1762
- * ํ•œ ๋ฒˆ์˜ ๊ทธ๋ฃน ์งˆ์˜๋กœ ๋ฐ”๊พผ๋‹ค(๊ฐ™์€ ๋ชจ์–‘์˜ ์„ ๋ก€๊ฐ€ ์ด ํŒŒ์ผ์— ์ด๋ฏธ ์žˆ๋‹ค: structureRev ์ง‘๊ณ„).
2602
+ * findOne ร— 13 `(domain, instance, revision)` ์ธ๋ฑ์Šค **๋์—์„œ ํ•œ ํ–‰** โ€” ๊ฐ๊ฐ O(1)
2603
+ * GROUP BY ํ•œ ๋ฒˆ ๊ทธ ๋„๋ฉ”์ธ์˜ ์ €๋„์„ **์ „์ˆ˜ ์ง‘๊ณ„** โ€” O(์ „์ฒด)
2604
+ *
2605
+ * ์ €๋„์ด ์ž‘์„ ๋•Œ๋Š” ์ด๋“์ด์—ˆ๊ณ , ์ปค์ง€๋ฉด์„œ ์†ํ•ด๊ฐ€ ๋๋‹ค. ์‹ค์ธก(์ €๋„ 1,134๋งŒ ํ–‰): ์ด ๊ทธ๋ฃน ์งˆ์˜๊ฐ€
2606
+ * **44.7์ดˆ**์˜€๋‹ค. ๊ทธ๋ฆฌ๊ณ  sqlite ๋“œ๋ผ์ด๋ฒ„๋Š” ์—ฐ๊ฒฐ์ด ํ•˜๋‚˜์ด๋ฏ€๋กœ ๊ทธ๋™์•ˆ ์•ฑ์˜ ๋ชจ๋“  ์งˆ์˜๊ฐ€ ๊ทธ ๋’ค์— ์„ฐ๋‹ค โ€”
2607
+ * ๊ฐ™์€ ์ˆœ๊ฐ„ 29ํ–‰์งœ๋ฆฌ `twin_instances` ์กฐํšŒ๋„ 44.7์ดˆ๋กœ ์ฐํ˜”๋‹ค. ์ด ๋ชฉ๋ก์€ ใ€ŒํŠธ์œˆ ๊ด€๋ฆฌยทํ˜„์žฅ ๊ตฌ์„ฑยท
2608
+ * ์—”ํ‹ฐํ‹ฐ ํŒจ๋„์ด ๋ชจ๋‘ ์ฝ๋Š” ์ž๋ฆฌใ€์—ฌ์„œ, ํ™”๋ฉด์„ ์—ด ๋•Œ๋งˆ๋‹ค ์„œ๋ฒ„ ์ „์ฒด๊ฐ€ ๊ทธ๋งŒํผ ๋ฉˆ์ท„๋‹ค.
2609
+ *
2610
+ * ํŠธ์œˆ ์ˆ˜๋Š” ์ˆ˜์‹ญ์ด๊ณ  ์ €๋„์€ ์ฒœ๋งŒ์ด๋‹ค. **์ž‘์€ ๊ฒƒ์„ ์—ฌ๋Ÿฌ ๋ฒˆ ์ฝ๋Š” ํŽธ์ด ํฐ ๊ฒƒ์„ ํ•œ ๋ฒˆ ํ›‘๋Š” ๊ฒƒ๋ณด๋‹ค ์‹ธ๋‹ค.**
2611
+ * ์งˆ์˜ ๊ฐœ์ˆ˜๊ฐ€ ํŠธ์œˆ ์ˆ˜์— ๋น„๋ก€ํ•ด ์ž๋ผ๋Š” ๊ฒƒ์€ ์‚ฌ์‹ค์ด์ง€๋งŒ, ๊ฐ ์งˆ์˜๊ฐ€ ์ธ๋ฑ์Šค ๋ ํ•œ ํ–‰์ด๋ฏ€๋กœ ๊ทธ ์„ฑ์žฅ์€
2612
+ * ๊ฐ๋‹น๋œ๋‹ค โ€” ๊ทธ๋ฆฌ๊ณ  ๋ณ‘๋ ฌ๋กœ ๋ฌป๋Š”๋‹ค.
2613
+ *
2614
+ * **๋‹ค์‹œ ๊ทธ๋ฃน ์งˆ์˜๋กœ ๋ฐ”๊พธ์ง€ ๋ง ๊ฒƒ.** ๋ฐ”๊พธ๋ ค๋ฉด ์ด ์ˆซ์ž๋ฅผ ๋จผ์ € ๋‹ค์‹œ ์žฌ๋ผ.
1763
2615
  */
1764
2616
  const tipOfInstance = new Map<string, number>()
1765
- try {
1766
- const tips = await getRepository(TwinEvent)
1767
- .createQueryBuilder('e')
1768
- .select('e.instanceId', 'instanceId')
1769
- .addSelect('MAX(e.revision)', 'revision')
1770
- .where('e.domain = :domainId', { domainId })
1771
- .groupBy('e.instanceId')
1772
- .getRawMany()
1773
- for (const t of tips) if (t?.instanceId != null) tipOfInstance.set(String(t.instanceId), Number(t.revision) || 0)
1774
- } catch (err: any) {
1775
- /* ์ง‘๊ณ„๊ฐ€ ์‹คํŒจํ•˜๋ฉด **0 ์œผ๋กœ ๋ฉ”์šฐ์ง€ ์•Š๋Š”๋‹ค** โ€” ๋ฆฌ๋น„์ „ 0 ์€ "์•„์ง ์•„๋ฌด ์ผ๋„ ์—†์—ˆ๋‹ค" ๋Š” ์‚ฌ์‹ค ์ฃผ์žฅ์ด๋‹ค.
1776
- ๋น„์›Œ ๋‘๋ฉด ์•„๋ž˜์—์„œ `?? 0` ์ด ์•„๋‹ˆ๋ผ undefined ๋กœ ๋‚จ๊ณ , ํ™”๋ฉด์€ ๊ทธ๊ฒƒ์„ "๋ชจ๋ฆ„" ์œผ๋กœ ๊ทธ๋ฆฐ๋‹ค. */
1777
- console.error('[twin-engine] latest revision aggregate failed', err?.message ?? err)
1778
- }
2617
+ await Promise.all(
2618
+ rows.map(async r => {
2619
+ try {
2620
+ /*
2621
+ * **์ง‘๊ณ„ ํ•˜๋‚˜๋กœ ๋ฌป๋Š”๋‹ค โ€” ํ–‰์„ ์‹ค์–ด ์˜ค์ง€ ์•Š๋Š”๋‹ค.**
2622
+ *
2623
+ * `findOne(order revision DESC)` ๋กœ ๋‘์—ˆ๋”๋‹ˆ ๋‘ ๊ฐ€์ง€๊ฐ€ ํ‹€๋ ธ๋‹ค. โ‘  ๊ด€๊ณ„๋ฅผ ๊ฐ€์ง„ ์—”ํ‹ฐํ‹ฐ์˜ ์ •๋ ฌ
2624
+ * ์กฐํšŒ๋Š” TypeORM ์ด `DISTINCT` ๋กœ ๊ฐ์‹ธ๋ฏ€๋กœ `select` ๋กœ ์ปฌ๋Ÿผ์„ ์ขํžˆ๋ฉด `distinctAlias.
2625
+ * TwinEvent_id` ๊ฐ€ ์—†๋‹ค๊ณ  ํ„ฐ์ง„๋‹ค(์‹ค์ธก: ๋ชจ๋“  ํŠธ์œˆ์—์„œ SQLITE_ERROR). โ‘ก `select` ๋ฅผ ๋–ผ๋ฉด
2626
+ * **`payload` ๊นŒ์ง€ ์‹ค์–ด ์˜จ๋‹ค** โ€” ์ด ๊ฐ’ ํ•˜๋‚˜๋ฅผ ์•Œ๋ ค๊ณ  ํฐ TEXT ๋ฅผ ์ฝ๋Š”๋‹ค.
2627
+ *
2628
+ * ๊ทธ๋ž˜์„œ ๋‹จ์ผ ์ง‘๊ณ„๋ฅผ ์“ด๋‹ค. `(domain, instance, revision)` ์ธ๋ฑ์Šค์—์„œ instance ๊ฐ€ ์ •ํ•ด์ง€๋ฉด
2629
+ * `MAX` ๋Š” ๊ทธ ๊ตฌ๊ฐ„์˜ ๋์ด๋ฏ€๋กœ sqlite ๊ฐ€ ๋์„ ์ง‘๋Š”๋‹ค(์‹ค์ธก 0.007์ดˆ ยท ๋„๋ฉ”์ธ ์ „์ฒด GROUP BY ๋Š”
2630
+ * 14.5์ดˆ). ์ด ํŒŒ์ผ์˜ ๋‹ค๋ฅธ ์ž๋ฆฌ๋„ ๊ฐ™์€ ๊ทœ์œจ์ด๋‹ค(ยง`timeRange` ยท ๊ธฐ๋™ ์‹œ ์ตœ์ข… ๋ฆฌ๋น„์ „).
2631
+ */
2632
+ const row = await getRepository(TwinEvent)
2633
+ .createQueryBuilder('e')
2634
+ .select('MAX(e.revision)', 'revision')
2635
+ .where('e.domain = :domainId', { domainId })
2636
+ .andWhere('e.instanceId = :instanceId', { instanceId: r.instanceId })
2637
+ .getRawOne<{ revision: unknown }>()
2638
+ /* ํ–‰์ด ์—†์œผ๋ฉด `null` ์ด๋‹ค โ€” ๊ทธ๋•Œ๋Š” ๋น„์›Œ ๋‘”๋‹ค(0 ์€ "์•„๋ฌด ์ผ๋„ ์—†์—ˆ๋‹ค" ๋Š” ์ฃผ์žฅ์ด๋‹ค). */
2639
+ if (row?.revision != null) tipOfInstance.set(String(r.instanceId), Number(row.revision) || 0)
2640
+ } catch (err: any) {
2641
+ /* ํ•œ ํŠธ์œˆ์˜ ์กฐํšŒ๊ฐ€ ์‹คํŒจํ•ด๋„ **0 ์œผ๋กœ ๋ฉ”์šฐ์ง€ ์•Š๋Š”๋‹ค** โ€” ๋ฆฌ๋น„์ „ 0 ์€ "์•„์ง ์•„๋ฌด ์ผ๋„ ์—†์—ˆ๋‹ค" ๋Š”
2642
+ ์‚ฌ์‹ค ์ฃผ์žฅ์ด๋‹ค. ๋น„์›Œ ๋‘๋ฉด ์•„๋ž˜์—์„œ undefined ๋กœ ๋‚จ๊ณ , ํ™”๋ฉด์€ ๊ทธ๊ฒƒ์„ "๋ชจ๋ฆ„" ์œผ๋กœ ๊ทธ๋ฆฐ๋‹ค.
2643
+ ๊ทธ๋ฆฌ๊ณ  ๊ทธ ํŠธ์œˆ๋งŒ ๋ชจ๋ฆ„์ด ๋œ๋‹ค โ€” ์˜ˆ์ „์—๋Š” ์ง‘๊ณ„ ํ•˜๋‚˜๊ฐ€ ์‹คํŒจํ•˜๋ฉด ์ „๋ถ€ ๋ชจ๋ฆ„์ด์—ˆ๋‹ค. */
2644
+ twinError(`[twin-engine] latest revision lookup failed "${r.instanceId}"`, err?.message ?? err)
2645
+ }
2646
+ })
2647
+ )
1779
2648
 
1780
2649
  const out: any[] = []
1781
2650
  for (const r of rows) {
@@ -2102,11 +2971,11 @@ export class TwinEngine {
2102
2971
  }
2103
2972
  }
2104
2973
  } catch (err: any) {
2105
- console.error(`[twin-engine] structure projection failed for "${master.source}":`, err?.message)
2974
+ twinError(`[twin-engine] structure projection failed for "${master.source}":`, err?.message)
2106
2975
  warnings.push(projectionFailed(err?.message ?? 'unknown'))
2107
2976
  }
2108
2977
 
2109
- if (warnings.length) console.warn(`[twin-engine] ingest "${master.source}" warnings: ${describeWarnings(warnings)}`)
2978
+ if (warnings.length) twinWarn(`[twin-engine] ingest "${master.source}" warnings: ${describeWarnings(warnings)}`)
2110
2979
  return { instanceId: master.source, spaceId, warnings }
2111
2980
  }
2112
2981
 
@@ -2129,15 +2998,15 @@ export class TwinEngine {
2129
2998
  * ๋ณด๋“œ ์ปดํฌ๋„ŒํŠธ๊ฐ€ tag ๋กœ ๊ตฌ๋…(board-ui provider)ํ•ด `component.data` ๋กœ ๋ผ์ด๋ธŒ ๊ฐฑ์‹ . delta ์‹œ์—๋งŒ(ํฌ์†Œ).
2130
2999
  * tag=์—”ํ‹ฐํ‹ฐ id(๋ฐ๋ชจ=๋‹จ์ผ ์ธ์Šคํ„ด์Šค). ๋ฉ€ํ‹ฐ ์ธ์Šคํ„ด์Šค/๋ณด๋“œ ์žฌ์‚ฌ์šฉ ์‹œ tag ๋„ค์ž„์ŠคํŽ˜์ด์Šค๋Š” ํ›„์†.
2131
3000
  */
2132
- /** ๋ฐฉ์†ก ์‹คํŒจ ๋กœ๊ทธ ์กฐ์ ˆ โ€” ์ฐฝ๋งˆ๋‹ค ํ•œ ์ค„(๋ง‰ํžŒ ๊ตฌ๋…์ž๋Š” ์ดˆ๋‹น ์ˆ˜์‹ญ ๋ฒˆ ์‹คํŒจํ•œ๋‹ค). */
3001
+ /** ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ์‹คํŒจ ๋กœ๊ทธ ์กฐ์ ˆ โ€” ์ฐฝ๋งˆ๋‹ค ํ•œ ์ค„(๋ง‰ํžŒ ๊ตฌ๋…์ž๋Š” ์ดˆ๋‹น ์ˆ˜์‹ญ ๋ฒˆ ์‹คํŒจํ•œ๋‹ค). */
2133
3002
  private static publishDrops = new Map<string, { count: number; lastLogMs: number }>()
2134
3003
  private static readonly PUBLISH_DROP_LOG_MS = 10_000
2135
3004
 
2136
3005
  /**
2137
- * ํ•œ ๋ฒˆ์˜ ๋ฐฉ์†ก โ€” **๊ตฌ๋…์ž ํ•˜๋‚˜๊ฐ€ ํ˜ธ์ŠคํŠธ๋ฅผ ์ฃฝ์ด์ง€ ๋ชปํ•˜๊ฒŒ.**
3006
+ * ํ•œ ๋ฒˆ์˜ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… โ€” **๊ตฌ๋…์ž ํ•˜๋‚˜๊ฐ€ ํ˜ธ์ŠคํŠธ๋ฅผ ์ฃฝ์ด์ง€ ๋ชปํ•˜๊ฒŒ.**
2138
3007
  *
2139
3008
  * โ”€โ”€ ๋ฌด์—‡์ด ์ฃฝ์˜€๋‚˜ (2026-08-14) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2140
- * ๋ฐ€๋ฆฐ push ๊ฐ€ 1024๋ฅผ ๋„˜์œผ๋ฉด pubsub ์ด ๋˜์ง„๋‹ค(`RepeaterOverflowError`). ๊ทธ ๋ฐฉ์†ก์€ ํƒ€์ด๋จธ ์ฝœ๋ฐฑ
3009
+ * ๋ฐ€๋ฆฐ push ๊ฐ€ 1024๋ฅผ ๋„˜์œผ๋ฉด pubsub ์ด ์˜ค๋ฅ˜๋ฅผ ๋‚ธ๋‹ค(`RepeaterOverflowError`). ๊ทธ ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์€ ํƒ€์ด๋จธ ์ฝœ๋ฐฑ
2141
3010
  * ์•ˆ์—์„œ ์ผ์–ด๋‚˜๋ฏ€๋กœ ์˜ˆ์™ธ๊ฐ€ ์žกํžˆ๋Š” ๊ณณ ์—†์ด ์˜ฌ๋ผ๊ฐ€ **ํ”„๋กœ์„ธ์Šค๊ฐ€ ๋๋‚ฌ๋‹ค** โ€” ํŠธ์œˆ 14๊ฐœ๊ฐ€ ๋„๋Š” ํ˜ธ์ŠคํŠธ๊ฐ€
2142
3011
  * ์†Œ๋น„๋ฅผ ๋ฉˆ์ถ˜ ๊ตฌ๋…์ž ํ•˜๋‚˜ ๋•Œ๋ฌธ์— ํ†ต์งธ๋กœ.
2143
3012
  *
@@ -2154,7 +3023,7 @@ export class TwinEngine {
2154
3023
  const now = Date.now()
2155
3024
  if (now - d.lastLogMs >= this.PUBLISH_DROP_LOG_MS) {
2156
3025
  d.lastLogMs = now
2157
- console.warn(`[twin-engine] broadcast dropped on "${what}" (${d.count} so far) โ€” ${err?.message ?? err}`)
3026
+ twinWarn(`[twin-engine] broadcast dropped on "${what}" (${d.count} so far) โ€” ${err?.message ?? err}`)
2158
3027
  }
2159
3028
  this.publishDrops.set(what, d)
2160
3029
  return false
@@ -2179,16 +3048,34 @@ export class TwinEngine {
2179
3048
  if (!st) return
2180
3049
  /*
2181
3050
  * ๋ณ€ํ™”ํ•œ ์—”ํ‹ฐํ‹ฐ๋งŒ ๋ฐœํ–‰ โ€” ์ด์ „์—” delta ๋งˆ๋‹ค ์ „ ์—”ํ‹ฐํ‹ฐ(๋…ธ๋“œ+๋ฌด๋ฒ„+์˜ค๋”)๋ฅผ ๊ฐ’ ๋ณ€ํ™”์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ์ „๋Ÿ‰ ์žฌ๋ฐœํ–‰ํ•ด
2182
- * ๊ฐ™์€ ๋ฐ์ดํ„ฐ๋ฅผ ๋งค์ดˆ ๋ฐ˜๋ณต push ํ•˜๊ณ  ์žˆ์—ˆ๋‹ค(์˜ค์šฉ). ์ตœ์‹ -์ƒํƒœ ์ฑ„๋„์ด๋ฏ€๋กœ ๋ฌด๋ณ€ํ™” ์žฌ๋ฐฉ์†ก์€ ๋ฌด์˜๋ฏธํ•˜๋‹ค.
3051
+ * ๊ฐ™์€ ๋ฐ์ดํ„ฐ๋ฅผ ๋งค์ดˆ ๋ฐ˜๋ณต push ํ•˜๊ณ  ์žˆ์—ˆ๋‹ค(์˜ค์šฉ). ์ตœ์‹ -์ƒํƒœ ์ฑ„๋„์ด๋ฏ€๋กœ ๋ฌด๋ณ€ํ™” ์žฌ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…์€ ๋ฌด์˜๋ฏธํ•˜๋‹ค.
2183
3052
  * ์—”ํ‹ฐํ‹ฐ๋ณ„ ์‹œ๊ทธ๋‹ˆ์ฒ˜๋ฅผ ๋น„๊ตํ•ด ๋ฐ”๋€ ๊ฒƒ๋งŒ push.
2184
3053
  */
2185
3054
  const sigs = inst.entitySigs ?? (inst.entitySigs = new Map())
2186
3055
  const seen = new Set<string>()
2187
3056
  /* payload ๋งคํ•‘์€ ์ˆœ์ˆ˜ ํ•จ์ˆ˜(buildEntityDeltas)๋กœ ๋ถ„๋ฆฌ โ€” ์—ฌ๊ธฐ์„  ์‹œ๊ทธ๋‹ˆ์ฒ˜ dedup + ๋ฐœํ–‰๋งŒ.
2188
- * ๋ณ€ํ™”ํ•œ ์—”ํ‹ฐํ‹ฐ๋งŒ ๋ฐœํ–‰(์ตœ์‹ -์ƒํƒœ ์ฑ„๋„์ด๋ผ ๋ฌด๋ณ€ํ™” ์žฌ๋ฐฉ์†ก ๋ฌด์˜๋ฏธ). */
3057
+ * ๋ณ€ํ™”ํ•œ ์—”ํ‹ฐํ‹ฐ๋งŒ ๋ฐœํ–‰(์ตœ์‹ -์ƒํƒœ ์ฑ„๋„์ด๋ผ ๋ฌด๋ณ€ํ™” ์žฌ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ๋ฌด์˜๋ฏธ). */
2189
3058
  /* โ‘ก payload ๋งŒ๋“ค๊ธฐ โ€” ์—”ํ‹ฐํ‹ฐ ์ˆ˜์— ๋น„๋ก€. โ‘ข ์‹œ๊ทธ๋‹ˆ์ฒ˜ ๋น„๊ต + ๋ฐœํ–‰ โ€” ๋ฐ”๋€ ๊ฒƒ ์ˆ˜์— ๋น„๋ก€. */
2190
3059
  const tDelta = performance.now()
2191
- const deltas = buildEntityDeltas(st, inst.id)
3060
+ /*
3061
+ * โ”€โ”€ ๋ฌผํ’ˆ์€ **๊ฑด๋“œ๋ฆฐ ๊ฒƒ๋งŒ** ๋งŒ๋“ ๋‹ค (2026-08-21 ์‹ค์ธก) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3062
+ * ๋งŒ๋“œ๋Š” payload ์˜ ๋Œ€๋ถ€๋ถ„์ด ๋ฌผํ’ˆ์ด๋‹ค(์—”ํ‹ฐํ‹ฐ 3,611 ์ค‘ 2,400). ๋ฐ”๋€ ๊ฒƒ์ด ํ•˜๋‚˜์—ฌ๋„ ์ „๋ถ€ ๋งŒ๋“ค์–ด
3063
+ * ๋ฌธ์ž์—ด๋กœ ๋ฐ”๊พผ ๋’ค ใ€Œ๊ฐ™๋‹คใ€๋ฅผ ํ™•์ธํ•˜๊ณ  ๋ฒ„๋ ธ๋‹ค.
3064
+ *
3065
+ * ๋ฒ”์œ„๋Š” ์ด ์ฐฝ์— ๋“ค์–ด์˜จ ์‚ฌ๊ฑด์—์„œ ๋ชจ์•˜๋‹ค(`touchedItemKeys`). **๋งํ•  ์ˆ˜ ์—†๋Š” ์‚ฌ๊ฑด์ด ํ•˜๋‚˜๋ผ๋„ ์žˆ์œผ๋ฉด
3066
+ * ๋ฒ”์œ„๋Š” ์—†๊ณ  ์ „๋ถ€ ๋งŒ๋“ ๋‹ค** โ€” ๋‚ฏ์„  ์–ดํœ˜๊ฐ€ ์˜ค๋ฉด ์กฐ์šฉํžˆ ๋น ๋œจ๋ฆฌ๋Š” ๋Œ€์‹  ๋น„์‹ธ๊ฒŒ ์•ˆ์ „ํ•œ ์ชฝ์œผ๋กœ ๋–จ์–ด์ง„๋‹ค.
3067
+ *
3068
+ * ๊ทธ๋ฆฌ๊ณ  ์ฃผ๊ธฐ๋งˆ๋‹ค ํ•œ ๋ฒˆ์€ **๋ฌด์กฐ๊ฑด ์ „๋ถ€** ๋งŒ๋“ ๋‹ค(`FULL_BROADCAST_EVERY`). ์ปค๋„์ด ์‚ฌ๊ฑด ์—†์ด ๋ฌผํ’ˆ์„
3069
+ * ๋ฐ”๊พธ๋Š” ์ž๋ฆฌ๊ฐ€ ์ƒ๊ธฐ๋ฉด ๊ทธ ๊ฐ’์ด ํ™”๋ฉด์— ๋‚จ์„ ์ˆ˜ ์žˆ๋Š”๋ฐ, ๊ทธ ์ฐฝ์„ ๋ช‡ ์ดˆ๋กœ ๋ฌถ๋Š” ๊ทธ๋ฌผ์ด๋‹ค. **๋ณด์žฅ์ด ์•„๋‹ˆ๋ผ
3070
+ * ๊ทธ๋ฌผ์ด๋‹ค** โ€” ์‚ฌ๊ฑด ์—†์ด ๋ฐ”๋€Œ๋Š” ์ž๋ฆฌ๋ฅผ ์ฐพ์œผ๋ฉด ๊ทธ๊ฒƒ์„ ๊ณ ์น˜๋Š” ๊ฒƒ์ด ๋‹ต์ด๊ณ  ์ด ๊ทธ๋ฌผ์€ ์‹œ๊ฐ„์„ ๋ฒŒ ๋ฟ์ด๋‹ค.
3071
+ */
3072
+ const full = inst.fullBroadcastDue === true || !inst.dirtyItems
3073
+ const deltas = buildEntityDeltas(st, inst.id, full ? undefined : { items: inst.dirtyItems! })
3074
+ this.broadcastPasses++
3075
+ if (full) this.broadcastFullPasses++
3076
+ /* ์ด๋ฒˆ ์ฐฝ์˜ ๋ฒ”์œ„๋Š” ์—ฌ๊ธฐ์„œ ๋‹ซ๋Š”๋‹ค โ€” ๋‹ค์Œ ์ฐฝ์€ ๋‹ค์‹œ ๋ชจ์€๋‹ค. */
3077
+ inst.dirtyItems = new Set()
3078
+ inst.fullBroadcastDue = false
2192
3079
  recordPhase(load, 'deltas', performance.now() - tDelta)
2193
3080
 
2194
3081
  const tPub = performance.now()
@@ -2204,14 +3091,21 @@ export class TwinEngine {
2204
3091
  if (!this.publishGuarded('data', { data: { domain, tag, data } }, `data:${inst.id}`)) sigs.delete(tag)
2205
3092
  }
2206
3093
  recordPhase(load, 'publish', performance.now() - tPub)
2207
- /* ์‚ฌ๋ผ์ง„ ์—”ํ‹ฐํ‹ฐ์˜ ์‹œ๊ทธ๋‹ˆ์ฒ˜ ์ •๋ฆฌ(๋งต ๋ฌดํ•œ ์„ฑ์žฅ ๋ฐฉ์ง€). */
2208
- if (sigs.size > seen.size) for (const tag of sigs.keys()) if (!seen.has(tag)) sigs.delete(tag)
3094
+ /*
3095
+ * ์‚ฌ๋ผ์ง„ ์—”ํ‹ฐํ‹ฐ์˜ ์‹œ๊ทธ๋‹ˆ์ฒ˜ ์ •๋ฆฌ(๋งต ๋ฌดํ•œ ์„ฑ์žฅ ๋ฐฉ์ง€) โ€” **์ „๋ถ€ ๋งŒ๋“  ์ฐฝ์—์„œ๋งŒ.**
3096
+ * ๋ฒ”์œ„๋ฅผ ์ขํžŒ ์ฐฝ์˜ `seen` ์—๋Š” ๋งŒ๋“ค์ง€ ์•Š์€ ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์—†์œผ๋ฏ€๋กœ, ๊ทธ๋•Œ ์ •๋ฆฌํ•˜๋ฉด ์‚ด์•„ ์žˆ๋Š” ํƒœ๊ทธ์˜
3097
+ * ์‹œ๊ทธ๋‹ˆ์ฒ˜๋ฅผ ์ง€์šด๋‹ค. ๋‚ก์€ ๊ฐ’์ด ๋‚˜๊ฐ€๋Š” ๊ฒƒ์€ ์•„๋‹ˆ์ง€๋งŒ(๋‹ค์Œ ์ฐฝ์— ๋‹ค์‹œ ๋งŒ๋“ค์–ด ๋ณด๋‚ธ๋‹ค) ๊ฐ™์€ ๊ฐ’์„
3098
+ * ๋˜ํ’€์–ด ๋ณด๋‚ด๊ฒŒ ๋˜์–ด, ์ค„์ด๋ ค๋˜ ๊ฒƒ์„ ๋˜๋Œ๋ฆฐ๋‹ค.
3099
+ */
3100
+ if (full && sigs.size > seen.size) for (const tag of sigs.keys()) if (!seen.has(tag)) sigs.delete(tag)
2209
3101
  }
2210
3102
 
2211
- static async persist(domainId: string, instanceId: string, msg: any): Promise<void> {
2212
- const repo = getRepository(TwinEvent)
2213
- await repo.save(this.journalRow(repo, domainId, instanceId, msg.event, msg.revision, await this.structureRevOf(domainId, instanceId)))
2214
- }
3103
+ /*
3104
+ * ๋‹จ๊ฑด ์ €์žฅ(`persist`)์€ **์—†์•ด๋‹ค** (2026-08-20, ยง7.1).
3105
+ *
3106
+ * ์‹œ๋ฎฌ์ด ๋ธํƒ€๋งˆ๋‹ค ์ด๊ฒƒ์„ ๋ถˆ๋Ÿฌ ํ–‰ ํ•˜๋‚˜์”ฉ ์ผ๊ณ , ๊ทธ๊ฒƒ์ด ๊ทœ๋ชจ์—์„œ ํ˜ธ์ŠคํŠธ๋ฅผ ๋จน์—ˆ๋‹ค. ๋‚จ๊ฒจ ๋‘๋ฉด ๋‹ค์Œ ์‚ฌ๋žŒ์ด
3107
+ * ๋‹ค์‹œ ๋ถ€๋ฅผ ์ž๋ฆฌ๊ฐ€ ๋˜๋ฏ€๋กœ ์ง€์šด๋‹ค โ€” ์“ฐ๋Š” ๋ฌธ์€ `flushJournal` ํ•˜๋‚˜๋‹ค(`persistBatch`ยท`persistCarried`).
3108
+ */
2215
3109
 
2216
3110
  /**
2217
3111
  * ์žฌ๋ถ€ํŒ… ๋ณต๊ตฌ / ์‹œ๊ฐ„์—ฌํ–‰ โ€” DB ์ €๋„์„ replay ํ•ด ์ƒํƒœ ์žฌ๊ตฌ์„ฑ.
@@ -2303,6 +3197,10 @@ export class TwinEngine {
2303
3197
  const baseWhere = { domain: { id: domainId }, instanceId }
2304
3198
  /* ์ด์–ด ์ ‘์„ ๋•Œ๋Š” **๊ทธ ๋’ค๋งŒ** ์ฝ๋Š”๋‹ค โ€” ์žฌ๊ฐœ์ ๊นŒ์ง€์˜ ์‚ฌ์‹ค์€ ์ด๋ฏธ ์”จ์•— ์•ˆ์— ์žˆ๋‹ค. */
2305
3199
  const from = resume ? (resume.revision ?? 0) : undefined
3200
+ /*
3201
+ * journal-fold: ์žฌ์ƒ์€ ์‚ฌ์‹ค์„ ํ•˜๋‚˜์”ฉ ์ ‘๋Š” ๊ฒƒ์ด๋ฏ€๋กœ ๊ทธ ๊ตฌ๊ฐ„์˜ ํ–‰์ด ํ•„์š”ํ•˜๋‹ค. ์ปค์„œ(`from`)์™€ ์‹œ๊ฐ
3202
+ * ์ƒํ•œ์ด ๊ตฌ๊ฐ„์„ ์ž๋ฅด๊ณ , ์ฒดํฌํฌ์ธํŠธ๊ฐ€ ์•ž์ชฝ์„ ์”จ์•—์œผ๋กœ ๋Œ€์‹ ํ•œ๋‹ค โ€” ํ‘œ ์ „์ฒด๋ฅผ ์ฝ์ง€ ์•Š๋Š”๋‹ค.
3203
+ */
2306
3204
  const rows = await getRepository(TwinEvent).find({
2307
3205
  where: useTime
2308
3206
  ? from
@@ -2483,22 +3381,71 @@ export class TwinEngine {
2483
3381
  /**
2484
3382
  * ๊ณต๊ฐ„(๊ณต๋™๋ฐฐ์น˜) ์‹œ๊ฐ ๋ฒ”์œ„ โ€” ์Šคํฌ๋Ÿฌ๋ฒ„ ์•ต์ปค(runtime-state-model ยง4ยทยง6). ๊ทธ ๊ณต๊ฐ„ ์ „ ์ธ์Šคํ„ด์Šค ์ €๋„์˜ min/max eventTime.
2485
3383
  * ๋ฐ˜ํ™˜ {minTime, maxTime}(ISO) โ€” ์ด๋ฒคํŠธ/์‹œ๊ฐ ์—†์œผ๋ฉด null. ํด๋ผ ํžˆ์Šคํ† ๋ฆฌ ์Šคํฌ๋Ÿฌ๋ฒ„๊ฐ€ ์ด ๋ฒ”์œ„๋ฅผ ์‹œ๊ฐ์ถ•์œผ๋กœ ๊ทธ๋ฆฐ๋‹ค.
3384
+ *
3385
+ * โ”€โ”€ ๋‘ ์ˆ˜๋ฅผ ๊ตฌํ•˜๋ ค๊ณ  ์ €๋„์„ ๋‹ค ์ฝ์ง€ ์•Š๋Š”๋‹ค (2026-08-20 ์‹ค์ธก์œผ๋กœ ์žก์Œ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3386
+ * ์—ฌ๊ธฐ์„œ ์ธ์Šคํ„ด์Šค๋งˆ๋‹ค `find()` ๋กœ **ํ–‰์„ ์ „๋ถ€ ์—”ํ‹ฐํ‹ฐ๋กœ ํ•˜์ด๋“œ๋ ˆ์ด์…˜**ํ•œ ๋‹ค์Œ JS ์—์„œ min/max ๋ฅผ
3387
+ * ๊ณจ๋ž๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ์ด ํ•จ์ˆ˜๋Š” ํ™”๋ฉด ์ƒ๋‹จ์˜ ์ปจํ…์ŠคํŠธ ๋ ๊ฐ€ **4์ดˆ๋งˆ๋‹ค** ๋ถ€๋ฅธ๋‹ค. ์‹ค์ธกํ•œ ๊ฐœ๋ฐœ ์„œ๋ฒ„์—์„œ
3388
+ * `order-check` ํ•œ ํŠธ์œˆ์ด 275,882ํ–‰ ยท `payload` 131MB ์˜€๋‹ค โ€” 4์ดˆ๋งˆ๋‹ค ๊ทธ JSON ์„ ์ „๋ถ€ ํŒŒ์‹ฑํ•œ ๊ฒƒ์ด๋‹ค.
3389
+ *
3390
+ * ๊ทธ ๊ฒฐ๊ณผ๊ฐ€ ์ด๋žฌ๋‹ค: ํ”„๋กœ์„ธ์Šค CPU 81~152%, ์•„๋ฌด ์ผ๋„ ํ•˜์ง€ ์•Š๋Š” ์งˆ์˜๊ฐ€ 8~14์ดˆ. JS ํ”„๋กœํŒŒ์ผ์˜ ์ƒ์œ„๊ฐ€
3391
+ * TypeORM ์˜ `RelationIdLoader`ยท`RawSqlResultsToEntityTransformer`ยท`stringToSimpleJson`(= `payload`
3392
+ * ํŒŒ์‹ฑ)์ด๊ณ  GC ๊ฐ€ 16.7% ์˜€๋‹ค. **ํŠธ์œˆ ํ‹ฑ๋„ ์ €๋„ ์“ฐ๊ธฐ๋„ ์•„๋‹ˆ๋ผ ์ด ์ฝ๊ธฐ์˜€๋‹ค.**
3393
+ *
3394
+ * ์ง‘๊ณ„๋Š” DB ๊ฐ€ ํ•œ๋‹ค. `(domain, instanceId, eventTime)` ์ธ๋ฑ์Šค๊ฐ€ ์ด๋ฏธ ์žˆ์–ด(`ix_twin_event_1`)
3395
+ * ์ธ๋ฑ์Šค์˜ **์–‘ ๋์„ ์ง‘๋Š”๋‹ค** โ€” ํ–‰์„ ํ•˜๋‚˜๋„ ์‹ค์–ด ์˜ค์ง€ ์•Š๋Š”๋‹ค.
3396
+ *
3397
+ * โ”€โ”€ ์™œ MIN ๊ณผ MAX ๋ฅผ ํ•œ ๋ฌธ์žฅ์— ๋„ฃ์ง€ ์•Š๋‚˜ (์‹ค์ธก) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3398
+ * ์ฒ˜์Œ์— `SELECT MIN(...), MAX(...) ... WHERE instanceId IN (...)` ํ•œ ๋ฐฉ์œผ๋กœ ๋‘์—ˆ๋”๋‹ˆ **21ms** ์˜€๋‹ค.
3399
+ * ์ง‘๊ณ„๊ฐ€ **๋‘˜์ด๋ฉด** ์˜ตํ‹ฐ๋งˆ์ด์ €์˜ ใ€Œ์ธ๋ฑ์Šค ๋์„ ์ง‘๋Š”ใ€ ์ตœ์ ํ™”๊ฐ€ ๊ฑธ๋ฆฌ์ง€ ์•Š์•„ ์ธ๋ฑ์Šค ๊ตฌ๊ฐ„์„ ํ›‘๋Š”๋‹ค โ€”
3400
+ * ์ฆ‰ ๋น„์šฉ์ด ์—ฌ์ „ํžˆ **ํ–‰ ์ˆ˜์— ๋น„๋ก€**ํ•œ๋‹ค. ํŠธ์œˆ๋งˆ๋‹ค ๋‹จ์ผ ์ง‘๊ณ„๋กœ ๋‚˜๋ˆ  ๋ฌผ์œผ๋ฉด **0.32ms**(ํŠธ์œˆ 3๊ฐœ ยท 6์™•๋ณต)
3401
+ * ์ด๊ณ , ๋น„์šฉ์ด **ํŠธ์œˆ ์ˆ˜์— ๋น„๋ก€**ํ•œ๋‹ค. ๊ทœ๋ชจ ๊ธฐ์ค€(์—”ํ‹ฐํ‹ฐ 10๋งŒ)์—์„œ๋Š” ์ด ์ฐจ์ด๊ฐ€ ๋ณธ์งˆ์ด๋‹ค.
3402
+ *
3403
+ * ์™•๋ณต์€ ํŠธ์œˆ๋‹น ๋‘˜์ด์ง€๋งŒ **ํ•จ๊ป˜ ๋„์šด๋‹ค** โ€” ์›๊ฒฉ DB ์—์„œ ์ง๋ ฌ๋กœ ๋Œ๋ฉด ์™•๋ณต ์ง€์—ฐ์ด ๊ทธ๋Œ€๋กœ ์Œ“์ธ๋‹ค.
3404
+ *
3405
+ * ๋“œ๋ผ์ด๋ฒ„ ๋‹ค์„ฏ์„ ๋‹ค ์ง€๋‚˜๊ฐ€์•ผ ํ•˜๋ฏ€๋กœ raw SQL ์„ ์“ฐ์ง€ ์•Š๋Š”๋‹ค(์ฟผ๋ฆฌ๋นŒ๋”์˜ MIN/MAX ๋Š” ์ด์‹๋œ๋‹ค).
3406
+ * ๋Œ๋ ค์ฃผ๋Š” ๊ฐ’์˜ **๋ชจ์–‘์€ ๋“œ๋ผ์ด๋ฒ„๋งˆ๋‹ค ๋‹ค๋ฅด๋‹ค**(๋ฌธ์ž์—ดยทDate) โ€” ๋ฐ›์€ ๋’ค์— ํ•œ ๋ฒˆ ์ •๊ทœํ™”ํ•œ๋‹ค(`parseTime`).
2486
3407
  */
2487
3408
  static async timeRange(domainId: string, spaceId: string): Promise<{ minTime: string | null; maxTime: string | null }> {
2488
3409
  const regs = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, spaceId } })
2489
3410
  const ids = regs.map(r => r.instanceId)
2490
3411
  if (!ids.length) return { minTime: null, maxTime: null }
3412
+
3413
+ /**
3414
+ * ํ•œ ํŠธ์œˆ ์ €๋„์˜ ์‹œ๊ฐ ์–‘ ๋ ํ•˜๋‚˜ โ€” ๋‹จ์ผ ์ง‘๊ณ„๋ผ ์ธ๋ฑ์Šค ๋์„ ์ง‘๋Š”๋‹ค.
3415
+ *
3416
+ * ์ง‘๊ณ„์‹์„ **์กฐ๋ฆฝํ•˜์ง€ ์•Š๊ณ  ํ‘œ์— ๋ฆฌํ„ฐ๋Ÿด๋กœ ์ ๋Š”๋‹ค.** `` `${agg}(e.eventTime)` `` ๋กœ ๋งŒ๋“ค๋ฉด ์ด ์ž๋ฆฌ๊ฐ€
3417
+ * ์ง‘๊ณ„๋ฅผ ์“ฐ๋Š”์ง€ ํ›‘๋Š” ๊ฒ€์‚ฌ(`journal-read-discipline`)๊ฐ€ ์ฐพ์ง€ ๋ชปํ•œ๋‹ค โ€” ์‹ค์ œ๋กœ ๊ทธ๋ ‡๊ฒŒ ๋นจ๊ฐœ์กŒ๋‹ค.
3418
+ * ๋ฒˆ์—ญ ํ‚ค์—์„œ ๊ฒช์€ ๊ฒƒ๊ณผ ๊ฐ™์€ ๊ทœ์œจ์ด๋‹ค: ๊ฒ€์‚ฌ๊ฐ€ ์ผํ•˜๊ฒŒ ๋‘๋Š” ํŽธ์ด ๋‚ซ๋‹ค.
3419
+ */
3420
+ const AGG_SELECT = { MIN: 'MIN(e.eventTime)', MAX: 'MAX(e.eventTime)' } as const
3421
+ const edge = async (instanceId: string, agg: 'MIN' | 'MAX'): Promise<number | null> => {
3422
+ const row = await getRepository(TwinEvent)
3423
+ .createQueryBuilder('e')
3424
+ .select(AGG_SELECT[agg], 't')
3425
+ .where('e.domain = :domainId', { domainId })
3426
+ .andWhere('e.instanceId = :instanceId', { instanceId })
3427
+ .getRawOne<{ t: unknown }>()
3428
+ return parseTime(row?.t)
3429
+ }
3430
+
2491
3431
  let min = Infinity
2492
3432
  let max = -Infinity
2493
- for (const instanceId of ids) {
2494
- const rows = await getRepository(TwinEvent).find({ where: { domain: { id: domainId }, instanceId } })
2495
- for (const r of rows) {
2496
- const t = r.eventTime != null ? Date.parse(String(r.eventTime)) : NaN
2497
- if (Number.isNaN(t)) continue
2498
- if (t < min) min = t
2499
- if (t > max) max = t
3433
+
3434
+ /*
3435
+ * ํ•œ ๋ฒˆ์— ๋„์šฐ๋Š” ์ˆ˜๋ฅผ ๋ฌถ๋Š”๋‹ค โ€” ๊ณต๊ฐ„์— ํŠธ์œˆ์ด ์ˆ˜๋ฐฑ์ด๋ฉด ์™•๋ณต ์ˆ˜๋ฐฑ ๊ฐœ๋ฅผ ๋™์‹œ์— ๋ณด๋‚ด ์ปค๋„ฅ์…˜ ํ’€์„
3436
+ * ๋ง๋ ค ๋ฒ„๋ฆฐ๋‹ค(์ด ํ•จ์ˆ˜ ํ•˜๋‚˜ ๋•Œ๋ฌธ์— ๋‹ค๋ฅธ ์งˆ์˜๊ฐ€ ๊ธฐ๋‹ค๋ฆฌ๊ฒŒ ๋œ๋‹ค).
3437
+ */
3438
+ for (let i = 0; i < ids.length; i += TIME_RANGE_FANOUT) {
3439
+ const batch = ids.slice(i, i + TIME_RANGE_FANOUT)
3440
+ const edges = await Promise.all(batch.flatMap(id => [edge(id, 'MIN'), edge(id, 'MAX')]))
3441
+ for (let k = 0; k < edges.length; k += 2) {
3442
+ const lo = edges[k]
3443
+ const hi = edges[k + 1]
3444
+ if (lo !== null && lo < min) min = lo
3445
+ if (hi !== null && hi > max) max = hi
2500
3446
  }
2501
3447
  }
3448
+
2502
3449
  if (min === Infinity) return { minTime: null, maxTime: null }
2503
3450
  return { minTime: new Date(min).toISOString(), maxTime: new Date(max).toISOString() }
2504
3451
  }
@@ -2519,14 +3466,20 @@ export class TwinEngine {
2519
3466
  * ์ฒดํฌํฌ์ธํŠธ ๋ฃจํ”„๊ฐ€ 20์ดˆ๋งˆ๋‹ค ๋œจ์ง€๋งŒ ๊ทธ ์‚ฌ์ด์— ๋ฉˆ์ถ”๋ฉด ๊ทธ ๊ตฌ๊ฐ„์ด ํ†ต์งธ๋กœ ๋‹ค์‹œ ์ ‘ํžŒ๋‹ค.
2520
3467
  */
2521
3468
  await this.persistSnapshot(domainId, id).catch(err =>
2522
- console.error(`[twin-engine] snapshot on stop fail "${id}"`, err?.message ?? err)
3469
+ twinError(`[twin-engine] snapshot on stop fail "${id}"`, err?.message ?? err)
2523
3470
  )
3471
+ /*
3472
+ * **๋ชจ์•„ ๋‘” ์ €๋„๋„ ์ง€๊ธˆ ํ˜๋ฆฐ๋‹ค** โ€” ์ฃผ๊ธฐ flush ์‚ฌ์ด(์ตœ๋Œ€ `BROADCAST_COALESCE_MS`)์— ๋ฉˆ์ถ”๋ฉด ๊ทธ ๊ตฌ๊ฐ„์˜
3473
+ * ์‚ฌ์‹ค์ด ์‚ฌ๋ผ์ง„๋‹ค. ์Šค๋ƒ…์ƒท๋งŒ ๋‚จ๊ธฐ๋ฉด ์ƒํƒœ๋Š” ์‚ด์•„๋„ **์™œ ๊ทธ๋ ‡๊ฒŒ ๋๋Š”์ง€**๊ฐ€ ๋นˆ๋‹ค(์ €๋„์ด ๋‹ตํ•˜๋Š” ๊ฒƒ์ด๋‹ค).
3474
+ * ์“ฐ๊ธฐ๋Š” ์ด ํ•จ์ˆ˜๋ฅผ ๊ธฐ๋‹ค๋ฆฌ์ง€ ์•Š์ง€๋งŒ(๋น„์ฐจ๋‹จ) ๋ฒ„ํผ๋Š” ์—ฌ๊ธฐ์„œ ๋น„์›Œ์ง€๋ฏ€๋กœ ๋‹ค์Œ ๊ธฐ๋™์ด ๋‘ ๋ฒˆ ์ ์ง€ ์•Š๋Š”๋‹ค.
3475
+ */
3476
+ await this.flushJournal(i)
2524
3477
  clearInterval(i.timer)
2525
3478
  i.unsub()
2526
3479
  delete this.instances[runtimeKey(domainId, id)]
2527
3480
  await getRepository(TwinInstance)
2528
3481
  .update({ domain: { id: i.domainId }, instanceId: id }, { status: 'stopped' })
2529
- .catch(err => console.error('twin deregister fail', err))
3482
+ .catch(err => twinError('twin deregister fail', err))
2530
3483
  }
2531
3484
  for (const hook of this.stopHooks) { try { hook(id) } catch { /* ํ›… ๊ฒฉ๋ฆฌ */ } }
2532
3485
  }
@@ -2585,7 +3538,7 @@ export class TwinEngine {
2585
3538
  const took = performance.now() - t0
2586
3539
  recordPhase(load, 'tick', took)
2587
3540
  const j = judgeCycle(load, took, this.TICK_MS, Date.now())
2588
- if (j.warn) console.warn(slowTickMessage(id, took, j.budgetMs, load))
3541
+ if (j.warn) twinWarn(slowTickMessage(id, took, j.budgetMs, load))
2589
3542
  this.guardStarvation(domainId, id, took)
2590
3543
  }
2591
3544
  } catch (err: any) {
@@ -2635,9 +3588,9 @@ export class TwinEngine {
2635
3588
 
2636
3589
  /** ๋ฉˆ์ถ”๊ณ  **์ด์œ ๋ฅผ ๋‚จ๊ธด๋‹ค** โ€” ์ด์œ  ์—†๋Š” ใ€Œ์ •์ง€ใ€๋Š” ์‚ฌ๋žŒ์ด ์ž๊ธฐ๊ฐ€ ๋ฉˆ์ถ˜ ๊ฒƒ์œผ๋กœ ์ฝ๋Š”๋‹ค. */
2637
3590
  private static stopWithNote(domainId: string, id: string, note: StopNote, log: string): void {
2638
- console.error(log)
3591
+ twinError(log)
2639
3592
  this.stopNotes.set(runtimeKey(domainId, id), note)
2640
- this.stop(domainId, id).catch(err => console.error(`[twin-engine] stop after guard failed "${id}"`, err?.message ?? err))
3593
+ this.stop(domainId, id).catch(err => twinError(`[twin-engine] stop after guard failed "${id}"`, err?.message ?? err))
2641
3594
  }
2642
3595
 
2643
3596
  /** ์ด ํŠธ์œˆ์ด ์Šค์Šค๋กœ ๋ฉˆ์ถ˜ ์ด์œ (์žˆ์œผ๋ฉด) โ€” ํ™”๋ฉด์ด ์˜ฎ๊ฒจ ๋งํ•œ๋‹ค. */
@@ -2699,7 +3652,7 @@ export class TwinEngine {
2699
3652
  }
2700
3653
  if (ack?.accepted && emitted.length) {
2701
3654
  inst!.pendingJournal = [...(inst!.pendingJournal ?? []), ...emitted]
2702
- inst!.dirty = true // ์ฝ”์–ผ๋ ˆ์„œ๊ฐ€ ์ด๋ฒˆ ์ฃผ๊ธฐ์— ๋น„์šฐ๊ณ  ๋ฐฉ์†ก๊นŒ์ง€ ํ•˜๊ฒŒ ํ•œ๋‹ค
3655
+ inst!.dirty = true // ์ฝ”์–ผ๋ ˆ์„œ๊ฐ€ ์ด๋ฒˆ ์ฃผ๊ธฐ์— ๋น„์šฐ๊ณ  ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ…๊นŒ์ง€ ํ•˜๊ฒŒ ํ•œ๋‹ค
2703
3656
  }
2704
3657
  return ack ?? { accepted: false, errorCode: 'unknown-command', error: 'unknown-command' }
2705
3658
  }
@@ -2790,7 +3743,7 @@ export class TwinEngine {
2790
3743
  instanceId: id,
2791
3744
  ingestedTotal: m.ingestedTotal, broadcastTotal: m.broadcastTotal, journaledTotal: m.journaledTotal,
2792
3745
  ingestRate: m.ingestRate, broadcastRate: m.broadcastRate, journalRate: m.journalRate,
2793
- backlog: m.backlog, broadcastCoalesceMs: this.BROADCAST_COALESCE_MS,
3746
+ backlog: m.backlog, broadcastCoalesceMs: this.broadcastPeriodMs,
2794
3747
  /* ์ž‘์—…๋ณ„ ๋ถ€ํ•˜ โ€” ๋ฌด๊ฑฐ์šด ๊ฒƒ๋ถ€ํ„ฐ. ์žฐ ์ ์ด ์—†์œผ๋ฉด null(0 ์œผ๋กœ ์ฑ„์šฐ๋ฉด "๋น ๋ฅด๋‹ค" ๋กœ ์ฝํžŒ๋‹ค). */
2795
3748
  load: loadSummary(this.instances[runtimeKey(domainId, id)]?.load, this.TICK_MS)
2796
3749
  }
@@ -2841,7 +3794,15 @@ export class TwinEngine {
2841
3794
 
2842
3795
  return {
2843
3796
  tickMs: this.TICK_MS,
2844
- broadcastCoalesceMs: this.BROADCAST_COALESCE_MS,
3797
+ broadcastCoalesceMs: this.broadcastPeriodMs,
3798
+ /* ๋ธŒ๋กœ๋“œ์บ์ŠคํŒ… ์ฃผ๊ธฐ๋ฅผ ๋Š˜๋ฆฐ ํšŸ์ˆ˜ โ€” ๋Š˜์–ด๋‚œ ์ฃผ๊ธฐ๋งŒ ๋ณด๋ฉด ใ€Œ์›๋ž˜ ๊ทธ๋Ÿฐ ๊ฐ’ใ€์œผ๋กœ ์ฝํžŒ๋‹ค. */
3799
+ broadcastBackoffs: this.broadcastBackoffs,
3800
+ /* ๋ฌผํ’ˆ ๋ฒ”์œ„๋ฅผ ์ขํžˆ์ง€ ๋ชปํ•ด ์ „๋ถ€ ๋งŒ๋“  ํšŸ์ˆ˜ โ€” ์ขํžˆ๊ธฐ๊ฐ€ ์‹ค์ œ๋กœ ๋“ฃ๊ณ  ์žˆ๋Š”์ง€๋ฅผ ์ด ๊ฐ’์œผ๋กœ ๋ณธ๋‹ค. */
3801
+ broadcastPasses: this.broadcastPasses,
3802
+ broadcastFullPasses: this.broadcastFullPasses,
3803
+ broadcastFullEvery: this.FULL_BROADCAST_EVERY,
3804
+ /* **๋ฃจํ”„๊ฐ€ ์‹ค์ œ๋กœ ์–ผ๋งˆ๋‚˜ ๋ง‰ํ˜”๋‚˜** โ€” ํŠธ์œˆ ๋ถ€ํ•˜์™€ ๋‚˜๋ž€ํžˆ ๋†“๊ณ  ์›์ธ์„ ๊ฐ€๋ฆฐ๋‹ค(loop-lag.ts ์ฃผ์„). */
3805
+ loopLag: loopLag.view(),
2845
3806
  ...fleetLoad(rows, this.TICK_MS),
2846
3807
  /* ์ค„๋งˆ๋‹ค ์ƒ์„ธ โ€” ํ™”๋ฉด์ด ํŽผ์ณ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ. ์ตœ๊ทผ ๋ถ€ํ•˜ ์ˆœ์„œ๋Š” fleetLoad ๊ฐ€ ์ •ํ•œ๋‹ค. */
2847
3808
  details: keys.map(key => { const at = parseRuntimeKey(key); return this.load(at.domainId, at.instanceId) }).filter(Boolean)
@@ -2860,7 +3821,204 @@ export class TwinEngine {
2860
3821
  if (inst) recordPhase(inst.load ?? (inst.load = newLoadMeter()), phase, tookMs)
2861
3822
  }
2862
3823
 
2863
- /** ์ „์ฒด ๋ผ์ด๋ธŒ ์ธ์Šคํ„ด์Šค ๊ณ„์ธก(๋ชจ๋‹ˆํ„ฐ ๋Œ€์‹œ๋ณด๋“œ์šฉ). */
3824
+ /*
3825
+ * โ”€โ”€ ๋™๊ธฐํ™” ๊ฑด๊ฐ• ์žฅ๋ถ€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3826
+ *
3827
+ * **๋Ÿฐํƒ€์ž„์ด ์•„๋‹ˆ๋ผ ์—”์ง„์ด ๋“ ๋‹ค.** ์ธ์Šคํ„ด์Šค์— ๋ถ™์ด๋ฉด ํŠธ์œˆ์„ ์žฌ๊ธฐ๋™ํ•  ๋•Œ ์‚ฌ๋ผ์ง€๋Š”๋ฐ, ์‚ฌ๋žŒ์ด ์•Œ๊ณ  ์‹ถ์€
3828
+ * ๊ฒƒ์€ ๋ฐ”๋กœ ๊ทธ ์ˆœ๊ฐ„์ด๋‹ค โ€” ใ€Œ๊ณ ์ณ์„œ ๋‹ค์‹œ ๋„์› ๋Š”๋ฐ ์ด์ œ ํ†ต๊ณผํ•˜๋‚˜ใ€. ํ”„๋กœ์„ธ์Šค๊ฐ€ ์‚ฌ๋Š” ๋™์•ˆ ์ด์–ด์ง„๋‹ค.
3829
+ *
3830
+ * ์ง€๊ธˆ์€ ๋ฉ”๋ชจ๋ฆฌ๋งŒ์ด๋‹ค. ์ฐฝ์ด ๋‹ซํž ๋•Œ ํ•œ ํ–‰์”ฉ ์˜์†ํ•˜๋Š” ๊ฒƒ์€ `onWindowClosed` ํ•œ ์ž๋ฆฌ์— ๋ถ™๋Š”๋‹ค.
3831
+ */
3832
+ private static ingestLedgers: Record<string, IngestLedger> = {}
3833
+
3834
+ /** ์ฐฝ์ด ๋‹ซํž ๋•Œ ๋ถ€๋ฅผ ๊ณณ โ€” ์˜์† ๊ณ„์ธต์ด ๋ถ€ํŒ…์—์„œ ํ•œ ๋ฒˆ ๋“ฑ๋กํ•œ๋‹ค(์ปค๋„ ์ธต์€ ๊ทธ ๊ณ„์ธต์„ ๋ชจ๋ฅธ๋‹ค). */
3835
+ private static onWindowClosed?: (at: { domainId: string; instanceId: string }, closed: IngestWindow) => void
3836
+
3837
+ /**
3838
+ * ๋‹ซํžŒ ์ฐฝ์„ ๋ฐ›์„ ๊ณณ์„ ๋“ฑ๋กํ•œ๋‹ค โ€” **์˜์†(B)์ด ๋ถ™๋Š” ์œ ์ผํ•œ ๋ฌธ.**
3839
+ *
3840
+ * ์ด ํŒจํ‚ค์ง€๋Š” ์ €์žฅ ๊ณ„์ธต์„ ๋ชจ๋ฅธ๋‹ค. ๋“ฑ๋กํ•˜๋Š” ์ชฝ์ด ์ž๊ธฐ ๋ฐฉ์‹์œผ๋กœ ์“ด๋‹ค. ํ›…์ด ์˜ค๋ฅ˜๋ฅผ ๋‚ด๋„ ์žฅ๋ถ€๋Š” ๊ณ„์† ๊ตด๋Ÿฌ๊ฐ„๋‹ค
3841
+ * (`rollIngestWindow` ๊ฐ€ ๊ฐ์‹ผ๋‹ค) โ€” ์˜์†์„ ์ง€ํ‚ค๋ ค๊ณ  ๊ด€์ธก์„ ๋ฉˆ์ถ”์ง€ ์•Š๋Š”๋‹ค.
3842
+ */
3843
+ static onIngestWindowClosed(
3844
+ fn: (at: { domainId: string; instanceId: string }, closed: IngestWindow) => void
3845
+ ): void {
3846
+ this.onWindowClosed = fn
3847
+ }
3848
+
3849
+ /**
3850
+ * ํ•œ ๋ฒˆ์˜ ์ธ์ œ์ŠคํŠธ ๊ฒฐ๊ณผ๋ฅผ ์žฅ๋ถ€์— ์ ๋Š”๋‹ค.
3851
+ *
3852
+ * `offered` ๋Š” **์ œ์‹œ๋œ ๋ ˆ์ฝ”๋“œ ์ˆ˜**๋‹ค(๊ฑฐ๋ถ€ ์—ฌ๋ถ€ ๋ฌด๊ด€). ํ†ต๊ณผ์œจ์„ ์„œ๋กœ ๋‹ค๋ฅธ ๋‘ ๊ณ„์ˆ˜๊ธฐ์—์„œ ๋‚˜๋ˆ  ๊ณ„์‚ฐํ•˜๋ฉด
3853
+ * ๋ถ„๋ชจ์™€ ๋ถ„์ž๊ฐ€ ๋‹ค๋ฅธ ๊ฒƒ์„ ์„ธ๊ฒŒ ๋˜๋ฏ€๋กœ, ํ•œ์ž๋ฆฌ์—์„œ ๋ณธ ์ˆ˜๋ฅผ ๊ทธ๋Œ€๋กœ ๋„˜๊ธด๋‹ค.
3854
+ */
3855
+ static recordIngestResult(
3856
+ domainId: string,
3857
+ instanceId: string,
3858
+ offered: number,
3859
+ rejected: { record?: unknown; errors?: string[] }[] | undefined,
3860
+ nowMs = Date.now(),
3861
+ /**
3862
+ * ๋งคํ•‘์€ ํ†ต๊ณผํ–ˆ๋Š”๋ฐ **์ปค๋„์ด ๋ฐ›์ง€ ์•Š์€** ์ˆ˜(`ingestLive` ์˜ ๋ฐ˜ํ™˜๊ณผ ๊ฒฌ์ฃผ์–ด ์–ป๋Š”๋‹ค).
3863
+ *
3864
+ * ใ€Œํ†ต๊ณผใ€๋กœ ์„ธ์ง€ ์•Š๋Š”๋‹ค โ€” ํŠธ์œˆ์ด ๋ฉˆ์ถ˜ ์‚ฌ์ด ์‚ฌ์‹ค์ด ์‚ฌ๋ผ์ง€๋Š”๋ฐ ํ™”๋ฉด์ด 100% ๋ผ๊ณ  ๋งํ•˜๊ฒŒ ๋œ๋‹ค.
3865
+ */
3866
+ undelivered = 0
3867
+ ): void {
3868
+ const key = runtimeKey(domainId, instanceId)
3869
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
3870
+ recordIngest(
3871
+ ledger,
3872
+ offered,
3873
+ rejected,
3874
+ nowMs,
3875
+ closed => this.onWindowClosed?.({ domainId, instanceId }, closed),
3876
+ undelivered
3877
+ )
3878
+ }
3879
+
3880
+ /**
3881
+ * **์›๋ณธ์— ๋‹ฟ์ง€ ๋ชปํ–ˆ๋‹ค**๋ฅผ ์ ๋Š”๋‹ค โ€” ใ€Œ๋ฐ›์€ ๊ฒƒ์ด ์—†๋‹คใ€์™€ ๊ฐ€๋ฅธ๋‹ค(ยง`recordReadFailure`).
3882
+ *
3883
+ * ์ด ๋ฌธ์ด ์—†๋˜ ๋™์•ˆ ์‹ค ์›๋ณธ์ด ๋Š๊ฒจ๋„ ํŠธ์œˆ์˜ ์กฐํšŒ ๊ฐ€๋Šฅํ•œ ์ƒํƒœ์— ๊ทธ ์‚ฌ์‹ค์ด ์—†์—ˆ๋‹ค. ํ™”๋ฉด์ด ๋ณผ ์ˆ˜ ์žˆ๋Š”
3884
+ * ๊ฒƒ์€ ใ€Œ์ƒˆ ์‚ฌ์‹ค์ด ์—†๋‹คใ€๋ฟ์ด์—ˆ๊ณ  ๊ทธ๊ฒƒ์€ ใ€Œ์›๋ณธ์ด ์กฐ์šฉํ•˜๋‹คใ€์™€ ๊ตฌ๋ณ„๋˜์ง€ ์•Š๋Š”๋‹ค โ€” ์‹ค์ฆ ์ค‘์— ์›๋ณธ์ด
3885
+ * ๋Š๊ธฐ๋ฉด ์‚ฌ์šฉ์ž๊ฐ€ ์›์ธ์„ ์ฐพ์„ ์ˆ˜ ์—†๋‹ค.
3886
+ *
3887
+ * ๋กœ๊ทธ๋กœ๋Š” ๋งํ•˜๊ณ  ์žˆ์—ˆ๋‹ค(์–ด๋Œ‘ํ„ฐ๊ฐ€ ์žฌ์‹œ๋„๋ฅผ ๊ฒฝ๊ณ ํ•œ๋‹ค). ๊ทธ๋Ÿฌ๋‚˜ **๋กœ๊ทธ๋Š” ์‚ฌ๋žŒ์ด ๋ณผ ๋•Œ๋งŒ ๊ฐ’์ด ์žˆ๋‹ค** โ€”
3888
+ * ํ™”๋ฉด์ด ๋งํ•˜๋ ค๋ฉด ์ƒํƒœ์— ์žˆ์–ด์•ผ ํ•œ๋‹ค.
3889
+ */
3890
+ static recordIngestReadFailure(domainId: string, instanceId: string, reason: string, nowMs = Date.now(), stream?: string): void {
3891
+ const key = runtimeKey(domainId, instanceId)
3892
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
3893
+ recordReadFailure(ledger, reason, nowMs, stream)
3894
+ }
3895
+
3896
+ /**
3897
+ * ์ฝ๊ธฐ๊ฐ€ ์„ฑ๊ณตํ–ˆ๋‹ค โ€” ๋‹จ์ ˆ ๊ธฐ๋ก์„ ์ง€์šด๋‹ค.
3898
+ *
3899
+ * **๋นˆ ์ฝ๊ธฐ๋„ ์„ฑ๊ณต์ด๋‹ค.** ์›๋ณธ์ด ใ€Œ์ƒˆ ๊ฒƒ์ด ์—†๋‹คใ€๊ณ  ๋‹ตํ•œ ๊ฒƒ์€ ๋‹ฟ์•˜๋‹ค๋Š” ๋œป์ด๋ฏ€๋กœ, ๊ทธ๋•Œ๋„ ๋ถ€๋ฅธ๋‹ค.
3900
+ * ๊ทธ ๋‘˜์„ ๊ฐ™๊ฒŒ ๋‘๋ฉด ์กฐ์šฉํ•œ ์›๋ณธ์ด ๋Š๊ธด ์›๋ณธ์œผ๋กœ ๋ณด์ธ๋‹ค.
3901
+ */
3902
+ static clearIngestReadFailure(domainId: string, instanceId: string): void {
3903
+ const ledger = this.ingestLedgers[runtimeKey(domainId, instanceId)]
3904
+ if (ledger) clearReadFailure(ledger)
3905
+ }
3906
+
3907
+ /**
3908
+ * **์ฝ์—ˆ๋Š”๋ฐ ์ฐฝ์„ ๋„˜๊ธธ ์ˆ˜ ์—†๋‹ค**๋ฅผ ์ ๋Š”๋‹ค โ€” ์œ„์™€ **์กฐ์น˜๊ฐ€ ๋ฐ˜๋Œ€์ธ** ์‚ฌ์‹ค์ด๋‹ค(ยง`recordCursorStall`).
3909
+ *
3910
+ * ์ด ๋ฌธ์ด ์—†๋˜ ๋™์•ˆ ์ด ์‚ฌ์‹ค์ด `recordIngestReadFailure` ๋กœ ๋‚˜๊ฐ”๋‹ค. ๊ทธ๋ž˜์„œ ํ™”๋ฉด์ด ใ€Œ์›๋ณธ์— ๋‹ฟ์ง€
3911
+ * ๋ชปํ•œ๋‹คใ€๊ณ  ๋งํ–ˆ๋Š”๋ฐ ์›๋ณธ์€ **๋‹ตํ•œ** ์ƒํƒœ์˜€๊ณ , ๊ทธ ๋‹ต์˜ ๋ชจ์–‘์ด ์ปค์„œ๋ฅผ ์ด๊ธด ๊ฒƒ์ด์—ˆ๋‹ค(ํ•œ ์‹œ๊ฐ์— ํ•œ
3912
+ * ํŽ˜์ด์ง€๋ณด๋‹ค ๋งŽ์€ ํ–‰). ์‚ฌ๋žŒ์€ ์›๋ณธ์„ ์˜์‹ฌํ•˜๊ณ  ๊ธฐ๋‹ค๋ฆฌ๋Š”๋ฐ **๊ธฐ๋‹ค๋ฆผ์œผ๋กœ๋Š” ์˜์›ํžˆ ํ’€๋ฆฌ์ง€ ์•Š๋Š”๋‹ค.**
3913
+ *
3914
+ * ์กฐ์น˜๊ฐ€ ๋ฐ˜๋Œ€์ธ ๋‘ ์‚ฌ์‹ค์„ ํ•œ ์ด๋ฆ„์œผ๋กœ ๋ถ€๋ฅด๋ฉด ๊ทธ ์ด๋ฆ„์€ ์ •๋ณด๊ฐ€ ์•„๋‹ˆ๋ผ ์˜คํ•ด๋‹ค.
3915
+ */
3916
+ static recordIngestCursorStall(domainId: string, instanceId: string, reason: string, nowMs = Date.now(), stream?: string): void {
3917
+ const key = runtimeKey(domainId, instanceId)
3918
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
3919
+ recordCursorStall(ledger, reason, nowMs, stream)
3920
+ }
3921
+
3922
+ /** ์ฐฝ์„ ๋„˜๊ฒผ๋‹ค โ€” ์ •์ฒด ๊ธฐ๋ก์„ ์ง€์šด๋‹ค(ํ’€๋ฆฐ ์ •์ฒด๊ฐ€ ํ™”๋ฉด์— ๋‚จ์•„ ์žˆ์œผ๋ฉด ๊ทธ๊ฒƒ๋„ ๊ฑฐ์ง“์ด๋‹ค). */
3923
+ static clearIngestCursorStall(domainId: string, instanceId: string): void {
3924
+ const ledger = this.ingestLedgers[runtimeKey(domainId, instanceId)]
3925
+ if (ledger) clearCursorStall(ledger)
3926
+ }
3927
+
3928
+ /**
3929
+ * ์ €๋„์— **์ ์€ ๊ฒƒ**์„ ๊ฐ™์€ ์žฅ๋ถ€์— ๋‚จ๊ธด๋‹ค โ€” ์œ ์ž…๊ณผ ๊ฐ™์€ 10๋ถ„ ์ฐฝ์—.
3930
+ *
3931
+ * โ”€โ”€ ์™œ ์œ ์ž… ์žฅ๋ถ€์— ๋„ฃ๋‚˜ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3932
+ * ์ƒˆ ์žฅ๋ถ€๋ฅผ ๋งŒ๋“ค๋ฉด ใ€Œ๋‹ซํžŒ ์ฐฝ๋งŒ ์ตœ๊ทผใ€ยทใ€Œ0 ๊ณผ ์—†์Œ์„ ๊ฐ€๋ฅธ๋‹คใ€ยท์˜์†์„ ๋‘ ๋ฒŒ ์ง€์ผœ์•ผ ํ•˜๊ณ , ๊ทธ์ค‘ ํ•œ ๋ฒŒ๋งŒ
3933
+ * ๊ณ ์ณ์ง€๋Š” ๊ฒƒ์ด ๋ณดํ†ต์ด๋‹ค. ์œ ์ž… ์ฐฝ์€ ๊ทธ ๊ทœ์œจ์ด ์ด๋ฏธ ๋“ค์–ด ์žˆ๊ณ  ๋‹ซํž ๋•Œ ํ–‰์œผ๋กœ ๋‚จ๋Š”๋‹ค.
3934
+ *
3935
+ * โ”€โ”€ ์ด ๊ฐ’์ด ์—†์œผ๋ฉด ๋ฌด์—‡์„ ๋ชป ๋ณด๋‚˜ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3936
+ * ๊ณ„๊ธฐ(`journalRate`)๋Š” **์ง€๊ธˆ**๋งŒ ๋‹ตํ•œ๋‹ค. ํ”„๋กœ์„ธ์Šค๋ฅผ ๋‹ค์‹œ ๋„์šฐ๋ฉด 0 ์—์„œ ์‹œ์ž‘ํ•˜๋ฏ€๋กœ ใ€Œ์–ด์ œ ์ด ์‹œ๊ฐ์—๋„
3937
+ * ์ด๋žฌ๋‚˜ใ€ยทใ€Œ๊ณ ์นœ ๋’ค๋กœ ์ค„์—ˆ๋‚˜ใ€๋ฅผ ๋ฌผ์„ ์ž๋ฆฌ๊ฐ€ ์—†๋‹ค. 2026-08-20 ์— ๊ณ ์นœ ๊ฒƒ์ด ๋ฐ”๋กœ ์“ฐ๊ธฐ ๊ฒฝ๋กœ์ด๋ฏ€๋กœ,
3938
+ * ๋˜๋Œ์•„๊ฐ€๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค.
3939
+ */
3940
+ static recordJournalWrite(domainId: string, instanceId: string, rows: number, nowMs = Date.now()): void {
3941
+ const key = runtimeKey(domainId, instanceId)
3942
+ const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = newIngestLedger())
3943
+ recordLedgerWrite(ledger, rows, nowMs, closed => this.onWindowClosed?.({ domainId, instanceId }, closed))
3944
+ }
3945
+
3946
+ /**
3947
+ * ใ€Œ์ด ํŠธ์œˆ์ด ํ˜„์žฅ๊ณผ ๋งž์ถฐ์ง€๊ณ  ์žˆ๋‚˜ใ€ โ€” ํ•œ๋ˆˆ ํŒ์ • + ์ถ”์ด + ์‚ฌ์œ  + ํ‘œ๋ณธ.
3948
+ *
3949
+ * ์กฐํšŒํ•  ๋•Œ ์ฐฝ์„ ํ•œ ๋ฒˆ ๊ตด๋ฆฐ๋‹ค: ์œ ์ž…์ด ๋ฉˆ์ถ”๋ฉด ๋‹ค์Œ ์ธ์ œ์ŠคํŠธ๊ฐ€ ์—†์–ด ์ฐฝ์ด ์˜์›ํžˆ ๋‹ซํžˆ์ง€ ์•Š๋Š”๋ฐ, ๊ทธ๋Ÿฌ๋ฉด
3950
+ * ใ€Œ์ตœ๊ทผใ€์ด ์˜›๊ฒƒ์œผ๋กœ ๋‚จ๋Š”๋‹ค. ํƒ€์ด๋จธ๋ฅผ ๋‘์ง€ ์•Š๋Š” ์ด์œ ๋Š” ํŠธ์œˆ๋งˆ๋‹ค ํƒ€์ด๋จธ๋ฅผ ๊ฑธ๋ฉด ๊ทธ ํƒ€์ด๋จธ๋“ค์ด ๋‹ค์‹œ
3951
+ * ๋ฉ”์ธ ๋ฃจํ”„์— ์–นํžˆ๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค(์˜ค๋Š˜ ํ™•์ธํ•œ ๊ทธ ๋ถ€ํ•˜๋ฅผ ์ด ๊ธฐ๋Šฅ์ด ๋‹ค์‹œ ๋งŒ๋“ค ์ด์œ ๊ฐ€ ์—†๋‹ค).
3952
+ */
3953
+ /**
3954
+ * ๋„๋ฉ”์ธ์˜ ํŠธ์œˆ๋งˆ๋‹ค **ํ•œ ์ค„ ์š”์•ฝ** โ€” ๋ชฉ๋ก ํ™”๋ฉด๊ณผ ๋ฏธ๋‹ˆ ์ถ”์ด์šฉ.
3955
+ *
3956
+ * โ”€โ”€ ์™œ ์ƒ์„ธ์™€ ๋”ฐ๋กœ์ธ๊ฐ€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3957
+ * `ingestHealthOf` ๋Š” ์‚ฌ์œ  ๋ฌธ๊ตฌ์™€ **๋ ˆ์ฝ”๋“œ ์›๋ฌธ ํ‘œ๋ณธ**๊นŒ์ง€ ๋‚ธ๋‹ค. ๋ชฉ๋ก์— ํŠธ์œˆ์ด ์Šค๋ฌด ๊ฐœ๋ฉด ๊ทธ ์›๋ฌธ์ด
3958
+ * ์Šค๋ฌด ๋ฒŒ ์‹ค๋ ค ์กฐํšŒ๊ฐ€ ๋ฌด๊ฑฐ์›Œ์ง€๊ณ , ํ™”๋ฉด์€ ์–ด์ฐจํ”ผ ๊ทธ๊ฒƒ์„ ๊ทธ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค. ๊ทธ๋ž˜์„œ ์—ฌ๊ธฐ์„œ๋Š” **์ˆซ์ž๋งŒ** ๋‚ธ๋‹ค.
3959
+ *
3960
+ * ์ถ”์ด๋Š” ์ฐฝ๋‹น ์ˆซ์ž ๋„ท์ด๋ผ ์ŠคํŒŒํฌ๋ผ์ธ ํ•˜๋‚˜์— ์ถฉ๋ถ„ํ•˜๊ณ  ๊ฐ€๋ณ๋‹ค.
3961
+ *
3962
+ * โ”€โ”€ ๋“ฑ๋ก๋œ ํŠธ์œˆ์„ ๊ธฐ์ค€์œผ๋กœ ํ›‘๋Š”๋‹ค โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3963
+ * ๋„๋Š” ํŠธ์œˆ๋งŒ ํ›‘์œผ๋ฉด ใ€Œ๊ณ ์น˜๋ ค๊ณ  ๋ฉˆ์ถฐ ๋‘” ํŠธ์œˆใ€์ด ๋ชฉ๋ก์—์„œ ์‚ฌ๋ผ์ง„๋‹ค โ€” ์‚ฌ๋žŒ์ด ๋ฐฉ๊ธˆ ๋ฉˆ์ถ˜ ๊ทธ๊ฒƒ์„ ๋ณด๋ ค๊ณ 
3964
+ * ๋ชฉ๋ก์„ ์—ฌ๋Š”๋ฐ ์—†์œผ๋ฉด ๊ฒฐํ•จ์œผ๋กœ ์ฝ๋Š”๋‹ค. ๊ทธ๋ž˜์„œ ํ˜ธ์ถœ๋ถ€(๋ชฉ๋ก ํ™”๋ฉด)๊ฐ€ ์•„๋Š” instanceId ๋“ค์„ ๋ฐ›๋Š”๋‹ค.
3965
+ */
3966
+ static ingestHealthBrief(
3967
+ domainId: string,
3968
+ instanceIds: string[]
3969
+ ): { instanceId: string; verdict: string; acceptedRatio: number | null; trend: { offered: number; rejected: number; undelivered: number; rows: number }[]; lastAt: string | null }[] {
3970
+ return instanceIds.map(instanceId => {
3971
+ const full = this.ingestHealthOf(domainId, instanceId)
3972
+ return {
3973
+ instanceId,
3974
+ verdict: full.verdict,
3975
+ /* ์ตœ๊ทผ **๋‹ซํžŒ** ์ฐฝ์˜ ํ†ต๊ณผ์œจ. ์ฐฝ์ด ์•ˆ ๋‹ซํ˜”์œผ๋ฉด ๋น„์šด๋‹ค โ€” 0 ์ด๋‚˜ 1 ๋กœ ์ฑ„์šฐ์ง€ ์•Š๋Š”๋‹ค. */
3976
+ acceptedRatio: full.recent?.acceptedRatio ?? null,
3977
+ /*
3978
+ * ์ŠคํŒŒํฌ๋ผ์ธ์šฉ โ€” ์ฐฝ๋งˆ๋‹ค ์…‹. **๋ฏธ์ „๋‹ฌ์„ ๋นผ๋†“์œผ๋ฉด ์ŠคํŒŒํฌ๋ผ์ธ์ด ๊ฑฐ์ง“์„ ๊ทธ๋ฆฐ๋‹ค**: ๋ฒ„๋ ค์ง„ ๊ฒƒ์„
3979
+ * ํ†ต๊ณผ๋กœ ์„ธ๋ฉด ํŠธ์œˆ์ด ๋ฉˆ์ถ˜ ๊ตฌ๊ฐ„์—์„œ๋„ ์„ ์ด 100% ์— ๋ถ™๋Š”๋‹ค.
3980
+ */
3981
+ /* ์ ํžŒ ํ–‰๋„ ํ•จ๊ป˜ โ€” ์‹œ๋ฎฌ ํŠธ์œˆ์€ ์œ ์ž…์ด 0 ์ด๋ผ ์ด ๊ฐ’ ์—†์ด๋Š” ์ถ”์ด๊ฐ€ ๋นˆ ์„ ์œผ๋กœ ๋ณด์ธ๋‹ค. */
3982
+ trend: full.trend.map(w => ({ offered: w.offered, rejected: w.rejected, undelivered: w.undelivered, rows: w.rows })),
3983
+ lastAt: full.lastAt
3984
+ }
3985
+ })
3986
+ }
3987
+
3988
+ /**
3989
+ * ํ•œ ํŠธ์œˆ์˜ ๋™๊ธฐํ™” ๊ฑด๊ฐ•.
3990
+ *
3991
+ * โ”€โ”€ ์กฐํšŒ๊ฐ€ ์žฅ๋ถ€๋ฅผ ์ „์ง„์‹œํ‚จ๋‹ค(์˜๋„) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
3992
+ * ์ฐฝ์€ **๋‹ค์Œ ์œ ์ž…์ด ์žˆ์„ ๋•Œ** ๋‹ซํžŒ๋‹ค. ๊ทธ๋ž˜์„œ ํ”ผ๋“œ๊ฐ€ ์ฃฝ์œผ๋ฉด ๋งˆ์ง€๋ง‰ ์ฐฝ์ด ์—ด๋ฆฐ ์ฑ„๋กœ ๋‚จ์•„ ์˜์›ํžˆ ์˜์†๋˜์ง€
3993
+ * ์•Š๊ณ , ์ถ”์ด์—๋„ ๋“ค์–ด๊ฐ€์ง€ ์•Š๋Š”๋‹ค. ์กฐํšŒ ์‹œ์ ์— ํ•œ ๋ฒˆ ๊ตด๋ ค ์ฃผ๋ฉด ๊ทธ ๋งˆ์ง€๋ง‰ ์ฐฝ์ด ๋‹ซํžˆ๋ฉด์„œ ์˜์† ํ›…๋„ ๋ถˆ๋ฆฐ๋‹ค
3994
+ * โ€” ์ฆ‰ **์ฃฝ์€ ํ”ผ๋“œ์˜ ๋งˆ์ง€๋ง‰ ๊ตฌ๊ฐ„์„ ์žƒ์ง€ ์•Š๊ธฐ ์œ„ํ•ด** ์ฝ๊ธฐ๊ฐ€ ๊ตด๋ฆฐ๋‹ค. ๋ถ€์ˆ˜ํšจ๊ณผ์ง€๋งŒ ํ•„์š”ํ•œ ๋ถ€์ˆ˜ํšจ๊ณผ๋‹ค.
3995
+ *
3996
+ * ์‹œ๊ฐ์€ **ํ•œ ๋ฒˆ๋งŒ ์ฝ์–ด** ๊ตด๋ฆฌ๊ธฐ์™€ ํŒ์ •์— ๊ฐ™์€ ๊ฐ’์„ ์“ด๋‹ค. ๋‘ ๋ฒˆ ์ฝ์œผ๋ฉด ๊ทธ ์‚ฌ์ด์— ์ฐฝ์ด ๋‹ซํ˜€ ํŒ์ •์ด
3997
+ * ๊ตด๋ฆฌ๊ธฐ ์ „ ์ƒํƒœ๋ฅผ ๋ณด๋Š” ์ผ์ด ์ƒ๊ธด๋‹ค.
3998
+ */
3999
+ static ingestHealthOf(domainId: string, instanceId: string): IngestHealthView {
4000
+ const key = runtimeKey(domainId, instanceId)
4001
+ const nowMs = Date.now()
4002
+ const ledger = this.ingestLedgers[key]
4003
+ if (ledger) {
4004
+ rollIngestWindow(ledger, nowMs, undefined, closed => this.onWindowClosed?.({ domainId, instanceId }, closed))
4005
+ }
4006
+
4007
+ const inst = this.instances[key]
4008
+ const feedState = liveFeedStateOf({
4009
+ restartPolicy: inst?.restartPolicy,
4010
+ running: !!inst,
4011
+ instanceId
4012
+ })
4013
+ return ingestHealth(ledger, feedState, undefined, nowMs)
4014
+ }
4015
+
4016
+ /**
4017
+ * ๋„๋Š” ์ธ์Šคํ„ด์Šค ์ „์ฒด์˜ ๊ณ„์ธก(๋ชจ๋‹ˆํ„ฐ ๋Œ€์‹œ๋ณด๋“œ์šฉ) โ€” **์‹œ๋ฎฌ๊ณผ ๋ฏธ๋Ÿฌ๋ฅผ ํ•จ๊ป˜**.
4018
+ *
4019
+ * ์˜ˆ์ „์—๋Š” ์‹œ๋ฎฌ์— ๊ณ„๊ธฐ๊ฐ€ ์—†์–ด ์ด ๋ชฉ๋ก์—์„œ ์กฐ์šฉํžˆ ๋น ์กŒ๋‹ค(๊ณ„๊ธฐ๊ฐ€ `null` ์ด๋ผ ๊ฑธ๋Ÿฌ์กŒ๋‹ค). ๋„๋Š” ํŠธ์œˆ
4020
+ * ๋Œ€๋ถ€๋ถ„์ด ์‹œ๋ฎฌ์ธ ์„œ๋ฒ„์—์„œ ๊ทธ ๋ชฉ๋ก์€ ใ€Œ๋ถ€ํ•˜๊ฐ€ ๊ฑฐ์˜ ์—†๋‹คใ€๋กœ ๋ณด์˜€๋‹ค.
4021
+ */
2864
4022
  static async allMetrics(domainId?: string): Promise<any[]> {
2865
4023
  /* ๋„๋ฉ”์ธ ์—†์ด ๋ถ€๋ฅด๋ฉด ์ „ ํ…Œ๋„ŒํŠธ๋ฅผ ํ›‘๋Š”๋‹ค(๋‚ด๋ถ€ ๋ชจ๋‹ˆํ„ฐ์šฉ) โ€” ํ‚ค์—์„œ ๋„๋ฉ”์ธ์„ ๋˜๋Œ๋ ค ๊ฐ์ž์—๊ฒŒ ๋ฌป๋Š”๋‹ค. */
2866
4024
  const rows = Object.keys(this.instances)
@@ -2913,6 +4071,14 @@ export class TwinEngine {
2913
4071
  const state = await this.recover(domainId, instanceId, undefined, untilTime).catch(() => null)
2914
4072
  if (!state) return null
2915
4073
  const cutoff = untilTime != null ? Date.parse(untilTime) : Infinity
4074
+ /*
4075
+ * journal-fold: ์˜ค๋”์˜ ๋งˆ์ง€๋ง‰ ์ƒํƒœ๋Š” ๊ทธ ์˜ค๋”์— ์ผ์–ด๋‚œ ์‚ฌ์‹ค๋“ค์„ ์ ‘์–ด์•ผ ๋‚˜์˜จ๋‹ค.
4076
+ *
4077
+ * **์—†์–ด์งˆ ์กฐ๊ฑด** โ€” ์ง€๊ธˆ ์ด ์ฝ๊ธฐ์—๋Š” ์ƒํ•œ์ด ์—†๊ณ  `eventType` ์— ์ธ๋ฑ์Šค๋„ ์—†๋‹ค. ๊ทธ๋ž˜์„œ ์ €๋„์ด ํฐ
4078
+ * ํŠธ์œˆ์—์„œ๋Š” ์ด ํ•œ ๋ฒˆ์ด ๊ทธ ํŠธ์œˆ์˜ ์ €๋„ ์ „์ฒด ์ฃผ์‚ฌ๋‹ค. ์˜ค๋” ์ƒํƒœ๋ฅผ ๋”ฐ๋กœ ์ ‘์–ด ๋‘” ํ‘œ(ํˆฌ์˜)๋ฅผ ๋‘๊ฑฐ๋‚˜,
4079
+ * `(domain, instanceId, eventType, revision)` ์ธ๋ฑ์Šค๋ฅผ ๋ถ™์ด๊ณ  ์ปค์„œ๋ฅผ ๋„ฃ์œผ๋ฉด ์ด ์˜ˆ์™ธ๋Š” ์‚ฌ๋ผ์ง„๋‹ค.
4080
+ * ์‹œ๊ฐ ์ƒํ•œ(`cutoff`)์„ SQL ๋กœ ๋‚ด๋ ค๋„ ์ ˆ๋ฐ˜์€ ์ค€๋‹ค.
4081
+ */
2916
4082
  const rows = await getRepository(TwinEvent).find({ where: { domain: { id: domainId }, instanceId, eventType: OP_EVENT.order }, order: { revision: 'ASC' } })
2917
4083
  const latest = new Map<string, any>()
2918
4084
  for (const r of rows) {