Files

607 lines
22 KiB
Python

"""Crash-safe Redis protocol for MeshCore link jobs."""
import hashlib
import json
import os
import secrets
import threading
import time
import uuid
READY = 'meshcore:link:v3:ready'
DEFERRED = 'meshcore:link:v3:deferred'
PAYLOADS = 'meshcore:link:v3:payloads'
STATES = 'meshcore:link:v3:states'
ATTEMPTS = 'meshcore:link:v3:attempts'
BYTES = 'meshcore:link:v3:bytes'
DEDUPE = 'meshcore:link:v3:dedupe'
DEDUPE_BY_JOB = 'meshcore:link:v3:dedupe_by_job'
LEASES = 'meshcore:link:v3:leases'
TOKENS = 'meshcore:link:v3:tokens'
DEAD = 'meshcore:link:v3:dead'
DEAD_REASONS = 'meshcore:link:v3:dead_reasons'
ENQUEUED = 'meshcore:link:v3:enqueued'
COMPLETED = 'meshcore:link:v3:completed'
COUNTERS = 'meshcore:link:v3:counters'
REBUILD = 'meshcore:link:v3:rebuild'
WORKER_HEARTBEAT = 'meshcore:link:v3:worker_heartbeat'
EVENTS = 'meshcore:link:v3:events'
WAKE = 'meshcore:link:v3:wake'
MAX_JOBS = max(1, min(100_000, int(os.environ.get('LINK_QUEUE_V3_MAX_JOBS', '5000'))))
MAX_BYTES = max(1, min(1024 * 1024 * 1024, int(os.environ.get('LINK_QUEUE_V3_MAX_BYTES', str(64 * 1024 * 1024)))))
MAX_PAYLOAD_BYTES = max(1, min(1024 * 1024, int(os.environ.get('LINK_QUEUE_V3_MAX_PAYLOAD_BYTES', str(32 * 1024)))))
MAX_ATTEMPTS = max(1, min(20, int(os.environ.get('LINK_QUEUE_V3_MAX_ATTEMPTS', '5'))))
LEASE_MS = max(10_000, min(30 * 60_000, int(os.environ.get('LINK_QUEUE_V3_LEASE_MS', '120000'))))
COMPLETED_RETENTION_MS = max(60_000, int(os.environ.get('LINK_QUEUE_V3_COMPLETED_RETENTION_MS', str(7 * 24 * 60 * 60_000))))
DEAD_MAX_JOBS = max(1, min(10_000, int(os.environ.get('LINK_QUEUE_V3_DEAD_MAX_JOBS', '1000'))))
DEAD_MAX_BYTES = max(1, min(256 * 1024 * 1024, int(os.environ.get('LINK_QUEUE_V3_DEAD_MAX_BYTES', str(32 * 1024 * 1024)))))
DEAD_RETENTION_MS = max(60_000, int(os.environ.get('LINK_QUEUE_V3_DEAD_RETENTION_MS', str(30 * 24 * 60 * 60_000))))
ADMIT_SCRIPT = """
local existing = redis.call('HGET', KEYS[6], ARGV[2])
if existing then
local existing_state = redis.call('HGET', KEYS[3], existing)
if existing == ARGV[1] and existing_state == 'complete' then
return {'duplicate', existing}
end
if existing_state == 'queued' or existing_state == 'in_flight' or existing_state == 'dead' then
return {'coalesced', existing}
end
end
local payload_bytes = tonumber(ARGV[4])
if payload_bytes > tonumber(ARGV[7]) then return {'oversized', ''} end
local count = tonumber(redis.call('HGET', KEYS[10], 'count') or '0')
local bytes = tonumber(redis.call('HGET', KEYS[10], 'bytes') or '0')
if count + 1 > tonumber(ARGV[5]) or bytes + payload_bytes > tonumber(ARGV[6]) then
return {'full', ''}
end
redis.call('HSET', KEYS[2], ARGV[1], ARGV[3])
redis.call('HSET', KEYS[3], ARGV[1], 'queued')
redis.call('HSET', KEYS[4], ARGV[1], '0')
redis.call('HSET', KEYS[5], ARGV[1], tostring(payload_bytes))
redis.call('HSET', KEYS[6], ARGV[2], ARGV[1])
redis.call('HSET', KEYS[7], ARGV[1], ARGV[2])
redis.call('HINCRBY', KEYS[10], 'count', 1)
redis.call('HINCRBY', KEYS[10], 'bytes', payload_bytes)
redis.call('ZADD', KEYS[12], ARGV[9], ARGV[1])
if ARGV[8] == '' and redis.call('EXISTS', KEYS[9]) == 1 then
redis.call('LPUSH', KEYS[8], ARGV[1])
else
redis.call('LPUSH', KEYS[1], ARGV[1])
end
redis.call('LPUSH', KEYS[11], 'admit')
redis.call('LTRIM', KEYS[11], 0, 255)
redis.call('LPUSH', KEYS[13], '1')
redis.call('LTRIM', KEYS[13], 0, 0)
return {'accepted', ARGV[1]}
"""
CLAIM_SCRIPT = """
if redis.call('EXISTS', KEYS[8]) == 0 then
local recovered = 0
while recovered < 1000 do
local deferred_id = redis.call('RPOP', KEYS[7])
if not deferred_id then break end
if redis.call('HGET', KEYS[3], deferred_id) == 'queued' then
redis.call('LPUSH', KEYS[1], deferred_id)
recovered = recovered + 1
end
end
end
while true do
local job_id = redis.call('RPOP', KEYS[1])
if not job_id then return nil end
if redis.call('HGET', KEYS[3], job_id) == 'queued' then
redis.call('HSET', KEYS[3], job_id, 'in_flight')
redis.call('HINCRBY', KEYS[4], job_id, 1)
redis.call('HSET', KEYS[6], job_id, ARGV[1])
redis.call('ZADD', KEYS[5], ARGV[2], job_id)
local payload = redis.call('HGET', KEYS[2], job_id)
redis.call('LPUSH', KEYS[9], 'claim')
redis.call('LTRIM', KEYS[9], 0, 255)
return {job_id, payload or '', redis.call('HGET', KEYS[4], job_id)}
end
end
"""
ACK_SCRIPT = """
if redis.call('HGET', KEYS[3], ARGV[1]) ~= 'in_flight'
or redis.call('HGET', KEYS[6], ARGV[1]) ~= ARGV[2] then
return 0
end
local payload_bytes = tonumber(redis.call('HGET', KEYS[5], ARGV[1]) or '0')
redis.call('ZREM', KEYS[7], ARGV[1])
redis.call('ZREM', KEYS[12], ARGV[1])
redis.call('HDEL', KEYS[6], ARGV[1])
redis.call('HDEL', KEYS[2], ARGV[1])
redis.call('HDEL', KEYS[4], ARGV[1])
redis.call('HDEL', KEYS[5], ARGV[1])
redis.call('HSET', KEYS[3], ARGV[1], 'complete')
redis.call('ZADD', KEYS[10], ARGV[3], ARGV[1])
local count = math.max(0, tonumber(redis.call('HGET', KEYS[9], 'count') or '0') - 1)
local bytes = math.max(0, tonumber(redis.call('HGET', KEYS[9], 'bytes') or '0') - payload_bytes)
redis.call('HSET', KEYS[9], 'count', count, 'bytes', bytes)
redis.call('LPUSH', KEYS[11], 'ack')
redis.call('LTRIM', KEYS[11], 0, 255)
return 1
"""
NACK_SCRIPT = """
if redis.call('HGET', KEYS[3], ARGV[1]) ~= 'in_flight'
or redis.call('HGET', KEYS[6], ARGV[1]) ~= ARGV[2] then
return 'invalid'
end
redis.call('ZREM', KEYS[7], ARGV[1])
redis.call('HDEL', KEYS[6], ARGV[1])
local attempts = tonumber(redis.call('HGET', KEYS[4], ARGV[1]) or '0')
if attempts >= tonumber(ARGV[3]) then
local payload_bytes = tonumber(redis.call('HGET', KEYS[5], ARGV[1]) or '0')
local active_count = math.max(0, tonumber(redis.call('HGET', KEYS[10], 'count') or '0') - 1)
local active_bytes = math.max(0, tonumber(redis.call('HGET', KEYS[10], 'bytes') or '0') - payload_bytes)
redis.call('HSET', KEYS[10], 'count', active_count, 'bytes', active_bytes)
redis.call('ZREM', KEYS[13], ARGV[1])
local dead_count = tonumber(redis.call('HGET', KEYS[10], 'dead_count') or '0')
local dead_bytes = tonumber(redis.call('HGET', KEYS[10], 'dead_bytes') or '0')
if dead_count + 1 > tonumber(ARGV[4]) or dead_bytes + payload_bytes > tonumber(ARGV[5]) then
local dedupe_key = redis.call('HGET', KEYS[12], ARGV[1])
if dedupe_key and redis.call('HGET', KEYS[11], dedupe_key) == ARGV[1] then
redis.call('HDEL', KEYS[11], dedupe_key)
end
redis.call('HDEL', KEYS[2], ARGV[1])
redis.call('HDEL', KEYS[3], ARGV[1])
redis.call('HDEL', KEYS[4], ARGV[1])
redis.call('HDEL', KEYS[5], ARGV[1])
redis.call('HDEL', KEYS[12], ARGV[1])
redis.call('HDEL', KEYS[14], ARGV[1])
redis.call('LPUSH', KEYS[9], 'dead_purged')
redis.call('LTRIM', KEYS[9], 0, 255)
return 'purged'
end
redis.call('HSET', KEYS[3], ARGV[1], 'dead')
redis.call('ZADD', KEYS[8], ARGV[6], ARGV[1])
redis.call('HSET', KEYS[14], ARGV[1], ARGV[7])
redis.call('HSET', KEYS[10], 'dead_count', dead_count + 1, 'dead_bytes', dead_bytes + payload_bytes)
redis.call('LPUSH', KEYS[9], 'dead')
redis.call('LTRIM', KEYS[9], 0, 255)
return 'dead'
end
redis.call('HSET', KEYS[3], ARGV[1], 'queued')
redis.call('LPUSH', KEYS[1], ARGV[1])
redis.call('LPUSH', KEYS[9], 'retry')
redis.call('LTRIM', KEYS[9], 0, 255)
return 'retry'
"""
VERIFY_LEASE_SCRIPT = """
if redis.call('HGET', KEYS[1], ARGV[1]) ~= 'in_flight' then return 0 end
if redis.call('HGET', KEYS[2], ARGV[1]) ~= ARGV[2] then return 0 end
local expires = redis.call('ZSCORE', KEYS[3], ARGV[1])
if not expires or tonumber(expires) <= tonumber(ARGV[3]) then return 0 end
return 1
"""
PURGE_DEAD_SCRIPT = """
if redis.call('HGET', KEYS[2], ARGV[1]) ~= 'dead' then return 0 end
local payload_bytes = tonumber(redis.call('HGET', KEYS[4], ARGV[1]) or '0')
local dedupe_key = redis.call('HGET', KEYS[7], ARGV[1])
redis.call('ZREM', KEYS[1], ARGV[1])
redis.call('HDEL', KEYS[3], ARGV[1])
redis.call('HDEL', KEYS[2], ARGV[1])
redis.call('HDEL', KEYS[5], ARGV[1])
redis.call('HDEL', KEYS[4], ARGV[1])
redis.call('HDEL', KEYS[7], ARGV[1])
redis.call('HDEL', KEYS[9], ARGV[1])
if dedupe_key and redis.call('HGET', KEYS[6], dedupe_key) == ARGV[1] then
redis.call('HDEL', KEYS[6], dedupe_key)
end
local dead_count = math.max(0, tonumber(redis.call('HGET', KEYS[8], 'dead_count') or '0') - 1)
local dead_bytes = math.max(0, tonumber(redis.call('HGET', KEYS[8], 'dead_bytes') or '0') - payload_bytes)
redis.call('HSET', KEYS[8], 'dead_count', dead_count, 'dead_bytes', dead_bytes)
return 1
"""
REQUEUE_DEAD_SCRIPT = """
if redis.call('HGET', KEYS[2], ARGV[1]) ~= 'dead' then return 0 end
local payload_bytes = tonumber(redis.call('HGET', KEYS[4], ARGV[1]) or '0')
local count = tonumber(redis.call('HGET', KEYS[6], 'count') or '0')
local bytes = tonumber(redis.call('HGET', KEYS[6], 'bytes') or '0')
if count + 1 > tonumber(ARGV[2]) or bytes + payload_bytes > tonumber(ARGV[3]) then return -1 end
redis.call('ZREM', KEYS[1], ARGV[1])
redis.call('HDEL', KEYS[8], ARGV[1])
redis.call('HSET', KEYS[2], ARGV[1], 'queued')
redis.call('HSET', KEYS[3], ARGV[1], '0')
redis.call('LPUSH', KEYS[5], ARGV[1])
redis.call('ZADD', KEYS[7], ARGV[4], ARGV[1])
redis.call('HSET', KEYS[6],
'count', count + 1,
'bytes', bytes + payload_bytes,
'dead_count', math.max(0, tonumber(redis.call('HGET', KEYS[6], 'dead_count') or '0') - 1),
'dead_bytes', math.max(0, tonumber(redis.call('HGET', KEYS[6], 'dead_bytes') or '0') - payload_bytes))
return 1
"""
RENEW_SCRIPT = """
if redis.call('HGET', KEYS[1], ARGV[1]) ~= 'in_flight'
or redis.call('HGET', KEYS[2], ARGV[1]) ~= ARGV[2] then
return 0
end
redis.call('ZADD', KEYS[3], ARGV[3], ARGV[1])
return 1
"""
REAP_SCRIPT = """
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
local count = 0
for _, job_id in ipairs(expired) do
redis.call('ZREM', KEYS[1], job_id)
if redis.call('HGET', KEYS[2], job_id) == 'in_flight' then
redis.call('HSET', KEYS[2], job_id, 'queued')
redis.call('HDEL', KEYS[3], job_id)
redis.call('LPUSH', KEYS[4], job_id)
count = count + 1
end
end
if count > 0 then
redis.call('LPUSH', KEYS[5], 'reap')
redis.call('LTRIM', KEYS[5], 0, 255)
end
return count
"""
CLEAN_COMPLETED_SCRIPT = """
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
local count = 0
for _, job_id in ipairs(expired) do
redis.call('ZREM', KEYS[1], job_id)
if redis.call('HGET', KEYS[2], job_id) == 'complete' then
local dedupe_key = redis.call('HGET', KEYS[3], job_id)
if dedupe_key and redis.call('HGET', KEYS[4], dedupe_key) == job_id then
redis.call('HDEL', KEYS[4], dedupe_key)
end
redis.call('HDEL', KEYS[3], job_id)
redis.call('HDEL', KEYS[2], job_id)
count = count + 1
end
end
return count
"""
AUDIT_REPAIR_SCRIPT = """
local rows = redis.call('HGETALL', KEYS[1])
local active_count = 0
local active_bytes = 0
local dead_count = 0
local dead_bytes = 0
local queued_count = 0
local in_flight_count = 0
local retry_attempts = 0
local invalid_states = 0
for index = 1, #rows, 2 do
local job_id = rows[index]
local state = rows[index + 1]
local payload_bytes = tonumber(redis.call('HGET', KEYS[2], job_id) or '0')
local attempts = tonumber(redis.call('HGET', KEYS[3], job_id) or '0')
retry_attempts = retry_attempts + math.max(0, attempts - 1)
if state == 'queued' then
active_count = active_count + 1
active_bytes = active_bytes + payload_bytes
queued_count = queued_count + 1
if ARGV[1] == '1' and not redis.call('ZSCORE', KEYS[10], job_id) then
redis.call('ZADD', KEYS[10], ARGV[2], job_id)
end
elseif state == 'in_flight' then
active_count = active_count + 1
active_bytes = active_bytes + payload_bytes
in_flight_count = in_flight_count + 1
if ARGV[1] == '1' and not redis.call('ZSCORE', KEYS[10], job_id) then
redis.call('ZADD', KEYS[10], ARGV[2], job_id)
end
elseif state == 'dead' then
dead_count = dead_count + 1
dead_bytes = dead_bytes + payload_bytes
if ARGV[1] == '1' then redis.call('ZREM', KEYS[10], job_id) end
elseif state ~= 'complete' then
invalid_states = invalid_states + 1
elseif ARGV[1] == '1' then
redis.call('ZREM', KEYS[10], job_id)
end
end
local old_count = tonumber(redis.call('HGET', KEYS[4], 'count') or '0')
local old_bytes = tonumber(redis.call('HGET', KEYS[4], 'bytes') or '0')
local old_dead_count = tonumber(redis.call('HGET', KEYS[4], 'dead_count') or '0')
local old_dead_bytes = tonumber(redis.call('HGET', KEYS[4], 'dead_bytes') or '0')
if ARGV[1] == '1' then
redis.call('HSET', KEYS[4],
'count', active_count,
'bytes', active_bytes,
'dead_count', dead_count,
'dead_bytes', dead_bytes)
redis.call('LPUSH', KEYS[9], 'repair')
redis.call('LTRIM', KEYS[9], 0, 255)
end
local oldest = redis.call('ZRANGE', KEYS[10], 0, 0, 'WITHSCORES')
return {
old_count, active_count,
old_bytes, active_bytes,
old_dead_count, dead_count,
old_dead_bytes, dead_bytes,
queued_count, in_flight_count,
redis.call('LLEN', KEYS[5]),
redis.call('LLEN', KEYS[6]),
redis.call('ZCARD', KEYS[7]),
retry_attempts,
invalid_states,
redis.call('HLEN', KEYS[8]),
redis.call('ZCARD', KEYS[10]),
oldest[2] or '0'
}
"""
def audit_invariants(client, apply: bool = False) -> dict[str, int | bool]:
values = [
int(value) for value in client.eval(
AUDIT_REPAIR_SCRIPT,
10,
STATES,
BYTES,
ATTEMPTS,
COUNTERS,
READY,
DEFERRED,
DEAD,
TOKENS,
EVENTS,
ENQUEUED,
'1' if apply else '0',
int(time.time() * 1000),
)
]
(
old_count,
actual_count,
old_bytes,
actual_bytes,
old_dead_count,
actual_dead_count,
old_dead_bytes,
actual_dead_bytes,
queued,
in_flight,
ready_entries,
deferred_entries,
dead_entries,
retries,
invalid_states,
token_count,
enqueued_entries,
oldest_enqueued_at_ms,
) = values
return {
'applied': apply,
'recorded_count': old_count,
'actual_count': actual_count,
'count_delta': actual_count - old_count,
'recorded_bytes': old_bytes,
'actual_bytes': actual_bytes,
'bytes_delta': actual_bytes - old_bytes,
'recorded_dead_count': old_dead_count,
'actual_dead_count': actual_dead_count,
'dead_count_delta': actual_dead_count - old_dead_count,
'recorded_dead_bytes': old_dead_bytes,
'actual_dead_bytes': actual_dead_bytes,
'dead_bytes_delta': actual_dead_bytes - old_dead_bytes,
'queued': queued,
'in_flight': in_flight,
'ready_entries': ready_entries,
'deferred_entries': deferred_entries,
'dead_entries': dead_entries,
'retries': retries,
'invalid_states': invalid_states,
'lease_tokens': token_count,
'enqueued_entries': enqueued_entries,
'oldest_enqueued_at_ms': oldest_enqueued_at_ms,
'consistent': (
old_count == actual_count
and old_bytes == actual_bytes
and old_dead_count == actual_dead_count
and old_dead_bytes == actual_dead_bytes
and invalid_states == 0
and enqueued_entries == actual_count
),
}
def _payload_bytes(payload: str) -> int:
return len(payload.encode('utf-8'))
def observation_identity(packet_hash: str, rx_node_id: str) -> tuple[str, str]:
digest = hashlib.sha256(f'observe\0{packet_hash.lower()}\0{rx_node_id.lower()}'.encode()).hexdigest()
return f'lo_{digest}', f'observe:{digest}'
def physical_identity(node_a_id: str, node_b_id: str, generation: str | None = None) -> tuple[str, str, str, str]:
a_id, b_id = sorted((node_a_id, node_b_id))
return f'lp_{uuid.uuid4()}', f'physical:{generation or "live"}:{a_id}:{b_id}', a_id, b_id
def admit(client, job: dict) -> tuple[str, str | None]:
payload = json.dumps(job, separators=(',', ':'), sort_keys=True)
result = client.eval(
ADMIT_SCRIPT, 13,
READY, PAYLOADS, STATES, ATTEMPTS, BYTES, DEDUPE, DEDUPE_BY_JOB,
DEFERRED, REBUILD, COUNTERS, EVENTS, ENQUEUED, WAKE,
job['job_id'], job['dedupe_key'], payload, _payload_bytes(payload),
MAX_JOBS, MAX_BYTES, MAX_PAYLOAD_BYTES, job.get('generation') or '',
int(time.time() * 1000),
)
status = str(result[0])
job_id = str(result[1]) if result[1] else None
return status, job_id
def admit_physical(client, node_a_id: str, node_b_id: str, generation: str | None = None) -> tuple[str, str | None]:
job_id, dedupe_key, a_id, b_id = physical_identity(node_a_id, node_b_id, generation)
job = {
'version': 3, 'type': 'physical_pair', 'job_id': job_id,
'dedupe_key': dedupe_key, 'node_a_id': a_id, 'node_b_id': b_id,
}
if generation:
job['generation'] = generation
return admit(client, job)
def claim(client) -> tuple[str, str, dict, int] | None:
token = secrets.token_hex(16)
result = client.eval(
CLAIM_SCRIPT, 9, READY, PAYLOADS, STATES, ATTEMPTS, LEASES, TOKENS,
DEFERRED, REBUILD, EVENTS,
token, int(time.time() * 1000) + LEASE_MS,
)
if not result:
return None
return str(result[0]), token, json.loads(result[1]), int(result[2])
def ack(client, job_id: str, token: str) -> bool:
result = client.eval(
ACK_SCRIPT, 12,
READY, PAYLOADS, STATES, ATTEMPTS, BYTES, TOKENS, LEASES, DEAD,
COUNTERS, COMPLETED, EVENTS, ENQUEUED,
job_id, token, int(time.time() * 1000) + COMPLETED_RETENTION_MS,
)
return int(result) == 1
def nack(client, job_id: str, token: str, reason: str = 'attempt_limit_exceeded') -> str:
return str(client.eval(
NACK_SCRIPT, 14,
READY, PAYLOADS, STATES, ATTEMPTS, BYTES, TOKENS, LEASES, DEAD, EVENTS,
COUNTERS, DEDUPE, DEDUPE_BY_JOB, ENQUEUED, DEAD_REASONS,
job_id, token, MAX_ATTEMPTS, DEAD_MAX_JOBS, DEAD_MAX_BYTES, int(time.time() * 1000),
str(reason)[:100],
))
def owns_lease(client, job_id: str, token: str) -> bool:
return int(client.eval(
VERIFY_LEASE_SCRIPT, 3, STATES, TOKENS, LEASES,
job_id, token, int(time.time() * 1000),
)) == 1
def purge_dead(client, job_id: str) -> bool:
return int(client.eval(
PURGE_DEAD_SCRIPT, 9,
DEAD, STATES, PAYLOADS, BYTES, ATTEMPTS, DEDUPE, DEDUPE_BY_JOB, COUNTERS,
DEAD_REASONS,
job_id,
)) == 1
def requeue_dead(client, job_id: str) -> str:
result = int(client.eval(
REQUEUE_DEAD_SCRIPT, 8,
DEAD, STATES, ATTEMPTS, BYTES, READY, COUNTERS, ENQUEUED,
DEAD_REASONS,
job_id, MAX_JOBS, MAX_BYTES, int(time.time() * 1000),
))
return 'requeued' if result == 1 else 'full' if result == -1 else 'not_found'
def cleanup_dead(client, limit: int = 100) -> int:
expired = client.zrangebyscore(DEAD, '-inf', int(time.time() * 1000) - DEAD_RETENTION_MS, start=0, num=limit)
return sum(1 for job_id in expired if purge_dead(client, job_id))
def reap(client, limit: int = 100) -> int:
return int(client.eval(
REAP_SCRIPT, 5, LEASES, STATES, TOKENS, READY, EVENTS,
int(time.time() * 1000), limit,
))
def cleanup_completed(client, limit: int = 500) -> int:
return int(client.eval(
CLEAN_COMPLETED_SCRIPT, 4, COMPLETED, STATES, DEDUPE_BY_JOB, DEDUPE,
int(time.time() * 1000), limit,
))
class LeaseRenewer:
def __init__(self, redis_factory, job_id: str, token: str):
self.redis_factory = redis_factory
self.job_id = job_id
self.token = token
self.stop_event = threading.Event()
self.lease_lost = threading.Event()
self.thread = threading.Thread(target=self._run, name=f'link-lease-{job_id[:8]}', daemon=True)
def _run(self):
client = None
last_success = time.monotonic()
backoff = 0.25
while not self.stop_event.wait(max(0.25, min(backoff, LEASE_MS / 3000))):
try:
if client is None:
client = self.redis_factory()
renewed = client.eval(
RENEW_SCRIPT, 3, STATES, TOKENS, LEASES,
self.job_id, self.token, int(time.time() * 1000) + LEASE_MS,
)
if int(renewed) != 1:
self.lease_lost.set()
break
last_success = time.monotonic()
backoff = max(1.0, LEASE_MS / 3000)
except Exception:
if client is not None:
try:
client.close()
except Exception:
pass
client = None
if (time.monotonic() - last_success) * 1000 >= LEASE_MS:
self.lease_lost.set()
break
backoff = min(5.0, backoff * 2)
if client is not None:
client.close()
def assert_owned(self):
if self.lease_lost.is_set():
raise RuntimeError(f'LINK_LEASE_LOST:{self.job_id}')
def __enter__(self):
self.thread.start()
return self
def __exit__(self, exc_type, exc, tb):
self.stop_event.set()
self.thread.join(timeout=5)
self.assert_owned()
def start_worker_heartbeat(redis_factory, stop_event: threading.Event) -> threading.Thread:
def run():
client = None
while not stop_event.is_set():
try:
if client is None:
client = redis_factory()
client.set(WORKER_HEARTBEAT, str(int(time.time())), ex=45)
stop_event.wait(10)
except Exception:
if client is not None:
client.close()
client = None
stop_event.wait(2)
if client is not None:
client.close()
thread = threading.Thread(target=run, name='link-worker-heartbeat', daemon=True)
thread.start()
return thread