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,114 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/amkisko/partition_gardener.rb/main/docs/schemas/partition_garden.schema.json",
4
+ "title": "Partition Gardener table registration",
5
+ "description": "Portable fields for registering a partitioned parent table. Ruby lambdas (partition_name_format, partition_definition) are omitted; use layout templates instead.",
6
+ "type": "object",
7
+ "required": ["table_name", "layout", "partition_key_column", "conflict_key"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "table_name": {
11
+ "type": "string",
12
+ "minLength": 1
13
+ },
14
+ "layout": {
15
+ "type": "string",
16
+ "enum": [
17
+ "sliding_window",
18
+ "rolling_current",
19
+ "calendar_year",
20
+ "premake_monthly",
21
+ "integer_window",
22
+ "hash_branches"
23
+ ]
24
+ },
25
+ "bucket": {
26
+ "type": "string",
27
+ "enum": ["day", "week", "month", "quarter", "year"]
28
+ },
29
+ "partition_key_column": {
30
+ "type": "string",
31
+ "minLength": 1
32
+ },
33
+ "conflict_key": {
34
+ "type": "array",
35
+ "items": { "type": "string" },
36
+ "minItems": 1
37
+ },
38
+ "active_days": {
39
+ "type": "integer",
40
+ "minimum": 1,
41
+ "maximum": 366
42
+ },
43
+ "active_weeks": {
44
+ "type": "integer",
45
+ "minimum": 1,
46
+ "maximum": 104
47
+ },
48
+ "active_months": {
49
+ "type": "integer",
50
+ "minimum": 1,
51
+ "maximum": 36
52
+ },
53
+ "active_quarters": {
54
+ "type": "integer",
55
+ "minimum": 1,
56
+ "maximum": 20
57
+ },
58
+ "active_years": {
59
+ "type": "integer",
60
+ "minimum": 1,
61
+ "maximum": 10
62
+ },
63
+ "premake_months": {
64
+ "type": "integer",
65
+ "minimum": 0,
66
+ "maximum": 24
67
+ },
68
+ "split_row_threshold": {
69
+ "type": "integer",
70
+ "minimum": 1
71
+ },
72
+ "move_batch_size": {
73
+ "type": "integer",
74
+ "minimum": 1
75
+ },
76
+ "statement_timeout": {
77
+ "type": "integer",
78
+ "minimum": 1,
79
+ "description": "Statement timeout in seconds for maintenance on this table"
80
+ },
81
+ "retention_months": {
82
+ "type": "integer",
83
+ "minimum": 1
84
+ },
85
+ "retention_apply": {
86
+ "type": "boolean",
87
+ "description": "When true, detach or drop archive partitions past retention_months; when false or omitted, log would-drop actions only"
88
+ },
89
+ "retention_keep_table": {
90
+ "type": "boolean"
91
+ },
92
+ "retention_detach_concurrently": {
93
+ "type": "boolean"
94
+ },
95
+ "hash_modulus": {
96
+ "type": "integer",
97
+ "minimum": 2,
98
+ "maximum": 128
99
+ },
100
+ "maintenance_backend": {
101
+ "type": "string",
102
+ "enum": ["gardener", "pg_partman", "hybrid_layout_only"]
103
+ },
104
+ "incremental_rebalance": {
105
+ "type": "boolean"
106
+ },
107
+ "run_record_enabled": {
108
+ "type": "boolean"
109
+ },
110
+ "analyze_after_rebalance": {
111
+ "type": "boolean"
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,82 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/amkisko/partition_gardener.rb/main/docs/schemas/plan_report.schema.json",
4
+ "title": "Partition Gardener plan report",
5
+ "description": "Dry-run output from PartitionGardener.plan or run!(dry_run: true)",
6
+ "type": "object",
7
+ "required": [
8
+ "schema_version",
9
+ "table_name",
10
+ "layout",
11
+ "changed",
12
+ "plan_signature",
13
+ "target_segments",
14
+ "attached_segments",
15
+ "operations",
16
+ "gaps",
17
+ "hot_buckets"
18
+ ],
19
+ "additionalProperties": false,
20
+ "properties": {
21
+ "schema_version": {
22
+ "type": "string",
23
+ "const": "1.0"
24
+ },
25
+ "table_name": { "type": "string" },
26
+ "layout": { "type": "string" },
27
+ "changed": { "type": "boolean" },
28
+ "plan_signature": { "type": "string" },
29
+ "target_segments": {
30
+ "type": "array",
31
+ "items": { "$ref": "#/$defs/segment" }
32
+ },
33
+ "attached_segments": {
34
+ "type": "array",
35
+ "items": { "$ref": "#/$defs/segment" }
36
+ },
37
+ "operations": {
38
+ "type": "array",
39
+ "items": { "$ref": "#/$defs/operation" }
40
+ },
41
+ "gaps": {
42
+ "type": "array",
43
+ "items": { "$ref": "#/$defs/gap" }
44
+ },
45
+ "hot_buckets": {
46
+ "type": "array"
47
+ }
48
+ },
49
+ "$defs": {
50
+ "segment": {
51
+ "type": "object",
52
+ "required": ["name", "range_start", "range_end", "kind"],
53
+ "properties": {
54
+ "name": { "type": "string" },
55
+ "range_start": {},
56
+ "range_end": {},
57
+ "kind": { "type": "string" }
58
+ }
59
+ },
60
+ "operation": {
61
+ "type": "object",
62
+ "required": ["action"],
63
+ "properties": {
64
+ "action": {
65
+ "type": "string",
66
+ "enum": ["keep", "create", "reshape", "drop"]
67
+ },
68
+ "segment": { "$ref": "#/$defs/segment" },
69
+ "attached_segment": { "$ref": "#/$defs/segment" }
70
+ }
71
+ },
72
+ "gap": {
73
+ "type": "object",
74
+ "required": ["message"],
75
+ "properties": {
76
+ "range_start": {},
77
+ "range_end": {},
78
+ "message": { "type": "string" }
79
+ }
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,79 @@
1
+ # Tooling split: DDL creation, runtime maintenance, and extension maintenance
2
+
3
+ Partition work splits into three layers. Each layer has a different owner and schedule. Mixing owners on the same parent table without an explicit contract causes overlapping bounds, double drops, and rows stuck in the default partition.
4
+
5
+ ## pg_party — creation (DDL)
6
+
7
+ Use during migrations and cutover.
8
+
9
+ - Create partitioned parent and first children
10
+ - Attach indexes across children
11
+ - Model helpers for partition DDL
12
+
13
+ pg_party does not replace nightly maintenance. After cutover, hand the table to runtime maintenance.
14
+
15
+ ## pg_partman — extension runtime maintenance (premake and retention)
16
+
17
+ Use when the hosting environment allows the extension and the table is a plain time or id series.
18
+
19
+ - Premake upcoming intervals on a fixed cadence
20
+ - Retention via detach or drop
21
+ - Background worker or `run_maintenance_proc`
22
+
23
+ partman fits tables that do not need three-area layout, heat splits inside current, or default-last drain logic.
24
+
25
+ Register such tables with `maintenance_backend: :pg_partman` so Partition Gardener skips them.
26
+
27
+ ## Partition Gardener — application runtime maintenance (layout)
28
+
29
+ Use for runtime maintenance when layout is more than fixed premake.
30
+
31
+ - Sliding window archive, current, and future
32
+ - Heat-driven splits inside current
33
+ - Mandatory default drain last
34
+ - Incremental tail rebalance and gap detection
35
+ - Hot-switch cutover helper
36
+
37
+ Gardener is the default `maintenance_backend: :gardener`.
38
+
39
+ ## Pick one maintainer per table
40
+
41
+ New date-keyed OLTP table — create with pg_party or SQL; maintain with Gardener sliding window.
42
+
43
+ Plain monthly series when the extension is OK — create with pg_party or SQL; maintain with partman only (`maintenance_backend: :pg_partman`).
44
+
45
+ Migrating from premake-only cron — keep existing DDL; use Gardener after `premake_monthly` bridge, then upgrade template.
46
+
47
+ partman premake plus custom tail layout — create with pg_party or SQL; maintain with `maintenance_backend: :hybrid_layout_only` (gardener layout only; partman premake).
48
+
49
+ ## Hybrid mode
50
+
51
+ `maintenance_backend: :hybrid_layout_only` means:
52
+
53
+ - pg_partman owns premake and simple retention on `partman.part_config`
54
+ - Gardener runs tail rebalance, default drain, and gap repair only
55
+ - Gardener must not create the same monthly bounds partman already premakes
56
+
57
+ On register, Gardener probes `partman.part_config` and warns when:
58
+
59
+ - `maintenance_backend: :gardener` but partman also lists the parent
60
+ - `maintenance_backend: :pg_partman` but partman has no row
61
+ - `hybrid_layout_only` but partman has no row
62
+
63
+ ## Legacy premake template
64
+
65
+ `Templates.premake_monthly` is a stepping stone from cron premake jobs.
66
+
67
+ - Ensures current month through `premake_months` ahead exist
68
+ - Drains default
69
+ - Does not run sliding-window tail rebalance
70
+
71
+ Migrate to `sliding_window_monthly` when default stays empty and horizon warnings clear.
72
+
73
+ ## Related
74
+
75
+ - [decision_flow.md](decision_flow.md) — when to partition and which layout
76
+ - [configuration.md](configuration.md) — `maintenance_backend` and per-table registry
77
+ - [background_job.md](background_job.md) — schedule gardener runs
78
+ - [cutover.md](cutover.md) — creation through hot switch
79
+ - [related_postgres_tooling.md](related_postgres_tooling.md) — ankane tools; pgslice hot-switch detail in [cutover.md](cutover.md#lessons-from-pgslice)
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "partition_gardener"
4
+ require "partition_gardener/cli"
5
+
6
+ PartitionGardener::CLI.start
@@ -0,0 +1 @@
1
+ require_relative "sql_run_record_store"
@@ -0,0 +1,48 @@
1
+ module PartitionGardener
2
+ module AdvisoryLock
3
+ LOCK_NAMESPACE = "partition_gardener"
4
+
5
+ module_function
6
+
7
+ def with_table_lock(table_name, &block)
8
+ case PartitionGardener.configuration.advisory_lock_mode
9
+ when :transaction
10
+ with_transaction_lock(table_name, &block)
11
+ else
12
+ with_session_lock(table_name, &block)
13
+ end
14
+ end
15
+
16
+ def with_session_lock(table_name)
17
+ connection = Connection.connection
18
+ lock_sql = lock_expression(connection, table_name)
19
+ acquired = false
20
+ acquired = connection.execute("SELECT pg_try_advisory_lock(#{lock_sql})").first.values.first
21
+ acquired = acquired == true || acquired == "t"
22
+ raise LockNotAcquired, "session advisory lock not acquired for #{table_name}" unless acquired
23
+
24
+ yield
25
+ ensure
26
+ connection.execute("SELECT pg_advisory_unlock(#{lock_sql})") if connection && lock_sql && acquired
27
+ end
28
+
29
+ def with_transaction_lock(table_name)
30
+ connection = Connection.connection
31
+ lock_sql = lock_expression(connection, table_name)
32
+
33
+ connection.transaction do
34
+ acquired = connection.execute("SELECT pg_try_advisory_xact_lock(#{lock_sql})").first.values.first
35
+ acquired = acquired == true || acquired == "t"
36
+ raise LockNotAcquired, "transaction advisory lock not acquired for #{table_name}" unless acquired
37
+
38
+ yield
39
+ end
40
+ end
41
+
42
+ def lock_expression(connection, table_name)
43
+ namespace = connection.quote(LOCK_NAMESPACE)
44
+ table = connection.quote(table_name)
45
+ "hashtext(#{namespace}), hashtext(#{table})"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,88 @@
1
+ module PartitionGardener
2
+ class ArchiveRetention
3
+ include Naming
4
+
5
+ def initialize(config, executor: nil, job_class_name: "PartitionGardener")
6
+ @config = config
7
+ @executor = executor || Executor.for_config(config)
8
+ @job_class_name = job_class_name
9
+ end
10
+
11
+ def apply!(dry_run: nil)
12
+ retention_months = @config[:retention_months]
13
+ return 0 unless retention_months
14
+
15
+ apply_retention = if dry_run.nil?
16
+ @config.fetch(:retention_apply, false)
17
+ else
18
+ !dry_run
19
+ end
20
+
21
+ strategy = Strategy.for(@config)
22
+ return 0 unless strategy.is_a?(Strategy::DateRange)
23
+
24
+ table_name = @config[:table_name]
25
+ cutoff = DateCalendar.add_months(PartitionGardener.configuration.today, -retention_months)
26
+ dropped = 0
27
+ managed_tail_names = managed_tail_name_set(strategy)
28
+
29
+ Connection.attached_partitions(table_name).each do |partition|
30
+ next if partition.default
31
+ next if skip_retention_partition?(strategy, partition.name, managed_tail_names)
32
+
33
+ bucket = strategy.archive_bucket_from_partition_name(partition.name)
34
+ next unless bucket
35
+ next if bucket >= DateCalendar.beginning_of_month(cutoff)
36
+
37
+ if apply_retention
38
+ @executor.detach_partition(table_name, partition.name, concurrently: detach_concurrently?)
39
+ @executor.drop_table(partition.name) unless @config.fetch(:retention_keep_table, false)
40
+
41
+ PartitionGardener.configuration.notify(
42
+ "[PartitionGardener] Dropped archive partition #{partition.name} (retention #{retention_months} months)",
43
+ context: {
44
+ table_name: table_name,
45
+ partition_name: partition.name,
46
+ retention_months: retention_months,
47
+ job: @job_class_name
48
+ }
49
+ )
50
+ dropped += 1
51
+ next
52
+ end
53
+
54
+ PartitionGardener.configuration.notify(
55
+ "[PartitionGardener] Would drop archive partition #{partition.name} (bucket #{bucket}, retention #{retention_months} months)",
56
+ context: {
57
+ table_name: table_name,
58
+ partition_name: partition.name,
59
+ bucket: bucket,
60
+ dry_run: true
61
+ }
62
+ )
63
+ dropped += 1
64
+ end
65
+
66
+ dropped
67
+ end
68
+
69
+ private
70
+
71
+ def detach_concurrently?
72
+ @config.fetch(:retention_detach_concurrently, PartitionGardener.configuration.retention_detach_concurrently)
73
+ end
74
+
75
+ def managed_tail_name_set(strategy)
76
+ Set.new(strategy.managed_tail_partition_names)
77
+ rescue NoMethodError
78
+ Set.new
79
+ end
80
+
81
+ def skip_retention_partition?(strategy, partition_name, managed_tail_names)
82
+ (strategy.respond_to?(:tail_slot_name?) && strategy.tail_slot_name?(partition_name)) ||
83
+ managed_tail_names.include?(partition_name)
84
+ rescue NoMethodError
85
+ false
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,93 @@
1
+ module PartitionGardener
2
+ class Audit
3
+ SCHEMA_VERSION = "1.0"
4
+ HORIZON_WARNING_DAYS = 30
5
+
6
+ AuditResult = Data.define(
7
+ :table_name,
8
+ :partitioned,
9
+ :default_row_count,
10
+ :attached_child_count,
11
+ :horizon_days,
12
+ :gaps,
13
+ :warnings
14
+ )
15
+
16
+ def self.call(table_name, config: Registry.find_by_table_name(table_name))
17
+ new(table_name, config: config).call
18
+ end
19
+
20
+ def initialize(table_name, config: nil)
21
+ @table_name = table_name
22
+ @config = config || Registry.find_by_table_name(table_name)
23
+ end
24
+
25
+ def call
26
+ unless Connection.table_is_partitioned?(@table_name)
27
+ return AuditResult.new(
28
+ table_name: @table_name,
29
+ partitioned: false,
30
+ default_row_count: 0,
31
+ attached_child_count: 0,
32
+ horizon_days: nil,
33
+ gaps: [],
34
+ warnings: ["#{@table_name} is not a partitioned table"]
35
+ )
36
+ end
37
+
38
+ default_name = Naming.default_partition_name(@table_name)
39
+ default_row_count = if Connection.partition_exists?(default_name)
40
+ Connection.count_rows_in_partition_table(default_name)
41
+ else
42
+ 0
43
+ end
44
+
45
+ partitions = Connection.attached_partitions(@table_name)
46
+ warnings = []
47
+ warnings << "default partition #{default_name} has #{default_row_count} rows" if default_row_count.positive?
48
+ warnings << "default partition #{default_name} is missing" unless Connection.partition_exists?(default_name)
49
+
50
+ horizon_days = compute_horizon_days(partitions)
51
+ if horizon_days && horizon_days < HORIZON_WARNING_DAYS
52
+ warnings << "partition horizon is #{horizon_days} days ahead (below #{HORIZON_WARNING_DAYS})"
53
+ end
54
+
55
+ if partitions.size > 200
56
+ warnings << "attached child count is #{partitions.size} (high catalog pressure)"
57
+ end
58
+
59
+ gaps = GapDetection.call(@table_name, config: @config)
60
+ gaps.each do |gap|
61
+ warnings << "partition gap: #{gap.message}"
62
+ end
63
+
64
+ AuditResult.new(
65
+ table_name: @table_name,
66
+ partitioned: true,
67
+ default_row_count: default_row_count,
68
+ attached_child_count: partitions.size,
69
+ horizon_days: horizon_days,
70
+ gaps: gaps,
71
+ warnings: warnings
72
+ )
73
+ end
74
+
75
+ private
76
+
77
+ def compute_horizon_days(partitions)
78
+ today = PartitionGardener.configuration.today
79
+ finite_ends = partitions.filter_map do |partition|
80
+ next if partition.default
81
+ next if partition.range_end == :max
82
+ next unless partition.range_end.is_a?(Date)
83
+
84
+ partition.range_end
85
+ end
86
+
87
+ return nil if finite_ends.empty?
88
+
89
+ max_end = finite_ends.max
90
+ (max_end - today).to_i
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,13 @@
1
+ module PartitionGardener
2
+ module Blank
3
+ module_function
4
+
5
+ def blank?(value)
6
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
7
+ end
8
+
9
+ def present?(value)
10
+ !blank?(value)
11
+ end
12
+ end
13
+ end