@things-factory/headless-twin 10.0.11 → 10.0.13
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.
- package/dist-server/engine/canonical-ingest.d.ts +81 -3
- package/dist-server/engine/canonical-ingest.js +89 -10
- package/dist-server/engine/canonical-ingest.js.map +1 -1
- package/dist-server/engine/command-routing.js +1 -1
- package/dist-server/engine/command-routing.js.map +1 -1
- package/dist-server/engine/declared-stimulus.d.ts +43 -0
- package/dist-server/engine/declared-stimulus.js +57 -0
- package/dist-server/engine/declared-stimulus.js.map +1 -0
- package/dist-server/engine/index.d.ts +5 -0
- package/dist-server/engine/index.js +6 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/ingest-health.d.ts +335 -0
- package/dist-server/engine/ingest-health.js +434 -0
- package/dist-server/engine/ingest-health.js.map +1 -0
- package/dist-server/engine/integration-coverage.d.ts +76 -0
- package/dist-server/engine/integration-coverage.js +73 -0
- package/dist-server/engine/integration-coverage.js.map +1 -0
- package/dist-server/engine/integration-probes.d.ts +65 -0
- package/dist-server/engine/integration-probes.js +100 -0
- package/dist-server/engine/integration-probes.js.map +1 -0
- package/dist-server/engine/integration-runner.d.ts +45 -0
- package/dist-server/engine/integration-runner.js +59 -0
- package/dist-server/engine/integration-runner.js.map +1 -0
- package/dist-server/engine/integration-target-profile.d.ts +57 -0
- package/dist-server/engine/integration-target-profile.js +79 -0
- package/dist-server/engine/integration-target-profile.js.map +1 -0
- package/dist-server/engine/kpi-fold.d.ts +39 -0
- package/dist-server/engine/kpi-fold.js +68 -5
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +3 -3
- package/dist-server/engine/kpi-query.js +132 -18
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-feed-registry.d.ts +1 -1
- package/dist-server/engine/live-feed-registry.js +3 -17
- package/dist-server/engine/live-feed-registry.js.map +1 -1
- package/dist-server/engine/load-meter.d.ts +1 -1
- package/dist-server/engine/load-meter.js +12 -3
- package/dist-server/engine/load-meter.js.map +1 -1
- package/dist-server/engine/local-declarations.d.ts +3 -6
- package/dist-server/engine/local-declarations.js +97 -16
- package/dist-server/engine/local-declarations.js.map +1 -1
- package/dist-server/engine/log.d.ts +18 -0
- package/dist-server/engine/log.js +80 -0
- package/dist-server/engine/log.js.map +1 -0
- package/dist-server/engine/loop-lag.d.ts +54 -0
- package/dist-server/engine/loop-lag.js +87 -0
- package/dist-server/engine/loop-lag.js.map +1 -0
- package/dist-server/engine/measured-yield.d.ts +42 -0
- package/dist-server/engine/measured-yield.js +75 -0
- package/dist-server/engine/measured-yield.js.map +1 -0
- package/dist-server/engine/model-gap.d.ts +108 -0
- package/dist-server/engine/model-gap.js +95 -0
- package/dist-server/engine/model-gap.js.map +1 -0
- package/dist-server/engine/operation-basis.d.ts +16 -0
- package/dist-server/engine/operation-basis.js +20 -2
- package/dist-server/engine/operation-basis.js.map +1 -1
- package/dist-server/engine/property-effects.js +17 -0
- package/dist-server/engine/property-effects.js.map +1 -1
- package/dist-server/engine/restart-policy.d.ts +13 -0
- package/dist-server/engine/restart-policy.js +52 -0
- package/dist-server/engine/restart-policy.js.map +1 -0
- package/dist-server/engine/runtime-key.js +1 -1
- package/dist-server/engine/runtime-key.js.map +1 -1
- package/dist-server/engine/spec-coverage.d.ts +8 -0
- package/dist-server/engine/spec-coverage.js +3 -1
- package/dist-server/engine/spec-coverage.js.map +1 -1
- package/dist-server/engine/stage-path.d.ts +83 -0
- package/dist-server/engine/stage-path.js +118 -0
- package/dist-server/engine/stage-path.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +400 -31
- package/dist-server/engine/twin-engine.js +1198 -187
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/index.js +35 -3
- package/dist-server/index.js.map +1 -1
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.d.ts +5 -0
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js +58 -0
- package/dist-server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.js.map +1 -0
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.d.ts +5 -0
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js +55 -0
- package/dist-server/migrations/1786200000000-CarryLiveFeedCursor.js.map +1 -0
- package/dist-server/migrations/index.js +7 -1
- package/dist-server/migrations/index.js.map +1 -1
- package/dist-server/service/index.d.ts +5 -2
- package/dist-server/service/index.js +18 -7
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/reference/control-routing.d.ts +14 -0
- package/dist-server/service/reference/control-routing.js +64 -0
- package/dist-server/service/reference/control-routing.js.map +1 -0
- package/dist-server/service/reference/discovery-result.d.ts +1 -1
- package/dist-server/service/reference/discovery-result.js +1 -1
- package/dist-server/service/reference/discovery-result.js.map +1 -1
- package/dist-server/service/reference/index.d.ts +5 -1
- package/dist-server/service/reference/index.js +8 -1
- package/dist-server/service/reference/index.js.map +1 -1
- package/dist-server/service/reference/reference-adapter.d.ts +251 -3
- package/dist-server/service/reference/reference-adapter.js +43 -1
- package/dist-server/service/reference/reference-adapter.js.map +1 -1
- package/dist-server/service/reference/reference-assessment.d.ts +68 -0
- package/dist-server/service/reference/reference-assessment.js +136 -0
- package/dist-server/service/reference/reference-assessment.js.map +1 -0
- package/dist-server/service/reference/reference-live.d.ts +12 -1
- package/dist-server/service/reference/reference-live.js +119 -14
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +47 -1
- package/dist-server/service/reference/reference-master.js +34 -4
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-probe.d.ts +92 -0
- package/dist-server/service/reference/reference-probe.js +186 -0
- package/dist-server/service/reference/reference-probe.js.map +1 -0
- package/dist-server/service/reference/reference-progress-subscription.d.ts +17 -0
- package/dist-server/service/reference/reference-progress-subscription.js +94 -0
- package/dist-server/service/reference/reference-progress-subscription.js.map +1 -0
- package/dist-server/service/reference/reference-progress.d.ts +38 -0
- package/dist-server/service/reference/reference-progress.js +71 -0
- package/dist-server/service/reference/reference-progress.js.map +1 -0
- package/dist-server/service/reference/reference-resolver.d.ts +3 -3
- package/dist-server/service/reference/reference-resolver.js +242 -58
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/reference/twin-reference.d.ts +32 -0
- package/dist-server/service/reference/twin-reference.js +10 -0
- package/dist-server/service/reference/twin-reference.js.map +1 -1
- package/dist-server/service/twin-audit/command-audit.d.ts +34 -0
- package/dist-server/service/twin-audit/command-audit.js +15 -1
- package/dist-server/service/twin-audit/command-audit.js.map +1 -1
- package/dist-server/service/twin-audit/twin-audit-event.d.ts +3 -0
- package/dist-server/service/twin-audit/twin-audit-event.js +10 -0
- package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.d.ts +15 -2
- package/dist-server/service/twin-control/twin-control-mutation.js +88 -36
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-event/journal-count.d.ts +21 -0
- package/dist-server/service/twin-event/journal-count.js +26 -0
- package/dist-server/service/twin-event/journal-count.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-keys.d.ts +9 -0
- package/dist-server/service/twin-event/twin-event-keys.js +15 -18
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
- package/dist-server/service/twin-event/twin-event-type.d.ts +3 -1
- package/dist-server/service/twin-event/twin-event-type.js +18 -1
- package/dist-server/service/twin-event/twin-event-type.js.map +1 -1
- package/dist-server/service/twin-event/twin-event.d.ts +1 -0
- package/dist-server/service/twin-event/twin-event.js +9 -1
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-ingest-window/index.d.ts +5 -0
- package/dist-server/service/twin-ingest-window/index.js +10 -0
- package/dist-server/service/twin-ingest-window/index.js.map +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.d.ts +12 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js +123 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-query.js.map +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.d.ts +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js +51 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window-writer.js.map +1 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window.d.ts +16 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window.js +108 -0
- package/dist-server/service/twin-ingest-window/twin-ingest-window.js.map +1 -0
- package/dist-server/service/twin-instance/twin-instance.d.ts +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +2 -2
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.js +58 -5
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +14 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +74 -3
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.d.ts +1 -0
- package/dist-server/service/twin-metrics/twin-metrics-query.js +78 -3
- package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
- package/dist-server/service/twin-model/axis-journal-evidence.d.ts +2 -0
- package/dist-server/service/twin-model/axis-journal-evidence.js +89 -0
- package/dist-server/service/twin-model/axis-journal-evidence.js.map +1 -0
- package/dist-server/service/twin-model/epcis-coverage.d.ts +1 -1
- package/dist-server/service/twin-model/epcis-coverage.js +19 -7
- package/dist-server/service/twin-model/epcis-coverage.js.map +1 -1
- package/dist-server/service/twin-model/iec61850-coverage.d.ts +1 -1
- package/dist-server/service/twin-model/iec61850-coverage.js +18 -7
- package/dist-server/service/twin-model/iec61850-coverage.js.map +1 -1
- package/dist-server/service/twin-model/isa95-coverage.d.ts +14 -0
- package/dist-server/service/twin-model/isa95-coverage.js +23 -9
- package/dist-server/service/twin-model/isa95-coverage.js.map +1 -1
- package/dist-server/service/twin-model/item-ref.d.ts +24 -0
- package/dist-server/service/twin-model/item-ref.js +88 -0
- package/dist-server/service/twin-model/item-ref.js.map +1 -0
- package/dist-server/service/twin-model/name-index.d.ts +36 -0
- package/dist-server/service/twin-model/name-index.js +116 -0
- package/dist-server/service/twin-model/name-index.js.map +1 -0
- package/dist-server/service/twin-model/project-structure.js +1 -1
- package/dist-server/service/twin-model/project-structure.js.map +1 -1
- package/dist-server/service/twin-model/status-tally.d.ts +9 -0
- package/dist-server/service/twin-model/status-tally.js +37 -0
- package/dist-server/service/twin-model/status-tally.js.map +1 -0
- package/dist-server/service/twin-model/twin-lineage-query.js +40 -12
- package/dist-server/service/twin-model/twin-lineage-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-item-query.js +38 -39
- package/dist-server/service/twin-model/twin-model-item-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-query.js +157 -5
- package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
- package/dist-server/service/twin-model/twin-model-tree-query.js +7 -0
- package/dist-server/service/twin-model/twin-model-tree-query.js.map +1 -1
- package/dist-server/service/twin-readiness/index.d.ts +2 -0
- package/dist-server/service/twin-readiness/index.js +6 -0
- package/dist-server/service/twin-readiness/index.js.map +1 -0
- package/dist-server/service/twin-readiness/twin-readiness-query.d.ts +3 -0
- package/dist-server/service/twin-readiness/twin-readiness-query.js +103 -0
- package/dist-server/service/twin-readiness/twin-readiness-query.js.map +1 -0
- package/dist-server/service/twin-space/twin-space-resolver.d.ts +2 -2
- package/dist-server/service/twin-space/twin-space-resolver.js +5 -4
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-shared/entity-delta.d.ts +23 -3
- package/dist-shared/entity-delta.js +12 -8
- package/dist-shared/entity-delta.js.map +1 -1
- package/dist-shared/kpi-broadcast.js +1 -1
- package/dist-shared/kpi-broadcast.js.map +1 -1
- package/dist-shared/touched-items.d.ts +7 -0
- package/dist-shared/touched-items.js +80 -0
- package/dist-shared/touched-items.js.map +1 -0
- package/package.json +7 -7
- package/server/engine/canonical-ingest.ts +164 -15
- package/server/engine/command-routing.ts +1 -1
- package/server/engine/declared-stimulus.ts +66 -0
- package/server/engine/index.ts +6 -0
- package/server/engine/ingest-health.ts +704 -0
- package/server/engine/integration-coverage.ts +147 -0
- package/server/engine/integration-probes.ts +144 -0
- package/server/engine/integration-runner.ts +95 -0
- package/server/engine/integration-target-profile.ts +103 -0
- package/server/engine/kpi-fold.ts +101 -5
- package/server/engine/kpi-query.ts +137 -23
- package/server/engine/live-feed-registry.ts +4 -3
- package/server/engine/load-meter.ts +12 -3
- package/server/engine/local-declarations.ts +97 -19
- package/server/engine/log.ts +72 -0
- package/server/engine/loop-lag.ts +120 -0
- package/server/engine/measured-yield.ts +89 -0
- package/server/engine/model-gap.ts +168 -0
- package/server/engine/operation-basis.ts +33 -2
- package/server/engine/property-effects.ts +17 -0
- package/server/engine/restart-policy.ts +55 -0
- package/server/engine/runtime-key.ts +1 -1
- package/server/engine/spec-coverage.ts +23 -3
- package/server/engine/stage-path.ts +172 -0
- package/server/engine/twin-engine.ts +1318 -186
- package/server/index.ts +36 -3
- package/server/migrations/1786100000000-PromoteEventActionAndSyncWarnings.ts +54 -0
- package/server/migrations/1786200000000-CarryLiveFeedCursor.ts +53 -0
- package/server/migrations/index.ts +7 -1
- package/server/service/index.ts +11 -0
- package/server/service/reference/control-routing.ts +62 -0
- package/server/service/reference/discovery-result.ts +1 -1
- package/server/service/reference/index.ts +7 -1
- package/server/service/reference/reference-adapter.ts +275 -5
- package/server/service/reference/reference-assessment.ts +215 -0
- package/server/service/reference/reference-live.ts +126 -14
- package/server/service/reference/reference-master.ts +64 -4
- package/server/service/reference/reference-probe.ts +264 -0
- package/server/service/reference/reference-progress-subscription.ts +73 -0
- package/server/service/reference/reference-progress.ts +95 -0
- package/server/service/reference/reference-resolver.ts +246 -19
- package/server/service/reference/twin-reference.ts +39 -1
- package/server/service/twin-audit/command-audit.ts +34 -1
- package/server/service/twin-audit/twin-audit-event.ts +20 -0
- package/server/service/twin-control/twin-control-mutation.ts +78 -30
- package/server/service/twin-event/journal-count.ts +53 -0
- package/server/service/twin-event/twin-event-keys.ts +16 -1
- package/server/service/twin-event/twin-event-type.ts +26 -2
- package/server/service/twin-event/twin-event.ts +7 -0
- package/server/service/twin-forecast/twin-forecast-query.ts +2 -1
- package/server/service/twin-ingest-window/index.ts +7 -0
- package/server/service/twin-ingest-window/twin-ingest-window-query.ts +125 -0
- package/server/service/twin-ingest-window/twin-ingest-window-writer.ts +57 -0
- package/server/service/twin-ingest-window/twin-ingest-window.ts +113 -0
- package/server/service/twin-instance/twin-instance.ts +16 -10
- package/server/service/twin-journal/twin-journal-query.ts +59 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +79 -4
- package/server/service/twin-metrics/twin-metrics-query.ts +81 -3
- package/server/service/twin-model/axis-journal-evidence.ts +57 -0
- package/server/service/twin-model/epcis-coverage.ts +2 -8
- package/server/service/twin-model/iec61850-coverage.ts +2 -8
- package/server/service/twin-model/isa95-coverage.ts +22 -9
- package/server/service/twin-model/item-ref.ts +80 -0
- package/server/service/twin-model/name-index.ts +94 -0
- package/server/service/twin-model/project-structure.ts +1 -1
- package/server/service/twin-model/status-tally.ts +37 -0
- package/server/service/twin-model/twin-lineage-query.ts +38 -9
- package/server/service/twin-model/twin-model-item-query.ts +26 -27
- package/server/service/twin-model/twin-model-query.ts +156 -5
- package/server/service/twin-model/twin-model-tree-query.ts +7 -0
- package/server/service/twin-readiness/index.ts +3 -0
- package/server/service/twin-readiness/twin-readiness-query.ts +96 -0
- package/server/service/twin-space/twin-space-resolver.ts +5 -4
- package/shared/entity-delta.ts +31 -7
- package/shared/kpi-broadcast.ts +1 -1
- package/shared/touched-items.ts +73 -0
- package/test/adopt-structure-live.test.ts +7 -7
- package/test/axis-journal-evidence.test.ts +71 -0
- package/test/axis-read.test.ts +101 -1
- package/test/boot-resume.test.ts +93 -27
- package/test/broadcast-cost-baseline.test.ts +200 -0
- package/test/broadcast-period.test.ts +58 -0
- package/test/canonical-ingest-vocabularies.test.ts +36 -1
- package/test/canonical-quantity-door.test.ts +61 -0
- package/test/command-routing.test.ts +1 -1
- package/test/control-capability.test.ts +103 -0
- package/test/declared-location-types.test.ts +89 -0
- package/test/declared-stimulus.test.ts +88 -0
- package/test/discovery-result.test.ts +1 -1
- package/test/duration-estimators.test.ts +1 -1
- package/test/entity-delta.test.ts +2 -2
- package/test/ingest-bench.test.ts +3 -3
- package/test/ingest-health-engine.test.ts +248 -0
- package/test/ingest-health-wiring.test.ts +119 -0
- package/test/ingest-health.test.ts +306 -0
- package/test/ingest-history.test.ts +247 -0
- package/test/ingest-running-guard.test.ts +5 -5
- package/test/instance-cache-lifecycle.test.ts +1 -1
- package/test/integration-probes.test.ts +103 -0
- package/test/integration-runner.test.ts +95 -0
- package/test/item-ref.test.ts +78 -0
- package/test/journal-read-discipline.test.ts +177 -0
- package/test/journal-retention.test.ts +133 -0
- package/test/journal-sort-axis.test.ts +142 -0
- package/test/journal-write-door.test.ts +110 -0
- package/test/journal-write-trend.test.ts +115 -0
- package/test/kernel-kind-guard.test.ts +4 -4
- package/test/kpi-baseline-db.test.ts +1 -1
- package/test/kpi-fold.test.ts +90 -3
- package/test/kpi-query-bench.test.ts +3 -3
- package/test/lineage-survives-restart.test.ts +20 -4
- package/test/live-cursor-wiring.test.ts +87 -0
- package/test/live-feed-registry.test.ts +15 -7
- package/test/live-kernel-facts.test.ts +7 -7
- package/test/live-mirror-parity.test.ts +6 -0
- package/test/load-meter.test.ts +29 -15
- package/test/local-declarations.test.ts +109 -2
- package/test/log-stamp.test.ts +59 -0
- package/test/loop-lag.test.ts +82 -0
- package/test/measured-yield.test.ts +90 -0
- package/test/model-gap.test.ts +153 -0
- package/test/oee-accumulator.test.ts +4 -0
- package/test/operation-basis.test.ts +28 -1
- package/test/operational-vocabulary.test.ts +108 -0
- package/test/operations-capability-db.test.ts +4 -4
- package/test/project-structure-db.test.ts +1 -1
- package/test/projection-reaches-screen.test.ts +1 -1
- package/test/property-effects.test.ts +28 -0
- package/test/read-failure-visible.test.ts +145 -0
- package/test/reference-grounding.test.ts +70 -0
- package/test/resolve-ts-siblings.mjs +52 -0
- package/test/restart-policy.test.ts +111 -0
- package/test/resync-origin-site.test.ts +7 -1
- package/test/revision-axis.test.ts +93 -0
- package/test/runtime-key.test.ts +2 -2
- package/test/scale-twin-bench.test.ts +2 -2
- package/test/source-outcome-audit.test.ts +104 -0
- package/test/spec-coverage.test.ts +1 -1
- package/test/stage-path.test.ts +95 -0
- package/test/standard-coverage.test.ts +15 -3
- package/test/status-tally.test.ts +55 -0
- package/test/structure-revision-db.test.ts +31 -27
- package/test/tenant-registry-db.test.ts +3 -3
- package/test/time-range.test.ts +152 -0
- package/test/touched-items.test.ts +61 -0
- package/test/twin-event-keys.test.ts +10 -2
- package/test/twin-model-item-db.test.ts +5 -3
- package/test/twin-model-tree-db.test.ts +7 -7
- package/test/twin-origin-resync.test.ts +4 -4
- package/test/warm-start-seam.test.ts +4 -0
- package/test/yield-loop.test.ts +199 -0
- package/tsconfig.shared.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* (설계 SoT: operato-twin/design/integration/things-factory-host.md)
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.TwinEngine =
|
|
11
|
+
exports.TwinEngine = void 0;
|
|
12
|
+
const log_js_1 = require("./log.js");
|
|
12
13
|
const shell_1 = require("@things-factory/shell");
|
|
13
14
|
/* 저널을 자를 조건은 SQL 이 안다 — 관용구만 쓴다(원시 SQL 은 5개 드라이버에서 갈라진다). */
|
|
14
15
|
const typeorm_1 = require("typeorm");
|
|
@@ -20,6 +21,8 @@ const local_declarations_js_1 = require("./local-declarations.js");
|
|
|
20
21
|
const runtime_key_js_1 = require("./runtime-key.js");
|
|
21
22
|
const command_routing_js_1 = require("./command-routing.js");
|
|
22
23
|
const twin_instance_js_1 = require("../service/twin-instance/twin-instance.js");
|
|
24
|
+
/* 자극의 집은 원본이다(ADR-0029) — 그 행을 읽고 쓴다. */
|
|
25
|
+
const twin_reference_js_1 = require("../service/reference/twin-reference.js");
|
|
23
26
|
const twin_structure_js_1 = require("../service/twin-structure/twin-structure.js");
|
|
24
27
|
const twin_space_js_1 = require("../service/twin-space/twin-space.js");
|
|
25
28
|
const reference_master_js_1 = require("../service/reference/reference-master.js");
|
|
@@ -32,21 +35,64 @@ const reference_master_js_3 = require("../service/reference/reference-master.js"
|
|
|
32
35
|
const project_structure_js_1 = require("../service/twin-model/project-structure.js");
|
|
33
36
|
const travel_estimator_js_1 = require("./travel-estimator.js");
|
|
34
37
|
const measured_estimator_js_1 = require("./measured-estimator.js");
|
|
38
|
+
const measured_yield_js_1 = require("./measured-yield.js");
|
|
39
|
+
const declared_stimulus_js_1 = require("./declared-stimulus.js");
|
|
40
|
+
const restart_policy_js_1 = require("./restart-policy.js");
|
|
35
41
|
const kpi_query_js_1 = require("./kpi-query.js");
|
|
36
42
|
const node_crypto_1 = require("node:crypto");
|
|
37
43
|
const entity_delta_js_1 = require("@things-factory/headless-twin/dist-shared/entity-delta.js");
|
|
38
44
|
const load_meter_js_1 = require("./load-meter.js");
|
|
45
|
+
const loop_lag_js_1 = require("./loop-lag.js");
|
|
46
|
+
const touched_items_js_1 = require("@things-factory/headless-twin/dist-shared/touched-items.js");
|
|
39
47
|
const structure_diff_js_1 = require("./structure-diff.js");
|
|
40
48
|
const oee_accumulator_js_1 = require("./oee-accumulator.js");
|
|
41
49
|
const live_attentions_js_1 = require("./live-attentions.js");
|
|
42
50
|
const attention_digest_js_1 = require("./attention-digest.js");
|
|
43
51
|
const live_feed_registry_js_1 = require("./live-feed-registry.js");
|
|
52
|
+
const ingest_health_js_1 = require("./ingest-health.js");
|
|
44
53
|
const twin_kernel_1 = require("@operato/twin-kernel");
|
|
45
54
|
/* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
|
|
46
|
-
const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel');
|
|
55
|
+
const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/twin-kernel');
|
|
47
56
|
const KERNELS = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel };
|
|
48
57
|
/**
|
|
49
|
-
*
|
|
58
|
+
* 시각축을 구할 때 한 번에 띄우는 트윈 수.
|
|
59
|
+
*
|
|
60
|
+
* 트윈마다 왕복이 둘이라, 공간에 트윈이 수백이면 동시 왕복도 수백이 된다 — 커넥션 풀이 마르면 이
|
|
61
|
+
* 함수 하나 때문에 다른 질의가 줄을 선다. 20이면 왕복 40개로 지연은 감추면서 풀은 남는다.
|
|
62
|
+
*/
|
|
63
|
+
const TIME_RANGE_FANOUT = 20;
|
|
64
|
+
/**
|
|
65
|
+
* DB 가 준 시각을 epoch ms 로 — **모양이 드라이버마다 다르다.**
|
|
66
|
+
*
|
|
67
|
+
* 집계(MIN/MAX)의 반환은 드라이버가 정한다: sqlite 는 문자열, pg·mysql·mssql 은 `Date`. 엔티티
|
|
68
|
+
* 하이드레이션을 건너뛰면 TypeORM 의 날짜 변환도 함께 건너뛰므로 받는 쪽에서 한 번 정규화한다.
|
|
69
|
+
*
|
|
70
|
+
* ── 시간대를 잃지 않는다 (시험이 잡았다) ────────────────────────────────────
|
|
71
|
+
* 처음에 `Date.parse(String(v))` 로 두었더니 **9시간이 밀렸다.** sqlite 는 UTC 로 저장한 값을
|
|
72
|
+
* `2026-01-15 09:00:00.000` 처럼 **시간대 표시 없이** 돌려주는데, 그 형태를 `Date.parse` 는 **로컬
|
|
73
|
+
* 시각**으로 읽는다(여기가 KST 라 UTC 00:00 이 됐다). 원래 코드가 이 함정을 피한 것은 TypeORM 이
|
|
74
|
+
* 하이드레이션에서 먼저 `Date` 로 바꿔 줬기 때문이다 — 그 단계를 건너뛴 대가를 여기서 치른다.
|
|
75
|
+
*
|
|
76
|
+
* 그래서 시간대 표시가 **없는 문자열은 UTC 로 읽는다**(TypeORM 이 UTC 로 적으므로). 표시가 있으면
|
|
77
|
+
* 그대로 믿고, `Date` 는 손대지 않는다.
|
|
78
|
+
*
|
|
79
|
+
* 읽을 수 없는 값은 **없음(`null`)** 이다 — 0 으로 떨어뜨리면 1970년이 시각축의 시작이 된다.
|
|
80
|
+
*/
|
|
81
|
+
function parseTime(v) {
|
|
82
|
+
if (v == null)
|
|
83
|
+
return null;
|
|
84
|
+
if (v instanceof Date)
|
|
85
|
+
return Number.isFinite(v.getTime()) ? v.getTime() : null;
|
|
86
|
+
const s = String(v).trim();
|
|
87
|
+
if (!s)
|
|
88
|
+
return null;
|
|
89
|
+
/* 끝에 `Z` 나 `±hh:mm` 이 붙어 있으면 시간대를 아는 문자열이다 — 그대로 믿는다. */
|
|
90
|
+
const zoned = /(?:Z|[+-]\d{2}:?\d{2})$/.test(s);
|
|
91
|
+
const t = Date.parse(zoned ? s : `${s.replace(' ', 'T')}Z`);
|
|
92
|
+
return Number.isFinite(t) ? t : null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 종류 문자열 → 커널. **모르는 값이면 오류를 낸다.**
|
|
50
96
|
*
|
|
51
97
|
* 예전에는 표를 찾고 없으면 WmsKernel 로 떨어졌다. `kind` 는 검증 없는 자유 문자열(`@Arg('kind') kind: string`)
|
|
52
98
|
* 이라 오타 하나·대소문자 하나로 야드/생산 트윈이 **조용히 창고 커널로 돌았다.** 오류가 없으니 화면에는
|
|
@@ -61,7 +107,33 @@ function kernelFor(kind) {
|
|
|
61
107
|
throw new Error(`unknown twin kind "${kind}" — expected one of ${Object.keys(KERNELS).join(' | ')}`);
|
|
62
108
|
return K;
|
|
63
109
|
}
|
|
64
|
-
|
|
110
|
+
/** `observe()` 를 갖지 않은 커널을 만난 적이 있는가 — 경고를 한 번만 낸다(틱마다 쏟지 않는다). */
|
|
111
|
+
let observeUnavailableWarned = false;
|
|
112
|
+
/**
|
|
113
|
+
* **이 커널의 진실이 원본에서 온다고 선언한다** — 그리고 선언이 닿지 않았으면 말한다.
|
|
114
|
+
*
|
|
115
|
+
* ── 무엇이 틀렸나 (2026-08-21) ──────────────────────────────────────────────
|
|
116
|
+
* 예전에는 `kernel.observe?.()` 였다. 옵셔널 호출이라 **메서드가 없으면 조용히 아무것도 하지 않는다.**
|
|
117
|
+
* 설치된 커널 0.7.39 에는 그 메서드가 없으므로, 지금 배포본에서는 미러가 관측 구동으로 선언되지
|
|
118
|
+
* 않는다 — 그런데 코드를 읽으면 선언한 것처럼 보인다.
|
|
119
|
+
*
|
|
120
|
+
* 그 차이가 실제 거동을 가른다: 관측 구동은 원본의 빈틈을 받아들이고 세지만, 시뮬로 오인된 미러는
|
|
121
|
+
* **멈춘다.** 즉 「관용이 켜진 줄 알았는데 안 켜져 있다」가 조용히 지나가고, 장애가 났을 때 원인이
|
|
122
|
+
* 커널에 있는 것처럼 보인다 — 원인은 버전이다.
|
|
123
|
+
*
|
|
124
|
+
* 그래서 옵셔널 호출을 없애고, 없으면 **한 번 경고한다.** 커널이 배포되면 이 경고는 사라진다.
|
|
125
|
+
*/
|
|
126
|
+
function declareObserved(kernel, what) {
|
|
127
|
+
if (typeof kernel?.observe === 'function') {
|
|
128
|
+
kernel.observe();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (observeUnavailableWarned)
|
|
132
|
+
return;
|
|
133
|
+
observeUnavailableWarned = true;
|
|
134
|
+
(0, log_js_1.twinWarn)(`[twin-engine] ${what}: kernel has no observe() — this mirror is not declared observation-driven. ` +
|
|
135
|
+
'Source gaps will stop it instead of being tolerated and counted. Deploy a kernel that provides observe().');
|
|
136
|
+
}
|
|
65
137
|
class TwinEngine {
|
|
66
138
|
/*
|
|
67
139
|
* 기동 중인 런타임 — **키는 `runtimeKey(domainId, instanceId)`** 다(`runtime-key.ts` 에 이유).
|
|
@@ -81,13 +153,45 @@ class TwinEngine {
|
|
|
81
153
|
* 멈춘다 — HTTP·구독·다른 트윈의 틱까지. 실측으로 `order-check` 의 틱 하나가 34.9초였고, 그 사이
|
|
82
154
|
* 구독자가 아무것도 빼내지 못해 pubsub 이 넘쳐 프로세스가 죽었다.
|
|
83
155
|
*
|
|
84
|
-
*
|
|
156
|
+
* 브로드캐스팅 반복은 걷어냈지만(`flushLiveBroadcasts` 로 병합) **커널 틱 자체는 여전히 메인 루프에 있다.**
|
|
85
157
|
* 근본 해결은 분산이고 그것은 이연됐다 — 그때까지의 안전망이 이 셋이다.
|
|
86
158
|
*
|
|
87
159
|
* 판정을 예산(500ms)이 아니라 **굶김 문턱**으로 따로 둔다: 조금 느린 트윈은 계기판이 말하게 두고
|
|
88
160
|
* (경고), 호스트를 굶기는 트윈만 멈춘다. 한 번으로 멈추지 않는다 — 웜스타트 직후의 첫 틱은 원래
|
|
89
161
|
* 무겁다(복구한 상태를 처음 접는다). **연속**으로 이어질 때가 구조적으로 느린 것이다.
|
|
90
162
|
*/
|
|
163
|
+
/*
|
|
164
|
+
* ── 틱을 **한 순간에 몰지 않는다** (2026-08-21 실측) ────────────────────────
|
|
165
|
+
* 트윈마다 `setInterval(TICK_MS)` 를 부팅 때 나란히 세우면, 스무 개가 **같은 밀리초에** 깨어난다.
|
|
166
|
+
* 각자의 틱이 짧아도(실측: 물품 2,400 개에서 0.23ms) 그 순간에는 스무 개가 한 줄로 붙어 실행되고,
|
|
167
|
+
* 그 사이 도착한 HTTP 요청은 전부 뒤에서 기다린다. 초당 한 번의 정체가 「누를 때마다 걸린다」로
|
|
168
|
+
* 나타나는 자리다.
|
|
169
|
+
*
|
|
170
|
+
* 그래서 첫 발화만 간격 안에서 **고르게 흩는다** — 총 작업량은 같고 한 순간의 최대치만 낮아진다.
|
|
171
|
+
* 트윈이 간격보다 많아지면 다시 겹치므로, 그때는 이 흩기가 아니라 분산이 답이다(이연됨).
|
|
172
|
+
*/
|
|
173
|
+
/** 부팅 때 트윈 하나를 되살린 뒤 루프를 비워 주는 시간(ms) — 그 사이 도착한 요청이 처리된다. */
|
|
174
|
+
static { this.BOOT_YIELD_MS = 25; }
|
|
175
|
+
static { this.tickPhase = 0; }
|
|
176
|
+
/**
|
|
177
|
+
* 흩어진 첫 발화 뒤 주기 틱. **핸들은 항상 진짜 타이머**다 — 정지하는 쪽이 `clearInterval(inst.timer)`
|
|
178
|
+
* 하나로 끝내야 하므로, 첫 발화 전에는 그 `setTimeout` 을, 이후에는 `setInterval` 을 같은 자리에 둔다
|
|
179
|
+
* (감싼 객체를 주면 `clearInterval` 이 아무 일도 하지 않고 트윈이 멈추지 않는다 — 조용한 결함이 된다).
|
|
180
|
+
*/
|
|
181
|
+
static startTickTimer(fn, hold) {
|
|
182
|
+
const spread = Math.max(1, Math.round(this.TICK_MS / 20));
|
|
183
|
+
const offset = (this.tickPhase = (this.tickPhase + spread) % this.TICK_MS);
|
|
184
|
+
const first = setTimeout(() => {
|
|
185
|
+
const interval = setInterval(fn, this.TICK_MS);
|
|
186
|
+
if (typeof interval?.unref === 'function')
|
|
187
|
+
interval.unref();
|
|
188
|
+
hold(interval); // 정지 경로가 지울 대상을 바꿔 준다
|
|
189
|
+
fn();
|
|
190
|
+
}, offset);
|
|
191
|
+
if (typeof first?.unref === 'function')
|
|
192
|
+
first.unref();
|
|
193
|
+
return first;
|
|
194
|
+
}
|
|
91
195
|
/** 굶김 문턱 — 틱 간격의 배수(1초 간격이면 5초). 이 시간만큼 호스트가 멈춘다. */
|
|
92
196
|
static { this.STARVE_FACTOR = 5; }
|
|
93
197
|
/** 연속 몇 번이면 멈추나 — 3번이면 15초를 굶긴 셈이고, 그건 우연이 아니다. */
|
|
@@ -119,6 +223,30 @@ class TwinEngine {
|
|
|
119
223
|
static { this.CHAIN_KEEP = 5; } // 최근 몇 지점을 들고 있나(그보다 과거는 0부터 접는다)
|
|
120
224
|
static { this.SNAPSHOT_TTL_S = 7 * 24 * 3600; } // 7일 — 정상 다운타임 생존, 만료 시 저널 replay 폴백
|
|
121
225
|
static { this.CHECKPOINT_MS = 20000; } // 체크포인트 주기(핫 브로드캐스트 경로와 분리, O(state) 스로틀)
|
|
226
|
+
/**
|
|
227
|
+
* **저널 보존 — 지우는 것은 선언이 있을 때만 한다.**
|
|
228
|
+
*
|
|
229
|
+
* ── 왜 필요한가 (2026-08-22 실측) ─────────────────────────────────────────
|
|
230
|
+
* 개발 저널이 시간당 **581,794행 · 0.85GB** 로 자랐다(하루 20GB). 데모 MES 트윈 셋이 전체의 **97%**
|
|
231
|
+
* 를 만들고 지우는 것이 없었다. 오늘 고친 것들은 「그 크기에서도 질의가 빠르게」이고, **크기 자체를
|
|
232
|
+
* 줄이는 것은 아무것도 없었다.** 그러면 며칠마다 같은 자리로 돌아온다.
|
|
233
|
+
*
|
|
234
|
+
* ── 그런데 저널을 지우는 것은 사실을 잃는 일이다 ──────────────────────────
|
|
235
|
+
* 그래서 세 규율을 지킨다.
|
|
236
|
+
*
|
|
237
|
+
* ① **선언이 없으면 아무것도 지우지 않는다.** 기본값은 없음이다 — 조용히 지우는 편이 조용히 쌓는
|
|
238
|
+
* 것보다 나쁘다. 지우는 것은 사람이 정한다.
|
|
239
|
+
* ② **체크포인트가 대신할 수 있는 만큼만.** 스냅샷이 없거나 그 리비전을 넘는 자리는 건드리지 않는다.
|
|
240
|
+
* 주석이 「만료 시 저널 replay 폴백」이라고 적어 둔 그대로 — 스냅샷이 사라지면 저널이 **유일한**
|
|
241
|
+
* 복구 수단이므로, 둘을 함께 잃으면 그 트윈의 상태는 되돌릴 수 없다.
|
|
242
|
+
* ③ **지운 것을 말한다.** 몇 건을 어느 시각까지 지웠는지 로그에 남긴다. 조용히 줄어든 저널은
|
|
243
|
+
* 「없었던 일」과 구별되지 않는다.
|
|
244
|
+
*
|
|
245
|
+
* 그리고 보존 기간은 **스냅샷 TTL(7일)보다 짧을 수 없다.** 더 짧으면 스냅샷이 살아 있는데 그것이
|
|
246
|
+
* 가리키는 앞쪽 저널이 없는 구간이 생기고, 시간여행·계보 추적이 그 구간에서 조용히 빈다.
|
|
247
|
+
*/
|
|
248
|
+
static { this.JOURNAL_RETENTION_DAYS = undefined; }
|
|
249
|
+
static { this.RETENTION_SWEEP_MS = 10 * 60 * 1000; } // 10분마다 한 번 — 지우는 일은 급하지 않다
|
|
122
250
|
/** 최신 스냅샷을 cache-service 에 체크포인트(도메인+instanceId 키). display-only·비차단·오류흡수. */
|
|
123
251
|
static async persistSnapshot(domainId, instanceId) {
|
|
124
252
|
const inst = this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, instanceId)];
|
|
@@ -164,7 +292,7 @@ class TwinEngine {
|
|
|
164
292
|
return;
|
|
165
293
|
await cache_service_1.cacheService
|
|
166
294
|
.setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, value, this.SNAPSHOT_TTL_S)
|
|
167
|
-
.catch((err) =>
|
|
295
|
+
.catch((err) => (0, log_js_1.twinError)(`[twin-engine] snapshot save fail "${instanceId}"`, err?.message ?? err));
|
|
168
296
|
}
|
|
169
297
|
/** 사슬 색인 — 어떤 리비전 지점을 들고 있나(최신순 아님, 오름차순). */
|
|
170
298
|
static async chainIndex(domainId, instanceId) {
|
|
@@ -234,6 +362,104 @@ class TwinEngine {
|
|
|
234
362
|
]);
|
|
235
363
|
return { revision: tip?.revision ?? 0, structureRev: newest?.rev ?? null };
|
|
236
364
|
}
|
|
365
|
+
/**
|
|
366
|
+
* **저널을 보존 기간까지만 둔다** — 체크포인트가 대신할 수 있는 만큼만 지운다.
|
|
367
|
+
*
|
|
368
|
+
* 한 인스턴스에서 지우는 조건은 **둘 다** 만족해야 한다.
|
|
369
|
+
*
|
|
370
|
+
* · `createdAt` 이 보존 기간보다 오래됐다 — **행이 쓰인 실제 시각**이 기준이다
|
|
371
|
+
* · `revision` 이 **체크포인트 리비전 이하**다 — 그 앞은 스냅샷이 대신한다
|
|
372
|
+
*
|
|
373
|
+
* ── 왜 `eventTime` 이 아니라 `createdAt` 인가 (2026-08-22 실측으로 고침) ────
|
|
374
|
+
* 처음에 `eventTime` 으로 적었다. 그것은 **트윈의 시계**다 — 시뮬레이션은 자기 시계로 사건을 찍고,
|
|
375
|
+
* 그 시계는 실제 시각과 무관하게 앞서거나 뒤선다. 실측:
|
|
376
|
+
*
|
|
377
|
+
* order-check 가장 늦은 eventTime = 2027-06-08 ← 실제 시각보다 10개월 앞
|
|
378
|
+
* hatio-mx2 2026-01-01 ~ 지금 ← 7개월치를 한 번에 지우게 된다
|
|
379
|
+
*
|
|
380
|
+
* 즉 「7일」이 트윈마다 다른 뜻이 된다: 시계가 앞선 트윈은 영원히 지워지지 않고, 과거부터 찍은 트윈은
|
|
381
|
+
* 거의 전부가 한 번에 지워진다. **보존은 저장 나이의 문제**이고 저장 나이는 실제 시계다.
|
|
382
|
+
*
|
|
383
|
+
* `createdAt` 은 그 행이 DB 에 쓰인 시각이고 1,599만 행 전부 채워져 있다(확인함). 도메인 시각을
|
|
384
|
+
* 정책에 쓰지 않는다 — 그 둘을 섞으면 시뮬과 미러에서 같은 설정이 다르게 동작한다.
|
|
385
|
+
*
|
|
386
|
+
* 스냅샷이 없으면 **그 인스턴스는 건드리지 않는다.** 저널이 유일한 복구 수단인 상태이므로, 지우면
|
|
387
|
+
* 그 트윈의 상태를 되돌릴 수 없다. 「지울 수 없었다」는 사실도 함께 센다 — 조용히 넘기면 「보존이
|
|
388
|
+
* 도는데 왜 안 줄어드나」가 된다.
|
|
389
|
+
*
|
|
390
|
+
* `createdAt` 이 빈 옛 행은 **지우지 않는다**(시각을 모르는 것을 「오래됐다」로 읽지 않는다).
|
|
391
|
+
*
|
|
392
|
+
* 드라이버 다섯을 다 지나야 하므로 raw SQL 을 쓰지 않는다 — 조건 삭제는 쿼리빌더가 이식한다.
|
|
393
|
+
*/
|
|
394
|
+
static async pruneJournal(domainId) {
|
|
395
|
+
/* 도메인의 정책이 먼저다(§`retentionDaysOf`). 없으면 프로세스 기본값. 둘 다 없으면 지우지 않는다. */
|
|
396
|
+
const perDomain = this.retentionDaysOf ? await this.retentionDaysOf(domainId).catch(() => undefined) : undefined;
|
|
397
|
+
const days = perDomain ?? this.JOURNAL_RETENTION_DAYS;
|
|
398
|
+
if (!days || days <= 0)
|
|
399
|
+
return { deleted: 0, instances: 0, skipped: [] };
|
|
400
|
+
/* 보존 기간은 스냅샷 TTL 보다 짧을 수 없다 — 짧으면 스냅샷이 가리키는 앞쪽이 비는 구간이 생긴다. */
|
|
401
|
+
const minDays = this.SNAPSHOT_TTL_S / 86400;
|
|
402
|
+
const effectiveDays = Math.max(days, minDays);
|
|
403
|
+
if (effectiveDays !== days) {
|
|
404
|
+
(0, log_js_1.twinWarn)(`[twin-engine] journal retention ${days}일은 스냅샷 TTL(${minDays}일)보다 짧다 — ${effectiveDays}일로 올린다 ` +
|
|
405
|
+
'(더 짧으면 스냅샷이 살아 있는데 그것이 가리키는 앞쪽 저널이 없는 구간이 생긴다)');
|
|
406
|
+
}
|
|
407
|
+
const cutoff = new Date(Date.now() - effectiveDays * 86400 * 1000);
|
|
408
|
+
const rows = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).find({ where: { domain: { id: domainId } } });
|
|
409
|
+
let deleted = 0;
|
|
410
|
+
let instances = 0;
|
|
411
|
+
const skipped = [];
|
|
412
|
+
for (const r of rows) {
|
|
413
|
+
const snap = await this.loadSnapshot(domainId, r.instanceId).catch(() => null);
|
|
414
|
+
const upTo = Number(snap?.revision ?? 0);
|
|
415
|
+
if (!upTo) {
|
|
416
|
+
/* 스냅샷이 없다 — 저널이 유일한 복구 수단이므로 손대지 않는다. */
|
|
417
|
+
skipped.push(r.instanceId);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const res = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent)
|
|
421
|
+
.createQueryBuilder()
|
|
422
|
+
.delete()
|
|
423
|
+
.from(twin_event_js_1.TwinEvent)
|
|
424
|
+
.where('domain_id = :domainId', { domainId })
|
|
425
|
+
.andWhere('instance_id = :instanceId', { instanceId: r.instanceId })
|
|
426
|
+
.andWhere('revision <= :upTo', { upTo })
|
|
427
|
+
.andWhere('created_at IS NOT NULL')
|
|
428
|
+
.andWhere('created_at < :cutoff', { cutoff })
|
|
429
|
+
.execute();
|
|
430
|
+
const n = res.affected ?? 0;
|
|
431
|
+
if (n > 0) {
|
|
432
|
+
deleted += n;
|
|
433
|
+
instances++;
|
|
434
|
+
/* **지운 것을 말한다** — 조용히 줄어든 저널은 「없었던 일」과 구별되지 않는다. */
|
|
435
|
+
(0, log_js_1.twinLog)(`[twin-engine] journal pruned "${r.instanceId}" — ${n}건 (revision ≤ ${upTo} · ${cutoff.toISOString()} 이전). ` +
|
|
436
|
+
'그 앞은 체크포인트가 대신한다.');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (skipped.length) {
|
|
440
|
+
(0, log_js_1.twinLog)(`[twin-engine] journal prune skipped ${skipped.length} instance(s) with no checkpoint — ` +
|
|
441
|
+
`저널이 유일한 복구 수단이라 손대지 않았다: ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ' …' : ''}`);
|
|
442
|
+
}
|
|
443
|
+
return { deleted, instances, skipped };
|
|
444
|
+
}
|
|
445
|
+
/** 보존 정리 주기 기동(1회) — 선언이 없으면 아무것도 하지 않는다. */
|
|
446
|
+
static startRetentionLoop(domainId) {
|
|
447
|
+
if (this.retentionTimer)
|
|
448
|
+
return;
|
|
449
|
+
/*
|
|
450
|
+
* 걸지 않는 조건: 프로세스 기본값도 없고 **도메인에 물을 길도 없을** 때다. 시임이 심겨 있으면
|
|
451
|
+
* 기본값이 없어도 걸어야 한다 — 그 도메인이 자기 값을 가질 수 있다.
|
|
452
|
+
*/
|
|
453
|
+
if (!this.JOURNAL_RETENTION_DAYS && !this.retentionDaysOf)
|
|
454
|
+
return;
|
|
455
|
+
this.retentionTimer = setInterval(() => {
|
|
456
|
+
this.pruneJournal(domainId).catch(err => (0, log_js_1.twinError)('[twin-engine] journal prune failed', err?.message ?? err));
|
|
457
|
+
}, this.RETENTION_SWEEP_MS);
|
|
458
|
+
if (typeof this.retentionTimer?.unref === 'function')
|
|
459
|
+
this.retentionTimer.unref();
|
|
460
|
+
(0, log_js_1.twinLog)(`[twin-engine] journal retention on — ${this.JOURNAL_RETENTION_DAYS ?? '(도메인 정책)'}일 · ` +
|
|
461
|
+
`${this.RETENTION_SWEEP_MS / 60000}분마다`);
|
|
462
|
+
}
|
|
237
463
|
/** 체크포인트 루프 기동(1회) — 라이브 인스턴스들의 최신 스냅샷을 주기 영속. */
|
|
238
464
|
static startCheckpointLoop() {
|
|
239
465
|
if (this.checkpointTimer)
|
|
@@ -241,7 +467,7 @@ class TwinEngine {
|
|
|
241
467
|
this.checkpointTimer = setInterval(() => {
|
|
242
468
|
for (const [key, inst] of Object.entries(this.instances)) {
|
|
243
469
|
const { instanceId } = (0, runtime_key_js_1.parseRuntimeKey)(key);
|
|
244
|
-
this.persistSnapshot(inst.domainId, instanceId).catch(err =>
|
|
470
|
+
this.persistSnapshot(inst.domainId, instanceId).catch(err => (0, log_js_1.twinError)(`[twin-engine] snapshot checkpoint fail "${instanceId}"`, err?.message ?? err));
|
|
245
471
|
}
|
|
246
472
|
}, this.CHECKPOINT_MS);
|
|
247
473
|
if (typeof this.checkpointTimer?.unref === 'function')
|
|
@@ -252,6 +478,8 @@ class TwinEngine {
|
|
|
252
478
|
* 라이브 tick 재개(sim 결정적 re-run / live 인제스트 지속)는 후속. 여기선 상태 복원 + 노출.
|
|
253
479
|
*/
|
|
254
480
|
static async bootstrap() {
|
|
481
|
+
/* 부팅부터 잰다 — 사람이 가장 답답한 구간이 여기이고, 그 구간을 재지 않으면 값으로 말할 수 없다. */
|
|
482
|
+
loop_lag_js_1.loopLag.start();
|
|
255
483
|
try {
|
|
256
484
|
const repo = (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance);
|
|
257
485
|
const rows = await repo.find({ where: { status: 'running' } });
|
|
@@ -272,22 +500,39 @@ class TwinEngine {
|
|
|
272
500
|
* 로그만 읽으면 심긴 줄 알게 된다 — 실제로 그렇게 읽고 재기동 뒤 지속시간이 사라진 것을
|
|
273
501
|
* 데이터 문제로 오진할 뻔했다.
|
|
274
502
|
*/
|
|
275
|
-
|
|
503
|
+
(0, log_js_1.twinLog)(`[twin-engine] found cached state for "${row.instanceId}" → revision ${cached.revision} (seeded at start).`);
|
|
276
504
|
continue;
|
|
277
505
|
}
|
|
278
506
|
const state = await this.recover(row.domainId, row.instanceId).catch(() => null);
|
|
279
507
|
if (state) {
|
|
280
508
|
this.recovered[(0, runtime_key_js_1.runtimeKey)(row.domainId, row.instanceId)] = { revision: state.revision, state };
|
|
281
|
-
|
|
509
|
+
(0, log_js_1.twinLog)(`[twin-engine] recovered "${row.instanceId}" from journal → revision ${state.revision}.`);
|
|
282
510
|
}
|
|
283
511
|
}
|
|
284
|
-
/*
|
|
285
|
-
|
|
512
|
+
/*
|
|
513
|
+
* 상태만 되찾는 것으로는 **도는 트윈이 되지 않는다** — 런타임까지 되살린다(아래).
|
|
514
|
+
*
|
|
515
|
+
* 되살리기 사이에 **루프를 한 번 비워 준다** (2026-08-21). 웜스타트 하나가 물품 수천 개를 접으므로
|
|
516
|
+
* 스무 개를 연달아 하면 그 시간 내내 HTTP 가 서지 않는다 — 서버는 이미 `Server ready` 를 찍은
|
|
517
|
+
* 뒤라서, 사람에게는 「열렸는데 아무 반응이 없는 화면」으로 보인다. 총 시간은 같고, 그 사이에
|
|
518
|
+
* 도착한 요청이 처리될 틈만 생긴다.
|
|
519
|
+
*/
|
|
520
|
+
for (const row of rows) {
|
|
286
521
|
await this.resumeRow(row);
|
|
522
|
+
await new Promise(resolve => setTimeout(resolve, this.BOOT_YIELD_MS));
|
|
523
|
+
}
|
|
287
524
|
this.startCheckpointLoop(); // 이후 기동되는 라이브 인스턴스의 최신 스냅샷을 주기 영속
|
|
525
|
+
/*
|
|
526
|
+
* 보존 정리 — **선언이 있을 때만** 돈다(§`JOURNAL_RETENTION_DAYS`). 도메인마다 한 번 건다.
|
|
527
|
+
* 체크포인트 루프 뒤에 두는 이유: 지울 수 있는 경계가 스냅샷이므로, 스냅샷을 남기는 쪽이 먼저
|
|
528
|
+
* 돌아야 첫 정리가 실제로 지울 것을 갖는다.
|
|
529
|
+
*/
|
|
530
|
+
for (const domainId of new Set(rows.map(r => r.domainId).filter(Boolean))) {
|
|
531
|
+
this.startRetentionLoop(domainId);
|
|
532
|
+
}
|
|
288
533
|
}
|
|
289
534
|
catch (err) {
|
|
290
|
-
|
|
535
|
+
(0, log_js_1.twinError)('[twin-engine] recovery scan failed', err);
|
|
291
536
|
}
|
|
292
537
|
}
|
|
293
538
|
/**
|
|
@@ -301,7 +546,7 @@ class TwinEngine {
|
|
|
301
546
|
*
|
|
302
547
|
* ── 모드를 지어내지 않는다 ──────────────────────────────────────────────────
|
|
303
548
|
* 미러였던 트윈을 시뮬로 되살리면 **없던 움직임을 만들어 낸다**(관측 트윈이 스스로 물건을 옮긴다).
|
|
304
|
-
* 그래서 선언된 `
|
|
549
|
+
* 그래서 선언된 `restartPolicy` 그대로 되살린다 — 미러는 관측 구동으로, 시뮬은 시뮬로.
|
|
305
550
|
*
|
|
306
551
|
* ── 되살릴 수 없으면 그렇게 적는다 ──────────────────────────────────────────
|
|
307
552
|
* 실패를 삼키면 등록부가 계속 `running` 이라 말한다 — 우리가 고치려던 그 거짓말이다. 그래서 실패한
|
|
@@ -323,18 +568,21 @@ class TwinEngine {
|
|
|
323
568
|
return;
|
|
324
569
|
}
|
|
325
570
|
try {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
571
|
+
/*
|
|
572
|
+
* **선언된 모드로 되살리는 판단은 한 곳에 있다**(`startFromRegistry`) — 2026-08-20.
|
|
573
|
+
*
|
|
574
|
+
* 예전에는 이 자리에서 `resync` 를 갈라 `startLive` 를 불렀다. 그래서 부팅으로 살아난 미러는
|
|
575
|
+
* 관측 구동이었지만 **사람이 화면에서 시작한 미러는 시뮬로 떴다**(그 문에는 갈림이 없었다).
|
|
576
|
+
* 같은 선언이 두 결과를 내지 않도록 갈림을 문 안으로 옮겼다.
|
|
577
|
+
*/
|
|
578
|
+
await this.startFromRegistry(domainId, instanceId);
|
|
579
|
+
if (row.restartPolicy === 'resync') {
|
|
331
580
|
/* 계측을 나르는 피드는 커넥터의 것이다 — 레퍼런스 계층이 부팅 훅에서 다시 붙인다
|
|
332
581
|
(`resumeReferenceLiveFeeds`). 여기서 어댑터를 아는 것은 계층을 거꾸로 잇는 것이다. */
|
|
333
|
-
|
|
582
|
+
(0, log_js_1.twinLog)(`[twin-engine] resumed mirror "${instanceId}" — feed reattach is the reference layer's job.`);
|
|
334
583
|
}
|
|
335
584
|
else {
|
|
336
|
-
|
|
337
|
-
console.log(`[twin-engine] resumed ${row.realityMode} "${instanceId}".`);
|
|
585
|
+
(0, log_js_1.twinLog)(`[twin-engine] resumed ${row.restartPolicy} "${instanceId}".`);
|
|
338
586
|
}
|
|
339
587
|
}
|
|
340
588
|
catch (err) {
|
|
@@ -343,12 +591,12 @@ class TwinEngine {
|
|
|
343
591
|
}
|
|
344
592
|
/** 되살리지 못한 행을 정직하게 적는다 — 「도는 중」이라 말하는 채로 두지 않는다. */
|
|
345
593
|
static async markStopped(row, why) {
|
|
346
|
-
|
|
594
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${row.instanceId}" not resumed (${why}) — registry says stopped now.`);
|
|
347
595
|
try {
|
|
348
596
|
await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).update({ id: row.id }, { status: 'stopped' });
|
|
349
597
|
}
|
|
350
598
|
catch (e) {
|
|
351
|
-
|
|
599
|
+
(0, log_js_1.twinError)(`[twin-engine] could not mark "${row.instanceId}" stopped — registry now lies about it.`, e?.message);
|
|
352
600
|
}
|
|
353
601
|
}
|
|
354
602
|
/**
|
|
@@ -384,10 +632,10 @@ class TwinEngine {
|
|
|
384
632
|
const plan = (0, warm_start_js_1.planWarmStart)(this.recovered[(0, runtime_key_js_1.runtimeKey)(domainId, id)]?.state, purpose, typeof hydrate === 'function');
|
|
385
633
|
if (plan.action === 'skip') {
|
|
386
634
|
if (plan.reason === 'bench') {
|
|
387
|
-
|
|
635
|
+
(0, log_js_1.twinLog)(`[twin-engine] "${id}" is a bench twin — starting empty on purpose (seeding would skew the measurement).`);
|
|
388
636
|
}
|
|
389
637
|
else if (plan.reason === 'unsupported') {
|
|
390
|
-
|
|
638
|
+
(0, log_js_1.twinWarn)(`[twin-engine] kernel for "${id}" cannot be warm-started (no hydrateObserved) — it starts with structure only, so held stock will read as zero.`);
|
|
391
639
|
}
|
|
392
640
|
return;
|
|
393
641
|
}
|
|
@@ -404,10 +652,10 @@ class TwinEngine {
|
|
|
404
652
|
/* 「언제부터인가」도 말한다 — 잃으면 지속된 조건이 모두 「방금」으로 보인다. */
|
|
405
653
|
...(plan.attentionSinceCount ? [`${plan.attentionSinceCount} attention start time(s)`] : [])
|
|
406
654
|
].join(', ');
|
|
407
|
-
|
|
655
|
+
(0, log_js_1.twinLog)(`[twin-engine] warm-started "${id}" — restored ${restored}.`);
|
|
408
656
|
/* 뺀 것은 조용히 넘기지 않는다 — 지어내지 않았다는 사실 자체를 말해야 화면의 빈칸이 읽힌다. */
|
|
409
657
|
if (plan.ordersWithoutDemand > 0) {
|
|
410
|
-
|
|
658
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${id}": ${plan.ordersWithoutDemand} order(s) could not be restored — they carry progress only, ` +
|
|
411
659
|
'with no requested/fulfilled counts, so the remaining demand is unknown. They are left out rather than guessed.');
|
|
412
660
|
}
|
|
413
661
|
}
|
|
@@ -432,7 +680,7 @@ class TwinEngine {
|
|
|
432
680
|
const declared = model?.localDurations;
|
|
433
681
|
if (declared && Object.keys(declared).length) {
|
|
434
682
|
if (typeof kernel?.declareDurations !== 'function') {
|
|
435
|
-
|
|
683
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${id}": this facility declared ${Object.keys(declared).length} operation duration(s) but the kernel cannot consume them ` +
|
|
436
684
|
'(declareDurations missing — kernel needs publishing). The simulation keeps running on built-in constants, and specCoverage() will keep reporting "default".');
|
|
437
685
|
}
|
|
438
686
|
else {
|
|
@@ -441,7 +689,23 @@ class TwinEngine {
|
|
|
441
689
|
}
|
|
442
690
|
catch (err) {
|
|
443
691
|
/* 커널이 거절한 값은 조용히 넘기지 않는다 — 화면은 「넣었습니다」라고 말한 값이다. */
|
|
444
|
-
|
|
692
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${id}": declared operation duration rejected by the kernel — ${err?.message ?? err}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/* 공정 모수(수율·셋업)도 같은 규율로 싣는다 — 커널이 그 창구를 갖지 않으면 그 사실을 말한다. */
|
|
697
|
+
const params = model?.localParams;
|
|
698
|
+
if (params && Object.keys(params).length) {
|
|
699
|
+
if (typeof kernel?.declareParameters !== 'function') {
|
|
700
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${id}": this facility declared operation parameters for ${Object.keys(params).length} operation(s) but the kernel cannot consume them ` +
|
|
701
|
+
'(declareParameters missing — kernel needs publishing). Yield and setup keep running on built-in constants.');
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
try {
|
|
705
|
+
kernel.declareParameters(params);
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${id}": declared operation parameter rejected by the kernel — ${err?.message ?? err}`);
|
|
445
709
|
}
|
|
446
710
|
}
|
|
447
711
|
}
|
|
@@ -449,7 +713,7 @@ class TwinEngine {
|
|
|
449
713
|
if (!ops?.length)
|
|
450
714
|
return;
|
|
451
715
|
if (typeof kernel?.loadOperations !== 'function') {
|
|
452
|
-
|
|
716
|
+
(0, log_js_1.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.`);
|
|
453
717
|
return;
|
|
454
718
|
}
|
|
455
719
|
kernel.loadOperations(ops);
|
|
@@ -475,13 +739,38 @@ class TwinEngine {
|
|
|
475
739
|
if (!chained) {
|
|
476
740
|
/* 왜 못 넣었는지 한 번만 알린다 — 이동시간이 상수로 남은 이유를 모르고 지나가지 않게. */
|
|
477
741
|
if (travel.reasons.length)
|
|
478
|
-
|
|
742
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}": no duration estimator installed — ${travel.reasons.join('; ')}`);
|
|
479
743
|
return;
|
|
480
744
|
}
|
|
481
745
|
kernel.durationEstimator = chained;
|
|
746
|
+
/*
|
|
747
|
+
* **양품률도 이력에서 배운다** (2026-08-19) — 소요와 같은 자리에서 붙인다.
|
|
748
|
+
*
|
|
749
|
+
* 커널이 그 시임을 갖지 않은 버전이면(발행 이전) 조용히 넘어가지 않고 말한다: 수율이 상수로 남은
|
|
750
|
+
* 이유를 모르고 지나가면, 화면의 불량 판정이 그 현장의 사실이 아니라 우리 상수의 결과다.
|
|
751
|
+
*/
|
|
752
|
+
const yields = await this.measuredYield(domainId, instanceId);
|
|
753
|
+
if (yields?.estimator) {
|
|
754
|
+
if (typeof kernel.yieldOf !== 'function') {
|
|
755
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}": learned yield for ${Object.keys(yields.learned).length} operation kind(s) but the kernel cannot consume it ` +
|
|
756
|
+
'(yieldOf missing — kernel needs publishing). Yield keeps running on the declared value or the built-in constant.');
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
kernel.yieldEstimator = yields.estimator;
|
|
760
|
+
(0, log_js_1.twinLog)(`[twin-engine] "${instanceId}": measured yield installed — ${Object.entries(yields.learned)
|
|
761
|
+
.map(([k, v]) => `${k}=${Math.round(v * 1000) / 10}%(${yields.samples[k].good + yields.samples[k].scrap}건)`)
|
|
762
|
+
.join(', ')}${yields.skipped.length ? ` · 표본 부족으로 뺀 종류: ${yields.skipped.map(s => `${s.kind}(${s.judged})`).join(', ')}` : ''}`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
else if (yields?.skipped.length) {
|
|
766
|
+
/* 배운 것이 없고 버린 것만 있으면 그 사실도 말한다 — 「이력이 없다」와 「표본이 모자라다」는 다르다. */
|
|
767
|
+
(0, log_js_1.twinLog)(`[twin-engine] "${instanceId}": no measured yield yet — samples below ${yields.minSamples}: ${yields.skipped
|
|
768
|
+
.map(s => `${s.kind}(${s.judged})`)
|
|
769
|
+
.join(', ')}`);
|
|
770
|
+
}
|
|
482
771
|
const learned = Object.keys(measured?.learned ?? {});
|
|
483
772
|
const spread = Object.keys(measured?.spreads ?? {});
|
|
484
|
-
|
|
773
|
+
(0, log_js_1.twinLog)(`[twin-engine] "${instanceId}": duration estimator installed — measured kinds: ${learned.length ? learned.join(',') : 'none'}` +
|
|
485
774
|
` (with observed spread: ${spread.length ? spread.join(',') : 'none'})` +
|
|
486
775
|
`${travel.estimator ? `, travel from distance (speeds: ${Object.keys(travel.speedsByKind).join(',')})` : `, travel not derived (${travel.reasons.join('; ')})`}`);
|
|
487
776
|
}
|
|
@@ -508,6 +797,11 @@ class TwinEngine {
|
|
|
508
797
|
* 정상 규모에서 서로 밀어내며 캐시가 무의미해진다(그게 더 나쁘다: 조용히 느려진다).
|
|
509
798
|
*/
|
|
510
799
|
static { this.MEASURED_MAX = 5_000; }
|
|
800
|
+
/** 이 트윈이 이력에서 배운 양품률 — 소요와 **같은 폴드·같은 캐시**에서 온다(저널을 두 번 접지 않는다). */
|
|
801
|
+
static async measuredYield(domainId, instanceId) {
|
|
802
|
+
await this.measuredEstimator(domainId, instanceId);
|
|
803
|
+
return this.measuredCache.get((0, runtime_key_js_1.runtimeKey)(domainId, instanceId))?.yields;
|
|
804
|
+
}
|
|
511
805
|
static async measuredEstimator(domainId, instanceId) {
|
|
512
806
|
/* 키는 `runtimeKey` 하나로 — 손으로 조립하면 지우는 쪽과 어긋나 못 지우는 항목이 생긴다. */
|
|
513
807
|
const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
|
|
@@ -515,17 +809,20 @@ class TwinEngine {
|
|
|
515
809
|
if (hit && Date.now() - hit.at < this.MEASURED_TTL_MS)
|
|
516
810
|
return hit.value;
|
|
517
811
|
let value;
|
|
812
|
+
let yields;
|
|
518
813
|
try {
|
|
519
814
|
/* 작업 종류별 실측 — 창은 넉넉히(하루) 두고 표본이 모자란 종류는 추정기가 스스로 뺀다. */
|
|
520
815
|
const kpi = await (0, kpi_query_js_1.computeTwinKpi)({ domainId, instanceId, windowMinutes: 24 * 60, groupBy: 'taskKind' });
|
|
521
816
|
value = (0, measured_estimator_js_1.buildMeasuredEstimator)(kpi?.groups?.items, {});
|
|
817
|
+
/* 같은 그룹에서 양품률도 배운다 — 한 번 접은 저널을 둘이 나눠 쓴다. */
|
|
818
|
+
yields = (0, measured_yield_js_1.buildYieldEstimator)(kpi?.groups?.items, {});
|
|
522
819
|
}
|
|
523
820
|
catch (err) {
|
|
524
|
-
|
|
821
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}": measured duration lookup failed — falling back to declared/default durations.`, err?.message);
|
|
525
822
|
}
|
|
526
823
|
/* 다시 넣어 **최근 쓴 것**으로 만든다 — 삽입 순서가 곧 버릴 순서이므로 이 한 줄이 LRU 를 만든다. */
|
|
527
824
|
this.measuredCache.delete(key);
|
|
528
|
-
this.measuredCache.set(key, { at: Date.now(), value });
|
|
825
|
+
this.measuredCache.set(key, { at: Date.now(), value, yields });
|
|
529
826
|
if (this.measuredCache.size > this.MEASURED_MAX) {
|
|
530
827
|
const oldest = this.measuredCache.keys().next();
|
|
531
828
|
if (!oldest.done)
|
|
@@ -533,6 +830,70 @@ class TwinEngine {
|
|
|
533
830
|
}
|
|
534
831
|
return value;
|
|
535
832
|
}
|
|
833
|
+
/**
|
|
834
|
+
* **원본이 선언한 자극을 싣는다** — 재기동해도 살아 있게 (2026-08-19).
|
|
835
|
+
*
|
|
836
|
+
* ── 무엇이 났나 ────────────────────────────────────────────────────────────
|
|
837
|
+
* 데모의 시나리오는 시드 코드가 메모리에만 실었다. 그래서 트윈을 재기동하면 자극이 사라지고 구조만
|
|
838
|
+
* 서 있는 트윈이 남았다(작업 0·오더 0) — 「살아 있는 데모」가 **첫 재기동까지만** 살았다.
|
|
839
|
+
*
|
|
840
|
+
* 자극의 집은 **원본**이다(`TwinReference.connectionConfig.scenario`, ADR-0029 ·
|
|
841
|
+
* `plans/simulator-as-source.md` §4): 무엇이 들어오고 무슨 주문이 나는지는 시뮬레이터가 정하는 사실이고
|
|
842
|
+
* 트윈은 반영한다. 트윈에 새 축을 만들면 ADR-0029 가 걷어내야 할 표면이 하나 늘어난다.
|
|
843
|
+
*
|
|
844
|
+
* 판정은 순수 함수가 한다(`planStimulus`) — 미러에 싣지 않고, 검사를 통과하지 못한 선언은 태우지 않고
|
|
845
|
+
* (그 이력이 있다: 잘못된 선언 하나가 다음 틱에서 서버를 내렸다), 못 실은 이유는 **말한다.**
|
|
846
|
+
*/
|
|
847
|
+
static async installStimulus(domainId, instanceId, inst) {
|
|
848
|
+
let config;
|
|
849
|
+
try {
|
|
850
|
+
const ref = await (0, shell_1.getRepository)(twin_reference_js_1.TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } });
|
|
851
|
+
config = ref?.connectionConfig;
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}": could not read the declared stimulus — ${err?.message ?? err}`);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const plan = (0, declared_stimulus_js_1.planStimulus)(config, { hasScenarioEngine: !!inst.runtime?.scenario, mode: inst.mode }, validateScenario);
|
|
858
|
+
if (plan.action === 'skip') {
|
|
859
|
+
/* 선언이 없는 것은 정상이므로 조용히 지난다. 나머지 셋은 **말한다** — 선언했는데 안 실린 상태다. */
|
|
860
|
+
if (plan.reason !== 'none') {
|
|
861
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}": a stimulus is declared on its source but was not loaded (${plan.reason}${plan.detail ? `: ${plan.detail}` : ''}).` +
|
|
862
|
+
(plan.reason === 'observed' ? ' This twin runs on observation — we do not manufacture arrivals for it.' : ''));
|
|
863
|
+
}
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
try {
|
|
867
|
+
/* 자극을 싣고 시작하는 데 든 시간 — 실측에서 큰 트윈은 이 구간이 29~72초였다. */
|
|
868
|
+
const tStim = performance.now();
|
|
869
|
+
inst.runtime.scenario.load(plan.scenario);
|
|
870
|
+
inst.runtime.scenario.start();
|
|
871
|
+
const stimMs = performance.now() - tStim;
|
|
872
|
+
(0, load_meter_js_1.recordPhase)(inst.load ?? (inst.load = (0, load_meter_js_1.newLoadMeter)()), 'stimulus', stimMs);
|
|
873
|
+
const kinds = (plan.scenario.generators ?? []).map((g) => g?.kind).filter(Boolean);
|
|
874
|
+
(0, log_js_1.twinLog)(`[twin-engine] "${instanceId}": stimulus from its source started in ${Math.round(stimMs)}ms — ${kinds.length ? kinds.join(', ') : 'no generators'}.`);
|
|
875
|
+
}
|
|
876
|
+
catch (err) {
|
|
877
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}": the declared stimulus was rejected at load — ${err?.message ?? err}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* 자극을 **원본에 선언한다** — 프로비저닝·데모 시드가 부르는 문.
|
|
882
|
+
*
|
|
883
|
+
* 트윈이 아니라 원본에 적는 이유는 위와 같다(ADR-0029). 원본 행이 없으면 만들지 않는다 — 어떤 원본에서
|
|
884
|
+
* 온 트윈인지 모르는 채 자극을 지어 붙이면, 그 자극이 어디서 왔는지 아무도 되짚을 수 없다.
|
|
885
|
+
*/
|
|
886
|
+
static async declareStimulus(domainId, instanceId, scenario) {
|
|
887
|
+
const repo = (0, shell_1.getRepository)(twin_reference_js_1.TwinReference);
|
|
888
|
+
const ref = await repo.findOne({ where: { domain: { id: domainId }, source: instanceId } });
|
|
889
|
+
if (!ref) {
|
|
890
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}": no source reference — a stimulus has nowhere to be declared.`);
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
ref.connectionConfig = (0, declared_stimulus_js_1.withStimulus)(ref.connectionConfig, scenario);
|
|
894
|
+
await repo.save(ref);
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
536
897
|
/**
|
|
537
898
|
* 이 트윈이 **이력에서 시간을 배운 작업 종류들** — 재기동에도 남는 근거.
|
|
538
899
|
*
|
|
@@ -637,7 +998,7 @@ class TwinEngine {
|
|
|
637
998
|
if (!plan.ackedCount && !plan.attentionSinceCount && !plan.energy)
|
|
638
999
|
return;
|
|
639
1000
|
if (typeof kernel.hydrateContinuity !== 'function') {
|
|
640
|
-
|
|
1001
|
+
(0, log_js_1.twinWarn)(`[twin-engine] kernel for "${id}" cannot carry continuity (no hydrateContinuity) — the open demand window, ` +
|
|
641
1002
|
'its peak and the attention start times are lost on every restart.');
|
|
642
1003
|
return;
|
|
643
1004
|
}
|
|
@@ -646,7 +1007,7 @@ class TwinEngine {
|
|
|
646
1007
|
}
|
|
647
1008
|
catch (err) {
|
|
648
1009
|
/* 이어받기가 실패해도 미러는 돌아야 한다 — 다만 무엇을 잃었는지 말한다. */
|
|
649
|
-
|
|
1010
|
+
(0, log_js_1.twinWarn)(`[twin-engine] continuity seed failed for "${id}" — starting without it: ${err?.message ?? err}`);
|
|
650
1011
|
return;
|
|
651
1012
|
}
|
|
652
1013
|
const carried = [
|
|
@@ -654,9 +1015,9 @@ class TwinEngine {
|
|
|
654
1015
|
...(plan.ackedCount ? [`${plan.ackedCount} acknowledged attention(s)`] : []),
|
|
655
1016
|
...(plan.attentionSinceCount ? [`${plan.attentionSinceCount} attention start time(s)`] : [])
|
|
656
1017
|
].join(', ');
|
|
657
|
-
|
|
1018
|
+
(0, log_js_1.twinLog)(`[twin-engine] mirror "${id}" carried over ${carried} — observation axes come from the source.`);
|
|
658
1019
|
}
|
|
659
|
-
static start(id, domainId, kind, model,
|
|
1020
|
+
static start(id, domainId, kind, model, restartPolicy, purpose, resumeFrom) {
|
|
660
1021
|
const key = (0, runtime_key_js_1.runtimeKey)(domainId, id);
|
|
661
1022
|
if (this.instances[key])
|
|
662
1023
|
return this.instances[key];
|
|
@@ -665,8 +1026,17 @@ class TwinEngine {
|
|
|
665
1026
|
kernel.loadTwinModel(model); // 구조만. 상태는 아래 웜스타트가 주입한다.
|
|
666
1027
|
this.applyOperations(kernel, model, id); // 시간·수율 명세(있으면) — 없으면 커널 기본값
|
|
667
1028
|
/* 추정기는 DB 조회를 포함해 비동기 — 기동을 막지 않고 붙는다(붙기 전 작업은 명세·상수로 산출). */
|
|
668
|
-
this.installEstimators(kernel, domainId, id, model).catch(err =>
|
|
1029
|
+
this.installEstimators(kernel, domainId, id, model).catch(err => (0, log_js_1.twinWarn)('[twin-engine] estimator install failed', err?.message));
|
|
1030
|
+
/*
|
|
1031
|
+
* **웜스타트에 든 시간을 값으로 남긴다** (2026-08-22). 실측으로 부팅이 620~717초였고 트윈 하나에
|
|
1032
|
+
* 15~93초였는데, 어느 작업이 그 시간을 쓰는지 답할 계기가 없었다. 로그에도 밀리초까지 적어
|
|
1033
|
+
* 부팅 로그만으로 구간을 읽을 수 있게 한다.
|
|
1034
|
+
*/
|
|
1035
|
+
const tWarm = performance.now();
|
|
669
1036
|
this.warmStart(domainId, id, kernel, purpose);
|
|
1037
|
+
const warmMs = performance.now() - tWarm;
|
|
1038
|
+
if (warmMs >= 1000)
|
|
1039
|
+
(0, log_js_1.twinLog)(`[twin-engine] "${id}": warm start took ${Math.round(warmMs)}ms.`);
|
|
670
1040
|
/*
|
|
671
1041
|
* **번호를 이어 센다** — 저널에 이미 있는 번호와 겹치지 않게.
|
|
672
1042
|
*
|
|
@@ -695,40 +1065,84 @@ class TwinEngine {
|
|
|
695
1065
|
* 실제로는 도는 커널이 옛 수를 그대로 쓰고 재기동 때 받는데, 그 사실이 답에서 사라진 것이다.
|
|
696
1066
|
* 규약에 기대는 대신 사실을 적는다: 「돌고 있나」를 묻는 쪽이 그 답을 받아야 한다.
|
|
697
1067
|
*/
|
|
698
|
-
const inst = {
|
|
1068
|
+
const inst = {
|
|
1069
|
+
id,
|
|
1070
|
+
domainId,
|
|
1071
|
+
mode: 'sim',
|
|
1072
|
+
runtime,
|
|
1073
|
+
kernel,
|
|
1074
|
+
/* 정책은 부르는 쪽이 선언한다 — 여기서 고르면 같은 트윈이 부르는 자리에 따라 다르게 재기동한다. */
|
|
1075
|
+
restartPolicy: (0, restart_policy_js_1.readRestartPolicy)(restartPolicy, `start("${id}")`),
|
|
1076
|
+
spaceId: model?.spaceId,
|
|
1077
|
+
/*
|
|
1078
|
+
* **시뮬도 계기를 든다** (2026-08-20).
|
|
1079
|
+
*
|
|
1080
|
+
* 예전에는 `startLive` 에서만 만들었다. 그래서 도는 트윈 22개가 시뮬인 서버에서 「초당 몇 행을
|
|
1081
|
+
* 쓰나」·「밀린 게 있나」를 물을 자리가 **아예 없었다** — 저널 쓰기를 배치로 고친 뒤에도 그 효과와
|
|
1082
|
+
* 회귀를 숫자로 볼 수 없다. 세지 않는 개선은 다음 사람이 되돌려도 아무도 모른다.
|
|
1083
|
+
*
|
|
1084
|
+
* 유입(`ingested`)은 시뮬에 뜻이 없다(원본에서 받는 것이 아니라 자기가 낸다) — 그 칸은 0 으로
|
|
1085
|
+
* 남고, 그것이 사실이다(「받은 것이 없다」).
|
|
1086
|
+
*/
|
|
1087
|
+
metrics: this.newMetrics(),
|
|
1088
|
+
unsub: () => { }
|
|
1089
|
+
};
|
|
699
1090
|
this.instances[key] = inst;
|
|
700
1091
|
/* 다시 세웠으므로 지난 정지 이유는 사실이 아니다 — 남겨 두면 도는 트윈이 「굶겨서 멈췄다」고 말한다. */
|
|
701
1092
|
this.stopNotes.delete(key);
|
|
702
1093
|
delete this.recovered[key]; // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
|
|
1094
|
+
/* 원본이 선언한 자극 — 기동을 막지 않는다(추정기와 같은 규율). 못 실었으면 그 사실을 말한다. */
|
|
1095
|
+
this.installStimulus(domainId, id, inst).catch(err => (0, log_js_1.twinWarn)(`[twin-engine] "${id}": stimulus install failed — ${err?.message ?? err}`));
|
|
703
1096
|
/* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
|
|
704
1097
|
(0, shell_1.getRepository)(shell_1.Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => { });
|
|
705
|
-
/* State 채널: runtime.subscribe(snapshot→delta→clock) → pubsub
|
|
1098
|
+
/* State 채널: runtime.subscribe(snapshot→delta→clock) → pubsub 브로드캐스팅(구독 리졸버가 instanceId 필터). */
|
|
706
1099
|
const sub = runtime.subscribe((msg) => {
|
|
707
1100
|
this.publishGuarded('twin-state', { twinState: { instanceId: id, kind: msg.kind, revision: msg.revision, payload: msg } }, `twin-state:${id}`);
|
|
708
|
-
/* 영속 + 라이브 바인딩 브리지: delta
|
|
1101
|
+
/* 영속 + 라이브 바인딩 브리지: delta 를 저널 버퍼에 담고 + 엔티티별 data(tag:) publish → 보드 컴포넌트 라이브. */
|
|
709
1102
|
if (msg.kind === 'delta') {
|
|
710
|
-
this.persist(domainId, id, msg).catch(err => console.error('twin persist fail', err));
|
|
711
1103
|
/*
|
|
712
|
-
* ──
|
|
713
|
-
*
|
|
1104
|
+
* ── 쓰기도 **모아서** 한 번 (2026-08-20 실측으로 잡음) ────────────────────
|
|
1105
|
+
*
|
|
1106
|
+
* 여기서 델타마다 `persist()` 를 불렀다 — 행 하나당 `save()` 하나, 그리고 그 안에서
|
|
1107
|
+
* `structureRevOf` 를 await(구조 행이 없는 트윈은 캐시 미스가 기억되지 않아 **델타마다
|
|
1108
|
+
* SELECT** 였다). 데모 자극을 켠 트윈 23개에서 그 결과가 이랬다: 프로세스 CPU 81~112%,
|
|
1109
|
+
* 아무 일도 하지 않는 질의가 8~14초. 프로파일은 `JSON.parse`·대형 GC·스레드풀을 가리키고
|
|
1110
|
+
* 계기는 틱의 몫이 16% 라고 말했다 — **틱이 아니라 쓰기였다.**
|
|
1111
|
+
*
|
|
1112
|
+
* 바로 위 주석이 **브로드캐스팅**에서 같은 함정을 적어 두었다(틱 하나가 35초). 브로드캐스팅은 모으도록
|
|
1113
|
+
* 고쳤고 쓰기는 단건으로 남아 있었다. 같은 규율로 들인다 — ADR-0030 이 *"기록 경로가 둘인
|
|
1114
|
+
* 것은 시뮬만 옆문으로 들어오기 때문"* 이라 예고한 그 자리다.
|
|
1115
|
+
*
|
|
1116
|
+
* **리비전은 커널의 것을 그대로 든다**(라이브는 flush 때 호스트가 부여한다). 시뮬의 저널은
|
|
1117
|
+
* 커널 리비전으로 접히므로 여기서 다시 번호를 매기면 시간여행이 어긋난다.
|
|
1118
|
+
*/
|
|
1119
|
+
;
|
|
1120
|
+
(inst.pendingJournal ?? (inst.pendingJournal = [])).push({ event: msg.event, revision: msg.revision });
|
|
1121
|
+
/*
|
|
1122
|
+
* ── 브로드캐스팅은 **모아서** 한 번 (2026-08-14 실측으로 잡음) ────────────────────
|
|
1123
|
+
* 여기서 곧바로 브로드캐스팅하고 있었다. 그런데 커널은 한 번의 tick 에서 사실을 **여러 개** 낸다
|
|
714
1124
|
* (예약·배치·완료…). 그래서 tick 하나가 전 상태 투영을 수백 번 반복했다 — `order-check`
|
|
715
1125
|
* 트윈에서 **틱 하나가 35초**를 먹고(단계 합은 32ms 였다: 시간은 반복 횟수에 있었다) 그
|
|
716
1126
|
* 35초 동안 이벤트 루프가 막혀 구독자가 아무것도 빼내지 못했다. 밀린 push 가 1024를 넘는
|
|
717
|
-
* 순간 pubsub 이
|
|
1127
|
+
* 순간 pubsub 이 오류를 내고, 그 예외가 타이머 콜백을 타고 올라와 **호스트가 죽었다.**
|
|
718
1128
|
*
|
|
719
1129
|
* 라이브는 이미 dirty 표시 + 주기 flush 로 이 문제를 풀어 두었다(BROADCAST_COALESCE_MS).
|
|
720
1130
|
* 시뮬만 그 규율 밖에 있었다 — 같은 규율로 들인다(최신-상태 채널이라 중간 상태를 모두
|
|
721
1131
|
* 보낼 이유가 없다: 200ms 마다 마지막 것 하나면 화면은 같다).
|
|
722
1132
|
*/
|
|
723
1133
|
inst.dirty = true;
|
|
1134
|
+
/* 이 사실이 건드린 물품만 다음 브로드캐스팅에서 만든다(모르면 전부). */
|
|
1135
|
+
this.markItemsDirty(inst, [msg.event]);
|
|
724
1136
|
this.ensureBroadcastCoalescer();
|
|
725
1137
|
}
|
|
726
1138
|
});
|
|
727
1139
|
inst.unsub = () => sub.unsubscribe();
|
|
728
|
-
/*
|
|
729
|
-
|
|
1140
|
+
/* 위에서 잰 웜스타트 시간을 계기에 적는다 — 계기는 인스턴스가 생긴 뒤에야 있다. */
|
|
1141
|
+
(0, load_meter_js_1.recordPhase)(inst.load ?? (inst.load = (0, load_meter_js_1.newLoadMeter)()), 'warmStart', warmMs);
|
|
1142
|
+
/* 복구 앵커: 레지스트리에 model/kind/status/restartPolicy 영속(재부팅 시 이게 있어야 replay·선언 거동 가능). */
|
|
1143
|
+
this.register(domainId, id, kind, model, 'running', inst.restartPolicy).catch(err => (0, log_js_1.twinError)('twin register fail', err));
|
|
730
1144
|
/* 워커 tick — 스켈레톤은 setInterval(메인 루프). 긴 시뮬 오프-루프(worker thread)는 스케일 하드닝(향후, §host 경계). */
|
|
731
|
-
inst.timer =
|
|
1145
|
+
inst.timer = this.startTickTimer(() => this.tickGuarded(domainId, id, runtime), t => (inst.timer = t));
|
|
732
1146
|
return inst;
|
|
733
1147
|
}
|
|
734
1148
|
/**
|
|
@@ -790,7 +1204,7 @@ class TwinEngine {
|
|
|
790
1204
|
const offset = (0, reference_master_js_1.utcOffsetOf)(space?.timezone);
|
|
791
1205
|
if (offset === undefined) {
|
|
792
1206
|
if (space?.timezone)
|
|
793
|
-
|
|
1207
|
+
(0, log_js_1.twinWarn)(`[twin-engine] space "${sid}" declares time zone "${space.timezone}" but it is not a known IANA zone — times will be read as UTC.`);
|
|
794
1208
|
return model;
|
|
795
1209
|
}
|
|
796
1210
|
return { ...model, utcOffsetMinutes: offset };
|
|
@@ -804,12 +1218,12 @@ class TwinEngine {
|
|
|
804
1218
|
kernel.loadTwinModel(model);
|
|
805
1219
|
/* **세우는 쪽이 아는 사실은 세울 때 말한다.** 예전에는 첫 이벤트가 도착해야 커널이 스스로를
|
|
806
1220
|
관측 구동으로 여겼고, 그래서 아직 아무것도 못 받은 미러는 시뮬레이션 취급을 받았다. */
|
|
807
|
-
kernel
|
|
1221
|
+
declareObserved(kernel, `live twin '${id}'`);
|
|
808
1222
|
this.applyOperations(kernel, model, id); // 명세는 라이브에도 실린다(예측 자격이 sim 과 같아진다)
|
|
809
1223
|
/* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다.
|
|
810
1224
|
* (P3 에서 소비처를 커널 어휘로 바꾸면 사라진다.) */
|
|
811
1225
|
const projector = { apply: (e) => kernel.apply(e), snapshot: () => kernel.getSnapshot() };
|
|
812
|
-
const inst = { id, domainId, mode: 'live',
|
|
1226
|
+
const inst = { id, domainId, mode: 'live', restartPolicy: 'resync', kernel, projector, oee: new oee_accumulator_js_1.OeeAccumulator(), spaceId: model?.spaceId, unsub: () => { } };
|
|
813
1227
|
/*
|
|
814
1228
|
* ── 커널이 **판정으로 낸 사실**도 저널에 남는다 (2026-08-14 실측으로 잡음) ────
|
|
815
1229
|
*
|
|
@@ -831,14 +1245,17 @@ class TwinEngine {
|
|
|
831
1245
|
return // 인입의 재방출 — 저널은 인입에서 한 번만
|
|
832
1246
|
;
|
|
833
1247
|
(inst.pendingJournal ?? (inst.pendingJournal = [])).push(e);
|
|
834
|
-
/* 커널이 낸 사실도 상태를 바꾼다(피크·마감) — 다음
|
|
1248
|
+
/* 커널이 낸 사실도 상태를 바꾼다(피크·마감) — 다음 브로드캐스팅 주기에 실린다. */
|
|
835
1249
|
inst.dirty = true;
|
|
1250
|
+
this.markItemsDirty(inst, [e]);
|
|
836
1251
|
this.ensureBroadcastCoalescer();
|
|
837
1252
|
}) ?? (() => { });
|
|
838
1253
|
/* 추정기(실측·거리)도 라이브에 붙인다 — 예측이 상수로 계산되지 않게. 기동을 막지 않는다. */
|
|
839
|
-
this.installEstimators(kernel, domainId, id, model).catch(err =>
|
|
840
|
-
inst.metrics =
|
|
1254
|
+
this.installEstimators(kernel, domainId, id, model).catch(err => (0, log_js_1.twinWarn)('[twin-engine] estimator install failed', err?.message));
|
|
1255
|
+
inst.metrics = this.newMetrics();
|
|
841
1256
|
this.instances[key] = inst;
|
|
1257
|
+
/* 미러에도 부른다 — 선언이 있으면 「미러에는 싣지 않는다」고 말해야 한다(조용한 무시 금지). */
|
|
1258
|
+
this.installStimulus(domainId, id, inst).catch(err => (0, log_js_1.twinWarn)(`[twin-engine] "${id}": stimulus check failed — ${err?.message ?? err}`));
|
|
842
1259
|
/*
|
|
843
1260
|
* **원천이 되풀어 주지 않는 것만 잇는다** (2026-08-18 실측으로 붙임).
|
|
844
1261
|
*
|
|
@@ -854,23 +1271,65 @@ class TwinEngine {
|
|
|
854
1271
|
delete this.recovered[key];
|
|
855
1272
|
/* 라이브 바인딩(data 채널) subdomain 필터용 Domain 1회 해석(sim 과 동일). */
|
|
856
1273
|
(0, shell_1.getRepository)(shell_1.Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => { });
|
|
857
|
-
/*
|
|
1274
|
+
/*
|
|
1275
|
+
* 저널 revision 카운터 시드 — 기존 저널 최대치에서 이어붙임(재기동 시 revision 충돌 방지).
|
|
1276
|
+
*
|
|
1277
|
+
* ── **커널에도 같은 번호를 알려 준다** (2026-08-23 실측) ────────────────────
|
|
1278
|
+
* 예전에는 이 자리에서 `inst.revision`(저널 줄 번호)만 이어받고 **커널은 0 부터 세게 두었다.**
|
|
1279
|
+
* 그래서 체크포인트가 뜻이 다른 두 수를 나란히 적었다.
|
|
1280
|
+
*
|
|
1281
|
+
* 바깥 revision 12,972 저널에 적힌 줄 번호 (이 자리에서 이어받는다)
|
|
1282
|
+
* state.revision 6,478 커널이 처리한 봉투 수 (0 부터 셌다)
|
|
1283
|
+
*
|
|
1284
|
+
* 포천 미러에서 확정했다: 차이 6,494 는 그 트윈을 **다시 세운 시각**(09:11)에 저널이 이미 갖고
|
|
1285
|
+
* 있던 줄 수와 정확히 같았다(줄 6494 = 08:23:59, 줄 6495 = 09:11:13). 산수가 맞았다.
|
|
1286
|
+
*
|
|
1287
|
+
* 결함은 두 수가 다르다는 것 자체가 아니라 **비대칭**이다: 재기동 경로(`start` → `resumeRevision`)는
|
|
1288
|
+
* 커널에 번호를 알려 주는데 **이 경로(미러)는 알려 주지 않았다.** 그래서 같은 저장물을 읽는 소비처가
|
|
1289
|
+
* 같은 이름의 두 수를 같은 축으로 견주게 되고, 실제로 그렇게 읽혔다.
|
|
1290
|
+
*
|
|
1291
|
+
* 저널이 비어 있으면(0) 아무 일도 하지 않는다 — 새 트윈은 0 부터 세는 것이 맞다. 그리고 이 조회는
|
|
1292
|
+
* 비동기라 그 사이에 이벤트가 몇 건 들어와 있을 수 있는데, 그때 뒤로 되돌리는 것은 커널이 거절한다
|
|
1293
|
+
* (겹치는 번호를 막는 그 판정이다). 거절은 삼키지 않고 남긴다.
|
|
1294
|
+
*/
|
|
858
1295
|
(0, shell_1.getRepository)(twin_event_js_1.TwinEvent)
|
|
859
1296
|
.findOne({ where: { domain: { id: domainId }, instanceId: id }, order: { revision: 'DESC' } })
|
|
860
|
-
.then(top =>
|
|
1297
|
+
.then(top => {
|
|
1298
|
+
const head = top?.revision ?? 0;
|
|
1299
|
+
inst.revision = head;
|
|
1300
|
+
if (!head || typeof kernel.resumeRevision !== 'function')
|
|
1301
|
+
return;
|
|
1302
|
+
try {
|
|
1303
|
+
;
|
|
1304
|
+
kernel.resumeRevision(head);
|
|
1305
|
+
}
|
|
1306
|
+
catch (err) {
|
|
1307
|
+
/* 뒤로 갈 수 없다는 거절 — 이 조회가 도착하기 전에 이미 그만큼 처리했다는 뜻이다. */
|
|
1308
|
+
(0, log_js_1.twinWarn)(`[twin-engine] "${id}": journal head ${head} is behind the kernel — ${err?.message ?? err}`);
|
|
1309
|
+
}
|
|
1310
|
+
})
|
|
861
1311
|
.catch(() => (inst.revision = 0));
|
|
862
|
-
this.register(domainId, id, kind, model, 'running', '
|
|
1312
|
+
this.register(domainId, id, kind, model, 'running', 'resync').catch(err => (0, log_js_1.twinError)('twin register fail', err));
|
|
863
1313
|
return inst;
|
|
864
1314
|
}
|
|
865
1315
|
/**
|
|
866
|
-
* live 이벤트 인제스트 — projector 구동 + data(tag)
|
|
1316
|
+
* live 이벤트 인제스트 — projector 구동 + data(tag) 브로드캐스팅(폐루프의 인바운드 도착 지점, command-routing §8.2).
|
|
867
1317
|
* reference 어댑터가 낸 records → 커널 face2-adapter.ingest → CanonicalEnvelope 를 여기로 밀어넣는다.
|
|
868
1318
|
* (State 채널 델타/저널 결선은 후속 — 스켈레톤은 data(tag) 미러 중심.)
|
|
1319
|
+
*
|
|
1320
|
+
* ── 넣은 수를 **답한다** (2026-08-20) ────────────────────────────────────────
|
|
1321
|
+
* 트윈이 라이브로 돌지 않으면 여기서 봉투를 버린다. 그것 자체는 맞다(넣을 커널이 없다). 문제는
|
|
1322
|
+
* **조용히** 버린 것이었다: 트윈이 멈춘 뒤에도 피드는 남아 레코드를 나르고, 유입 장부는 그것을
|
|
1323
|
+
* 「통과」로 셌다. 화면은 멈춘 트윈 옆에 「150 통과 · 100%」라고 적었다 — 사실이 사라지는 동안
|
|
1324
|
+
* 화면이 안심시킨 것이다.
|
|
1325
|
+
*
|
|
1326
|
+
* 그래서 **넣은 수를 돌려준다.** 부르는 쪽이 제시 수와 견주어 버려진 수를 장부에 적는다. 반환을
|
|
1327
|
+
* 무시하는 호출부는 그대로 동작한다(전과 같다).
|
|
869
1328
|
*/
|
|
870
1329
|
static ingestLive(domainId, id, envelopes) {
|
|
871
1330
|
const inst = this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, id)];
|
|
872
1331
|
if (inst?.mode !== 'live' || !inst.projector)
|
|
873
|
-
return;
|
|
1332
|
+
return 0;
|
|
874
1333
|
const tIngest = performance.now();
|
|
875
1334
|
/* 인입 봉투를 표시해 두고 넣는다 — 커널이 그것을 재방출해도 저널에 두 번 적히지 않게(위 구독 주석). */
|
|
876
1335
|
for (const e of envelopes) {
|
|
@@ -885,46 +1344,146 @@ class TwinEngine {
|
|
|
885
1344
|
inst.metrics.ingestedTotal += envelopes.length;
|
|
886
1345
|
inst.metrics._accIngest += envelopes.length;
|
|
887
1346
|
} // 계측(④-1)
|
|
888
|
-
//
|
|
889
|
-
// 이벤트마다
|
|
1347
|
+
// 브로드캐스팅 병합(ingest-scale §1.1/§4.4) — apply 는 O(1)·싸다. 그러나 브로드캐스팅(snapshot O(state))은 비싸(대규모 5ms+)
|
|
1348
|
+
// 이벤트마다 브로드캐스팅하면 폭발 → dirty 만 찍고 coalescer tick 이 주기 브로드캐스팅(브로드캐스팅률 ≠ 인제스트률).
|
|
890
1349
|
inst.dirty = true;
|
|
1350
|
+
this.markItemsDirty(inst, envelopes);
|
|
891
1351
|
this.ensureBroadcastCoalescer();
|
|
1352
|
+
return envelopes.length;
|
|
892
1353
|
}
|
|
893
1354
|
/**
|
|
894
|
-
* 구간 성과
|
|
1355
|
+
* 구간 성과 브로드캐스팅은 **없앴다**(2026-08-06). 카드가 `twinKpi` 를 직접 묻는다.
|
|
895
1356
|
*
|
|
896
|
-
* 왜: 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려면
|
|
1357
|
+
* 왜: 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려면 브로드캐스팅으로는 태그가 트윈당 1,200개가 되고,
|
|
897
1358
|
* **모델에 카드를 하나도 안 놓아도** 30초마다 트윈마다 저널을 접었다. 질의로 바꾸니 보고 있는 카드
|
|
898
1359
|
* 수만큼만 들고, 같은 (대상·창·축) 은 클라이언트가 하나로 합친다.
|
|
899
1360
|
*
|
|
900
1361
|
* 덤으로 질의만 할 수 있는 것이 둘 생겼다 — **과거 시각**(`toTime`)과 **공간 단위 합산**(여러 트윈을
|
|
901
|
-
* 한 번에 접기).
|
|
1362
|
+
* 한 번에 접기). 브로드캐스팅 루프는 트윈별이라 둘 다 못 했다.
|
|
902
1363
|
*
|
|
903
1364
|
* 축을 나눠도 폴드 비용이 같다는 실측이 근거다(`test/kpi-query-bench.test.ts`).
|
|
904
1365
|
*/
|
|
905
|
-
/**
|
|
1366
|
+
/**
|
|
1367
|
+
* 몇 창마다 한 번은 **전부** 만드나 — 사건 없이 값이 바뀌는 자리에 대한 그물.
|
|
1368
|
+
*
|
|
1369
|
+
* 25 창이면 기본 주기에서 5초다. 보장이 아니라 그물이다(위 `publishEntityData` 주석).
|
|
1370
|
+
*/
|
|
1371
|
+
static { this.FULL_BROADCAST_EVERY = 25; }
|
|
1372
|
+
/** 전부 만든 횟수 — 범위를 좁히지 못한 창이 얼마나 되는지 값으로 남는다. */
|
|
1373
|
+
static { this.broadcastFullPasses = 0; }
|
|
1374
|
+
/**
|
|
1375
|
+
* 브로드캐스팅을 만든 횟수 전부 — **전부 만든 횟수의 분모.**
|
|
1376
|
+
*
|
|
1377
|
+
* 분자만 내면 「전부 만들기 1,200회」가 많은 것인지 적은 것인지 읽을 수 없다. 좁히기가 듣고 있으면
|
|
1378
|
+
* 이 값의 `1/FULL_BROADCAST_EVERY` 쯤이 전부 만든 횟수이고, 두 값이 비슷하면 범위를 거의 못 좁힌
|
|
1379
|
+
* 것이다(원인은 대개 「모른다」로 떨어지는 사건이다).
|
|
1380
|
+
*/
|
|
1381
|
+
static { this.broadcastPasses = 0; }
|
|
1382
|
+
/**
|
|
1383
|
+
* 이 창에 건드린 물품을 모은다 — **말할 수 없으면 범위를 버린다(전부 만든다).**
|
|
1384
|
+
*
|
|
1385
|
+
* `events` 를 주지 않으면 「무엇이 바뀌었는지 모른다」다(구조 전환처럼 상태 전반이 달라지는 자리).
|
|
1386
|
+
* 한 창에서 한 번 「모른다」가 되면 그 창은 끝까지 모르는 채로 둔다 — 뒤에 온 사건으로 범위를
|
|
1387
|
+
* 되살리면 앞 사건이 건드린 것을 빠뜨린다.
|
|
1388
|
+
*/
|
|
1389
|
+
static markItemsDirty(inst, events) {
|
|
1390
|
+
if (inst.dirtyItems === undefined)
|
|
1391
|
+
return; // 이 창은 이미 「모른다」
|
|
1392
|
+
if (!events) {
|
|
1393
|
+
inst.dirtyItems = undefined;
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
for (const e of events) {
|
|
1397
|
+
const touched = (0, touched_items_js_1.touchedItemKeys)(e);
|
|
1398
|
+
if (!touched) {
|
|
1399
|
+
inst.dirtyItems = undefined;
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
for (const epc of touched)
|
|
1403
|
+
inst.dirtyItems.add(epc);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
/** 브로드캐스팅 병합 주기(ms) — 브로드캐스팅률 상한. 인제스트가 아무리 빨라도 이 주기로만 브로드캐스팅. */
|
|
906
1407
|
static { this.BROADCAST_COALESCE_MS = 200; }
|
|
907
|
-
/**
|
|
1408
|
+
/**
|
|
1409
|
+
* ── 브로드캐스팅 주기는 **재 본 비용에 맞춘다** (2026-08-21 실측) ────────────────────
|
|
1410
|
+
* 한 번의 브로드캐스팅은 상태 크기에 비례한다(실측: 물품 2,400 개인 트윈 하나가 4.5ms — 상태 투영 1.7ms,
|
|
1411
|
+
* payload 만들기 1.8ms, 시그니처 1.0ms). 트윈이 스무 개면 200ms 마다 90ms 가 브로드캐스팅에 들어가고,
|
|
1412
|
+
* 그 시간에는 HTTP 도 구독도 서지 못한다.
|
|
1413
|
+
*
|
|
1414
|
+
* 그래서 한 창에서 브로드캐스팅에 쓴 시간이 주기의 일정 몫을 넘으면 **주기를 늘린다**. 화면은 조금 늦게
|
|
1415
|
+
* 갱신되고(최신-상태 채널이라 값은 마지막 것 하나뿐이므로 내용은 같다), 늘렸다는 것은 계기판이
|
|
1416
|
+
* 말한다(`broadcastCoalesceMs`). 여유가 생기면 원래 주기로 되돌린다.
|
|
1417
|
+
*
|
|
1418
|
+
* 이것은 브로드캐스팅 비용을 **줄이는 것이 아니다** — 비용을 줄이는 것은 변경분만 만드는 일이고 그것은 별
|
|
1419
|
+
* 작업이다. 여기서는 그때까지 호스트가 굶지 않게 상한을 둔다.
|
|
1420
|
+
*/
|
|
1421
|
+
static { this.BROADCAST_MAX_COALESCE_MS = 1000; }
|
|
1422
|
+
/** 주기의 몇 몫까지 브로드캐스팅에 써도 되는가 — 넘으면 주기를 늘린다(절반이면 나머지 절반은 남긴다). */
|
|
1423
|
+
static { this.BROADCAST_LOAD_RATIO = 0.3; }
|
|
1424
|
+
/** 지금 쓰고 있는 주기(ms) — 계기판이 이 값을 읽는다. 늘어난 채로 있으면 그것이 사실이다. */
|
|
1425
|
+
static { this.broadcastPeriodMs = 200; }
|
|
1426
|
+
/** 주기를 늘린 횟수 — 조용히 늦추지 않는다. */
|
|
1427
|
+
static { this.broadcastBackoffs = 0; }
|
|
1428
|
+
/** live 브로드캐스팅 coalescer — dirty 인 live 인스턴스만 주기적으로 1회 브로드캐스팅(entitySigs 로 변경 엔티티만). */
|
|
908
1429
|
static ensureBroadcastCoalescer() {
|
|
909
1430
|
if (this.broadcastTimer)
|
|
910
1431
|
return;
|
|
911
|
-
this.
|
|
1432
|
+
this.broadcastPeriodMs = this.BROADCAST_COALESCE_MS;
|
|
1433
|
+
this.broadcastTimer = setInterval(() => this.flushLiveBroadcasts(), this.broadcastPeriodMs);
|
|
912
1434
|
if (typeof this.broadcastTimer.unref === 'function')
|
|
913
1435
|
this.broadcastTimer.unref(); // 종료 비차단
|
|
914
1436
|
}
|
|
915
1437
|
/**
|
|
916
|
-
*
|
|
1438
|
+
* 브로드캐스팅에 쓴 시간을 보고 주기를 정한다 — **늘리는 것도 줄이는 것도 값에 근거한다.**
|
|
1439
|
+
*
|
|
1440
|
+
* 한 번의 flush 가 주기의 `BROADCAST_LOAD_RATIO` 를 넘게 쓰면 주기를 두 배로(상한까지), 그 몫의
|
|
1441
|
+
* 절반 아래로 내려오면 절반으로(원래 주기까지) 되돌린다. 문턱을 두 개 두는 이유는 하나면 경계에서
|
|
1442
|
+
* 늘리고 줄이기를 반복하기 때문이다.
|
|
1443
|
+
*/
|
|
1444
|
+
static adjustBroadcastPeriod(flushMs) {
|
|
1445
|
+
const period = this.broadcastPeriodMs;
|
|
1446
|
+
const high = period * this.BROADCAST_LOAD_RATIO;
|
|
1447
|
+
const low = high / 2;
|
|
1448
|
+
let next = period;
|
|
1449
|
+
if (flushMs > high && period < this.BROADCAST_MAX_COALESCE_MS) {
|
|
1450
|
+
next = Math.min(this.BROADCAST_MAX_COALESCE_MS, period * 2);
|
|
1451
|
+
this.broadcastBackoffs++;
|
|
1452
|
+
(0, log_js_1.twinWarn)(`[twin-engine] broadcast period ${period}ms → ${next}ms — one flush took ${Math.round(flushMs)}ms across ${Object.keys(this.instances).length} twin(s)`);
|
|
1453
|
+
}
|
|
1454
|
+
else if (flushMs < low && period > this.BROADCAST_COALESCE_MS) {
|
|
1455
|
+
next = Math.max(this.BROADCAST_COALESCE_MS, Math.round(period / 2));
|
|
1456
|
+
}
|
|
1457
|
+
if (next === period)
|
|
1458
|
+
return;
|
|
1459
|
+
this.broadcastPeriodMs = next;
|
|
1460
|
+
if (this.broadcastTimer)
|
|
1461
|
+
clearInterval(this.broadcastTimer);
|
|
1462
|
+
this.broadcastTimer = setInterval(() => this.flushLiveBroadcasts(), next);
|
|
1463
|
+
if (typeof this.broadcastTimer.unref === 'function')
|
|
1464
|
+
this.broadcastTimer.unref();
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* dirty 인스턴스 브로드캐스팅 flush(주기 tick 또는 명시 호출) — **시뮬과 라이브 둘 다.**
|
|
917
1468
|
*
|
|
918
|
-
* 예전에는 라이브만 봤다(`mode !== 'live'` 면 건너뜀). 시뮬은 delta 마다 곧바로
|
|
919
|
-
* 한 tick 에서 수백 번 반복되며 이벤트 루프를 막았다(위 `start()` 주석의 35초 틱).
|
|
1469
|
+
* 예전에는 라이브만 봤다(`mode !== 'live'` 면 건너뜀). 시뮬은 delta 마다 곧바로 브로드캐스팅했고, 그것이
|
|
1470
|
+
* 한 tick 에서 수백 번 반복되며 이벤트 루프를 막았다(위 `start()` 주석의 35초 틱). 브로드캐스팅을 모으는
|
|
920
1471
|
* 규율은 모드의 성질이 아니라 **채널의 성질**이다 — 최신-상태 채널이면 중간 상태는 보낼 값이 없다.
|
|
921
1472
|
*/
|
|
922
1473
|
static flushLiveBroadcasts() {
|
|
923
1474
|
const now = Date.now();
|
|
1475
|
+
const flushStart = performance.now();
|
|
924
1476
|
for (const inst of Object.values(this.instances)) {
|
|
925
1477
|
const isLive = inst.mode === 'live';
|
|
926
|
-
|
|
927
|
-
|
|
1478
|
+
/*
|
|
1479
|
+
* 처리량 계측(④-1) — 창(≥1s)마다 유입·브로드캐스팅·저널률 갱신. dirty 무관(유휴면 0으로 수렴).
|
|
1480
|
+
*
|
|
1481
|
+
* **두 구동을 함께 센다** (2026-08-20). 예전에는 `isLive ? … : undefined` 로 시뮬을 잘라 냈다.
|
|
1482
|
+
* 그래서 도는 트윈 대부분이 시뮬인 서버에서 「초당 몇 행을 쓰나」에 답할 자리가 없었다 — 저널 쓰기를
|
|
1483
|
+
* 배치로 고친 뒤에도 그 효과를 숫자로 볼 수 없었다. 유입(`ingestRate`)은 시뮬에서 0 으로 수렴하고,
|
|
1484
|
+
* 그것이 사실이다(원본에서 받는 것이 없다).
|
|
1485
|
+
*/
|
|
1486
|
+
const m = inst.metrics;
|
|
928
1487
|
if (m) {
|
|
929
1488
|
const dt = (now - m._windowStartMs) / 1000;
|
|
930
1489
|
if (dt >= 1) {
|
|
@@ -940,44 +1499,42 @@ class TwinEngine {
|
|
|
940
1499
|
if (!inst.dirty)
|
|
941
1500
|
continue;
|
|
942
1501
|
inst.dirty = false;
|
|
943
|
-
|
|
1502
|
+
/* 주기마다 한 번은 범위를 버리고 전부 만든다 — 사건 없이 값이 바뀌는 자리에 대한 그물. */
|
|
1503
|
+
const left = (inst.fullBroadcastCountdown ?? 0) - 1;
|
|
1504
|
+
if (left <= 0) {
|
|
1505
|
+
inst.fullBroadcastDue = true;
|
|
1506
|
+
inst.fullBroadcastCountdown = this.FULL_BROADCAST_EVERY;
|
|
1507
|
+
}
|
|
1508
|
+
else {
|
|
1509
|
+
inst.fullBroadcastCountdown = left;
|
|
1510
|
+
}
|
|
1511
|
+
// ① 엔티티 data(tag) 브로드캐스팅 — 보드 컴포넌트 라이브 렌더.
|
|
944
1512
|
this.publishEntityData(inst);
|
|
945
1513
|
if (m) {
|
|
946
1514
|
m.broadcastTotal++;
|
|
947
1515
|
m._accBroadcast++;
|
|
948
1516
|
}
|
|
949
|
-
/*
|
|
950
|
-
|
|
1517
|
+
/* ③ 저널 배치 기록 — **두 구동이 같은 문을 쓴다**(시뮬도 여기서 흘린다, §7.1). */
|
|
1518
|
+
this.flushJournal(inst);
|
|
1519
|
+
/* 여기서부터는 **라이브만**이다: 시뮬의 state 채널은 자기 콜백이 델타마다 보내므로(리비전이 커널의
|
|
1520
|
+
것이다) 여기서 또 보내면 같은 신호가 두 번 간다. 저널은 위에서 이미 두 구동 몫을 흘렸다. */
|
|
951
1521
|
if (!isLive)
|
|
952
1522
|
continue;
|
|
953
|
-
//
|
|
954
|
-
// revision 카운터는 인메모리(startLive 에서 저널 high-water 로 1회 시드) → tick 마다 DB 질의 없음.
|
|
955
|
-
const batch = inst.pendingJournal;
|
|
956
|
-
if (batch?.length && inst.revision != null) {
|
|
957
|
-
inst.pendingJournal = [];
|
|
958
|
-
const start = inst.revision;
|
|
959
|
-
inst.revision = start + batch.length;
|
|
960
|
-
if (m) {
|
|
961
|
-
m.journaledTotal += batch.length;
|
|
962
|
-
m._accJournal += batch.length;
|
|
963
|
-
m.backlog = batch.length;
|
|
964
|
-
}
|
|
965
|
-
const tJournal = performance.now();
|
|
966
|
-
this.persistBatch(inst.domainId, inst.id, batch, start)
|
|
967
|
-
.then(() => (0, load_meter_js_1.recordPhase)(inst.load ?? (inst.load = (0, load_meter_js_1.newLoadMeter)()), 'journal', performance.now() - tJournal))
|
|
968
|
-
.catch(err => console.error('twin live journal fail', err));
|
|
969
|
-
}
|
|
970
|
-
// ② State 채널 방송 — "바뀌었다"는 가벼운 신호만(kind+revision). 맵 구독(subscribeTwinState)은 이 신호에
|
|
1523
|
+
// ② State 채널 브로드캐스팅 — "바뀌었다"는 가벼운 신호만(kind+revision). 맵 구독(subscribeTwinState)은 이 신호에
|
|
971
1524
|
// scheduleRefresh(250ms 디바운스)→pollLive 로 되물어봄. 스냅샷(O(state))은 보는 사람이 물을 때만 1회 계산.
|
|
972
1525
|
// (여기서 payload 로 스냅샷을 실으면 아무도 안 읽는데 tick 마다 통째로 떠서 순수 낭비 — 신호만 보낸다.)
|
|
973
1526
|
this.publishGuarded('twin-state', { twinState: { instanceId: inst.id, kind: 'delta', revision: inst.revision ?? 0 } }, `twin-state:${inst.id}`);
|
|
974
1527
|
}
|
|
975
|
-
/*
|
|
976
|
-
시뮬도 이 flush 에 기대므로 그 조건이면 시뮬
|
|
1528
|
+
/* 가동 중인 인스턴스가 하나도 없으면 tick 을 멈춘다(예전엔 「라이브가 없으면」이었는데, 이제
|
|
1529
|
+
시뮬도 이 flush 에 기대므로 그 조건이면 시뮬 브로드캐스팅이 멈춘 채 남는다). */
|
|
977
1530
|
if (!Object.keys(this.instances).length && this.broadcastTimer) {
|
|
978
1531
|
clearInterval(this.broadcastTimer);
|
|
979
1532
|
this.broadcastTimer = undefined;
|
|
1533
|
+
this.broadcastPeriodMs = this.BROADCAST_COALESCE_MS;
|
|
1534
|
+
return;
|
|
980
1535
|
}
|
|
1536
|
+
/* 이번 flush 가 얼마를 썼는지로 다음 주기를 정한다 — 브로드캐스팅이 루프를 다 쓰지 못하게. */
|
|
1537
|
+
this.adjustBroadcastPeriod(performance.now() - flushStart);
|
|
981
1538
|
}
|
|
982
1539
|
/**
|
|
983
1540
|
* 저널 행 한 줄 — **기록 경로가 둘이라(라이브 벌크·심 단건) 행 모양은 반드시 한 곳에서 만든다.**
|
|
@@ -1015,26 +1572,138 @@ class TwinEngine {
|
|
|
1015
1572
|
* 없으면 `undefined` 다 — 0 이 아니다. 구조 리비전이 생기기 전에 만들어진 트윈은 아직 리비전이
|
|
1016
1573
|
* 없고, 그 사실을 0 이라는 **유효해 보이는 번호**로 위장하면 안 된다.
|
|
1017
1574
|
*/
|
|
1575
|
+
/** 구조 리비전 캐시 — `null` 은 **없다는 것을 알고 있다**는 뜻이다(모름과 구별한다, §7.1). */
|
|
1018
1576
|
static { this.structureRevCache = {}; }
|
|
1019
1577
|
static async structureRevOf(domainId, instanceId) {
|
|
1020
1578
|
const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
|
|
1021
1579
|
const cached = this.structureRevCache[key];
|
|
1580
|
+
/*
|
|
1581
|
+
* **없다는 것도 답이다** — 예전에는 찾지 못하면 캐시하지 않았다(`if (latest) …`). 그래서 구조 행이
|
|
1582
|
+
* 없는 트윈은 **부를 때마다 SELECT** 했고, 그 경로가 델타마다 불리고 있었다(§7.1). `null` 로 기억한다.
|
|
1583
|
+
*/
|
|
1022
1584
|
if (cached !== undefined)
|
|
1023
|
-
return cached;
|
|
1585
|
+
return cached === null ? undefined : cached;
|
|
1024
1586
|
const latest = await (0, shell_1.getRepository)(twin_structure_js_1.TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } });
|
|
1025
|
-
|
|
1026
|
-
this.structureRevCache[key] = latest.rev;
|
|
1587
|
+
this.structureRevCache[key] = latest ? latest.rev : null;
|
|
1027
1588
|
return latest?.rev;
|
|
1028
1589
|
}
|
|
1590
|
+
/** 계기 한 벌 — **두 구동이 같은 것을 든다**(한쪽만 들면 그 구동은 물어도 답이 없다). */
|
|
1591
|
+
static newMetrics() {
|
|
1592
|
+
return { ingestedTotal: 0, broadcastTotal: 0, journaledTotal: 0, ingestRate: 0, broadcastRate: 0, journalRate: 0, backlog: 0, _accIngest: 0, _accBroadcast: 0, _accJournal: 0, _windowStartMs: Date.now() };
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* 모아 둔 저널을 **한 번에** 쓴다 — 두 구동이 같은 문을 쓴다 (§7.1).
|
|
1596
|
+
*
|
|
1597
|
+
* ── 왜 한 함수인가 ─────────────────────────────────────────────────────────
|
|
1598
|
+
* 쓰는 자리가 둘이면(주기 flush · 정지) 한쪽만 고쳐지고, 그 어긋남은 **사실이 조용히 사라지는**
|
|
1599
|
+
* 모양으로 나타난다. 그래서 흘리는 규칙을 여기 한 곳에 둔다.
|
|
1600
|
+
*
|
|
1601
|
+
* ── 리비전을 누가 매기나 ───────────────────────────────────────────────────
|
|
1602
|
+
* · 라이브 — 원천은 리비전을 주지 않으므로 **호스트가** 이어 붙인다(저널 high-water 에서 시드).
|
|
1603
|
+
* · 시뮬 — **커널의 리비전**이 실려 온다(저널이 그것으로 접히고 시간여행이 그것을 딛는다).
|
|
1604
|
+
* 그래서 버퍼는 두 모양을 함께 든다: 봉투만 있으면 라이브, `{ event, revision }` 이면 시뮬이다.
|
|
1605
|
+
*/
|
|
1606
|
+
static flushJournal(inst) {
|
|
1607
|
+
const batch = inst.pendingJournal;
|
|
1608
|
+
if (!batch?.length)
|
|
1609
|
+
return Promise.resolve();
|
|
1610
|
+
inst.pendingJournal = [];
|
|
1611
|
+
const m = inst.metrics;
|
|
1612
|
+
/* 쓴 건수는 누적에, **지금 남은 것**은 backlog 에. 이 자리에서 배치 크기를 backlog 로 적으면
|
|
1613
|
+
정상적인 배치 저장이 화면에서 경고로 보인다(2026-08-21 교정). */
|
|
1614
|
+
if (m) {
|
|
1615
|
+
m.journaledTotal += batch.length;
|
|
1616
|
+
m._accJournal += batch.length;
|
|
1617
|
+
m.backlog = inst.pendingJournal?.length ?? 0;
|
|
1618
|
+
}
|
|
1619
|
+
/* 시뮬은 자기 리비전을 들고 온다 — 그대로 쓴다. 라이브는 여기서 이어 붙인다. */
|
|
1620
|
+
const carried = batch.filter((b) => b && typeof b === 'object' && 'event' in b && typeof b.revision === 'number');
|
|
1621
|
+
const plain = batch.filter((b) => !(b && typeof b === 'object' && 'event' in b && typeof b.revision === 'number'));
|
|
1622
|
+
const tJournal = performance.now();
|
|
1623
|
+
const done = () => (0, load_meter_js_1.recordPhase)(inst.load ?? (inst.load = (0, load_meter_js_1.newLoadMeter)()), 'journal', performance.now() - tJournal);
|
|
1624
|
+
const jobs = [];
|
|
1625
|
+
if (carried.length)
|
|
1626
|
+
jobs.push(this.persistCarried(inst.domainId, inst.id, carried));
|
|
1627
|
+
if (plain.length && inst.revision != null) {
|
|
1628
|
+
const start = inst.revision;
|
|
1629
|
+
inst.revision = start + plain.length;
|
|
1630
|
+
jobs.push(this.persistBatch(inst.domainId, inst.id, plain, start));
|
|
1631
|
+
}
|
|
1632
|
+
if (!jobs.length)
|
|
1633
|
+
return Promise.resolve();
|
|
1634
|
+
/*
|
|
1635
|
+
* **약속을 돌려준다** — 주기 flush 는 기다리지 않지만(핫 경로) **정지는 기다려야 한다.** 기다리지
|
|
1636
|
+
* 않으면 곧바로 이어지는 저널 재생이 아직 안 쓰인 구간을 못 보고, 웜스타트가 「없던 일」로 시작한다
|
|
1637
|
+
* (실제로 그렇게 깨졌다: 확보분을 든 오더가 되살아나지 못했다).
|
|
1638
|
+
*/
|
|
1639
|
+
const written = carried.length + plain.length;
|
|
1640
|
+
return Promise.all(jobs)
|
|
1641
|
+
.then(() => {
|
|
1642
|
+
done();
|
|
1643
|
+
/*
|
|
1644
|
+
* **적힌 뒤에 적는다** — 실패한 배치를 「남았다」고 세면 추이가 없는 사실을 있다고 말한다.
|
|
1645
|
+
* 창(10분)에 쌓이므로 재기동 뒤에도 「지난 여섯 시간 얼마나 적었나」를 답할 수 있다.
|
|
1646
|
+
*/
|
|
1647
|
+
this.recordJournalWrite(inst.domainId, inst.id, written);
|
|
1648
|
+
})
|
|
1649
|
+
.catch(err => (0, log_js_1.twinError)('twin journal flush fail', err));
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* 커널이 매긴 리비전을 그대로 들고 벌크 저장 — 시뮬 경로.
|
|
1653
|
+
*
|
|
1654
|
+
* 예전에는 이 경로가 **델타마다 한 행씩** 저장했다(그리고 행마다 구조 리비전을 물었다). 규모에서 그것이
|
|
1655
|
+
* 호스트를 먹었다(§7.1 실측). 여기서 구조 리비전은 **한 번만** 묻는다.
|
|
1656
|
+
*/
|
|
1657
|
+
static async persistCarried(domainId, instanceId, items) {
|
|
1658
|
+
const repo = (0, shell_1.getRepository)(twin_event_js_1.TwinEvent);
|
|
1659
|
+
const structureRev = await this.structureRevOf(domainId, instanceId);
|
|
1660
|
+
const rows = items.map(it => this.journalRow(repo, domainId, instanceId, it.event, it.revision, structureRev));
|
|
1661
|
+
await this.insertRows(repo, rows);
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* 저널 행을 넣는다 — **넣기만 한다**(2026-08-20).
|
|
1665
|
+
*
|
|
1666
|
+
* 예전에는 `save()` 였다. 그런데 `save` 는 넣은 뒤 생성 컬럼을 읽으려고 **행마다 SELECT 를 한 번 더**
|
|
1667
|
+
* 한다(시험 로그에서 그 질의가 그대로 보였다: `SELECT … FROM twin_events WHERE id = ?`). 저널은
|
|
1668
|
+
* append-only 이고 부르는 쪽은 돌려받은 엔티티를 쓰지 않으므로 그 왕복이 순수 낭비다.
|
|
1669
|
+
*
|
|
1670
|
+
* 묶음은 **나눠서** 넣는다: 한 문에 열이 열다섯인 행을 수천 개 실으면 드라이버의 파라미터 한계에
|
|
1671
|
+
* 걸린다(pg 는 65,535개). 500행이면 어느 드라이버에서도 안전하다.
|
|
1672
|
+
*/
|
|
1673
|
+
static async insertRows(repo, rows) {
|
|
1674
|
+
const CHUNK = 500;
|
|
1675
|
+
/*
|
|
1676
|
+
* ── 한 트랜잭션으로 감싸 보았고, **되돌렸다** (2026-08-22 실측) ─────────────
|
|
1677
|
+
* 「청크마다 커밋하면 fsync 가 그만큼 늘어난다」는 이유로 전체를 한 트랜잭션에 감쌌다. 쓰기 자체는
|
|
1678
|
+
* 실제로 빨라졌다 — 느린 질의 목록에서 이 INSERT 가 사라졌다. **그런데 서버가 더 느려졌다.**
|
|
1679
|
+
*
|
|
1680
|
+
* 감싸기 전 느린 질의 최대 32.3초 · ROLLBACK 없음
|
|
1681
|
+
* 감싼 뒤 느린 질의 최대 50.2초 · ROLLBACK 36.3초 등장
|
|
1682
|
+
*
|
|
1683
|
+
* 기전은 드라이버다. TypeORM 의 sqlite 드라이버는 연결을 **하나**만 든다(풀 없음). 트랜잭션이
|
|
1684
|
+
* 열려 있는 동안 그 유일한 연결은 이 묶음의 것이므로, **묶음이 끝날 때까지 앱의 모든 질의가
|
|
1685
|
+
* 기다린다.** 청크마다 커밋하면 그 사이에 다른 질의가 끼어들 자리가 생긴다 — fsync 를 더 치르는
|
|
1686
|
+
* 대신 **머리 막힘이 짧아진다.**
|
|
1687
|
+
*
|
|
1688
|
+
* 즉 여기서는 「커밋 수를 줄이는 것」이 목적이 아니다. 목적은 **한 번에 오래 붙잡지 않는 것**이다.
|
|
1689
|
+
* 저널 묶음의 원자성은 그 대가를 치를 만큼의 값이 아니다: 저널은 append-only 이고 리비전이
|
|
1690
|
+
* 이어지므로, 절반만 들어간 묶음은 다음 기동의 replay 가 그 지점부터 이어받는다.
|
|
1691
|
+
*
|
|
1692
|
+
* 연결 풀이 있는 드라이버(postgres·mysql)에서는 판단이 달라질 수 있다 — 그때는 이 주석을 근거로
|
|
1693
|
+
* 다시 재고 정하라. **드라이버마다 다른 결론이 나는 자리다.**
|
|
1694
|
+
*/
|
|
1695
|
+
for (let i = 0; i < rows.length; i += CHUNK)
|
|
1696
|
+
await repo.insert(rows.slice(i, i + CHUNK));
|
|
1697
|
+
}
|
|
1029
1698
|
/** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */
|
|
1030
1699
|
static async persistBatch(domainId, instanceId, envelopes, startRevision) {
|
|
1031
1700
|
const repo = (0, shell_1.getRepository)(twin_event_js_1.TwinEvent);
|
|
1032
1701
|
const structureRev = await this.structureRevOf(domainId, instanceId);
|
|
1033
1702
|
const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1, structureRev));
|
|
1034
|
-
await
|
|
1703
|
+
await this.insertRows(repo, rows);
|
|
1035
1704
|
}
|
|
1036
1705
|
/** 레지스트리 upsert(도메인+instanceId 유니크). status 인자로 provision(stopped)/start(running) 공용. */
|
|
1037
|
-
static async register(domainId, instanceId, kind, model, status = 'running',
|
|
1706
|
+
static async register(domainId, instanceId, kind, model, status = 'running', restartPolicy, origin,
|
|
1038
1707
|
/*
|
|
1039
1708
|
* 사람이 부르는 이름과 **누가 했나** — 업무키를 나눠 둔 대가로 이름을 함께 실어야 한다.
|
|
1040
1709
|
* 사람 없는 경로(부팅 자동 프로비저닝·복구)는 `actor` 를 주지 않는다 — 아무 사용자를 적으면
|
|
@@ -1052,10 +1721,11 @@ class TwinEngine {
|
|
|
1052
1721
|
// 인스턴스↔공간 1급 링크(space #3) — model.spaceId/areaId 를 컬럼으로 승격(dual-write). model JSON 도 유지(커널·복구용).
|
|
1053
1722
|
spaceId: model?.spaceId ?? existing?.spaceId,
|
|
1054
1723
|
areaId: model?.areaId ?? existing?.areaId,
|
|
1055
|
-
/*
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1724
|
+
/* 재기동 정책(ADR-0029 §2) — 명시값 우선, 없으면 기존값 보존(재프로비전이 선언을 지우지 않게).
|
|
1725
|
+
**둘 다 없으면 오류를 낸다**: 예전에는 여기서 기본값을 각인했는데, 그 기본값이 「저널 초기화」라서
|
|
1726
|
+
선언을 빠뜨린 프로비저닝이 조용히 이력을 지우는 트윈을 만들었다. 선언은 부르는 쪽의 몫이다. */
|
|
1727
|
+
restartPolicy: restartPolicy ??
|
|
1728
|
+
(0, restart_policy_js_1.readRestartPolicy)(existing?.restartPolicy, `register("${instanceId}") did not declare a restart policy and the stored row has none`),
|
|
1059
1729
|
/* 용도도 각인한다. 벤치로 뒤집는 것은 `setPurpose` 하나뿐이므로 기존값을 반드시 보존한다
|
|
1060
1730
|
— 여기서 덮으면 재프로비전이 벤치 사본을 운영 트윈으로 되돌린다. */
|
|
1061
1731
|
purpose: existing?.purpose ?? 'operational',
|
|
@@ -1114,7 +1784,7 @@ class TwinEngine {
|
|
|
1114
1784
|
if (!this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, instanceId)])
|
|
1115
1785
|
return;
|
|
1116
1786
|
/*
|
|
1117
|
-
* **거절도 번역돼야 한다.** 이 문장은
|
|
1787
|
+
* **거절도 번역돼야 한다.** 이 문장은 전달되어 리졸버의 `catch` 를 지나 화면 토스트에 그대로
|
|
1118
1788
|
* 떴다 — 다섯 언어 제품에서 영어 한 줄이 사용자에게 보였다(2026-08-14 실측).
|
|
1119
1789
|
*
|
|
1120
1790
|
* 그래서 코드와 파라미터를 예외에 실어 보낸다(`ImportSpaceRefusal` 과 같은 규약: 영어 문장은
|
|
@@ -1169,24 +1839,34 @@ class TwinEngine {
|
|
|
1169
1839
|
* 실패는 흡수한다: 투영이 막혀도(예: 모델에 중복 id) 커널은 이미 새 구조로 돌고 있으므로 그 사실을
|
|
1170
1840
|
* 되돌리지 않는다 — 다만 조용히 넘기지 않고 말한다.
|
|
1171
1841
|
*/
|
|
1172
|
-
await (0, project_structure_js_1.projectStructure)(domainId, instanceId, model, instanceId).catch((err) =>
|
|
1842
|
+
await (0, project_structure_js_1.projectStructure)(domainId, instanceId, model, instanceId).catch((err) => (0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}" adopted a new structure but its projected rows were not refreshed — ${err?.message ?? err}`));
|
|
1173
1843
|
/*
|
|
1174
|
-
* **구조가 바뀐 순간이 상태가 바뀐 순간이다** — 그러니
|
|
1844
|
+
* **구조가 바뀐 순간이 상태가 바뀐 순간이다** — 그러니 브로드캐스팅한다.
|
|
1175
1845
|
*
|
|
1176
1846
|
* ── 무엇이 났나 (2026-08-18) ────────────────────────────────────────────
|
|
1177
1847
|
* 구조 전환은 커널만 갈고 조용히 끝났다. 그런데 화면이 보는 것 상당수가 구조에서 파생된다 —
|
|
1178
1848
|
* 계약 대비 판정, 주목 신호, 자리 색. 현장이 계약을 고쳐 선언한 순간 조건이 성립하는데도, 상태
|
|
1179
|
-
*
|
|
1849
|
+
* 브로드캐스팅이 없어서 지도 레일은 **다음 계측 표본이 올 때까지** 옛 화면을 들고 있었다(그 사이 헤더
|
|
1180
1850
|
* 배지는 4초 폴링으로 먼저 알아, 「배지엔 있고 목록엔 없는」 어긋난 화면이 실제로 보였다).
|
|
1181
1851
|
*
|
|
1182
|
-
* 새
|
|
1183
|
-
* 드물지만, 여러 트윈에 잇달아 들어올 수 있고(현장 일괄 선언) 그때도
|
|
1852
|
+
* 새 브로드캐스팅 경로를 만들지 않는다: dirty 를 세워 **이미 있는 병합 규율**(200ms)에 얹는다. 구조 전환은
|
|
1853
|
+
* 드물지만, 여러 트윈에 잇달아 들어올 수 있고(현장 일괄 선언) 그때도 브로드캐스팅률 상한은 지켜야 한다.
|
|
1184
1854
|
*/
|
|
1185
1855
|
inst.dirty = true;
|
|
1856
|
+
/* 구조가 갈렸으면 무엇이 달라졌는지 사건으로 말할 수 없다 — 전부 다시 만든다. */
|
|
1857
|
+
this.markItemsDirty(inst);
|
|
1186
1858
|
this.ensureBroadcastCoalescer();
|
|
1187
1859
|
return { rev, ...shift };
|
|
1188
1860
|
}
|
|
1189
|
-
static async provision(domainId, instanceId, kind, model,
|
|
1861
|
+
static async provision(domainId, instanceId, kind, model,
|
|
1862
|
+
/**
|
|
1863
|
+
* **재기동 정책은 생성 시점의 선언이다**(ADR-0029 §2) — 트윈은 정책 없이 존재할 수 없다.
|
|
1864
|
+
*
|
|
1865
|
+
* 예전에는 이 자리가 없었고 `register` 가 기본값(`sim-experiment` = 저널 초기화)을 각인했다. 그래서
|
|
1866
|
+
* 선언을 빠뜨린 프로비저닝이 **재기동마다 이력을 지우는 트윈**을 조용히 만들었다. 이제 만드는 쪽이
|
|
1867
|
+
* 말해야 한다: 이 트윈이 자기 과거를 어떻게 대하는지는 만드는 사람이 아는 사실이다.
|
|
1868
|
+
*/
|
|
1869
|
+
restartPolicy, comment, origin,
|
|
1190
1870
|
/** 이름·저자 — 업무키를 나눠 둔 대가로 이름을 함께 남긴다(`register` 의 `meta` 그대로). */
|
|
1191
1871
|
meta) {
|
|
1192
1872
|
this.assertNotRunning(domainId, instanceId);
|
|
@@ -1207,7 +1887,7 @@ class TwinEngine {
|
|
|
1207
1887
|
* 달고 다닌다. 재생은 구조가 바뀌는 지점에서 전환한 뒤 이어 접는다(`replaySegments`).
|
|
1208
1888
|
*/
|
|
1209
1889
|
await this.recordStructure(domainId, instanceId, model, comment);
|
|
1210
|
-
await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : existing?.status ?? 'stopped',
|
|
1890
|
+
await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : existing?.status ?? 'stopped', restartPolicy, origin, meta);
|
|
1211
1891
|
}
|
|
1212
1892
|
/**
|
|
1213
1893
|
* 이 구조를 리비전으로 남기고 그 번호를 돌려준다 — **바뀌었을 때만** 새 번호가 생긴다.
|
|
@@ -1308,13 +1988,50 @@ class TwinEngine {
|
|
|
1308
1988
|
return `N[${locations}]M[${equipment}]`;
|
|
1309
1989
|
}
|
|
1310
1990
|
/** 레지스트리 model 로 기동(프로비전된 인스턴스 start). model 인자 없이 저장된 구조로 재기동. */
|
|
1311
|
-
static async startFromRegistry(domainId, instanceId,
|
|
1991
|
+
static async startFromRegistry(domainId, instanceId, restartPolicy) {
|
|
1312
1992
|
const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
|
|
1313
1993
|
if (this.instances[key])
|
|
1314
1994
|
return this.instances[key];
|
|
1315
1995
|
const reg = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } });
|
|
1316
1996
|
if (!reg?.model)
|
|
1317
1997
|
throw new Error(`instance "${instanceId}" not provisioned (no model)`);
|
|
1998
|
+
/*
|
|
1999
|
+
* ── 선언된 `restartPolicy` 가 **기동 방식을 정한다** (2026-08-20) ──────────
|
|
2000
|
+
*
|
|
2001
|
+
* 예전에는 이 함수가 정책을 **읽어서 넘기기만** 했고, 기동은 언제나 시뮬 경로였다. 그래서 미러로
|
|
2002
|
+
* 선언한 트윈을 화면에서 시작하면 **시뮬로 떴다** — 그 트윈은 유입을 받지 못한다(`ingestLive` 는
|
|
2003
|
+
* 라이브가 아니면 0 을 돌려준다). 오류는 나지 않고, 화면에는 「running」이라 적힌다.
|
|
2004
|
+
*
|
|
2005
|
+
* 부팅 경로(`resumeRow`)에는 그 갈림이 있었는데 여기에는 없었다. 즉 **부팅으로 살아난 미러는
|
|
2006
|
+
* 받고, 사람이 시작한 미러는 받지 못했다.** 같은 선언이 두 결과를 내는 것은 결함이다.
|
|
2007
|
+
*
|
|
2008
|
+
* 갈림을 여기 한 곳에 둔다 — 부팅도 이 문을 지난다.
|
|
2009
|
+
*
|
|
2010
|
+
* ── `reset` 은 선언대로 **저널을 비운다** (2026-08-20) ───────────────────
|
|
2011
|
+
* 「seed 재현」은 백지에서 다시 시작한다는 뜻이다. 예전에는 그 선언이 구현되지 않아 `reset` 트윈이
|
|
2012
|
+
* 사실상 이어졌고, 그래서 「이 트윈의 이력은 왜 재기동을 넘겨 남아 있나」를 아무도 설명할 수 없었다.
|
|
2013
|
+
*
|
|
2014
|
+
* 이것은 **사실을 지우는 동작**이라 순서를 지켜 켰다: 먼저 사람이 각 트윈의 정책을 다시 선언할 문을
|
|
2015
|
+
* 만들고(`setTwinRestartPolicy`), 이력을 두고 볼 트윈을 `resume` 으로 옮긴 뒤에 켠다. 그 순서를
|
|
2016
|
+
* 뒤집으면 선언을 지킨 대가로 남의 이력을 지운다.
|
|
2017
|
+
*/
|
|
2018
|
+
const policy = restartPolicy ?? (0, restart_policy_js_1.readRestartPolicy)(reg.restartPolicy, `twin "${instanceId}"`);
|
|
2019
|
+
if (policy === 'resync') {
|
|
2020
|
+
/* 미러는 **관측 구동**으로 세운다 — 시뮬로 세우면 없던 움직임을 스스로 만든다.
|
|
2021
|
+
시각 기준은 공간이 갖는다(부팅 경로와 같은 규칙). */
|
|
2022
|
+
return this.startLive(instanceId, domainId, reg.kind, await this.withSpaceTimeBase(reg.model, domainId));
|
|
2023
|
+
}
|
|
2024
|
+
if (policy === 'reset') {
|
|
2025
|
+
/*
|
|
2026
|
+
* 씨앗 재현 — 저널을 비우고 리비전 0 부터 다시 센다. 복구본도 함께 버린다(`resetJournal` 이 한다):
|
|
2027
|
+
* 남겨 두면 백지로 시작한 트윈에 옛 상태가 되살아나 「씨앗 재현」이 아니게 된다.
|
|
2028
|
+
*
|
|
2029
|
+
* **벤치 사본은 예외다.** 그것은 누군가 지켜보는 실험이고, 그 실험의 기록을 기동이 지우면 실험이
|
|
2030
|
+
* 사라진다(부팅은 벤치를 되살리지도 않는다 — `resumeRow`).
|
|
2031
|
+
*/
|
|
2032
|
+
if (reg.purpose !== 'bench')
|
|
2033
|
+
await this.resetJournal(domainId, instanceId);
|
|
2034
|
+
}
|
|
1318
2035
|
/*
|
|
1319
2036
|
* 웜스타트 재료를 **여기서 확실히 확보한다.**
|
|
1320
2037
|
* `start()` 는 동기라 스스로 캐시를 읽을 수 없어서 `recovered` 에 미리 담겨 있기를 기대하는데,
|
|
@@ -1336,7 +2053,7 @@ class TwinEngine {
|
|
|
1336
2053
|
/*
|
|
1337
2054
|
* 저널에 남아 있는 마지막 번호 — **모드와 무관하게** 이것을 이어 센다.
|
|
1338
2055
|
*
|
|
1339
|
-
* 저널을 초기화하고 기동하는
|
|
2056
|
+
* 저널을 초기화하고 기동하는 정책(`reset`)이라면 이 값이 0이라 아무 영향이 없다. 규칙을
|
|
1340
2057
|
* 모드별로 구분하지 않는 이유: "저널이 비어 있지 않으면 그 뒤부터" 하나면 어느 모드에서도
|
|
1341
2058
|
* 겹칠 수 없고, 모드가 늘어도 이 자리를 다시 손볼 일이 없다.
|
|
1342
2059
|
*/
|
|
@@ -1349,26 +2066,28 @@ class TwinEngine {
|
|
|
1349
2066
|
return this.start(instanceId, domainId,
|
|
1350
2067
|
/* 커널 종류·현실 선언은 레지스트리에 반드시 있다(둘 다 NOT NULL). 예전에는 `?? 'wms'` 로
|
|
1351
2068
|
메웠는데, 그건 YMS/MES 트윈을 **조용히 WMS 로 부팅**시키는 길이었다 — 오류 없이 다른 공장이 뜬다. */
|
|
1352
|
-
reg.kind, reg.model,
|
|
2069
|
+
reg.kind, reg.model,
|
|
2070
|
+
/* 저장된 값은 위에서 **엄격히** 읽었다 — 모르는 값을 기본값으로 메우면 그 트윈이 조용히 다르게 재기동한다. */
|
|
2071
|
+
policy, reg.purpose, Number(last?.max ?? 0) || 0);
|
|
1353
2072
|
}
|
|
1354
2073
|
/**
|
|
1355
|
-
* 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한
|
|
2074
|
+
* 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 restartPolicy 에 따라 재기동 방식을 구분한다.
|
|
1356
2075
|
* 부팅 경로를 한 곳에 모아 "런타임 ≠ 현실" 범주오류를 코드로 강제한다.
|
|
1357
|
-
* - '
|
|
1358
|
-
* - '
|
|
2076
|
+
* - 'resync' : 현실=외부 실물 → startLive(재동기). 이벤트는 어댑터 ingest 로 유입, 커널 tick 없음.
|
|
2077
|
+
* - 'resume' : 생성 타임라인 지속 → 저널 보존 + resume(현실 이어감).
|
|
1359
2078
|
* ⚠ GATE(§8 ②③): 커널 상태 직렬화/재개가 아직 없어 진짜 resume 불가 → 실 필요·실부하 시 구현.
|
|
1360
2079
|
* 그때까지는 저널을 보존하되 revision 충돌을 피하려 fresh 로 기동하고, 선언과 구현의 간극을 크게 경고한다.
|
|
1361
|
-
* - '
|
|
2080
|
+
* - 'reset' : seed 재현 → resetJournal + fresh 기동(백지 시작이 버그가 아니라 선언된 거동).
|
|
1362
2081
|
* 반환: 기동된 InstanceRuntime.
|
|
1363
2082
|
*/
|
|
1364
|
-
static async bootDeclared(domainId, instanceId, mode
|
|
1365
|
-
if (mode === '
|
|
2083
|
+
static async bootDeclared(domainId, instanceId, mode) {
|
|
2084
|
+
if (mode === 'resync') {
|
|
1366
2085
|
const reg = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } });
|
|
1367
2086
|
if (!reg?.model)
|
|
1368
2087
|
throw new Error(`instance "${instanceId}" not provisioned (no model)`);
|
|
1369
2088
|
return this.startLive(instanceId, domainId, reg.kind, reg.model);
|
|
1370
2089
|
}
|
|
1371
|
-
if (mode === '
|
|
2090
|
+
if (mode === 'resume') {
|
|
1372
2091
|
/*
|
|
1373
2092
|
* **이어지는 현실** — 저널을 지우지 않는다.
|
|
1374
2093
|
*
|
|
@@ -1381,11 +2100,11 @@ class TwinEngine {
|
|
|
1381
2100
|
* 같은 씨앗에서 다시 시작하므로 자극의 패턴이 재기동 지점에서 한 번 끊긴다. 쌓인 사실과
|
|
1382
2101
|
* 상태는 이어지고, 앞으로 일어날 일의 무작위 순서만 새로 시작한다.
|
|
1383
2102
|
*/
|
|
1384
|
-
return this.startFromRegistry(domainId, instanceId, '
|
|
2103
|
+
return this.startFromRegistry(domainId, instanceId, 'resume');
|
|
1385
2104
|
}
|
|
1386
|
-
//
|
|
2105
|
+
// `reset`: seed 재현 — 저널 초기화 후 revision 0 부터 재실행.
|
|
1387
2106
|
await this.resetJournal(domainId, instanceId);
|
|
1388
|
-
return this.startFromRegistry(domainId, instanceId, '
|
|
2107
|
+
return this.startFromRegistry(domainId, instanceId, 'reset');
|
|
1389
2108
|
}
|
|
1390
2109
|
/**
|
|
1391
2110
|
* 저널 초기화 — 인스턴스 이벤트 저널 purge(레지스트리·상태는 유지).
|
|
@@ -1395,6 +2114,23 @@ class TwinEngine {
|
|
|
1395
2114
|
static async resetJournal(domainId, instanceId) {
|
|
1396
2115
|
await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).delete({ domain: { id: domainId }, instanceId });
|
|
1397
2116
|
delete this.recovered[(0, runtime_key_js_1.runtimeKey)(domainId, instanceId)];
|
|
2117
|
+
/*
|
|
2118
|
+
* ── **체크포인트도 함께 무효로 만든다** (2026-08-20) ──────────────────────
|
|
2119
|
+
*
|
|
2120
|
+
* 저널만 지우면 씨앗 재현이 되지 않는다: 웜스타트는 체크포인트를 **먼저** 보고, 그것이 남아 있으면
|
|
2121
|
+
* 백지로 시작한 트윈에 옛 상태가 되살아난다(저널은 비었는데 재고가 있는 트윈이 된다).
|
|
2122
|
+
* 시간여행이 딛는 사슬 캐시도 같은 이유로 무효다 — 지워진 리비전을 가리키게 된다.
|
|
2123
|
+
*
|
|
2124
|
+
* 공통 캐시 서비스에는 삭제가 없다(get/set/clearStale 뿐). 그래서 **「없음」을 적는다** — 값을
|
|
2125
|
+
* 지어내는 것이 아니라 「체크포인트가 없다」는 사실을 적는 것이고, 읽는 쪽은 이미 `cached?.state` 로
|
|
2126
|
+
* 그것을 가려낸다. 공통 모듈에 삭제를 새로 뚫는 것은 이 한 자리를 위해 하기에는 큰 변경이다.
|
|
2127
|
+
*/
|
|
2128
|
+
await cache_service_1.cacheService
|
|
2129
|
+
.setInCache(this.SNAPSHOT_CACHE_ID, { domainId, instanceId }, { revision: 0, state: null }, this.SNAPSHOT_TTL_S)
|
|
2130
|
+
.catch(err => (0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}" checkpoint not invalidated — a seed run may inherit old state:`, err?.message ?? err));
|
|
2131
|
+
await cache_service_1.cacheService
|
|
2132
|
+
.setInCache(this.CHAIN_INDEX_CACHE_ID, { domainId, instanceId }, { revisions: [] }, this.SNAPSHOT_TTL_S)
|
|
2133
|
+
.catch(err => (0, log_js_1.twinWarn)(`[twin-engine] "${instanceId}" chain index not cleared — time travel may point at deleted revisions:`, err?.message ?? err));
|
|
1398
2134
|
}
|
|
1399
2135
|
/** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */
|
|
1400
2136
|
static async remove(domainId, instanceId) {
|
|
@@ -1430,30 +2166,60 @@ class TwinEngine {
|
|
|
1430
2166
|
*/
|
|
1431
2167
|
const spaceNameOf = new Map((await (0, shell_1.getRepository)(twin_space_js_1.TwinSpace).find({ where: { domain: { id: domainId } } })).map(s => [s.spaceId, s.name]));
|
|
1432
2168
|
/*
|
|
1433
|
-
* **최신 리비전은 한
|
|
2169
|
+
* **최신 리비전은 트윈마다 인덱스 끝에서 한 행씩 읽는다.**
|
|
2170
|
+
*
|
|
2171
|
+
* ── 그룹 질의로 바꾸었다가 되돌렸다 (2026-08-22 실측) ─────────────────────
|
|
2172
|
+
* 예전 주석은 이랬다: 「트윈마다 `findOne(order revision DESC)` 을 돌고 있었다 — 13개면 질의
|
|
2173
|
+
* 13번이고, 트윈이 늘면 그대로 자란다. 한 번의 그룹 질의로 바꾼다.」
|
|
1434
2174
|
*
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
2175
|
+
* **질의 개수를 줄였지만 일의 양을 늘렸다.**
|
|
2176
|
+
*
|
|
2177
|
+
* findOne × 13 `(domain, instance, revision)` 인덱스 **끝에서 한 행** — 각각 O(1)
|
|
2178
|
+
* GROUP BY 한 번 그 도메인의 저널을 **전수 집계** — O(전체)
|
|
2179
|
+
*
|
|
2180
|
+
* 저널이 작을 때는 이득이었고, 커지면서 손해가 됐다. 실측(저널 1,134만 행): 이 그룹 질의가
|
|
2181
|
+
* **44.7초**였다. 그리고 sqlite 드라이버는 연결이 하나이므로 그동안 앱의 모든 질의가 그 뒤에 섰다 —
|
|
2182
|
+
* 같은 순간 29행짜리 `twin_instances` 조회도 44.7초로 찍혔다. 이 목록은 「트윈 관리·현장 구성·
|
|
2183
|
+
* 엔티티 패널이 모두 읽는 자리」여서, 화면을 열 때마다 서버 전체가 그만큼 멈췄다.
|
|
2184
|
+
*
|
|
2185
|
+
* 트윈 수는 수십이고 저널은 천만이다. **작은 것을 여러 번 읽는 편이 큰 것을 한 번 훑는 것보다 싸다.**
|
|
2186
|
+
* 질의 개수가 트윈 수에 비례해 자라는 것은 사실이지만, 각 질의가 인덱스 끝 한 행이므로 그 성장은
|
|
2187
|
+
* 감당된다 — 그리고 병렬로 묻는다.
|
|
2188
|
+
*
|
|
2189
|
+
* **다시 그룹 질의로 바꾸지 말 것.** 바꾸려면 이 숫자를 먼저 다시 재라.
|
|
1438
2190
|
*/
|
|
1439
2191
|
const tipOfInstance = new Map();
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
2192
|
+
await Promise.all(rows.map(async (r) => {
|
|
2193
|
+
try {
|
|
2194
|
+
/*
|
|
2195
|
+
* **집계 하나로 묻는다 — 행을 실어 오지 않는다.**
|
|
2196
|
+
*
|
|
2197
|
+
* `findOne(order revision DESC)` 로 두었더니 두 가지가 틀렸다. ① 관계를 가진 엔티티의 정렬
|
|
2198
|
+
* 조회는 TypeORM 이 `DISTINCT` 로 감싸므로 `select` 로 컬럼을 좁히면 `distinctAlias.
|
|
2199
|
+
* TwinEvent_id` 가 없다고 터진다(실측: 모든 트윈에서 SQLITE_ERROR). ② `select` 를 떼면
|
|
2200
|
+
* **`payload` 까지 실어 온다** — 이 값 하나를 알려고 큰 TEXT 를 읽는다.
|
|
2201
|
+
*
|
|
2202
|
+
* 그래서 단일 집계를 쓴다. `(domain, instance, revision)` 인덱스에서 instance 가 정해지면
|
|
2203
|
+
* `MAX` 는 그 구간의 끝이므로 sqlite 가 끝을 집는다(실측 0.007초 · 도메인 전체 GROUP BY 는
|
|
2204
|
+
* 14.5초). 이 파일의 다른 자리도 같은 규율이다(§`timeRange` · 기동 시 최종 리비전).
|
|
2205
|
+
*/
|
|
2206
|
+
const row = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent)
|
|
2207
|
+
.createQueryBuilder('e')
|
|
2208
|
+
.select('MAX(e.revision)', 'revision')
|
|
2209
|
+
.where('e.domain = :domainId', { domainId })
|
|
2210
|
+
.andWhere('e.instanceId = :instanceId', { instanceId: r.instanceId })
|
|
2211
|
+
.getRawOne();
|
|
2212
|
+
/* 행이 없으면 `null` 이다 — 그때는 비워 둔다(0 은 "아무 일도 없었다" 는 주장이다). */
|
|
2213
|
+
if (row?.revision != null)
|
|
2214
|
+
tipOfInstance.set(String(r.instanceId), Number(row.revision) || 0);
|
|
2215
|
+
}
|
|
2216
|
+
catch (err) {
|
|
2217
|
+
/* 한 트윈의 조회가 실패해도 **0 으로 메우지 않는다** — 리비전 0 은 "아직 아무 일도 없었다" 는
|
|
2218
|
+
사실 주장이다. 비워 두면 아래에서 undefined 로 남고, 화면은 그것을 "모름" 으로 그린다.
|
|
2219
|
+
그리고 그 트윈만 모름이 된다 — 예전에는 집계 하나가 실패하면 전부 모름이었다. */
|
|
2220
|
+
(0, log_js_1.twinError)(`[twin-engine] latest revision lookup failed "${r.instanceId}"`, err?.message ?? err);
|
|
2221
|
+
}
|
|
2222
|
+
}));
|
|
1457
2223
|
const out = [];
|
|
1458
2224
|
for (const r of rows) {
|
|
1459
2225
|
const model = r.model ?? { locations: [], equipment: [] };
|
|
@@ -1471,7 +2237,7 @@ class TwinEngine {
|
|
|
1471
2237
|
spaceId: r.spaceId, // co-location: 같은 spaceId 인스턴스들이 한 현장 공유 — 현장뷰 집약 키(G7)
|
|
1472
2238
|
/* 사람이 읽는 현장 이름 — 없으면 `undefined`(화면이 "이름 없음" 을 알아볼 수 있어야 한다). */
|
|
1473
2239
|
spaceName: (r.spaceId ? spaceNameOf.get(r.spaceId) : undefined) || undefined,
|
|
1474
|
-
|
|
2240
|
+
restartPolicy: r.restartPolicy, // 재기동 정책(ADR-0029 §2) — resync/resume/reset. 선언이므로 비어 있을 수 없다.
|
|
1475
2241
|
purpose: r.purpose, // 운영 vs 벤치 사본(1급 구별 — 이름 접두사 아님). 저장 시 각인되므로 비지 않는다.
|
|
1476
2242
|
copyOf: r.copyOf ?? undefined,
|
|
1477
2243
|
running: !!this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, r.instanceId)],
|
|
@@ -1485,7 +2251,7 @@ class TwinEngine {
|
|
|
1485
2251
|
/* 스스로 멈춘 이유 — 있으면 낸다. 재기동 뒤에는 없다(그때는 「모른다」가 사실이다). */
|
|
1486
2252
|
stopNote: this.stopNotes.get((0, runtime_key_js_1.runtimeKey)(domainId, r.instanceId)) || undefined,
|
|
1487
2253
|
liveFeed: (0, live_feed_registry_js_1.liveFeedStateOf)({
|
|
1488
|
-
|
|
2254
|
+
restartPolicy: r.restartPolicy,
|
|
1489
2255
|
running: !!this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, r.instanceId)],
|
|
1490
2256
|
instanceId: r.instanceId
|
|
1491
2257
|
}),
|
|
@@ -1567,7 +2333,9 @@ class TwinEngine {
|
|
|
1567
2333
|
* 기존 공간으로 들어갈 수 있다. 아래 병합은 원래 그 경우를 위해 있었는데(N:1) 만드는 흐름에서
|
|
1568
2334
|
* 공간을 고를 방법이 없었다 — 마스터가 파생한 id 만 쓰였다. 판정은 `resolveIngestSpace`(순수).
|
|
1569
2335
|
*/
|
|
1570
|
-
static async ingestMaster(domainId, master,
|
|
2336
|
+
static async ingestMaster(domainId, master,
|
|
2337
|
+
/** 만들어지는 트윈의 재기동 정책 — 원본이 정하는 것이 아니라 **만드는 쪽의 선언**이다(ADR-0029 §2). */
|
|
2338
|
+
restartPolicy, into,
|
|
1571
2339
|
/** 누가 인제스트했나 — 사람이 없는 경로(부팅)는 주지 않는다(감사 기록을 지어내지 않는다). */
|
|
1572
2340
|
actor) {
|
|
1573
2341
|
/*
|
|
@@ -1734,7 +2502,7 @@ class TwinEngine {
|
|
|
1734
2502
|
}
|
|
1735
2503
|
else {
|
|
1736
2504
|
/* 사이트 이름이 곧 트윈의 이름이다 — 마스터가 이미 말했으므로 지어내지 않고 그대로 싣는다. */
|
|
1737
|
-
await this.provision(domainId, master.source, master.system, model, undefined, master.origin, {
|
|
2505
|
+
await this.provision(domainId, master.source, master.system, model, restartPolicy, undefined, master.origin, {
|
|
1738
2506
|
name: master.siteName,
|
|
1739
2507
|
description: master.description,
|
|
1740
2508
|
actor
|
|
@@ -1766,11 +2534,11 @@ class TwinEngine {
|
|
|
1766
2534
|
}
|
|
1767
2535
|
}
|
|
1768
2536
|
catch (err) {
|
|
1769
|
-
|
|
2537
|
+
(0, log_js_1.twinError)(`[twin-engine] structure projection failed for "${master.source}":`, err?.message);
|
|
1770
2538
|
warnings.push((0, reference_master_js_3.projectionFailed)(err?.message ?? 'unknown'));
|
|
1771
2539
|
}
|
|
1772
2540
|
if (warnings.length)
|
|
1773
|
-
|
|
2541
|
+
(0, log_js_1.twinWarn)(`[twin-engine] ingest "${master.source}" warnings: ${(0, reference_master_js_3.describeWarnings)(warnings)}`);
|
|
1774
2542
|
return { instanceId: master.source, spaceId, warnings };
|
|
1775
2543
|
}
|
|
1776
2544
|
/** 단건 상세 — 프로비저닝 에디터가 편집할 model(구조+layout) 포함. */
|
|
@@ -1783,7 +2551,7 @@ class TwinEngine {
|
|
|
1783
2551
|
kind: r.kind,
|
|
1784
2552
|
status: r.status,
|
|
1785
2553
|
running: !!this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, r.instanceId)],
|
|
1786
|
-
|
|
2554
|
+
restartPolicy: r.restartPolicy, // 현실 선언 — what-if 적용 가드용(mirror=예측 전용, 자극 주입 불가)
|
|
1787
2555
|
model: r.model
|
|
1788
2556
|
};
|
|
1789
2557
|
}
|
|
@@ -1792,14 +2560,14 @@ class TwinEngine {
|
|
|
1792
2560
|
* 보드 컴포넌트가 tag 로 구독(board-ui provider)해 `component.data` 로 라이브 갱신. delta 시에만(희소).
|
|
1793
2561
|
* tag=엔티티 id(데모=단일 인스턴스). 멀티 인스턴스/보드 재사용 시 tag 네임스페이스는 후속.
|
|
1794
2562
|
*/
|
|
1795
|
-
/**
|
|
2563
|
+
/** 브로드캐스팅 실패 로그 조절 — 창마다 한 줄(막힌 구독자는 초당 수십 번 실패한다). */
|
|
1796
2564
|
static { this.publishDrops = new Map(); }
|
|
1797
2565
|
static { this.PUBLISH_DROP_LOG_MS = 10_000; }
|
|
1798
2566
|
/**
|
|
1799
|
-
* 한 번의
|
|
2567
|
+
* 한 번의 브로드캐스팅 — **구독자 하나가 호스트를 죽이지 못하게.**
|
|
1800
2568
|
*
|
|
1801
2569
|
* ── 무엇이 죽였나 (2026-08-14) ─────────────────────────────────────────────
|
|
1802
|
-
* 밀린 push 가 1024를 넘으면 pubsub 이
|
|
2570
|
+
* 밀린 push 가 1024를 넘으면 pubsub 이 오류를 낸다(`RepeaterOverflowError`). 그 브로드캐스팅은 타이머 콜백
|
|
1803
2571
|
* 안에서 일어나므로 예외가 잡히는 곳 없이 올라가 **프로세스가 끝났다** — 트윈 14개가 도는 호스트가
|
|
1804
2572
|
* 소비를 멈춘 구독자 하나 때문에 통째로.
|
|
1805
2573
|
*
|
|
@@ -1817,7 +2585,7 @@ class TwinEngine {
|
|
|
1817
2585
|
const now = Date.now();
|
|
1818
2586
|
if (now - d.lastLogMs >= this.PUBLISH_DROP_LOG_MS) {
|
|
1819
2587
|
d.lastLogMs = now;
|
|
1820
|
-
|
|
2588
|
+
(0, log_js_1.twinWarn)(`[twin-engine] broadcast dropped on "${what}" (${d.count} so far) — ${err?.message ?? err}`);
|
|
1821
2589
|
}
|
|
1822
2590
|
this.publishDrops.set(what, d);
|
|
1823
2591
|
return false;
|
|
@@ -1841,16 +2609,35 @@ class TwinEngine {
|
|
|
1841
2609
|
return;
|
|
1842
2610
|
/*
|
|
1843
2611
|
* 변화한 엔티티만 발행 — 이전엔 delta 마다 전 엔티티(노드+무버+오더)를 값 변화와 무관하게 전량 재발행해
|
|
1844
|
-
* 같은 데이터를 매초 반복 push 하고 있었다(오용). 최신-상태 채널이므로 무변화
|
|
2612
|
+
* 같은 데이터를 매초 반복 push 하고 있었다(오용). 최신-상태 채널이므로 무변화 재브로드캐스팅은 무의미하다.
|
|
1845
2613
|
* 엔티티별 시그니처를 비교해 바뀐 것만 push.
|
|
1846
2614
|
*/
|
|
1847
2615
|
const sigs = inst.entitySigs ?? (inst.entitySigs = new Map());
|
|
1848
2616
|
const seen = new Set();
|
|
1849
2617
|
/* payload 매핑은 순수 함수(buildEntityDeltas)로 분리 — 여기선 시그니처 dedup + 발행만.
|
|
1850
|
-
* 변화한 엔티티만 발행(최신-상태 채널이라 무변화
|
|
2618
|
+
* 변화한 엔티티만 발행(최신-상태 채널이라 무변화 재브로드캐스팅 무의미). */
|
|
1851
2619
|
/* ② payload 만들기 — 엔티티 수에 비례. ③ 시그니처 비교 + 발행 — 바뀐 것 수에 비례. */
|
|
1852
2620
|
const tDelta = performance.now();
|
|
1853
|
-
|
|
2621
|
+
/*
|
|
2622
|
+
* ── 물품은 **건드린 것만** 만든다 (2026-08-21 실측) ──────────────────────
|
|
2623
|
+
* 만드는 payload 의 대부분이 물품이다(엔티티 3,611 중 2,400). 바뀐 것이 하나여도 전부 만들어
|
|
2624
|
+
* 문자열로 바꾼 뒤 「같다」를 확인하고 버렸다.
|
|
2625
|
+
*
|
|
2626
|
+
* 범위는 이 창에 들어온 사건에서 모았다(`touchedItemKeys`). **말할 수 없는 사건이 하나라도 있으면
|
|
2627
|
+
* 범위는 없고 전부 만든다** — 낯선 어휘가 오면 조용히 빠뜨리는 대신 비싸게 안전한 쪽으로 떨어진다.
|
|
2628
|
+
*
|
|
2629
|
+
* 그리고 주기마다 한 번은 **무조건 전부** 만든다(`FULL_BROADCAST_EVERY`). 커널이 사건 없이 물품을
|
|
2630
|
+
* 바꾸는 자리가 생기면 그 값이 화면에 남을 수 있는데, 그 창을 몇 초로 묶는 그물이다. **보장이 아니라
|
|
2631
|
+
* 그물이다** — 사건 없이 바뀌는 자리를 찾으면 그것을 고치는 것이 답이고 이 그물은 시간을 벌 뿐이다.
|
|
2632
|
+
*/
|
|
2633
|
+
const full = inst.fullBroadcastDue === true || !inst.dirtyItems;
|
|
2634
|
+
const deltas = (0, entity_delta_js_1.buildEntityDeltas)(st, inst.id, full ? undefined : { items: inst.dirtyItems });
|
|
2635
|
+
this.broadcastPasses++;
|
|
2636
|
+
if (full)
|
|
2637
|
+
this.broadcastFullPasses++;
|
|
2638
|
+
/* 이번 창의 범위는 여기서 닫는다 — 다음 창은 다시 모은다. */
|
|
2639
|
+
inst.dirtyItems = new Set();
|
|
2640
|
+
inst.fullBroadcastDue = false;
|
|
1854
2641
|
(0, load_meter_js_1.recordPhase)(load, 'deltas', performance.now() - tDelta);
|
|
1855
2642
|
const tPub = performance.now();
|
|
1856
2643
|
for (const { tag, data } of deltas) {
|
|
@@ -1867,16 +2654,23 @@ class TwinEngine {
|
|
|
1867
2654
|
sigs.delete(tag);
|
|
1868
2655
|
}
|
|
1869
2656
|
(0, load_meter_js_1.recordPhase)(load, 'publish', performance.now() - tPub);
|
|
1870
|
-
/*
|
|
1871
|
-
|
|
2657
|
+
/*
|
|
2658
|
+
* 사라진 엔티티의 시그니처 정리(맵 무한 성장 방지) — **전부 만든 창에서만.**
|
|
2659
|
+
* 범위를 좁힌 창의 `seen` 에는 만들지 않은 엔티티가 없으므로, 그때 정리하면 살아 있는 태그의
|
|
2660
|
+
* 시그니처를 지운다. 낡은 값이 나가는 것은 아니지만(다음 창에 다시 만들어 보낸다) 같은 값을
|
|
2661
|
+
* 되풀어 보내게 되어, 줄이려던 것을 되돌린다.
|
|
2662
|
+
*/
|
|
2663
|
+
if (full && sigs.size > seen.size)
|
|
1872
2664
|
for (const tag of sigs.keys())
|
|
1873
2665
|
if (!seen.has(tag))
|
|
1874
2666
|
sigs.delete(tag);
|
|
1875
2667
|
}
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
2668
|
+
/*
|
|
2669
|
+
* 단건 저장(`persist`)은 **없앴다** (2026-08-20, §7.1).
|
|
2670
|
+
*
|
|
2671
|
+
* 시뮬이 델타마다 이것을 불러 행 하나씩 썼고, 그것이 규모에서 호스트를 먹었다. 남겨 두면 다음 사람이
|
|
2672
|
+
* 다시 부를 자리가 되므로 지운다 — 쓰는 문은 `flushJournal` 하나다(`persistBatch`·`persistCarried`).
|
|
2673
|
+
*/
|
|
1880
2674
|
/**
|
|
1881
2675
|
* 재부팅 복구 / 시간여행 — DB 저널을 replay 해 상태 재구성.
|
|
1882
2676
|
* model 는 레지스트리(TwinInstance)에서, 이벤트는 TwinEvent(revision ASC)에서.
|
|
@@ -1965,6 +2759,10 @@ class TwinEngine {
|
|
|
1965
2759
|
const baseWhere = { domain: { id: domainId }, instanceId };
|
|
1966
2760
|
/* 이어 접을 때는 **그 뒤만** 읽는다 — 재개점까지의 사실은 이미 씨앗 안에 있다. */
|
|
1967
2761
|
const from = resume ? (resume.revision ?? 0) : undefined;
|
|
2762
|
+
/*
|
|
2763
|
+
* journal-fold: 재생은 사실을 하나씩 접는 것이므로 그 구간의 행이 필요하다. 커서(`from`)와 시각
|
|
2764
|
+
* 상한이 구간을 자르고, 체크포인트가 앞쪽을 씨앗으로 대신한다 — 표 전체를 읽지 않는다.
|
|
2765
|
+
*/
|
|
1968
2766
|
const rows = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).find({
|
|
1969
2767
|
where: useTime
|
|
1970
2768
|
? from
|
|
@@ -2141,24 +2939,68 @@ class TwinEngine {
|
|
|
2141
2939
|
/**
|
|
2142
2940
|
* 공간(공동배치) 시각 범위 — 스크러버 앵커(runtime-state-model §4·§6). 그 공간 전 인스턴스 저널의 min/max eventTime.
|
|
2143
2941
|
* 반환 {minTime, maxTime}(ISO) — 이벤트/시각 없으면 null. 클라 히스토리 스크러버가 이 범위를 시각축으로 그린다.
|
|
2942
|
+
*
|
|
2943
|
+
* ── 두 수를 구하려고 저널을 다 읽지 않는다 (2026-08-20 실측으로 잡음) ────────
|
|
2944
|
+
* 여기서 인스턴스마다 `find()` 로 **행을 전부 엔티티로 하이드레이션**한 다음 JS 에서 min/max 를
|
|
2945
|
+
* 골랐다. 그런데 이 함수는 화면 상단의 컨텍스트 띠가 **4초마다** 부른다. 실측한 개발 서버에서
|
|
2946
|
+
* `order-check` 한 트윈이 275,882행 · `payload` 131MB 였다 — 4초마다 그 JSON 을 전부 파싱한 것이다.
|
|
2947
|
+
*
|
|
2948
|
+
* 그 결과가 이랬다: 프로세스 CPU 81~152%, 아무 일도 하지 않는 질의가 8~14초. JS 프로파일의 상위가
|
|
2949
|
+
* TypeORM 의 `RelationIdLoader`·`RawSqlResultsToEntityTransformer`·`stringToSimpleJson`(= `payload`
|
|
2950
|
+
* 파싱)이고 GC 가 16.7% 였다. **트윈 틱도 저널 쓰기도 아니라 이 읽기였다.**
|
|
2951
|
+
*
|
|
2952
|
+
* 집계는 DB 가 한다. `(domain, instanceId, eventTime)` 인덱스가 이미 있어(`ix_twin_event_1`)
|
|
2953
|
+
* 인덱스의 **양 끝을 집는다** — 행을 하나도 실어 오지 않는다.
|
|
2954
|
+
*
|
|
2955
|
+
* ── 왜 MIN 과 MAX 를 한 문장에 넣지 않나 (실측) ─────────────────────────────
|
|
2956
|
+
* 처음에 `SELECT MIN(...), MAX(...) ... WHERE instanceId IN (...)` 한 방으로 두었더니 **21ms** 였다.
|
|
2957
|
+
* 집계가 **둘이면** 옵티마이저의 「인덱스 끝을 집는」 최적화가 걸리지 않아 인덱스 구간을 훑는다 —
|
|
2958
|
+
* 즉 비용이 여전히 **행 수에 비례**한다. 트윈마다 단일 집계로 나눠 물으면 **0.32ms**(트윈 3개 · 6왕복)
|
|
2959
|
+
* 이고, 비용이 **트윈 수에 비례**한다. 규모 기준(엔티티 10만)에서는 이 차이가 본질이다.
|
|
2960
|
+
*
|
|
2961
|
+
* 왕복은 트윈당 둘이지만 **함께 띄운다** — 원격 DB 에서 직렬로 돌면 왕복 지연이 그대로 쌓인다.
|
|
2962
|
+
*
|
|
2963
|
+
* 드라이버 다섯을 다 지나가야 하므로 raw SQL 을 쓰지 않는다(쿼리빌더의 MIN/MAX 는 이식된다).
|
|
2964
|
+
* 돌려주는 값의 **모양은 드라이버마다 다르다**(문자열·Date) — 받은 뒤에 한 번 정규화한다(`parseTime`).
|
|
2144
2965
|
*/
|
|
2145
2966
|
static async timeRange(domainId, spaceId) {
|
|
2146
2967
|
const regs = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).find({ where: { domain: { id: domainId }, spaceId } });
|
|
2147
2968
|
const ids = regs.map(r => r.instanceId);
|
|
2148
2969
|
if (!ids.length)
|
|
2149
2970
|
return { minTime: null, maxTime: null };
|
|
2971
|
+
/**
|
|
2972
|
+
* 한 트윈 저널의 시각 양 끝 하나 — 단일 집계라 인덱스 끝을 집는다.
|
|
2973
|
+
*
|
|
2974
|
+
* 집계식을 **조립하지 않고 표에 리터럴로 적는다.** `` `${agg}(e.eventTime)` `` 로 만들면 이 자리가
|
|
2975
|
+
* 집계를 쓰는지 훑는 검사(`journal-read-discipline`)가 찾지 못한다 — 실제로 그렇게 빨개졌다.
|
|
2976
|
+
* 번역 키에서 겪은 것과 같은 규율이다: 검사가 일하게 두는 편이 낫다.
|
|
2977
|
+
*/
|
|
2978
|
+
const AGG_SELECT = { MIN: 'MIN(e.eventTime)', MAX: 'MAX(e.eventTime)' };
|
|
2979
|
+
const edge = async (instanceId, agg) => {
|
|
2980
|
+
const row = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent)
|
|
2981
|
+
.createQueryBuilder('e')
|
|
2982
|
+
.select(AGG_SELECT[agg], 't')
|
|
2983
|
+
.where('e.domain = :domainId', { domainId })
|
|
2984
|
+
.andWhere('e.instanceId = :instanceId', { instanceId })
|
|
2985
|
+
.getRawOne();
|
|
2986
|
+
return parseTime(row?.t);
|
|
2987
|
+
};
|
|
2150
2988
|
let min = Infinity;
|
|
2151
2989
|
let max = -Infinity;
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2990
|
+
/*
|
|
2991
|
+
* 한 번에 띄우는 수를 묶는다 — 공간에 트윈이 수백이면 왕복 수백 개를 동시에 보내 커넥션 풀을
|
|
2992
|
+
* 말려 버린다(이 함수 하나 때문에 다른 질의가 기다리게 된다).
|
|
2993
|
+
*/
|
|
2994
|
+
for (let i = 0; i < ids.length; i += TIME_RANGE_FANOUT) {
|
|
2995
|
+
const batch = ids.slice(i, i + TIME_RANGE_FANOUT);
|
|
2996
|
+
const edges = await Promise.all(batch.flatMap(id => [edge(id, 'MIN'), edge(id, 'MAX')]));
|
|
2997
|
+
for (let k = 0; k < edges.length; k += 2) {
|
|
2998
|
+
const lo = edges[k];
|
|
2999
|
+
const hi = edges[k + 1];
|
|
3000
|
+
if (lo !== null && lo < min)
|
|
3001
|
+
min = lo;
|
|
3002
|
+
if (hi !== null && hi > max)
|
|
3003
|
+
max = hi;
|
|
2162
3004
|
}
|
|
2163
3005
|
}
|
|
2164
3006
|
if (min === Infinity)
|
|
@@ -2179,13 +3021,19 @@ class TwinEngine {
|
|
|
2179
3021
|
* 남기지 않으면 다음 조회가 저널을 전량 다시 접는다(그 값은 어차피 방금 메모리에 있던 것이다).
|
|
2180
3022
|
* 체크포인트 루프가 20초마다 뜨지만 그 사이에 멈추면 그 구간이 통째로 다시 접힌다.
|
|
2181
3023
|
*/
|
|
2182
|
-
await this.persistSnapshot(domainId, id).catch(err =>
|
|
3024
|
+
await this.persistSnapshot(domainId, id).catch(err => (0, log_js_1.twinError)(`[twin-engine] snapshot on stop fail "${id}"`, err?.message ?? err));
|
|
3025
|
+
/*
|
|
3026
|
+
* **모아 둔 저널도 지금 흘린다** — 주기 flush 사이(최대 `BROADCAST_COALESCE_MS`)에 멈추면 그 구간의
|
|
3027
|
+
* 사실이 사라진다. 스냅샷만 남기면 상태는 살아도 **왜 그렇게 됐는지**가 빈다(저널이 답하는 것이다).
|
|
3028
|
+
* 쓰기는 이 함수를 기다리지 않지만(비차단) 버퍼는 여기서 비워지므로 다음 기동이 두 번 적지 않는다.
|
|
3029
|
+
*/
|
|
3030
|
+
await this.flushJournal(i);
|
|
2183
3031
|
clearInterval(i.timer);
|
|
2184
3032
|
i.unsub();
|
|
2185
3033
|
delete this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, id)];
|
|
2186
3034
|
await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance)
|
|
2187
3035
|
.update({ domain: { id: i.domainId }, instanceId: id }, { status: 'stopped' })
|
|
2188
|
-
.catch(err =>
|
|
3036
|
+
.catch(err => (0, log_js_1.twinError)('twin deregister fail', err));
|
|
2189
3037
|
}
|
|
2190
3038
|
for (const hook of this.stopHooks) {
|
|
2191
3039
|
try {
|
|
@@ -2251,7 +3099,7 @@ class TwinEngine {
|
|
|
2251
3099
|
(0, load_meter_js_1.recordPhase)(load, 'tick', took);
|
|
2252
3100
|
const j = (0, load_meter_js_1.judgeCycle)(load, took, this.TICK_MS, Date.now());
|
|
2253
3101
|
if (j.warn)
|
|
2254
|
-
|
|
3102
|
+
(0, log_js_1.twinWarn)((0, load_meter_js_1.slowTickMessage)(id, took, j.budgetMs, load));
|
|
2255
3103
|
this.guardStarvation(domainId, id, took);
|
|
2256
3104
|
}
|
|
2257
3105
|
}
|
|
@@ -2290,9 +3138,9 @@ class TwinEngine {
|
|
|
2290
3138
|
}
|
|
2291
3139
|
/** 멈추고 **이유를 남긴다** — 이유 없는 「정지」는 사람이 자기가 멈춘 것으로 읽는다. */
|
|
2292
3140
|
static stopWithNote(domainId, id, note, log) {
|
|
2293
|
-
|
|
3141
|
+
(0, log_js_1.twinError)(log);
|
|
2294
3142
|
this.stopNotes.set((0, runtime_key_js_1.runtimeKey)(domainId, id), note);
|
|
2295
|
-
this.stop(domainId, id).catch(err =>
|
|
3143
|
+
this.stop(domainId, id).catch(err => (0, log_js_1.twinError)(`[twin-engine] stop after guard failed "${id}"`, err?.message ?? err));
|
|
2296
3144
|
}
|
|
2297
3145
|
/** 이 트윈이 스스로 멈춘 이유(있으면) — 화면이 옮겨 말한다. */
|
|
2298
3146
|
static stopNoteOf(domainId, id) {
|
|
@@ -2346,7 +3194,7 @@ class TwinEngine {
|
|
|
2346
3194
|
}
|
|
2347
3195
|
if (ack?.accepted && emitted.length) {
|
|
2348
3196
|
inst.pendingJournal = [...(inst.pendingJournal ?? []), ...emitted];
|
|
2349
|
-
inst.dirty = true; // 코얼레서가 이번 주기에 비우고
|
|
3197
|
+
inst.dirty = true; // 코얼레서가 이번 주기에 비우고 브로드캐스팅까지 하게 한다
|
|
2350
3198
|
}
|
|
2351
3199
|
return ack ?? { accepted: false, errorCode: 'unknown-command', error: 'unknown-command' };
|
|
2352
3200
|
}
|
|
@@ -2433,7 +3281,7 @@ class TwinEngine {
|
|
|
2433
3281
|
instanceId: id,
|
|
2434
3282
|
ingestedTotal: m.ingestedTotal, broadcastTotal: m.broadcastTotal, journaledTotal: m.journaledTotal,
|
|
2435
3283
|
ingestRate: m.ingestRate, broadcastRate: m.broadcastRate, journalRate: m.journalRate,
|
|
2436
|
-
backlog: m.backlog, broadcastCoalesceMs: this.
|
|
3284
|
+
backlog: m.backlog, broadcastCoalesceMs: this.broadcastPeriodMs,
|
|
2437
3285
|
/* 작업별 부하 — 무거운 것부터. 잰 적이 없으면 null(0 으로 채우면 "빠르다" 로 읽힌다). */
|
|
2438
3286
|
load: (0, load_meter_js_1.loadSummary)(this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, id)]?.load, this.TICK_MS)
|
|
2439
3287
|
};
|
|
@@ -2453,7 +3301,7 @@ class TwinEngine {
|
|
|
2453
3301
|
mode: inst.mode ?? 'sim',
|
|
2454
3302
|
domainId: inst.domainId,
|
|
2455
3303
|
domainLabel: inst.domain?.subdomain,
|
|
2456
|
-
|
|
3304
|
+
restartPolicy: inst.restartPolicy,
|
|
2457
3305
|
tickMs: this.TICK_MS,
|
|
2458
3306
|
running: !!inst.timer || inst.mode === 'live',
|
|
2459
3307
|
...((0, load_meter_js_1.loadSummary)(inst.load, this.TICK_MS) ?? { recent: null, total: null, forksCreated: 0, overBudget: 0, budgetMs: this.TICK_MS * 0.5, loadRatio: null })
|
|
@@ -2481,7 +3329,15 @@ class TwinEngine {
|
|
|
2481
3329
|
});
|
|
2482
3330
|
return {
|
|
2483
3331
|
tickMs: this.TICK_MS,
|
|
2484
|
-
broadcastCoalesceMs: this.
|
|
3332
|
+
broadcastCoalesceMs: this.broadcastPeriodMs,
|
|
3333
|
+
/* 브로드캐스팅 주기를 늘린 횟수 — 늘어난 주기만 보면 「원래 그런 값」으로 읽힌다. */
|
|
3334
|
+
broadcastBackoffs: this.broadcastBackoffs,
|
|
3335
|
+
/* 물품 범위를 좁히지 못해 전부 만든 횟수 — 좁히기가 실제로 듣고 있는지를 이 값으로 본다. */
|
|
3336
|
+
broadcastPasses: this.broadcastPasses,
|
|
3337
|
+
broadcastFullPasses: this.broadcastFullPasses,
|
|
3338
|
+
broadcastFullEvery: this.FULL_BROADCAST_EVERY,
|
|
3339
|
+
/* **루프가 실제로 얼마나 막혔나** — 트윈 부하와 나란히 놓고 원인을 가린다(loop-lag.ts 주석). */
|
|
3340
|
+
loopLag: loop_lag_js_1.loopLag.view(),
|
|
2485
3341
|
...(0, load_meter_js_1.fleetLoad)(rows, this.TICK_MS),
|
|
2486
3342
|
/* 줄마다 상세 — 화면이 펼쳐 볼 수 있게. 최근 부하 순서는 fleetLoad 가 정한다. */
|
|
2487
3343
|
details: keys.map(key => { const at = (0, runtime_key_js_1.parseRuntimeKey)(key); return this.load(at.domainId, at.instanceId); }).filter(Boolean)
|
|
@@ -2499,7 +3355,154 @@ class TwinEngine {
|
|
|
2499
3355
|
if (inst)
|
|
2500
3356
|
(0, load_meter_js_1.recordPhase)(inst.load ?? (inst.load = (0, load_meter_js_1.newLoadMeter)()), phase, tookMs);
|
|
2501
3357
|
}
|
|
2502
|
-
|
|
3358
|
+
/*
|
|
3359
|
+
* ── 동기화 건강 장부 ───────────────────────────────────────────────────────
|
|
3360
|
+
*
|
|
3361
|
+
* **런타임이 아니라 엔진이 든다.** 인스턴스에 붙이면 트윈을 재기동할 때 사라지는데, 사람이 알고 싶은
|
|
3362
|
+
* 것은 바로 그 순간이다 — 「고쳐서 다시 띄웠는데 이제 통과하나」. 프로세스가 사는 동안 이어진다.
|
|
3363
|
+
*
|
|
3364
|
+
* 지금은 메모리만이다. 창이 닫힐 때 한 행씩 영속하는 것은 `onWindowClosed` 한 자리에 붙는다.
|
|
3365
|
+
*/
|
|
3366
|
+
static { this.ingestLedgers = {}; }
|
|
3367
|
+
/**
|
|
3368
|
+
* 닫힌 창을 받을 곳을 등록한다 — **영속(B)이 붙는 유일한 문.**
|
|
3369
|
+
*
|
|
3370
|
+
* 이 패키지는 저장 계층을 모른다. 등록하는 쪽이 자기 방식으로 쓴다. 훅이 오류를 내도 장부는 계속 굴러간다
|
|
3371
|
+
* (`rollIngestWindow` 가 감싼다) — 영속을 지키려고 관측을 멈추지 않는다.
|
|
3372
|
+
*/
|
|
3373
|
+
static onIngestWindowClosed(fn) {
|
|
3374
|
+
this.onWindowClosed = fn;
|
|
3375
|
+
}
|
|
3376
|
+
/**
|
|
3377
|
+
* 한 번의 인제스트 결과를 장부에 적는다.
|
|
3378
|
+
*
|
|
3379
|
+
* `offered` 는 **제시된 레코드 수**다(거부 여부 무관). 통과율을 서로 다른 두 계수기에서 나눠 계산하면
|
|
3380
|
+
* 분모와 분자가 다른 것을 세게 되므로, 한자리에서 본 수를 그대로 넘긴다.
|
|
3381
|
+
*/
|
|
3382
|
+
static recordIngestResult(domainId, instanceId, offered, rejected, nowMs = Date.now(),
|
|
3383
|
+
/**
|
|
3384
|
+
* 매핑은 통과했는데 **커널이 받지 않은** 수(`ingestLive` 의 반환과 견주어 얻는다).
|
|
3385
|
+
*
|
|
3386
|
+
* 「통과」로 세지 않는다 — 트윈이 멈춘 사이 사실이 사라지는데 화면이 100% 라고 말하게 된다.
|
|
3387
|
+
*/
|
|
3388
|
+
undelivered = 0) {
|
|
3389
|
+
const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
|
|
3390
|
+
const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = (0, ingest_health_js_1.newIngestLedger)());
|
|
3391
|
+
(0, ingest_health_js_1.recordIngest)(ledger, offered, rejected, nowMs, closed => this.onWindowClosed?.({ domainId, instanceId }, closed), undelivered);
|
|
3392
|
+
}
|
|
3393
|
+
/**
|
|
3394
|
+
* **원본에 닿지 못했다**를 적는다 — 「받은 것이 없다」와 가른다(§`recordReadFailure`).
|
|
3395
|
+
*
|
|
3396
|
+
* 이 문이 없던 동안 실 원본이 끊겨도 트윈의 조회 가능한 상태에 그 사실이 없었다. 화면이 볼 수 있는
|
|
3397
|
+
* 것은 「새 사실이 없다」뿐이었고 그것은 「원본이 조용하다」와 구별되지 않는다 — 실증 중에 원본이
|
|
3398
|
+
* 끊기면 사용자가 원인을 찾을 수 없다.
|
|
3399
|
+
*
|
|
3400
|
+
* 로그로는 말하고 있었다(어댑터가 재시도를 경고한다). 그러나 **로그는 사람이 볼 때만 값이 있다** —
|
|
3401
|
+
* 화면이 말하려면 상태에 있어야 한다.
|
|
3402
|
+
*/
|
|
3403
|
+
static recordIngestReadFailure(domainId, instanceId, reason, nowMs = Date.now(), stream) {
|
|
3404
|
+
const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
|
|
3405
|
+
const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = (0, ingest_health_js_1.newIngestLedger)());
|
|
3406
|
+
(0, ingest_health_js_1.recordReadFailure)(ledger, reason, nowMs, stream);
|
|
3407
|
+
}
|
|
3408
|
+
/**
|
|
3409
|
+
* 읽기가 성공했다 — 단절 기록을 지운다.
|
|
3410
|
+
*
|
|
3411
|
+
* **빈 읽기도 성공이다.** 원본이 「새 것이 없다」고 답한 것은 닿았다는 뜻이므로, 그때도 부른다.
|
|
3412
|
+
* 그 둘을 같게 두면 조용한 원본이 끊긴 원본으로 보인다.
|
|
3413
|
+
*/
|
|
3414
|
+
static clearIngestReadFailure(domainId, instanceId) {
|
|
3415
|
+
const ledger = this.ingestLedgers[(0, runtime_key_js_1.runtimeKey)(domainId, instanceId)];
|
|
3416
|
+
if (ledger)
|
|
3417
|
+
(0, ingest_health_js_1.clearReadFailure)(ledger);
|
|
3418
|
+
}
|
|
3419
|
+
/**
|
|
3420
|
+
* 저널에 **적은 것**을 같은 장부에 남긴다 — 유입과 같은 10분 창에.
|
|
3421
|
+
*
|
|
3422
|
+
* ── 왜 유입 장부에 넣나 ────────────────────────────────────────────────────
|
|
3423
|
+
* 새 장부를 만들면 「닫힌 창만 최근」·「0 과 없음을 가른다」·영속을 두 벌 지켜야 하고, 그중 한 벌만
|
|
3424
|
+
* 고쳐지는 것이 보통이다. 유입 창은 그 규율이 이미 들어 있고 닫힐 때 행으로 남는다.
|
|
3425
|
+
*
|
|
3426
|
+
* ── 이 값이 없으면 무엇을 못 보나 ──────────────────────────────────────────
|
|
3427
|
+
* 계기(`journalRate`)는 **지금**만 답한다. 프로세스를 다시 띄우면 0 에서 시작하므로 「어제 이 시각에도
|
|
3428
|
+
* 이랬나」·「고친 뒤로 줄었나」를 물을 자리가 없다. 2026-08-20 에 고친 것이 바로 쓰기 경로이므로,
|
|
3429
|
+
* 되돌아가는 것을 볼 수 있어야 한다.
|
|
3430
|
+
*/
|
|
3431
|
+
static recordJournalWrite(domainId, instanceId, rows, nowMs = Date.now()) {
|
|
3432
|
+
const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
|
|
3433
|
+
const ledger = this.ingestLedgers[key] ?? (this.ingestLedgers[key] = (0, ingest_health_js_1.newIngestLedger)());
|
|
3434
|
+
(0, ingest_health_js_1.recordJournalWrite)(ledger, rows, nowMs, closed => this.onWindowClosed?.({ domainId, instanceId }, closed));
|
|
3435
|
+
}
|
|
3436
|
+
/**
|
|
3437
|
+
* 「이 트윈이 현장과 맞춰지고 있나」 — 한눈 판정 + 추이 + 사유 + 표본.
|
|
3438
|
+
*
|
|
3439
|
+
* 조회할 때 창을 한 번 굴린다: 유입이 멈추면 다음 인제스트가 없어 창이 영원히 닫히지 않는데, 그러면
|
|
3440
|
+
* 「최근」이 옛것으로 남는다. 타이머를 두지 않는 이유는 트윈마다 타이머를 걸면 그 타이머들이 다시
|
|
3441
|
+
* 메인 루프에 얹히기 때문이다(오늘 확인한 그 부하를 이 기능이 다시 만들 이유가 없다).
|
|
3442
|
+
*/
|
|
3443
|
+
/**
|
|
3444
|
+
* 도메인의 트윈마다 **한 줄 요약** — 목록 화면과 미니 추이용.
|
|
3445
|
+
*
|
|
3446
|
+
* ── 왜 상세와 따로인가 ─────────────────────────────────────────────────────
|
|
3447
|
+
* `ingestHealthOf` 는 사유 문구와 **레코드 원문 표본**까지 낸다. 목록에 트윈이 스무 개면 그 원문이
|
|
3448
|
+
* 스무 벌 실려 조회가 무거워지고, 화면은 어차피 그것을 그리지 않는다. 그래서 여기서는 **숫자만** 낸다.
|
|
3449
|
+
*
|
|
3450
|
+
* 추이는 창당 숫자 넷이라 스파크라인 하나에 충분하고 가볍다.
|
|
3451
|
+
*
|
|
3452
|
+
* ── 등록된 트윈을 기준으로 훑는다 ──────────────────────────────────────────
|
|
3453
|
+
* 도는 트윈만 훑으면 「고치려고 멈춰 둔 트윈」이 목록에서 사라진다 — 사람이 방금 멈춘 그것을 보려고
|
|
3454
|
+
* 목록을 여는데 없으면 결함으로 읽는다. 그래서 호출부(목록 화면)가 아는 instanceId 들을 받는다.
|
|
3455
|
+
*/
|
|
3456
|
+
static ingestHealthBrief(domainId, instanceIds) {
|
|
3457
|
+
return instanceIds.map(instanceId => {
|
|
3458
|
+
const full = this.ingestHealthOf(domainId, instanceId);
|
|
3459
|
+
return {
|
|
3460
|
+
instanceId,
|
|
3461
|
+
verdict: full.verdict,
|
|
3462
|
+
/* 최근 **닫힌** 창의 통과율. 창이 안 닫혔으면 비운다 — 0 이나 1 로 채우지 않는다. */
|
|
3463
|
+
acceptedRatio: full.recent?.acceptedRatio ?? null,
|
|
3464
|
+
/*
|
|
3465
|
+
* 스파크라인용 — 창마다 셋. **미전달을 빼놓으면 스파크라인이 거짓을 그린다**: 버려진 것을
|
|
3466
|
+
* 통과로 세면 트윈이 멈춘 구간에서도 선이 100% 에 붙는다.
|
|
3467
|
+
*/
|
|
3468
|
+
/* 적힌 행도 함께 — 시뮬 트윈은 유입이 0 이라 이 값 없이는 추이가 빈 선으로 보인다. */
|
|
3469
|
+
trend: full.trend.map(w => ({ offered: w.offered, rejected: w.rejected, undelivered: w.undelivered, rows: w.rows })),
|
|
3470
|
+
lastAt: full.lastAt
|
|
3471
|
+
};
|
|
3472
|
+
});
|
|
3473
|
+
}
|
|
3474
|
+
/**
|
|
3475
|
+
* 한 트윈의 동기화 건강.
|
|
3476
|
+
*
|
|
3477
|
+
* ── 조회가 장부를 전진시킨다(의도) ─────────────────────────────────────────
|
|
3478
|
+
* 창은 **다음 유입이 있을 때** 닫힌다. 그래서 피드가 죽으면 마지막 창이 열린 채로 남아 영원히 영속되지
|
|
3479
|
+
* 않고, 추이에도 들어가지 않는다. 조회 시점에 한 번 굴려 주면 그 마지막 창이 닫히면서 영속 훅도 불린다
|
|
3480
|
+
* — 즉 **죽은 피드의 마지막 구간을 잃지 않기 위해** 읽기가 굴린다. 부수효과지만 필요한 부수효과다.
|
|
3481
|
+
*
|
|
3482
|
+
* 시각은 **한 번만 읽어** 굴리기와 판정에 같은 값을 쓴다. 두 번 읽으면 그 사이에 창이 닫혀 판정이
|
|
3483
|
+
* 굴리기 전 상태를 보는 일이 생긴다.
|
|
3484
|
+
*/
|
|
3485
|
+
static ingestHealthOf(domainId, instanceId) {
|
|
3486
|
+
const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
|
|
3487
|
+
const nowMs = Date.now();
|
|
3488
|
+
const ledger = this.ingestLedgers[key];
|
|
3489
|
+
if (ledger) {
|
|
3490
|
+
(0, ingest_health_js_1.rollIngestWindow)(ledger, nowMs, undefined, closed => this.onWindowClosed?.({ domainId, instanceId }, closed));
|
|
3491
|
+
}
|
|
3492
|
+
const inst = this.instances[key];
|
|
3493
|
+
const feedState = (0, live_feed_registry_js_1.liveFeedStateOf)({
|
|
3494
|
+
restartPolicy: inst?.restartPolicy,
|
|
3495
|
+
running: !!inst,
|
|
3496
|
+
instanceId
|
|
3497
|
+
});
|
|
3498
|
+
return (0, ingest_health_js_1.ingestHealth)(ledger, feedState, undefined, nowMs);
|
|
3499
|
+
}
|
|
3500
|
+
/**
|
|
3501
|
+
* 도는 인스턴스 전체의 계측(모니터 대시보드용) — **시뮬과 미러를 함께**.
|
|
3502
|
+
*
|
|
3503
|
+
* 예전에는 시뮬에 계기가 없어 이 목록에서 조용히 빠졌다(계기가 `null` 이라 걸러졌다). 도는 트윈
|
|
3504
|
+
* 대부분이 시뮬인 서버에서 그 목록은 「부하가 거의 없다」로 보였다.
|
|
3505
|
+
*/
|
|
2503
3506
|
static async allMetrics(domainId) {
|
|
2504
3507
|
/* 도메인 없이 부르면 전 테넌트를 훑는다(내부 모니터용) — 키에서 도메인을 되돌려 각자에게 묻는다. */
|
|
2505
3508
|
const rows = Object.keys(this.instances)
|
|
@@ -2553,6 +3556,14 @@ class TwinEngine {
|
|
|
2553
3556
|
if (!state)
|
|
2554
3557
|
return null;
|
|
2555
3558
|
const cutoff = untilTime != null ? Date.parse(untilTime) : Infinity;
|
|
3559
|
+
/*
|
|
3560
|
+
* journal-fold: 오더의 마지막 상태는 그 오더에 일어난 사실들을 접어야 나온다.
|
|
3561
|
+
*
|
|
3562
|
+
* **없어질 조건** — 지금 이 읽기에는 상한이 없고 `eventType` 에 인덱스도 없다. 그래서 저널이 큰
|
|
3563
|
+
* 트윈에서는 이 한 번이 그 트윈의 저널 전체 주사다. 오더 상태를 따로 접어 둔 표(투영)를 두거나,
|
|
3564
|
+
* `(domain, instanceId, eventType, revision)` 인덱스를 붙이고 커서를 넣으면 이 예외는 사라진다.
|
|
3565
|
+
* 시각 상한(`cutoff`)을 SQL 로 내려도 절반은 준다.
|
|
3566
|
+
*/
|
|
2556
3567
|
const rows = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).find({ where: { domain: { id: domainId }, instanceId, eventType: OP_EVENT.order }, order: { revision: 'ASC' } });
|
|
2557
3568
|
const latest = new Map();
|
|
2558
3569
|
for (const r of rows) {
|