turbodata 0.1.0
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 +201 -0
- package/NOTICE +27 -0
- package/README.md +215 -0
- package/dist/compression.d.ts +8 -0
- package/dist/compression.d.ts.map +1 -0
- package/dist/compression.js +19 -0
- package/dist/compression.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/io.d.ts +37 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +174 -0
- package/dist/io.js.map +1 -0
- package/dist/iterators/message_heap.d.ts +17 -0
- package/dist/iterators/message_heap.d.ts.map +1 -0
- package/dist/iterators/message_heap.js +83 -0
- package/dist/iterators/message_heap.js.map +1 -0
- package/dist/iterators/preloaded_topics_group_iterator.d.ts +38 -0
- package/dist/iterators/preloaded_topics_group_iterator.d.ts.map +1 -0
- package/dist/iterators/preloaded_topics_group_iterator.js +109 -0
- package/dist/iterators/preloaded_topics_group_iterator.js.map +1 -0
- package/dist/iterators/topics_group_iterator.d.ts +45 -0
- package/dist/iterators/topics_group_iterator.d.ts.map +1 -0
- package/dist/iterators/topics_group_iterator.js +132 -0
- package/dist/iterators/topics_group_iterator.js.map +1 -0
- package/dist/loaded_bytes.d.ts +12 -0
- package/dist/loaded_bytes.d.ts.map +1 -0
- package/dist/loaded_bytes.js +43 -0
- package/dist/loaded_bytes.js.map +1 -0
- package/dist/msgpack.d.ts +10 -0
- package/dist/msgpack.d.ts.map +1 -0
- package/dist/msgpack.js +44 -0
- package/dist/msgpack.js.map +1 -0
- package/dist/multi_reader.d.ts +56 -0
- package/dist/multi_reader.d.ts.map +1 -0
- package/dist/multi_reader.js +281 -0
- package/dist/multi_reader.js.map +1 -0
- package/dist/read_fetcher.d.ts +9 -0
- package/dist/read_fetcher.d.ts.map +1 -0
- package/dist/read_fetcher.js +59 -0
- package/dist/read_fetcher.js.map +1 -0
- package/dist/read_options.d.ts +40 -0
- package/dist/read_options.d.ts.map +1 -0
- package/dist/read_options.js +16 -0
- package/dist/read_options.js.map +1 -0
- package/dist/read_planner.d.ts +37 -0
- package/dist/read_planner.d.ts.map +1 -0
- package/dist/read_planner.js +110 -0
- package/dist/read_planner.js.map +1 -0
- package/dist/read_source.d.ts +19 -0
- package/dist/read_source.d.ts.map +1 -0
- package/dist/read_source.js +15 -0
- package/dist/read_source.js.map +1 -0
- package/dist/read_strategy.d.ts +52 -0
- package/dist/read_strategy.d.ts.map +1 -0
- package/dist/read_strategy.js +70 -0
- package/dist/read_strategy.js.map +1 -0
- package/dist/reader.d.ts +209 -0
- package/dist/reader.d.ts.map +1 -0
- package/dist/reader.js +649 -0
- package/dist/reader.js.map +1 -0
- package/dist/sample.d.ts +41 -0
- package/dist/sample.d.ts.map +1 -0
- package/dist/sample.js +528 -0
- package/dist/sample.js.map +1 -0
- package/dist/sort_and_filter.d.ts +25 -0
- package/dist/sort_and_filter.d.ts.map +1 -0
- package/dist/sort_and_filter.js +126 -0
- package/dist/sort_and_filter.js.map +1 -0
- package/dist/sources/blob.d.ts +8 -0
- package/dist/sources/blob.d.ts.map +1 -0
- package/dist/sources/blob.js +38 -0
- package/dist/sources/blob.js.map +1 -0
- package/dist/sources/http.d.ts +17 -0
- package/dist/sources/http.d.ts.map +1 -0
- package/dist/sources/http.js +90 -0
- package/dist/sources/http.js.map +1 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -0
- package/src/compression.ts +28 -0
- package/src/index.ts +70 -0
- package/src/io.ts +214 -0
- package/src/iterators/message_heap.ts +104 -0
- package/src/iterators/preloaded_topics_group_iterator.ts +143 -0
- package/src/iterators/topics_group_iterator.ts +180 -0
- package/src/loaded_bytes.ts +61 -0
- package/src/msgpack.ts +46 -0
- package/src/multi_reader.ts +337 -0
- package/src/read_fetcher.ts +74 -0
- package/src/read_options.ts +58 -0
- package/src/read_planner.ts +153 -0
- package/src/read_source.ts +33 -0
- package/src/read_strategy.ts +111 -0
- package/src/reader.ts +910 -0
- package/src/sample.ts +715 -0
- package/src/sort_and_filter.ts +167 -0
- package/src/sources/blob.ts +52 -0
- package/src/sources/http.ts +121 -0
- package/src/types.ts +110 -0
package/src/reader.ts
ADDED
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
// Copyright 2026 Wanjia He
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
// Reader: top-level entry point. Mirrors go/reader.go and go/message_iterator.go.
|
|
16
|
+
|
|
17
|
+
import type { Decompressor } from "./compression.js";
|
|
18
|
+
import { defaultDecompressor } from "./compression.js";
|
|
19
|
+
import { BinaryReader, readFooter, readIndexChunk, readSummary } from "./io.js";
|
|
20
|
+
import { PreloadedTopicsGroupIterator } from "./iterators/preloaded_topics_group_iterator.js";
|
|
21
|
+
import { MessageHeap } from "./iterators/message_heap.js";
|
|
22
|
+
import { TopicsGroupIterator } from "./iterators/topics_group_iterator.js";
|
|
23
|
+
import { LoadedBytes } from "./loaded_bytes.js";
|
|
24
|
+
import { fetchAll } from "./read_fetcher.js";
|
|
25
|
+
import { plan, type Range } from "./read_planner.js";
|
|
26
|
+
import type { ReadOptions } from "./read_options.js";
|
|
27
|
+
import { MAX_INT64 } from "./read_options.js";
|
|
28
|
+
import type { ReadSource } from "./read_source.js";
|
|
29
|
+
import type { ReadStrategy } from "./read_strategy.js";
|
|
30
|
+
import { sampleMessages, type SampleSpec } from "./sample.js";
|
|
31
|
+
import { sortAndFilter, type MessageRef } from "./sort_and_filter.js";
|
|
32
|
+
import {
|
|
33
|
+
FOOTER_LEN,
|
|
34
|
+
MAGIC,
|
|
35
|
+
META_KEY_COMPRESSED,
|
|
36
|
+
META_KEY_VIDEO,
|
|
37
|
+
type IndexChunk,
|
|
38
|
+
type IndexChunkInfo,
|
|
39
|
+
type Summary,
|
|
40
|
+
type TopicsInfo,
|
|
41
|
+
} from "./types.js";
|
|
42
|
+
|
|
43
|
+
export interface Message {
|
|
44
|
+
/** int64 nanosecond (or arbitrary unit) timestamp from the writer. */
|
|
45
|
+
timestamp: bigint;
|
|
46
|
+
topicName: string;
|
|
47
|
+
/**
|
|
48
|
+
* Message bytes. By default this aliases an internal reusable buffer and
|
|
49
|
+
* is only valid until the next iteration step. Pass `{ copy: true }` to
|
|
50
|
+
* receive a fresh Uint8Array per message.
|
|
51
|
+
*/
|
|
52
|
+
data: Uint8Array;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ReaderOptions {
|
|
56
|
+
/** Override the default fzstd decompressor (e.g. for a WASM zstd build). */
|
|
57
|
+
decompress?: Decompressor;
|
|
58
|
+
/**
|
|
59
|
+
* Present in-file topic names under different exposed names. Keyed by in-file
|
|
60
|
+
* name, valued by the exposed name emitted from readMessages, accepted by
|
|
61
|
+
* ReadOptions.topicNames and SampleQuery.topic, and reported by summary().
|
|
62
|
+
* Names absent from the map pass through unchanged; omitting it means
|
|
63
|
+
* identity (no behavior change).
|
|
64
|
+
*
|
|
65
|
+
* Validated lazily against the file's summary on first use: it throws a
|
|
66
|
+
* TopicRemapError when two topics collapse onto the same exposed name
|
|
67
|
+
* (whether two in-file names map to the same target, or a renamed name
|
|
68
|
+
* collides with an untouched in-file name).
|
|
69
|
+
*/
|
|
70
|
+
topicRemap?: Record<string, string>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Thrown when a configured topic remap collapses two in-file topics onto the
|
|
75
|
+
* same exposed name. Detected lazily against the summary on first use.
|
|
76
|
+
*/
|
|
77
|
+
export class TopicRemapError extends Error {
|
|
78
|
+
readonly exposed: string;
|
|
79
|
+
readonly first: string;
|
|
80
|
+
readonly second: string;
|
|
81
|
+
constructor(exposed: string, first: string, second: string) {
|
|
82
|
+
super(
|
|
83
|
+
`turbodata: topic remap produces duplicate exposed name "${exposed}" (from in-file topics "${first}" and "${second}")`,
|
|
84
|
+
);
|
|
85
|
+
this.name = "TopicRemapError";
|
|
86
|
+
this.exposed = exposed;
|
|
87
|
+
this.first = first;
|
|
88
|
+
this.second = second;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One sampling request: the floor message of `topic` at each timestamp.
|
|
94
|
+
* Within `timestamps`, values must be strictly increasing, and the same
|
|
95
|
+
* `topic` must not appear in more than one query in a single sample() call.
|
|
96
|
+
*/
|
|
97
|
+
export interface SampleQuery {
|
|
98
|
+
topic: string;
|
|
99
|
+
/** int64 timestamps; strictly increasing. */
|
|
100
|
+
timestamps: bigint[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One coded video frame surfaced as part of a SampleResult's GOP prefix or
|
|
105
|
+
* incremental tail. `data` is an owned copy and is safe to retain.
|
|
106
|
+
*/
|
|
107
|
+
export interface Frame {
|
|
108
|
+
timestamp: bigint;
|
|
109
|
+
isKeyFrame: boolean;
|
|
110
|
+
data: Uint8Array;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One returned floor message. out[i][j] corresponds to queries[i].timestamps[j].
|
|
115
|
+
* `found` is false when there is no message with timestamp <= timestamps[j]
|
|
116
|
+
* for the topic.
|
|
117
|
+
*
|
|
118
|
+
* Without videoDecodable, every topic (including video topics) returns its
|
|
119
|
+
* floor message in `data` (an owned copy), `isVideo` is false, and `frames` /
|
|
120
|
+
* `resetDecoder` are unset.
|
|
121
|
+
*
|
|
122
|
+
* With videoDecodable, results for video topics instead carry a decoder-ready
|
|
123
|
+
* GOP sequence and set `isVideo` true. For those results, within a single row
|
|
124
|
+
* (one topic, strictly increasing query timestamps):
|
|
125
|
+
*
|
|
126
|
+
* - `data` is empty; the frame bytes live in `frames` and the target frame
|
|
127
|
+
* is the last element. `timestamp` names that target frame.
|
|
128
|
+
* - For the first found result in each GOP encountered in the row:
|
|
129
|
+
* `resetDecoder` is true, `frames` = [keyframe ... target] in storage
|
|
130
|
+
* (decode) order. The decoder must reset before feeding.
|
|
131
|
+
* - For subsequent found results within the same GOP: `resetDecoder` is
|
|
132
|
+
* false, `frames` = [previous_target+1 ... target] (only the new frames).
|
|
133
|
+
* `frames` is empty when two queries resolve to the same target frame;
|
|
134
|
+
* `timestamp` still names that target.
|
|
135
|
+
*/
|
|
136
|
+
export interface SampleResult {
|
|
137
|
+
found: boolean;
|
|
138
|
+
timestamp: bigint;
|
|
139
|
+
data: Uint8Array;
|
|
140
|
+
isVideo: boolean;
|
|
141
|
+
frames: Frame[];
|
|
142
|
+
resetDecoder: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Options for Reader.sample(). */
|
|
146
|
+
export interface SampleOptions {
|
|
147
|
+
/**
|
|
148
|
+
* Cost model for the concurrent reads. Defaults to DEFAULT_SAMPLE_STRATEGY,
|
|
149
|
+
* which is sized for a cloud-object / in-region profile.
|
|
150
|
+
*/
|
|
151
|
+
strategy?: ReadStrategy;
|
|
152
|
+
/**
|
|
153
|
+
* int64. Bytes to read speculatively from the file tail when loading the
|
|
154
|
+
* summary. Same semantics as ReadOptions.tailPrefetch.
|
|
155
|
+
*/
|
|
156
|
+
tailPrefetch?: bigint;
|
|
157
|
+
/**
|
|
158
|
+
* When true, results for video topics carry a decoder-ready GOP sequence
|
|
159
|
+
* (frames + resetDecoder) and set isVideo. See SampleResult. Non-video
|
|
160
|
+
* topics are unaffected. Default: false.
|
|
161
|
+
*/
|
|
162
|
+
videoDecodable?: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Default strategy for Reader.sample when none is supplied. Mirrors the Go and
|
|
167
|
+
* Python DEFAULT_SAMPLE_STRATEGY (1 MiB coalesce, 4 MiB split, 16-way fanout).
|
|
168
|
+
*/
|
|
169
|
+
export const DEFAULT_SAMPLE_STRATEGY: ReadStrategy = {
|
|
170
|
+
coalesceGap: 1n << 20n,
|
|
171
|
+
splitThreshold: 4n << 20n,
|
|
172
|
+
maxConcurrency: 16,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Aggregated precondition failures from Reader.sample, raised before any data
|
|
177
|
+
* I/O. `violations` lists every problem found across all queries.
|
|
178
|
+
*/
|
|
179
|
+
export class SampleValidationError extends Error {
|
|
180
|
+
readonly violations: string[];
|
|
181
|
+
constructor(violations: string[]) {
|
|
182
|
+
super(
|
|
183
|
+
`sample: ${violations.length} validation error(s): ${violations.join("; ")}`,
|
|
184
|
+
);
|
|
185
|
+
this.name = "SampleValidationError";
|
|
186
|
+
this.violations = violations;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* N timestamps starting at `start`, each `stride` apart. Useful for "N samples
|
|
192
|
+
* at a given frequency from T" patterns. Mirrors Go LinSpaceTimestamps.
|
|
193
|
+
* Returns [] when count <= 0; throws when stride <= 0 (would break the
|
|
194
|
+
* strictly-increasing contract on SampleQuery.timestamps).
|
|
195
|
+
*/
|
|
196
|
+
export function linSpaceTimestamps(
|
|
197
|
+
start: bigint,
|
|
198
|
+
stride: bigint,
|
|
199
|
+
count: number,
|
|
200
|
+
): bigint[] {
|
|
201
|
+
if (count <= 0) {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
if (stride <= 0n) {
|
|
205
|
+
throw new Error("linSpaceTimestamps requires stride > 0");
|
|
206
|
+
}
|
|
207
|
+
const out = new Array<bigint>(count);
|
|
208
|
+
for (let i = 0; i < count; i++) {
|
|
209
|
+
out[i] = start + BigInt(i) * stride;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Common shape of a per-group iterator: returns the next message or
|
|
216
|
+
* undefined on EOF. Both the default-path and cost-aware iterators are
|
|
217
|
+
* adapted to this shape inside the Reader.
|
|
218
|
+
*/
|
|
219
|
+
interface GroupIt {
|
|
220
|
+
next(): Promise<
|
|
221
|
+
{ timestamp: bigint; topicId: number; data: Uint8Array } | undefined
|
|
222
|
+
>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Per-exposed-topic span used by MultiReader. `isVideo` flags a video topic
|
|
227
|
+
* (always a single-topic group); [minTs, maxTs] is the inclusive timestamp
|
|
228
|
+
* span of the topic's messages. Mirrors go topicBound / py _TopicBound.
|
|
229
|
+
*
|
|
230
|
+
* @internal
|
|
231
|
+
*/
|
|
232
|
+
export interface TopicBound {
|
|
233
|
+
isVideo: boolean;
|
|
234
|
+
minTs: bigint;
|
|
235
|
+
maxTs: bigint;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export class Reader {
|
|
239
|
+
private readonly rs: ReadSource;
|
|
240
|
+
private readonly decompress: Decompressor;
|
|
241
|
+
|
|
242
|
+
// Lazy-loaded summary metadata.
|
|
243
|
+
private cachedSize: bigint | undefined;
|
|
244
|
+
private cachedSummary: Summary | undefined;
|
|
245
|
+
// Cached per-exposed-topic bounds, computed lazily by topicBounds().
|
|
246
|
+
private boundsCache: Map<string, TopicBound> | undefined;
|
|
247
|
+
|
|
248
|
+
// topicRemap maps in-file -> exposed names; undefined means identity.
|
|
249
|
+
private readonly topicRemap: Map<string, string> | undefined;
|
|
250
|
+
// Cached prepareRename outputs (computed lazily once the summary is known).
|
|
251
|
+
// renameInverse maps exposed -> in-file; renameErr captures a collision
|
|
252
|
+
// detected against the actual summary.
|
|
253
|
+
private renamePrepared = false;
|
|
254
|
+
private renameInverse: Map<string, string> | undefined;
|
|
255
|
+
private renameErr: TopicRemapError | undefined;
|
|
256
|
+
|
|
257
|
+
constructor(rs: ReadSource, opts: ReaderOptions = {}) {
|
|
258
|
+
this.rs = rs;
|
|
259
|
+
this.decompress = opts.decompress ?? defaultDecompressor;
|
|
260
|
+
const remap = opts.topicRemap;
|
|
261
|
+
this.topicRemap =
|
|
262
|
+
remap !== undefined && Object.keys(remap).length > 0
|
|
263
|
+
? new Map(Object.entries(remap))
|
|
264
|
+
: undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Build the exposed -> in-file inverse map once the summary is known and
|
|
269
|
+
* validate that no two topics collapse onto the same exposed name. Results
|
|
270
|
+
* (including any error) are cached. Returns undefined when no remap is set.
|
|
271
|
+
*/
|
|
272
|
+
private prepareRename(summary: Summary): Map<string, string> | undefined {
|
|
273
|
+
if (this.renamePrepared) {
|
|
274
|
+
if (this.renameErr !== undefined) {
|
|
275
|
+
throw this.renameErr;
|
|
276
|
+
}
|
|
277
|
+
return this.renameInverse;
|
|
278
|
+
}
|
|
279
|
+
this.renamePrepared = true;
|
|
280
|
+
if (this.topicRemap === undefined) {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
const inverse = new Map<string, string>();
|
|
284
|
+
for (const ti of summary.topicsInfos) {
|
|
285
|
+
for (const tm of ti.topicMetadatas) {
|
|
286
|
+
const exposed = this.topicRemap.get(tm.name) ?? tm.name;
|
|
287
|
+
const prev = inverse.get(exposed);
|
|
288
|
+
if (prev !== undefined) {
|
|
289
|
+
this.renameErr = new TopicRemapError(exposed, prev, tm.name);
|
|
290
|
+
throw this.renameErr;
|
|
291
|
+
}
|
|
292
|
+
inverse.set(exposed, tm.name);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
this.renameInverse = inverse;
|
|
296
|
+
return inverse;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Return a copy of `summary` with TopicMetadata.name rewritten to exposed
|
|
301
|
+
* names. Index-chunk lists and metadata maps are shared (read-only here).
|
|
302
|
+
* Returns the original summary when no remap is configured.
|
|
303
|
+
*/
|
|
304
|
+
private exposedSummary(summary: Summary): Summary {
|
|
305
|
+
if (this.topicRemap === undefined) {
|
|
306
|
+
return summary;
|
|
307
|
+
}
|
|
308
|
+
this.prepareRename(summary);
|
|
309
|
+
return {
|
|
310
|
+
topicsInfos: summary.topicsInfos.map((ti) => ({
|
|
311
|
+
topicMetadatas: ti.topicMetadatas.map((tm) => ({
|
|
312
|
+
id: tm.id,
|
|
313
|
+
name: this.topicRemap!.get(tm.name) ?? tm.name,
|
|
314
|
+
metadata: tm.metadata,
|
|
315
|
+
messageCount: tm.messageCount,
|
|
316
|
+
})),
|
|
317
|
+
indexChunkInfoList: ti.indexChunkInfoList,
|
|
318
|
+
totalLen: ti.totalLen,
|
|
319
|
+
})),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Returns the parsed summary, loading and caching it on first call. When a
|
|
325
|
+
* topic remap is configured, TopicMetadata.name reports exposed names.
|
|
326
|
+
*/
|
|
327
|
+
async summary(): Promise<Summary> {
|
|
328
|
+
return this.exposedSummary(await this.summaryWithHint(0n));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async summaryWithHint(prefetch: bigint): Promise<Summary> {
|
|
332
|
+
if (this.cachedSummary !== undefined) {
|
|
333
|
+
return this.cachedSummary;
|
|
334
|
+
}
|
|
335
|
+
if (this.cachedSize === undefined) {
|
|
336
|
+
this.cachedSize = await this.rs.size();
|
|
337
|
+
}
|
|
338
|
+
const size = this.cachedSize;
|
|
339
|
+
if (size < BigInt(FOOTER_LEN)) {
|
|
340
|
+
throw new Error("file is too small to contain a footer");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let pf = prefetch;
|
|
344
|
+
if (pf <= 0n) {
|
|
345
|
+
pf = BigInt(FOOTER_LEN);
|
|
346
|
+
}
|
|
347
|
+
if (pf > size) {
|
|
348
|
+
pf = size;
|
|
349
|
+
}
|
|
350
|
+
const tail = await this.rs.read(size - pf, pf);
|
|
351
|
+
|
|
352
|
+
const footerStart = tail.byteLength - FOOTER_LEN;
|
|
353
|
+
if (footerStart < 0) {
|
|
354
|
+
throw new Error("tail read shorter than footer");
|
|
355
|
+
}
|
|
356
|
+
const footer = readFooter(
|
|
357
|
+
new BinaryReader(tail.subarray(footerStart)),
|
|
358
|
+
);
|
|
359
|
+
if (!magicEquals(footer.magic, MAGIC)) {
|
|
360
|
+
throw new Error("invalid magic number");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const summaryLen = footer.summaryLen;
|
|
364
|
+
let compressedSummary: Uint8Array;
|
|
365
|
+
const tailLen = BigInt(tail.byteLength);
|
|
366
|
+
if (tailLen >= summaryLen + BigInt(FOOTER_LEN)) {
|
|
367
|
+
const start = Number(tailLen - BigInt(FOOTER_LEN) - summaryLen);
|
|
368
|
+
compressedSummary = tail.subarray(start, start + Number(summaryLen));
|
|
369
|
+
} else {
|
|
370
|
+
compressedSummary = await this.rs.read(
|
|
371
|
+
size - summaryLen - BigInt(FOOTER_LEN),
|
|
372
|
+
summaryLen,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const decompressed = this.decompress(compressedSummary);
|
|
377
|
+
const summary = readSummary(new BinaryReader(decompressed));
|
|
378
|
+
this.cachedSummary = summary;
|
|
379
|
+
return summary;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Read messages. Returns an async iterable; iterating consumes lazily.
|
|
384
|
+
*
|
|
385
|
+
* Concurrency note: because the iterator carries internal mutable state
|
|
386
|
+
* (cursors and a reusable buffer), only one iteration at a time over the
|
|
387
|
+
* same returned async iterable is supported.
|
|
388
|
+
*/
|
|
389
|
+
readMessages(opts: ReadOptions = {}): AsyncIterableIterator<Message> {
|
|
390
|
+
const reader = this;
|
|
391
|
+
const tailPrefetch = opts.tailPrefetch ?? 0n;
|
|
392
|
+
const startTimestamp = opts.startTimestamp ?? 0n;
|
|
393
|
+
const endTimestamp = opts.endTimestamp ?? MAX_INT64;
|
|
394
|
+
const reverse = opts.order === "reverse-time";
|
|
395
|
+
const wantedNames = opts.topicNames;
|
|
396
|
+
const strategy = opts.strategy;
|
|
397
|
+
const copy = opts.copy === true;
|
|
398
|
+
const videoDecodable = opts.videoDecodable === true;
|
|
399
|
+
|
|
400
|
+
let initialized = false;
|
|
401
|
+
let exhausted = false;
|
|
402
|
+
|
|
403
|
+
let groupIts: GroupIt[] = [];
|
|
404
|
+
const heap = new MessageHeap<{
|
|
405
|
+
groupIdx: number;
|
|
406
|
+
topicId: number;
|
|
407
|
+
data: Uint8Array;
|
|
408
|
+
}>(reverse);
|
|
409
|
+
const idToName = new Map<number, string>();
|
|
410
|
+
|
|
411
|
+
const init = async (): Promise<void> => {
|
|
412
|
+
const summary = await reader.summaryWithHint(tailPrefetch);
|
|
413
|
+
|
|
414
|
+
// When a remap is configured, caller-supplied topicNames arrive in
|
|
415
|
+
// exposed-name space. Internal matching stays in in-file space, so
|
|
416
|
+
// translate exposed -> in-file (unknown names pass through and match
|
|
417
|
+
// nothing). idToName is built in exposed space for output.
|
|
418
|
+
const remap = reader.topicRemap;
|
|
419
|
+
const inverse =
|
|
420
|
+
remap !== undefined ? reader.prepareRename(summary) : undefined;
|
|
421
|
+
|
|
422
|
+
// Build topic-name allowlist. Defaults to "all topics".
|
|
423
|
+
let wantedSet: Set<string>;
|
|
424
|
+
if (wantedNames !== undefined) {
|
|
425
|
+
wantedSet =
|
|
426
|
+
inverse !== undefined
|
|
427
|
+
? new Set(wantedNames.map((n) => inverse.get(n) ?? n))
|
|
428
|
+
: new Set(wantedNames);
|
|
429
|
+
} else {
|
|
430
|
+
wantedSet = collectAllTopicNames(summary);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Translate names -> ids; remember reverse mapping (exposed names).
|
|
434
|
+
const topicIds = new Set<number>();
|
|
435
|
+
for (const ti of summary.topicsInfos) {
|
|
436
|
+
for (const tm of ti.topicMetadatas) {
|
|
437
|
+
if (!wantedSet.has(tm.name)) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
topicIds.add(tm.id);
|
|
441
|
+
idToName.set(tm.id, remap?.get(tm.name) ?? tm.name);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (strategy !== undefined) {
|
|
446
|
+
groupIts = await reader.prepareCostAware({
|
|
447
|
+
summary,
|
|
448
|
+
topicIds,
|
|
449
|
+
wantedSet,
|
|
450
|
+
startTimestamp,
|
|
451
|
+
endTimestamp,
|
|
452
|
+
reverse,
|
|
453
|
+
strategy,
|
|
454
|
+
videoDecodable,
|
|
455
|
+
});
|
|
456
|
+
} else {
|
|
457
|
+
groupIts = reader.prepareDefault({
|
|
458
|
+
summary,
|
|
459
|
+
topicIds,
|
|
460
|
+
wantedSet,
|
|
461
|
+
startTimestamp,
|
|
462
|
+
endTimestamp,
|
|
463
|
+
reverse,
|
|
464
|
+
videoDecodable,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Prime the heap with one message from each group.
|
|
469
|
+
for (let i = 0; i < groupIts.length; i++) {
|
|
470
|
+
const m = await groupIts[i]!.next();
|
|
471
|
+
if (m === undefined) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
heap.push(m.timestamp, {
|
|
475
|
+
groupIdx: i,
|
|
476
|
+
topicId: m.topicId,
|
|
477
|
+
data: m.data,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const iter: AsyncIterableIterator<Message> = {
|
|
483
|
+
[Symbol.asyncIterator]() {
|
|
484
|
+
return iter;
|
|
485
|
+
},
|
|
486
|
+
async next(): Promise<IteratorResult<Message>> {
|
|
487
|
+
if (exhausted) {
|
|
488
|
+
return { value: undefined, done: true };
|
|
489
|
+
}
|
|
490
|
+
if (!initialized) {
|
|
491
|
+
await init();
|
|
492
|
+
initialized = true;
|
|
493
|
+
}
|
|
494
|
+
const top = heap.pop();
|
|
495
|
+
if (top === undefined) {
|
|
496
|
+
exhausted = true;
|
|
497
|
+
return { value: undefined, done: true };
|
|
498
|
+
}
|
|
499
|
+
const out: Message = {
|
|
500
|
+
timestamp: top.priority,
|
|
501
|
+
topicName: idToName.get(top.value.topicId) ?? "",
|
|
502
|
+
data: copy ? new Uint8Array(top.value.data) : top.value.data,
|
|
503
|
+
};
|
|
504
|
+
// Pull the next message from the same group and re-heap.
|
|
505
|
+
const nm = await groupIts[top.value.groupIdx]!.next();
|
|
506
|
+
if (nm !== undefined) {
|
|
507
|
+
heap.push(nm.timestamp, {
|
|
508
|
+
groupIdx: top.value.groupIdx,
|
|
509
|
+
topicId: nm.topicId,
|
|
510
|
+
data: nm.data,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return { value: out, done: false };
|
|
514
|
+
},
|
|
515
|
+
async return(): Promise<IteratorResult<Message>> {
|
|
516
|
+
exhausted = true;
|
|
517
|
+
return { value: undefined, done: true };
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
return iter;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Floor-message lookup at concrete timestamps per topic. For each
|
|
526
|
+
* (queries[i].topic, queries[i].timestamps[j]) pair, out[i][j] is the
|
|
527
|
+
* message with the greatest timestamp <= timestamps[j], or found=false when
|
|
528
|
+
* none exists.
|
|
529
|
+
*
|
|
530
|
+
* Preconditions, validated before any data I/O (all violations aggregated
|
|
531
|
+
* into a single SampleValidationError):
|
|
532
|
+
* - each queries[i].timestamps must be strictly increasing
|
|
533
|
+
* - each queries[i].topic must be unique across all i
|
|
534
|
+
* - each queries[i].topic must exist in the file's summary
|
|
535
|
+
*
|
|
536
|
+
* All data I/O runs concurrently under the hood, governed by the strategy.
|
|
537
|
+
*/
|
|
538
|
+
async sample(
|
|
539
|
+
queries: SampleQuery[],
|
|
540
|
+
opts: SampleOptions = {},
|
|
541
|
+
): Promise<SampleResult[][]> {
|
|
542
|
+
const strategy = opts.strategy ?? DEFAULT_SAMPLE_STRATEGY;
|
|
543
|
+
const videoDecodable = opts.videoDecodable === true;
|
|
544
|
+
const summary = await this.summaryWithHint(opts.tailPrefetch ?? 0n);
|
|
545
|
+
|
|
546
|
+
// When a remap is configured, queries arrive in exposed-name space.
|
|
547
|
+
// Translate to in-file names before validation and the engine call;
|
|
548
|
+
// SampleResult is positional, so no translation back is needed.
|
|
549
|
+
if (this.topicRemap !== undefined) {
|
|
550
|
+
const inverse = this.prepareRename(summary);
|
|
551
|
+
queries = queries.map((q) => ({
|
|
552
|
+
topic: inverse?.get(q.topic) ?? q.topic,
|
|
553
|
+
timestamps: q.timestamps,
|
|
554
|
+
}));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
validateSampleQueries(queries, summary);
|
|
558
|
+
|
|
559
|
+
const specs: SampleSpec[] = queries.map((q) => ({
|
|
560
|
+
topic: q.topic,
|
|
561
|
+
timestamps: q.timestamps,
|
|
562
|
+
}));
|
|
563
|
+
// SampleHit and SampleResult are structurally identical; the engine's
|
|
564
|
+
// hits double as results.
|
|
565
|
+
return sampleMessages(
|
|
566
|
+
this.rs,
|
|
567
|
+
summary,
|
|
568
|
+
specs,
|
|
569
|
+
strategy,
|
|
570
|
+
this.decompress,
|
|
571
|
+
videoDecodable,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Per-exposed-topic bounds, computed once from the cached summary. Honors any
|
|
577
|
+
* configured remap so keys are exposed names. Used by MultiReader to merge
|
|
578
|
+
* streams and enforce video time-disjointness. Mirrors go (*Reader).topicBounds
|
|
579
|
+
* and py Reader._topic_bounds.
|
|
580
|
+
*
|
|
581
|
+
* @internal
|
|
582
|
+
*/
|
|
583
|
+
async topicBounds(): Promise<Map<string, TopicBound>> {
|
|
584
|
+
if (this.boundsCache !== undefined) {
|
|
585
|
+
return this.boundsCache;
|
|
586
|
+
}
|
|
587
|
+
const summary = await this.summaryWithHint(0n);
|
|
588
|
+
this.prepareRename(summary);
|
|
589
|
+
const out = new Map<string, TopicBound>();
|
|
590
|
+
for (const ti of summary.topicsInfos) {
|
|
591
|
+
if (ti.indexChunkInfoList.length === 0) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
let minTs = ti.indexChunkInfoList[0]!.startTimestamp;
|
|
595
|
+
let maxTs = ti.indexChunkInfoList[0]!.endTimestamp;
|
|
596
|
+
for (const info of ti.indexChunkInfoList) {
|
|
597
|
+
if (info.startTimestamp < minTs) {
|
|
598
|
+
minTs = info.startTimestamp;
|
|
599
|
+
}
|
|
600
|
+
if (info.endTimestamp > maxTs) {
|
|
601
|
+
maxTs = info.endTimestamp;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const isVideo = isVideoTopicsInfo(ti);
|
|
605
|
+
for (const tm of ti.topicMetadatas) {
|
|
606
|
+
const exposed = this.topicRemap?.get(tm.name) ?? tm.name;
|
|
607
|
+
out.set(exposed, { isVideo, minTs, maxTs });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
this.boundsCache = out;
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---- Default path setup ---------------------------------------------
|
|
615
|
+
|
|
616
|
+
private prepareDefault(args: {
|
|
617
|
+
summary: Summary;
|
|
618
|
+
topicIds: Set<number>;
|
|
619
|
+
wantedSet: Set<string>;
|
|
620
|
+
startTimestamp: bigint;
|
|
621
|
+
endTimestamp: bigint;
|
|
622
|
+
reverse: boolean;
|
|
623
|
+
videoDecodable: boolean;
|
|
624
|
+
}): GroupIt[] {
|
|
625
|
+
const out: GroupIt[] = [];
|
|
626
|
+
for (const ti of args.summary.topicsInfos) {
|
|
627
|
+
// Skip groups with no relevant topic.
|
|
628
|
+
if (!groupHasAnyOf(ti, args.wantedSet)) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const it = new TopicsGroupIterator({
|
|
632
|
+
rs: this.rs,
|
|
633
|
+
decompress: this.decompress,
|
|
634
|
+
topicIds: args.topicIds,
|
|
635
|
+
startTimestamp: args.startTimestamp,
|
|
636
|
+
endTimestamp: args.endTimestamp,
|
|
637
|
+
reverse: args.reverse,
|
|
638
|
+
topicsInfo: ti,
|
|
639
|
+
videoDecodable: args.videoDecodable && isVideoTopicsInfo(ti),
|
|
640
|
+
});
|
|
641
|
+
if (!it.hasAny()) {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
out.push({ next: () => it.next() });
|
|
645
|
+
}
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ---- Cost-aware path setup ------------------------------------------
|
|
650
|
+
|
|
651
|
+
private async prepareCostAware(args: {
|
|
652
|
+
summary: Summary;
|
|
653
|
+
topicIds: Set<number>;
|
|
654
|
+
wantedSet: Set<string>;
|
|
655
|
+
startTimestamp: bigint;
|
|
656
|
+
endTimestamp: bigint;
|
|
657
|
+
reverse: boolean;
|
|
658
|
+
strategy: import("./read_strategy.js").ReadStrategy;
|
|
659
|
+
videoDecodable: boolean;
|
|
660
|
+
}): Promise<GroupIt[]> {
|
|
661
|
+
interface ScopedGroup {
|
|
662
|
+
topicsInfo: TopicsInfo;
|
|
663
|
+
isCompressed: boolean;
|
|
664
|
+
groupVideo: boolean;
|
|
665
|
+
filteredInfos: IndexChunkInfo[];
|
|
666
|
+
filteredInfoLs: bigint[];
|
|
667
|
+
}
|
|
668
|
+
const scoped: ScopedGroup[] = [];
|
|
669
|
+
for (const ti of args.summary.topicsInfos) {
|
|
670
|
+
if (!groupHasAnyOf(ti, args.wantedSet)) {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const isCompressed =
|
|
674
|
+
ti.topicMetadatas[0]?.metadata.get(META_KEY_COMPRESSED) === true;
|
|
675
|
+
const groupVideo = args.videoDecodable && isVideoTopicsInfo(ti);
|
|
676
|
+
|
|
677
|
+
const filteredInfos: IndexChunkInfo[] = [];
|
|
678
|
+
const filteredLens: bigint[] = [];
|
|
679
|
+
for (let i = 0; i < ti.indexChunkInfoList.length; i++) {
|
|
680
|
+
const info = ti.indexChunkInfoList[i]!;
|
|
681
|
+
if (info.endTimestamp < args.startTimestamp) {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (info.startTimestamp > args.endTimestamp) {
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
filteredInfos.push(info);
|
|
688
|
+
let ln: bigint;
|
|
689
|
+
if (i < ti.indexChunkInfoList.length - 1) {
|
|
690
|
+
ln = ti.indexChunkInfoList[i + 1]!.offset - info.offset;
|
|
691
|
+
} else {
|
|
692
|
+
ln = ti.totalLen - info.offset + ti.indexChunkInfoList[0]!.offset;
|
|
693
|
+
}
|
|
694
|
+
filteredLens.push(ln);
|
|
695
|
+
}
|
|
696
|
+
if (filteredInfos.length === 0) {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
scoped.push({
|
|
700
|
+
topicsInfo: ti,
|
|
701
|
+
isCompressed,
|
|
702
|
+
groupVideo,
|
|
703
|
+
filteredInfos,
|
|
704
|
+
filteredInfoLs: filteredLens,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
if (scoped.length === 0) {
|
|
708
|
+
return [];
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ---- Phase A: plan + fetch index chunks across all groups.
|
|
712
|
+
const rangesA: Range[] = [];
|
|
713
|
+
for (const g of scoped) {
|
|
714
|
+
for (let ci = 0; ci < g.filteredInfos.length; ci++) {
|
|
715
|
+
rangesA.push({
|
|
716
|
+
offset: g.filteredInfos[ci]!.offset,
|
|
717
|
+
length: g.filteredInfoLs[ci]!,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const plannedA = plan(rangesA, args.strategy);
|
|
722
|
+
const bufsA = await fetchAll(this.rs, plannedA.ops, args.strategy.maxConcurrency);
|
|
723
|
+
const loadedIndex = new LoadedBytes(rangesA, plannedA.locations, bufsA);
|
|
724
|
+
|
|
725
|
+
// ---- Decode each index chunk + run sortAndFilter.
|
|
726
|
+
interface DecodedChunk {
|
|
727
|
+
indexChunk: IndexChunk;
|
|
728
|
+
messages: MessageRef[];
|
|
729
|
+
messageLens: bigint[];
|
|
730
|
+
}
|
|
731
|
+
const perGroupChunks: DecodedChunk[][] = scoped.map(() => []);
|
|
732
|
+
for (let gi = 0; gi < scoped.length; gi++) {
|
|
733
|
+
const g = scoped[gi]!;
|
|
734
|
+
for (let ci = 0; ci < g.filteredInfos.length; ci++) {
|
|
735
|
+
const raw = loadedIndex.get(g.filteredInfos[ci]!.offset);
|
|
736
|
+
if (raw === undefined) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
`cost-aware setup: missing index chunk at offset ${g.filteredInfos[ci]!.offset}`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
const decompressed = this.decompress(raw);
|
|
742
|
+
const ic = readIndexChunk(new BinaryReader(decompressed));
|
|
743
|
+
const f = sortAndFilter(
|
|
744
|
+
ic.topicIndexes,
|
|
745
|
+
ic.uncompressedLen,
|
|
746
|
+
args.topicIds,
|
|
747
|
+
args.startTimestamp,
|
|
748
|
+
args.endTimestamp,
|
|
749
|
+
g.groupVideo,
|
|
750
|
+
);
|
|
751
|
+
perGroupChunks[gi]!.push({
|
|
752
|
+
indexChunk: ic,
|
|
753
|
+
messages: f.msgs,
|
|
754
|
+
messageLens: f.lens,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ---- Phase B: plan + fetch data ranges. Chunk-level for compressed,
|
|
760
|
+
// per-message for uncompressed.
|
|
761
|
+
const rangesB: Range[] = [];
|
|
762
|
+
for (let gi = 0; gi < scoped.length; gi++) {
|
|
763
|
+
const g = scoped[gi]!;
|
|
764
|
+
if (g.isCompressed) {
|
|
765
|
+
for (const dc of perGroupChunks[gi]!) {
|
|
766
|
+
if (dc.messages.length === 0) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
rangesB.push({
|
|
770
|
+
offset: dc.indexChunk.chunkOffset,
|
|
771
|
+
length: dc.indexChunk.chunkLen,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
for (const dc of perGroupChunks[gi]!) {
|
|
777
|
+
for (let k = 0; k < dc.messages.length; k++) {
|
|
778
|
+
const m = dc.messages[k]!;
|
|
779
|
+
rangesB.push({
|
|
780
|
+
offset: dc.indexChunk.chunkOffset + m.offsetInChunk,
|
|
781
|
+
length: dc.messageLens[k]!,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const plannedB = plan(rangesB, args.strategy);
|
|
787
|
+
const bufsB = await fetchAll(this.rs, plannedB.ops, args.strategy.maxConcurrency);
|
|
788
|
+
const loadedData = new LoadedBytes(rangesB, plannedB.locations, bufsB);
|
|
789
|
+
|
|
790
|
+
// ---- Build per-group iterators.
|
|
791
|
+
const iters: PreloadedTopicsGroupIterator[] = [];
|
|
792
|
+
for (let gi = 0; gi < scoped.length; gi++) {
|
|
793
|
+
const g = scoped[gi]!;
|
|
794
|
+
const indexChunks: IndexChunk[] = [];
|
|
795
|
+
const chunkMsgs: MessageRef[][] = [];
|
|
796
|
+
const chunkLens: bigint[][] = [];
|
|
797
|
+
for (const dc of perGroupChunks[gi]!) {
|
|
798
|
+
if (dc.messages.length === 0) {
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
indexChunks.push(dc.indexChunk);
|
|
802
|
+
chunkMsgs.push(dc.messages);
|
|
803
|
+
chunkLens.push(dc.messageLens);
|
|
804
|
+
}
|
|
805
|
+
if (indexChunks.length === 0) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
iters.push(
|
|
809
|
+
new PreloadedTopicsGroupIterator({
|
|
810
|
+
isCompressed: g.isCompressed,
|
|
811
|
+
reverse: args.reverse,
|
|
812
|
+
indexChunks,
|
|
813
|
+
chunkMessages: chunkMsgs,
|
|
814
|
+
chunkMsgLens: chunkLens,
|
|
815
|
+
loadedData,
|
|
816
|
+
decompress: this.decompress,
|
|
817
|
+
}),
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Wrap in the shared GroupIt shape so the merge loop in readMessages can
|
|
822
|
+
// treat default and cost-aware iterators identically.
|
|
823
|
+
return iters.map((it) => ({
|
|
824
|
+
next: async () => it.next(),
|
|
825
|
+
}));
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function magicEquals(a: Uint8Array, b: Uint8Array): boolean {
|
|
830
|
+
if (a.byteLength !== b.byteLength) {
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
for (let i = 0; i < a.byteLength; i++) {
|
|
834
|
+
if (a[i] !== b[i]) {
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Returns nothing when all preconditions hold; otherwise throws a
|
|
843
|
+
* SampleValidationError listing every violation (unknown/duplicate topic,
|
|
844
|
+
* non-strictly-increasing timestamps). Mirrors go validateSampleQueries.
|
|
845
|
+
*/
|
|
846
|
+
function validateSampleQueries(
|
|
847
|
+
queries: SampleQuery[],
|
|
848
|
+
summary: Summary,
|
|
849
|
+
): void {
|
|
850
|
+
const known = new Set<string>();
|
|
851
|
+
for (const ti of summary.topicsInfos) {
|
|
852
|
+
for (const tm of ti.topicMetadatas) {
|
|
853
|
+
known.add(tm.name);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const seen = new Map<string, number>();
|
|
857
|
+
const violations: string[] = [];
|
|
858
|
+
for (let i = 0; i < queries.length; i++) {
|
|
859
|
+
const q = queries[i]!;
|
|
860
|
+
if (!known.has(q.topic)) {
|
|
861
|
+
violations.push(`queries[${i}]: unknown topic "${q.topic}"`);
|
|
862
|
+
}
|
|
863
|
+
const prev = seen.get(q.topic);
|
|
864
|
+
if (prev !== undefined) {
|
|
865
|
+
violations.push(
|
|
866
|
+
`queries[${i}]: duplicate topic "${q.topic}" already used by queries[${prev}]`,
|
|
867
|
+
);
|
|
868
|
+
} else {
|
|
869
|
+
seen.set(q.topic, i);
|
|
870
|
+
}
|
|
871
|
+
for (let j = 1; j < q.timestamps.length; j++) {
|
|
872
|
+
if (q.timestamps[j]! <= q.timestamps[j - 1]!) {
|
|
873
|
+
violations.push(
|
|
874
|
+
`queries[${i}].timestamps not strictly increasing at position ${j} (${q.timestamps[j]} <= ${q.timestamps[j - 1]})`,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (violations.length > 0) {
|
|
880
|
+
throw new SampleValidationError(violations);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function collectAllTopicNames(summary: Summary): Set<string> {
|
|
885
|
+
const out = new Set<string>();
|
|
886
|
+
for (const ti of summary.topicsInfos) {
|
|
887
|
+
for (const tm of ti.topicMetadatas) {
|
|
888
|
+
out.add(tm.name);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return out;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function groupHasAnyOf(ti: TopicsInfo, names: Set<string>): boolean {
|
|
895
|
+
for (const tm of ti.topicMetadatas) {
|
|
896
|
+
if (names.has(tm.name)) {
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Whether the (single) topic in this group was opened with video=true. Video
|
|
905
|
+
* groups always have exactly one topic, so the first metadata suffices.
|
|
906
|
+
* Mirrors py _is_video_topics_info.
|
|
907
|
+
*/
|
|
908
|
+
function isVideoTopicsInfo(ti: TopicsInfo): boolean {
|
|
909
|
+
return ti.topicMetadatas[0]?.metadata.get(META_KEY_VIDEO) === true;
|
|
910
|
+
}
|