Skip to content

feat(persistence): add QueryPaged, cloning only the returned page#94

Merged
tgiachi merged 2 commits into
mainfrom
develop
Jul 17, 2026
Merged

feat(persistence): add QueryPaged, cloning only the returned page#94
tgiachi merged 2 commits into
mainfrom
develop

Conversation

@tgiachi

@tgiachi tgiachi commented Jul 17, 2026

Copy link
Copy Markdown
Owner

GetAll() and Query() deep-clone the whole store on every call — Query() does Bucket().Values.Select(_descriptor.Clone).ToArray() inside the lock, so despite returning IQueryable it is a full materialised snapshot, not a lazy view. Paging over either therefore costs the entire bucket per page.

QueryPaged filters and orders the live entities under the same lock and clones only what survives skip/take: O(bucket) cheap comparisons plus O(page) clones.

The sort key is required, not defaulted

Paging an unordered bucket is meaningless: Dictionary enumeration order is not contractual and shifts as entries come and go, so page 2 could repeat a row from page 1 or drop one entirely, with nothing in the result admitting it.

Ties break on the entity's own key. The caller's key is rarely unique — two rows can share a name — and leaving equal keys to Dictionary order would reintroduce exactly the instability the parameter exists to remove. This asks TKey to be comparable; the notnull constraint is deliberately not tightened, since that would break every store in the library. Comparer<TKey>.Default throws a clear error on the first comparison for an exotic key, rather than mis-ordering quietly — and every realistic key (Guid, int, string, a serial struct) is comparable.

The filter runs under the lock

That is where the saving comes from, and also the constraint: it sees live, uncloned entities and blocks writes while it runs, so it must be a pure, cheap read — no I/O, no locks, no calls back into the store, no mutating its argument. Documented on the interface.

Tests

8 new, 88 in the persistence suite, 1388 in the whole suite, all green.

The one worth reading is QueryPaged_ClonesOnlyThePage: a descriptor that counts Clone calls asserts exactly one per returned entity. It is the entire point of the method, and nothing else would notice it quietly degrading into GetAll. Two more assert that pages neither overlap nor skip when sort keys tie.

API surface

Adds PagedResult<TEntity> and one member on IEntityStore. A new interface member breaks external implementers in principle; in practice EntityStore is sealed with an internal constructor, and the interface's own remarks already tell callers never to construct or register it themselves. A downstream test double is the only plausible casualty, which the minor bump carries.

Releases as 0.37.0: both commits are feat:.

Also syncs chore(release): 0.36.0 back from main into develop — it had never returned, so Directory.Build.props on develop still read 0.35.0.

tgiachi added 2 commits July 17, 2026 11:13
Carries the page plus the pre-paging total, which is what a caller needs to know
how many pages exist. The total is free to compute: the filter has already run
over the bucket.
GetAll and Query deep-clone the whole store on every call, which makes paging
over them cost the entire bucket per page. QueryPaged filters and orders the live
entities under the same lock and clones only what survives skip/take: O(bucket)
cheap comparisons plus O(page) clones.

The sort key is required rather than defaulted. Paging an unordered bucket is
meaningless — enumeration order is not contractual and shifts as entries come and
go, so a page could repeat or drop an entity with nothing in the result admitting
it. Ties break on the entity key, since the caller's key is rarely unique and
equal keys would otherwise fall back to that same unstable order.

The filter runs under the state lock against live, uncloned entities, which is
where the saving comes from and also the constraint: it must be a pure, cheap
read. Documented on the interface.

A test asserts exactly one clone per returned entity. Nothing else would notice
this method quietly degrading into GetAll.

public sealed class EntityStorePagedTests : IAsyncDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "squidstd-paged-" + Guid.NewGuid().ToString("N"));
_descriptor = new(
new PersistenceEntityDescriptor<Player, int>(serializer, serializer, 1, "Player", 1, p => p.Id)
);
_journal = new(Path.Combine(_dir, "world.journal.bin"));
@tgiachi
tgiachi merged commit b55e864 into main Jul 17, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants