Files
Abdullah Atta 960c4363b6 sync: keep pushed ciphers as UTF-8 from the hub to MongoDB (#118)
* sync: keep pushed ciphers as UTF-8 from the hub to MongoDB

Pushed ciphers were parsed into strings, then encoded back to UTF-8 by
the driver into a new buffer for each large one: the two largest
sources of large object heap allocations on prod.

PushItems now reads each cipher straight into a pooled UTF-8 buffer and
UpsertMany writes the item's BSON itself, from that buffer, into another
pooled one. The stored documents are unchanged. Validation moves to
SyncItemValidator, and the base64 check is vectorized.

* sync: hand back pushed buffers when a push is rejected

The hub disposed the pushed items only after its own checks, and the
authorization filter rejects a call after SignalR has bound it, so those
rejections left their pooled buffers to the GC. A push with null items
also made the cleanup throw over the real error.
2026-10-31 10:53:00 +05:00

87 lines
3.4 KiB
C#

using MongoDB.Bson;
using MongoDB.Driver;
using Notesnook.API.Models;
namespace Notesnook.API.Tests;
public class UnitOfWorkTests(TestDatabase db) : IClassFixture<TestDatabase>
{
private static string NewUserId() => Guid.NewGuid().ToString("N");
// #10: an empty batch (e.g. an empty push) is a successful no-op
[Fact]
public async Task Empty_commit_succeeds()
{
var scope = db.CreateScope();
scope.Repositories.Notes.UpsertMany([], NewUserId());
Assert.True(await scope.Unit.Commit());
}
// #10: committing twice must not replay the first batch
[Fact]
public async Task Commands_are_cleared_after_commit()
{
var scope = db.CreateScope();
var device = new SyncDevice { Id = ObjectId.GenerateNewId(), UserId = NewUserId(), DeviceId = "A", LastAccessTime = 0, IsSyncReset = false };
scope.Repositories.SyncDevices.Insert(device);
Assert.True(await scope.Unit.Commit());
// a replayed insert would fail with a duplicate key error
Assert.True(await scope.Unit.Commit());
Assert.Equal(1, await scope.Repositories.SyncDevices.Collection.CountDocumentsAsync(d => d.UserId == device.UserId));
}
// #10: errors reach the caller instead of being swallowed
[Fact]
public async Task Commit_throws_on_failure()
{
var scope = db.CreateScope();
scope.DbContext.AddCommand((_, _) => throw new InvalidOperationException("boom"));
await Assert.ThrowsAsync<InvalidOperationException>(() => scope.Unit.Commit());
}
// #10: commands share one session, so they must run one after another, in order
[Fact]
public async Task Commands_run_sequentially_in_order()
{
var userId = NewUserId();
var scope = db.CreateScope();
var collection = scope.Repositories.Notes.Collection;
var running = 0;
var overlapped = false;
var seen = new List<long>();
for (var i = 0; i < 20; i++)
{
var expectedBefore = i;
scope.DbContext.AddCommand(async (session, ct) =>
{
if (Interlocked.Increment(ref running) > 1) overlapped = true;
// every earlier command's write must already be visible in this session
seen.Add(await collection.CountDocumentsAsync(session, Builders<SyncItem>.Filter.Eq(x => x.UserId, userId), cancellationToken: ct));
await collection.InsertOneAsync(session, new SyncItem { UserId = userId, ItemId = $"{expectedBefore}" }, cancellationToken: ct);
Interlocked.Decrement(ref running);
});
}
Assert.True(await scope.Unit.Commit());
Assert.False(overlapped);
Assert.Equal(Enumerable.Range(0, 20).Select(i => (long)i), seen);
}
// #10: all commands commit or roll back together
[Fact]
public async Task Failed_commit_rolls_back_everything()
{
var userId = NewUserId();
var scope = db.CreateScope();
scope.Repositories.Notes.UpsertMany([TestItems.PushedNote("1"), TestItems.PushedNote("2")], userId);
scope.DbContext.AddCommand((_, _) => throw new InvalidOperationException("boom"));
await Assert.ThrowsAsync<InvalidOperationException>(() => scope.Unit.Commit());
Assert.Equal(0, await scope.Repositories.Notes.Collection.CountDocumentsAsync(i => i.UserId == userId));
}
}