gitoriaLog in with ident

mpackdb

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmaster87888725release 1.0.7caramboleyomaster/ARCHITECTURE.md

17.0 KB

  1. # MPackDB Architecture
  2. ## Overview
  3. MPackDB 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.
  4. ## Core Components
  5. ### 1. MPackDB Class (`src/MPackDB.js`)
  6. The main database class that handles all CRUD operations and coordinates between components.
  7. **Key Responsibilities:**
  8. - Database initialization and lifecycle management
  9. - CRUD operations (insert, update, delete, find)
  10. - File locking for concurrent access
  11. - Metadata persistence
  12. - Compaction of deleted records
  13. **Key Properties:**
  14. - `_dataPath`: Path to the `.mpack` data file
  15. - `_dataStream`: Write stream for append-only operations
  16. - `_meta`: Metadata object containing `nextId` and `deleted` offsets
  17. - `_indexManager`: Optional IndexManager instance for indexed queries
  18. - `_primaryKey`: Name of the primary key field
  19. - `_primaryKeyType`: Type of primary key (NUMBER, UUID, STRING)
  20. ### 2. IndexManager Class (`src/IndexManager.js`)
  21. Manages binary search indexes for fast lookups on indexed fields.
  22. **Key Responsibilities:**
  23. - Building and maintaining indexes from data files
  24. - Binary search on disk-based index files
  25. - Delta indexes (in-memory changes not yet persisted)
  26. - Tombstone tracking for deleted records
  27. - Auto-persistence of indexes
  28. **Index File Format:**
  29. ```
  30. key,offset,length
  31. key,offset,length
  32. ...
  33. ```
  34. Each line represents an index entry where:
  35. - `key`: The indexed field value
  36. - `offset`: Byte offset in the data file
  37. - `length`: Length of the record in bytes
  38. **Index Types:**
  39. - **NUMERIC**: Numeric comparison for sorting/searching
  40. - **LEXICAL**: String comparison for sorting/searching
  41. - **UNIQUE**: Any index can be marked unique (`!` prefix) to reject duplicates on insert
  42. ### 3. Cursor Class (`src/Cursor.js`)
  43. Provides an async iterable interface for query results.
  44. **Key Responsibilities:**
  45. - Lazy evaluation of queries
  46. - Support for different iteration modes (record, offset, mixed, raw)
  47. - Integration with IndexManager for indexed queries
  48. - Filtering with query functions
  49. ### 4. MessagePack Utilities (`src/mpack.js`)
  50. Wrapper around `msgpackr` with custom utilities.
  51. **Key Exports:**
  52. - `serialize()`: Encode JavaScript objects to MessagePack binary
  53. - `deserialize()`: Decode MessagePack binary to JavaScript objects
  54. - `uuid()`: Generate sortable 12-char base36 unique IDs (9 timestamp + 3 random)
  55. - `PrimaryKeyType`: Enum for primary key types
  56. - `IndexType`: Enum for index types
  57. ## Data Flow
  58. ### Insert Operation
  59. ```
  60. 1. User calls db.insert(record)
  61. 2. Acquire file lock
  62. 3. Auto-generate primary key if needed (numeric/UUID)
  63. 4. Check unique index constraints (skip auto-generated PKs)
  64. 5. Serialize record to MessagePack
  65. 6. Prepend 4-byte size header
  66. 7. Append to data file via write stream
  67. 8. Add entry to IndexManager (if indexes enabled)
  68. 9. Persist metadata (if nextId changed)
  69. 10. Release file lock
  70. 11. Return primary key or full record
  71. ```
  72. ### Find Operation (Indexed)
  73. ```
  74. 1. User calls db.find(primaryKeyValue)
  75. 2. Cursor created with query function
  76. 3. IndexManager performs binary search on index file
  77. 4. Check delta indexes for recent changes
  78. 5. Check tombstones for deleted records
  79. 6. Read record from data file at found offset
  80. 7. Yield record to user
  81. ```
  82. ### Find Operation (Non-Indexed)
  83. ```
  84. 1. User calls db.find(queryFn)
  85. 2. Cursor created with query function
  86. 3. Stream through entire data file
  87. 4. Read 4-byte size header
  88. 5. Read MessagePack data based on size
  89. 6. Deserialize each record
  90. 7. Apply query function filter
  91. 8. Skip deleted records (check metadata.deleted)
  92. 9. Yield matching records to user
  93. ```
  94. ### Find with Index Hints
  95. ```
  96. 1. User calls db.find(filterFn, { index: { field, from, to, direction } })
  97. or db.find(filterFn, { index: [hint1, hint2, ...] })
  98. 2. For each index hint, collect offset sets:
  99. - value hint: exact match via get() → offset set
  100. - range hint: entries(from, to, direction) → offset set
  101. 3. If multiple hints: intersect all offset sets (smallest first)
  102. 4. Stream records from the resulting offsets
  103. 5. Apply filter function on deserialized records
  104. 6. Yield matching records
  105. ```
  106. ### Bounding Box Query
  107. ```
  108. 1. User calls db.boundingBox(corners, filter)
  109. 2. Extract min/max for x and y from corners (order irrelevant)
  110. 3. Delegates to find(filter, { index: [
  111. { field: 'x', from: minX, to: maxX },
  112. { field: 'y', from: minY, to: maxY }
  113. ]})
  114. 4. Intersection + streaming as above
  115. ```
  116. ### Update Operation
  117. ```
  118. 1. User calls db.update(query, callback, { index })
  119. 2. Internally calls delete(query, { index, callback })
  120. 3. For each match (using find with optional index hints):
  121. a. Mark old record as deleted
  122. b. User callback transforms old record → new record
  123. c. New record is inserted with same primary key
  124. 4. Persist metadata with new deleted offsets
  125. ```
  126. ### Delete Operation
  127. ```
  128. 1. User calls db.delete(query, { index })
  129. 2. Find matching records (uses index hints if provided, otherwise full scan)
  130. 3. Add offsets to metadata.deleted array
  131. 4. Remove from indexes (if enabled)
  132. 5. Persist metadata
  133. ```
  134. ### Compact Operation
  135. ```
  136. 1. Acquire file lock
  137. 2. Create temporary data file
  138. 3. Stream through all records
  139. 4. Write only non-deleted records to temp file
  140. 5. Atomically rename temp file to replace original
  141. 6. Clear metadata.deleted array
  142. 7. Rebuild all indexes from new file
  143. 8. Release file lock
  144. ```
  145. ## File Structure
  146. ```
  147. data/
  148. ├── users.mpack # Main data file (MessagePack records)
  149. ├── users.meta.json # Metadata (nextId, deleted offsets)
  150. ├── users.lock # Lock file (contains PID)
  151. ├── users.id.txt # Index file for 'id' field
  152. ├── users.email.txt # Index file for 'email' field
  153. └── ...
  154. ```
  155. ## Serialization Format
  156. MPackDB uses MessagePack format from the `msgpackr` npm package with a custom size header:
  157. ```
  158. [4 bytes: size][MessagePack data]
  159. ```
  160. Each record is prefixed with a 4-byte little-endian integer indicating the size of the MessagePack data (not including the size prefix itself).
  161. **Why the size header?**
  162. - MessagePack doesn't include record boundaries in the format
  163. - The size header allows streaming reads without parsing the entire file
  164. - Enables skipping deleted records efficiently
  165. - Matches the pattern used in BSON for consistency
  166. ## Locking Mechanism
  167. Two layers serialize writes:
  168. 1. **In-process FIFO queue** (`_mutexTail` promise chain): concurrent operations on the same instance run strictly one after another, without polling.
  169. 2. **Lock file** (`{dbPath}.lock`, created with the exclusive `wx` flag, containing the PID): serializes against other processes. Contention polls every 25ms. Lock files older than `staleLockTimeout` (default 30s) are treated as left behind by a crashed process and taken over (rename-then-verify, so racing waiters can't steal a live lock).
  170. The lock is **causally re-entrant** via `AsyncLocalStorage`: only operations invoked *inside* the lock-holding call chain (find inside delete, insert inside withLock, update's internal delete+insert) re-enter. An operation that merely *starts while* another one holds the lock queues up — this distinction matters; a depth-counter implementation used previously let any concurrently started operation walk into the critical section.
  171. On acquiring the file lock, the instance runs `refresh()` before entering the critical section, so writes always start from the newest cross-process state (`nextId`, tombstones).
  172. Reads take no lock. Their consistency is guaranteed by the meta version guard (below).
  173. ### withLock for Compound Operations
  174. `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:
  175. ```javascript
  176. await db.withLock(async () => {
  177. const [user] = await db.find(u => u.email === email);
  178. if (!user) await db.insert({ email });
  179. });
  180. ```
  181. This ensures only one process can write at a time while allowing multiple readers.
  182. ## Index Persistence Strategy
  183. Indexes use a two-tier approach:
  184. ### Disk Indexes
  185. - Sorted index files on disk
  186. - Binary searchable for O(log n) lookups
  187. - Rebuilt during compaction
  188. ### Delta Indexes (In-Memory)
  189. - Track changes since last persistence
  190. - Checked before disk indexes
  191. - Auto-persisted based on:
  192. - Time interval (default: 60 seconds)
  193. - Change threshold (default: 1000 operations)
  194. ### Tombstones
  195. - Track deleted records in memory
  196. - Prevent returning deleted records from disk indexes
  197. - Cleared during compaction
  198. ## Performance Characteristics
  199. ### Time Complexity
  200. - **Insert**: O(1) for append, O(log n) for index update
  201. - **Find by primary key (indexed)**: O(log n) binary search
  202. - **Find with query function**: O(n) full scan
  203. - **Find with single index hint**: O(log n) seek + O(k) scan where k = entries in range
  204. - **Find with index intersection**: O(k1 + k2 + ... + min(k)) for collecting + intersecting offset sets, then O(m) disk reads where m = intersection size
  205. - **Bounding box**: Same as index intersection with 2 range indexes
  206. - **Update**: O(find) + O(1) insert
  207. - **Delete**: O(find) + O(1) mark
  208. - **Compact**: O(n) full scan + O(n log n) index rebuild
  209. ### Space Complexity
  210. - Data file grows with inserts (append-only)
  211. - Deleted records remain until compaction
  212. - Index files: O(n) per indexed field
  213. - Delta indexes: O(m) where m = changes since last persist
  214. ### File Size Comparison
  215. MessagePack typically produces **15-20% smaller files** than BSON for the same data:
  216. - More compact integer encoding
  217. - Smaller string overhead
  218. - Efficient array/map encoding
  219. ## Concurrency Model
  220. - **Single-writer, multiple-reader**, in-process and across processes
  221. - Writes are serialized through the in-process queue + lock file
  222. - Reads can happen concurrently (no locks needed)
  223. - Causally re-entrant locks allow `withLock()` to wrap compound operations atomically
  224. - Unique indexes enforce constraints under the write lock (no duplicates even with concurrent inserts)
  225. - `meta.json` carries a **monotonic version**, bumped on every `persistMeta()` (which writes atomically via tmp+rename). `refresh()` runs before every read and only adopts the on-disk meta when its version is newer — the in-memory meta (which may hold un-persisted mutations of an in-flight write) is authoritative for this process. This is what makes lock-free reads safe next to writes.
  226. - `meta.json` also stores the collection **schema** (prefixed primaryKey + indexes) so external tools can open the db correctly.
  227. - **Cross-process index coherence:** `{name}.idxstate.json` records `coveredBytes` — how far into the data file the persisted indexes reach. `refresh()` stats the data file; if it grew beyond our coverage, the new tail is scanned and indexed incrementally (cheap, append-only). If the file's *inode* changed (another process compacted), the instance reopens its write stream and rebases its index state.
  228. - Index persistence (read-merge-write of the shared index files) runs under the db lock and dedupes entries by location; a threshold-triggered persist inside `insert()` deliberately escapes the insert's lock context (`lockContext.exit`) and queues for its own turn, because it outlives the insert.
  229. ## Primary Key Types
  230. ### NUMBER (PrimaryKeyType.NUMBER)
  231. - Auto-incremented integer
  232. - Stored in metadata.nextId
  233. - Prefix syntax: `*id`
  234. ### UUID (PrimaryKeyType.UUID)
  235. - Sortable base36 unique ID (9-char timestamp + 3-char random)
  236. - 12-character string format
  237. - Prefix syntax: `@id`
  238. ### STRING (PrimaryKeyType.STRING)
  239. - User-provided string
  240. - No auto-generation
  241. - Default (no prefix)
  242. ## Design Decisions
  243. ### Why Append-Only?
  244. - **Fast writes**: No seeking, just append
  245. - **Crash safety**: Partial writes don't corrupt existing data
  246. - **Simple implementation**: No complex update-in-place logic
  247. ### Why MessagePack?
  248. - **Smaller files**: 15-20% smaller than BSON on average
  249. - **Fast serialization**: Comparable or faster than BSON
  250. - **Wide language support**: Available in many programming languages
  251. - **Simple format**: Easy to implement and debug
  252. - **No external binary dependencies**: Pure JavaScript implementation
  253. ### Why Custom Size Header?
  254. - MessagePack doesn't define record boundaries
  255. - Enables efficient streaming without full deserialization
  256. - Allows skipping deleted records quickly
  257. - Consistent with BSON's approach
  258. ### Why File-Based Locking?
  259. - **Simple**: No external dependencies
  260. - **Cross-process**: Works across multiple Node.js processes
  261. - **Portable**: Works on all platforms
  262. ### Why Binary Search Indexes?
  263. - **Disk-friendly**: Can search large indexes without loading into memory
  264. - **Simple format**: Plain text, easy to debug
  265. - **Fast lookups**: O(log n) for indexed queries
  266. ## MessagePack vs BSON
  267. ### Advantages of MessagePack
  268. - **Smaller files**: 15-20% size reduction
  269. - **Faster reads**: Simpler format, less parsing overhead
  270. - **Pure JavaScript**: No native dependencies
  271. - **Smaller library**: ~15KB vs ~173KB for BSON
  272. ### Advantages of BSON
  273. - **ObjectId type**: Built-in unique identifier type
  274. - **Date precision**: Millisecond timestamps
  275. - **Binary data**: Native binary type
  276. - **MongoDB compatibility**: Direct compatibility with MongoDB
  277. ### When to Choose MPackDB
  278. - File size is a concern
  279. - Pure JavaScript dependencies preferred
  280. - Don't need MongoDB compatibility
  281. - Want faster read performance
  282. ### When to Choose BsonDB
  283. - Need ObjectId primary keys
  284. - MongoDB compatibility desired
  285. - Working with binary data
  286. - Need precise date/time handling
  287. ## Limitations
  288. 1. **Single-writer**: Only one write operation at a time
  289. 2. **No multi-record transactions**: `withLock` provides atomicity for compound operations on a single DB, but not across multiple databases
  290. 3. **No query language**: Must use JavaScript functions for complex queries
  291. 4. **Compaction required**: Deleted records consume space until compaction
  292. 5. **Index overhead**: Each index doubles storage for that field
  293. 6. **No schema validation**: Records can have any structure
  294. ## Index Query Design Decisions
  295. ### Why `to` but not `limit` or `filter`
  296. The `entries()` generator supports `from`, `to`, and `direction` but intentionally has no `limit` or `filter` parameters.
  297. **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.
  298. **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.
  299. **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.
  300. ### Why index intersection uses pre-collected offset sets
  301. Index 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).
  302. **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.
  303. **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.
  304. **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`.
  305. ### Bounding box vs geospatial
  306. `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.
  307. ## Future Improvements
  308. - Batch insert operations
  309. - Async compaction (background process)
  310. - Query optimizer for complex filters
  311. - Compression support (MessagePack supports extensions)
  312. - Replication/backup utilities
  313. - Schema validation layer
  314. - Custom MessagePack extension types

Branches

  • mastermain branch

Latest commits

  • 87888725release 1.0.7caramboleyo
  • c4cdb9b6node: import prefixes (Deno compat) + pre-existing index-state WIPcaramboleyo
  • 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