triage4 1.0.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.
- triage4/__init__.py +70 -0
- triage4/adaptive_token_bucket.py +61 -0
- triage4/alarm_rate_monitor.py +96 -0
- triage4/band_classifier.py +110 -0
- triage4/device_fair_queue.py +184 -0
- triage4/results.py +203 -0
- triage4/source_aware_queue.py +105 -0
- triage4/token_bucket.py +70 -0
- triage4/triage4_config.py +192 -0
- triage4/triage4_scheduler.py +502 -0
- triage4-1.0.0.dist-info/METADATA +64 -0
- triage4-1.0.0.dist-info/RECORD +15 -0
- triage4-1.0.0.dist-info/WHEEL +5 -0
- triage4-1.0.0.dist-info/licenses/LICENSE +21 -0
- triage4-1.0.0.dist-info/top_level.txt +1 -0
triage4/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tiered Resource Allocation for IoT Alarm and Geographic-priority Emergency (TRIAGE/4).
|
|
3
|
+
|
|
4
|
+
Four-band hierarchical scheduler that resolves priority inversion by
|
|
5
|
+
separating semantic urgency (alarms) from geographic priority (zones).
|
|
6
|
+
|
|
7
|
+
Main components:
|
|
8
|
+
- TRIAGE4Scheduler: Main scheduler with discrete-event simulation
|
|
9
|
+
- TRIAGE4Config: Configuration dataclass with band thresholds and token parameters
|
|
10
|
+
- DeviceFairQueue: Per-device round-robin queue for fairness
|
|
11
|
+
- BandClassifier: Message-to-band classification logic
|
|
12
|
+
|
|
13
|
+
Band hierarchy:
|
|
14
|
+
0. ALARM: Emergency messages, strict priority
|
|
15
|
+
1. HIGH: High-priority zones, token-constrained
|
|
16
|
+
2. STANDARD: Standard zones, token-constrained
|
|
17
|
+
3. BACKGROUND: Low-priority zones, token-constrained
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
>>> from triage4 import TRIAGE4Scheduler, TRIAGE4Config
|
|
21
|
+
>>> config = TRIAGE4Config(
|
|
22
|
+
... high_zone_max=1,
|
|
23
|
+
... standard_zone_max=3,
|
|
24
|
+
... high_token_budget=10,
|
|
25
|
+
... service_rate=20.0
|
|
26
|
+
... )
|
|
27
|
+
>>> scheduler = TRIAGE4Scheduler(config)
|
|
28
|
+
>>> result = scheduler.schedule(
|
|
29
|
+
... arrival_times=[0.0, 0.1, 0.2],
|
|
30
|
+
... device_ids=["sensor_1", "sensor_2", "sensor_1"],
|
|
31
|
+
... zone_priorities=[0, 5, 2],
|
|
32
|
+
... is_alarm=[False, True, False]
|
|
33
|
+
... )
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from .band_classifier import (
|
|
37
|
+
BAND_ALARM,
|
|
38
|
+
BAND_BACKGROUND,
|
|
39
|
+
BAND_HIGH,
|
|
40
|
+
BAND_STANDARD,
|
|
41
|
+
BandClassifier,
|
|
42
|
+
)
|
|
43
|
+
from .adaptive_token_bucket import AdaptiveTokenBucket
|
|
44
|
+
from .alarm_rate_monitor import AlarmRateMonitor
|
|
45
|
+
from .device_fair_queue import DeviceFairQueue
|
|
46
|
+
from .triage4_config import TRIAGE4Config, create_triage4_custom, create_triage4_default
|
|
47
|
+
from .triage4_scheduler import TRIAGE4Scheduler
|
|
48
|
+
from .source_aware_queue import SourceAwareQueue
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
# Main scheduler
|
|
52
|
+
"TRIAGE4Scheduler",
|
|
53
|
+
# Configuration
|
|
54
|
+
"TRIAGE4Config",
|
|
55
|
+
"create_triage4_default",
|
|
56
|
+
"create_triage4_custom",
|
|
57
|
+
# Components
|
|
58
|
+
"BandClassifier",
|
|
59
|
+
"DeviceFairQueue",
|
|
60
|
+
"AdaptiveTokenBucket",
|
|
61
|
+
"AlarmRateMonitor",
|
|
62
|
+
"SourceAwareQueue",
|
|
63
|
+
# Band constants
|
|
64
|
+
"BAND_ALARM",
|
|
65
|
+
"BAND_HIGH",
|
|
66
|
+
"BAND_STANDARD",
|
|
67
|
+
"BAND_BACKGROUND",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Adaptive token bucket that activates only when alarm protection is enabled.
|
|
3
|
+
|
|
4
|
+
Wraps the standard TokenBucket and exposes activation/deactivation hooks with
|
|
5
|
+
hysteresis controlled by the caller (e.g., AlarmRateMonitor).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .token_bucket import TIME_TOLERANCE, TokenBucket
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AdaptiveTokenBucket:
|
|
12
|
+
"""Token bucket that can be activated/deactivated at runtime."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, budget: int, period: float, burst_capacity: int | None = None):
|
|
15
|
+
if budget <= 0:
|
|
16
|
+
raise ValueError("budget must be positive")
|
|
17
|
+
if period <= 0:
|
|
18
|
+
raise ValueError("period must be positive")
|
|
19
|
+
if burst_capacity is not None and burst_capacity < budget:
|
|
20
|
+
raise ValueError("burst_capacity must be >= budget")
|
|
21
|
+
|
|
22
|
+
self.bucket = TokenBucket(
|
|
23
|
+
budget=budget, period=period, burst_capacity=burst_capacity
|
|
24
|
+
)
|
|
25
|
+
self.active = False
|
|
26
|
+
|
|
27
|
+
def activate(self, current_time: float) -> None:
|
|
28
|
+
"""Enable rate limiting and realign refill schedule."""
|
|
29
|
+
if not self.active:
|
|
30
|
+
self.active = True
|
|
31
|
+
# Align next refill to current_time to avoid retroactive refills.
|
|
32
|
+
self.bucket.next_refill = current_time + self.bucket.period
|
|
33
|
+
self.bucket.tokens = self.bucket.max_capacity
|
|
34
|
+
|
|
35
|
+
def deactivate(self) -> None:
|
|
36
|
+
"""Disable rate limiting (bucket becomes pass-through)."""
|
|
37
|
+
self.active = False
|
|
38
|
+
|
|
39
|
+
def consume(self, current_time: float, amount: int = 1) -> bool:
|
|
40
|
+
"""
|
|
41
|
+
Consume tokens if active. Returns True if the request is allowed.
|
|
42
|
+
When inactive, always returns True (no limiting).
|
|
43
|
+
"""
|
|
44
|
+
if not self.active:
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
self.bucket.refill(current_time)
|
|
48
|
+
return self.bucket.consume(amount)
|
|
49
|
+
|
|
50
|
+
def get_next_refill_time(self) -> float:
|
|
51
|
+
"""Expose next refill time for event scheduling."""
|
|
52
|
+
if not self.active:
|
|
53
|
+
# If inactive, never schedule on bucket events
|
|
54
|
+
return float("inf")
|
|
55
|
+
return self.bucket.get_next_refill_time()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def tokens(self) -> int:
|
|
59
|
+
"""Current token count (0 when inactive implies unlimited)."""
|
|
60
|
+
return self.bucket.tokens if self.active else -1
|
|
61
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sliding-window alarm rate monitor for adaptive protection.
|
|
3
|
+
|
|
4
|
+
Detects abnormal alarm rates using a simple windowed counter with hysteresis.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Deque, Tuple
|
|
9
|
+
|
|
10
|
+
from .token_bucket import TIME_TOLERANCE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AlarmRateMonitor:
|
|
14
|
+
"""Track alarm arrivals and detect abnormal rates."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
window_duration: float,
|
|
19
|
+
abnormal_threshold: float,
|
|
20
|
+
deactivation_threshold: float,
|
|
21
|
+
min_observations: int = 1,
|
|
22
|
+
):
|
|
23
|
+
"""
|
|
24
|
+
Args:
|
|
25
|
+
window_duration: Sliding window size in seconds
|
|
26
|
+
abnormal_threshold: Alarms/sec that triggers protection
|
|
27
|
+
deactivation_threshold: Alarms/sec below which protection deactivates
|
|
28
|
+
min_observations: Minimum alarms before detection can trigger
|
|
29
|
+
"""
|
|
30
|
+
if window_duration <= 0:
|
|
31
|
+
raise ValueError("window_duration must be positive")
|
|
32
|
+
if abnormal_threshold <= 0:
|
|
33
|
+
raise ValueError("abnormal_threshold must be positive")
|
|
34
|
+
if deactivation_threshold < 0:
|
|
35
|
+
raise ValueError("deactivation_threshold must be non-negative")
|
|
36
|
+
if deactivation_threshold > abnormal_threshold:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"deactivation_threshold must be <= abnormal_threshold for hysteresis"
|
|
39
|
+
)
|
|
40
|
+
if min_observations <= 0:
|
|
41
|
+
raise ValueError("min_observations must be positive")
|
|
42
|
+
|
|
43
|
+
self.window_duration = float(window_duration)
|
|
44
|
+
self.abnormal_threshold = float(abnormal_threshold)
|
|
45
|
+
self.deactivation_threshold = float(deactivation_threshold)
|
|
46
|
+
self.min_observations = int(min_observations)
|
|
47
|
+
|
|
48
|
+
self._arrivals: Deque[Tuple[float, str]] = deque()
|
|
49
|
+
|
|
50
|
+
def record_arrival(self, timestamp: float, alarm_source: str) -> None:
|
|
51
|
+
"""Add an alarm arrival to the window."""
|
|
52
|
+
self._arrivals.append((timestamp, alarm_source))
|
|
53
|
+
self._prune(timestamp)
|
|
54
|
+
|
|
55
|
+
def get_rate(self, now: float | None = None) -> float:
|
|
56
|
+
"""Return alarms/sec over the current window."""
|
|
57
|
+
if now is None and self._arrivals:
|
|
58
|
+
now = self._arrivals[-1][0]
|
|
59
|
+
elif now is None:
|
|
60
|
+
return 0.0
|
|
61
|
+
|
|
62
|
+
self._prune(now)
|
|
63
|
+
if not self._arrivals:
|
|
64
|
+
return 0.0
|
|
65
|
+
|
|
66
|
+
window_span = max(self.window_duration, TIME_TOLERANCE)
|
|
67
|
+
return len(self._arrivals) / window_span
|
|
68
|
+
|
|
69
|
+
def is_abnormal(self, now: float | None = None) -> bool:
|
|
70
|
+
"""True if rate exceeds abnormal threshold with enough observations."""
|
|
71
|
+
if now is None and self._arrivals:
|
|
72
|
+
now = self._arrivals[-1][0]
|
|
73
|
+
elif now is None:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
self._prune(now)
|
|
77
|
+
if len(self._arrivals) < self.min_observations:
|
|
78
|
+
return False
|
|
79
|
+
return self.get_rate(now) >= self.abnormal_threshold - TIME_TOLERANCE
|
|
80
|
+
|
|
81
|
+
def is_recovered(self, now: float | None = None) -> bool:
|
|
82
|
+
"""True if rate has fallen below deactivation threshold."""
|
|
83
|
+
if now is None and self._arrivals:
|
|
84
|
+
now = self._arrivals[-1][0]
|
|
85
|
+
elif now is None:
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
self._prune(now)
|
|
89
|
+
return self.get_rate(now) <= self.deactivation_threshold + TIME_TOLERANCE
|
|
90
|
+
|
|
91
|
+
def _prune(self, now: float) -> None:
|
|
92
|
+
"""Drop arrivals outside the sliding window."""
|
|
93
|
+
cutoff = now - self.window_duration - TIME_TOLERANCE
|
|
94
|
+
while self._arrivals and self._arrivals[0][0] < cutoff:
|
|
95
|
+
self._arrivals.popleft()
|
|
96
|
+
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Band classification logic for TRIAGE/4.
|
|
3
|
+
|
|
4
|
+
Maps messages to one of four bands based on semantic urgency (is_alarm)
|
|
5
|
+
and geographic priority (zone_priority).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Band constants
|
|
9
|
+
BAND_ALARM = 0 # Emergency messages, always served first
|
|
10
|
+
BAND_HIGH = 1 # High-priority zone telemetry, token-constrained
|
|
11
|
+
BAND_STANDARD = 2 # Standard zone telemetry, token-constrained
|
|
12
|
+
BAND_BACKGROUND = 3 # Low-priority zone data, best-effort
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BandClassifier:
|
|
16
|
+
"""
|
|
17
|
+
Classifies messages into TRIAGE/4 bands.
|
|
18
|
+
|
|
19
|
+
Classification hierarchy:
|
|
20
|
+
1. Semantic urgency (is_alarm) overrides geographic priority
|
|
21
|
+
2. Geographic priority (zone_priority) determines band for non-alarms
|
|
22
|
+
|
|
23
|
+
Band assignment rules:
|
|
24
|
+
- is_alarm=True → ALARM (0) - regardless of zone priority
|
|
25
|
+
- zone_priority <= high_zone_max → HIGH (1)
|
|
26
|
+
- zone_priority <= standard_zone_max → STANDARD (2)
|
|
27
|
+
- zone_priority > standard_zone_max → BACKGROUND (3)
|
|
28
|
+
|
|
29
|
+
This separation resolves priority inversion where routine telemetry
|
|
30
|
+
from high-priority zones delays critical alarms from low-priority zones.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, high_zone_max: int, standard_zone_max: int):
|
|
34
|
+
"""
|
|
35
|
+
Initialize band classifier with zone thresholds.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
high_zone_max: Maximum zone priority for HIGH band (inclusive)
|
|
39
|
+
standard_zone_max: Maximum zone priority for STANDARD band (inclusive)
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> classifier = BandClassifier(high_zone_max=1, standard_zone_max=3)
|
|
43
|
+
>>> classifier.classify(zone_priority=0, is_alarm=False)
|
|
44
|
+
1 # HIGH band
|
|
45
|
+
>>> classifier.classify(zone_priority=5, is_alarm=True)
|
|
46
|
+
0 # ALARM band (semantic override)
|
|
47
|
+
"""
|
|
48
|
+
if high_zone_max < 0:
|
|
49
|
+
raise ValueError(f"high_zone_max must be non-negative, got {high_zone_max}")
|
|
50
|
+
if standard_zone_max < high_zone_max:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"standard_zone_max ({standard_zone_max}) must be >= "
|
|
53
|
+
f"high_zone_max ({high_zone_max})"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
self.high_zone_max = high_zone_max
|
|
57
|
+
self.standard_zone_max = standard_zone_max
|
|
58
|
+
|
|
59
|
+
def classify(self, zone_priority: int, is_alarm: bool) -> int:
|
|
60
|
+
"""
|
|
61
|
+
Classify message into TRIAGE/4 band.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
zone_priority: Geographic zone priority (0=highest priority zone)
|
|
65
|
+
is_alarm: Semantic urgency flag (True for emergency messages)
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Band number: 0=ALARM, 1=HIGH, 2=STANDARD, 3=BACKGROUND
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: If zone_priority is negative
|
|
72
|
+
"""
|
|
73
|
+
if zone_priority < 0:
|
|
74
|
+
raise ValueError(f"zone_priority must be non-negative, got {zone_priority}")
|
|
75
|
+
|
|
76
|
+
# Semantic urgency overrides geographic priority
|
|
77
|
+
if is_alarm:
|
|
78
|
+
return BAND_ALARM
|
|
79
|
+
|
|
80
|
+
# Geographic priority determines band for non-alarms
|
|
81
|
+
if zone_priority <= self.high_zone_max:
|
|
82
|
+
return BAND_HIGH
|
|
83
|
+
elif zone_priority <= self.standard_zone_max:
|
|
84
|
+
return BAND_STANDARD
|
|
85
|
+
else:
|
|
86
|
+
return BAND_BACKGROUND
|
|
87
|
+
|
|
88
|
+
def get_band_name(self, band: int) -> str:
|
|
89
|
+
"""
|
|
90
|
+
Get human-readable band name.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
band: Band number (0-3)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Band name string
|
|
97
|
+
"""
|
|
98
|
+
names = {
|
|
99
|
+
BAND_ALARM: "ALARM",
|
|
100
|
+
BAND_HIGH: "HIGH",
|
|
101
|
+
BAND_STANDARD: "STANDARD",
|
|
102
|
+
BAND_BACKGROUND: "BACKGROUND",
|
|
103
|
+
}
|
|
104
|
+
return names.get(band, f"UNKNOWN({band})")
|
|
105
|
+
|
|
106
|
+
def __repr__(self) -> str:
|
|
107
|
+
return (
|
|
108
|
+
f"BandClassifier(high_zone_max={self.high_zone_max}, "
|
|
109
|
+
f"standard_zone_max={self.standard_zone_max})"
|
|
110
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-device fair queue with round-robin device selection.
|
|
3
|
+
|
|
4
|
+
Prevents device monopolization by cycling through devices and serving
|
|
5
|
+
one message per device per round, maintaining FIFO within each device.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections import deque
|
|
9
|
+
from typing import Dict, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeviceFairQueue:
|
|
13
|
+
"""
|
|
14
|
+
Round-robin per-device queue within a band.
|
|
15
|
+
|
|
16
|
+
Ensures fairness by cycling through active devices and serving one message
|
|
17
|
+
from each device per round. Messages within each device are served FIFO.
|
|
18
|
+
|
|
19
|
+
Key properties:
|
|
20
|
+
- Fair device selection: Each device gets equal turns regardless of load
|
|
21
|
+
- FIFO within device: Messages from same device served in arrival order
|
|
22
|
+
- Monopolization prevention: High-rate device cannot starve low-rate device
|
|
23
|
+
- Dynamic device set: Devices added/removed as queues fill/empty
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
>>> queue = DeviceFairQueue()
|
|
27
|
+
>>> queue.enqueue(job_idx=0, device_id="A")
|
|
28
|
+
>>> queue.enqueue(job_idx=1, device_id="B")
|
|
29
|
+
>>> queue.enqueue(job_idx=2, device_id="A")
|
|
30
|
+
>>> queue.dequeue() # Returns 0 from device A
|
|
31
|
+
0
|
|
32
|
+
>>> queue.dequeue() # Returns 1 from device B (round-robin)
|
|
33
|
+
1
|
|
34
|
+
>>> queue.dequeue() # Returns 2 from device A (back to A)
|
|
35
|
+
2
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
"""Initialize empty per-device fair queue."""
|
|
40
|
+
self.device_queues: Dict[str, deque[int]] = {}
|
|
41
|
+
self.last_served_device: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
def enqueue(self, job_idx: int, device_id: str) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Add message to device's queue.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
job_idx: Index of job in arrival_times array
|
|
49
|
+
device_id: Device identifier (e.g., "sensor_42", "EDU_5")
|
|
50
|
+
"""
|
|
51
|
+
if device_id not in self.device_queues:
|
|
52
|
+
self.device_queues[device_id] = deque()
|
|
53
|
+
self.device_queues[device_id].append(job_idx)
|
|
54
|
+
|
|
55
|
+
def dequeue(self) -> Optional[int]:
|
|
56
|
+
"""
|
|
57
|
+
Remove and return next message using round-robin device selection.
|
|
58
|
+
|
|
59
|
+
Round-robin algorithm:
|
|
60
|
+
1. Get list of devices with messages
|
|
61
|
+
2. Find position of last_served_device (or start at 0)
|
|
62
|
+
3. Iterate circularly starting from next device
|
|
63
|
+
4. Return first message from first non-empty device
|
|
64
|
+
5. Update last_served_device pointer
|
|
65
|
+
6. Clean up empty device queues
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Job index of next message, or None if all queues empty
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
With devices ["A", "B", "C"] and last_served="A":
|
|
72
|
+
- Next check: B (if non-empty, serve from B)
|
|
73
|
+
- Then: C, then A, then B, ... (circular)
|
|
74
|
+
"""
|
|
75
|
+
if not self.device_queues:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# Get sorted device list for deterministic round-robin
|
|
79
|
+
devices = sorted(self.device_queues.keys())
|
|
80
|
+
|
|
81
|
+
# Find starting position (next device after last served)
|
|
82
|
+
# Maintain circular order even when devices are removed
|
|
83
|
+
start_idx = 0
|
|
84
|
+
if self.last_served_device is not None:
|
|
85
|
+
# Find first device that comes after last_served in sorted order
|
|
86
|
+
for i, dev in enumerate(devices):
|
|
87
|
+
if dev > self.last_served_device:
|
|
88
|
+
start_idx = i
|
|
89
|
+
break
|
|
90
|
+
# If no device comes after (last_served was alphabetically last),
|
|
91
|
+
# wrap around to start
|
|
92
|
+
|
|
93
|
+
# Search circularly for next non-empty device
|
|
94
|
+
for i in range(len(devices)):
|
|
95
|
+
idx = (start_idx + i) % len(devices)
|
|
96
|
+
device_id = devices[idx]
|
|
97
|
+
|
|
98
|
+
if self.device_queues[device_id]:
|
|
99
|
+
# Serve from this device
|
|
100
|
+
self.last_served_device = device_id
|
|
101
|
+
job_idx = self.device_queues[device_id].popleft()
|
|
102
|
+
|
|
103
|
+
# Clean up empty queues
|
|
104
|
+
if not self.device_queues[device_id]:
|
|
105
|
+
del self.device_queues[device_id]
|
|
106
|
+
|
|
107
|
+
return job_idx
|
|
108
|
+
|
|
109
|
+
# All queues empty (shouldn't reach here if self.device_queues non-empty)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def peek(self) -> Optional[int]:
|
|
113
|
+
"""
|
|
114
|
+
View next message without removing it.
|
|
115
|
+
|
|
116
|
+
Uses same round-robin logic as dequeue() but doesn't modify state.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Job index of next message, or None if all queues empty
|
|
120
|
+
"""
|
|
121
|
+
if not self.device_queues:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
devices = sorted(self.device_queues.keys())
|
|
125
|
+
start_idx = 0
|
|
126
|
+
if self.last_served_device is not None:
|
|
127
|
+
# Find first device that comes after last_served in sorted order
|
|
128
|
+
for i, dev in enumerate(devices):
|
|
129
|
+
if dev > self.last_served_device:
|
|
130
|
+
start_idx = i
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
for i in range(len(devices)):
|
|
134
|
+
idx = (start_idx + i) % len(devices)
|
|
135
|
+
device_id = devices[idx]
|
|
136
|
+
|
|
137
|
+
if self.device_queues[device_id]:
|
|
138
|
+
return self.device_queues[device_id][0]
|
|
139
|
+
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def is_empty(self) -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Check if all device queues are empty.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if no messages queued, False otherwise
|
|
148
|
+
"""
|
|
149
|
+
return len(self.device_queues) == 0
|
|
150
|
+
|
|
151
|
+
def queue_length(self) -> int:
|
|
152
|
+
"""
|
|
153
|
+
Get total number of messages across all devices.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Total queued messages
|
|
157
|
+
"""
|
|
158
|
+
return sum(len(q) for q in self.device_queues.values())
|
|
159
|
+
|
|
160
|
+
def device_count(self) -> int:
|
|
161
|
+
"""
|
|
162
|
+
Get number of active devices (devices with queued messages).
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Number of devices with non-empty queues
|
|
166
|
+
"""
|
|
167
|
+
return len(self.device_queues)
|
|
168
|
+
|
|
169
|
+
def get_device_queue_lengths(self) -> Dict[str, int]:
|
|
170
|
+
"""
|
|
171
|
+
Get per-device queue lengths.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Dictionary mapping device_id to queue length
|
|
175
|
+
"""
|
|
176
|
+
return {device_id: len(queue) for device_id, queue in self.device_queues.items()}
|
|
177
|
+
|
|
178
|
+
def __repr__(self) -> str:
|
|
179
|
+
total = self.queue_length()
|
|
180
|
+
devices = self.device_count()
|
|
181
|
+
return (
|
|
182
|
+
f"DeviceFairQueue(total_messages={total}, "
|
|
183
|
+
f"active_devices={devices}, last_served={self.last_served_device})"
|
|
184
|
+
)
|