0

I have been struggling with this for a few hours now. What I am essentially doing is move files based on their names, in link with a SQLite database. This is in an Electron application where I use IPC. When the renderer sends a message to the main process to move files, it then listens for an answer before displaying it in the application.

I have here very naively incremented a counter in multiple places. What I wish to do is count how many files will be moved or have been moved so that I can inform the user. The problem is that no matter what I try (Promises, async, await), the counter is never correct when it is sent. It is always 0 because the message is sent before it's incremented.

In short, what I need to do is wait for the counter to be fully incremented before sending a message. For that, the logical thing would be to wait for dir.files() or files.forEach() to be done but I haven't been able to get any of that to work. A note: printing the counter in fs.access() right after the incrementation, the values are correct (1, 2, 3, ...), printing it outside of that asynchronous call always prints 0.

Here is the full code:

ipcMain.on("moveFiles", (event, moveTo, moveFrom, databaseFile, doOverwrite, doRecursive, subFolder, doTestRun) => {
    let filesMoved = 0;

    const db = new sqlite3.Database(databaseFile, sqlite3.OPEN_READONLY, (cErr) => {
        if (cErr) throw cErr;

        let query = `...`;

        // Match files based on their name
        let regex = /.../;

        // Get data from the database in one query
        db.all(query, [], (qErr, rows) => {
            if (qErr) throw qErr;

            // Assume this is filled like this from the query result:
            // dbNameToFolder["imgur"] = {"type": "picture", "local_folder: "Pictures"}
            let dbNameToFolder = {};

            // List all files (recursively or not, based on parameter)
            dir.files(moveFrom, "file", function(error, files) {
                if (error) throw error;

                // For each file, check if it should be moved
                files.forEach(function(file) {
                    let filename = path.basename(file);

                    // Run the filename against the regex
                    let matches = regex.exec(filename);

                    // If the filename matched
                    if (matches !== null && matches.length > 1 && dbNameToFolder.hasOwnProperty(matches[2])) {
                        let newPath = path.join(moveTo, dbNameToFolder[matches[2]]["type"], dbNameToFolder[matches[2]]["local_folder"], subFolder, filename);

                        // If this a test run, simply inform the user of which files will be moved
                        if (doTestRun) {
                            // If the user didn't ask for files to be overwritten
                            if (!doOverwrite) {
                                // Check if there already is a file at the newly defined path
                                fs.access(newPath, fs.F_OK, (aErr) => {
                                    // If aErr, target file doesn't exist, we can move it freely
                                    if (aErr) {
                                        console.log("Will move ", file, " to ", newPath);
                                        filesMoved++;
                                    } else {
                                        console.log("Moving ", file, " would overwrite ", newPath);
                                    }
                                });
                            } else {
                                console.log("Will move ", file, " to ", newPath);
                                filesMoved++;
                            }
                        } else {
                            // Move files asynchronously
                            (async () => {
                                await moveFile(file, newPath, {
                                    overwrite: doOverwrite
                                });
                                console.log(file, " has been moved to ", newPath);
                                filesMoved++;
                            })();
                        }
                    }
                });
            }, {
                recursive: doRecursive
            });
        });
    });

    db.close();

    mainWindow.webContents.send("moveFilesResult", filesMoved);
});

And here is an attempt I made.

ipcMain.on("moveFiles", (event, moveTo, moveFrom, databaseFile, doOverwrite, doRecursive, subFolder, doTestRun) => {
    const db = new sqlite3.Database(databaseFile, sqlite3.OPEN_READONLY, (cErr) => {
        db.all(query, [], async (qErr, rows) => {
            const run = () => {
                return new Promise(resolve => {
                    let filesMoved = 0;

                    dir.files(moveFrom, "file", function (error, files) {
                        files.forEach(function (file) {
                            let filename = path.basename(file);
                            let matches = regex.exec(filename);

                            if (matches !== null && matches.length > 1 && dbNameToFolder.hasOwnProperty(matches[2])) {
                                let newPath = path.join(moveTo, dbNameToFolder[matches[2]]["type"], dbNameToFolder[matches[2]]["local_folder"], subFolder, filename);

                                if (doTestRun) {
                                    if (!doOverwrite) {
                                        fs.access(newPath, fs.F_OK, (aErr) => {
                                            if (aErr) {
                                                console.log("Will move ", file, " to ", newPath);
                                                filesMoved++;
                                            } else {
                                                console.log("Moving ", file, " would overwrite ", newPath);
                                            }
                                        });
                                    } else {
                                        console.log("Will move ", file, " to ", newPath);
                                        filesMoved++;
                                    }
                                } else {
                                    (async () => {
                                        await moveFile(file, newPath, {
                                            overwrite: doOverwrite
                                        });
                                        console.log(file, " has been moved to ", newPath);
                                        filesMoved++;
                                    })();
                                }
                            }
                        });
                    }, {
                        recursive: doRecursive
                    });

                    resolve(filesMoved);
                });
            };

            let filesMoved = await run();
            console.log(filesMoved);
            mainWindow.webContents.send("moveFilesResult", filesMoved);
        });
    });

    db.close();
});

So the goal here is to wait for all operations on files to be done before sending back a message containing any data relating to what was just done (a counter or the list of files, for example). Are race conditions a thing in NodeJS? How would I go about doing this the right way?

Keagel
  • 21
  • 1
  • 6
  • If you want to use promises and async/await, then never pass async callbacks to functions. Promisify them. And [avoid `forEach`](https://stackoverflow.com/q/37576685/1048572). – Bergi Apr 13 '20 at 21:23
  • This is a beautiful example of how code could be rewritten massively simpler if you converted ALL your asynchronous operations to promises. Then, add in `await` and things could be quite a bit more straightforward. It will take some work to move all your asynchronous operations over to promises, but nearly all common libraries in NPM have promise interfaces now (like databases) and nodejs has a promise interface for the `fs` module built-in. Rather than try to "fix" this complicated, I'd take the opportunity to move to promises and have a much simpler implementation. – jfriend00 Apr 13 '20 at 21:24
  • And, when moving to promises, move everything, not just some. As soon as you mix in a plain callback, you lose the whole control over the flow that promises give you. It really is all or nothing. – jfriend00 Apr 13 '20 at 21:25
  • Thanks to both of you for your comments. @jfriend00 I have posted an answer, is this proper use of promises? Is there anything I'm doing wrong? Thanks! – Keagel Apr 14 '20 at 09:30

1 Answers1

0

I converted all my asynchronous callbacks to promises and finally got it working, it seems much clearer now. Here is the code for anyone stumbling on this:

First version

ipcMain.on("moveFiles", (event, moveTo, moveFrom, databaseFile, doOverwrite, doRecursive, subFolder, doTestRun) => {
    const db = new sqlite3.Database(databaseFile, sqlite3.OPEN_READONLY, (cErr) => {
        if (cErr) {
            console.error("Could not connect to the database");
            throw cErr;
        } else {
            console.log("Connected to the database")
        }
    });

    let query = `...`;
    let regex = /.../;

    const getAll = (query, params = []) => {
        return new Promise((resolve, reject) => {
            db.all(query, params, function (qErr, rows) {
                if (qErr) {
                    console.error("Error running the query");
                    reject(qErr);
                } else {
                    resolve(rows);
                }
            });
        });
    };

    const getFiles = (path, type, options) => {
        return new Promise((resolve, reject) => {
            dir.files(path, type, function (fErr, files) {
                if (fErr) {
                    console.error("Error iterating through files");
                    reject(fErr);
                } else {
                    resolve(files);
                }
            }, options);
        });
    };

    // Resolves to false if the file doesn't exist, true if it does
    const checkFileExists = (path) => {
        return new Promise(resolve => {
            fs.access(path, fs.F_OK, function (aErr) {
                if (aErr) {
                    resolve(false);
                } else {
                    resolve(true);
                }
            });
        });
    };

    getAll(query).then(async function (rows) {
        let dbNameToFolder = {};

        for await (const row of rows) {
            dbNameToFolder[row["mediaType"]] = {
                "local_folder": row["local_folder"],
                "type": row["type"]
            };
        }

        return dbNameToFolder;
    }).then(function (assoc) {
        getFiles(moveFrom, "file", {recursive: doRecursive}).then(async function (files) {
            let filesToMove = [];

            for await (const file of files) {
                let filename = path.basename(file);
                let matches = regex.exec(filename);

                if (matches !== null && matches.length > 1 && assoc.hasOwnProperty(matches[2])) {
                    let newPath = path.join(moveTo, assoc[matches[2]]["type"], assoc[matches[2]]["local_folder"], subFolder, filename);

                    if (!doOverwrite) {
                        await checkFileExists(newPath).then(function(exists) {
                            if (!exists) {
                                console.log("Will move ", file, " to ", newPath);

                                filesToMove.push({
                                    "oldPath": file,
                                    "newPath": newPath
                                });
                            } else {
                                console.log("Moving ", file, " would overwrite ", newPath);
                            }
                        });
                    } else {
                        console.log("Will move ", file, " to ", newPath);

                        await filesToMove.push({
                            "oldPath": file,
                            "newPath": newPath
                        });
                    }
                }
            }

            return filesToMove;
        }).then(function(filesToMove) {
            mainWindow.webContents.send("moveFilesResult", filesToMove);

            if (doTestRun) {
                console.log("Not moving files");
            } else {
                console.log("Moving files");

                for (const file of filesToMove) {
                    (async () => {
                        await moveFile(file["oldPath"], file["newPath"], {
                            overwrite: doOverwrite
                        });
                    })();
                }
            }
        });
    });
});

I don't need a counter anymore, I simply get the length of the array in my renderer process. Am I making proper use of promises and await/async now?


Second version

This is the result after switching everything to await. I had to make the listener from ipcMain.on() async in order to be able to await. Maybe it's better practice to wrap all my await calls in a separate async block?

I have read that using async wraps the function in a Promise, and I needed to use await in the convertPaths function for the filesToMove array, is that alright?

I also removed the await on the second filesToMove.push() as it seemed unnecessary, but I'm not so sure.

You mentioned needing .catch() if I use .then(). Now that I don't use .then(), how do I handle the promise rejections other than with .catch()?

ipcMain.on("moveFiles", async (event, moveTo, moveFrom, databaseFile, doOverwrite, doRecursive, subFolder, doTestRun) => {
    const db = new sqlite3.Database(databaseFile, sqlite3.OPEN_READONLY, (cErr) => {
        if (cErr) {
            console.error("Could not connect to the database");
            throw cErr;
        } else {
            console.log("Connected to the database")
        }
    });

    let query = `...`;
    let regex = /.../;

    const getAll = (query, params = []) => {
        return new Promise((resolve, reject) => {
            db.all(query, params, function (qErr, rows) {
                if (qErr) {
                    console.error("Error running the query");
                    reject(qErr);
                } else {
                    resolve(rows);
                }
            });
        });
    };

    const getFiles = (path, type, options) => {
        return new Promise((resolve, reject) => {
            dir.files(path, type, function (fErr, files) {
                if (fErr) {
                    console.error("Error iterating through files");
                    reject(fErr);
                } else {
                    resolve(files);
                }
            }, options);
        });
    };

    const convertPaths = async (assoc, files) => {
        let filesToMove = [];

        for (const file of files) {
            let filename = path.basename(file);
            let matches = regex.exec(filename);

            if (matches !== null && matches.length > 1 && assoc.hasOwnProperty(matches[2])) {
                let newPath = path.join(moveTo, assoc[matches[2]]["type"], assoc[matches[2]]["local_folder"], subFolder, filename);

                if (!doOverwrite) {
                    await fs.promises.access(newPath, fs.constants.F_OK).then(() => console.log("Moving ", file, " would overwrite ", newPath)).catch(function () {
                        // If we catch an error, it's because the file doesn't exist, so we can move it without overwriting
                        console.log("Will move ", file, " to ", newPath);

                        filesToMove.push({
                            "oldPath": file,
                            "newPath": newPath
                        });
                    });
                } else {
                    console.log("Will move ", file, " to ", newPath);

                    filesToMove.push({
                        "oldPath": file,
                        "newPath": newPath
                    });
                }
            }
        }

        return filesToMove;
    };

    let rows = await getAll(query);
    let assoc = {};

    for (const row of rows) {
        assoc[row["mediaType"]] = {
            "local_folder": row["local_folder"],
            "type": row["type"]
        };
    }

    let files = await getFiles(moveFrom, "file", { recursive: doRecursive });
    let filesToMove = await convertPaths(assoc, files);

    if (!doTestRun) {
        for (const file of filesToMove) {
            await moveFile(file["oldPath"], file["newPath"], { overwrite: doOverwrite });
        }

        console.log("Done moving files");
    } else {
        console.log("Not moving files");
    }

    mainWindow.webContents.send("moveFilesResult", filesToMove);
});

Third version

I have also made a third version where I remove (what I think are) unnecessary functions - and added a Promise for the Database connection. How does that look now?

ipcMain.on("moveFiles", async (event, moveTo, moveFrom, databaseFile, doOverwrite, doRecursive, subFolder, doTestRun) => {
    let query = `...`;
    let regex = /.../;

    const connect = (databaseFile, mode) => {
        return new Promise((resolve, reject) => {
            const db = new sqlite3.Database(databaseFile, mode, function (cErr) {
                if (cErr) {
                    reject(cErr);
                } else {
                    resolve(db);
                }
            });
        });
    };

    const getAll = (db, query, params = []) => {
        return new Promise((resolve, reject) => {
            db.all(query, params, function (qErr, rows) {
                if (qErr) {
                    console.error("Error running the query");
                    reject(qErr);
                } else {
                    resolve(rows);
                }
            });
        });
    };

    const getFiles = (path, type, options) => {
        return new Promise((resolve, reject) => {
            dir.files(path, type, function (fErr, files) {
                if (fErr) {
                    console.error("Error iterating through files");
                    reject(fErr);
                } else {
                    resolve(files);
                }
            }, options);
        });
    };

    let db = await connect(databaseFile, sqlite3.OPEN_READONLY);
    let rows = await getAll(db, query);
    let assoc = {};

    for (const row of rows) {
        assoc[row["mediaType"]] = {
            "local_folder": row["local_folder"],
            "type": row["type"]
        };
    }

    let files = await getFiles(moveFrom, "file", { recursive: doRecursive });

    let filesToMove = [];

    for (const file of files) {
        let filename = path.basename(file);
        let matches = regex.exec(filename);

        if (matches === null || matches.length <= 1) continue;
        if (!assoc.hasOwnProperty(matches[2])) continue;

        let newPath = path.join(moveTo, assoc[matches[2]]["type"], assoc[matches[2]]["local_folder"], subFolder, filename);

        if (!doOverwrite) {
            await fs.promises.access(newPath, fs.constants.F_OK)
                .then(() => console.log("Moving ", file, " would overwrite ", newPath))
                .catch(function () {
                // If we catch an error, it's because the file doesn't exist, so we can move it without overwriting
                console.log("Will move ", file, " to ", newPath);

                filesToMove.push({
                    "oldPath": file,
                    "newPath": newPath
                });
            });
        } else {
            console.log("Will move ", file, " to ", newPath);

            filesToMove.push({
                "oldPath": file,
                "newPath": newPath
            });
        }
    }

    if (!doTestRun) {
        for (const file of filesToMove) {
            await moveFile(file["oldPath"], file["newPath"], { overwrite: doOverwrite }).catch(function(mErr) {
                console.error(mErr);
            });
        }

        console.log("Done moving files");
    } else {
        console.log("Not moving ", filesToMove.length, " files");
    }

    mainWindow.webContents.send("moveFilesResult", filesToMove);
});
Keagel
  • 21
  • 1
  • 6
  • You are making progress. You are complicating things by using `await` in some places and `.then()` in some places. If you're going for `await`, just use it everywhere. It will get rid of a lot of nesting. And, get rid of this artificial wrapping in an async IIFE. It does you no good at all. Also, `for await (...)` is for async iterators. I don't think you have such a thing so that's not doing you anything useful. Just use a regular `for` loop. – jfriend00 Apr 14 '20 at 10:09
  • For `fs` operations, node.js has built-in promises like `fs.promises.access()` so no need to make your own promises. Just use the promise version. – jfriend00 Apr 14 '20 at 10:10
  • Also, your `throw cErr;` will not work. Throwing from inside a plain asynchronous callback won't be something you can catch anywhere. You should promisify that too so you can handle the error like everything else. – jfriend00 Apr 14 '20 at 10:12
  • I also see a lot of missing error handling. If you're going to use `await` everywhere and have no nested functions (which is probably the best way to go), then all rejections or exceptions will pop up to the same place and returned to the caller as a rejected promise. But, when you start using `.then()` lots of places, then you need `.catch()`. So, just replace all the `.then()` with `let val = await fn()` – jfriend00 Apr 14 '20 at 10:14
  • Thank you for all the help @jfriend00. I have made some changes and I have some questions up in the post if you have some time to answer them. I think all that's left to do is catch the possible `Promise` rejections. – Keagel Apr 15 '20 at 07:19
  • Big progress. Does it seem to do what you want it to do? Does `moveFile()` return a promise that is connected to when that operation is done? What do you expect to happen if any of your `await` calls get an error? Right now, it will just abort whatever it was doing and maybe report an unhandled rejection. It seems like you probably want something more controlled than that. In case you didn't realize, you can catch all errors from all your `await` statements with a top level `try/catch` in your function (if that's all you want to do is abort and log the error). – jfriend00 Apr 15 '20 at 15:46
  • It does what I want and works nicely. `moveFile` isn't actually a function, I'm using IPC and `moveFile` is the channel used, so I don't actually connect the `Promise` to anything. In my renderer process I do `window.api.receive("moveFilesResult", (files) => { ... });`. I suppose it doesn't make much sense to have the callback from that listener be `async` but I can't use `await` otherwise. If any error happens I'd prefer to report it back to the user, so most likely I'll have to `.catch()` them individually. – Keagel Apr 15 '20 at 15:58
  • Right here: `await moveFile(file["oldPath"], file["newPath"], { overwrite: doOverwrite })`, `moveFile()` must be a function. That's what I was asking about. – jfriend00 Apr 15 '20 at 16:37
  • Oh that's right, sorry. I'm not doing anything with that Promise. I just want files to be all moved before sending the message with the list of files. Do I not need `await` if I don't do anything with the Promise? – Keagel Apr 15 '20 at 16:48
  • You need `await` if you want the code to wait for the promise before advancing even if you're not using a resolved value. And, you need either `await` or `.catch()` if you want to know if there are errors in that promise. What I asked was whether `moveFiles()` returns a promise that is linked to it's completion or error? That's what I asked. – jfriend00 Apr 15 '20 at 16:51
  • You said `moveFiles()` but I'll assume you're still talking about what you mentioned just before - `moveFile()` (sorry for the confusion, I named my listener before using that function). This is actually a function from the package `move-file` which returns a Promise that resolves if the file has been moved and rejects if there's been an error. – Keagel Apr 15 '20 at 16:57
  • Well, then it just looks like you need more granular error handling if you want to report specific errors back to the user and/or continue processing after errors. – jfriend00 Apr 15 '20 at 17:12