partition_gardener 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.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +203 -0
  5. data/SECURITY.md +27 -0
  6. data/docs/THIRD_PARTY_LICENSE_MANIFEST.tsv +60 -0
  7. data/docs/application_contract.md +82 -0
  8. data/docs/audit_reference.md +125 -0
  9. data/docs/background_job.md +161 -0
  10. data/docs/cli.md +71 -0
  11. data/docs/configuration.md +261 -0
  12. data/docs/cutover.md +180 -0
  13. data/docs/decision_flow.md +154 -0
  14. data/docs/host_testing.md +91 -0
  15. data/docs/monitoring.md +110 -0
  16. data/docs/naming.md +71 -0
  17. data/docs/operations.md +131 -0
  18. data/docs/partition_landscape.md +323 -0
  19. data/docs/pg_party_recipe.md +44 -0
  20. data/docs/related_postgres_tooling.md +195 -0
  21. data/docs/retention.md +65 -0
  22. data/docs/schemas/partition_garden.schema.json +114 -0
  23. data/docs/schemas/plan_report.schema.json +82 -0
  24. data/docs/tooling_split.md +79 -0
  25. data/exe/partition_gardener +6 -0
  26. data/lib/partition_gardener/active_record_run_record_store.rb +1 -0
  27. data/lib/partition_gardener/advisory_lock.rb +48 -0
  28. data/lib/partition_gardener/archive_retention.rb +88 -0
  29. data/lib/partition_gardener/audit.rb +93 -0
  30. data/lib/partition_gardener/blank.rb +13 -0
  31. data/lib/partition_gardener/cli.rb +190 -0
  32. data/lib/partition_gardener/config_document.rb +240 -0
  33. data/lib/partition_gardener/configuration.rb +102 -0
  34. data/lib/partition_gardener/connection.rb +260 -0
  35. data/lib/partition_gardener/date_bucket.rb +117 -0
  36. data/lib/partition_gardener/date_calendar.rb +65 -0
  37. data/lib/partition_gardener/date_range_maintenance.rb +297 -0
  38. data/lib/partition_gardener/default_partition.rb +54 -0
  39. data/lib/partition_gardener/executor.rb +324 -0
  40. data/lib/partition_gardener/gap_detection.rb +59 -0
  41. data/lib/partition_gardener/hash_routing.rb +70 -0
  42. data/lib/partition_gardener/layout/calendar_year.rb +28 -0
  43. data/lib/partition_gardener/layout/integer_window.rb +28 -0
  44. data/lib/partition_gardener/layout/sliding_window.rb +28 -0
  45. data/lib/partition_gardener/layout/three_area.rb +27 -0
  46. data/lib/partition_gardener/layout/zone_segments.rb +62 -0
  47. data/lib/partition_gardener/lock_not_acquired.rb +3 -0
  48. data/lib/partition_gardener/maintenance_backend.rb +73 -0
  49. data/lib/partition_gardener/memory_run_record_store.rb +19 -0
  50. data/lib/partition_gardener/migration/hot_switch_concern.rb +445 -0
  51. data/lib/partition_gardener/missing_conflict_index.rb +3 -0
  52. data/lib/partition_gardener/naming.rb +29 -0
  53. data/lib/partition_gardener/orphaned_rebalance_staging.rb +3 -0
  54. data/lib/partition_gardener/pg_connection.rb +94 -0
  55. data/lib/partition_gardener/plan.rb +49 -0
  56. data/lib/partition_gardener/plan_applier.rb +289 -0
  57. data/lib/partition_gardener/plan_diff.rb +51 -0
  58. data/lib/partition_gardener/plan_report.rb +95 -0
  59. data/lib/partition_gardener/planner.rb +21 -0
  60. data/lib/partition_gardener/predicate.rb +85 -0
  61. data/lib/partition_gardener/premake_monthly_maintenance.rb +44 -0
  62. data/lib/partition_gardener/rails.rb +12 -0
  63. data/lib/partition_gardener/registry.rb +84 -0
  64. data/lib/partition_gardener/run_failed.rb +10 -0
  65. data/lib/partition_gardener/run_metrics.rb +65 -0
  66. data/lib/partition_gardener/run_record.rb +76 -0
  67. data/lib/partition_gardener/sql_run_record_store.rb +106 -0
  68. data/lib/partition_gardener/stdlib_extensions.rb +15 -0
  69. data/lib/partition_gardener/strategy/composite.rb +27 -0
  70. data/lib/partition_gardener/strategy/cursor_columns.rb +18 -0
  71. data/lib/partition_gardener/strategy/date_range.rb +303 -0
  72. data/lib/partition_gardener/strategy/hash_branches.rb +161 -0
  73. data/lib/partition_gardener/strategy/integer_range.rb +261 -0
  74. data/lib/partition_gardener/strategy/list_split.rb +125 -0
  75. data/lib/partition_gardener/strategy/requires_default_partition.rb +19 -0
  76. data/lib/partition_gardener/strategy.rb +26 -0
  77. data/lib/partition_gardener/templates.rb +373 -0
  78. data/lib/partition_gardener/unmoved_rows_remaining.rb +15 -0
  79. data/lib/partition_gardener/version.rb +3 -0
  80. data/lib/partition_gardener.rb +215 -0
  81. data/sig/partition_gardener.rbs +19 -0
  82. metadata +367 -0
@@ -0,0 +1,323 @@
1
+ # Partition landscape
2
+
3
+ Partition Gardener targets PostgreSQL native declarative partitioning with gardener-owned maintenance plans. This page maps industry patterns to what the gem implements, documents experimental layouts, and lists what belongs outside the gem.
4
+
5
+ ## Implemented templates
6
+
7
+ sliding_window_monthly — layout sliding_window, bucket month. Default time-series on a date or timestamp column.
8
+
9
+ sliding_window_daily — layout sliding_window, bucket day. High-volume telemetry with short retention.
10
+
11
+ sliding_window_weekly — layout sliding_window, bucket week. Weekly rollups or compliance windows.
12
+
13
+ sliding_window_quarterly — layout sliding_window, bucket quarter. Finance or reporting quarters.
14
+
15
+ calendar_year — layout calendar_year, bucket year. Long-lived yearly archives.
16
+
17
+ rolling_current_monthly — layout rolling_current, bucket month. One wide current child, no heat splits inside the active window.
18
+
19
+ premake_monthly — layout premake_monthly, bucket month. Bridge from cron premake; migrate to sliding window.
20
+
21
+ integer_window — layout integer_window. Monotonic bigint keys with id-band pruning.
22
+
23
+ list_split — layout list_split. Small stable enum or tenant discriminator.
24
+
25
+ hash_branches — layout hash_branches. Even fan-out on a hash key.
26
+
27
+ composite_list_hash — layout composite. LIST parent with HASH sub-trees per branch.
28
+
29
+ composite_list_range — layout composite. LIST parent with RANGE sliding-window sub-trees.
30
+
31
+ list_range — layout composite. Alias for composite_list_range.
32
+
33
+ composite_range_hash — layout composite. RANGE parent with HASH child tables.
34
+
35
+ composite_range_list — layout composite. RANGE parent with LIST child tables.
36
+
37
+ Register with `Registry.register_template(:sliding_window_monthly, ...)` or `Registry.register(Templates.sliding_window_monthly(...))`.
38
+
39
+ ## Rolling current
40
+
41
+ `rolling_current_monthly` keeps the three-area sliding window but disables heat splits inside the active window. Archive months still detach one bucket at a time. Prefer `sliding_window_monthly` unless you explicitly want a single wide current partition.
42
+
43
+ ## Composite trees
44
+
45
+ Gardener maintains each registered table in a composite tree separately:
46
+
47
+ - LIST parent (`list_split`) when `parent_mode: :list`
48
+ - RANGE parent (`sliding_window` or `calendar_year`) when `parent_mode: :range`
49
+ - Child tables named `parent_branch` with layout from the branch entry (`hash_branches`, `sliding_window`, or `list_split`)
50
+
51
+ Native PostgreSQL sub-partition DDL (one attached tree) is your migration responsibility. Gardener plans and rebalances each registered node.
52
+
53
+ ## Rails horizontal sharding
54
+
55
+ Rails ships horizontal sharding in Active Record (since 6.0, expanded in 8.0). You declare shard databases in `database.yml`, connect models with `connects_to shards:`, route requests with `connected_to(shard:)`, and run cross-shard work with `connected_to_all_shards` (Rails 8+).
56
+
57
+ That is a different layer from PostgreSQL declarative partitioning:
58
+
59
+ - Rails sharding splits rows across database servers with the same schema (tenant or capacity isolation).
60
+ - Gardener splits rows across child tables on one PostgreSQL server (time or key bounds, retention, default drain).
61
+
62
+ They compose. A sharded Rails app can still partition `events` on each shard for retention and tail layout. Gardener maintenance runs against one connection at a time; point `connection_resolver` at the active shard connection, or loop shards in a job:
63
+
64
+ ```ruby
65
+ ShardRecord.connected_to_all_shards do
66
+ PartitionGardener.run!(table_name: "events")
67
+ end
68
+ ```
69
+
70
+ The default railtie uses `ActiveRecord::Base.connection`. Sharded models should set `connection_resolver` to their shard connection, or run maintenance inside `connected_to(shard:)` for each shard.
71
+
72
+ Choose Rails sharding when the bottleneck is total row volume or tenant isolation across servers. Choose Gardener templates when the bottleneck is partition catalog size, retention, or hot-month splits inside one database.
73
+
74
+ ## Composite keys and partition pruning
75
+
76
+ Partitioning only speeds up queries when PostgreSQL can skip child tables. That is partition pruning: the planner uses partition bounds and your `WHERE` clause to exclude partitions that cannot hold matching rows. Without pruning, one large scan becomes many smaller scans and latency often gets worse.
77
+
78
+ Longer narrative and Rails context: [Tiny story PostgreSQL partitioning and Rails](https://amkisko.github.io/posts/20260306112539_tiny_story_postgresql_partitioning_and_rails.html).
79
+
80
+ ### PostgreSQL rules
81
+
82
+ PostgreSQL enforces `UNIQUE` and `PRIMARY KEY` per partition, not globally on the parent. Every unique constraint on a partitioned table must include the partition key columns. A typical OLTP shape is composite primary key `(id, occurred_on)` when partitioning by `occurred_on`, or `(id, tenant_id)` when partitioning by `tenant_id`.
83
+
84
+ Pick the partition key from real query patterns. Community guidance (production write-ups and [PostgreSQL table partitioning](https://www.postgresql.org/docs/current/ddl-partitioning.html)) converges on: the column that appears in most hot `WHERE` clauses should drive `PARTITION BY`. If no single dimension dominates, pruning will not help the queries that omit it.
85
+
86
+ Filters must expose the partition key plainly:
87
+
88
+ - Prefer range predicates: `occurred_on >= '2026-03-01' AND occurred_on < '2026-04-01'`
89
+ - Avoid wrappers on the key: `DATE(occurred_on)`, `date_trunc('month', occurred_on)`, or casts that hide the bound from the planner
90
+ - Watch `OR` across disjoint ranges; the planner may scan every child
91
+ - Joins that only filter on `id` without the partition key often cannot prune until runtime, and sometimes not at all
92
+
93
+ Verify with `EXPLAIN (ANALYZE, BUFFERS)` on production-shaped queries. Pruning-capable plans show `Append` with only the relevant children, or `Subplans Removed: N` when runtime pruning applies. Sequential scans on every child mean the application contract is missing the partition key.
94
+
95
+ `enable_partition_pruning` is on by default. Pruning is driven by partition bounds, not by indexes on the key; indexes still matter inside each child for point lookups.
96
+
97
+ ### Rails application contract
98
+
99
+ Active Record queries the parent table; models do not need to know child names. Performance still depends on scopes and APIs that carry the partition key.
100
+
101
+ When the database primary key is composite only because of partitioning, but `id` remains the application identifier:
102
+
103
+ ```ruby
104
+ class Event < ApplicationRecord
105
+ self.primary_key = :id
106
+ query_constraints :id, :occurred_on
107
+ end
108
+ ```
109
+
110
+ `query_constraints` (Rails 7.1+) adds listed columns to `UPDATE` and `DELETE` `WHERE` clauses so writes can prune. It does not add the partition key to `SELECT` lookups: `Event.find(id)` may still scan all partitions unless `id` is globally unique and the planner can prove a single child.
111
+
112
+ For reads, encode the contract in scopes used by hot paths:
113
+
114
+ ```ruby
115
+ scope :in_window, ->(range) { where(occurred_on: range.begin...range.end) }
116
+ ```
117
+
118
+ Inserts must supply routable partition key values (`Event.create!(occurred_on: ...)`) so rows land in named children, not only in `default`.
119
+
120
+ If you register `partition_key_column: "created_at::date"`, keep application filters on that expression or use a generated column the planner can reason about.
121
+
122
+ ### Gardener `conflict_key`
123
+
124
+ Registry `conflict_key` must match a parent unique index and should include the partition key, for example `%w[id occurred_on]`. Gardener uses it for idempotent keyset moves during default drain and tail rebalance. The same composite shape that satisfies PostgreSQL uniqueness is what lets maintenance target one partition without cross-child ambiguity.
125
+
126
+ ### Sharded apps
127
+
128
+ With Rails `connects_to` sharding, carry both signals on hot paths: shard routing first (`connected_to(shard:)`), then partition key filters inside that shard. Losing either one pushes the system toward broad scans. Partition Gardener on each shard does not change that query contract.
129
+
130
+ ### UI and product surfaces
131
+
132
+ Partition boundaries are an application contract, not only a database detail. Screens and APIs should make the same separation key visible to people so routine work stays inside one prune-friendly slice. User-facing copy should describe periods, accounts, or branches in product language, not partitions, shards, or child tables.
133
+
134
+ Time-partitioned data (monthly sliding window):
135
+
136
+ - Default list and calendar views to the current month or a short rolling window, not all history
137
+ - Month or week navigation that maps one-to-one to bucket boundaries (calendar pager, period tabs)
138
+ - Require or strongly default a date range before loading large tables; block unbounded browse behind an explicit export or report flow
139
+ - Date range pickers that emit half-open ranges (`from` inclusive, `to` exclusive) matching server scopes
140
+ - Cursor and infinite scroll keyed by `(occurred_on, id)` or equivalent so pagination stays ordered within a window
141
+ - Create and edit forms that set the event date explicitly; avoid backdating without a visible date field
142
+
143
+ List- or tenant-partitioned data:
144
+
145
+ - Workspace, region, or branch selector as the first step when categories are stable LIST keys
146
+ - Scoped navigation so cross-category search is a deliberate action, not the default index
147
+ - Per-tenant dashboards that never load sibling tenants in one query
148
+
149
+ Sharded Rails apps:
150
+
151
+ - Account or org context in the URL or session before data loads (`connected_to(shard:)` matches what the user already chose)
152
+ - Avoid global admin search across shards; offer shard-scoped search with optional slow cross-shard report
153
+
154
+ APIs and exports:
155
+
156
+ - List endpoints accept `from` / `to` (or `period`) and reject or paginate narrowly when missing
157
+ - Bulk export jobs chunked by month or tenant with progress per chunk
158
+ - GraphQL connections and REST `page` tokens should carry the partition dimension in the cursor when the key is temporal
159
+
160
+ Anti-patterns:
161
+
162
+ - Homepage table of millions of rows with only sort-by-id and page number
163
+ - Single global search box that queries by id across all time
164
+ - "All workspaces" aggregate view on hot OLTP paths without a reporting pipeline
165
+
166
+ When adding a screen, ask whether a typical click produces a query that includes the same key you partition on. If not, change the interaction before adding indexes.
167
+
168
+ ### Aggregates, totals, and snapshots
169
+
170
+ Dashboard totals, KPI cards, and cross-period rollups should not scan every partition on each page load. Treat them as snapshots: precomputed rows stored outside the hot OLTP path, refreshed in background, with explicit drift control.
171
+
172
+ Services that run maintenance only when indicators show need (instead of nightly `run!`) should treat snapshots as mandatory on hot paths, not a later optimization. Deferred layout work is viable only when request threads never depend on live aggregates across the full retention window. Design snapshots first, then choose indicator-driven maintenance cadence ([decision_flow.md](decision_flow.md#maintenance-cadence), [background_job.md](background_job.md#schedule)).
173
+
174
+ Why this pairs with partitioning:
175
+
176
+ - A `COUNT(*)` or `SUM(amount)` over all history touches every child; latency grows with retention even when list views are scoped
177
+ - Wide reporting queries belong off the request thread; snapshots make the UI fast and predictable
178
+ - Detached or dropped archive partitions still need totals for closed periods; snapshots survive layout changes
179
+
180
+ Snapshot shape:
181
+
182
+ - Key snapshots by the same dimensions you partition on: `(period, tenant_id, metric)` or `(calendar_month, workspace_id)`
183
+ - One row per bucket per grain (day, month, quarter), not one global total unless the product truly needs it
184
+ - Store `computed_at`, optional `source_watermark` (max `updated_at` or max id seen in source), and `version` for cache busting
185
+ - Keep snapshots in a normal table or a dedicated rollup schema; they are not partition children of the fact table
186
+
187
+ Prefer a regular rollup table over a materialized view when bucket keys, drift checks, and incremental updates matter. Materialized views are a poor stand-in for partition-shaped aggregates; see [Materialized views](#materialized-views) below.
188
+
189
+ Background recalculation:
190
+
191
+ - Incremental: on each write to the fact table, adjust the open bucket snapshot (current month, active tenant) in the same transaction or via an outbox job
192
+ - Periodic full recompute: nightly or weekly job walks one bucket at a time (`WHERE occurred_on >= ... AND occurred_on < ...`) and replaces snapshot rows for closed periods
193
+ - Chunk jobs by partition bucket so each job prunes to one or few children; never one job that aggregates all shards and all time without bounds
194
+ - On sharded apps, compute per shard first, then merge shard snapshots in a coordinator job if the product needs a global number
195
+
196
+ Drift control:
197
+
198
+ - Reconciliation job compares snapshot totals to a fresh `COUNT` / `SUM` over the same bucket window on a schedule; record `drift` and `last_checked_at`
199
+ - Alert when drift exceeds a threshold or when `computed_at` is older than SLA for the period (stale snapshot)
200
+ - After Gardener moves rows (default drain, tail rebalance), rerun bucket recompute for affected windows; moves change row location but not logical totals if keys are stable
201
+ - UI shows "as of" time on totals; optional subtle stale state when reconciliation is behind
202
+ - Idempotent recompute: upsert snapshot rows by bucket key so retries do not double-count
203
+
204
+ Anti-patterns:
205
+
206
+ - Live `SELECT SUM(...)` across all partitions on the homepage
207
+ - Caching unbounded aggregate queries in Redis without bucket keys or TTL tied to period close
208
+ - Assuming ORM counter caches on the parent model stay correct when rows move between children during maintenance
209
+
210
+ Gardener does not maintain application snapshots; it only changes partition layout. Application jobs own rollup freshness and drift checks.
211
+
212
+ ### Materialized views
213
+
214
+ PostgreSQL does not support declarative partitioning on materialized views. You cannot attach `PARTITION BY RANGE`, `LIST`, or `HASH` to a materialized view, run `ATTACH PARTITION` / `DETACH PARTITION` on one, or add `CHECK` constraints that enable constraint exclusion on child slices. Partition Gardener operates on declarative partitioned tables only; it does not plan, audit, or refresh materialized views.
215
+
216
+ What still works:
217
+
218
+ - Create a materialized view whose defining query reads from a partitioned fact table. Partition pruning may reduce work during refresh when the query carries plain predicates on the partition key, but `REFRESH MATERIALIZED VIEW` (PostgreSQL 18 and earlier) still replaces the entire stored result each time.
219
+ - `REFRESH MATERIALIZED VIEW CONCURRENTLY` avoids blocking readers but refreshes the whole view and requires a unique index on plain columns covering all rows.
220
+
221
+ Manual sharding pattern (common workaround):
222
+
223
+ - One materialized view per time slice, each defined with a bounded `WHERE` on the source key.
224
+ - A regular `VIEW` with `UNION ALL` over those materialized views as the query surface.
225
+
226
+ Limits of that pattern:
227
+
228
+ - No partition pruning across the union; a filter like `WHERE sale_date = '2026-04-15'` typically scans every child materialized view unless the application queries a specific child by name.
229
+ - No `CHECK` constraints on materialized views, so the planner cannot prove row bounds per child.
230
+ - Lifecycle is manual: new period means new materialized view, alter the union view, manage dependencies, and schedule per-slice refresh jobs.
231
+ - pg_partman and Gardener do not maintain materialized views.
232
+
233
+ Partial refresh on the horizon:
234
+
235
+ - PostgreSQL 19 may add `REFRESH MATERIALIZED VIEW ... WHERE predicate` for predicate-scoped refresh. That narrows refresh cost; it does not make materialized views first-class partition citizens (no attach/detach, no gardener-style layout).
236
+
237
+ Recommended pairing with Gardener:
238
+
239
+ - Fast dashboard totals with drift control — bucket-keyed rollup table (optionally range-partitioned by period)
240
+ - Drop old periods with retention — partitioned fact table plus Gardener; snapshots keyed by closed bucket survive detach
241
+ - Stale-OK full recompute of one report — single materialized view over a bounded source query
242
+ - Independent refresh per month at very large scale — multiple materialized views plus union view, with application routing to children; accept no cross-union pruning
243
+
244
+ For hot paths next to indicator-driven maintenance, use snapshot tables with `computed_at` and reconciliation ([Aggregates, totals, and snapshots](#aggregates-totals-and-snapshots)), not a monolithic materialized view over all history.
245
+
246
+ After Gardener moves rows between children, refresh or recompute any materialized view or snapshot that aggregates affected buckets. Gardener does not trigger those jobs.
247
+
248
+ ### Common pitfalls (what teams report)
249
+
250
+ - Admin search by `id` only on a time-partitioned table scans every month until the key encodes time or lookups include `occurred_on`
251
+ - Reporting queries over wide date ranges intentionally touch many children; that is correct behavior, not a pruning bug
252
+ - Partition count in the thousands increases planner overhead even when pruning works; Gardener sliding window keeps the active catalog bounded
253
+ - Relying on `default` without monitoring; Gardener drains default last, but application inserts that never match bounds still accumulate there
254
+ - Assuming `includes` / `preload` on associations will prune parent partitions when the join does not constrain the partition key
255
+ - Product UI that encourages unbounded browse (all-time lists, global id search) while the table is partitioned by time or tenant
256
+ - Dashboard totals computed live across all partitions instead of served from bucket-keyed snapshots with background refresh and drift checks
257
+ - Treating one large materialized view as a partitioned rollup; refresh cost grows with retention and Gardener row moves do not update it automatically
258
+ - Expecting `UNION ALL` over per-month materialized views to prune like declarative partitions; query the child materialized view directly or use a rollup table
259
+
260
+ ## Out of scope
261
+
262
+ Delegate these to other tools or extensions; Gardener does not embed them.
263
+
264
+ ### Materialized views
265
+
266
+ Declarative partitioning and Gardener maintenance apply to tables, not materialized views. See [Materialized views](partition_landscape.md#materialized-views) for manual multi-view patterns, refresh limits, and when to use rollup tables instead.
267
+
268
+ ### ankane Postgres tools
269
+
270
+ PgHero (observe), Dexter (indexes), pgsync (database copy), and pgslice (one-time partition prep and swap) address different layers than nightly partition maintenance. See [related_postgres_tooling.md](related_postgres_tooling.md) for pairing and overlap.
271
+
272
+ ### pg_partman
273
+
274
+ Use `maintenance_backend: :pg_partman` or `:hybrid_layout_only` when partman owns premake and detach. Gardener can still plan layout-only diffs in hybrid mode.
275
+
276
+ ### TimescaleDB hypertables
277
+
278
+ Hypertables use chunk policies and compression, not declarative RANGE children. Use Timescale maintenance for time-series at scale.
279
+
280
+ ### Citus distributed tables
281
+
282
+ Citus distributes tables across worker nodes inside PostgreSQL with its own shard metadata and rebalance tooling. That is neither Rails `connects_to` sharding nor native declarative partitioning on a single instance. Gardener does not drive Citus shard placement.
283
+
284
+ ### UUIDv7 and encoded-time keys
285
+
286
+ Time-ordered UUID primary keys are increasingly common. Partitioning on `date_trunc` of a timestamp column remains the recommended Gardener path. For UUID-native range strategies, use pg_partman 5.2+ or custom DDL; Gardener does not parse UUID time components today.
287
+
288
+ ## Choosing a bucket
289
+
290
+ General application events — start with sliding_window_monthly, active_months 12.
291
+
292
+ Metrics or logs under 90 days — sliding_window_daily.
293
+
294
+ Weekly compliance exports — sliding_window_weekly.
295
+
296
+ Quarterly financial partitions — sliding_window_quarterly.
297
+
298
+ Multi-year cold storage — calendar_year.
299
+
300
+ Multi-tenant rows across database servers — Rails `connects_to` shards; optionally partition per shard with Gardener.
301
+
302
+ ## Source
303
+
304
+ Industry intervals use the same units as pg_partman `part_config.partition_interval` and PostgreSQL `date_trunc`. Composite naming follows native `PARTITION BY LIST` and `PARTITION BY RANGE` sub-partition trees described in PostgreSQL documentation.
305
+
306
+ Further reading:
307
+
308
+ - [Tiny story PostgreSQL partitioning and Rails](https://amkisko.github.io/posts/20260306112539_tiny_story_postgresql_partitioning_and_rails.html)
309
+ - [PostgreSQL: Table partitioning](https://www.postgresql.org/docs/current/ddl-partitioning.html)
310
+ - [PostgreSQL: Materialized views](https://www.postgresql.org/docs/current/rules-materializedviews.html)
311
+ - [PostgreSQL: REFRESH MATERIALIZED VIEW](https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html)
312
+ - [Rails Guides: Multiple databases (horizontal sharding)](https://guides.rubyonrails.org/active_record_multiple_databases.html)
313
+ - [GitLab: Partitioned tables and composite primary keys](https://docs.gitlab.com/development/database/partitioning/)
314
+ - [Aha!: Partitioning a large table in PostgreSQL with Rails](https://www.aha.io/engineering/articles/partitioning-a-large-table-in-postgresql-with-rails)
315
+
316
+ Operations and migration docs in this repository:
317
+
318
+ - [operations.md](operations.md) — runbook
319
+ - [cutover.md](cutover.md) — hot-switch playbook
320
+ - [application_contract.md](application_contract.md) — host app behavior
321
+ - [monitoring.md](monitoring.md) — metrics and alerts
322
+ - [retention.md](retention.md) — archive detach and drop
323
+ - [audit_reference.md](audit_reference.md) — audit and plan catalog
@@ -0,0 +1,44 @@
1
+ # pg_party recipe — partitioned table creation
2
+
3
+ Use [pg_party](https://github.com/rkrage/pg_party) in migrations only. Partition Gardener owns nightly runtime maintenance after cutover.
4
+
5
+ ## Migration outline
6
+
7
+ ```ruby
8
+ class PartitionEvents < ActiveRecord::Migration[8.0]
9
+ def up
10
+ create_range_partition :events,
11
+ partition_key: :occurred_on,
12
+ template: false
13
+
14
+ create_default_partition :events
15
+
16
+ # Minimal premake at creation; gardener extends the window after cutover
17
+ [Date.today.beginning_of_month, Date.today.next_month.beginning_of_month].each do |month|
18
+ create_range_partition_of :events,
19
+ name: "events_#{month.strftime('%Y_%m')}",
20
+ start_range: month,
21
+ end_range: month.next_month.beginning_of_month
22
+ end
23
+
24
+ add_index :events, %i[id occurred_on], unique: true
25
+ end
26
+ end
27
+ ```
28
+
29
+ ## Cutover
30
+
31
+ 1. Backfill shadow partitioned table (`p_events`) from current table.
32
+ 2. Include `PartitionGardener::Migration::HotSwitchConcern` in a follow-up migration.
33
+ 3. `hot_switch_tables` with `months_ahead: 1`.
34
+ 4. Register `events` in `PartitionGardener::Registry` with `sliding_window_monthly`.
35
+ 5. Enable nightly `PartitionGardener.run!`.
36
+
37
+ Full playbook: [cutover.md](cutover.md).
38
+
39
+ ## Do not
40
+
41
+ - Call pg_party `create_range_partition_of` every night for the same bounds gardener maintains.
42
+ - Run pg_partman premake and gardener tail rebalance on the same parent without `hybrid_layout_only`.
43
+
44
+ See [tooling_split.md](tooling_split.md) for the three-way responsibility split.
@@ -0,0 +1,195 @@
1
+ # Related Postgres tooling (ankane)
2
+
3
+ Partition Gardener owns runtime layout maintenance on declarative partitions. [Andrew Kane's Postgres tools](https://github.com/ankane) solve adjacent problems: observability, indexing, data copy, and one-time partition cutover. None of them replace Gardener's sliding window, heat splits, default drain, or incremental tail rebalance.
4
+
5
+ Use this page with [tooling_split.md](tooling_split.md) (creation vs runtime maintenance vs extension) and [partition_landscape.md](partition_landscape.md) (templates and out-of-scope).
6
+
7
+ ## At a glance
8
+
9
+ [PgHero](https://github.com/ankane/pghero) — database performance dashboard. Low overlap with Gardener (whole-database observability, not partition layout). Pair with nightly `audit` / `run!`.
10
+
11
+ [Dexter](https://github.com/ankane/dexter) — automatic indexes from query workload. No overlap on partition bounds; use after large rebalance or new hot children.
12
+
13
+ [pgsync](https://github.com/ankane/pgsync) — copy Postgres data between databases. Overlap on cutover backfill and staging refresh, not nightly maintenance. Use for staging refresh and offline backfill phases.
14
+
15
+ [pgslice](https://github.com/ankane/pgslice) — one-time partition prep, fill, swap. High overlap on creation; low on runtime maintenance. Create with pgslice before Gardener; do not run both as maintainers.
16
+
17
+ ## PgHero
18
+
19
+ PgHero is a performance dashboard for Postgres: slow queries, index usage, space, connections, vacuum statistics, and replication lag. It ships as a Rails engine, Docker image, or Linux package.
20
+
21
+ ### Usefulness with Gardener
22
+
23
+ High as complementary observability, not as a partition maintainer.
24
+
25
+ Gardener already exposes layout-specific signals: `audit`, `plan`, default row count, horizon days, attached child count, and `RunSummary` metrics ([monitoring.md](monitoring.md)). PgHero answers different questions: which queries are slow globally, which indexes are unused, whether autovacuum is keeping up, and whether connection pools are saturated.
26
+
27
+ Pair them when:
28
+
29
+ - A table shows high `rows_moved` or long `run.duration_ms` and you need query-level context (`pg_stat_statements`).
30
+ - Partition count or catalog warnings fire and you want bloat or sequential-scan visibility across the database.
31
+ - Operators want one dashboard for database observability and a second view (Gardener audit JSON or metrics) for partition layout.
32
+
33
+ ### What to learn
34
+
35
+ Focused scope — one problem (Postgres observability), multiple install paths (Rails engine, Docker, package). Gardener also ships as gem plus CLI plus optional railtie.
36
+
37
+ Related-project cross-links — each README points to Dexter, pgsync, pgslice. Gardener should keep creation tools (pg_party) and runtime maintenance tools (pg_partman, Gardener) linked the same way; this document is part of that.
38
+
39
+ Actionable defaults — PgHero highlights issues operators recognize without reading planner internals. Gardener's `audit` warning catalog ([audit_reference.md](audit_reference.md)) should stay similarly concrete.
40
+
41
+ ### What not to take
42
+
43
+ Do not fold general slow-query analysis into Gardener. Keep partition layout maintenance as the gem boundary ([partition_landscape.md](partition_landscape.md) out-of-scope).
44
+
45
+ ## Dexter
46
+
47
+ Dexter recommends and optionally creates indexes from `pg_stat_statements`, live activity, or log files. It uses HypoPG to test hypothetical indexes before `CREATE INDEX CONCURRENTLY`.
48
+
49
+ ### Usefulness with Gardener
50
+
51
+ Medium as a follow-up tool after partition work, not during layout planning.
52
+
53
+ Partition maintenance changes where data lives and how many child tables exist. New hot-month splits and tail rebalances can shift which indexes matter. Dexter helps when:
54
+
55
+ - A rebalance moved a large fraction of rows and query plans regressed.
56
+ - New dedicated hot partitions need indexes copied from the parent.
57
+ - Heat-driven splits increased partition count and planners started choosing different paths.
58
+
59
+ Gardener does not analyze `pg_stat_statements` or create indexes. Dexter does not create partition bounds, drain default, or detach archive children.
60
+
61
+ ### What to learn
62
+
63
+ Safe-by-default execution — Dexter finds indexes first; `--create` is explicit. Gardener's `plan` / `dry_run` and retention dry-run (`retention_apply: false`) follow the same operator contract.
64
+
65
+ Noise filters — `--min-calls` and `--min-time` avoid indexing one-off queries. Gardener's heatmap threshold (`split_row_threshold`) follows the same idea: act only when signal is strong enough.
66
+
67
+ Table allow/deny lists — `--include` / `--exclude` for write-heavy tables. Gardener's per-table registry and `maintenance_backend` skip list serve a similar ownership boundary.
68
+
69
+ ### What not to take
70
+
71
+ Index recommendation inside Gardener would duplicate Dexter and blur the hot path. Document "run Dexter after major layout change" in [operations.md](operations.md) if operators need a checklist, not a new dependency.
72
+
73
+ ## pgsync
74
+
75
+ pgsync copies data from one Postgres database to another: parallel table transfer, row filters, schema-first sync, sensitive-data rules, and batch mode for large append-only tables.
76
+
77
+ ### Usefulness with Gardener
78
+
79
+ High for staging and migration data movement; none for nightly partition maintenance.
80
+
81
+ Overlap with Gardener appears only in cutover and cross-environment copy:
82
+
83
+ Phase 2 backfill ([cutover.md](cutover.md)) — copy historical months into a shadow partitioned parent before hot switch. pgsync can move batches with `where` clauses and `--in-batches` on id-keyed tables when you are not using Gardener's built-in delta sync.
84
+
85
+ Staging refresh ([host_testing.md](host_testing.md)) — sync a subset of production partitions into staging so `plan` and `run!` see realistic shapes.
86
+
87
+ Cross-database fill — pgslice's README explicitly sends readers to pgsync for syncing between databases during migration.
88
+
89
+ pgsync does not add future months, split hot buckets, or detach archives. Running pgsync on a production parent while Gardener `run!` is active risks fighting over the same rows unless scopes are disjoint and schedules are coordinated.
90
+
91
+ ### What to learn
92
+
93
+ Safety rails — default destination is localhost unless `to_safe: true`. Worth echoing in Gardener docs: dry-run plan before apply, retention dry-run, advisory lock skip instead of blocking production.
94
+
95
+ data_rules — redact emails and secrets on the way out of production. Relevant when exporting partition dumps for compliance ([retention.md](retention.md)) even though Gardener does not implement sync.
96
+
97
+ Foreign-key ordering — `--defer-constraints` and `--jobs 1` for ordered loads. Cutover backfill order (archive months before current) is the partition-shaped version of the same problem.
98
+
99
+ ### What not to take
100
+
101
+ Gardener should not become a database-to-database sync tool. Keep `HotSwitchConcern` and cursor rebalance for in-place migration; point operators at pgsync for staging clones and offline copies.
102
+
103
+ ## pgslice
104
+
105
+ pgslice is the closest cousin: CLI workflow to partition an existing table — `prep` (intermediate parent), `add_partitions`, `fill`, `analyze`, `swap`, then ongoing `add_partitions` via cron. Archive is manual: `pg_dump` partition child, `DROP TABLE`.
106
+
107
+ ### Usefulness with Gardener
108
+
109
+ High for creation; intentionally redundant for runtime maintenance if both run uncoordinated.
110
+
111
+ Create partitioned parent — pgslice `prep` plus intermediate table; Gardener pg_party, SQL, or migration ([pg_party_recipe.md](pg_party_recipe.md)).
112
+
113
+ Premake future bounds — pgslice `add_partitions --future N` on cron; Gardener sliding window future zone or `premake_monthly` bridge.
114
+
115
+ Move rows into children — pgslice `fill` in id batches; Gardener tail rebalance, default drain, hot-switch delta.
116
+
117
+ Cutover — pgslice `swap` renames intermediate to live; Gardener `HotSwitchConcern`: `hot_switch_tables`, `hot_unswitch_tables` ([cutover.md](cutover.md)).
118
+
119
+ Archive old data — pgslice operator script dump plus drop child; Gardener `retention_months` plus detach/drop ([retention.md](retention.md)).
120
+
121
+ Hot month inside window — not supported in pgslice; Gardener heatmap plus dedicated hot partitions.
122
+
123
+ Default partition drain — not a first-class pgslice workflow; mandatory last phase in Gardener.
124
+
125
+ Recommended split (same pattern as [tooling_split.md](tooling_split.md)):
126
+
127
+ 1. Use pgslice (or pg_party plus SQL) once to create or convert the table.
128
+ 2. Register with Gardener and set `maintenance_backend: :gardener`.
129
+ 3. Stop pgslice `add_partitions` cron for that table — Gardener owns premake and archive layout.
130
+
131
+ If you keep pgslice cron for premake only, treat it like pg_partman: register `maintenance_backend: :hybrid_layout_only` or `:pg_partman` semantics so Gardener does not create duplicate bounds.
132
+
133
+ ### Hot-switch API (Gardener)
134
+
135
+ Rails migrations include `PartitionGardener::Migration::HotSwitchConcern`:
136
+
137
+ `hot_switch_tables` — pgslice equivalent `swap`. Rename live to `_old`, shadow to live, children, sequences.
138
+
139
+ `hot_unswitch_tables` — pgslice equivalent `unswap`. Reverse swap for drills or rollback.
140
+
141
+ `analyze_shadow_partitions!` — pgslice equivalent `analyze`. `ANALYZE` shadow children and parent before swap.
142
+
143
+ `sync_delta_data` — pgslice equivalent `fill`. UPSERT delta rows into shadow (pre-swap).
144
+
145
+ `sync_delta_data(swapped: true)` — pgslice equivalent `fill --swapped`. Catch-up from `_old` into live after swap.
146
+
147
+ `swap_lock_timeout` config — pgslice `lock_timeout` in swap. Fail fast on blocked renames (default `5s`).
148
+
149
+ See [cutover.md](cutover.md) for the full migration example.
150
+
151
+ ### What to learn
152
+
153
+ Intermediate plus swap — low-downtime rename pattern matches `hot_switch_tables` / `hot_unswitch_tables`. Teams using pgslice for creation can hand off to Gardener at swap ([cutover.md](cutover.md)).
154
+
155
+ `--dry-run` prints SQL — operators see exact DDL before execution. Gardener `plan` JSON and CLI `plan --pretty` serve a similar trust model for nightly layout; hot-switch migrations execute SQL directly — use staging drills and `compare_table_counts` for cutover.
156
+
157
+ Partition metadata in comments — pgslice stores `column`, `period`, `version` on the intermediate table comment. Gardener could optionally record layout version in run records for upgrade audits (future idea, not required today).
158
+
159
+ Missing-partition monitor query — pgslice documents a `pg_class` check for expected future child names. Gardener `horizon_days` and audit warnings cover missing bounds — see [audit_reference.md](audit_reference.md).
160
+
161
+ App guidance — pgslice README stresses filters on the partition key for pruning. Gardener already covers this in [partition_landscape.md](partition_landscape.md); keep one canonical pruning section.
162
+
163
+ ### What not to take
164
+
165
+ pgslice's fill loop is id-batch oriented for monolithic to partitioned copy. Gardener's incremental tail rebalance and default drain are ongoing lifecycle concerns — different algorithms, different schedules. Do not merge pgslice fill into Gardener.
166
+
167
+ ## Suggested operator stack
168
+
169
+ Typical production stack for a Rails app with time-series partitions:
170
+
171
+ 1. Creation — pg_party or pgslice `prep` / `swap` (or Gardener hot-switch migration).
172
+ 2. Runtime maintenance — Partition Gardener nightly `run!` plus `audit` cron.
173
+ 3. Observe — PgHero (or existing APM) plus Gardener metrics ([monitoring.md](monitoring.md)).
174
+ 4. Indexes — Dexter or manual review after large rebalance or template upgrade.
175
+ 5. Staging — pgsync from production subset; run `plan` before enabling `run!` in staging.
176
+
177
+ ## Optional follow-ups
178
+
179
+ Concrete patterns for future work. None are required for current releases.
180
+
181
+ Handoff checklist in [cutover.md](cutover.md): after pgslice swap, register template and disable pgslice cron.
182
+
183
+ Optional audit warning when expected future child name is missing (pgslice-style monitor), if not already covered by `horizon_days`.
184
+
185
+ Operations runbook: run `ANALYZE` on touched children after large `rows_moved` (pgslice `analyze` step; Gardener `analyze_after_rebalance` is the hook).
186
+
187
+ Cross-link [Dexter](https://github.com/ankane/dexter) in [operations.md](operations.md) when `split_row_threshold` fires often — new hot children may need index review.
188
+
189
+ ## Source
190
+
191
+ - [PgHero](https://github.com/ankane/pghero)
192
+ - [Dexter](https://github.com/ankane/dexter)
193
+ - [pgsync](https://github.com/ankane/pgsync)
194
+ - [pgslice](https://github.com/ankane/pgslice)
195
+ - In-repo: [tooling_split.md](tooling_split.md), [cutover.md](cutover.md), [monitoring.md](monitoring.md), [partition_landscape.md](partition_landscape.md)
data/docs/retention.md ADDED
@@ -0,0 +1,65 @@
1
+ # Retention and compliance
2
+
3
+ How archive partitions leave the database when `retention_months` is set, and how to coordinate drops with policy, backups, and legal hold.
4
+
5
+ Configuration reference: [configuration.md](configuration.md) (`retention_months`, `retention_apply`, `retention_keep_table`, `retention_detach_concurrently`).
6
+
7
+ ## How gardener applies retention
8
+
9
+ After layout work on a date-range sliding window, `ArchiveRetention`:
10
+
11
+ 1. Computes cutoff: `today - retention_months` (calendar months via `DateCalendar`).
12
+ 2. Walks attached non-default children.
13
+ 3. Skips default, current/open/future tail slots, and managed tail names.
14
+ 4. For archive buckets strictly before cutoff month: detach, then drop unless `retention_keep_table`.
15
+
16
+ Detach uses `ALTER TABLE … DETACH PARTITION`; drop uses `DROP TABLE` on the child. With `retention_detach_concurrently: true`, detach runs `CONCURRENTLY` (PostgreSQL 14+).
17
+
18
+ Preview by default: when `retention_months` is set and `retention_apply` is omitted or `false`, retention only logs would-drop partitions via `notifier`. Set `retention_apply: true` on the registry entry before archive children detach or drop.
19
+
20
+ ## Detach vs drop
21
+
22
+ Drop (default) — `retention_keep_table: false`. Child removed from parent and dropped.
23
+
24
+ Archive tables — `retention_keep_table: true`. Child detached but table remains in schema for cold storage or export.
25
+
26
+ Use `retention_keep_table: true` when compliance requires a quarantine period before physical drop, or when moving data to object storage from detached tables.
27
+
28
+ ## Before first production drop
29
+
30
+ 1. Confirm backup scope includes detached children or that exports completed.
31
+ 2. Run `plan` and review retention segment in operations log.
32
+ 3. Verify no legal hold on periods older than cutoff.
33
+ 4. Invalidate or freeze snapshot rows for dropped buckets ([partition_landscape.md](partition_landscape.md#aggregates-totals-and-snapshots)).
34
+ 5. Document dropped partition names ([naming.md](naming.md)) in change records.
35
+
36
+ ## Legal hold and GDPR
37
+
38
+ Partition Gardener has no legal-hold flag. Host applications implement hold by:
39
+
40
+ - Omitting `retention_months` until hold clears, or
41
+ - Setting `retention_keep_table: true` and managing detached tables manually, or
42
+ - Excluding specific buckets via registry change and manual attach (last resort).
43
+
44
+ Right-to-erasure across partitions still requires row-level delete or targeted child operations; dropping an entire month may suffice when all rows in that period are in scope for deletion and policy allows bulk removal.
45
+
46
+ Coordinate with counsel before using partition drop as an erasure mechanism.
47
+
48
+ ## pg_partman retention
49
+
50
+ When `maintenance_backend: :pg_partman`, gardener skips the table; partman owns retention. Hybrid mode: partman premake plus gardener layout only ([tooling_split.md](tooling_split.md)). Do not configure conflicting retention on both sides.
51
+
52
+ ## Operational checklist
53
+
54
+ - [ ] `retention_months` matches product policy document
55
+ - [ ] Backup verified for oldest archive month before first automated drop
56
+ - [ ] `retention_apply: true` only after backup and policy sign-off
57
+ - [ ] Monitoring alert on retention notifier messages
58
+ - [ ] Rollup snapshots updated or marked inactive for dropped buckets
59
+ - [ ] Runbook entry for restoring from backup if drop was premature ([operations.md](operations.md))
60
+
61
+ ## Related
62
+
63
+ - [decision_flow.md](decision_flow.md) — retention in layout choice
64
+ - [operations.md](operations.md) — incident response
65
+ - [monitoring.md](monitoring.md) — alerts