Post

Transactions, Touches, and Async Rollups in Ruby on Rails

Transactions, Touches, and Async Rollups in Ruby on Rails

Designing Consistent, Performant Derived Data

In real-world Rails applications, not all data is equal.

Some columns represent core truth — the values your business logic fundamentally depends on. Others exist for convenience, performance, or observability — counters, summaries, snapshots, and caches.

The challenge is keeping these derived fields accurate without slowing down writes or creating correctness bugs, especially when async jobs and transactions are involved.

This article explores:

  • Why naïve approaches fail
  • How Rails’ touch pattern fits into the picture
  • Multiple strategies for derived data
  • Trade-offs between sync vs async updates
  • Practical patterns that scale

1. The Core Problem

Consider a common example:

1
2
Student has_many Addresses
Student has summary fields derived from Addresses

Examples of derived data:

  • addresses_count
  • has_verified_address
  • latest_country
  • address_summary_json
  • addresses_updated_at

Whenever an Address changes, the Student should reflect that change, but:

  • We don’t want to scan all addresses on every read
  • We don’t want to recompute expensive summaries on every write
  • We don’t want async jobs to corrupt data due to retries or rollbacks

This is fundamentally a data consistency vs performance problem.


2. First Principle: Separate Core Writes from Derived Writes

A crucial design principle:

Only write core data inside the transaction. Derived data should be updated after commit.

Why?

  • Transactions can roll back
  • Jobs can retry
  • Side effects (stats, caches, summaries) should never reflect uncommitted data

❌ Anti-pattern

1
2
3
4
ActiveRecord::Base.transaction do
  address.update!(...)
  student.update!(addresses_count: student.addresses.count)
end

If this transaction retries, deadlocks, or partially fails, you risk:

  • incorrect counters
  • expensive queries inside locks
  • unnecessary contention

3. Rails touch: A Lightweight Change Signal

Rails’ touch exists for a reason.

1
2
3
class Address < ApplicationRecord
  belongs_to :student, touch: true
end

This gives you:

  • A cheap, automatic signal: “something under me changed”
  • No need to scan associations to detect changes
  • Natural compatibility with HTTP caching, fragment caching, and snapshots

What touch is good at

  • Cache invalidation
  • Change detection
  • Dependency tracking

What touch is not

  • A summary calculator
  • A counter manager
  • A guarantee that derived data is correct

Think of touch as a notification, not a computation.


4. Strategy 1: Increment / Decrement (Delta-Based Updates)

Idea

When a change happens, apply a small delta:

1
2
Address created  → +1
Address deleted  → -1

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Address < ApplicationRecord
  belongs_to :student

  after_commit :increment_counter, on: :create
  after_commit :decrement_counter, on: :destroy

  def increment_counter
    Student.update_counters(student_id, addresses_count: 1)
  end

  def decrement_counter
    Student.update_counters(student_id, addresses_count: -1)
  end
end

Pros

✅ Very fast reads ✅ No full-table scans ✅ Atomic SQL updates

Cons

❌ Hard to handle updates (what if an address becomes invalid?) ❌ Easy to double-count with job retries ❌ Requires idempotency discipline ❌ Drift accumulates over time

When to use

  • Append-only data
  • Simple counts
  • Extremely hot read paths
  • You have reconciliation jobs

5. Strategy 2: Recompute on Change (Snapshot-Based)

Idea

Every meaningful change triggers a rebuild:

1
2
3
4
5
6
7
8
9
class Address < ApplicationRecord
  belongs_to :student

  after_commit :enqueue_rollup

  def enqueue_rollup
    StudentAddressRollupJob.perform_later(student_id)
  end
end
1
2
3
4
5
6
7
8
9
10
class StudentAddressRollupJob < ApplicationJob
  def perform(student_id)
    student = Student.find(student_id)

    student.update!(
      addresses_count: student.addresses.count,
      has_verified_address: student.addresses.verified.exists?
    )
  end
end

Pros

✅ Naturally idempotent ✅ Easy to reason about ✅ Safe with retries ✅ Handles edits, deletes, complex logic

Cons

❌ More expensive ❌ Can spam jobs under burst updates ❌ Requires throttling or deduping

When to use

  • Complex derived logic
  • Edits affect prior state
  • Correctness > write performance
  • Async processing is acceptable

6. Strategy 3: Touch + Dirty Flag (Debounced Rebuild)

This is where things get interesting.

Idea

Separate change detection from work execution.

1
2
3
class Student < ApplicationRecord
  # needs_address_rollup :boolean
end
1
2
3
4
5
6
7
8
9
10
class Address < ApplicationRecord
  belongs_to :student

  after_commit :mark_student_dirty

  def mark_student_dirty
    Student.where(id: student_id)
           .update_all(needs_address_rollup: true, updated_at: Time.current)
  end
end

A worker periodically processes only dirty students:

1
2
3
4
Student.where(needs_address_rollup: true).find_each do |student|
  rebuild_address_summary(student)
  student.update!(needs_address_rollup: false)
end

Pros

✅ Coalesces bursts of updates ✅ Avoids job spam ✅ Avoids repeated recomputes ✅ Still avoids scanning associations for detection

Cons

❌ Slightly stale data ❌ Requires background sweeper ❌ More moving parts

This pattern scales extremely well.


7. Strategy 4: Versioned Touch (Modern & Powerful)

A more advanced evolution of touch.

Idea

Instead of just “something changed”, track how many times it changed.

1
# students.addresses_version :integer
1
2
3
4
5
6
class Address < ApplicationRecord
  after_commit do
    Student.where(id: student_id)
           .update_all("addresses_version = addresses_version + 1")
  end
end

Now:

  • Cache keys can include addresses_version
  • Jobs can carry the version they observed
  • Old jobs can safely no-op
1
2
3
4
5
6
def perform(student_id, version)
  student = Student.find(student_id)
  return if student.addresses_version > version

  rebuild_summary(student)
end

Pros

✅ Excellent for async safety ✅ Prevents stale writes ✅ Ideal for caches & rollups ✅ Minimal locking

Cons

❌ Slightly more complex mental model ❌ Requires discipline in usage

This pattern is underused and very effective.


8. Performance Considerations

Write amplification

  • touch updates parent rows
  • Frequent child updates → row lock contention
  • Replication lag on replicas

Mitigations:

  • Debounce updates
  • Batch imports
  • Use dirty flags or versions instead of raw touch

Async job storms

  • One change → one job does not scale
  • Prefer deduplication by (student_id)
  • Delay execution slightly to collapse bursts

Reconciliation

No matter the strategy:

Have a periodic full rebuild job

It:

  • Fixes drift
  • Detects bugs early
  • Lets you optimize aggressively elsewhere

9. A Practical Decision Matrix

Use caseRecommended strategy
Simple counterDelta or counter_cache
Editable / deletable rowsRecompute
Cache invalidationtouch
Burst-heavy writesDirty flag
Async correctnessVersioned touch
High-read systemHybrid

10. Final Takeaway

Derived data is not free — you pay either:

  • at write time (sync updates)
  • at read time (scans)
  • or in complexity (async + reconciliation)

Rails gives you powerful primitives (transactions, after_commit, touch), but architecture choices matter more than syntax.

The best systems:

  • Keep transactions small
  • Treat derived data as rebuildable
  • Use async thoughtfully
  • Accept eventual consistency where possible
  • Reconcile periodically

If you design with those principles, your app will stay both fast and correct as it grows.

This post is licensed under CC BY 4.0 by the author.