tenant_partition 0.2.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +136 -108
- data/lib/generators/tenant_partition/cutover_generator.rb +85 -0
- data/lib/generators/tenant_partition/prepare_generator.rb +78 -0
- data/lib/generators/tenant_partition/templates/CUTOVER_README +22 -0
- data/lib/generators/tenant_partition/templates/PREPARE_README +12 -0
- data/lib/generators/tenant_partition/templates/complete_online_migration.rb.erb +21 -0
- data/lib/generators/tenant_partition/templates/prepare_online_migration.rb.erb +22 -0
- data/lib/tenant_partition/concerns/partitioned.rb +93 -13
- data/lib/tenant_partition/migrator.rb +185 -0
- data/lib/tenant_partition/railtie.rb +4 -2
- data/lib/tenant_partition/safety_guard.rb +0 -9
- data/lib/tenant_partition/schema/statements.rb +134 -7
- data/lib/tenant_partition/tasks/migration.rake +20 -0
- data/lib/tenant_partition/version.rb +1 -1
- data/lib/tenant_partition.rb +19 -5
- metadata +15 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 968c88d57e55597859aceca92e0404d0430d8277f8dd84ebb20d25d0fb1b011f
|
|
4
|
+
data.tar.gz: 46fc3262411b92a3083373710b6cffac249201910ff8344a619610ed88b0cfd3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1aa3eb1a9a6ae2596b69458aebf7b8b77c985aa7087cac89ef679adefd59b0636835fba9e8ea9ee544f0d4065a18be6887d00a281236ad8bc50c82b03d58c9a1
|
|
7
|
+
data.tar.gz: e335c13f9df1178a53296599f99593e5fe3489e83403229a46f1e53fa41b572558ea1ca66947602c2e392dcb86188630a2c95d16473a861a1f0e26e5f4ee047a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-03-09
|
|
4
|
+
### Added (Zero-Downtime Migrations)
|
|
5
|
+
- **Migraciones Online:** Nueva suite de herramientas para migrar tablas masivas en producción sin tiempo de inactividad (Zero-Downtime).
|
|
6
|
+
- **Schema Statements:** Se agregaron los helpers `create_partition_sync_trigger`, `remove_partition_sync_trigger` y `swap_partitioned_tables` para manejar el Live Sync mediante Triggers de base de datos y Cutover atómico.
|
|
7
|
+
- **Backfill Engine (`TenantPartition::Migrator`):** Nueva clase para mover millones de registros en segundo plano. Incluye resolución automática de conflictos (`UPSERT`) para convivir con la replicación en vivo, y soporte nativo de paginación por fechas (`created_at`) para tablas que usan UUIDs.
|
|
8
|
+
- **Generador de Rails:** Nuevo comando `rails g tenant_partition:online_migration` que genera automáticamente las migraciones de 5 fases necesarias para migrar una tabla de forma segura.
|
|
9
|
+
- **Rake Tasks:** Nueva tarea `rake tenant_partition:backfill_data` para ejecutar el copiado de datos fácilmente desde la consola o background jobs.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- **Railtie:** Se actualizó la carga de tareas Rake para incluir dinámicamente todos los archivos `.rake` del directorio `tasks/`, permitiendo que coexistan las tareas de mantenimiento y migración.
|
|
13
|
+
|
|
3
14
|
## [0.2.1] - 2026-02-13
|
|
4
15
|
### Added
|
|
5
16
|
- **Mejora en Migraciones:** El helper `create_partitioned_table` ahora acepta la opción `id_type: :uuid` o `id_type: :bigint` (por defecto). Esto permite usar particionamiento con IDs enteros seriales estándar o UUIDs según la necesidad del proyecto.
|
data/README.md
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
# TenantPartition
|
|
1
|
+
# TenantPartition 🏢
|
|
2
2
|
|
|
3
|
-
**TenantPartition** es
|
|
3
|
+
**TenantPartition** es un framework de infraestructura "Rails-native" diseñado para implementar **Particionamiento Declarativo (List Partitioning)** de PostgreSQL en aplicaciones Ruby on Rails (7.1+).
|
|
4
4
|
|
|
5
|
-
A diferencia de otras soluciones que dependen de esquemas (schemas) o hackeos a
|
|
5
|
+
A diferencia de otras soluciones multi-tenant que dependen de múltiples esquemas (schemas) o hackeos a nivel de consultas (row-level filtering), `tenant_partition` utiliza características nativas del motor de PostgreSQL para dividir físicamente tablas gigantes en tablas más pequeñas y ultrarrápidas por tenant (Cliente, ISP, Organización, etc.). Todo esto, manteniendo intacta la experiencia de desarrollo estándar de ActiveRecord.
|
|
6
6
|
|
|
7
7
|
## 🚀 Características Principales
|
|
8
8
|
|
|
9
|
-
* **API Simple
|
|
10
|
-
* **
|
|
11
|
-
* **
|
|
12
|
-
* **
|
|
13
|
-
* **
|
|
14
|
-
* **
|
|
15
|
-
* **
|
|
9
|
+
* **API Simple y Opt-in:** Activa el particionamiento en tus modelos simplemente agregando la macro `partition_table`.
|
|
10
|
+
* **Zero-Downtime Migrations (2 Fases):** Herramientas de nivel empresarial para migrar tablas masivas en producción sin detener el servicio, con barreras anti-errores para despliegues en CI/CD.
|
|
11
|
+
* **Introspección Dinámica (Safe Deploys):** El código de tus modelos detecta automáticamente si la tabla en la base de datos ya fue particionada, permitiendo desplegar tu código *antes* de finalizar las migraciones de infraestructura.
|
|
12
|
+
* **Smart Hashing para UUIDs:** Evasión automática del límite de 63 caracteres de PostgreSQL en el nombrado de tablas al usar UUIDs largos como claves de partición.
|
|
13
|
+
* **Sincronización en Tiempo Real (Live Sync):** Triggers de base de datos automatizados con resolución de conflictos (`UPSERT`) integrada.
|
|
14
|
+
* **Backfill Engine con Auto-Aprovisionamiento JIT:** Motor de copiado en segundo plano que crea automáticamente las particiones hijas al vuelo (Just-In-Time) a medida que descubre nuevos tenants.
|
|
15
|
+
* **Soporte Nativo CPK:** Totalmente compatible con **Composite Primary Keys** de Rails 7.1+.
|
|
16
|
+
* **Gestión de Datos Huérfanos:** Auditoría y auto-reparación de registros que caen en la partición `_default`.
|
|
16
17
|
|
|
17
18
|
---
|
|
18
19
|
|
|
19
20
|
## 📦 Instalación
|
|
20
21
|
|
|
21
|
-
Agrega
|
|
22
|
+
Agrega la gema a tu `Gemfile`:
|
|
22
23
|
|
|
23
24
|
```ruby
|
|
24
25
|
gem 'tenant_partition'
|
|
@@ -30,191 +31,218 @@ Y ejecuta:
|
|
|
30
31
|
bundle install
|
|
31
32
|
```
|
|
32
33
|
|
|
34
|
+
**Requisitos mínimos:** Ruby 3.2+, Rails 7.1+ y PostgreSQL 13+ (Optimizado para PG 17). *(Nota: La gema incluye "magia" retrocompatible para operar en Rails 6.0+ bajo su propio riesgo).*
|
|
35
|
+
|
|
33
36
|
---
|
|
34
37
|
|
|
35
38
|
## ⚙️ Configuración Inicial
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
Crea un archivo para definir tu clave de partición
|
|
40
|
+
**1. Configuración Global**
|
|
41
|
+
Crea un archivo de inicialización para definir tu clave de partición base (por ejemplo, `:isp_id`, `:account_id` o `:tenant_id`).
|
|
39
42
|
|
|
40
43
|
```ruby
|
|
41
44
|
# config/initializers/tenant_partition.rb
|
|
42
|
-
|
|
43
45
|
TenantPartition.configure do |config|
|
|
44
|
-
#
|
|
46
|
+
# Columna que actuará como discriminador principal en toda la base de datos
|
|
45
47
|
config.partition_key = :isp_id
|
|
46
48
|
end
|
|
47
49
|
```
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
**2. Habilitar el DSL en ActiveRecord**
|
|
52
|
+
Inyecta el comportamiento base en tu aplicación. Esto **no** particiona tus modelos por defecto, solo les da la habilidad de entender la macro de la gema.
|
|
51
53
|
|
|
52
54
|
```ruby
|
|
53
55
|
# app/models/application_record.rb
|
|
54
56
|
class ApplicationRecord < ActiveRecord::Base
|
|
55
57
|
primary_abstract_class
|
|
56
58
|
|
|
57
|
-
# Habilita la herramienta (modo inactivo por defecto)
|
|
58
59
|
include TenantPartition::Concerns::Partitioned
|
|
59
60
|
end
|
|
60
61
|
```
|
|
61
62
|
|
|
62
63
|
---
|
|
63
64
|
|
|
64
|
-
## 🛠 Guía
|
|
65
|
+
## 🛠 Guía 1: Tablas Nuevas (Green Field)
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
Si estás desarrollando un feature desde cero, particionar una tabla nueva es sumamente directo.
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
2. Configura la Primary Key Compuesta `[:id, :partition_key]`.
|
|
71
|
-
3. Crea automáticamente la partición `_default` para capturar datos no asignados.
|
|
72
|
-
|
|
73
|
-
#### Opción A: Usando Enteros (BigInt) - Recomendado
|
|
69
|
+
### 1. La Migración
|
|
70
|
+
Usa el helper `create_partitioned_table`. Este configura automáticamente la Primary Key Compuesta, la partición tipo `LIST` y la tabla `_default`.
|
|
74
71
|
|
|
75
72
|
```ruby
|
|
76
73
|
class CreateConversations < ActiveRecord::Migration[7.1]
|
|
77
74
|
def change
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
create_partitioned_table :conversations do |t|
|
|
75
|
+
# Soporta id_type: :bigint (por defecto) o :uuid
|
|
76
|
+
create_partitioned_table :conversations, id_type: :uuid do |t|
|
|
81
77
|
t.string :subject
|
|
82
78
|
t.text :body
|
|
83
79
|
t.timestamps
|
|
84
|
-
|
|
85
|
-
# Nota:
|
|
80
|
+
|
|
81
|
+
# 🪄 Nota: NO definas explícitamente el :id ni el :isp_id aquí.
|
|
82
|
+
# La gema lo hace por ti automáticamente.
|
|
86
83
|
end
|
|
87
84
|
end
|
|
88
85
|
end
|
|
89
86
|
```
|
|
90
87
|
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
### 2. El Modelo
|
|
93
89
|
```ruby
|
|
94
|
-
class
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
create_partitioned_table :conversations, id_type: :uuid do |t|
|
|
99
|
-
t.string :subject
|
|
100
|
-
t.timestamps
|
|
101
|
-
end
|
|
102
|
-
end
|
|
90
|
+
class Conversation < ApplicationRecord
|
|
91
|
+
# Activa la Composite Primary Key y los scopes de enrutamiento
|
|
92
|
+
partition_table
|
|
103
93
|
end
|
|
104
94
|
```
|
|
105
95
|
|
|
106
|
-
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 🔥 Guía 2: Migrar una Tabla Existente (Zero-Downtime)
|
|
99
|
+
|
|
100
|
+
Si tienes una tabla legacy con millones de registros y necesitas particionarla en producción sin causar tiempo de inactividad, utiliza nuestra suite de migración en dos fases.
|
|
101
|
+
|
|
102
|
+
*(⚠️ **Requisito:** La tabla original ya debe tener la columna de tu `partition_key` definida).*
|
|
103
|
+
|
|
104
|
+
### Fase 1: Preparación, Código y Live Sync (Despliegue 1)
|
|
105
|
+
Genera la infraestructura inicial ejecutando:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
rails g tenant_partition:prepare versions isp_id
|
|
109
|
+
```
|
|
107
110
|
|
|
108
|
-
|
|
111
|
+
Abre la migración generada en `db/migrate/`. Gracias a la **introspección**, la gema clonará la estructura de tu tabla original y configurará los triggers de PostgreSQL (`INSERT/UPDATE/DELETE`) en un solo paso:
|
|
109
112
|
|
|
110
113
|
```ruby
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
# y los scopes necesarios.
|
|
116
|
-
partition_table
|
|
114
|
+
def up
|
|
115
|
+
create_partitioned_table_from(
|
|
116
|
+
:versions_partitioned, :versions, partition_key: :isp_id, sync_triggers: true
|
|
117
|
+
)
|
|
117
118
|
end
|
|
118
119
|
```
|
|
119
120
|
|
|
120
|
-
|
|
121
|
+
**Activa la gema en tu modelo:** Agrega la macro a tu clase ActiveRecord.
|
|
121
122
|
```ruby
|
|
122
|
-
class
|
|
123
|
-
|
|
124
|
-
partition_table key: :year
|
|
123
|
+
class Version < ApplicationRecord
|
|
124
|
+
partition_table
|
|
125
125
|
end
|
|
126
126
|
```
|
|
127
|
+
*(🪄 **Safe Deploy:** Gracias a la Introspección Dinámica, la gema sabe que la base de datos aún no ha finalizado la migración. El modelo seguirá comportándose como una tabla normal sin romper tu aplicación).*
|
|
127
128
|
|
|
128
|
-
|
|
129
|
+
**👉 Ejecuta `rails db:migrate` y despliega a producción.** A partir de este milisegundo, la base de datos enviará todo dato "vivo" nuevo a tu nueva tabla sombra, y tu código estará listo para el futuro.
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
### Fase 2: Backfill Histórico (Fase Manual)
|
|
132
|
+
Con la app corriendo, copia el historial pesado ejecutando esta tarea (idealmente en un entorno de background job o consola de ops):
|
|
131
133
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
Para tablas con **IDs Enteros**:
|
|
135
|
+
```bash
|
|
136
|
+
rake tenant_partition:backfill_data[PaperTrail::Version,versions_partitioned,id]
|
|
137
|
+
```
|
|
138
|
+
Para tablas con **UUIDs** (Paginación segura por fecha):
|
|
139
|
+
```bash
|
|
140
|
+
rake tenant_partition:backfill_data[PaperTrail::Version,versions_partitioned,created_at]
|
|
141
|
+
```
|
|
142
|
+
*(🪄 **Auto-Aprovisionamiento JIT:** El `Migrator` creará las particiones físicas al vuelo utilizando "Smart Hashing" para evitar los límites de 63 caracteres de PostgreSQL. Además, resolverá conflictos usando `ON CONFLICT DO UPDATE` para no pisar los datos vivos).*
|
|
136
143
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# => true
|
|
144
|
+
### Fase 3: Cutover Atómico (Despliegue 2)
|
|
145
|
+
Una vez que el Backfill termine al 100%, genera la migración de intercambio final:
|
|
140
146
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
# => Elimina "conversations_isp_100"
|
|
147
|
+
```bash
|
|
148
|
+
rails g tenant_partition:cutover versions isp_id
|
|
144
149
|
```
|
|
145
150
|
|
|
146
|
-
|
|
147
|
-
|
|
151
|
+
**👉 Ejecuta `rails db:migrate` y despliega a producción.** Una transacción atómica cruzará los nombres de las tablas y eliminará los triggers en 1 milisegundo.
|
|
152
|
+
En el instante en que los servidores se reinicien tras el deploy, tu modelo (`Version`) consultará a PostgreSQL, detectará que la tabla ahora sí es particionada, y activará automáticamente sus superpoderes (Composite Primary Keys y Partition Pruning). **¡Cero downtime logrado!**
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 🔍 Consultas y Rendimiento (Partition Pruning)
|
|
157
|
+
|
|
158
|
+
Para que PostgreSQL sea extremadamente rápido, debe aprovechar el **Partition Pruning** (poda de particiones), yendo directamente a la tabla hija en lugar de escanear toda la base de datos.
|
|
159
|
+
|
|
160
|
+
La gema inyecta automáticamente el scope `for_partition(valor)`.
|
|
148
161
|
|
|
162
|
+
```ruby
|
|
163
|
+
# ❌ LENTO: Escaneará todas las particiones hijas
|
|
164
|
+
Conversation.where(status: 'active')
|
|
165
|
+
|
|
166
|
+
# ✅ ULTRARRÁPIDO: Va directamente a 'conversations_isp_123'
|
|
167
|
+
Conversation.for_partition(123).where(status: 'active')
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 🏗 Orquestación del Ciclo de Vida de los Tenants
|
|
173
|
+
|
|
174
|
+
Debes crear la infraestructura física (la tabla hija) para cada Tenant a medida que nacen nuevos clientes en tu sistema.
|
|
175
|
+
|
|
176
|
+
**Integración directa en tus Modelos:**
|
|
149
177
|
```ruby
|
|
150
178
|
# app/models/isp.rb
|
|
151
179
|
class Isp < ApplicationRecord
|
|
152
180
|
after_create :provision_infrastructure
|
|
181
|
+
after_destroy :destroy_infrastructure
|
|
182
|
+
|
|
183
|
+
private
|
|
153
184
|
|
|
154
185
|
def provision_infrastructure
|
|
155
|
-
#
|
|
186
|
+
# Crea las particiones en TODOS los modelos que tengan `partition_table`
|
|
156
187
|
TenantPartition.create!(self.id)
|
|
157
188
|
end
|
|
189
|
+
|
|
190
|
+
def destroy_infrastructure
|
|
191
|
+
TenantPartition.destroy!(self.id)
|
|
192
|
+
end
|
|
158
193
|
end
|
|
159
194
|
```
|
|
160
195
|
|
|
161
|
-
|
|
196
|
+
**Generador de API:**
|
|
197
|
+
Si prefieres orquestar esto desde un microservicio externo, puedes generar un controlador pre-armado:
|
|
198
|
+
```bash
|
|
199
|
+
rails g tenant_partition:api_controller System
|
|
200
|
+
# Creará: app/controllers/system/tenant_partitions_controller.rb
|
|
201
|
+
```
|
|
162
202
|
|
|
163
|
-
|
|
203
|
+
---
|
|
164
204
|
|
|
165
|
-
|
|
205
|
+
## 🧹 Mantenimiento: Datos Huérfanos
|
|
166
206
|
|
|
167
|
-
|
|
168
|
-
Verifica si tienes datos "mal ubicados" en las tablas default:
|
|
207
|
+
Si un registro es insertado antes de que su tenant sea aprovisionado, PostgreSQL lo enviará de forma segura a la tabla `_default`. Puedes auditar y corregir esto con tareas Rake:
|
|
169
208
|
|
|
209
|
+
**1. Auditoría (Encontrar datos perdidos):**
|
|
170
210
|
```bash
|
|
171
|
-
|
|
211
|
+
rake tenant_partition:audit
|
|
172
212
|
# [TenantPartition] [AUDIT] Iniciando auditoría...
|
|
173
213
|
# [TenantPartition] [ALERTA] Conversation: 450 registros huérfanos encontrados.
|
|
174
214
|
```
|
|
175
215
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
```bash
|
|
180
|
-
bundle exec rake tenant_partition:cleanup
|
|
181
|
-
# [TenantPartition] [FIX] Conversation: Procesando 2 tenants con datos huérfanos.
|
|
182
|
-
# [TenantPartition] [MOVE] -> ID 101: 450 registros recuperados.
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
---
|
|
186
|
-
|
|
187
|
-
## 🛡️ Producción y Seguridad
|
|
188
|
-
|
|
189
|
-
La gema incluye un `SafetyGuard` que impide ejecutar comandos destructivos (`drop_partition`, `destroy!`) en entorno de producción para evitar catástrofes.
|
|
190
|
-
|
|
191
|
-
Si realmente necesitas borrar un tenant en producción, debes autorizarlo explícitamente:
|
|
192
|
-
|
|
216
|
+
**2. Limpieza (Mover a su lugar correcto):**
|
|
217
|
+
*(Asegúrate de haber aprovisionado el tenant primero)*
|
|
193
218
|
```bash
|
|
194
|
-
|
|
219
|
+
rake tenant_partition:cleanup
|
|
220
|
+
# [TenantPartition] [MOVE] -> ID 101: 450 registros recuperados hacia su partición.
|
|
195
221
|
```
|
|
196
222
|
|
|
197
223
|
---
|
|
198
224
|
|
|
199
|
-
## 📖 Referencia de API
|
|
225
|
+
## 📖 Referencia Rápida de la API
|
|
200
226
|
|
|
201
|
-
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* `exists?(id)`: Devuelve `true` si existe infraestructura para ese ID.
|
|
227
|
+
**Módulo Global `TenantPartition`**
|
|
228
|
+
* `.create!(id)`: Aprovisionamiento de tablas para un tenant en toda la app.
|
|
229
|
+
* `.destroy!(id)`: Eliminación de tablas de un tenant (protegido en Prod).
|
|
230
|
+
* `.exists?(id)`: Verifica infraestructura.
|
|
206
231
|
|
|
207
|
-
|
|
208
|
-
* `partition_table(key: nil)`:
|
|
232
|
+
**Macros de Modelo**
|
|
233
|
+
* `partition_table(key: nil)`: Activa particionamiento (opcional: sobreescribe clave).
|
|
234
|
+
* `for_partition(value)`: Scope de búsqueda optimizada (con auto-casteo de tipos).
|
|
235
|
+
* `create_partition(value)`, `drop_partition(value)`: Operaciones DDL manuales por modelo.
|
|
236
|
+
* `partitions`: Devuelve nombres de tablas físicas hijas (incluyendo `_default`).
|
|
237
|
+
* `partition_values`: Devuelve los IDs/valores de tenants que ya tienen partición.
|
|
209
238
|
|
|
210
|
-
|
|
211
|
-
* `
|
|
212
|
-
* `
|
|
213
|
-
* `
|
|
214
|
-
* `partition_table_name(value)`: Devuelve el nombre real de la tabla en Postgres.
|
|
239
|
+
**Helpers de Migraciones**
|
|
240
|
+
* `create_partitioned_table(table_name, **options)`
|
|
241
|
+
* `create_partitioned_table_from(target, source, sync_triggers: false, **options)`
|
|
242
|
+
* `swap_partitioned_tables(legacy, partitioned)`
|
|
215
243
|
|
|
216
244
|
---
|
|
217
245
|
|
|
218
|
-
##
|
|
246
|
+
## Licencia
|
|
219
247
|
|
|
220
|
-
|
|
248
|
+
Esta gema está disponible como código abierto bajo los términos de la [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module TenantPartition
|
|
7
|
+
module Generators
|
|
8
|
+
# Generador de la Fase 2 (Cutover) para migraciones Zero-Downtime.
|
|
9
|
+
#
|
|
10
|
+
# Este generador crea la migración final necesaria para completar el proceso de
|
|
11
|
+
# particionamiento en vivo. La migración generada se encarga de realizar un
|
|
12
|
+
# intercambio atómico (Swap) a nivel de PostgreSQL: renombra la tabla legacy a
|
|
13
|
+
# un nombre de respaldo, y activa la tabla particionada (sombra) con el nombre original,
|
|
14
|
+
# eliminando al mismo tiempo los triggers de sincronización temporal.
|
|
15
|
+
#
|
|
16
|
+
# @example Generar migración de cutover para la tabla 'versions'
|
|
17
|
+
# rails g tenant_partition:cutover versions isp_id
|
|
18
|
+
class CutoverGenerator < Rails::Generators::Base
|
|
19
|
+
include Rails::Generators::Migration
|
|
20
|
+
|
|
21
|
+
source_root File.expand_path("templates", __dir__)
|
|
22
|
+
|
|
23
|
+
# @!attribute [r] table_name
|
|
24
|
+
# @return [String] El nombre de la tabla legacy original.
|
|
25
|
+
argument :table_name, type: :string, banner: "nombre_de_la_tabla_actual"
|
|
26
|
+
|
|
27
|
+
# @!attribute [r] partition_key
|
|
28
|
+
# @return [String] El nombre de la columna utilizada como clave de partición.
|
|
29
|
+
argument :partition_key, type: :string, banner: "columna_partition_key"
|
|
30
|
+
|
|
31
|
+
desc "Genera la Fase 2 (Cutover): Intercambia las tablas de forma atómica y elimina triggers."
|
|
32
|
+
|
|
33
|
+
# Método requerido por Rails::Generators::Migration para la nomenclatura de archivos.
|
|
34
|
+
# Determina el siguiente número (timestamp) para el archivo de migración.
|
|
35
|
+
#
|
|
36
|
+
# @param dirname [String] El directorio donde se guardarán las migraciones.
|
|
37
|
+
# @return [String] El prefijo numérico para el archivo.
|
|
38
|
+
def self.next_migration_number(dirname)
|
|
39
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Crea el archivo de migración de cutover en la carpeta db/migrate.
|
|
43
|
+
# Utiliza la plantilla complete_online_migration.rb.erb.
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
def create_cutover_migration
|
|
47
|
+
migration_template(
|
|
48
|
+
"complete_online_migration.rb.erb",
|
|
49
|
+
"db/migrate/complete_online_migration_for_#{table_name}.rb",
|
|
50
|
+
migration_version: migration_version
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Muestra en consola las instrucciones críticas y advertencias de seguridad
|
|
55
|
+
# una vez que el generador termina de ejecutarse exitosamente.
|
|
56
|
+
#
|
|
57
|
+
# @return [void]
|
|
58
|
+
# Muestra en consola las instrucciones críticas y advertencias de seguridad
|
|
59
|
+
# forzando la interpolación ERB para una mejor experiencia de usuario.
|
|
60
|
+
def show_readme
|
|
61
|
+
if behavior == :invoke
|
|
62
|
+
template_path = File.join(self.class.source_root, "CUTOVER_README")
|
|
63
|
+
parsed_readme = ERB.new(File.read(template_path)).result(binding)
|
|
64
|
+
puts "\n#{parsed_readme}\n"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Obtiene la versión actual de ActiveRecord para inyectarla en la sintaxis de la migración.
|
|
71
|
+
#
|
|
72
|
+
# @return [String] Ejemplo: "[7.1]"
|
|
73
|
+
def migration_version
|
|
74
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Construye el nombre de la tabla destino (sombra) que ahora pasará a ser la principal.
|
|
78
|
+
#
|
|
79
|
+
# @return [String] Ejemplo: "versions_partitioned"
|
|
80
|
+
def target_table
|
|
81
|
+
"#{table_name}_partitioned"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module TenantPartition
|
|
7
|
+
module Generators
|
|
8
|
+
# Generador de la Fase 1 (Preparación) para migraciones Zero-Downtime.
|
|
9
|
+
#
|
|
10
|
+
# Este generador crea la infraestructura inicial necesaria para comenzar a migrar
|
|
11
|
+
# una tabla legacy hacia una tabla particionada sin tiempo de inactividad.
|
|
12
|
+
# Específicamente, genera una migración que clona la estructura de la tabla
|
|
13
|
+
# original y establece los triggers de PostgreSQL para la sincronización en vivo (Live Sync).
|
|
14
|
+
#
|
|
15
|
+
# @example Generar migración de preparación para la tabla 'versions'
|
|
16
|
+
# rails g tenant_partition:prepare versions isp_id
|
|
17
|
+
class PrepareGenerator < Rails::Generators::Base
|
|
18
|
+
include Rails::Generators::Migration
|
|
19
|
+
|
|
20
|
+
source_root File.expand_path("templates", __dir__)
|
|
21
|
+
|
|
22
|
+
# @!attribute [r] table_name
|
|
23
|
+
# @return [String] El nombre de la tabla legacy que se desea migrar.
|
|
24
|
+
argument :table_name, type: :string, banner: "nombre_de_la_tabla_actual"
|
|
25
|
+
|
|
26
|
+
# @!attribute [r] partition_key
|
|
27
|
+
# @return [String] El nombre de la columna que actuará como clave de partición.
|
|
28
|
+
argument :partition_key, type: :string, banner: "columna_partition_key"
|
|
29
|
+
|
|
30
|
+
desc "Genera la Fase 1 (Preparación): Crea la tabla sombra y los triggers de Live Sync."
|
|
31
|
+
|
|
32
|
+
# Método requerido por Rails::Generators::Migration para la nomenclatura de archivos.
|
|
33
|
+
# Determina el siguiente número (timestamp) para el archivo de migración.
|
|
34
|
+
#
|
|
35
|
+
# @param dirname [String] El directorio donde se guardarán las migraciones.
|
|
36
|
+
# @return [String] El prefijo numérico para el archivo.
|
|
37
|
+
def self.next_migration_number(dirname)
|
|
38
|
+
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Crea el archivo de migración de preparación en la carpeta db/migrate.
|
|
42
|
+
# Utiliza la plantilla prepare_online_migration.rb.erb.
|
|
43
|
+
#
|
|
44
|
+
# @return [void]
|
|
45
|
+
def create_preparation_migration
|
|
46
|
+
migration_template(
|
|
47
|
+
"prepare_online_migration.rb.erb",
|
|
48
|
+
"db/migrate/prepare_online_migration_for_#{table_name}.rb",
|
|
49
|
+
migration_version: migration_version
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Muestra en consola las instrucciones de los siguientes pasos
|
|
54
|
+
# una vez que el generador termina de ejecutarse exitosamente.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def show_readme
|
|
58
|
+
readme "PREPARE_README" if behavior == :invoke
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Obtiene la versión actual de ActiveRecord para inyectarla en la sintaxis de la migración.
|
|
64
|
+
#
|
|
65
|
+
# @return [String] Ejemplo: "[7.1]"
|
|
66
|
+
def migration_version
|
|
67
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Construye el nombre de la tabla destino (sombra) que recibirá los datos.
|
|
71
|
+
#
|
|
72
|
+
# @return [String] Ejemplo: "versions_partitioned"
|
|
73
|
+
def target_table
|
|
74
|
+
"#{table_name}_partitioned"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
===============================================================================
|
|
2
|
+
🚀 ¡Fase 2: Cutover Generado Exitosamente!
|
|
3
|
+
|
|
4
|
+
Has generado la migración final de intercambio atómico.
|
|
5
|
+
|
|
6
|
+
⚠️ ¡ALTO! ANTES DE DESPLEGAR A PRODUCCIÓN ⚠️
|
|
7
|
+
Asegúrate de que el proceso de Backfill masivo haya terminado al 100%.
|
|
8
|
+
Puedes verificarlo reemplazando "Model" por tu clase real (ej. PaperTrail::Version)
|
|
9
|
+
y ejecutando esto en tu consola de Rails (`rails c`):
|
|
10
|
+
|
|
11
|
+
Model.count == Model.from('<%= table_name %>_partitioned').count
|
|
12
|
+
|
|
13
|
+
Si te devuelve `true`, los números coinciden:
|
|
14
|
+
1. Sube este código y ejecuta la migración en Producción (Despliegue 2).
|
|
15
|
+
Esto hará el intercambio atómico de Postgres en 1 milisegundo.
|
|
16
|
+
|
|
17
|
+
2. ¡Listo! En el próximo reinicio de los servidores, tu modelo
|
|
18
|
+
detectará el cambio en la base de datos y activará automáticamente
|
|
19
|
+
sus superpoderes gracias a la Introspección Dinámica.
|
|
20
|
+
|
|
21
|
+
¡Felicidades por lograr una migración Zero-Downtime perfecta!
|
|
22
|
+
===============================================================================
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
===============================================================================
|
|
2
|
+
🪄 ¡Fase 1: Preparación Online Generada!
|
|
3
|
+
|
|
4
|
+
1. Revisa el archivo de migración generado en db/migrate/.
|
|
5
|
+
2. Sube este código a tu repositorio y despliega en Producción (Despliegue 1).
|
|
6
|
+
3. Con la nueva tabla sombra ya creada en BD, ejecuta el Backfill histórico:
|
|
7
|
+
`rake tenant_partition:backfill_data[MiModelo,mi_tabla_partitioned]`
|
|
8
|
+
|
|
9
|
+
Una vez que el copiado llegue al 100%, podrás generar el intercambio final
|
|
10
|
+
con el comando de Fase 2:
|
|
11
|
+
`rails g tenant_partition:cutover nombre_tabla partition_key`
|
|
12
|
+
===============================================================================
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class CompleteOnlineMigrationFor<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
# 3. Intercambio Atómico (Cutover)
|
|
4
|
+
swap_partitioned_tables(:<%= table_name %>, :<%= target_table %>)
|
|
5
|
+
|
|
6
|
+
# La tabla original quedó renombrada como '<%= table_name %>_legacy' como backup.
|
|
7
|
+
# Cuando estés seguro de que todo funciona, puedes crear otra migración para borrarla.
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def down
|
|
11
|
+
# Revierte el swap de nombres
|
|
12
|
+
swap_partitioned_tables(:<%= target_table %>, :<%= table_name %>)
|
|
13
|
+
|
|
14
|
+
# Restaura los triggers por si hay que volver atrás
|
|
15
|
+
create_partition_sync_trigger(
|
|
16
|
+
:<%= table_name %>,
|
|
17
|
+
:<%= target_table %>,
|
|
18
|
+
:<%= partition_key %>
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class PrepareOnlineMigrationFor<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def up
|
|
3
|
+
# 🪄 Magia de TenantPartition:
|
|
4
|
+
# Esto lee la tabla actual '<%= table_name %>', crea '<%= target_table %>'
|
|
5
|
+
# con exactamente las mismas columnas y automáticamente instala los triggers de sincronización.
|
|
6
|
+
create_partitioned_table_from(
|
|
7
|
+
:<%= target_table %>,
|
|
8
|
+
:<%= table_name %>,
|
|
9
|
+
partition_key: :<%= partition_key %>,
|
|
10
|
+
sync_triggers: true
|
|
11
|
+
# id_type: :uuid # Descomenta esto si tu tabla original usa UUIDs
|
|
12
|
+
) do |t|
|
|
13
|
+
# Opcional: Agrega aquí índices para tu nueva tabla si los necesitas.
|
|
14
|
+
# Ejemplo: t.index [:mi_columna, :<%= partition_key %>]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def down
|
|
19
|
+
remove_partition_sync_trigger(:<%= table_name %>, :<%= target_table %>)
|
|
20
|
+
drop_partitioned_table(:<%= target_table %>)
|
|
21
|
+
end
|
|
22
|
+
end
|