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.
- coti_safesync_framework-0.0.1/PKG-INFO +467 -0
- coti_safesync_framework-0.0.1/README.md +440 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/__init__.py +3 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/config.py +42 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/db/__init__.py +12 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/db/helpers.py +280 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/db/locking/__init__.py +4 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/db/locking/advisory_lock.py +129 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/db/locking/row_lock.py +130 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/db/metrics.py +33 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/db/session.py +171 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/errors.py +19 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/metrics/__init__.py +20 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/metrics/registry.py +46 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/queue/__init__.py +14 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/queue/consumer.py +328 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/queue/metrics.py +56 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/queue/models.py +22 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/queue/redis_streams.py +318 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework/signals.py +20 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/PKG-INFO +467 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/SOURCES.txt +26 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/dependency_links.txt +1 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/requires.txt +13 -0
- coti_safesync_framework-0.0.1/coti_safesync_framework.egg-info/top_level.txt +1 -0
- coti_safesync_framework-0.0.1/pyproject.toml +68 -0
- coti_safesync_framework-0.0.1/setup.cfg +4 -0
- 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
|