LaunchKit
← All posts
· 5 min read · by The LaunchKit team · 1 views

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.

A touch traced through what it writes and what it fires. Calling touch sends a single UPDATE statement setting updated_at for one row by id, with no SELECT and no reload. Calling touch with the argument created_at sends an UPDATE that sets both created_at and updated_at, because the named column is added to the default rather than replacing it. The callbacks that run are after_touch and after_commit; after_save is shown struck out, because it does not fire, which means a cache invalidation written as an after_save callback is skipped by every touch. A panel records that touch skips validations, returning true on a record whose title has been set to nil, and that the invalid value is not written either, since touch only ever writes timestamps.

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.

#rails #active-record #caching

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.