Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run-many -t test:types -p ghost |
❌ Failed | 8s | View ↗ |
nx run ghost:test:ci:integration |
✅ Succeeded | 4m 3s | View ↗ |
nx run ghost:test:integration |
✅ Succeeded | 3m 49s | View ↗ |
nx run ghost:test:ci:e2e |
✅ Succeeded | 3m 43s | View ↗ |
nx run ghost:test:e2e |
✅ Succeeded | 2m 54s | View ↗ |
nx run ghost:test:legacy |
✅ Succeeded | 3m 9s | View ↗ |
nx run ghost-monorepo:lint:boundaries |
✅ Succeeded | 29s | View ↗ |
nx run-many -t test:unit -p ghost |
✅ Succeeded | 39s | View ↗ |
Additional runs (4) |
✅ Succeeded | ... | View ↗ |
💡 Dealing with memory or CPU issues? See memory and CPU details with the resource usage add-on ↗.
☁️ Nx Cloud last updated this comment at 2026-09-18 09:07:09 UTC
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #30722 +/- ##
==========================================
+ Coverage 67.65% 67.76% +0.10%
==========================================
Files 1677 1683 +6
Lines 60555 60743 +188
Branches 10475 10481 +6
==========================================
+ Hits 40968 41160 +192
+ Misses 17268 17264 -4
Partials 2319 2319
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
c8c3b7b to
ba52337
Compare
53bcb20 to
97f5b22
Compare
no ref
Config is fully loaded by the time loadNconf() returns, and nothing in Ghost
writes to it afterwards, but nconf doesn't know that. Every config.get()
walks all nine stores, and for an object-valued key it collects a hit from
each one and deep-merges them - on every single call. There are 353 get call
sites in core, some on per-request paths.
freeze() makes the instance read-only and memoises get() by key. Because each
cached value is whatever nconf itself returned for that key, a frozen lookup
can't disagree with an unfrozen one, and with writes rejected a cache entry
can't go stale - so there's no invalidation to get wrong. Measured against the
real config: get('database') 3526ns -> 15ns, getSiteUrl() 1276ns -> 63ns.
The mutators throw rather than no-op. nconf's own readOnly flag would have
been the obvious lever, but Provider._execute *skips* read-only stores for a
destructive action and returns undefined, which turns a config write into a
silent failure instead of a loud one.
loadNconf freezes as its last step rather than leaving it to boot, so a write
during boot fails loudly instead of quietly working. Nothing in the tree does
that today - the last two runtime writes were the asset hash, moved into the
asset hash service - so this holds the line rather than changing behaviour.
It's skipped under test, where the suites rewrite config between cases on
purpose, and optimization.freezeConfig turns it off entirely.
A keyless get() is left uncached. nconf's env store holds the whole
environment, so the merged tree materialises it - on a boot here, 139 of 194
top-level keys came from env rather than config - and caching that would mean
a long-lived object holding every environment variable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011s9ZdgxjVXMr7YTh4UtmtH
no ref optimization.freezeConfig was added as a kill switch for freezing config, on the grounds that freezing makes config throw in production. On reflection it earns its config surface: nothing in the tree writes to config after load, so the flag only exists to re-enable a behaviour we don't want back, and a config key to control config's own loading is an awkward thing to reason about. defaults.json is now untouched by this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011s9ZdgxjVXMr7YTh4UtmtH
…ects
no ref
Two problems with the freeze helper, both found in review.
required() was in the mutator list, but it isn't one: nconf implements it as a
read - it calls get() for each key and throws when one is missing. Blocking it
would have made post-load config validation throw "Config is frozen" in every
non-test environment. It's out of the list, with a test covering both the
passing and missing-key cases while frozen.
Object-valued reads were cached and handed back by reference, so a caller that
mutated what it got rewrote the cache for every later reader. configure() in
data/db/connection.js does exactly that - it assembles the knex config by
mutating the object it's passed, which is config.get('database'). That made the
two ways of reading a key disagree:
get('database').pool -> {} (whatever knex bootstrap bolted on)
get('database:pool') -> undefined (no store has it)
Cached values are now deep-frozen, so a mutation raises a TypeError naming the
site instead of silently corrupting config, and configure() clones before
mutating. The clone is worth having on its own: mutating the object config
handed back was already writing through to nconf's stores for nested keys,
since nconf's merge shares subtrees by reference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011s9ZdgxjVXMr7YTh4UtmtH
12b416a to
f8de8e6
Compare
no ref
AdapterCacheRedis folds the adapter's `ttl` into `clusterConfig.options` by
assigning into the object it was handed. That object is the adapter's slice of
config itself — resolveAdapterOptions returns the config subtree directly, and
its other branch only shallow-copies, so `clusterConfig` is the config's own
object either way. Once config is frozen the assignment throws:
TypeError: Cannot assign to read only property 'ttl' of object '#<Object>'
at new AdapterCacheRedis (AdapterCacheRedis.js:44)
at AdapterManager.getAdapter (adapter-manager.ts)
at core/server/lib/image/index.js:11
which kills Ghost during init. Found by booting the Pro image: freeze is skipped
under NODE_ENV=testing so CI never sees it, and config.development.json
configures no adapters at all, so a development boot never constructs this
adapter either. It needs a Redis cache adapter with clusterConfig — which is
production Pro, and nothing else.
The ttl is now folded into a derived clusterConfig rather than assigned into
config. That is worth doing on its own: the old write went through into the
subtree every later reader of `adapters:...` sees, and changed the key the
adapter manager builds by stringifying this same config.
The three new tests freeze their input, because freeze is skipped under test and
without it the mutation passes unnoticed. Two of them fail against the old
constructor; the third guards the path where no ttl is configured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4KZATm5a3QU1xBM4Zntg4
Benchmarked on the Pro image — and a boot-breaking bug, now fixed on this branchTwo things for this PR. The bug first, because it blocks merge.
|
| Redis adapter configured? | Freeze active? | ||
|---|---|---|---|
| Ghost CI | n/a | no (NODE_ENV=testing) |
passes |
| Development boot | no adapters block at all |
yes | passes |
| Pro image | adapters.cache.Redis.clusterConfig |
yes | throws |
The ttl now goes into a derived clusterConfig instead of being assigned into config. Worth having regardless of the freeze: the old write went through into the subtree every later reader of adapters:… sees, and mutated the very object the adapter manager stringifies for its instance key.
Three tests added; they freeze their own input, because freeze is skipped under test and without that they pass against the broken constructor. Two fail against the old code. Also verified against the real path — getAdapter('cache:imageSizes') under a frozen development config with a Redis cluster block reproduces the exact production error without the fix and constructs cleanly with it.
I grepped for sibling sites. The two public-config hits write to their own fresh objects, not config's; no other adapter mutates its config argument. Treat that as "none found" rather than "none exist", since only Pro config exercises most adapters.
The freeze is the single biggest CPU win in the current perf stack
Measured on the Moya Pro benchmark (ghost-perf, 5,000-request weighted frontend mix, fixed-work CPU time), against an integration branch of this PR plus #30704, #30753, #30874, #30886 and #30887 versus main at the merge base. Two pairs.
nconf self time and allocation, reproduced independently in both baselines:
| baseline run 1 | baseline run 2 | with the freeze | |
|---|---|---|---|
nconf CPU self time |
4,327 ms (3.1% of busy) | 4,659 ms (3.5%) | absent from the top 25 (<578 ms) |
nconf allocated bytes |
569.8 MB (2.1%) | 571.1 MB (2.1%) | absent from the top 25 (<73 MB) |
The combined branch came in at −11.5% CPU for the same fixed workload (pairs: −13.3%, −9.6%), with the two arms not overlapping — the worst integration run still beat the best baseline by 9.6%. nconf is the largest single contributor to that.
Worth noting against this PR's own framing: it describes the absolute effect as "small — a few hundred gets per request is well under a millisecond". Aggregated over a real workload it was 3.1–3.5% of total busy CPU and ~570 MB of allocation per 5,000 requests. The immutability argument stands on its own, but the performance case is stronger than the PR claims.
No boot-memory cost. I flagged boot.rss as a possible small regression after one pair (+2.73 MiB, just outside the noise floor); the repeat put it at +0.08 MiB. It was noise. boot.wall_mean_ms and boot.heap_used_bytes also showed no reproducible change — both flipped sign between pairs.
For context on reading any of these numbers: load.cpu_s on this benchmark carries a 4.2% noise floor (pooled within-version sd across 20 scheduled runs), about half of it runner speed drift that runner.calibration_ms in each result.json can correct for.
Generated by Claude Code
no ref
loggingrc.js built the logging config by mutating the object config.get('logging')
handed back. Frozen, those writes do not land - and this file is a plain
CommonJS module body, so they fail *silently* rather than throwing: sloppy mode
ignores a write to a frozen object. @tryghost/logging then wraps the require in
`try { ... } catch { loggingConfig = {} }`, so the one error that did escape
(reading .metadata off the metrics property that never got assigned) was
swallowed too, and the logger started with no configuration at all.
Measured on a development boot, before and after:
path ghost/core/ -> ghost/core/content/logs/
domain localhost -> the configured site url
metadata {} -> {version: ...}
So log files went to the wrong directory and lost their version and domain
metadata, with nothing reported anywhere.
Built as a new object instead. The `metrics` block is spread for the same
reason: config.get('logging:metrics') is frozen too.
Worth recording for the rest of this PR: "freezing makes a stray write a loud
TypeError" only holds in strict-mode code. AdapterCacheRedis threw because the
write sits in a class body, which is always strict. A plain CommonJS module body
or a non-class function silently no-ops, which is how this went unnoticed. Most
of core/**/*.js is sloppy CommonJS, so a mutation there turns into config that
quietly is not applied rather than an error naming the site.
Found by instrumenting config.get to record mutation attempts on what it returns
and running the test suites, rather than waiting for something to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4KZATm5a3QU1xBM4Zntg4
no ref
MigratorConfig.js hands knex-migrator `config.get('database')` directly, and
knex-migrator mutates what it is given. lib/database.js, for a mysql2 client:
options.connection.timezone = options.connection.timezone || 'Z';
options.connection.charset = options.connection.charset || 'utf8mb4';
options.connection.decimalNumbers = true;
delete options.connection.filename;
Frozen, none of those land. Its module body is sloppy-mode CommonJS, so the
writes fail silently rather than throwing: verified against the real frozen
config, where the emulated sequence returns without error and leaves timezone
undefined instead of 'Z'.
The migrator would then run against a connection without the UTC timezone it
means to force, which for migrations that write timestamps is a correctness
problem rather than a cosmetic one. Nothing reports it. This path is reached
both from boot (DatabaseStateManager.getState -> isDatabaseOK) and from the
knex-migrator CLI, which loads this same file.
Handing over a deep clone fixes both entry points at once: the mutations land on
the copy, and config itself is left alone. Confirmed timezone now resolves to
'Z' and decimalNumbers to true, with config.get('database:connection:timezone')
still undefined afterwards.
Found by instrumenting config.get to record mutation attempts on the objects it
returns and running the unit, e2e and integration suites - 823 recorded attempts
across 40 distinct sites, of which this was the highest volume.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4KZATm5a3QU1xBM4Zntg4
Full audit of config mutation under the freeze — 3 production bugs, and a premise that needs revisitingFollowing the Method: instrumented Coverage: 823 recorded mutation attempts across 40 distinct sites. Production bugs found (all three now fixed on this branch)
#2 —
Log files to the wrong directory, version and domain metadata gone, no error anywhere. #3 — the highest-volume site in the whole audit. options.connection.timezone = options.connection.timezone || 'Z';
options.connection.charset = options.connection.charset || 'utf8mb4';
options.connection.decimalNumbers = true;
delete options.connection.filename;Verified against the real frozen config: the sequence returns without error and leaves Not a bug
Test-only sitesThe remaining ~30 sites are all in The premise that needs revisitingThis PR's design rests on mutations failing loudly:
That only holds in strict-mode code.
So for a large share of the codebase, the freeze converts "config write works" into "config write silently does nothing". That's arguably worse than either the old behaviour or a loud failure, and it's exactly why 2 of the 3 bugs above were invisible to CI, to a development boot, and to a Pro boot. Worth deciding explicitly before this lands — not necessarily blocking, but the justification for deep-freezing over cloning-on-read is weaker than the PR currently states. Options as I see them: keep the freeze and accept silent no-ops outside strict code; add a dev/CI-only recording mode like the probe I used, so these surface in testing; or clone on read for object values, trading the performance win for a guarantee that holds everywhere. Verification after the two new fixes: e2e 394 passing, and the logging and migrator values confirmed correct under a frozen config. Generated by Claude Code |
|
Superseded. Closing in favour of #31326 (merged) and #30936. The two bugs this branch found shipped in #31326, reimplemented rather than cherry-picked: The freeze mechanism is replaced in #30936. Rather than memoising Two findings from here that were worth keeping:
No performance left behind: Also correcting a claim I made on the way, in case it's read later: |

Follows #30721, now merged — that PR moved the global asset hash into the asset hash service, removing the last two runtime
config.setcalls, which this one depends on. Rebased onto main, so the diff here is just the freeze itself.Why are you making it?
Config is fully loaded by the time
loadNconf()returns, and nothing in Ghost writes to it afterwards — but nconf doesn't know that, so it re-derives every answer from scratch.Provider._executewalks all nine stores on everyget(), and for an object-valued key it collects a hit from each store and deep-merges them (common.mergebuilds a freshMemorystore and recursively re-merges the subtree) — every single call. There are 353config.getcall sites incore/, some on per-request paths.Measured against the real config:
get('database')get('url')get('paths:contentPath')getSiteUrl()(several gets)In absolute terms this is small — a few hundred gets per request is well under a millisecond — so the immutability guarantee is at least as much of the point as the speed.
What does it do?
freeze()makes the instance read-only and memoisesget()by key.The key property: every cached value is whatever nconf itself returned for that key. So a frozen lookup can't disagree with an unfrozen one — there's no second implementation of nconf's resolution order to keep correct — and with writes rejected, a cache entry can't go stale, so there's no invalidation logic at all.
Details that matter:
readOnlyflag on each store looks like the obvious lever, but_executeskips read-only stores for a destructive action and returnsundefined— so flipping it would turn a config write into a silent failure rather than a loud one.required()is not a mutator. Despite sitting alongside them on the provider, nconf implements it as a read —get()per key, throw on missing — so it stays usable while frozen. There's a comment onMUTATORSrecording why it's deliberately absent.TypeErrornaming the site. Chosen over cloning deliberately: cloning on read undoes the entire performance win, and cloning once on write still hands the same object to everyone.get()is left uncached. nconf's env store holds the entire environment, so the whole-tree merge materialises it: on a boot here, 139 of 194 top-level keys came from env rather than config,AWS_SECRET_ACCESS_KEYincluded. Caching that would mean a long-lived object holding every env var. Nothing incore/callsget()keyless anyway.One caller had to change.
configure()indata/db/connection.jsassembles the knex config by mutating the object it's handed, and it's called asconfigure(config.get('database')). Under caching that made the two ways of reading a key disagree —get('database').pool→{}whileget('database:pool')→undefined. It now clones first, which is worth having regardless: mutating the object config handed back was already writing through into nconf's stores for nested keys, since nconf'smergeshares subtrees by reference.Where it freezes:
loadNconffreezes as its last step, rather than leaving it to boot. A write during boot then fails loudly instead of quietly working. Nothing in the tree does that today, so this holds the line rather than changing behaviour. Skipped under test, where the suites rewrite config between cases on purpose viaconfigUtils.No opt-out flag: nothing writes to config after load, so a switch would only exist to re-enable a behaviour we don't want back. The diff touches no config defaults.
Why is this something Ghost users or developers need?
No user-facing change. For developers, config's lifecycle becomes honest and enforced — loaded once, read-only thereafter — so "is this value still the one I read at startup?" stops being a question you have to answer by reading the whole codebase. Any future attempt to use config as mutable runtime state fails immediately, at the write, with a message naming the key.
Testing
test/unit/shared/config/freeze.test.ts: cache hits, cached misses, keylessget()staying uncached, nested keys resolving through the store chain, unfreeze dropping the cache, all 9 mutators throwing,required()still validating while frozen, a rejected write leaving config unchanged, and two covering cached-object immutability.deepFreeze, and one reproduces the exactget('database').poolvsget('database:pool')divergence.GHOST_CI_SHUTDOWN_AFTER_BOOT=1) with freeze-at-load and deep-frozen config: exit 0, zero frozen-write errors and zeroTypeErrors. This matters because freeze is skipped under test, so boot is the only thing that exercises the frozen path end to end.set()throws, the rejected write left config unchanged);NODE_ENV=testingdoes not freeze and writes still work.adapter-managertest fixtures build aConfigInstanceby hand and now callbindFreezetoo, so they still satisfy the widened type.f8de8e6, Typecheck included.Known gap
Freeze is skipped under test, so CI doesn't exercise the frozen path — a mutation on a rarely-hit request path would only surface in production. That's how the
configure()case above got through review in the first place. Worth deciding separately whether the freeze path deserves coverage; I'd keep that out of this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_011s9ZdgxjVXMr7YTh4UtmtH