touch updates two columns when you name one
touch looks like the simplest method in Active Record, and three of its four behaviours are
things people get wrong in code review. Here's what it actually does, measured on Rails 8.1.4 by
counting queries and logging callbacks.
One query, and exactly one
survey.touch
# UPDATE "surveys" SET "updated_at" = ? WHERE "surveys"."id" = ?
That's the whole statement. No SELECT first, no reload after, no transaction beyond the implicit one. Which is the point of the method: it's the cheapest way to move a timestamp, and moving a timestamp is how Rails invalidates caches.
cache_key_with_version is built from the class, the id and updated_at, so every fragment keyed
on a record changes key the moment that column moves. Touch a parent and every cached view of it
is stale, at the cost of one UPDATE and without instantiating anything.
The named column is in addition, not instead
This is the one that surprises everyone.
survey.touch(:created_at)
# the UPDATE sets: ["updated_at", "created_at"]
You asked for created_at. You got updated_at as well. The argument adds columns to the write;
it doesn't replace the default one.
Most of the time that's harmless and occasionally it's a bug you'll spend an afternoon on, because
a touch(:last_seen_at) you added to avoid churning updated_at churns it anyway, and every cache
key in the app changes on every page view.
If you genuinely want one column and not the timestamp, update_column(:last_seen_at, Time.current)
is the method that does what touch(:last_seen_at) looks like it does.
after_save does not fire
survey.touch
# callbacks that ran: ["after_touch", "after_commit"]
after_touch and after_commit. Not after_save, not after_update, and no before_save
either.
That matters because of where people put cache invalidation. If you have an after_save that
clears a Redis key or reindexes a search document, it does not run on a touch, and touches happen
all over an app with touch: true associations in it. The record's updated_at moved, the
fragment cache noticed, your external index did not.
after_touch exists for exactly this and is the callback to use when the work has to happen on
both paths. Rails also gives you after_commit on: :update, which does fire here, since a touch
commits like anything else.
It skips validations, and skips your changes too
survey.title = nil # the model validates presence of :title
survey.valid? # => false
survey.touch # => true
Survey.find(survey.id).title # => "t", the old value
Two things in one. Touch doesn't validate, so an invalid record touches happily. And touch doesn't persist your in-memory changes either, because the UPDATE it builds contains timestamps and nothing else.
So the nil title is still sitting unsaved on the object, the database is untouched apart from
the timestamp, and the method returned true. Nothing is wrong here, but "I called touch and my
change didn't save" is a reasonable thing to be confused by once.
On an unsaved record it refuses outright: Survey.new.touch raises
ActiveRecord::ActiveRecordError.
touch: true, and what it costs
class Question < ApplicationRecord
belongs_to :survey, touch: true
end
question.touch
# 2 queries, updating ["questions", "surveys"]
One UPDATE per table, which is the cheapest possible cascade and still a cascade. Worth knowing for two reasons.
It multiplies. A chain of touch: true up three levels is three UPDATEs on every child write, on
rows that other requests are probably also touching, which is a contention pattern rather than a
performance one. The symptom is lock waits on a parent row under load, not slow queries.
And it fires on saves too, not just explicit touches. touch: true means every question.save
moves the survey's timestamp, which is usually what you wanted and is worth being deliberate
about, because it's also every bulk import you ever run.
What this page doesn't cover
No counter caches. They interact with touch: true in ways worth knowing and the combination
wasn't measured here.
Nothing above ran against a real cache. The cache_key_with_version argument is the reason the
method exists and it's described rather than demonstrated, so there's no measurement of what a
touch does to a fragment cache hit rate. And the lock contention named in the last section is
reasoned from the query shape rather than observed: that one needs a load test and a parent row
with real traffic on it.
Comments
No comments yet. Be the first.