feat: implement WorldBlockCache for efficient block management with LRU caching

This commit is contained in:
MayaTheShy
2026-03-22 11:44:58 -04:00
parent 811e2a6e18
commit 34725d7d71

226
server/WorldBlockCache.js Normal file
View File

@@ -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);
}
}