rails_pulse 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1a0c55a1386d00790c4bada3da5ed8b34c17e670c7bf1a0c8c55a55c662b16c
4
- data.tar.gz: 6de45806d2c72656dcc2ef06e61e00a7dd013e826a69a3f12f49cecf9efd360e
3
+ metadata.gz: b54f69a078537dd6dabc4074623c3deaf4b0123f4c229b57c706583f104d1b62
4
+ data.tar.gz: 2cd90650cc8ceb9cf15dac48c4d4695113c73a52fdb4661153fa5c45cbf3b378
5
5
  SHA512:
6
- metadata.gz: a946a339e72c604b0202f28f2e5650f9034619c1737f308cc14a640331a9d25ed60cfa86fc03c2c5a5bc9ab0793ab661844c93bcd1d394d0eedf7984e2fca211
7
- data.tar.gz: ebb59bb9f9c60c0a9b5ce5ab0a778d08396a841cab960b6f4f4a16a41ca86a38e5ab54e3902b949327b84f5bb6618a7de35366993fded467e8a3b6e5e946ddc6
6
+ metadata.gz: 125303510a989e6de226b732d14fd76d001b5641feb4f93bd8ae5daf07ec73b2328c022097c36be6b4153cd270a9344bf8d4f14e1364843b96fdabd07beb0aab
7
+ data.tar.gz: d262a5d599007772f1cccc087b6166fd3a151f93f3cdf990bcd37b3ffed6dc6df3aa785a959ae0ae92ac56df545d9a299aed1ec41e3f3d605adb868ce9bb22d0
data/CHANGELOG.md CHANGED
@@ -5,7 +5,16 @@ All notable changes to Rails Pulse will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## 0.4.2 - 2025-10-01
9
+
10
+ ### Changed
11
+
12
+ - Patch releases can be cut from a stable branch (`0-4-stable` for 0.4.x) while `main` carries the next release; `bin/release` and CI accept those branches.
13
+
14
+ ### Fixed
15
+
16
+ - **Cleanup deletes in bounded batches.** A table far over its limit was trimmed with one statement whose size grew with the overage, which could exhaust the database server's memory on a busy app; every delete now removes at most 5,000 rows per statement. (#322)
17
+ - **One failed cleanup stage no longer stops the rest.** The remaining tables are still cleaned and the error is raised once they have finished. (#322)
9
18
 
10
19
  ## [0.4.1] - 2026-09-23
11
20
 
@@ -1,5 +1,13 @@
1
1
  module RailsPulse
2
2
  class CleanupService
3
+ # Rows removed per DELETE statement. Statement size, lock time and
4
+ # transaction length stay flat however far a table has outgrown its limits.
5
+ BATCH_SIZE = 5_000
6
+
7
+ # Parent rows handled per round when their child rows must go first. The
8
+ # parent ids are held in Ruby and sent back as a list, so this bounds it.
9
+ PARENT_BATCH_SIZE = 1_000
10
+
3
11
  def self.perform
4
12
  new.perform
5
13
  end
@@ -11,6 +19,7 @@ module RailsPulse
11
19
  count_based: {},
12
20
  total_deleted: 0
13
21
  }
22
+ @failures = []
14
23
  end
15
24
 
16
25
  def perform
@@ -23,11 +32,65 @@ module RailsPulse
23
32
  perform_summary_cleanup
24
33
 
25
34
  log_cleanup_summary
35
+ raise @failures.first[:error] if @failures.any?
36
+
26
37
  @stats
27
38
  end
28
39
 
29
40
  private
30
41
 
42
+ # Runs one table's cleanup and records its count. A failure is logged and
43
+ # held until every stage has run, so one table that cannot be cleaned does
44
+ # not stop the others; each stage removes its own child rows or skips rows
45
+ # that still have them, so none depends on an earlier stage succeeding.
46
+ def run_stage(group, name)
47
+ @stats[group][name] = yield
48
+ rescue StandardError => e
49
+ @stats[group][name] = 0
50
+ @failures << { stage: "#{name} (#{group})", error: e }
51
+ RailsPulse.logger.error "Cleanup stage #{name} (#{group}) failed: #{e.class}: #{e.message}"
52
+ end
53
+
54
+ # Deletes the relation's rows in statements of at most BATCH_SIZE rows,
55
+ # choosing each batch inside the database. `order_column` decides which
56
+ # rows go first; `limit` stops after that many rows.
57
+ def delete_in_batches(relation, order_column: nil, limit: nil)
58
+ relation = relation.order(order_column => :asc) if order_column
59
+ deleted = 0
60
+
61
+ loop do
62
+ batch_size = limit ? [ BATCH_SIZE, limit - deleted ].min : BATCH_SIZE
63
+ break if batch_size <= 0
64
+
65
+ count = relation.limit(batch_size).delete_all
66
+ deleted += count
67
+ break if count < batch_size
68
+ end
69
+
70
+ deleted
71
+ end
72
+
73
+ # Deletes parent rows oldest first, removing each batch's child rows before
74
+ # the parents — there is no ON DELETE CASCADE on the foreign keys. Returns
75
+ # the number of parent rows deleted.
76
+ def delete_parents_in_batches(parents, order_column:, children:, foreign_key:, limit: nil)
77
+ deleted = 0
78
+
79
+ loop do
80
+ batch_size = limit ? [ PARENT_BATCH_SIZE, limit - deleted ].min : PARENT_BATCH_SIZE
81
+ break if batch_size <= 0
82
+
83
+ ids = parents.order(order_column => :asc).limit(batch_size).pluck(:id)
84
+ break if ids.empty?
85
+
86
+ delete_in_batches(children.where(foreign_key => ids))
87
+ deleted += parents.where(id: ids).delete_all
88
+ break if ids.size < batch_size
89
+ end
90
+
91
+ deleted
92
+ end
93
+
31
94
  def cleanup_enabled?
32
95
  @config.archiving_enabled
33
96
  end
@@ -41,15 +104,15 @@ module RailsPulse
41
104
  RailsPulse.logger.info "Time-based cleanup: removing records older than #{cutoff_time}"
42
105
 
43
106
  # Clean up in order that respects foreign key constraints
44
- @stats[:time_based][:operations] = cleanup_operations_by_time(cutoff_time)
45
- @stats[:time_based][:job_runs] = cleanup_job_runs_by_time(cutoff_time)
46
- @stats[:time_based][:requests] = cleanup_requests_by_time(cutoff_time)
47
- @stats[:time_based][:queries] = cleanup_queries_by_time(cutoff_time)
48
- @stats[:time_based][:routes] = cleanup_routes_by_time(cutoff_time)
49
- @stats[:time_based][:jobs] = cleanup_jobs_by_time(cutoff_time)
107
+ run_stage(:time_based, :operations) { cleanup_operations_by_time(cutoff_time) }
108
+ run_stage(:time_based, :job_runs) { cleanup_job_runs_by_time(cutoff_time) }
109
+ run_stage(:time_based, :requests) { cleanup_requests_by_time(cutoff_time) }
110
+ run_stage(:time_based, :queries) { cleanup_queries_by_time(cutoff_time) }
111
+ run_stage(:time_based, :routes) { cleanup_routes_by_time(cutoff_time) }
112
+ run_stage(:time_based, :jobs) { cleanup_jobs_by_time(cutoff_time) }
50
113
  if exception_tables_exist?
51
- @stats[:time_based][:exception_occurrences] = cleanup_exception_occurrences_by_time(cutoff_time)
52
- @stats[:time_based][:exception_groups] = cleanup_orphaned_exception_groups
114
+ run_stage(:time_based, :exception_occurrences) { cleanup_exception_occurrences_by_time(cutoff_time) }
115
+ run_stage(:time_based, :exception_groups) { cleanup_orphaned_exception_groups }
53
116
  end
54
117
  end
55
118
 
@@ -58,28 +121,20 @@ module RailsPulse
58
121
 
59
122
  RailsPulse.logger.info "Count-based cleanup: enforcing table record limits"
60
123
 
61
- # Only delete records from periods that have already been summarized.
62
- # This prevents count-based cleanup from removing data before the summary
63
- # job has had a chance to aggregate it.
64
- cutoff = summarized_cutoff
65
-
66
- # Operations: only delete those before the summarization cutoff
67
- ops_scope = cutoff ? RailsPulse::Operation.where("occurred_at < ?", cutoff) : RailsPulse::Operation.none
68
-
69
124
  # Clean up in order that respects foreign key constraints
70
- @stats[:count_based][:operations] = cleanup_by_count(RailsPulse::Operation, :rails_pulse_operations, order_column: :occurred_at, scope: ops_scope)
71
- @stats[:count_based][:job_runs] = cleanup_job_runs_by_count
72
- @stats[:count_based][:requests] = cleanup_requests_by_count
73
- @stats[:count_based][:queries] = cleanup_queries_by_count
74
- @stats[:count_based][:routes] = cleanup_routes_by_count
75
- @stats[:count_based][:jobs] = cleanup_jobs_by_count
125
+ run_stage(:count_based, :operations) { cleanup_operations_by_count }
126
+ run_stage(:count_based, :job_runs) { cleanup_job_runs_by_count }
127
+ run_stage(:count_based, :requests) { cleanup_requests_by_count }
128
+ run_stage(:count_based, :queries) { cleanup_queries_by_count }
129
+ run_stage(:count_based, :routes) { cleanup_routes_by_count }
130
+ run_stage(:count_based, :jobs) { cleanup_jobs_by_count }
76
131
  if exception_tables_exist?
77
- @stats[:count_based][:exception_occurrences] = cleanup_exception_occurrences_by_count
78
- @stats[:count_based][:exception_groups] = cleanup_exception_groups_by_count
79
- @stats[:count_based][:orphaned_exception_groups] = cleanup_orphaned_exception_groups
132
+ run_stage(:count_based, :exception_occurrences) { cleanup_exception_occurrences_by_count }
133
+ run_stage(:count_based, :exception_groups) { cleanup_exception_groups_by_count }
134
+ run_stage(:count_based, :orphaned_exception_groups) { cleanup_orphaned_exception_groups }
80
135
  end
81
136
  if deployments_table_exists?
82
- @stats[:count_based][:deployments] = cleanup_deployments_by_count
137
+ run_stage(:count_based, :deployments) { cleanup_deployments_by_count }
83
138
  end
84
139
  end
85
140
 
@@ -101,56 +156,77 @@ module RailsPulse
101
156
  return 0 unless max_records
102
157
 
103
158
  relation = scope || model_class
104
- current_count = relation.count
105
- return 0 if current_count <= max_records
159
+ overage = relation.count - max_records
160
+ return 0 if overage <= 0
106
161
 
107
- records_to_delete = current_count - max_records
108
- ids_to_delete = relation.order(order_column => :asc).limit(records_to_delete).pluck(:id)
109
- model_class.where(id: ids_to_delete).delete_all
110
- records_to_delete
162
+ delete_in_batches(relation, order_column: order_column, limit: overage)
111
163
  end
112
164
 
113
165
  # Time-based cleanup methods
114
166
 
115
167
  def cleanup_operations_by_time(cutoff_time)
116
- RailsPulse::Operation.where("occurred_at < ?", cutoff_time).delete_all
168
+ delete_in_batches(RailsPulse::Operation.where("occurred_at < ?", cutoff_time), order_column: :occurred_at)
117
169
  end
118
170
 
119
171
  def cleanup_requests_by_time(cutoff_time)
120
- request_subquery = RailsPulse::Request.where("occurred_at < ?", cutoff_time).select(:id)
121
- RailsPulse::Operation.where(request_id: request_subquery).delete_all
122
- RailsPulse::Request.where("occurred_at < ?", cutoff_time).delete_all
172
+ delete_parents_in_batches(
173
+ RailsPulse::Request.where("occurred_at < ?", cutoff_time),
174
+ order_column: :occurred_at,
175
+ children: RailsPulse::Operation,
176
+ foreign_key: :request_id
177
+ )
123
178
  end
124
179
 
125
180
  def cleanup_queries_by_time(cutoff_time)
126
- RailsPulse::Query
127
- .where("created_at < ?", cutoff_time)
128
- .where("NOT EXISTS (SELECT 1 FROM rails_pulse_operations WHERE rails_pulse_operations.query_id = rails_pulse_queries.id)")
129
- .delete_all
181
+ delete_in_batches(
182
+ RailsPulse::Query
183
+ .where("created_at < ?", cutoff_time)
184
+ .where("NOT EXISTS (SELECT 1 FROM rails_pulse_operations WHERE rails_pulse_operations.query_id = rails_pulse_queries.id)")
185
+ )
130
186
  end
131
187
 
132
188
  def cleanup_routes_by_time(cutoff_time)
133
- RailsPulse::Route
134
- .where("created_at < ?", cutoff_time)
135
- .where("NOT EXISTS (SELECT 1 FROM rails_pulse_requests WHERE rails_pulse_requests.route_id = rails_pulse_routes.id)")
136
- .delete_all
189
+ delete_in_batches(
190
+ RailsPulse::Route
191
+ .where("created_at < ?", cutoff_time)
192
+ .where("NOT EXISTS (SELECT 1 FROM rails_pulse_requests WHERE rails_pulse_requests.route_id = rails_pulse_routes.id)")
193
+ )
137
194
  end
138
195
 
139
196
  def cleanup_job_runs_by_time(cutoff_time)
140
- job_run_subquery = RailsPulse::JobRun.where("occurred_at < ?", cutoff_time).select(:id)
141
- RailsPulse::Operation.where(job_run_id: job_run_subquery).delete_all
142
- RailsPulse::JobRun.where("occurred_at < ?", cutoff_time).delete_all
197
+ delete_parents_in_batches(
198
+ RailsPulse::JobRun.where("occurred_at < ?", cutoff_time),
199
+ order_column: :occurred_at,
200
+ children: RailsPulse::Operation,
201
+ foreign_key: :job_run_id
202
+ )
143
203
  end
144
204
 
145
205
  def cleanup_jobs_by_time(cutoff_time)
146
- RailsPulse::Job
147
- .where("created_at < ?", cutoff_time)
148
- .where("NOT EXISTS (SELECT 1 FROM rails_pulse_job_runs WHERE rails_pulse_job_runs.job_id = rails_pulse_jobs.id)")
149
- .delete_all
206
+ delete_in_batches(
207
+ RailsPulse::Job
208
+ .where("created_at < ?", cutoff_time)
209
+ .where("NOT EXISTS (SELECT 1 FROM rails_pulse_job_runs WHERE rails_pulse_job_runs.job_id = rails_pulse_jobs.id)")
210
+ )
150
211
  end
151
212
 
152
213
  # Count-based cleanup methods (complex cases that need custom scoping)
153
214
 
215
+ # Only operations from periods that have already been summarized are
216
+ # deleted, so count-based cleanup never removes data the summary job has
217
+ # not yet aggregated.
218
+ def cleanup_operations_by_count
219
+ cutoff = summarized_cutoff
220
+ return 0 unless cutoff
221
+
222
+ cleanup_by_count(
223
+ RailsPulse::Operation,
224
+ :rails_pulse_operations,
225
+ order_column: :occurred_at,
226
+ scope: RailsPulse::Operation.where("occurred_at < ?", cutoff)
227
+ )
228
+ end
229
+
154
230
  def cleanup_requests_by_count
155
231
  max_records = @config.max_table_records[:rails_pulse_requests]
156
232
  return 0 unless max_records
@@ -159,36 +235,32 @@ module RailsPulse
159
235
  cutoff = summarized_cutoff
160
236
  return 0 unless cutoff
161
237
 
162
- current_count = RailsPulse::Request.count
163
- return 0 if current_count <= max_records
164
-
165
- records_to_delete = current_count - max_records
166
- ids_to_delete = RailsPulse::Request
167
- .where("occurred_at < ?", cutoff)
168
- .order(occurred_at: :asc)
169
- .limit(records_to_delete)
170
- .pluck(:id)
171
- return 0 if ids_to_delete.empty?
238
+ overage = RailsPulse::Request.count - max_records
239
+ return 0 if overage <= 0
172
240
 
173
- RailsPulse::Operation.where(request_id: ids_to_delete).delete_all
174
- RailsPulse::Request.where(id: ids_to_delete).delete_all
175
- ids_to_delete.size
241
+ delete_parents_in_batches(
242
+ RailsPulse::Request.where("occurred_at < ?", cutoff),
243
+ order_column: :occurred_at,
244
+ children: RailsPulse::Operation,
245
+ foreign_key: :request_id,
246
+ limit: overage
247
+ )
176
248
  end
177
249
 
178
250
  def cleanup_job_runs_by_count
179
251
  max_records = @config.max_table_records[:rails_pulse_job_runs]
180
252
  return 0 unless max_records
181
253
 
182
- current_count = RailsPulse::JobRun.count
183
- return 0 if current_count <= max_records
184
-
185
- records_to_delete = current_count - max_records
186
- ids_to_delete = RailsPulse::JobRun.order(occurred_at: :asc).limit(records_to_delete).pluck(:id)
187
- return 0 if ids_to_delete.empty?
254
+ overage = RailsPulse::JobRun.count - max_records
255
+ return 0 if overage <= 0
188
256
 
189
- RailsPulse::Operation.where(job_run_id: ids_to_delete).delete_all
190
- RailsPulse::JobRun.where(id: ids_to_delete).delete_all
191
- ids_to_delete.size
257
+ delete_parents_in_batches(
258
+ RailsPulse::JobRun,
259
+ order_column: :occurred_at,
260
+ children: RailsPulse::Operation,
261
+ foreign_key: :job_run_id,
262
+ limit: overage
263
+ )
192
264
  end
193
265
 
194
266
  def cleanup_queries_by_count
@@ -219,7 +291,10 @@ module RailsPulse
219
291
  end
220
292
 
221
293
  def cleanup_exception_occurrences_by_time(cutoff_time)
222
- exception_occurrences_cleanup_scope.where("occurred_at < ?", cutoff_time).delete_all
294
+ delete_in_batches(
295
+ exception_occurrences_cleanup_scope.where("occurred_at < ?", cutoff_time),
296
+ order_column: :occurred_at
297
+ )
223
298
  end
224
299
 
225
300
  def cleanup_exception_occurrences_by_count
@@ -255,29 +330,33 @@ module RailsPulse
255
330
  records_to_delete = [ overage, deletable_count ].min
256
331
  return 0 if records_to_delete <= 0
257
332
 
258
- ids_to_delete = scope.order(last_seen_at: :asc).limit(records_to_delete).pluck(:id)
259
- return 0 if ids_to_delete.empty?
260
-
261
- RailsPulse::ExceptionOccurrence.where(exception_group_id: ids_to_delete).delete_all
262
- RailsPulse::ExceptionGroup.where(id: ids_to_delete).delete_all
333
+ deleted = delete_parents_in_batches(
334
+ scope,
335
+ order_column: :last_seen_at,
336
+ children: RailsPulse::ExceptionOccurrence,
337
+ foreign_key: :exception_group_id,
338
+ limit: records_to_delete
339
+ )
263
340
 
264
341
  if overage > deletable_count
265
342
  RailsPulse.logger.warn("[RailsPulse] Exception group cap #{max_records} cannot be met: " \
266
- "#{current_count - ids_to_delete.size} remain (#{current_count - deletable_count} are preserved/ignored)")
343
+ "#{current_count - deleted} remain (#{current_count - deletable_count} are preserved/ignored)")
267
344
  end
268
345
 
269
- ids_to_delete.size
346
+ deleted
270
347
  end
271
348
 
272
- # Delete groups whose last occurrence was removed — uses an atomic subquery
273
- # so no orphan can be deleted while a concurrent occurrence is being inserted.
349
+ # Delete groups whose last occurrence was removed — the orphan check and
350
+ # the delete are one statement per batch, so no orphan can be deleted while
351
+ # a concurrent occurrence is being inserted.
274
352
  # preserve: true and ignored groups are never deleted automatically.
275
353
  def cleanup_orphaned_exception_groups
276
- RailsPulse::ExceptionGroup
277
- .where(preserve: false)
278
- .where.not(status: "ignored")
279
- .where("NOT EXISTS (SELECT 1 FROM rails_pulse_exception_occurrences WHERE rails_pulse_exception_occurrences.exception_group_id = rails_pulse_exception_groups.id)")
280
- .delete_all
354
+ delete_in_batches(
355
+ RailsPulse::ExceptionGroup
356
+ .where(preserve: false)
357
+ .where.not(status: "ignored")
358
+ .where("NOT EXISTS (SELECT 1 FROM rails_pulse_exception_occurrences WHERE rails_pulse_exception_occurrences.exception_group_id = rails_pulse_exception_groups.id)")
359
+ )
281
360
  end
282
361
 
283
362
  def perform_summary_cleanup
@@ -287,8 +366,12 @@ module RailsPulse
287
366
  # buys hour-accurate change points further back — at the cost of summary
288
367
  # table growth. Day, week and month summaries are never pruned.
289
368
  cutoff = @config.hourly_summary_retention.ago
290
- deleted = RailsPulse::Summary.where(period_type: "hour").where("period_start < ?", cutoff).delete_all
291
- @stats[:time_based][:hourly_summaries] = deleted
369
+ run_stage(:time_based, :hourly_summaries) do
370
+ delete_in_batches(
371
+ RailsPulse::Summary.where(period_type: "hour").where("period_start < ?", cutoff),
372
+ order_column: :period_start
373
+ )
374
+ end
292
375
  end
293
376
 
294
377
  # Returns the period_end of the most recent completed hourly overall-request
@@ -305,6 +388,10 @@ module RailsPulse
305
388
  total_count_based = @stats[:count_based].values.sum
306
389
  @stats[:total_deleted] = total_time_based + total_count_based
307
390
 
391
+ if @failures.any?
392
+ RailsPulse.logger.error "Cleanup finished with #{@failures.size} failed stage(s): #{@failures.map { |failure| failure[:stage] }.join(', ')}"
393
+ end
394
+
308
395
  RailsPulse.logger.info "Cleanup completed:"
309
396
  RailsPulse.logger.info " Time-based: #{total_time_based} records deleted"
310
397
  RailsPulse.logger.info " Count-based: #{total_count_based} records deleted"
@@ -1,3 +1,3 @@
1
1
  module RailsPulse
2
- VERSION = "0.4.1"
2
+ VERSION = "0.4.2"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_pulse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Scott Harvey
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-23 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -551,7 +551,7 @@ metadata:
551
551
  bug_tracker_uri: https://github.com/railspulse/rails_pulse/issues
552
552
  rubygems_mfa_required: 'true'
553
553
  post_install_message: |
554
- Rails Pulse 0.4.1 installed.
554
+ Rails Pulse 0.4.2 installed.
555
555
 
556
556
  UPGRADING from any 0.3.x? This release changes route identity and DROPS
557
557
  rails_pulse_routes.method. Back up your database first, then run: