dory-processor-sdk 0.0.1__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.
Files changed (86) hide show
  1. dory/__init__.py +101 -0
  2. dory/auth/__init__.py +10 -0
  3. dory/auth/oauth2.py +153 -0
  4. dory/auto_instrument.py +142 -0
  5. dory/cli/__init__.py +5 -0
  6. dory/cli/main.py +137 -0
  7. dory/cli/templates.py +123 -0
  8. dory/config/__init__.py +23 -0
  9. dory/config/defaults.py +24 -0
  10. dory/config/loader.py +430 -0
  11. dory/config/presets.py +73 -0
  12. dory/config/schema.py +84 -0
  13. dory/core/__init__.py +27 -0
  14. dory/core/app.py +434 -0
  15. dory/core/context.py +209 -0
  16. dory/core/lifecycle.py +214 -0
  17. dory/core/meta.py +121 -0
  18. dory/core/modes.py +479 -0
  19. dory/core/processor.py +564 -0
  20. dory/core/signals.py +122 -0
  21. dory/decorators.py +142 -0
  22. dory/edge/__init__.py +88 -0
  23. dory/edge/adaptive.py +644 -0
  24. dory/edge/detector.py +546 -0
  25. dory/edge/fencing.py +488 -0
  26. dory/edge/heartbeat.py +598 -0
  27. dory/edge/role.py +419 -0
  28. dory/errors/__init__.py +139 -0
  29. dory/errors/classification.py +362 -0
  30. dory/errors/codes.py +498 -0
  31. dory/geo/__init__.py +40 -0
  32. dory/geo/geolocalizer.py +1034 -0
  33. dory/health/__init__.py +12 -0
  34. dory/health/probes.py +210 -0
  35. dory/health/server.py +635 -0
  36. dory/k8s/__init__.py +80 -0
  37. dory/k8s/annotation_watcher.py +184 -0
  38. dory/k8s/client.py +251 -0
  39. dory/k8s/labels.py +505 -0
  40. dory/k8s/pod_metadata.py +182 -0
  41. dory/logging/__init__.py +9 -0
  42. dory/logging/logger.py +148 -0
  43. dory/metrics/__init__.py +7 -0
  44. dory/metrics/collector.py +301 -0
  45. dory/middleware/__init__.py +46 -0
  46. dory/middleware/connection_tracker.py +608 -0
  47. dory/middleware/request_id.py +325 -0
  48. dory/middleware/request_tracker.py +511 -0
  49. dory/migration/__init__.py +33 -0
  50. dory/migration/configmap.py +232 -0
  51. dory/migration/s3_store.py +594 -0
  52. dory/migration/serialization.py +135 -0
  53. dory/migration/state_manager.py +286 -0
  54. dory/migration/transfer.py +382 -0
  55. dory/monitoring/__init__.py +29 -0
  56. dory/monitoring/opentelemetry.py +489 -0
  57. dory/output/__init__.py +31 -0
  58. dory/output/envelope.py +137 -0
  59. dory/output/formatter.py +113 -0
  60. dory/output/rabbitmq.py +632 -0
  61. dory/output/routing.py +318 -0
  62. dory/output/validator.py +199 -0
  63. dory/py.typed +2 -0
  64. dory/recovery/__init__.py +60 -0
  65. dory/recovery/golden_image.py +487 -0
  66. dory/recovery/golden_snapshot.py +713 -0
  67. dory/recovery/golden_validator.py +518 -0
  68. dory/recovery/partial_recovery.py +482 -0
  69. dory/recovery/recovery_decision.py +242 -0
  70. dory/recovery/restart_detector.py +142 -0
  71. dory/recovery/state_validator.py +183 -0
  72. dory/resilience/__init__.py +45 -0
  73. dory/resilience/circuit_breaker.py +457 -0
  74. dory/resilience/retry.py +389 -0
  75. dory/simple.py +342 -0
  76. dory/types.py +68 -0
  77. dory/utils/__init__.py +31 -0
  78. dory/utils/errors.py +59 -0
  79. dory/utils/retry.py +115 -0
  80. dory/utils/timeout.py +80 -0
  81. dory_processor_sdk-0.0.1.dist-info/METADATA +424 -0
  82. dory_processor_sdk-0.0.1.dist-info/RECORD +86 -0
  83. dory_processor_sdk-0.0.1.dist-info/WHEEL +5 -0
  84. dory_processor_sdk-0.0.1.dist-info/entry_points.txt +2 -0
  85. dory_processor_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
  86. dory_processor_sdk-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,594 @@
1
+ """
2
+ S3 storage backend for state persistence.
3
+
4
+ Provides S3-based state storage with local buffering for edge nodes
5
+ that may have intermittent connectivity.
6
+
7
+ Features:
8
+ - S3 upload/download with retry logic
9
+ - Local SQLite buffer for offline scenarios
10
+ - Multiple credential options (IAM role, env vars, STS)
11
+ - Automatic sync when connectivity is restored
12
+ """
13
+
14
+ import asyncio
15
+ import logging
16
+ import os
17
+ import sqlite3
18
+ import time
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from dory.utils.errors import DoryStateError
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Optional boto3 import - gracefully handle if not available
28
+ try:
29
+ import boto3
30
+ from botocore.exceptions import ClientError, NoCredentialsError, BotoCoreError
31
+ BOTO3_AVAILABLE = True
32
+ except ImportError:
33
+ BOTO3_AVAILABLE = False
34
+ boto3 = None
35
+ ClientError = Exception
36
+ NoCredentialsError = Exception
37
+ BotoCoreError = Exception
38
+
39
+
40
+ @dataclass
41
+ class S3Config:
42
+ """Configuration for S3 state backend."""
43
+
44
+ bucket: str
45
+ prefix: str = "dory-state"
46
+ region: str | None = None
47
+ endpoint_url: str | None = None # For S3-compatible services (MinIO, LocalStack)
48
+
49
+ # Credential options
50
+ access_key_id: str | None = None
51
+ secret_access_key: str | None = None
52
+ session_token: str | None = None # For STS temporary credentials
53
+ role_arn: str | None = None # For assuming a role
54
+ # Offline buffering
55
+ enable_offline_buffer: bool = True
56
+ buffer_path: str = "/data/dory-state-buffer.db"
57
+ buffer_path_fallback: str = "/tmp/dory-state-buffer.db"
58
+ max_buffer_age_seconds: int = 86400 # 24 hours
59
+
60
+ # Retry settings
61
+ max_retries: int = 3
62
+ retry_delay_seconds: float = 1.0
63
+ retry_backoff_multiplier: float = 2.0
64
+
65
+ @classmethod
66
+ def from_env(cls) -> "S3Config":
67
+ """Create config from environment variables."""
68
+ bucket = os.environ.get("DORY_S3_BUCKET")
69
+ if not bucket:
70
+ raise DoryStateError(
71
+ "DORY_S3_BUCKET environment variable is required for S3 backend"
72
+ )
73
+
74
+ return cls(
75
+ bucket=bucket,
76
+ prefix=os.environ.get("DORY_S3_PREFIX", "dory-state"),
77
+ region=os.environ.get("DORY_S3_REGION", os.environ.get("AWS_REGION")),
78
+ endpoint_url=os.environ.get("DORY_S3_ENDPOINT_URL"),
79
+ access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
80
+ secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
81
+ session_token=os.environ.get("AWS_SESSION_TOKEN"),
82
+ role_arn=os.environ.get("DORY_S3_ROLE_ARN"),
83
+ enable_offline_buffer=os.environ.get(
84
+ "DORY_S3_ENABLE_OFFLINE_BUFFER", "true"
85
+ ).lower() == "true",
86
+ buffer_path=os.environ.get(
87
+ "DORY_S3_BUFFER_PATH", "/data/dory-state-buffer.db"
88
+ ),
89
+ )
90
+
91
+
92
+ class OfflineBuffer:
93
+ """
94
+ SQLite-based local buffer for offline state persistence.
95
+
96
+ Stores state locally when S3 is unreachable and syncs when
97
+ connectivity is restored.
98
+ """
99
+
100
+ def __init__(self, db_path: str, fallback_path: str):
101
+ """
102
+ Initialize offline buffer.
103
+
104
+ Args:
105
+ db_path: Primary path for SQLite database
106
+ fallback_path: Fallback path if primary is not writable
107
+ """
108
+ self._db_path = self._resolve_path(db_path, fallback_path)
109
+ self._conn: sqlite3.Connection | None = None
110
+ self._initialized = False
111
+
112
+ def _resolve_path(self, primary: str, fallback: str) -> str:
113
+ """Resolve which path to use for the database."""
114
+ primary_dir = Path(primary).parent
115
+ if primary_dir.exists() and os.access(primary_dir, os.W_OK):
116
+ return primary
117
+
118
+ fallback_dir = Path(fallback).parent
119
+ fallback_dir.mkdir(parents=True, exist_ok=True)
120
+
121
+ # Warn if falling back to /tmp in Kubernetes (data will be lost on restart)
122
+ if "/tmp" in fallback and (
123
+ os.environ.get("KUBERNETES_SERVICE_HOST") or os.environ.get("DORY_POD_NAME")
124
+ ):
125
+ logger.warning(
126
+ f"Using fallback buffer path {fallback} in Kubernetes. "
127
+ "Buffered state will be lost on pod restart. "
128
+ "Mount a PVC at /data for persistent offline buffering."
129
+ )
130
+ else:
131
+ logger.info(f"Using fallback buffer path: {fallback}")
132
+
133
+ return fallback
134
+
135
+ def _ensure_initialized(self) -> None:
136
+ """Initialize database if not already done."""
137
+ if self._initialized:
138
+ return
139
+
140
+ self._conn = sqlite3.connect(self._db_path)
141
+ self._conn.execute("""
142
+ CREATE TABLE IF NOT EXISTS state_buffer (
143
+ processor_id TEXT PRIMARY KEY,
144
+ state_json TEXT NOT NULL,
145
+ created_at REAL NOT NULL,
146
+ synced_at REAL,
147
+ sync_attempts INTEGER DEFAULT 0
148
+ )
149
+ """)
150
+ self._conn.execute("""
151
+ CREATE INDEX IF NOT EXISTS idx_synced
152
+ ON state_buffer(synced_at)
153
+ """)
154
+ self._conn.commit()
155
+ self._initialized = True
156
+ logger.debug(f"Offline buffer initialized at {self._db_path}")
157
+
158
+ def save(self, processor_id: str, state_json: str) -> None:
159
+ """Save state to local buffer."""
160
+ self._ensure_initialized()
161
+
162
+ self._conn.execute("""
163
+ INSERT OR REPLACE INTO state_buffer
164
+ (processor_id, state_json, created_at, synced_at, sync_attempts)
165
+ VALUES (?, ?, ?, NULL, 0)
166
+ """, (processor_id, state_json, time.time()))
167
+ self._conn.commit()
168
+ logger.debug(f"State buffered locally for {processor_id}")
169
+
170
+ def load(self, processor_id: str) -> str | None:
171
+ """Load state from local buffer."""
172
+ self._ensure_initialized()
173
+
174
+ cursor = self._conn.execute("""
175
+ SELECT state_json FROM state_buffer
176
+ WHERE processor_id = ?
177
+ """, (processor_id,))
178
+ row = cursor.fetchone()
179
+ return row[0] if row else None
180
+
181
+ def mark_synced(self, processor_id: str) -> None:
182
+ """Mark state as synced to S3."""
183
+ self._ensure_initialized()
184
+
185
+ self._conn.execute("""
186
+ UPDATE state_buffer
187
+ SET synced_at = ?
188
+ WHERE processor_id = ?
189
+ """, (time.time(), processor_id))
190
+ self._conn.commit()
191
+
192
+ def get_unsynced(self) -> list[tuple[str, str]]:
193
+ """Get all unsynced states."""
194
+ self._ensure_initialized()
195
+
196
+ cursor = self._conn.execute("""
197
+ SELECT processor_id, state_json
198
+ FROM state_buffer
199
+ WHERE synced_at IS NULL
200
+ ORDER BY created_at ASC
201
+ """)
202
+ return cursor.fetchall()
203
+
204
+ def increment_sync_attempts(self, processor_id: str) -> None:
205
+ """Increment sync attempt counter."""
206
+ self._ensure_initialized()
207
+
208
+ self._conn.execute("""
209
+ UPDATE state_buffer
210
+ SET sync_attempts = sync_attempts + 1
211
+ WHERE processor_id = ?
212
+ """, (processor_id,))
213
+ self._conn.commit()
214
+
215
+ def delete(self, processor_id: str) -> bool:
216
+ """Delete state from buffer."""
217
+ self._ensure_initialized()
218
+
219
+ cursor = self._conn.execute("""
220
+ DELETE FROM state_buffer WHERE processor_id = ?
221
+ """, (processor_id,))
222
+ self._conn.commit()
223
+ return cursor.rowcount > 0
224
+
225
+ def cleanup_old(self, max_age_seconds: int) -> int:
226
+ """Remove entries older than max_age_seconds."""
227
+ self._ensure_initialized()
228
+
229
+ cutoff = time.time() - max_age_seconds
230
+ cursor = self._conn.execute("""
231
+ DELETE FROM state_buffer
232
+ WHERE created_at < ? AND synced_at IS NOT NULL
233
+ """, (cutoff,))
234
+ self._conn.commit()
235
+ return cursor.rowcount
236
+
237
+ def close(self) -> None:
238
+ """Close database connection."""
239
+ if self._conn:
240
+ self._conn.close()
241
+ self._conn = None
242
+ self._initialized = False
243
+
244
+
245
+ class S3Store:
246
+ """
247
+ Store and retrieve state from AWS S3.
248
+
249
+ Supports offline buffering for edge nodes with intermittent connectivity.
250
+
251
+ S3 key format: {prefix}/{processor_id}/state.json
252
+ """
253
+
254
+ def __init__(self, config: S3Config | None = None):
255
+ """
256
+ Initialize S3 store.
257
+
258
+ Args:
259
+ config: S3 configuration (defaults to from_env())
260
+ """
261
+ if not BOTO3_AVAILABLE:
262
+ raise DoryStateError(
263
+ "boto3 is required for S3 backend. "
264
+ "Install with: pip install boto3"
265
+ )
266
+
267
+ self._config = config or S3Config.from_env()
268
+ self._client: Any = None
269
+ self._initialized = False
270
+
271
+ # Offline buffer
272
+ self._buffer: OfflineBuffer | None = None
273
+ if self._config.enable_offline_buffer:
274
+ self._buffer = OfflineBuffer(
275
+ self._config.buffer_path,
276
+ self._config.buffer_path_fallback,
277
+ )
278
+
279
+ def _ensure_initialized(self) -> None:
280
+ """Initialize S3 client if not already done."""
281
+ if self._initialized:
282
+ return
283
+
284
+ try:
285
+ session_kwargs = {}
286
+ client_kwargs = {}
287
+
288
+ # Region
289
+ if self._config.region:
290
+ session_kwargs["region_name"] = self._config.region
291
+
292
+ # Explicit credentials
293
+ if self._config.access_key_id and self._config.secret_access_key:
294
+ session_kwargs["aws_access_key_id"] = self._config.access_key_id
295
+ session_kwargs["aws_secret_access_key"] = self._config.secret_access_key
296
+ if self._config.session_token:
297
+ session_kwargs["aws_session_token"] = self._config.session_token
298
+
299
+ # Custom endpoint (MinIO, LocalStack)
300
+ if self._config.endpoint_url:
301
+ client_kwargs["endpoint_url"] = self._config.endpoint_url
302
+
303
+ # Create session and client
304
+ session = boto3.Session(**session_kwargs)
305
+
306
+ # Assume role if specified
307
+ if self._config.role_arn:
308
+ sts = session.client("sts")
309
+ assumed = sts.assume_role(
310
+ RoleArn=self._config.role_arn,
311
+ RoleSessionName="dory-state-manager",
312
+ )
313
+ credentials = assumed["Credentials"]
314
+ session = boto3.Session(
315
+ aws_access_key_id=credentials["AccessKeyId"],
316
+ aws_secret_access_key=credentials["SecretAccessKey"],
317
+ aws_session_token=credentials["SessionToken"],
318
+ region_name=self._config.region,
319
+ )
320
+
321
+ self._client = session.client("s3", **client_kwargs)
322
+ self._initialized = True
323
+ logger.debug(f"S3 client initialized for bucket {self._config.bucket}")
324
+
325
+ except NoCredentialsError as e:
326
+ raise DoryStateError(
327
+ "No AWS credentials found. Configure via environment variables, "
328
+ "IAM role, or credential broker.",
329
+ cause=e,
330
+ )
331
+ except Exception as e:
332
+ raise DoryStateError(f"Failed to initialize S3 client: {e}", cause=e)
333
+
334
+ def _s3_key(self, processor_id: str) -> str:
335
+ """Generate S3 key for processor state."""
336
+ return f"{self._config.prefix}/{processor_id}/state.json"
337
+
338
+ async def _retry_operation(
339
+ self,
340
+ operation: str,
341
+ func: Any,
342
+ *args: Any,
343
+ **kwargs: Any,
344
+ ) -> Any:
345
+ """Execute operation with retry logic."""
346
+ last_error = None
347
+ delay = self._config.retry_delay_seconds
348
+
349
+ for attempt in range(1, self._config.max_retries + 1):
350
+ try:
351
+ # Run synchronous boto3 call in executor
352
+ loop = asyncio.get_event_loop()
353
+ result = await loop.run_in_executor(None, lambda: func(*args, **kwargs))
354
+ return result
355
+
356
+ except (ClientError, BotoCoreError) as e:
357
+ last_error = e
358
+ if attempt < self._config.max_retries:
359
+ logger.warning(
360
+ f"S3 {operation} attempt {attempt} failed: {e}. "
361
+ f"Retrying in {delay:.1f}s..."
362
+ )
363
+ await asyncio.sleep(delay)
364
+ delay *= self._config.retry_backoff_multiplier
365
+
366
+ raise DoryStateError(
367
+ f"S3 {operation} failed after {self._config.max_retries} attempts: {last_error}",
368
+ cause=last_error,
369
+ )
370
+
371
+ async def save(
372
+ self,
373
+ processor_id: str,
374
+ state_json: str,
375
+ metadata: dict[str, str] | None = None,
376
+ ) -> None:
377
+ """
378
+ Save state to S3.
379
+
380
+ If S3 is unreachable and offline buffering is enabled,
381
+ state is saved to local buffer for later sync.
382
+
383
+ Args:
384
+ processor_id: Processor ID
385
+ state_json: JSON-serialized state
386
+ metadata: Optional metadata to store with object
387
+
388
+ Raises:
389
+ DoryStateError: If save fails and no buffer available
390
+ """
391
+ self._ensure_initialized()
392
+
393
+ s3_key = self._s3_key(processor_id)
394
+ s3_metadata = metadata or {}
395
+ s3_metadata["processor-id"] = processor_id
396
+ s3_metadata["saved-at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
397
+
398
+ try:
399
+ await self._retry_operation(
400
+ "put_object",
401
+ self._client.put_object,
402
+ Bucket=self._config.bucket,
403
+ Key=s3_key,
404
+ Body=state_json.encode("utf-8"),
405
+ ContentType="application/json",
406
+ Metadata=s3_metadata,
407
+ )
408
+ logger.debug(f"State saved to S3: s3://{self._config.bucket}/{s3_key}")
409
+
410
+ # If buffer exists, mark as synced
411
+ if self._buffer:
412
+ self._buffer.save(processor_id, state_json)
413
+ self._buffer.mark_synced(processor_id)
414
+
415
+ except DoryStateError:
416
+ # S3 failed - try to buffer locally
417
+ if self._buffer:
418
+ self._buffer.save(processor_id, state_json)
419
+ logger.warning(
420
+ f"S3 unavailable, state buffered locally for {processor_id}"
421
+ )
422
+ else:
423
+ raise
424
+
425
+ async def load(self, processor_id: str) -> str | None:
426
+ """
427
+ Load state from S3.
428
+
429
+ Falls back to local buffer if S3 is unreachable.
430
+
431
+ Args:
432
+ processor_id: Processor ID
433
+
434
+ Returns:
435
+ JSON-serialized state, or None if not found
436
+
437
+ Raises:
438
+ DoryStateError: If load fails
439
+ """
440
+ self._ensure_initialized()
441
+
442
+ s3_key = self._s3_key(processor_id)
443
+
444
+ try:
445
+ response = await self._retry_operation(
446
+ "get_object",
447
+ self._client.get_object,
448
+ Bucket=self._config.bucket,
449
+ Key=s3_key,
450
+ )
451
+
452
+ # Read body in executor
453
+ loop = asyncio.get_event_loop()
454
+ body = await loop.run_in_executor(
455
+ None,
456
+ lambda: response["Body"].read().decode("utf-8"),
457
+ )
458
+
459
+ logger.debug(f"State loaded from S3: s3://{self._config.bucket}/{s3_key}")
460
+ return body
461
+
462
+ except DoryStateError as e:
463
+ # Check if it's a 404
464
+ if "NoSuchKey" in str(e) or "404" in str(e):
465
+ logger.debug(f"State not found in S3: {s3_key}")
466
+
467
+ # Try local buffer as fallback
468
+ if self._buffer:
469
+ buffered = self._buffer.load(processor_id)
470
+ if buffered:
471
+ logger.info(
472
+ f"Using buffered state for {processor_id} (not yet synced to S3)"
473
+ )
474
+ return buffered
475
+
476
+ return None
477
+
478
+ # S3 error - try local buffer
479
+ if self._buffer:
480
+ buffered = self._buffer.load(processor_id)
481
+ if buffered:
482
+ logger.warning(
483
+ f"S3 unavailable, using buffered state for {processor_id}"
484
+ )
485
+ return buffered
486
+
487
+ raise
488
+
489
+ async def delete(self, processor_id: str) -> bool:
490
+ """
491
+ Delete state from S3.
492
+
493
+ Args:
494
+ processor_id: Processor ID
495
+
496
+ Returns:
497
+ True if deleted, False if not found
498
+
499
+ Raises:
500
+ DoryStateError: If delete fails
501
+ """
502
+ self._ensure_initialized()
503
+
504
+ s3_key = self._s3_key(processor_id)
505
+
506
+ try:
507
+ # Check if exists first
508
+ try:
509
+ await self._retry_operation(
510
+ "head_object",
511
+ self._client.head_object,
512
+ Bucket=self._config.bucket,
513
+ Key=s3_key,
514
+ )
515
+ except DoryStateError:
516
+ # Not found
517
+ if self._buffer:
518
+ self._buffer.delete(processor_id)
519
+ return False
520
+
521
+ # Delete from S3
522
+ await self._retry_operation(
523
+ "delete_object",
524
+ self._client.delete_object,
525
+ Bucket=self._config.bucket,
526
+ Key=s3_key,
527
+ )
528
+ logger.debug(f"State deleted from S3: s3://{self._config.bucket}/{s3_key}")
529
+
530
+ # Delete from buffer too
531
+ if self._buffer:
532
+ self._buffer.delete(processor_id)
533
+
534
+ return True
535
+
536
+ except ClientError as e:
537
+ if e.response.get("Error", {}).get("Code") == "NoSuchKey":
538
+ return False
539
+ raise DoryStateError(f"Failed to delete state from S3: {e}", cause=e)
540
+
541
+ async def sync_buffer(self) -> int:
542
+ """
543
+ Sync buffered states to S3.
544
+
545
+ Call periodically to upload states that were buffered
546
+ during connectivity issues.
547
+
548
+ Returns:
549
+ Number of states synced
550
+ """
551
+ if not self._buffer:
552
+ return 0
553
+
554
+ unsynced = self._buffer.get_unsynced()
555
+ synced_count = 0
556
+
557
+ for processor_id, state_json in unsynced:
558
+ try:
559
+ s3_key = self._s3_key(processor_id)
560
+
561
+ await self._retry_operation(
562
+ "put_object",
563
+ self._client.put_object,
564
+ Bucket=self._config.bucket,
565
+ Key=s3_key,
566
+ Body=state_json.encode("utf-8"),
567
+ ContentType="application/json",
568
+ Metadata={
569
+ "processor-id": processor_id,
570
+ "synced-from-buffer": "true",
571
+ "synced-at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
572
+ },
573
+ )
574
+
575
+ self._buffer.mark_synced(processor_id)
576
+ synced_count += 1
577
+ logger.info(f"Synced buffered state for {processor_id} to S3")
578
+
579
+ except DoryStateError as e:
580
+ self._buffer.increment_sync_attempts(processor_id)
581
+ logger.warning(f"Failed to sync buffered state for {processor_id}: {e}")
582
+
583
+ # Cleanup old synced entries
584
+ if synced_count > 0:
585
+ cleaned = self._buffer.cleanup_old(self._config.max_buffer_age_seconds)
586
+ if cleaned > 0:
587
+ logger.debug(f"Cleaned up {cleaned} old buffer entries")
588
+
589
+ return synced_count
590
+
591
+ def close(self) -> None:
592
+ """Close resources."""
593
+ if self._buffer:
594
+ self._buffer.close()