Skip to content

Commit d600ce0

Browse files
jonedmistonclaude
andcommitted
- Added spec for Lava to Fluid bridge performance improvements
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1ce7edd commit d600ce0

1 file changed

Lines changed: 271 additions & 0 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
---
2+
author: Jon Edmiston
3+
date_created: 2026-05-01
4+
summary: >-
5+
Performance and allocation review of the Lava → Fluid bridge introduced when
6+
Rock moved from DotLiquid to Fluid. Catalogs hot-path reflection, redundant
7+
per-render parsing, async-over-sync allocations, and a few latent thread
8+
safety bugs, with a checkbox per finding so reviewers can pick what to act on.
9+
contributors:
10+
- Daniel Hazelbaker
11+
---
12+
13+
# Lava → Fluid Bridge: Performance and Allocation Improvements
14+
15+
## Summary
16+
17+
When Rock moved Lava rendering from DotLiquid to Fluid, an abstraction layer was added in `Rock.Lava`, `Rock.Lava.Shared`, and `Rock.Lava.Fluid` to bridge how Rock-flavored Lava maps onto Fluid's parser, AST, and value model. The bridge is functionally correct, but a review of the hot paths surfaced several reflection-per-call patterns, repeated parsing of the same content, redundant per-render allocations, and a few thread-safety bugs that also have a perf cost. This spec catalogs each finding so the team can decide which to address.
18+
19+
Each finding is a checkbox. Tick the items the team agrees to address; leave the rest for later or for explicit rejection.
20+
21+
## Motivation
22+
23+
Lava rendering is one of the most frequently invoked code paths in Rock. Every page render, every communication template, every mobile shell, every check-in label, every workflow attribute that supports Lava ends up in this bridge. Even small per-call costs (one extra reflection lookup, one extra `Task` allocation) compound into measurable CPU and GC pressure under production load. Fixing the items in this spec should reduce both render latency and allocation rate for Rock instances of any size, with the largest wins on shortcode-heavy templates and entity-driven communications.
24+
25+
The findings below were collected by static review of the bridge code only. None of them have been benchmarked yet; the priority ordering reflects expected impact based on call frequency and the cost of the operation involved. A reviewer who disagrees with a priority is encouraged to call it out before implementation.
26+
27+
## Requirements
28+
29+
- Each finding MUST be addressable independently. No PR should be forced to bundle multiple findings unless explicitly noted as related.
30+
- Functional behavior of Lava rendering MUST remain identical. These are perf and allocation changes, not feature changes.
31+
- Public APIs in `Rock.Lava`, `Rock.Lava.Shared`, and `Rock.Lava.Fluid` MUST stay backward compatible (per Rock's prime directive). Internal helpers and private fields are fair game.
32+
- Thread-safety fixes MUST be made even if the perf impact is negligible. Correctness wins over perf.
33+
- Each accepted finding SHOULD be benchmarked before and after to confirm the expected improvement, using a representative template.
34+
35+
## Findings
36+
37+
Each finding has a checkbox, an estimated impact, an affected location, and a proposed fix. Reviewers should tick the items the team agrees to address.
38+
39+
### P0 — Major hot-path issues
40+
41+
#### [ ] F1. Block content is re-parsed on every render
42+
43+
**Where:** [Rock.Lava.Fluid/Parser/FluidLavaBlockStatement.cs:141-174](../Rock.Lava.Fluid/Parser/FluidLavaBlockStatement.cs)
44+
45+
`ILiquidFrameworkElementRenderer.Render` calls `_parser.Grammar.Parse(blockContext, ref parseResult)` against the block's source text every time the block is rendered. `WriteToAsync` separately calls `LavaFluidParser.ParseToTokens(_blockContent)` on every render at line 109. For any template that uses shortcodes or custom blocks (most Rock templates) this is the largest single sink in the pipeline.
46+
47+
**Proposed fix:** Parse `_blockContent` once at construction (or lazily under `Lazy<T>`) and cache `IReadOnlyList<Statement>` plus the token list as fields. Wrap the synchronous `task.Wait()` at line 164 in the same `IsCompletedSuccessfully` fast-path used in `FluidEngine.OnRenderTemplate` to avoid the `Task` allocation on the hot path.
48+
49+
#### [ ] F2. LavaDataWrapper does reflection on every property access
50+
51+
**Where:** [Rock.Lava.Shared/Core/LavaDataWrapper.cs:103-113](../Rock.Lava.Shared/Core/LavaDataWrapper.cs)
52+
53+
```csharp
54+
public object GetValue( string key ) {
55+
var property = _baseObject.GetType().GetProperty( key );
56+
if ( property == null ) return null;
57+
var value = property.GetValue( _baseObject );
58+
return GetWrappedObject( value );
59+
}
60+
```
61+
62+
`Type.GetProperty` plus `PropertyInfo.GetValue` runs on every merge-field lookup. `GetAvailableKeys` (line 132) likewise calls `GetProperties()` per wrapper instance instead of caching by Type.
63+
64+
**Proposed fix:** Static `ConcurrentDictionary<Type, Dictionary<string, Func<object, object>>>` of compiled getters via `Expression.Lambda` or `Delegate.CreateDelegate(getMethod)`. Roughly 10x faster than `PropertyInfo.GetValue` and zero allocation per call.
65+
66+
#### [ ] F3. LavaTypeMemberAccessor uses PropertyInfo.GetValue per call
67+
68+
**Where:** [Rock.Lava.Fluid/LavaObjectMemberAccessStrategy.cs:228-233](../Rock.Lava.Fluid/LavaObjectMemberAccessStrategy.cs)
69+
70+
Each `[LavaType]`-decorated class registers a `LavaTypeMemberAccessor` per property; that accessor stores `PropertyInfo` and uses `_info.GetValue(obj)` per call. Same pattern, same fix.
71+
72+
**Proposed fix:** Compile the property getter into a `Func<object, object>` once in the constructor and call the delegate at access time.
73+
74+
#### [ ] F4. LavaDataHelper.GetLavaTypeInfo recomputes per call and is uncached
75+
76+
**Where:** [Rock.Lava.Shared/Utility/LavaDataHelper.cs:37-76](../Rock.Lava.Shared/Utility/LavaDataHelper.cs)
77+
78+
`GetLavaTypeInfo` invokes `type.GetProperties()` up to three times per call (lines 55, 61, 66) and is called from `LavaDataObjectInternal.GetInstanceProperties`. The Internal helper caches per-instance, but the underlying type info is not cached globally, so each new instance pays the full reflection cost.
79+
80+
**Proposed fix:** Back the lookup with a `ConcurrentDictionary<Type, LavaDataTypeInfo>` and call `GetProperties()` only once per type.
81+
82+
#### [ ] F5. ToRealObjectValue uses reflection per dictionary unwrap
83+
84+
**Where:** [Rock.Lava.Fluid/FluidExtensions.cs:229-238](../Rock.Lava.Fluid/FluidExtensions.cs)
85+
86+
```csharp
87+
if ( value is DictionaryValue ) {
88+
var dictionary = value.ToObjectValue();
89+
var fieldInfo = dictionary.GetType().GetField( "_dictionary",
90+
BindingFlags.NonPublic | BindingFlags.Instance );
91+
return fieldInfo.GetValue( dictionary );
92+
}
93+
```
94+
95+
`ToRealObjectValue` is invoked from `FluidRenderContext.GetFieldPrivate`, `GetScopeDefinedValues`, `ConvertFluidFilterArguments`, and elsewhere. Reflecting on the same private field over and over is pure waste.
96+
97+
**Proposed fix:** Cache the `FieldInfo` (or, better, a compiled `Func<DictionaryValue, object>`) in a `static readonly` field initialized once.
98+
99+
#### [ ] F6. Filter invocation uses MethodInfo.Invoke and per-call array
100+
101+
**Where:** [Rock.Lava.Fluid/FluidEngine.cs:441-501](../Rock.Lava.Fluid/FluidEngine.cs)
102+
103+
The wrapper allocates `new object[lavaFilterMethodParameters.Length]` per filter call and uses `lavaFilterMethod.Invoke(null, args)`. Templates can apply filters thousands of times per render.
104+
105+
**Proposed fix:** Build a compiled `Func<object[], object>` (or strongly-typed delegate) at registration via `Expression.Lambda(...).Compile()`. The arg array still allocates, but the reflection invoke cost is gone. If we want to push further, we can generate per-arity wrappers to remove the array allocation.
106+
107+
Also at [line 397](../Rock.Lava.Fluid/FluidEngine.cs): the LINQ chain `OrderBy(x => x.Name).ThenByDescending(x => x.GetParameters().Count())` invokes `GetParameters()` O(n log n) times during sort. Materialize `(method, parameters)` tuples first.
108+
109+
### P1 — Thread-safety bugs (with perf side-effects)
110+
111+
#### [ ] F7. Per-render mutation of shared TemplateOptions
112+
113+
**Where:** [Rock.Lava.Fluid/FluidEngine.cs:658-665](../Rock.Lava.Fluid/FluidEngine.cs)
114+
115+
```csharp
116+
if ( parameters.Culture != null )
117+
templateContext.FluidContext.Options.CultureInfo = parameters.Culture;
118+
if ( parameters.TimeZone != null )
119+
templateContext.FluidContext.Options.TimeZone = parameters.TimeZone;
120+
```
121+
122+
`templateContext.FluidContext.Options` is the engine's single `_templateOptions` instance. Two concurrent renders with different cultures will race; one render can observe the other's culture mid-render. This is a correctness bug first, perf bug second.
123+
124+
**Proposed fix:** Set the values on the `TemplateContext` itself (`fluidContext.CultureInfo`, `fluidContext.TimeZone`) rather than on the shared `Options`.
125+
126+
#### [ ] F8. _map and factory dictionaries read lock-free while being written
127+
128+
**Where:**
129+
- [Rock.Lava.Fluid/LavaObjectMemberAccessStrategy.cs:32](../Rock.Lava.Fluid/LavaObjectMemberAccessStrategy.cs)
130+
- [Rock.Lava.Fluid/Parser/FluidLavaTagStatement.cs:32](../Rock.Lava.Fluid/Parser/FluidLavaTagStatement.cs)
131+
- [Rock.Lava.Fluid/Parser/FluidLavaBlockStatement.cs:36](../Rock.Lava.Fluid/Parser/FluidLavaBlockStatement.cs)
132+
133+
`_map` and the `_factoryMethods` dictionaries are `Dictionary<>` (not concurrent). Tag/block factory methods are written under a `lock` but read without one during render. Most registrations happen at startup, but dynamic shortcodes can be re-registered at runtime, so the race is real.
134+
135+
**Proposed fix:** Switch all three to `ConcurrentDictionary<>`. While here, replace the `ContainsKey` followed by `[]` indexing in both factory wrappers with a single `TryGetValue`.
136+
137+
#### [ ] F9. GetTemplateOptions is not thread-safe
138+
139+
**Where:** [Rock.Lava.Fluid/FluidEngine.cs:235-262](../Rock.Lava.Fluid/FluidEngine.cs)
140+
141+
The `if (_templateOptions == null) { ... }` initialization can let two threads each construct a `TemplateOptions`, register filters twice, and lose one. Not a hot path, but a real startup race.
142+
143+
**Proposed fix:** `Lazy<TemplateOptions>` or a lock with a double-check.
144+
145+
### P2 — Per-context / per-render allocations
146+
147+
#### [ ] F10. FluidRenderContext constructor sets four constants on every render
148+
149+
**Where:** [Rock.Lava.Fluid/FluidRenderContext.cs:38-48](../Rock.Lava.Fluid/FluidRenderContext.cs)
150+
151+
`Blank/blank/Empty/empty` get `SetValue` calls on every new context. Since `ModelNamesComparer` is `StringComparer.Ordinal`, both case variants are needed, but they're constants and don't have to be set per render.
152+
153+
**Proposed fix:** Register the four values once on the engine's `TemplateOptions` so each new context inherits them.
154+
155+
#### [ ] F11. Reflection-based access to TemplateContext.LocalScope per read
156+
157+
**Where:** [Rock.Lava.Fluid/FluidRenderContext.cs:204](../Rock.Lava.Fluid/FluidRenderContext.cs)
158+
159+
The `PropertyInfo` is cached as `static`, but `_contextScopeInternalField.GetValue(_context)` is invoked at lines 131 and 252 on every merge-field read/write. `PropertyInfo.GetValue` is slow.
160+
161+
**Proposed fix:**
162+
163+
```csharp
164+
private static readonly Func<TemplateContext, Scope> _getLocalScope =
165+
(Func<TemplateContext, Scope>) Delegate.CreateDelegate(
166+
typeof( Func<TemplateContext, Scope> ),
167+
_contextScopeInternalField.GetGetMethod( true ) );
168+
```
169+
170+
Then call `_getLocalScope(_context)` at the call sites.
171+
172+
#### [ ] F12. ParseTemplate copies the Statements list for no apparent reason
173+
174+
**Where:** [Rock.Lava.Fluid/FluidEngine.cs:633](../Rock.Lava.Fluid/FluidEngine.cs)
175+
176+
```csharp
177+
template = new FluidTemplate( new List<Statement>( fluidTemplateObject.Statements ) );
178+
```
179+
180+
The parser already returned a `FluidTemplate`. We're copying its statement list into another `FluidTemplate`.
181+
182+
**Proposed fix:** Return `fluidTemplateObject` directly. If there is a real reason for the copy that is not visible in the surrounding code, document it inline.
183+
184+
#### [ ] F13. Async-over-sync allocations
185+
186+
**Where:**
187+
- [Rock.Lava.Fluid/FluidEngine.cs:701-705](../Rock.Lava.Fluid/FluidEngine.cs)`task.AsTask().GetAwaiter().GetResult()`. ValueTask has its own `GetAwaiter()`; drop `AsTask()` to skip the Task allocation.
188+
- [Rock.Lava.Fluid/FluidExtensions.cs:317](../Rock.Lava.Fluid/FluidExtensions.cs)`arg.Expression.EvaluateAsync(context).Result`. `.Result` wraps exceptions in `AggregateException` and forces Task materialization. Use `.GetAwaiter().GetResult()`.
189+
- See also F1 — same pattern in `FluidLavaBlockStatement` render.
190+
191+
**Proposed fix:** Use ValueTask's own awaiter where possible and the `IsCompletedSuccessfully` fast-path everywhere a sync-over-async pattern exists.
192+
193+
#### [ ] F14. GetScopeAggregatedValues allocates two dictionaries per call
194+
195+
**Where:** [Rock.Lava.Fluid/FluidRenderContext.cs:284-323](../Rock.Lava.Fluid/FluidRenderContext.cs)
196+
197+
`GetScopeDefinedValues` returns its own dictionary, then `GetScopeAggregatedValues` allocates another and copies. The inner method also uses `properties.Where(...)` which allocates a deferred enumerator.
198+
199+
**Proposed fix:** Inline the prefix check and write directly into a single dictionary.
200+
201+
#### [ ] F15. Small but called every render
202+
203+
These are individually cheap but called per render or per merge-field operation.
204+
205+
- [ ] **F15a.** [FluidRenderContext.cs:167](../Rock.Lava.Fluid/FluidRenderContext.cs)`",".ToCharArray()` allocates per call. Use a `private static readonly char[]` or the `Split(',')` overload.
206+
- [ ] **F15b.** [LavaToLiquidTemplateConverter.cs:106](../Rock.Lava/Engine/LavaToLiquidTemplateConverter.cs)`inputTemplate?.Replace("elseif", "elsif")` always allocates a new string. Guard with `IndexOf("elseif", StringComparison.Ordinal) < 0` first.
207+
- [ ] **F15c.** [LavaDataObject.cs:788](../Rock.Lava.Shared/Core/LavaDataObject.cs)`propPath.Split('.').ToList()` then `propPath.First()` and `propPath.Skip(1).ToList()` per traversal step. For path "a.b.c" this allocates 6+ lists. Replace with an int cursor and `IndexOf('.')`.
208+
- [ ] **F15d.** [LavaDataObject.cs:973-990](../Rock.Lava.Shared/Core/LavaDataObject.cs)`GetDynamicMemberNames` allocates a new list; `AvailableKeys` then `.ToList()` again. Cache the property name list per type.
209+
- [ ] **F15e.** [LavaObjectMemberAccessStrategy.cs:127-134](../Rock.Lava.Fluid/LavaObjectMemberAccessStrategy.cs)`RegisterLavaTypeProperties` calls `type.GetProperties()` three times. Materialize once.
210+
- [ ] **F15f.** [LavaObjectMemberAccessStrategy.cs:66](../Rock.Lava.Fluid/LavaObjectMemberAccessStrategy.cs)`type.Name.Contains("AnonymousType")` per first-time access. Cache per-Type.
211+
212+
### P3 — Worth knowing, low cost
213+
214+
#### [ ] F16. Value converter ordering and basic-type short-circuit
215+
216+
**Where:** [Rock.Lava.Fluid/FluidEngine.cs:106-107](../Rock.Lava.Fluid/FluidEngine.cs)
217+
218+
The existing TODO already calls this out. Adding `if (value is string || value is int || value is bool) return null;` at the top of each converter would skip O(n_converters) per common value.
219+
220+
#### [ ] F17. LavaDataDictionary.AvailableKeys allocates per call
221+
222+
**Where:** [Rock.Lava.Shared/Core/LavaDataDictionary.cs:365](../Rock.Lava.Shared/Core/LavaDataDictionary.cs)
223+
224+
Returns `new List<string>(_dictionary.Keys)` every call. If the engine reads it more than once during a render, that's wasted. Note: changing the return type would break the public API, so the fix is either to expose an alternate accessor or to cache the materialized list and invalidate on writes.
225+
226+
#### [ ] F18. LavaDataObject IDictionary.Count and .Values do full reflection traversal
227+
228+
**Where:** [Rock.Lava.Shared/Core/LavaDataObject.cs:338-346](../Rock.Lava.Shared/Core/LavaDataObject.cs)
229+
230+
`((ICollection)ldo).Count` calls `GetProperties().Count()`, materializing all properties via reflection just to compute a count. Cache the count, or compute it from the cached `_instancePropertyInfoLookup` plus `_dynamicMembers.Count`.
231+
232+
## Out of Scope
233+
234+
This spec covers only the bridge layer. The following are explicitly **not** addressed:
235+
236+
- The Fluid library itself (we are pinned to a specific version; upstream perf work is upstream's call).
237+
- The DotLiquid → Fluid migration's correctness gaps (separate ongoing work).
238+
- Filter implementations in `Rock.Lava/Filters/Generic/*` — those have their own perf characteristics that warrant a separate review.
239+
- Lava template authoring guidance for end users (a docs concern, not a code concern).
240+
- Rendering paths that bypass the Lava engine (Lava-to-HTML helpers in WebForms blocks, etc.).
241+
242+
## Verification Steps
243+
244+
For each accepted finding:
245+
246+
1. Author a benchmark using BenchmarkDotNet that exercises the affected path with a representative template (recommend: a simple merge-field template, a shortcode-heavy template, and a mixed workflow communication).
247+
2. Capture before/after numbers for both mean time and allocated bytes.
248+
3. Confirm the existing Lava test suite passes (`Rock.Tests.Integration` covers most Lava behaviors).
249+
4. Spot-check a handful of common templates in a running Rock instance to confirm output is byte-identical.
250+
251+
For thread-safety findings (F7, F8, F9), add a stress test that concurrently renders templates with differing cultures/timezones and asserts each render observes its own configured value.
252+
253+
## Considered but Rejected
254+
255+
### Replace the bridge with direct Fluid usage
256+
257+
Considered. Rejected, at least for now. The bridge does real work (Lava-to-Liquid pre-processing, Lava shorthand comments, shortcode tag syntax `{[ ... ]}`, Lava operator semantics, custom block factory model). Removing it would force rewrites of every Lava extension Rock has shipped. The findings above are localized; the bridge stays.
258+
259+
### Pre-compile every public Lava template at startup
260+
261+
Considered. Rejected. Rock has thousands of template fragments stored in defined types, persisted templates, attribute values, and report fields. Eager compilation would slow startup and consume memory for templates that may never render. The existing `FluidTemplateCache` (lazy, modification-aware) is the correct shape; F1 fixes the inner block-content case that the outer cache cannot reach.
262+
263+
### Replace LavaDataObject's DynamicObject base with a hand-rolled implementation
264+
265+
Considered. Rejected for this spec. `DynamicObject` is the right abstraction for the public API surface, and the perf wins from F2-F5 are achievable without changing the inheritance shape. If profiling after those land still shows DynamicObject as a bottleneck, revisit.
266+
267+
## Related
268+
269+
- DotLiquid → Fluid migration discussion (internal).
270+
- Fluid issue [#811](https://github.com/sebastienros/fluid/issues/811) (referenced in `FluidEngine.OnRenderTemplate` comment).
271+
- Asana task referenced at [LavaToLiquidTemplateConverter.cs:103](../Rock.Lava/Engine/LavaToLiquidTemplateConverter.cs) for the `elseif` replace decision.

0 commit comments

Comments
 (0)