Zen and the Art of
File Upload Maintenance

An Inquiry into Legacies

Presentation by Uchio Kondo

Intro

INTRO
  • Think back to 200X.
  • You built your first blog app with Rails scaffold.
  • The title and body showed up on the screen.
INTRO
  • What feature do you want to build next...?
INTRO
  • Yes, file upload.
INTRO

Let's talk about file upload today

INTRO

About Me

  • Uchio Kondo / @udzura
  • SmartHR, Inc.
    • Technology Platform Division
    • Favorite SmartHR feature: Personnel Orders
    • Favorite Rust type: Cell<T>
  • Fukuoka.rb / Fukuoka.wasm
Photo of Uchio Kondo
INTRO

Today's Route

Seven chapters: Situation; Split the Problem; File Paths; History; Upgrade Blockers; Upgrade or Replace; Takeaways. File Paths and History are the main focus.
INTRO

Before We Begin

  • This talk is more a collection of questions than a single conclusion.
  • Some details have been adapted; the technical substance is unchanged.
  • Reasonable engineers may make different calls. Let's discuss them in the hallway or at the social event.
INTRO
ぜひ、廊下や懇親会でお話ししましょう!

1. Situation

1. SITUATION

Rails and ActiveStorage

  • One of the most basic requirements of a web service
  • Today, Rails has ActiveStorage
    • Introduced in Rails 5.2 (2018)
1. SITUATION

B.A. (Before ActiveStorage)

  • Before that, no "Rail" was laid for files
  • Many gems competed
    • attachment_fu
    • Paperclip (deprecated in 2018)
    • CarrierWave / Dragonfly / Refile
    • Shrine
1. SITUATION

When It Was Built

  • SmartHR's application got its first commit in February 2015
  • A world before ActiveStorage
1. SITUATION

9 Years Later

  • Migrated once from Paperclip, then about 9 years on CarrierWave
  • 32 Uploader classes · 87 mount_uploader calls
  • Up to 8 images per model
  • CarrierWave is on an older version; upgrades require compatibility work
  • Other dependencies, especially Ruby and Rails, are kept near the latest versions
1. SITUATION

2. Split the Problem

2. SPLIT THE PROBLEM

Symptoms

Fragile Path Logic, Synchronous Image Resizing, EXIF Processing, Implicit Uploads, Tangled Dependencies, Difficult Upgrades
2. SPLIT THE PROBLEM

The problems are tangled.
Let's break them down.

2. SPLIT THE PROBLEM

Start with Smaller Problems

  • “Replace CarrierWave” is too large to tackle at once
  • Separate file access, history behavior, and other dependencies
  • Choose a change small enough to implement and verify
2. SPLIT THE PROBLEM

Split the Problem into Three Qualities

Three categories: Reliability, Usability, and Maintainability. All shown in gray, without ranking.
2. SPLIT THE PROBLEM

Set Priorities Across Three Qualities

Prevent incidents first; improve customer-reported latency as a secondary focus alongside reliability work. Upgrades were initially third, with priority to rise soon based on the security situation.
2. SPLIT THE PROBLEM

Two Main Problems Today

Given our current situation, we focus on:

  1. File paths: keep saved files reachable
    • Fix fragile file paths and make them stable
  2. History: avoid needless uploads when copying records
    • Tangled history logic slows down uploads

Third, briefly: maintainability — simplify dependencies.

2. SPLIT THE PROBLEM

3. Focus on File Paths

3. FOCUS ON FILE PATHS

Why Do File Paths Matter?

  • Past incidents involved incorrect file references
  • No files were lost in these incidents
  • All these incidents were mitigated and resolved; affected customers were informed
  • Reliability comes first for HR documents
3. FOCUS ON FILE PATHS
過去の障害はすべて止血・復旧・顧客説明済みです。

Prerequisite: CarrierWave Lifecycle

Assign → cache!Temporary file
→
store!Persistent file
→
RetrieveAccess it later

On a validation retry, a Cache ID can restore the temporary file.

3. FOCUS ON FILE PATHS

CarrierWave: Cache

Assign uploads to cache_path in GCS. Keep Cache ID in the form and restore the cached file when resuming.
3. FOCUS ON FILE PATHS

CarrierWave: Store

Persist the image to GCS and save its identifier in PostgreSQL. Read the identifier to resolve store_path and access the image.
3. FOCUS ON FILE PATHS

File Paths Can Be Overridden Easily

  • Convenient customization through store_dir
  • Include model data such as an organization key or profile key
  • Changes to that data or the method can make file paths fragile
# Profile image example; attribute names are illustrative
def store_dir
  "profile_images/#{model.organization_key}/#{model.profile_key}"
end
3. FOCUS ON FILE PATHS

Complexity Leads to a Time-Based if

def store_dir
  if after_hotfix?   # updated after a certain date?
    new_store_dir
  else
    legacy_store_dir # kept for older files
  end
end
  • Paths are not kept as snapshots, so we cannot change the logic
3. FOCUS ON FILE PATHS

The Problem and the Two Fixes

  • Changing model state or path rules can produce incorrect file references
  • In these incidents, the files remained intact; the reference data was incorrect
Fix Record the path when… Keep it in…
#1 cache_path the cache is created Redis
#2 store_path the file is stored a dedicated DB column
3. FOCUS ON FILE PATHS
ここまでのまとめ: 1. cacheとstoreがある 2. パスは開発者が壊しやすい

Fix #1: Record cache_path

  • Between cache and store, the calculated cache path could change
    • e.g., code where the path changes depending on whether an ID exists
  • This caused multiple incidents in the past
  • Fix: save the path at cache time, and never recalculate it
3. FOCUS ON FILE PATHS

Cache: Before

Even with the same cache ID, if the recalculated path changes at restore time, the app looks at a path different from where the file was saved.
3. FOCUS ON FILE PATHS

Cache: After

At cache creation, the mapping from the ID to the path is recorded in Redis. At restore time, the same path is read from Redis without recalculation.
3. FOCUS ON FILE PATHS

Cache: Result

  • Completed at the end of 2025, as our first improvement
  • Incidents caused by cache_path dropped to zero 🎉
  • Next: prevent store_path incidents from recurring
3. FOCUS ON FILE PATHS

Fix #2: Record store_path

  • We could change the identifier format, but chose a dedicated column
    • On read, prefer that column; otherwise, calculate as before
    • Backfill existing data by recalculation
  • Start with the most problematic Uploader, then expand
3. FOCUS ON FILE PATHS

Store: Record the Saved Path

class ApplicationUploader < CarrierWave::Uploader::Base
  after :store, :persist_object_store_path

  def persist_object_store_path(*)
    return if should_not_store_full_path?

    persisted_store_path = object_store_path_to_persist
    save_object_store_path(persisted_store_path)
    log_if_mismatch
  end
end
  • Right after store, record the path in the dedicated column

NOTE: Simplified from the actual code.

3. FOCUS ON FILE PATHS

Store: Read the Recorded Path

class ApplicationUploader < CarrierWave::Uploader::Base
  def store_path(*args)
    calculated_store_path = super(*args)
    persisted_store_path = resolve_persisted_store_path
    log_if_mismatch
    return calculated_store_path if persisted_store_path.nil?
    use_persisted_store_path ? persisted_store_path : calculated_store_path
  end
end
  • No record? Use the calculated path, as before
  • Log any mismatch between the calculated and recorded paths

NOTE: Simplified from the actual code.

3. FOCUS ON FILE PATHS

Permanent Data Needs a Safe Rollout

  • A wrong stored path can outlive the rollout
  • Separate recording paths from using recorded paths
  • Verify rollback as well as the forward migration
3. FOCUS ON FILE PATHS

Store: Switch Writes, Then Reads

Step write read Notes
1 Partly ON OFF Double write for some tenants. Reads as before
2 All ON OFF Double write for all tenants. Reads as before
3 All ON Partly ON Some tenants read from the fixed paths
4 All ON All ON All tenants read from the fixed paths
3. FOCUS ON FILE PATHS

Flipper: Roll Out and Roll Back

  • Use Flipper to enable write/read flags for selected tenants
  • Add a dedicated column alongside the existing identifier
  • Include rollback checks in QA
3. FOCUS ON FILE PATHS

Monitor the Rollout with BigQuery

  • Application data was periodically synced to BigQuery
  • We queried that copy to monitor the file path rollout
-- Daily check on the BigQuery copy (simplified)
SELECT id, tenant_id, avatar, avatar_object_path
FROM app_mirror.employee_profiles
WHERE
  -- a file without a path, or a path without a file
  (COALESCE(avatar, '') = '') != (avatar_object_path IS NULL)
  -- or the path does not end with the file name
  OR NOT ENDS_WITH(avatar_object_path, CONCAT('/', avatar))

NOTE: Simplified from the actual code.

3. FOCUS ON FILE PATHS

QA: Users Should See No Change

  • All AI-assisted QA ran in staging with no customer data
  • Features × preconditions create many test cases
  • Define user stories and expected results with AI
  • Keep automated tests and check human-visible evidence
3. FOCUS ON FILE PATHS
AIを用いたQAはすべて、顧客情報を含まないstaging環境で実施。

QA: Stories Become Evidence

Like compiler passes, each stage passes an artifact to the next: User Story → scenario.yml → result.yml → human-readable report → human review
3. FOCUS ON FILE PATHS

QA: Results and Human Review

  • About 30 large stories, checked in 2 days by about 3 people
  • Screenshots, logs, verdicts, and reasons are reviewed in PRs
  • Existing tests are kept; automated coverage is strengthened
  • The value: more cases checked, with a human final review
3. FOCUS ON FILE PATHS

Store: Current Status

  • Some images have fully switched to fixed paths
  • We will expand to all images, step by step, the same way
3. FOCUS ON FILE PATHS

File Paths: What Matters

  • Record a stable reference to each saved file
  • Separate writes and reads to make the rollout controllable
  • Test the way back, not only the way forward
3. FOCUS ON FILE PATHS
まとめ: 壊れやすいパスを1:1で引けるよう固定した。
大きな変更は必ず戻せるように、AI駆動QAで品質を担保。

4. Focus on History

4. FOCUS ON HISTORY

Making Images and History Work

  • Employee history is essential to our service
  • Handling images is essential too
  • Combining them requires extra care
4. FOCUS ON HISTORY

Bitemporal Data Model and Us

Valid time runs left to right and transaction time runs top to bottom. On 10/17, the location since 10/1 is corrected; the old row is kept and two rows, Tokyo and Hakata, are added.
4. FOCUS ON HISTORY

History Copying Has Side Effects

Copy history: previous row to new row → internal assign → upload via cache!

Our history-copy workflow + CarrierWave's assignment behavior
can upload the same file again.

4. FOCUS ON HISTORY

The Fix: Mark History Copies

The fix: copy the history row and assign internally, but skip the upload when bitemporal operations are marked as in progress.

In the PR: CurrentAttributes carries
the “copy in progress” state into hooks.

4. FOCUS ON HISTORY

CurrentAttributes: Into the Hook

1. Mark the scope

# Per-request state, reset by Rails
class HistoryCopy < AS::CurrentAttributes
  attribute :in_progress, default: false
end

# In ApplicationUploader
def self.copying_history
  HistoryCopy.in_progress = true
  yield
ensure # reset, even on errors
  HistoryCopy.in_progress = false
end

2. Skip the upload in the hook

# In ApplicationUploader
# Assignment runs cache!; skip it during copy
def cache!(*)
  return if HistoryCopy.in_progress

  super
end

# Usage
ApplicationUploader.copying_history do
  record.copy_history!
end

NOTE: Simplified from the actual code.

4. FOCUS ON HISTORY

History: Measuring the Upcoming PR

Before: 17.0 seconds and 39 GCS calls. With duplicate-upload suppression: 9.6 seconds and 22 GCS calls. put, delete, and copy each drop from 11 to 6.

Local run of a near-release PR: 17.0s → 9.6s (−44%) · GCS calls 39 → 22

4. FOCUS ON HISTORY

History: Where the Time Went

Spans of one request on the same time scale. Before: 16.86 seconds, including building the new history row (4.38 s) and a repeated store (2.56 s). With suppression: 9.36 seconds; both of those spans drop to 0 s.

Two spans disappear: rebuilding the history row and the repeated store

4. FOCUS ON HISTORY

History: What Matters

  • Make the difference between copying a reference and uploading a file explicit
  • Keep lifecycle side effects under application control
  • Validate the history behavior before rolling out
  • The PR is nearly ready; the production rollout is next
4. FOCUS ON HISTORY
まとめ: 履歴実装+画像で不要なアップロードが起こった。
CurrentAttributesで内部フックを制御。PRは本番投入間近。

Another Performance Opportunity:
Preprocessing

ASIDE · PERFORMANCE
寄り道: 前処理にも性能改善の余地あり

Preprocessing: More Room to Improve

Local measurement, average of three runs: with preprocessing 17.9 seconds; without it 5.8 seconds. Fewer resized images also meant fewer GCS uploads.

Separate local PoC: 17.9s → 5.8s · fewer resized images, fewer GCS uploads

ASIDE · PERFORMANCE

5. Upgrade Blockers

5. UPGRADE BLOCKERS

Two Blockers to Upgrading

  1. Tight coupling
    • Application code depends on CarrierWave internals
  2. Incompatibilities
    • A version upgrade changes behavior we rely on
5. UPGRADE BLOCKERS
更新のブロッカーは、密結合と非互換性の二つ。

Current: Coupled Image Responsibilities

Inside the application, CarrierWave callbacks connect business logic, internal API calls, file path logic, image resizing, and EXIF removal.
5. UPGRADE BLOCKERS

Image Operations: Find the Boundary

Nearly 300 call sites depend on CarrierWave.

Before: Uploader behavior

user.avatar.present?

Depends on the Uploader's blank?

After: Application intent

CarrierWaveCompatLayer.attached?(
  user, :avatar
)

Ask whether a file is attached

Same behavior, an explicit boundary.
Migrate callers incrementally; extraction is under analysis.

5. UPGRADE BLOCKERS

Image Operations: Wrap the API

SmartHR calls the wrapped image operation API to get a name, get image size, and perform other operations. The CarrierWave API stays inside the wrapper.
5. UPGRADE BLOCKERS

Incompatibilities: Investigated

  • We have investigated the incompatibilities in the CarrierWave upgrade
  • The next challenge is how to roll it out safely
  • Understanding the differences is the starting point
5. UPGRADE BLOCKERS

The Same Rollback Strategy Won't Fit

  • For file paths, we switched writes and reads separately
  • A library upgrade changes the underlying implementation
  • We cannot reuse the same flag-based rollback strategy
5. UPGRADE BLOCKERS

Reuse What We Learned

  • AI-assisted QA may help check compatibility
  • Gradual rollout may help limit the impact
  • Build on our file-path migration experience

To be updated.

5. UPGRADE BLOCKERS
パス固定で得た品質管理のノウハウを活かして、更新に取り組みたい

Address Both Upgrade Blockers

  1. Tight coupling → clearer boundaries
    • Wrap library-specific behavior; migrate callers gradually
  2. Incompatibilities → a safe upgrade plan
    • Investigation done; adapt our QA and rollout experience
5. UPGRADE BLOCKERS
密結合→境界づくり。非互換性については調査はできていて、その上で品質管理しつつ作業

6. Upgrade or Replace?

6. UPGRADE OR REPLACE?

What Could Replace CarrierWave?

Three possible replacements: ActiveStorage, Shrine, or an in-house solution. Continuing to upgrade CarrierWave is also an option.
6. UPGRADE OR REPLACE?

No Decision Yet

  • Upgrading CarrierWave and replacing it are both options
  • Catching up to a newer version may be the practical next step
  • First, simplify the behavior we must preserve
  • Keep application needs separate from library internals
6. UPGRADE OR REPLACE?

Whatever the Choice,
Make Responsibilities Clear.

6. UPGRADE OR REPLACE?
結論はまだ出ていないが、更新にせよ、移行にせよ、
ファイルアップロードの責務と影響範囲を明確にすることが重要。

7. Takeaways

7. TAKEAWAYS

First, split the problem
by quality attribute.

7. TAKEAWAYS
まず、課題を性質で分割する。

Make important changes
reversible.

7. TAKEAWAYS
重要な変更は、必ず戻せるようにする。

Watch for hidden bugs
where behaviors interact.

7. TAKEAWAYS
仕様の組み合わせによる、暗黙のバグに注意する。

Extract image complexity
step by step.
For upgrades or replacement.

7. TAKEAWAYS
画像の複雑な仕様を、少しずつ切り出す。
最終的な更新でも移行でも、その整理が必要になる。

Keep building
our quality assurance know-how.

7. TAKEAWAYS
品質管理のノウハウを積み重ねていこう。

“The real cycle you’re working on
is a cycle called yourself.”

Robert M. Pirsig, Zen and the Art of Motorcycle Maintenance

7. TAKEAWAYS
参考訳: あなたが本当に整備しているのは“あなた自身”という名のオートバイだ。

Face your problems.
Make you and your users happy.

7. TAKEAWAYS

Thank you!

QR code for the slides URL

udzura.jp/slides/2026/kaigionrails

Thanks: Team members! @eminem, @a5-stable, @yudai

We are Hiring! Scan the QR code for career opportunities