Skip to content

perf: close BE request-lifecycle regression vs 8.1 stable - #701

Merged
lmajano merged 4 commits into
developmentfrom
claude/wonderful-sagan-i425oq
Sep 23, 2026
Merged

lmajano merged 4 commits into
developmentfrom
claude/wonderful-sagan-i425oq

Conversation

@lmajano

@lmajano lmajano commented Sep 23, 2026

Copy link
Copy Markdown
Member

Description

Follow-up to the perf-harness work in #700, which surfaced a consistent throughput regression on ColdBox BE (8.2-dev) vs 8.1 stable across engines. This PR fixes five per-request hot-path overheads found by diffing v8.1.0 against development and validating each candidate with paired BE/stable throughput measurements before turning it into a real fix.

Fixes

  • HandlerService.getRouteCachingMetadata() (route-level .withCache() support) was resolved twice per request — once from getEventMetadataEntry() (pre-dispatch cacheability check) and again from getEventCachingMetadata() (at dispatch) — for a route record that cannot change for the life of a request. Memoized in request scope so it's resolved once.
  • The cache-key-suffix dynamic-vs-static check (isClosure()/isCustomFunction()/isSimpleValue()) was recomputed via function calls on every cache-metadata lookup. Precomputed once as a boolean field (suffixIsDynamic) when the dictionary entry is built instead.
  • BaseService.getLogger() dropped a redundant structKeyExists() check ahead of isNull() — the declared log property already guarantees the variables-scope slot exists.
  • InterceptorState (extends EventPool, a separate class hierarchy from BaseService, so it had independently reimplemented the same lazy-getter pattern) now resolves the logger eagerly in init() instead of lazily on every call.
  • InterceptorService.getLazyBuffer() was allocating a new InterceptorBuffer component on every announce() call (10+ per request). Pooled per request with checkout/release, falling back to an unpooled instance for the async/asyncAll paths (whose buffer can outlive the announce() call on a background thread) and for a reentrant announce() call (e.g. onException triggered from within an interceptor), so a nested call can never corrupt a buffer still in flight further up the call stack.

Validation

  • Functional: hit all 5 perf-harness scenarios plus an invalid-event path (to force onInvalidEvent/onException, exercising announce() reentrancy) against live BoxLang and Lucee 7 servers — all correct, no errors.
  • Full TestBox suite could not be run in this sandbox (needs a MySQL datasource and a Lucee ORM extension, neither available here) — this is an infrastructure gap unrelated to the change, flagging for CI to confirm.
  • Performance: repeated paired BE/8.1-stable throughput runs (150 iterations, 20s throughput window) on boxlang, boxlang-cfml, and lucee-7 show the BE/stable gap narrowed or closed, though this sandbox has significant run-to-run noise (documented throughout the investigation in feat(perf-harness): compare BE against 8.1 stable and 7.x latest, fix BoxLang compat #700) so treat exact percentages as directional, not precise.

Type of change

  • Performance improvement

Checklist

  • I have commented my code, particularly in hard-to-understand areas
  • I have added tests that prove my fix is effective or that my feature works — these are internal micro-optimizations to existing hot paths with no behavior change; correctness is covered by the existing framework test suite (not runnable in this sandbox, see above) and manual functional validation
  • New and existing unit tests pass locally with my changes — unable to run the full suite in this sandbox (see Validation); no behavior change intended, only per-request memoization/pooling of existing computations

🤖 Generated with Claude Code

https://claude.ai/code/session_01KiK6DMek9iJMuYk2PzcjPj


Generated by Claude Code

claude and others added 3 commits September 23, 2026 11:43
Root-caused and fixed five per-request hot-path overheads introduced
since 8.1 that were adding up across every request:

- HandlerService: getRouteCachingMetadata() was resolved twice per
  request (once from getEventMetadataEntry(), once from
  getEventCachingMetadata()) for a route record that cannot change for
  the life of a request. Memoized in `request` scope so it's resolved
  once.
- HandlerService: the cache-key-suffix dynamic-vs-static check
  (isClosure()/isCustomFunction()/isSimpleValue()) was recomputed via
  function calls on every cache-metadata lookup. Precomputed once as a
  boolean field when the dictionary entry is built instead.
- BaseService.getLogger(): dropped a redundant structKeyExists() check
  ahead of isNull() - the declared `log` property already guarantees
  the variables-scope slot exists.
- InterceptorState (a separate class hierarchy from BaseService, so it
  independently reimplemented the same lazy-getter pattern): resolve
  the logger eagerly in init() instead of lazily on every call.
- InterceptorService.getLazyBuffer(): was allocating a new
  InterceptorBuffer component on every announce() call (10+ per
  request). Pooled per request with checkout/release, falling back to
  an unpooled instance for the async/asyncAll paths (whose buffer can
  outlive the announce() call on a background thread) and for a
  reentrant announce() call (e.g. onException triggered from within an
  interceptor), so a nested call can never corrupt a buffer still in
  flight further up the call stack.

Validated functionally against live BoxLang and Lucee 7 servers
(all 5 perf-harness scenarios plus an invalid-event/exception path to
exercise interceptor reentrancy) and via repeated paired BE/8.1-stable
throughput runs on boxlang, boxlang-cfml, and lucee-7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiK6DMek9iJMuYk2PzcjPj
…tructs

getEventMetadataEntry()'s fast path and resolveCacheSuffix() assumed
every mdEntry-shaped struct carries the precomputed suffixIsDynamic
field added in the previous commit. That's only true for entries built
by getEventCachingMetadata()'s dictionary-population path - a struct
built any other way (e.g. EventCachingSpec's direct call to
resolveCacheSuffix() with a bare {suffix, cacheable} struct) doesn't
have it, and BoxLang throws KeyNotFoundException on the direct-access
read instead of just treating it as undefined.

Fall back to computing it inline via the same Elvis-with-missing-key
idiom already used elsewhere in this codebase (LuceeMappingHelper.cfc,
RestHandler.cfc) - verified directly against live BoxLang and Lucee 7
instances that struct.missingKey ?: default does not throw and
evaluates the fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiK6DMek9iJMuYk2PzcjPj
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Test Results

0 tests  ±0   0 ✅ ±0   0s ⏱️ ±0s
0 suites ±0   0 💤 ±0 
0 files   ±0   0 ❌ ±0 

Results for commit 7cdfc7d. ± Comparison against base commit 9a3be0e.

♻️ This comment has been updated with latest results.

…uest scope

getRouteCachingMetadata() memoized its result on the raw CFML `request`
scope, assuming it's one-per-logical-request. That's true for a real
HTTP request but false in TestBox: the whole spec suite runs inside a
single physical HTTP request, so the first HandlerServiceTest case to
call getRouteCachingMetadata() poisoned every subsequent case in that
describe block with its own route record's result, regardless of the
route record each later test actually passed in.

Moved the memo key onto the requestContext instance instead, via the
same setPrivateValue()/getPrivateValue()/privateValueExists() methods
RequestContext already uses to store currentRouteRecord. That struct
(variables.privateContext) is a genuine instance property re-created
fresh in every init(), so it's isolated per object by the CFML/BoxLang
object model itself - not dependent on any engine-specific behavior.
Tests build a fresh mock RequestContext per case (BaseTestCase.setup()
calls removeContext() in a beforeEach), so this scopes the memo exactly
where it needs to be: once per real (or test) request, never leaking
across cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KiK6DMek9iJMuYk2PzcjPj

lmajano commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

One tests / Test Suites (adobe@2025, 21, false, 8) run failed on this commit (7cdfc7d) with:

tests/specs/web/services/ModuleServiceTest.cfc | Error loading module routes as the module requested 'test-module-conventions' is not loaded.

This isn't this PR's failure — the stack trace only touches Router.cfc and ModuleService.cfc (module route registration), neither of which this PR modifies (this PR only touches HandlerService.cfc, BaseService.cfc, InterceptorService.cfc, and InterceptorState.cfc). Two CI workflow runs fired for this push; the sibling run's identical adobe@2025 job on the same commit passed cleanly (870/870), which serves as the confirming re-run. Treating this as a pre-existing flake, likely from the two duplicate concurrent runs contending over module-registration state.


Generated by Claude Code

@lmajano
lmajano merged commit c318d8d into development Sep 23, 2026
27 of 28 checks passed
@lmajano
lmajano deleted the claude/wonderful-sagan-i425oq branch September 23, 2026 12:15
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