perf: allocation reduction sweep across Rx, SQLite and serializer hot paths#1179
perf: allocation reduction sweep across Rx, SQLite and serializer hot paths#1179glennawatson merged 4 commits intomainfrom
Conversation
… 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)
- 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"
|
Thanks for the review. Triaged: 1.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
glennawatson
left a comment
There was a problem hiding this comment.
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.
…ection handling edge cases
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 fornet462/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
Observable<Unit>success signal.Rx.NET'sObservable.Return(Unit.Default)allocates a newScalarObservable<Unit>on every call. Methods likeFlush,Invalidate, and successful-write paths return that value constantly. A single internalCachedObservables.UnitDefaultsingleton 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.staticlambdas everywhere they belong. Non-capturing Rx operator lambdas (.Select(x => x),.Where(x => x.Id is not null),.SelectMany(static x => x), etc.) acrossSqliteBlobCache,InMemoryBlobCacheBase,HttpService,SerializerExtensions,RequestCache,BitmapImageExtensions,ImageCacheExtensionsandV10MigrationServiceare now markedstatic, so the compiler caches the delegate instance instead of allocating one per subscription.List<string>/List<KeyValuePair<…>>instances in bulk paths now use[.. source]spread syntax or explicitnew(capacity)where the upper bound is known from anICollection<T>.Count, avoiding the default-capacity-then-regrow allocation pattern.InMemoryBlobCacheBase._cache, the per-typeHashSet<string>buckets, andRequestCache._inflightRequestsall useStringComparer.Ordinalexplicitly. 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 onnet462.Faster SQLite bulk paths
DateTime.Nowper entry.SqliteBlobCache.Insert(IEnumerable<KVP>, ...)used to read the wall clock and reflecttype.FullNameinside the per-entry.Select(...)projection. Both are hoisted out of the loop so one batch = one wall-clock read.InsertorInvalidatewith an emptyIEnumerableno longer schedules anObservable.Start, acquires the lock, or allocates a work item — the method short-circuits to the cachedUnitobservable.GetCreatedAt(key)/GetCreatedAt(key, type)folded into a single async projection. The previousSelectMany → SelectMany → Where → Select → DefaultIfEmptychain is now one asyncSelectManythat always emits exactly one value (the timestamp ornull). Same semantics, four fewer Rx operators per call. The defensive null-Idfilter is preserved inline, verified against the existingPostQueryDefensiveFilterstest.SqliteAkavacheConnection.TryReadLegacyV10ValueAsyncused to materialise anew List<(string Sql, object[] Args)>(3)andforeachover 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
InMemoryBlobCacheBaseThe in-memory cache previously pruned the type index on every invalidation or expiration by scanning every registered
Type'sHashSet<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.InvalidateandVacuumExpiredEntriesFastuse this to jump directly to the right bucket in O(1) instead of scanning.Serializer allocation & CPU reductions
NewtonsoftSerializerandSystemJsonBsonSerializerused to decode the entire payload into astringviaEncoding.UTF8.GetString(data), call.TrimStart()twice, thenStartsWith("{")/StartsWith("["). They now go through a sharedBinaryHelpers.StartsWithJsonOpener(byte[])helper that walks the bytes directly. Onnet5+it usesMemoryExtensions.TrimStart(ReadOnlySpan<byte>, ReadOnlySpan<byte>)with a" \t\n\r"u8literal, which the JIT can vectorise.net462falls back to a tight scalar loop. No string allocation on the probe path at all.UniversalSerializercaches serializer-kind flags perType.IsBsonSerializer/IsPlainNewtonsoftSerializerpreviously ranGetType().Name.Contains(...)on every call. They now cache the boolean result in aConcurrentDictionary<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 walktypeof(T).FullName,.Name, andtypeof(T).Assembly.GetName().Nameon every cache miss to build the prefix forms. Those three strings are now materialised exactly once per closed generic instantiation via astatic readonlyfield onKeyMetadata<T>.MemoryStreamread paths usewritable: false. Reading a knownbyte[]vianew MemoryStream(bytes)used to copy the buffer internally. The read paths inNewtonsoftSerializer,SystemJsonBsonSerializer,BitmapImageExtensionsandImageCacheExtensionsnow passwritable: falseso the underlying array is used directly.MemoryStreamwrite paths are pre-sized. BSON write paths pre-size to 256 bytes; theBitmapImageExtensions.ImageToBytesPNG encode path pre-sizes to 16 KB, dodging the usual doubling regrowths for typical payloads.NewtonsoftSerializer.TryDeserializeFromOtherFormatsnow checks the first non-whitespace character viajsonString.AsSpan().TrimStart()before any further work, and in most cases short-circuits to theBinaryHelpersbyte probe before theEncoding.UTF8.GetStringis ever called.Async &
ConfigureAwaithygieneasync/awaitstate machines removed.InMemoryBlobCacheBase.DisposeAsyncand the innerAkavacheBuilderwrapper'sDisposeAsyncno longer compile into an async state machine — they return the underlyingValueTaskdirectly.ConfigureAwait(false)sweep. Task-returning awaits inBitmapImageExtensions,ImageCacheExtensionsandCacheDatabase's shutdown aggregator now passfalseso 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
CacheEntryparameterised constructor. Alongside the sqlite-net-mandated parameterless constructor, there's now a fullCacheEntry(string? id, string? typeName, byte[]? value, DateTimeOffset createdAt, DateTimeOffset? expiresAt)ctor. On older runtimes (notablynet462) the JIT tends to produce tighter codegen for a single ctor + field writes than for anew() { … }initializer that expands to parameterless ctor + property setters. Insert paths in bothInMemoryBlobCacheBaseandSqliteBlobCacheuse it.SplitFullPathis now ayield returniterator. The historicalList<string>+Reverse()pattern is replaced with a depth precount and an array-backed root-down iterator.CreateRecursiveconsumers never see a materialised intermediate list.Compatibility
#if NET5_0_OR_GREATER/#if NET9_0_OR_GREATERwith a preservednet462/netstandard2.0fallback.net462consumers still get the scalar code paths and nothing regresses.CacheEntrystill matches the existing table definition; the new constructor is additive.Unitsingleton, theGetCreatedAtfold, and the defensive null-Idfilters all produce the same emissions the existing test suite expects.Maintainer notes
Test coverage added
A few helpers were promoted from
privatetointernal staticso they can be driven from unit tests without going through the full observable pipeline:InMemoryBlobCacheBase.VacuumExpiredEntriesFast(cache, typeIndex, keyToType, now)— 3 testsInMemoryBlobCacheBase.RemoveKeyFromTypeIndexFast(typeIndex, keyToType, key)— 3 testsCacheEntry.ValueEquals(byte[]?, byte[]?)— 6 tests (same ref, both null, one null, distinct equal, equal length differing content, different length, two empty)CacheEntryparameterised ctor — 2 testsUniversalSerializer.IsBsonSerializer/IsPlainNewtonsoftSerializer/ResetCaches— 4 testsVerification
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 cobertura— 3,894 / 3,894 passing on net8 and net9..Where(x => x.Id is not null)" removal that broke theEncryptedPostQueryDefensiveFiltersShouldSkipNullIdEntriestest, reminding us the Rx-level filter is the only guard when a backend hasBypassPredicate = true. That change was reverted; only the foldedGetCreatedAtshape (with the defensive check preserved inline) made it into the final commit.Test plan
dotnet test --project tests/Akavache.Tests/Akavache.Tests.csprojgreen on all TFMsdotnet test --project tests/Akavache.Settings.Tests/Akavache.Settings.Tests.csprojgreen on all TFMssrc/benchmarks/before and after this branch with[MemoryDiagnoser]to confirm the allocation reductions on the bulk-insert and in-memory invalidation paths