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,154 @@
1
+ # Partition decision flow
2
+
3
+ This document describes how to decide whether a PostgreSQL table should be partitioned, which declarative method and runtime layout to use, and how that choice maps to Partition Gardener templates. It is written for operators and application authors who maintain large tables on plain PostgreSQL without a time-series extension.
4
+
5
+ The flow has four stages: whether to partition at all, which partition method fits the data shape, which maintenance layout fits the load, and which tools own DDL creation versus runtime maintenance. Reliability invariants apply at every stage.
6
+
7
+ ## Stage zero: whether to partition
8
+
9
+ Most tables never need partitions. Partitioning adds catalog complexity, migration cost, and operational surface. Start here.
10
+
11
+ Defer partitioning when the table is small and growth is modest. A common practical band is below a few hundred megabytes on disk with no retention pressure and no recurring vacuum or reindex pain on the parent. In that band, indexes, query shape, and retention policy usually deliver more than partitions.
12
+
13
+ Defer partitioning when queries do not consistently filter on a single dominant dimension. If reports routinely scan wide date ranges, join many tenants in one query, or ignore the column you would partition on, the planner cannot prune children and you pay partition overhead without benefit. Plan those reports as snapshot or warehouse paths, not live OLTP aggregates. A single materialized view over all history has the same scan cost on read and does not partition; see [partition_landscape.md](partition_landscape.md#materialized-views).
14
+
15
+ Defer partitioning when the real problem is a missing index or an unbounded delete. Bulk delete followed by vacuum is the wrong motivation. Partitioning pays off when you can drop or detach whole children, or when per-child maintenance and pruning match how the application reads and writes.
16
+
17
+ Proceed toward partitioning when several signals agree. Table size or row count is large and still growing. You must archive or drop data by period. Most important queries include the same key you would partition on. Parent-level vacuum or index maintenance is painful. You can change primary or unique keys so every uniqueness constraint includes the partition key, which PostgreSQL requires for declarative partitions. Product surfaces can default to the same key (month picker, tenant context, date range) so routine use stays prune-friendly; see [partition_landscape.md](partition_landscape.md#ui-and-product-surfaces).
18
+
19
+ ## Stage one: choose the partition method
20
+
21
+ The partition method follows access shape, not storage convenience.
22
+
23
+ Use range partitioning when time or calendar bounds dominate. Examples are created_at, occurred_on, order_date, and business dates cast to date. Reads and writes usually carry a bounded interval on that column. Retention is expressed as dropping or detaching months or years. This is the default case for operational and audit data in Rails applications.
24
+
25
+ Use list partitioning when the table splits into a small, stable set of categories that queries always filter on. Examples are region codes, product lines, or a fixed branch discriminator such as cached versus workspace-scoped rows. List is a poor fit when category membership changes often or when queries cross categories without filters.
26
+
27
+ Use hash partitioning when volume is high but no natural range or list dimension exists, and equality on a single key is the main access path. Hash spreads writes; it does not help time-range reports. Changing hash modulus is a major migration and should be rare.
28
+
29
+ Use composite partitioning when one dimension is stable and another needs fan-out inside each branch. A typical pattern is list at the parent for branch type, then range or hash on a high-cardinality key under each branch. This matches mixed workloads where one slice is global and cold and another is tenant-heavy and hot.
30
+
31
+ Before any method is chosen, resolve uniqueness. If the business unique key does not already include the partition key, redesign the primary key or unique indexes to composite form such as id plus partition key. Partitioning cannot proceed safely until that constraint work is done.
32
+
33
+ ## Stage two: choose the runtime layout
34
+
35
+ Declarative method answers how PostgreSQL routes a row. Runtime layout answers how many children you keep attached, how far ahead you premake, and how you prevent rows from landing in the default partition.
36
+
37
+ Premake-only layout creates the current interval and the next interval, then drains the default partition on a schedule. It is simple and familiar in many codebases. It fails quietly when premake stops or when bounds leave holes: inserts route to default, and attach operations during cutover become fragile. Treat premake-only as a legacy stepping stone, not the long-term target for tables that matter.
38
+
39
+ Rolling current layout keeps one wide child spanning the current month and many months ahead, then rolls one month at a time into named archive children. It minimizes partition count in the catalog. It demands strict operational discipline: default stays empty, current holds future-dated rows, and monthly roll must not overlap archive bounds. Use only when catalog size is the overriding constraint and the team accepts manual semantics.
40
+
41
+ Sliding window with three areas is the recommended layout for date-keyed mixed OLTP. Archive holds finalized monthly children before the active window. Current holds a bounded active span, often twelve months, with optional heat-driven splits that carve hot months into dedicated children while gap fillers cover non-hot months. Future is a single open-ended child from the end of the active window to MAXVALUE. Default is mandatory and must trend toward zero rows. A planner reconciles the target tree each maintenance run so ranges do not overlap and do not leave holes.
42
+
43
+ Extension-managed premake through pg_partman fits plain monthly or id-based series when the hosting environment allows the extension and the table does not need custom current or open fillers. Partman owns premake and retention well. Do not run full custom layout repair on the same parent table without an explicit hybrid contract, or the two maintainers will fight over bounds and drops.
44
+
45
+ Hypertable-style chunk automation through a time-series extension fits pure metrics and analytics when extension cost and operations are acceptable. It is not a substitute for mixed OLTP where queries ignore time or where composite keys and default-partition maintenance dominate reliability work.
46
+
47
+ ## Stage three: indicators that drive partition creation and sizing
48
+
49
+ Operators and maintainers watch a small set of indicators. None of them alone decides the design, but together they show when to create children, split buckets, or drop archive.
50
+
51
+ Watch default partition row count. Sustained rows in default mean the layout is wrong or premake lagged. Default should be a safety net, not a destination. Alert when default is non-zero at the start of a maintenance run.
52
+
53
+ Watch partition horizon. The upper bound of the latest non-default child should stay comfortably ahead of the maximum insert key. A common operational rule is at least thirty days of headroom for monthly partitions. Shorter horizon means premake or sliding-window rebalance is falling behind.
54
+
55
+ Watch per-bucket heat inside the active window. When a single month or band exceeds a row threshold on the order of tens to hundreds of thousands of rows, or grows large on disk, dedicate a child for that bucket inside the current zone rather than letting one wide filler absorb all traffic. When a dedicated child stays cold, merge policy may collapse it back, conservatively.
56
+
57
+ Watch attached child count. Hundreds of partitions increase planner work and schema cache pressure. Prefer a bounded active window and a single future tail over creating many months ahead as separate premade children.
58
+
59
+ Watch retention policy against archive age. When archive children are older than the policy allows, detach or drop them. Retention belongs in the maintenance story even when layout repair is automated. See [retention.md](retention.md) for detach vs drop, `retention_apply`, and compliance.
60
+
61
+ Watch query plans for partition pruning on realistic workloads. If pruning does not appear for the queries you care about, the partition key is wrong or the application contract is missing filters. See [partition_landscape.md](partition_landscape.md) for composite primary keys, Rails `query_constraints`, and filter patterns that avoid scanning all partitions.
62
+
63
+ ## Stage four: tools and responsibilities
64
+
65
+ Separate DDL creation from runtime maintenance.
66
+
67
+ Use migration helpers such as pg_party or explicit SQL to create the partitioned parent, initial children, indexes, and default partition. At cutover, use a hot-switch migration pattern: shadow partitioned table, minimal premake, atomic rename, then hand off to runtime maintenance. Premake one month ahead at cutover is enough when nightly maintenance owns the window afterward.
68
+
69
+ Use Partition Gardener for runtime maintenance when you need sliding window layout, default drain last, heat splits inside current, and non-overlapping tail rebalance. Register each parent with partition key, conflict key, template, and thresholds. Run under a single maintainer per table with advisory locking.
70
+
71
+ Choose maintenance cadence per service, not as a global default. See [Maintenance cadence](#maintenance-cadence) below.
72
+
73
+ Use pg_partman when the table is a straightforward time or id series, the extension is available, and you do not need three-area layout. Pick one maintainer per table. If partman premakes and gardener also reshapes the same bounds, treat that as a configuration error unless hybrid layout-only mode is documented and implemented. See [tooling_split.md](tooling_split.md).
74
+
75
+ ### Maintenance cadence
76
+
77
+ Nightly `run!` fits services where layout drift is cheap to repair and row moves are acceptable on a fixed schedule. That is the common case for sliding-window monthly tables with modest move volume and OLTP paths scoped to one or few buckets.
78
+
79
+ Indicator-driven maintenance fits services where periodic row moves are costly, replica lag is sensitive, or maintenance I/O competes with peak traffic. Those services still need partition layout, but they should not run `apply` on a calendar alone. Run `apply` only when audit indicators cross explicit thresholds (default rows, horizon, heat, gap warnings from [Stage three](#stage-three-indicators-that-drive-partition-creation-and-sizing)). Between runs, rely on premake headroom and application insert discipline to keep default near zero.
80
+
81
+ Indicator-driven services must treat aggregate snapshots as the hot-path contract, not an optional optimization. Dashboard totals, KPI cards, and cross-period rollups read from bucket-keyed snapshot tables with `computed_at` and drift checks; live `SUM` / `COUNT` across all children stays off request threads. Incremental snapshot updates on writes keep the open bucket current; bounded recompute jobs refresh closed buckets after maintenance moves rows. Without that layer, deferring maintenance forces users onto wide scans that partitioning was meant to avoid. See [partition_landscape.md](partition_landscape.md#aggregates-totals-and-snapshots).
82
+
83
+ Operational split:
84
+
85
+ - Nightly or daily: scheduled `run!` after business-date rollover; morning audit confirms invariants.
86
+ - Indicator-driven: scheduled read-only `audit` (hourly or daily); enqueue `run!` for one table when warnings persist or thresholds breach; keep snapshot recompute jobs on their own cadence and rerun affected buckets after high `rows_moved`.
87
+
88
+ Both cadences use the same Gardener primitives (`plan`, `audit`, `apply`, `run!`). The difference is when `apply` runs and how heavily the application depends on snapshots on hot paths.
89
+
90
+ Partition Gardener does not replace application query contracts. When child tables must co-locate with a parent on a business date, denormalize that date onto children and require it in filters and joins. The gem does not enforce that contract; migrations and reviews do.
91
+
92
+ ## Reliability invariants
93
+
94
+ Whatever path the decision flow selects, these invariants stay the same.
95
+
96
+ Every partitioned parent has a default child attached. Maintenance targets zero rows in default after each run.
97
+
98
+ Every key value maps to exactly one non-default child after a successful maintenance run. No overlapping bounds and no gaps that force default routing.
99
+
100
+ Uniqueness constraints include the partition key.
101
+
102
+ One maintenance owner per parent table per run, guarded by advisory lock.
103
+
104
+ Retention uses detach or drop of whole children, not bulk delete across the parent, when the goal is to remove old periods.
105
+
106
+ After creating or reshaping children, analyze the parent when statistics matter for constraint exclusion, especially on large tables.
107
+
108
+ ## Decision flow diagram
109
+
110
+ ```mermaid
111
+ flowchart TD
112
+ start([Table growth or maintenance pain]) --> size{Large size or retention need?}
113
+ size -->|no| indexes[Index and query tuning first]
114
+ size -->|yes| prune{Most queries filter one dimension?}
115
+ prune -->|no| indexes
116
+ prune -->|yes| unique{Unique keys include partition key?}
117
+ unique -->|no| redesign[Redesign PK and unique indexes]
118
+ redesign --> method
119
+ unique -->|yes| method{Dominant dimension}
120
+ method -->|time or date| range[RANGE monthly or yearly]
121
+ method -->|fixed categories| list[LIST optional subpartition]
122
+ method -->|even fan-out| hash[HASH modulus fixed at DDL]
123
+ method -->|branch plus volume| composite[LIST then RANGE or HASH]
124
+ range --> layout{Runtime layout}
125
+ layout -->|mixed OLTP default risk| sliding[sliding_window_monthly recommended]
126
+ layout -->|plain series extension OK| partman[pg_partman premake and retention]
127
+ layout -->|minimal catalog wide current| rolling[rolling_current_monthly experimental]
128
+ layout -->|temporary simple cron| premake[premake_monthly legacy migrate away]
129
+ sliding --> tools[Create: pg_party or SQL. Maintain: Partition Gardener run]
130
+ partman --> partman_tools[Create: SQL. Maintain: partman only]
131
+ rolling --> rolling_tools[Create: pg_party or SQL. Maintain: Templates.rolling_current_monthly or custom job]
132
+ premake --> premake_tools[Create: pg_party or SQL. Maintain: premake template then upgrade]
133
+ ```
134
+
135
+ ## Mapping to Partition Gardener
136
+
137
+ Pick a template from [decision_flow.md](decision_flow.md) and register it with `Registry.register_template` or `Registry.register` unless hosting policy forces pg_partman for that table.
138
+
139
+ For cutover migrations, include Migration::HotSwitchConcern, resolve partition_config from Registry.hot_switch_partition_config when the table is already registered, and pass months_ahead of one at switch time.
140
+
141
+ ## What this flow does not decide
142
+
143
+ It does not choose hardware, connection pooling, or read replicas. It does not replace index design. It does not mandate a time-series extension when plain PostgreSQL plus disciplined layout is enough. It does not absolve applications from including the partition key in scopes that must prune, or from shaping UI and APIs so users naturally work inside those bounds.
144
+
145
+ When in doubt, prefer fewer moving parts: range on a real access key, sliding window maintenance, empty default, one maintainer, and retention by dropping archive children once policy and backups allow.
146
+
147
+ ## Related
148
+
149
+ - [configuration.md](configuration.md) — registry options and `maintenance_backend`
150
+ - [background_job.md](background_job.md) — schedule `run!` after layout is chosen
151
+ - [tooling_split.md](tooling_split.md) — pg_party, pg_partman, Gardener ownership
152
+ - [partition_landscape.md](partition_landscape.md) — application contract (pruning, UI, snapshots)
153
+ - [cutover.md](cutover.md) — hot-switch checklist
154
+ - [operations.md](operations.md) — post-go-live runbook
@@ -0,0 +1,91 @@
1
+ # Host application testing
2
+
3
+ How consuming Rails apps (and standalone services) test partitioned tables alongside Partition Gardener. Gem-internal testing lives in [spec/README.md](../spec/README.md).
4
+
5
+ ## Goals
6
+
7
+ - Prove registry config matches production templates.
8
+ - Prove `plan` / `audit` stay clean on CI data.
9
+ - Catch application queries that omit the partition key before production.
10
+
11
+ ## CI database
12
+
13
+ Use a real PostgreSQL instance in CI (same major version as production). Docker service or CI-provided Postgres is enough.
14
+
15
+ Minimal env:
16
+
17
+ ```bash
18
+ export DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/myapp_test
19
+ export INTEGRATION=1 # if reusing gardener integration patterns
20
+ ```
21
+
22
+ Load schema including partitioned parents created by migrations (pg_party or SQL).
23
+
24
+ ## Registry in test
25
+
26
+ Use the same registry as production in `config/initializers/partition_gardener.rb` or load `config/partition_garden.json` in test with the same template names. Staging should use the same `active_months`, `retention_months`, and `conflict_key` as production unless you intentionally test divergence.
27
+
28
+ Smoke after migrations:
29
+
30
+ ```ruby
31
+ RSpec.describe "partition registry" do
32
+ it "audits clean for registered tables" do
33
+ PartitionGardener::Registry.configs.each do |config|
34
+ result = PartitionGardener.audit(config[:table_name])
35
+ expect(result.partitioned).to be(true)
36
+ expect(result.warnings).to be_empty
37
+ end
38
+ end
39
+ end
40
+ ```
41
+
42
+ Run only when PostgreSQL is available; skip in unit-only jobs.
43
+
44
+ ## Application contract specs
45
+
46
+ Test behavior, not implementation:
47
+
48
+ - Scoped queries used by controllers return rows only inside the requested window.
49
+ - `create!` with partition key lands queryable row without relying on `default`.
50
+ - Snapshot totals match fixture sums for a single bucket after recompute job.
51
+
52
+ Avoid asserting exact partition child table names unless testing migration code.
53
+
54
+ ## Gardener maintenance in test
55
+
56
+ Optional nightly-style test on a disposable table:
57
+
58
+ ```ruby
59
+ PartitionGardener.run!(table_name: "events", dry_run: true)
60
+ PartitionGardener.run!(table_name: "events")
61
+ ```
62
+
63
+ Use a dedicated test parent or truncate children in `around` hooks; do not run full `apply --all` against shared dev databases without isolation.
64
+
65
+ ## Staging checklist
66
+
67
+ - [ ] Same PostgreSQL major version
68
+ - [ ] Same registry templates and `active_*` spans
69
+ - [ ] `today_resolver` uses app time zone
70
+ - [ ] Maintenance job schedule enabled
71
+ - [ ] Audit cron or monitoring wired ([monitoring.md](monitoring.md))
72
+ - [ ] Cutover playbook exercised once on clone ([cutover.md](cutover.md))
73
+
74
+ ## Staging data with pgsync
75
+
76
+ [pgsync](https://github.com/ankane/pgsync) copies rows between Postgres databases. Use it to refresh staging with a production-shaped subset before exercising `plan`, `run!`, or cutover drills.
77
+
78
+ Practices:
79
+
80
+ - Default destination is localhost unless `to_safe: true` — confirm the target URL before each run.
81
+ - Filter with `where` on the partition key so staging holds realistic month windows without full production volume.
82
+ - Use `--in-batches` on large append-only tables; schedule loads outside Gardener `run!` so rebalance and sync do not contend on the same parent.
83
+ - Apply `data_rules` when copying from production so secrets and PII are redacted in staging.
84
+
85
+ Gardener does not invoke pgsync. After sync, run `audit` and `plan` on registered tables before enabling nightly maintenance in staging.
86
+
87
+ ## Related
88
+
89
+ - [application_contract.md](application_contract.md) — what to test
90
+ - [cli.md](cli.md) — local plan and audit
91
+ - [configuration.md](configuration.md) — JSON registry for non-Rails
@@ -0,0 +1,110 @@
1
+ # Monitoring and SLOs
2
+
3
+ What to measure and alert on for partitioned tables maintained by Partition Gardener. Pair with [operations.md](operations.md) for remediation steps.
4
+
5
+ ## Suggested metrics
6
+
7
+ Emit from maintenance or audit jobs, CLI audit cron, or a thin wrapper around `PartitionGardener.audit`. Indicator-driven services audit on a schedule and call `run!` only when metrics breach thresholds ([decision_flow.md](decision_flow.md#maintenance-cadence)).
8
+
9
+ `partition_gardener.default_row_count` — from `audit` per table. Gauge; label `table_name`.
10
+
11
+ `partition_gardener.horizon_days` — from `audit` per table. Gauge; null if not computable.
12
+
13
+ `partition_gardener.attached_child_count` — from `audit` per table. Gauge.
14
+
15
+ `partition_gardener.audit_warning_count` — from `audit` per table. Gauge.
16
+
17
+ `partition_gardener.run.duration_ms` — from `RunSummary.tables[]`. Histogram per table.
18
+
19
+ `partition_gardener.run.rows_moved` — from `RunSummary.tables[]`. Counter or gauge per run.
20
+
21
+ `partition_gardener.run.skipped` — from `RunSummary`. Counter by `skip_reason`.
22
+
23
+ `partition_gardener.run.failed` — job rescue on `RunFailed`. Counter.
24
+
25
+ `partition_gardener.snapshot.drift` — application rollup reconcile. Gauge per bucket; see landscape doc.
26
+
27
+ `partition_gardener.snapshot.stale_seconds` — `now - computed_at` on snapshots. Gauge.
28
+
29
+ ## Alert rules (starting points)
30
+
31
+ Tune thresholds per table size and bucket grain.
32
+
33
+ `default_row_count > 0` for 2+ consecutive audit days — warning. Layout or insert contract slipping.
34
+
35
+ `default_row_count` growing week over week — page. Active mis-routing.
36
+
37
+ `horizon_days < 30` (monthly buckets) — warning. Premake or rebalance behind.
38
+
39
+ `horizon_days < 7` — page. Imminent insert routing risk.
40
+
41
+ `RunFailed` on nightly job — page. At least one table did not complete.
42
+
43
+ `rows_moved` > baseline × 5 — warning. Large drain or first rebalance; watch I/O and replicas.
44
+
45
+ `skip_reason = lock_not_acquired` on every table — warning. Duplicate schedulers.
46
+
47
+ `attached_child_count > 200` — warning. Catalog pressure ([audit](audit_reference.md)).
48
+
49
+ `snapshot.drift` above threshold — warning. Rollup pipeline bug or move window not reconciled.
50
+
51
+ `snapshot.stale_seconds` > SLA for closed periods — warning. Background recompute stuck.
52
+
53
+ ## SLO sketches
54
+
55
+ Document per product; examples:
56
+
57
+ Default empty: 95% of audit days show `default_row_count = 0` within 24h of any spike.
58
+
59
+ Horizon: `horizon_days >= 30` for all monthly tables 99% of audit days.
60
+
61
+ Job success (scheduled maintenance): nightly `run!` completes without `RunFailed` 99.5% of nights.
62
+
63
+ Layout invariants (indicator-driven maintenance): audit clears breach thresholds within SLA after a triggered `apply`; zero sustained default growth between applies.
64
+
65
+ Snapshot freshness: closed-month totals `computed_at` within 6h of period close.
66
+
67
+ ## Dashboards
68
+
69
+ Minimum panels per production partitioned table:
70
+
71
+ 1. Default row count (time series)
72
+ 2. Horizon days
73
+ 3. Last run `rows_moved` and duration
74
+ 4. Audit warning list (table)
75
+ 5. Snapshot drift and staleness (application metrics)
76
+
77
+ ## Notifier hook
78
+
79
+ Wire global `notifier` to your pipeline ([configuration.md](configuration.md)):
80
+
81
+ ```ruby
82
+ PartitionGardener.configure do |config|
83
+ config.notifier = ->(message_or_error, context: {}) {
84
+ Metrics.increment("partition_gardener.notify", tags: context)
85
+ ErrorTracker.notify(message_or_error, extra: context)
86
+ }
87
+ end
88
+ ```
89
+
90
+ Retention drops and dry-run retention logs also use `notifier`.
91
+
92
+ ## Synthetic checks
93
+
94
+ Optional daily job (read-only):
95
+
96
+ ```ruby
97
+ PartitionGardener::Registry.each do |config|
98
+ result = PartitionGardener.audit(config[:table_name])
99
+ report_to_metrics(result)
100
+ end
101
+ ```
102
+
103
+ Cheaper than full `apply`; catches drift between maintenance runs.
104
+
105
+ ## Related
106
+
107
+ - [audit_reference.md](audit_reference.md) — field meanings
108
+ - [operations.md](operations.md) — incident response
109
+ - [retention.md](retention.md) — alerts before drop
110
+ - [background_job.md](background_job.md) — job concurrency
data/docs/naming.md ADDED
@@ -0,0 +1,71 @@
1
+ # Partition naming catalog
2
+
3
+ How Gardener names children so operators can read `pg_catalog`, audit output, and `plan` JSON without guessing.
4
+
5
+ ## Tail slots (three-area layout)
6
+
7
+ Fixed suffixes on the parent table name:
8
+
9
+ `{table}_default` — catch-all; must trend empty.
10
+
11
+ `{table}_current` — start of active window.
12
+
13
+ `{table}_open` — gap filler in current zone.
14
+
15
+ `{table}_open_N` — extra fillers after heat splits.
16
+
17
+ `{table}_future` — open-ended tail to MAXVALUE.
18
+
19
+ `{table}_rebalance_staging` — temporary during tail rebalance.
20
+
21
+ Defined in `PartitionGardener::Naming`.
22
+
23
+ ## Archive buckets (date range)
24
+
25
+ Pattern: `{table}_{suffix}` where suffix comes from `DateBucket.partition_name_suffix`:
26
+
27
+ month — suffix example `events_2026_03`, bounds `[2026-03-01, 2026-04-01)`.
28
+
29
+ day — suffix example `events_2026_03_15`, bounds one day.
30
+
31
+ week — suffix example `events_2026_W10`, bounds ISO week.
32
+
33
+ quarter — suffix example `events_2026_Q1`, bounds calendar quarter.
34
+
35
+ year — suffix example `events_2026`, bounds calendar year.
36
+
37
+ Gardener parses archive names back to bucket starts for retention and heat maps. Manual names outside these patterns are ignored for archive retention and may become orphans.
38
+
39
+ ## Integer and hash layouts
40
+
41
+ - Integer window: band names follow strategy config (`active_id_lo`, band width).
42
+ - Hash branches: remainder or modulus in name per `hash_branches` template.
43
+ - List split: branch label from registry `branches` hash.
44
+
45
+ See template builders in [configuration.md](configuration.md).
46
+
47
+ ## Composite trees
48
+
49
+ - LIST parent: branch discriminator in child name.
50
+ - Sub-trees: `{parent}_{branch}` registered as separate gardener tables.
51
+ - Native PostgreSQL sub-partition names must stay consistent with what `expand` registers; gardener plans per registered node ([partition_landscape.md](partition_landscape.md#composite-trees)).
52
+
53
+ ## Hot-switch rename
54
+
55
+ After `hot_switch_tables`, children rename from `p_events_*` to `events_*` to match the production parent prefix. Plan audits using the post-switch names.
56
+
57
+ ## Drift from manual DDL
58
+
59
+ Symptoms:
60
+
61
+ - `partition gap` warnings in audit
62
+ - `plan` shows `reshape` or `drop` on unexpected names
63
+ - Retention skips or targets wrong children
64
+
65
+ Remediation: prefer `plan` + `apply` over manual attach. If manual children are required, match `FOR VALUES` bounds to planner segments exactly.
66
+
67
+ ## Related
68
+
69
+ - [operations.md](operations.md) — gap remediation
70
+ - [cutover.md](cutover.md) — shadow `p_` prefix
71
+ - [audit_reference.md](audit_reference.md) — gap messages
@@ -0,0 +1,131 @@
1
+ # Operations runbook
2
+
3
+ Day-to-day maintenance for partitioned tables registered with Partition Gardener. Use this page when audit warnings fire, a nightly job fails, or you need to recover a single table without rereading the whole configuration reference.
4
+
5
+ ## Daily rhythm
6
+
7
+ Two maintenance postures are common. Pick one per service; do not assume every registered table needs the same cadence ([decision_flow.md](decision_flow.md#maintenance-cadence)).
8
+
9
+ ### Scheduled maintenance
10
+
11
+ 1. Nightly `PartitionGardener.run!` (or CLI `apply` in non-Rails environments) after business-date rollover (`today_resolver`).
12
+ 2. Morning check: audit JSON for default rows, horizon, and gaps ([audit_reference.md](audit_reference.md)).
13
+ 3. Alert on `RunFailed`, sustained default growth, or horizon below threshold ([monitoring.md](monitoring.md)).
14
+
15
+ ### Indicator-driven maintenance
16
+
17
+ 1. Scheduled read-only `audit` (hourly or daily); no `apply` on a fixed calendar unless indicators demand it.
18
+ 2. Enqueue `run!` for a table when audit warnings persist: default rows, horizon below threshold, heat above split threshold, or layout gaps.
19
+ 3. Same alerts as scheduled maintenance; add snapshot drift and staleness when hot paths read rollups ([partition_landscape.md](partition_landscape.md#aggregates-totals-and-snapshots)).
20
+ 4. After `apply`, recompute snapshot buckets affected by high `rows_moved` before treating the incident closed.
21
+
22
+ Indicator-driven services depend on efficient bucket-keyed snapshots on hot paths so deferred maintenance does not push reporting onto full partition scans.
23
+
24
+ See [background_job.md](background_job.md) for job concurrency and [retention.md](retention.md) for archive detach and drop policy.
25
+
26
+ ## Commands by situation
27
+
28
+ Read-only audit: `bundle exec partition_gardener --rails audit TABLE --pretty`
29
+
30
+ Read-only layout diff: `bundle exec partition_gardener --rails plan TABLE --pretty`
31
+
32
+ Apply one table: `bundle exec partition_gardener --rails apply --confirm TABLE`
33
+
34
+ Apply full registry: `bundle exec partition_gardener --rails apply --all`
35
+
36
+ Ruby dry-run: `PartitionGardener.run!(dry_run: true, table_name: "events")`
37
+
38
+ Ruby single table: `PartitionGardener.run!(table_name: "events", job_class_name: "Ops")`
39
+
40
+ Always `plan` before the first `apply` after a registry or template change. In production, prefer the scheduled job; use CLI `apply` for targeted recovery.
41
+
42
+ ## RunSummary fields
43
+
44
+ `run!` and CLI `apply` return `RunSummary` JSON (`schema_version` 1.0):
45
+
46
+ `tables[].table_name` — parent table processed.
47
+
48
+ `tables[].duration_ms` — wall time for that table.
49
+
50
+ `tables[].plan_signature` — hash of target segments; changes when layout plan changes.
51
+
52
+ `tables[].rows_moved` — rows moved during default drain and tail rebalance.
53
+
54
+ `tables[].skipped` — true when the table was not maintained.
55
+
56
+ `tables[].skip_reason` — e.g. `lock_not_acquired`, `not_partitioned`, `pg_partman`.
57
+
58
+ `errors` — messages collected when `continue_on_error` is true; run still raises `RunFailed` at end if non-empty.
59
+
60
+ Interpretation:
61
+
62
+ - `lock_not_acquired` — another worker holds the advisory lock; usually harmless if one job owns maintenance. Investigate duplicate cron or overlapping deploy hooks.
63
+ - High `rows_moved` after a quiet period — default drain catching up, or first run after cutover. Expect elevated I/O; watch replica lag ([application_contract.md](application_contract.md)).
64
+ - `errors` non-empty — at least one table failed; other tables may have completed. Fix the failing table, then rerun that table only.
65
+
66
+ ## Audit warnings to actions
67
+
68
+ Full catalog: [audit_reference.md](audit_reference.md). Quick map:
69
+
70
+ default partition has N rows (N > 0) — likely inserts outside bounds, premake lag, or wrong partition key on writes. Action: `plan` then `apply`; fix application inserts; check horizon.
71
+
72
+ default partition is missing — incomplete creation migration. Action: create default child; never leave parent without default on RANGE layouts that require it.
73
+
74
+ partition horizon is X days ahead (below 30) — premake or sliding-window rebalance behind. Action: `apply`; verify `active_months` / premake settings.
75
+
76
+ partition gap: uncovered range between … — overlapping manual DDL or interrupted rebalance. Action: `plan` to see target; `apply`; avoid hand-attaching overlapping children.
77
+
78
+ no attached tail partition extends to MAXVALUE — missing `_future` or open tail. Action: `apply`; compare `attached_segments` vs `target_segments` in plan.
79
+
80
+ attached child count is N (high catalog pressure) — too many archive months attached or manual sprawl. Action: tighten `active_months`; enable `retention_months`; review [naming.md](naming.md) for orphan children.
81
+
82
+ table is not a partitioned table — registry typo or wrong connection. Action: fix registry; point `connection_resolver` at correct database.
83
+
84
+ ## Failed or partial runs
85
+
86
+ Gardener persists checkpoints in `partition_gardener_run_records` when `run_record_enabled` is true (default).
87
+
88
+ 1. Read `errors` and notifier context (`table_name`, `action`).
89
+ 2. `audit TABLE` — confirm default count and gaps unchanged or worse.
90
+ 3. `plan TABLE` — if `changed` is true, layout still needs work.
91
+ 4. Retry `run!(table_name: TABLE)` — incremental rebalance resumes when `plan_signature` matches the stored record.
92
+ 5. If a staging partition `TABLE_rebalance_staging` exists and blocks progress, inspect row count and consult gem issues; orphaned staging outside a matching `plan_signature` is cleared on the next full apply.
93
+
94
+ Set `continue_on_error: false` only when you want the job to stop on the first table failure.
95
+
96
+ ## Retention during operations
97
+
98
+ When `retention_months` is set, gardener detaches (and optionally drops) archive children older than the cutoff after layout work. See [retention.md](retention.md) for legal hold, `retention_keep_table`, and backup checks before drop.
99
+
100
+ `retention_detach_concurrently: true` reduces lock duration on detach; requires PostgreSQL 14+ and no long transactions holding the partition.
101
+
102
+ ## Index review after large layout changes
103
+
104
+ When `rows_moved` is high after rebalance, heat splits, or template upgrades, query plans may need new indexes on hot children. Review manually or run [Dexter](https://github.com/ankane/dexter) against recent workload ([related_postgres_tooling.md](related_postgres_tooling.md)). Gardener does not create indexes; `plan` / `apply` only change partition bounds and row placement.
105
+
106
+ ## Sharded registries
107
+
108
+ Run maintenance per shard connection:
109
+
110
+ ```ruby
111
+ ShardRecord.connected_to_all_shards do
112
+ PartitionGardener.run!(job_class_name: self.class.name)
113
+ end
114
+ ```
115
+
116
+ Or set `connection_resolver` on a shard-specific registry load. Audit and apply are per connection; there is no cross-shard summary in one CLI invocation.
117
+
118
+ ## What not to do in incidents
119
+
120
+ - Do not hand-`DROP` archive children without retention policy and backup review.
121
+ - Do not attach overlapping RANGE children to fix gaps without reading `plan` output.
122
+ - Do not run two full `apply --all` jobs concurrently without concurrency limits.
123
+ - Do not disable default drain to “fix” slowness; fix bounds and inserts instead.
124
+
125
+ ## Related
126
+
127
+ - [audit_reference.md](audit_reference.md) — warning and plan field reference
128
+ - [monitoring.md](monitoring.md) — metrics and alerts
129
+ - [cutover.md](cutover.md) — first-time migration and rollback
130
+ - [configuration.md](configuration.md) — registry options
131
+ - [cli.md](cli.md) — CLI flags