mpackdb
All repositories: gitoria
15.2 KB
# MPackDB Architecture## OverviewMPackDB is a fast, local, append-only JSON database that uses MessagePack serialization. It's designed for Node.js/Bun applications that need a simple, file-based database with optional indexing capabilities and smaller file sizes compared to BSON.## Core Components### 1. MPackDB Class (`src/MPackDB.js`)The main database class that handles all CRUD operations and coordinates between components.**Key Responsibilities:**- Database initialization and lifecycle management- CRUD operations (insert, update, delete, find)- File locking for concurrent access- Metadata persistence- Compaction of deleted records**Key Properties:**- `_dataPath`: Path to the `.mpack` data file- `_dataStream`: Write stream for append-only operations- `_meta`: Metadata object containing `nextId` and `deleted` offsets- `_indexManager`: Optional IndexManager instance for indexed queries- `_primaryKey`: Name of the primary key field- `_primaryKeyType`: Type of primary key (NUMBER, UUID, STRING)### 2. IndexManager Class (`src/IndexManager.js`)Manages binary search indexes for fast lookups on indexed fields.**Key Responsibilities:**- Building and maintaining indexes from data files- Binary search on disk-based index files- Delta indexes (in-memory changes not yet persisted)- Tombstone tracking for deleted records- Auto-persistence of indexes**Index File Format:**```key,offset,lengthkey,offset,length...```Each line represents an index entry where:- `key`: The indexed field value- `offset`: Byte offset in the data file- `length`: Length of the record in bytes**Index Types:**- **NUMERIC**: Numeric comparison for sorting/searching- **LEXICAL**: String comparison for sorting/searching- **UNIQUE**: Any index can be marked unique (`!` prefix) to reject duplicates on insert### 3. Cursor Class (`src/Cursor.js`)Provides an async iterable interface for query results.**Key Responsibilities:**- Lazy evaluation of queries- Support for different iteration modes (record, offset, mixed, raw)- Integration with IndexManager for indexed queries- Filtering with query functions### 4. MessagePack Utilities (`src/mpack.js`)Wrapper around `msgpackr` with custom utilities.**Key Exports:**- `serialize()`: Encode JavaScript objects to MessagePack binary- `deserialize()`: Decode MessagePack binary to JavaScript objects- `uuid()`: Generate sortable 12-char base36 unique IDs (9 timestamp + 3 random)- `PrimaryKeyType`: Enum for primary key types- `IndexType`: Enum for index types## Data Flow### Insert Operation```1. User calls db.insert(record)2. Acquire file lock3. Auto-generate primary key if needed (numeric/UUID)4. Check unique index constraints (skip auto-generated PKs)5. Serialize record to MessagePack6. Prepend 4-byte size header7. Append to data file via write stream8. Add entry to IndexManager (if indexes enabled)9. Persist metadata (if nextId changed)10. Release file lock11. Return primary key or full record```### Find Operation (Indexed)```1. User calls db.find(primaryKeyValue)2. Cursor created with query function3. IndexManager performs binary search on index file4. Check delta indexes for recent changes5. Check tombstones for deleted records6. Read record from data file at found offset7. Yield record to user```### Find Operation (Non-Indexed)```1. User calls db.find(queryFn)2. Cursor created with query function3. Stream through entire data file4. Read 4-byte size header5. Read MessagePack data based on size6. Deserialize each record7. Apply query function filter8. Skip deleted records (check metadata.deleted)9. Yield matching records to user```### Find with Index Hints```1. User calls db.find(filterFn, { index: { field, from, to, direction } })or db.find(filterFn, { index: [hint1, hint2, ...] })2. For each index hint, collect offset sets:- value hint: exact match via get() → offset set- range hint: entries(from, to, direction) → offset set3. If multiple hints: intersect all offset sets (smallest first)4. Stream records from the resulting offsets5. Apply filter function on deserialized records6. Yield matching records```### Bounding Box Query```1. User calls db.boundingBox(corners, filter)2. Extract min/max for x and y from corners (order irrelevant)3. Delegates to find(filter, { index: [{ field: 'x', from: minX, to: maxX },{ field: 'y', from: minY, to: maxY }]})4. Intersection + streaming as above```### Update Operation```1. User calls db.update(query, callback, { index })2. Internally calls delete(query, { index, callback })3. For each match (using find with optional index hints):a. Mark old record as deletedb. User callback transforms old record → new recordc. New record is inserted with same primary key4. Persist metadata with new deleted offsets```### Delete Operation```1. User calls db.delete(query, { index })2. Find matching records (uses index hints if provided, otherwise full scan)3. Add offsets to metadata.deleted array4. Remove from indexes (if enabled)5. Persist metadata```### Compact Operation```1. Acquire file lock2. Create temporary data file3. Stream through all records4. Write only non-deleted records to temp file5. Atomically rename temp file to replace original6. Clear metadata.deleted array7. Rebuild all indexes from new file8. Release file lock```## File Structure```data/├── users.mpack # Main data file (MessagePack records)├── users.meta.json # Metadata (nextId, deleted offsets)├── users.lock # Lock file (contains PID)├── users.id.txt # Index file for 'id' field├── users.email.txt # Index file for 'email' field└── ...```## Serialization FormatMPackDB uses MessagePack format from the `msgpackr` npm package with a custom size header:```[4 bytes: size][MessagePack data]```Each record is prefixed with a 4-byte little-endian integer indicating the size of the MessagePack data (not including the size prefix itself).**Why the size header?**- MessagePack doesn't include record boundaries in the format- The size header allows streaming reads without parsing the entire file- Enables skipping deleted records efficiently- Matches the pattern used in BSON for consistency## Locking MechanismMPackDB uses file-based locking to prevent concurrent writes:1. Before any write operation, create `{dbPath}.lock` file with `wx` flag (exclusive)2. Write current process PID to lock file3. If lock exists, wait 100ms and retry4. After operation completes, delete lock fileThe lock is **re-entrant**: if a lock is already held (e.g. inside `withLock()`), nested operations (`insert`, `delete`, etc.) increment a depth counter instead of acquiring a new file lock. The file lock is only released when the outermost holder finishes.### withLock for Compound Operations`db.withLock(callback)` acquires the lock for the duration of the callback. All DB operations inside the callback reuse the same lock. This makes compound operations like find-then-insert atomic:```javascriptawait db.withLock(async () => {const [user] = await db.find(u => u.email === email);if (!user) await db.insert({ email });});```This ensures only one process can write at a time while allowing multiple readers.## Index Persistence StrategyIndexes use a two-tier approach:### Disk Indexes- Sorted index files on disk- Binary searchable for O(log n) lookups- Rebuilt during compaction### Delta Indexes (In-Memory)- Track changes since last persistence- Checked before disk indexes- Auto-persisted based on:- Time interval (default: 60 seconds)- Change threshold (default: 1000 operations)### Tombstones- Track deleted records in memory- Prevent returning deleted records from disk indexes- Cleared during compaction## Performance Characteristics### Time Complexity- **Insert**: O(1) for append, O(log n) for index update- **Find by primary key (indexed)**: O(log n) binary search- **Find with query function**: O(n) full scan- **Find with single index hint**: O(log n) seek + O(k) scan where k = entries in range- **Find with index intersection**: O(k1 + k2 + ... + min(k)) for collecting + intersecting offset sets, then O(m) disk reads where m = intersection size- **Bounding box**: Same as index intersection with 2 range indexes- **Update**: O(find) + O(1) insert- **Delete**: O(find) + O(1) mark- **Compact**: O(n) full scan + O(n log n) index rebuild### Space Complexity- Data file grows with inserts (append-only)- Deleted records remain until compaction- Index files: O(n) per indexed field- Delta indexes: O(m) where m = changes since last persist### File Size ComparisonMessagePack typically produces **15-20% smaller files** than BSON for the same data:- More compact integer encoding- Smaller string overhead- Efficient array/map encoding## Concurrency Model- **Single-writer, multiple-reader** via file locking- Writes are serialized through lock file- Reads can happen concurrently (no locks needed)- Re-entrant locks allow `withLock()` to wrap compound operations atomically- Unique indexes enforce constraints under the write lock (no duplicates even with concurrent inserts)- `refresh()` re-reads `meta.json` before every read so secondary instances see up-to-date state- Index persistence happens asynchronously but safely## Primary Key Types### NUMBER (PrimaryKeyType.NUMBER)- Auto-incremented integer- Stored in metadata.nextId- Prefix syntax: `*id`### UUID (PrimaryKeyType.UUID)- Sortable base36 unique ID (9-char timestamp + 3-char random)- 12-character string format- Prefix syntax: `@id`### STRING (PrimaryKeyType.STRING)- User-provided string- No auto-generation- Default (no prefix)## Design Decisions### Why Append-Only?- **Fast writes**: No seeking, just append- **Crash safety**: Partial writes don't corrupt existing data- **Simple implementation**: No complex update-in-place logic### Why MessagePack?- **Smaller files**: 15-20% smaller than BSON on average- **Fast serialization**: Comparable or faster than BSON- **Wide language support**: Available in many programming languages- **Simple format**: Easy to implement and debug- **No external binary dependencies**: Pure JavaScript implementation### Why Custom Size Header?- MessagePack doesn't define record boundaries- Enables efficient streaming without full deserialization- Allows skipping deleted records quickly- Consistent with BSON's approach### Why File-Based Locking?- **Simple**: No external dependencies- **Cross-process**: Works across multiple Node.js processes- **Portable**: Works on all platforms### Why Binary Search Indexes?- **Disk-friendly**: Can search large indexes without loading into memory- **Simple format**: Plain text, easy to debug- **Fast lookups**: O(log n) for indexed queries## MessagePack vs BSON### Advantages of MessagePack- **Smaller files**: 15-20% size reduction- **Faster reads**: Simpler format, less parsing overhead- **Pure JavaScript**: No native dependencies- **Smaller library**: ~15KB vs ~173KB for BSON### Advantages of BSON- **ObjectId type**: Built-in unique identifier type- **Date precision**: Millisecond timestamps- **Binary data**: Native binary type- **MongoDB compatibility**: Direct compatibility with MongoDB### When to Choose MPackDB- File size is a concern- Pure JavaScript dependencies preferred- Don't need MongoDB compatibility- Want faster read performance### When to Choose BsonDB- Need ObjectId primary keys- MongoDB compatibility desired- Working with binary data- Need precise date/time handling## Limitations1. **Single-writer**: Only one write operation at a time2. **No multi-record transactions**: `withLock` provides atomicity for compound operations on a single DB, but not across multiple databases3. **No query language**: Must use JavaScript functions for complex queries4. **Compaction required**: Deleted records consume space until compaction5. **Index overhead**: Each index doubles storage for that field6. **No schema validation**: Records can have any structure## Index Query Design Decisions### Why `to` but not `limit` or `filter`The `entries()` generator supports `from`, `to`, and `direction` but intentionally has no `limit` or `filter` parameters.**Why `to` is needed:** When collecting offset sets for index intersection, all matching offsets are loaded into a Map. Without `to`, a range scan like `{ field: 'x', from: 0 }` would collect every entry from 0 to the end of the index — potentially millions of offsets when only a small range is needed. `to` stops the scan at the upper bound, keeping the offset set small.**Why `limit` was removed:** `limit` caps the number of results, but the caller doesn't know the right count upfront. Since results are streamed via async generators, the caller can `break` out of the loop at any time, which terminates the generator immediately. This is more flexible than a fixed limit and works naturally with JavaScript's `for await` syntax.**Why `filter` was removed:** With streaming, the caller filters in the loop body after deserialization. A built-in filter would only save one `if` statement per iteration while adding API complexity. The filter function in `find()` handles this at the right layer — after records are fetched from disk.### Why index intersection uses pre-collected offset setsIndex intersection collects full offset sets from each index hint, then intersects them before reading any records from disk. This trades memory (storing offset integers) for disk I/O (avoiding unnecessary record reads).**Trade-off:** For a query like `x: 0..100 AND status: 'active'` on 10 million records, the x-index might yield 500k offsets and the status-index 50k offsets. Intersecting these sets (~4MB of integers in memory) reduces disk reads from 500k to maybe 10k — a massive I/O saving.**Why not stream-and-check:** An alternative would be to stream the primary index and check each candidate against secondary indexes on the fly (O(log n) lookup per candidate). This uses less memory but requires a disk seek per candidate. For large datasets where the secondary index is highly selective, pre-collected intersection wins. For small datasets, the difference is negligible.**Why the caller picks the primary index:** The engine cannot automatically pick the most selective index without scanning all of them first. Instead, the caller specifies which indexes to use via the `index` option. For streaming without intersection (single index), the caller controls the scan with `from`, `to`, `direction`, and `break`.### Bounding box vs geospatial`boundingBox()` operates on flat 2D coordinates — simple min/max comparisons on x and y axes. This is sufficient for game grids, tile maps, and any coordinate system where geometry is planar. Geospatial indexing (S2 cells, geohash) is only needed for spherical geometry (lat/lng on Earth) where "straight lines" are actually great-circle arcs and distances warp with latitude. Both approaches use the same fundamental pattern internally: coarse index scan → exact geometric filter.## Future Improvements- Batch insert operations- Async compaction (background process)- Query optimizer for complex filters- Compression support (MessagePack supports extensions)- Replication/backup utilities- Schema validation layer- Custom MessagePack extension types
Branches
- mastermain branch
Latest commits
- 0afb8f4bupdate now must be a callbackcaramboleyo
- cde73eb4release 1.0.6caramboleyo
- d01dda02add index hints, intersection, boundingBox; remove findByIndexcaramboleyo
- b8ffc1a0release 1.0.5caramboleyo
- d47876a1reimplemented lost features like indexed find and more testscaramboleyo
- 7f08da9afixed insert ignoring model definitioncaramboleyo
- 705774a9added flush before findcaramboleyo
- b4db6391initial commitcaramboleyo