Two defects with one shape: an execution reaches a terminal condition and the
orchestrator fails to write it down, so the system's own record disagrees with
what happened. Neither produced an error. Both produced silence.
T-2 -- a restart mid-task unscheduled that task forever.
get_tasks_for_minute excludes any task holding a task_executions row with
status='running'. The row is written before the executor runs and updated
after, so a process dying in between left it 'running' permanently, and the
task was then excluded from every future minute with no error, no alarm and no
log line. It did not fail; it went quiet.
test_example_task had held such a row since 2025-12-07 -- 5916 hours. It is
disabled, so nothing was broken by that instance; the mechanism is the point,
and the exposure is daily, because Watchtower restarts this container at 4 AM
while the config backup starts at 03:05 and runs ~21 minutes.
Startup now reconciles them, where the reasoning is sound by construction: this
process has just begun, so nothing it can see is genuinely running.
Marked 'orphaned', not 'failed'. When the process dies mid-task the work may
well have completed -- a backup that finished and never got to update its row
is indistinguishable from one that died halfway -- and 'failed' would assert an
outcome nobody observed. Same error as the health-report diagnostic fixed in
18be804: naming a cause you did not witness.
Not extended to a duration-based sweep. While this process lives, execute_task's
finally clause always closes the row, so a stale row implies a dead owner. A
time-based rule would have to tell a slow task from a dead one, and getting that
wrong closes the record of a task still working.
T-3 -- the 'timeout' status was unreachable.
execute_task has an `except asyncio.TimeoutError` branch that records
status='timeout'. It could never run: _run_executor wrapped the awaited call in
`except Exception`, and since 3.11 asyncio.TimeoutError IS the builtin
TimeoutError (OSError -> Exception), so the broad handler caught it first and
converted it to an ordinary error tuple. Confirmed in the deployed runtime and
against the history -- 18,785 executions since 2025-12-07, of which 'timeout'
rows: zero. Every timeout in eight months was filed as a generic failure,
erasing the distinction between "too slow for its window" and "broken".
A narrower except after a broader one is dead code, and no linter is configured
here to say so.
One trap in fixing it: while the branch was unreachable a timeout travelled the
normal path, which DOES update scheduled_tasks. Making the branch reachable
without that write would have traded a wrong status for a stale one, so
_update_task_outcome now mirrors terminal outcomes onto the parent row.
The timeout message also states that the work may still be running -- after
T-74 executors are handed to asyncio.to_thread, and a thread cannot be
cancelled, so wait_for frees the loop while the work continues.
Both fixes are mutation-checked: removing the startup call fails the ordering
test, removing the narrow except clause fails the propagation test. Suite goes
118 -> 126 passing with the same 36 pre-existing failures.
Co-Authored-By: Claude <noreply@anthropic.com>
Scheduler Tests
Comprehensive test suite for The Scheduler using pytest.
Test Structure
tests/
├── conftest.py # Shared fixtures and configuration
├── test_api.py # API endpoint tests
├── test_example_executor.py # Example executor tests
├── test_doc_sync_executor.py # Documentation sync executor tests
└── README.md # This file
Running Tests
Run all tests
docker exec scheduler pytest
Run with coverage report
docker exec scheduler pytest --cov=src --cov-report=term-missing
Run specific test file
docker exec scheduler pytest tests/test_api.py
Run specific test class
docker exec scheduler pytest tests/test_api.py::TestHealthEndpoint
Run specific test
docker exec scheduler pytest tests/test_api.py::TestHealthEndpoint::test_health_endpoint_returns_healthy
Run tests by marker
# Run only unit tests
docker exec scheduler pytest -m unit
# Run only API tests
docker exec scheduler pytest -m api
# Run only executor tests
docker exec scheduler pytest -m executor
# Run only fast tests (exclude slow)
docker exec scheduler pytest -m "not slow"
Run with verbose output
docker exec scheduler pytest -v
Run with detailed failure output
docker exec scheduler pytest -vv --tb=long
Stop on first failure
docker exec scheduler pytest -x
Test Markers
Tests are organized with pytest markers:
@pytest.mark.unit- Fast unit tests with no external dependencies@pytest.mark.integration- Integration tests (may use database)@pytest.mark.api- API endpoint tests@pytest.mark.executor- Task executor tests@pytest.mark.slow- Tests that take a while to run
Coverage Reports
After running tests with coverage:
- Terminal: Shows missing lines in terminal output
- HTML: Open
htmlcov/index.htmlin browser for detailed report - JSON: Machine-readable coverage data in
coverage.json
View HTML coverage report:
# From host machine
open /home/jpmschweitzer/docker-data/scheduler/htmlcov/index.html
Writing New Tests
Test File Naming
- Test files must start with
test_ - Place in
tests/directory - Use descriptive names:
test_<module_name>.py
Test Function Naming
- Test functions must start with
test_ - Use descriptive names:
test_<what_is_being_tested>
Using Fixtures
Fixtures are defined in conftest.py:
def test_something(test_settings, auth_headers):
# Use fixtures as function parameters
assert test_settings.app_name == "Test Scheduler"
Adding Markers
@pytest.mark.unit
@pytest.mark.api
def test_health_endpoint(client):
response = client.get("/health")
assert response.status_code == 200
Async Tests
@pytest.mark.asyncio
async def test_async_function():
result = await some_async_function()
assert result is not None
Mocking
from unittest.mock import patch, MagicMock
def test_with_mock():
with patch('module.function') as mock_func:
mock_func.return_value = "mocked"
result = call_function_that_uses_it()
assert result == "mocked"
Continuous Integration
These tests are designed to run in CI pipelines:
# Example GitHub Actions
- name: Run tests
run: |
docker exec scheduler pytest --cov=src --cov-report=xml
docker exec scheduler pytest --cov=src --cov-report=html
Test Database
Tests use mocked database connections by default. For integration tests that require a real database:
- Set up a test database
- Use
POSTGRES_DB=test_schedulerenvironment variable - Run migrations before tests
- Clean up after tests
Troubleshooting
Tests fail with "ModuleNotFoundError"
# Install test dependencies
docker exec scheduler /venv/bin/pip install -r requirements.txt
Tests fail with database errors
- Tests use mocked connections by default
- Check that mocks are properly set up in conftest.py
- For integration tests, ensure test database exists
Coverage not working
# Reinstall pytest-cov
docker exec scheduler /venv/bin/pip install --upgrade pytest-cov
Best Practices
- Fast by default: Unit tests should be fast (<1s each)
- Isolated: Tests should not depend on each other
- Descriptive: Test names should clearly describe what they test
- Single assertion: Test one thing per test when possible
- Use fixtures: Reuse common setup via fixtures
- Mock external deps: Don't hit real databases/APIs in unit tests
- Mark appropriately: Use markers to categorize tests
Current Coverage
Run this to see current coverage:
docker exec scheduler pytest --cov=src --cov-report=term
Target: 80%+ code coverage for critical paths