Skip to content

Added template pre-warming (Stage 2) - refs #1362 - #1393

Merged
philayres merged 1 commit into
consected:developfrom
hmsrc:prewarm-templates-1362
Aug 28, 2026
Merged

Added template pre-warming (Stage 2) - refs #1362#1393
philayres merged 1 commit into
consected:developfrom
hmsrc:prewarm-templates-1362

Conversation

@philayres

Copy link
Copy Markdown
Contributor

Summary

Stage 2 of #1362 (template pre-warming). Stage 1 (#1377) content-addressed compiled Handlebars artifacts so identical rendered content is shared across users; this closes the remaining gap - the very first login after a restart/deploy still pays the full node-CLI compile cost, and a brand-new user's first login is always cold.

Design diverges from the original phases 1/3/4 - see the full writeup posted to the issue: #1362 (comment)

Key differences from the original plan, both driven by what Stage 1 already left in place:

  • Warms one representative user per distinct (app_type, access variant), not every user x app type - the only per-user artifact left after Stage 1 is the multi-bundle, and its content depends only on the template set, not identity.
  • A shelled rake prewarm:templates spawned once at server boot, not an in-process background thread with a poll loop/quiet-period debounce. Trade-off: a mid-day generation rotation from a config change is not pre-warmed - the next request pays that cost, unchanged from today.

Phase 0 - measurement gate

Before building phases 1-4, benchmarked GET /pages/:id/template for the 1st/2nd/3rd distinct user after a simulated restart (spec/requests/pages/handlebars_compile_cost_benchmark_spec.rb, opt-in via RUN_BENCHMARKS=true, tag :benchmark):

user 1 pipe_in_out calls=2   (unavoidable first compile)
user 2 pipe_in_out calls=0
user 3 pipe_in_out calls=0

This confirms Stage 1 already eliminates the cost for every user after the first. Went ahead with Stage 2 anyway, since the first-request-per-restart and brand-new-user cases are still real.

What's added

  • PrewarmController + Prewarm::MasterTemplates - offline render harness via ActionController::Renderer, no HTTP request/session involved. Renders the master template partial for a real user with an in-memory-only app_type_id, never persisted.
  • Prewarm::Candidates - selects one representative user per distinct (app_type, access variant) from recently-signed-in, non-template, non-disabled users. The variant key is a coverage heuristic only - content addressing means an over-sharing key can only cause a missed warm, never a wrong artifact.
  • Prewarm::Runner + rake prewarm:templates - drives one warm pass, lock-guarded (zero-wait, skip-on-contention) so it's never able to block a real request, with a warm marker per combination and per-combination rescue.
  • Prewarm::Spawner - spawns the rake task as a detached child process (IO.popen + Process.detach) from the existing after_initialize block in config/initializers/handlebars_precompiler.rb, after startup_cleanup! - required ordering, since server_cache_version is shared (memcached) and startup_cleanup! deletes it on boot; spawning first would orphan every warmed artifact under a stale generation key. Never runs in the delayed_job worker or console/runner. Does not block server startup - IO.popen (no block form) returns immediately after fork/exec; the actual pass runs in a separate OS process.
  • Four new settings (PrewarmTemplatesEnabled - opt-in, forced off in test; PrewarmSignInWindowDays; PrewarmMaxVariants; PrewarmThrottleSeconds).

Payoff spec

spec/requests/pages/handlebars_prewarm_payoff_spec.rb is the spec that actually matters: after a simulated restart, a real user's first-ever request hits zero node-CLI compile calls once Prewarm::MasterTemplates has warmed their variant - paired with a negative example proving the same request does invoke the CLI when nothing warmed it first.

Security review

  • PrewarmController is not reachable over HTTP: no route points at it anywhere, and config/routes.rb ends with a catch-all (match '*path', via: :all, to: 'bad_route#not_routed'). ActionController::Renderer (the only way it's invoked) never goes through routing/middleware/sessions.
  • Its current_user/current_admin/user_signed_in? overrides are private (a public method on a controller is a dispatchable action by default in Rails - helper_method still works with private methods via send), so even an accidental future route couldn't dispatch them as actions. user_signed_in? reflects actual presence of the injected user rather than being hardcoded true.
  • The prewarm user's app_type_id is set in memory only via a duplicate load (User.find(user.id)), never saved - a warm pass can never change which app type a real user is assigned to.
  • The IO.popen argv is a fixed array (%w[bundle exec rake prewarm:templates]), not a shell string built from any input - no command injection surface.

Testing

All new/modified specs pass, plus a full re-run of the existing Stage 1 regression suite (159 examples, 0 failures) confirming no regressions:

app-scripts/headless_rspec.sh spec/models/prewarm spec/requests/pages/handlebars_prewarm_payoff_spec.rb \
  spec/helpers/handlebars_precompiler_helper_spec.rb spec/initializers/handlebars_precompiler_spec.rb \
  spec/requests/pages/handlebars_cross_user_artifact_sharing_spec.rb

Rubocop clean on all new/touched files.

Out of scope (deliberately)

Refs #1362

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.

1 participant