diff --git a/server/WorldBlockCache.js b/server/WorldBlockCache.js new file mode 100644 index 0000000..308b41d --- /dev/null +++ b/server/WorldBlockCache.js @@ -0,0 +1,226 @@ +/** + * WorldBlockCache — LRU write-through cache for world blocks + * + * Replaces the unbounded in-memory Map that previously loaded ALL blocks at + * startup. Provides the same Map-like interface (get/set/delete) expected by + * DStarLite pathfinder and Turtle._deleteBlockAtPosition, but caps memory + * usage at `maxSize` entries and falls through to SQLite on cache miss. + * + * Bulk reads (initial_state, /api/world/blocks) bypass the cache and query + * the database directly via helper methods. + */ + +export class WorldBlockCache { + /** + * @param {Object} db - The database module (server/database.js) + * @param {number} maxSize - Max entries in the LRU cache (default 50 000) + */ + constructor(db, maxSize = 50_000) { + this._db = db; + this._maxSize = maxSize; + this._cache = new Map(); // key -> { value, prev, next } (LRU doubly-linked) + this._head = null; // most recently used + this._tail = null; // least recently used + + // Track total block count from DB (updated lazily) + this._dbCount = null; + } + + // ========== Map-compatible interface ========== + + /** + * Get a block by "x,y,z" key. + * Returns the cached value or falls through to the database. + */ + get(key) { + // Cache hit + if (this._cache.has(key)) { + const node = this._cache.get(key); + this._promote(node); + return node.value; + } + + // Cache miss — query DB + const [x, y, z] = key.split(',').map(Number); + let row; + try { + row = this._db.getBlock(x, y, z); + } catch (e) { + return undefined; + } + + if (!row) return undefined; + + const value = { + name: row.name, + metadata: row.metadata, + discoveredBy: row.discoveredBy, + timestamp: row.discovered_at, + }; + + this._put(key, value); + return value; + } + + /** + * Store a block (write-through: updates cache + DB is handled by caller via storeBlock). + */ + set(key, value) { + this._put(key, value); + this._dbCount = null; // invalidate count cache + return this; + } + + /** + * Delete a block from cache (DB deletion handled by caller). + */ + delete(key) { + if (this._cache.has(key)) { + const node = this._cache.get(key); + this._unlink(node); + this._cache.delete(key); + } + this._dbCount = null; + return true; + } + + /** + * Check if a key exists (checks cache first, then DB). + */ + has(key) { + if (this._cache.has(key)) return true; + const [x, y, z] = key.split(',').map(Number); + try { + return this._db.getBlock(x, y, z) !== null; + } catch (e) { + return false; + } + } + + /** + * Approximate size — returns DB count (not cache size). + */ + get size() { + if (this._dbCount === null) { + try { + this._dbCount = this._db.getWorldBlockCount(); + } catch (e) { + // Fallback: count cached entries + this._dbCount = this._cache.size; + } + } + return this._dbCount; + } + + /** + * Iterate all entries — queries DB directly (not bounded by cache). + * Used for initial_state and /api/world/blocks. + * Returns an iterator of [key, value] pairs. + */ + *entries() { + const rows = this._db.getWorldBlocks(100000); + for (const row of rows) { + const key = `${row.x},${row.y},${row.z}`; + yield [key, { + name: row.block_name, + metadata: row.metadata, + discoveredBy: row.discovered_by, + timestamp: row.discovered_at, + }]; + } + } + + // ========== Bulk helpers for API / WebSocket ========== + + /** + * Get blocks in an area (for spatial queries). + */ + getBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ) { + return this._db.getWorldBlocksInArea(minX, minY, minZ, maxX, maxY, maxZ); + } + + /** + * Get all blocks formatted for the /api/world/blocks response. + * @param {number} limit - Max rows to return + */ + getAllBlocksForAPI(limit = 100000) { + const rows = this._db.getWorldBlocks(limit); + return rows.map(row => ({ + x: row.x, y: row.y, z: row.z, + name: row.block_name, + metadata: row.metadata, + discoveredBy: row.discovered_by, + timestamp: row.discovered_at, + })); + } + + /** + * Warm the cache with blocks near a set of positions (e.g., turtle locations). + */ + warmArea(cx, cy, cz, radius = 32) { + const blocks = this._db.getWorldBlocksInArea( + cx - radius, cy - radius, cz - radius, + cx + radius, cy + radius, cz + radius, + ); + for (const row of blocks) { + const key = `${row.x},${row.y},${row.z}`; + if (!this._cache.has(key)) { + this._put(key, { + name: row.block_name, + metadata: row.metadata, + discoveredBy: row.discovered_by, + timestamp: row.discovered_at, + }); + } + } + } + + // ========== Internal LRU ========== + + _put(key, value) { + if (this._cache.has(key)) { + const node = this._cache.get(key); + node.value = value; + this._promote(node); + } else { + const node = { key, value, prev: null, next: null }; + this._cache.set(key, node); + this._addToHead(node); + + // Evict if over capacity + if (this._cache.size > this._maxSize) { + this._evict(); + } + } + } + + _promote(node) { + if (node === this._head) return; + this._unlink(node); + this._addToHead(node); + } + + _addToHead(node) { + node.prev = null; + node.next = this._head; + if (this._head) this._head.prev = node; + this._head = node; + if (!this._tail) this._tail = node; + } + + _unlink(node) { + if (node.prev) node.prev.next = node.next; + else this._head = node.next; + if (node.next) node.next.prev = node.prev; + else this._tail = node.prev; + node.prev = null; + node.next = null; + } + + _evict() { + if (!this._tail) return; + const evicted = this._tail; + this._unlink(evicted); + this._cache.delete(evicted.key); + } +}