tenant_partition 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a64a55d18e146680cc0efa637a7551edd6c04e724f76ebcfed9024ee47f4d33a
4
+ data.tar.gz: 94486994c67028a1564f65de2f1b4ec40a9bea9b36d9f52634cfe3bf83c0687e
5
+ SHA512:
6
+ metadata.gz: 24b43da02e5216554e7b5b7f343a0a5a298e9c3b3e10c6525e8c8e90e36f84782d0c51c2ac06748906a357e287a621ab47c667159799d2915b13426dd30b7a5c
7
+ data.tar.gz: 421aeb63c9ea4230e25da6ebe09bec7107de32b3fdd2eff0a5291c390937dfd24c6a3223ef1fdec096b6205f43fa5342c584cd811e01321be3a0636d21d883b7
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-01-26
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 gabriel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # ActivePartition
2
+
3
+ **ActivePartition** es un framework de infraestructura para Ruby on Rails (7.1+) diseñado para simplificar y automatizar la gestión de **Particionamiento por Lista (List Partitioning)** nativo de PostgreSQL.
4
+
5
+ Específicamente construido para arquitecturas **Multi-tenant**, este framework resuelve la complejidad de:
6
+ 1. **Composite Primary Keys (CPK):** Configuración automática de claves compuestas (`id` + `partition_key`) requeridas por ActiveRecord para soportar particionamiento.
7
+ 2. **Orquestación Centralizada:** Una única interfaz para crear y eliminar particiones en **todos** los modelos de la aplicación simultáneamente.
8
+ 3. **Mantenimiento Zero-Downtime:** Migración atómica de datos "huérfanos" (que cayeron en la tabla `DEFAULT`) hacia sus particiones correctas sin perder servicio.
9
+
10
+ ## 🚀 Características Principales
11
+
12
+ * **Fachada de Servicio:** API unificada (`ActivePartition.create!`, `destroy!`, `audit`, `cleanup!`) para gestionar el ciclo de vida completo de los tenants.
13
+ * **Introspección Inteligente:** Los modelos de negocio detectan automáticamente su configuración de infraestructura por convención (ej: `User` -> `Partition::User`).
14
+ * **Migration DSL:** Helper `create_partitioned_table` para definir tablas particionadas y sus tablas `DEFAULT` en una sola instrucción.
15
+ * **Seguridad Estricta:** Concern para controladores que valida Headers HTTP (`X-Tenant-ID`) para asegurar el contexto del tenant.
16
+ * **Observabilidad:** Instrumentación integrada con `ActiveSupport::Notifications`.
17
+
18
+ ## 📋 Requisitos
19
+
20
+ * **Ruby:** >= 3.2
21
+ * **Ruby on Rails:** >= 7.1
22
+ * **PostgreSQL:** >= 13.0 (Requerido para `gen_random_uuid()` nativo)
23
+
24
+ ## 📦 Instalación
25
+
26
+ Agrega esto a tu `Gemfile`:
27
+
28
+ ```ruby
29
+ gem 'tenant_partition'
30
+ ```
31
+
32
+ Luego ejecuta:
33
+
34
+ ```bash
35
+ bundle install
36
+ ```
37
+
38
+ ## ⚙️ Configuración
39
+
40
+ Crea un inicializador en `config/initializers/activepartition.rb`. Es **obligatorio** definir la clave de partición.
41
+
42
+ ```ruby
43
+ ActivePartition.configure do |config|
44
+ # 1. La columna que discrimina los tenants (ej: :isp_id, :account_id, :tenant_id)
45
+ config.partition_key = :isp_id
46
+
47
+ # 2. El Header HTTP para la seguridad en controladores API
48
+ config.header_name = 'X-Tenant-ID'
49
+ end
50
+ ```
51
+
52
+ ## 🛠 Guía de Uso
53
+
54
+ ### 1. Migraciones (Crear las Tablas)
55
+
56
+ Usa el helper `create_partitioned_table`. Este método deshabilita el ID automático simple y configura una **Primary Key Compuesta** (`[:id, :partition_key]`) necesaria para que PostgreSQL permita el particionamiento.
57
+
58
+ ```ruby
59
+ class CreateConversations < ActiveRecord::Migration[7.1]
60
+ def change
61
+ create_partitioned_table :conversations do |t|
62
+ # No definas t.primary_key. La gema crea (id, isp_id) automáticamente.
63
+ t.string :topic
64
+ t.timestamps
65
+ end
66
+ end
67
+ end
68
+ ```
69
+
70
+ ### 2. Capa de Infraestructura (Modelos Partition)
71
+
72
+ Define modelos que hereden de `ActivePartition::Base`. Estos modelos son responsables de las operaciones DDL (Create/Drop tables). Por convención, se recomienda usar el namespace `Partition::`.
73
+
74
+ ```ruby
75
+ # app/models/partition/conversation.rb
76
+ module Partition
77
+ class Conversation < ActivePartition::Base
78
+ # Hereda la configuración global (:isp_id) automáticamente.
79
+ end
80
+ end
81
+ ```
82
+
83
+ ### 3. Capa de Negocio (Modelos Rails)
84
+
85
+ En tus modelos estándar (`ApplicationRecord`), incluye el concern `Partitioned`.
86
+
87
+ **Magia de Introspección:**
88
+ Al incluir el concern, la gema busca automáticamente si existe un modelo de infraestructura asociado (ej: `Partition::Conversation`) y hereda su configuración.
89
+
90
+ ```ruby
91
+ # app/models/conversation.rb
92
+ class Conversation < ApplicationRecord
93
+ include ActivePartition::Concerns::Partitioned
94
+
95
+ # ¡Listo! Rails ahora sabe que la Primary Key es [:id, :isp_id]
96
+ # y aplica scopes automáticos.
97
+ end
98
+ ```
99
+
100
+ ### 4. Orquestación (Ciclo de Vida del Tenant)
101
+
102
+ Ya no necesitas crear particiones tabla por tabla. Usa la **Fachada** `ActivePartition` para gestionar la infraestructura de un tenant en **todos** los modelos registrados simultáneamente.
103
+
104
+ **Crear un nuevo Tenant (Provisioning):**
105
+ Ideal para usar en tu `RegistrationService` o `AfterCommit` de la creación del tenant.
106
+
107
+ ```ruby
108
+ # En tu Service Object o Controller de registro
109
+ def create_tenant
110
+ isp = Isp.create!(params)
111
+
112
+ # Busca TODOS los modelos particionados y crea las tablas físicas para este ID.
113
+ # Es idempotente: si alguna ya existe, la salta sin error.
114
+ ActivePartition.create!(isp.id)
115
+ end
116
+ ```
117
+
118
+ **Eliminar un Tenant (Deprovisioning):**
119
+
120
+ ```ruby
121
+ # Esta operación realiza DETACH + DROP de las tablas físicas.
122
+ # ¡Es destructiva e irreversible!
123
+ ActivePartition.destroy!(old_isp.id)
124
+ ```
125
+
126
+ ### 5. Seguridad en Controladores
127
+
128
+ Protege tus API endpoints asegurando que siempre reciban el ID del tenant.
129
+
130
+ ```ruby
131
+ class ApiController < ActionController::API
132
+ include ActivePartition::Concerns::Controller
133
+
134
+ # Valida que el request traiga el header 'X-Tenant-ID'.
135
+ # Devuelve 400 Bad Request si falta.
136
+ before_action :require_partition_key!
137
+
138
+ def index
139
+ # current_partition_id contiene el valor seguro del Header
140
+ @chats = Conversation.for_partition(current_partition_id).all
141
+ render json: @chats
142
+ end
143
+ end
144
+ ```
145
+
146
+ ## 🛡 Mantenimiento y Recuperación
147
+
148
+ ActivePartition maneja el escenario de "Race Condition" donde llegan datos *antes* de que la partición exista. Esos datos caen automáticamente en la tabla `_default`.
149
+
150
+ ### Auditoría
151
+
152
+ Verifica si tienes datos "fugados" en las tablas default:
153
+
154
+ ```ruby
155
+ # Desde consola Rails
156
+ report = ActivePartition.audit
157
+ # => { "Partition::Conversation" => 14, "Partition::Message" => 0 }
158
+ ```
159
+
160
+ O vía Rake task:
161
+ ```bash
162
+ bundle exec rails active_partition:audit
163
+ ```
164
+
165
+ ### Limpieza (Cleanup)
166
+
167
+ Mueve los datos huérfanos a sus particiones correspondientes de forma atómica y segura.
168
+
169
+ ```ruby
170
+ # Ruby API (Ideal para Jobs nocturnos)
171
+ ActivePartition.cleanup!
172
+ ```
173
+
174
+ O vía Rake task:
175
+ ```bash
176
+ bundle exec rails active_partition:cleanup
177
+ ```
178
+
179
+ ## 📊 Observabilidad
180
+
181
+ Puedes suscribirte a los eventos para enviar métricas a tu sistema de monitoreo (Datadog, Prometheus, NewRelic).
182
+
183
+ ```ruby
184
+ # config/initializers/notifications.rb
185
+ ActiveSupport::Notifications.subscribe(/activepartition/) do |name, start, finish, id, payload|
186
+ duration = (finish - start) * 1000
187
+
188
+ case name
189
+ when "create.active_partition"
190
+ Rails.logger.info "📦 Partición creada: #{payload[:table]} para #{payload[:value]}"
191
+ when "populate.active_partition"
192
+ Rails.logger.info "🧹 Limpieza: #{payload[:count]} registros movidos en #{duration.round(2)}ms"
193
+ end
194
+ end
195
+ ```
196
+
197
+ ## 📄 Licencia
198
+
199
+ Este proyecto está disponible como código abierto bajo los términos de la [Licencia MIT](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rubocop/rake_task"
5
+
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: :rubocop
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActivePartition
4
+ # Clase base para la gestión de infraestructura de particionamiento en PostgreSQL.
5
+ #
6
+ # Proporciona una interfaz para manejar el ciclo de vida de las tablas físicas (Particiones).
7
+ #
8
+ # @abstract Hereda de esta clase para definir un recurso de partición.
9
+ # @example
10
+ # class Partition::Chat < ActivePartition::Base
11
+ # # Opcional: Sobrescribir la clave global
12
+ # self.partition_key = :region_code
13
+ # end
14
+ class Base
15
+ include ActiveModel::Model
16
+ include ActiveModel::Attributes
17
+
18
+ # Registra el atributo de partición en la subclase en el momento de la herencia.
19
+ def self.inherited(subclass)
20
+ super
21
+ # Intentamos definir el atributo por defecto si existe configuración global
22
+ key = ActivePartition.configuration&.partition_key
23
+ subclass.attribute key if key
24
+ end
25
+
26
+ class << self
27
+ attr_writer :parent_table, :prefix, :default_table
28
+
29
+ # Permite inyectar una clave de partición personalizada por clase
30
+ attr_writer :partition_key
31
+
32
+ def parent_table
33
+ @parent_table ||= name.demodulize.underscore.pluralize
34
+ end
35
+
36
+ # Prioridad: 1. Clave de la clase, 2. Clave global
37
+ def partition_key
38
+ @partition_key ||= ActivePartition.configuration&.partition_key ||
39
+ raise(ActivePartition::Error, "Clave de partición no configurada.")
40
+ end
41
+
42
+ def partition_key=(value)
43
+ @partition_key = value
44
+ attribute value # Define el atributo en ActiveModel automáticamente
45
+ end
46
+
47
+ def prefix
48
+ @prefix ||= "#{parent_table}_#{partition_key.to_s.gsub('_id', '')}"
49
+ end
50
+
51
+ def default_table
52
+ @default_table ||= "#{parent_table}_default"
53
+ end
54
+
55
+ def connection
56
+ ActiveRecord::Base.connection
57
+ end
58
+
59
+ # Crea físicamente una partición en PostgreSQL.
60
+ def create(value)
61
+ # Importante: Usamos el método partition_key (no la variable) para respetar overrides
62
+ payload = { partition_key: partition_key, value: value, table: parent_table }
63
+
64
+ ActiveSupport::Notifications.instrument("create.active_partition", payload) do
65
+ name = partition_name(value)
66
+ sql = "CREATE TABLE IF NOT EXISTS #{name} PARTITION OF #{parent_table} FOR VALUES IN ('#{value}');"
67
+ connection.execute(sql)
68
+ new(partition_key => value)
69
+ end
70
+ end
71
+
72
+ def partition_name(value)
73
+ sanitized = value.to_s.gsub('-', '_')
74
+ "#{prefix}_#{sanitized}"
75
+ end
76
+
77
+ def exists?(value)
78
+ name = partition_name(value)
79
+ sql = <<-SQL.squish
80
+ SELECT 1 FROM pg_class c
81
+ JOIN pg_inherits i ON c.oid = i.inhrelid
82
+ JOIN pg_class p ON i.inhparent = p.oid
83
+ WHERE p.relname = '#{parent_table}' AND c.relname = '#{name}';
84
+ SQL
85
+ connection.execute(sql).any?
86
+ end
87
+
88
+ def find(value)
89
+ new(partition_key => value) if exists?(value)
90
+ end
91
+ end
92
+
93
+ # --- Métodos de Instancia ---
94
+
95
+ def partition_id
96
+ # CORRECCIÓN: Usamos public_send porque ActiveModel no tiene read_attribute
97
+ # Esto invoca al getter generado dinámicamente (ej: .isp_id)
98
+ public_send(self.class.partition_key)
99
+ end
100
+
101
+ def partition_table_name
102
+ self.class.partition_name(partition_id)
103
+ end
104
+
105
+ def persisted?
106
+ self.class.exists?(partition_id)
107
+ end
108
+
109
+ # Mueve registros desde la tabla por defecto hacia la partición atómicamente.
110
+ def populate_from_default(batch_size: 5000)
111
+ return 0 unless persisted?
112
+
113
+ parent = self.class.parent_table
114
+ default = self.class.default_table
115
+ key = self.class.partition_key
116
+ val = partition_id
117
+
118
+ payload = { partition_key: key, value: val, parent_table: parent }
119
+
120
+ ActiveSupport::Notifications.instrument("populate.active_partition", payload) do |notification_payload|
121
+ total_moved = 0
122
+
123
+ loop do
124
+ batch_count = 0
125
+ self.class.connection.transaction do
126
+ # Usamos el ID para paginar el borrado/insertado
127
+ move_sql = <<-SQL.squish
128
+ WITH moved_rows AS (
129
+ DELETE FROM #{default}
130
+ WHERE #{key} = '#{val}'
131
+ AND id IN (
132
+ SELECT id FROM #{default} WHERE #{key} = '#{val}' LIMIT #{batch_size}
133
+ )
134
+ RETURNING *
135
+ )
136
+ INSERT INTO #{parent} SELECT * FROM moved_rows;
137
+ SQL
138
+
139
+ result = self.class.connection.execute(move_sql)
140
+ batch_count = result.cmd_tuples
141
+ end
142
+
143
+ total_moved += batch_count
144
+ break if batch_count < batch_size
145
+ end
146
+
147
+ notification_payload[:count] = total_moved
148
+ total_moved
149
+ end
150
+ end
151
+
152
+ def destroy
153
+ return false unless persisted?
154
+
155
+ p_name = partition_table_name
156
+ p_table = self.class.parent_table
157
+
158
+ self.class.connection.transaction do
159
+ self.class.connection.execute("ALTER TABLE #{p_table} DETACH PARTITION #{p_name};")
160
+ self.class.connection.execute("DROP TABLE IF EXISTS #{p_name};")
161
+ end
162
+ true
163
+ rescue ActiveRecord::StatementInvalid
164
+ false
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActivePartition
4
+ module Concerns
5
+ # Helpers y validaciones para controladores en arquitecturas Multi-tenant.
6
+ #
7
+ # Este módulo implementa una estrategia de extracción estricta:
8
+ # Solo acepta el ID de partición si viene en el Header HTTP configurado explícitamente.
9
+ #
10
+ # @example Configuración requerida
11
+ # # config/initializers/active_partition.rb
12
+ # ActivePartition.configure do |config|
13
+ # config.partition_key = :isp_id
14
+ # config.header_name = 'X-Tenant-ID' # <--- Obligatorio
15
+ # end
16
+ #
17
+ # @example Uso en el controlador
18
+ # class ApiController < ActionController::API
19
+ # include ActivePartition::Concerns::Controller
20
+ # before_action :require_partition_key!
21
+ # end
22
+ module Controller
23
+ extend ActiveSupport::Concern
24
+
25
+ included do
26
+ # Expone el método a las vistas de Rails (erb, jbuilder, etc.)
27
+ helper_method :current_partition_id if respond_to?(:helper_method)
28
+ end
29
+
30
+ # Devuelve el valor del ID de partición actual extraído exclusivamente de los Headers.
31
+ #
32
+ # Utiliza únicamente el nombre de header definido en +ActivePartition.configuration.header_name+.
33
+ # No realiza inferencias ni busca en parámetros de la URL.
34
+ #
35
+ # @return [String, nil] El valor del header o nil si no está presente o configurado.
36
+ def current_partition_id
37
+ return @current_partition_id if defined?(@current_partition_id)
38
+
39
+ header_key = ActivePartition.configuration.header_name
40
+
41
+ # Si no se configuró un nombre de header, no podemos buscar nada.
42
+ return @current_partition_id = nil unless header_key.present?
43
+
44
+ @current_partition_id = request.headers[header_key]
45
+ end
46
+
47
+ # Filtro (before_action) para detener la ejecución si el ID de partición no está presente.
48
+ #
49
+ # Retorna un error 400 Bad Request si el header falta.
50
+ #
51
+ # @return [void]
52
+ def require_partition_key!
53
+ return if current_partition_id.present?
54
+
55
+ header_key = ActivePartition.configuration.header_name
56
+
57
+ # Mensaje de error detallado dependiendo de si es un error de configuración o de petición
58
+ error_message = if header_key.blank?
59
+ "Server Configuration Error: 'header_name' is not configured in ActivePartition."
60
+ else
61
+ "Missing required header: '#{header_key}'"
62
+ end
63
+
64
+ render json: {
65
+ error: "Partitioning Error",
66
+ message: error_message
67
+ }, status: :bad_request
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActivePartition
4
+ module Concerns
5
+ # Módulo de infraestructura para modelos de negocio (ApplicationRecord).
6
+ #
7
+ # Este concern es el encargado de adaptar el modelo de Rails para trabajar con
8
+ # la estructura de tablas particionadas de PostgreSQL. Su función crítica es
9
+ # configurar las **Primary Keys Compuestas (CPK)** requeridas por Rails 7.1+.
10
+ #
11
+ # ### Estrategia de Resolución de Claves
12
+ # El módulo determina qué columna usar como clave de partición siguiendo este orden de prioridad:
13
+ #
14
+ # 1. **Explícita:** Definida manualmente con `partitioned_by :key` en el modelo.
15
+ # 2. **Inferencia (Convención):** Busca si existe una clase de infraestructura asociada
16
+ # (ej: para `Conversation` busca `Partition::Conversation`) y utiliza su configuración.
17
+ # 3. **Global:** Utiliza la clave definida en `ActivePartition.configure`.
18
+ #
19
+ # @example Modo Automático (Inferencia)
20
+ # # Si Partition::LegacyChat tiene `self.partition_key = :region_id`
21
+ # class LegacyChat < ApplicationRecord
22
+ # include ActivePartition::Concerns::Partitioned
23
+ # # Automáticamente configura PK: [:id, :region_id]
24
+ # end
25
+ #
26
+ # @example Modo Manual (Override)
27
+ # class Log < ApplicationRecord
28
+ # include ActivePartition::Concerns::Partitioned
29
+ # partitioned_by :custom_id
30
+ # end
31
+ module Partitioned
32
+ extend ActiveSupport::Concern
33
+
34
+ included do
35
+ # Punto de entrada para inclusión directa.
36
+ # 'self' es la clase que incluye el módulo.
37
+ ActivePartition::Concerns::Partitioned.configure_model(self)
38
+ end
39
+
40
+ class_methods do
41
+ # Hook de Ruby disparado al heredar.
42
+ # Permite incluir el módulo en ApplicationRecord y que la configuración
43
+ # se aplique automáticamente a cada subclase en el momento de su definición.
44
+ #
45
+ # @param subclass [Class] La clase que está heredando.
46
+ def inherited(subclass)
47
+ super
48
+ ActivePartition::Concerns::Partitioned.configure_model(subclass)
49
+ end
50
+
51
+ # Define manualmente la clave de partición, ignorando la inferencia y la config global.
52
+ #
53
+ # @param key [Symbol] Nombre de la columna de partición.
54
+ def partitioned_by(key)
55
+ ActivePartition::Concerns::Partitioned.apply_configuration(self, key)
56
+ end
57
+ end
58
+
59
+ # --- Métodos de Utilería (Privados de la Gema) ---
60
+
61
+ # Orquesta la configuración del modelo.
62
+ # @api private
63
+ def self.configure_model(klass)
64
+ # Ignoramos clases abstractas para evitar errores de tabla inexistente.
65
+ return if klass.respond_to?(:abstract_class?) && klass.abstract_class?
66
+
67
+ # Resolvemos la clave correcta según la prioridad establecida.
68
+ key_to_use = resolve_partition_key(klass)
69
+
70
+ apply_configuration(klass, key_to_use) if key_to_use.present?
71
+ end
72
+
73
+ # Determina la clave de partición inspeccionando la infraestructura y la configuración.
74
+ #
75
+ # @param klass [Class] El modelo de negocio a inspeccionar.
76
+ # @return [Symbol, nil] La clave de partición encontrada o nil.
77
+ # @api private
78
+ def self.resolve_partition_key(klass)
79
+ # 1. Inferencia por Convención:
80
+ # Buscamos la clase "Partition::NombreDelModelo".
81
+ # Ej: Conversation -> Partition::Conversation
82
+ infra_class_name = "Partition::#{klass.name}"
83
+
84
+ # safe_constantize (ActiveSupport) devuelve la clase si existe, nil si no.
85
+ # No lanza excepciones si la constante no está definida.
86
+ infra_class = infra_class_name.safe_constantize
87
+
88
+ if infra_class && infra_class.respond_to?(:partition_key)
89
+ # Si existe el modelo de infraestructura, confiamos en su configuración.
90
+ return infra_class.partition_key
91
+ end
92
+
93
+ # 2. Configuración Global (Fallback):
94
+ ActivePartition.configuration&.partition_key
95
+ end
96
+
97
+ # Aplica la configuración de Primary Key y Scopes al modelo.
98
+ #
99
+ # @param klass [Class] El modelo a configurar.
100
+ # @param key [Symbol] La clave de partición.
101
+ # @api private
102
+ def self.apply_configuration(klass, key)
103
+ # Idempotencia:
104
+ # Si la clase ya tiene la PK compuesta correcta, no hacemos nada.
105
+ # Esto previene conflictos si se llama múltiples veces.
106
+ return if klass.primary_key.is_a?(Array) && klass.primary_key.include?(key.to_s)
107
+
108
+ # A. Configuración de CPK (Composite Primary Keys) - Rails 7.1+
109
+ # Le dice a Rails que la identidad única es (id + partition_key).
110
+ klass.primary_key = [:id, key]
111
+
112
+ # B. Scope de Conveniencia
113
+ # Permite usar Model.for_partition("uuid")
114
+ klass.scope :for_partition, ->(value) { where(key => value) }
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActivePartition
4
+ class Configuration
5
+ # @return [Symbol, nil] Columna de base de datos (ej: :isp_id)
6
+ attr_accessor :partition_key
7
+
8
+ # @return [String, nil] Nombre del Header HTTP personalizado (ej: 'X-Tenant-ID')
9
+ attr_accessor :header_name
10
+
11
+ def initialize
12
+ @partition_key = nil
13
+ @header_name = nil
14
+ end
15
+
16
+ def valid?
17
+ !partition_key.nil?
18
+ end
19
+ end
20
+
21
+ class << self
22
+ attr_accessor :configuration
23
+
24
+ def configure
25
+ self.configuration ||= Configuration.new
26
+ yield(configuration)
27
+
28
+ unless configuration.valid?
29
+ raise ActivePartition::Error, "Debe configurar un 'partition_key' en el inicializador de ActivePartition."
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ActivePartition
6
+ # Railtie encargado de integrar ActivePartition en el ciclo de vida de Rails.
7
+ #
8
+ # Su responsabilidad principal es inyectar los componentes de la gema (Tareas Rake,
9
+ # Helpers de Migración) en la aplicación anfitriona durante el proceso de arranque.
10
+ class Railtie < Rails::Railtie
11
+ # Bloque para cargar tareas Rake.
12
+ # Debe estar definido DENTRO de la clase Railtie para tener acceso al método +rake_tasks+.
13
+ #
14
+ # Carga las tareas de mantenimiento (audit, cleanup) para que estén
15
+ # disponibles mediante el comando `rails`.
16
+ rake_tasks do
17
+ # Usamos expand_path para asegurar que la ruta sea absoluta respecto a la gema,
18
+ # evitando errores de "file not found" dependiendo de dónde se ejecute el comando.
19
+ load File.expand_path("tasks/maintenance.rake", __dir__)
20
+ end
21
+
22
+ # Inicializador que se ejecuta cuando Rails carga los componentes de ActiveRecord.
23
+ #
24
+ # Inyecta los helpers de migración (como +create_partitioned_table+) directamente
25
+ # en el adaptador de PostgreSQL para extender el DSL de las migraciones.
26
+ initializer "active_partition.insert_schema_statements" do
27
+ ActiveSupport.on_load(:active_record) do
28
+ require "activepartition/schema/statements"
29
+
30
+ # Validación de seguridad:
31
+ # Solo inyectamos el módulo si el adaptador configurado es efectivamente PostgreSQL.
32
+ # Esto previene errores si la gema se instala en proyectos con MySQL o SQLite.
33
+ if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) && connection_db_config.adapter == "postgresql"
34
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include ActivePartition::Schema::Statements
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActivePartition
4
+ # Clase encargada de validar que el entorno y la configuración sean aptos
5
+ # para el funcionamiento de ActivePartition.
6
+ class SafetyGuard
7
+ # Versión mínima de PostgreSQL soportada para particionamiento nativo estable.
8
+ MIN_POSTGRES_VERSION = 13.0
9
+
10
+ # Ejecuta todas las validaciones de seguridad.
11
+ # @raise [ActivePartition::Error] Si alguna validación falla.
12
+ # @return [void]
13
+ def self.validate!
14
+ check_configuration!
15
+ return unless defined?(Rails) && Rails.env.to_s != 'test'
16
+
17
+ check_database_adapter!
18
+ check_postgres_version!
19
+ end
20
+
21
+ private
22
+
23
+ # Verifica que la clave de partición esté presente.
24
+ def self.check_configuration!
25
+ if ActivePartition.configuration.partition_key.nil?
26
+ raise ActivePartition::Error, "Falta configuración: 'partition_key' no ha sido definido."
27
+ end
28
+ end
29
+
30
+ # Asegura que se esté utilizando PostgreSQL.
31
+ def self.check_database_adapter!
32
+ adapter = ActiveRecord::Base.connection_db_config.adapter
33
+ unless adapter == "postgresql"
34
+ raise ActivePartition::Error, "Adaptador incompatible: ActivePartition solo soporta PostgreSQL (usando: #{adapter})."
35
+ end
36
+ end
37
+
38
+ # Verifica la versión de PostgreSQL mediante una consulta directa al motor.
39
+ def self.check_postgres_version!
40
+ version = ActiveRecord::Base.connection.select_value("SHOW server_version").to_f
41
+ if version < MIN_POSTGRES_VERSION
42
+ raise ActivePartition::Error, "Versión de PostgreSQL insuficiente: Se requiere v#{MIN_POSTGRES_VERSION}+ (detectada: #{version})."
43
+ end
44
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
45
+ # Si no hay conexión aún, se posterga la validación
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActivePartition
4
+ module Schema
5
+ # Proporciona métodos adicionales para las migraciones de ActiveRecord
6
+ # enfocados en la automatización del particionamiento nativo de PostgreSQL.
7
+ module Statements
8
+ # Crea una tabla padre particionada por lista y genera automáticamente
9
+ # su partición por defecto (DEFAULT).
10
+ #
11
+ # CORRECCIÓN IMPORTANTE: Configura una Primary Key Compuesta (id + partition_key)
12
+ # para cumplir con los requisitos de unicidad de PostgreSQL en tablas particionadas.
13
+ #
14
+ # @param table_name [Symbol, String] El nombre de la tabla a crear.
15
+ # @param options [Hash] Opciones estándar + :partition_key opcional.
16
+ # @yield [t] Bloque para definir las columnas de la tabla.
17
+ def create_partitioned_table(table_name, **options, &block)
18
+ # Prioridad: 1. Opción pasada al método, 2. Configuración global
19
+ key = options.delete(:partition_key) || ActivePartition.configuration&.partition_key
20
+
21
+ unless key
22
+ raise ActivePartition::Error, "Debe configurar 'partition_key' globalmente o pasarlo como opción."
23
+ end
24
+
25
+ # --- LÓGICA DE PRIMARY KEY COMPUESTA ---
26
+ # 1. Desactivamos la creación automática del ID simple, ya que Postgres fallaría.
27
+ options[:id] = false
28
+
29
+ # 2. Definimos explícitamente que la PK está formada por el ID y la CLAVE DE PARTICIÓN.
30
+ # Esto genera en SQL: PRIMARY KEY (id, isp_id)
31
+ options[:primary_key] = [:id, key]
32
+
33
+ # 3. Inyectamos la estrategia de particionamiento
34
+ options[:options] = "PARTITION BY LIST (#{key})"
35
+
36
+ # Crear la tabla padre
37
+ create_table(table_name, **options) do |t|
38
+ # A. Como desactivamos id: false, debemos crear la columna ID manualmente.
39
+ # Usamos gen_random_uuid() para que sea autogenerado.
40
+ t.uuid :id, null: false, default: -> { "gen_random_uuid()" }
41
+
42
+ # B. Ejecutamos las definiciones del usuario
43
+ block.call(t)
44
+
45
+ # C. Inyección automática del atributo de partición si el usuario no lo definió.
46
+ # Nota: Si el usuario ya puso `t.uuid :isp_id` en su migración, esta línea la salta.
47
+ unless t.columns.any? { |c| c.name == key.to_s }
48
+ t.column key, :string, null: false
49
+ end
50
+ end
51
+
52
+ # Crear la partición DEFAULT
53
+ default_name = "#{table_name}_default"
54
+ execute <<-SQL.squish
55
+ CREATE TABLE IF NOT EXISTS #{default_name}
56
+ PARTITION OF #{table_name} DEFAULT;
57
+ SQL
58
+ end
59
+
60
+ # Elimina una tabla particionada y su tabla por defecto asociada.
61
+ def drop_partitioned_table(table_name)
62
+ drop_table(table_name, cascade: true)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,14 @@
1
+ # lib/activepartition/tasks/maintenance.rake
2
+ # frozen_string_literal: true
3
+
4
+ namespace :active_partition do
5
+ desc "Audita todas las tablas DEFAULT definidas en ActivePartition"
6
+ task audit: :environment do
7
+ ActivePartition.audit
8
+ end
9
+
10
+ desc "Limpia registros huérfanos moviéndolos a sus particiones"
11
+ task cleanup: :environment do
12
+ ActivePartition.cleanup!
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActivePartition
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_model"
5
+ require "active_support/all"
6
+
7
+ # Carga de la versión y componentes internos
8
+ require_relative "activepartition/version"
9
+ require_relative "activepartition/configuration"
10
+ require_relative "activepartition/safety_guard"
11
+ require_relative "activepartition/base"
12
+ require_relative "activepartition/concerns/partitioned"
13
+ require_relative "activepartition/concerns/controller"
14
+
15
+ # Integración con Ruby on Rails
16
+ require_relative "activepartition/railtie" if defined?(Rails)
17
+
18
+ # ActivePartition es el punto de entrada principal para la gestión de particionamiento
19
+ # en PostgreSQL dentro de aplicaciones Rails.
20
+ #
21
+ # Actúa como una **Fachada** que centraliza:
22
+ # 1. Configuración de la gema.
23
+ # 2. Orquestación del ciclo de vida de los tenants (Crear/Borrar particiones).
24
+ # 3. Mantenimiento y limpieza de datos huérfanos.
25
+ module ActivePartition
26
+ # Error base para todas las excepciones de la gema.
27
+ class Error < StandardError; end
28
+
29
+ class << self
30
+ # @return [ActivePartition::Configuration] El objeto de configuración global.
31
+ attr_accessor :configuration
32
+
33
+ # Configura la gema mediante un bloque e inicializa las validaciones de seguridad.
34
+ #
35
+ # @yieldparam [ActivePartition::Configuration] config
36
+ # @return [void]
37
+ def configure
38
+ self.configuration ||= Configuration.new
39
+ yield(configuration)
40
+ SafetyGuard.validate!
41
+ end
42
+
43
+ # =========================================================================
44
+ # GRUPO 1: ORQUESTACIÓN DE TENANTS (Ciclo de Vida)
45
+ # =========================================================================
46
+
47
+ # Crea las particiones físicas para un tenant específico en TODOS los modelos registrados.
48
+ #
49
+ # Este método es **idempotente**: verifica si la partición existe antes de intentar crearla,
50
+ # evitando errores de SQL.
51
+ #
52
+ # @param partition_id [String, Integer] El ID del tenant (ej: el UUID del ISP).
53
+ # @return [void]
54
+ def create!(partition_id)
55
+ ensure_models_loaded!
56
+
57
+ log_info "CREATE", "Iniciando aprovisionamiento para ID: #{partition_id}"
58
+
59
+ partitionable_models.each do |model|
60
+ if model.exists?(partition_id)
61
+ log_info "SKIP", "#{model.name}: La partición ya existe."
62
+ else
63
+ model.create(partition_id)
64
+ log_info "OK", "#{model.name}: Partición creada exitosamente."
65
+ end
66
+ end
67
+ end
68
+
69
+ # Elimina (DETACH + DROP) las particiones físicas de un tenant en TODOS los modelos.
70
+ #
71
+ # @warning Esta acción es destructiva e irreversible. Borra los datos físicos.
72
+ #
73
+ # @param partition_id [String, Integer] El ID del tenant a eliminar.
74
+ # @return [void]
75
+ def destroy!(partition_id)
76
+ ensure_models_loaded!
77
+
78
+ log_info "DESTROY", "Eliminando infraestructura para ID: #{partition_id}"
79
+
80
+ partitionable_models.each do |model|
81
+ if model.exists?(partition_id)
82
+ partition = model.find(partition_id)
83
+ if partition.destroy
84
+ log_info "DROP", "#{model.name}: Partición eliminada."
85
+ else
86
+ log_error "FAIL", "#{model.name}: No se pudo eliminar la partición."
87
+ end
88
+ else
89
+ log_info "SKIP", "#{model.name}: No existe partición para borrar."
90
+ end
91
+ end
92
+ end
93
+
94
+ # =========================================================================
95
+ # GRUPO 2: MANTENIMIENTO Y OPS (Audit & Cleanup)
96
+ # =========================================================================
97
+
98
+ # Audita todas las tablas DEFAULT buscando registros "huérfanos".
99
+ #
100
+ # Un registro huérfano es aquel que se insertó en la tabla padre pero, al no existir
101
+ # su partición correspondiente en ese momento, cayó en la tabla _default.
102
+ #
103
+ # @return [Hash{String => Integer}] Mapa con el nombre del modelo y la cantidad de registros huérfanos.
104
+ # @example
105
+ # ActivePartition.audit
106
+ # # => { "Partition::Message" => 150, "Partition::Log" => 0 }
107
+ def audit
108
+ ensure_models_loaded!
109
+ report = {}
110
+
111
+ log_info "AUDIT", "Iniciando auditoría de tablas DEFAULT..."
112
+
113
+ partitionable_models.each do |model|
114
+ count = count_default_rows(model)
115
+ report[model.name] = count
116
+
117
+ if count > 0
118
+ log_warn "ALERTA", "#{model.name}: #{count} registros huérfanos encontrados."
119
+ else
120
+ log_info "OK", "#{model.name}: Tabla default limpia."
121
+ end
122
+ end
123
+
124
+ report
125
+ end
126
+
127
+ # Realiza una limpieza global moviendo registros huérfanos a sus particiones correctas.
128
+ #
129
+ # 1. Identifica qué IDs de tenant tienen datos en las tablas DEFAULT.
130
+ # 2. Verifica si ya existe la partición física para esos IDs.
131
+ # 3. Mueve los datos atómicamente.
132
+ #
133
+ # @return [void]
134
+ def cleanup!
135
+ ensure_models_loaded!
136
+ key = configuration.partition_key
137
+
138
+ log_info "CLEANUP", "Iniciando proceso de limpieza global..."
139
+
140
+ partitionable_models.each do |model|
141
+ # Obtenemos los IDs únicos presentes en la tabla default
142
+ orphan_ids = fetch_orphan_ids(model, key)
143
+
144
+ if orphan_ids.empty?
145
+ log_info "OK", "#{model.name}: Sin datos huérfanos."
146
+ next
147
+ end
148
+
149
+ log_warn "FIX", "#{model.name}: Procesando #{orphan_ids.count} tenants con datos huérfanos."
150
+
151
+ orphan_ids.each do |id|
152
+ partition = model.find(id)
153
+
154
+ if partition
155
+ moved = partition.populate_from_default
156
+ log_info "MOVE", " -> ID #{id}: #{moved} registros recuperados."
157
+ else
158
+ log_error "ERROR", " -> ID #{id}: La partición física no existe. Cree el tenant primero."
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ private
165
+
166
+ # Encuentra todas las clases cargadas que heredan de ActivePartition::Base.
167
+ # @return [Array<Class>]
168
+ def partitionable_models
169
+ ObjectSpace.each_object(Class).select { |klass| klass < ActivePartition::Base }
170
+ end
171
+
172
+ # Cuenta registros en la tabla default de un modelo de infraestructura.
173
+ # @return [Integer]
174
+ def count_default_rows(model)
175
+ model.connection.select_value("SELECT count(*) FROM #{model.default_table}").to_i
176
+ rescue ActiveRecord::StatementInvalid
177
+ 0
178
+ end
179
+
180
+ # Obtiene los IDs de partición distintos que existen en la tabla default.
181
+ # @return [Array<String>]
182
+ def fetch_orphan_ids(model, key)
183
+ sql = "SELECT DISTINCT #{key} FROM #{model.default_table} WHERE #{key} IS NOT NULL"
184
+ model.connection.execute(sql).map { |r| r[key.to_s] }
185
+ rescue ActiveRecord::StatementInvalid
186
+ []
187
+ end
188
+
189
+ # Asegura que Rails haya cargado los modelos de infraestructura.
190
+ # Vital en modo Development donde la carga es perezosa (Lazy Loading).
191
+ def ensure_models_loaded!
192
+ return unless defined?(Rails)
193
+
194
+ partition_dir = Rails.root.join("app/models/partition")
195
+ return unless Dir.exist?(partition_dir)
196
+
197
+ # Forzamos la carga de todos los archivos en app/models/partition/
198
+ Dir[partition_dir.join("**/*.rb")].each { |file| require_dependency file }
199
+ end
200
+
201
+ # Helpers de Logging
202
+ def log_info(tag, msg) = logger&.info(format_log(tag, msg))
203
+ def log_warn(tag, msg) = logger&.warn(format_log(tag, msg))
204
+ def log_error(tag, msg) = logger&.error(format_log(tag, msg))
205
+
206
+ def format_log(tag, msg)
207
+ "[ActivePartition] [#{tag}] #{msg}"
208
+ end
209
+
210
+ def logger
211
+ defined?(Rails) ? Rails.logger : Logger.new($stdout)
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,4 @@
1
+ module ActivePartition
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tenant_partition
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - gabriel
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-01-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activemodel
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ description: Framework de infraestructura para Rails 7.1+ que automatiza el particionamiento
42
+ nativo (List Partitioning). Incluye soporte para Composite Primary Keys, orquestación
43
+ de tenants y migraciones zero-downtime.
44
+ email:
45
+ - gedera@wispro.co
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - lib/activepartition.rb
55
+ - lib/activepartition/base.rb
56
+ - lib/activepartition/concerns/controller.rb
57
+ - lib/activepartition/concerns/partitioned.rb
58
+ - lib/activepartition/configuration.rb
59
+ - lib/activepartition/railtie.rb
60
+ - lib/activepartition/safety_guard.rb
61
+ - lib/activepartition/schema/statements.rb
62
+ - lib/activepartition/tasks/maintenance.rake
63
+ - lib/activepartition/version.rb
64
+ - sig/activepartition.rbs
65
+ homepage: https://github.com/gedera/activepartition
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ source_code_uri: https://github.com/gedera/tenant_partition
70
+ changelog_uri: https://github.com/gedera/tenant_partition/blob/main/CHANGELOG.md
71
+ post_install_message:
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.2.0
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 3.4.19
87
+ signing_key:
88
+ specification_version: 4
89
+ summary: Gestión de particiones PostgreSQL al estilo Rails.
90
+ test_files: []