Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 1 addition & 23 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,29 +108,7 @@ jobs:
- Do not invent labels
- If "risk-high" is present, do not include "safe-small-change"
- If tests already sufficiently cover the change, do not include "needs-test"
claude_args: |
--json-schema {
"type": "object",
"properties": {
"labels": {
"type": "array",
"items": {
"type": "string",
"enum": [
"needs-test",
"risk-high",
"needs-human-review",
"safe-small-change"
]
},
"uniqueItems": true
},
"summary": {
"type": "string"
}
},
"required": ["labels"]
}
claude_args: '--json-schema {"type":"object","properties":{"labels":{"type":"array","items":{"type":"string","enum":["needs-test","risk-high","needs-human-review","safe-small-change"]},"uniqueItems":true},"summary":{"type":"string"}},"required":["labels"]}'

- name: Debug structured output
env:
Expand Down
6 changes: 0 additions & 6 deletions chronis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@
scheduler.resume_job("daily-report")
scheduler.cancel_job("daily-report")
"""

# Core classes
from chronis.adapters.lock import InMemoryLock
from chronis.adapters.storage import InMemoryStorage
from chronis.core import (
Expand All @@ -63,16 +61,12 @@
)

__all__ = [
# Core
"PollingScheduler",
"JobInfo",
"JobStatus",
# Exceptions
"SchedulerError",
"JobAlreadyExistsError",
"JobNotFoundError",
# Storage Adapters
"InMemoryStorage",
# Lock Adapters
"InMemoryLock",
]
27 changes: 2 additions & 25 deletions chronis/adapters/lock/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,11 @@ def __init__(self) -> None:

Logs a warning to remind developers this is not for production use.
"""
# Store locks as: lock_key -> (owner_id, expiry_time)
self._locks: dict[str, tuple[str, float]] = {}
self._mutex = threading.Lock() # Global lock for _locks dict
# Store conditions for blocking: lock_key -> Condition
self._mutex = threading.Lock()
self._conditions: dict[str, threading.Condition] = {}
self.instance_token = str(uuid.uuid4()) # Unique instance identifier
self.instance_token = str(uuid.uuid4())

# Warn about production usage
logger.warning(
"InMemoryLock is for testing/development only. "
"Use RedisLock or DynamoDBLockAdapter in production."
Expand Down Expand Up @@ -97,45 +94,35 @@ def acquire(
token = owner_id or self.instance_token
expiry_time = time.time() + ttl_seconds

# Ensure condition exists
with self._mutex:
if lock_key not in self._conditions:
self._conditions[lock_key] = threading.Condition(self._mutex)

condition = self._conditions[lock_key]

# Acquire lock with condition
with condition:
# Clean up expired lock
self._cleanup_expired_lock(lock_key)

# Try to acquire
if lock_key not in self._locks:
self._locks[lock_key] = (token, expiry_time)
return True

if not blocking:
return False

# Blocking mode: wait with timeout
start_time = time.time()
remaining_timeout = timeout

while lock_key in self._locks:
# Check timeout
if timeout is not None:
elapsed = time.time() - start_time
remaining_timeout = timeout - elapsed
if remaining_timeout <= 0:
return False

# Wait for signal (released by release())
condition.wait(timeout=remaining_timeout)

# Clean up expired lock
self._cleanup_expired_lock(lock_key)

# Retry acquisition
if lock_key not in self._locks:
self._locks[lock_key] = (token, expiry_time)
return True
Expand Down Expand Up @@ -170,15 +157,10 @@ def release(self, lock_key: str, owner_id: str | None = None) -> bool:
return False

stored_owner, _ = self._locks[lock_key]

# Verify ownership
if stored_owner != token:
return False

# Release lock
del self._locks[lock_key]

# Signal one waiting thread
condition.notify()

return True
Expand Down Expand Up @@ -208,19 +190,15 @@ def extend(
new_expiry_time = time.time() + ttl_seconds

with self._mutex:
# Clean up expired lock before extending
self._cleanup_expired_lock(lock_key)

if lock_key not in self._locks:
return False

stored_owner, _ = self._locks[lock_key]

# Verify ownership
if stored_owner != token:
return False

# Extend TTL
self._locks[lock_key] = (stored_owner, new_expiry_time)
return True

Expand All @@ -244,7 +222,6 @@ def reset(self, lock_key: str) -> bool:
with condition:
if lock_key in self._locks:
del self._locks[lock_key]
# Signal all waiting threads
condition.notify_all()
return True
return False
Expand Down
3 changes: 0 additions & 3 deletions chronis/adapters/storage/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,11 @@ def compare_and_swap_job(

current_job = self._jobs[job_id]

# Compare: Check if all expected values match current values
for field, expected_value in expected_values.items():
current_value = current_job.get(field)
if current_value != expected_value:
# Mismatch - return failure without updating
return (False, None)

# Swap: All expectations matched, apply updates
self._jobs[job_id].update(updates) # type: ignore[typeddict-item]
self._jobs[job_id]["updated_at"] = utc_now().isoformat() # type: ignore[typeddict-item]
return (True, self._jobs[job_id].copy()) # type: ignore[return-value]
Expand Down
25 changes: 9 additions & 16 deletions chronis/contrib/adapters/lock/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,26 +112,22 @@ def acquire(
signal_key = f"{full_key}:signal"
token = owner_id or self.instance_token

# Try to acquire lock
result = self.redis.set(
name=full_key,
value=token, # Store owner token for verification
value=token,
nx=True,
ex=ttl_seconds,
)

if result or not blocking:
return bool(result)

# Blocking mode: wait for signal
start_time = time.time()
remaining_timeout = timeout

while True:
# Wait for signal using BLPOP (efficient, no spinloop)
result = self.redis.blpop(signal_key, timeout=remaining_timeout or 0)

# Retry acquisition
acquired = self.redis.set(
name=full_key,
value=token,
Expand All @@ -142,7 +138,6 @@ def acquire(
if acquired:
return True

# Check timeout
if timeout is not None:
elapsed = time.time() - start_time
remaining_timeout = timeout - elapsed
Expand All @@ -169,13 +164,12 @@ def release(self, lock_key: str, owner_id: str | None = None) -> bool:
signal_key = f"{full_key}:signal"
token = owner_id or self.instance_token

# Atomic release with ownership verification using Lua
result = self.redis.eval(
LUA_RELEASE_SCRIPT,
2, # num keys
full_key, # KEYS[1]
signal_key, # KEYS[2]
token, # ARGV[1]
2,
full_key,
signal_key,
token,
)

return result == 1
Expand Down Expand Up @@ -204,13 +198,12 @@ def extend(
full_key = f"{self.key_prefix}{lock_key}"
token = owner_id or self.instance_token

# Atomic extend with ownership verification using Lua
result = self.redis.eval(
LUA_EXTEND_SCRIPT,
1, # num keys
full_key, # KEYS[1]
token, # ARGV[1]
ttl_seconds, # ARGV[2]
1,
full_key,
token,
ttl_seconds,
)

return result == 1
Expand Down
17 changes: 0 additions & 17 deletions chronis/contrib/adapters/storage/postgres/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,13 @@ def update_job(self, job_id: str, updates: JobUpdateData) -> JobStorageData:
Raises:
ValueError: If job not found
"""
# Get current data
job_data = self.get_job(job_id)
if job_data is None:
raise ValueError(f"Job {job_id} not found")

# Merge updates
job_data.update(updates) # type: ignore[typeddict-item]
job_data["updated_at"] = utc_now().isoformat() # type: ignore[typeddict-item]

# Update in database
with self.conn.cursor() as cursor:
cursor.execute(
sql.SQL("""
Expand Down Expand Up @@ -248,12 +245,10 @@ def compare_and_swap_job(
Raises:
ValueError: If job_id not found
"""
# Get current data
job_data = self.get_job(job_id)
if job_data is None:
raise ValueError(f"Job {job_id} not found")

# Build WHERE conditions dynamically
where_conditions = ["job_id = %s"]
where_params: list[Any] = [job_id]

Expand All @@ -265,17 +260,14 @@ def compare_and_swap_job(
where_conditions.append("next_run_time = %s")
where_params.append(expected_value)
else:
# For other fields, check JSONB data
where_conditions.append("data->>%s = %s")
where_params.append(field)
where_params.append(str(expected_value))

# Merge updates
updated_job_data = job_data.copy()
updated_job_data.update(updates) # type: ignore[typeddict-item]
updated_job_data["updated_at"] = utc_now().isoformat() # type: ignore[typeddict-item]

# Atomic UPDATE with WHERE conditions
with self.conn.cursor() as cursor:
where_clause = " AND ".join(where_conditions)
query = sql.SQL("""
Expand Down Expand Up @@ -307,7 +299,6 @@ def compare_and_swap_job(
self.conn.commit()

if rows_affected == 0:
# No rows updated - expectations didn't match
return (False, None)

return (True, updated_job_data)
Expand Down Expand Up @@ -344,12 +335,10 @@ def query_jobs(
params: list[Any] = []

if filters:
# Status filter
if "status" in filters:
conditions.append("status = %s")
params.append(filters["status"])

# Time filter
if "next_run_time_lte" in filters:
conditions.append("next_run_time <= %s")
params.append(filters["next_run_time_lte"])
Expand All @@ -358,22 +347,18 @@ def query_jobs(
conditions.append("updated_at <= %s")
params.append(filters["updated_at_lte"])

# Metadata filters (JSONB containment)
for key, value in filters.items():
if key.startswith("metadata."):
metadata_key = key.replace("metadata.", "")
# Use JSONB @> operator for containment
conditions.append("metadata @> %s::jsonb")
params.append(json.dumps({metadata_key: value}))

# Build WHERE clause safely
where_clause = (
sql.SQL("WHERE {}").format(sql.SQL(" AND ").join(sql.SQL(c) for c in conditions))
if conditions
else sql.SQL("")
)

# Build main query with sql.Identifier for table name
query = sql.SQL("""
SELECT data
FROM {}
Expand All @@ -384,15 +369,13 @@ def query_jobs(
where_clause,
)

# Add LIMIT and OFFSET as parameterized values
if limit:
query += sql.SQL(" LIMIT %s")
params.append(limit)
if offset:
query += sql.SQL(" OFFSET %s")
params.append(offset)

# Execute query
with self.conn.cursor() as cursor:
cursor.execute(query, params)
rows = cursor.fetchall()
Expand Down
Loading
Loading