From 6a0e0b3ed61eeda4af083b7640ef7f11d730e715 Mon Sep 17 00:00:00 2001 From: Willian Agostini Date: Thu, 2 May 2024 00:12:47 -0300 Subject: [PATCH 1/4] feat: method for insertMany itens on postgres --- package-lock.json | 9 +++++++++ package.json | 3 ++- src/background-task.js | 16 +++++++--------- src/db.js | 10 ++++++++++ 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8ad697b..e61eb40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "draftlog": "^1.0.13", "mongodb": "^6.5.0", "pg": "^8.11.5", + "pg-format": "^1.0.4", "sqlite3": "^5.1.7" }, "devDependencies": { @@ -1066,6 +1067,14 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" }, + "node_modules/pg-format": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz", + "integrity": "sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", diff --git a/package.json b/package.json index f07edea..7fac7e4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "draftlog": "^1.0.13", "mongodb": "^6.5.0", "pg": "^8.11.5", + "pg-format": "^1.0.4", "sqlite3": "^5.1.7" } -} \ No newline at end of file +} diff --git a/src/background-task.js b/src/background-task.js index 0069dd1..8387b74 100644 --- a/src/background-task.js +++ b/src/background-task.js @@ -5,13 +5,11 @@ const db = await getPostgresConnection() process.on('message', (items) => { // console.log(` ${process.pid} received ${items.length} items`,); - for (const item of items) { - db.students.insert(item) - .then(() => { - process.send('item-done'); - }) - .catch((error) => { - console.error(error); - }); - } + db.students.insertMany(items) + .then(() => { + process.send(items.length); + }) + .catch((error) => { + console.error(error); + }); }); diff --git a/src/db.js b/src/db.js index aa37a0c..23f4073 100644 --- a/src/db.js +++ b/src/db.js @@ -1,5 +1,6 @@ import { MongoClient } from 'mongodb'; import pg from 'pg'; +import format from 'pg-format'; const { Client } = pg; // Connection URL for MongoDB @@ -47,6 +48,15 @@ async function getPostgresConnection() { await client.query(query, values); + }, + async insertMany(persons) { + const query = format( + 'INSERT INTO students (name, email, age, registered_at) VALUES %L', + persons.map((person) => [person.name, person.email, person.age, person.registered_at]) + ); + + await client.query(query); + }, async list(limit = 100) { const query = 'SELECT * FROM students LIMIT $1'; From 1ea85ab9d2280ac9f6801472d17b103ec2f76acc Mon Sep 17 00:00:00 2001 From: Willian Agostini Date: Thu, 2 May 2024 00:23:23 -0300 Subject: [PATCH 2/4] feat: Added streaming for fetching and aggregating MongoDB data for batch insertion. --- src/cluster.js | 1 - src/data-streaming.js | 12 ++++++++++++ src/index.js | 45 ++++++++++++++++++------------------------- src/stram-cache.js | 33 +++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 27 deletions(-) create mode 100644 src/data-streaming.js create mode 100644 src/stram-cache.js diff --git a/src/cluster.js b/src/cluster.js index ade430e..6c01088 100644 --- a/src/cluster.js +++ b/src/cluster.js @@ -26,7 +26,6 @@ function initializeCluster({ backgroundTaskFile, clusterSize, onMessage }) { }) child.on('message', (message) => { - if (message !== 'item-done') return onMessage(message) }) diff --git a/src/data-streaming.js b/src/data-streaming.js new file mode 100644 index 0000000..a66455e --- /dev/null +++ b/src/data-streaming.js @@ -0,0 +1,12 @@ +import { StreamCache } from './stram-cache.js' +import { getMongoConnection } from './db.js' + +const ITEMS_PER_PAGE = 4000 + +const mongoDB = await getMongoConnection() +const stream = mongoDB.students.find().stream() +const cache = new StreamCache(stream, ITEMS_PER_PAGE) + +cache.stream().on('data', (data) => { + process.send(JSON.parse(data)); +}); diff --git a/src/index.js b/src/index.js index e954588..df80f6f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,27 +1,16 @@ import { initialize } from "./cluster.js" import { getMongoConnection, getPostgresConnection } from './db.js' import cliProgress from 'cli-progress' -import { setTimeout } from 'node:timers/promises' const mongoDB = await getMongoConnection() const postgresDB = await getPostgresConnection() -const ITEMS_PER_PAGE = 4000 -const CLUSTER_SIZE = 99 +// const ITEMS_PER_PAGE = 4000 +const CLUSTER_SIZE = 8 const TASK_FILE = new URL('./background-task.js', import.meta.url).pathname +const DATA_STREAMING_FILE = new URL('./data-streaming.js', import.meta.url).pathname // console.log(`there was ${await postgresDB.students.count()} items on Postgres, deleting all...`) await postgresDB.students.deleteAll() -async function* getAllPagedData(itemsPerPage, page = 0) { - - const data = mongoDB.students.find().skip(page).limit(itemsPerPage) - const items = await data.toArray() - if (!items.length) return - - yield items - - yield* getAllPagedData(itemsPerPage, page += itemsPerPage) -} - const total = await mongoDB.students.countDocuments() // console.log(`total items on DB: ${total}`) @@ -37,25 +26,29 @@ const cp = initialize( backgroundTaskFile: TASK_FILE, clusterSize: CLUSTER_SIZE, amountToBeProcessed: total, - async onMessage(message) { - progress.increment() + async onMessage(cumulativeProcessed) { + totalProcessed += cumulativeProcessed; + progress.update(totalProcessed); - if (++totalProcessed !== total) return + if (totalProcessed !== total) return // console.log(`all ${amountToBeProcessed} processed! Exiting...`) progress.stop() cp.killAll() - const insertedOnSQLite = await postgresDB.students.count() - console.log(`total on MongoDB ${total} and total on PostGres ${insertedOnSQLite}`) - console.log(`are the same? ${total === insertedOnSQLite ? 'yes' : 'no'}`) + const insertedOnSQLPostGres = await postgresDB.students.count() + console.log(`total on MongoDB ${total} and total on PostGres ${insertedOnSQLPostGres}`) + console.log(`are the same? ${total === insertedOnSQLPostGres ? 'yes' : 'no'}`) process.exit() - } } ) -await setTimeout(1000) - -for await (const data of getAllPagedData(ITEMS_PER_PAGE)) { - cp.sendToChild(data) -} +initialize( + { + backgroundTaskFile: DATA_STREAMING_FILE, + clusterSize: 1, + async onMessage(message) { + cp.sendToChild(message) + } + } +) diff --git a/src/stram-cache.js b/src/stram-cache.js new file mode 100644 index 0000000..027a089 --- /dev/null +++ b/src/stram-cache.js @@ -0,0 +1,33 @@ +import { Readable } from 'stream'; + +export class StreamCache { + constructor(inputStream, cacheThreshold = 4000) { + this.cacheStream = new Readable({ + read() { } + }); + this.cache = []; + this.cacheThreshold = cacheThreshold; + inputStream.on('data', this._addDataToCache); + inputStream.on('end', () => { + this._emitCache(); + this.cacheStream.emit('end'); + }); + } + + _addDataToCache = (data) => { + this.cache.push(data); + if (this.cache.length >= this.cacheThreshold) { + this._emitCache(); + } + } + + _emitCache() { + if (!this.cache.length) return + this.cacheStream.push(JSON.stringify(this.cache)); + this.cache = []; + } + + stream() { + return this.cacheStream; + } +} From 1a90711c9b30272663b742753526934856dab0b0 Mon Sep 17 00:00:00 2001 From: Willian Agostini Date: Fri, 3 May 2024 12:42:23 -0300 Subject: [PATCH 3/4] fix: changed CLUSTER_SIZE according to the number of cpus --- src/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index df80f6f..1ea7814 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,12 @@ import { initialize } from "./cluster.js" import { getMongoConnection, getPostgresConnection } from './db.js' import cliProgress from 'cli-progress' +import os from 'os'; + const mongoDB = await getMongoConnection() const postgresDB = await getPostgresConnection() // const ITEMS_PER_PAGE = 4000 -const CLUSTER_SIZE = 8 +const CLUSTER_SIZE = os.cpus().length const TASK_FILE = new URL('./background-task.js', import.meta.url).pathname const DATA_STREAMING_FILE = new URL('./data-streaming.js', import.meta.url).pathname From 644bb49e57088cd4a2f6577b0d2661d72b665fe7 Mon Sep 17 00:00:00 2001 From: Willian Agostini Date: Fri, 3 May 2024 12:57:35 -0300 Subject: [PATCH 4/4] docs: add image for benchmarking --- README.md | 2 +- assets/benchmarking.png | Bin 0 -> 12208 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 assets/benchmarking.png diff --git a/README.md b/README.md index fbe0ed7..1d8f26b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Example of [how to Migrate 1M items from MongoDB to Postgres in just a few minutes](https://youtu.be/EnK8-x8L9TY) using Node.js child process **First leave your star in the repo 🌟** -![Aumentando em 999x a velocidade de processamento de dados com Node](https://github.com/ErickWendel/parallelizing-nodejs-ops/assets/8060102/6974de93-7848-477a-9198-9d99dedc18f3) +![Aumentando em 999x a velocidade de processamento de dados com Node](./assets/benchmarking.png) ## Running diff --git a/assets/benchmarking.png b/assets/benchmarking.png new file mode 100644 index 0000000000000000000000000000000000000000..1e0d9b201c80f71ded7018593f74082ac65d189c GIT binary patch literal 12208 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 literal 0 HcmV?d00001