Skip to content

Cache key audit: under-scoped and shared-cache/local-state mismatches across Rails.cache usages #1289

Description

@philayres

Summary

Follow-up to #1279, #1287, and PRs #1271 / #1283. A full audit of all Rails.cache call sites (57 usages, 25 files) was performed. Most are correct. Four categories of issue were identified: two involving cross-worker cache poisoning, one involving per-process state in a shared cache, and one group that is currently masked by whole-cache clears (and will become bugs as the #1270 direction continues).

Issue 1 — def_handler.rb: item_types cache key is global but content is per-process

Location: app/models/dynamic/def_handler.rb (~L218–L226)

Key: "DynamicModel.item_types" / "ActivityLog.item_types" / "ExternalIdentifier.item_types" — global, no invalidation inputs.

Content: Built from implementation_classes, which calls implementation_class_defined? and skips any class not yet generated in the current process. In a multi-worker deployment, whichever worker first populates this key wins — workers with incomplete runtime models poison the shared cache with an incomplete list. This is the exact same pattern as the master-tabs bug (#1279): shared cache keyed globally, content dependent on per-process state.

Impact: Admin GeneralSelection item-type dropdowns silently lose entries (no error, just missing options).

Fix direction: Key on a DB-derived timestamp (e.g., the dynamic-definition latest_updates for DynamicModel, ActivityLog, ExternalIdentifier) rather than a static class name. Alternatively, exclude implementation_class_defined? from the cached path and do the filtering post-cache.


Issue 2 — reports_helper.rb: select_from_model_resource_name_options keyed on per-process Resources::Models.updated_at

Location: app/helpers/reports_helper.rb (~L253)

Key: "select_from_model_resource_name_options-#{Resources::Models.updated_at}"

Problem: Resources::Models.updated_at is a mattr_accessor set to Time.now per process when the registry is rebuilt. Every worker generates a different key (cache bloat, no sharing), and a worker with an incomplete or different-generation registry caches an incomplete options list under a key that no other worker ever reads. mattr_accessor state diverges between workers and is invisible to the cache key consumer.

Fix direction: Replace Resources::Models.updated_at with a DB-derived composite of DynamicModel.latest_update, ActivityLog.latest_update, ExternalIdentifier.latest_update — consistent across workers.


Issue 3 — secure_view: rendered file path cached in shared store

Location: app/models/secure_view/base_previewer.rb and image_previewer.rb (~L214, L288, L132)

Key: "SecureView-doc-#{orig_path}-#{file_type}-#{type}-#{view_type}"
Value: A Dir.mktmpdir path (local temp directory).

Problem: The cached value is a local filesystem path stored in shared memcached. In multi-server production:

  • Server B fetches a rendered-path cache entry written by Server A, but the temp directory does not exist on Server B → file-not-found or silent failure.
  • After any process restart or tmp-dir cleanup, stale cache entries point to deleted paths on every server.

Fix direction: Either include a host identifier and boot/startup token in the key (so each server's entries are isolated), or move rendered-file caching to local in-memory / filesystem cache rather than shared memcached.


Issue 4 — Masked by whole-cache clears: keys that will break when clears are scoped further

The following sites have under-scoped keys that currently survive only because saving the relevant admin models still calls Rails.cache.clear (the pre-#1271 behaviour). As #1270 continues to scope down those clears, these will surface:

Location Key Missing inputs
app/controllers/admin/user_access_controls_controller.rb admin_user_access_control_resource_names-#{UAC.latest_update} dynamic-definition and report latest_updates (content includes their resource names)
app/views/admin/reports/_info_block.html.erb 'info-block-protocol-block' / 'info-block-gs-block' No invalidation inputs at all — static keys, never expire
app/models/admin/migration_generator.rb "db_column_comments-#{Application.version}" etc. Dynamic-model migrations change DB schema comments/FKs without a version bump
app/controllers/definitions_controller.rb "definition_#{def_type}-#{Application.server_cache_version}" Freshness relies entirely on whole clears; no per-user scoping (though content is global, so no user-leak risk)
app/helpers/application_helper.rb partial_cache_key userrole/uac queries use where(app_type_id: apptype) Global (app_type_id: nil) role/UAC rows are excluded, inconsistent with PR #1283's fix in handlebars_precompiler_helper (app_type_id: [app_type_id, nil]). Global access-control changes don't rotate template_version/fragment/etag keys.

Issue 5 — master_handler.rb index_cache_key omits app_type_id

Location: app/controllers/concerns/master_handler.rb (~L187–L211)

The key includes current_user.id, params, and per-record updated_ats, but not current_user.app_type_id or role/UAC timestamps. In-session app-type switches could replay stale index JSON for the same user/record combination. Currently narrow (30-second browser TTL, record updates rotate it), but same pattern class.


Cross-cutting caveat: second-granularity timestamp keys

All keys built on latest_update / cache_key_for_access_for interpolate timestamps via Time#to_s (second precision). Two access-control changes within the same wall-clock second do not rotate dependent cache keys. Harmless in normal operation but relevant when writing cache-correctness specs (explicit updated_at offsets are needed, as documented in the specs added for #1279).


Side observation: definitions_controller exposes all active user emails

DefinitionsController exposes users → active_id_name_list (all active user emails) to any authenticated user with no access-control check beyond login. Worth a separate access-control review.


Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions