@tmlmobilidade/go-utils-exec 20260828.1700.4
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/dist/batch/batch-writer.d.ts +91 -0
- package/dist/batch/batch-writer.js +198 -0
- package/dist/batch/index.d.ts +4 -0
- package/dist/batch/index.js +4 -0
- package/dist/batch/perform-in-chunks.d.ts +7 -0
- package/dist/batch/perform-in-chunks.js +23 -0
- package/dist/batch/perform-in-time-chunks.d.ts +14 -0
- package/dist/batch/perform-in-time-chunks.js +33 -0
- package/dist/batch/replicate.d.ts +80 -0
- package/dist/batch/replicate.js +83 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/lifecycle/run-on-interval.d.ts +21 -0
- package/dist/lifecycle/run-on-interval.js +36 -0
- package/dist/lifecycle/run-with-concurrency.d.ts +10 -0
- package/dist/lifecycle/run-with-concurrency.js +28 -0
- package/package.json +51 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
interface BatchWriterParams<T> {
|
|
2
|
+
/**
|
|
3
|
+
* The maximum number of items to hold in memory
|
|
4
|
+
* before flushing to the database.
|
|
5
|
+
* @required
|
|
6
|
+
*/
|
|
7
|
+
batch_size: number;
|
|
8
|
+
/**
|
|
9
|
+
* How long, in milliseconds, data should be kept in memory before
|
|
10
|
+
* flushing to the database. If this feature is enabled, a flush will
|
|
11
|
+
* be triggered even if the batch is not full. Disabled by default.
|
|
12
|
+
* @default disabled
|
|
13
|
+
*/
|
|
14
|
+
batch_timeout?: number;
|
|
15
|
+
/**
|
|
16
|
+
* How long to wait, in milliseconds, after the last write operation
|
|
17
|
+
* before flushing the data to the database. This can be used to prevent
|
|
18
|
+
* items staying in memory for too long if the batch size is not reached
|
|
19
|
+
* frequently enough. Disabled by default.
|
|
20
|
+
* @default disabled
|
|
21
|
+
*/
|
|
22
|
+
idle_timeout?: number;
|
|
23
|
+
/**
|
|
24
|
+
* The insert function to use for inserting data into the batch.
|
|
25
|
+
* @required
|
|
26
|
+
*/
|
|
27
|
+
insertFn: (data: T[]) => Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Maximum number of retries for transient insert errors.
|
|
30
|
+
* @default 3
|
|
31
|
+
*/
|
|
32
|
+
max_retries?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Base delay in milliseconds for exponential backoff.
|
|
35
|
+
* @default 1000
|
|
36
|
+
*/
|
|
37
|
+
retry_base_delay_ms?: number;
|
|
38
|
+
/**
|
|
39
|
+
* The title of this BatchWriter instance,
|
|
40
|
+
* used to identify the source of the logs.
|
|
41
|
+
* @required
|
|
42
|
+
*/
|
|
43
|
+
title: string;
|
|
44
|
+
}
|
|
45
|
+
export declare class BatchWriter<T> {
|
|
46
|
+
private params;
|
|
47
|
+
private dataBucketAlwaysAvailable;
|
|
48
|
+
private dataBucketFlushOps;
|
|
49
|
+
private batchTimeoutTimer;
|
|
50
|
+
private idleTimeoutTimer;
|
|
51
|
+
private sessionTimer;
|
|
52
|
+
private flushInProgress;
|
|
53
|
+
constructor(params: BatchWriterParams<T>);
|
|
54
|
+
/**
|
|
55
|
+
* Flushes the current batch of data.
|
|
56
|
+
* This method is called internally when the batch size or timeouts are reached,
|
|
57
|
+
* but can also be called manually if needed.
|
|
58
|
+
* @param callback Optional callback to execute after the flush is complete, receiving the flushed data as a parameter
|
|
59
|
+
*/
|
|
60
|
+
flush(callback?: (data?: T[]) => Promise<void>): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Timer-triggered flush that guarantees the buffer is emptied even when a
|
|
63
|
+
* flush was already in progress. If the guarded flush() coalesced into a
|
|
64
|
+
* running flush, data written during that flush would strand on an idle tail
|
|
65
|
+
* (timers are cleared at flush start and only re-armed by the next write()).
|
|
66
|
+
* Re-flush while data remains and no timer is pending.
|
|
67
|
+
*/
|
|
68
|
+
private drain;
|
|
69
|
+
private runFlush;
|
|
70
|
+
/**
|
|
71
|
+
* Helper method to perform insert operations with retry logic for transient errors.
|
|
72
|
+
* This method will attempt to insert the data using the provided insert function,
|
|
73
|
+
* and if an error occurs, it will retry the operation with exponential backoff
|
|
74
|
+
* until the maximum number of retries is reached.
|
|
75
|
+
* @param data The data to insert.
|
|
76
|
+
* @returns A promise that resolves when the insert operation is successful, or rejects if all retries fail.
|
|
77
|
+
*/
|
|
78
|
+
private insertWithRetry;
|
|
79
|
+
/**
|
|
80
|
+
* Write data to the batch.
|
|
81
|
+
* @param data The data to write.
|
|
82
|
+
* @param options Options for the write operation (reserved for future use).
|
|
83
|
+
* @param writeCallback Callback function to call after the write operation is complete.
|
|
84
|
+
* @param flushCallback Callback function to call after the flush operation is complete.
|
|
85
|
+
*/
|
|
86
|
+
write(data: T | T[], { flushCallback, writeCallback }?: {
|
|
87
|
+
flushCallback?: (data?: T[]) => Promise<void>;
|
|
88
|
+
writeCallback?: () => Promise<void>;
|
|
89
|
+
}): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/* eslint-disable perfectionist/sort-classes */
|
|
2
|
+
/* * */
|
|
3
|
+
import { Timer } from '@tmlmobilidade/timer';
|
|
4
|
+
/* * */
|
|
5
|
+
export class BatchWriter {
|
|
6
|
+
//
|
|
7
|
+
params;
|
|
8
|
+
dataBucketAlwaysAvailable = [];
|
|
9
|
+
dataBucketFlushOps = [];
|
|
10
|
+
batchTimeoutTimer = null;
|
|
11
|
+
idleTimeoutTimer = null;
|
|
12
|
+
sessionTimer = new Timer();
|
|
13
|
+
// ponytail: single in-flight guard, not a queue. Serializes overlapping
|
|
14
|
+
// flushes (timer-triggered vs batch-full) so inserts never run concurrently
|
|
15
|
+
// and callers applying backpressure await the same promise.
|
|
16
|
+
flushInProgress = null;
|
|
17
|
+
constructor(params) {
|
|
18
|
+
if (!params.title)
|
|
19
|
+
throw new Error('BATCHWRITER: Title is required.');
|
|
20
|
+
if (!params.insertFn)
|
|
21
|
+
throw new Error('BATCHWRITER: Insert function is required.');
|
|
22
|
+
if (!params.batch_size)
|
|
23
|
+
throw new Error('BATCHWRITER: Batch size is required.');
|
|
24
|
+
this.params = params;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Flushes the current batch of data.
|
|
28
|
+
* This method is called internally when the batch size or timeouts are reached,
|
|
29
|
+
* but can also be called manually if needed.
|
|
30
|
+
* @param callback Optional callback to execute after the flush is complete, receiving the flushed data as a parameter
|
|
31
|
+
*/
|
|
32
|
+
async flush(callback) {
|
|
33
|
+
// If a flush is already running, await it instead of starting a
|
|
34
|
+
// concurrent insert. This is the backpressure seam: the change-stream
|
|
35
|
+
// handler awaits write() -> flush() and cannot outrun the insert.
|
|
36
|
+
if (this.flushInProgress) {
|
|
37
|
+
await this.flushInProgress;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.flushInProgress = this.runFlush(callback);
|
|
41
|
+
try {
|
|
42
|
+
await this.flushInProgress;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
this.flushInProgress = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Timer-triggered flush that guarantees the buffer is emptied even when a
|
|
50
|
+
* flush was already in progress. If the guarded flush() coalesced into a
|
|
51
|
+
* running flush, data written during that flush would strand on an idle tail
|
|
52
|
+
* (timers are cleared at flush start and only re-armed by the next write()).
|
|
53
|
+
* Re-flush while data remains and no timer is pending.
|
|
54
|
+
*/
|
|
55
|
+
async drain(callback) {
|
|
56
|
+
await this.flush(callback);
|
|
57
|
+
if (this.dataBucketAlwaysAvailable.length > 0 && !this.idleTimeoutTimer && !this.batchTimeoutTimer && !this.flushInProgress) {
|
|
58
|
+
await this.flush(callback);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async runFlush(callback) {
|
|
62
|
+
try {
|
|
63
|
+
//
|
|
64
|
+
const flushTimer = new Timer();
|
|
65
|
+
const sessionTimerResult = this.sessionTimer.get();
|
|
66
|
+
//
|
|
67
|
+
// Invalidate all timers since a flush operation is being performed
|
|
68
|
+
if (this.idleTimeoutTimer) {
|
|
69
|
+
clearTimeout(this.idleTimeoutTimer);
|
|
70
|
+
this.idleTimeoutTimer = null;
|
|
71
|
+
}
|
|
72
|
+
if (this.batchTimeoutTimer) {
|
|
73
|
+
clearTimeout(this.batchTimeoutTimer);
|
|
74
|
+
this.batchTimeoutTimer = null;
|
|
75
|
+
}
|
|
76
|
+
//
|
|
77
|
+
// Skip if there is no data to flush
|
|
78
|
+
if (this.dataBucketAlwaysAvailable.length === 0)
|
|
79
|
+
return;
|
|
80
|
+
//
|
|
81
|
+
// Copy everything in dataBucketAlwaysAvailable to dataBucketFlushOps
|
|
82
|
+
// to prevent any new incoming data to be added to the batch. This is to ensure
|
|
83
|
+
// that the batch is not modified while it is being processed.
|
|
84
|
+
this.dataBucketFlushOps = [...this.dataBucketFlushOps, ...this.dataBucketAlwaysAvailable];
|
|
85
|
+
this.dataBucketAlwaysAvailable = [];
|
|
86
|
+
//
|
|
87
|
+
// Process the data for batch insert
|
|
88
|
+
try {
|
|
89
|
+
// Call the insert function provided in the params to perform the actual database insertion.
|
|
90
|
+
if (!this.params.insertFn)
|
|
91
|
+
throw new Error('BATCHWRITER: No insert function provided in params');
|
|
92
|
+
await this.insertWithRetry(this.dataBucketFlushOps);
|
|
93
|
+
console.info(`BATCHWRITER [${this.params.title}]: Flush | Length: ${this.dataBucketFlushOps.length} (session: ${sessionTimerResult}) (flush: ${flushTimer.get()})`);
|
|
94
|
+
// Call the flush callback, if provided
|
|
95
|
+
if (callback)
|
|
96
|
+
await callback(this.dataBucketFlushOps);
|
|
97
|
+
// Reset the flush bucket
|
|
98
|
+
this.dataBucketFlushOps = [];
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
console.error(`BATCHWRITER [${this.params.title}]: Error @ flush().insert(): ${error.message}`);
|
|
102
|
+
throw error; // Re-throw to allow retry logic at higher level
|
|
103
|
+
}
|
|
104
|
+
//
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error(`BATCHWRITER [${this.params.title}]: Error @ flush(): ${error.message}`);
|
|
108
|
+
throw error; // Re-throw to allow retry logic at higher level
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Helper method to perform insert operations with retry logic for transient errors.
|
|
113
|
+
* This method will attempt to insert the data using the provided insert function,
|
|
114
|
+
* and if an error occurs, it will retry the operation with exponential backoff
|
|
115
|
+
* until the maximum number of retries is reached.
|
|
116
|
+
* @param data The data to insert.
|
|
117
|
+
* @returns A promise that resolves when the insert operation is successful, or rejects if all retries fail.
|
|
118
|
+
*/
|
|
119
|
+
async insertWithRetry(data) {
|
|
120
|
+
const maxRetries = this.params.max_retries ?? 3;
|
|
121
|
+
const retryBaseDelayMs = this.params.retry_base_delay_ms ?? 1000;
|
|
122
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
123
|
+
try {
|
|
124
|
+
await this.params.insertFn(data);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const parsedError = error;
|
|
129
|
+
const nextAttempt = attempt + 1;
|
|
130
|
+
const delayMs = retryBaseDelayMs * (2 ** attempt);
|
|
131
|
+
console.error(`BATCHWRITER [${this.params.title}]: Transient insert error (${parsedError.code ?? 'unknown'}). Retrying ${nextAttempt}/${maxRetries} in ${delayMs}ms. ${parsedError.message}`);
|
|
132
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Write data to the batch.
|
|
138
|
+
* @param data The data to write.
|
|
139
|
+
* @param options Options for the write operation (reserved for future use).
|
|
140
|
+
* @param writeCallback Callback function to call after the write operation is complete.
|
|
141
|
+
* @param flushCallback Callback function to call after the flush operation is complete.
|
|
142
|
+
*/
|
|
143
|
+
async write(data, { flushCallback, writeCallback } = {}) {
|
|
144
|
+
//
|
|
145
|
+
//
|
|
146
|
+
// Invalidate the previously set idle timeout timer
|
|
147
|
+
// since we are performing a write operation again.
|
|
148
|
+
if (this.idleTimeoutTimer) {
|
|
149
|
+
clearTimeout(this.idleTimeoutTimer);
|
|
150
|
+
this.idleTimeoutTimer = null;
|
|
151
|
+
}
|
|
152
|
+
//
|
|
153
|
+
// Check if the batch is full
|
|
154
|
+
const batchSize = this.params.batch_size ?? 10_000;
|
|
155
|
+
if (this.dataBucketAlwaysAvailable.length >= batchSize) {
|
|
156
|
+
console.info(`BATCHWRITER [${this.params.title}]: Batch full. Flushing data...`);
|
|
157
|
+
await this.flush(flushCallback);
|
|
158
|
+
}
|
|
159
|
+
//
|
|
160
|
+
// Reset the session timer (for logging purposes)
|
|
161
|
+
if (this.dataBucketAlwaysAvailable.length === 0) {
|
|
162
|
+
this.sessionTimer.reset();
|
|
163
|
+
}
|
|
164
|
+
//
|
|
165
|
+
// Add the current data to the batch
|
|
166
|
+
if (Array.isArray(data)) {
|
|
167
|
+
const combinedDataWithOptions = data.map(item => item);
|
|
168
|
+
this.dataBucketAlwaysAvailable = [...this.dataBucketAlwaysAvailable, ...combinedDataWithOptions];
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
this.dataBucketAlwaysAvailable.push(data);
|
|
172
|
+
}
|
|
173
|
+
//
|
|
174
|
+
// Call the write callback, if provided
|
|
175
|
+
if (writeCallback) {
|
|
176
|
+
await writeCallback();
|
|
177
|
+
}
|
|
178
|
+
//
|
|
179
|
+
// Setup the idle timeout timer to flush the data if too long has passed
|
|
180
|
+
// since the last write operation. Check if this functionality is enabled.
|
|
181
|
+
if (this.params.idle_timeout && this.params.idle_timeout > 0 && !this.idleTimeoutTimer) {
|
|
182
|
+
this.idleTimeoutTimer = setTimeout(async () => {
|
|
183
|
+
console.info(`BATCHWRITER [${this.params.title}]: Idle timeout reached. Flushing data...`);
|
|
184
|
+
await this.drain(flushCallback);
|
|
185
|
+
}, this.params.idle_timeout);
|
|
186
|
+
}
|
|
187
|
+
//
|
|
188
|
+
// Setup the batch timeout timer to flush the data, if the timeout value is reached,
|
|
189
|
+
// even if the batch is not full. Check if this functionality is enabled.
|
|
190
|
+
if (this.params.batch_timeout && this.params.batch_timeout > 0 && !this.batchTimeoutTimer) {
|
|
191
|
+
this.batchTimeoutTimer = setTimeout(async () => {
|
|
192
|
+
console.info(`BATCHWRITER [${this.params.title}]: Batch timeout reached. Flushing data...`);
|
|
193
|
+
await this.drain(flushCallback);
|
|
194
|
+
}, this.params.batch_timeout);
|
|
195
|
+
}
|
|
196
|
+
//
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performs an operation on large array of data in chunks.
|
|
3
|
+
* @param data The data to be processed in chunks.
|
|
4
|
+
* @param operation The operation to be performed on each chunk of data.
|
|
5
|
+
* @param chunkSize The size of each chunk. Defaults to 5000.
|
|
6
|
+
*/
|
|
7
|
+
export declare function performInChunks<T>(data: T[], operation: (chunk: T[]) => Promise<void>, chunkSize?: number): Promise<void>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performs an operation on large array of data in chunks.
|
|
3
|
+
* @param data The data to be processed in chunks.
|
|
4
|
+
* @param operation The operation to be performed on each chunk of data.
|
|
5
|
+
* @param chunkSize The size of each chunk. Defaults to 5000.
|
|
6
|
+
*/
|
|
7
|
+
export async function performInChunks(data, operation, chunkSize = 5000) {
|
|
8
|
+
//
|
|
9
|
+
//
|
|
10
|
+
// Define an array to hold arrays of data (called chunks).
|
|
11
|
+
const allChunksOfData = [];
|
|
12
|
+
//
|
|
13
|
+
// Split the orignal data into chunks
|
|
14
|
+
for (let i = 0; i < data.length; i += chunkSize) {
|
|
15
|
+
allChunksOfData.push(data.slice(i, i + chunkSize));
|
|
16
|
+
}
|
|
17
|
+
//
|
|
18
|
+
// Process each chunk of data
|
|
19
|
+
for (const chunk of allChunksOfData) {
|
|
20
|
+
await operation(chunk);
|
|
21
|
+
}
|
|
22
|
+
//
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type UnixTimestamp } from '@tmlmobilidade/go-types-shared';
|
|
2
|
+
export interface PerformInTimeChunksItem {
|
|
3
|
+
end: UnixTimestamp;
|
|
4
|
+
index: number;
|
|
5
|
+
start: UnixTimestamp;
|
|
6
|
+
total: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PerformInTimeChunksOptions {
|
|
9
|
+
endDate?: UnixTimestamp;
|
|
10
|
+
intervalHrs: number;
|
|
11
|
+
onChunk: (chunk: PerformInTimeChunksItem) => Promise<void>;
|
|
12
|
+
startDate: UnixTimestamp;
|
|
13
|
+
}
|
|
14
|
+
export declare function performInTimeChunks({ endDate, intervalHrs, onChunk, startDate }: PerformInTimeChunksOptions): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* * */
|
|
2
|
+
import { Dates, splitTimeIntervals } from '@tmlmobilidade/go-utils-dates';
|
|
3
|
+
export async function performInTimeChunks({ endDate, intervalHrs, onChunk, startDate }) {
|
|
4
|
+
//
|
|
5
|
+
// In order to sync both collections in a manageable way, due to the high volume of data,
|
|
6
|
+
// it is necessary to divide the process into smaller blocks. Instead of syncing all documents at once,
|
|
7
|
+
// divide the process by timestamps chunks and iterate over each one, getting all document IDs from both databases.
|
|
8
|
+
// Like this we can more easily compare the IDs in memory and sync only the missing documents.
|
|
9
|
+
// More recent data is more important than older data, so we start syncing the most recent data first.
|
|
10
|
+
// It makes sense to divide chunks by day, but this should be adjusted according to the volume of data in each chunk.
|
|
11
|
+
const endDateValue = endDate
|
|
12
|
+
? Dates.fromUnixTimestamp(endDate).unix_timestamp
|
|
13
|
+
: Dates.now('utc').minus({ seconds: 30 }).unix_timestamp;
|
|
14
|
+
const startDateValue = Dates.fromUnixTimestamp(startDate).unix_timestamp;
|
|
15
|
+
const allTimestampChunks = splitTimeIntervals(startDateValue, endDateValue, intervalHrs);
|
|
16
|
+
//
|
|
17
|
+
// Iterate over each timestamp chunk and sync the documents.
|
|
18
|
+
// Timestamp chunks are sorted in descending order, so that more recent data is processed first.
|
|
19
|
+
// Timestamp chunks are in the format { start: day1, end: day2 }, so end is always greater than start.
|
|
20
|
+
// This might be confusing as the array of chunks itself is sorted in descending order, but the chunks individually are not.
|
|
21
|
+
for (const [chunkIndex, chunkData] of allTimestampChunks.entries()) {
|
|
22
|
+
//
|
|
23
|
+
const chunkStartDate = Dates
|
|
24
|
+
.fromUnixTimestamp(chunkData.start)
|
|
25
|
+
.setZone('Europe/Lisbon', 'offset_only');
|
|
26
|
+
const chunkEndDate = Dates
|
|
27
|
+
.fromUnixTimestamp(chunkData.end)
|
|
28
|
+
.setZone('Europe/Lisbon', 'offset_only');
|
|
29
|
+
await onChunk({ end: chunkEndDate.unix_timestamp, index: chunkIndex, start: chunkStartDate.unix_timestamp, total: allTimestampChunks.length });
|
|
30
|
+
}
|
|
31
|
+
//
|
|
32
|
+
}
|
|
33
|
+
;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
interface ReplicateProps<SourceDocType> {
|
|
2
|
+
/**
|
|
3
|
+
* A function to count the total number of documents
|
|
4
|
+
* in the destination database. This must return a number.
|
|
5
|
+
* @returns A promise that resolves to a number.
|
|
6
|
+
*/
|
|
7
|
+
countDestinationDbFn: () => Promise<number>;
|
|
8
|
+
/**
|
|
9
|
+
* A function to count the total number of documents
|
|
10
|
+
* in the source database. This must return a number.
|
|
11
|
+
* @returns A promise that resolves to a number.
|
|
12
|
+
*/
|
|
13
|
+
countSourceDbFn: () => Promise<number>;
|
|
14
|
+
/**
|
|
15
|
+
* A function that deletes documents in the destination database,
|
|
16
|
+
* from an array of unique document IDs. This is used to remove any extra documents
|
|
17
|
+
* that are present in the destination database but not in the source database.
|
|
18
|
+
* This function should return a promise that resolves when the deletion is complete.
|
|
19
|
+
* @param uniqueIds An array of unique document IDs to be deleted from the destination database.
|
|
20
|
+
* @returns A promise that resolves when the deletion is complete.
|
|
21
|
+
*/
|
|
22
|
+
deleteDestinationDbFn: (uniqueIds: string[]) => Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* A function to get the distinct document IDs from the destination database.
|
|
25
|
+
* This must return an array of strings.
|
|
26
|
+
* @returns A promise that resolves to an array of strings.
|
|
27
|
+
*/
|
|
28
|
+
distinctDestinationDbFn: () => Promise<string[]>;
|
|
29
|
+
/**
|
|
30
|
+
* A function to get the distinct document IDs from the source database.
|
|
31
|
+
* This must return an array of strings.
|
|
32
|
+
* @returns A promise that resolves to an array of strings.
|
|
33
|
+
*/
|
|
34
|
+
distinctSourceDbFn: () => Promise<string[]>;
|
|
35
|
+
/**
|
|
36
|
+
* This is the function that should query the source database for the missing documents based on their IDs.
|
|
37
|
+
* It should return an async iterable (e.g., an async generator or a MongoDB `.stream()`) that yields
|
|
38
|
+
* the missing documents one by one.
|
|
39
|
+
* @param missingDocumentIds An array of document IDs that are missing in the destination database.
|
|
40
|
+
* @returns An async iterable that yields source documents one by one.
|
|
41
|
+
*/
|
|
42
|
+
missingDocumentsSourceDbAsyncIterator: (missingDocumentIds: string[]) => AsyncIterable<SourceDocType>;
|
|
43
|
+
/**
|
|
44
|
+
* An optional callback function that will be executed after the replication process is complete.
|
|
45
|
+
* This can be used to perform any necessary cleanup tasks, such as flushing writers or logging.
|
|
46
|
+
*/
|
|
47
|
+
onCompleteCallbackFn?: () => Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* This function receives a document from the source database and should write it to the destination database.
|
|
50
|
+
* You can use any method you prefer to write the document to the destination database, such as a bulk insert or individual writes,
|
|
51
|
+
* and perform any necessary transformations on the document before writing it.
|
|
52
|
+
* @param sourceDocument The source document to be written to the destination database.
|
|
53
|
+
* @returns A promise that resolves when the document has been successfully written to the destination database.
|
|
54
|
+
*/
|
|
55
|
+
writeSourceDocumentToDestinationDbFn: (sourceDocument: SourceDocType) => Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Copy documents from a source database to a destination database in multiple steps.
|
|
59
|
+
* The goal of this function is to ensure that the destination database has the same documents
|
|
60
|
+
* as the source database. The replication process is designed to be efficient and to minimize
|
|
61
|
+
* the amount of data transferred between the two databases by only syncing the missing documents.
|
|
62
|
+
*
|
|
63
|
+
* 1. First count the total number of documents in both databases to check if they match. This is a
|
|
64
|
+
* crucial optimization step, as it allows us to skip the replication process if both databases already
|
|
65
|
+
* have the same number of documents, which would indicate that they are already in sync.
|
|
66
|
+
* Though, it's important to note that having the same document count does not guarantee
|
|
67
|
+
* that the documents are identical, but it is a quick check to potentially avoid unnecessary replication.
|
|
68
|
+
*
|
|
69
|
+
* 2. If the counts do not match, get the distinct document IDs from both databases and compare them
|
|
70
|
+
* to find out which ones are missing in the destination database. This step is essential to identify
|
|
71
|
+
* the specific documents that need to be replicated, rather than syncing all documents again.
|
|
72
|
+
* Sync only the missing documents from the source database to the destination database,
|
|
73
|
+
*
|
|
74
|
+
* 3. Delete any extra documents in the destination database that are not present in the source database.
|
|
75
|
+
*
|
|
76
|
+
* 4. Run the onComplete callback function if provided. This allows for any additional actions to be performed after
|
|
77
|
+
* the replication process is complete, such as logging, flushing writers or any other necessary cleanup tasks.
|
|
78
|
+
*/
|
|
79
|
+
export declare function replicate<SourceDocType>({ countDestinationDbFn, countSourceDbFn, deleteDestinationDbFn, distinctDestinationDbFn, distinctSourceDbFn, missingDocumentsSourceDbAsyncIterator, onCompleteCallbackFn, writeSourceDocumentToDestinationDbFn }: ReplicateProps<SourceDocType>): Promise<void>;
|
|
80
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/* * */
|
|
2
|
+
import { Timer } from '@tmlmobilidade/timer';
|
|
3
|
+
/**
|
|
4
|
+
* Copy documents from a source database to a destination database in multiple steps.
|
|
5
|
+
* The goal of this function is to ensure that the destination database has the same documents
|
|
6
|
+
* as the source database. The replication process is designed to be efficient and to minimize
|
|
7
|
+
* the amount of data transferred between the two databases by only syncing the missing documents.
|
|
8
|
+
*
|
|
9
|
+
* 1. First count the total number of documents in both databases to check if they match. This is a
|
|
10
|
+
* crucial optimization step, as it allows us to skip the replication process if both databases already
|
|
11
|
+
* have the same number of documents, which would indicate that they are already in sync.
|
|
12
|
+
* Though, it's important to note that having the same document count does not guarantee
|
|
13
|
+
* that the documents are identical, but it is a quick check to potentially avoid unnecessary replication.
|
|
14
|
+
*
|
|
15
|
+
* 2. If the counts do not match, get the distinct document IDs from both databases and compare them
|
|
16
|
+
* to find out which ones are missing in the destination database. This step is essential to identify
|
|
17
|
+
* the specific documents that need to be replicated, rather than syncing all documents again.
|
|
18
|
+
* Sync only the missing documents from the source database to the destination database,
|
|
19
|
+
*
|
|
20
|
+
* 3. Delete any extra documents in the destination database that are not present in the source database.
|
|
21
|
+
*
|
|
22
|
+
* 4. Run the onComplete callback function if provided. This allows for any additional actions to be performed after
|
|
23
|
+
* the replication process is complete, such as logging, flushing writers or any other necessary cleanup tasks.
|
|
24
|
+
*/
|
|
25
|
+
export async function replicate({ countDestinationDbFn, countSourceDbFn, deleteDestinationDbFn, distinctDestinationDbFn, distinctSourceDbFn, missingDocumentsSourceDbAsyncIterator, onCompleteCallbackFn, writeSourceDocumentToDestinationDbFn }) {
|
|
26
|
+
//
|
|
27
|
+
const globalTimer = new Timer();
|
|
28
|
+
//
|
|
29
|
+
// Run the count functions for both databases, if enabled, to get the total number
|
|
30
|
+
// of documents that match a given query. This is done to check if the document count
|
|
31
|
+
// is the same for both databases, which would indicate that all documents are already synced.
|
|
32
|
+
const countStepTimer = new Timer();
|
|
33
|
+
const sourceDbCount = await countSourceDbFn();
|
|
34
|
+
const destinationDbCount = await countDestinationDbFn();
|
|
35
|
+
if (sourceDbCount === destinationDbCount) {
|
|
36
|
+
console.info(`MATCH: Found the same number of documents in both databases: ${sourceDbCount} Source = ${destinationDbCount} Destination (${countStepTimer.get()})`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.info(`MISMATCH: Document count was different for both databases: ${sourceDbCount} Source != ${destinationDbCount} Destination (${countStepTimer.get()})`);
|
|
40
|
+
//
|
|
41
|
+
// If the document count was different, then check which documents are missing.
|
|
42
|
+
// Instead of syncing all documents again, only the missing IDs are synced.
|
|
43
|
+
// This is done to get the distinct values from each database and comparing
|
|
44
|
+
// them to find the missing ones.
|
|
45
|
+
const distinctStepTimer = new Timer();
|
|
46
|
+
const sourceDbDocIds = await distinctSourceDbFn();
|
|
47
|
+
const sourceDbDocIdsUnique = new Set(sourceDbDocIds);
|
|
48
|
+
const destinationDbDocIds = await distinctDestinationDbFn();
|
|
49
|
+
const destinationDbDocIdsUnique = new Set(destinationDbDocIds);
|
|
50
|
+
const missingDocumentIds = sourceDbDocIds.filter((documentId) => !destinationDbDocIdsUnique.has(documentId));
|
|
51
|
+
const extraDocumentIds = destinationDbDocIds.filter(doc => !sourceDbDocIdsUnique.has(doc));
|
|
52
|
+
console.info(`Source Total: ${sourceDbCount} | Source Unique: ${sourceDbDocIdsUnique.size} | Source ▲: ${sourceDbCount - sourceDbDocIdsUnique.size} | Destination Total: ${destinationDbCount} | Destination Unique: ${destinationDbDocIdsUnique.size} | Destination ▲: ${destinationDbCount - destinationDbDocIdsUnique.size} | Destination Missing: ${missingDocumentIds.length} | Destination Extra: ${extraDocumentIds.length} (${distinctStepTimer.get()})`);
|
|
53
|
+
//
|
|
54
|
+
// If there are missing documents, then they are synced.
|
|
55
|
+
// We query the Source database for the missing documents
|
|
56
|
+
// and write them to the Destination database.
|
|
57
|
+
const missingStepTimer = new Timer();
|
|
58
|
+
if (missingDocumentIds.length > 0) {
|
|
59
|
+
console.info(`Syncing ${missingDocumentIds.length} missing documents to the Destination database...`);
|
|
60
|
+
for await (const sourceDbDocument of missingDocumentsSourceDbAsyncIterator(missingDocumentIds)) {
|
|
61
|
+
await writeSourceDocumentToDestinationDbFn(sourceDbDocument);
|
|
62
|
+
}
|
|
63
|
+
console.info(`Synced ${missingDocumentIds.length} missing documents to the Destination database. (${missingStepTimer.get()})`);
|
|
64
|
+
}
|
|
65
|
+
//
|
|
66
|
+
// Extra documents in the destination database should be removed,
|
|
67
|
+
// as they are not present in the source database.
|
|
68
|
+
const deleteStepTimer = new Timer();
|
|
69
|
+
if (extraDocumentIds.length > 0 && deleteDestinationDbFn) {
|
|
70
|
+
console.info(`Deleting ${extraDocumentIds.length} extra documents in the Destination database...`);
|
|
71
|
+
await deleteDestinationDbFn(extraDocumentIds);
|
|
72
|
+
console.info(`Deleted ${extraDocumentIds.length} extra documents in the Destination database. (${deleteStepTimer.get()})`);
|
|
73
|
+
}
|
|
74
|
+
//
|
|
75
|
+
// After syncing the missing documents,
|
|
76
|
+
// run the onComplete callback function if provided.
|
|
77
|
+
if (onCompleteCallbackFn) {
|
|
78
|
+
console.info(`Running onComplete callback function...`);
|
|
79
|
+
await onCompleteCallbackFn();
|
|
80
|
+
}
|
|
81
|
+
console.info(`Replication complete (${globalTimer.get()})`);
|
|
82
|
+
//
|
|
83
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type TimeSlot } from '@tmlmobilidade/go-types-shared';
|
|
2
|
+
interface RunOnIntervalOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Interval in milliseconds between the end of one invocation
|
|
5
|
+
* and the start of the next. The first invocation happens immediately.
|
|
6
|
+
* @required
|
|
7
|
+
*/
|
|
8
|
+
intervalMs: number | TimeSlot;
|
|
9
|
+
/**
|
|
10
|
+
* Whether to throw errors after logging them.
|
|
11
|
+
* If true, errors are logged and then re-thrown, stopping code execution.
|
|
12
|
+
* @default false
|
|
13
|
+
*/
|
|
14
|
+
throwOnError?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Runs an asynchronous function at regular intervals, ensuring that each invocation
|
|
18
|
+
* completes before the next one starts. Errors are logged and can optionally be re-thrown.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runOnInterval(fn: () => Promise<void>, options: RunOnIntervalOptions): Promise<void>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/* * */
|
|
2
|
+
import { TimeSlotMap } from '@tmlmobilidade/go-types-shared';
|
|
3
|
+
/**
|
|
4
|
+
* Runs an asynchronous function at regular intervals, ensuring that each invocation
|
|
5
|
+
* completes before the next one starts. Errors are logged and can optionally be re-thrown.
|
|
6
|
+
*/
|
|
7
|
+
export async function runOnInterval(fn, options) {
|
|
8
|
+
//
|
|
9
|
+
//
|
|
10
|
+
// Validate and determine the interval in milliseconds,
|
|
11
|
+
// or throw an error if the provided interval is invalid.
|
|
12
|
+
let intervalMs;
|
|
13
|
+
if (typeof options.intervalMs === 'number')
|
|
14
|
+
intervalMs = options.intervalMs;
|
|
15
|
+
else if (!TimeSlotMap[options.intervalMs])
|
|
16
|
+
throw new Error(`Invalid TimeSlot: ${options.intervalMs}`);
|
|
17
|
+
else
|
|
18
|
+
intervalMs = TimeSlotMap[options.intervalMs];
|
|
19
|
+
//
|
|
20
|
+
// Define the runner function that will execute
|
|
21
|
+
// the provided function and schedule the next execution.
|
|
22
|
+
const runner = async () => {
|
|
23
|
+
try {
|
|
24
|
+
await fn();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error('Error in runOnInterval:', error);
|
|
28
|
+
if (options.throwOnError)
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
setTimeout(runner, intervalMs);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
await runner();
|
|
36
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run an async function over all items in the array, with a specific concurrency limit.
|
|
3
|
+
* Each result is a { status, value } or { status, reason } object, as from Promise.allSettled().
|
|
4
|
+
* The pool will never run more than `concurrency` functions at once.
|
|
5
|
+
* @param items - The array of items to process.
|
|
6
|
+
* @param concurrency - The maximum number of concurrent executions.
|
|
7
|
+
* @param fn - Async function to run for each item. Receives the item and its index.
|
|
8
|
+
* @returns A Promise resolving to an array of PromiseSettledResult objects, preserving input order.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runWithConcurrency<T, R>(items: T[], concurrency: number, fn: (item: T, index: number) => Promise<R>): Promise<PromiseSettledResult<R>[]>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run an async function over all items in the array, with a specific concurrency limit.
|
|
3
|
+
* Each result is a { status, value } or { status, reason } object, as from Promise.allSettled().
|
|
4
|
+
* The pool will never run more than `concurrency` functions at once.
|
|
5
|
+
* @param items - The array of items to process.
|
|
6
|
+
* @param concurrency - The maximum number of concurrent executions.
|
|
7
|
+
* @param fn - Async function to run for each item. Receives the item and its index.
|
|
8
|
+
* @returns A Promise resolving to an array of PromiseSettledResult objects, preserving input order.
|
|
9
|
+
*/
|
|
10
|
+
export async function runWithConcurrency(items, concurrency, fn) {
|
|
11
|
+
if (items.length === 0)
|
|
12
|
+
return [];
|
|
13
|
+
const results = new Array(items.length);
|
|
14
|
+
let next = 0;
|
|
15
|
+
async function worker() {
|
|
16
|
+
while (next < items.length) {
|
|
17
|
+
const index = next++;
|
|
18
|
+
try {
|
|
19
|
+
results[index] = { status: 'fulfilled', value: await fn(items[index], index) };
|
|
20
|
+
}
|
|
21
|
+
catch (reason) {
|
|
22
|
+
results[index] = { reason, status: 'rejected' };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
|
|
27
|
+
return results;
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tmlmobilidade/go-utils-exec",
|
|
3
|
+
"version": "20260828.1700.4",
|
|
4
|
+
"author": {
|
|
5
|
+
"email": "iso@tmlmobilidade.pt",
|
|
6
|
+
"name": "TML-ISO"
|
|
7
|
+
},
|
|
8
|
+
"license": "AGPL-3.0-or-later",
|
|
9
|
+
"homepage": "https://go.tmlmobilidade.pt",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/tmlmobilidade/go/issues"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/tmlmobilidade/go.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"public transit",
|
|
19
|
+
"tml",
|
|
20
|
+
"transportes metropolitanos de lisboa",
|
|
21
|
+
"go"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc && resolve-tspaths",
|
|
34
|
+
"lint": "eslint ./src/ && tsc --noEmit",
|
|
35
|
+
"lint:fix": "eslint ./src/ --fix",
|
|
36
|
+
"watch": "tsc-watch --onSuccess 'resolve-tspaths'"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@tmlmobilidade/go-types-geo": "*",
|
|
40
|
+
"@tmlmobilidade/go-types-shared": "*",
|
|
41
|
+
"@tmlmobilidade/go-utils-dates": "*",
|
|
42
|
+
"@tmlmobilidade/timer": "*"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@tmlmobilidade/go-utils-tsconfig": "*",
|
|
46
|
+
"@types/node": "26.1.2",
|
|
47
|
+
"resolve-tspaths": "0.8.23",
|
|
48
|
+
"tsc-watch": "7.2.1",
|
|
49
|
+
"typescript": "6.0.3"
|
|
50
|
+
}
|
|
51
|
+
}
|