@logtape/file 1.0.0-dev.246 → 1.0.0-dev.248

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.
Files changed (43) hide show
  1. package/deno.json +1 -1
  2. package/dist/filesink.base.cjs +347 -52
  3. package/dist/filesink.base.d.cts +65 -3
  4. package/dist/filesink.base.d.cts.map +1 -1
  5. package/dist/filesink.base.d.ts +65 -3
  6. package/dist/filesink.base.d.ts.map +1 -1
  7. package/dist/filesink.base.js +347 -52
  8. package/dist/filesink.base.js.map +1 -1
  9. package/dist/filesink.deno.cjs +26 -23
  10. package/dist/filesink.deno.d.cts +17 -4
  11. package/dist/filesink.deno.d.cts.map +1 -1
  12. package/dist/filesink.deno.d.ts +17 -4
  13. package/dist/filesink.deno.d.ts.map +1 -1
  14. package/dist/filesink.deno.js +26 -24
  15. package/dist/filesink.deno.js.map +1 -1
  16. package/dist/filesink.node.cjs +33 -23
  17. package/dist/filesink.node.d.cts +17 -4
  18. package/dist/filesink.node.d.cts.map +1 -1
  19. package/dist/filesink.node.d.ts +17 -4
  20. package/dist/filesink.node.d.ts.map +1 -1
  21. package/dist/filesink.node.js +33 -24
  22. package/dist/filesink.node.js.map +1 -1
  23. package/dist/mod.cjs +3 -1
  24. package/dist/mod.d.cts +2 -1
  25. package/dist/mod.d.ts +2 -1
  26. package/dist/mod.js +2 -1
  27. package/dist/streamfilesink.cjs +84 -0
  28. package/dist/streamfilesink.d.cts +95 -0
  29. package/dist/streamfilesink.d.cts.map +1 -0
  30. package/dist/streamfilesink.d.ts +95 -0
  31. package/dist/streamfilesink.d.ts.map +1 -0
  32. package/dist/streamfilesink.js +84 -0
  33. package/dist/streamfilesink.js.map +1 -0
  34. package/filesink.base.ts +618 -37
  35. package/filesink.deno.ts +57 -4
  36. package/filesink.jsr.ts +32 -7
  37. package/filesink.node.ts +58 -4
  38. package/filesink.test.ts +120 -0
  39. package/mod.ts +2 -0
  40. package/package.json +4 -2
  41. package/streamfilesink.test.ts +336 -0
  42. package/streamfilesink.ts +136 -0
  43. package/tsdown.config.ts +2 -2
package/filesink.base.ts CHANGED
@@ -5,6 +5,257 @@ import {
5
5
  type StreamSinkOptions,
6
6
  } from "@logtape/logtape";
7
7
 
8
+ /**
9
+ * Adaptive flush strategy that dynamically adjusts buffer thresholds
10
+ * based on recent flush patterns for optimal performance.
11
+ */
12
+ class AdaptiveFlushStrategy {
13
+ private recentFlushSizes: number[] = [];
14
+ private recentFlushTimes: number[] = [];
15
+ private avgFlushSize: number;
16
+ private avgFlushInterval: number;
17
+ private readonly maxHistorySize = 10;
18
+ private readonly baseThreshold: number;
19
+
20
+ constructor(baseThreshold: number, baseInterval: number) {
21
+ this.baseThreshold = baseThreshold;
22
+ this.avgFlushSize = baseThreshold;
23
+ this.avgFlushInterval = baseInterval;
24
+ }
25
+
26
+ /**
27
+ * Record a flush event for pattern analysis.
28
+ * @param size The size of data flushed in bytes.
29
+ * @param timeSinceLastFlush Time since last flush in milliseconds.
30
+ */
31
+ recordFlush(size: number, timeSinceLastFlush: number): void {
32
+ this.recentFlushSizes.push(size);
33
+ this.recentFlushTimes.push(timeSinceLastFlush);
34
+
35
+ // Keep only recent history
36
+ if (this.recentFlushSizes.length > this.maxHistorySize) {
37
+ this.recentFlushSizes.shift();
38
+ this.recentFlushTimes.shift();
39
+ }
40
+
41
+ // Update averages
42
+ this.updateAverages();
43
+ }
44
+
45
+ /**
46
+ * Determine if buffer should be flushed based on adaptive strategy.
47
+ * @param currentSize Current buffer size in bytes.
48
+ * @param timeSinceLastFlush Time since last flush in milliseconds.
49
+ * @returns True if buffer should be flushed.
50
+ */
51
+ shouldFlush(currentSize: number, timeSinceLastFlush: number): boolean {
52
+ const adaptiveThreshold = this.calculateAdaptiveThreshold();
53
+ const adaptiveInterval = this.calculateAdaptiveInterval();
54
+
55
+ return currentSize >= adaptiveThreshold ||
56
+ (adaptiveInterval > 0 && timeSinceLastFlush >= adaptiveInterval);
57
+ }
58
+
59
+ private updateAverages(): void {
60
+ if (this.recentFlushSizes.length === 0) return;
61
+
62
+ this.avgFlushSize =
63
+ this.recentFlushSizes.reduce((sum, size) => sum + size, 0) /
64
+ this.recentFlushSizes.length;
65
+
66
+ this.avgFlushInterval =
67
+ this.recentFlushTimes.reduce((sum, time) => sum + time, 0) /
68
+ this.recentFlushTimes.length;
69
+ }
70
+
71
+ private calculateAdaptiveThreshold(): number {
72
+ // Adjust threshold based on recent patterns
73
+ // Higher average flush sizes suggest larger batches are beneficial
74
+ const adaptiveFactor = Math.min(
75
+ 2.0,
76
+ Math.max(0.5, this.avgFlushSize / this.baseThreshold),
77
+ );
78
+
79
+ return Math.max(
80
+ Math.min(4096, this.baseThreshold / 2),
81
+ Math.min(64 * 1024, this.baseThreshold * adaptiveFactor),
82
+ );
83
+ }
84
+
85
+ private calculateAdaptiveInterval(): number {
86
+ // If base interval is 0, time-based flushing is disabled
87
+ if (this.avgFlushInterval <= 0) return 0;
88
+
89
+ // Adjust interval based on recent flush frequency
90
+ // More frequent flushes suggest lower latency is preferred
91
+ if (this.recentFlushTimes.length < 3) return this.avgFlushInterval;
92
+
93
+ const variance = this.calculateVariance(this.recentFlushTimes);
94
+ const stabilityFactor = Math.min(2.0, Math.max(0.5, 1000 / variance));
95
+
96
+ return Math.max(
97
+ 1000,
98
+ Math.min(10000, this.avgFlushInterval * stabilityFactor),
99
+ );
100
+ }
101
+
102
+ private calculateVariance(values: number[]): number {
103
+ if (values.length < 2) return 1000; // Default variance
104
+
105
+ const mean = values.reduce((sum, val) => sum + val, 0) / values.length;
106
+ const squaredDiffs = values.map((val) => Math.pow(val - mean, 2));
107
+ return squaredDiffs.reduce((sum, diff) => sum + diff, 0) / values.length;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Memory pool for reusing Uint8Array buffers to minimize GC pressure.
113
+ * Maintains a pool of pre-allocated buffers for efficient reuse.
114
+ */
115
+ class BufferPool {
116
+ private pool: Uint8Array[] = [];
117
+ private readonly maxPoolSize = 50; // Keep a reasonable pool size
118
+ private readonly maxBufferSize = 64 * 1024; // Don't pool very large buffers
119
+
120
+ /**
121
+ * Acquire a buffer from the pool or create a new one.
122
+ * @param size The minimum size needed for the buffer.
123
+ * @returns A Uint8Array that can be used for encoding.
124
+ */
125
+ acquire(size: number): Uint8Array {
126
+ // Don't pool very large buffers to avoid memory waste
127
+ if (size > this.maxBufferSize) {
128
+ return new Uint8Array(size);
129
+ }
130
+
131
+ // Try to find a suitable buffer from the pool
132
+ for (let i = this.pool.length - 1; i >= 0; i--) {
133
+ const buffer = this.pool[i];
134
+ if (buffer.length >= size) {
135
+ // Remove from pool and return
136
+ this.pool.splice(i, 1);
137
+ return buffer.subarray(0, size);
138
+ }
139
+ }
140
+
141
+ // No suitable buffer found, create a new one
142
+ // Create slightly larger buffer to improve reuse chances
143
+ const actualSize = Math.max(size, 1024); // Minimum 1KB
144
+ return new Uint8Array(actualSize);
145
+ }
146
+
147
+ /**
148
+ * Return a buffer to the pool for future reuse.
149
+ * @param buffer The buffer to return to the pool.
150
+ */
151
+ release(buffer: Uint8Array): void {
152
+ // Don't pool if we're at capacity or buffer is too large
153
+ if (
154
+ this.pool.length >= this.maxPoolSize || buffer.length > this.maxBufferSize
155
+ ) {
156
+ return;
157
+ }
158
+
159
+ // Don't pool very small buffers as they're cheap to allocate
160
+ if (buffer.length < 256) {
161
+ return;
162
+ }
163
+
164
+ // Add to pool for reuse
165
+ this.pool.push(buffer);
166
+ }
167
+
168
+ /**
169
+ * Clear the pool to free memory. Useful for cleanup.
170
+ */
171
+ clear(): void {
172
+ this.pool.length = 0;
173
+ }
174
+
175
+ /**
176
+ * Get current pool statistics for monitoring.
177
+ * @returns Object with pool size and buffer count.
178
+ */
179
+ getStats(): { poolSize: number; totalBuffers: number } {
180
+ return {
181
+ poolSize: this.pool.reduce((sum, buf) => sum + buf.length, 0),
182
+ totalBuffers: this.pool.length,
183
+ };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * High-performance byte buffer for batching log records.
189
+ * Eliminates string concatenation overhead by storing pre-encoded bytes.
190
+ * Uses memory pooling to reduce GC pressure.
191
+ */
192
+ class ByteRingBuffer {
193
+ private buffers: Uint8Array[] = [];
194
+ private totalSize: number = 0;
195
+ private bufferPool: BufferPool;
196
+
197
+ constructor(bufferPool: BufferPool) {
198
+ this.bufferPool = bufferPool;
199
+ }
200
+
201
+ /**
202
+ * Append pre-encoded log record bytes to the buffer.
203
+ * @param data The encoded log record as bytes.
204
+ */
205
+ append(data: Uint8Array): void {
206
+ this.buffers.push(data);
207
+ this.totalSize += data.length;
208
+ }
209
+
210
+ /**
211
+ * Get the current total size of buffered data in bytes.
212
+ * @returns The total size in bytes.
213
+ */
214
+ size(): number {
215
+ return this.totalSize;
216
+ }
217
+
218
+ /**
219
+ * Get the number of buffered records.
220
+ * @returns The number of records in the buffer.
221
+ */
222
+ count(): number {
223
+ return this.buffers.length;
224
+ }
225
+
226
+ /**
227
+ * Flush all buffered data and return it as an array of byte arrays.
228
+ * This clears the internal buffer and returns used buffers to the pool.
229
+ * @returns Array of buffered byte arrays ready for writev() operations.
230
+ */
231
+ flush(): Uint8Array[] {
232
+ const result = [...this.buffers];
233
+ this.clear();
234
+ return result;
235
+ }
236
+
237
+ /**
238
+ * Clear the buffer without returning data.
239
+ * Returns buffers to the pool for reuse.
240
+ */
241
+ clear(): void {
242
+ // Return buffers to pool for reuse
243
+ for (const buffer of this.buffers) {
244
+ this.bufferPool.release(buffer);
245
+ }
246
+ this.buffers.length = 0;
247
+ this.totalSize = 0;
248
+ }
249
+
250
+ /**
251
+ * Check if the buffer is empty.
252
+ * @returns True if the buffer contains no data.
253
+ */
254
+ isEmpty(): boolean {
255
+ return this.buffers.length === 0;
256
+ }
257
+ }
258
+
8
259
  /**
9
260
  * Options for the {@link getBaseFileSink} function.
10
261
  */
@@ -31,6 +282,16 @@ export type FileSinkOptions = StreamSinkOptions & {
31
282
  * @since 0.12.0
32
283
  */
33
284
  flushInterval?: number;
285
+
286
+ /**
287
+ * Enable non-blocking mode with background flushing.
288
+ * When enabled, flush operations are performed asynchronously to prevent
289
+ * blocking the main thread during file I/O operations.
290
+ *
291
+ * @default `false`
292
+ * @since 1.0.0
293
+ */
294
+ nonBlocking?: boolean;
34
295
  };
35
296
 
36
297
  /**
@@ -51,6 +312,14 @@ export interface FileSinkDriver<TFile> {
51
312
  */
52
313
  writeSync(fd: TFile, chunk: Uint8Array): void;
53
314
 
315
+ /**
316
+ * Write multiple chunks of data to the file in a single operation.
317
+ * This is optional - if not implemented, falls back to multiple writeSync calls.
318
+ * @param fd The file descriptor.
319
+ * @param chunks Array of data chunks to write.
320
+ */
321
+ writeManySync?(fd: TFile, chunks: Uint8Array[]): void;
322
+
54
323
  /**
55
324
  * Flush the file to ensure that all data is written to the disk.
56
325
  * @param fd The file descriptor.
@@ -64,6 +333,33 @@ export interface FileSinkDriver<TFile> {
64
333
  closeSync(fd: TFile): void;
65
334
  }
66
335
 
336
+ /**
337
+ * A platform-specific async file sink driver.
338
+ * @typeParam TFile The type of the file descriptor.
339
+ * @since 1.0.0
340
+ */
341
+ export interface AsyncFileSinkDriver<TFile> extends FileSinkDriver<TFile> {
342
+ /**
343
+ * Asynchronously write multiple chunks of data to the file in a single operation.
344
+ * This is optional - if not implemented, falls back to multiple writeSync calls.
345
+ * @param fd The file descriptor.
346
+ * @param chunks Array of data chunks to write.
347
+ */
348
+ writeMany?(fd: TFile, chunks: Uint8Array[]): Promise<void>;
349
+
350
+ /**
351
+ * Asynchronously flush the file to ensure that all data is written to the disk.
352
+ * @param fd The file descriptor.
353
+ */
354
+ flush(fd: TFile): Promise<void>;
355
+
356
+ /**
357
+ * Asynchronously close the file.
358
+ * @param fd The file descriptor.
359
+ */
360
+ close(fd: TFile): Promise<void>;
361
+ }
362
+
67
363
  /**
68
364
  * Get a platform-independent file sink.
69
365
  *
@@ -71,49 +367,228 @@ export interface FileSinkDriver<TFile> {
71
367
  * @param path A path to the file to write to.
72
368
  * @param options The options for the sink and the file driver.
73
369
  * @returns A sink that writes to the file. The sink is also a disposable
74
- * object that closes the file when disposed.
370
+ * object that closes the file when disposed. If `nonBlocking` is enabled,
371
+ * returns a sink that also implements {@link AsyncDisposable}.
75
372
  */
76
373
  export function getBaseFileSink<TFile>(
77
374
  path: string,
78
375
  options: FileSinkOptions & FileSinkDriver<TFile>,
79
- ): Sink & Disposable {
376
+ ): Sink & Disposable;
377
+ export function getBaseFileSink<TFile>(
378
+ path: string,
379
+ options: FileSinkOptions & AsyncFileSinkDriver<TFile>,
380
+ ): Sink & AsyncDisposable;
381
+ export function getBaseFileSink<TFile>(
382
+ path: string,
383
+ options:
384
+ & FileSinkOptions
385
+ & (FileSinkDriver<TFile> | AsyncFileSinkDriver<TFile>),
386
+ ): Sink & (Disposable | AsyncDisposable) {
80
387
  const formatter = options.formatter ?? defaultTextFormatter;
81
388
  const encoder = options.encoder ?? new TextEncoder();
82
- const bufferSize = options.bufferSize ?? 1024 * 8; // Default buffer size of 8192 chars
389
+ const bufferSize = options.bufferSize ?? 1024 * 8; // Default buffer size of 8192 bytes
83
390
  const flushInterval = options.flushInterval ?? 5000; // Default flush interval of 5 seconds
84
391
  let fd = options.lazy ? null : options.openSync(path);
85
- let buffer: string = "";
392
+
393
+ // Initialize memory pool and buffer systems
394
+ const bufferPool = new BufferPool();
395
+ const byteBuffer = new ByteRingBuffer(bufferPool);
396
+ const adaptiveStrategy = new AdaptiveFlushStrategy(bufferSize, flushInterval);
86
397
  let lastFlushTimestamp: number = Date.now();
87
398
 
88
- function flushBuffer(): void {
89
- if (fd == null) return;
90
- if (buffer.length > 0) {
91
- options.writeSync(fd, encoder.encode(buffer));
92
- buffer = "";
399
+ if (!options.nonBlocking) {
400
+ // Blocking mode implementation
401
+ // deno-lint-ignore no-inner-declarations
402
+ function flushBuffer(): void {
403
+ if (fd == null || byteBuffer.isEmpty()) return;
404
+
405
+ const flushSize = byteBuffer.size();
406
+ const currentTime = Date.now();
407
+ const timeSinceLastFlush = currentTime - lastFlushTimestamp;
408
+
409
+ const chunks = byteBuffer.flush();
410
+ if (options.writeManySync && chunks.length > 1) {
411
+ // Use batch write if available
412
+ options.writeManySync(fd, chunks);
413
+ } else {
414
+ // Fallback to individual writes
415
+ for (const chunk of chunks) {
416
+ options.writeSync(fd, chunk);
417
+ }
418
+ }
93
419
  options.flushSync(fd);
94
- lastFlushTimestamp = Date.now();
420
+
421
+ // Record flush for adaptive strategy
422
+ adaptiveStrategy.recordFlush(flushSize, timeSinceLastFlush);
423
+ lastFlushTimestamp = currentTime;
95
424
  }
425
+
426
+ const sink: Sink & Disposable = (record: LogRecord) => {
427
+ if (fd == null) fd = options.openSync(path);
428
+
429
+ // ULTRA FAST PATH: Direct write when buffer is empty and using default buffer settings
430
+ if (byteBuffer.isEmpty() && bufferSize === 8192) {
431
+ // Inline everything for maximum speed - avoid all function calls
432
+ const formattedRecord = formatter(record);
433
+ const encodedRecord = encoder.encode(formattedRecord);
434
+
435
+ // Only use fast path for typical log sizes to avoid breaking edge cases
436
+ if (encodedRecord.length < 200) {
437
+ // Write directly for small logs - no complex buffering logic
438
+ options.writeSync(fd, encodedRecord);
439
+ options.flushSync(fd);
440
+ lastFlushTimestamp = Date.now();
441
+ return;
442
+ }
443
+ }
444
+
445
+ // STANDARD PATH: Complex logic for edge cases
446
+ const formattedRecord = formatter(record);
447
+ const encodedRecord = encoder.encode(formattedRecord);
448
+ byteBuffer.append(encodedRecord);
449
+
450
+ // Check for immediate flush conditions
451
+ if (bufferSize <= 0) {
452
+ // No buffering - flush immediately
453
+ flushBuffer();
454
+ } else {
455
+ // Use adaptive strategy for intelligent flushing
456
+ const timeSinceLastFlush = record.timestamp - lastFlushTimestamp;
457
+ const shouldFlush = adaptiveStrategy.shouldFlush(
458
+ byteBuffer.size(),
459
+ timeSinceLastFlush,
460
+ );
461
+
462
+ if (shouldFlush) {
463
+ flushBuffer();
464
+ }
465
+ }
466
+ };
467
+ sink[Symbol.dispose] = () => {
468
+ if (fd !== null) {
469
+ flushBuffer();
470
+ options.closeSync(fd);
471
+ }
472
+ // Clean up buffer pool
473
+ bufferPool.clear();
474
+ };
475
+ return sink;
96
476
  }
97
477
 
98
- const sink: Sink & Disposable = (record: LogRecord) => {
99
- if (fd == null) fd = options.openSync(path);
100
- buffer += formatter(record);
478
+ // Non-blocking mode implementation
479
+ const asyncOptions = options as AsyncFileSinkDriver<TFile>;
480
+ let disposed = false;
481
+ let activeFlush: Promise<void> | null = null;
482
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
101
483
 
102
- const shouldFlushBySize = buffer.length >= bufferSize;
103
- const shouldFlushByTime = flushInterval > 0 &&
104
- (record.timestamp - lastFlushTimestamp) >= flushInterval;
484
+ async function flushBuffer(): Promise<void> {
485
+ if (fd == null || byteBuffer.isEmpty()) return;
105
486
 
106
- if (shouldFlushBySize || shouldFlushByTime) {
107
- flushBuffer();
487
+ const flushSize = byteBuffer.size();
488
+ const currentTime = Date.now();
489
+ const timeSinceLastFlush = currentTime - lastFlushTimestamp;
490
+
491
+ const chunks = byteBuffer.flush();
492
+ try {
493
+ if (asyncOptions.writeMany && chunks.length > 1) {
494
+ // Use async batch write if available
495
+ await asyncOptions.writeMany(fd, chunks);
496
+ } else {
497
+ // Fallback to individual writes
498
+ for (const chunk of chunks) {
499
+ asyncOptions.writeSync(fd, chunk);
500
+ }
501
+ }
502
+ await asyncOptions.flush(fd);
503
+
504
+ // Record flush for adaptive strategy
505
+ adaptiveStrategy.recordFlush(flushSize, timeSinceLastFlush);
506
+ lastFlushTimestamp = currentTime;
507
+ } catch {
508
+ // Silently ignore errors in non-blocking mode
509
+ }
510
+ }
511
+
512
+ function scheduleFlush(): void {
513
+ if (activeFlush || disposed) return;
514
+
515
+ activeFlush = flushBuffer().finally(() => {
516
+ activeFlush = null;
517
+ });
518
+ }
519
+
520
+ function startFlushTimer(): void {
521
+ if (flushTimer !== null || disposed) return;
522
+
523
+ flushTimer = setInterval(() => {
524
+ scheduleFlush();
525
+ }, flushInterval);
526
+ }
527
+
528
+ const nonBlockingSink: Sink & AsyncDisposable = (record: LogRecord) => {
529
+ if (disposed) return;
530
+ if (fd == null) fd = asyncOptions.openSync(path);
531
+
532
+ // ULTRA FAST PATH: Direct write when buffer is empty and using default buffer settings
533
+ if (byteBuffer.isEmpty() && !activeFlush && bufferSize === 8192) {
534
+ // Inline everything for maximum speed - avoid all function calls
535
+ const formattedRecord = formatter(record);
536
+ const encodedRecord = encoder.encode(formattedRecord);
537
+
538
+ // Only use fast path for typical log sizes to avoid breaking edge cases
539
+ if (encodedRecord.length < 200) {
540
+ // Write directly for small logs - no complex buffering logic
541
+ asyncOptions.writeSync(fd, encodedRecord);
542
+ scheduleFlush(); // Async flush
543
+ lastFlushTimestamp = Date.now();
544
+ return;
545
+ }
546
+ }
547
+
548
+ // STANDARD PATH: Complex logic for edge cases
549
+ const formattedRecord = formatter(record);
550
+ const encodedRecord = encoder.encode(formattedRecord);
551
+ byteBuffer.append(encodedRecord);
552
+
553
+ // Check for immediate flush conditions
554
+ if (bufferSize <= 0) {
555
+ // No buffering - flush immediately
556
+ scheduleFlush();
557
+ } else {
558
+ // Use adaptive strategy for intelligent flushing
559
+ const timeSinceLastFlush = record.timestamp - lastFlushTimestamp;
560
+ const shouldFlush = adaptiveStrategy.shouldFlush(
561
+ byteBuffer.size(),
562
+ timeSinceLastFlush,
563
+ );
564
+
565
+ if (shouldFlush) {
566
+ scheduleFlush();
567
+ } else if (flushTimer === null && flushInterval > 0) {
568
+ startFlushTimer();
569
+ }
108
570
  }
109
571
  };
110
- sink[Symbol.dispose] = () => {
572
+
573
+ nonBlockingSink[Symbol.asyncDispose] = async () => {
574
+ disposed = true;
575
+ if (flushTimer !== null) {
576
+ clearInterval(flushTimer);
577
+ flushTimer = null;
578
+ }
579
+ await flushBuffer();
111
580
  if (fd !== null) {
112
- flushBuffer();
113
- options.closeSync(fd);
581
+ try {
582
+ await asyncOptions.close(fd);
583
+ } catch {
584
+ // Writer might already be closed or errored
585
+ }
114
586
  }
587
+ // Clean up buffer pool
588
+ bufferPool.clear();
115
589
  };
116
- return sink;
590
+
591
+ return nonBlockingSink;
117
592
  }
118
593
 
119
594
  /**
@@ -150,6 +625,27 @@ export interface RotatingFileSinkDriver<TFile> extends FileSinkDriver<TFile> {
150
625
  renameSync(oldPath: string, newPath: string): void;
151
626
  }
152
627
 
628
+ /**
629
+ * A platform-specific async rotating file sink driver.
630
+ * @since 1.0.0
631
+ */
632
+ export interface AsyncRotatingFileSinkDriver<TFile>
633
+ extends AsyncFileSinkDriver<TFile> {
634
+ /**
635
+ * Get the size of the file.
636
+ * @param path A path to the file.
637
+ * @returns The `size` of the file in bytes, in an object.
638
+ */
639
+ statSync(path: string): { size: number };
640
+
641
+ /**
642
+ * Rename a file.
643
+ * @param oldPath A path to the file to rename.
644
+ * @param newPath A path to be renamed to.
645
+ */
646
+ renameSync(oldPath: string, newPath: string): void;
647
+ }
648
+
153
649
  /**
154
650
  * Get a platform-independent rotating file sink.
155
651
  *
@@ -161,12 +657,23 @@ export interface RotatingFileSinkDriver<TFile> extends FileSinkDriver<TFile> {
161
657
  * @param path A path to the file to write to.
162
658
  * @param options The options for the sink and the file driver.
163
659
  * @returns A sink that writes to the file. The sink is also a disposable
164
- * object that closes the file when disposed.
660
+ * object that closes the file when disposed. If `nonBlocking` is enabled,
661
+ * returns a sink that also implements {@link AsyncDisposable}.
165
662
  */
166
663
  export function getBaseRotatingFileSink<TFile>(
167
664
  path: string,
168
665
  options: RotatingFileSinkOptions & RotatingFileSinkDriver<TFile>,
169
- ): Sink & Disposable {
666
+ ): Sink & Disposable;
667
+ export function getBaseRotatingFileSink<TFile>(
668
+ path: string,
669
+ options: RotatingFileSinkOptions & AsyncRotatingFileSinkDriver<TFile>,
670
+ ): Sink & AsyncDisposable;
671
+ export function getBaseRotatingFileSink<TFile>(
672
+ path: string,
673
+ options:
674
+ & RotatingFileSinkOptions
675
+ & (RotatingFileSinkDriver<TFile> | AsyncRotatingFileSinkDriver<TFile>),
676
+ ): Sink & (Disposable | AsyncDisposable) {
170
677
  const formatter = options.formatter ?? defaultTextFormatter;
171
678
  const encoder = options.encoder ?? new TextEncoder();
172
679
  const maxSize = options.maxSize ?? 1024 * 1024;
@@ -182,6 +689,7 @@ export function getBaseRotatingFileSink<TFile>(
182
689
  }
183
690
  let fd = options.openSync(path);
184
691
  let lastFlushTimestamp: number = Date.now();
692
+ let buffer: string = "";
185
693
 
186
694
  function shouldRollover(bytes: Uint8Array): boolean {
187
695
  return offset + bytes.length > maxSize;
@@ -202,20 +710,80 @@ export function getBaseRotatingFileSink<TFile>(
202
710
  fd = options.openSync(path);
203
711
  }
204
712
 
205
- function flushBuffer(): void {
206
- if (buffer.length > 0) {
207
- const bytes = encoder.encode(buffer);
208
- buffer = "";
713
+ if (!options.nonBlocking) {
714
+ // Blocking mode implementation
715
+ // deno-lint-ignore no-inner-declarations
716
+ function flushBuffer(): void {
717
+ if (buffer.length > 0) {
718
+ const bytes = encoder.encode(buffer);
719
+ buffer = "";
720
+ if (shouldRollover(bytes)) performRollover();
721
+ options.writeSync(fd, bytes);
722
+ options.flushSync(fd);
723
+ offset += bytes.length;
724
+ lastFlushTimestamp = Date.now();
725
+ }
726
+ }
727
+
728
+ const sink: Sink & Disposable = (record: LogRecord) => {
729
+ buffer += formatter(record);
730
+
731
+ const shouldFlushBySize = buffer.length >= bufferSize;
732
+ const shouldFlushByTime = flushInterval > 0 &&
733
+ (record.timestamp - lastFlushTimestamp) >= flushInterval;
734
+
735
+ if (shouldFlushBySize || shouldFlushByTime) {
736
+ flushBuffer();
737
+ }
738
+ };
739
+ sink[Symbol.dispose] = () => {
740
+ flushBuffer();
741
+ options.closeSync(fd);
742
+ };
743
+ return sink;
744
+ }
745
+
746
+ // Non-blocking mode implementation
747
+ const asyncOptions = options as AsyncRotatingFileSinkDriver<TFile>;
748
+ let disposed = false;
749
+ let activeFlush: Promise<void> | null = null;
750
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
751
+
752
+ async function flushBuffer(): Promise<void> {
753
+ if (buffer.length === 0) return;
754
+
755
+ const data = buffer;
756
+ buffer = "";
757
+ try {
758
+ const bytes = encoder.encode(data);
209
759
  if (shouldRollover(bytes)) performRollover();
210
- options.writeSync(fd, bytes);
211
- options.flushSync(fd);
760
+ asyncOptions.writeSync(fd, bytes);
761
+ await asyncOptions.flush(fd);
212
762
  offset += bytes.length;
213
763
  lastFlushTimestamp = Date.now();
764
+ } catch {
765
+ // Silently ignore errors in non-blocking mode
214
766
  }
215
767
  }
216
768
 
217
- let buffer: string = "";
218
- const sink: Sink & Disposable = (record: LogRecord) => {
769
+ function scheduleFlush(): void {
770
+ if (activeFlush || disposed) return;
771
+
772
+ activeFlush = flushBuffer().finally(() => {
773
+ activeFlush = null;
774
+ });
775
+ }
776
+
777
+ function startFlushTimer(): void {
778
+ if (flushTimer !== null || disposed) return;
779
+
780
+ flushTimer = setInterval(() => {
781
+ scheduleFlush();
782
+ }, flushInterval);
783
+ }
784
+
785
+ const nonBlockingSink: Sink & AsyncDisposable = (record: LogRecord) => {
786
+ if (disposed) return;
219
787
  buffer += formatter(record);
220
788
 
221
789
  const shouldFlushBySize = buffer.length >= bufferSize;
@@ -223,12 +791,25 @@ export function getBaseRotatingFileSink<TFile>(
223
791
  (record.timestamp - lastFlushTimestamp) >= flushInterval;
224
792
 
225
793
  if (shouldFlushBySize || shouldFlushByTime) {
226
- flushBuffer();
794
+ scheduleFlush();
795
+ } else if (flushTimer === null && flushInterval > 0) {
796
+ startFlushTimer();
227
797
  }
228
798
  };
229
- sink[Symbol.dispose] = () => {
230
- flushBuffer();
231
- options.closeSync(fd);
799
+
800
+ nonBlockingSink[Symbol.asyncDispose] = async () => {
801
+ disposed = true;
802
+ if (flushTimer !== null) {
803
+ clearInterval(flushTimer);
804
+ flushTimer = null;
805
+ }
806
+ await flushBuffer();
807
+ try {
808
+ await asyncOptions.close(fd);
809
+ } catch {
810
+ // Writer might already be closed or errored
811
+ }
232
812
  };
233
- return sink;
813
+
814
+ return nonBlockingSink;
234
815
  }