PLWL/src/hashcache.js
2026-10-07 16:29:40 +02:00

118 lines
3.6 KiB
JavaScript

'use strict'
// Content hashing for the LAN sync, plus the per-file digest cache
// (files/data/lansync-hashes.json, keyed by path with size + mtime). The
// cache is machine-local and never travels in a sync.
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
// BLAKE2b is faster than SHA-2; older runtimes fall back to SHA-2.
const HASH_ALGO = (() => {
for (const algo of ['blake2b512', 'sha512', 'sha256']) {
try {
crypto.createHash(algo)
return algo
} catch {}
}
return 'sha256'
})()
// Streamed hashing keeps memory flat on huge files.
function hashFile(file, onBytes) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash(HASH_ALGO)
const stream = fs.createReadStream(file, { highWaterMark: 64 * 1024 })
stream.on('error', reject)
if (onBytes) stream.on('data', chunk => onBytes(chunk.length))
stream.on('data', chunk => hash.update(chunk))
stream.on('end', () => resolve(hash.digest('hex')))
stream.resume()
})
}
// Beyond a quarter million entries, the least recently touched are dropped.
const HASH_CACHE_MAX = 250000
// Mid-sync saves are throttled; callers that must not lose entries pass force=true.
const SAVE_MIN_MS = 60000
function createHashCache(dataDir) {
const FILE = path.join(dataDir, 'lansync-hashes.json')
let cache = null
let dirty = false
let savedAt = 0
function load() {
if (cache) return cache
try {
const stored = JSON.parse(fs.readFileSync(FILE, 'utf8'))
cache = stored && typeof stored === 'object' && stored.files && typeof stored.files === 'object' ? stored.files : {}
} catch {
cache = {}
}
return cache
}
// Compact JSON: a full cache writes in tens of ms.
function save(force) {
if (!dirty || !cache) return
if (!force && Date.now() - savedAt < SAVE_MIN_MS) return
const names = Object.keys(cache)
if (names.length > HASH_CACHE_MAX) {
names.sort((a, b) => (cache[b][3] || 0) - (cache[a][3] || 0))
const trimmed = {}
for (const name of names.slice(0, HASH_CACHE_MAX)) trimmed[name] = cache[name]
cache = trimmed
}
dirty = false
savedAt = Date.now()
try {
fs.mkdirSync(path.dirname(FILE), { recursive: true })
const tmp = `${FILE}.tmp`
fs.writeFileSync(tmp, JSON.stringify({ v: 1, algo: HASH_ALGO, files: cache }))
fs.renameSync(tmp, FILE)
} catch {}
}
// The remembered digest, or null when it has to be read again. A hit marks
// the cache dirty only when the timestamp is stale.
function hit(rel, size, mtimeMs) {
const known = load()[rel]
if (!known || known[0] !== size || known[1] !== mtimeMs || typeof known[2] !== 'string') return null
if (Date.now() - (known[3] || 0) > 86400000) {
known[3] = Date.now()
dirty = true
}
return known[2]
}
function remember(rel, size, mtimeMs, hash) {
load()[rel] = [size, mtimeMs, hash, Date.now()]
dirty = true
}
// Unchanged size + mtime -> cached digest; otherwise read and store.
async function cachedHash(rel, abs, size, mtimeMs, onBytes) {
const known = hit(rel, size, mtimeMs)
if (known) return known
const hash = await hashFile(abs, onBytes)
if (hash) remember(rel, size, mtimeMs, hash)
return hash
}
// Settings view "clear cache" and stale files go through here.
function clear() {
cache = null
dirty = false
savedAt = 0
try {
fs.rmSync(FILE, { force: true })
fs.rmSync(`${FILE}.tmp`, { force: true })
} catch {}
}
return { HASH_ALGO, hashFile, hit, remember, cachedHash, save, clear }
}
module.exports = { createHashCache, hashFile, HASH_ALGO }