@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.
@@ -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,4 @@
1
+ export * from './batch-writer.js';
2
+ export * from './perform-in-chunks.js';
3
+ export * from './perform-in-time-chunks.js';
4
+ export * from './replicate.js';
@@ -0,0 +1,4 @@
1
+ export * from './batch-writer.js';
2
+ export * from './perform-in-chunks.js';
3
+ export * from './perform-in-time-chunks.js';
4
+ export * from './replicate.js';
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export * from './batch/index.js';
2
+ export * from './lifecycle/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './batch/index.js';
2
+ export * from './lifecycle/index.js';
@@ -0,0 +1,2 @@
1
+ export * from './run-on-interval.js';
2
+ export * from './run-with-concurrency.js';
@@ -0,0 +1,2 @@
1
+ export * from './run-on-interval.js';
2
+ export * from './run-with-concurrency.js';
@@ -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
+ }