# Document database comparison: methodology for review

## Purpose

Compare SoloDB 1.3.0, LiteDB 5.0.21 and MongoDB Community 8.3.11 through C# clients. Show native ordinary calls and verified retained/tuned alternatives. No overall winner is derived from unrelated operations. Tables are regenerated from completed paired runs against the packaged candidate.

## Equal requested work

Every contender receives the same synthetic objects, IDs, predicates and parameter values. A page returns 100 complete objects, in timestamp-descending order, at the same offset. Timestamps are unique in this fixture: no contender gets an additional ordering term. The result oracle compares every field and its order, not only row count.

Counts are fresh and exact, including zero on an empty match. Point tests retrieve the same existing IDs one call at a time. Grouping returns the same keys and totals. No contender gets result caching, approximate counts, ID-only pages, fewer returned fields, or a batched lookup in a sequential-lookup row.

Native storage engines may use different internal algorithms for the same task. Any alternative expressed through a different query formulation is identified explicitly, with its generated statement/plan retained.

All fixture names are neutral synthetic values. Benchmark databases, journals, logs, builds and disposable server files are under /tmp. Hardware and filesystem are recorded.

## Index policy

The common-index comparison starts with the same six single-field keys on every database. Index construction is measured separately from query execution. All ordinary and retained calls in this comparison run against that same schema and dataset state.

Extra compound or expression indexes belong to a separately labelled index-tuning comparison. Each engine's supported index designs must be investigated before claiming an equivalent opportunity. The report names any added index, its construction time and storage cost. A MongoDB compound-index result must not silently replace its common-index result in the main table.

An explicit hint selects an existing index; adding an index changes the schema. Those are separate interventions. Equal explain plans with unequal timings do not prove that a hint improved the plan.

## SoloDB API coverage

Checked against the actual 1.3.0 release-candidate package, including C# and F# consumers. Compile supports retained sequences, ordered sequences, scalar terminals and client materializers, with zero through three invocation parameters. The runner prepares direct LongCount delegates for each count shape, a parameterized page, a Single point lookup and a grouped projection. Sequence delegates return IEnumerable, without an IQueryable adapter. Values bind on each invocation and results remain fresh.

### Counts

```csharp
// SoloDB ordinary: native scalar terminal.
long count = solo.LongCount(x => x.System.StartsWith(prefix));

// SoloDB compiled scalar: construct once, invoke repeatedly.
var countCompiled = solo.Compile((IQueryable<Event> q, string value) =>
    q.LongCount(x => x.System.StartsWith(value)));
long count = countCompiled(prefix);

// LiteDB ordinary.
long count = lite.LongCount(x => x.System.StartsWith(prefix));

// LiteDB covered candidate; equivalence depends on non-null System values.
long count = lite.Query()
    .Where(LiteDB.Query.StartsWith("System", prefix))
    .Select("{n:COUNT(*.System)}").Single()["n"].AsInt64;

// MongoDB ordinary and hinted candidate, same exact count.
var filter = Builders<Event>.Filter.Regex(x => x.System,
    new BsonRegularExpression("^" + Regex.Escape(prefix)));
long count = mongo.CountDocuments(filter);
long hinted = mongo.CountDocuments(filter,
    new CountOptions { Hint = "System_1" });
```

Count-all, two-prefix conjunctions, unindexed tags and indexed tags each need their own native and compiled/tuned coverage decision. The one-prefix decision is not generalized without checking the resulting query. MongoDB EstimatedDocumentCount is a distinct operation and cannot replace an exact count.

### Pages

```csharp
// SoloDB ordinary.
var rows = solo.Where(x => x.System.StartsWith(prefix))
    .OrderByDescending(x => x.UnixTimestamp)
    .Skip(offset).Take(100).ToList();

// SoloDB retained, constructed once for both offsets.
var pageCompiled = solo.Compile(
    (IQueryable<Event> q, string value, int skip) =>
        q.Where(x => x.System.StartsWith(value))
         .OrderByDescending(x => x.UnixTimestamp)
         .Skip(skip).Take(100));
var rows = pageCompiled(prefix, offset).ToList();

// LiteDB native page.
var rows = lite.Query()
    .Where(LiteDB.Query.StartsWith("System", prefix))
    .OrderByDescending(x => x.UnixTimestamp)
    .Offset(offset).Limit(100).ToList();

// MongoDB ordinary and existing-index hint candidate.
var rows = mongo.Find(filter).SortByDescending(x => x.UnixTimestamp)
    .Skip(offset).Limit(100).ToList();
var hintedRows = mongo.Find(filter,
        new FindOptions { Hint = "UnixTimestamp_1" })
    .SortByDescending(x => x.UnixTimestamp)
    .Skip(offset).Limit(100).ToList();
```

LiteDB retains the native query builder and changes Offset between fully materialized calls. Each concurrent worker owns its builder because it is mutable. MongoDB retains filter and sort definitions and uses an explicit existing ordering-index hint.

### Point lookups and grouping

```csharp
// SoloDB native lookup of an existing ID.
var row = solo.GetById(id);

// SoloDB retained query of that same existing ID.
var pointCompiled = solo.Compile((IQueryable<Event> q, long key) =>
    q.Single(x => x.Id == key));
var row = pointCompiled(id);

// SoloDB native grouped aggregate.
var groups = solo.GroupBy(x => x.Username.Substring(0, 1))
    .Select(g => new { g.Key, N = g.LongCount() }).ToList();

// SoloDB retained grouped aggregate.
var groupsCompiled = solo.Compile(q =>
    q.GroupBy(x => x.Username.Substring(0, 1))
     .Select(g => new { g.Key, N = g.LongCount() }));
var groups = groupsCompiled().ToList();
```

The point workload uses existing IDs. Missing-ID contracts must be checked separately before claiming general equivalence between GetById and Single. All grouped results are normalized into the same dictionary outside database execution in both variants, and client materialization remains inside measured API latency.

LiteDB native grouping, covered grouping and query reuse must be distinguished. The tested covered grouped expression failed on 5.0.21; that particular failure is recorded, not used to omit working grouping paths. MongoDB native grouping and an existing Username-index hint are separate candidates.

## Writes and files

Compile is a read-query API. Inserts and updates use native bulk operations from the start, without introducing a per-document loop as an artificial ordinary baseline. Each insert batch and multi-document update must be atomic. MongoDB uses the transaction configuration below; its earlier standalone measurements are superseded.

File contracts are a capability comparison first: offset editing, resizing, sparse behavior, partial reads, upload replacement and atomicity. Prefix reads can be timed as a common narrow task with checked bytes. Offset edits, gap extension, growth and truncation are timed for every engine, including the reads, buffer modifications and uploads required by LiteDB and MongoDB replacement emulations. Reset each 6.25 MiB input outside timing and verify the complete resulting bytes outside timing. The table compares the elapsed work to produce the requested content; it does not equate atomicity or sparse allocation. Native versus emulated behavior is named beside the timing table.

## Measurement procedure

1. Verify calls and result contracts on a small fixture, including empty matches, fresh results after mutation, full objects and grouping. Capture statements and plans for the actual calls.
2. Prepare identical fresh datasets and the common index schema. Complete statistics maintenance and connection startup outside warm query timing. Measure setup and compilation separately.
3. For each operation, warm both variants. Interleave ordinary and retained/tuned samples, alternating which goes first. Do not run all ordinary calls before all optimized calls.
4. Use the same result materialization in both variants. Preserve each sample and report medians and spread. Validation and plan capture are outside timing.
5. Run one database workload at a time. Repeat the engine order in reverse for a second block to expose order effects. No database update or added index occurs between paired read variants.
6. Keep writes and index-tuning runs separate from paired read measurements. Do not reuse a post-update/post-index database for one variant while the other was measured beforehand.
7. Capture SQL and bindings for SoloDB, native plans for LiteDB and actual MongoDB commands with explains. Plans support the interpretation; they do not establish which runtime branch executed.
8. Treat managed client allocation as its own metric. MongoDB server memory and native allocations require separate accounting and cannot be ranked by the C# client's allocation counter.

## Table presentation

Each engine gets Ordinary and Optimized columns. Optimized means the fastest verified implementation found for that operation. A dagger marks optimized cells that use the ordinary API, including identical-call rows and cases where retaining the ordinary call is fastest. For each query and engine, the latter selects the lower of the ordinary and retained/tuned medians, each calculated from its full six-sample population; it does not select individual fastest samples. Raw reports retain both original variant medians and every sample. Named native file-read columns remain direct comparisons of those calls. The accompanying call table identifies the actual alternative, including direct compiled scalar terminals and any query formulation change. Where the execution API is unchanged, record the operation in sameApiOperations, measure a single ordinary population and display its median in both columns. This applies to LiteDB count-all, pages and grouping and MongoDB tag counts. Validate the retained call outside timing. Keep distinct query formulations and real hint changes paired. Preparation-only builder reuse does not receive an independent execution-speed claim.

Show the common-index comparison first and additional index tuning separately. Publish unsuccessful supported candidates and exact limitations alongside the successful paths. Avoid claiming a candidate is the fastest possible use of an engine.

## Verification

The runner checks parameterized retained count/page variants, grouped and point results, full page fields, empty and fresh counts, updates and file bytes. Ordinary and optimized read calls alternate on the same data. Additional index tuning is isolated from the common-schema table. Public reports retain per-sample timings and query plans but omit run-specific connection metadata. Full diagnostics and setup failures remain in the local run directory and are not successful samples.

## MongoDB transaction configuration

MongoDB runs as a single-node replica set so its native transaction API is available. Each measured insert batch and bulk update includes session creation, the transaction and its successful commit. TransactionOptions uses ReadConcern.Snapshot, ReadPreference.Primary and WriteConcern.WMajority with journal true. The callback-based WithTransaction API provides the driver's transaction retry handling; retry cost remains inside operation timing.

Individual find/aggregate reads use snapshot read concern on the collection. They do not add a redundant multi-statement transaction around every single query. The transaction test separately verifies dirty-read exclusion, abort of inserts and updates, committed updates, stable snapshot reads across an external update, and fresh reads afterwards.

The server's WiredTiger cache is set to 4 GiB to accommodate the full insert transaction; the attempted 2 GiB configuration raised transaction-cache error 388 and is retained as failed setup evidence. Cache settings for every engine and server memory remain disclosed separately. All data and journals are on tmpfs, so this verifies commit/rollback semantics rather than power-loss survival.

One replica-set member exercises local ACID transactions, not replicated availability or failover. GridFS does not support multi-document transactions and remains outside the atomic file-write comparison.

References: [C# transactions](https://www.mongodb.com/docs/drivers/csharp/current/crud/transactions/), [snapshot read concern](https://www.mongodb.com/docs/manual/reference/read-concern-snapshot/index.html), [transaction production considerations](https://www.mongodb.com/docs/manual/core/transactions-production-consideration/).

## Editorial acceptance

Explain the exact client work behind preparation timings; do not rank different preparation tasks as equivalent query compilation. The article must give an evidence-bound reason to choose SoloDB for an embedded .NET workload, using measured reads, typed ordinary and compiled APIs, and native editable-file behavior. Keep competitor advantages and limitations accurate. Generate every table from the new package runs and leave no unresolved article TODO. Review the rendered article and all linked reproduction artifacts before the local commit; do not push.


The file-edit table uses separate forward/reverse file-only blocks, three measured samples per block. Initial length is 6,553,600 bytes; patch length is 102,400 bytes; offsets are 409,600 and 104,857,600 bytes; grow and truncate lengths are 13,107,200 and 3,276,800 bytes. These are independent of the 64 KiB random-read fixtures. Raw file-edit reports preserve exact bounds, settings and sample populations.


Parser inputs are ten checked-in fictional application records under Parsers/Documents: compact and expanded orders, customer profiles, products, invoices and activity events. They replace the isolated numeric, Unicode and repeated-text cases in the timing table. The files preserve natural combinations of nullable fields, dates as strings, numbers, multilingual text, nested objects and arrays. Input loading and semantic validation are outside timing; six samples of 10000 deserialize-then-serialize round trips follow 1000 warm-up round trips per library and document. Both native calls and the output string allocation are timed; serializer settings are created beforehand, and the output semantic oracle runs after each sample. All parsers receive identical text, and input byte sizes accompany the table. JSON5 acceptance remains a separate syntax check. These examples are not claimed to be a statistical sample of production databases.

Parser numeric validation preserves integer values exactly. For fractional or exponent-form JSON numbers, it compares the exact binary64 value rather than decimal rendering text; no tolerance is used. This accommodates serializers that print additional digits for the same floating-point number. Strings, nulls, booleans, object members and array order remain exact.


Performance tables compute “Lead over second” as 100 times (runner-up median / lowest median minus one), using unrounded medians and one fastest verified call per database. This is a relative speedup, not the percentage reduction in elapsed time. The additional-index experiment separates before/after query timings from one-time index creation and reported storage growth, with databases in columns. Timing rows use the same speed-lead comparison; differing storage scopes are reported without ranking. Preparation tasks, input byte sizes, differing storage-allocation metrics and capability tables have no speed-lead ranking. The percentage describes the observed medians, not a statistical significance test.
