Conversation
… template rendering FreemarkerQueryUtilities.requestQuery was synchronized, serialising every yml render in the JVM, and each call re-parsed the yml, re-compiled the query template and re-rendered once per argument. - Cache the parsed yml definition keyed on (resource, cacheDir), used only when the content is unchanged; callers get a deep copy. Missing embedded files are re-created on a cache hit. - Add TemplateCache: compiled freemarker templates keyed on (configuration, name, full source). - Dialog uses a shared read-only Configuration (per-dialog when dialog.macrodir is set); skip, gor and the exception handler are per-render on the Environment. - Perspective compiles through TemplateCache instead of writing to its shared HashMap-backed loader; skip/handler are per-render. - RangedNumberFormatter clones the shared NumberFormat (was racy). - requestQuery defers updates so the query renders once, and drops synchronized. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Render the requestQuery dialog once: the factory now creates dialogs with
deferred updates, so the constructor no longer renders with default
arguments (gor(...) in a template ran with REQUIRED(x) placeholders).
- Render the deferred query in requestQuery so a template error reaches the
caller as the declared TemplateException, not a RuntimeException.
- Restore an embedded ${key} file on a cache hit when it is missing or its
content differs. makeTempFile names files by SHA-256 of the content and
writes atomically (temp file + move).
- Parse yml with SafeConstructor.
- Bound the definition and template caches by source size, not entry count.
- Remove unused Perspective.initializeTempleConfig; fix the loader comment.
- Declare caffeine directly in gortools.
- Tests: delta-based cache counters, overwritten embedded file, one gor()
call per request, template error path, skip/gor argument precedence,
yml global tag rejection and standard types.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Rendering a yml (freemarker) template query (
gor some.yml?a=1&b=2,gor some.yml(a=1), includingtemplate://sources) goes throughFreemarkerQueryUtilities.requestQuery, which wassynchronized public static. That serialises every yml render in the JVM. Each call also:Configurationper dialog, so the query template was re-compiled on every call,setArgumentValuesfires a property change per argument, and each change re-renders). A template that callsgor(...)ran those queries every time.Why the lock was there
The lock was hiding real shared mutable state. Removing only the
synchronizedkeyword makes the new concurrency test fail at once withNumberFormatException: For input string: ".1E.01E0". The shared state was:RangedNumberFormatterpassed one staticNumberFormat(not thread-safe) to everyNumberArgumentformatter.Perspectivekept a static freemarkerConfigurationwith a staticStringTemplateLoader(backed by a plainHashMap) and wrote to it from every dialog build. It also used one shared, statefulskipmethod model.Dialogconfiguration held stateful objects that were reset for each render: theskipmethod model and theDialogTemplateExceptionHandler, which counts missing required arguments.Change
AbstractDialogFactory.buildDialogs(resource, cacheDir)): the yml is still read on every call, but it is only re-parsed when its content differs from the cached copy. The cache is keyed on (resource, cacheDir) and bounded by Caffeine. Detection does not rely on modification times, so an edited file is always picked up. Every call gets a deep copy of the parsed map, so its dialog and argument objects are its own. Embedded${key}files are re-created if they have been removed from the cache dir.TemplateCache, new, Caffeine-bounded): compiled templates are keyed on (configuration, name, full source). An edited template compiles to a new entry, and a hash collision cannot hand out another template's source.Dialog. It is set up once in static init and never modified. All per-render state is now created for each render and set on that render'sEnvironment: a newskipmodel,gorbound to the caller'sQueryEvaluator, and a new exception handler. Data-model arguments namedskip/gorstill take precedence, as before. Whendialog.macrodiris set, macros load through the dialog's file resolver, so each dialog keeps its own configuration, as before.Perspectivecompiles its filter/view templates throughTemplateCacherather than writing them to the shared loader, and setsskipand its exception handler on each render'sEnvironment.RangedNumberFormattergives each formatter its own clone of the defaultNumberFormat.requestQuerysetssetDeferUpdates(true)before setting arguments, so the query is rendered once, with all arguments set, when it is requested. It no longer renders once per argument.synchronizedremoved fromrequestQuery. The privategetQueryhelper is unchanged.Thread-safety argument
After this change,
requestQueryonly shares these objects between threads:Configurations (Dialog shared config, Perspective static config). They are fully configured in static initialisers and not modified afterwards. Freemarker documents that aConfigurationis safe for concurrent use once it is no longer modified.Templates (viaTemplateCache). Freemarker documents that aTemplatecan be processed by many threads at once. We no longer callTemplate.setTemplateExceptionHandler. Every mutable piece of render state is on the per-renderEnvironment.ArgumentTypemap, the defaultNumberFormat, which is now only cloned).ArgumentBuilder.getPathMapwas already synchronized.Everything mutable is created per call: dialogs, arguments, the attribute maps (deep copies),
SkipFirstMethodModel, exception handlers,QueryEvalMethodModeland writers.Behaviour notes
getQuery()could silently return the output of an earlier render made with only some arguments set.${key}file. The content is identical, and the same class of race already exists forCmd. It is not made worse, because on a cache hit the file is only rewritten when it is missing.Measurements
I timed this with a throwaway harness (not committed). It rendered a liftover-style template through
requestQuery:<#setting>, six<#assign>s,?matchesguards, nested<#if>, 9 arguments, a ~35-line query. Setup: local file, JDK 17, 1000 warm-up calls, then 2000 timed calls. For the concurrent case, 8 threads each did 2000 calls with different arguments, and every output was checked. Ranges are over repeated runs on a laptop.Where the time went before (per call, single thread):
Configuration+ template compileBefore / after:
Tests
New
gorsat.UTestTemplateRenderingConcurrency:renderOutputReflectsArguments: checks the output, includingskip()in the query and in a perspective filter and a number argument. An optional argument from one call does not leak into the next.concurrentRendersWithDifferentArgumentsProduceTheirOwnOutput: 8 threads × 500 renders over 200 argument sets, each compared with the output of a single-threaded render. This test fails on the old code with onlysynchronizedremoved.editedTemplateIsPickedUp: the file is rewritten with the same length and the same modification time, and the new content is used.unchangedTemplateIsNotReparsed: 48 more renders with an unchanged file cause no yml parse and no template compile. After an edit, the file is re-parsed exactly once.embeddedFileIsRestoredIfCacheDirIsCleanedAlso ran: the yml/template-related classes (
UTestGorTemplate,UTestGorWrite,UTestTableFunction,UTestInputSourceParsing,UTestCommandParsing,UTestMacroUtilities,UTestGorPrePipe,UTestMacroParsing): 254 tests, 0 failures. Full./gradlew :gortools:test: 2600 tests, 0 failures, 69 skipped.🤖 Generated with Claude Code