Skip to content

fix(controller): rendering residuals C13/C16/C17 and $expandedAssociations metadata lock - #3152

Merged
bpamiri merged 3 commits into
developfrom
peter/issue-2961-2952-residuals
Jun 12, 2026
Merged

fix(controller): rendering residuals C13/C16/C17 and $expandedAssociations metadata lock#3152
bpamiri merged 3 commits into
developfrom
peter/issue-2961-2952-residuals

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes the last three rendering residuals of the #2961 roll-up (C13/C16/C17) and lands the $expandedAssociations metadata-fill lock flagged in the #2952 coverage-audit comment. Verify-first confirmed all four defects still reproduced on origin/develop before coding; everything else in #2961 (C2, DA1/DA5/DA14, DC6, SEC-8/P14) had already merged.

Fixes #2961 (C13/C16/C17 residuals) and Refs #2952 (the $expandedAssociations lock).

C17 — $getStatusCodes() rebuilt a 63-entry constant struct per render (vendor/wheels/controller/rendering.cfc)

  • The map is now built once per application lifetime and memoized as application[appKey].statusCodes, alongside a reverse text-to-code lookup application[appKey].statusCodeLookup (assigned lookup-first so a lock-free concurrent reader that observes statusCodes always sees both; both writes are idempotent).
  • $returnStatusCode() reads the reverse map instead of StructFindValue over the freshly rebuilt struct. The reverse map is built in numeric key order, so the duplicated text "Unassigned" (427/430/509) now deterministically resolves to 427 (previously engine-hash-order dependent).
  • The numeric branch of $setRequestStatusCode() keeps its validation call ($returnStatusText throws Wheels.RenderingError on unknown codes) but no longer assigns the unused text.

C16 — $includeFile() re-tokenized the column list per row

  • ListToArray(query.columnList) ran inside both per-row loops (grouped and ungrouped partial rendering). It is now hoisted above the loops; the column list is constant per query.

C13 — $includeFile() blanket catch silently blanked columns

  • The catch (any e) { arguments[property] = "" } now logs a warning to the wheels log — once per column per render, naming the column, partial, first failing row, and underlying error — before defaulting to the empty string. Logging is wrapped best-effort so it can never break rendering. (Kept catch (any) rather than narrowing: the exception types for unreadable/binary column reads are engine-specific.)

#2952 residual — unlocked shared-struct writes in $expandedAssociations (vendor/wheels/model/sql.cfc)

  • The context-independent metadata fill-ins above the JOIN-variant memo (foreignKey/joinKey defaults, tableName, columnList, properties, propertyList, aliasedPropertyList, calculated properties, columnStruct/propertyStruct) wrote the shared application-scoped association struct on every call with no lock — the same pattern fix(model): key association JOIN memo by soft-delete and alias context #2910 fixed for the JOIN string itself.
  • They now fill once under the existing wheelsJoinMemo double-checked named lock, with an expandedMetadataFilled marker written last so lock-skipping readers only ever observe a fully populated set. Values derive solely from class data, so fill-once is equivalent to the previous per-call rewrite (a reload rebuilds class data and the marker with it).
  • Rebased on top of fix(model): hasMany shortcut no longer breaks the association via include expansion #3133 ($expandThroughAssociations hasMany-shortcut fix, same file) — no conflict; hasManyShortcutSpec green in the runs below.

Drive-by: two Adobe-only COMPILE crashes from recently merged specs (required to verify anything on Adobe)

The core runner compiles every spec in the directory, and Adobe validates built-in arg counts / ValueList() operands at compile time, so each of these crashed the entire Adobe 2023 suite (HTTP 500, 0 specs run) on current develop:

What each issue still has left

Tests (TDD red-first)

  • renderingSpec.cfc: new $getStatusCodes is memoized describe (5 specs) — memoization in application scope, memo reuse, text↔code resolution, deterministic duplicate-text resolution, unknown-code/text throws. Red before the fix (Expected [false] to be true on both memoization specs), green after.
  • contentSpec.cfc + new _groupRow.cfm asset: first coverage of the grouped-partial branch (includePartial(query=…, group=…)) — regression guard for the C16 hoist.
  • ExpandedAssociationsJoinMemoSpec.cfc: new spec asserting the expandedMetadataFilled marker exists after expansion and that later calls do not rewrite the shared struct outside the lock (marker key did not exist pre-fix).

Evidence

Docker harness from the worktree (lucee7 + adobe2023 images, SQLite), full core suite on the rebased branch (4524 specs):

Engine Result
Lucee 7 4494 pass / 12 fail / 0 error — the 12 are exactly the tolerated internal.testClientSpec baseline artifacts
Adobe CF 2023 4504 pass / 1 fail / 1 error — both pre-existing on develop (see below)

Targeted bundles (both engines): renderingSpec 92/0, contentSpec 11/0 (was 10), ExpandedAssociationsJoinMemoSpec 5/0 (was 4), UpdateAllIncludeJoinSpec 4/0, miscellaneousSpec (controller) 44/0, seederSpec 22/0.

Pre-existing Adobe 2023 failures NOT addressed here (verified identical with this PR's framework changes stashed; both also survive a clean container restart, so they are not cache artifacts):

  • migrator.typedColumnDefaultsSpec :: float()The key(s) [default] does not exist in the target object (Adobe metadata quirk, recent migrator spec).
  • security.RouteTesterHardeningSpec :: HTML-encodes the path in the verb-mismatch messageNeither the method $$findMatchingRoutes was found in component Public.cfc (recent route-tester spec).

These two deserve a follow-up; before this PR's compile fixes the Adobe suite couldn't even run to reveal them (whole-suite DIRECTORYCREATE compile crash).

🤖 Generated with Claude Code

bpamiri and others added 3 commits June 12, 2026 11:41
…log $includeFile blanked columns

Closes the three rendering residuals of the #2961 roll-up (C13/C16/C17):

- C17: $getStatusCodes() rebuilt a 63-entry constant struct on every render
  path. It is now built once per application lifetime and memoized in the
  application scope together with a deterministic reverse (text-to-code)
  lookup; $returnStatusCode() reads that lookup instead of running
  StructFindValue over the rebuilt struct, and duplicated status texts
  (Unassigned at 427/430/509) deterministically resolve to the lowest code.
  The numeric branch of $setRequestStatusCode() keeps its validation call
  but no longer assigns the unused text.
- C16: $includeFile() re-ran ListToArray(query.columnList) inside both
  per-row loops; the column list is constant per query so it is tokenized
  once above the loops.
- C13: the blanket catch that blanked a column $includeFile() could not
  read now logs a warning (once per column per render) to the wheels log
  naming the column, partial, first failing row, and underlying error
  before defaulting to an empty string.

Refs #2961

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
…edAssociations

The metadata fill-ins above the JOIN-variant memo (foreign/join keys,
table name, column/property lists and structs) wrote the shared
application-scoped association struct on every call without a lock —
the same unlocked-shared-struct pattern #2910 fixed for the JOIN string
itself, flagged in the #2952 coverage-audit comment. They are now filled
once under the same double-checked named lock (wheelsJoinMemo), with a
fill-once marker written last so lock-skipping readers only ever observe
a fully populated metadata set. The values are derived solely from class
data, so fill-once is equivalent to the previous per-call rewrite.

Refs #2952

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
Adobe CF validates built-in argument counts and ValueList() operands at
COMPILE time, and the core runner compiles every spec in the directory,
so each of these crashed the entire Adobe 2023 suite (0 specs run):

- miscellaneousSpec.cfc (#3101): DirectoryCreate(path, true) — the
  createPath boolean is Lucee-only; Adobe's DirectoryCreate takes exactly
  one parameter. Three call sites, all with existing parents, switched to
  the single-argument form.
- seederSpec.cfc (#3107): ValueList(model(...).findAll(...).id) — Adobe
  only accepts a plain query.column reference inside ValueList(). The
  query is assigned to a variable first.

Verified: full core suite on Adobe 2023 + SQLite goes from a whole-suite
onRequest compile error to 4462 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri bpamiri changed the title fix(controller): close #2961 rendering residuals (C13/C16/C17) and lock $expandedAssociations metadata fix(controller): rendering residuals C13/C16/C17 and $expandedAssociations metadata lock Jun 12, 2026

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer

TL;DR: This PR closes the three rendering residuals of the #2961 roll-up (C13 logged-blank columns, C16 column-list hoist, C17 $getStatusCodes memoization), locks the context-independent metadata fill-ins in $expandedAssociations (#2952 residual), and fixes two Adobe-only whole-suite compile crashes in recently merged specs. I verified every claim against the checked-out head: the locking mirrors the shipped #2910 joinVariants pattern under the identical lock name, the memoization guard is sound, the fill-once semantics are behaviorally equivalent to the old per-call rewrite, and tests follow the established spec style with proper cleanup. No correctness, cross-engine, or security findings. Verdict: comment — three minor non-blocking notes below.

Correctness (verified, no findings)

  • vendor/wheels/model/sql.cfc:1296-1339 — the double-checked lock uses the same name as the existing JOIN-variant memo lock (wheelsJoinMemo#application.applicationName#, line 1420), so metadata fill and join memoization serialize against each other. The marker-written-last pattern matches the prior art #2910 established. local.class (line 1263) is the owner of the association struct being filled, so the "context-independent" claim holds — for a given association struct, both local.class and local.associatedClass are fixed. The foreignKey/joinKey fills were already fill-once before this PR (guarded by !Len(...) on a shared struct), so behavior is unchanged there too.
  • vendor/wheels/controller/rendering.cfc:867-960 — the memo-hit guard checks both statusCodes and statusCodeLookup before returning, and the miss path rebuilds both idempotently, so the lock-free design is sound regardless of write interleaving. $returnStatusText (line 838) and $returnStatusCode (line 854) are the only framework readers and neither mutates the now-shared struct (verified via git grep).
  • vendor/wheels/controller/rendering.cfc:648-649 — the C16 hoist is safe: local.query is fixed after the StructDelete(arguments, "query") at line 643, and columnList is constant per query. Both per-row loops now index the hoisted array.
  • vendor/wheels/controller/rendering.cfc:712-723 — the C13 once-per-column dedup mutates an existing struct (local.unreadableColumns[local.property] = true) rather than binding a new local variable inside the catch, which is exactly the BoxLang-safe pattern from Cross-Engine Invariant 11. The WriteLog shape (type="warning", [Wheels] prefix, file="wheels") matches existing prior art at vendor/wheels/Controller.cfc:172.

Conventions

  • Nit (informational, not blocking): $returnStatusCode now resolves status text via a struct-key lookup (rendering.cfc:854), and CFML struct keys are case-insensitive — so e.g. $returnStatusCode("not found") resolves to 404 where the old StructFindValue value-scan may not have matched, depending on engine. The direction is strictly more lenient on an internal $ helper, so this is fine — just flagging the subtle contract widening in case anyone greps for it later.
  • Relatedly trivial: the numeric branch of $setRequestStatusCode dropped its dead local.statusText assignment (rendering.cfc:820), but the else branch still carries an equally dead local.statusText = local.status; at line 824 — nothing after the branch reads it. Optional cleanup, no action required.

Cross-engine

No findings. The two spec fixes (miscellaneousSpec.cfc single-arg DirectoryCreate, seederSpec.cfc hoisting the query out of ValueList()) are themselves cross-engine corrections for documented Adobe compile-time validation, and the PR body shows full-suite Docker runs on both Lucee 7 (4494/12/0, the 12 being the tolerated testClientSpec baseline) and Adobe 2023 (4504/1/1, both pre-existing on develop with this PR's changes stashed). The new framework code uses no risky idioms — script-context lock already shipped at sql.cfc:1420, the spec finally blocks contain no loops (Invariant 12), and the new catch writes only struct keys (Invariant 11).

Tests

  • Good coverage where it counts: the memo describe in renderingSpec.cfc:946-996 covers memoization, memo reuse, both lookup directions, deterministic duplicate-text resolution, and the throw paths; contentSpec.cfc:97-108 adds the first coverage of the grouped-partial branch (regression guard for the hoist); ExpandedAssociationsJoinMemoSpec.cfc pins the fill-once marker with a careful finally that restores the real metadata by dropping the marker and re-expanding.
  • Nit (non-blocking): the C13 logging path itself has no spec — nothing provokes an unreadable column to assert the warning is written. I accept the rationale that the failing exception types are engine-specific (it's why the catch (any) was deliberately kept), and the log call is wrapped best-effort so the worst failure mode is a silent no-log, identical to today's behavior. If a portable way to fake an unreadable column surfaces later, a spec would be welcome.

Commits

  • All three commits use valid types (fix, fix, test), are DCO-signed, and explain the "why."
  • Nit: the header of 4508a27 (fix(controller): memoize $getStatusCodes, hoist column tokenization, log $includeFile blanked columns) is 101 characters — one over commitlint's header-max-length of 100. Not blocking because the repo squash-merges and CI lints only the PR title (.github/workflows/pr.yml:26), which at 88 chars is valid and becomes the landing subject — but worth trimming if you rebase for any other reason.

Docs

Changelog fragments are present and correctly shaped (changelog.d/*.fixed.md × 2, *.performance.md × 1 — valid types, complete bullet lines, no direct CHANGELOG.md edit). No public API changed, so no guides update is required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant