puffinflow 2.dev0__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 (55) hide show
  1. puffinflow/__init__.py +132 -0
  2. puffinflow/core/__init__.py +110 -0
  3. puffinflow/core/agent/__init__.py +320 -0
  4. puffinflow/core/agent/base.py +1635 -0
  5. puffinflow/core/agent/checkpoint.py +50 -0
  6. puffinflow/core/agent/context.py +521 -0
  7. puffinflow/core/agent/decorators/__init__.py +90 -0
  8. puffinflow/core/agent/decorators/builder.py +454 -0
  9. puffinflow/core/agent/decorators/flexible.py +714 -0
  10. puffinflow/core/agent/decorators/inspection.py +144 -0
  11. puffinflow/core/agent/dependencies.py +57 -0
  12. puffinflow/core/agent/scheduling/__init__.py +21 -0
  13. puffinflow/core/agent/scheduling/builder.py +160 -0
  14. puffinflow/core/agent/scheduling/exceptions.py +35 -0
  15. puffinflow/core/agent/scheduling/inputs.py +137 -0
  16. puffinflow/core/agent/scheduling/parser.py +209 -0
  17. puffinflow/core/agent/scheduling/scheduler.py +413 -0
  18. puffinflow/core/agent/state.py +141 -0
  19. puffinflow/core/config.py +62 -0
  20. puffinflow/core/coordination/__init__.py +137 -0
  21. puffinflow/core/coordination/agent_group.py +359 -0
  22. puffinflow/core/coordination/agent_pool.py +629 -0
  23. puffinflow/core/coordination/agent_team.py +577 -0
  24. puffinflow/core/coordination/coordinator.py +720 -0
  25. puffinflow/core/coordination/deadlock.py +1759 -0
  26. puffinflow/core/coordination/fluent_api.py +421 -0
  27. puffinflow/core/coordination/primitives.py +478 -0
  28. puffinflow/core/coordination/rate_limiter.py +520 -0
  29. puffinflow/core/observability/__init__.py +47 -0
  30. puffinflow/core/observability/agent.py +139 -0
  31. puffinflow/core/observability/alerting.py +73 -0
  32. puffinflow/core/observability/config.py +127 -0
  33. puffinflow/core/observability/context.py +88 -0
  34. puffinflow/core/observability/core.py +147 -0
  35. puffinflow/core/observability/decorators.py +105 -0
  36. puffinflow/core/observability/events.py +71 -0
  37. puffinflow/core/observability/interfaces.py +196 -0
  38. puffinflow/core/observability/metrics.py +137 -0
  39. puffinflow/core/observability/tracing.py +209 -0
  40. puffinflow/core/reliability/__init__.py +27 -0
  41. puffinflow/core/reliability/bulkhead.py +96 -0
  42. puffinflow/core/reliability/circuit_breaker.py +149 -0
  43. puffinflow/core/reliability/leak_detector.py +122 -0
  44. puffinflow/core/resources/__init__.py +77 -0
  45. puffinflow/core/resources/allocation.py +790 -0
  46. puffinflow/core/resources/pool.py +645 -0
  47. puffinflow/core/resources/quotas.py +567 -0
  48. puffinflow/core/resources/requirements.py +217 -0
  49. puffinflow/version.py +21 -0
  50. puffinflow-2.dev0.dist-info/METADATA +334 -0
  51. puffinflow-2.dev0.dist-info/RECORD +55 -0
  52. puffinflow-2.dev0.dist-info/WHEEL +5 -0
  53. puffinflow-2.dev0.dist-info/entry_points.txt +3 -0
  54. puffinflow-2.dev0.dist-info/licenses/LICENSE +21 -0
  55. puffinflow-2.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,478 @@
1
+ """Coordination primitives for distributed systems."""
2
+
3
+ import asyncio
4
+ import time
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import timedelta
8
+ from enum import Enum, auto
9
+ from typing import Any, Optional
10
+
11
+ import structlog
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+
16
+ class PrimitiveType(Enum):
17
+ """Coordination primitive types"""
18
+
19
+ MUTEX = auto() # Exclusive access
20
+ SEMAPHORE = auto() # Limited concurrent access
21
+ BARRIER = auto() # Synchronization point
22
+ LEASE = auto() # Time-based exclusive access
23
+ LOCK = auto() # Simple lock
24
+ QUOTA = auto() # Resource quota management
25
+
26
+
27
+ class ResourceState(Enum):
28
+ """Resource states"""
29
+
30
+ AVAILABLE = "available"
31
+ ACQUIRED = "acquired"
32
+ LOCKED = "locked"
33
+ EXPIRED = "expired"
34
+ ERROR = "error"
35
+
36
+
37
+ @dataclass
38
+ class CoordinationPrimitive:
39
+ """Enhanced coordination primitive"""
40
+
41
+ name: str
42
+ type: PrimitiveType
43
+ ttl: float = 30.0
44
+ max_count: int = 1
45
+ wait_timeout: Optional[float] = None
46
+ quota_limit: Optional[float] = None
47
+
48
+ # Internal state
49
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock)
50
+ _condition: asyncio.Condition = field(default_factory=asyncio.Condition)
51
+ _owners: set[str] = field(default_factory=set)
52
+ _acquired_times: dict[str, float] = field(default_factory=dict)
53
+ _quota_usage: dict[str, float] = field(default_factory=dict)
54
+ _wait_count: int = 0
55
+ _state: ResourceState = field(default=ResourceState.AVAILABLE)
56
+ _last_error: Optional[str] = None
57
+
58
+ async def acquire(
59
+ self,
60
+ caller_id: str,
61
+ timeout: Optional[float] = None,
62
+ quota_amount: Optional[float] = None,
63
+ ) -> bool:
64
+ """Acquire the primitive"""
65
+ try:
66
+ async with self._lock:
67
+ # Handle quota type specially
68
+ if self.type == PrimitiveType.QUOTA:
69
+ if quota_amount is None:
70
+ raise ValueError("Quota amount required")
71
+ current_usage = sum(self._quota_usage.values())
72
+ if current_usage + quota_amount <= (self.quota_limit or 0):
73
+ self._quota_usage[caller_id] = (
74
+ self._quota_usage.get(caller_id, 0) + quota_amount
75
+ )
76
+ return True
77
+ return False
78
+
79
+ # Check existing ownership
80
+ if caller_id in self._owners:
81
+ self._acquired_times[caller_id] = time.time()
82
+ return True
83
+
84
+ # Handle different primitive types
85
+ if self.type == PrimitiveType.MUTEX:
86
+ if not self._owners:
87
+ self._acquire_for(caller_id)
88
+ return True
89
+
90
+ elif self.type == PrimitiveType.SEMAPHORE:
91
+ if len(self._owners) < self.max_count:
92
+ self._acquire_for(caller_id)
93
+ return True
94
+
95
+ elif self.type == PrimitiveType.BARRIER:
96
+ # Unify the condition's lock with the primitive's lock on first use.
97
+ if not hasattr(self, "_barrier_lock_unified"):
98
+ self._condition = asyncio.Condition(self._lock)
99
+ self._barrier_lock_unified = True
100
+
101
+ self._acquire_for(caller_id)
102
+
103
+ if len(self._owners) < self.max_count:
104
+ # We must wait for other parties.
105
+ self._wait_count += 1
106
+ try:
107
+ # The `wait()` method will atomically release the lock and block until notified.
108
+ await asyncio.wait_for(
109
+ self._condition.wait(),
110
+ timeout=timeout or self.wait_timeout,
111
+ )
112
+ # Woke up successfully.
113
+ return True
114
+ except asyncio.TimeoutError:
115
+ # Timed out waiting for others.
116
+ self._remove_owner(caller_id)
117
+ return False
118
+ finally:
119
+ self._wait_count -= 1
120
+ else:
121
+ # We are the last party. Notify all waiters and proceed.
122
+ self._condition.notify_all()
123
+ return True
124
+
125
+ elif self.type == PrimitiveType.LEASE:
126
+ self._cleanup_expired()
127
+ if not self._owners:
128
+ self._acquire_for(caller_id)
129
+ return True
130
+
131
+ return False
132
+
133
+ except Exception as e:
134
+ self._state = ResourceState.ERROR
135
+ self._last_error = str(e)
136
+ raise
137
+
138
+ def _acquire_for(self, caller_id: str) -> None:
139
+ """Internal acquisition helper"""
140
+ self._owners.add(caller_id)
141
+ self._acquired_times[caller_id] = time.time()
142
+ self._state = ResourceState.ACQUIRED
143
+
144
+ def _remove_owner(self, caller_id: str) -> None:
145
+ """Internal removal helper"""
146
+ self._owners.discard(caller_id)
147
+ self._acquired_times.pop(caller_id, None)
148
+ self._quota_usage.pop(caller_id, None)
149
+ if not self._owners:
150
+ self._state = ResourceState.AVAILABLE
151
+
152
+ def _cleanup_expired(self) -> None:
153
+ """Clean up expired acquisitions"""
154
+ now = time.time()
155
+ expired = [
156
+ owner
157
+ for owner, acquired in self._acquired_times.items()
158
+ if now - acquired > self.ttl
159
+ ]
160
+ for owner in expired:
161
+ self._remove_owner(owner)
162
+
163
+ async def release(self, caller_id: str) -> bool:
164
+ """Release the primitive"""
165
+ async with self._lock:
166
+ # The acquire method for QUOTA does not add to _owners, so we handle its release separately.
167
+ if self.type == PrimitiveType.QUOTA and caller_id in self._quota_usage:
168
+ self._quota_usage.pop(caller_id)
169
+ return True
170
+
171
+ if caller_id in self._owners:
172
+ self._remove_owner(caller_id)
173
+
174
+ if self.type == PrimitiveType.BARRIER and self._wait_count > 0:
175
+ async with self._condition:
176
+ self._condition.notify_all()
177
+
178
+ return True
179
+
180
+ return False
181
+
182
+ def get_state(self) -> dict[str, Any]:
183
+ """Get current state information"""
184
+ return {
185
+ "state": self._state.value,
186
+ "owners": list(self._owners),
187
+ "wait_count": self._wait_count,
188
+ "quota_usage": dict(self._quota_usage),
189
+ "last_error": self._last_error,
190
+ "ttl_remaining": (
191
+ min(
192
+ (self.ttl - (time.time() - acquired))
193
+ for acquired in self._acquired_times.values()
194
+ )
195
+ if self._acquired_times
196
+ else None
197
+ ),
198
+ }
199
+
200
+
201
+ # Specialized primitive implementations
202
+ class Mutex(CoordinationPrimitive):
203
+ """Mutual exclusion lock"""
204
+
205
+ def __init__(self, name: str, ttl: float = 30.0):
206
+ super().__init__(name=name, type=PrimitiveType.MUTEX, ttl=ttl, max_count=1)
207
+
208
+ async def __aenter__(self) -> "Mutex":
209
+ """Async context manager support"""
210
+ caller_id = str(uuid.uuid4())
211
+ self._context_caller_id = caller_id
212
+ await self.acquire(caller_id)
213
+ return self
214
+
215
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
216
+ """Async context manager support"""
217
+ if hasattr(self, "_context_caller_id"):
218
+ await self.release(self._context_caller_id)
219
+ delattr(self, "_context_caller_id")
220
+
221
+
222
+ class Semaphore(CoordinationPrimitive):
223
+ """Counting semaphore"""
224
+
225
+ def __init__(self, name: str, max_count: int = 1, ttl: float = 30.0):
226
+ super().__init__(
227
+ name=name, type=PrimitiveType.SEMAPHORE, ttl=ttl, max_count=max_count
228
+ )
229
+
230
+ @property
231
+ def available_permits(self) -> int:
232
+ """Get number of available permits"""
233
+ return self.max_count - len(self._owners)
234
+
235
+
236
+ class Barrier(CoordinationPrimitive):
237
+ """Synchronization barrier"""
238
+
239
+ def __init__(self, name: str, parties: int, timeout: Optional[float] = None):
240
+ super().__init__(
241
+ name=name,
242
+ type=PrimitiveType.BARRIER,
243
+ max_count=parties,
244
+ wait_timeout=timeout,
245
+ )
246
+ self._parties = parties
247
+ self._generation = 0
248
+ self._condition = asyncio.Condition(self._lock)
249
+
250
+ async def wait(self, caller_id: Optional[str] = None) -> int:
251
+ """Wait at the barrier"""
252
+ if caller_id is None:
253
+ caller_id = str(uuid.uuid4())
254
+
255
+ async with self._lock:
256
+ generation = self._generation
257
+
258
+ self._owners.add(caller_id)
259
+ self._acquired_times[caller_id] = time.time()
260
+
261
+ if len(self._owners) < self._parties:
262
+ try:
263
+ await asyncio.wait_for(
264
+ self._condition.wait(), # Releases lock
265
+ timeout=self.wait_timeout,
266
+ )
267
+ return generation
268
+ except asyncio.TimeoutError:
269
+ self._owners.discard(caller_id)
270
+ # Notify others who might be waiting on a now-unreachable barrier
271
+ self._condition.notify_all()
272
+ raise
273
+ else:
274
+ # Last party: reset and notify
275
+ self._generation += 1
276
+ self._owners.clear()
277
+ self._acquired_times.clear()
278
+ self._condition.notify_all()
279
+ return generation
280
+
281
+
282
+ class Lease(CoordinationPrimitive):
283
+ """Time-based lease"""
284
+
285
+ def __init__(
286
+ self,
287
+ name: str,
288
+ ttl: float = 30.0,
289
+ auto_renew: bool = False,
290
+ renew_interval: float = 10.0,
291
+ ):
292
+ super().__init__(name=name, type=PrimitiveType.LEASE, ttl=ttl)
293
+ self.auto_renew = auto_renew
294
+ self.renew_interval = renew_interval
295
+ self._renew_task: Optional[asyncio.Task] = None
296
+
297
+ async def acquire(
298
+ self,
299
+ caller_id: str,
300
+ timeout: Optional[float] = None,
301
+ quota_amount: Optional[float] = None,
302
+ ) -> bool:
303
+ """Acquire lease with optional auto-renewal"""
304
+ success = await super().acquire(caller_id, timeout, quota_amount)
305
+
306
+ if success and self.auto_renew and not self._renew_task:
307
+ self._renew_task = asyncio.create_task(self._auto_renew_loop(caller_id))
308
+
309
+ return success
310
+
311
+ async def release(self, caller_id: str) -> bool:
312
+ """Release lease and cancel auto-renewal"""
313
+ if self._renew_task:
314
+ self._renew_task.cancel()
315
+ await asyncio.gather(self._renew_task, return_exceptions=True)
316
+ self._renew_task = None
317
+
318
+ return await super().release(caller_id)
319
+
320
+ async def _auto_renew_loop(self, caller_id: str) -> None:
321
+ """Auto-renew lease periodically"""
322
+ while True:
323
+ try:
324
+ await asyncio.sleep(self.renew_interval)
325
+
326
+ async with self._lock:
327
+ if caller_id in self._owners:
328
+ self._acquired_times[caller_id] = time.time()
329
+ logger.debug(
330
+ "lease_renewed", lease=self.name, caller_id=caller_id
331
+ )
332
+ else:
333
+ break
334
+
335
+ except asyncio.CancelledError:
336
+ break
337
+ except Exception as e:
338
+ logger.error("lease_renew_error", lease=self.name, error=str(e))
339
+ break
340
+
341
+
342
+ class Lock(CoordinationPrimitive):
343
+ """Simple reentrant lock"""
344
+
345
+ def __init__(self, name: str, ttl: float = 30.0):
346
+ super().__init__(name=name, type=PrimitiveType.LOCK, ttl=ttl)
347
+ self._lock_count: dict[str, int] = {}
348
+
349
+ async def acquire(
350
+ self,
351
+ caller_id: str,
352
+ timeout: Optional[float] = None,
353
+ quota_amount: Optional[float] = None,
354
+ ) -> bool:
355
+ """Acquire lock with reentrancy support"""
356
+ async with self._lock:
357
+ # Check if already owned by caller (reentrant)
358
+ if caller_id in self._owners:
359
+ self._lock_count[caller_id] = self._lock_count.get(caller_id, 1) + 1
360
+ return True
361
+
362
+ # Try to acquire
363
+ if not self._owners:
364
+ self._acquire_for(caller_id)
365
+ self._lock_count[caller_id] = 1
366
+ return True
367
+
368
+ return False
369
+
370
+ async def release(self, caller_id: str) -> bool:
371
+ """Release lock with reentrancy support"""
372
+ async with self._lock:
373
+ if caller_id not in self._owners:
374
+ return False
375
+
376
+ # Decrement lock count
377
+ count = self._lock_count.get(caller_id, 1) - 1
378
+
379
+ if count <= 0:
380
+ # Fully release
381
+ self._remove_owner(caller_id)
382
+ self._lock_count.pop(caller_id, None)
383
+ return True
384
+ else:
385
+ # Still locked
386
+ self._lock_count[caller_id] = count
387
+ return True
388
+
389
+
390
+ class Quota(CoordinationPrimitive):
391
+ """Resource quota management"""
392
+
393
+ def __init__(
394
+ self, name: str, limit: float, reset_interval: Optional[timedelta] = None
395
+ ):
396
+ super().__init__(name=name, type=PrimitiveType.QUOTA, quota_limit=limit)
397
+ self.reset_interval = reset_interval
398
+ self._last_reset = time.time()
399
+ self._reset_task: Optional[asyncio.Task] = None
400
+
401
+ def _start_reset_task_if_needed(self) -> None:
402
+ """Start the background reset task if configured and not already running."""
403
+ if self.reset_interval and self._reset_task is None:
404
+ self._reset_task = asyncio.create_task(self._reset_loop())
405
+
406
+ async def consume(self, caller_id: str, amount: float) -> bool:
407
+ """Consume quota amount"""
408
+ self._start_reset_task_if_needed()
409
+ return await self.acquire(caller_id, quota_amount=amount)
410
+
411
+ async def release_quota(self, caller_id: str, amount: float) -> None:
412
+ """Release quota (give back)"""
413
+ self._start_reset_task_if_needed()
414
+ async with self._lock:
415
+ if caller_id in self._quota_usage and amount > 0:
416
+ self._quota_usage[caller_id] = max(
417
+ 0, self._quota_usage[caller_id] - amount
418
+ )
419
+
420
+ @property
421
+ def available(self) -> float:
422
+ """Get available quota"""
423
+ used = sum(self._quota_usage.values())
424
+ return max(0, (self.quota_limit or 0) - used)
425
+
426
+ @property
427
+ def usage(self) -> dict[str, float]:
428
+ """Get current usage by caller"""
429
+ return dict(self._quota_usage)
430
+
431
+ async def reset(self) -> None:
432
+ """Reset all quota usage"""
433
+ self._start_reset_task_if_needed()
434
+ async with self._lock:
435
+ self._quota_usage.clear()
436
+ self._last_reset = time.time()
437
+ logger.info("quota_reset", quota=self.name)
438
+
439
+ async def _reset_loop(self) -> None:
440
+ """Periodic quota reset"""
441
+ while True:
442
+ try:
443
+ if self.reset_interval:
444
+ await asyncio.sleep(self.reset_interval.total_seconds())
445
+ await self.reset()
446
+ else:
447
+ break
448
+ except asyncio.CancelledError:
449
+ break
450
+ except Exception as e:
451
+ logger.error("quota_reset_error", quota=self.name, error=str(e))
452
+
453
+ def __del__(self) -> None:
454
+ """Cleanup reset task"""
455
+ if self._reset_task and not self._reset_task.done():
456
+ self._reset_task.cancel()
457
+
458
+
459
+ # Factory function
460
+ def create_primitive(
461
+ primitive_type: PrimitiveType, name: str, **kwargs: Any
462
+ ) -> CoordinationPrimitive:
463
+ """Create a coordination primitive by type"""
464
+ primitives = {
465
+ PrimitiveType.MUTEX: Mutex,
466
+ PrimitiveType.SEMAPHORE: Semaphore,
467
+ PrimitiveType.BARRIER: Barrier,
468
+ PrimitiveType.LEASE: Lease,
469
+ PrimitiveType.LOCK: Lock,
470
+ PrimitiveType.QUOTA: Quota,
471
+ }
472
+
473
+ primitive_class = primitives.get(primitive_type, CoordinationPrimitive)
474
+
475
+ if primitive_class == CoordinationPrimitive:
476
+ return CoordinationPrimitive(name=name, type=primitive_type, **kwargs)
477
+ else:
478
+ return primitive_class(name=name, **kwargs) # type: ignore[no-any-return]