edda-framework 0.10.0__py3-none-any.whl → 0.12.0__py3-none-any.whl

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.
@@ -0,0 +1,435 @@
1
+ """
2
+ Automatic migration support for dbmate-style SQL files.
3
+
4
+ This module provides functions to automatically apply dbmate migration files
5
+ at application startup, eliminating the need to manually run `dbmate up`.
6
+
7
+ The migration system is compatible with dbmate:
8
+ - Uses the same `schema_migrations` table for tracking applied migrations
9
+ - Reads the same `-- migrate:up` / `-- migrate:down` SQL format
10
+ - Supports SQLite, PostgreSQL, and MySQL
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import re
18
+ from collections.abc import AsyncIterator
19
+ from contextlib import asynccontextmanager
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING
22
+
23
+ from sqlalchemy import text
24
+
25
+ if TYPE_CHECKING:
26
+ from sqlalchemy.ext.asyncio import AsyncEngine
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Advisory lock key for migration (consistent across workers)
31
+ MIGRATION_LOCK_KEY = 20251217000000 # Using migration timestamp as lock key
32
+
33
+
34
+ def detect_db_type(engine: AsyncEngine) -> str:
35
+ """
36
+ Detect database type from engine URL.
37
+
38
+ Args:
39
+ engine: SQLAlchemy async engine
40
+
41
+ Returns:
42
+ One of 'sqlite', 'postgresql', 'mysql'
43
+
44
+ Raises:
45
+ ValueError: If database type cannot be detected
46
+ """
47
+ url = str(engine.url)
48
+
49
+ if "sqlite" in url:
50
+ return "sqlite"
51
+ elif "postgresql" in url or "postgres" in url:
52
+ return "postgresql"
53
+ elif "mysql" in url:
54
+ return "mysql"
55
+ else:
56
+ raise ValueError(f"Cannot detect database type from URL: {url}")
57
+
58
+
59
+ def find_migrations_dir() -> Path | None:
60
+ """
61
+ Auto-detect migrations directory.
62
+
63
+ Searches in the following order:
64
+ 1. Package-bundled migrations (edda/migrations/)
65
+ 2. Development environment (schema/db/migrations/)
66
+
67
+ Returns:
68
+ Path to migrations directory or None if not found
69
+ """
70
+ # 1. Package-bundled migrations
71
+ pkg_dir = Path(__file__).parent.parent / "migrations"
72
+ if pkg_dir.exists():
73
+ return pkg_dir
74
+
75
+ # 2. Development environment (schema submodule)
76
+ schema_dir = Path.cwd() / "schema" / "db" / "migrations"
77
+ if schema_dir.exists():
78
+ return schema_dir
79
+
80
+ return None
81
+
82
+
83
+ async def ensure_schema_migrations_table(engine: AsyncEngine) -> None:
84
+ """
85
+ Create schema_migrations table if it doesn't exist.
86
+
87
+ This table is compatible with dbmate's migration tracking.
88
+
89
+ Args:
90
+ engine: SQLAlchemy async engine
91
+ """
92
+ db_type = detect_db_type(engine)
93
+
94
+ if db_type == "mysql":
95
+ # MySQL uses VARCHAR with explicit length
96
+ create_sql = """
97
+ CREATE TABLE IF NOT EXISTS schema_migrations (
98
+ version VARCHAR(255) PRIMARY KEY
99
+ )
100
+ """
101
+ else:
102
+ # SQLite and PostgreSQL
103
+ create_sql = """
104
+ CREATE TABLE IF NOT EXISTS schema_migrations (
105
+ version VARCHAR(255) PRIMARY KEY
106
+ )
107
+ """
108
+
109
+ try:
110
+ async with engine.begin() as conn:
111
+ await conn.execute(text(create_sql))
112
+ except Exception as e:
113
+ err_msg = str(e).lower()
114
+ # Handle concurrent table creation - another worker might have created it
115
+ if "already exists" in err_msg or "duplicate" in err_msg or "42p07" in err_msg:
116
+ return
117
+ raise
118
+
119
+
120
+ async def get_applied_migrations(engine: AsyncEngine) -> set[str]:
121
+ """
122
+ Get set of already applied migration versions.
123
+
124
+ Args:
125
+ engine: SQLAlchemy async engine
126
+
127
+ Returns:
128
+ Set of applied migration version strings (e.g., "20251217000000")
129
+ """
130
+ try:
131
+ async with engine.connect() as conn:
132
+ result = await conn.execute(text("SELECT version FROM schema_migrations"))
133
+ return {row[0] for row in result.fetchall()}
134
+ except Exception:
135
+ # Table might not exist yet
136
+ return set()
137
+
138
+
139
+ async def record_migration(engine: AsyncEngine, version: str) -> bool:
140
+ """
141
+ Record a migration as applied.
142
+
143
+ Args:
144
+ engine: SQLAlchemy async engine
145
+ version: Migration version string (e.g., "20251217000000")
146
+
147
+ Returns:
148
+ True if recorded successfully, False if already recorded (race condition)
149
+ """
150
+ try:
151
+ async with engine.begin() as conn:
152
+ await conn.execute(
153
+ text("INSERT INTO schema_migrations (version) VALUES (:version)"),
154
+ {"version": version},
155
+ )
156
+ return True
157
+ except Exception as e:
158
+ # Handle race condition: another worker already recorded this migration
159
+ err_msg = str(e).lower()
160
+ if "unique constraint" in err_msg or "duplicate" in err_msg:
161
+ return False
162
+ raise
163
+
164
+
165
+ @asynccontextmanager
166
+ async def migration_lock(engine: AsyncEngine, db_type: str) -> AsyncIterator[bool]:
167
+ """
168
+ Acquire an advisory lock for migrations.
169
+
170
+ Uses database-specific advisory locks to ensure only one worker
171
+ applies migrations at a time.
172
+
173
+ Args:
174
+ engine: SQLAlchemy async engine
175
+ db_type: Database type ('sqlite', 'postgresql', 'mysql')
176
+
177
+ Yields:
178
+ True if lock was acquired, False otherwise
179
+ """
180
+ if db_type == "sqlite":
181
+ # SQLite doesn't support advisory locks, but it's single-writer anyway
182
+ yield True
183
+ return
184
+
185
+ acquired = False
186
+ try:
187
+ async with engine.connect() as conn:
188
+ if db_type == "postgresql":
189
+ # Try to acquire advisory lock (non-blocking)
190
+ result = await conn.execute(
191
+ text(f"SELECT pg_try_advisory_lock({MIGRATION_LOCK_KEY})")
192
+ )
193
+ acquired = result.scalar() is True
194
+ elif db_type == "mysql":
195
+ # Try to acquire named lock (0 = no wait)
196
+ result = await conn.execute(text("SELECT GET_LOCK('edda_migration', 0)"))
197
+ acquired = result.scalar() == 1
198
+
199
+ if acquired:
200
+ yield True
201
+ else:
202
+ logger.debug("Could not acquire migration lock, another worker is migrating")
203
+ yield False
204
+
205
+ finally:
206
+ if acquired:
207
+ try:
208
+ async with engine.connect() as conn:
209
+ if db_type == "postgresql":
210
+ await conn.execute(text(f"SELECT pg_advisory_unlock({MIGRATION_LOCK_KEY})"))
211
+ await conn.commit()
212
+ elif db_type == "mysql":
213
+ await conn.execute(text("SELECT RELEASE_LOCK('edda_migration')"))
214
+ await conn.commit()
215
+ except Exception as e:
216
+ logger.warning(f"Failed to release migration lock: {e}")
217
+
218
+
219
+ def extract_version_from_filename(filename: str) -> str:
220
+ """
221
+ Extract version from migration filename.
222
+
223
+ Args:
224
+ filename: Migration filename (e.g., "20251217000000_initial_schema.sql")
225
+
226
+ Returns:
227
+ Version string (e.g., "20251217000000")
228
+ """
229
+ # dbmate uses format: YYYYMMDDHHMMSS_description.sql
230
+ match = re.match(r"^(\d+)_", filename)
231
+ if match:
232
+ return match.group(1)
233
+ return filename.replace(".sql", "")
234
+
235
+
236
+ def parse_migration_file(content: str) -> tuple[str, str]:
237
+ """
238
+ Parse dbmate migration file content.
239
+
240
+ Args:
241
+ content: Full content of migration SQL file
242
+
243
+ Returns:
244
+ Tuple of (up_sql, down_sql)
245
+ """
246
+ # Extract "-- migrate:up" section
247
+ up_match = re.search(
248
+ r"-- migrate:up\s*(.*?)(?:-- migrate:down|$)",
249
+ content,
250
+ re.DOTALL,
251
+ )
252
+ up_sql = up_match.group(1).strip() if up_match else ""
253
+
254
+ # Extract "-- migrate:down" section
255
+ down_match = re.search(
256
+ r"-- migrate:down\s*(.*?)$",
257
+ content,
258
+ re.DOTALL,
259
+ )
260
+ down_sql = down_match.group(1).strip() if down_match else ""
261
+
262
+ return up_sql, down_sql
263
+
264
+
265
+ async def execute_sql_statements(
266
+ engine: AsyncEngine,
267
+ sql: str,
268
+ db_type: str,
269
+ ) -> None:
270
+ """
271
+ Execute SQL statements from migration file.
272
+
273
+ Handles:
274
+ - Multiple statements separated by semicolons
275
+ - Comment lines
276
+ - MySQL DELIMITER blocks (simplified handling)
277
+
278
+ Args:
279
+ engine: SQLAlchemy async engine
280
+ sql: SQL string containing one or more statements
281
+ db_type: Database type ('sqlite', 'postgresql', 'mysql')
282
+ """
283
+ if not sql:
284
+ return
285
+
286
+ # For MySQL, handle DELIMITER and stored procedures specially
287
+ if db_type == "mysql" and "DELIMITER" in sql:
288
+ # Skip procedure-based migrations for now, execute simpler parts
289
+ sql = re.sub(
290
+ r"DROP PROCEDURE.*?;|DELIMITER.*?DELIMITER ;",
291
+ "",
292
+ sql,
293
+ flags=re.DOTALL,
294
+ )
295
+
296
+ # Split by semicolons and execute each statement
297
+ statements = [s.strip() for s in sql.split(";") if s.strip()]
298
+
299
+ async with engine.begin() as conn:
300
+ for stmt in statements:
301
+ if not stmt:
302
+ continue
303
+
304
+ # Strip leading comment lines to get actual SQL
305
+ lines = stmt.split("\n")
306
+ sql_lines = []
307
+ for line in lines:
308
+ stripped = line.strip()
309
+ # Skip empty lines and comment-only lines
310
+ if not stripped or stripped.startswith("--"):
311
+ continue
312
+ sql_lines.append(line)
313
+
314
+ actual_sql = "\n".join(sql_lines).strip()
315
+ if not actual_sql:
316
+ continue
317
+
318
+ try:
319
+ await conn.execute(text(actual_sql))
320
+ except Exception as e:
321
+ # Log but continue - some statements might fail if objects exist
322
+ err_msg = str(e).lower()
323
+ if "already exists" not in err_msg and "duplicate" not in err_msg:
324
+ raise
325
+
326
+
327
+ async def apply_dbmate_migrations(
328
+ engine: AsyncEngine,
329
+ migrations_dir: Path | None = None,
330
+ ) -> list[str]:
331
+ """
332
+ Apply dbmate migration files automatically.
333
+
334
+ This function:
335
+ 1. Detects database type from engine URL
336
+ 2. Finds migration files for that database type
337
+ 3. Checks which migrations have already been applied
338
+ 4. Applies pending migrations in order
339
+ 5. Records applied migrations in schema_migrations table
340
+
341
+ Args:
342
+ engine: SQLAlchemy async engine
343
+ migrations_dir: Base migrations directory (contains sqlite/, postgresql/, mysql/ subdirs).
344
+ If None, auto-detects from package or schema/ submodule.
345
+
346
+ Returns:
347
+ List of applied migration versions
348
+
349
+ Raises:
350
+ FileNotFoundError: If migrations directory not found
351
+ ValueError: If database type cannot be detected
352
+ """
353
+ # Find migrations directory
354
+ if migrations_dir is None:
355
+ migrations_dir = find_migrations_dir()
356
+
357
+ if migrations_dir is None:
358
+ logger.warning(
359
+ "No migrations directory found. "
360
+ "Skipping automatic migration. "
361
+ "Set migrations_dir parameter or use auto_migrate=False."
362
+ )
363
+ return []
364
+
365
+ # Detect database type
366
+ db_type = detect_db_type(engine)
367
+ db_migrations_dir = migrations_dir / db_type
368
+
369
+ if not db_migrations_dir.exists():
370
+ logger.warning(
371
+ f"Migrations directory for {db_type} not found: {db_migrations_dir}. "
372
+ "Skipping automatic migration."
373
+ )
374
+ return []
375
+
376
+ # Ensure schema_migrations table exists
377
+ await ensure_schema_migrations_table(engine)
378
+
379
+ # Use advisory lock to ensure only one worker applies migrations
380
+ async with migration_lock(engine, db_type) as acquired:
381
+ if not acquired:
382
+ # Another worker is applying migrations, wait and check applied
383
+ logger.info("Another worker is applying migrations, waiting...")
384
+ # Wait a bit and return - migrations will be applied by the other worker
385
+ await asyncio.sleep(1)
386
+ return []
387
+
388
+ # Get already applied migrations (inside lock to avoid race)
389
+ applied = await get_applied_migrations(engine)
390
+
391
+ # Get all migration files sorted by name (timestamp order)
392
+ migration_files = sorted(db_migrations_dir.glob("*.sql"))
393
+
394
+ applied_versions: list[str] = []
395
+
396
+ for migration_file in migration_files:
397
+ version = extract_version_from_filename(migration_file.name)
398
+
399
+ # Skip if already applied
400
+ if version in applied:
401
+ logger.debug(f"Migration {version} already applied, skipping")
402
+ continue
403
+
404
+ logger.info(f"Applying migration: {migration_file.name}")
405
+
406
+ # Read and parse migration file
407
+ content = migration_file.read_text()
408
+ up_sql, _ = parse_migration_file(content)
409
+
410
+ if not up_sql:
411
+ logger.warning(f"No '-- migrate:up' section found in {migration_file.name}")
412
+ continue
413
+
414
+ # Execute migration
415
+ try:
416
+ await execute_sql_statements(engine, up_sql, db_type)
417
+
418
+ # Record as applied (handles race condition with other workers)
419
+ recorded = await record_migration(engine, version)
420
+ if recorded:
421
+ applied_versions.append(version)
422
+ logger.info(f"Successfully applied migration: {version}")
423
+ else:
424
+ # Another worker already applied this migration
425
+ logger.debug(f"Migration {version} was applied by another worker")
426
+ except Exception as e:
427
+ logger.error(f"Failed to apply migration {version}: {e}")
428
+ raise
429
+
430
+ if applied_versions:
431
+ logger.info(f"Applied {len(applied_versions)} migration(s)")
432
+ else:
433
+ logger.debug("No pending migrations to apply")
434
+
435
+ return applied_versions
edda/storage/models.py CHANGED
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS workflow_instances (
29
29
  workflow_name TEXT NOT NULL,
30
30
  source_hash TEXT NOT NULL,
31
31
  owner_service TEXT NOT NULL,
32
+ framework TEXT NOT NULL DEFAULT 'python',
32
33
  status TEXT NOT NULL DEFAULT 'running',
33
34
  current_activity_id TEXT,
34
35
  continued_from TEXT,
@@ -52,6 +53,7 @@ WORKFLOW_INSTANCES_INDEXES = [
52
53
  "CREATE INDEX IF NOT EXISTS idx_instances_status ON workflow_instances(status);",
53
54
  "CREATE INDEX IF NOT EXISTS idx_instances_workflow ON workflow_instances(workflow_name);",
54
55
  "CREATE INDEX IF NOT EXISTS idx_instances_owner ON workflow_instances(owner_service);",
56
+ "CREATE INDEX IF NOT EXISTS idx_instances_framework ON workflow_instances(framework);",
55
57
  "CREATE INDEX IF NOT EXISTS idx_instances_locked ON workflow_instances(locked_by, locked_at);",
56
58
  "CREATE INDEX IF NOT EXISTS idx_instances_updated ON workflow_instances(updated_at);",
57
59
  "CREATE INDEX IF NOT EXISTS idx_instances_hash ON workflow_instances(source_hash);",
edda/storage/pg_notify.py CHANGED
@@ -288,21 +288,18 @@ class PostgresNotifyListener:
288
288
  await asyncio.sleep(self._reconnect_interval)
289
289
 
290
290
 
291
- def get_notify_channel_for_message(channel: str) -> str:
291
+ def get_notify_channel_for_message(_channel: str) -> str:
292
292
  """Convert Edda channel name to PostgreSQL NOTIFY channel.
293
293
 
294
- Uses a hash to ensure valid PostgreSQL identifier (max 63 chars).
294
+ Returns a unified channel name that both Python and Go frameworks use.
295
295
 
296
296
  Args:
297
- channel: The Edda channel name.
297
+ _channel: The Edda channel name (unused, kept for API compatibility).
298
298
 
299
299
  Returns:
300
- PostgreSQL-safe channel name.
300
+ Unified PostgreSQL channel name.
301
301
  """
302
- import hashlib
303
-
304
- h = hashlib.sha256(channel.encode()).hexdigest()[:16]
305
- return f"edda_msg_{h}"
302
+ return "workflow_channel_message"
306
303
 
307
304
 
308
305
  def make_notify_payload(data: dict[str, Any]) -> str: