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,1328 @@
|
|
|
1
|
+
package com.margelo.nitro.nitrologger
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.FileOutputStream
|
|
5
|
+
import java.io.RandomAccessFile
|
|
6
|
+
import java.util.Locale
|
|
7
|
+
import java.util.concurrent.CountDownLatch
|
|
8
|
+
import java.util.concurrent.Executors
|
|
9
|
+
import java.util.concurrent.RejectedExecutionException
|
|
10
|
+
import java.util.concurrent.ThreadFactory
|
|
11
|
+
import java.util.concurrent.TimeUnit
|
|
12
|
+
import java.util.concurrent.locks.ReentrantLock
|
|
13
|
+
import java.util.zip.GZIPOutputStream
|
|
14
|
+
import kotlin.concurrent.withLock
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The thing that actually writes, one per canonical path.
|
|
18
|
+
*
|
|
19
|
+
* Handles for the same file share it — see [LogWriterRegistry]. Batching, drop
|
|
20
|
+
* policy and loss notices all live in JavaScript; this side appends pre-batched
|
|
21
|
+
* text, rotates, compresses, prunes and keeps counters. It is deliberately the
|
|
22
|
+
* dumb end.
|
|
23
|
+
*
|
|
24
|
+
* **Two synchronisation domains, and the split is the whole design.**
|
|
25
|
+
* [stateLock] guards counters and the stream swap and is never held across I/O;
|
|
26
|
+
* the single-threaded [executor] owns every byte that touches the disk. That is
|
|
27
|
+
* what makes [status] answerable while the writer thread is stalled on storage
|
|
28
|
+
* that has stopped responding — which the JavaScript side depends on, because
|
|
29
|
+
* its backpressure loop polls exactly then.
|
|
30
|
+
*
|
|
31
|
+
* **Accepted bytes stay reserved until the write terminally completes.** The cap
|
|
32
|
+
* is on bytes in flight, not bytes enqueued, so a burst cannot queue a gigabyte
|
|
33
|
+
* behind a slow disk by virtue of each individual batch fitting.
|
|
34
|
+
*
|
|
35
|
+
* This is a port of the iOS `LogFileWriter`, and the parity is the point: a
|
|
36
|
+
* rotation or purge that behaves differently on the two platforms is a bug
|
|
37
|
+
* report nobody can reproduce. Where the platforms genuinely differ — no
|
|
38
|
+
* `O_NOFOLLOW`, no protection class, `Os.fstat` behind [PlatformIo] — the
|
|
39
|
+
* comment says so rather than papering over it.
|
|
40
|
+
*/
|
|
41
|
+
class LogFileWriter internal constructor(
|
|
42
|
+
val file: File,
|
|
43
|
+
val canonicalPath: String,
|
|
44
|
+
val policy: LogRotationPolicy,
|
|
45
|
+
val lineFramed: Boolean,
|
|
46
|
+
private val platform: PlatformIo,
|
|
47
|
+
private val rawWrite: RawWrite,
|
|
48
|
+
private val compressor: Compressor,
|
|
49
|
+
/**
|
|
50
|
+
* Epoch millis. Answers "how old is this file" and stamps archive names, both
|
|
51
|
+
* of which have to mean the same thing across a restart — so this one is
|
|
52
|
+
* allowed to jump when the device clock is corrected.
|
|
53
|
+
*/
|
|
54
|
+
private val clock: () -> Long,
|
|
55
|
+
/**
|
|
56
|
+
* Monotonic millis. Answers "how much of the budget is left" and "has the
|
|
57
|
+
* backoff elapsed", neither of which may ever be affected by an NTP
|
|
58
|
+
* correction or a user changing the date.
|
|
59
|
+
*
|
|
60
|
+
* The two are separate because a wall clock that jumps forward mid-wait
|
|
61
|
+
* shortens a deadline harmlessly, but one that jumps *backward* extends it —
|
|
62
|
+
* and a close that was promised 200 ms and takes an hour is an ANR. iOS keeps
|
|
63
|
+
* the same split, using `DispatchTime` for deadlines and `Date` for ages.
|
|
64
|
+
*/
|
|
65
|
+
private val monotonic: () -> Long
|
|
66
|
+
) {
|
|
67
|
+
/**
|
|
68
|
+
* The raw write, injectable so short writes and hard failures can be tested
|
|
69
|
+
* without storage that misbehaves on demand.
|
|
70
|
+
*
|
|
71
|
+
* Returns the number of bytes written, which is allowed to be fewer than
|
|
72
|
+
* asked for. `FileOutputStream.write` loops internally, but it can still
|
|
73
|
+
* throw after having written some of the buffer — so the caller has to treat
|
|
74
|
+
* a failure as "an unknown prefix landed" either way.
|
|
75
|
+
*/
|
|
76
|
+
fun interface RawWrite {
|
|
77
|
+
fun write(stream: FileOutputStream, data: ByteArray, offset: Int, length: Int): Int
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Archive compression, injectable because the interesting case is failure. */
|
|
81
|
+
fun interface Compressor {
|
|
82
|
+
fun compress(source: File, destination: File): Boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
companion object {
|
|
86
|
+
/**
|
|
87
|
+
* Hard cap on payload bytes in flight. Documented as a payload bound: the
|
|
88
|
+
* process still holds the `ByteArray` copies and queue entries on top.
|
|
89
|
+
*/
|
|
90
|
+
const val HARD_CAP_BYTES = 1_048_576L
|
|
91
|
+
|
|
92
|
+
/** Reads used to find the last record boundary during a startup trim. */
|
|
93
|
+
private const val TAIL_SCAN_WINDOW = 1 shl 20
|
|
94
|
+
|
|
95
|
+
/** Successful writes between descriptor-liveness checks. */
|
|
96
|
+
private const val HEALTH_CHECK_STRIDE = 8
|
|
97
|
+
|
|
98
|
+
/** How long a failed rotation waits before being attempted again. */
|
|
99
|
+
private const val ROTATION_BACKOFF_MS = 5_000L
|
|
100
|
+
|
|
101
|
+
/** How long a failed reopen waits. */
|
|
102
|
+
private const val REOPEN_BACKOFF_MS = 1_000L
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Longest any deadline-bounded call will wait. Well short of the ANR
|
|
106
|
+
* window a synchronous crash-path flush has to live inside.
|
|
107
|
+
*/
|
|
108
|
+
const val MAX_DEADLINE_MS = 30_000L
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How long [logFilePaths] will wait for the executor before answering with
|
|
112
|
+
* the active path alone. Short: this call takes no deadline of its own, and
|
|
113
|
+
* a support-log collection is not worth blocking the JS thread over.
|
|
114
|
+
*/
|
|
115
|
+
const val PATHS_DEADLINE_MS = 2_000L
|
|
116
|
+
|
|
117
|
+
private const val NEWLINE = '\n'.code.toByte()
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `Infinity` means "wait as long as you are allowed to", which is the
|
|
121
|
+
* ceiling — not zero. `NaN` and non-positive values mean no waiting, which
|
|
122
|
+
* is also what a caller passing 0 deliberately asks for.
|
|
123
|
+
*/
|
|
124
|
+
fun clampDeadline(value: Double): Long = when {
|
|
125
|
+
value.isNaN() || value <= 0 -> 0L
|
|
126
|
+
value.isInfinite() -> MAX_DEADLINE_MS
|
|
127
|
+
else -> minOf(value, MAX_DEADLINE_MS.toDouble()).toLong()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private val defaultRawWrite = RawWrite { stream, data, offset, length ->
|
|
131
|
+
stream.write(data, offset, length)
|
|
132
|
+
length
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private val defaultCompressor = Compressor { source, destination ->
|
|
136
|
+
try {
|
|
137
|
+
source.inputStream().use { input ->
|
|
138
|
+
GZIPOutputStream(destination.outputStream().buffered()).use { output ->
|
|
139
|
+
input.copyTo(output, 64 * 1024)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
true
|
|
143
|
+
} catch (_: Exception) {
|
|
144
|
+
false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `<base>.YYYYMMDDTHHMMSSZ_<8 hex>`.
|
|
150
|
+
*
|
|
151
|
+
* Spelled out rather than left to a locale-sensitive formatter so the
|
|
152
|
+
* produced name and [isArchiveName] cannot drift apart — a purge that stops
|
|
153
|
+
* recognising its own archives is a silent compliance failure. The random
|
|
154
|
+
* suffix disambiguates rotations inside the same second, which the
|
|
155
|
+
* one-second stamp cannot.
|
|
156
|
+
*/
|
|
157
|
+
private val ARCHIVE_SUFFIX = Regex("""^\d{8}T\d{6}Z_[a-f0-9]{8}(\.gz)?$""")
|
|
158
|
+
private val STAGING_SUFFIX = Regex("""^\d{8}T\d{6}Z_[a-f0-9]{8}\.gz\.part$""")
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A finished archive: `<base>.<stamp>` or `<base>.<stamp>.gz`.
|
|
162
|
+
*
|
|
163
|
+
* Deliberately excludes `.part`. A staging file is a compression that was
|
|
164
|
+
* interrupted — counting it as an archive would let it occupy a retention
|
|
165
|
+
* slot a real archive should have, and would hand a truncated gzip to
|
|
166
|
+
* anyone calling `getLogFilePaths()` to collect logs for support.
|
|
167
|
+
*/
|
|
168
|
+
fun isArchiveName(name: String, baseName: String): Boolean =
|
|
169
|
+
matches(name, baseName, ARCHIVE_SUFFIX)
|
|
170
|
+
|
|
171
|
+
/** A gzip staging file, `<base>.<stamp>.gz.part`. */
|
|
172
|
+
fun isStagingName(name: String, baseName: String): Boolean =
|
|
173
|
+
matches(name, baseName, STAGING_SUFFIX)
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Everything this writer can ever put on disk under its directory: the
|
|
177
|
+
* active file, the sidecar, every archive, every gzip staging file.
|
|
178
|
+
*
|
|
179
|
+
* This predicate IS the naming scheme, and `clearLogs` is the reason it is
|
|
180
|
+
* written down in one place. A purge that recognises fewer names than
|
|
181
|
+
* rotation can produce leaves survivors — and an interrupted compression
|
|
182
|
+
* leaving a plaintext orphan that outlives a compliance purge is exactly
|
|
183
|
+
* the failure this is here to prevent.
|
|
184
|
+
*/
|
|
185
|
+
fun isArtifactName(name: String, baseName: String): Boolean {
|
|
186
|
+
if (name == baseName) return true
|
|
187
|
+
if (name == "$baseName.meta") return true
|
|
188
|
+
return isArchiveName(name, baseName) || isStagingName(name, baseName)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private fun matches(name: String, baseName: String, pattern: Regex): Boolean {
|
|
192
|
+
val prefix = "$baseName."
|
|
193
|
+
if (!name.startsWith(prefix)) return false
|
|
194
|
+
return pattern.matches(name.substring(prefix.length))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@Throws(LogWriterException::class)
|
|
198
|
+
fun open(
|
|
199
|
+
file: File,
|
|
200
|
+
canonicalPath: String,
|
|
201
|
+
policy: LogRotationPolicy,
|
|
202
|
+
lineFramed: Boolean,
|
|
203
|
+
/**
|
|
204
|
+
* Required, with no default on purpose. [PlatformIo.Jvm] is the only
|
|
205
|
+
* thing in this package that touches `java.nio.file`, so a caller who
|
|
206
|
+
* silently fell back to it on Android would reintroduce the API-26
|
|
207
|
+
* problem the seam exists to keep out. Tests pass it explicitly too.
|
|
208
|
+
*/
|
|
209
|
+
platform: PlatformIo,
|
|
210
|
+
rawWrite: RawWrite? = null,
|
|
211
|
+
compressor: Compressor? = null,
|
|
212
|
+
clock: (() -> Long)? = null,
|
|
213
|
+
monotonic: (() -> Long)? = null
|
|
214
|
+
): LogFileWriter = LogFileWriter(
|
|
215
|
+
file = file,
|
|
216
|
+
canonicalPath = canonicalPath,
|
|
217
|
+
policy = policy,
|
|
218
|
+
lineFramed = lineFramed,
|
|
219
|
+
platform = platform,
|
|
220
|
+
rawWrite = rawWrite ?: defaultRawWrite,
|
|
221
|
+
compressor = compressor ?: defaultCompressor,
|
|
222
|
+
clock = clock ?: System::currentTimeMillis,
|
|
223
|
+
monotonic = monotonic ?: { System.nanoTime() / 1_000_000L }
|
|
224
|
+
).also { it.start() }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Set by the thread factory so the inline-flush guard can recognise itself. */
|
|
228
|
+
@Volatile
|
|
229
|
+
private var writerThread: Thread? = null
|
|
230
|
+
|
|
231
|
+
private val executor = Executors.newSingleThreadExecutor(ThreadFactory { runnable ->
|
|
232
|
+
Thread(runnable, "com.nitrologger.filewriter").apply {
|
|
233
|
+
isDaemon = true
|
|
234
|
+
writerThread = this
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// State behind stateLock — cheap, never held across I/O.
|
|
239
|
+
private val stateLock = ReentrantLock()
|
|
240
|
+
/** Held for the whole of [clearLogs], so purges cannot interleave. */
|
|
241
|
+
private val purgeLock = ReentrantLock()
|
|
242
|
+
private var reservedBytes = 0L
|
|
243
|
+
private var generation = 1L
|
|
244
|
+
private var closed = false
|
|
245
|
+
private var degraded = LogDegradation.NONE
|
|
246
|
+
private val loss = HashMap<Long, LongArray>()
|
|
247
|
+
private var lastSyncSucceeded = true
|
|
248
|
+
/** Handles currently holding this writer. The registry evicts at zero. */
|
|
249
|
+
var refCount = 0
|
|
250
|
+
private set
|
|
251
|
+
|
|
252
|
+
// State confined to the executor thread.
|
|
253
|
+
private var stream: FileOutputStream? = null
|
|
254
|
+
private var currentFileSize = 0L
|
|
255
|
+
private var currentFileStart = 0L
|
|
256
|
+
private var lastReopenAttempt = Long.MIN_VALUE / 4
|
|
257
|
+
private var rotationBlockedUntil = Long.MIN_VALUE / 4
|
|
258
|
+
/**
|
|
259
|
+
* A rotation's fresh start time that is not safely on disk yet.
|
|
260
|
+
*
|
|
261
|
+
* Set when a rotation archives the file, cleared only once the sidecar has
|
|
262
|
+
* actually been written. While it is set it outranks the sidecar entirely,
|
|
263
|
+
* because the sidecar still holds the *archived* file's age — and every
|
|
264
|
+
* reopen has to survive that, not just the one rotation performs. A failed
|
|
265
|
+
* sidecar write followed by a failed reopen would otherwise send the next
|
|
266
|
+
* backoff retry back to the stale value and restart the rotate-on-every-write
|
|
267
|
+
* runaway.
|
|
268
|
+
*/
|
|
269
|
+
private var pendingFileStart: Long? = null
|
|
270
|
+
private var writesSinceHealthCheck = 0
|
|
271
|
+
/**
|
|
272
|
+
* Set by the close barrier, on the executor. Everything enqueued before the
|
|
273
|
+
* barrier still writes; everything after it is refused.
|
|
274
|
+
*/
|
|
275
|
+
private var terminated = false
|
|
276
|
+
/** Rotations that got past the backoff guard. Test support. */
|
|
277
|
+
private var rotationAttempts = 0
|
|
278
|
+
|
|
279
|
+
private val directory: File get() = file.parentFile ?: File(".")
|
|
280
|
+
private val baseName: String get() = file.name
|
|
281
|
+
|
|
282
|
+
private fun start() {
|
|
283
|
+
val shortfall = !LogSecureFile.createDirectory(directory, platform)
|
|
284
|
+
if (shortfall) note(LogDegradation.PROTECTION)
|
|
285
|
+
|
|
286
|
+
// The startup trim happens before the append stream exists, through its own
|
|
287
|
+
// read-write descriptor. iOS reads and truncates through the very
|
|
288
|
+
// descriptor it will write with; here the JDK gives no single handle that
|
|
289
|
+
// is both appending and readable, so this is a separate open. The window
|
|
290
|
+
// that opens is the construction of one writer over app-private storage
|
|
291
|
+
// with no other descriptor on the file yet, which is the narrowest form of
|
|
292
|
+
// the problem — but it is a real difference and not worth hiding.
|
|
293
|
+
trimTornTailIfFramed()
|
|
294
|
+
|
|
295
|
+
if (!reopen(initial = true)) {
|
|
296
|
+
throw LogWriterException(
|
|
297
|
+
LogWriterException.Kind.OPEN_FAILED,
|
|
298
|
+
"could not open the log file"
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
onExecutor { sweepRetention() }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// MARK: - Reference counting (registry-owned, called under the registry lock)
|
|
305
|
+
|
|
306
|
+
fun retain() {
|
|
307
|
+
refCount += 1
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fun releaseOne(): Int {
|
|
311
|
+
refCount -= 1
|
|
312
|
+
return refCount
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// MARK: - Opening
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Opens for appending, creating if needed. Every open funnels through here.
|
|
319
|
+
*
|
|
320
|
+
* Append mode is `O_APPEND` underneath, which makes each write land at the
|
|
321
|
+
* current end of file whatever else wrote in between. Without it a second
|
|
322
|
+
* descriptor on the same file — a replaced destination draining late, a stale
|
|
323
|
+
* handle after a rotation — writes from its own stale offset and silently
|
|
324
|
+
* overwrites.
|
|
325
|
+
*
|
|
326
|
+
* There is no `O_NOFOLLOW` equivalent here, so the symlink check is done by
|
|
327
|
+
* the registry before the path is ever handed over, and re-checked here.
|
|
328
|
+
* That is a check-then-open rather than one atomic syscall; on Android the
|
|
329
|
+
* directory is app-private storage that no other app can write to, which is
|
|
330
|
+
* what makes the remaining window acceptable rather than merely unavoidable.
|
|
331
|
+
*/
|
|
332
|
+
private fun reopen(initial: Boolean = false): Boolean {
|
|
333
|
+
if (!initial) lastReopenAttempt = monotonic()
|
|
334
|
+
closeCurrentStream()
|
|
335
|
+
|
|
336
|
+
if (!LogSecureFile.createDirectory(directory, platform)) note(LogDegradation.PROTECTION)
|
|
337
|
+
|
|
338
|
+
val opened = try {
|
|
339
|
+
if (LogSecureFile.isSymbolicLink(file, platform)) null else FileOutputStream(file, true)
|
|
340
|
+
} catch (_: Exception) {
|
|
341
|
+
null
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (opened == null) {
|
|
345
|
+
// Reset the rotation triggers even though nothing opened. Leaving the
|
|
346
|
+
// size above the threshold makes every later write re-enter rotation,
|
|
347
|
+
// which archives the file again and again until pruning has eaten every
|
|
348
|
+
// real archive.
|
|
349
|
+
currentFileSize = 0
|
|
350
|
+
// `pendingFileStart` deliberately survives a failed open — it is the only
|
|
351
|
+
// record that this file was rotated, and the retry needs it.
|
|
352
|
+
currentFileStart = pendingFileStart ?: clock()
|
|
353
|
+
writesSinceHealthCheck = 0
|
|
354
|
+
return false
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!LogSecureFile.secure(file, platform)) note(LogDegradation.PROTECTION)
|
|
358
|
+
|
|
359
|
+
stream = opened
|
|
360
|
+
currentFileSize = try {
|
|
361
|
+
opened.channel.size()
|
|
362
|
+
} catch (_: Exception) {
|
|
363
|
+
file.length()
|
|
364
|
+
}
|
|
365
|
+
// A pending rotation time outranks the sidecar, which until it is rewritten
|
|
366
|
+
// still describes the file that was just archived. The write is retried on
|
|
367
|
+
// every reopen and the value is only dropped once it lands, so no number of
|
|
368
|
+
// failed opens in between can send this process back to the stale age.
|
|
369
|
+
val pending = pendingFileStart
|
|
370
|
+
if (pending != null) {
|
|
371
|
+
currentFileStart = pending
|
|
372
|
+
if (writeSidecar(pending)) pendingFileStart = null else note(LogDegradation.SIDECAR)
|
|
373
|
+
} else {
|
|
374
|
+
currentFileStart = creationTimeOf(file)
|
|
375
|
+
}
|
|
376
|
+
writesSinceHealthCheck = 0
|
|
377
|
+
return true
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private fun closeCurrentStream() {
|
|
381
|
+
val live = stream ?: return
|
|
382
|
+
stream = null
|
|
383
|
+
try {
|
|
384
|
+
live.close()
|
|
385
|
+
} catch (_: Exception) {
|
|
386
|
+
// The descriptor is going away regardless.
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* When the current log file was created, for age-based rotation.
|
|
392
|
+
*
|
|
393
|
+
* **The sidecar is authoritative once it exists.** Age rotation has to survive
|
|
394
|
+
* a restart, so it cannot be measured from when this process happened to open
|
|
395
|
+
* the file — but the filesystem's own answer cannot carry it either.
|
|
396
|
+
* `creationTime()` is API 26, and where it exists it is not reliably
|
|
397
|
+
* populated: several Android filesystems have no birth time and hand back the
|
|
398
|
+
* mtime instead. An mtime advances on *every write*, so a writer that trusted
|
|
399
|
+
* it would see a freshly-created file at every restart and postpone age
|
|
400
|
+
* rotation forever — the exact failure the sidecar was introduced to prevent,
|
|
401
|
+
* which reading the filesystem first quietly reintroduced.
|
|
402
|
+
*
|
|
403
|
+
* So the filesystem is consulted only to *seed* a sidecar that does not exist
|
|
404
|
+
* yet, and only when its answer is plausible (positive, not in the future).
|
|
405
|
+
* After that the recorded value is the only one that counts, and rotation
|
|
406
|
+
* deletes it along with the file it describes.
|
|
407
|
+
*/
|
|
408
|
+
private fun creationTimeOf(target: File): Long {
|
|
409
|
+
val now = clock()
|
|
410
|
+
val sidecar = File(directory, "$baseName.meta")
|
|
411
|
+
val recorded = try {
|
|
412
|
+
if (sidecar.isFile) sidecar.readText().trim().toLongOrNull() else null
|
|
413
|
+
} catch (_: Exception) {
|
|
414
|
+
null
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Positive AND not in the future. A recorded time ahead of the clock is
|
|
418
|
+
// either a corrupt sidecar or one written before the device's time was
|
|
419
|
+
// corrected backwards; either way `now - it` is negative, which reads as
|
|
420
|
+
// "this file is not old yet" and postpones age rotation until wall time
|
|
421
|
+
// catches up — potentially days. Falling through rewrites it.
|
|
422
|
+
if (recorded != null && recorded > 0 && recorded <= now) return recorded
|
|
423
|
+
|
|
424
|
+
// `Throwable`, not `Exception`. This is a best-effort enrichment on a path
|
|
425
|
+
// that must never fail to open a log file, and the specific hazard is
|
|
426
|
+
// `NoClassDefFoundError` from an implementation that reached for a class its
|
|
427
|
+
// API level does not have — an `Error`, which `catch (Exception)` would let
|
|
428
|
+
// straight through.
|
|
429
|
+
val reported = try {
|
|
430
|
+
platform.creationTimeMillis(target)
|
|
431
|
+
} catch (_: Throwable) {
|
|
432
|
+
null
|
|
433
|
+
}
|
|
434
|
+
val seed = if (reported != null && reported > 0 && reported <= now) reported else now
|
|
435
|
+
|
|
436
|
+
// Without a sidecar, age-based rotation restarts its clock on every open.
|
|
437
|
+
// That degrades the policy rather than breaking the writer, so it is
|
|
438
|
+
// recorded and logging continues.
|
|
439
|
+
if (!writeSidecar(seed)) note(LogDegradation.SIDECAR)
|
|
440
|
+
return seed
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Records [value] as the current file's start time. */
|
|
444
|
+
private fun writeSidecar(value: Long): Boolean {
|
|
445
|
+
val sidecar = File(directory, "$baseName.meta")
|
|
446
|
+
return try {
|
|
447
|
+
sidecar.writeText(value.toString())
|
|
448
|
+
if (!LogSecureFile.secure(sidecar, platform)) note(LogDegradation.PROTECTION)
|
|
449
|
+
true
|
|
450
|
+
} catch (_: Exception) {
|
|
451
|
+
false
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Cuts a torn trailing record left by a crash — but only when the producer
|
|
457
|
+
* has declared that records are newline-framed.
|
|
458
|
+
*
|
|
459
|
+
* Without that declaration the trailing bytes are indistinguishable from a
|
|
460
|
+
* record that simply contains newlines, and trimming would eat good data to
|
|
461
|
+
* tidy up after a crash that may not have happened.
|
|
462
|
+
*/
|
|
463
|
+
private fun trimTornTailIfFramed() {
|
|
464
|
+
if (!lineFramed || !file.isFile || file.length() == 0L) return
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
RandomAccessFile(file, "rw").use { raf ->
|
|
468
|
+
val length = raf.length()
|
|
469
|
+
if (length == 0L) return
|
|
470
|
+
|
|
471
|
+
raf.seek(length - 1)
|
|
472
|
+
if (raf.read() == NEWLINE.toInt()) return // already on a record boundary
|
|
473
|
+
|
|
474
|
+
// Scan backwards a window at a time rather than giving up after one.
|
|
475
|
+
// A single fixed window is wrong in both directions: a record larger
|
|
476
|
+
// than the window hides its own preceding boundary, and a file whose
|
|
477
|
+
// only content is one incomplete record has no boundary at all.
|
|
478
|
+
val buffer = ByteArray(TAIL_SCAN_WINDOW)
|
|
479
|
+
var end = length
|
|
480
|
+
var keep: Long? = null
|
|
481
|
+
|
|
482
|
+
while (end > 0) {
|
|
483
|
+
val start = if (end > TAIL_SCAN_WINDOW) end - TAIL_SCAN_WINDOW else 0L
|
|
484
|
+
val wanted = (end - start).toInt()
|
|
485
|
+
raf.seek(start)
|
|
486
|
+
raf.readFully(buffer, 0, wanted)
|
|
487
|
+
val index = lastIndexOfNewline(buffer, wanted)
|
|
488
|
+
if (index >= 0) {
|
|
489
|
+
keep = start + index + 1
|
|
490
|
+
break
|
|
491
|
+
}
|
|
492
|
+
end = start
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// No newline in the whole file. Under a *declared* framing contract
|
|
496
|
+
// that is not ambiguous — every record ends in one, so a file without
|
|
497
|
+
// any contains no complete record and all of it is torn.
|
|
498
|
+
val target = keep ?: 0L
|
|
499
|
+
if (target < length) raf.setLength(target)
|
|
500
|
+
}
|
|
501
|
+
} catch (_: Exception) {
|
|
502
|
+
// A file that cannot be read is left exactly as it was found.
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private fun lastIndexOfNewline(buffer: ByteArray, length: Int): Int {
|
|
507
|
+
for (i in length - 1 downTo 0) {
|
|
508
|
+
if (buffer[i] == NEWLINE) return i
|
|
509
|
+
}
|
|
510
|
+
return -1
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// MARK: - Appending
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Accept or refuse a batch, then write it on the executor.
|
|
517
|
+
*
|
|
518
|
+
* The decision is made entirely under [stateLock] and before anything is
|
|
519
|
+
* enqueued, so the cap holds no matter how many threads are calling: two
|
|
520
|
+
* batches that each fit but do not fit together cannot both be accepted.
|
|
521
|
+
*/
|
|
522
|
+
fun append(
|
|
523
|
+
handleId: Long,
|
|
524
|
+
handleGeneration: Long,
|
|
525
|
+
batch: String,
|
|
526
|
+
entryCount: Long
|
|
527
|
+
): LogAppendResult {
|
|
528
|
+
val data = batch.toByteArray(Charsets.UTF_8)
|
|
529
|
+
val bytes = data.size.toLong()
|
|
530
|
+
|
|
531
|
+
stateLock.lock()
|
|
532
|
+
if (closed) return rejectUnlocking(LogRejectReason.CLOSED, handleId)
|
|
533
|
+
if (handleGeneration != generation) {
|
|
534
|
+
return rejectUnlocking(LogRejectReason.STALE_GENERATION, handleId)
|
|
535
|
+
}
|
|
536
|
+
// Validated on both sides of the bridge. An entry count that disagrees with
|
|
537
|
+
// the payload makes every loss number downstream a guess.
|
|
538
|
+
if (entryCount < 0 || entryCount > 1_000_000) {
|
|
539
|
+
return rejectUnlocking(LogRejectReason.FAILED, handleId)
|
|
540
|
+
}
|
|
541
|
+
if ((bytes == 0L) != (entryCount == 0L)) {
|
|
542
|
+
return rejectUnlocking(LogRejectReason.FAILED, handleId)
|
|
543
|
+
}
|
|
544
|
+
if (bytes == 0L) {
|
|
545
|
+
// Nothing to do, and nothing to complain about.
|
|
546
|
+
val status = statusLocked(handleId)
|
|
547
|
+
stateLock.unlock()
|
|
548
|
+
return LogAppendResult(accepted = true, rejectReason = null, status = status)
|
|
549
|
+
}
|
|
550
|
+
if (reservedBytes + bytes > HARD_CAP_BYTES) {
|
|
551
|
+
return rejectUnlocking(LogRejectReason.FULL, handleId)
|
|
552
|
+
}
|
|
553
|
+
reservedBytes += bytes
|
|
554
|
+
val status = statusLocked(handleId)
|
|
555
|
+
stateLock.unlock()
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
executor.execute { performWrite(data, handleId, entryCount, handleGeneration) }
|
|
559
|
+
} catch (_: RejectedExecutionException) {
|
|
560
|
+
// A close shut the executor down between this batch being accepted and
|
|
561
|
+
// being submitted. Two things have to happen and neither is optional:
|
|
562
|
+
// give back the reservation, because nothing will ever run to release it
|
|
563
|
+
// and a leak here permanently inflates `queuedBytes` until the 1 MB cap
|
|
564
|
+
// refuses everything; and refuse the batch rather than letting the
|
|
565
|
+
// exception cross the bridge, since the caller must be told these records
|
|
566
|
+
// did not make it.
|
|
567
|
+
release(bytes)
|
|
568
|
+
return LogAppendResult(
|
|
569
|
+
accepted = false,
|
|
570
|
+
rejectReason = LogRejectReason.CLOSED,
|
|
571
|
+
status = status(handleId)
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
return LogAppendResult(accepted = true, rejectReason = null, status = status)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private fun rejectUnlocking(reason: LogRejectReason, handleId: Long): LogAppendResult {
|
|
578
|
+
val status = statusLocked(handleId)
|
|
579
|
+
stateLock.unlock()
|
|
580
|
+
return LogAppendResult(accepted = false, rejectReason = reason, status = status)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private fun performWrite(data: ByteArray, handleId: Long, entryCount: Long, writeGeneration: Long) {
|
|
584
|
+
try {
|
|
585
|
+
if (terminated) {
|
|
586
|
+
// Past the close barrier. Anything reaching here was accepted, missed
|
|
587
|
+
// the flush, and has no stream left to go to — reopening one would
|
|
588
|
+
// resurrect a writer the caller has finished with. It is counted lost
|
|
589
|
+
// rather than dropped silently, which matches the `durable = false`
|
|
590
|
+
// and non-zero `pendingBytes` that close already reported.
|
|
591
|
+
record(entryCount, data.size.toLong(), handleId)
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
val stale = stateLock.withLock { writeGeneration != generation }
|
|
596
|
+
if (stale) {
|
|
597
|
+
// A purge landed between acceptance and here. These bytes belong to a
|
|
598
|
+
// file that was deliberately deleted, so they are dropped WITHOUT being
|
|
599
|
+
// counted as loss: writing them into the fresh file would resurrect
|
|
600
|
+
// pre-purge data, and reporting them would describe a gap the user
|
|
601
|
+
// asked for.
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
val live = writableStream()
|
|
606
|
+
if (live == null) {
|
|
607
|
+
record(entryCount, data.size.toLong(), handleId)
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// The true end of file, not a tracked counter: it is what a partial write
|
|
612
|
+
// has to be rolled back to, and being wrong about it means truncating
|
|
613
|
+
// somebody else's bytes.
|
|
614
|
+
val offsetBefore = try {
|
|
615
|
+
live.channel.size()
|
|
616
|
+
} catch (_: Exception) {
|
|
617
|
+
currentFileSize
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
writeAll(live, data)
|
|
622
|
+
currentFileSize = offsetBefore + data.size
|
|
623
|
+
healthCheckPeriodically()
|
|
624
|
+
rotateIfNeeded()
|
|
625
|
+
} catch (_: Exception) {
|
|
626
|
+
// Roll back to the record boundary the batch started at. A half-written
|
|
627
|
+
// batch is a half-written record, and a half-written record makes the
|
|
628
|
+
// rest of the file unparseable from that point on — the loss would
|
|
629
|
+
// spread from one batch to everything after it.
|
|
630
|
+
try {
|
|
631
|
+
live.channel.truncate(offsetBefore)
|
|
632
|
+
currentFileSize = offsetBefore
|
|
633
|
+
} catch (_: Exception) {
|
|
634
|
+
// Nothing better to try; the batch is counted lost either way.
|
|
635
|
+
}
|
|
636
|
+
record(entryCount, data.size.toLong(), handleId)
|
|
637
|
+
invalidateStreamIfUnlinked()
|
|
638
|
+
}
|
|
639
|
+
} finally {
|
|
640
|
+
release(data.size.toLong())
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Writes every byte or throws.
|
|
646
|
+
*
|
|
647
|
+
* `FileOutputStream.write` loops internally, but it is still allowed to throw
|
|
648
|
+
* having written part of the buffer, and an injected [RawWrite] may report a
|
|
649
|
+
* short write directly. Treating either as success is how a log file ends up
|
|
650
|
+
* with a record missing its second half.
|
|
651
|
+
*/
|
|
652
|
+
private fun writeAll(target: FileOutputStream, data: ByteArray) {
|
|
653
|
+
var written = 0
|
|
654
|
+
while (written < data.size) {
|
|
655
|
+
val n = rawWrite.write(target, data, written, data.size - written)
|
|
656
|
+
if (n <= 0) throw LogWriterException(LogWriterException.Kind.OPEN_FAILED, "write failed")
|
|
657
|
+
written += n
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// MARK: - Stream liveness (executor only)
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The live stream, reopening if the backoff allows.
|
|
665
|
+
*
|
|
666
|
+
* [ignoringBackoff] is for the explicit-durability paths — [flush] and
|
|
667
|
+
* [close]. A caller there is asking for what is buffered to be on storage
|
|
668
|
+
* NOW, and a degraded writer sitting inside its reopen backoff would
|
|
669
|
+
* otherwise report failure and hand back nothing, with no second chance
|
|
670
|
+
* coming. That is exactly the process-death case, where the records being
|
|
671
|
+
* given up on are the ones explaining the shutdown.
|
|
672
|
+
*
|
|
673
|
+
* The parameter comes from SwiftLogger's `FileDestination`, by way of the
|
|
674
|
+
* iOS twin's `writableHandle(ignoringBackoff:)`; both ports had dropped it.
|
|
675
|
+
*/
|
|
676
|
+
private fun writableStream(ignoringBackoff: Boolean = false): FileOutputStream? {
|
|
677
|
+
stream?.let { return it }
|
|
678
|
+
if (!ignoringBackoff && monotonic() - lastReopenAttempt < REOPEN_BACKOFF_MS) return null
|
|
679
|
+
reopen()
|
|
680
|
+
return stream
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Confirms the stream still points at a file anyone can read.
|
|
685
|
+
*
|
|
686
|
+
* Writes to an unlinked inode succeed forever and land nowhere. No error is
|
|
687
|
+
* raised, so without this an externally deleted log file means silent loss
|
|
688
|
+
* for the rest of the process's life. A link count of zero catches the plain
|
|
689
|
+
* delete and the delete-then-recreate that a path-existence check misses.
|
|
690
|
+
*/
|
|
691
|
+
private fun healthCheckPeriodically() {
|
|
692
|
+
writesSinceHealthCheck += 1
|
|
693
|
+
if (writesSinceHealthCheck < HEALTH_CHECK_STRIDE) return
|
|
694
|
+
writesSinceHealthCheck = 0
|
|
695
|
+
invalidateStreamIfUnlinked()
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private fun invalidateStreamIfUnlinked() {
|
|
699
|
+
val live = stream ?: return
|
|
700
|
+
val links = try {
|
|
701
|
+
platform.linkCount(live.fd)
|
|
702
|
+
} catch (_: Exception) {
|
|
703
|
+
-1
|
|
704
|
+
}
|
|
705
|
+
// -1 means the platform cannot say, which is not evidence of deletion.
|
|
706
|
+
if (links != 0) return
|
|
707
|
+
reopen()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// MARK: - Status, flush, close
|
|
711
|
+
|
|
712
|
+
fun status(handleId: Long): LogSinkStatus = stateLock.withLock { statusLocked(handleId) }
|
|
713
|
+
|
|
714
|
+
private fun statusLocked(handleId: Long): LogSinkStatus {
|
|
715
|
+
val totals = loss[handleId]
|
|
716
|
+
return LogSinkStatus(
|
|
717
|
+
queuedBytes = reservedBytes,
|
|
718
|
+
lostBytes = totals?.get(1) ?: 0L,
|
|
719
|
+
lostEntries = totals?.get(0) ?: 0L,
|
|
720
|
+
degraded = degraded
|
|
721
|
+
)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Drains and syncs, bounded by a monotonic deadline.
|
|
726
|
+
*
|
|
727
|
+
* The barrier is enqueued behind whatever the executor is already doing, so a
|
|
728
|
+
* writer wedged mid-write times out here rather than blocking the caller
|
|
729
|
+
* forever — which on the crash path is the difference between a partial log
|
|
730
|
+
* and an ANR.
|
|
731
|
+
*/
|
|
732
|
+
fun flush(handleId: Long, deadlineMs: Double): LogFlushOutcome =
|
|
733
|
+
flushUntil(handleId, monotonic() + clampDeadline(deadlineMs))
|
|
734
|
+
|
|
735
|
+
private fun flushUntil(handleId: Long, expiry: Long): LogFlushOutcome {
|
|
736
|
+
var timedOut = false
|
|
737
|
+
|
|
738
|
+
if (Thread.currentThread() === writerThread) {
|
|
739
|
+
// Already on the write thread. Enqueueing and waiting here would be a
|
|
740
|
+
// deadlock against ourselves — the barrier could never run until we
|
|
741
|
+
// returned. Run it inline instead; ordering is preserved either way,
|
|
742
|
+
// because nothing enqueued after us has started.
|
|
743
|
+
syncNow()
|
|
744
|
+
} else {
|
|
745
|
+
val done = CountDownLatch(1)
|
|
746
|
+
try {
|
|
747
|
+
executor.execute {
|
|
748
|
+
try {
|
|
749
|
+
syncNow()
|
|
750
|
+
} finally {
|
|
751
|
+
done.countDown()
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
timedOut = !done.await(remaining(expiry), TimeUnit.MILLISECONDS)
|
|
755
|
+
} catch (_: InterruptedException) {
|
|
756
|
+
// Nothing was established about durability, so this reports the same as
|
|
757
|
+
// a timeout — with the interrupt put back, because a caller that is
|
|
758
|
+
// being cancelled has to stay cancellable.
|
|
759
|
+
Thread.currentThread().interrupt()
|
|
760
|
+
timedOut = true
|
|
761
|
+
} catch (_: Exception) {
|
|
762
|
+
// A rejected execution means the executor is gone; nothing is pending
|
|
763
|
+
// that could still become durable.
|
|
764
|
+
timedOut = true
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
stateLock.lock()
|
|
769
|
+
val status = statusLocked(handleId)
|
|
770
|
+
val pending = reservedBytes
|
|
771
|
+
val synced = lastSyncSucceeded
|
|
772
|
+
stateLock.unlock()
|
|
773
|
+
|
|
774
|
+
return LogFlushOutcome(
|
|
775
|
+
durable = !timedOut && pending == 0L && synced,
|
|
776
|
+
timedOut = timedOut,
|
|
777
|
+
pendingBytes = pending,
|
|
778
|
+
status = status
|
|
779
|
+
)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Executor-confined. Records whether the data actually reached storage, which
|
|
784
|
+
* is the difference between `durable` and "we asked".
|
|
785
|
+
*/
|
|
786
|
+
private fun syncNow() {
|
|
787
|
+
// Ignoring the backoff, which is the whole point: this runs only for a
|
|
788
|
+
// caller that asked for durability now. Without it a writer that lost its
|
|
789
|
+
// stream inside the backoff window reports a failed flush and reopens
|
|
790
|
+
// nothing, so the retry the caller is told to make fails the same way.
|
|
791
|
+
val live = writableStream(ignoringBackoff = true)
|
|
792
|
+
if (live == null) {
|
|
793
|
+
stateLock.withLock { lastSyncSucceeded = false }
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
val ok = try {
|
|
797
|
+
live.fd.sync()
|
|
798
|
+
true
|
|
799
|
+
} catch (_: Exception) {
|
|
800
|
+
false
|
|
801
|
+
}
|
|
802
|
+
stateLock.withLock { lastSyncSucceeded = ok }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Flushes, then closes the stream — both inside ONE budget.
|
|
807
|
+
*
|
|
808
|
+
* The deadline is computed once, at entry. Giving the flush the full budget
|
|
809
|
+
* and then the close barrier the full budget again means a stalled writer
|
|
810
|
+
* blocks for twice what the caller asked for.
|
|
811
|
+
*
|
|
812
|
+
* [onTerminated] runs on the executor once the stream is really shut,
|
|
813
|
+
* **regardless of whether this call waited that long**. The two are
|
|
814
|
+
* deliberately decoupled: the caller's deadline bounds how long it blocks;
|
|
815
|
+
* the callback reports when the writer actually stopped. The registry needs
|
|
816
|
+
* the second, because releasing a path because a close *gave up waiting*
|
|
817
|
+
* would let a replacement writer open the same file while this one still has
|
|
818
|
+
* a write executing.
|
|
819
|
+
*/
|
|
820
|
+
fun close(handleId: Long, deadlineMs: Double, onTerminated: (() -> Unit)? = null): LogFlushOutcome {
|
|
821
|
+
val expiry = monotonic() + clampDeadline(deadlineMs)
|
|
822
|
+
|
|
823
|
+
stateLock.withLock { closed = true }
|
|
824
|
+
|
|
825
|
+
val outcome = flushUntil(handleId, expiry)
|
|
826
|
+
|
|
827
|
+
val done = CountDownLatch(1)
|
|
828
|
+
var submitted = false
|
|
829
|
+
try {
|
|
830
|
+
executor.execute {
|
|
831
|
+
try {
|
|
832
|
+
terminated = true
|
|
833
|
+
closeCurrentStream()
|
|
834
|
+
onTerminated?.invoke()
|
|
835
|
+
} finally {
|
|
836
|
+
done.countDown()
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
submitted = true
|
|
840
|
+
// Whatever the flush left of the budget, and nothing more.
|
|
841
|
+
done.await(remaining(expiry), TimeUnit.MILLISECONDS)
|
|
842
|
+
} catch (_: InterruptedException) {
|
|
843
|
+
Thread.currentThread().interrupt()
|
|
844
|
+
} catch (_: RejectedExecutionException) {
|
|
845
|
+
// Never enqueued, so nothing else will ever run it.
|
|
846
|
+
}
|
|
847
|
+
// Only when the barrier never got submitted. Invoking it because the *wait*
|
|
848
|
+
// ended early would run it twice — once here and once on the executor — and
|
|
849
|
+
// the registry's close claim is counted, so a second call releases a path
|
|
850
|
+
// another close still holds.
|
|
851
|
+
if (!submitted) onTerminated?.invoke()
|
|
852
|
+
executor.shutdown()
|
|
853
|
+
return outcome
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Milliseconds left on a [monotonic]-based expiry, never negative. */
|
|
857
|
+
private fun remaining(expiry: Long): Long = maxOf(0L, expiry - monotonic())
|
|
858
|
+
|
|
859
|
+
private fun release(bytes: Long) {
|
|
860
|
+
stateLock.withLock { reservedBytes = maxOf(0L, reservedBytes - bytes) }
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
private fun record(entries: Long, bytes: Long, handleId: Long) {
|
|
864
|
+
if (entries <= 0 && bytes <= 0) return
|
|
865
|
+
stateLock.withLock {
|
|
866
|
+
val totals = loss.getOrPut(handleId) { longArrayOf(0, 0) }
|
|
867
|
+
totals[0] += entries
|
|
868
|
+
totals[1] += bytes
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private fun note(flag: Int) {
|
|
873
|
+
stateLock.withLock { degraded = degraded or flag }
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// MARK: - Rotation (executor only)
|
|
877
|
+
|
|
878
|
+
private fun rotateIfNeeded() {
|
|
879
|
+
val live = stream ?: return
|
|
880
|
+
|
|
881
|
+
// Two clocks, deliberately. The backoff asks "has enough time passed since
|
|
882
|
+
// the last failure", which must not be re-answered by an NTP correction;
|
|
883
|
+
// the age test asks "how old is this file", which is measured against a
|
|
884
|
+
// creation time recorded on a previous run and so has to be epoch.
|
|
885
|
+
val steady = monotonic()
|
|
886
|
+
if (steady < rotationBlockedUntil) return
|
|
887
|
+
|
|
888
|
+
val now = clock()
|
|
889
|
+
val tooBig = currentFileSize >= policy.maxFileSizeBytes
|
|
890
|
+
val tooOld = policy.maxFileAgeSeconds?.let {
|
|
891
|
+
now - currentFileStart >= (it * 1000).toLong()
|
|
892
|
+
} ?: false
|
|
893
|
+
if (!tooBig && !tooOld) return
|
|
894
|
+
rotationAttempts += 1
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
live.fd.sync()
|
|
898
|
+
} catch (_: Exception) {
|
|
899
|
+
// The rename below still moves whatever did land.
|
|
900
|
+
}
|
|
901
|
+
closeCurrentStream()
|
|
902
|
+
|
|
903
|
+
val archive = File(directory, "$baseName.${rotationStamp()}")
|
|
904
|
+
if (!file.renameTo(archive)) {
|
|
905
|
+
// Back off. A rotation that fails on every write — a read-only volume, a
|
|
906
|
+
// directory someone removed — would otherwise retry on every single
|
|
907
|
+
// batch, turning a degraded log into a busy one.
|
|
908
|
+
note(LogDegradation.ROTATION)
|
|
909
|
+
rotationBlockedUntil = steady + ROTATION_BACKOFF_MS
|
|
910
|
+
reopen()
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
if (!LogSecureFile.secure(archive, platform)) note(LogDegradation.PROTECTION)
|
|
914
|
+
|
|
915
|
+
// The sidecar describes the file that was just archived, so it must not
|
|
916
|
+
// survive to date the fresh one: leaving it makes a brand new file look old
|
|
917
|
+
// enough to rotate immediately, on every write.
|
|
918
|
+
//
|
|
919
|
+
// Overwritten rather than deleted, and held in memory until it lands.
|
|
920
|
+
// Deleting was the obvious move and the wrong one — now that the sidecar is
|
|
921
|
+
// authoritative, a deletion that *fails* leaves the archived file's age in
|
|
922
|
+
// charge of the fresh file and causes exactly the runaway this prevents.
|
|
923
|
+
// Recording the intent here and letting `reopen` perform and retry the
|
|
924
|
+
// write is what makes a failed write survivable across as many failed
|
|
925
|
+
// reopens as it takes.
|
|
926
|
+
pendingFileStart = clock()
|
|
927
|
+
|
|
928
|
+
if (policy.compressArchives) compress(archive)
|
|
929
|
+
sweepRetention()
|
|
930
|
+
reopen()
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Replaces [source] with a gzipped copy, keeping the original if anything
|
|
935
|
+
* goes wrong: a bigger archive beats a lost one.
|
|
936
|
+
*
|
|
937
|
+
* Compression writes to a `.part` staging name and renames on success, so an
|
|
938
|
+
* interruption leaves something the purge recognises rather than a
|
|
939
|
+
* plausible-looking `.gz` that no tool can open.
|
|
940
|
+
*/
|
|
941
|
+
private fun compress(source: File) {
|
|
942
|
+
val finalFile = File(source.absolutePath + ".gz")
|
|
943
|
+
val staging = File(finalFile.absolutePath + ".part")
|
|
944
|
+
staging.delete()
|
|
945
|
+
|
|
946
|
+
if (!compressor.compress(source, staging)) {
|
|
947
|
+
staging.delete()
|
|
948
|
+
note(LogDegradation.GZIP)
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
if (!LogSecureFile.secure(staging, platform)) note(LogDegradation.PROTECTION)
|
|
952
|
+
|
|
953
|
+
if (!staging.renameTo(finalFile)) {
|
|
954
|
+
staging.delete()
|
|
955
|
+
note(LogDegradation.GZIP)
|
|
956
|
+
return
|
|
957
|
+
}
|
|
958
|
+
if (!LogSecureFile.secure(finalFile, platform)) note(LogDegradation.PROTECTION)
|
|
959
|
+
|
|
960
|
+
if (!source.delete()) {
|
|
961
|
+
// The plaintext original survived alongside the compressed copy. Remove
|
|
962
|
+
// the compressed one instead of leaving two: a plaintext log the caller
|
|
963
|
+
// believes was compressed away is a file nobody remembers to delete.
|
|
964
|
+
finalFile.delete()
|
|
965
|
+
note(LogDegradation.GZIP)
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private fun rotationStamp(): String {
|
|
970
|
+
val stamp = java.text.SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply {
|
|
971
|
+
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
|
972
|
+
}.format(java.util.Date(clock()))
|
|
973
|
+
val suffix = java.util.UUID.randomUUID().toString().replace("-", "").take(8).lowercase(Locale.US)
|
|
974
|
+
return "${stamp}_$suffix"
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// MARK: - Retention (executor only)
|
|
978
|
+
|
|
979
|
+
data class Artifact(val file: File, val modified: Long, val size: Long)
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Applies all three retention limits. Runs at open and after each rotation.
|
|
983
|
+
*
|
|
984
|
+
* No background timer: an idle process writes nothing, so there is nothing to
|
|
985
|
+
* sweep, and a timer that fires in the background is a wakeup the app pays
|
|
986
|
+
* for. An active process rotates, and rotation sweeps.
|
|
987
|
+
*/
|
|
988
|
+
private fun sweepRetention() {
|
|
989
|
+
var failed = false
|
|
990
|
+
fun remove(target: File) {
|
|
991
|
+
if (target.delete()) return
|
|
992
|
+
// Not `exists()`: it returns false both for "already gone" and for "could
|
|
993
|
+
// not tell", and only the first of those is a successful removal.
|
|
994
|
+
if (platform.lookup(target) != PlatformIo.Presence.ABSENT) failed = true
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
val names = directory.list()
|
|
998
|
+
if (names == null) {
|
|
999
|
+
// An unreadable directory is not an empty one, and a sweep that silently
|
|
1000
|
+
// did nothing would let retention drift without a word.
|
|
1001
|
+
note(LogDegradation.PRUNE)
|
|
1002
|
+
return
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Orphaned compressions first. A `.part` is a gzip that was interrupted —
|
|
1006
|
+
// by a crash, or by a process that died mid-rotation — and nothing will
|
|
1007
|
+
// ever finish it. Compression runs on this same executor, so a staging file
|
|
1008
|
+
// seen from here is never one being written.
|
|
1009
|
+
names.filter { isStagingName(it, baseName) }.forEach { remove(File(directory, it)) }
|
|
1010
|
+
|
|
1011
|
+
var archives = archives(directory, baseName)
|
|
1012
|
+
|
|
1013
|
+
// Oldest first for age, then count, then total size — each pass works on
|
|
1014
|
+
// what the previous one left.
|
|
1015
|
+
policy.maxArchiveAgeSeconds?.let { maxAge ->
|
|
1016
|
+
val cutoff = clock() - (maxAge * 1000).toLong()
|
|
1017
|
+
val expired = archives.filter { it.modified < cutoff }
|
|
1018
|
+
expired.forEach { remove(it.file) }
|
|
1019
|
+
archives = archives - expired.toSet()
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (archives.size > policy.maxArchivedFilesCount) {
|
|
1023
|
+
val excess = archives.drop(policy.maxArchivedFilesCount)
|
|
1024
|
+
excess.forEach { remove(it.file) }
|
|
1025
|
+
archives = archives.take(policy.maxArchivedFilesCount)
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
policy.maxTotalLogBytes?.let { cap ->
|
|
1029
|
+
var total = currentFileSize + archives.sumOf { it.size }
|
|
1030
|
+
val remaining = archives.toMutableList()
|
|
1031
|
+
// Newest-first order, so dropping from the end sheds the oldest.
|
|
1032
|
+
while (total > cap && remaining.isNotEmpty()) {
|
|
1033
|
+
val oldest = remaining.removeAt(remaining.size - 1)
|
|
1034
|
+
remove(oldest.file)
|
|
1035
|
+
total = if (total > oldest.size) total - oldest.size else 0
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (failed) note(LogDegradation.PRUNE)
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Archives for [baseName], newest first.
|
|
1044
|
+
*
|
|
1045
|
+
* Ordered by modification time rather than by name. The name's timestamp has
|
|
1046
|
+
* one-second resolution, so a burst of rotations inside the same second all
|
|
1047
|
+
* share it and only the random suffix differs — sorting by name would keep an
|
|
1048
|
+
* arbitrary subset and delete newer archives than it kept. Names break exact
|
|
1049
|
+
* ties so the order is still deterministic.
|
|
1050
|
+
*/
|
|
1051
|
+
private fun archives(directory: File, baseName: String): List<Artifact> {
|
|
1052
|
+
val names = directory.list() ?: return emptyList()
|
|
1053
|
+
return names
|
|
1054
|
+
.filter { isArchiveName(it, baseName) }
|
|
1055
|
+
.map { File(directory, it) }
|
|
1056
|
+
.map { Artifact(it, it.lastModified(), it.length()) }
|
|
1057
|
+
.sortedWith(compareByDescending<Artifact> { it.modified }.thenByDescending { it.file.name })
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* The active file and every archive, newest first — read **on the executor**.
|
|
1062
|
+
*
|
|
1063
|
+
* Rotation, compression, retention and purge all mutate these names, and all
|
|
1064
|
+
* of them run on the executor. Enumerating from the caller's thread would
|
|
1065
|
+
* race every one of them: the honest failure is handing back a `.gz` that is
|
|
1066
|
+
* mid-rename, or an archive that pruning deleted a microsecond later, to a
|
|
1067
|
+
* caller whose whole purpose is to open those files.
|
|
1068
|
+
*
|
|
1069
|
+
* Bounded, because this is reachable from the JS thread and the executor may
|
|
1070
|
+
* be wedged on storage that has stopped answering. On timeout the active
|
|
1071
|
+
* path is returned alone: it is the one name this writer owns unconditionally
|
|
1072
|
+
* and can state without reading the directory, whereas a partial archive list
|
|
1073
|
+
* would be indistinguishable from a complete one.
|
|
1074
|
+
*/
|
|
1075
|
+
fun logFilePaths(): List<String> {
|
|
1076
|
+
var snapshot: List<String>? = null
|
|
1077
|
+
val captured = onExecutorBounded(PATHS_DEADLINE_MS) {
|
|
1078
|
+
val paths = mutableListOf(file.absolutePath)
|
|
1079
|
+
paths.addAll(archives(directory, baseName).map { it.file.absolutePath })
|
|
1080
|
+
snapshot = paths
|
|
1081
|
+
}
|
|
1082
|
+
return if (captured) snapshot ?: listOf(file.absolutePath) else listOf(file.absolutePath)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// MARK: - Purge
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Deletes every artifact and fences every handle.
|
|
1089
|
+
*
|
|
1090
|
+
* The generation bumps FIRST and unconditionally. Anything still in flight is
|
|
1091
|
+
* dropped when it reaches the executor, and any handle that has not rebound
|
|
1092
|
+
* is refused — so a deletion still running cannot race a fresh write, and a
|
|
1093
|
+
* partial deletion leaves everyone fenced rather than half of them writing
|
|
1094
|
+
* into files that are about to disappear.
|
|
1095
|
+
*
|
|
1096
|
+
* Only a complete deletion reopens the file. The caller rebinds on
|
|
1097
|
+
* `durable && rebound`, and on anything else stays fenced until it retries.
|
|
1098
|
+
*/
|
|
1099
|
+
fun clearLogs(deadlineMs: Double): Pair<LogClearOutcome, Long> {
|
|
1100
|
+
// The budget starts HERE, before waiting for anything. Computing it after
|
|
1101
|
+
// acquiring the lock would let a caller asking for 100 ms sit behind
|
|
1102
|
+
// another purge's full 30 seconds and still believe it was given 100.
|
|
1103
|
+
val budget = clampDeadline(deadlineMs)
|
|
1104
|
+
val expiry = monotonic() + budget
|
|
1105
|
+
|
|
1106
|
+
// One purge at a time per writer. Two overlapping purges each bump the
|
|
1107
|
+
// generation, and the first to finish would otherwise report success for a
|
|
1108
|
+
// fence the second has already moved.
|
|
1109
|
+
val acquired = try {
|
|
1110
|
+
purgeLock.tryLock(budget, TimeUnit.MILLISECONDS)
|
|
1111
|
+
} catch (_: InterruptedException) {
|
|
1112
|
+
Thread.currentThread().interrupt()
|
|
1113
|
+
false
|
|
1114
|
+
}
|
|
1115
|
+
if (!acquired) {
|
|
1116
|
+
val current = stateLock.withLock { generation }
|
|
1117
|
+
return LogClearOutcome(0, listOf(file.absolutePath), durable = false) to current
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
try {
|
|
1121
|
+
stateLock.lock()
|
|
1122
|
+
generation += 1
|
|
1123
|
+
val fenced = generation
|
|
1124
|
+
loss.clear()
|
|
1125
|
+
degraded = LogDegradation.NONE
|
|
1126
|
+
stateLock.unlock()
|
|
1127
|
+
|
|
1128
|
+
var outcome = LogClearOutcome(0, emptyList(), durable = false)
|
|
1129
|
+
val done = CountDownLatch(1)
|
|
1130
|
+
|
|
1131
|
+
try {
|
|
1132
|
+
executor.execute {
|
|
1133
|
+
try {
|
|
1134
|
+
closeCurrentStream()
|
|
1135
|
+
|
|
1136
|
+
// An unreadable directory is NOT an empty one. Sweeping an empty
|
|
1137
|
+
// list would report a durable purge while every artifact sat
|
|
1138
|
+
// untouched behind a permissions or I/O failure — the worst
|
|
1139
|
+
// possible lie for this particular call to tell. `list()` returns
|
|
1140
|
+
// null for both "not a directory" and "could not read it", so
|
|
1141
|
+
// absence has to be established separately.
|
|
1142
|
+
val names = directory.list()
|
|
1143
|
+
val directoryAbsent =
|
|
1144
|
+
names == null && platform.lookup(directory) == PlatformIo.Presence.ABSENT
|
|
1145
|
+
if (names == null && !directoryAbsent) {
|
|
1146
|
+
outcome = LogClearOutcome(0, listOf(directory.absolutePath), durable = false)
|
|
1147
|
+
return@execute
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
var deleted = 0
|
|
1151
|
+
val failures = mutableListOf<String>()
|
|
1152
|
+
for (name in (names ?: emptyArray())) {
|
|
1153
|
+
if (!isArtifactName(name, baseName)) continue
|
|
1154
|
+
val target = File(directory, name)
|
|
1155
|
+
if (target.delete()) {
|
|
1156
|
+
deleted += 1
|
|
1157
|
+
continue
|
|
1158
|
+
}
|
|
1159
|
+
// `delete()` said no. Only a platform that positively reports the
|
|
1160
|
+
// path as gone lets this count as deleted — something else removed
|
|
1161
|
+
// it between the listing and here. `File.exists()` cannot make
|
|
1162
|
+
// that distinction: it returns false for an absent file and for
|
|
1163
|
+
// one behind a permissions or I/O failure alike, and treating the
|
|
1164
|
+
// second as the first is how a purge reports `durable = true` over
|
|
1165
|
+
// artifacts still sitting on disk. For this call, of every lie
|
|
1166
|
+
// available, that is the worst one.
|
|
1167
|
+
if (platform.lookup(target) == PlatformIo.Presence.ABSENT) {
|
|
1168
|
+
deleted += 1
|
|
1169
|
+
} else {
|
|
1170
|
+
// The path is this package's own artifact name, not user
|
|
1171
|
+
// content.
|
|
1172
|
+
failures.add(target.absolutePath)
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (failures.isNotEmpty()) {
|
|
1177
|
+
outcome = LogClearOutcome(deleted, failures, durable = false)
|
|
1178
|
+
return@execute
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// `delete()` returning true only means the change is in the
|
|
1182
|
+
// directory's in-memory state. Until the directory itself is
|
|
1183
|
+
// synced, a crash or a power loss can bring every one of those
|
|
1184
|
+
// names back — and this call exists precisely to promise they are
|
|
1185
|
+
// gone.
|
|
1186
|
+
if (!directoryAbsent && !platform.syncDirectory(directory)) {
|
|
1187
|
+
outcome = LogClearOutcome(deleted, listOf(directory.absolutePath), durable = false)
|
|
1188
|
+
return@execute
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
val current = stateLock.withLock { generation }
|
|
1192
|
+
if (current != fenced) {
|
|
1193
|
+
outcome = LogClearOutcome(deleted, emptyList(), durable = false)
|
|
1194
|
+
return@execute
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
currentFileSize = 0
|
|
1198
|
+
currentFileStart = clock()
|
|
1199
|
+
// The purge took the sidecar with everything else, so a rotation
|
|
1200
|
+
// that never managed to record its time has nothing left to
|
|
1201
|
+
// reconcile: the file that follows is new, not rotated.
|
|
1202
|
+
pendingFileStart = null
|
|
1203
|
+
// Deletion succeeded whether or not a fresh file could be opened,
|
|
1204
|
+
// and `durable` describes the deletion — that is what a compliance
|
|
1205
|
+
// caller asked about. Whether the writer is usable again is a
|
|
1206
|
+
// separate fact, reported separately, because a handle that rebinds
|
|
1207
|
+
// onto a writer with no stream would accept records and lose them.
|
|
1208
|
+
//
|
|
1209
|
+
// A purge that lands after the close barrier still deletes — that
|
|
1210
|
+
// is the whole point of the call — but it must not reopen.
|
|
1211
|
+
outcome = LogClearOutcome(
|
|
1212
|
+
deletedCount = deleted,
|
|
1213
|
+
failedPaths = emptyList(),
|
|
1214
|
+
durable = true,
|
|
1215
|
+
rebound = if (terminated) false else reopen()
|
|
1216
|
+
)
|
|
1217
|
+
} finally {
|
|
1218
|
+
done.countDown()
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
} catch (_: Exception) {
|
|
1222
|
+
return LogClearOutcome(0, listOf(file.absolutePath), durable = false) to fenced
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
val completed = try {
|
|
1226
|
+
done.await(remaining(expiry), TimeUnit.MILLISECONDS)
|
|
1227
|
+
} catch (_: InterruptedException) {
|
|
1228
|
+
// An interrupted wait establishes nothing about the deletion, and this
|
|
1229
|
+
// call must never claim durability it did not observe.
|
|
1230
|
+
Thread.currentThread().interrupt()
|
|
1231
|
+
false
|
|
1232
|
+
}
|
|
1233
|
+
if (!completed) {
|
|
1234
|
+
return LogClearOutcome(0, listOf(file.absolutePath), durable = false) to fenced
|
|
1235
|
+
}
|
|
1236
|
+
return outcome to fenced
|
|
1237
|
+
} finally {
|
|
1238
|
+
purgeLock.unlock()
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** The generation a handle must rebind to after a durable purge. */
|
|
1243
|
+
val currentGeneration: Long get() = stateLock.withLock { generation }
|
|
1244
|
+
|
|
1245
|
+
val isClosed: Boolean get() = stateLock.withLock { closed }
|
|
1246
|
+
|
|
1247
|
+
// MARK: - Test support
|
|
1248
|
+
|
|
1249
|
+
private fun onExecutor(block: () -> Unit) {
|
|
1250
|
+
if (Thread.currentThread() === writerThread) {
|
|
1251
|
+
block()
|
|
1252
|
+
return
|
|
1253
|
+
}
|
|
1254
|
+
val done = CountDownLatch(1)
|
|
1255
|
+
executor.execute {
|
|
1256
|
+
try {
|
|
1257
|
+
block()
|
|
1258
|
+
} finally {
|
|
1259
|
+
done.countDown()
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
try {
|
|
1263
|
+
done.await()
|
|
1264
|
+
} catch (_: InterruptedException) {
|
|
1265
|
+
Thread.currentThread().interrupt()
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Runs [block] on the executor and waits at most [budgetMs] for it.
|
|
1271
|
+
*
|
|
1272
|
+
* Returns whether it actually completed — false for a timeout and false for a
|
|
1273
|
+
* shut-down executor, both of which mean the caller must not read whatever
|
|
1274
|
+
* [block] was going to produce. A successful `await` is also the happens-before
|
|
1275
|
+
* edge that makes what [block] wrote visible here.
|
|
1276
|
+
*/
|
|
1277
|
+
private fun onExecutorBounded(budgetMs: Long, block: () -> Unit): Boolean {
|
|
1278
|
+
if (Thread.currentThread() === writerThread) {
|
|
1279
|
+
block()
|
|
1280
|
+
return true
|
|
1281
|
+
}
|
|
1282
|
+
val done = CountDownLatch(1)
|
|
1283
|
+
return try {
|
|
1284
|
+
executor.execute {
|
|
1285
|
+
try {
|
|
1286
|
+
block()
|
|
1287
|
+
} finally {
|
|
1288
|
+
done.countDown()
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
done.await(budgetMs, TimeUnit.MILLISECONDS)
|
|
1292
|
+
} catch (_: RejectedExecutionException) {
|
|
1293
|
+
false
|
|
1294
|
+
} catch (_: InterruptedException) {
|
|
1295
|
+
// "Did it complete" is the question, and an interrupted wait never found
|
|
1296
|
+
// out — so the caller takes its fallback. The flag goes back on: swallowing
|
|
1297
|
+
// an interrupt is how a thread stops being cancellable.
|
|
1298
|
+
Thread.currentThread().interrupt()
|
|
1299
|
+
false
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/** Blocks until everything already enqueued has run. */
|
|
1304
|
+
fun settleForTesting() = onExecutor {}
|
|
1305
|
+
|
|
1306
|
+
/** Closes the stream so the next write fails, standing in for revoked storage. */
|
|
1307
|
+
fun closeStreamForTesting() = onExecutor { closeCurrentStream() }
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* Shuts the executor down without going through [close], standing in for the
|
|
1311
|
+
* window where a concurrent close has torn it down but `closed` is not yet
|
|
1312
|
+
* visible to a batch already past the acceptance check.
|
|
1313
|
+
*/
|
|
1314
|
+
fun shutdownExecutorForTesting() {
|
|
1315
|
+
executor.shutdownNow()
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Blocks the executor until the returned lambda is called. */
|
|
1319
|
+
fun stallForTesting(): () -> Unit {
|
|
1320
|
+
val gate = CountDownLatch(1)
|
|
1321
|
+
executor.execute { gate.await() }
|
|
1322
|
+
return { gate.countDown() }
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
val trackedFileSizeForTesting: Long get() { settleForTesting(); return currentFileSize }
|
|
1326
|
+
val hasLiveStreamForTesting: Boolean get() { settleForTesting(); return stream != null }
|
|
1327
|
+
val rotationAttemptsForTesting: Int get() { settleForTesting(); return rotationAttempts }
|
|
1328
|
+
}
|