mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b28b3106a | ||
|
|
9ea475a386 | ||
|
|
008ea1ab2a | ||
|
|
8de3e22ab4 | ||
|
|
f792e16e64 | ||
|
|
fd49099729 | ||
|
|
d9c3207886 | ||
|
|
cd0414ef8f | ||
|
|
b4ab2df20f | ||
|
|
32748e7fd6 | ||
|
|
7271eb59f4 | ||
|
|
9fd1563c9b | ||
|
|
105f4b8fd1 | ||
|
|
a95361dd15 | ||
|
|
8d02540e31 | ||
|
|
1f5858098b | ||
|
|
b220486c16 | ||
|
|
ac73267b07 |
No files matched your search
@@ -41,7 +41,8 @@ jobs:
|
||||
arch: arm64
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
# Everything goes to <repo>-edge, with this repository's own token.
|
||||
IMAGE_NAME: ${{ github.repository }}-edge
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -87,9 +88,6 @@ jobs:
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short,prefix=sha-
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=-${{ matrix.arch }}
|
||||
@@ -128,6 +126,8 @@ jobs:
|
||||
name: Create multi-arch manifest and sign
|
||||
needs: build
|
||||
if: github.event_name != 'pull_request'
|
||||
# Multi-platform tags, signed, in <repo>-edge only. basecamp/release-images
|
||||
# promotes v* tags from there into the release package after a reviewer approves.
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
IMAGE_NAME: ${{ github.repository }}-edge
|
||||
steps:
|
||||
- name: Set up Docker Buildx (for imagetools)
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
@@ -166,10 +166,6 @@ jobs:
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short,prefix=sha-
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
flavor: |
|
||||
latest=false
|
||||
labels: |
|
||||
@@ -188,10 +184,6 @@ jobs:
|
||||
[ -z "$tag" ] && continue
|
||||
echo "Creating manifest for $tag"
|
||||
src_tag="$tag"
|
||||
if [[ "$tag" == *:latest && "${GITHUB_REF}" == refs/tags/* ]]; then
|
||||
ref="${GITHUB_REF#refs/tags/}"
|
||||
src_tag="${tag%:latest}:$ref"
|
||||
fi
|
||||
if [ -n "${IMAGE_DESCRIPTION:-}" ]; then
|
||||
docker buildx imagetools create \
|
||||
--tag "$tag" \
|
||||
|
||||
@@ -49,17 +49,23 @@ If you'd rather run the Docker image yourself, you can read more about that in t
|
||||
> that people have someone to contact if they need help with their account. If that bothers you, put in any
|
||||
> email address you want and create yourself a new admin account.
|
||||
|
||||
Authenticated room, message, sidebar and search pages use a bounded 64 MiB cache per worker.
|
||||
Set `CAMPFIRE_RESPONSE_CACHE_MB=0` to disable it. Every request still checks authentication
|
||||
and room access; commits from any SQLite writer invalidate pages, and CSRF masks stay fresh.
|
||||
Native HTML, JSON and stream fragments have a separate 64 MiB memory limit per worker;
|
||||
shared rate limits retain their existing store.
|
||||
|
||||
## Other implementations
|
||||
|
||||
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
|
||||
|
||||
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| Room page | 230 | 62 | 760 | 2,622 | 942 | 31,673 | 35,484 | 141,834 |
|
||||
| Messages page | 402 | 70 | 924 | 3,245 | 1,267 | 30,746 | 40,674 | 151,564 |
|
||||
| Sidebar | 468 | 229 | 1,383 | 34,938 | 2,515 | 18,586 | 34,479 | 159,850 |
|
||||
| Search | 399 | 118 | 1,135 | 6,613 | 1,814 | 29,765 | 34,432 | 155,456 |
|
||||
| Post a message | 248 | 112 | 498 | 2,088 | 1,400 | 9,073 | 8,998 | 7,460 |
|
||||
| Room page | 710 | 414 | 1,696 | 42,481 | 1,126 | 52,512 | 105,909 | 137,505 |
|
||||
| Messages page | 1,113 | 454 | 1,890 | 74,779 | 1,407 | 54,100 | 103,301 | 142,669 |
|
||||
| Sidebar | 1,901 | 576 | 3,364 | 94,460 | 3,621 | 58,714 | 120,930 | 151,001 |
|
||||
| Search | 1,332 | 549 | 2,615 | 83,493 | 2,127 | 60,444 | 121,502 | 148,766 |
|
||||
| Post a message | 226 | 113 | 567 | 2,121 | 1,392 | 9,000 | 8,004 | 7,486 |
|
||||
|
||||
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
|
||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, CachedResponses
|
||||
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
module CachedResponses
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
CACHE_HEADERS = %w[ content-type cache-control etag last-modified vary ].freeze
|
||||
CSRF_TAG = /<meta\b[^>]*\bname="csrf-token"[^>]*>|<input\b[^>]*\bname="authenticity_token"[^>]*>/
|
||||
|
||||
included do
|
||||
prepend_before_action :capture_response_cache_version
|
||||
end
|
||||
|
||||
def perform_caching
|
||||
return false unless super && @response_cache_version.present? && !ActiveRecord::Base.connection.transaction_open?
|
||||
|
||||
# Check again after authentication, before the first native fragment lookup.
|
||||
# Later renders retain this immutable namespace and cannot poison a new epoch.
|
||||
unless defined?(@fragment_cache_snapshot_valid)
|
||||
@fragment_cache_snapshot_valid = @response_cache_version == ResponseCache.instance.version
|
||||
end
|
||||
@fragment_cache_snapshot_valid
|
||||
end
|
||||
|
||||
# Keep the class store (including shared rate limits) and Rails.cache unchanged.
|
||||
def cache_store
|
||||
FragmentCache.store
|
||||
end
|
||||
|
||||
def combined_fragment_cache_key(key)
|
||||
@fragment_cache_namespace ||= [
|
||||
@response_cache_version, request.base_url, request.script_name, request.format.to_s, I18n.locale,
|
||||
Current.user&.id, (Digest::SHA256.hexdigest(Current.session.token) if Current.session),
|
||||
(Digest::SHA256.hexdigest(real_csrf_token) if request.format.html? || request.format.turbo_stream?)
|
||||
].freeze
|
||||
super([ @fragment_cache_namespace, key ])
|
||||
end
|
||||
|
||||
private
|
||||
def capture_response_cache_version
|
||||
# Capture for native HTML/JSON/stream renders too, even with page reuse off.
|
||||
# Detached renderers do not run callbacks and therefore render uncached.
|
||||
@response_cache_version = ResponseCache.instance.version
|
||||
end
|
||||
|
||||
# Register after room authorization, but before presentation queries.
|
||||
def cache_read_response
|
||||
if cacheable_read_request?
|
||||
token = form_authenticity_token
|
||||
key = response_cache_key
|
||||
original_session = session.to_hash.deep_dup
|
||||
|
||||
return yield if key.bytesize > ResponseCache::MAX_KEY_BYTES
|
||||
|
||||
entry = ResponseCache.instance.read(key, @response_cache_version)
|
||||
rendered = false
|
||||
unless entry
|
||||
ResponseCache.instance.synchronize_render(key, @response_cache_version) do
|
||||
entry = ResponseCache.instance.read(key, @response_cache_version)
|
||||
if !entry && ResponseCache.instance.version == @response_cache_version
|
||||
yield
|
||||
rendered = true
|
||||
cache_completed_response(key, original_session)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if entry
|
||||
response.headers.merge!(entry[:headers])
|
||||
self.response_body = entry[:body].gsub(entry[:marker], token)
|
||||
elsif !rendered
|
||||
# A queued request retains its pre-auth snapshot. If it has expired,
|
||||
# render outside the stripe instead of blocking the next generation.
|
||||
yield
|
||||
end
|
||||
else
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def cache_completed_response(key, original_session)
|
||||
if response.status == 200 && response.media_type == "text/html" && session.to_hash == original_session
|
||||
marker = "campfire-csrf-#{SecureRandom.hex(32)}"
|
||||
# Replace only framework token attributes, never a matching token in
|
||||
# message text. Postprocessing also leaves fragment caches untouched.
|
||||
body = csrf_neutral_body(response.body, marker)
|
||||
headers = response.headers.slice(*CACHE_HEADERS).to_h.freeze
|
||||
ResponseCache.instance.write(key, @response_cache_version, { body: body.freeze, marker: marker.freeze, headers: headers }.freeze)
|
||||
end
|
||||
end
|
||||
|
||||
def cacheable_read_request?
|
||||
ResponseCache.instance.budget.positive? && @response_cache_version && request.get? && request.format.html? && Current.session &&
|
||||
!authenticated_by.bot_key? && flash.empty? &&
|
||||
!request.headers["If-None-Match"] && !request.headers["If-Modified-Since"] &&
|
||||
!Rails.application.config.content_security_policy_nonce_generator &&
|
||||
!ActiveRecord::Base.connection.transaction_open?
|
||||
end
|
||||
|
||||
def response_cache_key
|
||||
ActiveSupport::JSON.encode([
|
||||
controller_path, request.fullpath, request.base_url, request.user_agent,
|
||||
request.headers["Accept"], request.headers["Turbo-Frame"], I18n.locale,
|
||||
# Tokens are hydrated per request, including clients that replay an old
|
||||
# cookie. Their raw CSRF secret does not select a presentation variant.
|
||||
Current.user.id, Current.session.token, session.to_hash.except("_csrf_token"),
|
||||
cookies.to_h.except("_campfire_session", "session_token")
|
||||
])
|
||||
end
|
||||
|
||||
def csrf_neutral_body(body, marker)
|
||||
body.gsub(CSRF_TAG) do |tag|
|
||||
attribute = tag.start_with?("<meta") ? "content" : "value"
|
||||
tag.sub(/\b#{attribute}="[^"]*"/, %(#{attribute}="#{marker}"))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -37,7 +37,7 @@ class Messages::ByBotsController < MessagesController
|
||||
end
|
||||
|
||||
def set_pagination_headers
|
||||
headers["X-Total-Count"] = @room.messages.count.to_s
|
||||
headers["X-Total-Count"] = @room.messages_count.to_s
|
||||
|
||||
if next_page = next_page_params
|
||||
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
|
||||
|
||||
@@ -4,6 +4,7 @@ class MessagesController < ApplicationController
|
||||
before_action :set_room, except: :create
|
||||
before_action :set_message, only: %i[ show edit update destroy ]
|
||||
before_action :ensure_can_administer, only: %i[ edit update destroy ]
|
||||
around_action :cache_read_response, only: :index
|
||||
|
||||
layout false, only: :index
|
||||
|
||||
@@ -11,7 +12,14 @@ class MessagesController < ApplicationController
|
||||
@messages = find_paged_messages
|
||||
|
||||
if @messages.any?
|
||||
fresh_when @messages
|
||||
body = render_to_string(:index)
|
||||
# Creator, body and boost edits can change HTML without touching messages.
|
||||
# Masked CSRF tokens remain fresh while the presentation validator stays stable.
|
||||
fresh_when etag: Digest::SHA256.hexdigest(csrf_neutral_body(body, "")), template: false
|
||||
unless performed?
|
||||
response.content_type = "text/html"
|
||||
self.response_body = body
|
||||
end
|
||||
else
|
||||
head :no_content
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ class RoomsController < ApplicationController
|
||||
before_action :set_room, only: %i[ show destroy ]
|
||||
before_action :ensure_can_administer, only: %i[ destroy ]
|
||||
before_action :remember_last_room_visited, only: :show
|
||||
around_action :cache_read_response, only: :show
|
||||
|
||||
def index
|
||||
redirect_to room_url(Current.user.rooms.last)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class SearchesController < ApplicationController
|
||||
around_action :cache_read_response, only: :index
|
||||
before_action :set_messages
|
||||
|
||||
def index
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Users::SidebarsController < ApplicationController
|
||||
DIRECT_PLACEHOLDERS = 20
|
||||
around_action :cache_read_response, only: :show
|
||||
|
||||
def show
|
||||
visible_memberships = Current.user.memberships.visible
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Native view caches share one byte budget across every database generation.
|
||||
class FragmentCache
|
||||
STORE = ActiveSupport::Cache::MemoryStore.new(size: 64.megabytes)
|
||||
private_constant :STORE
|
||||
|
||||
def self.store
|
||||
STORE
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
require "sqlite3"
|
||||
|
||||
# A read-only observer sees commits from every writer, including other runtimes.
|
||||
# PRAGMA data_version can only be compared on the same persistent connection.
|
||||
class ResponseCache
|
||||
MAX_ENTRY_BYTES = 1.megabyte
|
||||
MAX_KEY_BYTES = 2.kilobytes
|
||||
|
||||
def self.instance
|
||||
INSTANCE
|
||||
end
|
||||
|
||||
def initialize
|
||||
@mutex = Mutex.new
|
||||
@entries = {}
|
||||
@bytes = 0
|
||||
@render_locks = Array.new(16) { Mutex.new }
|
||||
end
|
||||
|
||||
def budget
|
||||
[ ENV.fetch("CAMPFIRE_RESPONSE_CACHE_MB", "64").to_i, 0 ].max.megabytes
|
||||
end
|
||||
|
||||
def version
|
||||
@mutex.synchronize { current_version }
|
||||
rescue SQLite3::Exception
|
||||
clear
|
||||
nil
|
||||
end
|
||||
|
||||
def read(key, version)
|
||||
@mutex.synchronize do
|
||||
@entries[key]&.first if current_version == version
|
||||
end
|
||||
rescue SQLite3::Exception
|
||||
clear
|
||||
nil
|
||||
end
|
||||
|
||||
# Collapse cold renders without retaining a mutex for every viewer or URL.
|
||||
# Rendering must never hold the observer/entry mutex: controllers read it too.
|
||||
def synchronize_render(key, version, &block)
|
||||
@render_locks[[ key, version ].hash % @render_locks.length].synchronize(&block)
|
||||
end
|
||||
|
||||
def write(key, version, entry)
|
||||
size = key.bytesize + entry[:body].bytesize + entry[:headers].sum { |name, value| name.bytesize + value.bytesize } + 256
|
||||
return if key.bytesize > MAX_KEY_BYTES || size > [ budget, MAX_ENTRY_BYTES ].min
|
||||
|
||||
@mutex.synchronize do
|
||||
return unless current_version == version
|
||||
return if @entries.key?(key)
|
||||
|
||||
while @entries.any? && @bytes + size > budget
|
||||
_, (_, removed_size) = @entries.shift
|
||||
@bytes -= removed_size
|
||||
end
|
||||
@entries[key] = [ entry, size ]
|
||||
@bytes += size
|
||||
end
|
||||
rescue SQLite3::Exception
|
||||
clear
|
||||
end
|
||||
|
||||
def clear
|
||||
@mutex.synchronize do
|
||||
@entries.clear
|
||||
@bytes = 0
|
||||
@observer&.close
|
||||
@observer = @database = @version = nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def current_version
|
||||
database = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||
if @database != database || !@observer
|
||||
@entries.clear
|
||||
@bytes = 0
|
||||
@observer&.close
|
||||
@observer = nil
|
||||
@observer = SQLite3::Database.new(database, readonly: true)
|
||||
@database = database
|
||||
@namespace = SecureRandom.hex(16)
|
||||
@version = nil
|
||||
end
|
||||
|
||||
version = @observer.get_first_value("PRAGMA data_version")
|
||||
if @version != version
|
||||
@entries.clear
|
||||
@bytes = 0
|
||||
@version = version
|
||||
end
|
||||
[ @database, @namespace, @version ]
|
||||
end
|
||||
|
||||
INSTANCE = new
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
# Keeps rooms.messages_count correct for ordinary SQLite writes — Rails, bulk SQL,
|
||||
# and foreign connections — without ActiveRecord counter_cache callbacks that
|
||||
# those paths skip (and that would double-count if combined with triggers).
|
||||
# Foreign REPLACE writes require PRAGMA recursive_triggers=ON so SQLite also
|
||||
# fires the delete trigger. Migrations rebuilding messages must call ensure!
|
||||
# before reading counters inside that migration; database tasks repair afterwards.
|
||||
class Room::MessagesCount
|
||||
INSERT_TRIGGER = "messages_ai_rooms_messages_count"
|
||||
DELETE_TRIGGER = "messages_ad_rooms_messages_count"
|
||||
UPDATE_TRIGGER = "messages_au_rooms_messages_count"
|
||||
TRIGGERS = [ INSERT_TRIGGER, DELETE_TRIGGER, UPDATE_TRIGGER ].freeze
|
||||
|
||||
class << self
|
||||
def install!(connection = ActiveRecord::Base.connection)
|
||||
with_immediate_write(connection) do
|
||||
replace_triggers!(connection)
|
||||
end
|
||||
end
|
||||
|
||||
def uninstall!(connection = ActiveRecord::Base.connection)
|
||||
TRIGGERS.each do |name|
|
||||
connection.execute("DROP TRIGGER IF EXISTS #{name}")
|
||||
end
|
||||
end
|
||||
|
||||
def backfill!(connection = ActiveRecord::Base.connection)
|
||||
connection.execute <<~SQL
|
||||
UPDATE rooms SET messages_count = (
|
||||
SELECT COUNT(*) FROM messages WHERE messages.room_id = rooms.id
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
# schema.rb does not dump SQLite triggers; reinstall after schema:load.
|
||||
# Repair rechecks, rebuilds counts, and installs triggers in one write txn
|
||||
# so concurrent writers and concurrent boot repairs cannot observe a gap.
|
||||
def ensure!(connection = ActiveRecord::Base.connection)
|
||||
return unless connection.adapter_name.match?(/sqlite/i)
|
||||
return unless connection.data_source_exists?(:rooms)
|
||||
return unless connection.column_exists?(:rooms, :messages_count)
|
||||
return if triggers_installed?(connection)
|
||||
|
||||
with_immediate_write(connection) do
|
||||
next if triggers_installed?(connection)
|
||||
|
||||
uninstall!(connection)
|
||||
backfill!(connection)
|
||||
create_triggers!(connection)
|
||||
end
|
||||
end
|
||||
|
||||
def trigger_installed?(connection, name)
|
||||
connection.select_value(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = #{connection.quote(name)}"
|
||||
).present?
|
||||
end
|
||||
|
||||
def triggers_installed?(connection = ActiveRecord::Base.connection)
|
||||
TRIGGERS.all? { |name| trigger_installed?(connection, name) }
|
||||
end
|
||||
|
||||
private
|
||||
def with_immediate_write(connection)
|
||||
if connection.transaction_open?
|
||||
yield
|
||||
else
|
||||
connection.raw_connection.transaction(:immediate) { yield }
|
||||
end
|
||||
end
|
||||
|
||||
def replace_triggers!(connection)
|
||||
uninstall!(connection)
|
||||
create_triggers!(connection)
|
||||
end
|
||||
|
||||
def create_triggers!(connection)
|
||||
connection.execute <<~SQL
|
||||
CREATE TRIGGER #{INSERT_TRIGGER} AFTER INSERT ON messages
|
||||
BEGIN
|
||||
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
|
||||
END
|
||||
SQL
|
||||
connection.execute <<~SQL
|
||||
CREATE TRIGGER #{DELETE_TRIGGER} AFTER DELETE ON messages
|
||||
BEGIN
|
||||
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
|
||||
END
|
||||
SQL
|
||||
connection.execute <<~SQL
|
||||
CREATE TRIGGER #{UPDATE_TRIGGER} AFTER UPDATE OF room_id ON messages
|
||||
WHEN OLD.room_id IS NOT NEW.room_id
|
||||
BEGIN
|
||||
UPDATE rooms SET messages_count = messages_count - 1 WHERE id = OLD.room_id;
|
||||
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
|
||||
END
|
||||
SQL
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
require "jbuilder/jbuilder_template"
|
||||
|
||||
# Jbuilder 2.14.1 uses Rails.cache directly; use the same bounded store as ERB
|
||||
# while retaining its native keys and controller instrumentation.
|
||||
module BoundedJbuilderFragments
|
||||
private
|
||||
def _read_fragment_cache(key, options = nil)
|
||||
@context.controller.instrument_fragment_cache :read_fragment, key do
|
||||
@context.controller.cache_store.read(key, options)
|
||||
end
|
||||
end
|
||||
|
||||
def _write_fragment_cache(key, options = nil)
|
||||
@context.controller.instrument_fragment_cache :write_fragment, key do
|
||||
yield.tap { |value| @context.controller.cache_store.write(key, value, options) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
JbuilderTemplate.prepend(BoundedJbuilderFragments)
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
ActionView::PartialRenderer.collection_cache = FragmentCache.store
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
# schema.rb cannot dump SQLite triggers. Cover existing DBs that lost them.
|
||||
Rails.application.config.after_initialize do
|
||||
Room::MessagesCount.ensure!
|
||||
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class AddMessagesCountToRooms < ActiveRecord::Migration[8.2]
|
||||
def up
|
||||
add_column :rooms, :messages_count, :integer, null: false, default: 0
|
||||
|
||||
# Backfill before installing triggers so the COUNT rewrite does not race with
|
||||
# concurrent inserts, and so we never rely on Rails callbacks for the tally.
|
||||
Room::MessagesCount.backfill!(connection)
|
||||
Room::MessagesCount.install!(connection)
|
||||
end
|
||||
|
||||
def down
|
||||
Room::MessagesCount.uninstall!(connection)
|
||||
remove_column :rooms, :messages_count
|
||||
end
|
||||
end
|
||||
Generated
+3
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_10_07_190000) do
|
||||
create_table "accounts", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.text "custom_styles"
|
||||
@@ -124,6 +124,7 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
|
||||
t.string "name"
|
||||
t.string "type", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "messages_count", default: 0, null: false
|
||||
end
|
||||
|
||||
create_table "searches", force: :cascade do |t|
|
||||
@@ -183,3 +184,4 @@ ActiveRecord::Schema[8.2].define(version: 2026_10_05_022000) do
|
||||
# Note that virtual tables may not work with other database engines. Be careful if changing database.
|
||||
create_virtual_table "message_search_index", "fts5", ["body", "tokenize=porter"]
|
||||
end
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Repair the database selected by each schema-load or migration operation.
|
||||
# db:prepare calls DatabaseTasks directly, bypassing Rake enhancements;
|
||||
# method hooks also run on every invocation.
|
||||
module RoomMessagesCountDatabaseTasks
|
||||
def load_schema(...)
|
||||
super.tap { repair_room_messages_count }
|
||||
end
|
||||
|
||||
def migrate(...)
|
||||
super.tap { repair_room_messages_count }
|
||||
end
|
||||
|
||||
private
|
||||
def repair_room_messages_count
|
||||
migration_connection_pool.with_connection do |connection|
|
||||
Room::MessagesCount.ensure!(connection)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ActiveRecord::Tasks::DatabaseTasks.singleton_class.prepend(RoomMessagesCountDatabaseTasks)
|
||||
|
||||
namespace :room_messages_count do
|
||||
task ensure: :environment do
|
||||
ActiveRecord::Base.connection_pool.with_connection do |connection|
|
||||
Room::MessagesCount.ensure!(connection)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,165 @@
|
||||
require "test_helper"
|
||||
|
||||
class CachedResponsesTest < ActionDispatch::IntegrationTest
|
||||
self.use_transactional_tests = false
|
||||
|
||||
setup do
|
||||
host! "once.campfire.test"
|
||||
sign_in :david
|
||||
@login_cookie = cookies["_campfire_session"]
|
||||
@previous_forgery = ActionController::Base.allow_forgery_protection
|
||||
ActionController::Base.allow_forgery_protection = true
|
||||
@previous_cache = Rails.cache
|
||||
@previous_caching = ActionController::Base.perform_caching
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
ActionController::Base.perform_caching = true
|
||||
@room = rooms(:watercooler)
|
||||
# Establish last-room and CSRF cookies before checking reuse.
|
||||
2.times { get room_url(@room) }
|
||||
ResponseCache.instance.clear
|
||||
end
|
||||
|
||||
teardown do
|
||||
ResponseCache.instance.clear
|
||||
ActionController::Base.allow_forgery_protection = @previous_forgery
|
||||
Rails.cache = @previous_cache
|
||||
ActionController::Base.perform_caching = @previous_caching
|
||||
end
|
||||
|
||||
test "all four read actions reuse completed pages" do
|
||||
urls = [ room_url(@room), room_messages_url(@room), user_sidebar_url(:me), searches_url(q: "hello") ]
|
||||
urls.each do |url|
|
||||
get url
|
||||
assert_response :success
|
||||
end
|
||||
ResponseCache.instance.expects(:write).never
|
||||
urls.each do |url|
|
||||
get url
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "pagination caches the completed HTML rather than silently bypassing admission" do
|
||||
get room_messages_url(@room)
|
||||
assert_response :success
|
||||
assert_equal "text/html", response.media_type
|
||||
MessagesController.any_instance.expects(:find_paged_messages).never
|
||||
get room_messages_url(@room)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "clients without a persisted CSRF session still reuse token-neutral HTML" do
|
||||
cookies["_campfire_session"] = @login_cookie
|
||||
get room_url(@room)
|
||||
first = css_select('meta[name="csrf-token"]').first["content"]
|
||||
cookies["_campfire_session"] = @login_cookie
|
||||
ResponseCache.instance.expects(:write).never
|
||||
get room_url(@room)
|
||||
second = css_select('meta[name="csrf-token"]').first["content"]
|
||||
assert_response :success
|
||||
assert_not_equal first, second
|
||||
assert_no_match /campfire-csrf-/, response.body
|
||||
post room_messages_url(@room, format: :turbo_stream), params: {
|
||||
authenticity_token: second, message: { body: "fresh replay token works", client_message_id: "cache-replay-token" } }
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "cached tokens stay fresh and literal token-like text survives" do
|
||||
get room_url(@room)
|
||||
literal = css_select('meta[name="csrf-token"]').first["content"]
|
||||
@room.messages.create!(creator: users(:david), body: "literal #{literal}")
|
||||
get room_url(@room)
|
||||
first = css_select('meta[name="csrf-token"]').first["content"]
|
||||
get room_url(@room)
|
||||
second = css_select('meta[name="csrf-token"]').first["content"]
|
||||
assert_not_equal first, second
|
||||
assert_includes response.body, "literal #{literal}"
|
||||
assert_no_match /campfire-csrf-/, response.body
|
||||
|
||||
post room_messages_url(@room, format: :turbo_stream), params: {
|
||||
authenticity_token: second, message: { body: "cached token works", client_message_id: "cache-token" } }
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "local and foreign commits invalidate pages and nested fragments" do
|
||||
message = @room.messages.ordered.last
|
||||
get room_url(@room)
|
||||
@room.messages.create!(creator: users(:david), body: "local commit")
|
||||
get room_url(@room)
|
||||
assert_includes response.body, "local commit"
|
||||
|
||||
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign creator", message.creator_id)
|
||||
get room_url(@room)
|
||||
assert_includes response.body, "Foreign creator"
|
||||
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign message body", message.id)
|
||||
get room_url(@room)
|
||||
assert_includes response.body, "foreign message body"
|
||||
end
|
||||
|
||||
test "cached room access and sessions are checked afresh" do
|
||||
get room_url(@room)
|
||||
foreign_write("DELETE FROM memberships WHERE room_id = ? AND user_id = ?", @room.id, users(:david).id)
|
||||
get room_url(@room)
|
||||
assert_redirected_to root_url
|
||||
get user_sidebar_url(:me)
|
||||
foreign_write("DELETE FROM sessions WHERE user_id = ?", users(:david).id)
|
||||
get user_sidebar_url(:me)
|
||||
assert_redirected_to new_session_url
|
||||
end
|
||||
|
||||
test "conditional requests keep native validators" do
|
||||
get room_messages_url(@room)
|
||||
etag = response.headers["ETag"]
|
||||
assert etag.present?
|
||||
get room_messages_url(@room), headers: { "If-None-Match" => etag }
|
||||
assert_response :not_modified
|
||||
end
|
||||
|
||||
test "foreign presentation changes cannot return a false not-modified response" do
|
||||
get room_messages_url(@room)
|
||||
etag = response.headers["ETag"]
|
||||
message = @room.messages.ordered.last
|
||||
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "changed presentation", message.id)
|
||||
get room_messages_url(@room), headers: { "If-None-Match" => etag }
|
||||
assert_response :success
|
||||
assert_includes response.body, "changed presentation"
|
||||
assert_not_equal etag, response.headers["ETag"]
|
||||
end
|
||||
|
||||
test "origins and frame variants have separate entries" do
|
||||
get room_url(@room)
|
||||
ResponseCache.instance.expects(:write).twice
|
||||
get room_url(@room), headers: { "Turbo-Frame" => "different-frame" }
|
||||
host! "once.campfire.test:8081"
|
||||
get room_path(@room)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "cache can be disabled" do
|
||||
ResponseCache.instance.stubs(:budget).returns(0)
|
||||
ResponseCache.instance.expects(:read).never
|
||||
2.times { get room_url(@room) }
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "a commit after authentication cannot admit captured user fields" do
|
||||
Users::SidebarsController.any_instance.stubs(:set_version_headers).with do
|
||||
foreign_write("UPDATE users SET name = ? WHERE id = ?", "During authentication", users(:david).id)
|
||||
true
|
||||
end
|
||||
get user_sidebar_url(:me)
|
||||
Users::SidebarsController.any_instance.unstub(:set_version_headers)
|
||||
ResponseCache.instance.expects(:write).once
|
||||
get user_sidebar_url(:me)
|
||||
assert_includes response.body, "During authentication"
|
||||
end
|
||||
|
||||
private
|
||||
def foreign_write(sql, *bindings)
|
||||
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
|
||||
database.execute(sql, bindings)
|
||||
end
|
||||
# Fixtures share this thread's query cache outside the request executor.
|
||||
ActiveRecord::Base.clear_query_caches_for_current_thread
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,144 @@
|
||||
require "test_helper"
|
||||
|
||||
class FragmentRenderingTest < ActionDispatch::IntegrationTest
|
||||
self.use_transactional_tests = false
|
||||
|
||||
setup do
|
||||
host! "once.campfire.test"
|
||||
sign_in :david
|
||||
@previous_caching = ActionController::Base.perform_caching
|
||||
@previous_message_caching = MessagesController.perform_caching
|
||||
@class_store = ApplicationController.cache_store
|
||||
@global_store = Rails.cache
|
||||
ActionController::Base.perform_caching = true
|
||||
MessagesController.perform_caching = true
|
||||
FragmentCache.store.clear
|
||||
ResponseCache.instance.clear
|
||||
@room = rooms(:watercooler)
|
||||
@message = @room.messages.ordered.last
|
||||
end
|
||||
|
||||
teardown do
|
||||
ActionController::Base.perform_caching = @previous_caching
|
||||
MessagesController.perform_caching = @previous_message_caching
|
||||
FragmentCache.store.clear
|
||||
ResponseCache.instance.clear
|
||||
end
|
||||
|
||||
test "native collection caching uses the bounded store without replacing shared stores" do
|
||||
assert_same FragmentCache.store, ApplicationController.new.cache_store
|
||||
assert_same FragmentCache.store, ActionView::PartialRenderer.collection_cache
|
||||
assert_same @class_store, ApplicationController.cache_store
|
||||
assert_same @global_store, Rails.cache
|
||||
ResponseCache.instance.stubs(:budget).returns(0)
|
||||
get room_messages_url(@room)
|
||||
assert_response :success
|
||||
hits = []
|
||||
ActiveSupport::Notifications.subscribed(->(event) { hits.concat(event.payload[:hits]) }, "cache_read_multi.active_support") do
|
||||
get room_messages_url(@room)
|
||||
end
|
||||
assert_response :success
|
||||
assert_not_empty hits
|
||||
token = users(:david).sessions.order(:id).last.token
|
||||
assert_not_includes hits.join, token
|
||||
end
|
||||
|
||||
test "disabled page caching retains fresh native fragments after foreign leaf writes" do
|
||||
ResponseCache.instance.stubs(:budget).returns(0)
|
||||
get room_messages_url(@room)
|
||||
assert_response :success
|
||||
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign disabled body", @message.id)
|
||||
get room_messages_url(@room)
|
||||
assert_response :success
|
||||
assert_includes response.body, "foreign disabled body"
|
||||
end
|
||||
|
||||
test "conditional pagination invalidates warmed native fragments without timestamp changes" do
|
||||
get room_messages_url(@room), headers: { "If-None-Match" => "unmatched" }
|
||||
assert_response :success
|
||||
etag = response.headers["ETag"]
|
||||
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign conditional body", @message.id)
|
||||
get room_messages_url(@room), headers: { "If-None-Match" => etag }
|
||||
assert_response :success
|
||||
assert_includes response.body, "foreign conditional body"
|
||||
end
|
||||
|
||||
test "a foreign commit after capture bypasses old native fragment lookups" do
|
||||
ResponseCache.instance.stubs(:budget).returns(0)
|
||||
get room_messages_url(@room)
|
||||
assert_response :success
|
||||
MessagesController.any_instance.stubs(:set_version_headers).with do
|
||||
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign before fragments", @message.id)
|
||||
true
|
||||
end
|
||||
get room_messages_url(@room), headers: { "If-None-Match" => "unmatched" }
|
||||
assert_response :success
|
||||
assert_includes response.body, "foreign before fragments"
|
||||
end
|
||||
|
||||
test "refresh streams recheck native cached creator body and boost presentation" do
|
||||
message = messages(:first)
|
||||
room = message.room
|
||||
get room_refresh_url(room, format: :turbo_stream), params: { since: 0 }
|
||||
assert_response :success
|
||||
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign refreshed body", message.id)
|
||||
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign refreshed creator", message.creator_id)
|
||||
foreign_write("UPDATE boosts SET content = ? WHERE message_id = ?", "Foreign refreshed boost", message.id)
|
||||
get room_refresh_url(room, format: :turbo_stream), params: { since: 0 }
|
||||
assert_response :success
|
||||
assert_includes response.body, "foreign refreshed body"
|
||||
assert_includes response.body, "Foreign refreshed creator"
|
||||
assert_includes response.body, "Foreign refreshed boost"
|
||||
end
|
||||
|
||||
test "Jbuilder fragments use the bounded store and do not create HTML token state" do
|
||||
api = open_session { |session| session.host! "once.campfire.test" }
|
||||
path = room_bot_messages_path(@room, users(:bender).bot_key)
|
||||
api.get path
|
||||
assert_equal 200, api.response.status
|
||||
assert_not api.cookies["_campfire_session"]
|
||||
hits = []
|
||||
ActiveSupport::Notifications.subscribed(->(event) { hits << event.payload[:key] if event.payload[:hit] }, "cache_read.active_support") do
|
||||
api.get path
|
||||
end
|
||||
assert_equal 200, api.response.status
|
||||
assert_not_empty hits
|
||||
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign JSON body", @message.id)
|
||||
foreign_write("UPDATE users SET name = ? WHERE id = ?", "Foreign JSON creator", @message.creator_id)
|
||||
api.get path
|
||||
json = JSON.parse(api.response.body).find { |message| message["id"] == @message.id }
|
||||
assert_includes json.dig("body", "html"), "foreign JSON body"
|
||||
assert_equal "Foreign JSON creator", json.dig("creator", "name")
|
||||
api.host! "another.campfire.test:8081"
|
||||
api.get path
|
||||
json = JSON.parse(api.response.body).find { |message| message["id"] == @message.id }
|
||||
assert_includes json["url"], "another.campfire.test:8081"
|
||||
assert_same @global_store, Rails.cache
|
||||
end
|
||||
|
||||
test "detached broadcasts bypass fragments without a pre-render model snapshot" do
|
||||
get room_messages_url(@room)
|
||||
foreign_write("UPDATE action_text_rich_texts SET body = ? WHERE record_type = 'Message' AND record_id = ?", "foreign broadcast body", @message.id)
|
||||
FragmentCache.store.expects(:read).never
|
||||
FragmentCache.store.expects(:write).never
|
||||
FragmentCache.store.expects(:read_multi).never
|
||||
FragmentCache.store.expects(:write_multi).never
|
||||
@message.reload.broadcast_create
|
||||
assert_rendered_turbo_stream_broadcast @room, :messages, action: "append", target: [ @room, :messages ] do |stream|
|
||||
assert_includes stream.to_html, "foreign broadcast body"
|
||||
end
|
||||
body = ApplicationController.render(partial: "messages/message", locals: { message: @message.reload })
|
||||
assert_includes body, "foreign broadcast body"
|
||||
collection = ApplicationController.render(partial: "messages/message", collection: [ @message.reload ], cached: true)
|
||||
assert_includes collection, "foreign broadcast body"
|
||||
assert_not ApplicationController.new.perform_caching
|
||||
end
|
||||
|
||||
private
|
||||
def foreign_write(sql, *bindings)
|
||||
SQLite3::Database.new(ActiveRecord::Base.connection_db_config.database) do |database|
|
||||
database.execute(sql, bindings)
|
||||
end
|
||||
ActiveRecord::Base.clear_query_caches_for_current_thread
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,9 @@
|
||||
require "test_helper"
|
||||
require "active_record/testing/query_assertions"
|
||||
|
||||
class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
|
||||
include ActiveRecord::Assertions::QueryAssertions
|
||||
|
||||
setup do
|
||||
@room = rooms(:watercooler)
|
||||
end
|
||||
@@ -93,7 +96,9 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
|
||||
@room.messages.create!(body: "Filler #{i}", creator: users(:jason), client_message_id: "filler-#{i}")
|
||||
end
|
||||
|
||||
get room_bot_messages_url(@room, users(:bender).bot_key)
|
||||
assert_no_queries_match(/SELECT COUNT\(\*\) FROM "messages"/i) do
|
||||
get room_bot_messages_url(@room, users(:bender).bot_key)
|
||||
end
|
||||
assert_response :success
|
||||
|
||||
json = JSON.parse(response.body)
|
||||
@@ -119,7 +124,10 @@ class Messages::ByBotsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "index in a room with no messages" do
|
||||
get room_bot_messages_url(rooms(:bender_and_kevin), users(:bender).bot_key)
|
||||
room = rooms(:bender_and_kevin)
|
||||
assert_equal 0, room.messages_count
|
||||
|
||||
get room_bot_messages_url(room, users(:bender).bot_key)
|
||||
assert_response :success
|
||||
|
||||
assert_equal [], JSON.parse(response.body)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
require "test_helper"
|
||||
require "tmpdir"
|
||||
|
||||
class ResponseCacheTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@directory = Dir.mktmpdir
|
||||
@database = File.join(@directory, "cache.sqlite3")
|
||||
SQLite3::Database.new(@database) do |database|
|
||||
database.execute("PRAGMA journal_mode=WAL")
|
||||
database.execute("CREATE TABLE values_for_test (value TEXT)")
|
||||
end
|
||||
ActiveRecord::Base.stubs(:connection_db_config).returns(Struct.new(:database).new(@database))
|
||||
@cache = ResponseCache.new
|
||||
@cache.stubs(:budget).returns(1024)
|
||||
end
|
||||
|
||||
teardown do
|
||||
@cache.clear
|
||||
FileUtils.remove_entry(@directory)
|
||||
end
|
||||
|
||||
test "foreign commits expire entries and reject old render admission" do
|
||||
version = @cache.version
|
||||
@cache.write("page", version, entry("first"))
|
||||
assert_equal "first", @cache.read("page", version)[:body]
|
||||
SQLite3::Database.new(@database) { |database| database.execute("INSERT INTO values_for_test VALUES ('committed')") }
|
||||
assert_nil @cache.read("page", version)
|
||||
@cache.write("page", version, entry("old in-flight render"))
|
||||
assert_nil @cache.read("page", @cache.version)
|
||||
@cache.write("page", @cache.version, entry("fresh"))
|
||||
assert_equal "fresh", @cache.read("page", @cache.version)[:body]
|
||||
end
|
||||
|
||||
test "byte budget evicts oldest and bypasses oversized entries and keys" do
|
||||
version = @cache.version
|
||||
@cache.stubs(:budget).returns(512)
|
||||
@cache.write("first", version, entry("a" * 100))
|
||||
@cache.write("second", version, entry("b" * 100))
|
||||
assert_nil @cache.read("first", version)
|
||||
assert_equal "b" * 100, @cache.read("second", version)[:body]
|
||||
@cache.write("oversized", version, entry("c" * 1000))
|
||||
assert_nil @cache.read("oversized", version)
|
||||
@cache.write("k" * 2049, version, entry("small"))
|
||||
assert_nil @cache.read("k" * 2049, version)
|
||||
end
|
||||
|
||||
test "clearing the observer gives later pages a distinct namespace" do
|
||||
version = @cache.version
|
||||
@cache.clear
|
||||
assert_not_equal version, @cache.version
|
||||
end
|
||||
|
||||
test "cold renders coalesce without holding the observer mutex" do
|
||||
version = @cache.version
|
||||
entered = Queue.new
|
||||
release = Queue.new
|
||||
first = Thread.new do
|
||||
@cache.synchronize_render("page", version) do
|
||||
entered << true
|
||||
release.pop
|
||||
@cache.write("page", version, entry("completed render"))
|
||||
end
|
||||
end
|
||||
entered.pop
|
||||
second_started = Queue.new
|
||||
second = Thread.new do
|
||||
second_started << true
|
||||
@cache.synchronize_render("page", version) { @cache.read("page", version) }
|
||||
end
|
||||
second_started.pop
|
||||
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
|
||||
Thread.pass until second.status == "sleep" || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
||||
assert_equal "sleep", second.status
|
||||
assert_nil @cache.read("page", version)
|
||||
assert_equal version, @cache.version
|
||||
release << true
|
||||
first.value
|
||||
assert_equal "completed render", second.value[:body]
|
||||
ensure
|
||||
release << true if release
|
||||
[ first, second ].compact.each { |thread| thread.join(2) }
|
||||
end
|
||||
|
||||
private
|
||||
def entry(body)
|
||||
{ body: body, marker: "unexposed-marker", headers: {} }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,237 @@
|
||||
require "test_helper"
|
||||
require "sqlite3"
|
||||
|
||||
# Destructive trigger DDL and foreign connections need their own file so parallel
|
||||
# workers (and transactional tests in messages_count_test) keep a stable trigger set.
|
||||
class Room::MessagesCountLifecycleTest < ActiveSupport::TestCase
|
||||
self.use_transactional_tests = false
|
||||
|
||||
setup do
|
||||
Room::MessagesCount.ensure!
|
||||
@room = rooms(:designers)
|
||||
@other_room = rooms(:pets)
|
||||
Room::MessagesCount.backfill!
|
||||
@room.reload
|
||||
@other_room.reload
|
||||
end
|
||||
|
||||
teardown do
|
||||
Message.where("client_message_id LIKE ?", "count-%").delete_all
|
||||
Room::MessagesCount.ensure!
|
||||
Room::MessagesCount.backfill!
|
||||
end
|
||||
|
||||
test "ensure! installs missing triggers and keeps accurate counts" do
|
||||
before = @room.reload.messages_count
|
||||
Room::MessagesCount.uninstall!
|
||||
|
||||
assert_not Room::MessagesCount.triggers_installed?
|
||||
|
||||
Room::MessagesCount.ensure!
|
||||
|
||||
assert Room::MessagesCount.triggers_installed?
|
||||
assert_equal before, @room.reload.messages_count
|
||||
assert_equal @room.messages.count, @room.messages_count
|
||||
|
||||
assert_difference -> { @room.reload.messages_count }, +1 do
|
||||
@room.messages.create!(creator: users(:jason), body: "After ensure", client_message_id: "count-ensure")
|
||||
end
|
||||
end
|
||||
|
||||
test "ensure! repairs a partial trigger install and backfills drifted counts" do
|
||||
Room::MessagesCount.uninstall!
|
||||
ActiveRecord::Base.connection.execute <<~SQL
|
||||
CREATE TRIGGER #{Room::MessagesCount::INSERT_TRIGGER} AFTER INSERT ON messages
|
||||
BEGIN
|
||||
UPDATE rooms SET messages_count = messages_count + 1 WHERE id = NEW.room_id;
|
||||
END
|
||||
SQL
|
||||
|
||||
assert Room::MessagesCount.trigger_installed?(ActiveRecord::Base.connection, Room::MessagesCount::INSERT_TRIGGER)
|
||||
assert_not Room::MessagesCount.trigger_installed?(ActiveRecord::Base.connection, Room::MessagesCount::DELETE_TRIGGER)
|
||||
|
||||
# Delete fires with no delete trigger → counter drifts high.
|
||||
drifted = @room.messages.create!(creator: users(:jason), body: "Drift", client_message_id: "count-partial-drift")
|
||||
ActiveRecord::Base.connection.execute("DELETE FROM messages WHERE id = #{drifted.id}")
|
||||
assert_operator @room.reload.messages_count, :>, @room.messages.count
|
||||
|
||||
Room::MessagesCount.ensure!
|
||||
|
||||
assert Room::MessagesCount.triggers_installed?
|
||||
assert_equal @room.messages.count, @room.reload.messages_count
|
||||
end
|
||||
|
||||
test "ensure! backfills writes that landed while the insert trigger was missing" do
|
||||
Room::MessagesCount.uninstall!
|
||||
before_count = @room.messages.count
|
||||
before_cached = @room.reload.messages_count
|
||||
assert_equal before_count, before_cached
|
||||
|
||||
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
|
||||
ActiveRecord::Base.connection_pool.release_connection
|
||||
|
||||
SQLite3::Database.new(path) do |db|
|
||||
db.busy_timeout = 5_000
|
||||
db.execute(
|
||||
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
[ @room.id, users(:david).id, "count-missing-insert", now, now ]
|
||||
)
|
||||
end
|
||||
|
||||
assert_equal before_count + 1, @room.messages.count
|
||||
assert_equal before_cached, @room.reload.messages_count
|
||||
|
||||
Room::MessagesCount.ensure!
|
||||
|
||||
assert Room::MessagesCount.triggers_installed?
|
||||
assert_equal @room.messages.count, @room.reload.messages_count
|
||||
assert_equal before_cached + 1, @room.messages_count
|
||||
end
|
||||
|
||||
test "concurrent ensure! repairs leave triggers installed and counts accurate" do
|
||||
Room::MessagesCount.uninstall!
|
||||
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
|
||||
ActiveRecord::Base.connection_pool.release_connection
|
||||
|
||||
SQLite3::Database.new(path) do |db|
|
||||
db.busy_timeout = 5_000
|
||||
db.execute(
|
||||
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
[ @room.id, users(:david).id, "count-concurrent-drift", now, now ]
|
||||
)
|
||||
end
|
||||
|
||||
assert_not_equal @room.messages.count, @room.reload.messages_count
|
||||
|
||||
errors = []
|
||||
errors_mutex = Mutex.new
|
||||
|
||||
workers = 2.times.map do
|
||||
Thread.new do
|
||||
ActiveRecord::Base.connection_pool.with_connection do |connection|
|
||||
Room::MessagesCount.ensure!(connection)
|
||||
end
|
||||
rescue => error
|
||||
errors_mutex.synchronize { errors << error }
|
||||
end
|
||||
end
|
||||
|
||||
workers.each(&:join)
|
||||
|
||||
assert_empty errors, -> { errors.map(&:full_message).join("\n") }
|
||||
assert Room::MessagesCount.triggers_installed?
|
||||
assert_equal @room.messages.count, @room.reload.messages_count
|
||||
end
|
||||
|
||||
test "writes concurrent with ensure! repair leave accurate counts" do
|
||||
Room::MessagesCount.uninstall!
|
||||
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
|
||||
ActiveRecord::Base.connection_pool.release_connection
|
||||
|
||||
errors = []
|
||||
errors_mutex = Mutex.new
|
||||
|
||||
ensure_thread = Thread.new do
|
||||
ActiveRecord::Base.connection_pool.with_connection do |connection|
|
||||
Room::MessagesCount.ensure!(connection)
|
||||
end
|
||||
rescue => error
|
||||
errors_mutex.synchronize { errors << error }
|
||||
end
|
||||
|
||||
insert_thread = Thread.new do
|
||||
SQLite3::Database.new(path) do |db|
|
||||
db.busy_timeout = 10_000
|
||||
db.execute(
|
||||
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
[ @room.id, users(:david).id, "count-during-repair", now, now ]
|
||||
)
|
||||
end
|
||||
rescue => error
|
||||
errors_mutex.synchronize { errors << error }
|
||||
end
|
||||
|
||||
[ ensure_thread, insert_thread ].each(&:join)
|
||||
|
||||
assert_empty errors, -> { errors.map(&:full_message).join("\n") }
|
||||
assert Room::MessagesCount.triggers_installed?
|
||||
assert_equal @room.messages.count, @room.reload.messages_count
|
||||
assert Message.exists?(client_message_id: "count-during-repair")
|
||||
end
|
||||
|
||||
test "install! never exposes a partial trigger set to other connections" do
|
||||
Room::MessagesCount.uninstall!
|
||||
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||
ActiveRecord::Base.connection_pool.release_connection
|
||||
|
||||
stop = false
|
||||
partial_snapshots = []
|
||||
snapshots_mutex = Mutex.new
|
||||
|
||||
watcher = Thread.new do
|
||||
SQLite3::Database.new(path) do |db|
|
||||
db.busy_timeout = 5_000
|
||||
until stop
|
||||
names = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (#{Room::MessagesCount::TRIGGERS.map { "'#{it}'" }.join(", ")})"
|
||||
).flatten
|
||||
if names.any? && names.sort != Room::MessagesCount::TRIGGERS.sort
|
||||
snapshots_mutex.synchronize { partial_snapshots << names.sort }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
25.times { Room::MessagesCount.install! }
|
||||
stop = true
|
||||
watcher.join
|
||||
|
||||
assert_empty partial_snapshots
|
||||
assert Room::MessagesCount.triggers_installed?
|
||||
end
|
||||
|
||||
test "foreign SQLite connections keep the counter in step" do
|
||||
path = File.expand_path(ActiveRecord::Base.connection_db_config.database)
|
||||
now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
|
||||
before = @room.reload.messages_count
|
||||
other_before = @other_room.reload.messages_count
|
||||
|
||||
ActiveRecord::Base.connection_pool.release_connection
|
||||
|
||||
SQLite3::Database.new(path) do |db|
|
||||
db.busy_timeout = 5_000
|
||||
db.execute(
|
||||
"INSERT INTO messages (room_id, creator_id, client_message_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
[ @room.id, users(:david).id, "count-foreign-insert", now, now ]
|
||||
)
|
||||
end
|
||||
|
||||
assert_equal before + 1, @room.reload.messages_count
|
||||
assert_equal @room.messages.count, @room.messages_count
|
||||
|
||||
foreign_id = Message.find_by!(client_message_id: "count-foreign-insert").id
|
||||
|
||||
ActiveRecord::Base.connection_pool.release_connection
|
||||
|
||||
SQLite3::Database.new(path) do |db|
|
||||
db.busy_timeout = 5_000
|
||||
db.execute("UPDATE messages SET room_id = ? WHERE id = ?", [ @other_room.id, foreign_id ])
|
||||
end
|
||||
|
||||
assert_equal before, @room.reload.messages_count
|
||||
assert_equal other_before + 1, @other_room.reload.messages_count
|
||||
|
||||
ActiveRecord::Base.connection_pool.release_connection
|
||||
|
||||
SQLite3::Database.new(path) do |db|
|
||||
db.busy_timeout = 5_000
|
||||
db.execute("DELETE FROM messages WHERE id = ?", [ foreign_id ])
|
||||
end
|
||||
|
||||
assert_equal before, @room.reload.messages_count
|
||||
assert_equal other_before, @other_room.reload.messages_count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,152 @@
|
||||
require "test_helper"
|
||||
require "rake"
|
||||
require "tmpdir"
|
||||
|
||||
Rails.application.load_tasks unless Rake::Task.task_defined?("room_messages_count:ensure")
|
||||
|
||||
class Room::MessagesCountTasksTest < ActiveSupport::TestCase
|
||||
self.use_transactional_tests = false
|
||||
|
||||
setup do
|
||||
@directory = Dir.mktmpdir("campfire-counter-tasks")
|
||||
@schema = File.join(@directory, "schema.rb")
|
||||
@migrations = File.join(@directory, "migrations")
|
||||
Dir.mkdir(@migrations)
|
||||
File.write(@schema, <<~RUBY)
|
||||
ActiveRecord::Schema[8.2].define(version: 0) do
|
||||
create_table :rooms, force: true do |t|
|
||||
t.integer :messages_count, default: 0, null: false
|
||||
end
|
||||
create_table :messages, force: true do |t|
|
||||
t.integer :room_id
|
||||
t.string :client_message_id
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
@tasks = ActiveRecord::Tasks::DatabaseTasks
|
||||
@original_database_url = ENV.delete("DATABASE_URL")
|
||||
@original_environment = @tasks.env
|
||||
@original_configurations = ActiveRecord::Base.configurations
|
||||
@original_migrations = @tasks.migrations_paths
|
||||
@original_dump = ActiveRecord.dump_schema_after_migration
|
||||
@original_seed_loader = @tasks.seed_loader
|
||||
@tasks.migrations_paths = [ @migrations ]
|
||||
ActiveRecord.dump_schema_after_migration = false
|
||||
end
|
||||
|
||||
teardown do
|
||||
ActiveRecord::Base.configurations = @original_configurations
|
||||
ENV["DATABASE_URL"] = @original_database_url if @original_database_url
|
||||
@tasks.env = @original_environment
|
||||
@tasks.migrations_paths = @original_migrations
|
||||
@tasks.seed_loader = @original_seed_loader
|
||||
ActiveRecord.dump_schema_after_migration = @original_dump
|
||||
FileUtils.remove_entry(@directory)
|
||||
end
|
||||
|
||||
test "development schema task repairs both databases before restoring its original pool" do
|
||||
development = configuration("development")
|
||||
test = configuration("test")
|
||||
ActiveRecord::Base.configurations = { "development" => { "primary" => development.configuration_hash }, "test" => { "primary" => test.configuration_hash } }
|
||||
@tasks.env = "development"
|
||||
|
||||
@tasks.with_temporary_connection(development) do |connection|
|
||||
@tasks.load_schema(development)
|
||||
original_path = connection.pool.db_config.database
|
||||
|
||||
2.times do
|
||||
task = Rake::Task["db:schema:load"]
|
||||
task.all_prerequisite_tasks.each(&:reenable)
|
||||
task.reenable
|
||||
task.invoke
|
||||
|
||||
assert_equal original_path, ActiveRecord::Base.connection_db_config.database
|
||||
assert Room::MessagesCount.triggers_installed?(connection)
|
||||
@tasks.with_temporary_connection(test) do |test_connection|
|
||||
assert Room::MessagesCount.triggers_installed?(test_connection)
|
||||
assert_counter_tracks_insert(test_connection)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "test schema task repairs the test database selected by purge" do
|
||||
config = configuration("test")
|
||||
ActiveRecord::Base.configurations = { "test" => { "primary" => config.configuration_hash } }
|
||||
|
||||
@tasks.with_temporary_connection(config) do |connection|
|
||||
task = Rake::Task["db:test:load_schema"]
|
||||
task.all_prerequisite_tasks.each(&:reenable)
|
||||
task.reenable
|
||||
task.invoke
|
||||
assert Room::MessagesCount.triggers_installed?(connection)
|
||||
assert_counter_tracks_insert(connection)
|
||||
end
|
||||
end
|
||||
|
||||
test "prepare installs schema triggers before loading seeds" do
|
||||
config = configuration("test")
|
||||
ActiveRecord::Base.configurations = { "test" => { "primary" => config.configuration_hash } }
|
||||
seeded = false
|
||||
loader = Object.new
|
||||
loader.define_singleton_method(:load_seed) do
|
||||
connection = ActiveRecord::Base.connection
|
||||
raise "seeds ran without counter triggers" unless Room::MessagesCount.triggers_installed?(connection)
|
||||
connection.execute("INSERT INTO rooms (id) VALUES (1)")
|
||||
connection.execute("INSERT INTO messages (room_id) VALUES (1), (1)")
|
||||
raise "seed counter is stale" unless connection.select_value("SELECT messages_count FROM rooms WHERE id = 1") == 2
|
||||
seeded = true
|
||||
end
|
||||
@tasks.seed_loader = loader
|
||||
|
||||
@tasks.with_temporary_connection(config) do |connection|
|
||||
@tasks.prepare_all
|
||||
assert seeded
|
||||
assert Room::MessagesCount.triggers_installed?(connection)
|
||||
assert_equal 2, connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||
end
|
||||
end
|
||||
|
||||
test "migrate repairs rebuilt message tables and repeats repair with no new migration" do
|
||||
config = configuration("test")
|
||||
File.write(File.join(@migrations, "20261008000100_change_counter_task_message_column.rb"), <<~RUBY)
|
||||
class ChangeCounterTaskMessageColumn < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
change_column :messages, :client_message_id, :text
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
|
||||
@tasks.with_temporary_connection(config) do |connection|
|
||||
@tasks.load_schema(config)
|
||||
Room::MessagesCount.ensure!(connection)
|
||||
assert_counter_tracks_insert(connection)
|
||||
@tasks.migrate
|
||||
assert Room::MessagesCount.triggers_installed?(connection)
|
||||
assert_counter_tracks_insert(connection)
|
||||
|
||||
Room::MessagesCount.uninstall!(connection)
|
||||
connection.execute("INSERT INTO messages (room_id) VALUES (1)")
|
||||
assert_not_equal connection.select_value("SELECT COUNT(*) FROM messages WHERE room_id = 1"), connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||
@tasks.migrate
|
||||
assert Room::MessagesCount.triggers_installed?(connection)
|
||||
assert_equal connection.select_value("SELECT COUNT(*) FROM messages WHERE room_id = 1"), connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def configuration(environment)
|
||||
ActiveRecord::DatabaseConfigurations::HashConfig.new(environment, "primary", {
|
||||
adapter: "sqlite3", database: File.join(@directory, "#{environment}.sqlite3"),
|
||||
default_transaction_mode: :immediate, schema_dump: @schema,
|
||||
migrations_paths: [ @migrations ]
|
||||
})
|
||||
end
|
||||
|
||||
def assert_counter_tracks_insert(connection)
|
||||
connection.execute("INSERT INTO rooms (id) VALUES (1) ON CONFLICT DO NOTHING")
|
||||
before = connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||
connection.execute("INSERT INTO messages (room_id) VALUES (1)")
|
||||
assert_equal before + 1, connection.select_value("SELECT messages_count FROM rooms WHERE id = 1")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
require "test_helper"
|
||||
|
||||
class Room::MessagesCountTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@room = rooms(:designers)
|
||||
@other_room = rooms(:pets)
|
||||
end
|
||||
|
||||
test "fixture rooms start with an accurate messages_count" do
|
||||
[ rooms(:watercooler), rooms(:designers), rooms(:bender_and_kevin) ].each do |room|
|
||||
assert_equal room.messages.count, room.messages_count, "#{room.name || room.id} fixture count"
|
||||
end
|
||||
end
|
||||
|
||||
test "ActiveRecord create and destroy adjust the counter once" do
|
||||
assert_difference -> { @room.reload.messages_count }, +1 do
|
||||
assert_difference -> { @room.messages.count }, +1 do
|
||||
@room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "count-ar-create")
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal @room.messages.count, @room.reload.messages_count
|
||||
|
||||
assert_difference -> { @room.reload.messages_count }, -1 do
|
||||
assert_difference -> { @room.messages.count }, -1 do
|
||||
@room.messages.order(:id).last.destroy
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal @room.messages.count, @room.reload.messages_count
|
||||
end
|
||||
|
||||
test "bulk insert_all and delete_all keep the counter in step" do
|
||||
rows = Array.new(3) do |i|
|
||||
{
|
||||
room_id: @room.id,
|
||||
creator_id: users(:david).id,
|
||||
client_message_id: "count-bulk-#{i}",
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
end
|
||||
|
||||
assert_difference -> { @room.reload.messages_count }, +3 do
|
||||
Message.insert_all!(rows)
|
||||
end
|
||||
|
||||
assert_equal @room.messages.count, @room.reload.messages_count
|
||||
|
||||
assert_difference -> { @room.reload.messages_count }, -3 do
|
||||
@room.messages.where(client_message_id: rows.map { it[:client_message_id] }).delete_all
|
||||
end
|
||||
|
||||
assert_equal @room.messages.count, @room.reload.messages_count
|
||||
end
|
||||
|
||||
test "rolled back writes leave the counter unchanged" do
|
||||
before = @room.reload.messages_count
|
||||
|
||||
Message.transaction do
|
||||
@room.messages.create!(creator: users(:jason), body: "Nope", client_message_id: "count-rollback")
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
|
||||
assert_equal before, @room.reload.messages_count
|
||||
assert_nil Message.find_by(client_message_id: "count-rollback")
|
||||
end
|
||||
|
||||
test "moving a message between rooms moves the counter" do
|
||||
message = @room.messages.create!(creator: users(:jason), body: "Move me", client_message_id: "count-move")
|
||||
|
||||
assert_difference -> { @room.reload.messages_count }, -1 do
|
||||
assert_difference -> { @other_room.reload.messages_count }, +1 do
|
||||
message.update!(room: @other_room)
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal @room.messages.count, @room.reload.messages_count
|
||||
assert_equal @other_room.messages.count, @other_room.reload.messages_count
|
||||
end
|
||||
|
||||
test "Message does not declare an ActiveRecord counter_cache" do
|
||||
reflection = Message.reflect_on_association(:room)
|
||||
assert_not reflection.counter_cache_column
|
||||
end
|
||||
end
|
||||
@@ -33,4 +33,13 @@ class ActiveSupport::TestCase
|
||||
teardown do
|
||||
WebMock.reset!
|
||||
end
|
||||
|
||||
# fixtures :all inserts messages before rooms, so INSERT triggers cannot count
|
||||
# yet. Backfill once after load; ensure! covers schema.rb (triggers not dumped).
|
||||
def load_fixtures(config)
|
||||
fixtures = super
|
||||
Room::MessagesCount.ensure!
|
||||
Room::MessagesCount.backfill!
|
||||
fixtures
|
||||
end
|
||||
end
|
||||
@@ -22,6 +22,8 @@ module SystemTestHelper
|
||||
def send_message(message)
|
||||
fill_in_rich_text_area "message_body", with: message
|
||||
click_on "send"
|
||||
# Keep job helpers active through the actual response, not the optimistic DOM.
|
||||
assert_selector ".message[data-message-id]:not([data-pending-message]) .message__body", text: message
|
||||
end
|
||||
|
||||
def within_message(message, &block)
|
||||
|
||||
Reference in new issue
Block a user