{"id":50588760,"url":"https://github.com/nallenscott/undertow","last_synced_at":"2026-06-05T08:30:59.084Z","repository":{"id":350956547,"uuid":"1202027113","full_name":"nallenscott/undertow","owner":"nallenscott","description":"✨ Materialized views for ActiveRecord","archived":false,"fork":false,"pushed_at":"2026-05-05T11:24:45.000Z","size":69,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-05T13:29:18.495Z","etag":null,"topics":["activejob","activerecord","background-jobs","change-propagation","dependency-tracking","gem","rails","redis","ruby"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/nallenscott.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-05T13:54:29.000Z","updated_at":"2026-05-05T11:17:53.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/nallenscott/undertow","commit_stats":null,"previous_names":["nallenscott/undertow"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/nallenscott/undertow","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nallenscott%2Fundertow","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nallenscott%2Fundertow/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nallenscott%2Fundertow/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nallenscott%2Fundertow/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/nallenscott","download_url":"https://codeload.github.com/nallenscott/undertow/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nallenscott%2Fundertow/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33937661,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-05T02:00:06.157Z","response_time":120,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["activejob","activerecord","background-jobs","change-propagation","dependency-tracking","gem","rails","redis","ruby"],"created_at":"2026-06-05T08:30:58.252Z","updated_at":"2026-06-05T08:30:59.079Z","avatar_url":"https://github.com/nallenscott.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# :ocean: undertow\n\n[![Gem Version](https://badge.fury.io/rb/undertow.svg?ts=2026050701)](https://badge.fury.io/rb/undertow)\n[![CI](https://github.com/nallenscott/undertow/actions/workflows/ci.yml/badge.svg)](https://github.com/nallenscott/undertow/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)\n\nRails apps often have models that represent a composition of data from multiple sources. A product listing might pull from categories, sellers, and inventory. A search document might aggregate fields from a dozen associations. A cache entry might depend on records several joins away. When any of those sources change, the composed record is stale and something downstream needs to react.\n\nThe usual approach is to add callbacks to the upstream models and fan out from there. That works for simple cases, but it gets messy fast. It's easy to miss associations, it creates hidden coupling between models that have no business knowing about each other, and it breaks down entirely when the relationship is indirect, through a join table or a scope.\n\nRelational databases solve a version of this with materialized views: a precomputed result that tracks its own staleness and refreshes lazily when sources change. Undertow brings that pattern to ActiveRecord. Dependencies are declared on the root model, undertow resolves which records are affected when upstream data changes, and the affected IDs are buffered in a configurable store and delivered in batches to a handler you define, off the write path.\n\n## Mental model\n\nYou don’t “update” derived state.\n\nYou declare how to rebuild it.. and let Undertow pull it back into consistency.\n\nLike an undertow under the surface, it quietly keeps everything aligned.\n\n## Requirements\n\n- Ruby \u003e= 3.0\n- ActiveRecord ~\u003e 7.0\n- ActiveSupport ~\u003e 7.0\n- ActiveJob ~\u003e 7.0\n\n## Installation\n\nAdd Undertow to your Gemfile:\n\n```ruby\ngem \"undertow\"\n```\n\nIf you want to use Redis or Valkey as the backing store, also add:\n\n```ruby\ngem \"redis\"\n```\n\nThen run:\n\n```bash\nbundle install\n```\n\n## Setup\n\nCreate `config/initializers/undertow.rb`:\n\n```ruby\nUndertow.configure do |c|\n  c.store          = Undertow::Store::MemoryStore.new\n  c.queue_name     = :undertow\n  c.max_batch      = 1_000\n  c.drain_lock_key = \"undertow:drain:lock\"\nend\n```\n\n`MemoryStore` is the default, so setting `c.store` is optional unless you want a different backend.\n\n`MemoryStore` keeps state in process memory and is intended for tests and single process development. Use `RedisStore` for multi process or multi dyno deployments.\n\nFor Redis or Valkey:\n\n```ruby\nUndertow.configure do |c|\n  c.store = Undertow::Store::RedisStore.new(\n    Redis.new(url: ENV[\"REDIS_URL\"])\n  )\nend\n```\n\n`RedisStore` is compatible with Redis and Valkey servers that support standard Redis set and lock commands. It accepts a direct Redis client or a pooled client that responds to `with`.\n\n| Option | Default | Description |\n|---|---|---|\n| `store` | `Undertow::Store::MemoryStore.new` | Store adapter implementation. |\n| `queue_name` | `:undertow` | ActiveJob queue for `DrainJob`. |\n| `max_batch` | `1_000` | Maximum IDs popped per model per drain. |\n| `drain_lock_key` | `\"undertow:drain:lock\"` | Lock key used by the configured store. Set to `nil` to disable lock management. |\n\nCall `Undertow.tick` from your scheduler on each interval:\n\n```ruby\nevery(1.second, \"undertow\") { Undertow.tick }\n```\n\n## Root models\n\nUndertow starts from the root model, the model that owns derived or aggregated state and needs to know when upstream data changes.\n\nThe root model defines:\n\n- what it depends on  \n- which columns matter  \n- what to do when affected IDs are ready  \n\nUpstream models need no configuration. Undertow wires their callbacks automatically at boot when a root model declares a dependency on them.\n\nThat’s the point.\n\nThe model that owns the derived state defines the contract.\n\n## Defining dependencies\n\nHere’s the whole shape:\n\n```ruby\nclass Post \u003c ApplicationRecord\n  belongs_to :author\n  has_many :post_tags\n  has_many :tags, through: :post_tags\n\n  undertow_skip %w[view_count updated_at]\n\n  undertow_depends_on :author,\n    foreign_key: :author_id,\n    watched_columns: %w[name bio]\n\n  undertow_depends_on :tag,\n    resolver: -\u003e(tag) {\n      Post.joins(:post_tags).where(post_tags: { tag_id: tag.id })\n    },\n    watched_columns: %w[name slug]\n\n  undertow_on_drain -\u003e(model_name, ids, deleted_ids) {\n    PostSyncJob.perform_later(ids, deleted_ids)\n  }\nend\n```\n\nDeclare what affects the root model. Undertow figures out which root records are stale and delivers the IDs in batches.\n\nUse `foreign_key:` when the root model directly references the upstream model:\n\n```ruby\nundertow_depends_on :author,\n  foreign_key: :author_id,\n  watched_columns: %w[name bio]\n```\n\nUse `resolver:` when there is no direct foreign key from the root model to the upstream model:\n\n```ruby\nundertow_depends_on :tag,\n  resolver: -\u003e(tag) {\n    Post.joins(:post_tags).where(post_tags: { tag_id: tag.id })\n  },\n  watched_columns: %w[name slug]\n```\n\nUse `watched_columns:` when only certain upstream changes matter:\n\n```ruby\nundertow_depends_on :author,\n  foreign_key: :author_id,\n  watched_columns: %w[name bio]\n```\n\n## Skipping noisy columns\n\nUse `undertow_skip` for columns on the root model that should not trigger downstream work.\n\n```ruby\nundertow_skip %w[view_count updated_at]\n```\n\n## Drain handler\n\nUse `undertow_on_drain` to define what happens when a batch is ready.\n\n```ruby\nundertow_on_drain -\u003e(model_name, ids, deleted_ids) {\n  PostSyncJob.perform_later(ids, deleted_ids)\n}\n```\n\n## Disabling tracking\n\n```ruby\nUndertow.without_tracking do\n  Author.find_each { |author| author.update!(legacy: true) }\nend\n```\n\n## DrainJob\n\n`Undertow::DrainJob` is enqueued by `Undertow.tick` when pending work exists and the drain lock can be acquired.\n\n- releases the lock immediately on start  \n- drains in batches (`max_batch`)  \n- restores IDs and emits an error event when the handler raises  \n- continues draining on next tick if capped or after an error  \n\nThe drain lock has a default TTL of 30 seconds.\n\n## Instrumentation\n\nUndertow publishes `ActiveSupport::Notifications` events:\n\n```ruby\nActiveSupport::Notifications.subscribe(\"drain.undertow\") do |*args|\n  event = ActiveSupport::Notifications::Event.new(*args)\n  Rails.logger.info(event.payload)\nend\n```\n\n```ruby\nActiveSupport::Notifications.subscribe(\"error.undertow\") do |*args|\n  event = ActiveSupport::Notifications::Event.new(*args)\n  Rails.logger.error(event.payload)\nend\n```\n\n## Upgrading\n\nSee [UPGRADING.md](UPGRADING.md) for version migration steps.\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnallenscott%2Fundertow","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnallenscott%2Fundertow","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnallenscott%2Fundertow/lists"}