Initial commit
This commit is contained in:
commit
fe5ef9132a
15 changed files with 888 additions and 0 deletions
155
.gitignore
vendored
Normal file
155
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
|
||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|
||||||
|
### Python ###
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
pytestdebug.log
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
doc/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
pythonenv*
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# profiling data
|
||||||
|
.prof
|
||||||
|
|
||||||
|
# Db and static files
|
||||||
|
*.sqlite3
|
||||||
|
/media
|
||||||
|
/static
|
||||||
|
/postgres
|
||||||
|
/EXAMPLE_*
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
docker-compose.override
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/python
|
||||||
89
README.md
Normal file
89
README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# yksa-source-poller
|
||||||
|
|
||||||
|
The machine that polls upstreams on a schedule: a source model, a run log, the
|
||||||
|
dispatcher and the bookkeeping around one attempt. The cargo — what a fetched
|
||||||
|
record *is* and what happens to it — stays in the service.
|
||||||
|
|
||||||
|
Extracted from `tdas/yksa_tdas/sources/` and `tle/yksa_tle/sources/`, which were
|
||||||
|
the same ~500 lines with different payload types.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
yksa-source-poller @ git+https://git.intra.yksa.space/web/yksa-source-poller.git@v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires [yksa-django-kit](../yksa-django-kit) for `RunStatus` and `read_secret`.
|
||||||
|
It is **not** added to `INSTALLED_APPS`: every model in it is abstract, so it
|
||||||
|
ships no tables and needs no app config.
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```python
|
||||||
|
# sources/models.py
|
||||||
|
class TelemetrySource(AbstractPollingSource):
|
||||||
|
SUBSCRIPTION_ID_FIELDS = {"norad": "norad_cat_id", "satnogs": "satnogs_id"}
|
||||||
|
|
||||||
|
kind = models.CharField(max_length=32, choices=SourceKind.choices)
|
||||||
|
subscription_id_kind = models.CharField(..., default=SubscriptionIdKind.SATNOGS)
|
||||||
|
tracked_satellites = models.ManyToManyField("satellites.Satellite", blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRun(AbstractSourceRun):
|
||||||
|
source = models.ForeignKey(TelemetrySource, on_delete=models.CASCADE,
|
||||||
|
related_name="runs")
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# sources/tasks.py
|
||||||
|
POLLER = Poller(
|
||||||
|
source_model=TelemetrySource,
|
||||||
|
run_model=SourceRun,
|
||||||
|
adapters=ADAPTERS,
|
||||||
|
persist=_persist_frame, # -> True when the record is new
|
||||||
|
track=_track_latest_rx, # optional: extra source fields on success
|
||||||
|
unpollable_kinds=(SourceKind.INTERNAL_PUSH,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3, default_retry_delay=120)
|
||||||
|
def run_source_task(self, source_id):
|
||||||
|
try:
|
||||||
|
return run_source(POLLER, source_id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise self.retry(exc=exc)
|
||||||
|
```
|
||||||
|
|
||||||
|
The Celery tasks stay in the service on purpose: `CELERY_BEAT_SCHEDULE` names
|
||||||
|
them by dotted path, and a task that moved into this package would silently stop
|
||||||
|
being scheduled.
|
||||||
|
|
||||||
|
## Why the models are abstract
|
||||||
|
|
||||||
|
The two concrete source models differ where they have to — which satellite model
|
||||||
|
the M2M points at, which identifiers the upstream accepts, and (in tdas) a
|
||||||
|
`last_rx_at` watermark. Everything else was identical. Abstract bases share the
|
||||||
|
identical part without pretending the rest is the same.
|
||||||
|
|
||||||
|
Field definitions here reproduce what the services already had, so adopting the
|
||||||
|
package needs **no migration**. Where a service's own definition differed — a
|
||||||
|
different `poll_interval_sec` default, different wording — the service overrides
|
||||||
|
the field, which Django allows for abstract inheritance. Check with
|
||||||
|
`manage.py makemigrations --check --dry-run` after wiring it up.
|
||||||
|
|
||||||
|
## What the runner guarantees
|
||||||
|
|
||||||
|
Every exit path — success, failure, unconfigured adapter, missing adapter —
|
||||||
|
writes `last_status`, updates a counter and re-arms `next_poll_at`. That is the
|
||||||
|
reason to share it: a path that forgets leaves the dispatcher either hammering
|
||||||
|
the source once a minute or ignoring it forever, and both shipped once.
|
||||||
|
|
||||||
|
`NotImplementedError` from an adapter is a *skip*, not a failure: a half-configured
|
||||||
|
source must not look like an outage.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install -e ".[test]" ../yksa-django-kit
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
28
pyproject.toml
Normal file
28
pyproject.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "yksa-source-poller"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Polling framework for YKSA ingest services: sources, runs, dispatch"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
license = { text = "Proprietary" }
|
||||||
|
dependencies = [
|
||||||
|
"Django>=5.2",
|
||||||
|
"celery>=5.4",
|
||||||
|
"yksa-django-kit>=0.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-django>=4.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["yksa_poller*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||||
|
python_files = ["test_*.py"]
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
19
tests/settings.py
Normal file
19
tests/settings.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
SECRET_KEY = "test-only"
|
||||||
|
DEBUG = True
|
||||||
|
USE_TZ = True
|
||||||
|
PRODUCTION = False
|
||||||
|
|
||||||
|
YKSA_SERVICE = "poller"
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"yksa_kit",
|
||||||
|
"tests.testapp",
|
||||||
|
]
|
||||||
|
|
||||||
|
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
|
||||||
|
|
||||||
|
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||||
|
|
||||||
|
CELERY_TASK_ALWAYS_EAGER = True
|
||||||
169
tests/test_runner.py
Normal file
169
tests/test_runner.py
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"""What the runner has to guarantee: every exit leaves the source schedulable.
|
||||||
|
|
||||||
|
The bugs this suite exists for are all the same shape — a poll that ends without
|
||||||
|
re-arming next_poll_at, so the dispatcher either hammers the source every minute
|
||||||
|
or drops it forever.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from yksa_kit.choices import RunStatus
|
||||||
|
from yksa_poller.adapters import AdapterRegistry, BaseAdapter
|
||||||
|
from yksa_poller.runner import Poller, cleanup_runs, due_source_ids, run_source
|
||||||
|
|
||||||
|
from tests.testapp.models import Kind, Run, Satellite, Source
|
||||||
|
|
||||||
|
ADAPTERS = AdapterRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
@ADAPTERS.register(Kind.HTTP)
|
||||||
|
class TwoRecords(BaseAdapter):
|
||||||
|
def fetch(self):
|
||||||
|
yield {"id": 1}
|
||||||
|
yield {"id": 2}
|
||||||
|
|
||||||
|
|
||||||
|
class Exploding(BaseAdapter):
|
||||||
|
def fetch(self):
|
||||||
|
raise RuntimeError("upstream on fire")
|
||||||
|
yield # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
|
class Unconfigured(BaseAdapter):
|
||||||
|
def fetch(self):
|
||||||
|
raise NotImplementedError("no credentials for this source")
|
||||||
|
yield # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
|
def poller(adapters=None, **kwargs):
|
||||||
|
return Poller(
|
||||||
|
source_model=Source,
|
||||||
|
run_model=Run,
|
||||||
|
adapters=adapters if adapters is not None else ADAPTERS,
|
||||||
|
persist=kwargs.pop("persist", lambda src, rec: True),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def source(db):
|
||||||
|
return Source.objects.create(name="Upstream", slug="upstream", kind=Kind.HTTP)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_a_successful_poll_counts_and_reschedules(source):
|
||||||
|
before = source.next_poll_at
|
||||||
|
result = run_source(poller(), source.pk)
|
||||||
|
|
||||||
|
assert result["fetched"] == 2
|
||||||
|
assert result["new"] == 2
|
||||||
|
source.refresh_from_db()
|
||||||
|
assert source.last_status == RunStatus.SUCCESS
|
||||||
|
assert source.success_count == 1
|
||||||
|
assert source.next_poll_at > before
|
||||||
|
assert source.runs.get().status == RunStatus.SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_only_new_records_are_counted_as_new(source):
|
||||||
|
result = run_source(poller(persist=lambda src, rec: rec["id"] == 1), source.pk)
|
||||||
|
assert (result["fetched"], result["new"]) == (2, 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_a_failing_poll_still_reschedules_and_reraises(source):
|
||||||
|
before = source.next_poll_at
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
run_source(poller(adapters={Kind.HTTP: Exploding}), source.pk)
|
||||||
|
|
||||||
|
source.refresh_from_db()
|
||||||
|
assert source.last_status == RunStatus.FAILED
|
||||||
|
assert source.failure_count == 1
|
||||||
|
assert source.next_poll_at > before
|
||||||
|
assert "upstream on fire" in source.runs.get().error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_an_unconfigured_adapter_is_skipped_not_failed(source):
|
||||||
|
"""A source nobody finished configuring must not look like an outage."""
|
||||||
|
result = run_source(poller(adapters={Kind.HTTP: Unconfigured}), source.pk)
|
||||||
|
|
||||||
|
assert result["status"] == RunStatus.SKIPPED
|
||||||
|
source.refresh_from_db()
|
||||||
|
assert source.failure_count == 0
|
||||||
|
assert source.runs.get().status == RunStatus.SKIPPED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_a_kind_with_no_adapter_is_skipped(source):
|
||||||
|
source.kind = Kind.PUSH
|
||||||
|
source.save()
|
||||||
|
result = run_source(poller(), source.pk)
|
||||||
|
assert result["status"] == RunStatus.SKIPPED
|
||||||
|
assert "no adapter" in source.runs.get().error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_a_disabled_source_does_nothing_at_all(source):
|
||||||
|
source.is_enabled = False
|
||||||
|
source.save()
|
||||||
|
assert run_source(poller(), source.pk)["skipped"] is True
|
||||||
|
assert source.runs.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_track_carries_extra_fields_onto_the_source(source):
|
||||||
|
"""tdas uses this for last_rx_at, the lower bound of its next query."""
|
||||||
|
stamp = timezone.now()
|
||||||
|
|
||||||
|
def track(src, record, extra):
|
||||||
|
extra["last_run_at"] = stamp
|
||||||
|
|
||||||
|
run_source(poller(track=track), source.pk)
|
||||||
|
source.refresh_from_db()
|
||||||
|
assert source.last_run_at == stamp
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_dispatch_picks_only_due_enabled_pollable_sources():
|
||||||
|
past = timezone.now() - timedelta(minutes=1)
|
||||||
|
future = timezone.now() + timedelta(hours=1)
|
||||||
|
due = Source.objects.create(name="due", slug="due", kind=Kind.HTTP, next_poll_at=past)
|
||||||
|
Source.objects.create(name="later", slug="later", kind=Kind.HTTP, next_poll_at=future)
|
||||||
|
Source.objects.create(name="off", slug="off", kind=Kind.HTTP, next_poll_at=past, is_enabled=False)
|
||||||
|
Source.objects.create(name="pushed", slug="pushed", kind=Kind.PUSH, next_poll_at=past)
|
||||||
|
|
||||||
|
ids = due_source_ids(poller(unpollable_kinds=(Kind.PUSH,)))
|
||||||
|
assert ids == [due.pk]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_cleanup_deletes_only_old_runs(source):
|
||||||
|
fresh = Run.objects.create(source=source, status=RunStatus.SUCCESS)
|
||||||
|
old = Run.objects.create(source=source, status=RunStatus.SUCCESS)
|
||||||
|
# started_at is auto_now_add, so age has to be forced.
|
||||||
|
Run.objects.filter(pk=old.pk).update(
|
||||||
|
started_at=timezone.now() - timedelta(days=40)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert cleanup_runs(Run, retention_days=30)["deleted"] == 1
|
||||||
|
assert list(Run.objects.values_list("pk", flat=True)) == [fresh.pk]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_subscription_ids_skip_satellites_missing_the_identifier(source):
|
||||||
|
"""An upstream query built from a blank identifier returns either nothing or
|
||||||
|
everything, and both are wrong."""
|
||||||
|
with_norad = Satellite.objects.create(norad_cat_id="25544", internal_id="iss")
|
||||||
|
Satellite.objects.create(norad_cat_id="", internal_id="unlaunched")
|
||||||
|
source.tracked_satellites.set(Satellite.objects.all())
|
||||||
|
|
||||||
|
assert source.subscription_id_values() == [with_norad.norad_cat_id]
|
||||||
|
|
||||||
|
source.subscription_id_kind = "internal"
|
||||||
|
assert sorted(source.subscription_id_values()) == ["iss", "unlaunched"]
|
||||||
0
tests/testapp/__init__.py
Normal file
0
tests/testapp/__init__.py
Normal file
0
tests/testapp/migrations/__init__.py
Normal file
0
tests/testapp/migrations/__init__.py
Normal file
27
tests/testapp/models.py
Normal file
27
tests/testapp/models.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""A minimal service, standing in for tdas and odms."""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from yksa_poller.models import AbstractPollingSource, AbstractSourceRun
|
||||||
|
|
||||||
|
|
||||||
|
class Kind(models.TextChoices):
|
||||||
|
HTTP = "http", "HTTP"
|
||||||
|
PUSH = "push", "Push (fed from outside)"
|
||||||
|
|
||||||
|
|
||||||
|
class Satellite(models.Model):
|
||||||
|
norad_cat_id = models.CharField(max_length=16, blank=True)
|
||||||
|
internal_id = models.SlugField(max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class Source(AbstractPollingSource):
|
||||||
|
SUBSCRIPTION_ID_FIELDS = {"norad": "norad_cat_id", "internal": "internal_id"}
|
||||||
|
|
||||||
|
kind = models.CharField(max_length=32, choices=Kind.choices)
|
||||||
|
subscription_id_kind = models.CharField(max_length=16, default="norad")
|
||||||
|
tracked_satellites = models.ManyToManyField(Satellite, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Run(AbstractSourceRun):
|
||||||
|
source = models.ForeignKey(Source, on_delete=models.CASCADE, related_name="runs")
|
||||||
1
yksa_poller/__init__.py
Normal file
1
yksa_poller/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
__version__ = "0.1.0"
|
||||||
62
yksa_poller/adapters.py
Normal file
62
yksa_poller/adapters.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""What an adapter is, and how the runner finds one.
|
||||||
|
|
||||||
|
An adapter does I/O and parsing and nothing else: it yields records, and the
|
||||||
|
runner matches, dedups and writes them. Keeping the split means an adapter can be
|
||||||
|
tested against a captured payload with no database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAdapter:
|
||||||
|
"""Subclasses implement :meth:`fetch`.
|
||||||
|
|
||||||
|
The payload type is the service's own — a frame in tdas, an element set in
|
||||||
|
odms. That is the cargo, not the machine, so it is not defined here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, source) -> None:
|
||||||
|
self.source = source
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> dict:
|
||||||
|
return self.source.config or {}
|
||||||
|
|
||||||
|
def api_key(self) -> str | None:
|
||||||
|
"""This source's API key, or None.
|
||||||
|
|
||||||
|
An inline ``api_key`` in the config wins; otherwise ``api_key_secret``
|
||||||
|
names a Docker secret or env var. Production keeps the key out of the
|
||||||
|
database that way.
|
||||||
|
"""
|
||||||
|
direct = self.config.get("api_key")
|
||||||
|
if direct:
|
||||||
|
return str(direct).strip()
|
||||||
|
secret_name = self.config.get("api_key_secret")
|
||||||
|
if secret_name:
|
||||||
|
from yksa_kit.settings import read_secret
|
||||||
|
|
||||||
|
value = read_secret(str(secret_name))
|
||||||
|
return value.strip() if value else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch(self) -> Iterator:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterRegistry(dict):
|
||||||
|
"""`kind` -> adapter class, with a decorator for declaring one.
|
||||||
|
|
||||||
|
A kind with no adapter is legitimate — tdas's `internal_push` is fed by an
|
||||||
|
API, odms's `internal_corrected` by a pipeline — and the runner records those
|
||||||
|
as skipped rather than failed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def register(self, kind: str):
|
||||||
|
def decorator(cls):
|
||||||
|
self[kind] = cls
|
||||||
|
return cls
|
||||||
|
|
||||||
|
return decorator
|
||||||
58
yksa_poller/admin.py
Normal file
58
yksa_poller/admin.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Admin building blocks. A service still registers its own models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class SourceAdminMixin:
|
||||||
|
"""The columns, filters and the run-now action every source list needs.
|
||||||
|
|
||||||
|
``run_task`` is the service's Celery task. It is queued rather than run
|
||||||
|
inline so a slow upstream cannot hold the admin request open.
|
||||||
|
"""
|
||||||
|
|
||||||
|
run_task = None
|
||||||
|
|
||||||
|
list_filter = ("kind", "is_enabled", "is_public", "last_status")
|
||||||
|
search_fields = ("name", "slug", "description")
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
filter_horizontal = ("tracked_satellites",)
|
||||||
|
readonly_fields = (
|
||||||
|
"last_run_at", "last_status", "last_error",
|
||||||
|
"success_count", "failure_count", "created_at", "updated_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description=_("Run selected sources now"))
|
||||||
|
def run_now(self, request, queryset):
|
||||||
|
if self.run_task is None: # pragma: no cover - configuration error
|
||||||
|
raise NotImplementedError("set run_task on the ModelAdmin")
|
||||||
|
queued = 0
|
||||||
|
for src in queryset:
|
||||||
|
self.run_task.delay(src.pk)
|
||||||
|
queued += 1
|
||||||
|
messages.success(request, _("Queued %(n)d source(s) for fetch.") % {"n": queued})
|
||||||
|
|
||||||
|
actions = ["run_now"]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRunAdminMixin:
|
||||||
|
"""Run history is a log: readable, filterable, never editable."""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
"source", "started_at", "finished_at", "status", "fetched_count", "new_count",
|
||||||
|
)
|
||||||
|
list_filter = ("status", "source")
|
||||||
|
search_fields = ("source__name", "source__slug")
|
||||||
|
readonly_fields = (
|
||||||
|
"source", "started_at", "finished_at",
|
||||||
|
"status", "fetched_count", "new_count", "error",
|
||||||
|
)
|
||||||
|
date_hierarchy = "started_at"
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
7
yksa_poller/apps.py
Normal file
7
yksa_poller/apps.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class YksaPollerConfig(AppConfig):
|
||||||
|
name = "yksa_poller"
|
||||||
|
label = "yksa_poller"
|
||||||
|
verbose_name = "YKSA source poller"
|
||||||
138
yksa_poller/models.py
Normal file
138
yksa_poller/models.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Abstract models. This package ships no tables of its own.
|
||||||
|
|
||||||
|
A source is a place to fetch from on a schedule; a run is one attempt. Both are
|
||||||
|
abstract because the concrete models differ where they must — which satellite
|
||||||
|
model the M2M points at, which identifier the upstream subscribes by — and are
|
||||||
|
identical everywhere else.
|
||||||
|
|
||||||
|
Field definitions here match what the services already had, so adopting this
|
||||||
|
package needs no migration. A service that wants a different default or wording
|
||||||
|
overrides the field; Django permits that for fields inherited from an abstract
|
||||||
|
base, and it is the intended escape hatch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from yksa_kit.choices import RunStatus
|
||||||
|
from yksa_kit.states import health_state
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractPollingSource(models.Model):
|
||||||
|
#: subscription_id_kind value -> Satellite field to read. Set by the service;
|
||||||
|
#: subscription_id_values() is a no-op without it.
|
||||||
|
SUBSCRIPTION_ID_FIELDS: dict[str, str] = {}
|
||||||
|
|
||||||
|
name = models.CharField(max_length=128, unique=True)
|
||||||
|
slug = models.SlugField(max_length=64, unique=True)
|
||||||
|
config = models.JSONField(
|
||||||
|
default=dict, blank=True,
|
||||||
|
help_text=_("Adapter-specific config."),
|
||||||
|
)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
poll_interval_sec = models.PositiveIntegerField(default=3600)
|
||||||
|
rate_limit_host = models.CharField(
|
||||||
|
max_length=128, blank=True,
|
||||||
|
help_text=_("Optional shared-bucket key for outbound rate limiting."),
|
||||||
|
)
|
||||||
|
rate_limit_per_min = models.PositiveIntegerField(default=20)
|
||||||
|
|
||||||
|
is_enabled = models.BooleanField(default=True)
|
||||||
|
is_public = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text=_("Show on the public source-status page."),
|
||||||
|
)
|
||||||
|
|
||||||
|
next_poll_at = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
|
last_run_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
last_status = models.CharField(max_length=16, choices=RunStatus.choices, blank=True)
|
||||||
|
last_error = models.TextField(blank=True)
|
||||||
|
success_count = models.PositiveIntegerField(default=0)
|
||||||
|
failure_count = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ui_state(self) -> str:
|
||||||
|
"""For yksa/ui/_state.html. A source that has never run reads as unknown,
|
||||||
|
not as an error: nothing has gone wrong yet."""
|
||||||
|
return health_state(self.last_status)
|
||||||
|
|
||||||
|
def schedule_next_poll(self, *, now=None) -> None:
|
||||||
|
ref = now or timezone.now()
|
||||||
|
self.next_poll_at = ref + timedelta(seconds=self.poll_interval_sec)
|
||||||
|
self.save(update_fields=["next_poll_at", "updated_at"])
|
||||||
|
|
||||||
|
def subscription_id_values(self) -> list:
|
||||||
|
"""Identifier values for the satellites this source subscribes to.
|
||||||
|
|
||||||
|
Reads the field named by ``subscription_id_kind`` through
|
||||||
|
``SUBSCRIPTION_ID_FIELDS``. Satellites whose chosen field is empty are
|
||||||
|
dropped: an upstream query built from a blank identifier returns either
|
||||||
|
nothing or everything, and both are wrong.
|
||||||
|
"""
|
||||||
|
if not self.SUBSCRIPTION_ID_FIELDS:
|
||||||
|
return []
|
||||||
|
default_field = next(iter(self.SUBSCRIPTION_ID_FIELDS.values()))
|
||||||
|
field_name = self.SUBSCRIPTION_ID_FIELDS.get(
|
||||||
|
self.subscription_id_kind, default_field
|
||||||
|
)
|
||||||
|
values = (
|
||||||
|
self.tracked_satellites
|
||||||
|
.exclude(**{f"{field_name}__isnull": True})
|
||||||
|
.values_list(field_name, flat=True)
|
||||||
|
)
|
||||||
|
return [v for v in values if v not in ("", None)]
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractSourceRun(models.Model):
|
||||||
|
started_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
finished_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
status = models.CharField(max_length=16, choices=RunStatus.choices)
|
||||||
|
fetched_count = models.PositiveIntegerField(default=0)
|
||||||
|
new_count = models.PositiveIntegerField(default=0)
|
||||||
|
error = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.source_id} @ {self.started_at:%Y-%m-%d %H:%M:%S} ({self.status})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ui_state(self) -> str:
|
||||||
|
return health_state(self.status)
|
||||||
|
|
||||||
|
def mark_success(self, *, fetched: int, new: int) -> None:
|
||||||
|
self.status = RunStatus.SUCCESS
|
||||||
|
self.fetched_count = fetched
|
||||||
|
self.new_count = new
|
||||||
|
self.finished_at = timezone.now()
|
||||||
|
self.save(update_fields=["status", "fetched_count", "new_count", "finished_at"])
|
||||||
|
|
||||||
|
def mark_failed(self, error: str) -> None:
|
||||||
|
# Truncated: a traceback from a broken upstream can be megabytes, and the
|
||||||
|
# column is read on a status page.
|
||||||
|
self.status = RunStatus.FAILED
|
||||||
|
self.error = error[:4000]
|
||||||
|
self.finished_at = timezone.now()
|
||||||
|
self.save(update_fields=["status", "error", "finished_at"])
|
||||||
|
|
||||||
|
def mark_skipped(self, reason: str) -> None:
|
||||||
|
self.status = RunStatus.SKIPPED
|
||||||
|
self.error = reason
|
||||||
|
self.finished_at = timezone.now()
|
||||||
|
self.save(update_fields=["status", "error", "finished_at"])
|
||||||
135
yksa_poller/runner.py
Normal file
135
yksa_poller/runner.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""One poll of one source, and the two housekeeping jobs around it.
|
||||||
|
|
||||||
|
The bookkeeping is the whole point of sharing this: every branch out of a poll
|
||||||
|
has to leave the source with a next_poll_at, a last_status and a counter, or the
|
||||||
|
dispatcher either re-runs it every minute forever or never runs it again. Both
|
||||||
|
happened while this logic lived in two places.
|
||||||
|
|
||||||
|
A service supplies a :class:`Poller`; the Celery tasks stay in the service so the
|
||||||
|
beat schedule keeps naming them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from yksa_kit.choices import RunStatus
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Poller:
|
||||||
|
"""How one service polls.
|
||||||
|
|
||||||
|
``persist`` is called per fetched record and returns True when the record is
|
||||||
|
new. ``track`` is called per record with a dict of extra source fields to
|
||||||
|
write on success — tdas uses it to carry ``last_rx_at`` forward. Per record
|
||||||
|
and not per run, because a poll can yield more frames than fit in memory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
source_model: type
|
||||||
|
run_model: type
|
||||||
|
adapters: dict
|
||||||
|
persist: Callable[[Any, Any], bool]
|
||||||
|
track: Callable[[Any, Any, dict], None] | None = None
|
||||||
|
#: Kinds that are fed from outside and must never be polled.
|
||||||
|
unpollable_kinds: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
|
||||||
|
def run_source(poller: Poller, source_id: int) -> dict:
|
||||||
|
"""Poll one source now. Synchronous, so the admin action can call it too."""
|
||||||
|
src = poller.source_model.objects.get(pk=source_id)
|
||||||
|
if not src.is_enabled:
|
||||||
|
return {"source_id": source_id, "skipped": True, "reason": "disabled"}
|
||||||
|
|
||||||
|
adapter_cls = poller.adapters.get(src.kind)
|
||||||
|
if adapter_cls is None:
|
||||||
|
return _skip(poller, src, f"no adapter registered for kind={src.kind}")
|
||||||
|
|
||||||
|
run = poller.run_model.objects.create(source=src, status=RunStatus.RUNNING)
|
||||||
|
fetched = 0
|
||||||
|
new_count = 0
|
||||||
|
extra: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
for record in adapter_cls(src).fetch():
|
||||||
|
fetched += 1
|
||||||
|
if poller.persist(src, record):
|
||||||
|
new_count += 1
|
||||||
|
if poller.track:
|
||||||
|
poller.track(src, record, extra)
|
||||||
|
except NotImplementedError as exc:
|
||||||
|
# An adapter that cannot run for this configuration is not a failure;
|
||||||
|
# counting it as one would page someone for a source nobody enabled.
|
||||||
|
run.mark_skipped(str(exc))
|
||||||
|
_mark_source(poller, src, RunStatus.SKIPPED, error=str(exc))
|
||||||
|
return {"source_id": source_id, "status": RunStatus.SKIPPED, "fetched": 0, "new": 0}
|
||||||
|
except Exception as exc:
|
||||||
|
run.mark_failed(f"{exc}\n{traceback.format_exc()}")
|
||||||
|
_mark_source(poller, src, RunStatus.FAILED, error=str(exc), count_failure=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
run.mark_success(fetched=fetched, new=new_count)
|
||||||
|
_mark_source(poller, src, RunStatus.SUCCESS, count_success=True, extra=extra)
|
||||||
|
return {
|
||||||
|
"source_id": source_id,
|
||||||
|
"status": RunStatus.SUCCESS,
|
||||||
|
"fetched": fetched,
|
||||||
|
"new": new_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def due_source_ids(poller: Poller) -> list[int]:
|
||||||
|
queryset = poller.source_model.objects.filter(
|
||||||
|
is_enabled=True, next_poll_at__lte=timezone.now()
|
||||||
|
)
|
||||||
|
if poller.unpollable_kinds:
|
||||||
|
queryset = queryset.exclude(kind__in=poller.unpollable_kinds)
|
||||||
|
return list(queryset.values_list("id", flat=True))
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_runs(run_model: type, retention_days: int) -> dict:
|
||||||
|
cutoff = timezone.now() - timedelta(days=retention_days)
|
||||||
|
deleted, _ = run_model.objects.filter(started_at__lt=cutoff).delete()
|
||||||
|
return {"deleted": deleted, "retention_days": retention_days}
|
||||||
|
|
||||||
|
|
||||||
|
def _skip(poller: Poller, src, reason: str) -> dict:
|
||||||
|
poller.run_model.objects.create(
|
||||||
|
source=src, status=RunStatus.SKIPPED, error=reason, finished_at=timezone.now(),
|
||||||
|
)
|
||||||
|
_mark_source(poller, src, RunStatus.SKIPPED, error=reason)
|
||||||
|
return {"source_id": src.pk, "status": RunStatus.SKIPPED, "reason": reason}
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_source(
|
||||||
|
poller: Poller,
|
||||||
|
src,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
error: str = "",
|
||||||
|
count_success: bool = False,
|
||||||
|
count_failure: bool = False,
|
||||||
|
extra: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write the outcome and re-arm the schedule.
|
||||||
|
|
||||||
|
Written with an UPDATE rather than save(): a poll can take minutes, and the
|
||||||
|
in-memory source is stale by the time it ends.
|
||||||
|
"""
|
||||||
|
fields: dict[str, Any] = {
|
||||||
|
"last_run_at": timezone.now(),
|
||||||
|
"last_status": status,
|
||||||
|
"last_error": error[:4000],
|
||||||
|
}
|
||||||
|
if count_success:
|
||||||
|
fields["success_count"] = src.success_count + 1
|
||||||
|
if count_failure:
|
||||||
|
fields["failure_count"] = src.failure_count + 1
|
||||||
|
fields.update(extra or {})
|
||||||
|
poller.source_model.objects.filter(pk=src.pk).update(**fields)
|
||||||
|
src.refresh_from_db()
|
||||||
|
src.schedule_next_poll()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue