@mhmdhammoud/meritt-utils 1.6.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,42 +33,141 @@ function getIndexName(index, time) {
33
33
  }
34
34
  return index.replace('%{DATE}', time.substring(0, 10));
35
35
  }
36
- function initializeBulkHandler(opts, client, splitter, onFatalError) {
36
+ function createBulkSender(opts, client, splitter) {
37
37
  var _a, _b, _c, _d, _e, _f, _g;
38
38
  const esVersion = Number((_b = (_a = opts.esVersion) !== null && _a !== void 0 ? _a : opts['es-version']) !== null && _b !== void 0 ? _b : 7);
39
39
  const index = (_c = opts.index) !== null && _c !== void 0 ? _c : 'pino';
40
40
  const buildIndexName = typeof index === 'function' ? index : null;
41
41
  const opType = esVersion >= 7 ? undefined : undefined;
42
+ const flushBytes = (_e = (_d = opts.flushBytes) !== null && _d !== void 0 ? _d : opts['flush-bytes']) !== null && _e !== void 0 ? _e : 1000;
43
+ const flushInterval = (_g = (_f = opts.flushInterval) !== null && _f !== void 0 ? _f : opts['flush-interval']) !== null && _g !== void 0 ? _g : 3000;
44
+ let buffer = [];
45
+ let bufferedBytes = 0;
46
+ let timer;
47
+ let isFlushing = false;
48
+ let flushAgain = false;
42
49
  const indexName = (time = new Date().toISOString()) => buildIndexName ? buildIndexName(time) : getIndexName(index, time);
43
- const bulkInsert = client.helpers.bulk({
44
- datasource: splitter,
45
- flushBytes: (_e = (_d = opts.flushBytes) !== null && _d !== void 0 ? _d : opts['flush-bytes']) !== null && _e !== void 0 ? _e : 1000,
46
- flushInterval: (_g = (_f = opts.flushInterval) !== null && _f !== void 0 ? _f : opts['flush-interval']) !== null && _g !== void 0 ? _g : 3000,
47
- refreshOnCompletion: false,
48
- onDocument(doc) {
49
- var _a, _b;
50
+ const clearFlushTimer = () => {
51
+ if (timer) {
52
+ clearTimeout(timer);
53
+ timer = undefined;
54
+ }
55
+ };
56
+ const scheduleFlush = () => {
57
+ var _a;
58
+ if (timer || buffer.length === 0) {
59
+ return;
60
+ }
61
+ timer = setTimeout(() => {
62
+ timer = undefined;
63
+ void flush();
64
+ }, flushInterval);
65
+ (_a = timer.unref) === null || _a === void 0 ? void 0 : _a.call(timer);
66
+ };
67
+ const buildOperation = (doc) => {
68
+ var _a, _b;
69
+ try {
50
70
  const d = doc;
51
71
  const date = (_b = (_a = d.time) !== null && _a !== void 0 ? _a : d['@timestamp']) !== null && _b !== void 0 ? _b : new Date().toISOString();
52
72
  if (opType === 'create') {
53
73
  d['@timestamp'] = date;
54
74
  }
55
- return {
56
- index: {
57
- _index: indexName(date),
58
- op_type: opType,
75
+ return [
76
+ {
77
+ index: {
78
+ _index: indexName(date),
79
+ op_type: opType,
80
+ },
81
+ },
82
+ doc,
83
+ ];
84
+ }
85
+ catch (_c) {
86
+ return [
87
+ {
88
+ index: {
89
+ _index: indexName(),
90
+ op_type: opType,
91
+ },
59
92
  },
60
- };
93
+ doc,
94
+ ];
95
+ }
96
+ };
97
+ const emitDroppedDocument = (doc, cause) => {
98
+ const error = new Error('Dropped document');
99
+ error.document = doc;
100
+ error.cause = cause;
101
+ splitter.emit('insertError', error);
102
+ };
103
+ const flush = async () => {
104
+ var _a, _b;
105
+ if (isFlushing) {
106
+ flushAgain = true;
107
+ return;
108
+ }
109
+ clearFlushTimer();
110
+ if (buffer.length === 0) {
111
+ return;
112
+ }
113
+ isFlushing = true;
114
+ const batch = buffer;
115
+ buffer = [];
116
+ bufferedBytes = 0;
117
+ try {
118
+ const operations = batch.flatMap(buildOperation);
119
+ const response = await client.bulk({
120
+ operations,
121
+ refresh: false,
122
+ timeout: opts.requestTimeout ? `${opts.requestTimeout}ms` : undefined,
123
+ });
124
+ const body = response;
125
+ if (body.errors && Array.isArray(body.items)) {
126
+ body.items.forEach((item, index) => {
127
+ const result = Object.values(item)[0];
128
+ if (result === null || result === void 0 ? void 0 : result.error) {
129
+ emitDroppedDocument(batch[index], result.error);
130
+ }
131
+ });
132
+ }
133
+ splitter.emit('insert', {
134
+ successful: batch.length,
135
+ failed: body.errors ? ((_b = (_a = body.items) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) : 0,
136
+ });
137
+ }
138
+ catch (err) {
139
+ splitter.emit('error', err);
140
+ // Drop the failed batch instead of wedging the stream. The next log line
141
+ // creates a fresh bulk request and can recover without a process restart.
142
+ batch.forEach((doc) => emitDroppedDocument(doc, err));
143
+ }
144
+ finally {
145
+ isFlushing = false;
146
+ if (flushAgain || buffer.length > 0) {
147
+ flushAgain = false;
148
+ scheduleFlush();
149
+ if (bufferedBytes >= flushBytes) {
150
+ void flush();
151
+ }
152
+ }
153
+ }
154
+ };
155
+ return {
156
+ add(doc) {
157
+ buffer.push(doc);
158
+ bufferedBytes += Buffer.byteLength(JSON.stringify(doc));
159
+ if (bufferedBytes >= flushBytes) {
160
+ void flush();
161
+ return;
162
+ }
163
+ scheduleFlush();
61
164
  },
62
- onDrop(doc) {
63
- const error = new Error('Dropped document');
64
- error.document = doc;
65
- splitter.emit('insertError', error);
165
+ flush,
166
+ async close() {
167
+ clearFlushTimer();
168
+ await flush();
66
169
  },
67
- });
68
- bulkInsert.then((stats) => splitter.emit('insert', stats), (err) => {
69
- splitter.emit('error', err);
70
- onFatalError(err);
71
- });
170
+ };
72
171
  }
73
172
  const createElasticTransport = (opts = {}) => {
74
173
  const splitter = split(function (line) {
@@ -119,56 +218,19 @@ const createElasticTransport = (opts = {}) => {
119
218
  clientOpts.ConnectionPool = opts.ConnectionPool;
120
219
  }
121
220
  const client = new elasticsearch_1.Client(clientOpts);
122
- // CRITICAL FIX (pino-elasticsearch issues #140/#72): after retries are
123
- // exhausted the bulk helper can stop consuming the stream while the process
124
- // stays alive. Keep exactly one helper active and replace it after fatal
125
- // helper failures instead of waiting for a server restart.
126
- let isBulkHandlerActive = false;
127
- let isRestartScheduled = false;
128
- let isTransportClosed = false;
129
- const pool = client.connectionPool;
221
+ const bulkSender = createBulkSender(opts, client, splitter);
222
+ splitter.on('data', (doc) => {
223
+ bulkSender.add(doc);
224
+ });
225
+ splitter.on('finish', () => {
226
+ void bulkSender.close();
227
+ });
130
228
  const splitterWithDestroy = splitter;
131
229
  const originalDestroy = splitterWithDestroy.destroy.bind(splitterWithDestroy);
132
- const startBulkHandler = () => {
133
- if (isTransportClosed || isBulkHandlerActive) {
134
- return;
135
- }
136
- isBulkHandlerActive = true;
137
- initializeBulkHandler(opts, client, splitter, () => {
138
- isBulkHandlerActive = false;
139
- scheduleBulkHandlerRestart();
140
- });
141
- };
142
- const scheduleBulkHandlerRestart = () => {
143
- var _a, _b, _c;
144
- if (isTransportClosed || isRestartScheduled) {
145
- return;
146
- }
147
- isRestartScheduled = true;
148
- if (typeof pool.resurrect === 'function') {
149
- pool.resurrect({ name: 'elasticsearch-js' });
150
- }
151
- const retryDelayMs = Math.min(Number((_b = (_a = opts.flushInterval) !== null && _a !== void 0 ? _a : opts['flush-interval']) !== null && _b !== void 0 ? _b : 3000), 5000);
152
- const timer = setTimeout(() => {
153
- isRestartScheduled = false;
154
- startBulkHandler();
155
- }, retryDelayMs);
156
- (_c = timer.unref) === null || _c === void 0 ? void 0 : _c.call(timer);
157
- };
158
230
  splitterWithDestroy.destroy = function (err) {
159
- if (err && !isTransportClosed) {
160
- scheduleBulkHandlerRestart();
161
- return;
162
- }
163
- isTransportClosed = true;
231
+ void bulkSender.close();
164
232
  originalDestroy(err);
165
233
  };
166
- client.diagnostic.on('resurrect', () => {
167
- if (!isBulkHandlerActive) {
168
- scheduleBulkHandlerRestart();
169
- }
170
- });
171
- startBulkHandler();
172
234
  return splitter;
173
235
  };
174
236
  exports.createElasticTransport = createElasticTransport;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhmdhammoud/meritt-utils",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
@@ -17,7 +17,6 @@ const split = require('split2') as (
17
17
  fn: (line: string) => unknown,
18
18
  opts?: { autoDestroy?: boolean }
19
19
  ) => NodeJS.ReadWriteStream
20
- import { Readable } from 'stream'
21
20
  import { Client } from '@elastic/elasticsearch'
22
21
  import type { ClientOptions } from '@elastic/elasticsearch'
23
22
 
@@ -73,54 +72,161 @@ function getIndexName(
73
72
  return index.replace('%{DATE}', time.substring(0, 10))
74
73
  }
75
74
 
76
- function initializeBulkHandler(
75
+ function createBulkSender(
77
76
  opts: ElasticTransportOptions,
78
77
  client: Client,
79
- splitter: NodeJS.ReadWriteStream,
80
- onFatalError: (err: Error) => void
81
- ): void {
78
+ splitter: NodeJS.ReadWriteStream
79
+ ): {
80
+ add: (doc: unknown) => void
81
+ flush: () => Promise<void>
82
+ close: () => Promise<void>
83
+ } {
82
84
  const esVersion = Number(opts.esVersion ?? opts['es-version'] ?? 7)
83
85
  const index = opts.index ?? 'pino'
84
86
  const buildIndexName = typeof index === 'function' ? index : null
85
87
  const opType = esVersion >= 7 ? undefined : undefined
88
+ const flushBytes = opts.flushBytes ?? opts['flush-bytes'] ?? 1000
89
+ const flushInterval = opts.flushInterval ?? opts['flush-interval'] ?? 3000
90
+
91
+ let buffer: unknown[] = []
92
+ let bufferedBytes = 0
93
+ let timer: NodeJS.Timeout | undefined
94
+ let isFlushing = false
95
+ let flushAgain = false
86
96
 
87
97
  const indexName = (time = new Date().toISOString()) =>
88
98
  buildIndexName ? buildIndexName(time) : getIndexName(index as string, time)
89
99
 
90
- const bulkInsert = client.helpers.bulk({
91
- datasource: splitter as unknown as Readable,
92
- flushBytes: opts.flushBytes ?? opts['flush-bytes'] ?? 1000,
93
- flushInterval: opts.flushInterval ?? opts['flush-interval'] ?? 3000,
94
- refreshOnCompletion: false,
95
- onDocument(doc: unknown) {
100
+ const clearFlushTimer = () => {
101
+ if (timer) {
102
+ clearTimeout(timer)
103
+ timer = undefined
104
+ }
105
+ }
106
+
107
+ const scheduleFlush = () => {
108
+ if (timer || buffer.length === 0) {
109
+ return
110
+ }
111
+ timer = setTimeout(() => {
112
+ timer = undefined
113
+ void flush()
114
+ }, flushInterval)
115
+ timer.unref?.()
116
+ }
117
+
118
+ const buildOperation = (doc: unknown): [Record<string, unknown>, unknown] => {
119
+ try {
96
120
  const d = doc as LogDocument
97
121
  const date = d.time ?? d['@timestamp'] ?? new Date().toISOString()
98
122
  if (opType === 'create') {
99
123
  d['@timestamp'] = date
100
124
  }
101
- return {
102
- index: {
103
- _index: indexName(date),
104
- op_type: opType,
125
+ return [
126
+ {
127
+ index: {
128
+ _index: indexName(date),
129
+ op_type: opType,
130
+ },
131
+ },
132
+ doc,
133
+ ]
134
+ } catch {
135
+ return [
136
+ {
137
+ index: {
138
+ _index: indexName(),
139
+ op_type: opType,
140
+ },
105
141
  },
142
+ doc,
143
+ ]
144
+ }
145
+ }
146
+
147
+ const emitDroppedDocument = (doc: unknown, cause?: unknown) => {
148
+ const error = new Error('Dropped document') as Error & {
149
+ document: unknown
150
+ cause?: unknown
151
+ }
152
+ error.document = doc
153
+ error.cause = cause
154
+ splitter.emit('insertError', error)
155
+ }
156
+
157
+ const flush = async (): Promise<void> => {
158
+ if (isFlushing) {
159
+ flushAgain = true
160
+ return
161
+ }
162
+ clearFlushTimer()
163
+ if (buffer.length === 0) {
164
+ return
165
+ }
166
+
167
+ isFlushing = true
168
+ const batch = buffer
169
+ buffer = []
170
+ bufferedBytes = 0
171
+
172
+ try {
173
+ const operations = batch.flatMap(buildOperation)
174
+ const response = await client.bulk({
175
+ operations,
176
+ refresh: false,
177
+ timeout: opts.requestTimeout ? `${opts.requestTimeout}ms` : undefined,
178
+ })
179
+
180
+ const body = response as {
181
+ errors?: boolean
182
+ items?: Array<Record<string, { error?: unknown }>>
106
183
  }
107
- },
108
- onDrop(doc: unknown) {
109
- const error = new Error('Dropped document') as Error & {
110
- document: unknown
184
+ if (body.errors && Array.isArray(body.items)) {
185
+ body.items.forEach((item, index) => {
186
+ const result = Object.values(item)[0]
187
+ if (result?.error) {
188
+ emitDroppedDocument(batch[index], result.error)
189
+ }
190
+ })
111
191
  }
112
- error.document = doc
113
- splitter.emit('insertError', error)
114
- },
115
- })
116
192
 
117
- bulkInsert.then(
118
- (stats) => splitter.emit('insert', stats),
119
- (err) => {
193
+ splitter.emit('insert', {
194
+ successful: batch.length,
195
+ failed: body.errors ? (body.items?.length ?? 0) : 0,
196
+ })
197
+ } catch (err) {
120
198
  splitter.emit('error', err)
121
- onFatalError(err)
199
+ // Drop the failed batch instead of wedging the stream. The next log line
200
+ // creates a fresh bulk request and can recover without a process restart.
201
+ batch.forEach((doc) => emitDroppedDocument(doc, err))
202
+ } finally {
203
+ isFlushing = false
204
+ if (flushAgain || buffer.length > 0) {
205
+ flushAgain = false
206
+ scheduleFlush()
207
+ if (bufferedBytes >= flushBytes) {
208
+ void flush()
209
+ }
210
+ }
122
211
  }
123
- )
212
+ }
213
+
214
+ return {
215
+ add(doc: unknown) {
216
+ buffer.push(doc)
217
+ bufferedBytes += Buffer.byteLength(JSON.stringify(doc))
218
+ if (bufferedBytes >= flushBytes) {
219
+ void flush()
220
+ return
221
+ }
222
+ scheduleFlush()
223
+ },
224
+ flush,
225
+ async close() {
226
+ clearFlushTimer()
227
+ await flush()
228
+ },
229
+ }
124
230
  }
125
231
 
126
232
  export const createElasticTransport = (
@@ -179,71 +285,23 @@ export const createElasticTransport = (
179
285
  }
180
286
 
181
287
  const client = new Client(clientOpts)
288
+ const bulkSender = createBulkSender(opts, client, splitter)
182
289
 
183
- // CRITICAL FIX (pino-elasticsearch issues #140/#72): after retries are
184
- // exhausted the bulk helper can stop consuming the stream while the process
185
- // stays alive. Keep exactly one helper active and replace it after fatal
186
- // helper failures instead of waiting for a server restart.
187
- let isBulkHandlerActive = false
188
- let isRestartScheduled = false
189
- let isTransportClosed = false
290
+ splitter.on('data', (doc) => {
291
+ bulkSender.add(doc)
292
+ })
293
+ splitter.on('finish', () => {
294
+ void bulkSender.close()
295
+ })
190
296
 
191
- const pool = client.connectionPool as {
192
- resurrect?: (opts: { name: string }) => void
193
- }
194
297
  const splitterWithDestroy = splitter as NodeJS.ReadWriteStream & {
195
298
  destroy: (err?: Error) => void
196
299
  }
197
300
  const originalDestroy = splitterWithDestroy.destroy.bind(splitterWithDestroy)
198
-
199
- const startBulkHandler = () => {
200
- if (isTransportClosed || isBulkHandlerActive) {
201
- return
202
- }
203
- isBulkHandlerActive = true
204
- initializeBulkHandler(opts, client, splitter, () => {
205
- isBulkHandlerActive = false
206
- scheduleBulkHandlerRestart()
207
- })
208
- }
209
-
210
- const scheduleBulkHandlerRestart = () => {
211
- if (isTransportClosed || isRestartScheduled) {
212
- return
213
- }
214
- isRestartScheduled = true
215
-
216
- if (typeof pool.resurrect === 'function') {
217
- pool.resurrect({ name: 'elasticsearch-js' })
218
- }
219
-
220
- const retryDelayMs = Math.min(
221
- Number(opts.flushInterval ?? opts['flush-interval'] ?? 3000),
222
- 5000
223
- )
224
- const timer = setTimeout(() => {
225
- isRestartScheduled = false
226
- startBulkHandler()
227
- }, retryDelayMs)
228
- timer.unref?.()
229
- }
230
-
231
301
  splitterWithDestroy.destroy = function (err?: Error) {
232
- if (err && !isTransportClosed) {
233
- scheduleBulkHandlerRestart()
234
- return
235
- }
236
- isTransportClosed = true
302
+ void bulkSender.close()
237
303
  originalDestroy(err)
238
304
  }
239
305
 
240
- client.diagnostic.on('resurrect', () => {
241
- if (!isBulkHandlerActive) {
242
- scheduleBulkHandlerRestart()
243
- }
244
- })
245
-
246
- startBulkHandler()
247
-
248
306
  return splitter
249
307
  }