@logtape/file 1.0.0-dev.237 → 1.0.0-dev.241

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/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
  */
@@ -61,6 +312,14 @@ export interface FileSinkDriver<TFile> {
61
312
  */
62
313
  writeSync(fd: TFile, chunk: Uint8Array): void;
63
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
+
64
323
  /**
65
324
  * Flush the file to ensure that all data is written to the disk.
66
325
  * @param fd The file descriptor.
@@ -80,6 +339,14 @@ export interface FileSinkDriver<TFile> {
80
339
  * @since 1.0.0
81
340
  */
82
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
+
83
350
  /**
84
351
  * Asynchronously flush the file to ensure that all data is written to the disk.
85
352
  * @param fd The file descriptor.
@@ -119,35 +386,82 @@ export function getBaseFileSink<TFile>(
119
386
  ): Sink & (Disposable | AsyncDisposable) {
120
387
  const formatter = options.formatter ?? defaultTextFormatter;
121
388
  const encoder = options.encoder ?? new TextEncoder();
122
- 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
123
390
  const flushInterval = options.flushInterval ?? 5000; // Default flush interval of 5 seconds
124
391
  let fd = options.lazy ? null : options.openSync(path);
125
- 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);
126
397
  let lastFlushTimestamp: number = Date.now();
127
398
 
128
399
  if (!options.nonBlocking) {
129
400
  // Blocking mode implementation
130
401
  // deno-lint-ignore no-inner-declarations
131
402
  function flushBuffer(): void {
132
- if (fd == null) return;
133
- if (buffer.length > 0) {
134
- options.writeSync(fd, encoder.encode(buffer));
135
- buffer = "";
136
- options.flushSync(fd);
137
- lastFlushTimestamp = Date.now();
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
+ }
138
418
  }
419
+ options.flushSync(fd);
420
+
421
+ // Record flush for adaptive strategy
422
+ adaptiveStrategy.recordFlush(flushSize, timeSinceLastFlush);
423
+ lastFlushTimestamp = currentTime;
139
424
  }
140
425
 
141
426
  const sink: Sink & Disposable = (record: LogRecord) => {
142
427
  if (fd == null) fd = options.openSync(path);
143
- buffer += formatter(record);
144
428
 
145
- const shouldFlushBySize = buffer.length >= bufferSize;
146
- const shouldFlushByTime = flushInterval > 0 &&
147
- (record.timestamp - lastFlushTimestamp) >= flushInterval;
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
+ }
148
444
 
149
- if (shouldFlushBySize || shouldFlushByTime) {
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
150
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
+ }
151
465
  }
152
466
  };
153
467
  sink[Symbol.dispose] = () => {
@@ -155,6 +469,8 @@ export function getBaseFileSink<TFile>(
155
469
  flushBuffer();
156
470
  options.closeSync(fd);
157
471
  }
472
+ // Clean up buffer pool
473
+ bufferPool.clear();
158
474
  };
159
475
  return sink;
160
476
  }
@@ -166,14 +482,28 @@ export function getBaseFileSink<TFile>(
166
482
  let flushTimer: ReturnType<typeof setInterval> | null = null;
167
483
 
168
484
  async function flushBuffer(): Promise<void> {
169
- if (fd == null || buffer.length === 0) return;
485
+ if (fd == null || byteBuffer.isEmpty()) return;
170
486
 
171
- const data = buffer;
172
- buffer = "";
487
+ const flushSize = byteBuffer.size();
488
+ const currentTime = Date.now();
489
+ const timeSinceLastFlush = currentTime - lastFlushTimestamp;
490
+
491
+ const chunks = byteBuffer.flush();
173
492
  try {
174
- asyncOptions.writeSync(fd, encoder.encode(data));
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
+ }
175
502
  await asyncOptions.flush(fd);
176
- lastFlushTimestamp = Date.now();
503
+
504
+ // Record flush for adaptive strategy
505
+ adaptiveStrategy.recordFlush(flushSize, timeSinceLastFlush);
506
+ lastFlushTimestamp = currentTime;
177
507
  } catch {
178
508
  // Silently ignore errors in non-blocking mode
179
509
  }
@@ -198,16 +528,45 @@ export function getBaseFileSink<TFile>(
198
528
  const nonBlockingSink: Sink & AsyncDisposable = (record: LogRecord) => {
199
529
  if (disposed) return;
200
530
  if (fd == null) fd = asyncOptions.openSync(path);
201
- buffer += formatter(record);
202
531
 
203
- const shouldFlushBySize = buffer.length >= bufferSize;
204
- const shouldFlushByTime = flushInterval > 0 &&
205
- (record.timestamp - lastFlushTimestamp) >= flushInterval;
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
+ }
206
547
 
207
- if (shouldFlushBySize || shouldFlushByTime) {
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
208
556
  scheduleFlush();
209
- } else if (flushTimer === null && flushInterval > 0) {
210
- startFlushTimer();
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
+ }
211
570
  }
212
571
  };
213
572
 
@@ -225,6 +584,8 @@ export function getBaseFileSink<TFile>(
225
584
  // Writer might already be closed or errored
226
585
  }
227
586
  }
587
+ // Clean up buffer pool
588
+ bufferPool.clear();
228
589
  };
229
590
 
230
591
  return nonBlockingSink;
package/filesink.deno.ts CHANGED
@@ -18,6 +18,13 @@ export const denoDriver: RotatingFileSinkDriver<Deno.FsFile> = {
18
18
  writeSync(fd, chunk) {
19
19
  fd.writeSync(chunk);
20
20
  },
21
+ writeManySync(fd: Deno.FsFile, chunks: Uint8Array[]): void {
22
+ // Deno doesn't have writev, but we can optimize by writing all chunks
23
+ // then doing a single sync operation
24
+ for (const chunk of chunks) {
25
+ fd.writeSync(chunk);
26
+ }
27
+ },
21
28
  flushSync(fd) {
22
29
  fd.syncSync();
23
30
  },
@@ -34,6 +41,13 @@ export const denoDriver: RotatingFileSinkDriver<Deno.FsFile> = {
34
41
  */
35
42
  export const denoAsyncDriver: AsyncRotatingFileSinkDriver<Deno.FsFile> = {
36
43
  ...denoDriver,
44
+ async writeMany(fd: Deno.FsFile, chunks: Uint8Array[]): Promise<void> {
45
+ // Deno doesn't have async writev, but we can write all chunks
46
+ // then do a single async sync
47
+ for (const chunk of chunks) {
48
+ await fd.write(chunk);
49
+ }
50
+ },
37
51
  async flush(fd) {
38
52
  await fd.sync();
39
53
  },
package/filesink.node.ts CHANGED
@@ -18,6 +18,15 @@ export const nodeDriver: RotatingFileSinkDriver<number | void> = {
18
18
  return fs.openSync(path, "a");
19
19
  },
20
20
  writeSync: fs.writeSync,
21
+ writeManySync(fd: number, chunks: Uint8Array[]): void {
22
+ if (chunks.length === 0) return;
23
+ if (chunks.length === 1) {
24
+ fs.writeSync(fd, chunks[0]);
25
+ return;
26
+ }
27
+ // Use writev for multiple chunks
28
+ fs.writevSync(fd, chunks);
29
+ },
21
30
  flushSync: fs.fsyncSync,
22
31
  closeSync: fs.closeSync,
23
32
  statSync: fs.statSync,
@@ -30,6 +39,15 @@ export const nodeDriver: RotatingFileSinkDriver<number | void> = {
30
39
  */
31
40
  export const nodeAsyncDriver: AsyncRotatingFileSinkDriver<number | void> = {
32
41
  ...nodeDriver,
42
+ async writeMany(fd: number, chunks: Uint8Array[]): Promise<void> {
43
+ if (chunks.length === 0) return;
44
+ if (chunks.length === 1) {
45
+ await promisify(fs.write)(fd, chunks[0]);
46
+ return;
47
+ }
48
+ // Use async writev for multiple chunks
49
+ await promisify(fs.writev)(fd, chunks);
50
+ },
33
51
  flush: promisify(fs.fsync),
34
52
  close: promisify(fs.close),
35
53
  };
package/mod.ts CHANGED
@@ -4,4 +4,6 @@ export type {
4
4
  RotatingFileSinkDriver,
5
5
  RotatingFileSinkOptions,
6
6
  } from "./filesink.base.ts";
7
+ export type { StreamFileSinkOptions } from "./streamfilesink.ts";
7
8
  export { getFileSink, getRotatingFileSink } from "#filesink";
9
+ export { getStreamFileSink } from "./streamfilesink.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logtape/file",
3
- "version": "1.0.0-dev.237+0615301b",
3
+ "version": "1.0.0-dev.241+13810dcf",
4
4
  "description": "File sink and rotating file sink for LogTape",
5
5
  "keywords": [
6
6
  "logging",
@@ -50,7 +50,7 @@
50
50
  }
51
51
  },
52
52
  "peerDependencies": {
53
- "@logtape/logtape": "1.0.0-dev.237+0615301b"
53
+ "@logtape/logtape": "1.0.0-dev.241+13810dcf"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@alinea/suite": "^0.6.3",