chore(rails): upgrade to rails 8.1 - #8558
Conversation
d9a6c0c to
2f1154f
Compare
There was a problem hiding this comment.
Pull request overview
Upgrades the app from Rails 8.0.5.1 to 8.1.3.1 (including config.load_defaults 8.1) and applies compatibility fixes needed to keep the app booting and tests stable—most notably around the calculated_attributes gem and a Rails 8.1 head double-render behavior change.
Changes:
- Bump Rails to
~> 8.1.0and update lockfile to Rails 8.1.3.1, withconfig.load_defaults 8.1. - Update
calculated_attributesto a Rails-8.1-compatible ref and add an initializer to neutralize the upstreamRelation#calculateregression; update leaderboard ordering to use the underlying calculated SQL expression. - Adjust affected controller/spec behavior uncovered during the bump (Devise registration flow ordering, controller spec params, and a flaky
.lastlookup in a service spec).
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
Gemfile |
Updates Rails dependency to ~> 8.1.0 and bumps calculated_attributes ref with explanatory notes. |
Gemfile.lock |
Locks Rails 8.1.3.1 and updated dependency graph (including calculated_attributes 1.2.0). |
config/application.rb |
Moves config.load_defaults from 8.0 to 8.1. |
config/initializers/calculated_attributes_patch.rb |
Removes the gem’s problematic Relation#calculate override on Rails boot. |
app/models/application_record.rb |
Adds calculated_expression helper to order by the calculated SQL expression instead of alias. |
app/models/course/group.rb |
Updates leaderboard ordering scopes to use calculated_expression(...). |
app/models/course_user.rb |
Updates leaderboard ordering scopes to use calculated_expression(...). |
app/controllers/user/registrations_controller.rb |
Reorders enrol-course validation before Devise super to avoid Rails 8.1 double-render errors and transaction pitfalls. |
spec/controllers/course/assessment/question/rubric_based_responses_controller_spec.rb |
Avoids passing nil where Rails 8.1 param encoding now preserves it. |
spec/services/course/assessment/answer/ai_generated_post_service_spec.rb |
Avoids global .last by scoping the lookup to the relevant association. |
Suppressed comments (1)
app/controllers/user/registrations_controller.rb:70
- Since
enrol_coursewas already loaded/validated, prefer using it directly when creating the enrol request (rather than re-readinginvitation_params[:enrol_course_id]) and useresourcefor clarity. This keeps the creation consistent with the earlier validation and avoids accidental drift if the params change.
user: @user,
course_id: invitation_params[:enrol_course_id],
creator: @user,
updater: @user
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
2f1154f to
c6559ce
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
app/controllers/user/registrations_controller.rb:72
- Inside the transaction,
Course::EnrolRequest.create!uses@userandinvitation_params[:enrol_course_id]even though the validatedresourceandenrol_courseare already available. Using the validated objects avoids relying on controller instance variable conventions and ensures the enrol request always points at the course that was checked/published/enrollable.
if resource.persisted? && enrol_course
@enrol_request = Course::EnrolRequest.create!(
user: @user,
course_id: invitation_params[:enrol_course_id],
creator: @user,
updater: @user
)
c6559ce to
eb8f077
Compare
Summary
Bumps Rails 8.0.5.1 → 8.1.3.1 and moves
config.load_defaultsto 8.1. Ruby stays on 3.3.5(3.4 is a separate PR).
Taking the framework defaults in the same PR is deliberate: it surfaces any behaviour change on
staging now, rather than deferring it into a later deploy that would have to be debugged on its own.
All seven of the 8.1 defaults were audited against the codebase first — see §4.
All four application-code items flagged in the pre-bump audit landed as verified no-ops. The work
that actually turned up here was found during the bump, by the test suite and by a full CHANGELOG
sweep — not by the release-notes guide.
2.
calculated_attributes— the one genuine trapThe checklist had this filed as a routine ref bump ("verify it dispatches 8.1 patches"). It was two
separate problems.
2a. The old pin is a hard boot failure on 8.1, not a misbehaviour
The gem requires its patch file by exact Rails version:
v1.1.1 ships
rails_7_0/7_1/7_2/8_0_patches.rband no 8.1 file →LoadErrorat boot. Its gemspec(
activerecord >= 7.0.0, < 9) resolves happily, so Bundler gives no warning. This would have surfacedonly as a dead deploy.
2b. v1.2.0 — the ref we must move to — carries a regression that breaks us twice
Upstream commit
9cfea61("Support.counton relations that have previously hadcalculatedapplied") adds an override of
ActiveRecord::Relation#calculateplaced after a bareprivate:Wrong visibility → infinite recursion.
calculateis public API, andActiveRecord::Queryingdelegates it via
delegate :calculate, ..., to: :all. Demoted to private, the delegation cannotdispatch, so the call falls to
Relation#method_missing, which delegates back to the class:Querying#calculate → all.calculate → method_missing → …⇒SystemStackError. This broke ~30 specs.Clearing
select_valuescorrupts the relation. The override doesself.select_values = []in place. Any
counton the relation therefore wipes the projections for subsequent use —the query that actually failed was not the count but the record load after it, which came back as
SELECT course_groups.*with anORDER BYstill naming a calculated alias ⇒PG::UndefinedColumn.No clean ref exists: the regression (
9cfea61, 2026-05-14) predates the 8.1-support commit(
464fd11, 2026-05-29), so every ref carryingrails_8_1_patches.rbalso carries the bug.2c. Fix —
config/initializers/calculated_attributes_patch.rbRemoves the override so stock
ActiveRecord::Calculations#calculateis used again. Guarded onsource_location, so a future gem version that drops or relocates the override degrades this to ano-op rather than silently removing a fixed implementation.
With this patch, v1.2.0 is behaviourally identical to the v1.1.1 running in production.
Stock
calculatewas verified to handle every aggregate shape on a calculated relation — plaincount,limit/offsetcount,distinctcount,count(:id),sum, andcount-then-to_a(the sequence the override corrupted). Plain
counteven emits a cleanSELECT COUNT(*)with nocalculated subqueries, so there is no performance cost either.
Only two production call sites aggregate over a calculated relation —
System Admin → Coursesandits per-instance twin, both
.countthen.map(&:id). Both verified. They are precisely what theoverride would have broken: the in-place wipe would have left the following load without projections,
degrading the page to a silent N+1.
privatelooks unintentional and is not 8.1-specific.2d. Decoupling the leaderboards from
select_valuesIndependently of the patch, the four leaderboard scopes now order by the underlying SQL expression
rather than by the aliased column, via
ApplicationRecord.calculated_expression:The expression is derived from the same lambda that defines the projection, so the two cannot drift.
Sort Key: ((SubPlan 1)) DESCreuses the same SubPlan as the select list (verified withEXPLAIN).gem's broken override active.
This means a future gem version that fixes only the visibility bug would leave us with a slow path
(values no longer selected ⇒ per-record lazy re-query) rather than a broken one.
3. Application-code changes forced by 8.1
3a.
headnow raisesDoubleRenderError— and it exposed a real bugRails 8.1 adds one line to
ActionController::Metal#head:On 8.0,
headafter a render silently overwrote the response; on 8.1 it raises.Eight controller methods call both
superandhead. The sevendestroyactions are safe — theirsuperisCourse::Assessment::Question::Controller#destroy, which only callsflag_assessment_not_synced_with_koditsuand never renders. The exception isUser::RegistrationsController#create, whosesuperis Devise'screate, rendering viarespond_with.The fix hoists the
enrol_coursevalidation abovesuper, which is independently correct: anon-local
returnout of a transaction block commits it (Rails 7.1+), so the previous orderingcreated the user account and then returned 404/403, leaving an orphaned registration. The specs only
ever asserted status codes, so no expectation changed.
3b. Controller-test param encoding now preserves
nilnil.to_query("key")returnskeyinstead ofkey=(ActiveSupport CHANGELOG: "preventing roundtripping with
Rack::Utils.parse_nested_query"). A spec passingexplanation: niltherefore reachesthe controller as
nilrather than"", hitting theNOT NULLconstraint oncourse_assessment_question_rubric_based_response_criterions.explanation.Spec artifact, not a product bug — verified against the client types: on the question side
QuestionRubricGradeData.explanationis a plainstring; only the answer side allows null. Realclients never send null here. Spec now sends
'', which is what the form posts.4. Framework defaults
config.load_defaultsis moved to 8.1 in this PR, so any behaviour change surfaces on staging nowrather than being deferred into a later, separately-debugged deploy.
bin/rails app:updatewas not run wholesale — it rewritesbin/,config/boot.rband everyenvironment file for no benefit here. Its one artefact,
config/initializers/new_framework_defaults_8_1.rb, is deliberately not committed: every one ofits options ships commented out, so the file is pure comments and adds no behaviour.
The complete 8.1 delta — seven settings, not six
load_defaults "8.1"sets seventhings; the template documents only six. Read the source, not the template:
yjit = !Rails.env.local?load_defaults 7.2already setyjit = trueunconditionally (configuration.rb:325), so production has had YJIT all along; 8.1 only narrows it, switching it off in dev/test — which is what Rails intends, since reloading and mocking make YJIT unhelpful there. Moot locally anyway: this Ruby build has noRubyVM::YJIT.escape_json_responses = falseescape_js_separators_in_json = falseaction_on_path_relative_redirect = :raiseredirect_tosites audited: 22 use named path helpers (always/…); the only dynamic one,attachment_references_controller.rb:24, redirects toFileUploader#url, a presignedhttps://…S3 URL (or a/uploads/…path) and already passesallow_other_host: true. Nothing path-relative. (Theredirect_tocalls underapp/jobs/areTrackableJob's own method, not the controller one.)raise_on_missing_required_finder_order_columns = trueload_defaults 8.1includes it. The audit found all 207 concrete models have order columns, so it upgrades an unreachable deprecation into an unreachable error.render_tracker = :rubyremove_hidden_field_autocomplete = trueautocomplete="off"from helper-generated hidden inputs. Only 8 server-rendered form views exist (simple_form_for, all under assessment question bundles/groups); everything else is the React SPA.The two JSON-escaping options are already no-ops here — because of Yajl
An earlier draft of this PR warned that flipping
load_defaults 8.1would stop the JSON rendererescaping HTML entities and U+2028/9, and called for a security review. That warning was wrong, and
the reason is worth recording.
config/application.rb:11doesrequire 'yajl/json_gem', which replaces the JSON gem's encoder withYajl's C implementation. That bypasses ActiveSupport's escaping entirely:
Both response paths go through Yajl, so neither is affected by the flag:
render json:(315 sites) — the renderer'sescape: falseoption is simply ignored by Yajl;Jbuilder#target!calls plain@attributes.to_json, without options.Verified end-to-end through the real controller renderer:
ApplicationController.render(json: …)produces byte-identical output with
escape_json_responsesset totrueand tofalse, and thebody carries a literal U+2028 (bytes
E2 80 A8) today, not an escape sequence — soescape_js_separators_in_jsonis inert as well.There is nothing to test for these two options, on staging or anywhere. The app has been emitting
unescaped JSON since Yajl was adopted. That is safe here because of the architecture, not because of
Rails: this is an API + React SPA, JSON is only ever fetched and
JSON.parsed, and there is noserver-rendered JSON embedded in
<script>tags and no JSONP endpoint (Rails preserves escapingwhen a
callbackis present anyway). Should anyone later add server-rendered inline JSON, escapingmust be handled at that call site — the framework default will not do it.
We are not adopting
raise_on_missing_required_finder_order_columns: every table already has aprimary key by convention, so it guards against something that should not arise.
5. Full CHANGELOG sweep
Why it was needed. The pre-bump audit read the release-notes guide. That guide does not mention
the
head→DoubleRenderErrorchange at all — its Action Pack section has only Removals,Deprecations and Notable changes, and this shipped with no deprecation cycle. Per-framework CHANGELOGs
are a strict superset of the guide. Do the CHANGELOG sweep first on the 8.2 hop.
Extracted the complete 8.0 → 8.1 delta from all 12 component CHANGELOGs — 353 entries. Read every
headline for
activerecord(103),activesupport(63) andactionpack(47); keyword-filtered therest.
Empirical cross-check: across ~5,000 examples the only deprecation the app emits is a Devise
routing one. Nothing else trips an 8.1 path.
Verified clear against the codebase
update_allwithDISTINCT/WITH/WITH RECURSIVEdeprecated:class_nameinvalid on polymorphicbelongs_toEnumerable#solereturns the full tuple:azureservice removedWatch after deploy (no action taken)
remote_ipno longer ignores X-Forwarded-For entries carrying port info, and link-local rangeswere added to the default proxy list. Feeds
current_sign_in_ip, ActionCable and lograge.Exception.schema.rbcolumns are now sorted alphabetically — the next migration will produce a large,noisy schema diff. Land that reordering on its own or it will bury a real change.
Known deprecations, deliberately out of scope
devise_for(
resource received a hash argument …). Devise's internals, not our routes. Removed in 8.2; Devise5.0.4 exists and
devise-multi_emailallows it, but a major Devise bump is its own PR with its ownauth risk.
:unprocessable_entity→:unprocessable_content(19 usages). Removal is"a future Rack", not 8.1/8.2 — a mechanical rename for its own PR.
6. Verification
Automated. Run in halves against a freshly reset test DB:
spec/models spec/services spec/libraries— 3291 examples, 0 failuresspec/controllers spec/jobs spec/mailers spec/notifiers spec/helpers spec/uploaders spec/components—1703 examples; after the fixes above, the only remaining failures are two order-dependent flakes
(below). With seed 12345, one.
leaderboards_controller+spec/helpers—114 examples, 0 failures;
course/group+course_user— 93 examples, 0 failures, bothwith and without the
calculated_attributespatch.Two pre-existing flakes, neither caused by this PR:
koditsu/submissions_concern_spec.rb:84— fails identically on 8.0; passes in isolation.ai_generated_post_service_spec.rb(:123,:185) — retrieves its just-created record viaCourse::Discussion::Post.last. The model hasdefault_scope { ordered_by_created_at.with_creator },so
.lastmeans maxcreated_at, not highestid. Meanwhilespec/models/concerns/acts_as_contract_spec.rb:67doestravel_to(2.minutes.from_now) { post.save! }and, because the suite commits, that future-dated rowwins
.lastfor every spec running afterwards. Reproduced deterministically on a clean DB:Fix: Have the spec query
.lastscoped to the individual submission question instead of globally.Manual, on staging. Leaderboards verified working, including the tiebreaker — courses where the
whole visible ranking is decided by it (metric constant, timestamp strictly ascending down the page).
Other exploratory testing surfaced no issues.
7. Not in this PR
— now included in this PR (see §4).config.load_defaults 8.1:unprocessable_contentrename — own PRs.enqueue_after_transaction_commit— written and mutation-verified during theaudit but held back; its value is pinning behaviour against the 8.2 default flip.
Sources
actionpack/activerecord/activesupport8.1.3.1 (the authoritative source — see §5)ecaf6c9(v1.1.1),9cfea61(regression),464fd11(8.1 support),992fdd9(v1.2.0)