@kici-dev/shared 0.0.0 → 0.1.1
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/LICENSE +202 -0
- package/README.md +1 -6
- package/dist/chunk-gOLHoazu.js +4 -0
- package/dist/cold-store/bucket.d.ts +26 -0
- package/dist/cold-store/bucket.js +40 -0
- package/dist/cold-store/bucket.test.d.ts +2 -0
- package/dist/cold-store/chunk-encoder.d.ts +63 -0
- package/dist/cold-store/chunk-encoder.js +94 -0
- package/dist/cold-store/chunk-encoder.test.d.ts +2 -0
- package/dist/cold-store/chunk-id.d.ts +10 -0
- package/dist/cold-store/chunk-id.js +29 -0
- package/dist/cold-store/chunk-id.test.d.ts +2 -0
- package/dist/cold-store/cold-store.d.ts +345 -0
- package/dist/cold-store/cold-store.js +1078 -0
- package/dist/cold-store/cold-store.test.d.ts +2 -0
- package/dist/cold-store/config.d.ts +62 -0
- package/dist/cold-store/config.js +28 -0
- package/dist/cold-store/index.d.ts +20 -0
- package/dist/cold-store/index.js +11 -0
- package/dist/cold-store/key.d.ts +89 -0
- package/dist/cold-store/key.js +88 -0
- package/dist/cold-store/key.test.d.ts +2 -0
- package/dist/cold-store/lru.d.ts +33 -0
- package/dist/cold-store/lru.js +59 -0
- package/dist/cold-store/lru.test.d.ts +2 -0
- package/dist/cold-store/manifest.d.ts +11 -0
- package/dist/cold-store/manifest.js +57 -0
- package/dist/cold-store/manifest.test.d.ts +2 -0
- package/dist/cold-store/metrics.d.ts +103 -0
- package/dist/cold-store/metrics.js +179 -0
- package/dist/cold-store/table-adapter.d.ts +229 -0
- package/dist/cold-store/table-adapter.js +2 -0
- package/dist/cold-store/types.d.ts +111 -0
- package/dist/cold-store/types.js +2 -0
- package/dist/crypto.d.ts +33 -0
- package/dist/crypto.js +67 -0
- package/dist/db-admin.d.ts +1422 -0
- package/dist/db-admin.js +2525 -0
- package/dist/db-admin.test.d.ts +2 -0
- package/dist/db.d.ts +14 -0
- package/dist/db.js +23 -0
- package/dist/env/allowlist.d.ts +79 -0
- package/dist/env/allowlist.js +86 -0
- package/dist/env/define-env.d.ts +172 -0
- package/dist/env/define-env.js +295 -0
- package/dist/env/define-env.test.d.ts +2 -0
- package/dist/env/env-rule-allowlist.test.d.ts +2 -0
- package/dist/env/index.d.ts +11 -0
- package/dist/env/index.js +4 -0
- package/dist/env/logger-env.d.ts +30 -0
- package/dist/env/logger-env.js +95 -0
- package/dist/error.d.ts +16 -0
- package/dist/error.js +58 -0
- package/dist/error.test.d.ts +2 -0
- package/dist/format-bytes.d.ts +5 -0
- package/dist/format-bytes.js +15 -0
- package/dist/format-bytes.test.d.ts +2 -0
- package/dist/format-duration.d.ts +11 -0
- package/dist/format-duration.js +32 -0
- package/dist/format-duration.test.d.ts +2 -0
- package/dist/graceful-shutdown.d.ts +58 -0
- package/dist/graceful-shutdown.js +71 -0
- package/dist/graceful-shutdown.test.d.ts +2 -0
- package/dist/idempotency-files.d.ts +113 -0
- package/dist/idempotency-files.js +190 -0
- package/dist/idempotency-files.test.d.ts +2 -0
- package/dist/idempotency.d.ts +68 -0
- package/dist/idempotency.js +49 -0
- package/dist/idempotency.test.d.ts +2 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +31 -0
- package/dist/logger.d.ts +57 -0
- package/dist/logger.js +175 -0
- package/dist/logger.test.d.ts +2 -0
- package/dist/reconnect-delay.d.ts +11 -0
- package/dist/reconnect-delay.js +22 -0
- package/dist/request-context.d.ts +42 -0
- package/dist/request-context.js +37 -0
- package/dist/ring-buffer.d.ts +35 -0
- package/dist/ring-buffer.js +62 -0
- package/dist/ring-buffer.test.d.ts +2 -0
- package/dist/routes/health.d.ts +26 -0
- package/dist/routes/health.js +45 -0
- package/dist/routes/health.test.d.ts +2 -0
- package/dist/routes/metrics.d.ts +20 -0
- package/dist/routes/metrics.js +38 -0
- package/dist/s3-client.d.ts +42 -0
- package/dist/s3-client.js +25 -0
- package/dist/telemetry/index.d.ts +3 -0
- package/dist/telemetry/index.js +4 -0
- package/dist/telemetry/init.d.ts +20 -0
- package/dist/telemetry/init.js +42 -0
- package/dist/telemetry/init.test.d.ts +2 -0
- package/dist/telemetry/metrics.d.ts +8 -0
- package/dist/telemetry/metrics.js +16 -0
- package/dist/tool-check.d.ts +33 -0
- package/dist/tool-check.js +60 -0
- package/dist/tool-check.test.d.ts +2 -0
- package/dist/ts-loader-hook.d.ts +26 -0
- package/dist/ts-loader-hook.js +48 -0
- package/dist/zx.d.ts +8 -0
- package/dist/zx.js +78 -0
- package/package.json +69 -5
- package/sbom.spdx.json +8775 -0
- package/index.js +0 -3
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
import "../chunk-gOLHoazu.js";
|
|
2
|
+
import { sha256 } from "../crypto.js";
|
|
3
|
+
import { createS3Client } from "../s3-client.js";
|
|
4
|
+
import { chunkObjectKey, encodeKeySegment, tablePrefix, tenantDayPrefix } from "./key.js";
|
|
5
|
+
import { coldDaysToBucket, isLongerColdRetention } from "./bucket.js";
|
|
6
|
+
import { computeChunkId } from "./chunk-id.js";
|
|
7
|
+
import { decodeChunk, encodeChunk } from "./chunk-encoder.js";
|
|
8
|
+
import { parseManifest, serializeManifest } from "./manifest.js";
|
|
9
|
+
import { coldStoreArchiveBytesTotal, coldStoreArchiveCyclesTotal, coldStoreArchiveDurationSeconds, coldStoreArchiveRowsTotal, coldStorePurgeBytesTotal, coldStorePurgeChunksTotal, coldStorePurgeDurationSeconds, coldStoreRehydrateBytesTotal, coldStoreRehydrateDurationSeconds, coldStoreRehydrateRequestsTotal, coldStoreReplayDurationSeconds, coldStoreReplayRowsTotal, coldStoreVerifyFailuresTotal } from "./metrics.js";
|
|
10
|
+
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
11
|
+
//#region src/cold-store/cold-store.ts
|
|
12
|
+
/**
|
|
13
|
+
* BaseColdStore — the DB-agnostic framework class.
|
|
14
|
+
*
|
|
15
|
+
* Multiple concrete subclasses extend this one (e.g., `PlatformColdStore`,
|
|
16
|
+
* `OrchestratorColdStore`). Each wires the adapter map with its
|
|
17
|
+
* package-specific adapters.
|
|
18
|
+
*
|
|
19
|
+
* Phase B: `runArchiveCycle()` now drives the per-adapter archive loop
|
|
20
|
+
* (design §4). `fetchRange` / `hasRange` / `countRange` are real — they
|
|
21
|
+
* resolve manifests via `ListObjectsV2` and GET overlapping chunks
|
|
22
|
+
* through an in-process LRU.
|
|
23
|
+
*
|
|
24
|
+
* ## Advisory-lock namespace
|
|
25
|
+
*
|
|
26
|
+
* All Postgres advisory locks acquired by the cold-store framework use
|
|
27
|
+
* keys derived from `hashtext('cold-store|...')`. The `cold-store|`
|
|
28
|
+
* prefix is reserved — do NOT reuse it in pipeline or engine code
|
|
29
|
+
* without coordinating to avoid collisions.
|
|
30
|
+
*
|
|
31
|
+
* ## S3 bucket versioning caveat
|
|
32
|
+
*
|
|
33
|
+
* When the cold-store bucket has versioning ON (recommended default),
|
|
34
|
+
* retried chunk PUTs produce new object versions at the same key.
|
|
35
|
+
* Reconciliation (the `reconcile` CLI) MUST use `ListObjectVersions`,
|
|
36
|
+
* not `ListObjects`, to detect orphaned objects correctly. The live
|
|
37
|
+
* read-through uses `ListObjectsV2` because it only cares about the
|
|
38
|
+
* current version of each manifest.
|
|
39
|
+
*/
|
|
40
|
+
const CONTENT_HASH_META = "content-hash";
|
|
41
|
+
var BaseColdStore = class {
|
|
42
|
+
db;
|
|
43
|
+
config;
|
|
44
|
+
instanceId;
|
|
45
|
+
log;
|
|
46
|
+
chunkCache;
|
|
47
|
+
s3;
|
|
48
|
+
/** Registered table adapters by `table` name. */
|
|
49
|
+
adapters = /* @__PURE__ */ new Map();
|
|
50
|
+
constructor(deps) {
|
|
51
|
+
this.db = deps.db;
|
|
52
|
+
this.config = deps.config;
|
|
53
|
+
this.instanceId = deps.instanceId;
|
|
54
|
+
this.log = deps.log;
|
|
55
|
+
this.chunkCache = deps.chunkCache;
|
|
56
|
+
this.s3 = deps.s3Client ?? createS3Client({
|
|
57
|
+
region: deps.config.storage.region,
|
|
58
|
+
endpoint: deps.config.storage.endpoint,
|
|
59
|
+
forcePathStyle: deps.config.storage.forcePathStyle
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Register a concrete table adapter. Subclasses typically call this
|
|
64
|
+
* from their constructor.
|
|
65
|
+
*/
|
|
66
|
+
registerAdapter(adapter) {
|
|
67
|
+
if (adapter.db !== this.db) throw new Error(`BaseColdStore: adapter db=${adapter.db} does not match cold-store db=${this.db}`);
|
|
68
|
+
if (this.adapters.has(adapter.table)) throw new Error(`BaseColdStore: duplicate adapter for table ${adapter.table}`);
|
|
69
|
+
this.adapters.set(adapter.table, adapter);
|
|
70
|
+
}
|
|
71
|
+
/** Test / CLI helper — adapter-map read access. */
|
|
72
|
+
getAdapter(table) {
|
|
73
|
+
return this.adapters.get(table);
|
|
74
|
+
}
|
|
75
|
+
listAdapters() {
|
|
76
|
+
return Array.from(this.adapters.values());
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* CLI-accessible S3 primitives. Exposed so admin CLI commands can run
|
|
80
|
+
* inspection / reconciliation operations without a direct dependency
|
|
81
|
+
* on `@aws-sdk/client-s3`. Not intended for hot-path use.
|
|
82
|
+
*/
|
|
83
|
+
get bucket() {
|
|
84
|
+
return this.config.storage.bucket;
|
|
85
|
+
}
|
|
86
|
+
get storagePrefix() {
|
|
87
|
+
return this.config.storage.prefix;
|
|
88
|
+
}
|
|
89
|
+
/** List all object keys under a prefix, paging internally. */
|
|
90
|
+
async listObjectKeys(prefix) {
|
|
91
|
+
const keys = [];
|
|
92
|
+
let continuationToken;
|
|
93
|
+
do {
|
|
94
|
+
const resp = await this.s3.send(new ListObjectsV2Command({
|
|
95
|
+
Bucket: this.config.storage.bucket,
|
|
96
|
+
Prefix: prefix,
|
|
97
|
+
ContinuationToken: continuationToken,
|
|
98
|
+
MaxKeys: 1e3
|
|
99
|
+
}));
|
|
100
|
+
for (const obj of resp.Contents ?? []) if (obj.Key) keys.push(obj.Key);
|
|
101
|
+
continuationToken = resp.IsTruncated ? resp.NextContinuationToken : void 0;
|
|
102
|
+
} while (continuationToken);
|
|
103
|
+
return keys;
|
|
104
|
+
}
|
|
105
|
+
/** Fetch an object's raw body as a Buffer. */
|
|
106
|
+
async getObjectBody(key) {
|
|
107
|
+
const resp = await this.s3.send(new GetObjectCommand({
|
|
108
|
+
Bucket: this.config.storage.bucket,
|
|
109
|
+
Key: key
|
|
110
|
+
}));
|
|
111
|
+
if (!resp.Body) throw new Error(`cold-store: empty body for ${key}`);
|
|
112
|
+
const bytes = await resp.Body.transformToByteArray();
|
|
113
|
+
return Buffer.from(bytes);
|
|
114
|
+
}
|
|
115
|
+
/** Upload a manifest JSON object at `key`. */
|
|
116
|
+
async putManifestObject(key, manifest) {
|
|
117
|
+
return this.putManifest(key, manifest);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Delete a single object by key. CLI-accessible companion to
|
|
121
|
+
* `listObjectKeys` / `getObjectBody`, used by tenant-scoped sweep
|
|
122
|
+
* paths (e.g. the post-`deleteOrgCascade` S3 cleanup) that operate
|
|
123
|
+
* on chunks the index already forgot about. Does NOT touch the
|
|
124
|
+
* `cold_store_chunks` index — callers that need both must follow
|
|
125
|
+
* the `purgeExpiredChunks` pattern with `withPurgeLock` +
|
|
126
|
+
* `adapter.purgeChunkRecord`.
|
|
127
|
+
*/
|
|
128
|
+
async deleteObject(key) {
|
|
129
|
+
await this.s3.send(new DeleteObjectCommand({
|
|
130
|
+
Bucket: this.config.storage.bucket,
|
|
131
|
+
Key: key
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
async runArchiveCycle(opts) {
|
|
135
|
+
const start = Date.now();
|
|
136
|
+
const summary = {
|
|
137
|
+
tablesProcessed: 0,
|
|
138
|
+
chunksWritten: 0,
|
|
139
|
+
rowsArchived: 0,
|
|
140
|
+
rowsFailed: 0,
|
|
141
|
+
skipped: {
|
|
142
|
+
disabled: 0,
|
|
143
|
+
min_chunk: 0,
|
|
144
|
+
min_warm: 0,
|
|
145
|
+
no_tables: 0
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
if (!this.config.enabled) {
|
|
149
|
+
this.log("info", "cold-store cycle skipped: disabled by config", {
|
|
150
|
+
db: this.db,
|
|
151
|
+
instanceId: this.instanceId
|
|
152
|
+
});
|
|
153
|
+
summary.skipped.disabled += 1;
|
|
154
|
+
coldStoreArchiveCyclesTotal().add(1, {
|
|
155
|
+
db: this.db,
|
|
156
|
+
result: "disabled"
|
|
157
|
+
});
|
|
158
|
+
return summary;
|
|
159
|
+
}
|
|
160
|
+
if (this.adapters.size === 0) {
|
|
161
|
+
coldStoreArchiveCyclesTotal().add(1, {
|
|
162
|
+
db: this.db,
|
|
163
|
+
result: "no_tables"
|
|
164
|
+
});
|
|
165
|
+
summary.skipped.no_tables += 1;
|
|
166
|
+
this.log("info", "cold-store cycle: no adapters registered", {
|
|
167
|
+
db: this.db,
|
|
168
|
+
instanceId: this.instanceId,
|
|
169
|
+
durationMs: Date.now() - start
|
|
170
|
+
});
|
|
171
|
+
return summary;
|
|
172
|
+
}
|
|
173
|
+
const tableFilter = opts?.tableFilter;
|
|
174
|
+
if (tableFilter && !this.adapters.has(tableFilter)) throw new Error(`runArchiveCycle: tableFilter='${tableFilter}' has no registered adapter on db=${this.db} (registered: ${[...this.adapters.keys()].join(", ") || "(none)"})`);
|
|
175
|
+
for (const adapter of this.adapters.values()) {
|
|
176
|
+
if (tableFilter && adapter.table !== tableFilter) continue;
|
|
177
|
+
await this.processAdapter(adapter, summary);
|
|
178
|
+
summary.tablesProcessed += 1;
|
|
179
|
+
}
|
|
180
|
+
const cycleResult = summary.rowsFailed > 0 ? "failure" : summary.chunksWritten > 0 ? "success" : "no_tables";
|
|
181
|
+
coldStoreArchiveCyclesTotal().add(1, {
|
|
182
|
+
db: this.db,
|
|
183
|
+
result: cycleResult
|
|
184
|
+
});
|
|
185
|
+
this.log("info", "cold-store cycle complete", {
|
|
186
|
+
db: this.db,
|
|
187
|
+
instanceId: this.instanceId,
|
|
188
|
+
durationMs: Date.now() - start,
|
|
189
|
+
summary
|
|
190
|
+
});
|
|
191
|
+
return summary;
|
|
192
|
+
}
|
|
193
|
+
async processAdapter(adapter, summary) {
|
|
194
|
+
if (!adapter.config.enabled) {
|
|
195
|
+
summary.skipped.disabled += 1;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const warmCutoff = /* @__PURE__ */ new Date(Date.now() - adapter.config.warmTtlDays * 864e5);
|
|
199
|
+
let rowsThisCycle = 0;
|
|
200
|
+
for await (const { tenantId, partitionDate } of adapter.listEligiblePartitions({ warmCutoff })) {
|
|
201
|
+
if (rowsThisCycle >= adapter.config.maxRowsPerCycle) {
|
|
202
|
+
this.log("info", "cold-store: per-cycle row cap reached; yielding", {
|
|
203
|
+
db: this.db,
|
|
204
|
+
table: adapter.table,
|
|
205
|
+
rowsThisCycle
|
|
206
|
+
});
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
if (await adapter.countTenantWarmBytes({
|
|
210
|
+
tenantId,
|
|
211
|
+
warmCutoff
|
|
212
|
+
}) < adapter.config.minWarmTenantBytes) {
|
|
213
|
+
summary.skipped.min_warm += 1;
|
|
214
|
+
coldStoreArchiveRowsTotal().add(0, {
|
|
215
|
+
db: this.db,
|
|
216
|
+
table: adapter.table,
|
|
217
|
+
result: "skipped_min_warm"
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const lockResult = await adapter.withPartitionLock({
|
|
222
|
+
tenantId,
|
|
223
|
+
partitionDate
|
|
224
|
+
}, async () => {
|
|
225
|
+
return this.archivePartition(adapter, tenantId, partitionDate, summary);
|
|
226
|
+
});
|
|
227
|
+
if (lockResult === null) {
|
|
228
|
+
this.log("info", "cold-store: another archiver holds the partition lock; skipping", {
|
|
229
|
+
db: this.db,
|
|
230
|
+
table: adapter.table,
|
|
231
|
+
tenantId,
|
|
232
|
+
partitionDate
|
|
233
|
+
});
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
rowsThisCycle += lockResult.rowsArchived;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Archive one (tenant, partitionDate).
|
|
241
|
+
*
|
|
242
|
+
* - Adapters WITHOUT `coldTtlDays`: legacy single-chunk path. Writes one
|
|
243
|
+
* chunk per partition at the day-prefix root with a v1 manifest. The
|
|
244
|
+
* GC sweep treats v1 chunks as `'forever'`.
|
|
245
|
+
* - Adapters WITH `coldTtlDays`: Phase 2 per-bucket path. Buffers all
|
|
246
|
+
* eligible rows, groups by `coldDaysToBucket(coldTtlDays(row))`,
|
|
247
|
+
* then emits one chunk per non-empty bucket under the bucket
|
|
248
|
+
* subprefix with a v2 manifest carrying `bucket` + `maxColdDays`.
|
|
249
|
+
*
|
|
250
|
+
* In both paths the caller holds the advisory lock for the
|
|
251
|
+
* partition. Returns the total row count archived across all chunks
|
|
252
|
+
* so the outer loop can honor `maxRowsPerCycle`.
|
|
253
|
+
*/
|
|
254
|
+
async archivePartition(adapter, tenantId, partitionDate, summary) {
|
|
255
|
+
if (adapter.coldTtlDays) return this.archivePartitionBucketed(adapter, tenantId, partitionDate, summary);
|
|
256
|
+
return this.archivePartitionLegacy(adapter, tenantId, partitionDate, summary);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Legacy single-chunk-per-partition flow. Adapters that do not
|
|
260
|
+
* implement `coldTtlDays` get a v1 manifest at the day-prefix root.
|
|
261
|
+
*/
|
|
262
|
+
async archivePartitionLegacy(adapter, tenantId, partitionDate, summary) {
|
|
263
|
+
const partitionStart = Date.now();
|
|
264
|
+
const label = {
|
|
265
|
+
db: this.db,
|
|
266
|
+
table: adapter.table
|
|
267
|
+
};
|
|
268
|
+
let encoded;
|
|
269
|
+
try {
|
|
270
|
+
encoded = await encodeChunk({
|
|
271
|
+
rows: adapter.selectEligible({
|
|
272
|
+
tenantId,
|
|
273
|
+
partitionDate,
|
|
274
|
+
limit: adapter.config.maxRowsPerCycle
|
|
275
|
+
}),
|
|
276
|
+
encodeRow: adapter.encodeRow.bind(adapter),
|
|
277
|
+
rowId: adapter.rowId.bind(adapter),
|
|
278
|
+
rowTimestamp: adapter.rowTimestamp.bind(adapter),
|
|
279
|
+
replayLookupKey: adapter.replayLookupKey?.bind(adapter)
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (err instanceof Error && /empty row stream/.test(err.message)) {
|
|
283
|
+
summary.skipped.min_chunk += 1;
|
|
284
|
+
return { rowsArchived: 0 };
|
|
285
|
+
}
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
if (encoded.gzipByteCount < adapter.config.minChunkBytes) {
|
|
289
|
+
summary.skipped.min_chunk += 1;
|
|
290
|
+
coldStoreArchiveRowsTotal().add(0, {
|
|
291
|
+
...label,
|
|
292
|
+
result: "skipped_min_chunk"
|
|
293
|
+
});
|
|
294
|
+
return { rowsArchived: 0 };
|
|
295
|
+
}
|
|
296
|
+
return this.writeChunk(adapter, {
|
|
297
|
+
tenantId,
|
|
298
|
+
partitionDate,
|
|
299
|
+
encoded,
|
|
300
|
+
partitionStart,
|
|
301
|
+
summary,
|
|
302
|
+
bucket: void 0,
|
|
303
|
+
maxColdDays: void 0
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Phase 2 per-bucket flow. Buffers all eligible rows, groups by
|
|
308
|
+
* `coldDaysToBucket(coldTtlDays(row))`, then emits one chunk per
|
|
309
|
+
* non-empty bucket.
|
|
310
|
+
*/
|
|
311
|
+
async archivePartitionBucketed(adapter, tenantId, partitionDate, summary) {
|
|
312
|
+
const label = {
|
|
313
|
+
db: this.db,
|
|
314
|
+
table: adapter.table
|
|
315
|
+
};
|
|
316
|
+
const coldTtlDays = adapter.coldTtlDays.bind(adapter);
|
|
317
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
318
|
+
let bufferedRows = 0;
|
|
319
|
+
for await (const row of adapter.selectEligible({
|
|
320
|
+
tenantId,
|
|
321
|
+
partitionDate,
|
|
322
|
+
limit: adapter.config.maxRowsPerCycle
|
|
323
|
+
})) {
|
|
324
|
+
const ttl = coldTtlDays(row);
|
|
325
|
+
const bucket = coldDaysToBucket(ttl);
|
|
326
|
+
const entry = buckets.get(bucket);
|
|
327
|
+
if (entry) {
|
|
328
|
+
entry.rows.push(row);
|
|
329
|
+
if (isLongerColdRetention(ttl, entry.maxColdDays)) entry.maxColdDays = ttl;
|
|
330
|
+
} else buckets.set(bucket, {
|
|
331
|
+
rows: [row],
|
|
332
|
+
maxColdDays: ttl
|
|
333
|
+
});
|
|
334
|
+
bufferedRows += 1;
|
|
335
|
+
}
|
|
336
|
+
if (bufferedRows === 0) {
|
|
337
|
+
summary.skipped.min_chunk += 1;
|
|
338
|
+
return { rowsArchived: 0 };
|
|
339
|
+
}
|
|
340
|
+
let totalArchived = 0;
|
|
341
|
+
for (const [bucket, { rows, maxColdDays }] of buckets) {
|
|
342
|
+
const partitionStart = Date.now();
|
|
343
|
+
let encoded;
|
|
344
|
+
try {
|
|
345
|
+
encoded = await encodeChunk({
|
|
346
|
+
rows: arrayToAsync(rows),
|
|
347
|
+
encodeRow: adapter.encodeRow.bind(adapter),
|
|
348
|
+
rowId: adapter.rowId.bind(adapter),
|
|
349
|
+
rowTimestamp: adapter.rowTimestamp.bind(adapter),
|
|
350
|
+
replayLookupKey: adapter.replayLookupKey?.bind(adapter)
|
|
351
|
+
});
|
|
352
|
+
} catch (err) {
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
if (encoded.gzipByteCount < adapter.config.minChunkBytes) {
|
|
356
|
+
summary.skipped.min_chunk += 1;
|
|
357
|
+
coldStoreArchiveRowsTotal().add(0, {
|
|
358
|
+
...label,
|
|
359
|
+
result: "skipped_min_chunk"
|
|
360
|
+
});
|
|
361
|
+
this.log("info", "cold-store: bucket below minChunkBytes; skipping this cycle", {
|
|
362
|
+
...label,
|
|
363
|
+
tenantId,
|
|
364
|
+
partitionDate,
|
|
365
|
+
bucket,
|
|
366
|
+
rowCount: rows.length,
|
|
367
|
+
gzipByteCount: encoded.gzipByteCount,
|
|
368
|
+
minChunkBytes: adapter.config.minChunkBytes
|
|
369
|
+
});
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const result = await this.writeChunk(adapter, {
|
|
373
|
+
tenantId,
|
|
374
|
+
partitionDate,
|
|
375
|
+
encoded,
|
|
376
|
+
partitionStart,
|
|
377
|
+
summary,
|
|
378
|
+
bucket,
|
|
379
|
+
maxColdDays
|
|
380
|
+
});
|
|
381
|
+
totalArchived += result.rowsArchived;
|
|
382
|
+
}
|
|
383
|
+
return { rowsArchived: totalArchived };
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Common chunk-write flow: PUT data, verify, PUT manifest, transactional
|
|
387
|
+
* mark-archived-and-delete on the adapter, increment metrics.
|
|
388
|
+
*
|
|
389
|
+
* Shared by `archivePartitionLegacy` (v1 manifests) and
|
|
390
|
+
* `archivePartitionBucketed` (v2 manifests with bucket + maxColdDays).
|
|
391
|
+
*/
|
|
392
|
+
async writeChunk(adapter, args) {
|
|
393
|
+
const { tenantId, partitionDate, encoded, partitionStart, summary, bucket, maxColdDays } = args;
|
|
394
|
+
const label = {
|
|
395
|
+
db: this.db,
|
|
396
|
+
table: adapter.table
|
|
397
|
+
};
|
|
398
|
+
if (bucket === void 0 !== (maxColdDays === void 0)) throw new Error(`cold-store writeChunk: bucket and maxColdDays must both be set or both undefined; got bucket=${JSON.stringify(bucket)} maxColdDays=${JSON.stringify(maxColdDays)}`);
|
|
399
|
+
const chunkId = computeChunkId({
|
|
400
|
+
db: this.db,
|
|
401
|
+
table: adapter.table,
|
|
402
|
+
tenantId,
|
|
403
|
+
partitionDate,
|
|
404
|
+
minRowId: encoded.minRowId,
|
|
405
|
+
maxRowId: encoded.maxRowId
|
|
406
|
+
});
|
|
407
|
+
const keyArgs = {
|
|
408
|
+
prefix: this.config.storage.prefix,
|
|
409
|
+
db: this.db,
|
|
410
|
+
table: adapter.table,
|
|
411
|
+
tenantId,
|
|
412
|
+
partitionDate,
|
|
413
|
+
chunkId,
|
|
414
|
+
bucket
|
|
415
|
+
};
|
|
416
|
+
const dataKey = chunkObjectKey({
|
|
417
|
+
...keyArgs,
|
|
418
|
+
kind: "data"
|
|
419
|
+
});
|
|
420
|
+
const manifestKey = chunkObjectKey({
|
|
421
|
+
...keyArgs,
|
|
422
|
+
kind: "manifest"
|
|
423
|
+
});
|
|
424
|
+
try {
|
|
425
|
+
await this.putChunkData(dataKey, encoded.data, encoded.contentHash);
|
|
426
|
+
await this.verifyChunkData(dataKey, encoded.contentHash, adapter.table);
|
|
427
|
+
const manifest = bucket !== void 0 && maxColdDays !== void 0 ? {
|
|
428
|
+
schemaVersion: 2,
|
|
429
|
+
db: this.db,
|
|
430
|
+
table: adapter.table,
|
|
431
|
+
tenantId,
|
|
432
|
+
partitionDate,
|
|
433
|
+
rowCount: encoded.rowCount,
|
|
434
|
+
byteCount: encoded.byteCount,
|
|
435
|
+
gzipByteCount: encoded.gzipByteCount,
|
|
436
|
+
minTimestamp: encoded.minTimestamp,
|
|
437
|
+
maxTimestamp: encoded.maxTimestamp,
|
|
438
|
+
minRowId: encoded.minRowId,
|
|
439
|
+
maxRowId: encoded.maxRowId,
|
|
440
|
+
contentHash: encoded.contentHash,
|
|
441
|
+
chunkId,
|
|
442
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
443
|
+
archiverInstanceId: this.instanceId,
|
|
444
|
+
replayLookupKeys: encoded.replayLookupKeys,
|
|
445
|
+
bucket,
|
|
446
|
+
maxColdDays
|
|
447
|
+
} : {
|
|
448
|
+
schemaVersion: 1,
|
|
449
|
+
db: this.db,
|
|
450
|
+
table: adapter.table,
|
|
451
|
+
tenantId,
|
|
452
|
+
partitionDate,
|
|
453
|
+
rowCount: encoded.rowCount,
|
|
454
|
+
byteCount: encoded.byteCount,
|
|
455
|
+
gzipByteCount: encoded.gzipByteCount,
|
|
456
|
+
minTimestamp: encoded.minTimestamp,
|
|
457
|
+
maxTimestamp: encoded.maxTimestamp,
|
|
458
|
+
minRowId: encoded.minRowId,
|
|
459
|
+
maxRowId: encoded.maxRowId,
|
|
460
|
+
contentHash: encoded.contentHash,
|
|
461
|
+
chunkId,
|
|
462
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
463
|
+
archiverInstanceId: this.instanceId,
|
|
464
|
+
replayLookupKeys: encoded.replayLookupKeys
|
|
465
|
+
};
|
|
466
|
+
await this.putManifest(manifestKey, manifest);
|
|
467
|
+
await adapter.markArchivedAndDelete({
|
|
468
|
+
rowIds: encoded.rowIds,
|
|
469
|
+
chunkMeta: {
|
|
470
|
+
chunkId,
|
|
471
|
+
tenantId,
|
|
472
|
+
partitionDate,
|
|
473
|
+
rowCount: encoded.rowCount,
|
|
474
|
+
byteCount: encoded.byteCount,
|
|
475
|
+
gzipByteCount: encoded.gzipByteCount,
|
|
476
|
+
objectKey: dataKey,
|
|
477
|
+
bucket,
|
|
478
|
+
maxColdDays
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
summary.chunksWritten += 1;
|
|
482
|
+
summary.rowsArchived += encoded.rowCount;
|
|
483
|
+
coldStoreArchiveRowsTotal().add(encoded.rowCount, {
|
|
484
|
+
...label,
|
|
485
|
+
result: "success"
|
|
486
|
+
});
|
|
487
|
+
coldStoreArchiveBytesTotal().add(encoded.byteCount, {
|
|
488
|
+
...label,
|
|
489
|
+
kind: "raw"
|
|
490
|
+
});
|
|
491
|
+
coldStoreArchiveBytesTotal().add(encoded.gzipByteCount, {
|
|
492
|
+
...label,
|
|
493
|
+
kind: "gzipped"
|
|
494
|
+
});
|
|
495
|
+
coldStoreArchiveDurationSeconds().record((Date.now() - partitionStart) / 1e3, label);
|
|
496
|
+
this.log("info", "cold-store chunk archived", {
|
|
497
|
+
event: "chunk_archived",
|
|
498
|
+
...label,
|
|
499
|
+
tenantId,
|
|
500
|
+
partitionDate,
|
|
501
|
+
chunkId,
|
|
502
|
+
bucket,
|
|
503
|
+
maxColdDays,
|
|
504
|
+
rowCount: encoded.rowCount,
|
|
505
|
+
byteCount: encoded.byteCount,
|
|
506
|
+
gzipByteCount: encoded.gzipByteCount,
|
|
507
|
+
durationMs: Date.now() - partitionStart
|
|
508
|
+
});
|
|
509
|
+
return { rowsArchived: encoded.rowCount };
|
|
510
|
+
} catch (err) {
|
|
511
|
+
summary.rowsFailed += encoded.rowCount;
|
|
512
|
+
coldStoreArchiveRowsTotal().add(encoded.rowCount, {
|
|
513
|
+
...label,
|
|
514
|
+
result: "failure"
|
|
515
|
+
});
|
|
516
|
+
coldStoreArchiveDurationSeconds().record((Date.now() - partitionStart) / 1e3, label);
|
|
517
|
+
this.log("error", "cold-store chunk archive failed", {
|
|
518
|
+
...label,
|
|
519
|
+
tenantId,
|
|
520
|
+
partitionDate,
|
|
521
|
+
bucket,
|
|
522
|
+
chunkId,
|
|
523
|
+
error: err instanceof Error ? err.message : String(err)
|
|
524
|
+
});
|
|
525
|
+
return { rowsArchived: 0 };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async putChunkData(key, body, contentHash) {
|
|
529
|
+
await this.s3.send(new PutObjectCommand({
|
|
530
|
+
Bucket: this.config.storage.bucket,
|
|
531
|
+
Key: key,
|
|
532
|
+
Body: body,
|
|
533
|
+
ContentType: "application/gzip",
|
|
534
|
+
Metadata: { [CONTENT_HASH_META]: contentHash }
|
|
535
|
+
}));
|
|
536
|
+
}
|
|
537
|
+
async verifyChunkData(key, expectedHash, table, attempt = 0) {
|
|
538
|
+
const gotHash = (await this.s3.send(new HeadObjectCommand({
|
|
539
|
+
Bucket: this.config.storage.bucket,
|
|
540
|
+
Key: key
|
|
541
|
+
}))).Metadata?.[CONTENT_HASH_META];
|
|
542
|
+
if (gotHash === expectedHash) return;
|
|
543
|
+
coldStoreVerifyFailuresTotal().add(1, {
|
|
544
|
+
db: this.db,
|
|
545
|
+
table
|
|
546
|
+
});
|
|
547
|
+
if (attempt >= 2) throw new Error(`cold-store: HeadObject content-hash mismatch for ${key} after 3 attempts (got ${gotHash ?? "<missing>"}, expected ${expectedHash})`);
|
|
548
|
+
this.log("warn", "cold-store: verify hash mismatch; retrying", {
|
|
549
|
+
db: this.db,
|
|
550
|
+
table,
|
|
551
|
+
key,
|
|
552
|
+
attempt
|
|
553
|
+
});
|
|
554
|
+
return this.verifyChunkData(key, expectedHash, table, attempt + 1);
|
|
555
|
+
}
|
|
556
|
+
async putManifest(key, manifest) {
|
|
557
|
+
await this.s3.send(new PutObjectCommand({
|
|
558
|
+
Bucket: this.config.storage.bucket,
|
|
559
|
+
Key: key,
|
|
560
|
+
Body: Buffer.from(serializeManifest(manifest), "utf-8"),
|
|
561
|
+
ContentType: "application/json"
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
564
|
+
async *fetchRange(args) {
|
|
565
|
+
if (!this.config.enabled) return;
|
|
566
|
+
const adapter = this.adapters.get(args.table);
|
|
567
|
+
const decodeLine = args.decode ?? (adapter ? (line) => adapter.decodeRow(line) : (line) => JSON.parse(line));
|
|
568
|
+
const rowTimestamp = adapter ? (row) => this.toDate(adapter.rowTimestamp(row)) : null;
|
|
569
|
+
const manifests = await this.listRelevantManifests({
|
|
570
|
+
db: args.db,
|
|
571
|
+
table: args.table,
|
|
572
|
+
tenantId: args.tenantId,
|
|
573
|
+
fromTs: args.fromTs,
|
|
574
|
+
toTs: args.toTs
|
|
575
|
+
});
|
|
576
|
+
const label = {
|
|
577
|
+
db: args.db,
|
|
578
|
+
table: args.table
|
|
579
|
+
};
|
|
580
|
+
for (const manifest of manifests) {
|
|
581
|
+
const dataKey = chunkObjectKey({
|
|
582
|
+
prefix: this.config.storage.prefix,
|
|
583
|
+
db: args.db,
|
|
584
|
+
table: args.table,
|
|
585
|
+
tenantId: args.tenantId,
|
|
586
|
+
partitionDate: manifest.partitionDate,
|
|
587
|
+
chunkId: manifest.chunkId,
|
|
588
|
+
kind: "data",
|
|
589
|
+
bucket: manifest.schemaVersion === 2 ? manifest.bucket : void 0
|
|
590
|
+
});
|
|
591
|
+
const fetchStart = Date.now();
|
|
592
|
+
const gzipped = await this.getChunkData(dataKey, manifest.contentHash);
|
|
593
|
+
coldStoreRehydrateDurationSeconds().record((Date.now() - fetchStart) / 1e3, label);
|
|
594
|
+
for await (const row of decodeChunk({
|
|
595
|
+
gzipped,
|
|
596
|
+
decodeLine
|
|
597
|
+
})) {
|
|
598
|
+
if (rowTimestamp) {
|
|
599
|
+
const ts = rowTimestamp(row);
|
|
600
|
+
if (ts < args.fromTs || ts >= args.toTs) continue;
|
|
601
|
+
}
|
|
602
|
+
yield row;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async hasRange(args) {
|
|
607
|
+
if (!this.config.enabled) return false;
|
|
608
|
+
return (await this.listRelevantManifests(args)).length > 0;
|
|
609
|
+
}
|
|
610
|
+
async countRange(args) {
|
|
611
|
+
if (!this.config.enabled) return 0;
|
|
612
|
+
const manifests = await this.listRelevantManifests(args);
|
|
613
|
+
let total = 0;
|
|
614
|
+
for (const m of manifests) total += m.rowCount;
|
|
615
|
+
return total;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Resolve all manifest sidecars whose time-range overlaps
|
|
619
|
+
* [fromTs, toTs). Uses one LIST walk under the tenant prefix, then
|
|
620
|
+
* GETs each `.manifest.json`.
|
|
621
|
+
*/
|
|
622
|
+
async listRelevantManifests(args) {
|
|
623
|
+
const tenantPrefix = tablePrefix({
|
|
624
|
+
prefix: this.config.storage.prefix,
|
|
625
|
+
db: args.db,
|
|
626
|
+
table: args.table
|
|
627
|
+
}) + `/${encodeKeySegment(args.tenantId)}/`;
|
|
628
|
+
const manifestKeys = [];
|
|
629
|
+
let continuationToken;
|
|
630
|
+
do {
|
|
631
|
+
const resp = await this.s3.send(new ListObjectsV2Command({
|
|
632
|
+
Bucket: this.config.storage.bucket,
|
|
633
|
+
Prefix: tenantPrefix,
|
|
634
|
+
ContinuationToken: continuationToken,
|
|
635
|
+
MaxKeys: 1e3
|
|
636
|
+
}));
|
|
637
|
+
for (const obj of resp.Contents ?? []) if (obj.Key && obj.Key.endsWith(".manifest.json")) manifestKeys.push(obj.Key);
|
|
638
|
+
continuationToken = resp.IsTruncated ? resp.NextContinuationToken : void 0;
|
|
639
|
+
} while (continuationToken);
|
|
640
|
+
const manifests = [];
|
|
641
|
+
for (const key of manifestKeys) {
|
|
642
|
+
const m = await this.getManifest(key);
|
|
643
|
+
const min = new Date(m.minTimestamp);
|
|
644
|
+
if (new Date(m.maxTimestamp) < args.fromTs) continue;
|
|
645
|
+
if (min >= args.toTs) continue;
|
|
646
|
+
manifests.push(m);
|
|
647
|
+
}
|
|
648
|
+
manifests.sort((a, b) => a.minTimestamp < b.minTimestamp ? -1 : 1);
|
|
649
|
+
return manifests;
|
|
650
|
+
}
|
|
651
|
+
async getManifest(key) {
|
|
652
|
+
const resp = await this.s3.send(new GetObjectCommand({
|
|
653
|
+
Bucket: this.config.storage.bucket,
|
|
654
|
+
Key: key
|
|
655
|
+
}));
|
|
656
|
+
if (!resp.Body) throw new Error(`cold-store: empty manifest body for ${key}`);
|
|
657
|
+
const bytes = await resp.Body.transformToByteArray();
|
|
658
|
+
return parseManifest(Buffer.from(bytes));
|
|
659
|
+
}
|
|
660
|
+
async getChunkData(key, expectedHash) {
|
|
661
|
+
const label = {
|
|
662
|
+
db: this.db,
|
|
663
|
+
table: keyToTable(key)
|
|
664
|
+
};
|
|
665
|
+
const cached = this.chunkCache?.get(key);
|
|
666
|
+
if (cached) {
|
|
667
|
+
coldStoreRehydrateRequestsTotal().add(1, {
|
|
668
|
+
...label,
|
|
669
|
+
cache_outcome: "hit"
|
|
670
|
+
});
|
|
671
|
+
return cached;
|
|
672
|
+
}
|
|
673
|
+
coldStoreRehydrateRequestsTotal().add(1, {
|
|
674
|
+
...label,
|
|
675
|
+
cache_outcome: "miss"
|
|
676
|
+
});
|
|
677
|
+
const resp = await this.s3.send(new GetObjectCommand({
|
|
678
|
+
Bucket: this.config.storage.bucket,
|
|
679
|
+
Key: key
|
|
680
|
+
}));
|
|
681
|
+
if (!resp.Body) throw new Error(`cold-store: empty chunk body for ${key}`);
|
|
682
|
+
const bytes = await resp.Body.transformToByteArray();
|
|
683
|
+
const body = Buffer.from(bytes);
|
|
684
|
+
coldStoreRehydrateBytesTotal().add(body.byteLength, label);
|
|
685
|
+
const got = sha256(body);
|
|
686
|
+
if (got !== expectedHash) {
|
|
687
|
+
coldStoreVerifyFailuresTotal().add(1, label);
|
|
688
|
+
throw new Error(`cold-store: chunk content-hash mismatch for ${key} (got ${got}, expected ${expectedHash})`);
|
|
689
|
+
}
|
|
690
|
+
this.chunkCache?.set(key, body);
|
|
691
|
+
return body;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Promote every row in a chunk back into PG. The adapter owns the
|
|
695
|
+
* actual INSERT + audit + chunk-counter decrement (transactional).
|
|
696
|
+
*
|
|
697
|
+
* Throws when:
|
|
698
|
+
* - the cold store is disabled
|
|
699
|
+
* - the adapter is unregistered or doesn't implement `replayInsert`
|
|
700
|
+
* - the manifest is missing
|
|
701
|
+
* - the data file's sha256 doesn't match the manifest's
|
|
702
|
+
* `contentHash` (refuse to replay a tampered chunk)
|
|
703
|
+
*
|
|
704
|
+
* Idempotent on re-run: the adapter's `ON CONFLICT DO NOTHING`
|
|
705
|
+
* collapses the second pass to `inserted=0, skipped=rowCount`.
|
|
706
|
+
*/
|
|
707
|
+
async replayChunk(args) {
|
|
708
|
+
if (!this.config.enabled) throw new Error("cold-store: replayChunk called while cold-store is disabled");
|
|
709
|
+
const adapter = this.adapters.get(args.table);
|
|
710
|
+
if (!adapter) throw new Error(`cold-store: replay-chunk requires a registered adapter for table '${args.table}' on db=${this.db}`);
|
|
711
|
+
if (!adapter.replayInsert) throw new Error(`cold-store: replay-into-pg not supported for table '${args.table}' (adapter has no replayInsert)`);
|
|
712
|
+
if (adapter.db !== args.db) throw new Error(`cold-store: replayChunk db=${args.db} does not match adapter db=${adapter.db}`);
|
|
713
|
+
const start = Date.now();
|
|
714
|
+
const label = {
|
|
715
|
+
db: args.db,
|
|
716
|
+
table: args.table
|
|
717
|
+
};
|
|
718
|
+
try {
|
|
719
|
+
const dayPrefix = tenantDayPrefix({
|
|
720
|
+
prefix: this.config.storage.prefix,
|
|
721
|
+
db: args.db,
|
|
722
|
+
table: args.table,
|
|
723
|
+
tenantId: args.tenantId,
|
|
724
|
+
partitionDate: args.partitionDate
|
|
725
|
+
});
|
|
726
|
+
const dayKeys = await this.listObjectKeys(dayPrefix);
|
|
727
|
+
const manifestSuffix = `/${args.chunkId}.manifest.json`;
|
|
728
|
+
const manifestKey = dayKeys.find((k) => k.endsWith(manifestSuffix));
|
|
729
|
+
if (!manifestKey) throw new Error(`cold-store: replay-chunk ${args.chunkId} not found under ${dayPrefix} (db=${args.db} table=${args.table} tenantId=${args.tenantId} partitionDate=${args.partitionDate})`);
|
|
730
|
+
const dataKey = manifestKey.replace(/\.manifest\.json$/, ".jsonl.gz");
|
|
731
|
+
const [data, manifest] = await Promise.all([this.getObjectBody(dataKey), this.getManifest(manifestKey)]);
|
|
732
|
+
const got = sha256(data);
|
|
733
|
+
if (got !== manifest.contentHash) {
|
|
734
|
+
coldStoreVerifyFailuresTotal().add(1, label);
|
|
735
|
+
throw new Error(`cold-store: replay-chunk ${args.chunkId} refused — contentHash mismatch (got ${got}, manifest ${manifest.contentHash})`);
|
|
736
|
+
}
|
|
737
|
+
const rows = [];
|
|
738
|
+
for await (const row of decodeChunk({
|
|
739
|
+
gzipped: data,
|
|
740
|
+
decodeLine: (line) => adapter.decodeRow(line)
|
|
741
|
+
})) rows.push(row);
|
|
742
|
+
const chunkMeta = {
|
|
743
|
+
chunkId: manifest.chunkId,
|
|
744
|
+
tenantId: manifest.tenantId,
|
|
745
|
+
partitionDate: manifest.partitionDate,
|
|
746
|
+
rowCount: manifest.rowCount,
|
|
747
|
+
byteCount: manifest.byteCount,
|
|
748
|
+
gzipByteCount: manifest.gzipByteCount,
|
|
749
|
+
objectKey: dataKey
|
|
750
|
+
};
|
|
751
|
+
const result = await adapter.replayInsert.bind(adapter)({
|
|
752
|
+
rows,
|
|
753
|
+
chunkMeta
|
|
754
|
+
});
|
|
755
|
+
coldStoreReplayRowsTotal().add(result.inserted, {
|
|
756
|
+
...label,
|
|
757
|
+
result: "success"
|
|
758
|
+
});
|
|
759
|
+
if (result.skipped > 0) coldStoreReplayRowsTotal().add(result.skipped, {
|
|
760
|
+
...label,
|
|
761
|
+
result: "idempotent_skip"
|
|
762
|
+
});
|
|
763
|
+
coldStoreReplayDurationSeconds().record((Date.now() - start) / 1e3, label);
|
|
764
|
+
this.log("info", "cold-store chunk replayed", {
|
|
765
|
+
event: "chunk_replayed",
|
|
766
|
+
...label,
|
|
767
|
+
tenantId: manifest.tenantId,
|
|
768
|
+
partitionDate: manifest.partitionDate,
|
|
769
|
+
chunkId: args.chunkId,
|
|
770
|
+
rowsReplayed: result.inserted,
|
|
771
|
+
rowsSkipped: result.skipped,
|
|
772
|
+
durationMs: Date.now() - start
|
|
773
|
+
});
|
|
774
|
+
return {
|
|
775
|
+
inserted: result.inserted,
|
|
776
|
+
skipped: result.skipped,
|
|
777
|
+
chunkId: args.chunkId
|
|
778
|
+
};
|
|
779
|
+
} catch (err) {
|
|
780
|
+
coldStoreReplayRowsTotal().add(0, {
|
|
781
|
+
...label,
|
|
782
|
+
result: "failure"
|
|
783
|
+
});
|
|
784
|
+
coldStoreReplayDurationSeconds().record((Date.now() - start) / 1e3, label);
|
|
785
|
+
this.log("error", "cold-store chunk replay failed", {
|
|
786
|
+
...label,
|
|
787
|
+
chunkId: args.chunkId,
|
|
788
|
+
tenantId: args.tenantId,
|
|
789
|
+
partitionDate: args.partitionDate,
|
|
790
|
+
error: err instanceof Error ? err.message : String(err)
|
|
791
|
+
});
|
|
792
|
+
throw err;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Locate the chunk whose `[minRowId, maxRowId]` window covers
|
|
797
|
+
* `rowId` and replay it. Scans manifests under the tenant prefix
|
|
798
|
+
* (LIST is one round-trip; manifests are tiny). When several manifests
|
|
799
|
+
* match (overlapping ranges from re-archives), the one with the
|
|
800
|
+
* smallest range is preferred — it's the most recently re-archived
|
|
801
|
+
* shard and therefore the one whose row contents are freshest.
|
|
802
|
+
*
|
|
803
|
+
* Returns `chunkId: null` when no manifest matches — the caller
|
|
804
|
+
* decides whether that means "row never existed" (rerun → 404) or
|
|
805
|
+
* "S3 outage" (rerun → 410 replayFailed). This method only fails
|
|
806
|
+
* loudly on infrastructure errors (missing chunk after manifest
|
|
807
|
+
* found, hash mismatch).
|
|
808
|
+
*/
|
|
809
|
+
async replayRow(args) {
|
|
810
|
+
if (!this.config.enabled) return {
|
|
811
|
+
inserted: 0,
|
|
812
|
+
skipped: 0,
|
|
813
|
+
chunkId: null
|
|
814
|
+
};
|
|
815
|
+
const tenantPrefix = tablePrefix({
|
|
816
|
+
prefix: this.config.storage.prefix,
|
|
817
|
+
db: args.db,
|
|
818
|
+
table: args.table
|
|
819
|
+
}) + `/${encodeKeySegment(args.tenantId)}/`;
|
|
820
|
+
const manifestKeys = [];
|
|
821
|
+
let continuationToken;
|
|
822
|
+
do {
|
|
823
|
+
const resp = await this.s3.send(new ListObjectsV2Command({
|
|
824
|
+
Bucket: this.config.storage.bucket,
|
|
825
|
+
Prefix: tenantPrefix,
|
|
826
|
+
ContinuationToken: continuationToken,
|
|
827
|
+
MaxKeys: 1e3
|
|
828
|
+
}));
|
|
829
|
+
for (const obj of resp.Contents ?? []) if (obj.Key && obj.Key.endsWith(".manifest.json")) manifestKeys.push(obj.Key);
|
|
830
|
+
continuationToken = resp.IsTruncated ? resp.NextContinuationToken : void 0;
|
|
831
|
+
} while (continuationToken);
|
|
832
|
+
const wanted = String(args.rowId);
|
|
833
|
+
let best = null;
|
|
834
|
+
let exact = null;
|
|
835
|
+
for (const key of manifestKeys) {
|
|
836
|
+
const m = await this.getManifest(key);
|
|
837
|
+
if (m.replayLookupKeys && m.replayLookupKeys.includes(wanted)) {
|
|
838
|
+
exact = m;
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
const lo = m.minRowId;
|
|
842
|
+
const hi = m.maxRowId;
|
|
843
|
+
if (compareIds(args.rowId, lo) < 0) continue;
|
|
844
|
+
if (compareIds(args.rowId, hi) > 0) continue;
|
|
845
|
+
const width = idDistance(lo, hi);
|
|
846
|
+
if (best === null || width < best.rangeWidth) best = {
|
|
847
|
+
manifest: m,
|
|
848
|
+
rangeWidth: width
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
const picked = exact ?? best?.manifest ?? null;
|
|
852
|
+
if (!picked) return {
|
|
853
|
+
inserted: 0,
|
|
854
|
+
skipped: 0,
|
|
855
|
+
chunkId: null
|
|
856
|
+
};
|
|
857
|
+
return this.replayChunk({
|
|
858
|
+
db: args.db,
|
|
859
|
+
table: args.table,
|
|
860
|
+
tenantId: picked.tenantId,
|
|
861
|
+
partitionDate: picked.partitionDate,
|
|
862
|
+
chunkId: picked.chunkId
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
toDate(v) {
|
|
866
|
+
return v instanceof Date ? v : new Date(v);
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Subclass hook — query the per-DB `cold_store_chunks` index for
|
|
870
|
+
* purge candidates. Concrete subclasses (PlatformColdStore /
|
|
871
|
+
* OrchestratorColdStore) implement this with one Kysely SQL query
|
|
872
|
+
* keyed off `archived_at + max_cold_days * INTERVAL '1 day' < now()`
|
|
873
|
+
* and `max_cold_days != 'forever'`.
|
|
874
|
+
*
|
|
875
|
+
* Returning an empty array makes `purgeExpiredChunks` a no-op.
|
|
876
|
+
* Default implementation returns `[]` so non-purge subclasses (or
|
|
877
|
+
* test fixtures) can opt out trivially.
|
|
878
|
+
*/
|
|
879
|
+
async listPurgeableChunks(_opts) {
|
|
880
|
+
return [];
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Acquire a per-chunk advisory lock so concurrent sweeps can't
|
|
884
|
+
* race on the same `DeleteObject` + `cold_store_chunks` row.
|
|
885
|
+
*
|
|
886
|
+
* Default implementation is a no-op (always succeeds). Subclasses
|
|
887
|
+
* with a Kysely instance override to use `pg_try_advisory_lock` on
|
|
888
|
+
* `hashtext('cold-store-purge|<db>|<table>|<chunkId>')` and release
|
|
889
|
+
* inside `fn`'s `finally`. Returns `null` when another worker holds
|
|
890
|
+
* the lock; returns the `fn` result on success.
|
|
891
|
+
*/
|
|
892
|
+
async withPurgeLock(_args, fn) {
|
|
893
|
+
return fn();
|
|
894
|
+
}
|
|
895
|
+
async purgeExpiredChunks(opts) {
|
|
896
|
+
const start = Date.now();
|
|
897
|
+
if (!this.config.enabled) return {
|
|
898
|
+
results: [],
|
|
899
|
+
bytesPurged: 0,
|
|
900
|
+
chunksPurged: 0,
|
|
901
|
+
durationMs: 0
|
|
902
|
+
};
|
|
903
|
+
const limit = opts?.limit ?? 1e3;
|
|
904
|
+
const dryRun = opts?.dryRun ?? false;
|
|
905
|
+
const candidates = await this.listPurgeableChunks({
|
|
906
|
+
tableFilter: opts?.tableFilter,
|
|
907
|
+
bucketFilter: opts?.bucketFilter,
|
|
908
|
+
limit
|
|
909
|
+
});
|
|
910
|
+
const results = [];
|
|
911
|
+
let bytesPurged = 0;
|
|
912
|
+
let chunksPurged = 0;
|
|
913
|
+
for (const c of candidates) {
|
|
914
|
+
const baseResult = {
|
|
915
|
+
table: c.table,
|
|
916
|
+
tenantId: c.tenantId,
|
|
917
|
+
chunkId: c.chunkId,
|
|
918
|
+
bucket: c.bucket,
|
|
919
|
+
gzipBytes: c.gzipBytes,
|
|
920
|
+
rowCount: c.rowCount
|
|
921
|
+
};
|
|
922
|
+
const label = {
|
|
923
|
+
db: this.db,
|
|
924
|
+
table: c.table
|
|
925
|
+
};
|
|
926
|
+
if (dryRun) {
|
|
927
|
+
results.push({
|
|
928
|
+
...baseResult,
|
|
929
|
+
outcome: "dry_run"
|
|
930
|
+
});
|
|
931
|
+
coldStorePurgeChunksTotal().add(1, {
|
|
932
|
+
...label,
|
|
933
|
+
result: "dry_run"
|
|
934
|
+
});
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const adapter = this.adapters.get(c.table);
|
|
938
|
+
if (!adapter) {
|
|
939
|
+
results.push({
|
|
940
|
+
...baseResult,
|
|
941
|
+
outcome: "failure",
|
|
942
|
+
error: `no adapter registered for table=${c.table}`
|
|
943
|
+
});
|
|
944
|
+
coldStorePurgeChunksTotal().add(1, {
|
|
945
|
+
...label,
|
|
946
|
+
result: "failure"
|
|
947
|
+
});
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (!adapter.purgeChunkRecord) {
|
|
951
|
+
results.push({
|
|
952
|
+
...baseResult,
|
|
953
|
+
outcome: "failure",
|
|
954
|
+
error: `adapter ${c.table} does not implement purgeChunkRecord`
|
|
955
|
+
});
|
|
956
|
+
coldStorePurgeChunksTotal().add(1, {
|
|
957
|
+
...label,
|
|
958
|
+
result: "failure"
|
|
959
|
+
});
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const lockResult = await this.withPurgeLock({
|
|
963
|
+
table: c.table,
|
|
964
|
+
chunkId: c.chunkId
|
|
965
|
+
}, async () => {
|
|
966
|
+
const manifestKey = c.objectKey.replace(/\.jsonl\.gz$/, ".manifest.json");
|
|
967
|
+
await this.s3.send(new DeleteObjectCommand({
|
|
968
|
+
Bucket: this.config.storage.bucket,
|
|
969
|
+
Key: c.objectKey
|
|
970
|
+
}));
|
|
971
|
+
await this.s3.send(new DeleteObjectCommand({
|
|
972
|
+
Bucket: this.config.storage.bucket,
|
|
973
|
+
Key: manifestKey
|
|
974
|
+
}));
|
|
975
|
+
await adapter.purgeChunkRecord({
|
|
976
|
+
tenantId: c.tenantId,
|
|
977
|
+
chunkId: c.chunkId,
|
|
978
|
+
gzipBytes: c.gzipBytes,
|
|
979
|
+
rowCount: c.rowCount,
|
|
980
|
+
bucket: c.bucket,
|
|
981
|
+
maxColdDays: c.maxColdDays,
|
|
982
|
+
objectKey: c.objectKey
|
|
983
|
+
});
|
|
984
|
+
return true;
|
|
985
|
+
}).catch((err) => {
|
|
986
|
+
results.push({
|
|
987
|
+
...baseResult,
|
|
988
|
+
outcome: "failure",
|
|
989
|
+
error: err instanceof Error ? err.message : String(err)
|
|
990
|
+
});
|
|
991
|
+
coldStorePurgeChunksTotal().add(1, {
|
|
992
|
+
...label,
|
|
993
|
+
result: "failure"
|
|
994
|
+
});
|
|
995
|
+
return false;
|
|
996
|
+
});
|
|
997
|
+
if (lockResult === null) {
|
|
998
|
+
results.push({
|
|
999
|
+
...baseResult,
|
|
1000
|
+
outcome: "skipped_locked"
|
|
1001
|
+
});
|
|
1002
|
+
coldStorePurgeChunksTotal().add(1, {
|
|
1003
|
+
...label,
|
|
1004
|
+
result: "skipped_locked"
|
|
1005
|
+
});
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
if (lockResult === true) {
|
|
1009
|
+
results.push({
|
|
1010
|
+
...baseResult,
|
|
1011
|
+
outcome: "purged"
|
|
1012
|
+
});
|
|
1013
|
+
bytesPurged += c.gzipBytes;
|
|
1014
|
+
chunksPurged += 1;
|
|
1015
|
+
coldStorePurgeChunksTotal().add(1, {
|
|
1016
|
+
...label,
|
|
1017
|
+
result: "purged"
|
|
1018
|
+
});
|
|
1019
|
+
coldStorePurgeBytesTotal().add(c.gzipBytes, label);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
coldStorePurgeDurationSeconds().record((Date.now() - start) / 1e3, { db: this.db });
|
|
1023
|
+
const summary = {
|
|
1024
|
+
results,
|
|
1025
|
+
bytesPurged,
|
|
1026
|
+
chunksPurged,
|
|
1027
|
+
durationMs: Date.now() - start
|
|
1028
|
+
};
|
|
1029
|
+
this.log("info", "cold-store purge sweep complete", {
|
|
1030
|
+
db: this.db,
|
|
1031
|
+
dryRun,
|
|
1032
|
+
candidates: candidates.length,
|
|
1033
|
+
chunksPurged,
|
|
1034
|
+
bytesPurged,
|
|
1035
|
+
durationMs: summary.durationMs
|
|
1036
|
+
});
|
|
1037
|
+
return summary;
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
function compareIds(a, b) {
|
|
1041
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
1042
|
+
const as = String(a);
|
|
1043
|
+
const bs = String(b);
|
|
1044
|
+
if (as < bs) return -1;
|
|
1045
|
+
if (as > bs) return 1;
|
|
1046
|
+
return 0;
|
|
1047
|
+
}
|
|
1048
|
+
function idDistance(lo, hi) {
|
|
1049
|
+
if (typeof lo === "number" && typeof hi === "number") return Math.max(0, hi - lo);
|
|
1050
|
+
const lenDelta = String(hi).length - String(lo).length;
|
|
1051
|
+
if (lenDelta !== 0) return Math.max(0, lenDelta);
|
|
1052
|
+
return String(hi) < String(lo) ? 0 : 1;
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Best-effort extraction of the table name from a chunk key of shape
|
|
1056
|
+
* `<prefix>/<db>/<table>/<tenant>/<YYYY>/<MM>/<DD>/<chunk>.ext` (v1) or
|
|
1057
|
+
* `<prefix>/<db>/<table>/<tenant>/<YYYY>/<MM>/<DD>/<bucket>/<chunk>.ext`
|
|
1058
|
+
* (v2 with bucket subprefix). Used for metric labels on the read-through
|
|
1059
|
+
* path; returns `'unknown'` if the key doesn't parse.
|
|
1060
|
+
*/
|
|
1061
|
+
function keyToTable(key) {
|
|
1062
|
+
const parts = key.split("/");
|
|
1063
|
+
for (let i = parts.length - 4; i >= 2; i--) if (/^\d{4}$/.test(parts[i] ?? "")) return parts[i - 2] ?? "unknown";
|
|
1064
|
+
return "unknown";
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Lift an in-memory array into an `AsyncIterable` so it can feed
|
|
1068
|
+
* `encodeChunk` (which expects a stream). Used by the per-bucket
|
|
1069
|
+
* archive flow to re-stream each bucket's row subset through the
|
|
1070
|
+
* existing single-stream encoder without further refactoring.
|
|
1071
|
+
*/
|
|
1072
|
+
async function* arrayToAsync(items) {
|
|
1073
|
+
for (const item of items) yield item;
|
|
1074
|
+
}
|
|
1075
|
+
//#endregion
|
|
1076
|
+
export { BaseColdStore };
|
|
1077
|
+
|
|
1078
|
+
//# sourceMappingURL=cold-store.js.map
|