@things-factory/headless-twin 10.0.5 → 10.0.6
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/kpi-fold.js +24 -49
- package/dist-server/engine/kpi-fold.js.map +1 -1
- package/dist-server/engine/kpi-query.d.ts +7 -0
- package/dist-server/engine/kpi-query.js +11 -0
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/twin-engine.d.ts +29 -2
- package/dist-server/engine/twin-engine.js +78 -21
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/warm-start.d.ts +39 -0
- package/dist-server/engine/warm-start.js +37 -0
- package/dist-server/engine/warm-start.js.map +1 -0
- package/dist-server/index.js +8 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
- package/dist-server/service/twin-event/backfill-keys.js +63 -0
- package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
- package/dist-server/service/twin-event/twin-event-keys.js +95 -0
- package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
- package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
- package/dist-server/service/twin-event/twin-event-type.js +32 -0
- package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
- package/dist-server/service/twin-event/twin-event.d.ts +5 -0
- package/dist-server/service/twin-event/twin-event.js +45 -0
- package/dist-server/service/twin-event/twin-event.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +19 -0
- package/dist-server/service/twin-journal/twin-journal-query.js +74 -0
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/kpi-fold.ts +23 -52
- package/server/engine/kpi-query.ts +11 -0
- package/server/engine/twin-engine.ts +95 -28
- package/server/engine/warm-start.ts +53 -0
- package/server/index.ts +9 -0
- package/server/service/twin-event/backfill-keys.ts +72 -0
- package/server/service/twin-event/twin-event-keys.ts +102 -0
- package/server/service/twin-event/twin-event-type.ts +27 -0
- package/server/service/twin-event/twin-event.ts +48 -0
- package/server/service/twin-journal/twin-journal-query.ts +79 -3
- package/test/kpi-fold.test.ts +22 -0
- package/test/twin-event-keys.test.ts +108 -0
- package/test/warm-start.test.ts +78 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/headless-twin",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.6",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh <heartyoh@hatiolab.com>",
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
"test": "node --test test/*.test.ts"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@operato/twin-kernel": "^0.0
|
|
29
|
-
"@things-factory/auth-base": "^10.0.
|
|
30
|
-
"@things-factory/cache-service": "^10.0.
|
|
28
|
+
"@operato/twin-kernel": "^0.1.0",
|
|
29
|
+
"@things-factory/auth-base": "^10.0.6",
|
|
30
|
+
"@things-factory/cache-service": "^10.0.6",
|
|
31
31
|
"@things-factory/env": "^10.0.0",
|
|
32
|
-
"@things-factory/shell": "^10.0.
|
|
32
|
+
"@things-factory/shell": "^10.0.6"
|
|
33
33
|
},
|
|
34
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "b929c16f83f70bfcc9a144ac6a50232efa56cd64"
|
|
35
35
|
}
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
* · DB·커널을 모른다. 입력은 평범한 배열이고 출력은 평범한 객체다(테스트가 값싸다).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
/*
|
|
23
|
+
* 짝맞춤은 **커널이 소유한다**(`foldTaskRecords`). 완료는 작업당 하나·나중 것이 사실, 착수는 처음 것,
|
|
24
|
+
* 종류·오더는 어느 전이에서든 — 그 규칙은 커널 상태 기계에서 나오는 지식이다. 여기서 다시 적었더니
|
|
25
|
+
* 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 두 건으로 세어 처리량·점유가 부풀려졌다).
|
|
26
|
+
* 이 파일은 이제 "접힌 기록 → 창 지표" 만 한다.
|
|
27
|
+
*/
|
|
28
|
+
import { foldTaskRecords, type TaskFacets } from '@operato/twin-kernel'
|
|
29
|
+
|
|
22
30
|
/** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */
|
|
23
31
|
export interface KpiEvent {
|
|
24
32
|
eventType?: string
|
|
@@ -121,14 +129,6 @@ export interface KpiFoldOptions {
|
|
|
121
129
|
* 축의 값은 이벤트 payload 에 이미 들어 있다(`TaskStatusDelta`: kind·orderId·fromNode·toNode·
|
|
122
130
|
* resourceRef). 새 계측을 심을 필요가 없다 — **이미 쌓인 저널을 다르게 묶기만** 한다.
|
|
123
131
|
*/
|
|
124
|
-
interface TaskFacets {
|
|
125
|
-
resource?: string
|
|
126
|
-
taskKind?: string
|
|
127
|
-
/** 작업이 **도착한** 지점. 없으면 출발 지점(둘 다 없으면 축은 'unknown'). */
|
|
128
|
-
node?: string
|
|
129
|
-
order?: string
|
|
130
|
-
}
|
|
131
|
-
|
|
132
132
|
/** 창 안에 완료된 한 건의 기록 — 통계의 원료이자 관점 축의 원료. */
|
|
133
133
|
interface CompletionRecord {
|
|
134
134
|
taskId: string
|
|
@@ -139,23 +139,6 @@ interface CompletionRecord {
|
|
|
139
139
|
waitMs?: number
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
/**
|
|
143
|
-
* 이 작업에 대해 본 축의 값을 기억한다 — **처음 본 값을 남긴다**.
|
|
144
|
-
*
|
|
145
|
-
* 왜 처음 것인가: 작업의 종류·소속 오더는 생성 시점에 정해지고 이후 전이는 그것을 되풀이할 뿐이다.
|
|
146
|
-
* 반면 노드는 진행에 따라 바뀌므로 **마지막 것**(가장 최근 도착지)이 사실이다.
|
|
147
|
-
*/
|
|
148
|
-
function rememberFacets(store: Map<string, TaskFacets>, id: string, d: any): void {
|
|
149
|
-
const prev = store.get(id) ?? {}
|
|
150
|
-
const node = d.toNode ?? d.fromNode
|
|
151
|
-
store.set(id, {
|
|
152
|
-
resource: prev.resource ?? (d.resourceRef || undefined),
|
|
153
|
-
taskKind: prev.taskKind ?? (d.kind || undefined),
|
|
154
|
-
order: prev.order ?? (d.orderId || undefined),
|
|
155
|
-
node: node || prev.node
|
|
156
|
-
})
|
|
157
|
-
}
|
|
158
|
-
|
|
159
142
|
/** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(조용히 버리지 않는다). */
|
|
160
143
|
function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
|
|
161
144
|
const f = rec.facets
|
|
@@ -163,7 +146,7 @@ function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
|
|
|
163
146
|
case 'resource':
|
|
164
147
|
return f.resource ?? 'unknown'
|
|
165
148
|
case 'taskKind':
|
|
166
|
-
return f.
|
|
149
|
+
return f.kind ?? 'unknown'
|
|
167
150
|
case 'node':
|
|
168
151
|
return f.node ?? 'unknown'
|
|
169
152
|
case 'order':
|
|
@@ -257,13 +240,8 @@ function stats(values: number[]): DurationStats {
|
|
|
257
240
|
* (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다.
|
|
258
241
|
*/
|
|
259
242
|
export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldOptions = {}): KpiResult {
|
|
260
|
-
/*
|
|
261
|
-
const
|
|
262
|
-
const started = new Map<string, number>()
|
|
263
|
-
/* 축의 값은 **어느 전이에서든** 올 수 있다(완료 이벤트에 kind 가 빠져 있고 생성에만 있는 구현이 있다).
|
|
264
|
-
* 그래서 작업별로 한 번 본 값을 기억한다 — 축이 이벤트 모양에 따라 통째로 비는 것을 막는다. */
|
|
265
|
-
const facets = new Map<string, TaskFacets>()
|
|
266
|
-
const completedTasks: { id: string; at: number; resourceId?: string }[] = []
|
|
243
|
+
/* 작업 짝맞춤은 **커널 규칙**으로(중복 구현 금지 — 두 곳에 적으면 같은 저널로 다른 답이 나온다). */
|
|
244
|
+
const { records: tasks } = foldTaskRecords(events.filter(e => e.eventType === 'task.status'))
|
|
267
245
|
let completedOrders = 0
|
|
268
246
|
let seen = 0
|
|
269
247
|
let inWindow = 0
|
|
@@ -275,15 +253,6 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
275
253
|
if (at >= window.fromMs && at <= window.toMs) inWindow++
|
|
276
254
|
const d = e.payload?.data ?? e.payload ?? {}
|
|
277
255
|
|
|
278
|
-
if (e.eventType === 'task.status') {
|
|
279
|
-
const id = d.taskId
|
|
280
|
-
if (!id) continue
|
|
281
|
-
rememberFacets(facets, id, d)
|
|
282
|
-
if (d.status === 'created') created.set(id, at)
|
|
283
|
-
else if (d.status === 'in-progress' && !started.has(id)) started.set(id, at)
|
|
284
|
-
else if (d.status === 'completed') completedTasks.push({ id, at, resourceId: d.resourceRef })
|
|
285
|
-
continue
|
|
286
|
-
}
|
|
287
256
|
if (e.eventType === 'order.status') {
|
|
288
257
|
/* 오더 완료 어휘는 도메인 소유다 — 코어가 강제하지 않는다. 그래서 이름을 하나로 못 박지 않고
|
|
289
258
|
* 완료로 읽히는 표현을 넓게 받는다(그 밖은 세지 않는다). */
|
|
@@ -305,19 +274,21 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
|
|
|
305
274
|
let unpairedLead = 0
|
|
306
275
|
let unpairedWork = 0
|
|
307
276
|
|
|
308
|
-
for (const t of
|
|
309
|
-
|
|
277
|
+
for (const t of tasks) {
|
|
278
|
+
const completed = t.completedMs
|
|
279
|
+
if (completed === undefined) continue // 아직 완료되지 않은 작업 — 성과로 세지 않는다
|
|
280
|
+
if (completed < window.fromMs || completed > window.toMs) continue // 성과는 완료 시점으로 센다
|
|
310
281
|
tasksInWindow++
|
|
311
|
-
const c =
|
|
312
|
-
const s =
|
|
313
|
-
const rec: CompletionRecord = { taskId: t.
|
|
314
|
-
|
|
315
|
-
if (c !== undefined &&
|
|
282
|
+
const c = t.createdMs
|
|
283
|
+
const s = t.startedMs
|
|
284
|
+
const rec: CompletionRecord = { taskId: t.taskId, at: completed, facets: t.facets }
|
|
285
|
+
const resource = rec.facets.resource
|
|
286
|
+
if (c !== undefined && completed >= c) leads.push((rec.leadMs = completed - c))
|
|
316
287
|
else unpairedLead++
|
|
317
|
-
if (s !== undefined &&
|
|
318
|
-
const busy =
|
|
288
|
+
if (s !== undefined && completed >= s) {
|
|
289
|
+
const busy = completed - s
|
|
319
290
|
works.push((rec.workMs = busy))
|
|
320
|
-
if (
|
|
291
|
+
if (resource) busyByResource.set(resource, (busyByResource.get(resource) ?? 0) + busy)
|
|
321
292
|
} else {
|
|
322
293
|
unpairedWork++
|
|
323
294
|
}
|
|
@@ -249,6 +249,17 @@ async function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: strin
|
|
|
249
249
|
return { instanceIds, realityModes }
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
|
|
254
|
+
*
|
|
255
|
+
* 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
|
|
256
|
+
* 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
|
|
257
|
+
*/
|
|
258
|
+
export async function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise<string[]> {
|
|
259
|
+
const { instanceIds } = await resolveTargets({ domainId, instanceId, spaceId } as TwinKpiInput)
|
|
260
|
+
return instanceIds
|
|
261
|
+
}
|
|
262
|
+
|
|
252
263
|
/**
|
|
253
264
|
* 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
|
|
254
265
|
*
|
|
@@ -11,6 +11,8 @@ import { pubsub, getRepository, Domain } from '@things-factory/shell'
|
|
|
11
11
|
import { cacheService } from '@things-factory/cache-service'
|
|
12
12
|
|
|
13
13
|
import { TwinEvent } from '../service/twin-event/twin-event.js'
|
|
14
|
+
import { twinEventKeys } from '../service/twin-event/twin-event-keys.js'
|
|
15
|
+
import { planWarmStart } from './warm-start.js'
|
|
14
16
|
import { TwinInstance } from '../service/twin-instance/twin-instance.js'
|
|
15
17
|
import { TwinSpace } from '../service/twin-space/twin-space.js'
|
|
16
18
|
import { TwinSpaceRepresentation } from '../service/twin-space/twin-space-representation.js'
|
|
@@ -177,19 +179,62 @@ export class TwinEngine {
|
|
|
177
179
|
}
|
|
178
180
|
}
|
|
179
181
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
182
|
+
/**
|
|
183
|
+
* 웜스타트 — 기동하는 커널에 **직전 관측 상태**를 심는다.
|
|
184
|
+
*
|
|
185
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
186
|
+
* `loadBoard` 는 **구조만** 싣는다(노드·무버). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한
|
|
187
|
+
* 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고
|
|
188
|
+
* 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인).
|
|
189
|
+
* `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데,
|
|
190
|
+
* 기동 순간 그걸 **버리고** 있었다. 반만 연결돼 있던 장치를 잇는다.
|
|
191
|
+
*
|
|
192
|
+
* ── 정직한 한계 ─────────────────────────────────────────────────────────────
|
|
193
|
+
* · **오더는 복원하지 않는다.** `hydrateObserved` 의 오더 인자는 requested/fulfilled/lines 를 요구하는데
|
|
194
|
+
* 스냅샷의 `OrderState` 에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이라
|
|
195
|
+
* 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작한다.
|
|
196
|
+
* · 진행 중 개별 task 의 내부 상태도 관측만으로는 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김).
|
|
197
|
+
* · 근본 해법(상태 영속 계약·revision 이어붙임)은 별도 과제.
|
|
198
|
+
*
|
|
199
|
+
* ── 벤치는 시드하지 않는다 ──────────────────────────────────────────────────
|
|
200
|
+
* 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 심으면 측정이 오염된다.
|
|
201
|
+
*/
|
|
202
|
+
private static warmStart(id: string, kernel: TwinKernel, purpose?: string): void {
|
|
203
|
+
const hydrate = (kernel as any).hydrateObserved
|
|
204
|
+
const plan = planWarmStart(this.recovered[id]?.state, purpose, typeof hydrate === 'function')
|
|
205
|
+
|
|
206
|
+
if (plan.action === 'skip') {
|
|
207
|
+
if (plan.reason === 'bench') {
|
|
208
|
+
console.log(`[twin-engine] "${id}" is a bench twin — starting empty on purpose (seeding would skew the measurement).`)
|
|
209
|
+
} else if (plan.reason === 'unsupported') {
|
|
210
|
+
console.warn(
|
|
211
|
+
`[twin-engine] kernel for "${id}" cannot be warm-started (no hydrateObserved) — it starts with structure only, so held stock will read as zero.`
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
hydrate.call(kernel, plan.seed)
|
|
218
|
+
console.log(
|
|
219
|
+
`[twin-engine] warm-started "${id}" — ${plan.itemCount} item(s), ${plan.moverCount} mover(s) restored. ` +
|
|
220
|
+
'Open orders are not restored (the snapshot carries no requested/fulfilled counts).'
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
|
|
225
|
+
static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode, purpose?: string): InstanceRuntime {
|
|
182
226
|
if (this.instances[id]) return this.instances[id]
|
|
183
227
|
|
|
184
228
|
const Kernel = KERNELS[kind] ?? WmsKernel
|
|
185
229
|
const kernel: TwinKernel = new Kernel(domainId)
|
|
186
|
-
kernel.loadBoard(board)
|
|
230
|
+
kernel.loadBoard(board) // 구조만. 상태는 아래 웜스타트가 심는다.
|
|
231
|
+
this.warmStart(id, kernel, purpose)
|
|
187
232
|
const runtime: TwinRuntimeType = new TwinRuntime(kernel)
|
|
188
233
|
|
|
189
234
|
/* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */
|
|
190
235
|
const inst: InstanceRuntime = { id, domainId, runtime, kernel, realityMode: realityMode ?? DEFAULT_REALITY_MODE, unsub: () => {} }
|
|
191
236
|
this.instances[id] = inst
|
|
192
|
-
delete this.recovered[id] // 라이브가
|
|
237
|
+
delete this.recovered[id] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
|
|
193
238
|
|
|
194
239
|
/* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
|
|
195
240
|
getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
|
|
@@ -312,20 +357,28 @@ export class TwinEngine {
|
|
|
312
357
|
if (!anyLive && this.broadcastTimer) { clearInterval(this.broadcastTimer); this.broadcastTimer = undefined } // live 없으면 tick 정지
|
|
313
358
|
}
|
|
314
359
|
|
|
360
|
+
/**
|
|
361
|
+
* 저널 행 한 줄 — **기록 경로가 둘이라(라이브 벌크·심 단건) 행 모양은 반드시 한 곳에서 만든다.**
|
|
362
|
+
* 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데
|
|
363
|
+
* 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다.
|
|
364
|
+
*/
|
|
365
|
+
private static journalRow(repo: any, domainId: string, instanceId: string, e: any, revision: number) {
|
|
366
|
+
return repo.create({
|
|
367
|
+
domain: { id: domainId } as any,
|
|
368
|
+
instanceId,
|
|
369
|
+
tenantId: e?.tenantId,
|
|
370
|
+
eventType: e?.eventType,
|
|
371
|
+
revision,
|
|
372
|
+
eventTime: e?.eventTime,
|
|
373
|
+
...twinEventKeys(e),
|
|
374
|
+
payload: e
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
315
378
|
/** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */
|
|
316
379
|
static async persistBatch(domainId: string, instanceId: string, envelopes: any[], startRevision: number): Promise<void> {
|
|
317
380
|
const repo = getRepository(TwinEvent)
|
|
318
|
-
const rows = envelopes.map((e, i) =>
|
|
319
|
-
repo.create({
|
|
320
|
-
domain: { id: domainId } as any,
|
|
321
|
-
instanceId,
|
|
322
|
-
tenantId: e?.tenantId,
|
|
323
|
-
eventType: e?.eventType,
|
|
324
|
-
revision: startRevision + i + 1,
|
|
325
|
-
eventTime: e?.eventTime,
|
|
326
|
-
payload: e
|
|
327
|
-
})
|
|
328
|
-
)
|
|
381
|
+
const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1))
|
|
329
382
|
await repo.save(rows, { chunk: 500 })
|
|
330
383
|
}
|
|
331
384
|
|
|
@@ -417,7 +470,32 @@ export class TwinEngine {
|
|
|
417
470
|
if (this.instances[instanceId]) return this.instances[instanceId]
|
|
418
471
|
const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
|
|
419
472
|
if (!reg?.board) throw new Error(`instance "${instanceId}" not provisioned (no board)`)
|
|
420
|
-
|
|
473
|
+
|
|
474
|
+
/*
|
|
475
|
+
* 웜스타트 재료를 **여기서 확실히 확보한다.**
|
|
476
|
+
* `start()` 는 동기라 스스로 캐시를 읽을 수 없어서 `recovered` 에 미리 담겨 있기를 기대하는데,
|
|
477
|
+
* 그건 `bootstrap()` 이 먼저 돌았을 때만 참이다. 부팅 순서에 기대면 어떤 날은 재고가 살아나고
|
|
478
|
+
* 어떤 날은 조용히 빈 채로 뜬다 — 재현되지 않는 결함이 가장 나쁘다.
|
|
479
|
+
* 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서).
|
|
480
|
+
*/
|
|
481
|
+
if (!this.recovered[instanceId] && reg.purpose !== 'bench') {
|
|
482
|
+
const cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
|
|
483
|
+
if (cached?.state) {
|
|
484
|
+
this.recovered[instanceId] = { revision: cached.revision, state: cached.state }
|
|
485
|
+
} else {
|
|
486
|
+
const state = await this.recover(domainId, instanceId).catch(() => null)
|
|
487
|
+
if (state) this.recovered[instanceId] = { revision: state.revision, state }
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return this.start(
|
|
492
|
+
instanceId,
|
|
493
|
+
domainId,
|
|
494
|
+
reg.kind ?? 'wms',
|
|
495
|
+
reg.board as BoardDef,
|
|
496
|
+
realityMode ?? (reg.realityMode as RealityMode) ?? undefined,
|
|
497
|
+
reg.purpose ?? undefined
|
|
498
|
+
)
|
|
421
499
|
}
|
|
422
500
|
|
|
423
501
|
/**
|
|
@@ -681,18 +759,7 @@ export class TwinEngine {
|
|
|
681
759
|
|
|
682
760
|
static async persist(domainId: string, instanceId: string, msg: any): Promise<void> {
|
|
683
761
|
const repo = getRepository(TwinEvent)
|
|
684
|
-
|
|
685
|
-
await repo.save(
|
|
686
|
-
repo.create({
|
|
687
|
-
domain: { id: domainId } as any,
|
|
688
|
-
instanceId,
|
|
689
|
-
tenantId: e?.tenantId,
|
|
690
|
-
eventType: e?.eventType,
|
|
691
|
-
revision: msg.revision,
|
|
692
|
-
eventTime: e?.eventTime,
|
|
693
|
-
payload: e
|
|
694
|
-
})
|
|
695
|
-
)
|
|
762
|
+
await repo.save(this.journalRow(repo, domainId, instanceId, msg.event, msg.revision))
|
|
696
763
|
}
|
|
697
764
|
|
|
698
765
|
/**
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 웜스타트 판정 — **순수**. "기동하는 커널에 직전 상태를 심을 것인가, 심는다면 무엇을" 만 정한다.
|
|
3
|
+
* 실제 주입(hydrateObserved 호출)과 로그는 엔진이 한다.
|
|
4
|
+
*
|
|
5
|
+
* ── 왜 떼어냈나 ─────────────────────────────────────────────────────────────
|
|
6
|
+
* 이 판정이 틀리면 증상이 정반대 두 방향으로 나온다: 심어야 할 때 안 심으면 **있는 재고가 0 으로**
|
|
7
|
+
* 보이고(2026-07-31 hatiolab-wms: 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다), 심지
|
|
8
|
+
* 말아야 할 벤치에 심으면 **용량 측정이 오염된다.** 둘 다 조용히 틀리는 종류라 규칙을 고정한다.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 커널에 심을 관측 상태 — 구조가 아니라 "무엇이 어디에 얼마나". */
|
|
12
|
+
export interface ObservedSeed {
|
|
13
|
+
nodes: unknown[]
|
|
14
|
+
items: unknown[]
|
|
15
|
+
movers: unknown[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type WarmStartPlan =
|
|
19
|
+
| { action: 'hydrate'; seed: ObservedSeed; itemCount: number; moverCount: number }
|
|
20
|
+
/** 벤치 트윈 — 새 시작에서 용량을 재는 게 목적이라 현재 상태를 심으면 측정이 오염된다. */
|
|
21
|
+
| { action: 'skip'; reason: 'bench' }
|
|
22
|
+
/** 심을 상태가 없다 — 처음 만든 트윈이거나 저널·체크포인트가 비었다. 정상이다. */
|
|
23
|
+
| { action: 'skip'; reason: 'no-state' }
|
|
24
|
+
/** 커널이 관측 주입을 지원하지 않는다 — 구조만으로 시작하므로 보유량은 0 으로 읽힌다(알려야 한다). */
|
|
25
|
+
| { action: 'skip'; reason: 'unsupported' }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 무엇을 할지 정한다.
|
|
29
|
+
*
|
|
30
|
+
* **오더는 의도적으로 심지 않는다.** 커널의 관측 주입은 오더에 requested/fulfilled/lines 를 요구하는데
|
|
31
|
+
* 스냅샷의 오더에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이므로
|
|
32
|
+
* 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작하는 편이 정직하다.
|
|
33
|
+
*/
|
|
34
|
+
export function planWarmStart(
|
|
35
|
+
state: { nodes?: unknown[]; items?: unknown[]; movers?: unknown[] } | null | undefined,
|
|
36
|
+
purpose: string | undefined,
|
|
37
|
+
canHydrate: boolean
|
|
38
|
+
): WarmStartPlan {
|
|
39
|
+
/* 벤치 판정이 먼저다 — 상태가 있든 없든 벤치에는 심지 않는다는 사실이 바뀌지 않는다. */
|
|
40
|
+
if (purpose === 'bench') return { action: 'skip', reason: 'bench' }
|
|
41
|
+
if (!state) return { action: 'skip', reason: 'no-state' }
|
|
42
|
+
|
|
43
|
+
const nodes = state.nodes ?? []
|
|
44
|
+
const items = state.items ?? []
|
|
45
|
+
const movers = state.movers ?? []
|
|
46
|
+
/* 셋 다 비었으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */
|
|
47
|
+
if (nodes.length === 0 && items.length === 0 && movers.length === 0) return { action: 'skip', reason: 'no-state' }
|
|
48
|
+
|
|
49
|
+
/* 지원 여부는 마지막에 본다 — 심을 게 있는데 못 심는 상황이라야 경고할 값어치가 있다. */
|
|
50
|
+
if (!canHydrate) return { action: 'skip', reason: 'unsupported' }
|
|
51
|
+
|
|
52
|
+
return { action: 'hydrate', seed: { nodes, items, movers }, itemCount: items.length, moverCount: movers.length }
|
|
53
|
+
}
|
package/server/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from './service/index.js'
|
|
|
4
4
|
import './routes.js'
|
|
5
5
|
|
|
6
6
|
import { TwinEngine } from './engine/index.js'
|
|
7
|
+
import { backfillTwinEventKeys } from './service/twin-event/backfill-keys.js'
|
|
7
8
|
|
|
8
9
|
/* 모듈 부팅 — 영속 인스턴스 복구 훅(향후). 지금은 명시 start(mutation/부팅 설정)로 인스턴스 생성. */
|
|
9
10
|
process.on('bootstrap-module-start' as any, async ({ app, config, client }: any) => {
|
|
@@ -13,4 +14,12 @@ process.on('bootstrap-module-start' as any, async ({ app, config, client }: any)
|
|
|
13
14
|
} catch (ex) {
|
|
14
15
|
console.error('Headless Twin host failed to start.', ex)
|
|
15
16
|
}
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* 승격 검색 키 백필 — 컬럼이 생기기 전에 쌓인 저널을 채운다. 멱등·재개 가능이라 매 기동 불러도
|
|
20
|
+
* 채울 게 없으면 조각 한 번 읽고 끝난다(로그도 남기지 않는다).
|
|
21
|
+
* 기동을 막지 않는다 — 저널이 크면 오래 걸릴 수 있고, 그동안 트윈은 정상 동작해야 한다.
|
|
22
|
+
* 실패해도 서비스는 계속된다: 못 채운 만큼 **과거 이력 검색이 덜 나올 뿐**이므로 조용히 삼키지 않고 알린다.
|
|
23
|
+
*/
|
|
24
|
+
backfillTwinEventKeys().catch(ex => console.error('twin-event key backfill failed — search over older journal rows will be incomplete.', ex))
|
|
16
25
|
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { IsNull } from 'typeorm'
|
|
2
|
+
|
|
3
|
+
import { getRepository } from '@things-factory/shell'
|
|
4
|
+
|
|
5
|
+
import { TwinEvent } from './twin-event.js'
|
|
6
|
+
import { twinEventKeys } from './twin-event-keys.js'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* 승격 검색 키 백필 — 컬럼이 생기기 **전에** 쌓인 저널 행을 채운다.
|
|
10
|
+
*
|
|
11
|
+
* 이게 없으면 검색은 "오늘부터의 이력" 만 찾는다. 사용자에게는 그냥 **과거가 없는 것으로 보이고**,
|
|
12
|
+
* 그건 이번에 고치려던 결함(조용히 빠진 데이터)과 정확히 같은 종류다.
|
|
13
|
+
*
|
|
14
|
+
* 성질:
|
|
15
|
+
* · 멱등 — 이미 채워진 행은 건드리지 않는다(bizStep IS NULL 인 것만 집는다).
|
|
16
|
+
* · 재개 가능 — 중간에 죽어도 다음 기동에서 남은 것부터 이어간다.
|
|
17
|
+
* · 조각내서 — 한 번에 다 읽지 않는다. 저널은 크다는 전제로 만든다.
|
|
18
|
+
* · payload 는 손대지 않는다 — 정본은 그대로 두고 파생 색인만 채운다.
|
|
19
|
+
*
|
|
20
|
+
* 아주 큰 운영 저널이라면 기동 시 백그라운드보다 **마이그레이션으로 한 번** 도는 편이 낫다.
|
|
21
|
+
* 이 함수를 그대로 부르면 되므로 경로는 하나다.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const CHUNK = 1000
|
|
25
|
+
|
|
26
|
+
export interface BackfillResult {
|
|
27
|
+
/** 실제로 갱신한 행 수. */
|
|
28
|
+
updated: number
|
|
29
|
+
/** 훑었지만 payload 가 없어 채울 수 없던 행 수 — 0 이 아니면 인제스트 쪽을 봐야 한다. */
|
|
30
|
+
skipped: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 남은 행을 전부 채운다. 진행 상황을 로그로 남긴다 — 조용히 오래 도는 작업은
|
|
35
|
+
* 멈춘 것과 구분되지 않는다.
|
|
36
|
+
*/
|
|
37
|
+
export async function backfillTwinEventKeys(): Promise<BackfillResult> {
|
|
38
|
+
const repo = getRepository(TwinEvent)
|
|
39
|
+
let updated = 0
|
|
40
|
+
let skipped = 0
|
|
41
|
+
let round = 0
|
|
42
|
+
|
|
43
|
+
for (;;) {
|
|
44
|
+
/* bizStep 은 이벤트 타입에서라도 유추되므로 **정상 인제스트라면 반드시 채워진다** —
|
|
45
|
+
* 즉 NULL 은 "승격 이전 행" 의 확실한 표식이다. epc 로 판정하면 품목 없는 이벤트를
|
|
46
|
+
* 매번 다시 집어 무한히 돈다. */
|
|
47
|
+
const rows = await repo.find({ where: { bizStep: IsNull() }, take: CHUNK, order: { revision: 'ASC' } })
|
|
48
|
+
if (rows.length === 0) break
|
|
49
|
+
|
|
50
|
+
const dirty: TwinEvent[] = []
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
if (!row.payload) {
|
|
53
|
+
skipped++
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
Object.assign(row, twinEventKeys(row.payload))
|
|
57
|
+
dirty.push(row)
|
|
58
|
+
}
|
|
59
|
+
if (dirty.length) await repo.save(dirty, { chunk: 500 })
|
|
60
|
+
updated += dirty.length
|
|
61
|
+
|
|
62
|
+
/* payload 가 없는 행만 남으면 같은 조각을 영원히 다시 읽는다 — 진도가 없으면 멈춘다. */
|
|
63
|
+
if (dirty.length === 0) break
|
|
64
|
+
|
|
65
|
+
if (++round % 10 === 0) console.log(`[twin-event backfill] ${updated} rows filled…`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (updated || skipped) {
|
|
69
|
+
console.log(`[twin-event backfill] done — ${updated} filled, ${skipped} skipped (no payload)`)
|
|
70
|
+
}
|
|
71
|
+
return { updated, skipped }
|
|
72
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 저널 검색 키 추출 — **순수**. 인제스트가 기록할 때 한 번 뽑아 인덱스 가능한 실컬럼으로 승격한다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 승격하는가 ───────────────────────────────────────────────────────────
|
|
5
|
+
* 사용자가 저널에서 실제로 찾는 것은 "이 팔레트의 이력", "이 오더가 어디까지 갔나", "이 도크에서
|
|
6
|
+
* 무슨 일이 있었나" 다. 그런데 그 값들은 전부 `payload`(simple-json = TEXT) **안**에 있었다.
|
|
7
|
+
* things-factory 는 5개 DB 드라이버를 지원해야 해서 DB별 JSON 연산자를 쓸 수 없다 —
|
|
8
|
+
* 즉 승격 없이는 **어떤 방법으로도 서버에서 그 조건으로 거를 수 없었다**. 클라이언트가 받아온
|
|
9
|
+
* 몇 천 건 안에서만 찾는 시늉이 최선이었고, 저널이 커질수록 그 시늉은 거짓말에 가까워진다.
|
|
10
|
+
*
|
|
11
|
+
* 그래서 검색 축이 되는 값만 골라 컬럼으로 꺼낸다. payload 는 그대로 둔다(정본은 여전히 payload —
|
|
12
|
+
* 이건 파생 색인이지 새로운 진실이 아니다).
|
|
13
|
+
*
|
|
14
|
+
* ── 왜 여기(순수 모듈)인가 ──────────────────────────────────────────────────
|
|
15
|
+
* 기록 경로가 둘이다(`persistBatch` 라이브 벌크 · `persist` 심 단건). 두 곳에 각자 적으면
|
|
16
|
+
* 반드시 어긋나고, 어긋난 색인은 "없는 것처럼 보이는 이벤트" 를 만든다 — 저널에서 가장 나쁜 결함이다.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 승격된 검색 키 — 전부 선택적. 뽑히지 않으면 **빈 문자열이 아니라 undefined**(결측≠빈값). */
|
|
20
|
+
export interface TwinEventKeys {
|
|
21
|
+
bizStep?: string
|
|
22
|
+
epc?: string
|
|
23
|
+
orderId?: string
|
|
24
|
+
locationId?: string
|
|
25
|
+
moverId?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* 컬럼 길이 상한. GS1 식별자(EPC URN·GDTI·SGLN)는 규격상 이보다 훨씬 짧다.
|
|
30
|
+
* 넘치는 값이 오면 **조용히 자르지 않고** 경고를 남긴다 — 색인이 원본과 다르면 검색 결과가 거짓이 되는데,
|
|
31
|
+
* 그 사실이 어디에도 안 남으면 아무도 모른다.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_KEY = 255
|
|
34
|
+
|
|
35
|
+
function clip(v: unknown, field: string): string | undefined {
|
|
36
|
+
if (v === undefined || v === null) return undefined
|
|
37
|
+
const s = String(v)
|
|
38
|
+
if (!s) return undefined
|
|
39
|
+
if (s.length <= MAX_KEY) return s
|
|
40
|
+
console.warn(
|
|
41
|
+
`[twin-event-keys] ${field} exceeds ${MAX_KEY} chars and was clipped for indexing — ` +
|
|
42
|
+
`search on this value may be incomplete. payload keeps the full value. (${s.slice(0, 60)}…)`
|
|
43
|
+
)
|
|
44
|
+
return s.slice(0, MAX_KEY)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** CBV bizStep URN 의 끝마디. 없으면 이벤트 타입에서 유추(`epcis.` 접두 제거). */
|
|
48
|
+
export function bizStepOf(envelope: any): string | undefined {
|
|
49
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
50
|
+
const tail = String(d.bizStep ?? '').split(':').pop()
|
|
51
|
+
return tail || String(envelope?.eventType ?? '').replace('epcis.', '') || undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 품목 식별자 — **전체 값**을 저장한다(끝마디만 저장하지 않는다).
|
|
56
|
+
*
|
|
57
|
+
* 표시용 축약은 화면이 하고, 색인은 원본을 갖는다. `search` 는 부분일치(contains)라
|
|
58
|
+
* 전체를 저장해 두면 끝마디("402.2")로도 URN 전체로도 찾힌다. 반대로 끝마디만 저장하면
|
|
59
|
+
* URN 으로 찾는 경로가 사라진다.
|
|
60
|
+
*/
|
|
61
|
+
export function epcOf(envelope: any): string | undefined {
|
|
62
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
63
|
+
return d.epcList?.[0] ?? d.parentID ?? d.quantityList?.[0]?.epcClass ?? undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 거래 식별자(PO/SO) — EPCIS bizTransactionList 우선, 운영 델타는 `order`. */
|
|
67
|
+
export function orderOf(envelope: any): string | undefined {
|
|
68
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
69
|
+
return d.bizTransactionList?.[0]?.bizTransaction ?? d.order ?? undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 위치 — EPCIS 는 읽은 지점(readPoint) 우선, 없으면 업무 위치(bizLocation).
|
|
74
|
+
* 운영 델타(무버 이동 등)는 그 둘이 없고 평범한 `location` 을 쓴다 — 빠뜨리면 설비가 어디서
|
|
75
|
+
* 무엇을 했는지가 위치 축에서 통째로 사라진다.
|
|
76
|
+
*/
|
|
77
|
+
export function locationOf(envelope: any): string | undefined {
|
|
78
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
79
|
+
return d.readPoint?.id ?? d.bizLocation?.id ?? d.location ?? undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 설비·무버 — 운영 델타(equipment.status·task.status)가 대상을 가리키는 축.
|
|
84
|
+
*
|
|
85
|
+
* EPCIS 어휘가 아니라서 다른 축 어디에도 안 잡힌다. 이게 없으면 "이 지게차가 오늘 무엇을 했나" 를
|
|
86
|
+
* 서버에서 물을 방법이 없어, 화면이 저널을 통째로 받아 훑는 수밖에 없다.
|
|
87
|
+
*/
|
|
88
|
+
export function moverOf(envelope: any): string | undefined {
|
|
89
|
+
const d = envelope?.data ?? envelope ?? {}
|
|
90
|
+
return d.moverId ?? undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 한 이벤트에서 승격 키 전부 — 기록 경로가 이 함수 하나만 부른다. */
|
|
94
|
+
export function twinEventKeys(envelope: any): TwinEventKeys {
|
|
95
|
+
return {
|
|
96
|
+
bizStep: clip(bizStepOf(envelope), 'bizStep'),
|
|
97
|
+
epc: clip(epcOf(envelope), 'epc'),
|
|
98
|
+
orderId: clip(orderOf(envelope), 'orderId'),
|
|
99
|
+
locationId: clip(locationOf(envelope), 'locationId'),
|
|
100
|
+
moverId: clip(moverOf(envelope), 'moverId')
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Field, Int, ObjectType } from 'type-graphql'
|
|
2
|
+
|
|
3
|
+
import { TwinEvent } from './twin-event.js'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* 저널 목록 반환형 — things-factory 표준 `{ items, total }`(AttributeSetList·DomainList 등과 동형).
|
|
7
|
+
*
|
|
8
|
+
* `total` 이 이 타입의 존재 이유다. 기존 `twinEvents` 는 배열만 돌려줘서 **화면이 자기가 전체를 받은
|
|
9
|
+
* 건지 잘린 건지 알 방법이 없었다** — 그래서 리스트가 조용히 잘린 채로 "이게 전부" 처럼 보였다.
|
|
10
|
+
* 총건수를 함께 주면 화면은 "48 / 12,904" 라고 정직하게 말할 수 있고, 사용자는 좁혀야 한다는 걸 안다.
|
|
11
|
+
*/
|
|
12
|
+
@ObjectType({ description: 'A page of twin journal events together with the total number of matching records.' })
|
|
13
|
+
export class TwinEventList {
|
|
14
|
+
@Field(type => [TwinEvent], { description: 'The events on this page, ordered by the requested sorting (revision descending by default).' })
|
|
15
|
+
items: TwinEvent[]
|
|
16
|
+
|
|
17
|
+
@Field(type => Int, { description: 'Total number of events matching the filters, ignoring pagination. Lets the caller show an honest "shown of total" count instead of silently truncating.' })
|
|
18
|
+
total: number
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
* 다음 페이지 커서. 저널은 **머리에 계속 쌓이는 목록**이라 offset 으로 뒤를 읽으면
|
|
22
|
+
* 읽는 사이 들어온 이벤트만큼 밀려 본 행이 또 나오거나 못 본 행이 사라진다.
|
|
23
|
+
* 이 값을 그대로 다음 요청의 `pagination.after` 로 돌려주면 그 문제가 없다.
|
|
24
|
+
*/
|
|
25
|
+
@Field({ nullable: true, description: 'Opaque cursor for the next page. Pass it back as pagination.after to continue exactly where this page ended, without the duplicate or skipped rows that offset paging produces on a journal that keeps growing at the head. Null when this page is the last one.' })
|
|
26
|
+
nextCursor?: string
|
|
27
|
+
}
|