coti-safesync-framework 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. coti_safesync_framework-0.0.1/PKG-INFO +467 -0
  2. coti_safesync_framework-0.0.1/README.md +440 -0
  3. coti_safesync_framework-0.0.1/coti_safesync_framework/__init__.py +3 -0
  4. coti_safesync_framework-0.0.1/coti_safesync_framework/config.py +42 -0
  5. coti_safesync_framework-0.0.1/coti_safesync_framework/db/__init__.py +12 -0
  6. coti_safesync_framework-0.0.1/coti_safesync_framework/db/helpers.py +280 -0
  7. coti_safesync_framework-0.0.1/coti_safesync_framework/db/locking/__init__.py +4 -0
  8. coti_safesync_framework-0.0.1/coti_safesync_framework/db/locking/advisory_lock.py +129 -0
  9. coti_safesync_framework-0.0.1/coti_safesync_framework/db/locking/row_lock.py +130 -0
  10. coti_safesync_framework-0.0.1/coti_safesync_framework/db/metrics.py +33 -0
  11. coti_safesync_framework-0.0.1/coti_safesync_framework/db/session.py +171 -0
  12. coti_safesync_framework-0.0.1/coti_safesync_framework/errors.py +19 -0
  13. coti_safesync_framework-0.0.1/coti_safesync_framework/metrics/__init__.py +20 -0
  14. coti_safesync_framework-0.0.1/coti_safesync_framework/metrics/registry.py +46 -0
  15. coti_safesync_framework-0.0.1/coti_safesync_framework/queue/__init__.py +14 -0
  16. coti_safesync_framework-0.0.1/coti_safesync_framework/queue/consumer.py +328 -0
  17. coti_safesync_framework-0.0.1/coti_safesync_framework/queue/metrics.py +56 -0
  18. coti_safesync_framework-0.0.1/coti_safesync_framework/queue/models.py +22 -0
  19. coti_safesync_framework-0.0.1/coti_safesync_framework/queue/redis_streams.py +318 -0
  20. coti_safesync_framework-0.0.1/coti_safesync_framework/signals.py +20 -0
  21. coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/PKG-INFO +467 -0
  22. coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/SOURCES.txt +26 -0
  23. coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/dependency_links.txt +1 -0
  24. coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/requires.txt +13 -0
  25. coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/top_level.txt +1 -0
  26. coti_safesync_framework-0.0.1/pyproject.toml +68 -0
  27. coti_safesync_framework-0.0.1/setup.cfg +4 -0
  28. coti_safesync_framework-0.0.1/tests/test_signals.py +181 -0
@@ -0,0 +1,467 @@
1
+ Metadata-Version: 2.4
2
+ Name: coti-safesync-framework
3
+ Version: 0.0.1
4
+ Summary: Safe concurrent MySQL writes and Redis Streams queue operations
5
+ Author-email: COTI <dev@coti.io>
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: sqlalchemy>=2.0
16
+ Requires-Dist: redis>=5.0
17
+ Requires-Dist: prometheus-client>=0.19
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.0; extra == "dev"
20
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
21
+ Requires-Dist: black>=23.0; extra == "dev"
22
+ Requires-Dist: flake8>=6.0; extra == "dev"
23
+ Requires-Dist: mypy>=1.0; extra == "dev"
24
+ Requires-Dist: bandit>=1.0; extra == "dev"
25
+ Requires-Dist: safety>=2.0; extra == "dev"
26
+ Requires-Dist: pymysql>=1.0; extra == "dev"
27
+
28
+ # COTI SafeSync Framework
29
+
30
+ **Safe concurrent MySQL writes and Redis Streams queue operations**
31
+
32
+ COTI SafeSync Framework is a Python library for building robust, concurrent backend systems. It provides explicit concurrency control primitives for MySQL and safe message consumption from Redis Streams, designed for multi-process and multi-host environments.
33
+
34
+ ## Features
35
+
36
+ - 🔒 **Explicit concurrency control** - Pessimistic locking, optimistic concurrency control (OCC), and atomic SQL operations
37
+ - 🗄️ **Transactional MySQL operations** - Safe, explicit transaction management with `DbSession`
38
+ - 📨 **Redis Streams queue consumption** - At-least-once delivery with explicit acknowledgment
39
+ - 🚀 **Multi-process safe** - Designed for distributed systems with multiple workers
40
+ - 📊 **Prometheus metrics** - Built-in observability for operations and locks
41
+ - 🎯 **Framework-agnostic** - Works with FastAPI, CLI workers, schedulers, etc.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install coti-safesync-framework
47
+ ```
48
+
49
+ ## Requirements
50
+
51
+ - Python >= 3.11
52
+ - MySQL 8.0+ with InnoDB storage engine
53
+ - Redis 5.0+ (for queue operations)
54
+
55
+ ## Quick Start
56
+
57
+ ### Database Operations
58
+
59
+ ```python
60
+ from sqlalchemy import create_engine
61
+ from coti_safesync_framework.db.session import DbSession
62
+
63
+ # Create engine (typically done once at application startup)
64
+ engine = create_engine("mysql+pymysql://user:password@host/database")
65
+
66
+ # Use DbSession for transactional operations
67
+ with DbSession(engine) as session:
68
+ # Execute SQL
69
+ session.execute(
70
+ "UPDATE accounts SET balance = balance + :amount WHERE id = :id",
71
+ {"id": 123, "amount": 100}
72
+ )
73
+ # Transaction commits automatically on success
74
+ ```
75
+
76
+ ### Queue Consumption
77
+
78
+ ```python
79
+ from redis import Redis
80
+ from coti_safesync_framework.config import QueueConfig
81
+ from coti_safesync_framework.queue.consumer import QueueConsumer
82
+ from coti_safesync_framework.db.session import DbSession
83
+ from coti_safesync_framework.queue.models import QueueMessage
84
+
85
+ # Setup
86
+ redis_client = Redis(host="localhost", port=6379)
87
+ config = QueueConfig(
88
+ stream_key="orders",
89
+ consumer_group="workers",
90
+ consumer_name="worker_1"
91
+ )
92
+ consumer = QueueConsumer(redis_client, config)
93
+
94
+ # Process messages
95
+ def handle_message(msg: QueueMessage, session: DbSession) -> None:
96
+ order_id = msg.payload["order_id"]
97
+ session.execute(
98
+ "UPDATE orders SET status = 'processed' WHERE id = :id",
99
+ {"id": order_id}
100
+ )
101
+
102
+ consumer.run(handler=handle_message, engine=engine)
103
+ ```
104
+
105
+ ## Database Examples
106
+
107
+ ### 1. Atomic SQL Updates
108
+
109
+ For simple operations that can be expressed as a single SQL statement:
110
+
111
+ ```python
112
+ from coti_safesync_framework.db.session import DbSession
113
+
114
+ def increment_counter(engine, counter_id: int) -> None:
115
+ """Increment a counter atomically - no locks needed."""
116
+ with DbSession(engine) as session:
117
+ session.execute(
118
+ "UPDATE counters SET value = value + 1 WHERE id = :id",
119
+ {"id": counter_id}
120
+ )
121
+ # MySQL guarantees atomicity for single statements
122
+ ```
123
+
124
+ ### 2. Pessimistic Row Locking
125
+
126
+ When you need strict serialization for read-modify-write operations:
127
+
128
+ ```python
129
+ from coti_safesync_framework.db.session import DbSession
130
+ from coti_safesync_framework.db.locking.row_lock import RowLock
131
+
132
+ def process_order(engine, order_id: int) -> None:
133
+ """Process an order - only one worker can process a specific order."""
134
+ with DbSession(engine) as session:
135
+ # Acquire exclusive lock on the order row
136
+ order = RowLock(session, "orders", {"id": order_id}).acquire()
137
+
138
+ if order is None:
139
+ return # Order doesn't exist
140
+
141
+ if order["status"] == "processed":
142
+ return # Already processed
143
+
144
+ # Safe to modify - we hold the lock
145
+ session.execute(
146
+ "UPDATE orders SET status = :status WHERE id = :id",
147
+ {"id": order_id, "status": "processed"}
148
+ )
149
+ # Lock released when transaction commits
150
+ ```
151
+
152
+ ### 3. Optimistic Concurrency Control (OCC)
153
+
154
+ For high-throughput scenarios where conflicts are rare:
155
+
156
+ ```python
157
+ from coti_safesync_framework.db.session import DbSession
158
+ from coti_safesync_framework.db.helpers import occ_update
159
+ import time
160
+ import random
161
+
162
+ def update_account_balance(engine, account_id: int, amount_change: int) -> None:
163
+ """Update account balance using OCC with retry."""
164
+ MAX_RETRIES = 10
165
+
166
+ for attempt in range(MAX_RETRIES):
167
+ with DbSession(engine) as session:
168
+ # Read current balance and version
169
+ account = session.fetch_one(
170
+ "SELECT balance, version FROM accounts WHERE id = :id",
171
+ {"id": account_id}
172
+ )
173
+
174
+ if account is None:
175
+ raise ValueError(f"Account {account_id} not found")
176
+
177
+ # Attempt OCC update
178
+ rowcount = occ_update(
179
+ session=session,
180
+ table="accounts",
181
+ id_column="id",
182
+ id_value=account_id,
183
+ version_column="version",
184
+ version_value=account["version"],
185
+ updates={"balance": account["balance"] + amount_change}
186
+ )
187
+
188
+ if rowcount == 1:
189
+ return # Success!
190
+
191
+ # Version mismatch - retry with new transaction
192
+ time.sleep(random.uniform(0.001, 0.01))
193
+
194
+ raise RuntimeError(f"Failed to update account after {MAX_RETRIES} retries")
195
+ ```
196
+
197
+ **Important**: Each OCC attempt must use a **new transaction**. Never retry inside a single `DbSession`.
198
+
199
+ ### 4. Advisory Locks
200
+
201
+ For application-level synchronization across multiple tables:
202
+
203
+ ```python
204
+ from coti_safesync_framework.db.session import DbSession
205
+ from coti_safesync_framework.db.locking.advisory_lock import AdvisoryLock
206
+ from coti_safesync_framework.errors import LockTimeoutError
207
+
208
+ def process_user_data(engine, user_id: int) -> None:
209
+ """Process all data for a user - only one worker at a time."""
210
+ lock_key = f"user_processing:{user_id}"
211
+
212
+ try:
213
+ with DbSession(engine) as session:
214
+ with AdvisoryLock(session, lock_key, timeout=10):
215
+ # Lock acquired - we're the only worker processing this user
216
+
217
+ # Read user's orders
218
+ orders = session.fetch_all(
219
+ "SELECT id, total FROM orders WHERE user_id = :user_id",
220
+ {"user_id": user_id}
221
+ )
222
+
223
+ # Update user's summary
224
+ total_spent = sum(order["total"] for order in orders)
225
+ session.execute(
226
+ "UPDATE users SET total_spent = :total WHERE id = :id",
227
+ {"id": user_id, "total": total_spent}
228
+ )
229
+ # Lock released when connection closes (after commit)
230
+
231
+ except LockTimeoutError:
232
+ # Another worker is processing this user
233
+ print(f"Could not acquire lock for user {user_id}")
234
+ ```
235
+
236
+ ### 5. Idempotent INSERTs
237
+
238
+ For safe duplicate inserts using database constraints:
239
+
240
+ ```python
241
+ from coti_safesync_framework.db.session import DbSession
242
+ from coti_safesync_framework.db.helpers import insert_idempotent
243
+
244
+ def create_user_profile(engine, user_id: int, initial_data: dict) -> None:
245
+ """Create user profile - safe to call multiple times."""
246
+ with DbSession(engine) as session:
247
+ inserted = insert_idempotent(
248
+ session,
249
+ """
250
+ INSERT INTO user_profiles (user_id, display_name, created_at)
251
+ VALUES (:user_id, :display_name, NOW())
252
+ """,
253
+ {
254
+ "user_id": user_id,
255
+ "display_name": initial_data.get("display_name", "User")
256
+ }
257
+ )
258
+
259
+ if inserted:
260
+ print("Profile created")
261
+ else:
262
+ print("Profile already exists")
263
+ ```
264
+
265
+ ## Queue Examples
266
+
267
+ ### 1. Basic Message Consumption
268
+
269
+ ```python
270
+ from redis import Redis
271
+ from coti_safesync_framework.config import QueueConfig
272
+ from coti_safesync_framework.queue.consumer import QueueConsumer
273
+
274
+ redis_client = Redis(host="localhost", port=6379)
275
+ config = QueueConfig(
276
+ stream_key="orders",
277
+ consumer_group="workers",
278
+ consumer_name="worker_1",
279
+ block_ms=5_000, # Block 5 seconds when no messages
280
+ )
281
+
282
+ consumer = QueueConsumer(redis_client, config)
283
+
284
+ # Iterator-based consumption
285
+ for msg in consumer.iter_messages():
286
+ try:
287
+ process_message(msg.payload)
288
+ consumer.ack(msg) # Acknowledge after successful processing
289
+ except Exception as e:
290
+ # Don't ack on failure - message remains pending
291
+ print(f"Failed to process: {e}")
292
+ ```
293
+
294
+ ### 2. Template-Method Pattern (Recommended)
295
+
296
+ The `run()` method handles the complete flow: fetch → process → commit → ack:
297
+
298
+ ```python
299
+ from sqlalchemy import create_engine
300
+ from coti_safesync_framework.queue.models import QueueMessage
301
+ from coti_safesync_framework.db.session import DbSession
302
+
303
+ engine = create_engine("mysql+pymysql://user:password@host/database")
304
+
305
+ def handle_message(msg: QueueMessage, session: DbSession) -> None:
306
+ """Process message within a database transaction."""
307
+ order_id = msg.payload["order_id"]
308
+
309
+ # Read current state
310
+ order = session.fetch_one(
311
+ "SELECT id, status FROM orders WHERE id = :id",
312
+ {"id": order_id}
313
+ )
314
+
315
+ if not order:
316
+ raise ValueError(f"Order {order_id} not found")
317
+
318
+ # Update order
319
+ session.execute(
320
+ "UPDATE orders SET status = :status WHERE id = :id",
321
+ {"id": order_id, "status": "processed"}
322
+ )
323
+ # Transaction commits automatically on exit
324
+ # Message is ACKed after commit
325
+
326
+ # Run the consumer
327
+ consumer.run(handler=handle_message, engine=engine)
328
+ ```
329
+
330
+ **Error handling**: If `handle_message` raises an exception:
331
+ - Transaction rolls back automatically
332
+ - Message is **NOT** acknowledged
333
+ - Message remains pending for retry
334
+
335
+ ### 3. Stale Message Recovery
336
+
337
+ Messages may become stale if a worker crashes before acknowledging them. These messages remain pending in Redis and are not automatically redelivered. Use `run_claim_stale()` in a separate worker process to recover stale messages:
338
+
339
+ ```python
340
+ def recovery_worker():
341
+ """Run in a separate process to recover stale messages."""
342
+ consumer = QueueConsumer(redis_client, config)
343
+
344
+ consumer.run_claim_stale(
345
+ handler=handle_message, # Same handler as main consumer
346
+ engine=engine,
347
+ min_idle_ms=60_000, # Claim messages idle > 60 seconds
348
+ claim_interval_ms=5_000, # Check every 5 seconds
349
+ max_claim_count=10 # Claim up to 10 messages per check
350
+ )
351
+ ```
352
+
353
+ **How it works**: `run_claim_stale()` periodically checks for stale messages (every `claim_interval_ms`), claims them, and processes them using the same handler pattern as `run()`. It loops until `stop()` is called.
354
+
355
+ **Important**: Run the recovery worker in a separate process alongside your main consumer. The recovery worker should use the same `handler` function for consistency.
356
+
357
+ ### 4. Manual Message Fetching
358
+
359
+ For more control over the consumption loop:
360
+
361
+ ```python
362
+ while not consumer._stopping.is_set():
363
+ msg = consumer.next(block_ms=5_000)
364
+ if msg is None:
365
+ continue # No message available
366
+
367
+ try:
368
+ with DbSession(engine) as session:
369
+ process_message(msg.payload, session)
370
+ consumer.ack(msg)
371
+ except Exception:
372
+ # Transaction rolled back, message not acked
373
+ raise
374
+ ```
375
+
376
+ ### 5. Graceful Shutdown
377
+
378
+ ```python
379
+ import signal
380
+
381
+ consumer = QueueConsumer(redis_client, config)
382
+
383
+ def shutdown_handler(signum, frame):
384
+ consumer.stop()
385
+
386
+ signal.signal(signal.SIGTERM, shutdown_handler)
387
+ signal.signal(signal.SIGINT, shutdown_handler)
388
+
389
+ # Consumer will stop after current message completes
390
+ consumer.run(handler=handle_message, engine=engine)
391
+ ```
392
+
393
+ ## Concurrency Strategies
394
+
395
+ COTI SafeSync Framework provides multiple strategies for safe concurrent operations:
396
+
397
+ | Strategy | Use When | Performance | Contention |
398
+ |----------|----------|-------------|------------|
399
+ | **Atomic SQL** | Single-statement operations | Highest | Low |
400
+ | **Idempotent INSERT** | One-time initialization | High | Low |
401
+ | **OCC** | Low contention, can retry | High | Low |
402
+ | **Row Lock** | Need strict serialization | Medium | High |
403
+ | **Advisory Lock** | Cross-table synchronization | Medium | Medium |
404
+
405
+ See [LOCKING_STRATEGIES.md](docs/LOCKING_STRATEGIES.md) for detailed guidance.
406
+
407
+ ## Design Principles
408
+
409
+ 1. **Explicit over implicit** - Locks and transactions are always explicit
410
+ 2. **Primitives, not workflows** - Building blocks you compose
411
+ 3. **Control stays with you** - You compose logic inside locks/transactions
412
+ 4. **No magic retries** - Retry logic is your decision
413
+ 5. **Framework-agnostic** - Works with any Python framework
414
+
415
+ ## Important Notes
416
+
417
+ ### Database Transactions
418
+
419
+ - ⚠️ **Never retry inside a single `DbSession`** - Each retry must use a new transaction
420
+ - ⚠️ **Keep transactions short** - Long-held locks increase contention
421
+ - ⚠️ **Index WHERE clauses** - Non-indexed predicates can cause performance issues
422
+
423
+ ### Queue Semantics
424
+
425
+ - ⚠️ **At-least-once delivery** - Messages may be redelivered if ACK fails
426
+ - ⚠️ **Handlers must be idempotent** - Or use DB constraints/locks/OCC
427
+ - ⚠️ **Stale message recovery** - Run a separate recovery worker for stale messages
428
+
429
+ ### OCC Usage
430
+
431
+ - ⚠️ **Each attempt uses a new transaction** - Never retry inside `DbSession`
432
+ - ⚠️ **Must retry on `rowcount == 0`** - Indicates version mismatch
433
+ - ⚠️ **Must re-read before retrying** - Don't reuse stale data
434
+
435
+ See [docs/occ.md](docs/occ.md) for the complete OCC usage guide.
436
+
437
+ ## Metrics
438
+
439
+ COTI SafeSync Framework exposes Prometheus metrics:
440
+
441
+ - `coti_safesync_db_write_total` - DB write operation counts
442
+ - `coti_safesync_db_write_latency_seconds` - DB write latencies
443
+ - `coti_safesync_db_lock_acquire_latency_seconds` - Lock acquisition timing
444
+ - `coti_safesync_queue_messages_read_total` - Queue message reads
445
+ - `coti_safesync_queue_messages_ack_total` - Message acknowledgments
446
+ - `coti_safesync_queue_messages_claimed_total` - Stale messages claimed
447
+
448
+ ## Documentation
449
+
450
+ - [Complete API Reference](docs/bootstrap.md) - Authoritative design document
451
+ - [Locking Strategies Guide](docs/LOCKING_STRATEGIES.md) - When to use each strategy
452
+ - [OCC Usage Guide](docs/occ.md) - Optimistic concurrency control patterns
453
+ - [Queue Consumer Guide](docs/queue_consumer_bootstrap.md) - Redis Streams patterns
454
+
455
+ ## Requirements
456
+
457
+ - **Database**: MySQL 8.0+ with InnoDB storage engine
458
+ - **Queue**: Redis 5.0+ with Streams support
459
+ - **Python**: >= 3.11
460
+
461
+ ## License
462
+
463
+ MIT
464
+
465
+ ## Author
466
+
467
+ COTI - dev@coti.io