Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ The container can be configured using the following environment variables:
| ---------------------- | -------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `IMPORT_MODE` | `db`, `jsonl` | `db` | Selects how the index is built. `db` downloads a prebuilt index for a single region. `jsonl` (**experimental**) builds the index in-container from OpenStreetMap JSONL dumps and supports combining multiple regions. See [Import Modes](#import-modes). |
| `UPDATE_STRATEGY` | `PARALLEL`, `SEQUENTIAL`, `DISABLED` | `SEQUENTIAL` | Controls how index updates are handled. `PARALLEL` downloads the new index in the background then swaps with minimal downtime (requires 2x space). `SEQUENTIAL` stops Photon, deletes the existing index, downloads the new one, then restarts. `DISABLED` prevents automatic updates. Only applies in `db` mode. |
| `UPDATE_INTERVAL` | Time string (e.g., "720h", "30d") | `30d` | How often to check for updates. To reduce server load, it is recommended to set this to a long interval (e.g., `720h` for 30 days) or disable updates altogether if you do not need the latest data. |
| `UPDATE_INTERVAL` | Time string (e.g., "720h", "30d") | `30d` | Maximum age of the local index before a new one is fetched. The age is read from the index itself, so it is not reset by container restarts. Once the index is older than this, the mirror is checked at most once an hour until a newer index is published; nothing is downloaded while the mirror has nothing newer. To reduce server load, set this to a long interval (e.g., `720h` for 30 days) or disable updates altogether if you do not need the latest data. |
| `REGION` | Region name, country code, or `planet` | `planet` | Region for a specific dataset. Can be a continent (`europe`, `asia`), individual country/region (`germany`, `usa`, `japan`), country code (`de`, `us`, `jp`), or `planet` for worldwide data. In `db` mode exactly one region may be set, and it must be one with a prebuilt index. In `jsonl` mode you may pass multiple regions as a comma-separated list. See [Available Regions](#available-regions) section for details. |
| `LANGUAGES` | Comma-separated language codes | - | Only used in `jsonl` mode. Languages to import, passed to Photon's `-languages` (e.g. `en,de,fr`). |
| `EXTRA_TAGS` | Comma-separated OSM tags | - | Only used in `jsonl` mode. Additional OSM tags to import, passed to Photon's `-extra-tags`. |
Expand Down
8 changes: 8 additions & 0 deletions src/index.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import shutil
import time
from pathlib import Path

from src.utils import config
Expand Down Expand Up @@ -38,6 +39,13 @@ def last_updated() -> float:
return os.path.getmtime(config.OS_NODE_DIR)


def age_seconds() -> float:
timestamp = last_updated()
if timestamp == 0.0:
return float("inf")
return max(0.0, time.time() - timestamp)


def mark_updated():
marker_file = _updated_marker()
try:
Expand Down
53 changes: 36 additions & 17 deletions src/process_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@

logger = get_logger()

POLL_TICK_SECONDS = 60
POLL_BACKOFF_SECONDS = 3600


def check_photon_health(timeout=30, max_retries=10) -> bool:
url = "http://localhost:2322/status"
Expand Down Expand Up @@ -70,6 +73,7 @@ def __init__(self):
self.state = AppState.INITIALIZING
self.photon_process = None
self.should_exit = False
self._next_poll_at = 0.0

signal.signal(signal.SIGTERM, self.handle_shutdown)
signal.signal(signal.SIGINT, self.handle_shutdown)
Expand Down Expand Up @@ -246,6 +250,21 @@ def run_update(self):
finally:
self.state = AppState.RUNNING

def _is_update_due(self) -> bool:
elapsed = index.age_seconds()
interval = config.parse_interval(config.UPDATE_INTERVAL)

if elapsed == float("inf"):
logger.info("No index timestamp found, update is due")
return True

if elapsed < interval:
logger.debug(f"Index is {elapsed / 86400:.1f}d old (interval {config.UPDATE_INTERVAL}), not due")
return False

logger.info(f"Index is {elapsed / 86400:.1f}d old (interval {config.UPDATE_INTERVAL}), update due")
return True

def schedule_updates(self):
if config.UPDATE_STRATEGY == "DISABLED":
logger.info("Updates disabled, not scheduling")
Expand All @@ -255,23 +274,11 @@ def schedule_updates(self):
logger.info("Skipping scheduled updates in JSONL mode until rebuild support is implemented")
return

interval = config.UPDATE_INTERVAL.lower()

if interval.endswith("d"):
days = int(interval[:-1])
schedule.every(days).days.do(self.run_update)
logger.info(f"Scheduling updates every {days} days")
elif interval.endswith("h"):
hours = int(interval[:-1])
schedule.every(hours).hours.do(self.run_update)
logger.info(f"Scheduling updates every {hours} hours")
elif interval.endswith("m"):
minutes = int(interval[:-1])
schedule.every(minutes).minutes.do(self.run_update)
logger.info(f"Scheduling updates every {minutes} minutes")
else:
logger.warning(f"Invalid UPDATE_INTERVAL format: {interval}, defaulting to daily")
schedule.every().day.do(self.run_update)
logger.info(
f"Checking index age every {POLL_TICK_SECONDS}s (UPDATE_INTERVAL={config.UPDATE_INTERVAL}, "
f"retry backoff {POLL_BACKOFF_SECONDS}s)"
)
schedule.every(POLL_TICK_SECONDS).seconds.do(self._maybe_update)

def scheduler_loop():
while not self.should_exit:
Expand All @@ -281,6 +288,18 @@ def scheduler_loop():
thread = threading.Thread(target=scheduler_loop, daemon=True)
thread.start()

def _maybe_update(self):
if not self._is_update_due():
return

now = time.monotonic()
if now < self._next_poll_at:
logger.debug(f"Update attempt throttled for another {self._next_poll_at - now:.0f}s")
return

self._next_poll_at = now + POLL_BACKOFF_SECONDS
self.run_update()

def _run_pending_jobs(self):
try:
schedule.run_pending()
Expand Down
8 changes: 8 additions & 0 deletions src/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ def get_jsonl_regions() -> list[str]:
return _get_csv_values(REGION) or []


def parse_interval(interval: str) -> int:
value = interval.strip().lower()
for suffix, seconds in (("d", 86400), ("h", 3600), ("m", 60)):
if value.endswith(suffix) and value[:-1].isdigit():
return int(value[:-1]) * seconds
return 86400


def _get_csv_values(value: str | None) -> list[str] | None:
if not value:
return None
Expand Down
21 changes: 21 additions & 0 deletions tests/test_index.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import time
from pathlib import Path
from unittest.mock import patch

Expand Down Expand Up @@ -52,6 +53,26 @@ def test_last_updated_returns_zero_when_nothing_exists(fake_dirs: Path):
assert index.last_updated() == 0.0


def test_age_seconds_is_infinite_when_nothing_exists(fake_dirs: Path):
assert index.age_seconds() == float("inf")


def test_age_seconds_measures_marker_age(fake_dirs: Path):
marker = fake_dirs / ".photon-index-updated"
marker.write_text("")
mtime = time.time() - 3600
os.utime(marker, (mtime, mtime))
assert index.age_seconds() == pytest.approx(3600, abs=5)


def test_age_seconds_never_negative_for_future_marker(fake_dirs: Path):
marker = fake_dirs / ".photon-index-updated"
marker.write_text("")
mtime = time.time() + 3600
os.utime(marker, (mtime, mtime))
assert index.age_seconds() == 0.0


def test_mark_updated_creates_marker(fake_dirs: Path):
index.mark_updated()
assert (fake_dirs / ".photon-index-updated").exists()
Expand Down
68 changes: 56 additions & 12 deletions tests/test_process_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import os
import signal
import subprocess
import time
from pathlib import Path
from unittest.mock import MagicMock, patch

Expand Down Expand Up @@ -398,29 +400,71 @@ def test_run_pending_jobs_survives_job_exception(
manager._run_pending_jobs()


@pytest.mark.parametrize(("interval", "expected_unit"), [("3d", "days"), ("12h", "hours"), ("30m", "minutes")])
def test_schedule_updates_parses_intervals(
manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, interval: str, expected_unit: str
@pytest.mark.parametrize("interval", ["3d", "12h", "30m"])
def test_schedule_updates_ticks_on_a_fixed_cadence(
manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, interval: str
):
monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL")
monkeypatch.setattr(config, "UPDATE_INTERVAL", interval)
monkeypatch.setattr(process_manager.threading, "Thread", lambda **_: MagicMock(start=lambda: None))
manager.schedule_updates()
jobs = schedule.get_jobs()
assert len(jobs) == 1
assert jobs[0].unit == expected_unit
assert jobs[0].unit == "seconds"
assert jobs[0].interval == process_manager.POLL_TICK_SECONDS


def test_schedule_updates_falls_back_to_daily_on_invalid_interval(
def test_is_update_due_when_no_index_timestamp(
manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(config, "OS_NODE_DIR", str(tmp_path / "photon_data" / "node_1"))
assert manager._is_update_due() is True


@pytest.mark.parametrize(("age_days", "due"), [(31, True), (1, False)])
def test_is_update_due_compares_index_age_to_interval(
manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, age_days: int, due: bool
):
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(config, "UPDATE_INTERVAL", "30d")
marker = tmp_path / ".photon-index-updated"
marker.touch()
mtime = time.time() - age_days * 86400
os.utime(marker, (mtime, mtime))
assert manager._is_update_due() is due


def test_maybe_update_runs_when_due_and_arms_throttle(manager: process_manager.PhotonManager):
with patch.object(manager, "_is_update_due", return_value=True), patch.object(manager, "run_update") as run:
manager._maybe_update()
run.assert_called_once()
assert manager._next_poll_at > time.monotonic()


def test_maybe_update_skips_when_not_due(manager: process_manager.PhotonManager):
with patch.object(manager, "_is_update_due", return_value=False), patch.object(manager, "run_update") as run:
manager._maybe_update()
run.assert_not_called()
assert manager._next_poll_at == 0.0


def test_maybe_update_throttles_repeated_attempts(manager: process_manager.PhotonManager):
with patch.object(manager, "_is_update_due", return_value=True), patch.object(manager, "run_update") as run:
manager._maybe_update()
manager._maybe_update()
manager._maybe_update()
run.assert_called_once()


def test_maybe_update_retries_once_throttle_expires(
manager: process_manager.PhotonManager, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(config, "UPDATE_STRATEGY", "SEQUENTIAL")
monkeypatch.setattr(config, "UPDATE_INTERVAL", "garbage")
monkeypatch.setattr(process_manager.threading, "Thread", lambda **_: MagicMock(start=lambda: None))
manager.schedule_updates()
jobs = schedule.get_jobs()
assert len(jobs) == 1
assert jobs[0].unit == "days"
with patch.object(manager, "_is_update_due", return_value=True), patch.object(manager, "run_update") as run:
manager._maybe_update()
monkeypatch.setattr(process_manager.time, "monotonic", lambda: manager._next_poll_at + 1)
manager._maybe_update()
assert run.call_count == 2


def test_schedule_updates_skipped_when_disabled(
Expand Down
16 changes: 16 additions & 0 deletions tests/utils/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import pytest

from src.utils import config


@pytest.mark.parametrize(
("interval", "expected"),
[("30d", 30 * 86400), ("12h", 12 * 3600), ("30m", 30 * 60), ("720H", 720 * 3600), (" 7d ", 7 * 86400)],
)
def test_parse_interval_returns_seconds(interval: str, expected: int):
assert config.parse_interval(interval) == expected


@pytest.mark.parametrize("interval", ["garbage", "", "d", "30", "30s", "-1d", "1.5d"])
def test_parse_interval_falls_back_to_one_day_without_raising(interval: str):
assert config.parse_interval(interval) == 86400
Loading