From 7d1a08c50c918b290d3d51cee69b31be344a97ad Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Sun, 1 Nov 2026 13:13:05 -0600 Subject: [PATCH 1/8] SNOMED CT: optional memory-mapped loading of .cache files (snomedLoad: mmap) With snomedLoad: mmap in the library yml, each SNOMED CT cache file is mapped read-only (libc mmap through koffi) instead of read into memory. Its pages are file-backed and shared with the page cache: loading an edition reads nothing up front, only the pages requests touch become resident, and the kernel can drop them under memory pressure and fault them back in. Where mapping is not available (Windows, 32-bit, or mmap fails) the file is read into memory as before. The default (read) is unchanged. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 356 +++++++++++++++++++++++++++++++++++++++++++ package.json | 3 + tx/cs/cs-snomed.js | 5 +- tx/library.js | 3 +- tx/sct/mmap.js | 61 ++++++++ tx/sct/structures.js | 18 ++- 6 files changed, 437 insertions(+), 9 deletions(-) create mode 100644 tx/sct/mmap.js diff --git a/package-lock.json b/package-lock.json index f796ffed..7be7b792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,9 @@ }, "engines": { "node": ">=24" + }, + "optionalDependencies": { + "koffi": "^3.3.6" } }, "node_modules/@babel/code-frame": { @@ -1522,6 +1525,326 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@koromix/koffi-android-arm64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.6.tgz", + "integrity": "sha512-wxsPuEMDrqOR5RUdABYFNDTabhh/xwDetCUDI+/CN93QF1T7Yeq218kvDWmYT/g1wV2kU1aYdRO4D7cH9PDskw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-android-x64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.6.tgz", + "integrity": "sha512-fHZE6ejYLmAEe2eou2uoG61HUtolY45RhQEsTKAKNI5eKSW7Z2Jh8eP5EE+3CVQSbzbaMeHXRTcgEQGk5uEF5Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.6.tgz", + "integrity": "sha512-++V4qscXff/Ya5cE6vOPOomcxk3T5JQu81Uc0fvoso3VVELcoHt2/dz9E0FY0EPyQNIRcWOVg7CgvsPCKId9lg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.6.tgz", + "integrity": "sha512-eOyit0eyfkmdXCNlOkG8cNtpQUx+dikr1QsozJdwm7/zH1u7KP5dRtmsaGUMLSUY7cVYSRTUStkwHBrHjBtuzw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.6.tgz", + "integrity": "sha512-7teGsq2BukkLZ60xxq4KyPxeJct51nV1G/MfNj8IoMTqlpd35w4aiIIoFWVwLL3j3DdT95Ye/FpVY4C7ZEuMHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.6.tgz", + "integrity": "sha512-WA7qEAMxTWS2WSFDceETVJTFrZgRwNvfG+FJpCC2XkJXftHwOhv8/X6d5HUEXh361sd7HwCHoT1wypL0nvI7Mw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.6.tgz", + "integrity": "sha512-OQ/eBhGefbtxcPs5VDkK9mwxoIeSwrIriOT1/JHHA/tbNAXdC0GiAYsBw82T4L4r2u0TuJ0CbgN3HrulzdPPig==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.6.tgz", + "integrity": "sha512-0uo15z9L7GsAEGvPCVKcXzCtRJCyghCYDrWO2L3vXk4G5lgk+OBvWS7RmlDZ99X/A0oDiL3vLCplGaBCEZZ8Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.6.tgz", + "integrity": "sha512-8LuJcdH74wTg7JcZjnRnb3z+qWPKcEDSv8HRPkWTVD56TFm0CXZXgl/WJ4maDbWem6ZoXxc87a8nQEA+wABzNA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.6.tgz", + "integrity": "sha512-aTOjTMdy2Q6JqNA9KkT2OaCs77d0TzEG5KipfinzDyiV9DkYyUCvFykIY5GDHCUKXyJi+xy1IfuUxeL64wzzBQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.6.tgz", + "integrity": "sha512-m33Zjrhj2Atpkcr0We61V2Zjm8wFb79QOtaOfzttmNFQMtryYpWZ4CBxx4gLKvcrR7NcK3GV/D9Qi2M0ppWK7A==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ppc64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.6.tgz", + "integrity": "sha512-L1+1cjH5GtNtv7TYWy469jsDberOOwCfE96h0AWFKrVHycDn8hTKH5fEA/er42R04lZiNWbiC0+sMb50enYRGw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.6.tgz", + "integrity": "sha512-VnzoNRg3MfTdGXLDtUrBj4wJnZD3buX6LajGrvYm5Mf1U132bzQwxP1ryZhs8Q0Cl1pTsN2fIovjNwpwE7Hs3A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.6.tgz", + "integrity": "sha512-RTRbVTZ1rf1GFowSsbuU2acYoRq66ZwJGh+rTgau1Cat3muv2884p0FRH6UKfwlgWkpjIHJ7N8N0liM7Oe2eAQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-arm64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.6.tgz", + "integrity": "sha512-eXHyRZw/BACrBIuntkqlNTa8p8i9KobtREJjhxldsVoEPRQTREnhBq5IbqbvXesY89MwFGn4lHmzG1x5ueV63A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.6.tgz", + "integrity": "sha512-xbJYOFY0IAIx3nyNdYad5g2f8cNAOY8OBcjPDYRlc7gkqQiPNER9ZA9g+M2LuVf48i65ZCn11XaWDSQymDJJBg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.6.tgz", + "integrity": "sha512-bNL4uIxUBhA93naaL3dWwXdLgQGAss6krDbwljO1uS3iJ+8K7KSo8EGbJfRYUf1L+ctaHTJuDwgZ1XTSEC9FQQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.6.tgz", + "integrity": "sha512-WDVsrvWAaHbA7xiCMXX3PBmfDf4+VnfY67ryRAW86QNW8109W9WPS3YVGGD2YoPswDmSZsMBVzzZCEFUrLdJuA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.6.tgz", + "integrity": "sha512-6hwsUGC7FCICiLdmEQwTK0+MOLmuraqWuVsUCBkdSCL6k62LQLScuTn2dzONFvN/H5EXq0zzX4ddgtq2abL/xQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.6.tgz", + "integrity": "sha512-g9KC6VKQLe88Tjgeubk3yhu0CjtqSeRq4XjWFWxUq4rsP0/wfLUBoeISvG60HoPmhYQp+U7hVd8Ms+amOMHDvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@lhncbc/ucum-lhc": { "version": "7.1.10", "resolved": "https://registry.npmjs.org/@lhncbc/ucum-lhc/-/ucum-lhc-7.1.10.tgz", @@ -6632,6 +6955,39 @@ "json-buffer": "3.0.1" } }, + "node_modules/koffi": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.6.tgz", + "integrity": "sha512-pPqCZg8o4laLvzxySGEju/T1Fd+eCJTVnJmcH6h1u98HFRoBQ9EokNuBSj3NxIQdzkEPRxPnHKjhBVkLeB+X0g==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-android-arm64": "3.3.6", + "@koromix/koffi-android-x64": "3.3.6", + "@koromix/koffi-darwin-arm64": "3.3.6", + "@koromix/koffi-darwin-x64": "3.3.6", + "@koromix/koffi-freebsd-arm64": "3.3.6", + "@koromix/koffi-freebsd-ia32": "3.3.6", + "@koromix/koffi-freebsd-x64": "3.3.6", + "@koromix/koffi-linux-arm": "3.3.6", + "@koromix/koffi-linux-arm64": "3.3.6", + "@koromix/koffi-linux-ia32": "3.3.6", + "@koromix/koffi-linux-loong64": "3.3.6", + "@koromix/koffi-linux-ppc64": "3.3.6", + "@koromix/koffi-linux-riscv64": "3.3.6", + "@koromix/koffi-linux-x64": "3.3.6", + "@koromix/koffi-openbsd-arm64": "3.3.6", + "@koromix/koffi-openbsd-ia32": "3.3.6", + "@koromix/koffi-openbsd-x64": "3.3.6", + "@koromix/koffi-win32-arm64": "3.3.6", + "@koromix/koffi-win32-ia32": "3.3.6", + "@koromix/koffi-win32-x64": "3.3.6" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", diff --git a/package.json b/package.json index b028e2da..415b9971 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,9 @@ "winston-daily-rotate-file": "^4.7.1", "yaml": "^2.8.2" }, + "optionalDependencies": { + "koffi": "^3.3.6" + }, "devDependencies": { "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.54.0", diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index 232ce853..c7460ca5 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -3994,9 +3994,10 @@ class SnomedProvider extends BaseCSServices { * Factory for creating SNOMED services and providers */ class SnomedServicesFactory extends CodeSystemFactoryProvider { - constructor(i18n, filePath) { + constructor(i18n, filePath, loadMode = 'read') { super(i18n); this.filePath = filePath; + this.loadMode = loadMode; // 'read' or 'mmap' - see SnomedFileReader this.uses = 0; this._loaded = false; this._sharedData = null; @@ -4176,7 +4177,7 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { } async load() { - const reader = new SnomedFileReader(this.filePath); + const reader = new SnomedFileReader(this.filePath, this.loadMode); this._sharedData = await reader.loadSnomedData(); this.snomedServices = new SnomedServices(this._sharedData); this._loaded = true; diff --git a/tx/library.js b/tx/library.js index 1e769d40..6a37156a 100644 --- a/tx/library.js +++ b/tx/library.js @@ -185,6 +185,7 @@ class Library { const yamlContent = await fs.readFile(yamlPath, 'utf8'); const config = yaml.parse(yamlContent); this.baseUrl = config.base.url; + this.snomedLoad = config.snomedLoad === 'mmap' ? 'mmap' : 'read'; this.oclConfig = config.ocl && typeof config.ocl === 'object' ? config.ocl : {}; this.ignored = new Set(Array.isArray(config.ignored) ? config.ignored : []); @@ -654,7 +655,7 @@ class Library { if (mode === "fetch" || mode === "npm") { return; } - const sct = new SnomedServicesFactory(this.i18n, sctFN); + const sct = new SnomedServicesFactory(this.i18n, sctFN, this.snomedLoad); await sct.load(); this.registerProvider(sctFN, sct, isDefault); this.#syndicateBinary('snomed', sctFN, sct); diff --git a/tx/sct/mmap.js b/tx/sct/mmap.js new file mode 100644 index 00000000..da6d4591 --- /dev/null +++ b/tx/sct/mmap.js @@ -0,0 +1,61 @@ +'use strict'; + +/** + * Read-only memory mapping of SNOMED CT cache files (snomedLoad: mmap). + * + * Node has no mmap of its own, so this calls the C library's mmap() through koffi (an FFI + * module that ships prebuilt binaries for Linux glibc and musl, macOS and the BSDs, so + * nothing is compiled at install time). The mapping is never unmapped: a loaded edition + * stays loaded for the life of the process. + * + * mapFile() returns null where mapping is not available (Windows, koffi missing, mmap + * failing), and the caller then reads the file into memory as before. + */ + +const fs = require('fs'); + +const PROT_READ = 1; +const MAP_PRIVATE = 2; // same value on Linux, macOS and the BSDs +const MAP_FAILED = 0xFFFFFFFFFFFFFFFFn; + +let libcMmap; // undefined: not tried yet; null: not available + +function getMmap() { + if (libcMmap === undefined) { + libcMmap = null; + if (process.platform !== 'win32' && process.arch.endsWith('64')) { + try { + const koffi = require('koffi'); + const mmap = koffi.load(null).func('void *mmap(void *addr, size_t len, int prot, int flags, int fd, int64_t off)'); + libcMmap = { koffi, mmap }; + } catch (e) { + console.warn(`SNOMED CT: memory mapping is not available here (${e.message}); reading cache files into memory`); + } + } + } + return libcMmap; +} + +/** + * @param {string} filePath + * @returns {Buffer|null} a read-only Buffer over the whole file, or null + */ +function mapFile(filePath) { + const lib = getMmap(); + if (!lib) return null; + const fd = fs.openSync(filePath, 'r'); + try { + const size = fs.fstatSync(fd).size; + if (size === 0) return null; + const ptr = lib.mmap(null, size, PROT_READ, MAP_PRIVATE, fd, 0); + if (ptr === null || lib.koffi.address(ptr) === MAP_FAILED) { + console.warn(`SNOMED CT: could not map ${filePath} (errno ${lib.koffi.errno()}); reading it into memory`); + return null; + } + return Buffer.from(lib.koffi.view(ptr, size)); + } finally { + fs.closeSync(fd); // the mapping keeps its own reference to the file + } +} + +module.exports = { mapFile }; diff --git a/tx/sct/structures.js b/tx/sct/structures.js index ccc5e3e8..59a527f0 100644 --- a/tx/sct/structures.js +++ b/tx/sct/structures.js @@ -1,9 +1,9 @@ const fs = require('fs'); +const { mapFile } = require('./mmap'); // The ArrayBuffers of cache files loaded by SnomedFileReader. A structure uses its section of -// one in place rather than copying it: nothing writes to a loaded structure, and the reader's -// sections (kept in the factory's shared data) would hold the whole file anyway. Any other -// buffer is copied, so the structure owns its bytes. +// one in place (nothing writes to a loaded structure, and the file stays loaded for the life +// of the process); any other buffer is copied, so the structure owns its bytes. const fileBuffers = new WeakSet(); function adopt(buffer) { @@ -1243,15 +1243,21 @@ class SnomedReferenceSetIndex { class SnomedFileReader { - constructor(filePath) { + /** + * @param {string} filePath + * @param {string} [loadMode] 'read' (default): read the file into memory. 'mmap': map it + * read-only, so its pages are file-backed and shared with the page cache; falls back to + * 'read' where mapping is not available (see mmap.js) + */ + constructor(filePath, loadMode = 'read') { this.filePath = filePath; + this.loadMode = loadMode; this.buffer = null; this.offset = 0; } async load() { - // Read entire file into buffer - this.buffer = await fs.promises.readFile(this.filePath); + this.buffer = (this.loadMode === 'mmap' && mapFile(this.filePath)) || await fs.promises.readFile(this.filePath); fileBuffers.add(this.buffer.buffer); this.offset = 0; } From 98f8e0c923196a94de2015e2ec65c8dcf5ec2703 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Sun, 1 Nov 2026 13:13:05 -0600 Subject: [PATCH 2/8] Memory guard: with snomedLoad: mmap, measure RSS less file-backed pages Mapped SNOMED CT pages that have been read count in RSS, but the kernel drops and re-reads them as it needs to. Counting them would make the 503 gate and the 422 abort trip on a server with plenty of headroom. Co-Authored-By: Claude Opus 5.5 --- library/memory-guard.js | 29 ++++++++++++++++++++++++++--- tests/library/memory-guard.test.js | 17 +++++++++++++++++ tx/library.js | 5 +++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/library/memory-guard.js b/library/memory-guard.js index 8623207d..76556e46 100644 --- a/library/memory-guard.js +++ b/library/memory-guard.js @@ -16,6 +16,7 @@ * @module library/memory-guard */ +const fs = require('fs'); const os = require('os'); const { readCgroupMemoryLimit } = require('./cgroup-memory'); @@ -32,7 +33,9 @@ const state = { rejectAt: 0, abortAt: 0, sampleTime: 0, - sampleRss: 0 + sampleRss: 0, + // set by measureExcludingFileBacked() + excludeFileBacked: false }; function fraction(v, dflt) { @@ -65,10 +68,29 @@ function configure(opts = {}) { state.sampleTime = 0; } +/** + * Measure RSS less its file-backed pages (Linux only). With SNOMED CT cache files memory-mapped + * (snomedLoad: mmap) RSS includes every mapped page that has been read, which the kernel drops + * and re-reads as it needs to - counting them would refuse requests on a server that is fine. + */ +function measureExcludingFileBacked() { + state.excludeFileBacked = process.platform === 'linux'; + state.sampleTime = 0; +} + +function rssFileBytes() { + try { + const m = /^RssFile:\s+(\d+) kB/m.exec(fs.readFileSync('/proc/self/status', 'utf8')); + return m ? Number(m[1]) * 1024 : 0; + } catch { + return 0; + } +} + function rss(fresh = false) { const now = Date.now(); if (fresh || now - state.sampleTime > SAMPLE_MS) { - state.sampleRss = process.memoryUsage.rss(); + state.sampleRss = process.memoryUsage.rss() - (state.excludeFileBacked ? rssFileBytes() : 0); state.sampleTime = now; } return state.sampleRss; @@ -119,6 +141,7 @@ function gb(bytes) { function describe() { return `Memory limit: ${gb(state.limit)} GB (from ${state.source}); ` + + (state.excludeFileBacked ? 'measured as RSS less file-backed pages; ' : '') + `new requests refused above ${gb(state.rejectAt)} GB (${Math.round(state.rejectFraction * 100)}%), ` + `operations aborted above ${gb(state.abortAt)} GB (${Math.round(state.abortFraction * 100)}%)`; } @@ -158,4 +181,4 @@ function middleware(opts = {}) { configure(); -module.exports = { configure, shouldReject, overAbortThreshold, describe, middleware, gb, _state: state }; +module.exports = { configure, measureExcludingFileBacked, shouldReject, overAbortThreshold, describe, middleware, gb, _state: state }; diff --git a/tests/library/memory-guard.test.js b/tests/library/memory-guard.test.js index fc9d6869..00ece25e 100644 --- a/tests/library/memory-guard.test.js +++ b/tests/library/memory-guard.test.js @@ -78,4 +78,21 @@ describe('memory-guard', () => { setRss(9.5 * GB); expect(memoryGuard.overAbortThreshold('test')).toBe(9.5 * GB); }); + + (process.platform === 'linux' ? test : test.skip)('with mapped files, file-backed pages are not counted', () => { + const fs = require('fs'); + const realRead = fs.readFileSync; + const readSpy = jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => + file === '/proc/self/status' ? `VmRSS:\t1 kB\nRssFile:\t${4 * 1024 * 1024} kB\n` : realRead(file, ...rest)); + try { + setRss(9 * GB); // 4 GB of it file-backed + expect(memoryGuard.shouldReject()).toBe(true); + memoryGuard.measureExcludingFileBacked(); + expect(memoryGuard.shouldReject()).toBe(false); + expect(memoryGuard.overAbortThreshold('test')).toBe(0); + } finally { + memoryGuard._state.excludeFileBacked = false; + readSpy.mockRestore(); + } + }); }); diff --git a/tx/library.js b/tx/library.js index 6a37156a..8885b2a6 100644 --- a/tx/library.js +++ b/tx/library.js @@ -34,6 +34,7 @@ const {ListCodeSystemProvider} = require("./cs/cs-provider-list"); const { Provider } = require("./provider"); const {I18nSupport} = require("../library/i18nsupport"); const folders = require('../library/folder-setup'); +const memoryGuard = require('../library/memory-guard'); const {VSACValueSetProvider} = require("./vs/vs-vsac"); const { OCLCodeSystemProvider, OCLSourceCodeSystemFactory } = require('./ocl/cs-ocl'); const { OCLValueSetProvider } = require('./ocl/vs-ocl'); @@ -186,6 +187,10 @@ class Library { const config = yaml.parse(yamlContent); this.baseUrl = config.base.url; this.snomedLoad = config.snomedLoad === 'mmap' ? 'mmap' : 'read'; + if (this.snomedLoad === 'mmap') { + memoryGuard.measureExcludingFileBacked(); + this.log.info(`SNOMED CT cache files will be memory-mapped. ${memoryGuard.describe()}`); + } this.oclConfig = config.ocl && typeof config.ocl === 'object' ? config.ocl : {}; this.ignored = new Set(Array.isArray(config.ignored) ? config.ignored : []); From 0d8ef7bbb833b08ca75e50ecd9ee9a55a32c9fa8 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Sun, 1 Nov 2026 22:31:14 -0600 Subject: [PATCH 3/8] SNOMED CT: load mode per edition in config.json (load / prewarm / read) How cache files are loaded is a deployment choice, so it moves from the library yml to the tx module's "snomed" block in config.json: "snomed": { "load": "read" | "mmap", "prewarm": [...], "read": [...] } load is the default for every edition (absent: read, as before). prewarm maps an edition and reads it once through a small buffer at startup, in the background, so its pages are in the shared page cache before requests need them (madvise(MADV_WILLNEED) read almost none of it). read loads an edition into the process even when load is mmap. Entries are cache file names, edition version URIs or edition URIs (every version); read wins over prewarm; entries that match nothing, bad values and conflicts are warned about. The memory guard leaves out file-backed pages once an edition is actually mapped. Co-Authored-By: Claude Opus 5.5 --- config-template.json | 6 ++ library/memory-guard.js | 2 +- tests/tx/snomed-load-config.test.js | 149 ++++++++++++++++++++++++++++ tx/README.md | 48 +++++++++ tx/cs/cs-snomed.js | 2 +- tx/library.js | 26 +++-- tx/sct/load-config.js | 107 ++++++++++++++++++++ tx/sct/mmap.js | 30 +++++- tx/sct/structures.js | 38 ++++++- tx/tx.js | 2 +- 10 files changed, 394 insertions(+), 16 deletions(-) create mode 100644 tests/tx/snomed-load-config.test.js create mode 100644 tx/sct/load-config.js diff --git a/config-template.json b/config-template.json index de2602ff..66b2464d 100644 --- a/config-template.json +++ b/config-template.json @@ -252,6 +252,12 @@ // (and a "Library" item in the tx nav bar), anything else gets the YAML file itself. // Off by default - the file names every database, cache and package the server runs. "publishLibrarySource": false, + // How SNOMED CT cache files are loaded (see tx/README.md, "Loading SNOMED CT"). Optional; + // without it every edition is read into the process, as before. "load": "read" or "mmap" + // for every edition; "prewarm": editions to map and read into the page cache at startup; + // "read": editions to read into the process anyway. Entries are cache file names, edition + // version URIs or edition URIs + // "snomed": { "load": "mmap", "prewarm": ["sct_intl_20250201.cache", "sct_us_20250901b.cache"], "read": [] }, "cacheTimeout": 30, // Optional. A short code (1-16 letters and digits) naming this server instance. Every // cache-id the server issues then starts with ".", so a proxy in front of diff --git a/library/memory-guard.js b/library/memory-guard.js index 76556e46..53534411 100644 --- a/library/memory-guard.js +++ b/library/memory-guard.js @@ -70,7 +70,7 @@ function configure(opts = {}) { /** * Measure RSS less its file-backed pages (Linux only). With SNOMED CT cache files memory-mapped - * (snomedLoad: mmap) RSS includes every mapped page that has been read, which the kernel drops + * ("snomed": {"load": "mmap"} in config.json) RSS includes every mapped page that has been read, which the kernel drops * and re-reads as it needs to - counting them would refuse requests on a server that is fine. */ function measureExcludingFileBacked() { diff --git a/tests/tx/snomed-load-config.test.js b/tests/tx/snomed-load-config.test.js new file mode 100644 index 00000000..6a7a9e8f --- /dev/null +++ b/tests/tx/snomed-load-config.test.js @@ -0,0 +1,149 @@ +/** + * How each SNOMED CT cache file is loaded: the tx module's "snomed" block in config.json + * (tx/sct/load-config.js), and the library applying it. + */ +const { SnomedLoadConfig } = require('../../tx/sct/load-config'); +const { SnomedFileReader } = require('../../tx/sct/structures'); +const { Library } = require('../../tx/library'); +const folders = require('../../library/folder-setup'); +const memoryGuard = require('../../library/memory-guard'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const INTL = 'http://snomed.info/sct/900000000000207008'; +const US = 'http://snomed.info/sct/731000124108'; +const intl25 = ['/data/sct_intl_20250201.cache', `${INTL}/version/20250201`]; +const intl26 = ['/data/Snomed_Intl_20260701.cache', `${INTL}/version/20260701`]; +const us = ['/data/sct_us_20250901b.cache', `${US}/version/20250901`]; +const uk = ['/data/sct_uk_20230412.cache', 'http://snomed.info/sct/83821000000107/version/20230412']; + +function config(block) { + const warnings = []; + return { cfg: new SnomedLoadConfig(block, (m) => warnings.push(m)), warnings }; +} + +describe('SnomedLoadConfig', () => { + test('no block: everything is read, as before', () => { + const { cfg, warnings } = config(undefined); + expect([intl25, us, uk].map(f => cfg.modeFor(...f))).toEqual(['read', 'read', 'read']); + expect(cfg.mapsAnything()).toBe(false); + cfg.checkUnused(); + expect(warnings).toEqual([]); + }); + + test('load mmap maps everything', () => { + const { cfg } = config({ load: 'mmap' }); + expect([intl25, us, uk].map(f => cfg.modeFor(...f))).toEqual(['mmap', 'mmap', 'mmap']); + expect(cfg.mapsAnything()).toBe(true); + }); + + test('entries match a file name, a version URI, or an edition URI (every version)', () => { + const { cfg, warnings } = config({ + load: 'mmap', + read: ['sct_us_20250901b.cache'], + prewarm: [`${INTL}/version/20250201`, 'http://snomed.info/sct/83821000000107'] + }); + expect(cfg.modeFor(...us)).toBe('read'); + expect(cfg.modeFor(...intl25)).toBe('prewarm'); + expect(cfg.modeFor(...intl26)).toBe('mmap'); + expect(cfg.modeFor(...uk)).toBe('prewarm'); + cfg.checkUnused(); + expect(warnings).toEqual([]); + }); + + test('an edition URI matches every version of it', () => { + const { cfg } = config({ load: 'mmap', read: [INTL] }); + expect(cfg.modeFor(...intl25)).toBe('read'); + expect(cfg.modeFor(...intl26)).toBe('read'); + expect(cfg.modeFor(...us)).toBe('mmap'); + }); + + test('"read" wins over "prewarm", which wins over "load"', () => { + const { cfg, warnings } = config({ load: 'read', read: ['sct_uk_20230412.cache'], prewarm: ['sct_uk_20230412.cache', `${US}/version/20250901`] }); + expect(cfg.modeFor(...uk)).toBe('read'); + expect(cfg.modeFor(...us)).toBe('prewarm'); // prewarm maps the file even when load is read + expect(cfg.modeFor(...intl25)).toBe('read'); + expect(cfg.mapsAnything()).toBe(true); + expect(warnings).toEqual([expect.stringMatching(/sct_uk_20230412.cache is in both "prewarm" and "read"/)]); + }); + + test('bad values are warned about and ignored', () => { + const { cfg, warnings } = config({ load: 'fast', read: 'sct_us_20250901b.cache', prewarm: [3] }); + expect(cfg.load).toBe('read'); + expect(cfg.modeFor(...us)).toBe('read'); + expect(warnings).toHaveLength(3); + expect(warnings[0]).toMatch(/snomed.load must be "read" or "mmap"/); + expect(warnings[1]).toMatch(/snomed.prewarm must be a list/); + expect(warnings[2]).toMatch(/snomed.read must be a list/); + }); + + test('entries that match no loaded edition are warned about', () => { + const { cfg, warnings } = config({ load: 'mmap', read: ['sct_us_20250901.cache'], prewarm: [US] }); + cfg.modeFor(...us); + cfg.modeFor(...intl25); + cfg.checkUnused(); + expect(warnings).toEqual([expect.stringMatching(/snomed.read: "sct_us_20250901.cache" matches no SNOMED CT edition/)]); + }); +}); + +describe('SnomedFileReader.versionUri', () => { + test('reads the edition version URI from the header', () => { + const file = path.resolve(__dirname, '../../tx/data/snomed-testing.cache'); + expect(SnomedFileReader.versionUri(file)).toMatch(/^http:\/\/snomed\.info\/x?sct\/\d+\/version\/\d{8}$/); + }); +}); + +describe('Library: SNOMED CT load mode from config', () => { + const COMMITTED = path.resolve(__dirname, '../../tx/data/snomed-testing.cache'); + const SYSTEM = 'http://snomed.info/sct'; + let tmpDir; + let yamlPath; + + beforeAll(() => { + const target = path.join(folders.subDir('terminology-cache'), 'snomed-testing.cache'); + if (!fs.existsSync(target)) { + fs.copyFileSync(COMMITTED, target); + } + }); + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-sct-load-')); + yamlPath = path.join(tmpDir, 'library.yml'); + fs.writeFileSync(yamlPath, 'base:\n url: https://storage.googleapis.com/tx-fhir-org\nsources:\n - snomed:snomed-testing.cache\n'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + memoryGuard._state.excludeFileBacked = false; + }); + + async function load(block) { + const log = { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }; + const library = new Library(yamlPath, undefined, log, { addStat: jest.fn() }, block); + await library.load(); + return { library, log }; + } + + const version = () => SnomedFileReader.versionUri(COMMITTED); + + test.each([ + ['no block', undefined, 'read'], + ['load mmap', { load: 'mmap' }, 'mmap'], + ['load mmap, file read', { load: 'mmap', read: ['snomed-testing.cache'] }, 'read'], + ['load read, version prewarmed', { prewarm: ['VERSION'] }, 'prewarm'] + ])('%s: loaded with mode %#', async (name, block, expected) => { + if (block && block.prewarm) block = { ...block, prewarm: [version()] }; + const { library, log } = await load(block); + const factory = library.codeSystemFactories.get(SYSTEM); + expect(factory.loadMode).toBe(expected); + expect(factory.snomedServices.concepts.count()).toBeGreaterThan(0); + expect(memoryGuard._state.excludeFileBacked).toBe(expected !== 'read' && process.platform === 'linux'); + expect(log.warn).not.toHaveBeenCalled(); + }, 30000); + + test('an entry that matches nothing is warned about', async () => { + const { log } = await load({ load: 'mmap', read: ['sct_us_20250901b.cache'] }); + expect(log.warn).toHaveBeenCalledWith(expect.stringMatching(/"sct_us_20250901b.cache" matches no SNOMED CT edition/)); + }, 30000); +}); diff --git a/tx/README.md b/tx/README.md index 493cba89..c38e33c7 100644 --- a/tx/README.md +++ b/tx/README.md @@ -258,6 +258,51 @@ actually returns (metadata, read and search, `$expand`, `$validate-code`, `$look `$subsumes`, `$batch-validate-code`, `$versions`, `$cache-control`, an error) against the schemas, using ajv. So a new parameter needs adding to openapi-operations.js. +## Loading SNOMED CT + +By default each SNOMED CT cache file is read into the server process, at about its file size +(0.6–1.2 GB an edition). A file can instead be memory-mapped read-only: + +- the edition loads in milliseconds; +- only the pages that requests touch become resident, as page cache that the kernel can drop + and read again; +- every server process on the machine that maps the same file shares those pages. + +The first request to touch a part of a mapped edition waits for the disk, unless that edition +is *prewarmed*: mapped, with the kernel asked to read the whole file into the page cache at +startup, in the background. + +This is set per server, in the tx module's `snomed` block in `config.json`: + +```json +"tx": { + "snomed": { + "load": "mmap", + "prewarm": ["http://snomed.info/sct/900000000000207008/version/20250201", + "sct_us_20250901b.cache"], + "read": [] + } +} +``` + +- **`load`** is `"read"` (the default) or `"mmap"`, for every edition not named below. +- **`prewarm`** lists editions to map and read into the page cache at startup, even when + `load` is `"read"`. +- **`read`** lists editions to read into the process even when `load` is `"mmap"`. +- An entry is any of: + - a cache file name (`sct_us_20250901b.cache`) + - an edition version URI (`http://snomed.info/sct/731000124108/version/20250901`) + - an edition URI (`http://snomed.info/sct/731000124108`), which covers every loaded version + of that edition +- `read` wins over `prewarm`. +- Entries that match no loaded edition are warned about at startup. +- With no `snomed` block, everything is read, as before. + +Mapping uses the optional `koffi` dependency. Where it is not available (Windows, 32-bit +platforms, or koffi not installed), a mapped edition is read instead, with a warning. When any +edition is mapped, the memory guard leaves file-backed pages out of the memory it measures +(Linux). + ## Library Configuration The library - the source to load - is configured using a YAML file. @@ -408,6 +453,9 @@ Common edition identifiers: The file is built by importing SNOMED CT (see [documentation](importers/readme.md)) +How the cache files are loaded is a deployment choice, so it is set in `config.json` (the +tx module's `snomed` block), not here - see [Loading SNOMED CT](#loading-snomed-ct). + #### `cpt` - CPT (Current Procedural Terminology) diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index c7460ca5..5d7e16b7 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -3997,7 +3997,7 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { constructor(i18n, filePath, loadMode = 'read') { super(i18n); this.filePath = filePath; - this.loadMode = loadMode; // 'read' or 'mmap' - see SnomedFileReader + this.loadMode = loadMode; // 'read', 'mmap' or 'prewarm' - see SnomedFileReader this.uses = 0; this._loaded = false; this._sharedData = null; diff --git a/tx/library.js b/tx/library.js index 8885b2a6..5667654a 100644 --- a/tx/library.js +++ b/tx/library.js @@ -22,6 +22,8 @@ const {RxNormServicesFactory} = require("./cs/cs-rxnorm"); const {NdcServicesFactory} = require("./cs/cs-ndc"); const {UniiServicesFactory} = require("./cs/cs-unii"); const {SnomedServicesFactory} = require("./cs/cs-snomed"); +const {SnomedFileReader} = require("./sct/structures"); +const {SnomedLoadConfig} = require("./sct/load-config"); const {CPTServicesFactory} = require("./cs/cs-cpt"); const {OMOPServicesFactory} = require("./cs/cs-omop"); const {ICD11ServicesFactory} = require("./cs/cs-icd11"); @@ -113,11 +115,20 @@ class Library { } } - constructor(configFile, vsacCfg, log, stats) { + /** + * @param {string} configFile - the library yml + * @param {object} vsacCfg + * @param {object} log + * @param {object} stats + * @param {object} [snomedCfg] - the tx module's "snomed" block: how SNOMED CT cache files are + * loaded (see tx/sct/load-config.js) + */ + constructor(configFile, vsacCfg, log, stats, snomedCfg = undefined) { this.configFile = configFile; this.vsacCfg = vsacCfg; this.log = log; this.stats = stats; + this.snomedLoad = new SnomedLoadConfig(snomedCfg, (msg) => (this.log ? this.log.warn(msg) : console.warn(msg))); // Only synchronous initialization here this.codeSystemFactories = new Map(); @@ -186,11 +197,6 @@ class Library { const yamlContent = await fs.readFile(yamlPath, 'utf8'); const config = yaml.parse(yamlContent); this.baseUrl = config.base.url; - this.snomedLoad = config.snomedLoad === 'mmap' ? 'mmap' : 'read'; - if (this.snomedLoad === 'mmap') { - memoryGuard.measureExcludingFileBacked(); - this.log.info(`SNOMED CT cache files will be memory-mapped. ${memoryGuard.describe()}`); - } this.oclConfig = config.ocl && typeof config.ocl === 'object' ? config.ocl : {}; this.ignored = new Set(Array.isArray(config.ignored) ? config.ignored : []); @@ -221,6 +227,7 @@ class Library { throw error; } } + this.snomedLoad.checkUnused(); this.log.info('Loading Packages'); this.#logPackagesHeader(); @@ -660,7 +667,12 @@ class Library { if (mode === "fetch" || mode === "npm") { return; } - const sct = new SnomedServicesFactory(this.i18n, sctFN, this.snomedLoad); + const loadMode = this.snomedLoad.modeFor(sctFN, SnomedFileReader.versionUri(sctFN)); + if (loadMode !== 'read' && !memoryGuard._state.excludeFileBacked) { + memoryGuard.measureExcludingFileBacked(); + this.log.info(`SNOMED CT cache files are memory-mapped. ${memoryGuard.describe()}`); + } + const sct = new SnomedServicesFactory(this.i18n, sctFN, loadMode); await sct.load(); this.registerProvider(sctFN, sct, isDefault); this.#syndicateBinary('snomed', sctFN, sct); diff --git a/tx/sct/load-config.js b/tx/sct/load-config.js new file mode 100644 index 00000000..21b4b916 --- /dev/null +++ b/tx/sct/load-config.js @@ -0,0 +1,107 @@ +'use strict'; + +/** + * How each SNOMED CT cache file is loaded, from the tx module's "snomed" block in config.json: + * + * "snomed": { + * "load": "read" | "mmap", // the default for every edition (absent: "read") + * "prewarm": [ ... ], // map these, and have the kernel read them in at startup + * "read": [ ... ] // read these into the process even when load is "mmap" + * } + * + * An entry names a cache file (e.g. "sct_us_20250901b.cache"), an edition version URI + * ("http://snomed.info/sct/731000124108/version/20250901"), or an edition URI + * ("http://snomed.info/sct/731000124108", every loaded version of the edition). "read" wins + * over "prewarm", which wins over "load". + * + * read - the file is read into this process (private memory, as before) + * mmap - the file is mapped read-only; pages are read from disk when first touched, and + * shared with every other process that maps the same file + * prewarm - mapped, and madvise(MADV_WILLNEED) asks the kernel to read the whole file + * into the page cache in the background, so the first requests don't wait + */ + +const path = require('path'); + +const MODES = ['read', 'mmap']; + +class SnomedLoadConfig { + /** + * @param {object} [block] - the "snomed" block of the tx module config + * @param {function(string)} [warn] + */ + constructor(block, warn = () => {}) { + this.warn = warn; + block = block || {}; + this.load = 'read'; + if (block.load !== undefined) { + if (MODES.includes(block.load)) { + this.load = block.load; + } else { + warn(`snomed.load must be "read" or "mmap", not ${JSON.stringify(block.load)}; using "read"`); + } + } + this.lists = {}; + for (const name of ['prewarm', 'read']) { + const v = block[name]; + if (v === undefined) { + this.lists[name] = []; + } else if (Array.isArray(v) && v.every(e => typeof e === 'string' && e.trim())) { + this.lists[name] = v.map(e => e.trim()); + } else { + warn(`snomed.${name} must be a list of cache file names or edition URIs; ignored`); + this.lists[name] = []; + } + } + for (const e of this.lists.prewarm) { + if (this.lists.read.includes(e)) { + warn(`snomed: ${e} is in both "prewarm" and "read"; it will be read`); + } + } + this.used = new Set(); + } + + /** True if any edition can end up mapped. */ + mapsAnything() { + return this.load === 'mmap' || this.lists.prewarm.length > 0; + } + + static #matches(entry, fileName, versionUri) { + if (entry === fileName) return true; + if (!versionUri) return false; + return entry === versionUri || versionUri.startsWith(entry.replace(/\/+$/, '') + '/version/'); + } + + #find(list, fileName, versionUri) { + const hit = this.lists[list].filter(e => SnomedLoadConfig.#matches(e, fileName, versionUri)); + hit.forEach(e => this.used.add(e)); + return hit.length > 0; + } + + /** + * @param {string} filePath - the cache file + * @param {string|null} versionUri - its edition version URI, e.g. http://snomed.info/sct/900000000000207008/version/20250201 + * @returns {'read'|'mmap'|'prewarm'} + */ + modeFor(filePath, versionUri) { + const fileName = path.basename(filePath); + const read = this.#find('read', fileName, versionUri); + const prewarm = this.#find('prewarm', fileName, versionUri); + if (read) return 'read'; + if (prewarm) return 'prewarm'; + return this.load; + } + + /** Warn about entries that matched no loaded edition. Call once every edition is loaded. */ + checkUnused() { + for (const list of ['prewarm', 'read']) { + for (const e of this.lists[list]) { + if (!this.used.has(e)) { + this.warn(`snomed.${list}: "${e}" matches no SNOMED CT edition in the library`); + } + } + } + } +} + +module.exports = { SnomedLoadConfig }; diff --git a/tx/sct/mmap.js b/tx/sct/mmap.js index da6d4591..be2edae9 100644 --- a/tx/sct/mmap.js +++ b/tx/sct/mmap.js @@ -1,7 +1,7 @@ 'use strict'; /** - * Read-only memory mapping of SNOMED CT cache files (snomedLoad: mmap). + * Read-only memory mapping of SNOMED CT cache files ("snomed": {"load": "mmap"} in config.json). * * Node has no mmap of its own, so this calls the C library's mmap() through koffi (an FFI * module that ships prebuilt binaries for Linux glibc and musl, macOS and the BSDs, so @@ -58,4 +58,30 @@ function mapFile(filePath) { } } -module.exports = { mapFile }; +/** + * Read a file through once, in the background, so its pages are in the page cache before + * requests need them (madvise(MADV_WILLNEED) is only a hint, and on Linux it read almost + * nothing of a mapped file). The reads go through one small buffer, so this costs no process + * memory; the pages land in the page cache that every mapping of the file shares. + * + * @param {string} filePath + * @returns {Promise} milliseconds taken + */ +async function prewarmFile(filePath) { + const start = Date.now(); + const buf = Buffer.allocUnsafe(4 * 1024 * 1024); + const fh = await fs.promises.open(filePath, 'r'); + try { + let pos = 0; + for (;;) { + const { bytesRead } = await fh.read(buf, 0, buf.length, pos); + if (bytesRead === 0) break; + pos += bytesRead; + } + } finally { + await fh.close(); + } + return Date.now() - start; +} + +module.exports = { mapFile, prewarmFile }; diff --git a/tx/sct/structures.js b/tx/sct/structures.js index 59a527f0..cd65de9b 100644 --- a/tx/sct/structures.js +++ b/tx/sct/structures.js @@ -1,5 +1,5 @@ const fs = require('fs'); -const { mapFile } = require('./mmap'); +const { mapFile, prewarmFile } = require('./mmap'); // The ArrayBuffers of cache files loaded by SnomedFileReader. A structure uses its section of // one in place (nothing writes to a loaded structure, and the file stays loaded for the life @@ -1246,8 +1246,9 @@ class SnomedFileReader { /** * @param {string} filePath * @param {string} [loadMode] 'read' (default): read the file into memory. 'mmap': map it - * read-only, so its pages are file-backed and shared with the page cache; falls back to - * 'read' where mapping is not available (see mmap.js) + * read-only, so its pages are file-backed and shared with the page cache. 'prewarm': map + * it, and have the kernel read it into the page cache straight away. Where mapping is not + * available (see mmap.js) the file is read. */ constructor(filePath, loadMode = 'read') { this.filePath = filePath; @@ -1256,9 +1257,38 @@ class SnomedFileReader { this.offset = 0; } + /** + * The edition version URI in a cache file's header, without loading the file. + * @param {string} filePath + * @returns {string|null} + */ + static versionUri(filePath) { + const head = Buffer.alloc(1024); + const fd = fs.openSync(filePath, 'r'); + let n; + try { + n = fs.readSync(fd, head, 0, head.length, 0); + } finally { + fs.closeSync(fd); + } + const reader = new SnomedFileReader(filePath); + reader.buffer = head.subarray(0, n); + try { + reader.readString(); // cache version + return reader.readString(); + } catch { + return null; + } + } + async load() { - this.buffer = (this.loadMode === 'mmap' && mapFile(this.filePath)) || await fs.promises.readFile(this.filePath); + const mapped = this.loadMode !== 'read' ? mapFile(this.filePath) : null; + this.buffer = mapped || await fs.promises.readFile(this.filePath); fileBuffers.add(this.buffer.buffer); + if (mapped && this.loadMode === 'prewarm') { + // not awaited: the server starts while the file is read into the page cache + this.prewarmed = prewarmFile(this.filePath).catch(() => null); + } this.offset = 0; } diff --git a/tx/tx.js b/tx/tx.js index 09d5ec42..a63f7e20 100644 --- a/tx/tx.js +++ b/tx/tx.js @@ -243,7 +243,7 @@ class TXModule { // Load the library from YAML this.log.info(`Loading library from: ${config.librarySource}`); - this.library = new Library(config.librarySource, config.vsacCfg, this.log, this.stats); + this.library = new Library(config.librarySource, config.vsacCfg, this.log, this.stats, config.snomed); // The syndication feed: content is recorded in the store as the library loads it, from // the time the administrator turns it on (see tx/syndication). One store serves every From ccece090b8cc36110a9dadd65dbecb461484eaf5 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Mon, 2 Nov 2026 02:07:04 -0600 Subject: [PATCH 4/8] SNOMED CT load config: describe prewarm as the background read it is Co-Authored-By: Claude Opus 5.5 --- tx/sct/load-config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tx/sct/load-config.js b/tx/sct/load-config.js index 21b4b916..5e828996 100644 --- a/tx/sct/load-config.js +++ b/tx/sct/load-config.js @@ -17,8 +17,8 @@ * read - the file is read into this process (private memory, as before) * mmap - the file is mapped read-only; pages are read from disk when first touched, and * shared with every other process that maps the same file - * prewarm - mapped, and madvise(MADV_WILLNEED) asks the kernel to read the whole file - * into the page cache in the background, so the first requests don't wait + * prewarm - mapped, and read through once in the background at startup so its pages are + * already in the shared page cache when the first requests arrive */ const path = require('path'); From 093cfe3da8765994e6afc5413142b3cad7c72f30 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 3 Nov 2026 11:04:25 -0600 Subject: [PATCH 5/8] SNOMED CT mmap: record whether a file really is mapped, and log fallbacks SnomedFileReader and SnomedServicesFactory now have a `mapped` flag, true only when the cache file's buffer is a memory mapping. The library logs it for each edition that was to be mapped ("load mmap, memory-mapped" or "not mapped, read into memory"), and only switches the memory guard to leave out file-backed pages once an edition actually is mapped. mmap.js's fallback warnings (mapping not available, mmap() failing) go through a warn function: the library's log via the reader and factory, or console.warn where there is no logger. They are now given per file. Tests: with load mmap the reader's buffer is a mapping (and on Linux the file is in /proc/self/maps), skipped with a message where mapping is not available; a mapped load and a read load give byte-identical structures and the same descriptions, relationships and parents for every concept; with mapping simulated as unavailable or failing, mapped is false and the fallback is logged, by the reader and through the Library's log. Co-Authored-By: Claude Opus 5.5 --- tests/tx/snomed-load-config.test.js | 156 +++++++++++++++++++++++++++- tx/cs/cs-snomed.js | 7 +- tx/library.js | 11 +- tx/sct/mmap.js | 36 +++++-- tx/sct/structures.js | 16 ++- 5 files changed, 206 insertions(+), 20 deletions(-) diff --git a/tests/tx/snomed-load-config.test.js b/tests/tx/snomed-load-config.test.js index 6a7a9e8f..a42b77dc 100644 --- a/tests/tx/snomed-load-config.test.js +++ b/tests/tx/snomed-load-config.test.js @@ -3,7 +3,9 @@ * (tx/sct/load-config.js), and the library applying it. */ const { SnomedLoadConfig } = require('../../tx/sct/load-config'); -const { SnomedFileReader } = require('../../tx/sct/structures'); +const { SnomedFileReader, SnomedConceptList } = require('../../tx/sct/structures'); +const { SnomedServices } = require('../../tx/cs/cs-snomed'); +const { mappingAvailable, _setMmapForTesting } = require('../../tx/sct/mmap'); const { Library } = require('../../tx/library'); const folders = require('../../library/folder-setup'); const memoryGuard = require('../../library/memory-guard'); @@ -94,6 +96,133 @@ describe('SnomedFileReader.versionUri', () => { }); }); +const CACHE = path.resolve(__dirname, '../../tx/data/snomed-testing.cache'); +const CAN_MAP = mappingAvailable(); +const NO_MAP = ' (skipped: memory mapping is not available on this platform - needs 64-bit Linux/macOS/BSD with koffi)'; +const testIfMapping = CAN_MAP ? test : test.skip; +const ifMapping = (name) => CAN_MAP ? name : name + NO_MAP; + +/** Whether this process has filePath mapped, from /proc/self/maps (Linux only). */ +function inProcMaps(filePath) { + return fs.readFileSync('/proc/self/maps', 'utf8').split('\n').some(l => l.endsWith(' ' + fs.realpathSync(filePath))); +} + +// Fails every mmap() call the way libc does (MAP_FAILED, errno set) +const FAILING_MMAP = { + koffi: { address: () => 0xFFFFFFFFFFFFFFFFn, errno: () => 12 }, + mmap: () => ({}) +}; + +async function readCache(loadMode, log) { + const reader = new SnomedFileReader(CACHE, loadMode, log); + const data = await reader.loadSnomedData(); + return { reader, data, services: new SnomedServices(data) }; +} + +/** Everything the services report about one concept. */ +function describeConcept(sct, index) { + const c = sct.concepts.getConcept(index); + const descriptions = c.descriptions === 0 ? [] : sct.refs.getReferences(c.descriptions).map(i => { + const d = sct.descriptions.getDescription(i); + return { id: String(d.id), term: sct.strings.getEntry(d.iDesc), active: d.active, kind: d.kind, lang: d.lang }; + }); + const rels = (ref) => ref === 0 ? [] : sct.refs.getReferences(ref).map(i => { + const r = sct.relationships.getRelationship(i); + return [String(sct.concepts.getConceptId(r.source)), String(sct.concepts.getConceptId(r.target)), + String(sct.concepts.getConceptId(r.relType)), r.group, r.active]; + }); + return { + id: String(sct.concepts.getConceptId(index)), + display: sct.getDisplayName(index), + active: sct.isActive(index), + descriptions, + outbound: rels(c.outbounds), + inbound: rels(c.inbounds), + parents: sct.getConceptParents(index).map(p => String(sct.concepts.getConceptId(p))) + }; +} + +describe('SnomedFileReader: memory mapping', () => { + afterEach(() => _setMmapForTesting()); + + test('load read: the buffer is not a mapping', async () => { + const { reader } = await readCache('read'); + expect(reader.mapped).toBe(false); + }); + + testIfMapping(ifMapping('load mmap: the buffer really is a mapping of the file'), async () => { + const before = process.platform === 'linux' && inProcMaps(CACHE); + const { reader } = await readCache('mmap'); + expect(reader.mapped).toBe(true); + if (process.platform === 'linux') { + expect(before).toBe(false); + expect(inProcMaps(CACHE)).toBe(true); // the kernel lists the file among this process's mappings + } + }); + + testIfMapping(ifMapping('prewarm: mapped, and the background read finishes'), async () => { + const { reader } = await readCache('prewarm'); + expect(reader.mapped).toBe(true); + expect(typeof await reader.prewarmed).toBe('number'); + }); + + testIfMapping(ifMapping('a mapped load gives the same data as a read load'), async () => { + const read = await readCache('read'); + const mapped = await readCache('mmap'); + expect(read.reader.mapped).toBe(false); + expect(mapped.reader.mapped).toBe(true); + + // the structures, byte for byte, and everything else in the header + const sections = ['strings', 'refs', 'desc', 'words', 'stems', 'concept', 'rel', 'refSetIndex', 'refSetMembers', 'descRef']; + const rest = (d) => Object.fromEntries(Object.entries(d).filter(([k]) => !sections.includes(k))); + for (const k of sections) { + expect(mapped.data[k].length).toBe(read.data[k].length); + expect(Buffer.compare(mapped.data[k], read.data[k])).toBe(0); + } + expect(rest(mapped.data)).toEqual(rest(read.data)); + + // and what the services make of them: counts, and every concept's descriptions and relationships + for (const what of ['concepts', 'descriptions', 'relationships', 'words', 'stems', 'refSetIndex']) { + expect(mapped.services[what].count()).toBe(read.services[what].count()); + } + const n = read.services.concepts.count(); + expect(n).toBeGreaterThan(1000); + let withRels = 0; + for (let i = 0; i < n; i++) { + const index = i * SnomedConceptList.CONCEPT_SIZE; + const a = describeConcept(read.services, index); + expect(describeConcept(mapped.services, index)).toEqual(a); + if (a.descriptions.length && a.outbound.length) withRels++; + } + expect(withRels).toBeGreaterThan(100); // the comparison covered real content, not empty concepts + }, 60000); + + test.each([ + ['mapping not available', () => _setMmapForTesting(null, 'simulated'), /memory mapping is not available here \(simulated\); reading .*snomed-testing.cache into memory/], + ['mmap() failing', () => _setMmapForTesting(FAILING_MMAP), /could not map .*snomed-testing.cache \(errno 12\); reading it into memory/] + ])('%s: falls back to reading, mapped is false, and the log says why', async (name, simulate, message) => { + simulate(); + const log = { warn: jest.fn() }; + const { reader, services } = await readCache('mmap', log); + expect(reader.mapped).toBe(false); + expect(services.concepts.count()).toBeGreaterThan(0); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith(expect.stringMatching(message)); + }); + + test('with no logger, the fallback warning goes to the console', async () => { + _setMmapForTesting(null, 'simulated'); + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { reader } = await readCache('mmap'); + expect(reader.mapped).toBe(false); + expect(spy).toHaveBeenCalledWith(expect.stringMatching(/memory mapping is not available here \(simulated\)/)); + } finally { + spy.mockRestore(); + } + }); +}); + describe('Library: SNOMED CT load mode from config', () => { const COMMITTED = path.resolve(__dirname, '../../tx/data/snomed-testing.cache'); const SYSTEM = 'http://snomed.info/sct'; @@ -137,9 +266,30 @@ describe('Library: SNOMED CT load mode from config', () => { const { library, log } = await load(block); const factory = library.codeSystemFactories.get(SYSTEM); expect(factory.loadMode).toBe(expected); + expect(factory.mapped).toBe(expected !== 'read' && CAN_MAP); expect(factory.snomedServices.concepts.count()).toBeGreaterThan(0); - expect(memoryGuard._state.excludeFileBacked).toBe(expected !== 'read' && process.platform === 'linux'); - expect(log.warn).not.toHaveBeenCalled(); + expect(memoryGuard._state.excludeFileBacked).toBe(factory.mapped && process.platform === 'linux'); + if (expected !== 'read') { + expect(log.info).toHaveBeenCalledWith(expect.stringMatching( + CAN_MAP ? /snomed-testing.cache: load \w+, memory-mapped$/ : /snomed-testing.cache: load \w+, not mapped, read into memory$/)); + } + if (CAN_MAP) expect(log.warn).not.toHaveBeenCalled(); + }, 30000); + + test('load mmap where mapping fails: read instead, and the server log says so', async () => { + _setMmapForTesting(FAILING_MMAP); + try { + const { library, log } = await load({ load: 'mmap' }); + const factory = library.codeSystemFactories.get(SYSTEM); + expect(factory.loadMode).toBe('mmap'); + expect(factory.mapped).toBe(false); + expect(factory.snomedServices.concepts.count()).toBeGreaterThan(0); + expect(memoryGuard._state.excludeFileBacked).toBe(false); + expect(log.warn).toHaveBeenCalledWith(expect.stringMatching(/could not map .*snomed-testing.cache \(errno 12\); reading it into memory/)); + expect(log.info).toHaveBeenCalledWith(expect.stringMatching(/snomed-testing.cache: load mmap, not mapped, read into memory$/)); + } finally { + _setMmapForTesting(); + } }, 30000); test('an entry that matches nothing is warned about', async () => { diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index 73ee6585..7a70ae91 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -4307,10 +4307,12 @@ class SnomedProvider extends BaseCSServices { * Factory for creating SNOMED services and providers */ class SnomedServicesFactory extends CodeSystemFactoryProvider { - constructor(i18n, filePath, loadMode = 'read') { + constructor(i18n, filePath, loadMode = 'read', log = null) { super(i18n); this.filePath = filePath; this.loadMode = loadMode; // 'read', 'mmap' or 'prewarm' - see SnomedFileReader + this.log = log; + this.mapped = false; // set by load(): whether the cache file really is memory-mapped this.uses = 0; this._loaded = false; this._sharedData = null; @@ -4565,8 +4567,9 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { } async load() { - const reader = new SnomedFileReader(this.filePath, this.loadMode); + const reader = new SnomedFileReader(this.filePath, this.loadMode, this.log); this._sharedData = await reader.loadSnomedData(); + this.mapped = reader.mapped; this.snomedServices = new SnomedServices(this._sharedData); this._loaded = true; } diff --git a/tx/library.js b/tx/library.js index 5667654a..1be32107 100644 --- a/tx/library.js +++ b/tx/library.js @@ -668,12 +668,17 @@ class Library { return; } const loadMode = this.snomedLoad.modeFor(sctFN, SnomedFileReader.versionUri(sctFN)); - if (loadMode !== 'read' && !memoryGuard._state.excludeFileBacked) { + const sct = new SnomedServicesFactory(this.i18n, sctFN, loadMode, this.log); + await sct.load(); + if (loadMode !== 'read') { + // if it could not be mapped, mapFile has already said why, through this.log + this.log.info(`SNOMED CT ${path.basename(sctFN)}: load ${loadMode}, ` + + (sct.mapped ? 'memory-mapped' : 'not mapped, read into memory')); + } + if (sct.mapped && !memoryGuard._state.excludeFileBacked) { memoryGuard.measureExcludingFileBacked(); this.log.info(`SNOMED CT cache files are memory-mapped. ${memoryGuard.describe()}`); } - const sct = new SnomedServicesFactory(this.i18n, sctFN, loadMode); - await sct.load(); this.registerProvider(sctFN, sct, isDefault); this.#syndicateBinary('snomed', sctFN, sct); } diff --git a/tx/sct/mmap.js b/tx/sct/mmap.js index be2edae9..4419c79a 100644 --- a/tx/sct/mmap.js +++ b/tx/sct/mmap.js @@ -9,7 +9,8 @@ * stays loaded for the life of the process. * * mapFile() returns null where mapping is not available (Windows, koffi missing, mmap - * failing), and the caller then reads the file into memory as before. + * failing), and the caller then reads the file into memory as before. It says why through the + * warn function it is given (the server log), or console.warn where there is none. */ const fs = require('fs'); @@ -19,37 +20,49 @@ const MAP_PRIVATE = 2; // same value on Linux, macOS and the BSDs const MAP_FAILED = 0xFFFFFFFFFFFFFFFFn; let libcMmap; // undefined: not tried yet; null: not available +let unavailable = null; // why libcMmap is null function getMmap() { if (libcMmap === undefined) { libcMmap = null; - if (process.platform !== 'win32' && process.arch.endsWith('64')) { + if (process.platform === 'win32' || !process.arch.endsWith('64')) { + unavailable = `not supported on ${process.platform}/${process.arch}`; + } else { try { const koffi = require('koffi'); const mmap = koffi.load(null).func('void *mmap(void *addr, size_t len, int prot, int flags, int fd, int64_t off)'); libcMmap = { koffi, mmap }; } catch (e) { - console.warn(`SNOMED CT: memory mapping is not available here (${e.message}); reading cache files into memory`); + unavailable = e.message; } } } return libcMmap; } +/** True if mapFile() can map files on this platform (it may still fail for a given file). */ +function mappingAvailable() { + return getMmap() !== null; +} + /** * @param {string} filePath + * @param {function(string)} [warn] - where to say why the file was not mapped * @returns {Buffer|null} a read-only Buffer over the whole file, or null */ -function mapFile(filePath) { +function mapFile(filePath, warn = (msg) => console.warn(msg)) { const lib = getMmap(); - if (!lib) return null; + if (!lib) { + warn(`SNOMED CT: memory mapping is not available here (${unavailable}); reading ${filePath} into memory`); + return null; + } const fd = fs.openSync(filePath, 'r'); try { const size = fs.fstatSync(fd).size; if (size === 0) return null; const ptr = lib.mmap(null, size, PROT_READ, MAP_PRIVATE, fd, 0); if (ptr === null || lib.koffi.address(ptr) === MAP_FAILED) { - console.warn(`SNOMED CT: could not map ${filePath} (errno ${lib.koffi.errno()}); reading it into memory`); + warn(`SNOMED CT: could not map ${filePath} (errno ${lib.koffi.errno()}); reading it into memory`); return null; } return Buffer.from(lib.koffi.view(ptr, size)); @@ -84,4 +97,13 @@ async function prewarmFile(filePath) { return Date.now() - start; } -module.exports = { mapFile, prewarmFile }; +/** + * For tests: replace the mmap binding ({koffi, mmap}, or null with a reason for "not + * available"), or with no arguments go back to the real one. + */ +function _setMmapForTesting(lib, reason = 'disabled by a test') { + libcMmap = arguments.length === 0 ? undefined : lib; + unavailable = lib ? null : reason; +} + +module.exports = { mapFile, prewarmFile, mappingAvailable, _setMmapForTesting }; diff --git a/tx/sct/structures.js b/tx/sct/structures.js index 26b2299d..256f941d 100644 --- a/tx/sct/structures.js +++ b/tx/sct/structures.js @@ -1345,14 +1345,18 @@ class SnomedFileReader { /** * @param {string} filePath * @param {string} [loadMode] 'read' (default): read the file into memory. 'mmap': map it - * read-only, so its pages are file-backed and shared with the page cache. 'prewarm': map - * it, and have the kernel read it into the page cache straight away. Where mapping is not - * available (see mmap.js) the file is read. + * read-only, so its pages are file-backed and shared with the page cache. 'prewarm': mapped, + * then read through once in the background at startup, so its pages are already in the + * page cache when requests arrive; startup doesn't wait for it. Where mapping is not + * available (see mmap.js) the file is read, and `mapped` stays false. + * @param {object} [log] - a logger with warn(); says why a file that was to be mapped was read */ - constructor(filePath, loadMode = 'read') { + constructor(filePath, loadMode = 'read', log = null) { this.filePath = filePath; this.loadMode = loadMode; + this.log = log; this.buffer = null; + this.mapped = false; // true once load() has the buffer as a memory mapping of the file this.offset = 0; } @@ -1381,7 +1385,9 @@ class SnomedFileReader { } async load() { - const mapped = this.loadMode !== 'read' ? mapFile(this.filePath) : null; + const warn = this.log ? (msg) => this.log.warn(msg) : undefined; + const mapped = this.loadMode !== 'read' ? mapFile(this.filePath, warn) : null; + this.mapped = mapped !== null; this.buffer = mapped || await fs.promises.readFile(this.filePath); fileBuffers.add(this.buffer.buffer); if (mapped && this.loadMode === 'prewarm') { From 1c1e11a3beaeda4c670d1b071e68665bf49d173b Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 3 Nov 2026 11:04:25 -0600 Subject: [PATCH 6/8] SNOMED CT docs: describe prewarm as a background read-through, not madvise tx/README.md, the load-config header and config-template.json now say a prewarmed edition is mapped, then read through once in the background at startup so its pages are already in the page cache when requests arrive, and that startup doesn't wait for it. The README also says the server log shows whether each edition that was to be mapped really was. Co-Authored-By: Claude Opus 5.5 --- config-template.json | 2 +- tx/README.md | 13 +++++++------ tx/sct/load-config.js | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/config-template.json b/config-template.json index 66b2464d..331f338a 100644 --- a/config-template.json +++ b/config-template.json @@ -254,7 +254,7 @@ "publishLibrarySource": false, // How SNOMED CT cache files are loaded (see tx/README.md, "Loading SNOMED CT"). Optional; // without it every edition is read into the process, as before. "load": "read" or "mmap" - // for every edition; "prewarm": editions to map and read into the page cache at startup; + // for every edition; "prewarm": editions to map, then read through once in the background at startup; // "read": editions to read into the process anyway. Entries are cache file names, edition // version URIs or edition URIs // "snomed": { "load": "mmap", "prewarm": ["sct_intl_20250201.cache", "sct_us_20250901b.cache"], "read": [] }, diff --git a/tx/README.md b/tx/README.md index c38e33c7..47b2de82 100644 --- a/tx/README.md +++ b/tx/README.md @@ -269,8 +269,8 @@ By default each SNOMED CT cache file is read into the server process, at about i - every server process on the machine that maps the same file shares those pages. The first request to touch a part of a mapped edition waits for the disk, unless that edition -is *prewarmed*: mapped, with the kernel asked to read the whole file into the page cache at -startup, in the background. +is *prewarmed*: mapped, then read through once in the background at startup, so its pages are +already in the page cache when requests arrive. Startup doesn't wait for it. This is set per server, in the tx module's `snomed` block in `config.json`: @@ -286,8 +286,8 @@ This is set per server, in the tx module's `snomed` block in `config.json`: ``` - **`load`** is `"read"` (the default) or `"mmap"`, for every edition not named below. -- **`prewarm`** lists editions to map and read into the page cache at startup, even when - `load` is `"read"`. +- **`prewarm`** lists editions to prewarm (map, then read through once in the background at + startup), even when `load` is `"read"`. - **`read`** lists editions to read into the process even when `load` is `"mmap"`. - An entry is any of: - a cache file name (`sct_us_20250901b.cache`) @@ -299,8 +299,9 @@ This is set per server, in the tx module's `snomed` block in `config.json`: - With no `snomed` block, everything is read, as before. Mapping uses the optional `koffi` dependency. Where it is not available (Windows, 32-bit -platforms, or koffi not installed), a mapped edition is read instead, with a warning. When any -edition is mapped, the memory guard leaves file-backed pages out of the memory it measures +platforms, or koffi not installed), a mapped edition is read instead, with a warning in the +server log. The log says, for each edition that was to be mapped, whether it really was. When +any edition is mapped, the memory guard leaves file-backed pages out of the memory it measures (Linux). ## Library Configuration diff --git a/tx/sct/load-config.js b/tx/sct/load-config.js index 5e828996..73b23f22 100644 --- a/tx/sct/load-config.js +++ b/tx/sct/load-config.js @@ -5,7 +5,7 @@ * * "snomed": { * "load": "read" | "mmap", // the default for every edition (absent: "read") - * "prewarm": [ ... ], // map these, and have the kernel read them in at startup + * "prewarm": [ ... ], // map these, then read them through once in the background at startup * "read": [ ... ] // read these into the process even when load is "mmap" * } * From dcce96f91fdc5a134a7185a4e67f00d30b484e97 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 3 Nov 2026 18:37:20 -0600 Subject: [PATCH 7/8] memory guard: with mapped files, read RssFile at most every 200 ms With mapped files (measureExcludingFileBacked), the in-operation check (overAbortThreshold, from every deadCheck) read /proc/self/status for RssFile on every call: about 14 us a read, several per request. RSS itself is still read fresh on every check; the file-backed part is the last RssFile read, refreshed at most every 200 ms, or at once when the sample is dropped (configure, measureExcludingFileBacked). Co-Authored-By: Claude Opus 5.5 --- library/memory-guard.js | 26 ++++++++++++++++--- tests/library/memory-guard.test.js | 40 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/library/memory-guard.js b/library/memory-guard.js index 1f90f7cd..27879e1e 100644 --- a/library/memory-guard.js +++ b/library/memory-guard.js @@ -34,6 +34,10 @@ const GC_INTERVAL_MS = 5000; // next, up to this: when most of RSS is outside the JS heap (the SNOMED and LOINC cache // buffers), collecting frees little, and every full GC stops the process const GC_MAX_INTERVAL_MS = 60000; +// RssFile (/proc/self/status) is read at most this often. The in-operation check runs many +// times a request, and a read of /proc/self/status costs twice what the RSS itself does; +// between reads, a fresh RSS less the last RssFile is a close enough estimate +const FILE_SAMPLE_MS = 200; const state = { limit: 0, @@ -45,7 +49,9 @@ const state = { sampleTime: 0, sampleRss: 0, // set by measureExcludingFileBacked() - excludeFileBacked: false + excludeFileBacked: false, + fileTime: 0, + fileRss: 0 // the last RssFile read (see fileBackedRss) }; function fraction(v, dflt) { @@ -88,6 +94,19 @@ function measureExcludingFileBacked() { state.sampleTime = 0; } +/** + * RssFile as last read, read again if that was FILE_SAMPLE_MS ago or more, or if the sample + * has been dropped (sampleTime 0: configure, measureExcludingFileBacked, a test). + */ +function fileBackedRss() { + const now = Date.now(); + if (state.sampleTime === 0 || now - state.fileTime >= FILE_SAMPLE_MS) { + state.fileRss = rssFileBytes(); + state.fileTime = now; + } + return state.fileRss; +} + function rssFileBytes() { try { const m = /^RssFile:\s+(\d+) kB/m.exec(fs.readFileSync('/proc/self/status', 'utf8')); @@ -100,7 +119,7 @@ function rssFileBytes() { function rss(fresh = false) { const now = Date.now(); if (fresh || now - state.sampleTime > SAMPLE_MS) { - state.sampleRss = process.memoryUsage.rss() - (state.excludeFileBacked ? rssFileBytes() : 0); + state.sampleRss = process.memoryUsage.rss() - (state.excludeFileBacked ? fileBackedRss() : 0); state.sampleTime = now; } return state.sampleRss; @@ -145,7 +164,8 @@ let lastAbortLog = 0; let abortLog = null; /** - * Current RSS in bytes if running operations should be aborted, else 0. Always a fresh read. + * Current RSS in bytes if running operations should be aborted, else 0. A fresh RSS (less the + * file-backed part as read at most FILE_SAMPLE_MS ago). * A trip is written to the server log (at most once a minute, with a running count) - the * caller's own message only goes to the aborted operation's diagnostics. * @param {string} [place] - where the check was made, for the log diff --git a/tests/library/memory-guard.test.js b/tests/library/memory-guard.test.js index 7d4a7d36..41c95930 100644 --- a/tests/library/memory-guard.test.js +++ b/tests/library/memory-guard.test.js @@ -165,4 +165,44 @@ describe('memory-guard', () => { readSpy.mockRestore(); } }); + + (process.platform === 'linux' ? test : test.skip)('with mapped files, the in-operation check reads RssFile at most once per window, and RSS every time', () => { + const fs = require('fs'); + const realRead = fs.readFileSync; + let reads = 0; + let rssFileKb = 2 * 1024 * 1024; + const readSpy = jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => { + if (file !== '/proc/self/status') { + return realRead(file, ...rest); + } + reads++; + return `VmRSS:\t1 kB\nRssFile:\t${rssFileKb} kB\n`; + }); + let now = 1000000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now); + try { + memoryGuard.measureExcludingFileBacked(); + setRss(10.5 * GB); + expect(memoryGuard.overAbortThreshold('test')).toBe(0); // 8.5 GB counts + expect(reads).toBe(1); + // many checks inside the window: one read, while a rise in RSS is still seen at once + for (let i = 0; i < 1000; i++) { + now += 0.1; + expect(memoryGuard.overAbortThreshold('test')).toBe(0); + } + rssSpy.mockReturnValue(11.5 * GB); + expect(memoryGuard.overAbortThreshold('test')).toBe(9.5 * GB); + expect(reads).toBe(1); + expect(rssSpy.mock.calls.length).toBeGreaterThanOrEqual(1002); + // once the window has passed, RssFile is read again + rssFileKb = 3 * 1024 * 1024; + now += 200; + expect(memoryGuard.overAbortThreshold('test')).toBe(0); // 8.5 GB counts + expect(reads).toBe(2); + } finally { + nowSpy.mockRestore(); + memoryGuard._state.excludeFileBacked = false; + readSpy.mockRestore(); + } + }); }); From 4243a510d7803486d96f24908ea1f7c54c84c041 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 3 Nov 2026 18:53:31 -0600 Subject: [PATCH 8/8] memory guard: the in-operation check reuses an RSS measure up to 10 ms old overAbortThreshold (from every deadCheck) read process.memoryUsage.rss() fresh on every call: about 11 a request, about 7 us each, near 3% of the main thread at saturation. It now reuses the last measure if it is at most ABORT_SAMPLE_MS (10 ms) old. A dropped sample (configure, measureExcludingFileBacked) is still measured again at once. The 200 ms RssFile window and the 250 ms reject-gate sample are unchanged. Co-Authored-By: Claude Opus 5.5 --- library/memory-guard.js | 10 ++++--- tests/library/memory-guard.test.js | 42 +++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/library/memory-guard.js b/library/memory-guard.js index 27879e1e..2fa760ab 100644 --- a/library/memory-guard.js +++ b/library/memory-guard.js @@ -38,6 +38,9 @@ const GC_MAX_INTERVAL_MS = 60000; // times a request, and a read of /proc/self/status costs twice what the RSS itself does; // between reads, a fresh RSS less the last RssFile is a close enough estimate const FILE_SAMPLE_MS = 200; +// the in-operation check reuses a measure at most this old. It runs about ten times a request, +// and RSS moves little in 10 ms +const ABORT_SAMPLE_MS = 10; const state = { limit: 0, @@ -164,8 +167,9 @@ let lastAbortLog = 0; let abortLog = null; /** - * Current RSS in bytes if running operations should be aborted, else 0. A fresh RSS (less the - * file-backed part as read at most FILE_SAMPLE_MS ago). + * Current RSS in bytes if running operations should be aborted, else 0. A measure at most + * ABORT_SAMPLE_MS old (less the file-backed part as read at most FILE_SAMPLE_MS ago), or fresh + * if the sample was dropped (configure, measureExcludingFileBacked). * A trip is written to the server log (at most once a minute, with a running count) - the * caller's own message only goes to the aborted operation's diagnostics. * @param {string} [place] - where the check was made, for the log @@ -174,7 +178,7 @@ function overAbortThreshold(place = 'unknown') { if (state.abortAt <= 0) { return 0; } - const r = rss(true); + const r = rss(Date.now() - state.sampleTime > ABORT_SAMPLE_MS); if (r <= state.abortAt) { return 0; } diff --git a/tests/library/memory-guard.test.js b/tests/library/memory-guard.test.js index 41c95930..f07803cf 100644 --- a/tests/library/memory-guard.test.js +++ b/tests/library/memory-guard.test.js @@ -166,7 +166,7 @@ describe('memory-guard', () => { } }); - (process.platform === 'linux' ? test : test.skip)('with mapped files, the in-operation check reads RssFile at most once per window, and RSS every time', () => { + (process.platform === 'linux' ? test : test.skip)('with mapped files, the in-operation check reads RssFile at most once per window, and RSS every 10 ms', () => { const fs = require('fs'); const realRead = fs.readFileSync; let reads = 0; @@ -185,15 +185,17 @@ describe('memory-guard', () => { setRss(10.5 * GB); expect(memoryGuard.overAbortThreshold('test')).toBe(0); // 8.5 GB counts expect(reads).toBe(1); - // many checks inside the window: one read, while a rise in RSS is still seen at once + // many checks inside the window: one read, while a rise in RSS is seen within 10 ms for (let i = 0; i < 1000; i++) { now += 0.1; expect(memoryGuard.overAbortThreshold('test')).toBe(0); } rssSpy.mockReturnValue(11.5 * GB); + now += 10.5; expect(memoryGuard.overAbortThreshold('test')).toBe(9.5 * GB); expect(reads).toBe(1); - expect(rssSpy.mock.calls.length).toBeGreaterThanOrEqual(1002); + expect(rssSpy.mock.calls.length).toBeGreaterThanOrEqual(10); + expect(rssSpy.mock.calls.length).toBeLessThanOrEqual(12); // once the window has passed, RssFile is read again rssFileKb = 3 * 1024 * 1024; now += 200; @@ -205,4 +207,38 @@ describe('memory-guard', () => { readSpy.mockRestore(); } }); + + test('many checks inside 10 ms read RSS once; a dropped sample is measured again at once', () => { + let now = 1000000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now); + try { + setRss(9.5 * GB); + rssSpy.mockClear(); + for (let i = 0; i < 100; i++) { + now += 0.09; + expect(memoryGuard.overAbortThreshold('test')).toBe(9.5 * GB); + } + expect(rssSpy).toHaveBeenCalledTimes(1); + // a fall inside the 10 ms is not seen yet... + rssSpy.mockReturnValue(7 * GB); + expect(memoryGuard.overAbortThreshold('test')).toBe(9.5 * GB); + expect(rssSpy).toHaveBeenCalledTimes(1); + // ...but configure drops the sample, and the next check reads afresh + memoryGuard.configure({ limit: 10 * GB, rejectFraction: 0.8, abortFraction: 0.9 }); + expect(memoryGuard.overAbortThreshold('test')).toBe(0); + expect(rssSpy).toHaveBeenCalledTimes(2); + // as do measureExcludingFileBacked and any other drop of the sample + rssSpy.mockReturnValue(9.5 * GB); + memoryGuard.measureExcludingFileBacked(); + memoryGuard._state.excludeFileBacked = false; + expect(memoryGuard.overAbortThreshold('test')).toBe(9.5 * GB); + expect(rssSpy).toHaveBeenCalledTimes(3); + rssSpy.mockReturnValue(7 * GB); + memoryGuard._state.sampleTime = 0; + expect(memoryGuard.overAbortThreshold('test')).toBe(0); + expect(rssSpy).toHaveBeenCalledTimes(4); + } finally { + nowSpy.mockRestore(); + } + }); });