react-native-nitro-logger 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 +20 -0
- package/NitroLogger.podspec +29 -0
- package/README.md +214 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +124 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +11 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/AndroidPlatformIo.kt +100 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/HybridFileSink.kt +292 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/HybridNativeConsoleSink.kt +23 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogFileWriter.kt +1328 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogRotationPolicy.kt +186 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogSecureFile.kt +80 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/LogWriterRegistry.kt +420 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/NativeConsoleWriter.kt +237 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/NitroLoggerPackage.kt +22 -0
- package/android/src/main/java/com/margelo/nitro/nitrologger/PlatformIo.kt +165 -0
- package/docs/PARITY.md +330 -0
- package/docs/PRIVACY.md +341 -0
- package/eslint-plugin/README.md +238 -0
- package/eslint-plugin/index.js +62 -0
- package/eslint-plugin/rules/literal-subsystem.js +102 -0
- package/eslint-plugin/rules/no-computed-metadata-key.js +174 -0
- package/eslint-plugin/rules/no-derived-correlation.js +171 -0
- package/eslint-plugin/rules/no-dynamic-message.js +60 -0
- package/eslint-plugin/shared.js +1948 -0
- package/ios/Gzip.swift +203 -0
- package/ios/HybridFileSink.swift +291 -0
- package/ios/HybridNativeConsoleSink.swift +20 -0
- package/ios/LogFileWriter.swift +1280 -0
- package/ios/LogSecureFile.swift +296 -0
- package/ios/LogWriterRegistry.swift +444 -0
- package/ios/NativeConsoleWriter.swift +180 -0
- package/lib/module/Logger.js +381 -0
- package/lib/module/Logger.js.map +1 -0
- package/lib/module/ScopedLogger.js +70 -0
- package/lib/module/ScopedLogger.js.map +1 -0
- package/lib/module/config.js +23 -0
- package/lib/module/config.js.map +1 -0
- package/lib/module/destinations/Batcher.js +647 -0
- package/lib/module/destinations/Batcher.js.map +1 -0
- package/lib/module/destinations/ConsoleDestination.js +51 -0
- package/lib/module/destinations/ConsoleDestination.js.map +1 -0
- package/lib/module/destinations/FileDestination.js +360 -0
- package/lib/module/destinations/FileDestination.js.map +1 -0
- package/lib/module/destinations/NativeConsoleDestination.js +170 -0
- package/lib/module/destinations/NativeConsoleDestination.js.map +1 -0
- package/lib/module/destinations/types.js +4 -0
- package/lib/module/destinations/types.js.map +1 -0
- package/lib/module/formatters/DefaultFormatter.js +136 -0
- package/lib/module/formatters/DefaultFormatter.js.map +1 -0
- package/lib/module/formatters/JsonLinesFormatter.js +162 -0
- package/lib/module/formatters/JsonLinesFormatter.js.map +1 -0
- package/lib/module/formatters/timestamp.js +15 -0
- package/lib/module/formatters/timestamp.js.map +1 -0
- package/lib/module/formatters/types.js +4 -0
- package/lib/module/formatters/types.js.map +1 -0
- package/lib/module/index.js +31 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/integrations/appState.js +81 -0
- package/lib/module/integrations/appState.js.map +1 -0
- package/lib/module/integrations/errorHandler.js +166 -0
- package/lib/module/integrations/errorHandler.js.map +1 -0
- package/lib/module/integrations/sanitizeError.js +238 -0
- package/lib/module/integrations/sanitizeError.js.map +1 -0
- package/lib/module/levels.js +35 -0
- package/lib/module/levels.js.map +1 -0
- package/lib/module/metadata.js +52 -0
- package/lib/module/metadata.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/privacy.js +349 -0
- package/lib/module/privacy.js.map +1 -0
- package/lib/module/specs/FileSink.nitro.js +4 -0
- package/lib/module/specs/FileSink.nitro.js.map +1 -0
- package/lib/module/specs/NativeConsoleSink.nitro.js +4 -0
- package/lib/module/specs/NativeConsoleSink.nitro.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utf8.js +42 -0
- package/lib/module/utf8.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/Logger.d.ts +122 -0
- package/lib/typescript/src/Logger.d.ts.map +1 -0
- package/lib/typescript/src/ScopedLogger.d.ts +36 -0
- package/lib/typescript/src/ScopedLogger.d.ts.map +1 -0
- package/lib/typescript/src/config.d.ts +12 -0
- package/lib/typescript/src/config.d.ts.map +1 -0
- package/lib/typescript/src/destinations/Batcher.d.ts +244 -0
- package/lib/typescript/src/destinations/Batcher.d.ts.map +1 -0
- package/lib/typescript/src/destinations/ConsoleDestination.d.ts +27 -0
- package/lib/typescript/src/destinations/ConsoleDestination.d.ts.map +1 -0
- package/lib/typescript/src/destinations/FileDestination.d.ts +200 -0
- package/lib/typescript/src/destinations/FileDestination.d.ts.map +1 -0
- package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts +86 -0
- package/lib/typescript/src/destinations/NativeConsoleDestination.d.ts.map +1 -0
- package/lib/typescript/src/destinations/types.d.ts +19 -0
- package/lib/typescript/src/destinations/types.d.ts.map +1 -0
- package/lib/typescript/src/formatters/DefaultFormatter.d.ts +22 -0
- package/lib/typescript/src/formatters/DefaultFormatter.d.ts.map +1 -0
- package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts +73 -0
- package/lib/typescript/src/formatters/JsonLinesFormatter.d.ts.map +1 -0
- package/lib/typescript/src/formatters/timestamp.d.ts +6 -0
- package/lib/typescript/src/formatters/timestamp.d.ts.map +1 -0
- package/lib/typescript/src/formatters/types.d.ts +27 -0
- package/lib/typescript/src/formatters/types.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +34 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/integrations/appState.d.ts +33 -0
- package/lib/typescript/src/integrations/appState.d.ts.map +1 -0
- package/lib/typescript/src/integrations/errorHandler.d.ts +55 -0
- package/lib/typescript/src/integrations/errorHandler.d.ts.map +1 -0
- package/lib/typescript/src/integrations/sanitizeError.d.ts +89 -0
- package/lib/typescript/src/integrations/sanitizeError.d.ts.map +1 -0
- package/lib/typescript/src/levels.d.ts +9 -0
- package/lib/typescript/src/levels.d.ts.map +1 -0
- package/lib/typescript/src/metadata.d.ts +23 -0
- package/lib/typescript/src/metadata.d.ts.map +1 -0
- package/lib/typescript/src/privacy.d.ts +103 -0
- package/lib/typescript/src/privacy.d.ts.map +1 -0
- package/lib/typescript/src/specs/FileSink.nitro.d.ts +131 -0
- package/lib/typescript/src/specs/FileSink.nitro.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts +21 -0
- package/lib/typescript/src/specs/NativeConsoleSink.nitro.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +37 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utf8.d.ts +15 -0
- package/lib/typescript/src/utf8.d.ts.map +1 -0
- package/nitro.json +33 -0
- package/nitrogen/generated/android/c++/JAppendResult.hpp +79 -0
- package/nitrogen/generated/android/c++/JClearOutcome.hpp +88 -0
- package/nitrogen/generated/android/c++/JFlushOutcome.hpp +81 -0
- package/nitrogen/generated/android/c++/JHybridFileSinkSpec.cpp +120 -0
- package/nitrogen/generated/android/c++/JHybridFileSinkSpec.hpp +69 -0
- package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.cpp +71 -0
- package/nitrogen/generated/android/c++/JHybridNativeConsoleSinkSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JRejectReason.hpp +64 -0
- package/nitrogen/generated/android/c++/JRotationConfig.hpp +77 -0
- package/nitrogen/generated/android/c++/JSinkStatus.hpp +69 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/AppendResult.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/ClearOutcome.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/FlushOutcome.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridFileSinkSpec.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/HybridNativeConsoleSinkSpec.kt +58 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RejectReason.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/RotationConfig.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/SinkStatus.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitrologger/nitrologgerOnLoad.kt +35 -0
- package/nitrogen/generated/android/nitrologger+autolinking.cmake +83 -0
- package/nitrogen/generated/android/nitrologger+autolinking.gradle +27 -0
- package/nitrogen/generated/android/nitrologgerOnLoad.cpp +70 -0
- package/nitrogen/generated/android/nitrologgerOnLoad.hpp +34 -0
- package/nitrogen/generated/ios/NitroLogger+autolinking.rb +62 -0
- package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.cpp +50 -0
- package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Bridge.hpp +216 -0
- package/nitrogen/generated/ios/NitroLogger-Swift-Cxx-Umbrella.hpp +69 -0
- package/nitrogen/generated/ios/NitroLoggerAutolinking.mm +41 -0
- package/nitrogen/generated/ios/NitroLoggerAutolinking.swift +38 -0
- package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridFileSinkSpecSwift.hpp +150 -0
- package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridNativeConsoleSinkSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/swift/AppendResult.swift +60 -0
- package/nitrogen/generated/ios/swift/ClearOutcome.swift +50 -0
- package/nitrogen/generated/ios/swift/FlushOutcome.swift +59 -0
- package/nitrogen/generated/ios/swift/HybridFileSinkSpec.swift +61 -0
- package/nitrogen/generated/ios/swift/HybridFileSinkSpec_cxx.swift +227 -0
- package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridNativeConsoleSinkSpec_cxx.swift +148 -0
- package/nitrogen/generated/ios/swift/RejectReason.swift +48 -0
- package/nitrogen/generated/ios/swift/RotationConfig.swift +93 -0
- package/nitrogen/generated/ios/swift/SinkStatus.swift +44 -0
- package/nitrogen/generated/shared/c++/AppendResult.hpp +105 -0
- package/nitrogen/generated/shared/c++/ClearOutcome.hpp +96 -0
- package/nitrogen/generated/shared/c++/FlushOutcome.hpp +107 -0
- package/nitrogen/generated/shared/c++/HybridFileSinkSpec.cpp +28 -0
- package/nitrogen/generated/shared/c++/HybridFileSinkSpec.hpp +84 -0
- package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridNativeConsoleSinkSpec.hpp +64 -0
- package/nitrogen/generated/shared/c++/RejectReason.hpp +84 -0
- package/nitrogen/generated/shared/c++/RotationConfig.hpp +103 -0
- package/nitrogen/generated/shared/c++/SinkStatus.hpp +95 -0
- package/package.json +151 -0
- package/src/Logger.ts +417 -0
- package/src/ScopedLogger.ts +94 -0
- package/src/config.ts +25 -0
- package/src/destinations/Batcher.ts +805 -0
- package/src/destinations/ConsoleDestination.ts +60 -0
- package/src/destinations/FileDestination.ts +473 -0
- package/src/destinations/NativeConsoleDestination.ts +220 -0
- package/src/destinations/types.ts +19 -0
- package/src/formatters/DefaultFormatter.ts +150 -0
- package/src/formatters/JsonLinesFormatter.ts +195 -0
- package/src/formatters/timestamp.ts +12 -0
- package/src/formatters/types.ts +29 -0
- package/src/index.tsx +106 -0
- package/src/integrations/appState.ts +111 -0
- package/src/integrations/errorHandler.ts +223 -0
- package/src/integrations/sanitizeError.ts +287 -0
- package/src/levels.ts +35 -0
- package/src/metadata.ts +52 -0
- package/src/privacy.ts +389 -0
- package/src/specs/FileSink.nitro.ts +144 -0
- package/src/specs/NativeConsoleSink.nitro.ts +21 -0
- package/src/types.ts +45 -0
- package/src/utf8.ts +39 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppendResult,
|
|
3
|
+
FlushOutcome,
|
|
4
|
+
RejectReason,
|
|
5
|
+
SinkStatus,
|
|
6
|
+
} from '../specs/FileSink.nitro';
|
|
7
|
+
import { utf8Length } from '../utf8';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The part of a `FileSink` the batcher drives. Narrow on purpose: it is what
|
|
11
|
+
* lets the whole backpressure and loss protocol be tested against a scripted
|
|
12
|
+
* double, with no native runtime and no filesystem.
|
|
13
|
+
*/
|
|
14
|
+
export interface BatchTarget {
|
|
15
|
+
appendBatch(batch: string, entryCount: number): AppendResult;
|
|
16
|
+
getStatus(): SinkStatus;
|
|
17
|
+
flush(deadlineMs: number): FlushOutcome;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Entries that never reached the file, and the bytes they would have been. */
|
|
21
|
+
export interface LossCounts {
|
|
22
|
+
readonly entries: number;
|
|
23
|
+
readonly bytes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Why the sink cut this handle off. Both are terminal for the batcher. */
|
|
27
|
+
export type FenceReason = 'staleGeneration' | 'closed';
|
|
28
|
+
|
|
29
|
+
export interface BatcherOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Render the consolidated loss notice in the destination's format. The
|
|
32
|
+
* notice is a record in the log file, so only the destination knows how to
|
|
33
|
+
* spell it.
|
|
34
|
+
*
|
|
35
|
+
* Returning `undefined` (or throwing) leaves the loss unacknowledged rather
|
|
36
|
+
* than silently forgotten — it will be offered again on the next batch.
|
|
37
|
+
*/
|
|
38
|
+
readonly renderNotice: (lost: LossCounts) => string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Called once when the sink fences this handle. The batcher has already
|
|
41
|
+
* discarded its buffer and baselined its counters by then; the owner
|
|
42
|
+
* decides between rebinding and self-disabling.
|
|
43
|
+
*/
|
|
44
|
+
readonly onFenced?: (reason: FenceReason) => void;
|
|
45
|
+
/** Bytes of pending content that trigger a batch. Default 4096. */
|
|
46
|
+
readonly batchBytes?: number;
|
|
47
|
+
/** Idle flush interval, also the backpressure poll interval. Default 100. */
|
|
48
|
+
readonly flushIntervalMs?: number;
|
|
49
|
+
/** Pending-buffer caps; whichever binds first. Default 1000 / 512 KB. */
|
|
50
|
+
readonly maxPendingEntries?: number;
|
|
51
|
+
readonly maxPendingBytes?: number;
|
|
52
|
+
/** Sink queue depth at which pushing pauses. Default 256 KB. */
|
|
53
|
+
readonly watermarkBytes?: number;
|
|
54
|
+
/** Largest single batch. Default 256 KB. */
|
|
55
|
+
readonly maxBatchBytes?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What a bounded drain achieved.
|
|
60
|
+
*
|
|
61
|
+
* Deliberately not the sink's `FlushOutcome`: the sink reports monotonic
|
|
62
|
+
* per-handle totals, whereas `unreportedEntries` / `unreportedBytes` are the
|
|
63
|
+
* losses that still have no notice in the file. That is the number a caller
|
|
64
|
+
* can act on, and conflating the two is how "we already logged that" turns
|
|
65
|
+
* into a loss no one ever hears about.
|
|
66
|
+
*/
|
|
67
|
+
export interface BatchFlushOutcome {
|
|
68
|
+
/** Every accepted byte reached storage, nothing is buffered, no loss is owed. */
|
|
69
|
+
readonly durable: boolean;
|
|
70
|
+
readonly timedOut: boolean;
|
|
71
|
+
/** Bytes not yet durable: still in the JS buffer plus still in the sink. */
|
|
72
|
+
readonly pendingBytes: number;
|
|
73
|
+
readonly unreportedEntries: number;
|
|
74
|
+
readonly unreportedBytes: number;
|
|
75
|
+
/** Sink degradation bitmask, passed through. */
|
|
76
|
+
readonly degraded: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Monotonic totals, JS-side and sink-side, at one instant. */
|
|
80
|
+
interface Cursor {
|
|
81
|
+
sinkEntries: number;
|
|
82
|
+
sinkBytes: number;
|
|
83
|
+
localEntries: number;
|
|
84
|
+
localBytes: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Pending {
|
|
88
|
+
readonly text: string;
|
|
89
|
+
/** UTF-8 bytes including the record's terminating newline. */
|
|
90
|
+
readonly bytes: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type PushResult = 'sent' | 'full' | 'failed' | 'fenced' | 'empty';
|
|
94
|
+
|
|
95
|
+
/** How far a bounded drain has got. See {@link advanced}. */
|
|
96
|
+
interface Position {
|
|
97
|
+
readonly entries: number;
|
|
98
|
+
readonly bytes: number;
|
|
99
|
+
readonly owedEntries: number;
|
|
100
|
+
readonly owedBytes: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DEFAULTS = {
|
|
104
|
+
batchBytes: 4096,
|
|
105
|
+
flushIntervalMs: 100,
|
|
106
|
+
maxPendingEntries: 1000,
|
|
107
|
+
maxPendingBytes: 512 * 1024,
|
|
108
|
+
watermarkBytes: 256 * 1024,
|
|
109
|
+
maxBatchBytes: 256 * 1024,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Upper bound on any deadline handed to the sink, in milliseconds. */
|
|
113
|
+
const MAX_DEADLINE_MS = 30_000;
|
|
114
|
+
|
|
115
|
+
/** Consecutive no-progress rounds before a bounded drain gives up. */
|
|
116
|
+
const MAX_STALLS = 8;
|
|
117
|
+
|
|
118
|
+
/** Pushes per timer tick, so catching up cannot monopolise the JS thread. */
|
|
119
|
+
const PUSHES_PER_TICK = 4;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Buffers formatted records and feeds them to a sink under backpressure.
|
|
123
|
+
*
|
|
124
|
+
* Three things happen here that do not happen anywhere else.
|
|
125
|
+
*
|
|
126
|
+
* **Backpressure.** The sink's queue is bounded, so once it passes the
|
|
127
|
+
* watermark the batcher stops pushing and polls `getStatus()` instead — which
|
|
128
|
+
* is why that call is specified never to wait behind writer I/O. Records
|
|
129
|
+
* accumulate in the JS buffer meanwhile, itself bounded by an entry count and
|
|
130
|
+
* a byte count.
|
|
131
|
+
*
|
|
132
|
+
* **Drop-newest.** When the JS buffer is full the arriving record is dropped,
|
|
133
|
+
* not the oldest one held. Under a sustained overload the two policies lose
|
|
134
|
+
* the same volume; they differ in what survives, and the older records are
|
|
135
|
+
* the ones with the context explaining how the overload started.
|
|
136
|
+
*
|
|
137
|
+
* **Loss is owed until it is written.** Every drop — here or in the sink —
|
|
138
|
+
* accrues to a counter that is only cleared once a notice describing it has
|
|
139
|
+
* been accepted, and is re-armed if the flush that should have made that
|
|
140
|
+
* notice durable says otherwise. A loss can therefore be reported twice; it
|
|
141
|
+
* cannot be reported zero times. Silence has to mean nothing was lost, or the
|
|
142
|
+
* counters are decoration.
|
|
143
|
+
*/
|
|
144
|
+
export class Batcher {
|
|
145
|
+
private readonly target: BatchTarget;
|
|
146
|
+
private readonly renderNotice: (lost: LossCounts) => string | undefined;
|
|
147
|
+
private readonly onFenced: ((reason: FenceReason) => void) | undefined;
|
|
148
|
+
private readonly batchBytes: number;
|
|
149
|
+
private readonly flushIntervalMs: number;
|
|
150
|
+
private readonly maxPendingEntries: number;
|
|
151
|
+
private readonly maxPendingBytes: number;
|
|
152
|
+
private readonly watermarkBytes: number;
|
|
153
|
+
private readonly maxBatchBytes: number;
|
|
154
|
+
|
|
155
|
+
private pending: Pending[] = [];
|
|
156
|
+
private pendingBytes = 0;
|
|
157
|
+
private paused = false;
|
|
158
|
+
private fenced = false;
|
|
159
|
+
private disposed = false;
|
|
160
|
+
/**
|
|
161
|
+
* A notice could not be delivered on its own — unrenderable, or refused by
|
|
162
|
+
* the sink — and nothing about ticking again would change that.
|
|
163
|
+
*
|
|
164
|
+
* The debt stays owed and still shows in `unreported()`; what stops is the
|
|
165
|
+
* retrying on a timer, which would go on for the life of the process
|
|
166
|
+
* achieving nothing. The next record to arrive reopens it — that retry
|
|
167
|
+
* rides a batch that carries the record, so it cannot spin — as does an
|
|
168
|
+
* explicit `flush()`.
|
|
169
|
+
*/
|
|
170
|
+
private noticeBlocked = false;
|
|
171
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
172
|
+
|
|
173
|
+
/** Highest sink totals seen, and every byte this side has dropped. */
|
|
174
|
+
private observed: Cursor = zeroCursor();
|
|
175
|
+
/** Covered by a notice the sink has accepted. */
|
|
176
|
+
private acked: Cursor = zeroCursor();
|
|
177
|
+
/** Covered by a notice a durable flush has confirmed. */
|
|
178
|
+
private confirmed: Cursor = zeroCursor();
|
|
179
|
+
|
|
180
|
+
private degraded = 0;
|
|
181
|
+
|
|
182
|
+
constructor(target: BatchTarget, options: BatcherOptions) {
|
|
183
|
+
this.target = target;
|
|
184
|
+
this.renderNotice = options.renderNotice;
|
|
185
|
+
this.onFenced = options.onFenced;
|
|
186
|
+
this.batchBytes = positive(options.batchBytes, DEFAULTS.batchBytes);
|
|
187
|
+
this.flushIntervalMs = positive(
|
|
188
|
+
options.flushIntervalMs,
|
|
189
|
+
DEFAULTS.flushIntervalMs
|
|
190
|
+
);
|
|
191
|
+
this.maxPendingEntries = positive(
|
|
192
|
+
options.maxPendingEntries,
|
|
193
|
+
DEFAULTS.maxPendingEntries
|
|
194
|
+
);
|
|
195
|
+
this.maxPendingBytes = positive(
|
|
196
|
+
options.maxPendingBytes,
|
|
197
|
+
DEFAULTS.maxPendingBytes
|
|
198
|
+
);
|
|
199
|
+
this.watermarkBytes = positive(
|
|
200
|
+
options.watermarkBytes,
|
|
201
|
+
DEFAULTS.watermarkBytes
|
|
202
|
+
);
|
|
203
|
+
this.maxBatchBytes = positive(
|
|
204
|
+
options.maxBatchBytes,
|
|
205
|
+
DEFAULTS.maxBatchBytes
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Producing ───────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Buffer one formatted record. Never performs I/O beyond handing a full
|
|
213
|
+
* batch to the sink, and never throws: a logging call must not fail the
|
|
214
|
+
* code that made it.
|
|
215
|
+
*/
|
|
216
|
+
add(record: string): void {
|
|
217
|
+
if (this.disposed || this.fenced) {
|
|
218
|
+
this.dropped(record);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const bytes = utf8Length(record) + 1; // the record's own newline
|
|
222
|
+
if (
|
|
223
|
+
this.pending.length >= this.maxPendingEntries ||
|
|
224
|
+
this.pendingBytes + bytes > this.maxPendingBytes
|
|
225
|
+
) {
|
|
226
|
+
this.dropped(record, bytes);
|
|
227
|
+
this.ensureTimer();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.pending.push({ text: record, bytes });
|
|
232
|
+
this.pendingBytes += bytes;
|
|
233
|
+
// A record to ride with is a fresh chance for a notice the sink refused
|
|
234
|
+
// earlier, and the reason it refused is usually temporary — a queue that
|
|
235
|
+
// was full a moment ago. Retrying on this event cannot spin the way a
|
|
236
|
+
// timer can: the batch carries a record, so it makes progress either way,
|
|
237
|
+
// and if the notice fails again the dead ends below block it again.
|
|
238
|
+
// Waiting instead for the next explicit flush means a process that dies
|
|
239
|
+
// first reports the loss zero times.
|
|
240
|
+
this.noticeBlocked = false;
|
|
241
|
+
|
|
242
|
+
if (!this.paused && this.pendingBytes >= this.batchBytes) {
|
|
243
|
+
this.pushOnce();
|
|
244
|
+
}
|
|
245
|
+
this.ensureTimer();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Record a loss that happened before the batcher saw it — a record that
|
|
250
|
+
* could not be rendered, or one too large to be rendered honestly. It is
|
|
251
|
+
* owed a notice on exactly the same terms as anything dropped here.
|
|
252
|
+
*/
|
|
253
|
+
noteLoss(entries: number, bytes: number): void {
|
|
254
|
+
this.observed.localEntries += toCount(entries);
|
|
255
|
+
this.observed.localBytes += toCount(bytes);
|
|
256
|
+
this.ensureTimer();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Losses with no notice in the file yet. */
|
|
260
|
+
unreported(): LossCounts {
|
|
261
|
+
return this.delta();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Bytes buffered on this side, not yet handed to the sink. */
|
|
265
|
+
bufferedBytes(): number {
|
|
266
|
+
return this.pendingBytes;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Payload-free bitmask of what has stopped working underneath — rotation,
|
|
271
|
+
* gzip, prune, sidecar, protection.
|
|
272
|
+
*
|
|
273
|
+
* This mirrors the sink's own mask, which accumulates bits and drops them
|
|
274
|
+
* only when a durable purge baselines everything. That is deliberate: these
|
|
275
|
+
* are things an app wants to know happened *at all*, and a mask that cleared
|
|
276
|
+
* itself the moment the next batch succeeded would report nothing the one
|
|
277
|
+
* time it mattered.
|
|
278
|
+
*/
|
|
279
|
+
degradation(): number {
|
|
280
|
+
return this.degraded;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
isFenced(): boolean {
|
|
284
|
+
return this.fenced;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Draining ────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Drain to the sink and fsync, bounded by a wall-clock deadline.
|
|
291
|
+
*
|
|
292
|
+
* Backpressure is overridden for the duration: the whole point of a flush
|
|
293
|
+
* is to push through a full queue rather than wait for it to clear, so a
|
|
294
|
+
* rejected batch is followed by a bounded sink flush and retried.
|
|
295
|
+
*
|
|
296
|
+
* The postcondition is honest rather than absolute. Under a disk that stays
|
|
297
|
+
* full, this returns `durable: false` with the losses preserved — the
|
|
298
|
+
* alternative, insisting everything is either durable or noticed, is a
|
|
299
|
+
* promise no implementation can keep.
|
|
300
|
+
*/
|
|
301
|
+
flush(deadlineMs: number): BatchFlushOutcome {
|
|
302
|
+
this.cancelTimer();
|
|
303
|
+
if (this.disposed || this.fenced) return this.outcome(false, false, 0);
|
|
304
|
+
|
|
305
|
+
const deadlineAt = Date.now() + clampDeadline(deadlineMs);
|
|
306
|
+
this.paused = false;
|
|
307
|
+
// An explicit flush is the retry: a notice the sink refused on its own
|
|
308
|
+
// gets one more chance here before being set aside again.
|
|
309
|
+
this.noticeBlocked = false;
|
|
310
|
+
|
|
311
|
+
let timedOut = false;
|
|
312
|
+
let stalls = 0;
|
|
313
|
+
let attempts = 0;
|
|
314
|
+
const maxAttempts = this.maxPendingEntries + MAX_STALLS * 2 + 16;
|
|
315
|
+
|
|
316
|
+
while (this.pending.length > 0 || this.owesNotice()) {
|
|
317
|
+
if (attempts >= maxAttempts || Date.now() >= deadlineAt) {
|
|
318
|
+
timedOut = true;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
attempts += 1;
|
|
322
|
+
|
|
323
|
+
const before = this.position();
|
|
324
|
+
const result = this.pushOnce();
|
|
325
|
+
if (result === 'empty' || result === 'fenced') break;
|
|
326
|
+
|
|
327
|
+
if (result === 'full') {
|
|
328
|
+
// Ask the sink to write what it has, then retry.
|
|
329
|
+
const remaining = deadlineAt - Date.now();
|
|
330
|
+
if (remaining <= 0) {
|
|
331
|
+
timedOut = true;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
const mid = this.targetFlush(remaining);
|
|
335
|
+
if (mid.timedOut) timedOut = true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Progress is MEASURED, never inferred from the result: a notice-only
|
|
339
|
+
// batch that fails removes no records and settles no debt, and a sink
|
|
340
|
+
// that finds a fresh loss on every append can hold the debt constant
|
|
341
|
+
// indefinitely — both look like progress to a check that only asks
|
|
342
|
+
// whether the push said 'failed'.
|
|
343
|
+
if (advanced(before, this.position())) {
|
|
344
|
+
stalls = 0;
|
|
345
|
+
} else {
|
|
346
|
+
stalls += 1;
|
|
347
|
+
if (stalls >= MAX_STALLS) break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const final = this.targetFlush(Math.max(0, deadlineAt - Date.now()));
|
|
352
|
+
if (final.timedOut) timedOut = true;
|
|
353
|
+
|
|
354
|
+
const settled =
|
|
355
|
+
final.durable && this.pending.length === 0 && !this.owesNotice();
|
|
356
|
+
if (settled) {
|
|
357
|
+
// Every notice accepted so far is now on disk.
|
|
358
|
+
this.confirmed = { ...this.acked };
|
|
359
|
+
} else {
|
|
360
|
+
// An accepted notice that no flush has confirmed is not a notice yet.
|
|
361
|
+
// Re-arming risks writing it twice, which beats losing it.
|
|
362
|
+
this.acked = { ...this.confirmed };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// The final sync is itself a place losses are discovered — a batch the
|
|
366
|
+
// writer could not land shows up in the counters it returns. Re-arm, or a
|
|
367
|
+
// loss learned about here waits for the next log call to be reported, and
|
|
368
|
+
// an app that has stopped logging never makes one.
|
|
369
|
+
this.ensureTimer();
|
|
370
|
+
|
|
371
|
+
return this.outcome(settled, timedOut, final.pendingBytes);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Release timers and let the buffer go. Idempotent.
|
|
376
|
+
*
|
|
377
|
+
* Deliberately does not flush: `LogDestination` calls `flush()` before
|
|
378
|
+
* `dispose()`, and a dispose that quietly blocked on a stalled disk would
|
|
379
|
+
* turn tearing down a destination into a hang.
|
|
380
|
+
*/
|
|
381
|
+
dispose(): void {
|
|
382
|
+
this.cancelTimer();
|
|
383
|
+
this.disposed = true;
|
|
384
|
+
this.pending = [];
|
|
385
|
+
this.pendingBytes = 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Forget everything buffered and start the loss counters over, returning
|
|
390
|
+
* what was discarded.
|
|
391
|
+
*
|
|
392
|
+
* This is the purge path. The counts go back to the caller instead of into
|
|
393
|
+
* a notice, because a "4,182 entries dropped" line written into a file that
|
|
394
|
+
* was just cleared for compliance reasons describes the clearing, and the
|
|
395
|
+
* caller already knows it cleared the file.
|
|
396
|
+
*/
|
|
397
|
+
discard(): LossCounts {
|
|
398
|
+
const discardedEntries = this.pending.length;
|
|
399
|
+
const discardedBytes = this.pendingBytes;
|
|
400
|
+
this.pending = [];
|
|
401
|
+
this.pendingBytes = 0;
|
|
402
|
+
this.syncCounters();
|
|
403
|
+
const owed = this.delta();
|
|
404
|
+
this.baseline();
|
|
405
|
+
this.paused = false;
|
|
406
|
+
return {
|
|
407
|
+
entries: discardedEntries + owed.entries,
|
|
408
|
+
bytes: discardedBytes + owed.bytes,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Resume against a fresh generation after the owner has rebound.
|
|
414
|
+
*
|
|
415
|
+
* The sink cursors are re-read from scratch rather than carried over,
|
|
416
|
+
* because the old totals are not a baseline for the new handle's. Whether
|
|
417
|
+
* the sink resets its per-handle counters on rebind or keeps them running
|
|
418
|
+
* is not something this side can know — and getting it wrong is silent in
|
|
419
|
+
* the worse direction: keeping a stale maximum would swallow every new loss
|
|
420
|
+
* smaller than the pre-purge total, reporting it zero times.
|
|
421
|
+
*
|
|
422
|
+
* Local counters are deliberately left alone. Records this side dropped
|
|
423
|
+
* after the fence went up are real drops of real entries, and they belong
|
|
424
|
+
* to the new file as much as to the old one.
|
|
425
|
+
*/
|
|
426
|
+
rebind(): void {
|
|
427
|
+
this.fenced = false;
|
|
428
|
+
this.paused = false;
|
|
429
|
+
this.noticeBlocked = false;
|
|
430
|
+
this.observed.sinkEntries = 0;
|
|
431
|
+
this.observed.sinkBytes = 0;
|
|
432
|
+
this.syncCounters();
|
|
433
|
+
this.acked.sinkEntries = this.observed.sinkEntries;
|
|
434
|
+
this.acked.sinkBytes = this.observed.sinkBytes;
|
|
435
|
+
this.confirmed.sinkEntries = this.observed.sinkEntries;
|
|
436
|
+
this.confirmed.sinkBytes = this.observed.sinkBytes;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Internals ───────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Hand at most one batch to the sink.
|
|
443
|
+
*
|
|
444
|
+
* The loss snapshot is taken BEFORE the call and acknowledged to that
|
|
445
|
+
* snapshot rather than to the totals the call returns. A drop that happens
|
|
446
|
+
* during this very append is not described by the notice riding in it, and
|
|
447
|
+
* acknowledging the post-call totals would bury it.
|
|
448
|
+
*/
|
|
449
|
+
private pushOnce(): PushResult {
|
|
450
|
+
const lines: string[] = [];
|
|
451
|
+
let entryCount = 0;
|
|
452
|
+
let entryBytes = 0;
|
|
453
|
+
for (const item of this.pending) {
|
|
454
|
+
if (entryCount > 0 && entryBytes + item.bytes > this.maxBatchBytes) break;
|
|
455
|
+
lines.push(item.text);
|
|
456
|
+
entryBytes += item.bytes;
|
|
457
|
+
entryCount += 1;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const snapshot: Cursor = { ...this.observed };
|
|
461
|
+
const owed = this.delta();
|
|
462
|
+
let carriesNotice = false;
|
|
463
|
+
if (!this.noticeBlocked && (owed.entries > 0 || owed.bytes > 0)) {
|
|
464
|
+
const notice = this.noticeText(owed);
|
|
465
|
+
if (notice !== undefined) {
|
|
466
|
+
lines.push(notice);
|
|
467
|
+
carriesNotice = true;
|
|
468
|
+
} else if (entryCount === 0) {
|
|
469
|
+
// A notice that will not render, and nothing else to send. Trying
|
|
470
|
+
// again in a hundred milliseconds will produce the same nothing, so
|
|
471
|
+
// the debt is set aside rather than retried on a timer for the life
|
|
472
|
+
// of the process. It is still owed, and an explicit flush reopens it.
|
|
473
|
+
this.noticeBlocked = true;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (lines.length === 0) return 'empty';
|
|
477
|
+
|
|
478
|
+
// Whether this batch is carrying the notice and nothing else, which is
|
|
479
|
+
// what makes a rejection unrecoverable by retrying: there is no record to
|
|
480
|
+
// give up to make it smaller, and no smaller notice to send.
|
|
481
|
+
const noticeOnly = entryCount === 0;
|
|
482
|
+
|
|
483
|
+
// The notice goes after the records it does not describe, so a reader
|
|
484
|
+
// meets the surviving entries first and the gap where the others were.
|
|
485
|
+
const batch = `${lines.join('\n')}\n`;
|
|
486
|
+
|
|
487
|
+
let result: AppendResult;
|
|
488
|
+
try {
|
|
489
|
+
result = this.target.appendBatch(batch, lines.length);
|
|
490
|
+
} catch {
|
|
491
|
+
// A throwing sink is a failing sink. The batch is gone either way.
|
|
492
|
+
this.loseHead(entryCount, entryBytes);
|
|
493
|
+
if (noticeOnly) this.noticeBlocked = true;
|
|
494
|
+
return 'failed';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
this.observeCounts(result.lostEntries, result.lostBytes, result.degraded);
|
|
498
|
+
|
|
499
|
+
if (result.accepted) {
|
|
500
|
+
this.loseHead(entryCount, 0, false);
|
|
501
|
+
if (carriesNotice) this.acked = snapshot;
|
|
502
|
+
if (toCount(result.queuedBytes) >= this.watermarkBytes)
|
|
503
|
+
this.paused = true;
|
|
504
|
+
return 'sent';
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const reason: RejectReason = result.rejectReason ?? 'failed';
|
|
508
|
+
if (reason === 'staleGeneration' || reason === 'closed') {
|
|
509
|
+
this.fence(reason);
|
|
510
|
+
return 'fenced';
|
|
511
|
+
}
|
|
512
|
+
if (reason === 'full') {
|
|
513
|
+
// An empty sink that still refuses means the batch is larger than the
|
|
514
|
+
// sink can ever hold, not that it is busy. Waiting for room that will
|
|
515
|
+
// never come wedges every later record behind this one.
|
|
516
|
+
if (toCount(result.queuedBytes) === 0) {
|
|
517
|
+
if (carriesNotice) {
|
|
518
|
+
// The notice may be the whole reason this did not fit, so it goes
|
|
519
|
+
// first — before any record is given up for it. Setting it aside
|
|
520
|
+
// beats pausing on a sink that has already given its final answer:
|
|
521
|
+
// the debt is kept and offered again on the next explicit flush,
|
|
522
|
+
// and meanwhile records are not held behind a line about old ones.
|
|
523
|
+
this.noticeBlocked = true;
|
|
524
|
+
return 'failed';
|
|
525
|
+
}
|
|
526
|
+
const head = this.pending[0];
|
|
527
|
+
if (head !== undefined) {
|
|
528
|
+
// Records alone, and still too big: give up the oldest and try a
|
|
529
|
+
// smaller batch.
|
|
530
|
+
this.loseHead(1, head.bytes);
|
|
531
|
+
}
|
|
532
|
+
return 'failed';
|
|
533
|
+
}
|
|
534
|
+
this.paused = true;
|
|
535
|
+
return 'full';
|
|
536
|
+
}
|
|
537
|
+
this.loseHead(entryCount, entryBytes);
|
|
538
|
+
// A notice-only batch the sink rejected outright leaves the state exactly
|
|
539
|
+
// as it was: nothing removed, nothing settled. Retrying it on the timer
|
|
540
|
+
// is a loop with no exit.
|
|
541
|
+
if (noticeOnly) this.noticeBlocked = true;
|
|
542
|
+
return 'failed';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Remove the batch's records from the head of the buffer, optionally
|
|
547
|
+
* counting them as lost.
|
|
548
|
+
*/
|
|
549
|
+
private loseHead(count: number, bytes: number, lost = true): void {
|
|
550
|
+
if (count === 0) return;
|
|
551
|
+
const removed = this.pending.splice(0, count);
|
|
552
|
+
for (const item of removed) this.pendingBytes -= item.bytes;
|
|
553
|
+
if (this.pendingBytes < 0) this.pendingBytes = 0;
|
|
554
|
+
if (lost) {
|
|
555
|
+
this.observed.localEntries += count;
|
|
556
|
+
this.observed.localBytes += bytes;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private dropped(record: string, bytes?: number): void {
|
|
561
|
+
this.observed.localEntries += 1;
|
|
562
|
+
this.observed.localBytes += bytes ?? utf8Length(record) + 1;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private fence(reason: FenceReason): void {
|
|
566
|
+
// Pre-purge records must never be replayed into a post-purge file, and a
|
|
567
|
+
// notice about them is a statement about data that was deliberately
|
|
568
|
+
// deleted. Both go.
|
|
569
|
+
this.pending = [];
|
|
570
|
+
this.pendingBytes = 0;
|
|
571
|
+
this.syncCounters();
|
|
572
|
+
this.baseline();
|
|
573
|
+
this.fenced = true;
|
|
574
|
+
this.paused = false;
|
|
575
|
+
this.cancelTimer();
|
|
576
|
+
try {
|
|
577
|
+
this.onFenced?.(reason);
|
|
578
|
+
} catch {
|
|
579
|
+
// the owner's problem, not the pipeline's
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Bring the sink's counters up to date without writing anything.
|
|
585
|
+
*
|
|
586
|
+
* Cursors normally advance off append results, which means a loss the sink
|
|
587
|
+
* has recorded but not yet had occasion to report is invisible here. Before
|
|
588
|
+
* drawing a line under the past — a purge, a fence — that window has to be
|
|
589
|
+
* closed, or a loss from the old file crosses over and gets described as if
|
|
590
|
+
* it had happened to the new one.
|
|
591
|
+
*/
|
|
592
|
+
private syncCounters(): void {
|
|
593
|
+
try {
|
|
594
|
+
const status = this.target.getStatus();
|
|
595
|
+
this.observeCounts(status.lostEntries, status.lostBytes, status.degraded);
|
|
596
|
+
} catch {
|
|
597
|
+
// An unreadable probe just means the line is drawn where we stand.
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Declare every loss seen so far as settled, reporting none of it. */
|
|
602
|
+
private baseline(): void {
|
|
603
|
+
this.acked = { ...this.observed };
|
|
604
|
+
this.confirmed = { ...this.observed };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private noticeText(lost: LossCounts): string | undefined {
|
|
608
|
+
let text: string | undefined;
|
|
609
|
+
try {
|
|
610
|
+
text = this.renderNotice(lost);
|
|
611
|
+
} catch {
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
return typeof text === 'string' && text.length > 0 ? text : undefined;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private delta(): LossCounts {
|
|
618
|
+
return {
|
|
619
|
+
entries: Math.max(
|
|
620
|
+
0,
|
|
621
|
+
this.observed.sinkEntries -
|
|
622
|
+
this.acked.sinkEntries +
|
|
623
|
+
(this.observed.localEntries - this.acked.localEntries)
|
|
624
|
+
),
|
|
625
|
+
bytes: Math.max(
|
|
626
|
+
0,
|
|
627
|
+
this.observed.sinkBytes -
|
|
628
|
+
this.acked.sinkBytes +
|
|
629
|
+
(this.observed.localBytes - this.acked.localBytes)
|
|
630
|
+
),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private owesNotice(): boolean {
|
|
635
|
+
const owed = this.delta();
|
|
636
|
+
return owed.entries > 0 || owed.bytes > 0;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Where the drain has got to: records still buffered, loss still owed. */
|
|
640
|
+
private position(): Position {
|
|
641
|
+
const owed = this.delta();
|
|
642
|
+
return {
|
|
643
|
+
entries: this.pending.length,
|
|
644
|
+
bytes: this.pendingBytes,
|
|
645
|
+
owedEntries: owed.entries,
|
|
646
|
+
owedBytes: owed.bytes,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Fold sink counters in. They are monotonic per handle, so the maximum
|
|
652
|
+
* seen is the truth; a sink that reported a smaller number than last time
|
|
653
|
+
* would otherwise drive the delta negative and cancel a real loss.
|
|
654
|
+
*/
|
|
655
|
+
private observeCounts(
|
|
656
|
+
lostEntries: number,
|
|
657
|
+
lostBytes: number,
|
|
658
|
+
degraded: number
|
|
659
|
+
): void {
|
|
660
|
+
this.observed.sinkEntries = Math.max(
|
|
661
|
+
this.observed.sinkEntries,
|
|
662
|
+
toCount(lostEntries)
|
|
663
|
+
);
|
|
664
|
+
this.observed.sinkBytes = Math.max(
|
|
665
|
+
this.observed.sinkBytes,
|
|
666
|
+
toCount(lostBytes)
|
|
667
|
+
);
|
|
668
|
+
this.degraded = toCount(degraded);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private targetFlush(deadlineMs: number): FlushOutcome {
|
|
672
|
+
let outcome: FlushOutcome;
|
|
673
|
+
try {
|
|
674
|
+
outcome = this.target.flush(clampDeadline(deadlineMs));
|
|
675
|
+
} catch {
|
|
676
|
+
return {
|
|
677
|
+
durable: false,
|
|
678
|
+
timedOut: true,
|
|
679
|
+
pendingBytes: 0,
|
|
680
|
+
queuedBytes: 0,
|
|
681
|
+
lostBytes: this.observed.sinkBytes,
|
|
682
|
+
lostEntries: this.observed.sinkEntries,
|
|
683
|
+
degraded: this.degraded,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
this.observeCounts(
|
|
687
|
+
outcome.lostEntries,
|
|
688
|
+
outcome.lostBytes,
|
|
689
|
+
outcome.degraded
|
|
690
|
+
);
|
|
691
|
+
return outcome;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private outcome(
|
|
695
|
+
durable: boolean,
|
|
696
|
+
timedOut: boolean,
|
|
697
|
+
sinkPending: number
|
|
698
|
+
): BatchFlushOutcome {
|
|
699
|
+
const owed = this.delta();
|
|
700
|
+
return {
|
|
701
|
+
durable,
|
|
702
|
+
timedOut,
|
|
703
|
+
// Both sides of the handoff: what never left the JS buffer, plus what
|
|
704
|
+
// the sink took but has not made durable.
|
|
705
|
+
pendingBytes: this.pendingBytes + toCount(sinkPending),
|
|
706
|
+
unreportedEntries: owed.entries,
|
|
707
|
+
unreportedBytes: owed.bytes,
|
|
708
|
+
degraded: this.degraded,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Timer ───────────────────────────────────────────────────────────────
|
|
713
|
+
|
|
714
|
+
private ensureTimer(): void {
|
|
715
|
+
if (this.timer !== undefined || this.disposed || this.fenced) return;
|
|
716
|
+
// A blocked notice is not a reason to keep ticking: the sink has already
|
|
717
|
+
// refused it, and the next explicit flush is what retries.
|
|
718
|
+
const owed = this.owesNotice() && !this.noticeBlocked;
|
|
719
|
+
if (this.pending.length === 0 && !this.paused && !owed) return;
|
|
720
|
+
this.timer = setTimeout(() => {
|
|
721
|
+
this.timer = undefined;
|
|
722
|
+
this.onTick();
|
|
723
|
+
}, this.flushIntervalMs);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private cancelTimer(): void {
|
|
727
|
+
if (this.timer !== undefined) {
|
|
728
|
+
clearTimeout(this.timer);
|
|
729
|
+
this.timer = undefined;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private onTick(): void {
|
|
734
|
+
if (this.disposed || this.fenced) return;
|
|
735
|
+
|
|
736
|
+
if (this.paused) {
|
|
737
|
+
// The non-enqueuing probe: this is the call that is specified to be
|
|
738
|
+
// answerable while the writer thread is stalled on disk, and the whole
|
|
739
|
+
// pause/resume cycle rests on that.
|
|
740
|
+
try {
|
|
741
|
+
const status = this.target.getStatus();
|
|
742
|
+
this.observeCounts(
|
|
743
|
+
status.lostEntries,
|
|
744
|
+
status.lostBytes,
|
|
745
|
+
status.degraded
|
|
746
|
+
);
|
|
747
|
+
if (toCount(status.queuedBytes) < this.watermarkBytes) {
|
|
748
|
+
this.paused = false;
|
|
749
|
+
}
|
|
750
|
+
} catch {
|
|
751
|
+
// stay paused; try again next tick
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (!this.paused) {
|
|
756
|
+
for (let i = 0; i < PUSHES_PER_TICK; i += 1) {
|
|
757
|
+
const result = this.pushOnce();
|
|
758
|
+
if (result !== 'sent') break;
|
|
759
|
+
if (this.paused) break;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
this.ensureTimer();
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function zeroCursor(): Cursor {
|
|
768
|
+
return { sinkEntries: 0, sinkBytes: 0, localEntries: 0, localBytes: 0 };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Did the drain get anywhere between these two positions?
|
|
773
|
+
*
|
|
774
|
+
* The two halves are compared separately rather than summed, because a batch
|
|
775
|
+
* the sink refused moves records out of the buffer and into the debt they now
|
|
776
|
+
* owe a notice — a conserved total, and real progress. Summing them says the
|
|
777
|
+
* drain is stuck at exactly the moment it is working through a failing sink,
|
|
778
|
+
* and abandons the records queued behind the ones it just gave up on.
|
|
779
|
+
*/
|
|
780
|
+
function advanced(before: Position, after: Position): boolean {
|
|
781
|
+
if (after.entries < before.entries || after.bytes < before.bytes) return true;
|
|
782
|
+
return (
|
|
783
|
+
after.owedEntries < before.owedEntries || after.owedBytes < before.owedBytes
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** A count from across the bridge; anything not a positive number is zero. */
|
|
788
|
+
function toCount(value: number): number {
|
|
789
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
790
|
+
? Math.floor(value)
|
|
791
|
+
: 0;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function positive(value: number | undefined, fallback: number): number {
|
|
795
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
796
|
+
? Math.floor(value)
|
|
797
|
+
: fallback;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function clampDeadline(value: number): number {
|
|
801
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
802
|
+
return 0;
|
|
803
|
+
}
|
|
804
|
+
return Math.min(Math.floor(value), MAX_DEADLINE_MS);
|
|
805
|
+
}
|