Skip to content

perf: allocation reduction sweep across Rx, SQLite and serializer hot paths#1179

Merged
glennawatson merged 4 commits intomainfrom
perf/alloc-reduction-sweep
Apr 15, 2026
Merged

perf: allocation reduction sweep across Rx, SQLite and serializer hot paths#1179
glennawatson merged 4 commits intomainfrom
perf/alloc-reduction-sweep

Conversation

@glennawatson
Copy link
Copy Markdown
Contributor

@glennawatson glennawatson commented Apr 14, 2026

Summary

This PR is a focused allocation-reduction and CPU-reduction pass across the hot paths most Akavache users actually hit every day: cache reads/writes, bulk insert/invalidate, serialization, and the in-memory type-scoped index. Nothing changes in the public API — every optimisation is either internal, additive, or behind a #if NET… guard for newer targets with a preserved fallback for net462 / netstandard2.0.

The net effect for consumers: less GC pressure, fewer per-call delegate allocations, tighter SQLite bulk paths, and faster in-memory cache invalidation under high type-count workloads — with zero breaking changes and the full test suite (3,894 tests) green on net8 and net9.


Why this matters for you

Akavache is typically used as a read-heavy, write-often key/value store sitting on an app's hot path — settings, HTTP response caching, image caching, offline data. The cost of a single cache hit is easy to ignore, but multiply it by every render or navigation and a handful of per-call allocations becomes a real GC pause, especially on mobile. This pass chips away at those allocations in concrete, measurable places.

Fewer short-lived objects on every cache call

  • Cached Observable<Unit> success signal. Rx.NET's Observable.Return(Unit.Default) allocates a new ScalarObservable<Unit> on every call. Methods like Flush, Invalidate, and successful-write paths return that value constantly. A single internal CachedObservables.UnitDefault singleton now serves every one of those sites — roughly 15 call sites across the library, each previously producing a fresh observable per invocation. Paired with a new .SelectUnit() extension that centralises the .Select(_ => Unit.Default) pattern.
  • static lambdas everywhere they belong. Non-capturing Rx operator lambdas (.Select(x => x), .Where(x => x.Id is not null), .SelectMany(static x => x), etc.) across SqliteBlobCache, InMemoryBlobCacheBase, HttpService, SerializerExtensions, RequestCache, BitmapImageExtensions, ImageCacheExtensions and V10MigrationService are now marked static, so the compiler caches the delegate instance instead of allocating one per subscription.
  • Pre-sized mutable collections. List<string> / List<KeyValuePair<…>> instances in bulk paths now use [.. source] spread syntax or explicit new(capacity) where the upper bound is known from an ICollection<T>.Count, avoiding the default-capacity-then-regrow allocation pattern.
  • Ordinal comparers on string-keyed collections. InMemoryBlobCacheBase._cache, the per-type HashSet<string> buckets, and RequestCache._inflightRequests all use StringComparer.Ordinal explicitly. Cache keys are opaque identifiers, not user-facing text — ordinal comparison is both semantically correct and materially faster than the default culture-sensitive comparer, particularly on net462.

Faster SQLite bulk paths

  • Bulk insert no longer calls DateTime.Now per entry. SqliteBlobCache.Insert(IEnumerable<KVP>, ...) used to read the wall clock and reflect type.FullName inside the per-entry .Select(...) projection. Both are hoisted out of the loop so one batch = one wall-clock read.
  • Empty-input early exit. Calling Insert or Invalidate with an empty IEnumerable no longer schedules an Observable.Start, acquires the lock, or allocates a work item — the method short-circuits to the cached Unit observable.
  • GetCreatedAt(key) / GetCreatedAt(key, type) folded into a single async projection. The previous SelectMany → SelectMany → Where → Select → DefaultIfEmpty chain is now one async SelectMany that always emits exactly one value (the timestamp or null). Same semantics, four fewer Rx operators per call. The defensive null-Id filter is preserved inline, verified against the existing PostQueryDefensiveFilters test.
  • Legacy V10 fallback reads drop the tuple-list staging buffer. SqliteAkavacheConnection.TryReadLegacyV10ValueAsync used to materialise a new List<(string Sql, object[] Args)>(3) and foreach over it. It now executes the three query forms inline via a small private helper — same cold-path behaviour, no transient list or tuple allocations.

O(1) type-index cleanup in InMemoryBlobCacheBase

The in-memory cache previously pruned the type index on every invalidation or expiration by scanning every registered Type's HashSet<string> to remove the key. That's O(T) per key and O(K·T) per bulk invalidation or vacuum — fine for a few types, painful for apps that register hundreds.

A reverse Dictionary<string, Type> map (_keyToType) is now maintained alongside the per-type index. Insert paths that target a specific type populate it, and if a key is reassigned to a different type the old bucket is evicted first to preserve the invariant. Invalidate and VacuumExpiredEntriesFast use this to jump directly to the right bucket in O(1) instead of scanning.

Serializer allocation & CPU reductions

  • Byte-level JSON/BSON probe. NewtonsoftSerializer and SystemJsonBsonSerializer used to decode the entire payload into a string via Encoding.UTF8.GetString(data), call .TrimStart() twice, then StartsWith("{") / StartsWith("["). They now go through a shared BinaryHelpers.StartsWithJsonOpener(byte[]) helper that walks the bytes directly. On net5+ it uses MemoryExtensions.TrimStart(ReadOnlySpan<byte>, ReadOnlySpan<byte>) with a " \t\n\r"u8 literal, which the JIT can vectorise. net462 falls back to a tight scalar loop. No string allocation on the probe path at all.
  • UniversalSerializer caches serializer-kind flags per Type. IsBsonSerializer / IsPlainNewtonsoftSerializer previously ran GetType().Name.Contains(...) on every call. They now cache the boolean result in a ConcurrentDictionary<Type, bool> so hot paths touch the name string once per closed type and then hit the cache forever after.
  • KeyMetadata<T> static generic cache. UniversalSerializer.FindKeyCandidates<T> used to walk typeof(T).FullName, .Name, and typeof(T).Assembly.GetName().Name on every cache miss to build the prefix forms. Those three strings are now materialised exactly once per closed generic instantiation via a static readonly field on KeyMetadata<T>.
  • MemoryStream read paths use writable: false. Reading a known byte[] via new MemoryStream(bytes) used to copy the buffer internally. The read paths in NewtonsoftSerializer, SystemJsonBsonSerializer, BitmapImageExtensions and ImageCacheExtensions now pass writable: false so the underlying array is used directly.
  • MemoryStream write paths are pre-sized. BSON write paths pre-size to 256 bytes; the BitmapImageExtensions.ImageToBytes PNG encode path pre-sizes to 16 KB, dodging the usual doubling regrowths for typical payloads.
  • Newtonsoft fallback probe uses a span trim. NewtonsoftSerializer.TryDeserializeFromOtherFormats now checks the first non-whitespace character via jsonString.AsSpan().TrimStart() before any further work, and in most cases short-circuits to the BinaryHelpers byte probe before the Encoding.UTF8.GetString is ever called.

Async & ConfigureAwait hygiene

  • Pass-through async/await state machines removed. InMemoryBlobCacheBase.DisposeAsync and the inner AkavacheBuilder wrapper's DisposeAsync no longer compile into an async state machine — they return the underlying ValueTask directly.
  • ConfigureAwait(false) sweep. Task-returning awaits in BitmapImageExtensions, ImageCacheExtensions and CacheDatabase's shutdown aggregator now pass false so the library never captures a synchronization context. (Observable awaits aren't eligible without a larger refactor and are documented as such internally.)

Ergonomic bits that rode along

  • CacheEntry parameterised constructor. Alongside the sqlite-net-mandated parameterless constructor, there's now a full CacheEntry(string? id, string? typeName, byte[]? value, DateTimeOffset createdAt, DateTimeOffset? expiresAt) ctor. On older runtimes (notably net462) the JIT tends to produce tighter codegen for a single ctor + field writes than for a new() { … } initializer that expands to parameterless ctor + property setters. Insert paths in both InMemoryBlobCacheBase and SqliteBlobCache use it.
  • SplitFullPath is now a yield return iterator. The historical List<string> + Reverse() pattern is replaced with a depth precount and an array-backed root-down iterator. CreateRecursive consumers never see a materialised intermediate list.

Compatibility

  • Public API: unchanged. No signatures modified, no types removed, no default behaviours flipped.
  • Target frameworks: all optimisations are either universally applicable or guarded by #if NET5_0_OR_GREATER / #if NET9_0_OR_GREATER with a preserved net462 / netstandard2.0 fallback. net462 consumers still get the scalar code paths and nothing regresses.
  • SQLite schema: untouched. CacheEntry still matches the existing table definition; the new constructor is additive.
  • Observable semantics: unchanged. The Unit singleton, the GetCreatedAt fold, and the defensive null-Id filters all produce the same emissions the existing test suite expects.

Maintainer notes

Test coverage added

A few helpers were promoted from private to internal static so they can be driven from unit tests without going through the full observable pipeline:

  • InMemoryBlobCacheBase.VacuumExpiredEntriesFast(cache, typeIndex, keyToType, now) — 3 tests
  • InMemoryBlobCacheBase.RemoveKeyFromTypeIndexFast(typeIndex, keyToType, key) — 3 tests
  • CacheEntry.ValueEquals(byte[]?, byte[]?) — 6 tests (same ref, both null, one null, distinct equal, equal length differing content, different length, two empty)
  • CacheEntry parameterised ctor — 2 tests
  • UniversalSerializer.IsBsonSerializer / IsPlainNewtonsoftSerializer / ResetCaches — 4 tests

Verification

  • dotnet build src/Akavache.slnx — 0 warnings, 0 errors, all target frameworks.
  • dotnet test --project tests/Akavache.Tests/Akavache.Tests.csproj -- --coverage --coverage-output-format cobertura3,894 / 3,894 passing on net8 and net9.
  • Behavioural regressions were caught and reverted during the sweep — notably an aggressive "redundant .Where(x => x.Id is not null)" removal that broke the EncryptedPostQueryDefensiveFiltersShouldSkipNullIdEntries test, reminding us the Rx-level filter is the only guard when a backend has BypassPredicate = true. That change was reverted; only the folded GetCreatedAt shape (with the defensive check preserved inline) made it into the final commit.

Test plan

  • CI: dotnet test --project tests/Akavache.Tests/Akavache.Tests.csproj green on all TFMs
  • CI: dotnet test --project tests/Akavache.Settings.Tests/Akavache.Settings.Tests.csproj green on all TFMs
  • Optional: run the existing benchmark projects under src/benchmarks/ before and after this branch with [MemoryDiagnoser] to confirm the allocation reductions on the bulk-insert and in-memory invalidation paths

… paths

- Singleton IObservable<Unit> success signal via Akavache.Core.CachedObservables.UnitDefault, replacing per-call Observable.Return(Unit.Default) across InMemoryBlobCacheBase, SqliteBlobCache, CacheDatabase, SerializerExtensions and InvalidateAll/Flush/empty-input guards
- SelectUnit() Rx extension (ObservableUnitExtensions) normalising the "discard value, signal completion" pattern at remaining production sites
- Non-capturing Rx lambdas in SqliteBlobCache, InMemoryBlobCacheBase, HttpService, SerializerExtensions, RequestCache, BitmapImageExtensions, ImageCacheExtensions and V10MigrationService are static so the compiler caches their delegate instances
- KeyMetadata<T> static reflection-string cache (FullName / Name / AssemblyQualifiedShortName) used by UniversalSerializer.FindKeyCandidates so per-T metadata is materialised once
- UniversalSerializer caches per-Type IsBsonSerializer / IsPlainNewtonsoftSerializer flags via ConcurrentDictionary; callers skip repeated GetType().Name substring probes
- BinaryHelpers.StartsWithJsonOpener(byte[]) byte-level JSON probe with a net5+ fast path using MemoryExtensions.TrimStart(ReadOnlySpan<byte>) and a " \t\n\r"u8 whitespace set; scalar fallback for net462. Replaces Encoding.UTF8.GetString().TrimStart().StartsWith chains in NewtonsoftSerializer and SystemJsonBsonSerializer
- InMemoryBlobCacheBase dictionaries and the RequestCache inflight map use StringComparer.Ordinal; HashSet<string> per-type buckets are constructed with the ordinal comparer explicitly
- Reverse key-to-type index (_keyToType) lets Invalidate/Vacuum prune per-key in O(1) instead of scanning every registered type's HashSet; VacuumExpiredEntriesFast and RemoveKeyFromTypeIndexFast are internal static helpers with direct unit-test coverage
- Empty-IEnumerable early-exit guards on Insert(IEnumerable) and Invalidate(IEnumerable) short-circuit to the cached Unit observable instead of scheduling an Observable.Start
- SqliteBlobCache bulk-insert paths hoist DateTime.Now (and type.FullName) out of the per-entry projection and build CacheEntry rows with the new parameterised constructor
- CacheEntry has a parameterised constructor alongside the sqlite-net-mandated parameterless one; InMemoryBlobCacheBase and SqliteBlobCache Insert call sites use it instead of property-initialiser object syntax
- SqliteAkavacheConnection.TryReadLegacyV10ValueAsync executes the three legacy query forms inline through a private TryExecuteLegacyAsync helper, dropping the List<(string, object[])> tuple staging buffer
- SqliteBlobCache.GetCreatedAt(string) and GetCreatedAt(string, Type) collapse the SelectMany/SelectMany/Where/Select/DefaultIfEmpty chain into a single async projection while keeping the defensive null-Id filter inline
- SplitFullPath uses a depth precount and a fixed-size array-backed iterator with yield return, so CreateRecursive consumers never see the historical List<string> + Reverse() step
- MemoryStream allocations on serializer read paths use the (bytes, writable: false) overload so the internal buffer isn't copied; write paths in BSON serializers pre-size to 256 bytes and BitmapImageExtensions pre-sizes to 16 KB
- InMemoryBlobCacheBase.DisposeAsync and the AkavacheBuilder inner-wrapper DisposeAsync drop the async/await state machine and return the underlying ValueTask directly
- ConfigureAwait(false) added on Task-returning awaits in BitmapImageExtensions, ImageCacheExtensions and CacheDatabase's shutdown aggregator so the library never captures a synchronization context
- CacheEntry.ValueEquals, UniversalSerializer.IsBsonSerializer and UniversalSerializer.IsPlainNewtonsoftSerializer promoted from private to internal static
- Added unit tests covering CacheEntry.ValueEquals (6 cases), the parameterised CacheEntry constructor (2 cases), UniversalSerializer.IsBsonSerializer / IsPlainNewtonsoftSerializer / ResetCaches (4 cases), VacuumExpiredEntriesFast (3 cases) and RemoveKeyFromTypeIndexFast (3 cases)
@glennawatson glennawatson requested review from ChrisPulman and Copilot and removed request for Copilot April 14, 2026 23:17
Comment thread src/Akavache.Core/Core/KeyMetadata.cs Fixed
Comment thread src/Akavache.Sqlite3/SqliteAkavacheConnection.cs Fixed
Comment thread src/Akavache.Core/Core/RequestCache.cs
- TryExecuteLegacyAsync only swallows sqlite-net schema errors now; other
  exceptions (disposed connection, cancellation, unexpected state) propagate
  so real failures aren't silently masked as "no legacy data"
@glennawatson
Copy link
Copy Markdown
Contributor Author

Thanks for the review. Triaged:

1. KeyMetadata.cs — "Useless assignment to asmName" → not a defect

The flagged line is a C# 8+ pattern-matching capture:

typeof(T).Assembly.GetName().Name is { } asmName
    ? asmName + "." + typeof(T).Name
    : typeof(T).Name

is { } asmName both null-checks and binds in one step, and asmName is then read on the very next expression (the true branch of the ternary). It is not a dead assignment — the analyzer is mis-reading the pattern. Rewriting to a local + null-check would be strictly more code for identical semantics.

2. SqliteAkavacheConnection.TryExecuteLegacyAsync — "Generic catch clause" → fixed in 87142f1

Fair point. A bare catch would silently turn real failures (ObjectDisposedException, cancellation, unexpected state) into "no legacy data", which is exactly the kind of thing you want CodeQL to flag. Narrowed to catch (SQLiteException) — we still swallow sqlite-net schema errors (the legitimate "no such table / no such column" case on post-v10 databases) and everything else propagates. No message-match heuristics needed.

3. RequestCache.RemoveRequestsForKey — "Missed opportunity to use Where" → not applying

This is the exact pattern I deliberately replaced during the allocation sweep, and the entire purpose of this PR is reducing allocations on hot paths.

Current code:

List<string> keysToRemove = new(_inflightRequests.Count);
foreach (var requestKey in _inflightRequests.Keys)
{
    if (requestKey.EndsWith(keySuffix, StringComparison.Ordinal))
    {
        keysToRemove.Add(requestKey);
    }
}

Suggested code:

var keysToRemove = _inflightRequests.Keys.Where(k => k.EndsWith(keySuffix, StringComparison.Ordinal)).ToList();

The LINQ form allocates:

  • a WhereEnumerableIterator<string>,
  • a closure capturing keySuffix,
  • a List<string> without the pre-sized capacity hint (so it doubles-regrows during fill).

The explicit foreach allocates a single pre-sized List<string> and nothing else. Swapping to .Where().ToList() is strictly more GC pressure on a per-invalidation hot path. The readability gain doesn't outweigh undoing the optimisation this PR exists to make.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 14, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (6dee936) to head (89eea3f).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main     #1179   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           41        45    +4     
  Lines         2864      2924   +60     
  Branches       506       521   +15     
=========================================
+ Hits          2864      2924   +60     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread src/tests/Akavache.Tests/SqliteAkavacheConnectionTests.cs
Copy link
Copy Markdown
Contributor Author

@glennawatson glennawatson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dismissing the Path.Combine suggestion on SqliteAkavacheConnectionTests.cs:454 — the second argument is a compile-time string literal ("legacy.db"), not a variable. The rule CodeQL is applying is meaningful when the second argument could be rooted via tainted input; it isn't here, and the suggested Path.GetFileName("legacy.db") wrap is a runtime no-op on a literal the compiler can already prove is a plain filename.

Tell: there are eight Path.Combine(path, "<literal>.db") sites in this same file, all identical in shape. The bot only flagged the one on a changed line (454). If the rule were real the other seven would be flagged too; they're not. This is diff-noise, not a genuine finding.

@glennawatson glennawatson enabled auto-merge (squash) April 15, 2026 00:24
Comment thread src/tests/Akavache.Tests/SqliteAkavacheConnectionTests.cs Dismissed
@glennawatson glennawatson merged commit bd980df into main Apr 15, 2026
9 checks passed
@glennawatson glennawatson deleted the perf/alloc-reduction-sweep branch April 15, 2026 00:41
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