@prisma/streams-server 0.1.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/CODE_OF_CONDUCT.md +1 -1
- package/CONTRIBUTING.md +5 -5
- package/README.md +3 -3
- package/SECURITY.md +2 -2
- package/package.json +2 -2
- package/src/app_core.ts +114 -391
- package/src/db/db.ts +0 -54
- package/src/db/schema.ts +12 -6
- package/src/touch/interpreter_worker.ts +16 -33
- package/src/touch/live_metrics.ts +18 -49
- package/src/touch/manager.ts +26 -168
- package/src/touch/spec.ts +8 -78
- package/src/touch/worker_protocol.ts +0 -2
- package/src/touch/naming.ts +0 -13
- package/src/touch/routing_key_notifier.ts +0 -275
package/src/app_core.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import type { Config } from "./config";
|
|
3
|
-
import { SqliteDurableStore
|
|
3
|
+
import { SqliteDurableStore } from "./db/db";
|
|
4
4
|
import { IngestQueue, type ProducerInfo, type AppendRow } from "./ingest";
|
|
5
5
|
import type { ObjectStore } from "./objectstore/interface";
|
|
6
6
|
import type { StreamReader, ReadBatch, ReaderError } from "./reader";
|
|
@@ -20,8 +20,6 @@ import { BackpressureGate } from "./backpressure";
|
|
|
20
20
|
import { MemoryGuard } from "./memory";
|
|
21
21
|
import { TouchInterpreterManager } from "./touch/manager";
|
|
22
22
|
import { isTouchEnabled } from "./touch/spec";
|
|
23
|
-
import { resolveTouchStreamName } from "./touch/naming";
|
|
24
|
-
import { RoutingKeyNotifier } from "./touch/routing_key_notifier";
|
|
25
23
|
import { parseTouchCursor } from "./touch/touch_journal";
|
|
26
24
|
import { touchKeyIdFromRoutingKeyResult } from "./touch/touch_key_id";
|
|
27
25
|
import { tableKeyIdFor, templateKeyIdFor } from "./touch/live_keys";
|
|
@@ -205,7 +203,6 @@ export type App = {
|
|
|
205
203
|
os: ObjectStore;
|
|
206
204
|
ingest: IngestQueue;
|
|
207
205
|
notifier: StreamNotifier;
|
|
208
|
-
touchRoutingKeyNotifier: RoutingKeyNotifier;
|
|
209
206
|
reader: StreamReader;
|
|
210
207
|
segmenter: SegmenterController;
|
|
211
208
|
uploader: UploaderController;
|
|
@@ -224,7 +221,6 @@ export type CreateAppRuntimeArgs = {
|
|
|
224
221
|
db: SqliteDurableStore;
|
|
225
222
|
ingest: IngestQueue;
|
|
226
223
|
notifier: StreamNotifier;
|
|
227
|
-
touchRoutingKeyNotifier: RoutingKeyNotifier;
|
|
228
224
|
registry: SchemaRegistryStore;
|
|
229
225
|
touch: TouchInterpreterManager;
|
|
230
226
|
stats?: StatsCollector;
|
|
@@ -270,15 +266,13 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
270
266
|
const metrics = new Metrics();
|
|
271
267
|
const ingest = new IngestQueue(cfg, db, stats, backpressure, memory, metrics);
|
|
272
268
|
const notifier = new StreamNotifier();
|
|
273
|
-
const touchRoutingKeyNotifier = new RoutingKeyNotifier();
|
|
274
269
|
const registry = new SchemaRegistryStore(db);
|
|
275
|
-
const touch = new TouchInterpreterManager(cfg, db, ingest, notifier, registry, backpressure
|
|
270
|
+
const touch = new TouchInterpreterManager(cfg, db, ingest, notifier, registry, backpressure);
|
|
276
271
|
const runtime = opts.createRuntime({
|
|
277
272
|
config: cfg,
|
|
278
273
|
db,
|
|
279
274
|
ingest,
|
|
280
275
|
notifier,
|
|
281
|
-
touchRoutingKeyNotifier,
|
|
282
276
|
registry,
|
|
283
277
|
touch,
|
|
284
278
|
stats,
|
|
@@ -495,7 +489,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
495
489
|
let pathKeyParam: string | null = null;
|
|
496
490
|
let touchMode:
|
|
497
491
|
| null
|
|
498
|
-
| { kind: "read"; key: string | null }
|
|
499
492
|
| { kind: "meta" }
|
|
500
493
|
| { kind: "wait" }
|
|
501
494
|
| { kind: "templates_activate" } = null;
|
|
@@ -516,12 +509,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
516
509
|
} else if (segments.length >= 2 && segments[segments.length - 2] === "touch" && segments[segments.length - 1] === "wait") {
|
|
517
510
|
touchMode = { kind: "wait" };
|
|
518
511
|
segments.splice(segments.length - 2, 2);
|
|
519
|
-
} else if (segments.length >= 3 && segments[segments.length - 3] === "touch" && segments[segments.length - 2] === "pk") {
|
|
520
|
-
touchMode = { kind: "read", key: decodeURIComponent(segments[segments.length - 1]) };
|
|
521
|
-
segments.splice(segments.length - 3, 3);
|
|
522
|
-
} else if (segments[segments.length - 1] === "touch") {
|
|
523
|
-
touchMode = { kind: "read", key: null };
|
|
524
|
-
segments.pop();
|
|
525
512
|
} else if (segments.length >= 2 && segments[segments.length - 2] === "pk") {
|
|
526
513
|
pathKeyParam = decodeURIComponent(segments[segments.length - 1]);
|
|
527
514
|
segments.splice(segments.length - 2, 2);
|
|
@@ -664,24 +651,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
664
651
|
if (!isTouchEnabled(reg.interpreter)) return notFound("touch not enabled");
|
|
665
652
|
|
|
666
653
|
const touchCfg = reg.interpreter.touch;
|
|
667
|
-
const touchStorage = touchCfg.storage ?? "memory";
|
|
668
|
-
const derived = resolveTouchStreamName(stream, touchCfg);
|
|
669
|
-
|
|
670
|
-
const ensureTouchStream = (): Result<void, { kind: "touch_stream_content_type_mismatch"; message: string }> => {
|
|
671
|
-
const existing = db.getStream(derived);
|
|
672
|
-
if (existing) {
|
|
673
|
-
if (String(existing.content_type) !== "application/json") {
|
|
674
|
-
return Result.err({
|
|
675
|
-
kind: "touch_stream_content_type_mismatch",
|
|
676
|
-
message: `touch stream content-type mismatch: ${existing.content_type}`,
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
if ((existing.stream_flags & STREAM_FLAG_TOUCH) === 0) db.addStreamFlags(derived, STREAM_FLAG_TOUCH);
|
|
680
|
-
return Result.ok(undefined);
|
|
681
|
-
}
|
|
682
|
-
db.ensureStream(derived, { contentType: "application/json", streamFlags: STREAM_FLAG_TOUCH });
|
|
683
|
-
return Result.ok(undefined);
|
|
684
|
-
};
|
|
685
654
|
|
|
686
655
|
if (touchMode.kind === "templates_activate") {
|
|
687
656
|
if (req.method !== "POST") return badRequest("unsupported method");
|
|
@@ -728,16 +697,7 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
728
697
|
maxActiveTemplatesPerEntity: touchCfg.templates?.maxActiveTemplatesPerEntity ?? 256,
|
|
729
698
|
};
|
|
730
699
|
|
|
731
|
-
|
|
732
|
-
if (touchStorage === "sqlite") {
|
|
733
|
-
const touchRes = ensureTouchStream();
|
|
734
|
-
if (Result.isError(touchRes)) return conflict(touchRes.error.message);
|
|
735
|
-
const trow = db.getStream(derived)!;
|
|
736
|
-
const tailSeq = trow.next_offset - 1n;
|
|
737
|
-
activeFromTouchOffset = encodeOffset(trow.epoch, tailSeq);
|
|
738
|
-
} else {
|
|
739
|
-
activeFromTouchOffset = touch.getOrCreateJournal(derived, touchCfg).getCursor();
|
|
740
|
-
}
|
|
700
|
+
const activeFromTouchOffset = touch.getOrCreateJournal(stream, touchCfg).getCursor();
|
|
741
701
|
|
|
742
702
|
const res = touch.activateTemplates({
|
|
743
703
|
stream,
|
|
@@ -760,40 +720,13 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
760
720
|
} catch {
|
|
761
721
|
activeTemplates = 0;
|
|
762
722
|
}
|
|
763
|
-
|
|
764
|
-
const touchRes = ensureTouchStream();
|
|
765
|
-
if (Result.isError(touchRes)) return conflict(touchRes.error.message);
|
|
766
|
-
const trow = db.getStream(derived)!;
|
|
767
|
-
const tailSeq = trow.next_offset - 1n;
|
|
768
|
-
const currentTouchOffset = encodeOffset(trow.epoch, tailSeq);
|
|
769
|
-
const oldestSeq = db.getWalOldestOffset(derived);
|
|
770
|
-
const oldestCursorSeq = oldestSeq == null ? -1n : oldestSeq - 1n;
|
|
771
|
-
const oldestAvailableTouchOffset = encodeOffset(trow.epoch, oldestCursorSeq);
|
|
772
|
-
const clampBigInt = (v: bigint): number => {
|
|
773
|
-
if (v <= 0n) return 0;
|
|
774
|
-
const max = BigInt(Number.MAX_SAFE_INTEGER);
|
|
775
|
-
return v > max ? Number.MAX_SAFE_INTEGER : Number(v);
|
|
776
|
-
};
|
|
777
|
-
return json(200, {
|
|
778
|
-
mode: "sqlite",
|
|
779
|
-
currentTouchOffset,
|
|
780
|
-
oldestAvailableTouchOffset,
|
|
781
|
-
coarseIntervalMs: touchCfg.coarseIntervalMs ?? 100,
|
|
782
|
-
touchCoalesceWindowMs: touchCfg.touchCoalesceWindowMs ?? 100,
|
|
783
|
-
touchRetentionMs: touchCfg.retention?.maxAgeMs ?? null,
|
|
784
|
-
activeTemplates,
|
|
785
|
-
touchWalRetainedRows: clampBigInt(trow.wal_rows),
|
|
786
|
-
touchWalRetainedBytes: clampBigInt(trow.wal_bytes),
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
const meta = touch.getOrCreateJournal(derived, touchCfg).getMeta();
|
|
723
|
+
const meta = touch.getOrCreateJournal(stream, touchCfg).getMeta();
|
|
790
724
|
const runtime = touch.getTouchRuntimeSnapshot({ stream, touchCfg });
|
|
791
725
|
const interp = db.getStreamInterpreter(stream);
|
|
792
726
|
return json(200, {
|
|
793
727
|
...meta,
|
|
794
728
|
coarseIntervalMs: touchCfg.coarseIntervalMs ?? 100,
|
|
795
729
|
touchCoalesceWindowMs: touchCfg.touchCoalesceWindowMs ?? 100,
|
|
796
|
-
touchRetentionMs: null,
|
|
797
730
|
activeTemplates,
|
|
798
731
|
lagSourceOffsets: runtime.lagSourceOffsets,
|
|
799
732
|
touchMode: runtime.touchMode,
|
|
@@ -844,7 +777,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
844
777
|
}
|
|
845
778
|
const keysRaw = body?.keys;
|
|
846
779
|
const cursorRaw = body?.cursor;
|
|
847
|
-
const sinceRaw = body?.sinceTouchOffset;
|
|
848
780
|
const timeoutMsRaw = body?.timeoutMs;
|
|
849
781
|
if (keysRaw !== undefined && (!Array.isArray(keysRaw) || !keysRaw.every((k: any) => typeof k === "string" && k.trim() !== ""))) {
|
|
850
782
|
return badRequest("wait.keys must be a non-empty string array when provided");
|
|
@@ -865,13 +797,8 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
865
797
|
}
|
|
866
798
|
if (keys.length === 0 && keyIds.length === 0) return badRequest("wait requires keys or keyIds");
|
|
867
799
|
if (keyIds.length > 1024) return badRequest("wait.keyIds too large (max 1024)");
|
|
868
|
-
if (
|
|
869
|
-
|
|
870
|
-
}
|
|
871
|
-
const cursorOrSince = typeof cursorRaw === "string" && cursorRaw.trim() !== "" ? cursorRaw : sinceRaw;
|
|
872
|
-
if (typeof cursorOrSince !== "string" || cursorOrSince.trim() === "") {
|
|
873
|
-
return badRequest(touchStorage === "memory" ? "wait.cursor must be a non-empty string" : "wait.sinceTouchOffset must be a non-empty string");
|
|
874
|
-
}
|
|
800
|
+
if (typeof cursorRaw !== "string" || cursorRaw.trim() === "") return badRequest("wait.cursor must be a non-empty string");
|
|
801
|
+
const cursor = cursorRaw.trim();
|
|
875
802
|
|
|
876
803
|
const timeoutMs =
|
|
877
804
|
timeoutMsRaw === undefined ? 30_000 : typeof timeoutMsRaw === "number" && Number.isFinite(timeoutMsRaw) ? Math.max(0, Math.min(120_000, timeoutMsRaw)) : null;
|
|
@@ -923,16 +850,7 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
923
850
|
templates.push({ entity, fields });
|
|
924
851
|
}
|
|
925
852
|
if (templates.length !== declareTemplatesRaw.length) return badRequest("wait.declareTemplates contains invalid template definitions");
|
|
926
|
-
|
|
927
|
-
if (touchStorage === "sqlite") {
|
|
928
|
-
const touchRes = ensureTouchStream();
|
|
929
|
-
if (Result.isError(touchRes)) return conflict(touchRes.error.message);
|
|
930
|
-
const trow = db.getStream(derived)!;
|
|
931
|
-
const tailSeq = trow.next_offset - 1n;
|
|
932
|
-
activeFromTouchOffset = encodeOffset(trow.epoch, tailSeq);
|
|
933
|
-
} else {
|
|
934
|
-
activeFromTouchOffset = touch.getOrCreateJournal(derived, touchCfg).getCursor();
|
|
935
|
-
}
|
|
853
|
+
const activeFromTouchOffset = touch.getOrCreateJournal(stream, touchCfg).getCursor();
|
|
936
854
|
touch.activateTemplates({
|
|
937
855
|
stream,
|
|
938
856
|
touchCfg,
|
|
@@ -943,344 +861,150 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
943
861
|
});
|
|
944
862
|
}
|
|
945
863
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
parsedKeyIds.push(keyIdRes.value);
|
|
956
|
-
}
|
|
957
|
-
rawFineKeyIds = parsedKeyIds;
|
|
958
|
-
}
|
|
959
|
-
const templateWaitKeyIds =
|
|
960
|
-
templateIdsUsed.length > 0
|
|
961
|
-
? Array.from(new Set(templateIdsUsed.map((templateId) => templateKeyIdFor(templateId) >>> 0)))
|
|
962
|
-
: [];
|
|
963
|
-
let waitKeyIds = rawFineKeyIds;
|
|
964
|
-
let effectiveWaitKind: "fineKey" | "templateKey" | "tableKey" = "fineKey";
|
|
965
|
-
|
|
966
|
-
if (interestMode === "coarse") {
|
|
967
|
-
effectiveWaitKind = "tableKey";
|
|
968
|
-
} else if (runtime.touchMode === "restricted" && templateIdsUsed.length > 0) {
|
|
969
|
-
effectiveWaitKind = "templateKey";
|
|
970
|
-
} else if (runtime.touchMode === "coarseOnly" && templateIdsUsed.length > 0) {
|
|
971
|
-
effectiveWaitKind = "tableKey";
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
if (effectiveWaitKind === "templateKey") {
|
|
975
|
-
waitKeyIds = templateWaitKeyIds;
|
|
976
|
-
} else if (effectiveWaitKind === "tableKey") {
|
|
977
|
-
if (templateIdsUsed.length > 0) {
|
|
978
|
-
const entities = touch.resolveTemplateEntitiesForWait({ stream, templateIdsUsed });
|
|
979
|
-
waitKeyIds = Array.from(new Set(entities.map((entity) => tableKeyIdFor(entity) >>> 0)));
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// Keep fine waits resilient to runtime mode flips: include template-key
|
|
984
|
-
// fallbacks even when the current mode is fine. This avoids starvation
|
|
985
|
-
// when a long-poll starts in fine mode but DS degrades to restricted
|
|
986
|
-
// before that waiter naturally re-issues.
|
|
987
|
-
if (interestMode === "fine" && effectiveWaitKind === "fineKey" && templateWaitKeyIds.length > 0) {
|
|
988
|
-
const merged = new Set<number>();
|
|
989
|
-
for (const keyId of waitKeyIds) merged.add(keyId >>> 0);
|
|
990
|
-
for (const keyId of templateWaitKeyIds) merged.add(keyId >>> 0);
|
|
991
|
-
waitKeyIds = Array.from(merged);
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
if (waitKeyIds.length === 0) {
|
|
995
|
-
waitKeyIds = rawFineKeyIds;
|
|
996
|
-
effectiveWaitKind = "fineKey";
|
|
864
|
+
const j = touch.getOrCreateJournal(stream, touchCfg);
|
|
865
|
+
const runtime = touch.getTouchRuntimeSnapshot({ stream, touchCfg });
|
|
866
|
+
let rawFineKeyIds = keyIds;
|
|
867
|
+
if (keyIds.length === 0) {
|
|
868
|
+
const parsedKeyIds: number[] = [];
|
|
869
|
+
for (const key of keys) {
|
|
870
|
+
const keyIdRes = touchKeyIdFromRoutingKeyResult(key);
|
|
871
|
+
if (Result.isError(keyIdRes)) return internalError();
|
|
872
|
+
parsedKeyIds.push(keyIdRes.value);
|
|
997
873
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
const latencyMs = Date.now() - waitStartMs;
|
|
1015
|
-
touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "stale", latencyMs });
|
|
1016
|
-
return json(200, {
|
|
1017
|
-
stale: true,
|
|
1018
|
-
cursor: j.getCursor(),
|
|
1019
|
-
epoch: j.getEpoch(),
|
|
1020
|
-
generation: j.getGeneration(),
|
|
1021
|
-
effectiveWaitKind,
|
|
1022
|
-
bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
|
|
1023
|
-
flushAtMs: j.getLastFlushAtMs(),
|
|
1024
|
-
bucketStartMs: j.getLastBucketStartMs(),
|
|
1025
|
-
error: { code: "stale", message: "cursor epoch mismatch; rerun/re-subscribe and start from cursor" },
|
|
1026
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
sinceGen = parsed.generation;
|
|
1029
|
-
}
|
|
874
|
+
rawFineKeyIds = parsedKeyIds;
|
|
875
|
+
}
|
|
876
|
+
const templateWaitKeyIds =
|
|
877
|
+
templateIdsUsed.length > 0
|
|
878
|
+
? Array.from(new Set(templateIdsUsed.map((templateId) => templateKeyIdFor(templateId) >>> 0)))
|
|
879
|
+
: [];
|
|
880
|
+
let waitKeyIds = rawFineKeyIds;
|
|
881
|
+
let effectiveWaitKind: "fineKey" | "templateKey" | "tableKey" = "fineKey";
|
|
882
|
+
|
|
883
|
+
if (interestMode === "coarse") {
|
|
884
|
+
effectiveWaitKind = "tableKey";
|
|
885
|
+
} else if (runtime.touchMode === "restricted" && templateIdsUsed.length > 0) {
|
|
886
|
+
effectiveWaitKind = "templateKey";
|
|
887
|
+
} else if (runtime.touchMode === "coarseOnly" && templateIdsUsed.length > 0) {
|
|
888
|
+
effectiveWaitKind = "tableKey";
|
|
889
|
+
}
|
|
1030
890
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
891
|
+
if (effectiveWaitKind === "templateKey") {
|
|
892
|
+
waitKeyIds = templateWaitKeyIds;
|
|
893
|
+
} else if (effectiveWaitKind === "tableKey" && templateIdsUsed.length > 0) {
|
|
894
|
+
const entities = touch.resolveTemplateEntitiesForWait({ stream, templateIdsUsed });
|
|
895
|
+
waitKeyIds = Array.from(new Set(entities.map((entity) => tableKeyIdFor(entity) >>> 0)));
|
|
896
|
+
}
|
|
1034
897
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
bucketStartMs: j.getLastBucketStartMs(),
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
898
|
+
// Keep fine waits resilient to runtime mode flips: include template-key
|
|
899
|
+
// fallbacks even when the current mode is fine. This avoids starvation
|
|
900
|
+
// when a long-poll starts in fine mode but DS degrades to restricted
|
|
901
|
+
// before that waiter naturally re-issues.
|
|
902
|
+
if (interestMode === "fine" && effectiveWaitKind === "fineKey" && templateWaitKeyIds.length > 0) {
|
|
903
|
+
const merged = new Set<number>();
|
|
904
|
+
for (const keyId of waitKeyIds) merged.add(keyId >>> 0);
|
|
905
|
+
for (const keyId of templateWaitKeyIds) merged.add(keyId >>> 0);
|
|
906
|
+
waitKeyIds = Array.from(merged);
|
|
907
|
+
}
|
|
1048
908
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
909
|
+
if (waitKeyIds.length === 0) {
|
|
910
|
+
waitKeyIds = rawFineKeyIds;
|
|
911
|
+
effectiveWaitKind = "fineKey";
|
|
912
|
+
}
|
|
913
|
+
const hotInterestKeyIds = interestMode === "fine" ? rawFineKeyIds : waitKeyIds;
|
|
914
|
+
const releaseHotInterest = touch.beginHotWaitInterest({
|
|
915
|
+
stream,
|
|
916
|
+
touchCfg,
|
|
917
|
+
keyIds: hotInterestKeyIds,
|
|
918
|
+
templateIdsUsed,
|
|
919
|
+
interestMode,
|
|
920
|
+
});
|
|
921
|
+
try {
|
|
922
|
+
let sinceGen: number;
|
|
923
|
+
if (cursor === "now") {
|
|
924
|
+
sinceGen = j.getGeneration();
|
|
925
|
+
} else {
|
|
926
|
+
const parsed = parseTouchCursor(cursor);
|
|
927
|
+
if (!parsed) return badRequest("wait.cursor must be in the form <epochHex>:<generation> or 'now'");
|
|
928
|
+
if (parsed.epoch !== j.getEpoch()) {
|
|
1052
929
|
const latencyMs = Date.now() - waitStartMs;
|
|
1053
|
-
touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "
|
|
930
|
+
touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "stale", latencyMs });
|
|
1054
931
|
return json(200, {
|
|
1055
|
-
|
|
932
|
+
stale: true,
|
|
1056
933
|
cursor: j.getCursor(),
|
|
934
|
+
epoch: j.getEpoch(),
|
|
935
|
+
generation: j.getGeneration(),
|
|
1057
936
|
effectiveWaitKind,
|
|
1058
937
|
bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
|
|
1059
938
|
flushAtMs: j.getLastFlushAtMs(),
|
|
1060
939
|
bucketStartMs: j.getLastBucketStartMs(),
|
|
940
|
+
error: { code: "stale", message: "cursor epoch mismatch; rerun/re-subscribe and start from cursor" },
|
|
1061
941
|
});
|
|
1062
942
|
}
|
|
943
|
+
sinceGen = parsed.generation;
|
|
944
|
+
}
|
|
1063
945
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
const hit = await j.waitForAny({ keys: waitKeyIds, afterGeneration: afterGen, timeoutMs: remaining, signal: req.signal });
|
|
1067
|
-
if (req.signal.aborted) return new Response(null, { status: 204 });
|
|
1068
|
-
|
|
1069
|
-
if (hit == null) {
|
|
1070
|
-
const latencyMs = Date.now() - waitStartMs;
|
|
1071
|
-
touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "timeout", latencyMs });
|
|
1072
|
-
return json(200, {
|
|
1073
|
-
touched: false,
|
|
1074
|
-
cursor: j.getCursor(),
|
|
1075
|
-
effectiveWaitKind,
|
|
1076
|
-
bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
|
|
1077
|
-
flushAtMs: j.getLastFlushAtMs(),
|
|
1078
|
-
bucketStartMs: j.getLastBucketStartMs(),
|
|
1079
|
-
});
|
|
1080
|
-
}
|
|
946
|
+
const nowGen = j.getGeneration();
|
|
947
|
+
if (sinceGen > nowGen) sinceGen = nowGen;
|
|
1081
948
|
|
|
949
|
+
if (j.maybeTouchedSinceAny(waitKeyIds, sinceGen)) {
|
|
1082
950
|
const latencyMs = Date.now() - waitStartMs;
|
|
1083
951
|
touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "touched", latencyMs });
|
|
1084
952
|
return json(200, {
|
|
1085
953
|
touched: true,
|
|
1086
954
|
cursor: j.getCursor(),
|
|
1087
955
|
effectiveWaitKind,
|
|
1088
|
-
bucketMaxSourceOffsetSeq:
|
|
1089
|
-
flushAtMs:
|
|
1090
|
-
bucketStartMs:
|
|
1091
|
-
});
|
|
1092
|
-
} finally {
|
|
1093
|
-
releaseHotInterest();
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
// touchStorage === "sqlite"
|
|
1098
|
-
const touchRes = ensureTouchStream();
|
|
1099
|
-
if (Result.isError(touchRes)) return conflict(touchRes.error.message);
|
|
1100
|
-
const trow = db.getStream(derived)!;
|
|
1101
|
-
const tailSeq = trow.next_offset - 1n;
|
|
1102
|
-
const currentTouchOffset = encodeOffset(trow.epoch, tailSeq);
|
|
1103
|
-
const oldestSeq = db.getWalOldestOffset(derived);
|
|
1104
|
-
const oldestCursorSeq = oldestSeq == null ? -1n : oldestSeq - 1n;
|
|
1105
|
-
const oldestAvailableTouchOffset = encodeOffset(trow.epoch, oldestCursorSeq);
|
|
1106
|
-
|
|
1107
|
-
const staleBody = () => ({
|
|
1108
|
-
stale: true,
|
|
1109
|
-
currentTouchOffset,
|
|
1110
|
-
oldestAvailableTouchOffset,
|
|
1111
|
-
error: {
|
|
1112
|
-
code: "stale",
|
|
1113
|
-
message:
|
|
1114
|
-
"offset is older than oldestAvailableTouchOffset; rerun/re-subscribe and start from currentTouchOffset",
|
|
1115
|
-
},
|
|
1116
|
-
});
|
|
1117
|
-
|
|
1118
|
-
let sinceSeq: bigint;
|
|
1119
|
-
if (cursorOrSince === "now") {
|
|
1120
|
-
sinceSeq = tailSeq;
|
|
1121
|
-
} else {
|
|
1122
|
-
const sinceRes = parseOffsetResult(cursorOrSince);
|
|
1123
|
-
if (Result.isError(sinceRes)) return badRequest(sinceRes.error.message);
|
|
1124
|
-
sinceSeq = offsetToSeqOrNeg1(sinceRes.value);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
if (sinceSeq < oldestCursorSeq) {
|
|
1128
|
-
const latencyMs = Date.now() - waitStartMs;
|
|
1129
|
-
touch.recordWaitMetrics({ stream, touchCfg, keysCount: keys.length, outcome: "stale", latencyMs });
|
|
1130
|
-
return json(200, staleBody());
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
const encoder = new TextEncoder();
|
|
1134
|
-
let keyBytes: Uint8Array[] | null = null;
|
|
1135
|
-
const ensureKeyBytes = (): Uint8Array[] => {
|
|
1136
|
-
if (keyBytes) return keyBytes;
|
|
1137
|
-
keyBytes = keys.map((k) => encoder.encode(k));
|
|
1138
|
-
return keyBytes;
|
|
1139
|
-
};
|
|
1140
|
-
|
|
1141
|
-
// Only use in-memory key notifications for small key sets; for huge key
|
|
1142
|
-
// sets this would cause O(keysPerWait) register/unregister overhead.
|
|
1143
|
-
const KEY_NOTIFIER_MAX_KEYS = 32;
|
|
1144
|
-
const useKeyNotifier = keys.length <= KEY_NOTIFIER_MAX_KEYS;
|
|
1145
|
-
|
|
1146
|
-
let cursorSeq = sinceSeq;
|
|
1147
|
-
const deadline = Date.now() + timeoutMs;
|
|
1148
|
-
for (;;) {
|
|
1149
|
-
if (req.signal.aborted) return new Response(null, { status: 204 });
|
|
1150
|
-
|
|
1151
|
-
const latest = db.getStream(derived);
|
|
1152
|
-
if (!latest || db.isDeleted(latest)) return notFound();
|
|
1153
|
-
|
|
1154
|
-
const endSeq = latest.next_offset - 1n;
|
|
1155
|
-
const endTouchOffset = encodeOffset(latest.epoch, endSeq);
|
|
1156
|
-
|
|
1157
|
-
const match = cursorSeq < endSeq ? db.findFirstWalOffsetForRoutingKeys(derived, cursorSeq, endSeq, ensureKeyBytes()) : null;
|
|
1158
|
-
if (match != null) {
|
|
1159
|
-
let touchedKey: string | null = null;
|
|
1160
|
-
try {
|
|
1161
|
-
const row = db.db.query(`SELECT routing_key FROM wal WHERE stream=? AND offset=? LIMIT 1;`).get(derived, match) as any;
|
|
1162
|
-
if (row && row.routing_key) {
|
|
1163
|
-
touchedKey = new TextDecoder().decode(row.routing_key as Uint8Array);
|
|
1164
|
-
}
|
|
1165
|
-
} catch {
|
|
1166
|
-
touchedKey = null;
|
|
1167
|
-
}
|
|
1168
|
-
const latencyMs = Date.now() - waitStartMs;
|
|
1169
|
-
touch.recordWaitMetrics({ stream, touchCfg, keysCount: keys.length, outcome: "touched", latencyMs });
|
|
1170
|
-
return json(200, {
|
|
1171
|
-
touched: true,
|
|
1172
|
-
touchOffset: encodeOffset(latest.epoch, match),
|
|
1173
|
-
currentTouchOffset: endTouchOffset,
|
|
1174
|
-
touchedKeys: touchedKey ? [touchedKey] : [],
|
|
956
|
+
bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
|
|
957
|
+
flushAtMs: j.getLastFlushAtMs(),
|
|
958
|
+
bucketStartMs: j.getLastBucketStartMs(),
|
|
1175
959
|
});
|
|
1176
960
|
}
|
|
1177
961
|
|
|
962
|
+
const deadline = Date.now() + timeoutMs;
|
|
1178
963
|
const remaining = deadline - Date.now();
|
|
1179
964
|
if (remaining <= 0) {
|
|
1180
|
-
// Return the tail as-of the timeout moment, not as-of the last scan.
|
|
1181
|
-
const latest2 = db.getStream(derived);
|
|
1182
|
-
if (!latest2 || db.isDeleted(latest2)) return notFound();
|
|
1183
|
-
const endSeq2 = latest2.next_offset - 1n;
|
|
1184
|
-
const endTouchOffset2 = encodeOffset(latest2.epoch, endSeq2);
|
|
1185
965
|
const latencyMs = Date.now() - waitStartMs;
|
|
1186
|
-
touch.recordWaitMetrics({ stream, touchCfg, keysCount:
|
|
1187
|
-
return json(200, {
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
afterSeq: endSeq,
|
|
1195
|
-
timeoutMs: remaining,
|
|
1196
|
-
signal: req.signal,
|
|
966
|
+
touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "timeout", latencyMs });
|
|
967
|
+
return json(200, {
|
|
968
|
+
touched: false,
|
|
969
|
+
cursor: j.getCursor(),
|
|
970
|
+
effectiveWaitKind,
|
|
971
|
+
bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
|
|
972
|
+
flushAtMs: j.getLastFlushAtMs(),
|
|
973
|
+
bucketStartMs: j.getLastBucketStartMs(),
|
|
1197
974
|
});
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
const latest2 = db.getStream(derived);
|
|
1201
|
-
if (!latest2 || db.isDeleted(latest2)) return notFound();
|
|
1202
|
-
const endSeq2 = latest2.next_offset - 1n;
|
|
1203
|
-
const endTouchOffset2 = encodeOffset(latest2.epoch, endSeq2);
|
|
975
|
+
}
|
|
1204
976
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
return json(200, { touched: false, currentTouchOffset: endTouchOffset2 });
|
|
1209
|
-
}
|
|
977
|
+
const afterGen = j.getGeneration();
|
|
978
|
+
const hit = await j.waitForAny({ keys: waitKeyIds, afterGeneration: afterGen, timeoutMs: remaining, signal: req.signal });
|
|
979
|
+
if (req.signal.aborted) return new Response(null, { status: 204 });
|
|
1210
980
|
|
|
981
|
+
if (hit == null) {
|
|
1211
982
|
const latencyMs = Date.now() - waitStartMs;
|
|
1212
|
-
touch.recordWaitMetrics({ stream, touchCfg, keysCount:
|
|
983
|
+
touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "timeout", latencyMs });
|
|
1213
984
|
return json(200, {
|
|
1214
|
-
touched:
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
985
|
+
touched: false,
|
|
986
|
+
cursor: j.getCursor(),
|
|
987
|
+
effectiveWaitKind,
|
|
988
|
+
bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
|
|
989
|
+
flushAtMs: j.getLastFlushAtMs(),
|
|
990
|
+
bucketStartMs: j.getLastBucketStartMs(),
|
|
1218
991
|
});
|
|
1219
992
|
}
|
|
1220
993
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
const touchRes = ensureTouchStream();
|
|
1234
|
-
if (Result.isError(touchRes)) return conflict(touchRes.error.message);
|
|
1235
|
-
const trow = db.getStream(derived)!;
|
|
1236
|
-
const tailSeq = trow.next_offset - 1n;
|
|
1237
|
-
const currentTouchOffset = encodeOffset(trow.epoch, tailSeq);
|
|
1238
|
-
const oldestSeq = db.getWalOldestOffset(derived);
|
|
1239
|
-
const oldestCursorSeq = oldestSeq == null ? -1n : oldestSeq - 1n;
|
|
1240
|
-
const oldestAvailableTouchOffset = encodeOffset(trow.epoch, oldestCursorSeq);
|
|
1241
|
-
|
|
1242
|
-
const staleBody = () => ({
|
|
1243
|
-
stale: true,
|
|
1244
|
-
currentTouchOffset,
|
|
1245
|
-
oldestAvailableTouchOffset,
|
|
1246
|
-
error: {
|
|
1247
|
-
code: "stale",
|
|
1248
|
-
message:
|
|
1249
|
-
"offset is older than oldestAvailableTouchOffset; rerun/re-subscribe and start from currentTouchOffset",
|
|
1250
|
-
},
|
|
1251
|
-
});
|
|
1252
|
-
|
|
1253
|
-
// Default to "subscribe from now" for companion touches.
|
|
1254
|
-
const nextUrl = new URL(req.url, "http://localhost");
|
|
1255
|
-
if (!nextUrl.searchParams.has("offset")) {
|
|
1256
|
-
const sinceParam = nextUrl.searchParams.get("since");
|
|
1257
|
-
if (sinceParam) {
|
|
1258
|
-
const sinceRes = parseTimestampMsResult(sinceParam);
|
|
1259
|
-
if (Result.isError(sinceRes)) return badRequest(sinceRes.error.message);
|
|
1260
|
-
const key = touchMode.key ?? nextUrl.searchParams.get("key");
|
|
1261
|
-
const computedRes = await reader.seekOffsetByTimestampResult(derived, sinceRes.value, key ?? null);
|
|
1262
|
-
if (Result.isError(computedRes)) return readerErrorResponse(computedRes.error);
|
|
1263
|
-
nextUrl.searchParams.set("offset", computedRes.value);
|
|
1264
|
-
nextUrl.searchParams.delete("since");
|
|
1265
|
-
} else {
|
|
1266
|
-
nextUrl.searchParams.set("offset", "now");
|
|
994
|
+
const latencyMs = Date.now() - waitStartMs;
|
|
995
|
+
touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "touched", latencyMs });
|
|
996
|
+
return json(200, {
|
|
997
|
+
touched: true,
|
|
998
|
+
cursor: j.getCursor(),
|
|
999
|
+
effectiveWaitKind,
|
|
1000
|
+
bucketMaxSourceOffsetSeq: hit.bucketMaxSourceOffsetSeq.toString(),
|
|
1001
|
+
flushAtMs: hit.flushAtMs,
|
|
1002
|
+
bucketStartMs: hit.bucketStartMs,
|
|
1003
|
+
});
|
|
1004
|
+
} finally {
|
|
1005
|
+
releaseHotInterest();
|
|
1267
1006
|
}
|
|
1268
1007
|
}
|
|
1269
|
-
|
|
1270
|
-
const requestedOffset = nextUrl.searchParams.get("offset") ?? "";
|
|
1271
|
-
if (requestedOffset !== "now") {
|
|
1272
|
-
const requestedRes = parseOffsetResult(requestedOffset);
|
|
1273
|
-
if (Result.isError(requestedRes)) return badRequest(requestedRes.error.message);
|
|
1274
|
-
const seq = offsetToSeqOrNeg1(requestedRes.value);
|
|
1275
|
-
if (seq < oldestCursorSeq) return json(409, staleBody());
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// Delegate to the standard stream read path for the internal derived stream.
|
|
1279
|
-
const touchPath = touchMode.key
|
|
1280
|
-
? `${streamPrefix}${encodeURIComponent(derived)}/pk/${encodeURIComponent(touchMode.key)}`
|
|
1281
|
-
: `${streamPrefix}${encodeURIComponent(derived)}`;
|
|
1282
|
-
nextUrl.pathname = touchPath;
|
|
1283
|
-
return fetch(new Request(nextUrl.toString(), req));
|
|
1284
1008
|
}
|
|
1285
1009
|
|
|
1286
1010
|
// Stream lifecycle.
|
|
@@ -1967,7 +1691,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
|
|
|
1967
1691
|
os: store,
|
|
1968
1692
|
ingest,
|
|
1969
1693
|
notifier,
|
|
1970
|
-
touchRoutingKeyNotifier,
|
|
1971
1694
|
reader,
|
|
1972
1695
|
segmenter,
|
|
1973
1696
|
uploader,
|