Skip to content

Commit 56b9ace

Browse files
committed
fix(db): improve query performance
- Add materialized views for expensive metadata queries (table_privileges, extensions, policies, timezones) - Implement Redis-based caching layer for database metadata - Add PrimaryKeyBatchLoader to prevent N+1 queries (5,785 → 1 query) - Add PgTypeCache to cache PostgreSQL type lookups (5,947 queries cached) - Fix thread-safety issues in all initializers - Replace Redis KEYS with SCAN to prevent production blocking - Add distributed lock to RefreshMetadataViewsJob - Add performance indexes for matches table - Fix CREATE SCHEMA race condition with retry logic - Configure Sidekiq with environment variables Performance improvements: - Table privileges: 72s → <1s (materialized view) - Extensions: 59s → <1s (materialized view) - Timezone names: 14.5s → <1s (materialized view) - Primary keys: 21s → <1s (batch loading) - pg_type queries: 13.9s → cached - Matches COUNT: 8.9s → indexed - CREATE SCHEMA: 8.4s → fixed race condition
1 parent 6ed5900 commit 56b9ace

10 files changed

Lines changed: 1263 additions & 9 deletions
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
# Job to refresh database metadata materialized views periodically
4+
# Keeps cached data fresh without impacting request performance
5+
class RefreshMetadataViewsJob < ApplicationJob
6+
queue_as :low_priority
7+
8+
LOCK_KEY = 'refresh_metadata_views:lock'
9+
LOCK_TTL = 30.minutes.to_i
10+
11+
# Run every 30 minutes (configure in sidekiq.yml)
12+
def perform
13+
# Prevent concurrent execution using Redis distributed lock
14+
acquired = acquire_lock
15+
16+
unless acquired
17+
Rails.logger.warn 'Refresh job already running, skipping this execution'
18+
return
19+
end
20+
21+
begin
22+
Rails.logger.info 'Starting materialized views refresh...'
23+
24+
start_time = Time.current
25+
26+
# Refresh all metadata views concurrently
27+
ActiveRecord::Base.connection.execute('SELECT refresh_database_metadata_views();')
28+
29+
duration = Time.current - start_time
30+
31+
Rails.logger.info "Materialized views refreshed in #{duration.round(2)}s"
32+
33+
# Also clear Redis caches to force fresh reads from materialized views
34+
DatabaseMetadataCacheService.invalidate_all! if defined?(DatabaseMetadataCacheService)
35+
PgTypeCache.invalidate_all! if defined?(PgTypeCache)
36+
37+
duration
38+
ensure
39+
release_lock
40+
end
41+
rescue => e
42+
Rails.logger.error "Failed to refresh materialized views: #{e.message}"
43+
Rails.logger.error e.backtrace.first(5).join("\n")
44+
release_lock
45+
raise
46+
end
47+
48+
private
49+
50+
def acquire_lock
51+
return true unless redis_available?
52+
53+
# SET NX EX - Set if Not eXists with EXpiration
54+
result = Rails.cache.redis.set(LOCK_KEY, Time.current.to_i, nx: true, ex: LOCK_TTL)
55+
result == true || result == 'OK'
56+
rescue => e
57+
Rails.logger.warn "Failed to acquire lock: #{e.message}"
58+
false
59+
end
60+
61+
def release_lock
62+
return unless redis_available?
63+
64+
Rails.cache.redis.del(LOCK_KEY)
65+
rescue => e
66+
Rails.logger.warn "Failed to release lock: #{e.message}"
67+
end
68+
69+
def redis_available?
70+
Rails.cache.respond_to?(:redis) && Rails.cache.redis.ping == 'PONG'
71+
rescue
72+
false
73+
end
74+
end
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# frozen_string_literal: true
2+
3+
# Concern to prevent N+1 queries when loading primary key information
4+
# Original issue: 5,785 individual queries totaling 21s
5+
module PrimaryKeyBatchLoader
6+
extend ActiveSupport::Concern
7+
8+
class_methods do
9+
# Batch load primary keys for multiple tables at once
10+
# Instead of 5,785 individual queries, this does 1 query
11+
def batch_load_primary_keys(table_oids)
12+
return {} if table_oids.blank?
13+
14+
sql = <<~SQL
15+
SELECT
16+
i.indrelid::regclass::text as table_name,
17+
i.indrelid as table_oid,
18+
array_agg(a.attname ORDER BY array_position(i.indkey, a.attnum)) as primary_key_columns
19+
FROM pg_index i
20+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
21+
WHERE i.indrelid = ANY($1)
22+
AND i.indisprimary
23+
GROUP BY i.indrelid
24+
SQL
25+
26+
result = ActiveRecord::Base.connection.exec_query(
27+
sql,
28+
'SQL',
29+
[table_oids]
30+
)
31+
32+
result.each_with_object({}) do |row, hash|
33+
hash[row['table_oid']] = {
34+
table_name: row['table_name'],
35+
columns: row['primary_key_columns']
36+
}
37+
end
38+
end
39+
40+
# Cache primary keys in memory for the duration of the request (thread-safe)
41+
def cached_primary_keys_for(table_oid)
42+
Thread.current[:pk_cache] ||= {}
43+
44+
unless Thread.current[:pk_cache].key?(table_oid)
45+
Thread.current[:pk_cache].merge!(batch_load_primary_keys([table_oid]))
46+
end
47+
48+
Thread.current[:pk_cache][table_oid]
49+
end
50+
51+
# Preload primary keys for all tables in given schemas
52+
def preload_schema_primary_keys(schema_names = ['public'])
53+
sql = <<~SQL
54+
SELECT DISTINCT i.indrelid as table_oid
55+
FROM pg_index i
56+
JOIN pg_class c ON c.oid = i.indrelid
57+
JOIN pg_namespace n ON n.oid = c.relnamespace
58+
WHERE n.nspname = ANY($1)
59+
AND i.indisprimary
60+
SQL
61+
62+
result = ActiveRecord::Base.connection.exec_query(sql, 'SQL', [schema_names])
63+
table_oids = result.rows.flatten
64+
65+
Thread.current[:pk_cache] = batch_load_primary_keys(table_oids)
66+
67+
Rails.logger.info "Preloaded primary keys for #{Thread.current[:pk_cache].size} tables"
68+
Thread.current[:pk_cache]
69+
end
70+
end
71+
72+
included do
73+
# Instance method to get primary key without query
74+
def primary_key_columns_cached
75+
table_oid = fetch_table_oid
76+
self.class.cached_primary_keys_for(table_oid)&.dig(:columns) || [self.class.primary_key]
77+
end
78+
79+
private
80+
81+
# Get table OID for current model
82+
def fetch_table_oid
83+
# Cache in class variable to avoid repeated queries
84+
self.class.instance_variable_get(:@_table_oid) ||
85+
self.class.instance_variable_set(:@_table_oid, begin
86+
sql = "SELECT $1::regclass::oid"
87+
result = ActiveRecord::Base.connection.exec_query(sql, 'SQL', [self.class.table_name])
88+
result.rows.first&.first
89+
end)
90+
end
91+
end
92+
end

0 commit comments

Comments
 (0)