active_record-undo 0.2.0 → 0.3.0
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 +4 -4
- data/CHANGELOG.md +78 -2
- data/DOCUMENTATION.md +311 -178
- data/README.md +140 -10
- data/app/controllers/active_record/undo/application_controller.rb +9 -0
- data/app/controllers/active_record/undo/logs_controller/async_handler.rb +50 -0
- data/app/controllers/active_record/undo/logs_controller.rb +32 -3
- data/app/jobs/active_record/undo/restore_job.rb +72 -0
- data/app/jobs/active_record/undo/soft_delete_job.rb +75 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb +15 -0
- data/lib/active_record/undo/cascade_handler.rb +66 -0
- data/lib/active_record/undo/configuration.rb +2 -1
- data/lib/active_record/undo/model_extension/async_methods.rb +68 -0
- data/lib/active_record/undo/model_extension.rb +7 -4
- data/lib/active_record/undo/status_predicates.rb +39 -0
- data/lib/active_record/undo/undo_log/token_verifier.rb +48 -0
- data/lib/active_record/undo/undo_log.rb +49 -32
- data/lib/active_record/undo/version.rb +1 -1
- data/lib/active_record/undo/view_helpers/status_badge_helper.rb +84 -0
- data/lib/active_record/undo/view_helpers.rb +4 -0
- data/lib/active_record/undo.rb +24 -1
- data/sig/active_record/undo.rbs +50 -1
- metadata +23 -1
data/README.md
CHANGED
|
@@ -194,14 +194,115 @@ post.soft_deleted? # => false
|
|
|
194
194
|
post.comments.count # => 2
|
|
195
195
|
```
|
|
196
196
|
|
|
197
|
-
#### Option B: Restore from the `UndoLog`
|
|
198
|
-
|
|
199
197
|
```ruby
|
|
200
198
|
undo_log.restore!
|
|
201
199
|
|
|
202
200
|
post.reload.soft_deleted? # => false
|
|
203
201
|
```
|
|
204
202
|
|
|
203
|
+
### Asynchronous Cascades (`soft_delete_later!` & `restore_later!`)
|
|
204
|
+
|
|
205
|
+
For deep or massive association trees (5,000+ items), synchronous processing can breach web request timeouts (e.g., Puma 30s limits, Cloudflare 524 timeouts). `active_record-undo` provides non-blocking asynchronous operations powered by ActiveJob, using batched DFS traversal to keep memory flat:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
# 1. Soft delete asynchronously: hides root record immediately and enqueues SoftDeleteJob
|
|
209
|
+
undo_log = post.soft_delete_later!
|
|
210
|
+
# Or specify a custom ActiveJob queue:
|
|
211
|
+
undo_log = post.soft_delete_later!(queue: :low_priority)
|
|
212
|
+
|
|
213
|
+
post.soft_deleted? # => true (immediately hidden from Post.kept)
|
|
214
|
+
post.soft_delete_status # => "pending" -> "processing" -> "completed"
|
|
215
|
+
post.soft_delete_processing? # => true / false
|
|
216
|
+
post.undoable? # => false until SoftDeleteJob completes
|
|
217
|
+
|
|
218
|
+
# 2. Restore asynchronously: enqueues RestoreJob to restore all cascaded items bottom-up
|
|
219
|
+
post.restore_later!
|
|
220
|
+
# Or call directly on the UndoLog:
|
|
221
|
+
undo_log.restore_later!(queue: :critical)
|
|
222
|
+
|
|
223
|
+
post.restore_status # => "pending" -> "processing"
|
|
224
|
+
post.restore_processing? # => true / false
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Asynchronous Failure Recovery & Execution Logging
|
|
228
|
+
|
|
229
|
+
When running asynchronous cascades over thousands of associated records, background jobs can encounter transient failures such as database connection drops, lock acquisition timeouts, deadlocks, or worker restarts. `active_record-undo` provides robust failure tracking, persistent error logging, and idempotent recovery semantics.
|
|
230
|
+
|
|
231
|
+
#### 1. Lifecycle State Machine
|
|
232
|
+
|
|
233
|
+
Every asynchronous operation updates execution state directly on the database `undo_logs` record:
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
[:pending] ────► [:processing] ────► [:completed] (Destroyed on restore completion)
|
|
237
|
+
│
|
|
238
|
+
▼
|
|
239
|
+
[:failed] (with persistent error_message)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
- **`status`**: `:pending` (0), `:processing` (1), `:completed` (2), `:failed` (3).
|
|
243
|
+
- **`action`**: `:soft_delete` (0), `:restore` (1).
|
|
244
|
+
- **`error_message`**: Captures `exception.message` when a job fails.
|
|
245
|
+
|
|
246
|
+
#### 2. Worker Re-raising & Queue Integration
|
|
247
|
+
|
|
248
|
+
When an exception occurs during `SoftDeleteJob` or `RestoreJob`:
|
|
249
|
+
1. The job catches `StandardError`.
|
|
250
|
+
2. It immediately updates the `UndoLog` with `status: :failed` and `error_message: exception.message`.
|
|
251
|
+
3. It **re-raises** the exception (`raise e`).
|
|
252
|
+
|
|
253
|
+
Re-raising guarantees full integration with your ActiveJob backend (Sidekiq, Solid Queue, GoodJob, DelayedJob) and application monitoring:
|
|
254
|
+
- **Queue Retries**: Native queue retries (e.g., `retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3`) function normally.
|
|
255
|
+
- **Error Tracking**: Exceptions are automatically sent to Sentry, Honeybadger, Bugsnag, Datadog, etc., with full backtraces.
|
|
256
|
+
- **Custom Base Job**: Set `config.base_job = 'ApplicationJob'` to inherit your application's logging tags, retry strategies, and discard handlers.
|
|
257
|
+
|
|
258
|
+
#### 3. Idempotency & Safe Resumption
|
|
259
|
+
|
|
260
|
+
Both jobs are engineered for safe re-entrancy, allowing failed or interrupted jobs to be retried without corrupting data or duplicating log items:
|
|
261
|
+
|
|
262
|
+
- **Soft Delete Re-entrancy**: `CascadeHandler` uses `in_batches` and checks `associated.reload.soft_deleted?`. If a worker dies halfway or encounters a transient error, retrying the job safely skips already soft-deleted records and continues cascade processing for the remaining associations.
|
|
263
|
+
- **Restore Batches & Row Locking**: `RestoreJob` acquires a database row lock using `undo_log.with_lock` (`SELECT FOR UPDATE`). This prevents duplicate or concurrent worker runs on the same log. Restorations update `deleted_at: nil` in batches (`find_each(batch_size: 1000)`). The `UndoLog` and its `undo_log_items` are only pruned after all batches finish. If an error interrupts restoration, the log and all un-restored items remain preserved in the database for inspection, retry, or manual recovery.
|
|
264
|
+
|
|
265
|
+
#### 4. Inspecting & Retrying Failed Operations
|
|
266
|
+
|
|
267
|
+
Engineers or automated recovery tasks can query and inspect failed jobs directly via ActiveRecord:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
# Query failed operations
|
|
271
|
+
failed_logs = ActiveRecord::Undo::UndoLog.failed # or .where(status: :failed)
|
|
272
|
+
|
|
273
|
+
failed_logs.each do |log|
|
|
274
|
+
puts "UndoLog ##{log.id} (#{log.action}) failed: #{log.error_message}"
|
|
275
|
+
|
|
276
|
+
# Re-enqueue restoration
|
|
277
|
+
log.restore_later! if log.action == "restore"
|
|
278
|
+
|
|
279
|
+
# Or execute synchronously to inspect immediately
|
|
280
|
+
log.restore! if log.action == "restore"
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
#### 5. Frontend Polling & Error Observability
|
|
285
|
+
|
|
286
|
+
Frontends can poll status endpoints using the numeric ID or signed token:
|
|
287
|
+
- `GET /undo/logs/:id/status`
|
|
288
|
+
- `GET /undo/status/:token`
|
|
289
|
+
|
|
290
|
+
On failure, the endpoint returns a `200 OK` JSON payload with error details:
|
|
291
|
+
|
|
292
|
+
```json
|
|
293
|
+
{
|
|
294
|
+
"id": 42,
|
|
295
|
+
"status": "failed",
|
|
296
|
+
"action": "restore",
|
|
297
|
+
"error_message": "PG::QueryCanceled: canceling statement due to statement timeout",
|
|
298
|
+
"completed": false,
|
|
299
|
+
"failed": true
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The view helpers (`undo_status_badge`, `soft_delete_status_badge`, `restore_status_badge`) automatically apply the `.undo-status-failed` CSS class so your UI can display a failure indicator or retry button.
|
|
304
|
+
|
|
305
|
+
|
|
205
306
|
### Status & Eligibility Helpers
|
|
206
307
|
|
|
207
308
|
`ActiveRecord::Undo` provides fast predicate methods and audit helpers on models:
|
|
@@ -210,14 +311,25 @@ post.reload.soft_deleted? # => false
|
|
|
210
311
|
# Checks if the soft-delete column is set
|
|
211
312
|
post.soft_deleted? # => true / false
|
|
212
313
|
|
|
213
|
-
# Verifies record is soft-deleted AND has a
|
|
214
|
-
post.undoable? # => true / false
|
|
314
|
+
# Verifies record is soft-deleted AND has a completed undo log ready to restore
|
|
315
|
+
post.undoable? # => true / false (false while async soft-delete/restore is in progress)
|
|
316
|
+
|
|
317
|
+
# Soft-delete & Restore operation statuses (for frontend badges / live polling)
|
|
318
|
+
post.soft_delete_status # => "pending", "processing", "completed", "failed", or nil
|
|
319
|
+
post.restore_status # => "pending", "processing", "completed", "failed", or nil
|
|
320
|
+
post.soft_delete_pending? # => true / false
|
|
321
|
+
post.soft_delete_processing? # => true / false
|
|
322
|
+
post.soft_delete_completed? # => true / false
|
|
323
|
+
post.soft_delete_failed? # => true / false
|
|
324
|
+
post.restore_pending? # => true / false
|
|
325
|
+
post.restore_processing? # => true / false
|
|
326
|
+
post.restore_failed? # => true / false
|
|
215
327
|
|
|
216
328
|
# Checks if the soft-deleted record has exceeded the configured retention period
|
|
217
329
|
post.expired? # => true / false
|
|
218
330
|
|
|
219
331
|
# Retrieves the latest UndoLog audit record associated with this model
|
|
220
|
-
post.undo_log # => #<ActiveRecord::Undo::UndoLog id: 14, ...>
|
|
332
|
+
post.undo_log # => #<ActiveRecord::Undo::UndoLog id: 14, status: "completed", ...>
|
|
221
333
|
post.latest_undo_log # alias
|
|
222
334
|
|
|
223
335
|
# Generates a cryptographic signed restoration token directly from the model
|
|
@@ -279,12 +391,14 @@ end
|
|
|
279
391
|
|
|
280
392
|
### Available Endpoints
|
|
281
393
|
|
|
282
|
-
| Method | Route | Controller#Action | Description
|
|
283
|
-
| :----- | :----------------------- | :----------------------------------- |
|
|
284
|
-
| `POST` | `/undo/logs/:id/restore` | `active_record/undo/logs#restore` | Restores an `UndoLog` by ID
|
|
285
|
-
| `POST` | `/undo/restore/:token` | `active_record/undo/restores#create` | Restores
|
|
394
|
+
| Method | Route | Controller#Action | Description |
|
|
395
|
+
| :----- | :----------------------- | :----------------------------------- | :-------------------------------------------------- |
|
|
396
|
+
| `POST` | `/undo/logs/:id/restore` | `active_record/undo/logs#restore` | Restores an `UndoLog` by ID (supports `async: true`)|
|
|
397
|
+
| `POST` | `/undo/restore/:token` | `active_record/undo/restores#create` | Restores using signed token (supports `async: true`)|
|
|
398
|
+
| `GET` | `/undo/logs/:id/status` | `active_record/undo/logs#status` | Polls restoration or soft delete status JSON |
|
|
399
|
+
| `GET` | `/undo/status/:token` | `active_record/undo/restores#status` | Polls status JSON using signed token |
|
|
286
400
|
|
|
287
|
-
### View Helpers (`undo_button_to` & `
|
|
401
|
+
### View Helpers (`undo_button_to`, `undo_link_to`, `undo_status_badge`, `soft_delete_status_badge` & `restore_status_badge`)
|
|
288
402
|
|
|
289
403
|
Clean view helpers are automatically available in all Rails views and forms:
|
|
290
404
|
|
|
@@ -295,6 +409,18 @@ Clean view helpers are automatically available in all Rails views and forms:
|
|
|
295
409
|
<%# Pass model instances directly %>
|
|
296
410
|
<%= undo_button_to(post, text: "Undo Delete", class: "btn btn-primary") %>
|
|
297
411
|
|
|
412
|
+
<%# Asynchronous restore button %>
|
|
413
|
+
<%= undo_button_to(post, params: { async: true }, text: "Restore Asynchronously") %>
|
|
414
|
+
|
|
415
|
+
<%# Auto-detecting status badge helper for frontend progress %>
|
|
416
|
+
<%= undo_status_badge(post) %>
|
|
417
|
+
<!-- Renders: <span class="undo-status-badge undo-soft-delete-badge undo-status-processing">Processing</span> -->
|
|
418
|
+
|
|
419
|
+
<%# Explicit status badge helpers for soft delete and restore %>
|
|
420
|
+
<%= soft_delete_status_badge(post) %>
|
|
421
|
+
<%= restore_status_badge(post) %>
|
|
422
|
+
<%= soft_delete_status_badge(post, text: "Deleting...") %>
|
|
423
|
+
|
|
298
424
|
<%# Secure direct link with signed token (prevents ID enumeration in emails or public links) %>
|
|
299
425
|
<%= undo_button_to(@undo_log, signed: true, class: "btn btn-success") %>
|
|
300
426
|
|
|
@@ -512,6 +638,10 @@ ActiveRecord::Undo.configure do |config|
|
|
|
512
638
|
# Defaults to "::ApplicationController" if defined, falling back to ActionController::Base
|
|
513
639
|
config.base_controller = "::ApplicationController"
|
|
514
640
|
|
|
641
|
+
# Base job class for asynchronous workers (SoftDeleteJob, RestoreJob, PurgeJob)
|
|
642
|
+
# Defaults to "::ActiveJob::Base"
|
|
643
|
+
config.base_job = "::ActiveJob::Base"
|
|
644
|
+
|
|
515
645
|
# Fallback redirect path after successful restore when no redirect_to or referer exists
|
|
516
646
|
config.default_redirect_path = ->(main_app) { main_app.root_path }
|
|
517
647
|
|
|
@@ -9,6 +9,7 @@ module ActiveRecord
|
|
|
9
9
|
|
|
10
10
|
protect_from_forgery with: :exception, unless: -> { request.format.json? }
|
|
11
11
|
|
|
12
|
+
rescue_from ActiveRecord::Undo::Error, with: :handle_undo_error
|
|
12
13
|
rescue_from ActiveRecord::Undo::SecurityError, with: :handle_security_error
|
|
13
14
|
|
|
14
15
|
private
|
|
@@ -41,6 +42,14 @@ module ActiveRecord
|
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
def handle_undo_error(error)
|
|
46
|
+
respond_to do |format|
|
|
47
|
+
format.html { handle_html_error(error.message, :unprocessable_entity) }
|
|
48
|
+
format.turbo_stream { render_turbo_stream_error(error.message, :unprocessable_entity) }
|
|
49
|
+
format.json { render json: { error: error.message }, status: :unprocessable_entity }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
44
53
|
def handle_not_found
|
|
45
54
|
msg = 'Undo log not found or already restored.'
|
|
46
55
|
respond_to do |format|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class LogsController < ApplicationController
|
|
6
|
+
module AsyncHandler
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def async_request?
|
|
10
|
+
params[:async].to_s == 'true' || request.headers['X-Undo-Async'].to_s == 'true'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def execute_async_restore_action
|
|
14
|
+
tenant = resolve_current_tenant
|
|
15
|
+
with_tenant_context(tenant) do
|
|
16
|
+
@undo_log.restore_later!(whodunnit: resolve_whodunnit_user)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def respond_with_async_restore_scheduled
|
|
21
|
+
notice = 'Restoration has been scheduled and is processing.'
|
|
22
|
+
respond_to do |format|
|
|
23
|
+
format.html { redirect_to determine_redirect_path, notice: notice, status: :see_other }
|
|
24
|
+
format.turbo_stream { render_turbo_stream_notice(notice, :accepted) }
|
|
25
|
+
format.json { render json: { success: true, status: 'pending', log_id: @undo_log.id }, status: :accepted }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_turbo_stream_notice(notice, status)
|
|
30
|
+
render inline: turbo_stream_template('notice', notice),
|
|
31
|
+
content_type: 'text/vnd.turbo-stream.html',
|
|
32
|
+
status: status
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def log_status_payload
|
|
36
|
+
{
|
|
37
|
+
id: @undo_log.id,
|
|
38
|
+
action: @undo_log.action,
|
|
39
|
+
status: @undo_log.status,
|
|
40
|
+
soft_delete_status: @undo_log.soft_delete_status,
|
|
41
|
+
restore_status: @undo_log.restore_status,
|
|
42
|
+
error_message: @undo_log.error_message,
|
|
43
|
+
completed: @undo_log.completed?,
|
|
44
|
+
failed: @undo_log.failed?
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'logs_controller/async_handler'
|
|
4
|
+
|
|
3
5
|
module ActiveRecord
|
|
4
6
|
module Undo
|
|
5
7
|
class LogsController < ApplicationController
|
|
8
|
+
include AsyncHandler
|
|
9
|
+
|
|
6
10
|
before_action :set_undo_log
|
|
7
11
|
before_action :check_not_found
|
|
8
12
|
before_action :check_expired
|
|
13
|
+
before_action :check_tenant_access
|
|
9
14
|
|
|
10
15
|
def restore
|
|
11
16
|
return unless @undo_log
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
if async_request?
|
|
19
|
+
execute_async_restore_action
|
|
20
|
+
respond_with_async_restore_scheduled
|
|
21
|
+
else
|
|
22
|
+
count = @undo_log.undo_log_items.count
|
|
23
|
+
execute_restore_action
|
|
24
|
+
respond_with_restored_log(count)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def status
|
|
29
|
+
return unless @undo_log
|
|
30
|
+
|
|
31
|
+
respond_to do |format|
|
|
32
|
+
format.json { render json: log_status_payload, status: :ok }
|
|
33
|
+
format.html { render plain: @undo_log.status, status: :ok }
|
|
34
|
+
end
|
|
16
35
|
end
|
|
17
36
|
|
|
18
37
|
private
|
|
@@ -29,6 +48,16 @@ module ActiveRecord
|
|
|
29
48
|
handle_expired if @undo_log&.expired?
|
|
30
49
|
end
|
|
31
50
|
|
|
51
|
+
def check_tenant_access
|
|
52
|
+
tenant = resolve_current_tenant
|
|
53
|
+
with_tenant_context(tenant) do
|
|
54
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
55
|
+
return unless @undo_log && (@undo_log.tenant_id.present? || @undo_log.tenant_type.present?)
|
|
56
|
+
|
|
57
|
+
@undo_log.send(:verify_tenant_match!, @undo_log)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
32
61
|
def find_undo_log
|
|
33
62
|
if params[:token].present?
|
|
34
63
|
ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class RestoreJob < ActiveRecord::Undo.base_job_class
|
|
6
|
+
queue_as :default
|
|
7
|
+
|
|
8
|
+
def perform(undo_log_id, whodunnit: nil)
|
|
9
|
+
undo_log = find_undo_log(undo_log_id)
|
|
10
|
+
return unless undo_log
|
|
11
|
+
|
|
12
|
+
acquired = lock_and_start_processing!(undo_log)
|
|
13
|
+
return unless acquired
|
|
14
|
+
|
|
15
|
+
execute_restore_batches(undo_log, whodunnit)
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
handle_failure(undo_log, e)
|
|
18
|
+
raise e
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def find_undo_log(undo_log_id)
|
|
24
|
+
ActiveRecord::Undo::UndoLog.find_by(id: undo_log_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def lock_and_start_processing!(undo_log)
|
|
28
|
+
undo_log.with_lock do
|
|
29
|
+
return false if undo_log.processing?
|
|
30
|
+
return false if undo_log.completed?
|
|
31
|
+
|
|
32
|
+
undo_log.update!(status: :processing)
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute_restore_batches(undo_log, whodunnit)
|
|
38
|
+
actor = undo_log.send(:resolve_whodunnit, whodunnit)
|
|
39
|
+
undo_log.send(:with_whodunnit_context, actor) do
|
|
40
|
+
with_tenant_context(undo_log) do
|
|
41
|
+
restore_all_items_in_batches(undo_log)
|
|
42
|
+
cleanup_log!(undo_log)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def with_tenant_context(undo_log)
|
|
48
|
+
ten = undo_log.tenant || undo_log.tenant_id
|
|
49
|
+
orig = ActiveRecord::Undo.current_tenant
|
|
50
|
+
ActiveRecord::Undo.current_tenant = ten if ten.present?
|
|
51
|
+
yield
|
|
52
|
+
ensure
|
|
53
|
+
ActiveRecord::Undo.current_tenant = orig
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def restore_all_items_in_batches(undo_log)
|
|
57
|
+
undo_log.undo_log_items.find_each(batch_size: 1000, order: :desc, &:restore_item!)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cleanup_log!(undo_log)
|
|
61
|
+
undo_log.undo_log_items.in_batches(of: 1000).delete_all
|
|
62
|
+
undo_log.destroy!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def handle_failure(undo_log, exception)
|
|
66
|
+
return unless undo_log && ActiveRecord::Undo::UndoLog.exists?(undo_log.id)
|
|
67
|
+
|
|
68
|
+
undo_log.update!(status: :failed, error_message: exception.message)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
class SoftDeleteJob < ActiveRecord::Undo.base_job_class
|
|
6
|
+
queue_as :default
|
|
7
|
+
|
|
8
|
+
def perform(record_class_name, record_id, undo_log_id, whodunnit: nil, tenant: nil)
|
|
9
|
+
undo_log = find_undo_log(undo_log_id)
|
|
10
|
+
return unless undo_log
|
|
11
|
+
|
|
12
|
+
acquired = lock_and_start_processing!(undo_log)
|
|
13
|
+
return unless acquired
|
|
14
|
+
|
|
15
|
+
execute_soft_delete_cascade(record_class_name, record_id, undo_log, whodunnit, tenant)
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
handle_failure(undo_log, e)
|
|
18
|
+
raise e
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def find_undo_log(undo_log_id)
|
|
24
|
+
ActiveRecord::Undo::UndoLog.find_by(id: undo_log_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def lock_and_start_processing!(undo_log)
|
|
28
|
+
undo_log.with_lock do
|
|
29
|
+
return false if undo_log.processing?
|
|
30
|
+
return false if undo_log.completed?
|
|
31
|
+
|
|
32
|
+
undo_log.update!(status: :processing)
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute_soft_delete_cascade(class_name, record_id, undo_log, whodunnit, tenant)
|
|
38
|
+
record = class_name.constantize.unscoped.find(record_id)
|
|
39
|
+
timestamp = record_timestamp(record, undo_log)
|
|
40
|
+
|
|
41
|
+
run_cascade_with_context(record, timestamp, undo_log, whodunnit, tenant)
|
|
42
|
+
undo_log.update!(status: :completed)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def record_timestamp(record, undo_log)
|
|
46
|
+
col = record.class.respond_to?(:undoable_column) ? record.class.undoable_column : :deleted_at
|
|
47
|
+
record.public_send(col) || undo_log.created_at || Time.current
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def run_cascade_with_context(record, timestamp, undo_log, whodunnit, tenant)
|
|
51
|
+
with_execution_context(record, whodunnit, tenant) do
|
|
52
|
+
CascadeHandler.new(record).soft_delete_associations_batched!(timestamp, undo_log)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def with_execution_context(record, whodunnit, tenant)
|
|
57
|
+
actor = whodunnit || record.send(:resolve_whodunnit, nil)
|
|
58
|
+
ten = tenant || record.send(:resolve_tenant, nil)
|
|
59
|
+
record.send(:with_whodunnit_context, actor) do
|
|
60
|
+
orig = ActiveRecord::Undo.current_tenant
|
|
61
|
+
ActiveRecord::Undo.current_tenant = ten if ten.present?
|
|
62
|
+
yield
|
|
63
|
+
ensure
|
|
64
|
+
ActiveRecord::Undo.current_tenant = orig
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def handle_failure(undo_log, exception)
|
|
69
|
+
return unless undo_log && ActiveRecord::Undo::UndoLog.exists?(undo_log.id)
|
|
70
|
+
|
|
71
|
+
undo_log.update!(status: :failed, error_message: exception.message)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -4,8 +4,10 @@ ActiveRecord::Undo::Engine.routes.draw do
|
|
|
4
4
|
resources :logs, only: [] do
|
|
5
5
|
member do
|
|
6
6
|
post :restore
|
|
7
|
+
get :status
|
|
7
8
|
end
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
post '/restore/:token', to: 'restores#create', as: :signed_restore
|
|
12
|
+
get '/status/:token', to: 'restores#status', as: :signed_status
|
|
11
13
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# db/migrate/20260920000000_add_status_and_error_message_to_undo_logs.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class AddStatusAndErrorMessageToUndoLogs < ActiveRecord::Migration[7.0]
|
|
5
|
+
def change
|
|
6
|
+
change_table :undo_logs, bulk: true do |t|
|
|
7
|
+
t.integer :status, default: 2, null: false unless column_exists?(:undo_logs, :status)
|
|
8
|
+
t.integer :action, default: 0, null: false unless column_exists?(:undo_logs, :action)
|
|
9
|
+
t.text :error_message unless column_exists?(:undo_logs, :error_message)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :undo_logs, :status unless index_exists?(:undo_logs, :status)
|
|
13
|
+
add_index :undo_logs, :action unless index_exists?(:undo_logs, :action)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -20,8 +20,74 @@ module ActiveRecord
|
|
|
20
20
|
undo_log.undo_log_items.build(item: @record)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def soft_delete_associations_batched!(timestamp, undo_log, batch_size: 1000)
|
|
24
|
+
associations_to_cascade.each do |reflection|
|
|
25
|
+
cascade_association_batched!(reflection, timestamp, undo_log, batch_size)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
23
29
|
private
|
|
24
30
|
|
|
31
|
+
def cascade_association_batched!(reflection, timestamp, undo_log, batch_size)
|
|
32
|
+
target = @record.public_send(reflection.name)
|
|
33
|
+
return if target.nil?
|
|
34
|
+
|
|
35
|
+
if reflection.collection? || target.is_a?(ActiveRecord::Relation)
|
|
36
|
+
cascade_collection_batched!(target, reflection, timestamp, undo_log, batch_size)
|
|
37
|
+
else
|
|
38
|
+
cascade_record_batched!(target, reflection, timestamp, undo_log, batch_size)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cascade_collection_batched!(target, reflection, timestamp, undo_log, batch_size)
|
|
43
|
+
each_collection_batch(target, batch_size) do |batch|
|
|
44
|
+
batch.each do |associated|
|
|
45
|
+
cascade_record_batched!(associated, reflection, timestamp, undo_log, batch_size)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def each_collection_batch(target, batch_size, &)
|
|
51
|
+
if target.respond_to?(:in_batches)
|
|
52
|
+
target.in_batches(of: batch_size, &)
|
|
53
|
+
else
|
|
54
|
+
target.each_slice(batch_size, &)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cascade_record_batched!(associated, reflection, timestamp, undo_log, batch_size)
|
|
59
|
+
return if skip_cascade?(associated)
|
|
60
|
+
|
|
61
|
+
if undoable_record?(associated)
|
|
62
|
+
process_undoable_child!(associated, timestamp, undo_log, batch_size)
|
|
63
|
+
elsif reflection.options[:dependent] == :destroy
|
|
64
|
+
associated.destroy!
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def skip_cascade?(associated)
|
|
69
|
+
return true if associated.nil?
|
|
70
|
+
return true if associated.respond_to?(:soft_deleted?) && associated.soft_deleted?
|
|
71
|
+
|
|
72
|
+
if associated.respond_to?(:soft_deleted?) && associated.persisted?
|
|
73
|
+
associated.reload
|
|
74
|
+
return true if associated.soft_deleted?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def undoable_record?(associated)
|
|
81
|
+
associated.respond_to?(:acts_as_undoable) || associated.class.respond_to?(:undoable_column)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def process_undoable_child!(associated, timestamp, undo_log, batch_size)
|
|
85
|
+
self.class.new(associated).soft_delete_associations_batched!(timestamp, undo_log, batch_size: batch_size)
|
|
86
|
+
column_name = associated.class.respond_to?(:undoable_column) ? associated.class.undoable_column : :deleted_at
|
|
87
|
+
associated.update_columns(column_name => timestamp, updated_at: timestamp)
|
|
88
|
+
ActiveRecord::Undo::UndoLogItem.create!(undo_log_id: undo_log.id, item: associated)
|
|
89
|
+
end
|
|
90
|
+
|
|
25
91
|
def cascade_to_associations!(timestamp, undo_log)
|
|
26
92
|
associations_to_cascade.each do |reflection|
|
|
27
93
|
associated_records_for(reflection).each do |associated|
|
|
@@ -4,7 +4,7 @@ module ActiveRecord
|
|
|
4
4
|
module Undo
|
|
5
5
|
class Configuration
|
|
6
6
|
attr_accessor :retention_period, :current_user_method, :current_tenant_method,
|
|
7
|
-
:base_controller, :default_redirect_path, :token_secret_key,
|
|
7
|
+
:base_controller, :base_job, :default_redirect_path, :token_secret_key,
|
|
8
8
|
:token_expires_in, :error_handling
|
|
9
9
|
|
|
10
10
|
def initialize
|
|
@@ -12,6 +12,7 @@ module ActiveRecord
|
|
|
12
12
|
@current_user_method = nil
|
|
13
13
|
@current_tenant_method = nil
|
|
14
14
|
@base_controller = '::ApplicationController'
|
|
15
|
+
@base_job = '::ActiveJob::Base'
|
|
15
16
|
@default_redirect_path = ->(main_app) { main_app.respond_to?(:root_path) ? main_app.root_path : '/' }
|
|
16
17
|
@token_secret_key = nil
|
|
17
18
|
@token_expires_in = 24.hours
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Undo
|
|
5
|
+
module ModelExtension
|
|
6
|
+
module AsyncMethods
|
|
7
|
+
def soft_delete_status
|
|
8
|
+
return nil unless soft_deleted?
|
|
9
|
+
return 'completed' unless undo_log
|
|
10
|
+
|
|
11
|
+
undo_log.soft_delete_status
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def restore_status
|
|
15
|
+
return nil unless undo_log
|
|
16
|
+
|
|
17
|
+
undo_log.restore_status
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def soft_delete_later!(whodunnit: nil, tenant: nil, queue: nil)
|
|
21
|
+
ensure_undoable_column_exists!
|
|
22
|
+
return false if soft_deleted?
|
|
23
|
+
|
|
24
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
25
|
+
attrs = build_undo_log_attributes(whodunnit, tenant)
|
|
26
|
+
undo_log = execute_initial_soft_delete_later!(attrs)
|
|
27
|
+
enqueue_soft_delete_job!(undo_log, whodunnit, tenant, queue)
|
|
28
|
+
undo_log
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def restore_later!(whodunnit: nil, queue: nil)
|
|
32
|
+
ensure_undoable_column_exists!
|
|
33
|
+
return false unless soft_deleted?
|
|
34
|
+
|
|
35
|
+
ActiveRecord::Undo.verify_configured_context!
|
|
36
|
+
log = undo_log
|
|
37
|
+
raise ActiveRecord::Undo::Error, 'No undo log found to restore.' unless log
|
|
38
|
+
|
|
39
|
+
log.restore_later!(whodunnit: whodunnit, queue: queue)
|
|
40
|
+
log
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def execute_initial_soft_delete_later!(attrs)
|
|
46
|
+
transaction do
|
|
47
|
+
timestamp = Time.current
|
|
48
|
+
undo_log = ActiveRecord::Undo::UndoLog.create!(attrs.merge(action: :soft_delete, status: :pending))
|
|
49
|
+
update_soft_delete_timestamps!(timestamp)
|
|
50
|
+
ActiveRecord::Undo::UndoLogItem.create!(undo_log_id: undo_log.id, item: self)
|
|
51
|
+
undo_log
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def update_soft_delete_timestamps!(timestamp)
|
|
56
|
+
column_name = self.class.undoable_column
|
|
57
|
+
update_columns(column_name => timestamp, updated_at: timestamp)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def enqueue_soft_delete_job!(undo_log, whodunnit, tenant, queue)
|
|
61
|
+
job_class = ActiveRecord::Undo::SoftDeleteJob
|
|
62
|
+
job = queue.present? ? job_class.set(queue: queue) : job_class
|
|
63
|
+
job.perform_later(self.class.name, id, undo_log.id, whodunnit: whodunnit, tenant: tenant)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|