bulk_destroyer 0.1.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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +193 -0
- data/lib/bulk_destroyer/api.rb +24 -0
- data/lib/bulk_destroyer/batch.rb +143 -0
- data/lib/bulk_destroyer/cascade_guard.rb +17 -0
- data/lib/bulk_destroyer/counter_caches.rb +51 -0
- data/lib/bulk_destroyer/dependents.rb +172 -0
- data/lib/bulk_destroyer/destroyer.rb +57 -0
- data/lib/bulk_destroyer/errors.rb +27 -0
- data/lib/bulk_destroyer/hook_runner.rb +85 -0
- data/lib/bulk_destroyer/tree.rb +93 -0
- data/lib/bulk_destroyer/version.rb +5 -0
- data/lib/bulk_destroyer.rb +69 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 49ddeb360222e4f193bd0d67f64e23ed280f700349251326eb72f96983e5f2a0
|
|
4
|
+
data.tar.gz: 02ed589f50098a4029101b2b0550e474f9e31ef7bec9b65d0d87eedce23ee815
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b423cefd1a3cd77f72efee55ade3d8f75ff463087dc1d229cbac158cee2ad7c79359557263594bcbd3254ed358d21aac5c59bcf6d2f01aaa1622470f19b8717f
|
|
7
|
+
data.tar.gz: 1952408bf68dcf19ead6f2ca32626341feca96802c78b198039dfa00ab0c14cf6db9f8a93770dbe1b8fc3032866707d6f9ae7466e1ddfee57167e90d7b29bfb8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-10-22
|
|
4
|
+
|
|
5
|
+
- Initial release: `bulk_destroy_all` and `bulk_destroy!`.
|
|
6
|
+
- Fires all destroy callbacks (including children's) with set-based SQL; `after_destroy` runs after the bulk DELETE (Fiber mode).
|
|
7
|
+
- Supports `dependent:` :destroy, :delete_all, :nullify, restrict_*, has_many :through, HABTM, and `belongs_to` :destroy/:delete.
|
|
8
|
+
- `max_batch_records` / `oversized_tree` guard against the Fiber ceiling.
|
|
9
|
+
- Rails 7.1 - 8.2, any ActiveRecord adapter (tested on SQLite and PostgreSQL).
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ramanan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# BulkDestroyer
|
|
2
|
+
|
|
3
|
+
`destroy_all` that fires **every destroy callback on every record** (including `dependent: :destroy`
|
|
4
|
+
descendants) but deletes with set-based SQL.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
Post.where(archived: true).bulk_destroy_all # => { "Post" => 120, "Comment" => 940, ... }
|
|
8
|
+
Post.bulk_destroy_all(batch_size: 500)
|
|
9
|
+
post.comments.bulk_destroy_all
|
|
10
|
+
post.bulk_destroy!
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
See [Benchmarks](#benchmarks) for timings. Query count is roughly one SELECT per association per level plus one DELETE
|
|
14
|
+
per 500 ids (`in_list_size`) per table, repeated for each batch of `batch_size` roots. It still grows with the
|
|
15
|
+
data, but about 700x more slowly than `destroy_all`'s two-plus queries per row (32,001 vs 45 queries at 1,000
|
|
16
|
+
posts, 160,001 vs 221 at 5,000).
|
|
17
|
+
|
|
18
|
+
## How it works
|
|
19
|
+
|
|
20
|
+
Per batch of roots (default 1000, and never more than `max_batch_records` records; see below), inside one
|
|
21
|
+
transaction (`requires_new: true`):
|
|
22
|
+
|
|
23
|
+
1. **Tree** - `ActiveRecord::Associations::Preloader` loads each `dependent: :destroy` association
|
|
24
|
+
once per level. Association scopes, `foreign_key`/`primary_key`, polymorphic `as:` and STI are
|
|
25
|
+
handled by Rails. A record reachable by several paths is placed at its deepest level.
|
|
26
|
+
2. **Restrictions** - `restrict_with_exception` / `restrict_with_error` are checked first.
|
|
27
|
+
3. **Start hooks** - each record's real `run_callbacks(:destroy)` runs inside a `Fiber` up to the
|
|
28
|
+
point where Rails would delete the row (before_destroy, first half of around_destroy), parents first.
|
|
29
|
+
4. **Per level, deepest first** - `nullify` / `delete_all` / HABTM join rows, then one bulk `DELETE`
|
|
30
|
+
per table, then counter caches, then the records are marked destroyed and their fibers resume
|
|
31
|
+
(second half of around_destroy, `after_destroy`).
|
|
32
|
+
|
|
33
|
+
Because Rails runs the callback chain itself, `if:`/`unless:`, `prepend:`, callback objects, ordering
|
|
34
|
+
and `throw :abort` behave as in `destroy`. `after_destroy` really runs after the row is gone, and a
|
|
35
|
+
child's `after_destroy` runs while its parent's row still exists (same as `destroy`).
|
|
36
|
+
|
|
37
|
+
`after_commit` / `after_rollback` work: records are registered with the transaction exactly as
|
|
38
|
+
`destroy` registers them, so Active Storage purges etc. fire at commit, and a rolled-back batch
|
|
39
|
+
restores `destroyed?`.
|
|
40
|
+
|
|
41
|
+
## Association support
|
|
42
|
+
|
|
43
|
+
| Association | How it is handled | Covered by a test |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `has_many` / `has_one`, `dependent: :destroy` | children loaded once per level, destroyed with their hooks | yes |
|
|
46
|
+
| polymorphic `has_many ... as:`, `dependent: :destroy` | same, type column respected | yes (likes on posts and comments) |
|
|
47
|
+
| polymorphic `as:`, `dependent: :delete_all` / `:nullify` | one `DELETE` / `UPDATE` filtered by owner id **and** type; nullify clears the type column too | yes (two classes sharing an id) |
|
|
48
|
+
| `has_many :through` (plain `has_many` for the join, `dependent: :destroy` there) | join rows destroyed, far side untouched | yes |
|
|
49
|
+
| `has_many :through ..., dependent: :destroy` on the through association itself | join records destroyed with their hooks, far side untouched (same as Rails) | yes, compared with stock `destroy_all` |
|
|
50
|
+
| `has_and_belongs_to_many` | join rows deleted without callbacks (Rails does the same), from either side; far side untouched | yes |
|
|
51
|
+
| `belongs_to ..., dependent: :destroy` / `:delete`, polymorphic and `delegated_type` | target destroyed after its owners, see below | yes |
|
|
52
|
+
| `dependent: :restrict_with_exception` / `:restrict_with_error` | checked before any hook runs | exception form only |
|
|
53
|
+
| `dependent: :destroy_async`, nested `:through` with `dependent:` | raises `BulkDestroyer::UnsupportedAssociation` | `destroy_async` only |
|
|
54
|
+
|
|
55
|
+
The hash returned by `bulk_destroy_all` counts rows deleted per table, including `dependent: :delete_all` rows and
|
|
56
|
+
`dependent: :delete` targets. HABTM join rows and `nullify`d rows are not counted.
|
|
57
|
+
|
|
58
|
+
## Differences from `destroy_all` (by design)
|
|
59
|
+
|
|
60
|
+
- All `before_destroy` hooks run before any row is deleted, rather than interleaved with child deletion.
|
|
61
|
+
- `throw :abort` / a failing hook rolls back the **whole batch** and raises
|
|
62
|
+
`ActiveRecord::RecordNotDestroyed` (or the hook's own error). There is no per-record skipping.
|
|
63
|
+
Batches are separate transactions; wrap the call in your own `transaction` for all-or-nothing.
|
|
64
|
+
A hook that raises `ActiveRecord::Rollback` is treated the same way: Rails' `transaction` would swallow it
|
|
65
|
+
and the batch would silently disappear, so the gem raises `ActiveRecord::RecordNotDestroyed` instead.
|
|
66
|
+
- `has_many :through, dependent: :destroy` and `has_and_belongs_to_many` remove join rows only (as Rails does).
|
|
67
|
+
- `belongs_to ..., dependent: :destroy` / `:delete` (this is what `delegated_type ..., dependent: :destroy` declares)
|
|
68
|
+
is supported. Rails destroys that target from the owner's `after_destroy`, so here each level of owners is
|
|
69
|
+
deleted first and then their targets are destroyed in a nested pass inside the same transaction, with their
|
|
70
|
+
own hooks, children and further `belongs_to` targets. Targets are preloaded (one query per target class,
|
|
71
|
+
polymorphic included), a target shared by several owners is destroyed once, and models that destroy each
|
|
72
|
+
other (`Slot belongs_to :holder, dependent: :destroy` / `Memo has_one :slot, dependent: :destroy`) terminate.
|
|
73
|
+
A target whose `before_destroy` aborts rolls back the whole batch, owners included.
|
|
74
|
+
- Counter caches are decremented with one grouped UPDATE per (column, amount) instead of one per row.
|
|
75
|
+
- `touch: true` is a callback and still runs per record.
|
|
76
|
+
- `destroy` itself is never called, so a model that overrides `destroy` (soft-delete gems) is bypassed.
|
|
77
|
+
Optimistic locking (`lock_version`) is not checked.
|
|
78
|
+
|
|
79
|
+
## Not supported (raises `BulkDestroyer::UnsupportedAssociation`)
|
|
80
|
+
|
|
81
|
+
`dependent: :destroy_async` (on any association), nested `:through`, `:nullify` on `:through`,
|
|
82
|
+
and, for `delete_all`/`nullify`/`restrict`, composite foreign keys and instance-dependent scopes
|
|
83
|
+
(`-> (owner) { ... }`). (`dependent: :destroy` goes through Rails' preloader and does not have the
|
|
84
|
+
last two limits.)
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
BulkDestroyer.batch_size = 1_000 # roots per transaction
|
|
90
|
+
BulkDestroyer.in_list_size = 500 # values per IN (...) chunk, capped per adapter
|
|
91
|
+
BulkDestroyer.hook_mode = :fiber # or :inline
|
|
92
|
+
BulkDestroyer.max_batch_records = 10_000 # records with destroy hooks per batch (see below)
|
|
93
|
+
BulkDestroyer.oversized_tree = :raise # or :inline, when ONE root's tree exceeds the cap
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`:fiber` needs `ActiveSupport::IsolatedExecutionState.isolation_level == :thread` (the default) because
|
|
97
|
+
hook fibers must share the caller's database connection; otherwise it raises `UnsupportedIsolation`.
|
|
98
|
+
`:inline` runs the whole callback chain before the DELETE (so `after_destroy` still sees the row).
|
|
99
|
+
Fiber-local `Thread.current[:x]` values are copied into the hook fibers.
|
|
100
|
+
|
|
101
|
+
### The Fiber ceiling and `max_batch_records`
|
|
102
|
+
|
|
103
|
+
In `:fiber` mode every record in a batch's tree keeps one paused Fiber until its level has been deleted, so a
|
|
104
|
+
batch holds as many live Fibers as it has records with destroy hooks (the `batch_size` of 1000 counts roots, not
|
|
105
|
+
records). Ruby on Linux fails with `FiberError: can't set a guard page` at about 31,700 live Fibers with the
|
|
106
|
+
default `vm.max_map_count` of 65530 (two memory mappings per Fiber stack); measured on Ruby 3.4, trivial Fibers
|
|
107
|
+
cost about 12 KB each, real hook stacks cost more. A tree of 700 posts with 50 comments each (35,700 records)
|
|
108
|
+
used to fail this way and now works.
|
|
109
|
+
|
|
110
|
+
To stay clear of it, the batch size is also capped by records:
|
|
111
|
+
|
|
112
|
+
- If a batch's tree has more than `max_batch_records` records, **before any hook runs** the roots are split in
|
|
113
|
+
half and each half is run as its own batch (its own transaction), recursively. Everything still fires once;
|
|
114
|
+
you just get more, smaller batches. Splitting is not atomic across parts: wrap the call in your own
|
|
115
|
+
`transaction` if you need all-or-nothing.
|
|
116
|
+
- Targets of `belongs_to ..., dependent:` (the nested pass) are split the same way, within the same transaction.
|
|
117
|
+
- If a **single root's** tree alone exceeds the cap it cannot be split. By default
|
|
118
|
+
`BulkDestroyer::TreeTooLarge` is raised, before any hook runs and with nothing deleted. With
|
|
119
|
+
`oversized_tree = :inline` that batch runs its hooks without Fibers instead (its `after_destroy` hooks then run
|
|
120
|
+
before the DELETE).
|
|
121
|
+
- The cap is per batch. Nested `belongs_to` batches run while the outer batch's remaining Fibers are still
|
|
122
|
+
paused, so keep `max_batch_records` well under half of the ceiling. The default of 10,000 does.
|
|
123
|
+
- `:inline` mode creates no Fibers and ignores the cap.
|
|
124
|
+
|
|
125
|
+
A paused Fiber has no timeout in Ruby or Rails; what stays open meanwhile is the database transaction, so the
|
|
126
|
+
usual database and request timeouts apply to the whole call.
|
|
127
|
+
|
|
128
|
+
## Testing
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
BULK_DESTROYER_DB=sqlite3|postgresql|trilogy|mysql2 bundle exec ruby -Itest test/bulk_destroyer_test.rb
|
|
132
|
+
BULK_DESTROYER_HOOK_MODE=inline ... # run the suite in inline hook mode
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Benchmarks
|
|
136
|
+
|
|
137
|
+
`rake bench` seeds a large tree with `insert_all` (no callbacks; about 17 rows per post: comments, polymorphic likes,
|
|
138
|
+
taggings, a HABTM join row, `delete_all` and `nullify` children, a `has_one`, counter caches) and then runs three
|
|
139
|
+
strategies against identical, freshly seeded data:
|
|
140
|
+
|
|
141
|
+
- `Post.destroy_all` (stock)
|
|
142
|
+
- `Post.find_each(&:destroy)` (per-record loop)
|
|
143
|
+
- `Post.bulk_destroy_all`
|
|
144
|
+
|
|
145
|
+
It first asserts that all three leave the same rows behind and fire exactly the same hook calls, then asserts that
|
|
146
|
+
`bulk_destroy_all` issues at least 10x fewer queries and is faster. Size and database are configurable:
|
|
147
|
+
`BENCH_POSTS=1000 BENCH_COMMENTS=5 BULK_DESTROYER_DB=postgresql rake bench`. It is skipped by `rake test`. The same file also has a test that destroys the 35,700-record tree described
|
|
148
|
+
under [The Fiber ceiling](#the-fiber-ceiling-and-max_batch_records) with default settings.
|
|
149
|
+
|
|
150
|
+
Single runs on a small cloud VM, Rails 8.1.3.1, Ruby 3.4.8, database on the same machine:
|
|
151
|
+
|
|
152
|
+
| Database | Posts / rows | Strategy | Seconds | Queries | Hook calls | Speedup |
|
|
153
|
+
|---|---|---|---|---|---|---|
|
|
154
|
+
| SQLite (in-memory) | 1,000 / 17,003 | `destroy_all` | 9.44 | 32,001 | 84,000 | 1.0x |
|
|
155
|
+
| | | `find_each(&:destroy)` | 9.25 | 32,002 | 84,000 | 1.0x |
|
|
156
|
+
| | | `bulk_destroy_all` | 1.34 | 45 | 84,000 | 7.1x |
|
|
157
|
+
| SQLite (in-memory) | 5,000 / 85,003 | `destroy_all` | 58.33 | 160,001 | 420,000 | 1.0x |
|
|
158
|
+
| | | `find_each(&:destroy)` | 56.76 | 160,006 | 420,000 | 1.0x |
|
|
159
|
+
| | | `bulk_destroy_all` | 6.69 | 221 | 420,000 | 8.7x |
|
|
160
|
+
| PostgreSQL 16 (TCP) | 1,000 / 17,003 | `destroy_all` | 19.73 | 32,001 | 84,000 | 1.0x |
|
|
161
|
+
| | | `find_each(&:destroy)` | 20.57 | 32,002 | 84,000 | 1.0x |
|
|
162
|
+
| | | `bulk_destroy_all` | 1.58 | 45 | 84,000 | 12.5x |
|
|
163
|
+
|
|
164
|
+
What the numbers say: the speedup comes from removing queries, and the callbacks themselves still cost the same
|
|
165
|
+
(84,000 hook calls either way), so the gap is smallest on an in-memory database and larger when each query costs
|
|
166
|
+
a round trip. I only measured a database on the same machine; I would expect a remote database to widen the gap
|
|
167
|
+
but have not measured it. `destroy_all` and `find_each(&:destroy)` are equivalent in cost, since both issue the
|
|
168
|
+
same per-record queries; the loop only avoids loading every record at once.
|
|
169
|
+
|
|
170
|
+
## Compatibility
|
|
171
|
+
|
|
172
|
+
Supports Rails **7.1 through 8.2** (`activerecord >= 7.1, < 8.3`). The full suite (44 tests, foreign keys
|
|
173
|
+
enforced) passes unchanged on:
|
|
174
|
+
|
|
175
|
+
| Rails | SQLite 3 | PostgreSQL 16 | MariaDB 10.11 |
|
|
176
|
+
|---|---|---|---|
|
|
177
|
+
| 7.1.6 | pass | not run | not run |
|
|
178
|
+
| 7.2.4 | pass | not run | not run |
|
|
179
|
+
| 8.0.5 | pass | not run | not run |
|
|
180
|
+
| 8.1.3.1 | pass | pass | not run |
|
|
181
|
+
| 8.2.0.alpha (main) | pass | pass (first 22 tests; `belongs_to` tests not run) | pass (first 22 tests; `belongs_to` tests not run) |
|
|
182
|
+
|
|
183
|
+
Ruby was only tested on 3.4; the `>= 3.1` floor in the gemspec is an assumption (Rails itself enforces
|
|
184
|
+
its own Ruby floor). Oracle and SQL Server are unverified (IN-list ceilings are built in, nothing has been run there).
|
|
185
|
+
|
|
186
|
+
**Rails 7.0 and earlier are not supported.** In 7.0 the connection lock is held by the calling fiber for the
|
|
187
|
+
whole transaction, so any hook that runs a query inside a hook Fiber raises
|
|
188
|
+
`ThreadError: deadlock; lock already owned by another fiber`. Rails 7.1 changed that locking. On 7.0,
|
|
189
|
+
`BulkDestroyer.hook_mode = :inline` avoids Fibers (in an earlier 22-test run, 20 passed; the other two assert the
|
|
190
|
+
post-DELETE `after_destroy` timing that inline mode intentionally gives up), but this is not a supported configuration.
|
|
191
|
+
|
|
192
|
+
Version differences handled internally: `Model.with_connection` (7.2+, falls back to `connection` on 7.1) and
|
|
193
|
+
`counter_cached_association_names` (7.2+, falls back to the `belongs_to` reflections on 7.1).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkDestroyer
|
|
4
|
+
module RelationMethods
|
|
5
|
+
# Like #destroy_all: every destroy callback fires on every record, including dependent:
|
|
6
|
+
# :destroy descendants. Unlike #destroy_all: one SELECT per association per level and one
|
|
7
|
+
# DELETE per table per level.
|
|
8
|
+
def bulk_destroy_all(batch_size: BulkDestroyer.batch_size)
|
|
9
|
+
Destroyer.new(self, batch_size: batch_size).call
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module ClassMethods
|
|
14
|
+
def bulk_destroy_all(batch_size: BulkDestroyer.batch_size)
|
|
15
|
+
all.bulk_destroy_all(batch_size: batch_size)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module RecordMethods
|
|
20
|
+
def bulk_destroy!
|
|
21
|
+
Destroyer.new(self).call
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkDestroyer
|
|
4
|
+
# One transaction's worth of work: a batch of root records and everything below them.
|
|
5
|
+
#
|
|
6
|
+
# 1. Tree load the dependent: :destroy graph (one query per association per level)
|
|
7
|
+
# 2. restrictions dependent: :restrict_with_* checked before anything runs
|
|
8
|
+
# 3. start hooks before_destroy / first half of around_destroy, parents before children
|
|
9
|
+
# 4. per level, deepest first:
|
|
10
|
+
# nullify / delete_all / HABTM join rows -> bulk DELETE -> counter caches
|
|
11
|
+
# -> mark records destroyed -> after_destroy / second half of around_destroy
|
|
12
|
+
#
|
|
13
|
+
# Level-by-level means a child's after_destroy runs while its parent row still exists, and a
|
|
14
|
+
# parent's after_destroy runs after its children are gone, as with plain `destroy`.
|
|
15
|
+
class Batch
|
|
16
|
+
def initialize(roots, handled: Set.new)
|
|
17
|
+
@roots = roots
|
|
18
|
+
@handled = handled
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
levels = Tree.new(@roots, exclude: @handled).build
|
|
23
|
+
records = levels.flatten
|
|
24
|
+
inline = oversized?(records) # raises before anything ran if the tree is too big for Fibers
|
|
25
|
+
records.each { |record| @handled << Tree.key_for(record) }
|
|
26
|
+
counts = Hash.new(0)
|
|
27
|
+
runner = nil
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
Dependents.check_restrictions!(records)
|
|
31
|
+
runner = HookRunner.build(mode: inline ? :inline : BulkDestroyer.hook_mode)
|
|
32
|
+
records.each { |record| prepare(record) }
|
|
33
|
+
levels.flatten.each do |record|
|
|
34
|
+
next if runner.start(record)
|
|
35
|
+
|
|
36
|
+
raise ActiveRecord::RecordNotDestroyed.new("Failed to destroy #{record.class.name} with #{record.class.primary_key}=#{record.id}", record)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
levels.reverse_each do |level|
|
|
40
|
+
Dependents.apply_bulk_operations(level, counts)
|
|
41
|
+
delete_rows(level, counts)
|
|
42
|
+
CounterCaches.decrement(level)
|
|
43
|
+
level.each { |record| mark_destroyed(record) }
|
|
44
|
+
level.each { |record| runner.finish(record) }
|
|
45
|
+
destroy_belongs_to_targets(level, counts)
|
|
46
|
+
end
|
|
47
|
+
ensure
|
|
48
|
+
runner&.abort_all
|
|
49
|
+
records.each { |record| record.remove_instance_variable(CascadeGuard::FLAG) if record.instance_variable_defined?(CascadeGuard::FLAG) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
counts
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# belongs_to ..., dependent: :destroy / :delete. Rails does this in the owner's after_destroy; here it
|
|
58
|
+
# happens once the whole level of owners is gone. :destroy targets go through a nested Batch (their own
|
|
59
|
+
# hooks, children, restrictions and further belongs_to targets), still inside this transaction.
|
|
60
|
+
def destroy_belongs_to_targets(level, counts)
|
|
61
|
+
found = Dependents.belongs_to_targets(level, @handled)
|
|
62
|
+
|
|
63
|
+
run_nested(found[:destroy], counts) unless found[:destroy].empty?
|
|
64
|
+
|
|
65
|
+
found[:delete].group_by { |target| target.class.base_class }.each do |base, group|
|
|
66
|
+
ids = sortable(group.map(&:id))
|
|
67
|
+
Dependents.in_slices(ids, base) do |slice|
|
|
68
|
+
deleted = base.unscoped.where(base.primary_key => slice).delete_all
|
|
69
|
+
counts[base.name] += deleted if deleted.positive?
|
|
70
|
+
end
|
|
71
|
+
# Same end state as Rails' #delete: marked destroyed, no callbacks, no commit hooks.
|
|
72
|
+
group.each do |target|
|
|
73
|
+
target.instance_variable_set(:@destroyed, true)
|
|
74
|
+
target.freeze
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Same transaction as the enclosing batch. A tree that is too big for Fibers is split by targets.
|
|
80
|
+
def run_nested(targets, counts)
|
|
81
|
+
Batch.new(targets, handled: @handled).run.each { |name, n| counts[name] += n }
|
|
82
|
+
rescue SplitRequired
|
|
83
|
+
middle = targets.size / 2
|
|
84
|
+
run_nested(targets.first(middle), counts)
|
|
85
|
+
run_nested(targets.drop(middle), counts)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# One paused Fiber per record, all alive at once, so the tree size is capped. => true when this batch
|
|
89
|
+
# has to run its hooks without Fibers.
|
|
90
|
+
def oversized?(records)
|
|
91
|
+
return false if BulkDestroyer.hook_mode == :inline
|
|
92
|
+
return false if records.size <= BulkDestroyer.max_batch_records
|
|
93
|
+
raise SplitRequired if @roots.size > 1
|
|
94
|
+
|
|
95
|
+
case BulkDestroyer.oversized_tree
|
|
96
|
+
when :inline then true
|
|
97
|
+
when :raise
|
|
98
|
+
raise TreeTooLarge,
|
|
99
|
+
"the dependent: :destroy tree below #{@roots.first.class.name}##{@roots.first.id} has " \
|
|
100
|
+
"#{records.size} records with destroy hooks, more than BulkDestroyer.max_batch_records " \
|
|
101
|
+
"(#{BulkDestroyer.max_batch_records}). Each needs a paused Fiber and Ruby on Linux fails around " \
|
|
102
|
+
"31,000. Raise max_batch_records (keep it well below that), or set " \
|
|
103
|
+
"BulkDestroyer.oversized_tree = :inline to run these hooks without Fibers " \
|
|
104
|
+
"(after_destroy then runs before the DELETE)."
|
|
105
|
+
else raise ArgumentError, "unknown oversized_tree #{BulkDestroyer.oversized_tree.inspect}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def prepare(record)
|
|
110
|
+
record.instance_variable_set(CascadeGuard::FLAG, true)
|
|
111
|
+
record.send(:_raise_readonly_record_error) if record.readonly?
|
|
112
|
+
# Same bookkeeping Rails' with_transaction_returning_status does for a normal destroy: it
|
|
113
|
+
# lets after_commit / after_rollback fire and restores @destroyed if the batch rolls back.
|
|
114
|
+
record.send(:add_to_transaction)
|
|
115
|
+
record.send(:remember_transaction_record_state)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def delete_rows(level, counts)
|
|
119
|
+
level.group_by { |record| record.class.base_class }.each do |base, group|
|
|
120
|
+
ids = sortable(group.map(&:id))
|
|
121
|
+
base.transaction do
|
|
122
|
+
Dependents.in_slices(ids, base) do |slice|
|
|
123
|
+
counts[base.name] += base.unscoped.where(base.primary_key => slice).delete_all
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Ascending ids give concurrent bulk deletes a consistent lock order (fewer deadlocks).
|
|
130
|
+
def sortable(ids)
|
|
131
|
+
ids.sort
|
|
132
|
+
rescue ArgumentError, NoMethodError
|
|
133
|
+
ids
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def mark_destroyed(record)
|
|
137
|
+
record.instance_variable_set(:@_trigger_destroy_callback, true)
|
|
138
|
+
record.instance_variable_set(:@destroyed, true)
|
|
139
|
+
record.instance_variable_set(:@previously_new_record, false)
|
|
140
|
+
record.freeze
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkDestroyer
|
|
4
|
+
# Rails implements `dependent:` as a before_destroy/after_destroy lambda that calls
|
|
5
|
+
# association.handle_dependency, which loads and destroys children one by one. While a record
|
|
6
|
+
# is being handled by BulkDestroyer we handle every dependent: mode ourselves, so the owner's
|
|
7
|
+
# own callback must do nothing. Records not flagged behave exactly as in stock Rails.
|
|
8
|
+
module CascadeGuard
|
|
9
|
+
FLAG = :@__bulk_destroying
|
|
10
|
+
|
|
11
|
+
def handle_dependency
|
|
12
|
+
return if owner.instance_variable_get(FLAG)
|
|
13
|
+
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkDestroyer
|
|
4
|
+
# Rails decrements belongs_to counter caches inside Persistence#destroy_row, which bulk
|
|
5
|
+
# deletion bypasses. This reproduces it with one UPDATE per (counter column, amount) rather
|
|
6
|
+
# than one per row.
|
|
7
|
+
module CounterCaches
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Rails 7.2+ exposes counter_cached_association_names; 7.1 only has the reflections.
|
|
11
|
+
def counter_cached_reflections(klass)
|
|
12
|
+
if klass.respond_to?(:counter_cached_association_names)
|
|
13
|
+
klass.counter_cached_association_names.map { |name| klass._reflect_on_association(name) }
|
|
14
|
+
else
|
|
15
|
+
klass.reflect_on_all_associations(:belongs_to).select(&:counter_cache_column)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def decrement(records)
|
|
20
|
+
records.group_by(&:class).each do |klass, group|
|
|
21
|
+
counter_cached_reflections(klass).each do |reflection|
|
|
22
|
+
name = reflection.name
|
|
23
|
+
simple = !reflection.polymorphic? && Array(reflection.foreign_key).size == 1
|
|
24
|
+
tally = Hash.new(0)
|
|
25
|
+
|
|
26
|
+
group.each do |record|
|
|
27
|
+
# Same rule as Rails: a child destroyed via its parent's dependent: association does
|
|
28
|
+
# not touch that parent's counter.
|
|
29
|
+
via = record.destroyed_by_association
|
|
30
|
+
next if via && via.foreign_key == reflection.foreign_key
|
|
31
|
+
|
|
32
|
+
if simple
|
|
33
|
+
id = record._read_attribute(reflection.foreign_key)
|
|
34
|
+
tally[id] += 1 if id
|
|
35
|
+
else
|
|
36
|
+
record.association(name).decrement_counters
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
tally.group_by { |_id, n| n }.each do |n, pairs|
|
|
41
|
+
counters = { reflection.counter_cache_column => -n }
|
|
42
|
+
counters[:touch] = reflection.options[:touch] if reflection.options[:touch]
|
|
43
|
+
Dependents.in_slices(pairs.map(&:first), reflection.klass) do |ids|
|
|
44
|
+
reflection.klass.update_counters(ids.sort, counters.dup)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkDestroyer
|
|
4
|
+
# Understands the `dependent:` options of ActiveRecord associations and reproduces their
|
|
5
|
+
# semantics with set-based SQL.
|
|
6
|
+
module Dependents
|
|
7
|
+
Plan = Struct.new(:reflection, :kind)
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# kinds: :destroy (children join the tree), :through_destroy (join records join the tree),
|
|
12
|
+
# :delete_all, :nullify, :restrict, :habtm (join rows are deleted without callbacks)
|
|
13
|
+
def plans_for(klass)
|
|
14
|
+
klass.reflect_on_all_associations.filter_map { |reflection| classify(reflection) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def classify(reflection)
|
|
18
|
+
return Plan.new(reflection, :habtm) if reflection.macro == :has_and_belongs_to_many
|
|
19
|
+
|
|
20
|
+
dependent = reflection.options[:dependent]
|
|
21
|
+
return nil if dependent.nil?
|
|
22
|
+
|
|
23
|
+
if reflection.belongs_to?
|
|
24
|
+
# Includes delegated_type, which declares `belongs_to ..., polymorphic: true, dependent: ...`.
|
|
25
|
+
case dependent
|
|
26
|
+
when :destroy then return Plan.new(reflection, :belongs_to_destroy)
|
|
27
|
+
when :delete then return Plan.new(reflection, :belongs_to_delete)
|
|
28
|
+
else unsupported!(reflection, "belongs_to with dependent: #{dependent.inspect} is not supported")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
through = reflection.options[:through]
|
|
33
|
+
unsupported!(reflection, "nested has_many :through is not supported") if through && reflection.nested?
|
|
34
|
+
|
|
35
|
+
case dependent
|
|
36
|
+
when :destroy
|
|
37
|
+
Plan.new(reflection, through ? :through_destroy : :destroy)
|
|
38
|
+
when :delete_all, :delete
|
|
39
|
+
Plan.new(reflection, :delete_all)
|
|
40
|
+
when :nullify
|
|
41
|
+
unsupported!(reflection, "dependent: :nullify on a :through association") if through
|
|
42
|
+
Plan.new(reflection, :nullify)
|
|
43
|
+
when :restrict_with_exception, :restrict_with_error
|
|
44
|
+
Plan.new(reflection, :restrict)
|
|
45
|
+
when :destroy_async
|
|
46
|
+
unsupported!(reflection, "dependent: :destroy_async cannot be combined with bulk destroy")
|
|
47
|
+
else
|
|
48
|
+
unsupported!(reflection, "unknown dependent: #{dependent.inspect}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def unsupported!(reflection, message)
|
|
53
|
+
raise UnsupportedAssociation, "#{reflection.active_record.name}##{reflection.name}: #{message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Checks every restrict_with_* association before any hook runs.
|
|
57
|
+
def check_restrictions!(records)
|
|
58
|
+
records.group_by(&:class).each do |klass, owners|
|
|
59
|
+
plans_for(klass).each do |plan|
|
|
60
|
+
next unless plan.kind == :restrict
|
|
61
|
+
|
|
62
|
+
reflection = plan.reflection
|
|
63
|
+
source = reflection.options[:through] ? reflection.through_reflection : reflection
|
|
64
|
+
owner_keys = owners.to_h { |o| [o._read_attribute(source.active_record_primary_key), o] }
|
|
65
|
+
blocked = Set.new
|
|
66
|
+
in_slices(owner_keys.keys, source.klass) do |keys|
|
|
67
|
+
blocked.merge(child_relation(source, keys, owners.first).distinct.pluck(source.foreign_key))
|
|
68
|
+
end
|
|
69
|
+
next if blocked.empty?
|
|
70
|
+
|
|
71
|
+
if reflection.options[:dependent] == :restrict_with_exception
|
|
72
|
+
raise ActiveRecord::DeleteRestrictionError.new(reflection.name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
owner = owner_keys.fetch(blocked.first)
|
|
76
|
+
human = owner.class.human_attribute_name(reflection.name).downcase
|
|
77
|
+
owner.errors.add(:base, :"restrict_dependent_destroy.#{reflection.has_one? ? 'has_one' : 'has_many'}", record: human)
|
|
78
|
+
raise ActiveRecord::RecordNotDestroyed.new("Failed to destroy #{owner.class.name}: #{owner.errors.full_messages.to_sentence}", owner)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Targets of `belongs_to ..., dependent: :destroy / :delete` for one level of owners.
|
|
84
|
+
# Rails destroys these inside the owner's after_destroy, i.e. once the owner's row is gone, so
|
|
85
|
+
# Batch calls this after each level. Preloader loads them in one query per target class,
|
|
86
|
+
# polymorphic included.
|
|
87
|
+
#
|
|
88
|
+
# +handled+ holds the keys of records already in (or already taken by) this batch; those are
|
|
89
|
+
# skipped, and every target returned is added to it. That makes a target shared by several owners
|
|
90
|
+
# come back once, and stops two models that point at each other from destroying each other forever.
|
|
91
|
+
#
|
|
92
|
+
# => { destroy: [records], delete: [records] }
|
|
93
|
+
def belongs_to_targets(owners, handled)
|
|
94
|
+
found = { destroy: [], delete: [] }
|
|
95
|
+
owners.group_by(&:class).each do |klass, group|
|
|
96
|
+
plans_for(klass).each do |plan|
|
|
97
|
+
next unless plan.kind == :belongs_to_destroy || plan.kind == :belongs_to_delete
|
|
98
|
+
|
|
99
|
+
name = plan.reflection.name
|
|
100
|
+
ActiveRecord::Associations::Preloader.new(records: group, associations: [name]).call
|
|
101
|
+
bucket = plan.kind == :belongs_to_destroy ? found[:destroy] : found[:delete]
|
|
102
|
+
group.each do |owner|
|
|
103
|
+
target = owner.association(name).target
|
|
104
|
+
next if target.nil? || target.destroyed?
|
|
105
|
+
next unless handled.add?(Tree.key_for(target))
|
|
106
|
+
|
|
107
|
+
bucket << target
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
found
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# :delete_all, :nullify and HABTM join rows for one level of the tree.
|
|
115
|
+
# Rows removed by dependent: :delete_all are added to +counts+ under their model's base class name
|
|
116
|
+
# (HABTM join rows are not counted: the join table has no model of its own).
|
|
117
|
+
def apply_bulk_operations(records, counts)
|
|
118
|
+
records.group_by(&:class).each do |klass, owners|
|
|
119
|
+
plans_for(klass).each do |plan|
|
|
120
|
+
reflection = plan.reflection
|
|
121
|
+
case plan.kind
|
|
122
|
+
when :delete_all
|
|
123
|
+
source = reflection.options[:through] ? reflection.through_reflection : reflection
|
|
124
|
+
each_child_relation(source, owners) do |relation|
|
|
125
|
+
deleted = relation.delete_all
|
|
126
|
+
counts[source.klass.base_class.name] += deleted if deleted.positive?
|
|
127
|
+
end
|
|
128
|
+
when :nullify
|
|
129
|
+
attributes = { reflection.foreign_key => nil }
|
|
130
|
+
attributes[reflection.type] = nil if reflection.type
|
|
131
|
+
each_child_relation(reflection, owners) { |relation| relation.update_all(attributes) }
|
|
132
|
+
when :habtm
|
|
133
|
+
middle = klass._reflections.values.find { |r| r.parent_reflection.equal?(reflection) && r.options[:through].nil? }
|
|
134
|
+
each_child_relation(middle, owners) { |relation| relation.delete_all }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def each_child_relation(reflection, owners)
|
|
141
|
+
keys = owners.map { |o| o._read_attribute(reflection.active_record_primary_key) }
|
|
142
|
+
in_slices(keys, reflection.klass) do |slice|
|
|
143
|
+
yield child_relation(reflection, slice, owners.first)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Rows that belong to +owner_keys+ through +reflection+, honouring the association scope,
|
|
148
|
+
# custom keys, and polymorphic `as:` type columns.
|
|
149
|
+
def child_relation(reflection, owner_keys, sample_owner)
|
|
150
|
+
if Array(reflection.foreign_key).size > 1
|
|
151
|
+
unsupported!(reflection, "composite foreign keys are only supported for dependent: :destroy")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
relation = reflection.klass.all
|
|
155
|
+
if reflection.scope
|
|
156
|
+
if reflection.scope.arity.positive?
|
|
157
|
+
unsupported!(reflection, "instance-dependent association scopes are only supported for dependent: :destroy")
|
|
158
|
+
end
|
|
159
|
+
relation = relation.instance_exec(&reflection.scope)
|
|
160
|
+
end
|
|
161
|
+
relation = relation.where(reflection.foreign_key => owner_keys)
|
|
162
|
+
relation = relation.where(reflection.type => sample_owner.class.polymorphic_name) if reflection.type
|
|
163
|
+
relation
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Splits long IN lists to stay below adapter limits (Oracle 1000, SQL Server ~2100, ...).
|
|
167
|
+
def in_slices(values, model, &block)
|
|
168
|
+
limit = BulkDestroyer.in_list_size_for(model)
|
|
169
|
+
values.each_slice(limit, &block)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkDestroyer
|
|
4
|
+
class Destroyer
|
|
5
|
+
def initialize(source, batch_size: BulkDestroyer.batch_size)
|
|
6
|
+
@source = source
|
|
7
|
+
@batch_size = Integer(batch_size)
|
|
8
|
+
raise ArgumentError, "batch_size must be positive" unless @batch_size.positive?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# => { "Post" => 3, "Comment" => 12, ... } rows deleted per table (by base class name)
|
|
12
|
+
def call
|
|
13
|
+
counts = Hash.new(0)
|
|
14
|
+
each_root_batch { |roots| run_roots(roots, counts) }
|
|
15
|
+
counts.to_h
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def run_roots(roots, counts)
|
|
21
|
+
# requires_new: a failing batch rolls back to its own savepoint (inside a caller's
|
|
22
|
+
# transaction) or its own real transaction (outside one).
|
|
23
|
+
completed = false
|
|
24
|
+
roots.first.class.transaction(requires_new: true) do
|
|
25
|
+
Batch.new(roots).run.each { |name, n| counts[name] += n }
|
|
26
|
+
completed = true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# `transaction` swallows ActiveRecord::Rollback: it rolls the batch back and returns nil without
|
|
30
|
+
# raising. A hook that does that would otherwise make this batch vanish silently.
|
|
31
|
+
return if completed
|
|
32
|
+
|
|
33
|
+
raise ActiveRecord::RecordNotDestroyed.new(
|
|
34
|
+
"bulk destroy rolled back a batch of #{roots.size} #{roots.first.class.name} record(s): " \
|
|
35
|
+
"ActiveRecord::Rollback was raised in a destroy callback",
|
|
36
|
+
roots.first
|
|
37
|
+
)
|
|
38
|
+
rescue SplitRequired
|
|
39
|
+
# The tree under these roots has too many records for one batch of paused Fibers. Nothing ran yet
|
|
40
|
+
# (the savepoint is already rolled back); do each half as its own batch.
|
|
41
|
+
middle = roots.size / 2
|
|
42
|
+
run_roots(roots.first(middle), counts)
|
|
43
|
+
run_roots(roots.drop(middle), counts)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def each_root_batch(&block)
|
|
47
|
+
case @source
|
|
48
|
+
when ActiveRecord::Base
|
|
49
|
+
yield [@source] if @source.persisted?
|
|
50
|
+
when ActiveRecord::Relation
|
|
51
|
+
@source.find_in_batches(batch_size: @batch_size, &block)
|
|
52
|
+
else
|
|
53
|
+
raise ArgumentError, "expected an ActiveRecord relation or record, got #{@source.class}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkDestroyer
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# An association configuration the gem cannot reproduce faithfully
|
|
7
|
+
# (e.g. belongs_to dependent:, :destroy_async, composite-key :nullify).
|
|
8
|
+
class UnsupportedAssociation < Error; end
|
|
9
|
+
|
|
10
|
+
# ActiveSupport::IsolatedExecutionState.isolation_level == :fiber and hook_mode == :fiber.
|
|
11
|
+
class UnsupportedIsolation < Error; end
|
|
12
|
+
|
|
13
|
+
# The dependent: :destroy graph contains a cycle in the data.
|
|
14
|
+
class CycleDetected < Error; end
|
|
15
|
+
|
|
16
|
+
# One root's dependent: :destroy tree has more hook-bearing records than BulkDestroyer.max_batch_records
|
|
17
|
+
# and cannot be split any further (see BulkDestroyer.oversized_tree).
|
|
18
|
+
class TreeTooLarge < Error; end
|
|
19
|
+
|
|
20
|
+
# Internal: a batch with several roots built a tree over the limit before running any hook; the caller
|
|
21
|
+
# retries with the roots split in two. Never reaches application code.
|
|
22
|
+
class SplitRequired < Error; end
|
|
23
|
+
|
|
24
|
+
# Raised inside suspended hook fibers to unwind them (so `ensure` blocks in around_destroy
|
|
25
|
+
# callbacks run). Deliberately not a StandardError so `rescue => e` in app hooks can't swallow it.
|
|
26
|
+
class Unwind < Exception; end # rubocop:disable Lint/InheritException
|
|
27
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkDestroyer
|
|
4
|
+
# Runs a record's real :destroy callback chain in two halves around the bulk DELETE.
|
|
5
|
+
#
|
|
6
|
+
# FiberRunner: each record's `run_callbacks(:destroy)` runs inside a Fiber whose block yields.
|
|
7
|
+
# - start(record) runs before_destroy and the first half of around_destroy, up to the block
|
|
8
|
+
# - finish(record) resumes it after the rows are gone: second half of around_destroy, then
|
|
9
|
+
# after_destroy. Conditions (if:/unless:), ordering, prepend:, callback
|
|
10
|
+
# objects and `throw :abort` are all handled by Rails itself.
|
|
11
|
+
#
|
|
12
|
+
# InlineRunner: runs the whole chain before the DELETE (after_destroy sees the row). Only for
|
|
13
|
+
# apps running with ActiveSupport::IsolatedExecutionState.isolation_level = :fiber.
|
|
14
|
+
module HookRunner
|
|
15
|
+
def self.build(mode: BulkDestroyer.hook_mode)
|
|
16
|
+
case mode
|
|
17
|
+
when :fiber
|
|
18
|
+
if ActiveSupport::IsolatedExecutionState.isolation_level == :fiber
|
|
19
|
+
raise UnsupportedIsolation,
|
|
20
|
+
"isolation_level is :fiber, so each hook Fiber would get its own database connection. " \
|
|
21
|
+
"Set BulkDestroyer.hook_mode = :inline (after_destroy then runs just before the DELETE)."
|
|
22
|
+
end
|
|
23
|
+
FiberRunner.new
|
|
24
|
+
when :inline then InlineRunner.new
|
|
25
|
+
else raise ArgumentError, "unknown hook_mode #{mode.inspect}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class FiberRunner
|
|
30
|
+
def initialize
|
|
31
|
+
@fibers = {}.compare_by_identity
|
|
32
|
+
# Thread.current[] values are fiber-local; copy them so hooks that read request-scoped
|
|
33
|
+
# state (RequestStore-style) see the same values inside the hook fibers.
|
|
34
|
+
@locals = Thread.current.keys.reject { |k| k.to_s.start_with?("__") }.to_h { |k| [k, Thread.current[k]] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# => true when the chain reached the destroy step, false when a before_destroy halted it.
|
|
38
|
+
def start(record)
|
|
39
|
+
locals = @locals
|
|
40
|
+
fiber = Fiber.new do
|
|
41
|
+
locals.each { |k, v| Thread.current[k] = v }
|
|
42
|
+
record.run_callbacks(:destroy) do
|
|
43
|
+
Fiber.yield(:ready)
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
@fibers[record] = fiber
|
|
48
|
+
fiber.resume
|
|
49
|
+
return true if fiber.alive?
|
|
50
|
+
|
|
51
|
+
@fibers.delete(record)
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def finish(record)
|
|
56
|
+
fiber = @fibers.delete(record)
|
|
57
|
+
fiber&.resume
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Unwinds fibers still suspended after a failure so around_destroy `ensure` blocks run.
|
|
61
|
+
def abort_all
|
|
62
|
+
@fibers.each_value do |fiber|
|
|
63
|
+
next unless fiber.alive?
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
fiber.raise(Unwind)
|
|
67
|
+
rescue Unwind, StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
@fibers.clear
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class InlineRunner
|
|
76
|
+
def start(record)
|
|
77
|
+
record.run_callbacks(:destroy) { true } ? true : false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def finish(_record); end
|
|
81
|
+
|
|
82
|
+
def abort_all; end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BulkDestroyer
|
|
4
|
+
# Loads the dependent: :destroy graph below a batch of root records one level at a time:
|
|
5
|
+
# one preload query per association per level, never per record.
|
|
6
|
+
#
|
|
7
|
+
# A record reachable by several paths is placed at its deepest level, so it is always deleted
|
|
8
|
+
# before anything it references.
|
|
9
|
+
class Tree
|
|
10
|
+
def self.key_for(record)
|
|
11
|
+
[record.class.base_class, record.id]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# +exclude+: keys of records that belong to an enclosing batch. Descendants found under them are
|
|
15
|
+
# left alone (roots are always taken), so a nested pass never handles a record twice.
|
|
16
|
+
def initialize(roots, exclude: nil)
|
|
17
|
+
@roots = roots
|
|
18
|
+
@exclude = exclude || Set.new
|
|
19
|
+
@records = {}
|
|
20
|
+
@depths = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# => Array of Arrays; index 0 holds the roots, higher indexes are deeper.
|
|
24
|
+
def build
|
|
25
|
+
frontier = []
|
|
26
|
+
@roots.each { |root| add(root, 0, frontier) }
|
|
27
|
+
|
|
28
|
+
until frontier.empty?
|
|
29
|
+
current = frontier.uniq
|
|
30
|
+
frontier = []
|
|
31
|
+
current.group_by(&:class).each do |klass, owners|
|
|
32
|
+
expand(klass, owners) do |child, owner, via|
|
|
33
|
+
next if skipped?(child)
|
|
34
|
+
|
|
35
|
+
canonical = add(child, @depths.fetch(key(owner)) + 1, frontier)
|
|
36
|
+
canonical.destroyed_by_association = via if via
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
by_depth = Hash.new { |hash, depth| hash[depth] = [] }
|
|
42
|
+
@records.each { |k, record| by_depth[@depths.fetch(k)] << record }
|
|
43
|
+
by_depth.sort.map(&:last)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def key(record)
|
|
49
|
+
self.class.key_for(record)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def skipped?(record)
|
|
53
|
+
k = key(record)
|
|
54
|
+
!@records.key?(k) && @exclude.include?(k)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add(record, depth, frontier)
|
|
58
|
+
k = key(record)
|
|
59
|
+
if (existing = @records[k])
|
|
60
|
+
return existing if @depths.fetch(k) >= depth
|
|
61
|
+
|
|
62
|
+
raise CycleDetected, "dependent: :destroy cycle detected at #{record.class.name}##{record.id}" if depth > @records.size
|
|
63
|
+
|
|
64
|
+
@depths[k] = depth
|
|
65
|
+
frontier << existing
|
|
66
|
+
existing
|
|
67
|
+
else
|
|
68
|
+
@records[k] = record
|
|
69
|
+
@depths[k] = depth
|
|
70
|
+
frontier << record
|
|
71
|
+
record
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def expand(klass, owners)
|
|
76
|
+
plans = Dependents.plans_for(klass).select { |p| p.kind == :destroy || p.kind == :through_destroy }
|
|
77
|
+
return if plans.empty?
|
|
78
|
+
|
|
79
|
+
names = plans.map { |p| p.kind == :destroy ? p.reflection.name : p.reflection.through_reflection.name }.uniq
|
|
80
|
+
ActiveRecord::Associations::Preloader.new(records: owners, associations: names).call
|
|
81
|
+
|
|
82
|
+
plans.each do |plan|
|
|
83
|
+
direct = plan.kind == :destroy
|
|
84
|
+
name = direct ? plan.reflection.name : plan.reflection.through_reflection.name
|
|
85
|
+
owners.each do |owner|
|
|
86
|
+
Array(owner.association(name).target).each do |child|
|
|
87
|
+
yield child, owner, (plan.reflection if direct)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_record"
|
|
6
|
+
|
|
7
|
+
require_relative "bulk_destroyer/version"
|
|
8
|
+
require_relative "bulk_destroyer/errors"
|
|
9
|
+
require_relative "bulk_destroyer/cascade_guard"
|
|
10
|
+
require_relative "bulk_destroyer/dependents"
|
|
11
|
+
require_relative "bulk_destroyer/tree"
|
|
12
|
+
require_relative "bulk_destroyer/hook_runner"
|
|
13
|
+
require_relative "bulk_destroyer/counter_caches"
|
|
14
|
+
require_relative "bulk_destroyer/batch"
|
|
15
|
+
require_relative "bulk_destroyer/destroyer"
|
|
16
|
+
require_relative "bulk_destroyer/api"
|
|
17
|
+
|
|
18
|
+
module BulkDestroyer
|
|
19
|
+
# Hard ceilings for IN (...) lists by adapter; the effective size is min(in_list_size, ceiling).
|
|
20
|
+
ADAPTER_IN_LIMITS = {
|
|
21
|
+
"oracleenhanced" => 1_000,
|
|
22
|
+
"sqlserver" => 2_000,
|
|
23
|
+
"sqlite" => 32_766
|
|
24
|
+
}.freeze
|
|
25
|
+
DEFAULT_IN_LIMIT = 65_000
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Parents loaded per transaction.
|
|
29
|
+
attr_accessor :batch_size
|
|
30
|
+
# Max values per `WHERE id IN (...)` chunk.
|
|
31
|
+
attr_accessor :in_list_size
|
|
32
|
+
# :fiber (default; after_destroy runs after the DELETE) or :inline (see HookRunner).
|
|
33
|
+
attr_accessor :hook_mode
|
|
34
|
+
# Most records (with destroy hooks) one batch may hold. :fiber mode keeps one paused Fiber per record,
|
|
35
|
+
# and Ruby on Linux fails (FiberError) at roughly 31,000 live Fibers with the default vm.max_map_count.
|
|
36
|
+
# A batch whose tree is larger is split in half until it fits; keep this well below that ceiling,
|
|
37
|
+
# because nested belongs_to target batches are counted separately.
|
|
38
|
+
attr_accessor :max_batch_records
|
|
39
|
+
# What to do when ONE root's tree alone exceeds max_batch_records: :raise (default, TreeTooLarge)
|
|
40
|
+
# or :inline (run that batch's hooks without Fibers, so its after_destroy hooks run before the DELETE).
|
|
41
|
+
attr_accessor :oversized_tree
|
|
42
|
+
|
|
43
|
+
def in_list_size_for(model)
|
|
44
|
+
# Model.with_connection arrived in Rails 7.2; 7.1 only has Model.connection.
|
|
45
|
+
name = model.respond_to?(:with_connection) ? model.with_connection(&:adapter_name) : model.connection.adapter_name
|
|
46
|
+
adapter = name.to_s.downcase.delete("_ ")
|
|
47
|
+
ceiling = ADAPTER_IN_LIMITS.find { |name, _| adapter.include?(name) }&.last || DEFAULT_IN_LIMIT
|
|
48
|
+
[in_list_size, ceiling].min
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
self.batch_size = 1_000
|
|
53
|
+
self.in_list_size = 500
|
|
54
|
+
self.hook_mode = :fiber
|
|
55
|
+
self.max_batch_records = 10_000
|
|
56
|
+
self.oversized_tree = :raise
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
ActiveSupport.on_load(:active_record) do
|
|
60
|
+
ActiveRecord::Relation.include BulkDestroyer::RelationMethods
|
|
61
|
+
ActiveRecord::Base.extend BulkDestroyer::ClassMethods
|
|
62
|
+
ActiveRecord::Base.include BulkDestroyer::RecordMethods
|
|
63
|
+
|
|
64
|
+
[ActiveRecord::Associations::HasManyAssociation,
|
|
65
|
+
ActiveRecord::Associations::HasOneAssociation,
|
|
66
|
+
ActiveRecord::Associations::BelongsToAssociation].each do |association|
|
|
67
|
+
association.prepend BulkDestroyer::CascadeGuard
|
|
68
|
+
end
|
|
69
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bulk_destroyer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ramanan Neelakantan
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '8.3'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '8.3'
|
|
32
|
+
description: |
|
|
33
|
+
BulkDestroyer destroys a relation (and its dependent: :destroy tree) while firing every
|
|
34
|
+
destroy callback on every record, but issues one SELECT per association per level and one
|
|
35
|
+
DELETE per table per level instead of one DELETE per row. Works on any ActiveRecord adapter.
|
|
36
|
+
email:
|
|
37
|
+
- ramanan.i.neelakantan@gmail.com
|
|
38
|
+
executables: []
|
|
39
|
+
extensions: []
|
|
40
|
+
extra_rdoc_files: []
|
|
41
|
+
files:
|
|
42
|
+
- CHANGELOG.md
|
|
43
|
+
- LICENSE
|
|
44
|
+
- README.md
|
|
45
|
+
- lib/bulk_destroyer.rb
|
|
46
|
+
- lib/bulk_destroyer/api.rb
|
|
47
|
+
- lib/bulk_destroyer/batch.rb
|
|
48
|
+
- lib/bulk_destroyer/cascade_guard.rb
|
|
49
|
+
- lib/bulk_destroyer/counter_caches.rb
|
|
50
|
+
- lib/bulk_destroyer/dependents.rb
|
|
51
|
+
- lib/bulk_destroyer/destroyer.rb
|
|
52
|
+
- lib/bulk_destroyer/errors.rb
|
|
53
|
+
- lib/bulk_destroyer/hook_runner.rb
|
|
54
|
+
- lib/bulk_destroyer/tree.rb
|
|
55
|
+
- lib/bulk_destroyer/version.rb
|
|
56
|
+
homepage: https://github.com/rneelakantan/bulk_destroyer
|
|
57
|
+
licenses:
|
|
58
|
+
- MIT
|
|
59
|
+
metadata:
|
|
60
|
+
source_code_uri: https://github.com/rneelakantan/bulk_destroyer
|
|
61
|
+
changelog_uri: https://github.com/rneelakantan/bulk_destroyer/blob/main/CHANGELOG.md
|
|
62
|
+
bug_tracker_uri: https://github.com/rneelakantan/bulk_destroyer/issues
|
|
63
|
+
rubygems_mfa_required: 'true'
|
|
64
|
+
rdoc_options: []
|
|
65
|
+
require_paths:
|
|
66
|
+
- lib
|
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '3.2'
|
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '0'
|
|
77
|
+
requirements: []
|
|
78
|
+
rubygems_version: 3.6.2
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: 'destroy_all in bulk: every destroy callback fires, rows are deleted with
|
|
81
|
+
set-based SQL.'
|
|
82
|
+
test_files: []
|