VolocyNazad.Revit.TransactionMemoryCache
2025.0.9
dotnet add package VolocyNazad.Revit.TransactionMemoryCache --version 2025.0.9
NuGet\Install-Package VolocyNazad.Revit.TransactionMemoryCache -Version 2025.0.9
<PackageReference Include="VolocyNazad.Revit.TransactionMemoryCache" Version="2025.0.9" />
<PackageVersion Include="VolocyNazad.Revit.TransactionMemoryCache" Version="2025.0.9" />
<PackageReference Include="VolocyNazad.Revit.TransactionMemoryCache" />
paket add VolocyNazad.Revit.TransactionMemoryCache --version 2025.0.9
#r "nuget: VolocyNazad.Revit.TransactionMemoryCache, 2025.0.9"
#:package VolocyNazad.Revit.TransactionMemoryCache@2025.0.9
#addin nuget:?package=VolocyNazad.Revit.TransactionMemoryCache&version=2025.0.9
#tool nuget:?package=VolocyNazad.Revit.TransactionMemoryCache&version=2025.0.9
Revit.Transaction-Cache
An in-memory value cache bound to the lifecycle of a Revit document.
Revit.TransactionMemoryCache — a memoization service for expensive Revit API computations, with automatic cache invalidation on document changes or view switches, and support for DI containerization.
Features
IRevitTransactionMemoryCache—GetOrCreate<TItem>(object key, Func<TItem> factory): returns the cached value for a key, or computes it viafactoryand caches it.IRevitTransactionMemoryCacheInitializer—Initialize()/Deinitialize(): subscribes/unsubscribes the cache to theDocumentChangedandViewActivatedevents, automatically clearing all cached values when they fire.RevitTransactionMemoryCache— the single implementation of both interfaces on top ofIMemoryCacheandIRevitContext.- One-line DI registration via
AddTransactionMemoryCache(). - Thread-safe lifecycle (initialization, reset,
Dispose). CachedElementCollector— a fluent wrapper overFilteredElementCollectorwith automatic result caching (see the section below).- Two Roslyn analyzers (
RTMC001/RTMC002), wired in automatically with the package.
Installation
dotnet add package VolocyNazad.Revit.TransactionMemoryCache
Usage
Registering services in the DI container (also requires VolocyNazad.Revit.Context):
using Microsoft.Extensions.DependencyInjection;
using Revit.Context.DI;
using Revit.TransactionMemoryCache.DI;
services.AddRevitContext();
services.AddTransactionMemoryCache();
Initialization in IExternalApplication.OnStartup (after the context has been initialized):
using Autodesk.Revit.UI;
using Revit.Context.Abstractions.Services;
using Revit.TransactionMemoryCache.Abstractions.Services;
public Result OnStartup(UIControlledApplication application)
{
serviceProvider.GetRequiredService<IRevitContextInitializer>().Initialize(application);
serviceProvider.GetRequiredService<IRevitTransactionMemoryCacheInitializer>().Initialize();
return Result.Succeeded;
}
Using the cache in any service:
using Revit.TransactionMemoryCache.Abstractions.Services;
public sealed class MyService(IRevitTransactionMemoryCache cache)
{
public IList<Wall> GetWalls(Document doc) =>
cache.GetOrCreate("walls", () => new FilteredElementCollector(doc)
.OfClass(typeof(Wall))
.Cast<Wall>()
.ToList())!;
}
The cache is automatically reset when the document changes (DocumentChanged) and when the active view is switched (ViewActivated), so calling GetOrCreate again with the same key after these events recomputes the value.
CachedElementCollector
A fluent wrapper over FilteredElementCollector, but with automatic result caching in IRevitTransactionMemoryCache. Nothing touches the Revit API until the terminal call (ToElements()/ToElementIds()) — up to that point, the call chain only accumulates the fragments of the future cache key.
using Revit.TransactionMemoryCache.Abstractions.Services;
public sealed class MyService(ICachedElementCollectorFactory collectorFactory)
{
public IReadOnlyList<Wall> GetWalls(Document doc) =>
collectorFactory.Create(doc)
.OfClass(typeof(Wall)) // or .Of<Wall>()
.WhereElementIsNotElementType()
.ToElements()
.Cast<Wall>()
.ToList();
}
ICachedElementCollectorFactory is resolved via DI (registered together with AddTransactionMemoryCache()) and throws InvalidOperationException if IRevitTransactionMemoryCacheInitializer.Initialize() has not been called yet — a cache without automatic invalidation would silently serve stale data after a document change.
Key rules:
OfClass/Of<T>,OfCategory,Excludingmay each be called at most once per chain;WhereElementIsElementType/WhereElementIsNotElementTypeare mutually exclusive. Violating this throwsInvalidOperationExceptionimmediately, without waiting for a cache miss. The compile-time counterpart of this check is theRTMC002(Error) analyzer.NotOfClass/NotOf<T>— the inverted version ofOfClass/Of<T>(ElementClassFilterwithinverted: true): elements that do not belong to the class. Once-only among themselves (their own "slot", separate fromOfClass/Of<T>), but can be combined withOfClass/Of<T>in the same chain — independent quick filters, just like with categories.- The order of fluent calls does not affect the cache key — fragments are canonicalized (sorted) before being combined, so
OfClass(...).Excluding(...)andExcluding(...).OfClass(...)produce the same key. - The result of
ToElements()/ToElementIds()is shared across all callers with an equivalent chain. Do not cast it to a mutable type (List<T>, an array) or mutate it — that would silently corrupt the cached value for everyone else. TheRTMC001(Warning) analyzer catches such a cast at the call site and offers a code fix — replace it with.ToList()/.ToArray()(which creates a copy). WherePasses(ElementFilter)is deliberately unsupported — mostElementFiltersubclasses lack reliable value equality, which is needed to build a deterministic cache key from them. Instead, there are narrow fluent wrappers for specific value-typed parameters:OfCategories(IEnumerable<BuiltInCategory>)— the counterpart toOfCategory, but for multiple categories at once (ElementMulticategoryFilter). LikeOfCategory, it can be called at most once per chain, and conflicts withOfCategory(they share the same category "slot").NotOfCategory(BuiltInCategory)/NotOfCategories(IEnumerable<BuiltInCategory>)— the inverted versions (ElementCategoryFilter/ElementMulticategoryFilterwithinverted: true): elements that do not belong to the category/categories. Once-only among themselves (their own "slot", separate fromOfCategory/OfCategories), but can be combined withOfCategory/OfCategoriesin the same chain — these are independent quick filters, not alternative ways of expressing the same thing.WhereParameterEquals(BuiltInParameter, ...)/WhereParameterEquals(ElementId parameterId, ...)— a filter on a parameter value (ElementParameterFilter/ParameterFilterRuleFactory.CreateEqualsRule). Unlike the other fluent methods, this one can be called any number of times per chain — each call narrows the result (similar to chaining severalWherePassescalls onFilteredElementCollector). It covers all 4Parameter.StorageTypevalues:int,string(case-insensitive),ElementId— no assumptions needed;double— only with an explicitepsilon(WhereParameterEquals(parameter, value, epsilon)), since exactdoublecomparison is almost never what you want, and a sensible tolerance depends on the parameter's unit of measure (length/area/angle).- The overload taking
ElementId parameterIdinstead ofBuiltInParameteris for shared/project parameters that have noBuiltInParameter(e.g.SharedParameterElement.Id).
WhereParameterNotEquals(...)— the same overloads asWhereParameterEquals, but inverted (ElementParameterFilterwithinverted: true). Also not limited in the number of calls.WhereIsRoom()/WhereIsSpace()— quick filtersRoomFilter/SpaceFilter(no parameters, so no value-equality concerns either). Once-only each, but independent "slots" — both can be combined in the same chain (the result will always be empty, but no exception is thrown).WhereBoundingBoxIntersects(XYZ min, XYZ max, double epsilon)— the only supported geometric filter (BoundingBoxIntersectsFilter/Outline). The filter itself is built on the Revit side from exact coordinates, but each coordinate is rounded toepsilonfor the cache key — otherwiseXYZprovides no stable equality for a key.epsilonis required explicitly (for the same reason as thedoubleoverloads ofWhereParameterEquals). Not limited in the number of calls.
Known limitations
Invalidation is global across all open documents, not per document.
IRevitTransactionMemoryCacheis the single singleton instance per add-in process (registered viaAddTransactionMemoryCache()); isolation between documents is achieved not through separate cache instances, but because the document identifier is baked into the key itself (RuntimeHelpers.GetHashCode(document)insideCachedElementCollectorKeyBuilder/arbitrary keys viaGetOrCreate). ButRevitTransactionMemoryCache.Refresh()resets a single sharedCancellationTokenSourcethat entries for all documents are subscribed to — onDocumentChanged/ViewActivatedin one document, the cache for all currently open documents is cleared entirely, not just the one that changed.- This does not cause stale data (it's over-invalidation, not under-invalidation), but it reduces cache effectiveness when working with several open documents at once.
- Planned for the future: make invalidation per document — for example, partitioning the
CancellationTokenSourceby the document identifier fromDocumentChangedEventArgs/the activeDocument, instead of holding one shared token for all entries.
The
CachedElementCollectorkey is tied to a specificDocumentreference, not to the "logical" document. The cache key usesRuntimeHelpers.GetHashCode(document)— the identity of the managed wrapper object, not of the document as such. Empirically confirmed (RevitTests):Element.Documentcan return a different wrapper instance than the one passed intoFilteredElementCollector/ICachedElementCollectorFactory.Create(document). Practical consequence: if you obtain theDocumentfor the same open document through two different paths (e.g. once viaUIDocument.Document, once viaelement.Document) and pass both intoCreate(...), the cache is not reused between them — not data corruption, just a missed cache hit. Recommendation until this is fixed: always pass the sameDocumentreference intoCreate(document)(e.g. one obtained once at the start of the command), rather than re-fetching it from different places in the API.- Planned for the future: replace object identity with something stable at the logical-document level (e.g.
Document.PathName/Document.Titlecombined with a "workshared/unsaved" flag, if such a key turns out to be more reliable than reference identity).
- Planned for the future: replace object identity with something stable at the logical-document level (e.g.
Supported Revit versions
The package is built and tested for Revit 2021.1.9, 2023.0.0, and 2025.0.0. Revit 2021 and 2023 target net48; Revit 2025 targets net8.0-windows.
Requirements
- .NET SDK 10.0.103+ (see
global.json) - Revit API (the
Revit_All_Main_Versions_API_x64package) VolocyNazad.Revit.Context
Benchmarks
A performance comparison of FilteredElementCollector queries against the Revit database with and without caching via
IRevitTransactionMemoryCache — see benchmark/. Run manually inside a live
Revit session (Nice3point.BenchmarkDotNet.Revit), so it does not run in CI.
Light/Medium/Complex are three complexity levels of a FilteredElementCollector query. Each is run with the CallsPerSession parameter (1/5/20/100) — how many times the same query is requested in a row within a single "transaction", before the document changes and the cache is invalidated. Uncached always pays the full price CallsPerSession times; Cached uses a fresh key for each measurement, so each time it pays for exactly one real miss plus CallsPerSession − 1 hits — this shows how the savings grow with the number of repeated accesses, not just the marginal cost of a single already-warm hit.
Setup
Model and data
- One new project document (
Application.NewProjectDocument(UnitSystem.Metric)), created fresh for each class/parameter combination. - 1000 walls (
WallCount), lined up on a single seeded level, in a single transaction (OnGlobalSetupinCachingBenchmarksBase).
What each complexity level actually queries
| Level | Query |
|---|---|
| Light | OfClass(typeof(Level)) — a single cheap class filter, the one seeded level. |
| Medium | OfClass(typeof(Wall)).WhereElementIsNotElementType() — all 1000 walls, class and non-type filtering only. |
| Complex | The same, plus BoundingBoxIntersectsFilter, then in managed code: reading the CURVE_ELEM_LENGTH parameter of each wall and sorting by Id — touches the geometry/parameters of each element, not just its row in the element table. |
Session parameter
CallsPerSession: 1 / 3 / 5 / 10 — how many times the same result is requested in a row within one "transaction" before the cache is invalidated (see the previous paragraph). Each value is a separate row in the report (BenchmarkDotNet[Params]).
Cache behavior
- The cache is the real
RevitTransactionMemoryCachefromsrc/, wired up viaAddRevitContext()+AddTransactionMemoryCache()— the same path as in production. SlidingExpiration = 10 minutesper entry (seeRevitTransactionMemoryCache.GetOrCreate) — the whole run takes seconds, so within a single measurement an entry never expires on its own; the only miss is the one we deliberately create with a fresh key for each measurement.- Invalidation via
DocumentChanged/ViewActivateddoes not participate in the benchmark —Initialize()is not called, since it requires aUIControlledApplication, which the benchmark host does not have.
Measurement configuration (BenchmarkDotNet)
Job.Default— the number of invocations per iteration (InvocationCount), the number of warm-up/measurement iterations, etc. are not set manually; the engine calibrates them itself (see the note about the Pilot stage above).MemoryDiagnoser.Default— enables theAllocated/Alloc Ratiocolumn.- Exporters: CSV (
-report.csv), detailed measurements (-measurements.csv), JSON, GitHub markdown (the one that ends up here). - Target — the
Release_2025.0.0configuration (net8.0-windows, x64 platform); the host machine/OS/.NET SDK version/BenchmarkDotNet version are recorded automatically in the header of each report below.
Updated: 2026-09-05 14:21 (local benchmark run).
Light
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9278/25H2/2025Update/HudsonValley2)
Intel Core Ultra 7 155H 3.80GHz, 1 CPU, 22 logical and 16 physical cores
.NET SDK 10.0.400
[Host] : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3 [AttachedDebugger]
Job-BNKTQO : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3
BuildConfiguration=Release_2025.0.0
| Method | CallsPerSession | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|
| Uncached | 1 | 363.2 μs | 5.03 μs | 4.70 μs | 1.00 | 0.02 | 656 B | 1.00 |
| Cached | 1 | 404.2 μs | 7.84 μs | 6.95 μs | 1.11 | 0.02 | 1360 B | 2.07 |
| Uncached | 3 | 1,227.5 μs | 23.62 μs | 22.10 μs | 1.00 | 0.02 | 1968 B | 1.00 |
| Cached | 3 | 470.2 μs | 9.20 μs | 16.12 μs | 0.38 | 0.01 | 1680 B | 0.85 |
| Uncached | 5 | 2,063.4 μs | 40.27 μs | 62.70 μs | 1.00 | 0.04 | 3280 B | 1.00 |
| Cached | 5 | 439.0 μs | 8.46 μs | 11.30 μs | 0.21 | 0.01 | 2000 B | 0.61 |
| Uncached | 10 | 4,305.8 μs | 75.61 μs | 84.04 μs | 1.00 | 0.03 | 6560 B | 1.00 |
| Cached | 10 | 442.0 μs | 8.47 μs | 9.42 μs | 0.10 | 0.00 | 2800 B | 0.43 |
Medium
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9278/25H2/2025Update/HudsonValley2)
Intel Core Ultra 7 155H 3.80GHz, 1 CPU, 22 logical and 16 physical cores
.NET SDK 10.0.400
[Host] : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3 [AttachedDebugger]
Job-BNKTQO : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3
BuildConfiguration=Release_2025.0.0
| Method | CallsPerSession | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Uncached | 1 | 1.424 ms | 0.0484 ms | 0.1411 ms | 1.410 ms | 1.01 | 0.14 | 7.8125 | 5.8594 | 102 KB | 1.00 |
| Cached | 1 | 1.355 ms | 0.0404 ms | 0.1145 ms | 1.324 ms | 0.96 | 0.12 | 7.8125 | 5.8594 | 102.61 KB | 1.01 |
| Uncached | 3 | 4.090 ms | 0.0944 ms | 0.2740 ms | 3.999 ms | 1.00 | 0.09 | 23.4375 | 15.6250 | 306 KB | 1.00 |
| Cached | 3 | 1.268 ms | 0.0333 ms | 0.0945 ms | 1.225 ms | 0.31 | 0.03 | 7.8125 | 5.8594 | 102.92 KB | 0.34 |
| Uncached | 5 | 7.403 ms | 0.0840 ms | 0.0786 ms | 7.416 ms | 1.00 | 0.01 | 39.0625 | 31.2500 | 510 KB | 1.00 |
| Cached | 5 | 1.383 ms | 0.0753 ms | 0.2209 ms | 1.429 ms | 0.19 | 0.03 | 7.8125 | 5.8594 | 103.23 KB | 0.20 |
| Uncached | 10 | 11.652 ms | 0.2294 ms | 0.2146 ms | 11.588 ms | 1.00 | 0.03 | 78.1250 | 62.5000 | 1020 KB | 1.00 |
| Cached | 10 | 1.221 ms | 0.0350 ms | 0.1004 ms | 1.218 ms | 0.10 | 0.01 | 7.8125 | 5.8594 | 104.02 KB | 0.10 |
Complex
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9278/25H2/2025Update/HudsonValley2)
Intel Core Ultra 7 155H 3.80GHz, 1 CPU, 22 logical and 16 physical cores
.NET SDK 10.0.400
[Host] : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3 [AttachedDebugger]
Job-BNKTQO : .NET 8.0.30 (8.0.30, 8.0.3026.36720), X64 RyuJIT x86-64-v3
BuildConfiguration=Release_2025.0.0
| Method | CallsPerSession | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Uncached | 1 | 3.751 ms | 0.0743 ms | 0.0991 ms | 3.742 ms | 1.00 | 0.04 | 19.5313 | 15.6250 | 255.73 KB | 1.00 |
| Cached | 1 | 4.533 ms | 0.1995 ms | 0.5725 ms | 4.319 ms | 1.21 | 0.16 | 19.5313 | 15.6250 | 256.42 KB | 1.00 |
| Uncached | 3 | 10.152 ms | 0.1761 ms | 0.2028 ms | 10.177 ms | 1.00 | 0.03 | 62.5000 | 46.8750 | 767.2 KB | 1.00 |
| Cached | 3 | 3.662 ms | 0.0716 ms | 0.1327 ms | 3.629 ms | 0.36 | 0.01 | 19.5313 | 15.6250 | 256.73 KB | 0.33 |
| Uncached | 5 | 16.508 ms | 0.2091 ms | 0.1854 ms | 16.477 ms | 1.00 | 0.02 | 93.7500 | 62.5000 | 1278.67 KB | 1.00 |
| Cached | 5 | 3.570 ms | 0.0709 ms | 0.0897 ms | 3.557 ms | 0.22 | 0.01 | 19.5313 | 15.6250 | 257.05 KB | 0.20 |
| Uncached | 10 | 33.797 ms | 0.4736 ms | 0.4198 ms | 33.817 ms | 1.00 | 0.02 | 200.0000 | 133.3333 | 2557.34 KB | 1.00 |
| Cached | 10 | 3.601 ms | 0.0677 ms | 0.0725 ms | 3.613 ms | 0.11 | 0.00 | 19.5313 | 15.6250 | 257.83 KB | 0.10 |
License
MIT, see LICENSE.md.
Development documentation
Contributing
CONTRIBUTING.md before submitting changes.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0-windows7.0 is compatible. net9.0-windows was computed. net10.0-windows was computed. |
-
net8.0-windows7.0
- Microsoft.Extensions.Caching.Memory (>= 10.0.11)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.11)
- Revit_All_Main_Versions_API_x64 (>= 2025.0.0)
- VolocyNazad.Revit.Context (>= 2025.0.8)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2025.0.9 | 84 | 9/21/2026 |
| 2025.0.8 | 108 | 9/6/2026 |
| 2025.0.7 | 114 | 8/13/2026 |
| 2025.0.6 | 131 | 6/11/2026 |
| 2025.0.5 | 119 | 6/8/2026 |
| 2023.0.9 | 82 | 9/21/2026 |
| 2023.0.8 | 119 | 9/6/2026 |
| 2023.0.7 | 107 | 8/13/2026 |
| 2023.0.6 | 115 | 6/11/2026 |
| 2023.0.5 | 149 | 6/8/2026 |
| 2021.0.9 | 74 | 9/21/2026 |
| 2021.0.8 | 99 | 9/6/2026 |
| 2021.0.7 | 103 | 8/13/2026 |
| 2021.0.6 | 123 | 6/11/2026 |
| 2021.0.5 | 130 | 6/8/2026 |