Skip to content

Rolling back ≥ 65 inserts into an empty collection corrupts it permanently; reads hang while a transaction holds ≥ 65 documents #151

Description

@CaffeinatedCoder

Version: BLite 5.1.1 (NuGet), .NET 10.0.12, macOS 27 arm64. Default PageFileConfig, single process.

A. Rollback leaves the collection unreadable

Insert N documents into an empty collection inside an explicit transaction, then roll back. For N ≤ 64 everything is fine (count 0 afterwards). For N ≥ 65, every later read of the collection spins at 100 % CPU and never returns, also after reopening the file in a new process. The file stays unreadable.

public sealed class Figure { public ObjectId Id { get; set; } public decimal Amount { get; set; } }

public partial class ReproDbContext : DocumentDbContext
{
    public DocumentCollection<ObjectId, Figure> Figures { get; set; } = null!;
    public ReproDbContext(string path) : base(path) => InitializeCollections();
    protected override void OnModelCreating(ModelBuilder mb) => mb.Entity<Figure>();
}

using (var db = new ReproDbContext(path))
{
    using (var txn = db.BeginTransaction())
    {
        for (var i = 0; i < 65; i++)
            await db.Figures.InsertAsync(new Figure { Amount = i }, txn);
        await txn.RollbackAsync();
    }
    var all = await db.Figures.AsQueryable().ToListAsync();   // never returns, 100 % CPU
}

// new process:
using var db2 = new ReproDbContext(path);
var all2 = await db2.Figures.AsQueryable().ToListAsync();     // never returns either
N rolled back read after rollback read after reopen (new process)
10, 50, 64 0 documents 0 documents
65, 79, 80, 100, 500 hangs (100 % CPU) hangs (100 % CPU)

The threshold of 65 looks like the moment the transaction allocates a second data page for the collection. In practice this means one failed first import leaves a database that can never be read again.

B. Reads outside an open transaction hang

Same threshold, no rollback needed. One document is committed. A transaction then inserts N more without committing, and a read that does not use that transaction runs:

await db.Figures.InsertAsync(new Figure { Amount = -1 });        // auto-commit
var txn = db.BeginTransaction();
for (var i = 0; i < 64; i++)
    await db.Figures.InsertAsync(new Figure { Amount = i }, txn);
var all = await db.Figures.AsQueryable().ToListAsync();          // hangs, 100 % CPU
committed + uncommitted read outside the transaction
1 + 10, 1 + 62, 1 + 63 returns 1 document (correct)
1 + 64, 1 + 65, 1 + 100, 1 + 500 hangs (100 % CPU)

Expected: the read returns the 1 committed document (read committed). AsQueryable() has no overload that takes a transaction, so there is no way around this for LINQ reads while a larger write transaction is open.

A and B are probably the same root cause: a page allocated by an uncommitted (or rolled-back) transaction becomes visible in the collection's page chain, and the scan follows a link it cannot resolve.

Side note on the README

The README's explicit-transaction example calls db.Users.InsertAsync(new User { … }) inside using (var txn = db.BeginTransaction()) without passing txn. In 5.1.1 such inserts auto-commit, so RollbackAsync() in that pattern silently rolls back nothing (the documents are still there after a reopen). The BeginTransaction XML doc says the transaction must be passed to every collection method; the README example should do the same.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    • Status
      Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions