code0-zero_track 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ba30f4224e83da8ce5d9c86233ccd8bec8f480636f50306bb5f786b5875867d
4
- data.tar.gz: a47c562d06286ebd35c977e4825af11cf4de5d87c5339d15a4047d57ad0b7b4b
3
+ metadata.gz: d6150c745af141848ee0848ec9beda4db5c1809ece022f90b9006cf69df88f10
4
+ data.tar.gz: 73128d1d5ab33dec7887b195831c8a1b591eb46999c5cb4e20eb79387d5d138e
5
5
  SHA512:
6
- metadata.gz: 8318133e64d693242f17415dda6aae7d70bcc0e27533a2f6ba67a5ca2547568770038ba2f0638dc7cbec73cf9e1e175948219c1315424921191d41165c98ac63
7
- data.tar.gz: 90bb401d956616f4c6f5979073dbbdf4b8c97dcbd0b55aa53aebf1ab48ec99d4cba72c9ca4e878efc77391819ff5040767fab34b3513122e764eeec3e39eceba
6
+ metadata.gz: 129416f538b904432c213a42a22d95244220fb9c35548b089d9a9ef4836b11b1b3fd0a93c910ab748dfd51d024d56d00ac525e7fd22855330916462b848a3824
7
+ data.tar.gz: 78bba0f4d37e6a08b2d5b02a46478060e7ea944ee8a40c407a4f63c819c450ae41e86b33df9b47a28472ebb202dad28a49c2c95fa221de041d672de1164e8a6d
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2023-2026 Code0 UG (haftungsbeschränkt) i.G.
1
+ Copyright 2023-2026 Code0 UG (haftungsbeschränkt)
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
4
 
data/README.md CHANGED
@@ -65,4 +65,131 @@ can be filled with the correct entries when the schema is loaded from the schema
65
65
 
66
66
  This approach is prone to git conflicts, so you can switch to a file based persistence
67
67
  with `config.zero_track.active_record.schema_migrations = true`. Instead of an `INSERT INTO` in
68
- the `db/structure.sql`, this mode creates files in the `db/schema_migrations` directory.
68
+ the `db/structure.sql`, this mode creates files in the `db/schema_migrations` directory.
69
+
70
+ ### Table Partitioning
71
+
72
+ PostgreSQL supports declarative table partitioning. The partition manager automates the
73
+ management of partitions without manual operations or extensions on the PostgreSQL server.
74
+
75
+ #### Configuration
76
+
77
+ ```ruby
78
+ config.zero_track.db_partitioning.dynamic_partition_schema = 'partitions_dynamic' # default
79
+ config.zero_track.db_partitioning.base_ar_class = 'ActiveRecord::Base' # default
80
+ ```
81
+
82
+ - `dynamic_partition_schema`: The PostgreSQL schema where dynamic partitions are stored.
83
+ - `base_ar_class`: The ActiveRecord base class used for the internal partitioning models.
84
+
85
+ #### Migration Helpers
86
+
87
+ Include the migration helpers by inheriting from `Code0::ZeroTrack::Database::Migration[1.0]` (or the
88
+ appropriate version). The following methods become available:
89
+
90
+ `create_partition_by_date_table(table_name, partition_column:, **options, &block)` creates a table
91
+ partitioned by range on the given column. It automatically sets up a composite primary key
92
+ of `(id, partition_column)`.
93
+
94
+ `create_dynamic_partition_schema` / `drop_dynamic_partition_schema` creates or drops the schema
95
+ used for storing dynamic partitions.
96
+
97
+ `create_partitioning_views` / `drop_partitioning_views` creates or drops the PostgreSQL views
98
+ (`postgres_partitioned_tables`, `postgres_partitions`, `postgres_detached_partitions`) that the
99
+ partition manager uses to inspect existing partitions.
100
+
101
+ Example migration:
102
+
103
+ ```ruby
104
+ class CreatePartitionedEvents < Code0::ZeroTrack::Database::Migration[1.0]
105
+ def change
106
+ create_dynamic_partition_schema
107
+ create_partitioning_views
108
+
109
+ create_partition_by_date_table :events, partition_column: :created_at do |t|
110
+ t.text :name, null: false
111
+ t.timestamps_with_timezone null: false
112
+ end
113
+ end
114
+ end
115
+ ```
116
+
117
+ The schema and views only need to be created once before creating the first partitioned table.
118
+
119
+ Tables don't necessarily have to be created with the provided helper. The partition manager will
120
+ work as long as the model is correctly configured.
121
+
122
+ #### Defining a Partitioned Model
123
+
124
+ Include `Code0::ZeroTrack::Database::Partitioning::PartitionedTable` in your model and declare
125
+ the partitioning strategy:
126
+
127
+ ```ruby
128
+ class Event < ApplicationRecord
129
+ include Code0::ZeroTrack::Database::Partitioning::PartitionedTable
130
+
131
+ partition_by :created_at, strategy: :monthly, retain_for: 12.months
132
+ end
133
+ ```
134
+
135
+ Available strategies: `:daily` and `:monthly`.
136
+
137
+ Options passed to `partition_by`:
138
+
139
+ | Option | Description | Default |
140
+ |--------|-------------|---------|
141
+ | `strategy` | `:daily` or `:monthly` | *required* |
142
+ | `headroom` | How far ahead to pre-create partitions | 30 days (daily) / 6 months (monthly) |
143
+ | `retain_for` | How long to keep partitions before detaching (enables retention) | `nil` (disabled) |
144
+ | `retain_detached_for` | How long to keep detached partitions before dropping | 7 days |
145
+
146
+ #### Partition Manager
147
+
148
+ Register models for automatic partition management:
149
+
150
+ ```ruby
151
+ Code0::ZeroTrack::Database::Partitioning::PartitionManager.register_model(Event)
152
+ Code0::ZeroTrack::Database::Partitioning::PartitionManager.register_model(EventDetail)
153
+ ```
154
+
155
+ Then synchronize all registered models. The gem won't run this for you.
156
+ Call it from a cron job, Sidekiq scheduler, or deploy script:
157
+
158
+ ```ruby
159
+ Code0::ZeroTrack::Database::Partitioning::PartitionManager.sync_all_partitions!
160
+ ```
161
+
162
+ Or manage a single model:
163
+
164
+ ```ruby
165
+ manager = Code0::ZeroTrack::Database::Partitioning::PartitionManager.new(Event)
166
+ manager.sync_partitions!
167
+ ```
168
+
169
+ `sync_all_partitions!` first creates partitions for all registered models, then detaches and drops
170
+ partitions for all models in reverse registration order.
171
+
172
+ `sync_partitions!` performs three operations in order for a single model:
173
+ 1. Create and attach new partitions to cover the desired range (up to the configured headroom).
174
+ 2. Detach partitions that fall outside the desired range (when retention is enabled).
175
+ 3. Drop detached partitions that have been detached longer than `retain_detached_for`.
176
+
177
+ If needed, the three phases can be called individually with `create_partitions!`, `detach_partitions!`
178
+ and `drop_partitions!`. This only works on a partition manager for a specific model. There is no
179
+ shortcut to run this on all registered models like the `sync_all_partitions!` method.
180
+
181
+ Each table gets a PostgreSQL advisory lock, so concurrent calls won't conflict.
182
+
183
+ When tables have foreign key relationships, registration order and retention configuration matter:
184
+
185
+ - Register parent tables before child tables. `sync_all_partitions!` creates partitions in
186
+ registration order and detaches/drops them in reverse order. This way the parent tables
187
+ are created before the children and dropped after their children.
188
+ - A child table's `retain_for` must be less than or equal to the parent table's `retain_for`.
189
+ If a child retains partitions longer than its parent, dropping the parent partition will
190
+ fail because the child's foreign key still references it.
191
+
192
+ #### Schema Cleaner Integration
193
+
194
+ Dynamic partition tables are automatically removed from `db/structure.sql` if the
195
+ [schema cleaner](#configzero_trackactive_recordschema_cleaner) is enabled.
@@ -11,6 +11,7 @@ module Code0
11
11
  include Database::MigrationHelpers::IndexHelpers
12
12
  include Database::MigrationHelpers::RemoveColumnEnhancements
13
13
  include Database::MigrationHelpers::TableEnhancements
14
+ include Database::MigrationHelpers::TablePartitioning
14
15
  end
15
16
  # rubocop:enable Naming/ClassAndModuleCamelCase
16
17
 
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module MigrationHelpers
7
+ module TablePartitioning
8
+ def create_partition_by_date_table(table_name, partition_column:, **options, &block)
9
+ options[:options] = "PARTITION BY RANGE (#{quote_column_name(partition_column)})"
10
+ options[:id] = false
11
+
12
+ create_table(table_name, **options) do |t|
13
+ t.bigserial :id, null: false
14
+
15
+ block.call(t)
16
+ end
17
+
18
+ reversible do |dir|
19
+ dir.up do
20
+ execute <<~SQL.squish
21
+ ALTER TABLE #{quote_table_name(table_name)}
22
+ ADD PRIMARY KEY (#{quote_column_name(:id)}, #{quote_column_name(partition_column)})
23
+ SQL
24
+ end
25
+ end
26
+ end
27
+
28
+ def create_dynamic_partition_schema
29
+ schema = quote_table_name(Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema)
30
+ execute "CREATE SCHEMA #{schema}"
31
+ end
32
+
33
+ def drop_dynamic_partition_schema
34
+ schema = quote_table_name(Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema)
35
+ execute "DROP SCHEMA #{schema}"
36
+ end
37
+
38
+ def create_partitioning_views
39
+ dynamic_schema = Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema
40
+
41
+ execute <<-SQL.squish
42
+ CREATE OR REPLACE VIEW postgres_partitioned_tables AS
43
+ SELECT c.oid::regclass::text AS identifier,
44
+ c.oid,
45
+ n.nspname AS schema,
46
+ c.relname AS name,
47
+ CASE p.partstrat
48
+ WHEN 'l' THEN 'list'
49
+ WHEN 'r' THEN 'range'
50
+ WHEN 'h' THEN 'hash'
51
+ END AS strategy,
52
+ pg_get_partkeydef(c.oid) AS partition_key
53
+ FROM pg_partitioned_table p
54
+ JOIN pg_class c ON c.oid = p.partrelid
55
+ JOIN pg_namespace n ON n.oid = c.relnamespace
56
+ WHERE n.nspname = current_schema();
57
+ SQL
58
+
59
+ execute <<-SQL.squish
60
+ CREATE OR REPLACE VIEW postgres_partitions AS
61
+ SELECT c.oid::regclass::text AS identifier,
62
+ c.oid,
63
+ n.nspname AS schema,
64
+ c.relname AS name,
65
+ i.inhparent::regclass::text AS parent_identifier,
66
+ pg_get_expr(c.relpartbound, c.oid) AS condition,
67
+ obj_description(c.oid) AS comment,
68
+ i.inhrelid IS NOT NULL AS attached
69
+ FROM pg_class c
70
+ LEFT JOIN pg_inherits i ON c.oid = i.inhrelid
71
+ JOIN pg_namespace n ON n.oid = c.relnamespace
72
+ WHERE c.relispartition
73
+ AND c.relkind = 'r'
74
+ AND n.nspname IN (current_schema(), #{quote(dynamic_schema)});
75
+ SQL
76
+
77
+ execute <<-SQL.squish
78
+ CREATE OR REPLACE VIEW postgres_detached_partitions AS
79
+ SELECT c.oid::regclass::text AS identifier,
80
+ c.oid,
81
+ n.nspname AS schema,
82
+ c.relname AS name,
83
+ obj_description(c.oid)::jsonb ->> 'table' AS parent_identifier,
84
+ (obj_description(c.oid)::jsonb ->> 'detached_at')::timestamptz AS detached_at
85
+ FROM pg_class c
86
+ JOIN pg_namespace n ON n.oid = c.relnamespace
87
+ WHERE c.relkind = 'r'
88
+ AND n.nspname = #{quote(dynamic_schema)}
89
+ AND NOT EXISTS (
90
+ SELECT 1 FROM pg_inherits WHERE inhrelid = c.oid
91
+ )
92
+ AND obj_description(c.oid)::jsonb ? 'table'
93
+ AND obj_description(c.oid)::jsonb ? 'detached_at';
94
+ SQL
95
+ end
96
+
97
+ def drop_partitioning_views
98
+ execute 'DROP VIEW IF EXISTS postgres_detached_partitions'
99
+ execute 'DROP VIEW IF EXISTS postgres_partitions'
100
+ execute 'DROP VIEW IF EXISTS postgres_partitioned_tables'
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Code0
6
+ module ZeroTrack
7
+ module Database
8
+ module Partitioning
9
+ class PartitionManager
10
+ include Loggable
11
+
12
+ cattr_accessor :models
13
+ self.models = []
14
+
15
+ def self.register_model(clazz)
16
+ models << clazz
17
+ end
18
+
19
+ def self.reset_registered_models!
20
+ models.clear
21
+ end
22
+
23
+ def self.sync_all_partitions!
24
+ models.each do |model|
25
+ new(model).create_partitions!
26
+ end
27
+
28
+ models.reverse_each do |model|
29
+ manager = new(model)
30
+ manager.detach_partitions!
31
+ manager.drop_partitions!
32
+ end
33
+ end
34
+
35
+ attr_reader :model
36
+
37
+ def initialize(model)
38
+ if model.try(:partitioning_strategy).nil?
39
+ raise ArgumentError, "Model #{model} not configured for partitioning"
40
+ end
41
+
42
+ @model = model
43
+ end
44
+
45
+ def sync_partitions!
46
+ create_partitions!
47
+
48
+ detach_partitions!
49
+
50
+ drop_partitions!
51
+ end
52
+
53
+ def create_partitions!
54
+ with_lock do |connection|
55
+ model.partitioning_strategy.partitions_to_create.each do |partition|
56
+ create_partition(partition, connection)
57
+ attach_partition(partition, connection)
58
+ end
59
+ end
60
+ end
61
+
62
+ def detach_partitions!
63
+ with_lock do |connection|
64
+ model.partitioning_strategy.partitions_to_detach.each do |partition|
65
+ detach_partition(partition, connection)
66
+ end
67
+ end
68
+ end
69
+
70
+ def drop_partitions!
71
+ with_lock do |connection|
72
+ model.partitioning_strategy.partitions_to_drop.each do |detached_partition|
73
+ drop_partition(detached_partition, connection)
74
+ end
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def create_partition(partition, connection)
81
+ connection.execute(partition.to_create_sql(connection))
82
+ logger.info(
83
+ message: 'Created new partition',
84
+ table_name: partition.model.table_name,
85
+ partition_name: partition.partition_name
86
+ )
87
+ end
88
+
89
+ def attach_partition(partition, connection)
90
+ connection.execute(partition.to_attach_sql(connection))
91
+ logger.info(
92
+ message: 'Attached partition',
93
+ table_name: partition.model.table_name,
94
+ partition_name: partition.partition_name
95
+ )
96
+ end
97
+
98
+ def detach_partition(partition, connection)
99
+ connection.execute(partition.to_detach_sql(connection))
100
+
101
+ partition_comment = connection.quote({ table: model.table_name, detached_at: Time.current.iso8601 }.to_json)
102
+ fully_qualified_partition = partition.fully_qualified_partition(connection)
103
+ connection.execute("COMMENT ON TABLE #{fully_qualified_partition} IS #{partition_comment}")
104
+
105
+ logger.info(
106
+ message: 'Detached partition',
107
+ table_name: partition.model.table_name,
108
+ partition_name: partition.partition_name
109
+ )
110
+ end
111
+
112
+ def drop_partition(detached_partition, connection)
113
+ schema_name = connection.quote_table_name(detached_partition.schema)
114
+ partition_name = connection.quote_table_name(detached_partition.name)
115
+ qualified_name = "#{schema_name}.#{partition_name}"
116
+ connection.execute("DROP TABLE #{qualified_name}")
117
+
118
+ logger.info(
119
+ message: 'Dropped partition',
120
+ table_name: detached_partition.parent_identifier,
121
+ partition_name: detached_partition.name
122
+ )
123
+ end
124
+
125
+ def with_lock
126
+ lock_key = lock_key_for(model.table_name)
127
+
128
+ with_connection do |connection|
129
+ connection.transaction do
130
+ connection.execute("SELECT pg_advisory_xact_lock(#{lock_key})")
131
+ yield connection
132
+ end
133
+ end
134
+ end
135
+
136
+ def lock_key_for(table_name)
137
+ namespace = 'zero_track:partition_sync'
138
+ Zlib.crc32("#{namespace}:#{table_name}")
139
+ end
140
+
141
+ def with_connection(&block)
142
+ model.with_connection(&block)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module Partitioning
7
+ module PartitionedTable
8
+ extend ActiveSupport::Concern
9
+
10
+ PARTITIONING_STRATEGIES = {
11
+ daily: Partitioning::Strategy::Daily,
12
+ monthly: Partitioning::Strategy::Monthly,
13
+ }.freeze
14
+
15
+ class_methods do
16
+ attr_reader :partitioning_strategy
17
+
18
+ def partition_by(column, strategy:, **kwargs)
19
+ raise(ArgumentError, 'Table is already partitioned') unless partitioning_strategy.nil?
20
+
21
+ strategy_class = PARTITIONING_STRATEGIES[strategy] || raise(
22
+ ArgumentError,
23
+ "Unknown partitioning strategy: #{strategy}"
24
+ )
25
+
26
+ @partitioning_strategy = strategy_class.new(self, column, **kwargs)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module Partitioning
7
+ class PostgresDetachedPartition < Rails.application.config.zero_track.db_partitioning.base_ar_class.constantize
8
+ self.table_name = 'postgres_detached_partitions'
9
+ self.primary_key = 'identifier'
10
+
11
+ def readonly?
12
+ true
13
+ end
14
+
15
+ scope :detached_before, ->(timestamp) { where(detached_at: ..timestamp) }
16
+
17
+ belongs_to :postgres_partitioned_table,
18
+ class_name: 'Code0::ZeroTrack::Database::Partitioning::PostgresPartitionedTable',
19
+ foreign_key: 'parent_identifier',
20
+ primary_key: 'identifier',
21
+ inverse_of: :postgres_detached_partitions
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module Partitioning
7
+ class PostgresPartition < Rails.application.config.zero_track.db_partitioning.base_ar_class.constantize
8
+ self.table_name = 'postgres_partitions'
9
+ self.primary_key = 'identifier'
10
+
11
+ def readonly?
12
+ true
13
+ end
14
+
15
+ belongs_to :postgres_partitioned_table,
16
+ class_name: 'Code0::ZeroTrack::Database::Partitioning::PostgresPartitionedTable',
17
+ foreign_key: 'parent_identifier',
18
+ primary_key: 'identifier',
19
+ inverse_of: :postgres_partitions
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module Partitioning
7
+ class PostgresPartitionedTable < Rails.application.config.zero_track.db_partitioning.base_ar_class.constantize
8
+ self.table_name = 'postgres_partitioned_tables'
9
+ self.primary_key = 'identifier'
10
+
11
+ def readonly?
12
+ true
13
+ end
14
+
15
+ has_many :postgres_partitions,
16
+ class_name: 'Code0::ZeroTrack::Database::Partitioning::PostgresPartition',
17
+ foreign_key: 'parent_identifier',
18
+ primary_key: 'identifier',
19
+ inverse_of: :postgres_partitioned_table
20
+
21
+ has_many :postgres_detached_partitions,
22
+ class_name: 'Code0::ZeroTrack::Database::Partitioning::PostgresDetachedPartition',
23
+ foreign_key: 'parent_identifier',
24
+ primary_key: 'identifier',
25
+ inverse_of: :postgres_partitioned_table
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module Partitioning
7
+ module Strategy
8
+ class Base
9
+ attr_reader :model, :partitioning_column, :headroom, :retain_for, :retain_detached_for
10
+
11
+ def initialize(
12
+ model,
13
+ partitioning_column,
14
+ headroom: default_headroom,
15
+ retain_for: nil,
16
+ retain_detached_for: 7.days
17
+ )
18
+ @model = model
19
+ @partitioning_column = partitioning_column
20
+ @headroom = headroom
21
+ @retain_for = retain_for
22
+ @retain_detached_for = retain_detached_for
23
+ end
24
+
25
+ def current_partitions
26
+ raise NotImplementedError
27
+ end
28
+
29
+ def desired_partitions
30
+ raise NotImplementedError
31
+ end
32
+
33
+ def oldest_active_date
34
+ raise NotImplementedError
35
+ end
36
+
37
+ def partition_name(lower_bound)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ def default_headroom
42
+ raise NotImplementedError
43
+ end
44
+
45
+ def partitions_to_create
46
+ desired_partitions - current_partitions
47
+ end
48
+
49
+ def partitions_to_detach
50
+ current_partitions - desired_partitions
51
+ end
52
+
53
+ def partitions_to_drop
54
+ partitioned_table = PostgresPartitionedTable.find_by(identifier: model.table_name)
55
+
56
+ if partitioned_table.nil?
57
+ logger.warn(message: 'Failed to find partitioned table', identifier: model.table_name)
58
+ return []
59
+ end
60
+
61
+ partitioned_table.postgres_detached_partitions.detached_before(retain_detached_for.ago)
62
+ end
63
+
64
+ def retention_enabled?
65
+ retain_for.present?
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module Partitioning
7
+ module Strategy
8
+ class Daily < Time
9
+ PARTITION_SUFFIX = 'Y%YM%mD%d'
10
+
11
+ def advance_date(date)
12
+ date + 1.day
13
+ end
14
+
15
+ def normalize_date(date)
16
+ date.beginning_of_day.to_date
17
+ end
18
+
19
+ def partition_name(lower_bound)
20
+ suffix = lower_bound&.strftime(PARTITION_SUFFIX) || 'Y0000M00D00'
21
+
22
+ "#{model.table_name}_#{suffix}"
23
+ end
24
+
25
+ def default_headroom
26
+ 30.days
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module Partitioning
7
+ module Strategy
8
+ class Monthly < Time
9
+ PARTITION_SUFFIX = 'Y%YM%m'
10
+
11
+ def advance_date(date)
12
+ date.next_month
13
+ end
14
+
15
+ def normalize_date(date)
16
+ date.beginning_of_month.to_date
17
+ end
18
+
19
+ def partition_name(lower_bound)
20
+ suffix = lower_bound&.strftime(PARTITION_SUFFIX) || 'Y0000M00'
21
+
22
+ "#{model.table_name}_#{suffix}"
23
+ end
24
+
25
+ def default_headroom
26
+ 6.months
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module Partitioning
7
+ module Strategy
8
+ class Time < Base
9
+ include Loggable
10
+
11
+ def current_partitions
12
+ partitioned_table = PostgresPartitionedTable.find_by(identifier: model.table_name)
13
+
14
+ if partitioned_table.nil?
15
+ logger.warn(message: 'Failed to find partitioned table', identifier: model.table_name)
16
+ return []
17
+ end
18
+
19
+ partitioned_table.postgres_partitions.map do |partition|
20
+ TimePartition.from_sql(model, partition.name, partition.condition)
21
+ end
22
+ end
23
+
24
+ def desired_partitions
25
+ partitions = []
26
+
27
+ min_date, max_date = desired_range
28
+
29
+ while min_date < max_date
30
+ next_date = advance_date(min_date)
31
+
32
+ partitions << TimePartition.new(
33
+ model,
34
+ min_date,
35
+ next_date,
36
+ partition_name: partition_name(min_date)
37
+ )
38
+
39
+ min_date = next_date
40
+ end
41
+
42
+ partitions
43
+ end
44
+
45
+ def desired_range
46
+ if retention_enabled?
47
+ min_date = oldest_active_date
48
+ else
49
+ first_partition = current_partitions.min
50
+
51
+ min_date = first_partition.from || first_partition.to if first_partition
52
+ min_date ||= Date.current
53
+ end
54
+
55
+ min_date = normalize_date(min_date)
56
+
57
+ max_date = advance_date(Date.current) + headroom
58
+
59
+ [min_date, max_date]
60
+ end
61
+
62
+ def oldest_active_date
63
+ normalize_date(retain_for.ago)
64
+ end
65
+
66
+ def advance_date(date)
67
+ raise NotImplementedError
68
+ end
69
+
70
+ def normalize_date(date)
71
+ raise NotImplementedError
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Code0
4
+ module ZeroTrack
5
+ module Database
6
+ module Partitioning
7
+ class TimePartition
8
+ include Comparable
9
+
10
+ def self.from_sql(table, partition_name, definition)
11
+ matches = definition.match(/FOR VALUES FROM \('?(?<from>[^)']+)'?\) TO \('?(?<to>[^)']+)'?\)/)
12
+
13
+ raise ArgumentError, "Unknown partition definition: #{definition}" unless matches
14
+
15
+ raise NotImplementedError, 'MAXVALUE as upper bound is not supported' if matches[:to] == 'MAXVALUE'
16
+
17
+ from = matches[:from] == 'MINVALUE' ? nil : matches[:from]
18
+ to = matches[:to]
19
+
20
+ new(table, from, to, partition_name: partition_name)
21
+ end
22
+
23
+ attr_reader :model, :from, :to, :partition_name
24
+
25
+ def initialize(model, from, to, partition_name:)
26
+ @model = model
27
+ @from = date_or_nil(from)
28
+ @to = date_or_nil(to)
29
+ @partition_name = partition_name
30
+ end
31
+
32
+ def ==(other)
33
+ model == other.model && partition_name == other.partition_name && from == other.from && to == other.to
34
+ end
35
+ alias eql? ==
36
+
37
+ def hash
38
+ [model, partition_name, from, to].hash
39
+ end
40
+
41
+ def <=>(other)
42
+ return if model != other.model
43
+
44
+ partition_name <=> other.partition_name
45
+ end
46
+
47
+ def to_create_sql(connection)
48
+ <<~SQL.squish
49
+ CREATE TABLE IF NOT EXISTS #{fully_qualified_partition(connection)}
50
+ (LIKE #{connection.quote_table_name(model.table_name)} INCLUDING ALL)
51
+ SQL
52
+ end
53
+
54
+ def to_attach_sql(connection)
55
+ from_sql = from ? connection.quote(from.to_date.iso8601) : 'MINVALUE'
56
+ to_sql = connection.quote(to.to_date.iso8601)
57
+
58
+ <<~SQL.squish
59
+ ALTER TABLE #{connection.quote_table_name(model.table_name)}
60
+ ATTACH PARTITION #{fully_qualified_partition(connection)}
61
+ FOR VALUES FROM (#{from_sql}) TO (#{to_sql})
62
+ SQL
63
+ end
64
+
65
+ def to_detach_sql(connection)
66
+ <<~SQL.squish
67
+ ALTER TABLE #{connection.quote_table_name(model.table_name)}
68
+ DETACH PARTITION #{fully_qualified_partition(connection)}
69
+ SQL
70
+ end
71
+
72
+ def fully_qualified_partition(connection)
73
+ format(
74
+ '%<schema>s.%<partition>s',
75
+ schema: connection.quote_table_name(
76
+ Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema
77
+ ),
78
+ partition: connection.quote_table_name(partition_name)
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ def date_or_nil(obj)
85
+ return unless obj
86
+ return obj if obj.is_a?(Date)
87
+
88
+ Date.parse(obj)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -38,6 +38,10 @@ module Code0
38
38
  'CREATE EXTENSION IF NOT EXISTS \1;'
39
39
  )
40
40
 
41
+ # Remove dynamic partition objects that are managed automatically at runtime.
42
+ # These would cause schema drift on every partition rotation if left in the dump.
43
+ remove_dynamic_partitions!(structure)
44
+
41
45
  structure.gsub!(/\n{3,}/, "\n\n")
42
46
 
43
47
  io << structure.strip
@@ -45,6 +49,31 @@ module Code0
45
49
 
46
50
  nil
47
51
  end
52
+
53
+ private
54
+
55
+ def dynamic_partition_schema
56
+ Rails.application.config.zero_track.db_partitioning.dynamic_partition_schema
57
+ end
58
+
59
+ def remove_dynamic_partitions!(structure)
60
+ schema = Regexp.escape(dynamic_partition_schema)
61
+
62
+ # Remove CREATE TABLE <schema>.<partition> (...);
63
+ structure.gsub!(/^CREATE TABLE #{schema}\.\S+\s*\(.*?\);\n/m, '')
64
+
65
+ # Remove ALTER TABLE ... ATTACH PARTITION <schema>.<partition> ...;
66
+ structure.gsub!(/^ALTER TABLE .+ ATTACH PARTITION #{schema}\.\S+.*?;\n/, '')
67
+
68
+ # Remove ALTER TABLE ONLY <schema>.<partition> ...;
69
+ structure.gsub!(/^ALTER TABLE ONLY #{schema}\.\S+\n.*?;\n/m, '')
70
+
71
+ # Remove CREATE [UNIQUE] INDEX ... ON <schema>.<partition> ...;
72
+ structure.gsub!(/^CREATE (?:UNIQUE )?INDEX \S+ ON #{schema}\.\S+.*?;\n/m, '')
73
+
74
+ # Remove ALTER INDEX ... ATTACH PARTITION <schema>.<partition>;
75
+ structure.gsub!(/^ALTER INDEX \S+ ATTACH PARTITION #{schema}\.\S+;\n/, '')
76
+ end
48
77
  end
49
78
  end
50
79
  end
@@ -4,11 +4,16 @@ module Code0
4
4
  module ZeroTrack
5
5
  class Railtie < ::Rails::Railtie
6
6
  config.zero_track = ActiveSupport::OrderedOptions.new
7
+
7
8
  config.zero_track.active_record = ActiveSupport::OrderedOptions.new
8
9
  config.zero_track.active_record.timestamps = false
9
10
  config.zero_track.active_record.schema_migrations = false
10
11
  config.zero_track.active_record.schema_cleaner = false
11
12
 
13
+ config.zero_track.db_partitioning = ActiveSupport::OrderedOptions.new
14
+ config.zero_track.db_partitioning.dynamic_partition_schema = 'partitions_dynamic'
15
+ config.zero_track.db_partitioning.base_ar_class = 'ActiveRecord::Base'
16
+
12
17
  rake_tasks do
13
18
  path = File.expand_path(__dir__)
14
19
  Dir.glob("#{path}/../../tasks/**/*.rake").each { |f| load f }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Code0
4
4
  module ZeroTrack
5
- VERSION = '0.0.7'
5
+ VERSION = '0.0.8'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: code0-zero_track
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Niklas van Schrick
@@ -167,6 +167,17 @@ files:
167
167
  - lib/code0/zero_track/database/migration_helpers/index_helpers.rb
168
168
  - lib/code0/zero_track/database/migration_helpers/remove_column_enhancements.rb
169
169
  - lib/code0/zero_track/database/migration_helpers/table_enhancements.rb
170
+ - lib/code0/zero_track/database/migration_helpers/table_partitioning.rb
171
+ - lib/code0/zero_track/database/partitioning/partition_manager.rb
172
+ - lib/code0/zero_track/database/partitioning/partitioned_table.rb
173
+ - lib/code0/zero_track/database/partitioning/postgres_detached_partition.rb
174
+ - lib/code0/zero_track/database/partitioning/postgres_partition.rb
175
+ - lib/code0/zero_track/database/partitioning/postgres_partitioned_table.rb
176
+ - lib/code0/zero_track/database/partitioning/strategy/base.rb
177
+ - lib/code0/zero_track/database/partitioning/strategy/daily.rb
178
+ - lib/code0/zero_track/database/partitioning/strategy/monthly.rb
179
+ - lib/code0/zero_track/database/partitioning/strategy/time.rb
180
+ - lib/code0/zero_track/database/partitioning/time_partition.rb
170
181
  - lib/code0/zero_track/database/postgresql_adapter/dump_schema_versions_mixin.rb
171
182
  - lib/code0/zero_track/database/postgresql_database_tasks/load_schema_versions_mixin.rb
172
183
  - lib/code0/zero_track/database/schema_cleaner.rb