@things-factory/ingest 10.1.4
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-client/bootstrap.d.ts +2 -0
- package/dist-client/bootstrap.js +4 -0
- package/dist-client/bootstrap.js.map +1 -0
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +2 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/ingest-flow-badge.d.ts +30 -0
- package/dist-client/ingest-flow-badge.js +110 -0
- package/dist-client/ingest-flow-badge.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +12 -0
- package/dist-client/route.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-server/index.d.ts +3 -0
- package/dist-server/index.js +11 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/ingest-dedupe.d.ts +50 -0
- package/dist-server/ingest-dedupe.js +331 -0
- package/dist-server/ingest-dedupe.js.map +1 -0
- package/dist-server/ingest-health.d.ts +550 -0
- package/dist-server/ingest-health.js +671 -0
- package/dist-server/ingest-health.js.map +1 -0
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +6 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/ingest-window/ingest-window-record.d.ts +42 -0
- package/dist-server/service/ingest-window/ingest-window-record.js +120 -0
- package/dist-server/service/ingest-window/ingest-window-record.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +34 -0
- package/things-factory.config.js +8 -0
- package/translations/en.json +9 -0
- package/translations/ja.json +9 -0
- package/translations/ko.json +9 -0
- package/translations/ms.json +9 -0
- package/translations/zh.json +9 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* 같은 사실을 두 번 받으면 한 번만 반영한다 — **유입 경계 한 곳에서.**
|
|
4
|
+
*
|
|
5
|
+
* ── 왜 필요한가 (2026-08-26) ────────────────────────────────────────────────
|
|
6
|
+
* 지금 중복을 막는 것은 커넥터마다 각자 하고 있다. 그래서 커넥터가 그것을 잊으면 트윈이 그대로 두 번
|
|
7
|
+
* 센다. 실제로 그랬다: 태양광 커넥터가 10초마다 물으면서 10분마다 바뀌는 값을 매번 다시 보냈고,
|
|
8
|
+
* 한 주기에 76건이 되풀이됐다(커넥터에서 고쳐서 2건이 됐다).
|
|
9
|
+
*
|
|
10
|
+
* 그리고 이것은 웹훅으로 받을 때 더 자주 난다. 밀어 주는 방식은 재전송이 정상이다 — 그쪽이 응답을
|
|
11
|
+
* 못 받으면 같은 것을 다시 보낸다.
|
|
12
|
+
*
|
|
13
|
+
* ── 무엇이 두 번 세어지나 ──────────────────────────────────────────────────
|
|
14
|
+
* 절대값으로 말하는 사실은 여러 번 반영해도 결과가 같다(잔량 · 상태 · 적산 · 순간 kW). 그런데 세는
|
|
15
|
+
* 값은 그렇지 않다.
|
|
16
|
+
*
|
|
17
|
+
* 구간의 표본 수 · 평균 부하(표본 수로 나눈다) · OEE 카운터 · 유입 계수
|
|
18
|
+
* 그리고 지난 기록에 같은 사실이 두 줄로 남는다 — 나중에 그 기록으로 다시 계산하면 또 두 번 센다
|
|
19
|
+
*
|
|
20
|
+
* ── 정체를 무엇으로 보나 ────────────────────────────────────────────────────
|
|
21
|
+
* 봉투의 `eventId` 로는 판단할 수 없다. 보낸 쪽이 매번 새로 만드는 순번이고(`…-energy-1` 처럼 호출마다
|
|
22
|
+
* 1부터 다시 시작한다), 같은 사실을 두 번 보내면 다른 번호가 붙는다. 다른 사실에 같은 번호가 붙는 일도
|
|
23
|
+
* 생긴다.
|
|
24
|
+
*
|
|
25
|
+
* 그래서 **사실 자체로** 판단한다.
|
|
26
|
+
*
|
|
27
|
+
* 무엇에 대한 것인가 설비 · 계량 지점 · 물품 · 작업 · 오더 · 자리
|
|
28
|
+
* 어떤 종류인가 사건 종류(eventType)
|
|
29
|
+
* 언제 잰 것인가 연결된 시스템이 준 시각
|
|
30
|
+
*
|
|
31
|
+
* 이 셋이 같으면 같은 사실이다. 태양광 커넥터가 `collectedAt` 으로 판단한 것과 같은 방식이고, 그것을
|
|
32
|
+
* 커넥터마다 하지 않도록 여기로 올린다.
|
|
33
|
+
*
|
|
34
|
+
* ── 정확히 같은 것만 버린다 ─────────────────────────────────────────────────
|
|
35
|
+
* 늦게 도착한 옛 사실은 버리지 않는다. 그것이 왔다는 것 자체가 사실이고, 상태에 반영되지 않는 것은
|
|
36
|
+
* 커널의 순서 판단이 이미 맡는다. 여기서 버리는 것은 **정체가 완전히 같은 것**뿐이다.
|
|
37
|
+
*/
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.FactDeduper = void 0;
|
|
40
|
+
exports.takeUnidentified = takeUnidentified;
|
|
41
|
+
exports.factIdentity = factIdentity;
|
|
42
|
+
/** 시각을 밀리초로 — 연결된 시스템이 준 시각이 먼저, 없으면 봉투의 시각. */
|
|
43
|
+
function timeOf(envelope) {
|
|
44
|
+
for (const v of [envelope?.data?.at, envelope?.data?.eventTime, envelope?.eventTime]) {
|
|
45
|
+
const t = Date.parse(String(v ?? ''));
|
|
46
|
+
if (Number.isFinite(t))
|
|
47
|
+
return t;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 이 봉투가 말하는 것을 **가르는 값들** — 하나라도 다르면 다른 사실이다.
|
|
53
|
+
*
|
|
54
|
+
* ── 예전에는 하나만 골랐다 (2026-09-05 실측) ───────────────────────────────
|
|
55
|
+
* 아는 이름을 순서대로 보고 **먼저 걸리는 하나**를 대상으로 삼았다. 「주체를 먼저 본다」는 판단
|
|
56
|
+
* 자체는 옳다 — 설비 사실은 「지금 붙어 있는 작업」을 참조로 싣는다. 그런데 **고르고 나머지를
|
|
57
|
+
* 버린 것**이 결함이었다.
|
|
58
|
+
*
|
|
59
|
+
* 실제로 난 일: plant 이 실적 둘을 보냈는데 한 건이 저널에 안 남았다.
|
|
60
|
+
*
|
|
61
|
+
* seq 36 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-01 · goodCount 12
|
|
62
|
+
* seq 41 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-02 · goodCount 9
|
|
63
|
+
*
|
|
64
|
+
* 두 실적의 시각이 같았고(원본이 끝시각을 복사) `moverId` 가 `taskId` 보다 먼저라 **둘이 한 사실이
|
|
65
|
+
* 됐다.** 거부가 아니라 되풀이로 세어졌다 — `offered 5 · rejected 0 · journaled 3`.
|
|
66
|
+
*
|
|
67
|
+
* **구별할 값이 이미 봉투 안에 있었는데 판정이 그것을 안 봤다.**
|
|
68
|
+
*
|
|
69
|
+
* ── 그리고 EPCIS 도 같았다 ─────────────────────────────────────────────────
|
|
70
|
+
* 저널 246만 건에 현재 규칙을 다시 걸어 보니 겹치는 묶음이 여섯이었고, 전부 이 모양이다.
|
|
71
|
+
*
|
|
72
|
+
* 같은 EPC · 같은 밀리초
|
|
73
|
+
* ① bizStep receiving action ADD disposition sellable_accessible
|
|
74
|
+
* ② bizStep commissioning action OBSERVE disposition reserved
|
|
75
|
+
*
|
|
76
|
+
* **한 개체의 서로 다른 관측**이다. EPCIS 에서 이상한 일이 아닌데 규칙이 그 셋을 안 봤다.
|
|
77
|
+
*
|
|
78
|
+
* ── 왜 넓히는 쪽이 맞나 ────────────────────────────────────────────────────
|
|
79
|
+
* 이 파일이 스스로 적어 둔 값이 그것이다 — 「알리지 않고 잃는 것보다 두 번 세는 것이 낫다」.
|
|
80
|
+
* 지금 거동은 **알리지 않고 잃는 쪽**이었다.
|
|
81
|
+
*
|
|
82
|
+
* 넓혀도 되풀이는 그대로 걸린다: 같은 봉투를 다시 보내면 **이 값들이 전부 같다.** 넓히는 것은
|
|
83
|
+
* 「다른 사실을 합치지 않는다」만 강해지고 「되풀이를 잡는다」는 안 약해진다.
|
|
84
|
+
*/
|
|
85
|
+
function discriminatorsOf(envelope) {
|
|
86
|
+
const d = envelope?.data ?? {};
|
|
87
|
+
const out = [];
|
|
88
|
+
const put = (name, v) => {
|
|
89
|
+
const t = String(v ?? '').trim();
|
|
90
|
+
if (t)
|
|
91
|
+
out.push(`${name}=${t}`);
|
|
92
|
+
};
|
|
93
|
+
/*
|
|
94
|
+
* 주체·참조를 **다 적는다.** 순서는 값이 아니라 이름으로 고정되므로 봉투마다 흔들리지 않는다.
|
|
95
|
+
*
|
|
96
|
+
* ⚠ **이 목록은 계약의 `identity` 선언을 손으로 옮긴 것이다.** 계약이 kind 마다 무엇이 그 사실을
|
|
97
|
+
* 가리키는지 이미 선언하는데(`operational-ingest.ts` 의 `SPECS[kind].identity`), 그것을 내보내지
|
|
98
|
+
* 않아서 여기가 따로 들고 있다.
|
|
99
|
+
*
|
|
100
|
+
* 그래서 새 kind 가 생기면 여기가 조용히 뒤처진다. 실제로 두 번 그랬다 — `subjectId`(처분)와
|
|
101
|
+
* `testableObjectId`(시험)가 빠져 있었고, 그동안 그 두 종류는 정체를 못 만들거나 서로 다른 사실이
|
|
102
|
+
* 뭉칠 수 있었다.
|
|
103
|
+
*
|
|
104
|
+
* 계약이 `operationalIdentityField(kind)` 를 내보내면 이 목록을 지운다. 그 문을 열어 두었고
|
|
105
|
+
* 배포를 기다린다.
|
|
106
|
+
*/
|
|
107
|
+
put('meter', d.meterId);
|
|
108
|
+
put('equipment', d.equipmentId);
|
|
109
|
+
put('mover', d.moverId); // vocabulary-guard: allow — 봉투의 옛 세대 필드 이름(저장된 계약)
|
|
110
|
+
put('task', d.taskId);
|
|
111
|
+
put('order', d.orderId);
|
|
112
|
+
put('person', d.personId);
|
|
113
|
+
put('asset', d.assetId);
|
|
114
|
+
put('location', d.locationId); // vocabulary-guard: allow — 같음
|
|
115
|
+
put('epc', d.epcList?.[0]);
|
|
116
|
+
put('parent', d.parentID);
|
|
117
|
+
put('class', d.quantityList?.[0]?.epcClass);
|
|
118
|
+
put('transformation', d.transformationID);
|
|
119
|
+
/*
|
|
120
|
+
* ── 주체가 `subjectId` 인 사실이 있다 (2026-09-06 측정) ──────────────────────
|
|
121
|
+
* 부적합 처분과 시험 결과는 **표준 `TestableObjectID` 와 같은 자리**를 쓴다(로트 · 설비 · 자리에
|
|
122
|
+
* 두루 쓰는 하나의 이름). 그 이름이 여기 없어서 두 종류가 **정체를 못 만들었다** —
|
|
123
|
+
* `parts` 가 비면 `factIdentity` 가 `undefined` 를 내고, 그러면 dedupe 밖이다.
|
|
124
|
+
*
|
|
125
|
+
* 실제로 그랬다. `fillTwinScope` 를 두 번 돌릴 때마다 그 둘이 7건씩 다시 앉았다(처분 4 · 시험 3).
|
|
126
|
+
* 나머지 34건은 걸러졌는데 이 둘만 계속 늘었다.
|
|
127
|
+
*/
|
|
128
|
+
put('subject', d.subjectId);
|
|
129
|
+
put('spec', d.specId);
|
|
130
|
+
/*
|
|
131
|
+
* 시험 결과의 주체는 `testableObjectId` 다 — 표준 `TestableObjectID` 의 이름을 그대로 쓴다.
|
|
132
|
+
*
|
|
133
|
+
* 이 이름이 없어서 **같은 명세로 같은 시각에 잰 서로 다른 로트가 한 사실로 뭉쳤다.** 중복이 생기는
|
|
134
|
+
* 것보다 나쁘다 — 두 로트의 시험 결과 중 하나가 사라진다.
|
|
135
|
+
*
|
|
136
|
+
* 실제로 `mes-miratek` 에 같은 시험 결과가 10건 있었다(2026-09-06 확인). 그것은 뭉친 것이 아니라
|
|
137
|
+
* 반대로 dedupe 를 못 지난 것인데, 같은 자리에서 뭉치는 쪽도 열려 있었다.
|
|
138
|
+
*/
|
|
139
|
+
put('object', d.testableObjectId);
|
|
140
|
+
/* 완료 선언의 주체 — 「이 축은 여기까지가 전부다」를 말하는 축 이름. */
|
|
141
|
+
put('axis', d.completeAxis);
|
|
142
|
+
/*
|
|
143
|
+
* EPCIS 는 「무엇을 말하는 관측인가」가 이 셋으로 갈린다. 한 개체가 한 순간에 두 관측을 갖는
|
|
144
|
+
* 것은 표준에서 정상이고, 이것을 안 보면 뒤엣것이 사라진다.
|
|
145
|
+
*/
|
|
146
|
+
put('bizStep', d.bizStep);
|
|
147
|
+
put('action', d.action);
|
|
148
|
+
put('disposition', d.disposition);
|
|
149
|
+
/*
|
|
150
|
+
* ── 변환은 **무엇이 무엇으로 되었나**로 갈린다 (2026-09-07 측정) ─────────────
|
|
151
|
+
* 이 자리가 없어서 **사실 하나가 없어졌습니다.** 거절이 아니라 되풀이로 세어져서 경고도 없었습니다.
|
|
152
|
+
*
|
|
153
|
+
* ```
|
|
154
|
+
* plant outbox seq 37 in LOT-CT-SIDE-001 24개 → out LOT-CT-MAIN-003 12개
|
|
155
|
+
* plant outbox seq 42 in LOT-CT-SIDE-001 18개 → out LOT-CT-MAIN-004 9개
|
|
156
|
+
* transformationID · bizStep · 초까지 전부 같다
|
|
157
|
+
* 저널에 남은 것 LOT-CT-MAIN-003 하나
|
|
158
|
+
* ```
|
|
159
|
+
*
|
|
160
|
+
* `LOT-CT-MAIN-004` 를 어느 패널로 만들었는지 트윈이 답하지 못하는 상태였습니다.
|
|
161
|
+
*
|
|
162
|
+
* 그리고 **구별할 값이 봉투 안에 이미 있었습니다** — `outputQuantityList[0].epcClass` 가 서로
|
|
163
|
+
* 다릅니다. 이 파일 머리말이 적어 둔 그 모양 그대로입니다: 판정이 그것을 안 봤습니다.
|
|
164
|
+
*
|
|
165
|
+
* 산출만 보지 않고 **입력도 봅니다.** 같은 로트를 같은 초에 두 번 소비하면서 산출 이름이 같은
|
|
166
|
+
* 경우(수량만 다른 분할)가 남고, 그때 입력 수량이 유일한 차이입니다. 수량까지 넣는 이유가 그것입니다.
|
|
167
|
+
*/
|
|
168
|
+
put('inClass', classOf(d.inputQuantityList));
|
|
169
|
+
put('inEpc', d.inputEPCList?.[0]);
|
|
170
|
+
put('inQty', qtyOf(d.inputQuantityList));
|
|
171
|
+
put('outClass', classOf(d.outputQuantityList));
|
|
172
|
+
put('outEpc', d.outputEPCList?.[0]);
|
|
173
|
+
put('outQty', qtyOf(d.outputQuantityList));
|
|
174
|
+
/*
|
|
175
|
+
* 담김의 자식도 같은 이유로 본다 — 한 팔레트에 서로 다른 묶음을 같은 초에 두 번 실으면 뒤엣것이
|
|
176
|
+
* 사라진다. 위 변환에서 실제로 그렇게 잃었으니 여기도 함께 막는다(한 종류만 고치면 나머지에 남는다).
|
|
177
|
+
*/
|
|
178
|
+
put('childEpc', d.childEPCs?.[0]);
|
|
179
|
+
put('childClass', classOf(d.childQuantityList));
|
|
180
|
+
put('childQty', qtyOf(d.childQuantityList));
|
|
181
|
+
/*
|
|
182
|
+
* 어느 오더의 사실인가도 정체의 일부다. 같은 공정을 같은 초에 두 오더로 돌리는 것은 정상이고,
|
|
183
|
+
* 이것을 안 보면 뒤엣것이 사라진다.
|
|
184
|
+
*/
|
|
185
|
+
put('tx', d.bizTransactionList?.[0]?.bizTransaction);
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/** 수량 목록의 첫 종류 — 목록이 비면 `undefined`(빈 문자열을 넣지 않는다). */
|
|
189
|
+
function classOf(list) {
|
|
190
|
+
const first = Array.isArray(list) ? list[0] : undefined;
|
|
191
|
+
const v = first?.epcClass;
|
|
192
|
+
return typeof v === 'string' && v.trim() ? v : undefined;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* 수량 목록을 수까지 적는다 — 종류가 같고 수량만 다른 사실을 가른다.
|
|
196
|
+
*
|
|
197
|
+
* 목록 전체를 적는다. 첫 원소만 보면 「같은 것 둘 + 다른 것 하나」가 첫 원소로는 구별되지 않는다.
|
|
198
|
+
*/
|
|
199
|
+
function qtyOf(list) {
|
|
200
|
+
if (!Array.isArray(list) || !list.length)
|
|
201
|
+
return undefined;
|
|
202
|
+
const parts = list.map((x) => `${x?.epcClass ?? ''}:${x?.quantity ?? ''}${x?.uom ? '/' + x.uom : ''}`);
|
|
203
|
+
return parts.join('|');
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 이 봉투가 말하는 사실의 정체 — 같으면 같은 사실이다.
|
|
207
|
+
*
|
|
208
|
+
* 대상이나 시각을 알 수 없으면 **정체를 만들지 않는다**(`undefined`). 그때는 거르지 않고 통과시킨다 —
|
|
209
|
+
* 모르는 것을 같다고 판단하면 서로 다른 사실이 하나로 합쳐진다. 알리지 않고 잃는 것보다 두 번 세는 것이 낫다.
|
|
210
|
+
*/
|
|
211
|
+
/**
|
|
212
|
+
* 정체를 못 만든 사실을 **종류별로** 센다 — dedupe 밖으로 나간 것들.
|
|
213
|
+
*
|
|
214
|
+
* 이 수가 0이 아니면 그 종류는 다시 받을 때마다 저널에 다시 앉는다. 그 종류의 주체 이름을
|
|
215
|
+
* `discriminatorsOf` 에 더해야 한다.
|
|
216
|
+
*/
|
|
217
|
+
const unidentified = {};
|
|
218
|
+
/** 지금까지 정체를 못 만든 것 — 종류별. 읽고 비운다. */
|
|
219
|
+
function takeUnidentified() {
|
|
220
|
+
const out = { ...unidentified };
|
|
221
|
+
for (const k of Object.keys(unidentified))
|
|
222
|
+
delete unidentified[k];
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
function factIdentity(envelope) {
|
|
226
|
+
const type = String(envelope?.eventType ?? '').trim();
|
|
227
|
+
if (!type)
|
|
228
|
+
return undefined;
|
|
229
|
+
const parts = discriminatorsOf(envelope);
|
|
230
|
+
/*
|
|
231
|
+
* **정체를 못 만들면 dedupe 밖이다** — 그 사실은 다시 받을 때마다 새로 앉는다.
|
|
232
|
+
*
|
|
233
|
+
* 오늘 그것으로 저널이 부풀었다(처분·시험이 `subjectId` 를 주체로 쓰는데 그 이름이 위 목록에
|
|
234
|
+
* 없었다). 조용히 `undefined` 를 내면 다음 종류에서 또 나고 그때도 아무 흔적이 없다.
|
|
235
|
+
*
|
|
236
|
+
* 그래서 무엇이 정체를 못 만들었는지 센다(`unidentified`). 세는 것이지 막는 것이 아니다 —
|
|
237
|
+
* 정체를 못 만든다고 사실을 버리면 그것이 더 나쁘다.
|
|
238
|
+
*/
|
|
239
|
+
if (!parts.length) {
|
|
240
|
+
unidentified[type] = (unidentified[type] ?? 0) + 1;
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
const at = timeOf(envelope);
|
|
244
|
+
if (at === undefined)
|
|
245
|
+
return undefined;
|
|
246
|
+
return `${type}|${parts.join('&')}|${at}`;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 최근에 본 정체를 기억한다 — **크기에 상한을 둔다.**
|
|
250
|
+
*
|
|
251
|
+
* 전부 기억할 수는 없다(트윈 하나가 수천만 사건을 갖는다). 창을 넘으면 오래된 것부터 잊는다. 창 밖의
|
|
252
|
+
* 재전송은 다시 통과하는데, 그것은 절대값 사실이라 상태에 해가 없고 지난 기록에 한 줄 더 남는다.
|
|
253
|
+
* 창을 크게 잡으면 그만큼 메모리를 쓴다 — 그 값을 여기서 이름으로 못 박는다.
|
|
254
|
+
*
|
|
255
|
+
* **재기동을 건너지 못한다.** 프로세스가 새로 뜨면 기억이 비어 있으므로, 재기동 직후 첫 주기가 같은
|
|
256
|
+
* 값을 다시 보내면 걸러지지 않는다. 그것을 막으려면 이 기억이 저장돼야 하고, 그것은 다음 걸음이다.
|
|
257
|
+
* 지금은 그 사실을 여기 적어 둔다 — 적어 두지 않으면 「중복은 이제 없다」로 읽힌다.
|
|
258
|
+
*/
|
|
259
|
+
class FactDeduper {
|
|
260
|
+
constructor() {
|
|
261
|
+
this.seen = new Set();
|
|
262
|
+
this.order = [];
|
|
263
|
+
}
|
|
264
|
+
/** 기억하는 정체의 수 — 창. */
|
|
265
|
+
static { this.WINDOW = 20_000; }
|
|
266
|
+
/**
|
|
267
|
+
* 이미 아는 것으로 **미리 기억한다** — 저널에서 읽어 채울 때.
|
|
268
|
+
*
|
|
269
|
+
* ── 왜 필요한가 (2026-09-06 측정) ─────────────────────────────────────────
|
|
270
|
+
* 이 기억은 프로세스 안에만 있다. 재기동하면 비워지고, 그 뒤 backfill 을 돌리면 **이미 저널에
|
|
271
|
+
* 있는 사실도 「처음 본 것」**이 된다.
|
|
272
|
+
*
|
|
273
|
+
* 실제로 그랬다. `mes-line-a` 를 재기동 뒤 backfill 하니 39건이 전부 다시 앉아 저널이
|
|
274
|
+
* 44 → 83건이 되고 44가지가 중복이 됐다.
|
|
275
|
+
*
|
|
276
|
+
* push 경로에서는 안 보인다. 재전송이 몇 분 안에 오므로 창 안에서 걸린다. **backfill 이 그 전제를
|
|
277
|
+
* 깬다** — 몇 달 전 사실을 지금 받으니 재기동 한 번이면 그 구간이 통째로 다시 앉는다.
|
|
278
|
+
*
|
|
279
|
+
* 그래서 backfill 이 부르기 전에 그 구간의 정체를 저널에서 읽어 여기 넣는다.
|
|
280
|
+
*/
|
|
281
|
+
remember(identities) {
|
|
282
|
+
let added = 0;
|
|
283
|
+
for (const id of identities)
|
|
284
|
+
if (id && !this.seen.has(id)) {
|
|
285
|
+
this.known(id);
|
|
286
|
+
added++;
|
|
287
|
+
}
|
|
288
|
+
return added;
|
|
289
|
+
}
|
|
290
|
+
/** 이 사실을 본 적이 있나 — 없으면 기억하고 `false`. */
|
|
291
|
+
known(identity) {
|
|
292
|
+
if (this.seen.has(identity))
|
|
293
|
+
return true;
|
|
294
|
+
this.seen.add(identity);
|
|
295
|
+
this.order.push(identity);
|
|
296
|
+
if (this.order.length > FactDeduper.WINDOW) {
|
|
297
|
+
const gone = this.order.splice(0, this.order.length - FactDeduper.WINDOW);
|
|
298
|
+
for (const g of gone)
|
|
299
|
+
this.seen.delete(g);
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* 처음 보는 것만 돌려준다 — 그리고 몇 개를 버렸는지 함께 낸다.
|
|
305
|
+
*
|
|
306
|
+
* 버린 수를 함께 내는 이유: 알리지 않고 버리면 「보낸 수」와 「반영된 수」가 어긋나는데 아무도 그
|
|
307
|
+
* 이유를 모른다. 그 수가 크면 보내는 쪽이 되풀이 보내고 있다는 뜻이고, 그것은 고칠 일이다.
|
|
308
|
+
*/
|
|
309
|
+
filter(envelopes) {
|
|
310
|
+
const fresh = [];
|
|
311
|
+
let duplicates = 0;
|
|
312
|
+
for (const e of envelopes) {
|
|
313
|
+
const id = factIdentity(e);
|
|
314
|
+
if (id === undefined) {
|
|
315
|
+
fresh.push(e);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (this.known(id))
|
|
319
|
+
duplicates++;
|
|
320
|
+
else
|
|
321
|
+
fresh.push(e);
|
|
322
|
+
}
|
|
323
|
+
return { fresh, duplicates };
|
|
324
|
+
}
|
|
325
|
+
/** 기억하고 있는 정체의 수 — 계기가 읽는다. */
|
|
326
|
+
size() {
|
|
327
|
+
return this.seen.size;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
exports.FactDeduper = FactDeduper;
|
|
331
|
+
//# sourceMappingURL=ingest-dedupe.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ingest-dedupe.js","sourceRoot":"","sources":["../server/ingest-dedupe.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 같은 사실을 두 번 받으면 한 번만 반영한다 — **유입 경계 한 곳에서.**\n *\n * ── 왜 필요한가 (2026-08-26) ────────────────────────────────────────────────\n * 지금 중복을 막는 것은 커넥터마다 각자 하고 있다. 그래서 커넥터가 그것을 잊으면 트윈이 그대로 두 번\n * 센다. 실제로 그랬다: 태양광 커넥터가 10초마다 물으면서 10분마다 바뀌는 값을 매번 다시 보냈고,\n * 한 주기에 76건이 되풀이됐다(커넥터에서 고쳐서 2건이 됐다).\n *\n * 그리고 이것은 웹훅으로 받을 때 더 자주 난다. 밀어 주는 방식은 재전송이 정상이다 — 그쪽이 응답을\n * 못 받으면 같은 것을 다시 보낸다.\n *\n * ── 무엇이 두 번 세어지나 ──────────────────────────────────────────────────\n * 절대값으로 말하는 사실은 여러 번 반영해도 결과가 같다(잔량 · 상태 · 적산 · 순간 kW). 그런데 세는\n * 값은 그렇지 않다.\n *\n * 구간의 표본 수 · 평균 부하(표본 수로 나눈다) · OEE 카운터 · 유입 계수\n * 그리고 지난 기록에 같은 사실이 두 줄로 남는다 — 나중에 그 기록으로 다시 계산하면 또 두 번 센다\n *\n * ── 정체를 무엇으로 보나 ────────────────────────────────────────────────────\n * 봉투의 `eventId` 로는 판단할 수 없다. 보낸 쪽이 매번 새로 만드는 순번이고(`…-energy-1` 처럼 호출마다\n * 1부터 다시 시작한다), 같은 사실을 두 번 보내면 다른 번호가 붙는다. 다른 사실에 같은 번호가 붙는 일도\n * 생긴다.\n *\n * 그래서 **사실 자체로** 판단한다.\n *\n * 무엇에 대한 것인가 설비 · 계량 지점 · 물품 · 작업 · 오더 · 자리\n * 어떤 종류인가 사건 종류(eventType)\n * 언제 잰 것인가 연결된 시스템이 준 시각\n *\n * 이 셋이 같으면 같은 사실이다. 태양광 커넥터가 `collectedAt` 으로 판단한 것과 같은 방식이고, 그것을\n * 커넥터마다 하지 않도록 여기로 올린다.\n *\n * ── 정확히 같은 것만 버린다 ─────────────────────────────────────────────────\n * 늦게 도착한 옛 사실은 버리지 않는다. 그것이 왔다는 것 자체가 사실이고, 상태에 반영되지 않는 것은\n * 커널의 순서 판단이 이미 맡는다. 여기서 버리는 것은 **정체가 완전히 같은 것**뿐이다.\n */\n\n/** 시각을 밀리초로 — 연결된 시스템이 준 시각이 먼저, 없으면 봉투의 시각. */\nfunction timeOf(envelope: any): number | undefined {\n for (const v of [envelope?.data?.at, envelope?.data?.eventTime, envelope?.eventTime]) {\n const t = Date.parse(String(v ?? ''))\n if (Number.isFinite(t)) return t\n }\n return undefined\n}\n\n/**\n * 이 봉투가 말하는 것을 **가르는 값들** — 하나라도 다르면 다른 사실이다.\n *\n * ── 예전에는 하나만 골랐다 (2026-09-05 실측) ───────────────────────────────\n * 아는 이름을 순서대로 보고 **먼저 걸리는 하나**를 대상으로 삼았다. 「주체를 먼저 본다」는 판단\n * 자체는 옳다 — 설비 사실은 「지금 붙어 있는 작업」을 참조로 싣는다. 그런데 **고르고 나머지를\n * 버린 것**이 결함이었다.\n *\n * 실제로 난 일: plant 이 실적 둘을 보냈는데 한 건이 저널에 안 남았다.\n *\n * seq 36 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-01 · goodCount 12\n * seq 41 quality.output moverId EQ-CT-MWELD-01 · taskId PR-CT-WIRE-02 · goodCount 9\n *\n * 두 실적의 시각이 같았고(원본이 끝시각을 복사) `moverId` 가 `taskId` 보다 먼저라 **둘이 한 사실이\n * 됐다.** 거부가 아니라 되풀이로 세어졌다 — `offered 5 · rejected 0 · journaled 3`.\n *\n * **구별할 값이 이미 봉투 안에 있었는데 판정이 그것을 안 봤다.**\n *\n * ── 그리고 EPCIS 도 같았다 ─────────────────────────────────────────────────\n * 저널 246만 건에 현재 규칙을 다시 걸어 보니 겹치는 묶음이 여섯이었고, 전부 이 모양이다.\n *\n * 같은 EPC · 같은 밀리초\n * ① bizStep receiving action ADD disposition sellable_accessible\n * ② bizStep commissioning action OBSERVE disposition reserved\n *\n * **한 개체의 서로 다른 관측**이다. EPCIS 에서 이상한 일이 아닌데 규칙이 그 셋을 안 봤다.\n *\n * ── 왜 넓히는 쪽이 맞나 ────────────────────────────────────────────────────\n * 이 파일이 스스로 적어 둔 값이 그것이다 — 「알리지 않고 잃는 것보다 두 번 세는 것이 낫다」.\n * 지금 거동은 **알리지 않고 잃는 쪽**이었다.\n *\n * 넓혀도 되풀이는 그대로 걸린다: 같은 봉투를 다시 보내면 **이 값들이 전부 같다.** 넓히는 것은\n * 「다른 사실을 합치지 않는다」만 강해지고 「되풀이를 잡는다」는 안 약해진다.\n */\nfunction discriminatorsOf(envelope: any): string[] {\n const d = envelope?.data ?? {}\n const out: string[] = []\n const put = (name: string, v: unknown) => {\n const t = String(v ?? '').trim()\n if (t) out.push(`${name}=${t}`)\n }\n\n /*\n * 주체·참조를 **다 적는다.** 순서는 값이 아니라 이름으로 고정되므로 봉투마다 흔들리지 않는다.\n *\n * ⚠ **이 목록은 계약의 `identity` 선언을 손으로 옮긴 것이다.** 계약이 kind 마다 무엇이 그 사실을\n * 가리키는지 이미 선언하는데(`operational-ingest.ts` 의 `SPECS[kind].identity`), 그것을 내보내지\n * 않아서 여기가 따로 들고 있다.\n *\n * 그래서 새 kind 가 생기면 여기가 조용히 뒤처진다. 실제로 두 번 그랬다 — `subjectId`(처분)와\n * `testableObjectId`(시험)가 빠져 있었고, 그동안 그 두 종류는 정체를 못 만들거나 서로 다른 사실이\n * 뭉칠 수 있었다.\n *\n * 계약이 `operationalIdentityField(kind)` 를 내보내면 이 목록을 지운다. 그 문을 열어 두었고\n * 배포를 기다린다.\n */\n put('meter', d.meterId)\n put('equipment', d.equipmentId)\n put('mover', d.moverId) // vocabulary-guard: allow — 봉투의 옛 세대 필드 이름(저장된 계약)\n put('task', d.taskId)\n put('order', d.orderId)\n put('person', d.personId)\n put('asset', d.assetId)\n put('location', d.locationId) // vocabulary-guard: allow — 같음\n put('epc', d.epcList?.[0])\n put('parent', d.parentID)\n put('class', d.quantityList?.[0]?.epcClass)\n put('transformation', d.transformationID)\n /*\n * ── 주체가 `subjectId` 인 사실이 있다 (2026-09-06 측정) ──────────────────────\n * 부적합 처분과 시험 결과는 **표준 `TestableObjectID` 와 같은 자리**를 쓴다(로트 · 설비 · 자리에\n * 두루 쓰는 하나의 이름). 그 이름이 여기 없어서 두 종류가 **정체를 못 만들었다** —\n * `parts` 가 비면 `factIdentity` 가 `undefined` 를 내고, 그러면 dedupe 밖이다.\n *\n * 실제로 그랬다. `fillTwinScope` 를 두 번 돌릴 때마다 그 둘이 7건씩 다시 앉았다(처분 4 · 시험 3).\n * 나머지 34건은 걸러졌는데 이 둘만 계속 늘었다.\n */\n put('subject', d.subjectId)\n put('spec', d.specId)\n /*\n * 시험 결과의 주체는 `testableObjectId` 다 — 표준 `TestableObjectID` 의 이름을 그대로 쓴다.\n *\n * 이 이름이 없어서 **같은 명세로 같은 시각에 잰 서로 다른 로트가 한 사실로 뭉쳤다.** 중복이 생기는\n * 것보다 나쁘다 — 두 로트의 시험 결과 중 하나가 사라진다.\n *\n * 실제로 `mes-miratek` 에 같은 시험 결과가 10건 있었다(2026-09-06 확인). 그것은 뭉친 것이 아니라\n * 반대로 dedupe 를 못 지난 것인데, 같은 자리에서 뭉치는 쪽도 열려 있었다.\n */\n put('object', d.testableObjectId)\n /* 완료 선언의 주체 — 「이 축은 여기까지가 전부다」를 말하는 축 이름. */\n put('axis', d.completeAxis)\n\n /*\n * EPCIS 는 「무엇을 말하는 관측인가」가 이 셋으로 갈린다. 한 개체가 한 순간에 두 관측을 갖는\n * 것은 표준에서 정상이고, 이것을 안 보면 뒤엣것이 사라진다.\n */\n put('bizStep', d.bizStep)\n put('action', d.action)\n put('disposition', d.disposition)\n\n /*\n * ── 변환은 **무엇이 무엇으로 되었나**로 갈린다 (2026-09-07 측정) ─────────────\n * 이 자리가 없어서 **사실 하나가 없어졌습니다.** 거절이 아니라 되풀이로 세어져서 경고도 없었습니다.\n *\n * ```\n * plant outbox seq 37 in LOT-CT-SIDE-001 24개 → out LOT-CT-MAIN-003 12개\n * plant outbox seq 42 in LOT-CT-SIDE-001 18개 → out LOT-CT-MAIN-004 9개\n * transformationID · bizStep · 초까지 전부 같다\n * 저널에 남은 것 LOT-CT-MAIN-003 하나\n * ```\n *\n * `LOT-CT-MAIN-004` 를 어느 패널로 만들었는지 트윈이 답하지 못하는 상태였습니다.\n *\n * 그리고 **구별할 값이 봉투 안에 이미 있었습니다** — `outputQuantityList[0].epcClass` 가 서로\n * 다릅니다. 이 파일 머리말이 적어 둔 그 모양 그대로입니다: 판정이 그것을 안 봤습니다.\n *\n * 산출만 보지 않고 **입력도 봅니다.** 같은 로트를 같은 초에 두 번 소비하면서 산출 이름이 같은\n * 경우(수량만 다른 분할)가 남고, 그때 입력 수량이 유일한 차이입니다. 수량까지 넣는 이유가 그것입니다.\n */\n put('inClass', classOf(d.inputQuantityList))\n put('inEpc', d.inputEPCList?.[0])\n put('inQty', qtyOf(d.inputQuantityList))\n put('outClass', classOf(d.outputQuantityList))\n put('outEpc', d.outputEPCList?.[0])\n put('outQty', qtyOf(d.outputQuantityList))\n /*\n * 담김의 자식도 같은 이유로 본다 — 한 팔레트에 서로 다른 묶음을 같은 초에 두 번 실으면 뒤엣것이\n * 사라진다. 위 변환에서 실제로 그렇게 잃었으니 여기도 함께 막는다(한 종류만 고치면 나머지에 남는다).\n */\n put('childEpc', d.childEPCs?.[0])\n put('childClass', classOf(d.childQuantityList))\n put('childQty', qtyOf(d.childQuantityList))\n /*\n * 어느 오더의 사실인가도 정체의 일부다. 같은 공정을 같은 초에 두 오더로 돌리는 것은 정상이고,\n * 이것을 안 보면 뒤엣것이 사라진다.\n */\n put('tx', d.bizTransactionList?.[0]?.bizTransaction)\n\n return out\n}\n\n/** 수량 목록의 첫 종류 — 목록이 비면 `undefined`(빈 문자열을 넣지 않는다). */\nfunction classOf(list: unknown): string | undefined {\n const first = Array.isArray(list) ? (list[0] as any) : undefined\n const v = first?.epcClass\n return typeof v === 'string' && v.trim() ? v : undefined\n}\n\n/**\n * 수량 목록을 수까지 적는다 — 종류가 같고 수량만 다른 사실을 가른다.\n *\n * 목록 전체를 적는다. 첫 원소만 보면 「같은 것 둘 + 다른 것 하나」가 첫 원소로는 구별되지 않는다.\n */\nfunction qtyOf(list: unknown): string | undefined {\n if (!Array.isArray(list) || !list.length) return undefined\n const parts = list.map((x: any) => `${x?.epcClass ?? ''}:${x?.quantity ?? ''}${x?.uom ? '/' + x.uom : ''}`)\n return parts.join('|')\n}\n\n/**\n * 이 봉투가 말하는 사실의 정체 — 같으면 같은 사실이다.\n *\n * 대상이나 시각을 알 수 없으면 **정체를 만들지 않는다**(`undefined`). 그때는 거르지 않고 통과시킨다 —\n * 모르는 것을 같다고 판단하면 서로 다른 사실이 하나로 합쳐진다. 알리지 않고 잃는 것보다 두 번 세는 것이 낫다.\n */\n/**\n * 정체를 못 만든 사실을 **종류별로** 센다 — dedupe 밖으로 나간 것들.\n *\n * 이 수가 0이 아니면 그 종류는 다시 받을 때마다 저널에 다시 앉는다. 그 종류의 주체 이름을\n * `discriminatorsOf` 에 더해야 한다.\n */\nconst unidentified: Record<string, number> = {}\n\n/** 지금까지 정체를 못 만든 것 — 종류별. 읽고 비운다. */\nexport function takeUnidentified(): Record<string, number> {\n const out = { ...unidentified }\n for (const k of Object.keys(unidentified)) delete unidentified[k]\n return out\n}\n\nexport function factIdentity(envelope: any): string | undefined {\n const type = String(envelope?.eventType ?? '').trim()\n if (!type) return undefined\n const parts = discriminatorsOf(envelope)\n /*\n * **정체를 못 만들면 dedupe 밖이다** — 그 사실은 다시 받을 때마다 새로 앉는다.\n *\n * 오늘 그것으로 저널이 부풀었다(처분·시험이 `subjectId` 를 주체로 쓰는데 그 이름이 위 목록에\n * 없었다). 조용히 `undefined` 를 내면 다음 종류에서 또 나고 그때도 아무 흔적이 없다.\n *\n * 그래서 무엇이 정체를 못 만들었는지 센다(`unidentified`). 세는 것이지 막는 것이 아니다 —\n * 정체를 못 만든다고 사실을 버리면 그것이 더 나쁘다.\n */\n if (!parts.length) {\n unidentified[type] = (unidentified[type] ?? 0) + 1\n return undefined\n }\n const at = timeOf(envelope)\n if (at === undefined) return undefined\n return `${type}|${parts.join('&')}|${at}`\n}\n\n/**\n * 최근에 본 정체를 기억한다 — **크기에 상한을 둔다.**\n *\n * 전부 기억할 수는 없다(트윈 하나가 수천만 사건을 갖는다). 창을 넘으면 오래된 것부터 잊는다. 창 밖의\n * 재전송은 다시 통과하는데, 그것은 절대값 사실이라 상태에 해가 없고 지난 기록에 한 줄 더 남는다.\n * 창을 크게 잡으면 그만큼 메모리를 쓴다 — 그 값을 여기서 이름으로 못 박는다.\n *\n * **재기동을 건너지 못한다.** 프로세스가 새로 뜨면 기억이 비어 있으므로, 재기동 직후 첫 주기가 같은\n * 값을 다시 보내면 걸러지지 않는다. 그것을 막으려면 이 기억이 저장돼야 하고, 그것은 다음 걸음이다.\n * 지금은 그 사실을 여기 적어 둔다 — 적어 두지 않으면 「중복은 이제 없다」로 읽힌다.\n */\nexport class FactDeduper {\n /** 기억하는 정체의 수 — 창. */\n static WINDOW = 20_000\n\n private seen = new Set<string>()\n private order: string[] = []\n\n /**\n * 이미 아는 것으로 **미리 기억한다** — 저널에서 읽어 채울 때.\n *\n * ── 왜 필요한가 (2026-09-06 측정) ─────────────────────────────────────────\n * 이 기억은 프로세스 안에만 있다. 재기동하면 비워지고, 그 뒤 backfill 을 돌리면 **이미 저널에\n * 있는 사실도 「처음 본 것」**이 된다.\n *\n * 실제로 그랬다. `mes-line-a` 를 재기동 뒤 backfill 하니 39건이 전부 다시 앉아 저널이\n * 44 → 83건이 되고 44가지가 중복이 됐다.\n *\n * push 경로에서는 안 보인다. 재전송이 몇 분 안에 오므로 창 안에서 걸린다. **backfill 이 그 전제를\n * 깬다** — 몇 달 전 사실을 지금 받으니 재기동 한 번이면 그 구간이 통째로 다시 앉는다.\n *\n * 그래서 backfill 이 부르기 전에 그 구간의 정체를 저널에서 읽어 여기 넣는다.\n */\n remember(identities: readonly string[]): number {\n let added = 0\n for (const id of identities) if (id && !this.seen.has(id)) { this.known(id); added++ }\n return added\n }\n\n /** 이 사실을 본 적이 있나 — 없으면 기억하고 `false`. */\n private known(identity: string): boolean {\n if (this.seen.has(identity)) return true\n this.seen.add(identity)\n this.order.push(identity)\n if (this.order.length > FactDeduper.WINDOW) {\n const gone = this.order.splice(0, this.order.length - FactDeduper.WINDOW)\n for (const g of gone) this.seen.delete(g)\n }\n return false\n }\n\n /**\n * 처음 보는 것만 돌려준다 — 그리고 몇 개를 버렸는지 함께 낸다.\n *\n * 버린 수를 함께 내는 이유: 알리지 않고 버리면 「보낸 수」와 「반영된 수」가 어긋나는데 아무도 그\n * 이유를 모른다. 그 수가 크면 보내는 쪽이 되풀이 보내고 있다는 뜻이고, 그것은 고칠 일이다.\n */\n filter<T>(envelopes: readonly T[]): { fresh: T[]; duplicates: number } {\n const fresh: T[] = []\n let duplicates = 0\n for (const e of envelopes) {\n const id = factIdentity(e)\n if (id === undefined) {\n fresh.push(e)\n continue\n }\n if (this.known(id)) duplicates++\n else fresh.push(e)\n }\n return { fresh, duplicates }\n }\n\n /** 기억하고 있는 정체의 수 — 계기가 읽는다. */\n size(): number {\n return this.seen.size\n }\n}\n"]}
|