cold_storage 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 +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +399 -0
- data/lib/cold_storage/archivable.rb +144 -0
- data/lib/cold_storage/archive_model.rb +181 -0
- data/lib/cold_storage/archive_record.rb +116 -0
- data/lib/cold_storage/archiver.rb +283 -0
- data/lib/cold_storage/association_tree.rb +26 -0
- data/lib/cold_storage/configuration.rb +105 -0
- data/lib/cold_storage/internal_schema.rb +56 -0
- data/lib/cold_storage/jobs.rb +61 -0
- data/lib/cold_storage/logging.rb +18 -0
- data/lib/cold_storage/metadata.rb +33 -0
- data/lib/cold_storage/model.rb +48 -0
- data/lib/cold_storage/policy.rb +212 -0
- data/lib/cold_storage/railtie.rb +51 -0
- data/lib/cold_storage/registry.rb +60 -0
- data/lib/cold_storage/report.rb +50 -0
- data/lib/cold_storage/restorer.rb +194 -0
- data/lib/cold_storage/row_reader.rb +31 -0
- data/lib/cold_storage/run.rb +51 -0
- data/lib/cold_storage/schema_mirror.rb +337 -0
- data/lib/cold_storage/tasks/cold_storage.rake +102 -0
- data/lib/cold_storage/version.rb +5 -0
- data/lib/cold_storage.rb +139 -0
- data/lib/generators/cold_storage/install/install_generator.rb +39 -0
- data/lib/generators/cold_storage/install/templates/initializer.rb.tt +36 -0
- metadata +105 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2d7a93fbe18555d8eafe6a0720722476a369eea45bf86d81e7c43490c8a33456
|
|
4
|
+
data.tar.gz: 33399d9c88a64d53b7fff48a7ab75881454b2e77248be4247ad7e488b9f5ce12
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7147ede6d8a60b043f093f0ce1ab8000828f2eae5583d9a02d7efb7dd7588c88bdbf04d87a88e82faaedc004bf8df93f7c4ef22010cc1f31d101c258fb2d148c
|
|
7
|
+
data.tar.gz: 4b5954d3ad66ef1c48e2ad3d6db848633076310399df4fd49049f50860ea918f03b7782765926e6181f622a043fa29751c127b21f49807784eb121d15cd5cf26
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
* `archivable` macro: `after:`, `on:`, `deleted:`, `deleted_column:`,
|
|
6
|
+
`on_destroy:`, `every:`, `scope:`, `cascade:`, `batch_size:`,
|
|
7
|
+
`delete_after_archive:`, `delete_method:`.
|
|
8
|
+
* `on_destroy: true` keeps hard-deleted rows by copying them to the archive in
|
|
9
|
+
a `before_destroy` hook, together with the policy's cascade and without
|
|
10
|
+
deleting anything itself; `config.on_destroy_error` decides whether an
|
|
11
|
+
unreachable archive blocks the delete.
|
|
12
|
+
* Association scopes that the archive class cannot evaluate degrade to the
|
|
13
|
+
unfiltered relation instead of raising on read.
|
|
14
|
+
* Unique indexes are mirrored as plain indexes; the primary key is the only
|
|
15
|
+
uniqueness the archive enforces.
|
|
16
|
+
* `config.enabled` master switch, so an app can stand the gem down per
|
|
17
|
+
environment (the test one, typically).
|
|
18
|
+
* `cascade:` accepts a nested tree (`cascade: { lines: [:taxes] }`) so a whole
|
|
19
|
+
object graph can be taken along, validated before anything moves.
|
|
20
|
+
* Separate archive database, connected lazily through `ColdStorage::ArchiveRecord`.
|
|
21
|
+
* Schema mirroring for the archivable models only, including PostgreSQL enum
|
|
22
|
+
types, arrays and indexes; automatic sync after `db:migrate`, and a
|
|
23
|
+
`cold_storage:schema:check` task for CI.
|
|
24
|
+
* Batched, idempotent archiving with cascade support, dry runs and limits.
|
|
25
|
+
* Reading: `Model.archived`, `archived_count`, `find_archived`,
|
|
26
|
+
`find_with_archived`, and archive models that mirror the source
|
|
27
|
+
associations (`has_many`, `has_one`, `belongs_to`, `:through`) so an archived
|
|
28
|
+
row can be read together with its archived children.
|
|
29
|
+
* Restore back into the primary database, on its own or with relations:
|
|
30
|
+
`with: :all`, `with: [:lines]`, `with: { lines: [:taxes] }`, from
|
|
31
|
+
`Model.restore_archived` or from an archived record's `restore!`.
|
|
32
|
+
* Run bookkeeping in the archive database, which is what makes `every:` work
|
|
33
|
+
across processes.
|
|
34
|
+
* `ColdStorage::ArchiveAllJob` / `ArchiveModelJob` and the
|
|
35
|
+
`cold_storage:*` rake tasks.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Afshin Amini
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# Cold Storage
|
|
2
|
+
|
|
3
|
+
Declarative auto-archiving for ActiveRecord.
|
|
4
|
+
|
|
5
|
+
Add one line to a model and its old (or soft-deleted) rows move, on a schedule,
|
|
6
|
+
into a **separate archive database** whose schema is a mirror of the primary
|
|
7
|
+
one — created for you, and kept up to date on every migration, for the
|
|
8
|
+
archivable models only.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
class Payroll < ApplicationRecord
|
|
12
|
+
archivable after: 18.months, every: 1.month
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class Document < ApplicationRecord
|
|
16
|
+
archivable deleted: true, after: 30.days # soft-deleted 30+ days ago
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Receipt < ApplicationRecord
|
|
20
|
+
archivable on_destroy: true # keep hard-deleted rows
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Invoice < ApplicationRecord
|
|
24
|
+
archivable after: 2.years, cascade: [:invoice_lines]
|
|
25
|
+
end
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# Gemfile
|
|
32
|
+
gem 'cold_storage', git: 'git@github.com:afshmini/cold_storage.git'
|
|
33
|
+
# or, while working on it:
|
|
34
|
+
gem 'cold_storage', path: '../cold_storage'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
rails generate cold_storage:install
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 1. Add the archive database
|
|
42
|
+
|
|
43
|
+
`config/database.yml`, in every environment that should archive:
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
development:
|
|
47
|
+
primary:
|
|
48
|
+
<<: *default
|
|
49
|
+
archive:
|
|
50
|
+
<<: *default
|
|
51
|
+
database: <%= ENV.fetch('ARCHIVE_POSTGRES_DB') %>
|
|
52
|
+
database_tasks: false
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`database_tasks: false` matters: it stops `rails db:migrate` from running your
|
|
56
|
+
application migrations against the archive database. ColdStorage mirrors the
|
|
57
|
+
schema itself, and only for the tables it needs.
|
|
58
|
+
|
|
59
|
+
### 2. Create it and mirror the schema
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
rails cold_storage:db:create # CREATE DATABASE
|
|
63
|
+
rails cold_storage:schema:sync # copy the archivable models' tables
|
|
64
|
+
rails cold_storage:status
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. Schedule it
|
|
68
|
+
|
|
69
|
+
The bundled job walks every archivable model and lets each one decide whether
|
|
70
|
+
its `every:` window has elapsed, so a daily schedule is enough:
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
# config/recurring.yml (solid_queue)
|
|
74
|
+
cold_storage:
|
|
75
|
+
schedule: "0 2 * * *"
|
|
76
|
+
class: "ColdStorage::ArchiveAllJob"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Or from cron/rake: `rails cold_storage:archive_all`.
|
|
80
|
+
|
|
81
|
+
## The `archivable` options
|
|
82
|
+
|
|
83
|
+
| Option | Meaning | Default |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `after:` (alias `older_than:`) | archive rows older than this duration | — |
|
|
86
|
+
| `on:` | column the age is measured on | `:created_at`, or the deleted column when `deleted: true` |
|
|
87
|
+
| `deleted:` | archive soft-deleted rows only | `false` |
|
|
88
|
+
| `deleted_column:` | where the soft-delete timestamp lives | `config.deleted_column` (`:deleted_at`) |
|
|
89
|
+
| `on_destroy:` (alias `hard_delete:`) | copy a row to the archive whenever it is really destroyed | `false` |
|
|
90
|
+
| `every:` | minimum time between two runs of this model | run on every pass |
|
|
91
|
+
| `scope:` | symbol, proc or relation narrowing the selection | — |
|
|
92
|
+
| `cascade:` | `has_many` / `has_one` names archived with the parent, nestable | `[]` |
|
|
93
|
+
| `batch_size:` | rows per round trip | `config.batch_size` (1000) |
|
|
94
|
+
| `delete_after_archive:` | remove the source rows once copied | `true` |
|
|
95
|
+
| `delete_method:` | `:delete_all` or `:destroy_all` | `:delete_all` |
|
|
96
|
+
|
|
97
|
+
At least one of `after:`, `deleted:`, `scope:` or `on_destroy:` is required —
|
|
98
|
+
without a criterion the whole table would be archivable, which is never what
|
|
99
|
+
you meant.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
archivable after: 12.months # by age
|
|
103
|
+
archivable after: 90.days, on: :closed_at # by another column
|
|
104
|
+
archivable deleted: true # soft-deleted rows
|
|
105
|
+
archivable deleted: true, after: 30.days # ... after a grace period
|
|
106
|
+
archivable on_destroy: true # hard deletes
|
|
107
|
+
archivable after: 5.years, on_destroy: true # both
|
|
108
|
+
archivable after: 5.years, every: 1.month # ... at most monthly
|
|
109
|
+
archivable after: 1.year, scope: -> { where(exported: true) }
|
|
110
|
+
archivable after: 2.years, cascade: [:items], batch_size: 5_000
|
|
111
|
+
archivable after: 2.years, cascade: [{ items: [:taxes, :notes] }, :comments]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`cascade:` takes a name, an array, or a nested hash, as deep as the graph goes.
|
|
115
|
+
Children are archived and deleted before their parents, so foreign keys hold at
|
|
116
|
+
every step, and anything the cascade does not name is left behind — which for a
|
|
117
|
+
child with a `NOT NULL` foreign key means the parent's delete fails, loudly.
|
|
118
|
+
Association scopes are ignored on the way down: every row pointing at the
|
|
119
|
+
parent is taken, never a subset, so nothing is orphaned.
|
|
120
|
+
|
|
121
|
+
The selection always starts from `unscoped`, so a soft-delete `default_scope`
|
|
122
|
+
cannot hide the very rows you asked to archive.
|
|
123
|
+
|
|
124
|
+
## Soft delete, hard delete, both
|
|
125
|
+
|
|
126
|
+
Three different things can make a row disappear, and each has its own switch:
|
|
127
|
+
|
|
128
|
+
* **it got old** — `after:` sweeps it on a schedule and then deletes it from
|
|
129
|
+
the primary database;
|
|
130
|
+
* **it was soft-deleted** — `deleted: true` picks up rows whose `deleted_at`
|
|
131
|
+
is set (optionally after a grace period with `after:`);
|
|
132
|
+
* **it is being hard-deleted right now** — `on_destroy: true` copies the row to
|
|
133
|
+
the archive in a `before_destroy` hook, so `destroy`, `destroy!` and
|
|
134
|
+
`destroy_all` keep a copy instead of losing one.
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
class Receipt < ApplicationRecord
|
|
138
|
+
archivable on_destroy: true # nothing swept, deletes kept
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class Payroll < ApplicationRecord
|
|
142
|
+
archivable after: 18.months, on_destroy: true # aged out *and* deleted early
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`on_destroy:` on its own means "react to deletes", not "archive this table":
|
|
147
|
+
a scheduled run reports the model as skipped (`:destroy_only`) and
|
|
148
|
+
`archivable_records` is empty, so nothing is swept behind your back.
|
|
149
|
+
|
|
150
|
+
Worth knowing:
|
|
151
|
+
|
|
152
|
+
* `delete`, `delete_all` and database-level `ON DELETE CASCADE` do not run
|
|
153
|
+
callbacks, so they cannot be captured. Use `destroy`/`destroy_all`, or
|
|
154
|
+
`dependent: :destroy` on the parent, for rows you must keep.
|
|
155
|
+
* The parent's `cascade:` comes along, because `dependent: :destroy` children
|
|
156
|
+
are about to go too. Nothing is deleted by the hook itself: the destroy that
|
|
157
|
+
triggered it is what removes the rows, so `dependent:` keeps deciding what
|
|
158
|
+
happens to the children (and a rolled-back destroy loses nothing).
|
|
159
|
+
* If the archive database cannot be reached, the destroy fails
|
|
160
|
+
(`config.on_destroy_error = :raise`, the default): losing the row is worse
|
|
161
|
+
than failing the delete. Set it to `:log` to let deletes through and only
|
|
162
|
+
record the problem.
|
|
163
|
+
* The copy is written before the surrounding transaction commits, so a
|
|
164
|
+
rolled-back destroy can leave a copy in the archive. It is keyed by primary
|
|
165
|
+
key, so the row is still identifiable and a later real archive overwrites it.
|
|
166
|
+
* Each destroyed record is one round trip to the archive database; mass
|
|
167
|
+
cleanups are better served by the scheduled sweep.
|
|
168
|
+
|
|
169
|
+
## What you get on the model
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
Payroll.archivable_records # relation of rows eligible right now
|
|
173
|
+
Payroll.archivable_count
|
|
174
|
+
Payroll.archive_now! # archive immediately, ignoring every:
|
|
175
|
+
payroll.archive! # a single record (with its cascade)
|
|
176
|
+
payroll.archived?
|
|
177
|
+
|
|
178
|
+
ColdStorage.archive(Payroll) # honours every:
|
|
179
|
+
ColdStorage.archive(Payroll, dry_run: true) # report, move nothing
|
|
180
|
+
ColdStorage.archive_all
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Reading archived data
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
Payroll.archived # relation on the archive database
|
|
187
|
+
Payroll.archived.where(year: 2019).order(:id).pluck(:total)
|
|
188
|
+
Payroll.archived_count
|
|
189
|
+
Payroll.find_archived(42) # RecordNotFound if it is not archived
|
|
190
|
+
Payroll.find_with_archived(42) # live record, or the archived one
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`archived` is an ordinary relation on a class connected to the archive
|
|
194
|
+
database, so scopes, `where`, `pluck`, `find_each` and `includes` all work.
|
|
195
|
+
**The model's associations are mirrored onto it**, pointing at the archive
|
|
196
|
+
database, so an archived row can be read together with its archived children:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
invoice = Invoice.archived.find(42)
|
|
200
|
+
invoice.invoice_lines # archived lines, from the archive DB
|
|
201
|
+
invoice.taxes # has_many :through works too
|
|
202
|
+
Invoice.archived.includes(:invoice_lines).find_each { |i| ... }
|
|
203
|
+
|
|
204
|
+
line = ColdStorage.archived_model(InvoiceLine).find(7)
|
|
205
|
+
line.invoice # ... and back up
|
|
206
|
+
line.source_model # => InvoiceLine
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Association scopes carry over when the archive class can evaluate them
|
|
210
|
+
(`-> { where(active: true) }`). One that reaches for something only the source
|
|
211
|
+
model has (`-> { sorted('start_time', 'asc') }`, a scope on the child) falls
|
|
212
|
+
back to the unfiltered relation instead of raising, so a read never dies on a
|
|
213
|
+
scope. Turn on debug logging to see which ones were ignored.
|
|
214
|
+
|
|
215
|
+
Mirrored: `has_many`, `has_one`, `belongs_to` and `:through`. Not mirrored:
|
|
216
|
+
polymorphic `belongs_to`, and `:through` with a `source_type:` — both would
|
|
217
|
+
have to resolve a class name back into the primary database, and reading would
|
|
218
|
+
silently cross into it. `dependent:`, counter caches and `touch` are dropped on
|
|
219
|
+
purpose: the archive is a reading surface and must not cascade anything.
|
|
220
|
+
|
|
221
|
+
Joins between the two databases are not possible — they are separate
|
|
222
|
+
connections.
|
|
223
|
+
|
|
224
|
+
## Recovering
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
ColdStorage.restore(Payroll, [1, 2, 3])
|
|
228
|
+
Payroll.restore_archived([1, 2, 3]) # same thing
|
|
229
|
+
Payroll.restore_archived(Payroll.archived.where(year: 2019))
|
|
230
|
+
Invoice.archived.find(42).restore! # from the row itself
|
|
231
|
+
|
|
232
|
+
# ... with its relations
|
|
233
|
+
Invoice.restore_archived([42], with: :all) # every archived child
|
|
234
|
+
Invoice.restore_archived([42], with: [:invoice_lines]) # named relations
|
|
235
|
+
Invoice.restore_archived([42], with: { invoice_lines: [:taxes] }) # nested
|
|
236
|
+
Invoice.archived.find(42).restore!(with: :all)
|
|
237
|
+
|
|
238
|
+
# keep the archived copy instead of moving the rows
|
|
239
|
+
Invoice.restore_archived([42], with: :all, delete_from_archive: false)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Rows are written to the primary database parent-first, so foreign keys hold at
|
|
243
|
+
every step, and the archived copies are removed as they land. The return value
|
|
244
|
+
is the number of restored rows, children included.
|
|
245
|
+
|
|
246
|
+
`with: :all` walks every `has_many`/`has_one` that has an archive table,
|
|
247
|
+
recursively, cutting cycles as it goes. Named associations are checked before
|
|
248
|
+
anything is written, so a typo cannot leave half a graph behind. A `belongs_to`
|
|
249
|
+
is refused: restore the owner first, then its children — restoring a child
|
|
250
|
+
whose parent is still archived would fail on the foreign key.
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
rails cold_storage:restore[Invoice,"42 43"] WITH=all
|
|
254
|
+
rails cold_storage:restore[Invoice,"42"] WITH=invoice_lines,taxes
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Rake tasks
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
rails cold_storage:status # models, rules, pending/archived counts, last run
|
|
261
|
+
rails cold_storage:db:create # create the archive database
|
|
262
|
+
rails cold_storage:schema:sync # create/update the mirrored tables
|
|
263
|
+
rails cold_storage:schema:plan # what sync would change
|
|
264
|
+
rails cold_storage:schema:check # exit 1 on drift (for CI)
|
|
265
|
+
rails cold_storage:archive[Payroll] # one model, now
|
|
266
|
+
rails cold_storage:archive_all # every model whose every: elapsed
|
|
267
|
+
rails cold_storage:restore[Payroll,"1 2"] # move rows back (WITH=all)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`DRY_RUN=true`, `LIMIT=1000` and `FORCE=true` are honoured by the archive tasks.
|
|
271
|
+
|
|
272
|
+
## Schema mirroring
|
|
273
|
+
|
|
274
|
+
`cold_storage:schema:sync` walks the archivable models (plus the tables they
|
|
275
|
+
cascade into) and, in the archive database:
|
|
276
|
+
|
|
277
|
+
* creates missing tables, column by column, with the same SQL types —
|
|
278
|
+
including PostgreSQL enum types, arrays and `jsonb`;
|
|
279
|
+
* keeps the primary key, so re-archiving a row updates it instead of
|
|
280
|
+
duplicating it;
|
|
281
|
+
* mirrors indexes, **dropping their uniqueness** — an archive accumulates
|
|
282
|
+
history, and a natural key that is unique in the primary database at any one
|
|
283
|
+
moment is not unique across everything that table ever held;
|
|
284
|
+
* adds an `archived_at` column (configurable, `nil` disables it);
|
|
285
|
+
* adds columns that later migrations introduced;
|
|
286
|
+
* records the migration version it synced against.
|
|
287
|
+
|
|
288
|
+
and deliberately does **not**:
|
|
289
|
+
|
|
290
|
+
* copy foreign keys — an archive holds partial object graphs;
|
|
291
|
+
* copy `NOT NULL` or defaults — a schema that gets stricter later must not
|
|
292
|
+
reject rows that were archived before that (`mirror_null_constraints`,
|
|
293
|
+
`mirror_defaults` if you disagree);
|
|
294
|
+
* drop columns the primary database dropped — archived rows keep the columns
|
|
295
|
+
they were archived with (`drop_removed_columns` if you disagree).
|
|
296
|
+
|
|
297
|
+
It runs automatically after `db:migrate`, `db:rollback` and `db:schema:load`
|
|
298
|
+
(`config.sync_schema_after_migrate`). In CI, `cold_storage:schema:check`
|
|
299
|
+
fails the build when a migration changed an archivable table and the archive
|
|
300
|
+
database was not brought along.
|
|
301
|
+
|
|
302
|
+
Column type changes are reported, not applied, unless you ask:
|
|
303
|
+
`config.on_type_mismatch = :warn | :raise | :change | :ignore`.
|
|
304
|
+
|
|
305
|
+
## How a row moves
|
|
306
|
+
|
|
307
|
+
Two databases cannot share a transaction, so each batch is:
|
|
308
|
+
|
|
309
|
+
1. read from the primary database, cast by the **database** column types (model
|
|
310
|
+
level serializers, enums and default scopes are bypassed on both sides, so
|
|
311
|
+
what lands in the archive is byte-for-byte what was in the source column);
|
|
312
|
+
2. `upsert`ed into the archive database, keyed on the primary key — a retry can
|
|
313
|
+
never duplicate a row;
|
|
314
|
+
3. deleted from the primary database.
|
|
315
|
+
|
|
316
|
+
A crash between 2 and 3 leaves the row in both databases; the next run upserts
|
|
317
|
+
it again and deletes it. Archiving is at-least-once, and never loses a row.
|
|
318
|
+
|
|
319
|
+
With `cascade:`, children are archived and deleted **before** their parent, so
|
|
320
|
+
foreign keys in the primary database hold at every step.
|
|
321
|
+
|
|
322
|
+
`delete_method: :delete_all` (the default) skips callbacks by design: `after_destroy`
|
|
323
|
+
hooks that notify, bill or cascade should not fire because a row aged out. Use
|
|
324
|
+
`:destroy_all` when you do want them.
|
|
325
|
+
|
|
326
|
+
## Scheduling and `every:`
|
|
327
|
+
|
|
328
|
+
Every successful run is recorded in `cold_storage_runs` **in the archive
|
|
329
|
+
database**, and `every:` is checked against it. This means the cadence survives
|
|
330
|
+
restarts and deploys, and two workers running `archive_all` on the same day do
|
|
331
|
+
not archive twice.
|
|
332
|
+
|
|
333
|
+
`ColdStorage::ArchiveAllJob` enqueues one `ArchiveModelJob` per model, so a
|
|
334
|
+
big table cannot starve the others.
|
|
335
|
+
|
|
336
|
+
## Configuration
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
ColdStorage.configure do |config|
|
|
340
|
+
config.enabled = !Rails.env.test?
|
|
341
|
+
config.archive_database = :archive
|
|
342
|
+
config.batch_size = 1_000
|
|
343
|
+
config.timestamp_column = :created_at
|
|
344
|
+
config.deleted_column = :deleted_at
|
|
345
|
+
config.archived_at_column = :archived_at # nil to disable
|
|
346
|
+
config.delete_after_archive = true
|
|
347
|
+
config.delete_method = :delete_all
|
|
348
|
+
config.mirror_indexes = true
|
|
349
|
+
config.mirror_null_constraints = false
|
|
350
|
+
config.mirror_defaults = false
|
|
351
|
+
config.drop_removed_columns = false
|
|
352
|
+
config.on_type_mismatch = :warn
|
|
353
|
+
config.on_destroy_error = :raise # or :log
|
|
354
|
+
config.sync_schema_after_migrate = true
|
|
355
|
+
config.throttle = 0 # seconds between batches
|
|
356
|
+
config.job_queue = :default
|
|
357
|
+
config.job_parent_class = 'ApplicationJob'
|
|
358
|
+
config.dry_run = false
|
|
359
|
+
end
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
`config.enabled = false` stands everything down: scheduled runs report
|
|
363
|
+
themselves as skipped and the `on_destroy` hook does nothing. That is usually
|
|
364
|
+
what you want in the test environment, where there is no archive database and
|
|
365
|
+
specs destroy records all the time. Schema tasks keep working either way, so
|
|
366
|
+
`cold_storage:schema:check` still guards CI.
|
|
367
|
+
|
|
368
|
+
## Requirements and limits
|
|
369
|
+
|
|
370
|
+
* Rails 7.1+, Ruby 3.1+. Developed and tested against PostgreSQL; the enum
|
|
371
|
+
mirroring is PostgreSQL specific and simply does nothing elsewhere.
|
|
372
|
+
* Models need a single-column primary key.
|
|
373
|
+
* Custom PostgreSQL types other than enums (domains, composites) are not
|
|
374
|
+
mirrored.
|
|
375
|
+
* Views, functions and triggers are not mirrored.
|
|
376
|
+
* `cascade:` supports `has_many`/`has_one`, including polymorphic children;
|
|
377
|
+
`:through` associations are rejected.
|
|
378
|
+
|
|
379
|
+
## Tests
|
|
380
|
+
|
|
381
|
+
The suite runs against two real PostgreSQL databases, because enums, arrays and
|
|
382
|
+
`jsonb` are exactly what a schema mirror gets wrong.
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
bundle install
|
|
386
|
+
bundle exec rspec
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
It reads `DB_HOST`, `DB_PORT`, `POSTGRES_USER` and `POSTGRES_PASSWORD`, and
|
|
390
|
+
creates `cold_storage_source_test` / `cold_storage_archive_test`.
|
|
391
|
+
|
|
392
|
+
To run it against the PostgreSQL of an app that already has a container, copy
|
|
393
|
+
the gem in and borrow that app's bundle:
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
docker cp . <app-container>:/tmp/ra
|
|
397
|
+
docker exec -w /tmp/ra <app-container> \
|
|
398
|
+
bash -lc 'BUNDLE_GEMFILE=/path/to/app/Gemfile bundle exec rspec'
|
|
399
|
+
```
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ColdStorage
|
|
4
|
+
# Mixed into a model the first time it declares `archivable`.
|
|
5
|
+
module Archivable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
class_attribute :archiving_policy, instance_accessor: false, default: nil
|
|
10
|
+
class_attribute :archiving_destroy_hook, instance_accessor: false, default: false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class_methods do
|
|
14
|
+
def archivable?
|
|
15
|
+
archiving_policy.present?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Copies a row to the archive just before it is really deleted.
|
|
19
|
+
# Installed by `archivable on_destroy: true`.
|
|
20
|
+
def install_archive_on_destroy!
|
|
21
|
+
return if archiving_destroy_hook
|
|
22
|
+
|
|
23
|
+
self.archiving_destroy_hook = true
|
|
24
|
+
# prepend: so the row is captured before dependent: callbacks start
|
|
25
|
+
# taking its children apart.
|
|
26
|
+
before_destroy(:archive_before_destroy, prepend: true)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Rows that are eligible for archiving right now.
|
|
30
|
+
# @return [ActiveRecord::Relation]
|
|
31
|
+
def archivable_records
|
|
32
|
+
archiving_policy!.relation(self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# How many rows are waiting to be archived.
|
|
36
|
+
def archivable_count
|
|
37
|
+
archivable_records.count
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Handle on the archived rows, living in the archive database. An
|
|
41
|
+
# ordinary relation: scopes, where, pluck, find_each all work, and the
|
|
42
|
+
# model's associations are mirrored, so archived children come along.
|
|
43
|
+
#
|
|
44
|
+
# Payroll.archived.where(company_id: 1).count
|
|
45
|
+
# Invoice.archived.find(42).invoice_lines
|
|
46
|
+
#
|
|
47
|
+
# @return [ActiveRecord::Relation]
|
|
48
|
+
def archived
|
|
49
|
+
archive_model.all
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# How many rows of this model sit in the archive.
|
|
53
|
+
def archived_count
|
|
54
|
+
archived.count
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [ActiveRecord::Base] the archived row
|
|
58
|
+
# @raise [ActiveRecord::RecordNotFound]
|
|
59
|
+
def find_archived(*ids)
|
|
60
|
+
archive_model.find(*ids)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Looks in the primary database first, then in the archive.
|
|
64
|
+
#
|
|
65
|
+
# @return [ActiveRecord::Base] a live record, or an archived one
|
|
66
|
+
# @raise [ActiveRecord::RecordNotFound] when it is in neither
|
|
67
|
+
def find_with_archived(id)
|
|
68
|
+
unscoped.find_by(primary_key => id) || find_archived(id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Moves archived rows back into this table.
|
|
72
|
+
#
|
|
73
|
+
# Payroll.restore_archived([1, 2, 3])
|
|
74
|
+
# Invoice.restore_archived(Invoice.archived.where(year: 2019), with: :all)
|
|
75
|
+
#
|
|
76
|
+
# @return [Integer] number of restored rows, children included
|
|
77
|
+
def restore_archived(ids, **options)
|
|
78
|
+
ColdStorage.restore(self, ids, **options)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The ActiveRecord class mapped onto this table in the archive database.
|
|
82
|
+
# @return [Class]
|
|
83
|
+
def archive_model
|
|
84
|
+
ArchiveModel.for(self)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Archives this model now, ignoring the `every:` window by default.
|
|
88
|
+
# @return [ColdStorage::Archiver::Result]
|
|
89
|
+
def archive_now!(**options)
|
|
90
|
+
Archiver.new(self, **{ force: true }.merge(options)).call
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [ColdStorage::Policy]
|
|
94
|
+
def archiving_policy!
|
|
95
|
+
archiving_policy || raise(NotArchivableError, "#{name} is not archivable")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Archives this single record (and its cascaded children).
|
|
100
|
+
# @return [ColdStorage::Archiver::Result]
|
|
101
|
+
def archive!
|
|
102
|
+
relation = self.class.unscoped.where(self.class.primary_key => id)
|
|
103
|
+
Archiver.new(self.class, relation: relation, force: true).call
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Is there a row with this id in the archive database?
|
|
107
|
+
def archived?
|
|
108
|
+
self.class.archive_model.where(self.class.primary_key => id).exists?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Keeps a copy of a hard-deleted row. The row is still readable here, so it
|
|
114
|
+
# is read (and archived) exactly like the scheduled run would.
|
|
115
|
+
#
|
|
116
|
+
# The policy's cascade comes along, because `dependent: :destroy` children
|
|
117
|
+
# are about to go too. Nothing is deleted here: the destroy that triggered
|
|
118
|
+
# this is what removes the rows, so `dependent:` keeps deciding what
|
|
119
|
+
# happens to the children.
|
|
120
|
+
#
|
|
121
|
+
# Raises by default: losing the row is worse than failing the delete. Set
|
|
122
|
+
# `config.on_destroy_error = :log` to let deletes through instead.
|
|
123
|
+
def archive_before_destroy
|
|
124
|
+
return unless ColdStorage.config.enabled
|
|
125
|
+
return unless self.class.archiving_policy&.on_destroy?
|
|
126
|
+
return if new_record? || id.nil?
|
|
127
|
+
|
|
128
|
+
Archiver.new(
|
|
129
|
+
self.class,
|
|
130
|
+
relation: self.class.unscoped.where(self.class.primary_key => id),
|
|
131
|
+
force: true,
|
|
132
|
+
track: false,
|
|
133
|
+
cascade: true,
|
|
134
|
+
delete_after_archive: false
|
|
135
|
+
).call
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
raise if ColdStorage.config.on_destroy_error == :raise
|
|
138
|
+
|
|
139
|
+
ColdStorage.logger.error do
|
|
140
|
+
"#{Logging::PREFIX} could not archive #{self.class}##{id} before destroy: #{e.class}: #{e.message}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|