From 7ac3e6ad06dacaabc1ca505bb7a7a47ff45e82d6 Mon Sep 17 00:00:00 2001 From: Matt Low Date: Sat, 20 Feb 2021 18:57:18 -0700 Subject: [PATCH] Use worker threads for sudoku generation Moves sudoku generation off of the main thread. Allows for multiple generation requests (up to the number of physical CPU cores) to be served in parallel. Uses the "physical-cpu-count" node package to determine the numer of physical CPUs of the host. Also introduces a timeout which causes too-difficult (or impossible) generation requests to fail if they take more than 5 seconds to complete. In effect soft-capping the complexity of generated puzzles. --- package-lock.json | 20 ++++++++++++- package.json | 3 ++ src/graphql/sudoku.ts | 8 +----- src/sudoku/index.ts | 66 ++++++++++++++++++++++++++++++++++--------- src/sudoku/math.ts | 1 - src/sudoku/worker.js | 8 ++++++ src/utils.ts | 0 7 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 src/sudoku/worker.js delete mode 100644 src/utils.ts diff --git a/package-lock.json b/package-lock.json index aa59083..d382af8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -276,7 +276,14 @@ "@types/node": { "version": "14.14.31", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.31.tgz", - "integrity": "sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==" + "integrity": "sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==", + "dev": true + }, + "@types/physical-cpu-count": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/physical-cpu-count/-/physical-cpu-count-2.0.0.tgz", + "integrity": "sha512-kSK757BMb0j/7uP8+UR2wQ7GqrF9+Zk99XvAG4K/sL1VCFEqFoKic3xRHUttsGuTR0WMC3BjOTrf6dUIU+2CmQ==", + "dev": true }, "@types/qs": { "version": "6.9.5", @@ -304,6 +311,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@types/stoppable/-/stoppable-1.1.0.tgz", "integrity": "sha512-BRR23Q9CJduH7AM6mk4JRttd8XyFkb4qIPZu4mdLF+VoP+wcjIxIWIKiBbN78NBbEuynrAyMPtzOHnIp2B/JPQ==", + "dev": true, "requires": { "@types/node": "*" } @@ -1336,6 +1344,11 @@ "tslib": "^2.0.3" } }, + "node-worker-threads-pool": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/node-worker-threads-pool/-/node-worker-threads-pool-1.4.3.tgz", + "integrity": "sha512-US55ZGzEDQY2oq8Bc33dFVNKGpx4KaCJqThMDomSsUeX8tMdp2eDjQ6OP0yFd1HTEuHuLqxXSTWC4eidEsbXlg==" + }, "nodemon": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.7.tgz", @@ -1450,6 +1463,11 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.0.tgz", "integrity": "sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==" }, + "physical-cpu-count": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/physical-cpu-count/-/physical-cpu-count-2.0.0.tgz", + "integrity": "sha1-GN4vl+S/epVRrXURlCtUlverpmA=" + }, "picomatch": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", diff --git a/package.json b/package.json index 0e145cf..3d83823 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "koa": "^2.13.1", "koa-bodyparser": "^4.3.0", "koa-router": "^9.4.0", + "node-worker-threads-pool": "^1.4.3", + "physical-cpu-count": "^2.0.0", "stoppable": "^1.1.0" }, "devDependencies": { @@ -20,6 +22,7 @@ "@types/koa-bodyparser": "^4.3.0", "@types/koa-router": "^7.4.1", "@types/markdown-it": "^10.0.3", + "@types/physical-cpu-count": "^2.0.0", "@types/stoppable": "^1.1.0", "nodemon": "^2.0.7", "ts-node": "^8.10.2", diff --git a/src/graphql/sudoku.ts b/src/graphql/sudoku.ts index a0258b4..3dbee07 100644 --- a/src/graphql/sudoku.ts +++ b/src/graphql/sudoku.ts @@ -1,5 +1,5 @@ import { gql } from "../mods"; -import { generate } from "../sudoku/index"; +import { generate, GenerateArguments } from "../sudoku/index"; export const typeDefs = gql` type Cell { @@ -37,12 +37,6 @@ export const typeDefs = gql` } `; -type GenerateArguments = { - regionWidth: number; - regionHeight: number; - clues: number; -}; - export const resolvers = { Query: { generate: ( diff --git a/src/sudoku/index.ts b/src/sudoku/index.ts index 793c711..19d3807 100644 --- a/src/sudoku/index.ts +++ b/src/sudoku/index.ts @@ -1,4 +1,8 @@ import { SudokuMath } from "./math"; +import { StaticPool, isTimeoutError } from "node-worker-threads-pool"; + +import WORKERS from "physical-cpu-count"; +const TIMEOUT = 5000; export type Cell = { value: number | null }; export type CellArray = Cell[]; @@ -7,6 +11,12 @@ export type Column = CellArray; export type Region = CellArray; export type Puzzle = CellArray; +export type GenerateArguments = { + regionWidth: number; + regionHeight: number; + clues: number; +}; + export type Sudoku = { regionWidth: number; regionHeight: number; @@ -17,21 +27,51 @@ export type Sudoku = { regions: () => Region[]; }; -export function generate( +const pool = new StaticPool({ + size: WORKERS, + task: "./src/sudoku/worker.js", +}); + +const _math = new SudokuMath(0, 0); +let activeWorkers = 0; + +export async function generate( regionWidth: number, regionHeight: number, clues: number -): Sudoku { - const math = new SudokuMath(regionWidth, regionHeight); - const puzzle = math.generatePuzzle(clues); +): Promise { + if (activeWorkers >= WORKERS) { + throw new Error("No workers available. Please try again in a moment."); + } - return { - regionWidth, - regionHeight, - cells: math.boardCells, - raw: puzzle, - rows: () => math.regionsToRows(puzzle, true), - columns: () => math.regionsToCols(puzzle, true), - regions: () => math.chunkRegions(puzzle), - }; + try { + activeWorkers++; + const [math, puzzle] = await pool.exec( + { + regionWidth, + regionHeight, + clues, + }, + TIMEOUT + ); + + Object.assign(_math, math); + + return { + regionWidth, + regionHeight, + cells: math.boardCells, + raw: puzzle, + rows: () => _math.regionsToRows(puzzle, true), + columns: () => _math.regionsToCols(puzzle, true), + regions: () => _math.chunkRegions(puzzle), + }; + } catch (err) { + if (isTimeoutError(err)) { + throw new Error("Timed out. Try increasing the number of clues."); + } + throw err; + } finally { + activeWorkers--; + } } diff --git a/src/sudoku/math.ts b/src/sudoku/math.ts index 12f06e2..ba78c32 100644 --- a/src/sudoku/math.ts +++ b/src/sudoku/math.ts @@ -227,7 +227,6 @@ export class SudokuMath { } if (fails > removeNext.length) { fails = 0; - console.log("Backstepping.."); Array(removed.length) .fill(null) .forEach(() => replace()); diff --git a/src/sudoku/worker.js b/src/sudoku/worker.js new file mode 100644 index 0000000..3ad5614 --- /dev/null +++ b/src/sudoku/worker.js @@ -0,0 +1,8 @@ +const { SudokuMath } = require("./math"); +const { parentPort } = require("worker_threads"); + +parentPort.on("message", ({ regionWidth, regionHeight, clues }) => { + const math = new SudokuMath(regionWidth, regionHeight); + const puzzle = math.generatePuzzle(clues); + parentPort.postMessage([math, puzzle]); +}); diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index e69de29..0000000