gitoriaLog in with ident

mpackdb

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit0afb8f4b0afb8f4bupdate now must be a callbackcaramboleyo0afb8f4b/ARCHITECTURE.md

15.2 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. MPackDB uses file-based locking to prevent concurrent writes:
  168. 1. Before any write operation, create `{dbPath}.lock` file with `wx` flag (exclusive)
  169. 2. Write current process PID to lock file
  170. 3. If lock exists, wait 100ms and retry
  171. 4. After operation completes, delete lock file
  172. The 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.
  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** via file locking
  221. - Writes are serialized through lock file
  222. - Reads can happen concurrently (no locks needed)
  223. - 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. - `refresh()` re-reads `meta.json` before every read so secondary instances see up-to-date state
  226. - Index persistence happens asynchronously but safely
  227. ## Primary Key Types
  228. ### NUMBER (PrimaryKeyType.NUMBER)
  229. - Auto-incremented integer
  230. - Stored in metadata.nextId
  231. - Prefix syntax: `*id`
  232. ### UUID (PrimaryKeyType.UUID)
  233. - Sortable base36 unique ID (9-char timestamp + 3-char random)
  234. - 12-character string format
  235. - Prefix syntax: `@id`
  236. ### STRING (PrimaryKeyType.STRING)
  237. - User-provided string
  238. - No auto-generation
  239. - Default (no prefix)
  240. ## Design Decisions
  241. ### Why Append-Only?
  242. - **Fast writes**: No seeking, just append
  243. - **Crash safety**: Partial writes don't corrupt existing data
  244. - **Simple implementation**: No complex update-in-place logic
  245. ### Why MessagePack?
  246. - **Smaller files**: 15-20% smaller than BSON on average
  247. - **Fast serialization**: Comparable or faster than BSON
  248. - **Wide language support**: Available in many programming languages
  249. - **Simple format**: Easy to implement and debug
  250. - **No external binary dependencies**: Pure JavaScript implementation
  251. ### Why Custom Size Header?
  252. - MessagePack doesn't define record boundaries
  253. - Enables efficient streaming without full deserialization
  254. - Allows skipping deleted records quickly
  255. - Consistent with BSON's approach
  256. ### Why File-Based Locking?
  257. - **Simple**: No external dependencies
  258. - **Cross-process**: Works across multiple Node.js processes
  259. - **Portable**: Works on all platforms
  260. ### Why Binary Search Indexes?
  261. - **Disk-friendly**: Can search large indexes without loading into memory
  262. - **Simple format**: Plain text, easy to debug
  263. - **Fast lookups**: O(log n) for indexed queries
  264. ## MessagePack vs BSON
  265. ### Advantages of MessagePack
  266. - **Smaller files**: 15-20% size reduction
  267. - **Faster reads**: Simpler format, less parsing overhead
  268. - **Pure JavaScript**: No native dependencies
  269. - **Smaller library**: ~15KB vs ~173KB for BSON
  270. ### Advantages of BSON
  271. - **ObjectId type**: Built-in unique identifier type
  272. - **Date precision**: Millisecond timestamps
  273. - **Binary data**: Native binary type
  274. - **MongoDB compatibility**: Direct compatibility with MongoDB
  275. ### When to Choose MPackDB
  276. - File size is a concern
  277. - Pure JavaScript dependencies preferred
  278. - Don't need MongoDB compatibility
  279. - Want faster read performance
  280. ### When to Choose BsonDB
  281. - Need ObjectId primary keys
  282. - MongoDB compatibility desired
  283. - Working with binary data
  284. - Need precise date/time handling
  285. ## Limitations
  286. 1. **Single-writer**: Only one write operation at a time
  287. 2. **No multi-record transactions**: `withLock` provides atomicity for compound operations on a single DB, but not across multiple databases
  288. 3. **No query language**: Must use JavaScript functions for complex queries
  289. 4. **Compaction required**: Deleted records consume space until compaction
  290. 5. **Index overhead**: Each index doubles storage for that field
  291. 6. **No schema validation**: Records can have any structure
  292. ## Index Query Design Decisions
  293. ### Why `to` but not `limit` or `filter`
  294. The `entries()` generator supports `from`, `to`, and `direction` but intentionally has no `limit` or `filter` parameters.
  295. **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.
  296. **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.
  297. **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.
  298. ### Why index intersection uses pre-collected offset sets
  299. 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).
  300. **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.
  301. **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.
  302. **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`.
  303. ### Bounding box vs geospatial
  304. `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.
  305. ## Future Improvements
  306. - Batch insert operations
  307. - Async compaction (background process)
  308. - Query optimizer for complex filters
  309. - Compression support (MessagePack supports extensions)
  310. - Replication/backup utilities
  311. - Schema validation layer
  312. - Custom MessagePack extension types

Branches

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