@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,671 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* 동기화 건강 — **이 트윈이 현장과 맞춰지고 있나.** 순수(import 없음).
|
|
4
|
+
*
|
|
5
|
+
* ── 한 질문 ─────────────────────────────────────────────────────────────────
|
|
6
|
+
* 화면에서 값이 변하지 않을 때 원인이 넷인데, 지금까지 **넷이 똑같이 보였다.**
|
|
7
|
+
* ① 피드가 붙지 않았다 ② 붙었는데 아무것도 오지 않는다
|
|
8
|
+
* ③ 오는데 전부 거부된다 ④ 정상인데 현장이 들어온 것이 없다
|
|
9
|
+
* ③은 특히 나쁘다 — 커넥터의 매핑이 한 글자 틀리면 트윈은 「빈 공장」이 되고, 그 사실이 서버 로그
|
|
10
|
+
* 한 줄에만 남았다. 현장에 나간 사람은 로그를 뒤져야 했다.
|
|
11
|
+
*
|
|
12
|
+
* ── 왜 저널로는 답할 수 없나 ────────────────────────────────────────────────
|
|
13
|
+
* **거부된 것은 저널에 없다** — 거부의 뜻이 그것이다. 저널을 계산하면 통과한 쪽만 나온다. 거부의 추이는
|
|
14
|
+
* 세는 자리를 따로 두어야 나온다. 이 장부가 그 자리다.
|
|
15
|
+
*
|
|
16
|
+
* ── 왜 건마다 저장하지 않나 ─────────────────────────────────────────────────
|
|
17
|
+
* 라이브 계측은 초당 여러 건이다. 거부마다 행을 쓰면 그 자체가 부하가 된다(`reference-live.ts` 가
|
|
18
|
+
* 이미 그렇게 적어 두었다). 그리고 이 패키지는 **`integration-base` 를 알지 않는다**(이식성 규약) —
|
|
19
|
+
* 시나리오 경로가 쓰는 dead-letter 를 여기서 부를 수 없다. 그래서 **집계한다.**
|
|
20
|
+
*
|
|
21
|
+
* ── 통과와 거부를 함께 센다 ─────────────────────────────────────────────────
|
|
22
|
+
* 거부만 그리면 「유입 없음」과 「정상」이 **둘 다 거부 0** 으로 같아 보인다. 제시 수가 있어야 갈린다.
|
|
23
|
+
*
|
|
24
|
+
* ── 지키는 규율 넷 ──────────────────────────────────────────────────────────
|
|
25
|
+
* ① **닫힌 창만 「최근」이다** — 채우는 중인 창은 과소 집계다(부하 계기판에서 겪었다).
|
|
26
|
+
* ② **0 과 「없음」을 가른다** — 안 받은 것은 `null`, 받았지만 거부 0 은 `0`.
|
|
27
|
+
* ③ **알리지 않고 자르지 않는다** — 표본·추이 상한에서 밀려난 수를 함께 낸다.
|
|
28
|
+
* ④ **통과율에 1 을 함부로 쓰지 않는다** — 1,000건 중 1건 거부는 0.999 다.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.HEALTHY_ACCEPTED_RATIO = exports.RECORD_MAX = exports.SAMPLE_LIMIT = exports.OTHER_REASON = exports.REASON_LIMIT = exports.TREND_LIMIT = exports.STALE_AFTER_MS = exports.INGEST_WINDOW_MS = void 0;
|
|
32
|
+
exports.recordReadFailure = recordReadFailure;
|
|
33
|
+
exports.recordRecovered = recordRecovered;
|
|
34
|
+
exports.clearReadFailure = clearReadFailure;
|
|
35
|
+
exports.recordCursorStall = recordCursorStall;
|
|
36
|
+
exports.recordIngestSource = recordIngestSource;
|
|
37
|
+
exports.recordVocabulary = recordVocabulary;
|
|
38
|
+
exports.recordDuplicates = recordDuplicates;
|
|
39
|
+
exports.recordWithheld = recordWithheld;
|
|
40
|
+
exports.clearCursorStall = clearCursorStall;
|
|
41
|
+
exports.newIngestLedger = newIngestLedger;
|
|
42
|
+
exports.rollIngestWindow = rollIngestWindow;
|
|
43
|
+
exports.reasonKey = reasonKey;
|
|
44
|
+
exports.recordIngest = recordIngest;
|
|
45
|
+
exports.recordJournalWrite = recordJournalWrite;
|
|
46
|
+
exports.syncVerdictOf = syncVerdictOf;
|
|
47
|
+
exports.ingestHealth = ingestHealth;
|
|
48
|
+
/**
|
|
49
|
+
* 읽기 실패를 장부에 적는다 — **받은 것이 없는 것과 가른다.**
|
|
50
|
+
*
|
|
51
|
+
* 연속 실패의 시작 시각을 지키는 것이 이 함수의 요점이다: 매번 새로 적으면 「20분째 못 읽는다」를
|
|
52
|
+
* 말할 수 없고, 화면은 늘 「방금 실패했다」만 보게 된다.
|
|
53
|
+
*/
|
|
54
|
+
function recordReadFailure(ledger, reason, nowMs, stream,
|
|
55
|
+
/**
|
|
56
|
+
* 다음에 다시 물을 때까지 남은 시간(ms) — 커넥터가 물러섰으면 그 값을 준다 (2026-08-27).
|
|
57
|
+
*
|
|
58
|
+
* 이것이 없으면 화면이 「닿지 못하고 있다」까지만 말하고, 사람은 **언제 다시 시도하는지** 알 수
|
|
59
|
+
* 없다. 그러면 기다려야 하는지 손을 써야 하는지 판단하지 못한다.
|
|
60
|
+
*
|
|
61
|
+
* **연달아 횟수와 시작 시각은 커넥터가 보내지 않는다** — 이 함수가 이미 센다. 두 곳에서 세면
|
|
62
|
+
* 갈라지고, 갈라지면 어느 쪽이 사실인지 알 수 없다.
|
|
63
|
+
*/
|
|
64
|
+
nextRetryMs) {
|
|
65
|
+
const prev = ledger.readFailure;
|
|
66
|
+
ledger.readFailure = {
|
|
67
|
+
consecutive: (prev?.consecutive ?? 0) + 1,
|
|
68
|
+
sinceMs: prev?.sinceMs ?? nowMs,
|
|
69
|
+
lastAtMs: nowMs,
|
|
70
|
+
reason,
|
|
71
|
+
...(stream ? { stream } : {}),
|
|
72
|
+
...(Number.isFinite(nextRetryMs) && nextRetryMs > 0 ? { nextRetryMs: nextRetryMs } : {})
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* **끊겼다가 돌아왔다** — 그 사실을 남긴다 (2026-08-27).
|
|
77
|
+
*
|
|
78
|
+
* 실패 기록만 지우면 「끊긴 적이 있었다」가 화면에서 사라진다. 그러면 밤새 두 시간 끊겼던 연결과 한
|
|
79
|
+
* 번도 끊기지 않은 연결이 아침에 똑같이 보인다. 몇 번 실패한 뒤에 돌아왔는지, 얼마나 끊겼는지를 함께
|
|
80
|
+
* 남긴다.
|
|
81
|
+
*/
|
|
82
|
+
function recordRecovered(ledger, afterFailures, downMs, nowMs) {
|
|
83
|
+
if (ledger.readFailure)
|
|
84
|
+
delete ledger.readFailure;
|
|
85
|
+
ledger.lastRecovery = { atMs: nowMs, afterFailures, downMs };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 읽기가 성공했다 — 단절 기록을 지운다.
|
|
89
|
+
*
|
|
90
|
+
* **빈 읽기도 성공이다**: 원본이 「새 것이 없다」고 답한 것은 닿았다는 뜻이다. 그 둘을 같게 두면
|
|
91
|
+
* 들어온 것이 없는 연결이 끊긴 원본으로 보인다(고치려던 것의 반대 방향으로 틀린다).
|
|
92
|
+
*/
|
|
93
|
+
function clearReadFailure(ledger) {
|
|
94
|
+
if (ledger.readFailure)
|
|
95
|
+
delete ledger.readFailure;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 커서 정체를 장부에 적는다 — **닿지 못한 것과 가른다.**
|
|
99
|
+
*
|
|
100
|
+
* `recordReadFailure` 와 같은 모양이지만 **다른 칸**이다. 한 주기가 두 이유로 실패할 수는 없으므로
|
|
101
|
+
* (먼저 닿아야 읽는다) 둘이 동시에 서지 않는다 — 그래서 화면은 둘 중 하나만 보게 되고, 그 하나가
|
|
102
|
+
* 조치를 정한다.
|
|
103
|
+
*/
|
|
104
|
+
function recordCursorStall(ledger, reason, nowMs, stream) {
|
|
105
|
+
const prev = ledger.cursorStall;
|
|
106
|
+
ledger.cursorStall = {
|
|
107
|
+
consecutive: (prev?.consecutive ?? 0) + 1,
|
|
108
|
+
sinceMs: prev?.sinceMs ?? nowMs,
|
|
109
|
+
lastAtMs: nowMs,
|
|
110
|
+
reason,
|
|
111
|
+
...(stream ? { stream } : {})
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* **세우지 않은 것을 적는다** — 이유별 누적.
|
|
116
|
+
*
|
|
117
|
+
* 주기마다 다시 알려도 시끄러워지지 않게 **이유로 묶어 센다**(줄이 늘지 않는다). 그리고 마지막 시각을
|
|
118
|
+
* 함께 든다 — 「지금도 그런가」와 「한때 그랬나」는 다른 사실이고, 시각 없이는 구별할 수 없다.
|
|
119
|
+
*
|
|
120
|
+
* 이유가 같고 수가 달라지면 **마지막 수로 덮는다**: 이것은 누적 사건이 아니라 **지금 세우지 않은 것의
|
|
121
|
+
* 수**다. 주기마다 8건이면 8이고, 더해서 800이 되면 거짓이다.
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* 어떤 길로 몇 건이 들어왔나 — 경로 이름은 부르는 쪽이 정한다.
|
|
125
|
+
*
|
|
126
|
+
* 이름을 자유 문자열로 두는 이유: 길이 늘어난다(지금은 물어보기·시나리오·부하 도구, 다음은 웹훅).
|
|
127
|
+
* 열거로 못 박으면 새 길이 생길 때 이 파일을 함께 고쳐야 하고, 잊으면 그 길이 통계에서 사라진다.
|
|
128
|
+
*/
|
|
129
|
+
function recordIngestSource(ledger, source, count, nowMs) {
|
|
130
|
+
const key = String(source ?? '').trim() || 'unknown';
|
|
131
|
+
if (count <= 0)
|
|
132
|
+
return;
|
|
133
|
+
const cur = (ledger.bySource[key] ??= { count: 0, lastAtMs: -1 });
|
|
134
|
+
cur.count += count;
|
|
135
|
+
cur.lastAtMs = nowMs;
|
|
136
|
+
}
|
|
137
|
+
/** 같은 사실이 두 번 와서 버린 수 — 알리지 않고 버리지 않는다. */
|
|
138
|
+
/**
|
|
139
|
+
* **저널에 안 간 어휘 요소를 센다** — 없어진 것이 아니라 저널로 가지 않는 것이다.
|
|
140
|
+
*
|
|
141
|
+
* 어휘(로트 번호·소비기한 등)는 시각축이 없어 일부러 저널로 안 간다. 그런데 그 수를 빼 주는 자리가
|
|
142
|
+
* 없으면 「보낸 것 − 앉은 것」이 안 맞고, 그 차이를 **사라진 것으로 읽게 된다.**
|
|
143
|
+
*
|
|
144
|
+
* 창을 굴리지 않는다 — 부르는 쪽(`recordIngest`)이 이미 굴린 뒤에 부른다. 여기서 또 굴리면 한
|
|
145
|
+
* 유입이 두 창에 걸친다.
|
|
146
|
+
*/
|
|
147
|
+
function recordVocabulary(ledger, count) {
|
|
148
|
+
if (!Number.isFinite(count) || count <= 0)
|
|
149
|
+
return;
|
|
150
|
+
ledger.vocabulary += count;
|
|
151
|
+
ledger.window.vocabulary += count;
|
|
152
|
+
}
|
|
153
|
+
function recordDuplicates(ledger, count, nowMs) {
|
|
154
|
+
if (count <= 0)
|
|
155
|
+
return;
|
|
156
|
+
ledger.duplicates += count;
|
|
157
|
+
ledger.lastDuplicateAtMs = nowMs;
|
|
158
|
+
}
|
|
159
|
+
function recordWithheld(ledger, reason, count, nowMs) {
|
|
160
|
+
if (!reason || !(count > 0))
|
|
161
|
+
return;
|
|
162
|
+
const list = (ledger.withheld ??= []);
|
|
163
|
+
const at = list.find(w => w.reason === reason);
|
|
164
|
+
if (at) {
|
|
165
|
+
at.count = count;
|
|
166
|
+
at.lastAtMs = nowMs;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
list.push({ reason, count, lastAtMs: nowMs });
|
|
170
|
+
}
|
|
171
|
+
/** 창을 넘겼으면 지운다 — 풀린 정체가 화면에 남아 있으면 그것도 거짓이다. */
|
|
172
|
+
function clearCursorStall(ledger) {
|
|
173
|
+
if (ledger.cursorStall)
|
|
174
|
+
delete ledger.cursorStall;
|
|
175
|
+
}
|
|
176
|
+
/** 창 길이 — 10분. 부하 계기판(10초)과 다른 값이라 이름을 따로 둔다(같은 이름이면 섞인다). */
|
|
177
|
+
exports.INGEST_WINDOW_MS = 600_000;
|
|
178
|
+
/**
|
|
179
|
+
* 이만큼 아무것도 오지 않으면 「유입 없음」이라 말한다.
|
|
180
|
+
*
|
|
181
|
+
* 창 하나와 같게 둔다 — 한 창을 통째로 비운 것은 실제로 「그 구간에 아무것도 오지 않았다」이고, 추이의
|
|
182
|
+
* 그 자리도 비게 된다. 그림과 알약이 같은 말을 하게 하려면 같은 길이여야 한다.
|
|
183
|
+
*/
|
|
184
|
+
exports.STALE_AFTER_MS = exports.INGEST_WINDOW_MS;
|
|
185
|
+
/** 추이 보관 창 수 — 36창 × 10분 = 6시간. 「고친 것이 먹혔나」를 보기에 충분하고 메모리는 숫자 넷×36 이다. */
|
|
186
|
+
exports.TREND_LIMIT = 36;
|
|
187
|
+
/**
|
|
188
|
+
* 사유 종류 상한.
|
|
189
|
+
*
|
|
190
|
+
* **없으면 메모리가 끝없이 자란다.** 사유 문구에 레코드마다 다른 값이 섞이는 커넥터가 하나만 있어도
|
|
191
|
+
* (`unknown bizStep "PUTAWAY_X17"`, `…X18`, …) 키가 무한히 늘어난다. 초당 1만 건 규모에서 이것은
|
|
192
|
+
* 미미한 오버헤드가 아니라 누수다.
|
|
193
|
+
*
|
|
194
|
+
* 넘치면 `OTHER_REASON` 한 칸에 모으고 **몇 종을 모았는지 함께 낸다**(알리지 않고 자르지 않는다).
|
|
195
|
+
* 30종이면 고칠 곳을 찾기에 충분하다 — 목록을 훑는 일이 아니라 매핑 몇 줄을 고치는 일이다.
|
|
196
|
+
*/
|
|
197
|
+
exports.REASON_LIMIT = 30;
|
|
198
|
+
/** 상한을 넘긴 사유들이 모이는 칸. */
|
|
199
|
+
exports.OTHER_REASON = '(other reasons)';
|
|
200
|
+
/**
|
|
201
|
+
* 표본 상한 — **사유마다 한 건**만 든다.
|
|
202
|
+
*
|
|
203
|
+
* 같은 사유 20건보다 **서로 다른 사유 1건씩**이 매핑을 고치는 데 유용하다. 그리고 이 규칙이 성능을
|
|
204
|
+
* 지킨다: 그 사유의 표본이 이미 있으면 `JSON.stringify` 를 **아예 하지 않는다.** 실측(레코드 241바이트)
|
|
205
|
+
* 으로 건당 0.24µs 인데, 초당 1만 건이 전부 거부되면 9,980건은 만들자마자 버려지는 문자열이었다 —
|
|
206
|
+
* CPU 는 감당되지만 GC 압박이 이벤트 루프로 돌아온다.
|
|
207
|
+
*/
|
|
208
|
+
exports.SAMPLE_LIMIT = exports.REASON_LIMIT;
|
|
209
|
+
/** 표본 하나의 직렬화 상한(문자). */
|
|
210
|
+
exports.RECORD_MAX = 2000;
|
|
211
|
+
/**
|
|
212
|
+
* 매핑이 막고 있다고 볼 통과율 문턱.
|
|
213
|
+
*
|
|
214
|
+
* 값과 함께 내보낸다 — 화면이 「95% 미만이라 매핑 문제로 봤다」를 말할 수 있게. 숨긴 문턱은 사람이
|
|
215
|
+
* 판정을 믿지 못한다.
|
|
216
|
+
*/
|
|
217
|
+
exports.HEALTHY_ACCEPTED_RATIO = 0.95;
|
|
218
|
+
function newIngestLedger() {
|
|
219
|
+
return {
|
|
220
|
+
window: { startMs: -1, endMs: -1, offered: 0, rejected: 0, vocabulary: 0, undelivered: 0, rows: 0, flushes: 0 },
|
|
221
|
+
trend: [],
|
|
222
|
+
trendDropped: 0,
|
|
223
|
+
offered: 0,
|
|
224
|
+
rejected: 0,
|
|
225
|
+
undelivered: 0,
|
|
226
|
+
rows: 0,
|
|
227
|
+
flushes: 0,
|
|
228
|
+
lastWriteAtMs: -1,
|
|
229
|
+
lastUndeliveredAtMs: -1,
|
|
230
|
+
bySource: {},
|
|
231
|
+
duplicates: 0,
|
|
232
|
+
vocabulary: 0,
|
|
233
|
+
lastDuplicateAtMs: -1,
|
|
234
|
+
byReason: {},
|
|
235
|
+
reasonsCapped: false,
|
|
236
|
+
sampledReasons: new Set(),
|
|
237
|
+
samples: [],
|
|
238
|
+
sampleDropped: 0,
|
|
239
|
+
lastAtMs: -1
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 창이 다 찼으면 닫아 추이에 넣고 새로 연다. **닫힌 창만 「최근」으로 읽는다.**
|
|
244
|
+
*
|
|
245
|
+
* ── 영속이 붙는 유일한 자리 ────────────────────────────────────────────────
|
|
246
|
+
* 닫힌 창 하나가 영속의 단위다(숫자 넷 + 구간). `onClosed` 를 받는 이유는 **이 함수가 창이 닫히는
|
|
247
|
+
* 유일한 지점**이기 때문이다 — 여기 한 곳에 붙이면 기록 경로가 갈리지 않는다.
|
|
248
|
+
*
|
|
249
|
+
* 왜 건마다가 아니라 창마다인가: 거부마다 행을 쓰면 초당 1만 행이고 그 부하가 이벤트 루프로 돌아온다.
|
|
250
|
+
* 창마다면 트윈 1,000개에 하루 14.4만 행(약 14MB)이다.
|
|
251
|
+
*
|
|
252
|
+
* `onClosed` 는 **오류를 내지 않아야 한다**(호출부가 감싼다) — 영속이 실패해도 장부는 계속 굴러야 한다.
|
|
253
|
+
* 영속을 지키는 것과 관측을 지키는 것은 다른 일이고, 뒤엣것이 앞엣것 때문에 멈추면 안 된다.
|
|
254
|
+
*/
|
|
255
|
+
function rollIngestWindow(ledger, nowMs, windowMs = exports.INGEST_WINDOW_MS, onClosed) {
|
|
256
|
+
if (ledger.window.startMs < 0) {
|
|
257
|
+
ledger.window.startMs = nowMs;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (nowMs - ledger.window.startMs < windowMs)
|
|
261
|
+
return;
|
|
262
|
+
const closed = { ...ledger.window, endMs: nowMs };
|
|
263
|
+
ledger.trend.push(closed);
|
|
264
|
+
ledger.window = { startMs: nowMs, endMs: -1, offered: 0, rejected: 0, vocabulary: 0, undelivered: 0, rows: 0, flushes: 0 };
|
|
265
|
+
if (ledger.trend.length > exports.TREND_LIMIT) {
|
|
266
|
+
const drop = ledger.trend.length - exports.TREND_LIMIT;
|
|
267
|
+
ledger.trend.splice(0, drop);
|
|
268
|
+
ledger.trendDropped += drop;
|
|
269
|
+
}
|
|
270
|
+
/* 영속은 관측을 막지 않는다 — 터져도 장부는 이미 갱신됐다. */
|
|
271
|
+
if (onClosed) {
|
|
272
|
+
try {
|
|
273
|
+
onClosed(closed);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
/* 호출부가 알린다. 여기서 삼키는 것은 **장부의 진행**을 지키기 위해서다. */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** 사유 묶음의 키. 사유가 여럿이면 한 줄로 잇고, 없으면 지어내지 않고 `unknown`. */
|
|
281
|
+
function reasonKey(errors) {
|
|
282
|
+
const list = (errors ?? []).map(e => String(e).trim()).filter(Boolean);
|
|
283
|
+
return list.length ? list.join(' · ') : 'unknown';
|
|
284
|
+
}
|
|
285
|
+
function safeJson(v) {
|
|
286
|
+
try {
|
|
287
|
+
const s = JSON.stringify(v);
|
|
288
|
+
return s === undefined ? String(v) : s;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return '(serialization failed)'; /* 순환 참조 등 — 모양을 못 보여 주는 것도 사실이다 */
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 한 번의 인제스트 결과를 적는다.
|
|
296
|
+
*
|
|
297
|
+
* `offered` 를 함께 받는 이유: 통과율을 **서로 다른 두 계수기**에서 나눠 계산하면 분모와 분자가 다른
|
|
298
|
+
* 것을 세게 된다. 둘을 한자리에서 본 수를 그대로 적는다.
|
|
299
|
+
*/
|
|
300
|
+
function recordIngest(ledger, offered, rejected, nowMs, onClosed,
|
|
301
|
+
/** 매핑은 통과했지만 트윈에 닿지 못한 수 — 「통과」로 세면 화면이 거짓을 말한다. */
|
|
302
|
+
undelivered = 0) {
|
|
303
|
+
if (!Number.isFinite(offered) || offered < 0)
|
|
304
|
+
return;
|
|
305
|
+
rollIngestWindow(ledger, nowMs, exports.INGEST_WINDOW_MS, onClosed);
|
|
306
|
+
ledger.offered += offered;
|
|
307
|
+
ledger.window.offered += offered;
|
|
308
|
+
ledger.lastAtMs = nowMs;
|
|
309
|
+
if (Number.isFinite(undelivered) && undelivered > 0) {
|
|
310
|
+
ledger.undelivered += undelivered;
|
|
311
|
+
ledger.window.undelivered += undelivered;
|
|
312
|
+
ledger.lastUndeliveredAtMs = nowMs;
|
|
313
|
+
}
|
|
314
|
+
const items = rejected ?? [];
|
|
315
|
+
if (!items.length)
|
|
316
|
+
return;
|
|
317
|
+
ledger.rejected += items.length;
|
|
318
|
+
ledger.window.rejected += items.length;
|
|
319
|
+
for (const it of items) {
|
|
320
|
+
const raw = reasonKey(it?.errors);
|
|
321
|
+
const known = raw in ledger.byReason;
|
|
322
|
+
/* 사유 종류 상한 — 넘치면 한 칸으로 모으고, **닿았다는 사실**을 표시한다. */
|
|
323
|
+
let key = raw;
|
|
324
|
+
if (!known && Object.keys(ledger.byReason).length >= exports.REASON_LIMIT) {
|
|
325
|
+
key = exports.OTHER_REASON;
|
|
326
|
+
ledger.reasonsCapped = true;
|
|
327
|
+
}
|
|
328
|
+
const seen = ledger.byReason[key];
|
|
329
|
+
ledger.byReason[key] = { count: (seen?.count ?? 0) + 1, lastAtMs: nowMs };
|
|
330
|
+
/*
|
|
331
|
+
* **표본은 사유마다 하나.** 이미 그 사유의 표본이 있으면 직렬화를 아예 하지 않는다 — 초당 1만 건이
|
|
332
|
+
* 전부 거부될 때 만들자마자 버려지는 문자열을 만들지 않기 위해서다(실측: 건당 0.24µs, GC 압박).
|
|
333
|
+
*/
|
|
334
|
+
if (ledger.sampledReasons.has(key)) {
|
|
335
|
+
ledger.sampleDropped += 1;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const text = safeJson(it?.record);
|
|
339
|
+
const truncated = text.length > exports.RECORD_MAX;
|
|
340
|
+
ledger.samples.push({
|
|
341
|
+
atMs: nowMs,
|
|
342
|
+
errors: key === exports.OTHER_REASON ? [exports.OTHER_REASON] : it?.errors?.length ? [...it.errors] : ['unknown'],
|
|
343
|
+
record: truncated ? text.slice(0, exports.RECORD_MAX) : text,
|
|
344
|
+
...(truncated ? { recordTruncated: true } : {})
|
|
345
|
+
});
|
|
346
|
+
ledger.sampledReasons.add(key);
|
|
347
|
+
if (ledger.samples.length > exports.SAMPLE_LIMIT) {
|
|
348
|
+
const gone = ledger.samples.splice(0, ledger.samples.length - exports.SAMPLE_LIMIT);
|
|
349
|
+
/* 밀려난 표본의 사유는 색인에서도 빼야 다음에 다시 뜰 수 있다(안 빼면 그 사유는 영원히 표본 없음). */
|
|
350
|
+
for (const g of gone)
|
|
351
|
+
ledger.sampledReasons.delete(reasonKey(g.errors));
|
|
352
|
+
ledger.sampleDropped += gone.length;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 저널에 **적은 것**을 기록한다 — 창을 넘길 때가 되었으면 넘기고, 행·왕복을 더한다.
|
|
358
|
+
*
|
|
359
|
+
* ── 왜 유입과 같은 창인가 ───────────────────────────────────────────────────
|
|
360
|
+
* 새 장부를 만들지 않는다. 이 창은 「닫힌 창만 최근」·「0 과 없음을 가른다」·「잘린 것을 세어 드러낸다」를
|
|
361
|
+
* 이미 지키고, 닫힐 때 영속까지 된다. 같은 규율을 두 벌 만들면 한쪽만 고쳐진다.
|
|
362
|
+
*
|
|
363
|
+
* ── 유입이 없어도 창은 넘어간다 ────────────────────────────────────────────
|
|
364
|
+
* 시뮬 트윈은 받는 것 없이 자기가 낸 사실을 적는다. 창이 **유입에서만** 넘어간다면 그 트윈은 추이에
|
|
365
|
+
* 영원히 나타나지 않는다 — 그래서 쓰기도 창을 넘긴다.
|
|
366
|
+
*/
|
|
367
|
+
function recordJournalWrite(ledger, rows, nowMs, onClosed) {
|
|
368
|
+
if (!Number.isFinite(rows) || rows <= 0)
|
|
369
|
+
return;
|
|
370
|
+
rollIngestWindow(ledger, nowMs, exports.INGEST_WINDOW_MS, onClosed);
|
|
371
|
+
ledger.rows += rows;
|
|
372
|
+
ledger.flushes += 1;
|
|
373
|
+
ledger.window.rows += rows;
|
|
374
|
+
ledger.window.flushes += 1;
|
|
375
|
+
/*
|
|
376
|
+
* **`lastAtMs` 는 건드리지 않는다.** 그 값은 「마지막 유입」이고, 끊긴 피드를 가려내는 근거다. 쓰기로
|
|
377
|
+
* 갱신하면 라이브 트윈은 유입이 끊겨도 틱이 만든 델타를 계속 적으므로 **영원히 「방금 받았다」**가 되고,
|
|
378
|
+
* 「오래 끊긴 피드에 옛 창으로 초록을 내지 않는다」가 오류 하나 없이 무력해진다. 축을 따로 든다.
|
|
379
|
+
*/
|
|
380
|
+
ledger.lastWriteAtMs = nowMs;
|
|
381
|
+
}
|
|
382
|
+
function syncVerdictOf(args) {
|
|
383
|
+
/*
|
|
384
|
+
* ── 버려지고 있는 것이 가장 먼저다 ────────────────────────────────────────
|
|
385
|
+
* 트윈이 멈추면 피드는 남아 계속 레코드를 나르고, 커널은 그것을 알리지 않고 버린다. 그 상태를 「해당 없음」
|
|
386
|
+
* 이라 부르면 **사실이 사라지는 동안 화면이 안심시킨다** — 실제로 그렇게 떴다(멈춘 트윈 옆에 「150
|
|
387
|
+
* 통과 · 100%」). 그래서 시뮬·멈춤 판정보다 먼저 본다.
|
|
388
|
+
*
|
|
389
|
+
* 시뮬 트윈에는 이 값이 서지 않는다(먹이는 피드가 없으므로 미전달도 없다). 그래서 이 순서가 시뮬에
|
|
390
|
+
* 거짓 경보를 주지 않는다.
|
|
391
|
+
*/
|
|
392
|
+
if (typeof args.lastUndeliveredAtMs === 'number' &&
|
|
393
|
+
args.lastUndeliveredAtMs >= 0 &&
|
|
394
|
+
typeof args.nowMs === 'number' &&
|
|
395
|
+
args.nowMs - args.lastUndeliveredAtMs <= exports.STALE_AFTER_MS) {
|
|
396
|
+
return 'twin-not-running';
|
|
397
|
+
}
|
|
398
|
+
if (args.feedState === 'not-applicable')
|
|
399
|
+
return 'not-applicable';
|
|
400
|
+
if (args.feedState === 'detached')
|
|
401
|
+
return 'not-connected';
|
|
402
|
+
/*
|
|
403
|
+
* ── 끊긴 피드가 초록으로 남지 않게 ────────────────────────────────────────
|
|
404
|
+
* 창은 **다음 유입이 있을 때** 닫힌다. 그래서 유입이 멈추면 창도 멈추고, 「최근 창」은 멈추기 직전의
|
|
405
|
+
* 건강했던 창으로 남는다. 그 창만 보고 판정하면 **어제 죽은 피드가 오늘도 「동기화 중」**이다. 이
|
|
406
|
+
* 기능이 있는 이유가 바로 그 상태를 드러내는 것이므로, 그때 초록을 내면 목적을 놓친다.
|
|
407
|
+
*
|
|
408
|
+
* 그래서 창과 별개로 **지금까지의 침묵**을 먼저 본다. 한 창을 통째로 아무것도 받지 않았으면 그것은
|
|
409
|
+
* 「유입 없음」이다 — 실제로 그 구간의 창은 비어 있게 된다.
|
|
410
|
+
*
|
|
411
|
+
* 이 판단을 서버에 두는 이유: 소비처가 여럿(목록·연결 상세·앞으로 생길 것)이고, 화면마다 다시
|
|
412
|
+
* 판단하면 규칙이 갈라져 어떤 화면은 초록, 어떤 화면은 경고를 말한다.
|
|
413
|
+
*
|
|
414
|
+
* 한계 — 한 창(10분)보다 **느리게 긷는 원천**은 긷는 사이에 「유입 없음」으로 보인다. 장부는 원천의
|
|
415
|
+
* 주기를 모르므로 이보다 정확히 답할 수 없다. 주기를 알게 되면 그 값으로 견주는 것이 맞다.
|
|
416
|
+
*/
|
|
417
|
+
/*
|
|
418
|
+
* 견줄 길이는 **원천이 선언한 값이 있으면 그것**이다(§`expectedSilenceMs`). 없으면 창 하나 —
|
|
419
|
+
* 위 한계 문단이 말하는 그대로다.
|
|
420
|
+
*/
|
|
421
|
+
const silenceLimitMs = typeof args.expectedSilenceMs === 'number' && args.expectedSilenceMs > 0
|
|
422
|
+
? args.expectedSilenceMs
|
|
423
|
+
: exports.STALE_AFTER_MS;
|
|
424
|
+
if (typeof args.lastAtMs === 'number' && typeof args.nowMs === 'number') {
|
|
425
|
+
if (args.lastAtMs < 0) {
|
|
426
|
+
/* 한 번도 받지 않았는데 지금이 조용한 시간이면 「기다린다」가 아니라 「지금은 맞다」다. */
|
|
427
|
+
return args.quietNow === true ? 'expected-quiet' : 'unknown';
|
|
428
|
+
}
|
|
429
|
+
if (args.nowMs - args.lastAtMs > silenceLimitMs) {
|
|
430
|
+
/*
|
|
431
|
+
* 오래 조용하다. **지금이 그 원천의 조용한 시간이면 그것이 정상이다.**
|
|
432
|
+
*
|
|
433
|
+
* 이 갈래를 위쪽(버려지는 중·피드 없음)보다 뒤에 두는 이유: 사실이 사라지고 있거나 결선이
|
|
434
|
+
* 없는 것은 **몇 시든 문제**다. 밤이라고 덮으면 밤새 새는 것을 아침에 안다.
|
|
435
|
+
*/
|
|
436
|
+
return args.quietNow === true ? 'expected-quiet' : 'no-traffic';
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/*
|
|
440
|
+
* ── **닿았는데 오는 것이 없다** 를 「모른다」로 답하지 않는다 (2026-08-28) ────
|
|
441
|
+
*
|
|
442
|
+
* 창은 **다음 유입이 있을 때** 닫힌다. 그래서 오는 것이 하나도 없는 트윈은 창이 영원히 닫히지 않고,
|
|
443
|
+
* 이 자리가 `unknown` 을 냈다 — 붙어 있고 37초마다 성공적으로 읽는 트윈이 화면에서 「모른다」였다.
|
|
444
|
+
*
|
|
445
|
+
* 그것이 드러난 계기: 커넥터가 재기동마다 재고 2,665건을 다시 말하던 것을 없앴다. 그러자 정상 상태의
|
|
446
|
+
* 유입이 0 이 되었고, **건강 신호가 그 소음을 타고 있었다**는 것이 보였다.
|
|
447
|
+
*
|
|
448
|
+
* ── 가르는 사실은 **받은 것이 있나**다 (첫 판을 고침) ─────────────────────
|
|
449
|
+
* 처음에는 닿은 시각으로 갈랐다. 그것이 틀렸다 — **빈 읽기도 닿은 것**이라 그 시각은 언제나 선다.
|
|
450
|
+
* 그래서 창을 채우는 중인 트윈(제시 200건)까지 「오는 것이 없다」로 답하게 됐고, 시험이 그것을 잡았다.
|
|
451
|
+
* 받고 있는 중인데 오는 것이 없다고 말하는 것은 **없는 것과 못 본 것을 뒤집는 것**이다.
|
|
452
|
+
*
|
|
453
|
+
* 닿았고 · 제시가 0 오는 것이 없다 `no-traffic`
|
|
454
|
+
* 닿았고 · 제시가 있다 받는 중이고 판정할 창이 아직 없다 `unknown`
|
|
455
|
+
* 한 번도 닿지 않았다 `unknown`(위 `lastAtMs < 0` 갈래)
|
|
456
|
+
*/
|
|
457
|
+
if (!args.recent) {
|
|
458
|
+
const touched = typeof args.lastAtMs === 'number' && args.lastAtMs >= 0;
|
|
459
|
+
const nothingOffered = (args.offeredTotal ?? 0) <= 0;
|
|
460
|
+
if (touched && nothingOffered)
|
|
461
|
+
return args.quietNow === true ? 'expected-quiet' : 'no-traffic';
|
|
462
|
+
return 'unknown';
|
|
463
|
+
}
|
|
464
|
+
if (args.recent.offered <= 0)
|
|
465
|
+
return args.quietNow === true ? 'expected-quiet' : 'no-traffic';
|
|
466
|
+
const ratio = (args.recent.offered - args.recent.rejected) / args.recent.offered;
|
|
467
|
+
return ratio >= (args.threshold ?? exports.HEALTHY_ACCEPTED_RATIO) ? 'syncing' : 'mapping-blocked';
|
|
468
|
+
}
|
|
469
|
+
function toWindowView(w, fallbackEndMs) {
|
|
470
|
+
/* 「통과」는 **트윈에 닿은** 것만이다. 버려진 것을 통과로 세면 화면이 안심시킨다. */
|
|
471
|
+
const accepted = w.offered - w.rejected - w.undelivered;
|
|
472
|
+
return {
|
|
473
|
+
from: new Date(w.startMs).toISOString(),
|
|
474
|
+
to: new Date(w.endMs >= 0 ? w.endMs : fallbackEndMs).toISOString(),
|
|
475
|
+
offered: w.offered,
|
|
476
|
+
accepted,
|
|
477
|
+
rejected: w.rejected,
|
|
478
|
+
undelivered: w.undelivered,
|
|
479
|
+
acceptedRatio: w.offered > 0 ? round4(accepted / w.offered) : null,
|
|
480
|
+
rows: w.rows,
|
|
481
|
+
flushes: w.flushes
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* 읽기용 요약.
|
|
486
|
+
*
|
|
487
|
+
* 장부가 없어도(한 번도 받지 않은 트윈) **판정은 낸다** — 피드 사실만으로 답할 수 있는 경우가 있다
|
|
488
|
+
* (`not-applicable`·`not-connected`). 그 경우 수치는 비운다: 없는 값을 0 으로 채우지 않는다.
|
|
489
|
+
*
|
|
490
|
+
* `nowMs` 를 받는 이유: 끊긴 피드를 가려내려면 「마지막 유입이 얼마나 전인가」를 봐야 하고, 그 판단을
|
|
491
|
+
* 화면이 아니라 여기서 한다(소비처마다 다시 판단하면 규칙이 갈라진다). 시험이 시각을 고정할 수 있도록
|
|
492
|
+
* 인자로 받는다.
|
|
493
|
+
*/
|
|
494
|
+
function ingestHealth(ledger, feedState, threshold = exports.HEALTHY_ACCEPTED_RATIO, nowMs = Date.now()) {
|
|
495
|
+
const recentRaw = ledger?.trend.length ? ledger.trend[ledger.trend.length - 1] : null;
|
|
496
|
+
/* 열린 창의 끝 시각 대체값 — 유입이 없는 장부에서 `-1` 을 시각으로 읽으면 1969년이 화면에 뜬다. */
|
|
497
|
+
const lastAnyMs = Math.max(ledger?.lastAtMs ?? -1, ledger?.lastWriteAtMs ?? -1);
|
|
498
|
+
const verdict = syncVerdictOf({
|
|
499
|
+
feedState,
|
|
500
|
+
recent: recentRaw,
|
|
501
|
+
lastAtMs: ledger?.lastAtMs ?? -1,
|
|
502
|
+
lastUndeliveredAtMs: ledger?.lastUndeliveredAtMs ?? -1,
|
|
503
|
+
/* 창이 닫히지 않았을 때 「받는 중」과 「오는 것이 없다」를 가르는 사실. */
|
|
504
|
+
offeredTotal: ledger?.offered ?? 0,
|
|
505
|
+
nowMs,
|
|
506
|
+
threshold
|
|
507
|
+
});
|
|
508
|
+
/*
|
|
509
|
+
* **유입이 없어도 「빈 장부」가 아니다.** 시뮬 트윈은 받는 것 없이 적는다. 유입 시각만 보고 비었다고
|
|
510
|
+
* 판단하면 그 트윈의 창은 만들어져 있는데 화면에는 아무 추이도 나오지 않는다(이 축을 넣은 이유가 그것이다).
|
|
511
|
+
*/
|
|
512
|
+
if (!ledger || (ledger.lastAtMs < 0 && ledger.lastWriteAtMs < 0)) {
|
|
513
|
+
return {
|
|
514
|
+
verdict,
|
|
515
|
+
threshold,
|
|
516
|
+
recent: null,
|
|
517
|
+
trend: [],
|
|
518
|
+
trendDropped: 0,
|
|
519
|
+
total: { offered: 0, accepted: 0, rejected: 0, undelivered: 0, acceptedRatio: null, rows: 0, flushes: 0 },
|
|
520
|
+
reasons: [],
|
|
521
|
+
reasonsCapped: false,
|
|
522
|
+
samples: [],
|
|
523
|
+
sampleDropped: 0,
|
|
524
|
+
lastAt: null,
|
|
525
|
+
lastWriteAt: null,
|
|
526
|
+
/*
|
|
527
|
+
* **이 짧은 답에도 단절은 실어야 한다** — 이 자리가 바로 그 트윈의 모습이다.
|
|
528
|
+
*
|
|
529
|
+
* 받은 것도 적은 것도 없는 장부는 「아직 아무 일도 없다」로 읽히는데, 원본에 닿지 못하는 트윈이
|
|
530
|
+
* 정확히 그 모습이다(읽지 못하면 받은 것도 적은 것도 없다). 여기서 떨어뜨리면 **가장 필요한
|
|
531
|
+
* 순간에** 그 사실이 사라진다 — 실측으로 원본이 끊긴 트윈이 이 경로로 답하고 있었다.
|
|
532
|
+
*/
|
|
533
|
+
...(ledger?.readFailure
|
|
534
|
+
? {
|
|
535
|
+
readFailure: {
|
|
536
|
+
consecutive: ledger.readFailure.consecutive,
|
|
537
|
+
since: new Date(ledger.readFailure.sinceMs).toISOString(),
|
|
538
|
+
lastAt: new Date(ledger.readFailure.lastAtMs).toISOString(),
|
|
539
|
+
reason: ledger.readFailure.reason,
|
|
540
|
+
...(ledger.readFailure.stream ? { stream: ledger.readFailure.stream } : {}),
|
|
541
|
+
...(ledger.readFailure.nextRetryMs ? { nextRetryMs: ledger.readFailure.nextRetryMs } : {})
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
: {}),
|
|
545
|
+
...(ledger?.lastRecovery
|
|
546
|
+
? {
|
|
547
|
+
lastRecovery: {
|
|
548
|
+
at: new Date(ledger.lastRecovery.atMs).toISOString(),
|
|
549
|
+
afterFailures: ledger.lastRecovery.afterFailures,
|
|
550
|
+
downMs: ledger.lastRecovery.downMs
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
: {}),
|
|
554
|
+
/* 커서 정체도 **이 갈래에서** 내보낸다 — 유입이 없는 장부에서도 정체는 실재한다(읽었으나 못
|
|
555
|
+
넘겼으면 유입이 0이다). 여기서 떨어뜨리면 바로 그 상황에서 들어온 것이 없어진다. */
|
|
556
|
+
...(ledger?.cursorStall
|
|
557
|
+
? {
|
|
558
|
+
cursorStall: {
|
|
559
|
+
consecutive: ledger.cursorStall.consecutive,
|
|
560
|
+
since: new Date(ledger.cursorStall.sinceMs).toISOString(),
|
|
561
|
+
lastAt: new Date(ledger.cursorStall.lastAtMs).toISOString(),
|
|
562
|
+
reason: ledger.cursorStall.reason,
|
|
563
|
+
...(ledger.cursorStall.stream ? { stream: ledger.cursorStall.stream } : {})
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
: {}),
|
|
567
|
+
/*
|
|
568
|
+
* **세우지 않은 것도 낸다 — 양쪽 갈래에서.** 유입이 한 건도 없는 장부에서도 이 사실은 실재한다
|
|
569
|
+
* (원본이 말하는데 전부 안 받는 경우가 있다). 여기서 떨어뜨리면 바로 그 상황에서 들어온 것이 없어진다.
|
|
570
|
+
*/
|
|
571
|
+
...(ledger?.withheld?.length
|
|
572
|
+
? {
|
|
573
|
+
withheld: ledger.withheld.map(w => ({
|
|
574
|
+
reason: w.reason,
|
|
575
|
+
count: w.count,
|
|
576
|
+
lastAt: new Date(w.lastAtMs).toISOString()
|
|
577
|
+
}))
|
|
578
|
+
}
|
|
579
|
+
: {})
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/* 「통과」는 트윈에 닿은 것만이다 — 창 뷰와 같은 규칙. */
|
|
583
|
+
const accepted = ledger.offered - ledger.rejected - ledger.undelivered;
|
|
584
|
+
return {
|
|
585
|
+
verdict,
|
|
586
|
+
threshold,
|
|
587
|
+
recent: recentRaw ? toWindowView(recentRaw, lastAnyMs) : null,
|
|
588
|
+
trend: ledger.trend.map(w => toWindowView(w, lastAnyMs)),
|
|
589
|
+
trendDropped: ledger.trendDropped,
|
|
590
|
+
/* 세우지 않은 것 — 양쪽 갈래에서 같은 모양으로 낸다(§`IngestLedger.withheld`). */
|
|
591
|
+
...(ledger.withheld?.length
|
|
592
|
+
? {
|
|
593
|
+
withheld: ledger.withheld.map(w => ({
|
|
594
|
+
reason: w.reason,
|
|
595
|
+
count: w.count,
|
|
596
|
+
lastAt: new Date(w.lastAtMs).toISOString()
|
|
597
|
+
}))
|
|
598
|
+
}
|
|
599
|
+
: {}),
|
|
600
|
+
/*
|
|
601
|
+
* **어떤 길로 들어오고 있나** — 화면이 「이 트윈은 무엇을 통해 값을 받나」를 말할 수 있게.
|
|
602
|
+
* 비어 있으면 칸을 만들지 않는다: 아직 아무것도 안 받은 트윈과 「길이 없다」를 구별한다.
|
|
603
|
+
*/
|
|
604
|
+
...(Object.keys(ledger.bySource).length
|
|
605
|
+
? {
|
|
606
|
+
sources: Object.entries(ledger.bySource)
|
|
607
|
+
.map(([source, v]) => ({ source, count: v.count, lastAt: new Date(v.lastAtMs).toISOString() }))
|
|
608
|
+
.sort((a, b) => b.count - a.count)
|
|
609
|
+
}
|
|
610
|
+
: {}),
|
|
611
|
+
/* 같은 사실이 두 번 와서 버린 수 — 0 이면 칸을 만들지 않는다(버린 적이 없다는 뜻이다). */
|
|
612
|
+
...(ledger.duplicates
|
|
613
|
+
? { duplicates: { count: ledger.duplicates, lastAt: new Date(ledger.lastDuplicateAtMs).toISOString() } }
|
|
614
|
+
: {}),
|
|
615
|
+
total: {
|
|
616
|
+
offered: ledger.offered,
|
|
617
|
+
accepted,
|
|
618
|
+
rejected: ledger.rejected,
|
|
619
|
+
undelivered: ledger.undelivered,
|
|
620
|
+
acceptedRatio: ledger.offered > 0 ? round4(accepted / ledger.offered) : null,
|
|
621
|
+
rows: ledger.rows,
|
|
622
|
+
flushes: ledger.flushes
|
|
623
|
+
},
|
|
624
|
+
reasons: Object.entries(ledger.byReason)
|
|
625
|
+
.map(([reason, v]) => ({ reason, count: v.count, lastAt: new Date(v.lastAtMs).toISOString() }))
|
|
626
|
+
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)),
|
|
627
|
+
reasonsCapped: ledger.reasonsCapped,
|
|
628
|
+
samples: [...ledger.samples],
|
|
629
|
+
sampleDropped: ledger.sampleDropped,
|
|
630
|
+
/* 「받은 적 없음」과 「방금 받음」을 가른다 — 적기만 하는 트윈의 유입 시각은 **없다**. */
|
|
631
|
+
lastAt: ledger.lastAtMs >= 0 ? new Date(ledger.lastAtMs).toISOString() : null,
|
|
632
|
+
lastWriteAt: ledger.lastWriteAtMs >= 0 ? new Date(ledger.lastWriteAtMs).toISOString() : null,
|
|
633
|
+
/* 닿지 못하고 있으면 그 사실을 낸다 — 없으면 필드를 만들지 않는다(빈 값이 「닿는다」로 읽히지 않게). */
|
|
634
|
+
...(ledger.readFailure
|
|
635
|
+
? {
|
|
636
|
+
readFailure: {
|
|
637
|
+
consecutive: ledger.readFailure.consecutive,
|
|
638
|
+
since: new Date(ledger.readFailure.sinceMs).toISOString(),
|
|
639
|
+
lastAt: new Date(ledger.readFailure.lastAtMs).toISOString(),
|
|
640
|
+
reason: ledger.readFailure.reason,
|
|
641
|
+
...(ledger.readFailure.stream ? { stream: ledger.readFailure.stream } : {}),
|
|
642
|
+
...(ledger.readFailure.nextRetryMs ? { nextRetryMs: ledger.readFailure.nextRetryMs } : {})
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
: {}),
|
|
646
|
+
/* 끊겼다가 돌아온 것 — 지금 닿고 있어도 남긴다(그것이 없으면 밤새의 단절이 사라진다). */
|
|
647
|
+
...(ledger.lastRecovery
|
|
648
|
+
? {
|
|
649
|
+
lastRecovery: {
|
|
650
|
+
at: new Date(ledger.lastRecovery.atMs).toISOString(),
|
|
651
|
+
afterFailures: ledger.lastRecovery.afterFailures,
|
|
652
|
+
downMs: ledger.lastRecovery.downMs
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
: {}),
|
|
656
|
+
...(ledger.cursorStall
|
|
657
|
+
? {
|
|
658
|
+
cursorStall: {
|
|
659
|
+
consecutive: ledger.cursorStall.consecutive,
|
|
660
|
+
since: new Date(ledger.cursorStall.sinceMs).toISOString(),
|
|
661
|
+
lastAt: new Date(ledger.cursorStall.lastAtMs).toISOString(),
|
|
662
|
+
reason: ledger.cursorStall.reason,
|
|
663
|
+
...(ledger.cursorStall.stream ? { stream: ledger.cursorStall.stream } : {})
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
: {})
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
/** 비율은 자릿수를 넉넉히 준다 — 0.999 가 1 로 반올림되면 「전부 통과」로 읽힌다. */
|
|
670
|
+
const round4 = (v) => Math.round(v * 10000) / 10000;
|
|
671
|
+
//# sourceMappingURL=ingest-health.js.map
|