Skip to content

Commit def28ff

Browse files
authored
Merge pull request #4 from starwit/feature/AB#2263-register-config-message
Feature/ab#2263 register config message
2 parents 6f4a0d1 + a72209b commit def28ff

19 files changed

Lines changed: 584 additions & 175 deletions

.github/workflows/build-publish-latest.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,23 @@ jobs:
1717

1818
steps:
1919
- name: checkout
20-
uses: actions/checkout@v4
20+
uses: actions/checkout@v7
2121

2222
- name: Login to Docker Hub
23-
uses: docker/login-action@v3
23+
uses: docker/login-action@v4
2424
with:
2525
username: ${{ env.DOCKERHUB_USERNAME }}
2626
password: ${{ secrets.DOCKERHUB_TOKEN }}
2727

2828
- name: Set up Docker Buildx
29-
uses: docker/setup-buildx-action@v3
29+
uses: docker/setup-buildx-action@v4
3030

3131
- name: build and push docker
3232
run: |
3333
echo "DOCKER_VERSION=$(cat pyproject.toml | grep "version" | head -1 | cut -d'=' -f2 | xargs echo -n)" >> $GITHUB_ENV
3434
3535
- name: Build and push
36-
uses: docker/build-push-action@v5
36+
uses: docker/build-push-action@v7
3737
with:
3838
context: .
3939
file: ./Dockerfile

.github/workflows/create-release.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
name: create new release
2626
runs-on: [self-hosted, linux, X64]
2727
container:
28-
image: starwitorg/debian-packaging:0.0.3
28+
image: starwitorg/debian-packaging:0.0.6
2929
volumes:
3030
- /home/githubrunner/.cache/pypoetry:/root/.cache/pypoetry
3131
env:
@@ -37,7 +37,7 @@ jobs:
3737
# check-out repo and set-up python
3838
#----------------------------------------------
3939
- name: Check out repository
40-
uses: actions/checkout@v4
40+
uses: actions/checkout@v7
4141

4242
- name: Install GH CLI
4343
uses: dev-hanz-ops/install-gh-cli-action@v0.2.1
@@ -78,7 +78,7 @@ jobs:
7878

7979
steps:
8080
- name: Checkout
81-
uses: actions/checkout@v4
81+
uses: actions/checkout@v7
8282
with:
8383
fetch-depth: 0
8484

@@ -91,16 +91,16 @@ jobs:
9191
echo "APP_VERSION=${APP_VERSION}" >> $GITHUB_ENV
9292
9393
- name: Set up Docker Buildx
94-
uses: docker/setup-buildx-action@v3
94+
uses: docker/setup-buildx-action@v4
9595

9696
- name: Login to Docker Hub
97-
uses: docker/login-action@v3
97+
uses: docker/login-action@v4
9898
with:
9999
username: ${{ env.DOCKERHUB_USERNAME }}
100100
password: ${{ secrets.DOCKERHUB_TOKEN }}
101101

102102
- name: Build and push
103-
uses: docker/build-push-action@v5
103+
uses: docker/build-push-action@v7
104104
if: env.APP_VERSION == env.LATEST_TAG
105105
with:
106106
context: .

.github/workflows/pr_build.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ jobs:
1010
name: PR Build with Poetry
1111
runs-on: [self-hosted, linux, X64]
1212
container:
13-
image: starwitorg/base-python-image:0.0.15
13+
image: starwitorg/base-python-image:3.14.0-py3.14-ptr2.3.4
1414
volumes:
1515
- /home/githubrunner/.cache/pypoetry:/root/.cache/pypoetry
1616
steps:
1717
#----------------------------------------------
1818
# check-out repo and set-up python
1919
#----------------------------------------------
2020
- name: Check out repository
21-
uses: actions/checkout@v4
21+
uses: actions/checkout@v7
2222

2323
- name: Setup
2424
run: |

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM starwitorg/base-python-image:3.13.0 AS build
1+
FROM starwitorg/base-python-image:3.14.0 AS build
22

33
# Copy only files that are necessary to install dependencies
44
COPY poetry.lock poetry.toml pyproject.toml /code/
@@ -9,7 +9,7 @@ RUN poetry install
99
COPY . /code/
1010

1111
### Main artifact / deliverable image
12-
FROM python:3.13-slim
12+
FROM python:3.14-slim
1313
RUN apt update && apt install --no-install-recommends -y \
1414
libglib2.0-0 \
1515
libgl1 \

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,48 @@
11
# SAE sae-api
2+
3+
The API layer between a SAE instance and the outside world (e.g. a cloud backend
4+
system). It publishes information about the local SAE instance to a backend Valkey,
5+
and is the place where inbound communication from the backend will be handled in the
6+
future.
7+
8+
## What it does
9+
10+
- **Reports lifecycle events** — on startup and shutdown it publishes an
11+
`EventMessage` describing the instance to the backend.
12+
- **Forwards frames** — periodically pulls the latest frame from each local video
13+
source stream and forwards a frame-only `SaeMessage` to the backend (one output
14+
stream per source).
15+
16+
## Interfaces
17+
18+
It reads from a local **source Valkey** and writes to a remote **backend Valkey**.
19+
Stream keys are built from the prefixes and IDs in the configuration:
20+
21+
| Direction | Valkey | Stream key | Payload |
22+
| --------- | -------- | --------------------------------------------------- | -------------- |
23+
| Input | source | `{video_source_stream_prefix}:{source_id}` | `SaeMessage` |
24+
| Output | backend | `{frame_forwarding.output_stream_prefix}:{source_id}` | frame-only `SaeMessage` |
25+
| Output | backend | `{event_reporting.output_stream_prefix}:{instance_id}` | `EventMessage` |
26+
27+
Source streams are discovered by scanning the source Valkey for keys matching
28+
`{video_source_stream_prefix}:*`.
29+
30+
## Running it
31+
32+
Configuration is read from `settings.yaml` (see `settings.template.yaml` for all
33+
options); every value can be overridden via environment variables, using `__` as the
34+
nesting delimiter (e.g. `BACKEND_VALKEY__HOST`). Point `SETTINGS_FILE` at a different
35+
file to use another location.
36+
37+
```sh
38+
poetry install
39+
poetry run python main.py
40+
```
41+
42+
A Prometheus metrics endpoint is served on `prometheus_port` (8000 by default).
43+
44+
## Tests
45+
46+
```sh
47+
poetry run pytest
48+
```

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ package-mode = false
1010
[tool.poetry.dependencies]
1111
python = ">=3.11,<4.0"
1212
pydantic = "2.13.4"
13-
visionapi = { git = "https://github.com/starwit/vision-api.git", subdirectory = "python/visionapi", tag = "3.5.3" }
13+
visionapi = { git = "https://github.com/starwit/vision-api.git", subdirectory = "python/visionapi", rev = "c895601" }
1414
visionlib = { git = "https://github.com/starwit/vision-lib.git", subdirectory = "python", tag = "1.0.1" }
1515
pydantic-settings = "2.14.1"
1616
prometheus-client = "0.25.0"

saeapi/config.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,39 @@
66
from visionlib.pipeline.settings import LogLevel
77

88

9-
class RedisConfig(BaseModel):
9+
class ValkeyConfig(BaseModel):
1010
host: str = 'localhost'
11-
port: Annotated[int, Field(ge=1, le=65536)] = 6379
12-
stream_id: str
13-
input_stream_prefix: str
14-
output_stream_prefix: str = 'saeapi'
11+
port: Annotated[int, Field(ge=1, le=65535)] = 6379
12+
13+
14+
class EventReportingConfig(BaseModel):
15+
# Event messages are written to '{output_stream_prefix}:{instance_id}'
16+
output_stream_prefix: str = 'saeapi-event'
17+
18+
19+
class FrameForwardingConfig(BaseModel):
20+
# Source streams are discovered by scanning '{video_source_stream_prefix}:*'
21+
video_source_stream_prefix: str
22+
# Forwarded frames are written to '{output_stream_prefix}:{source_id}'
23+
output_stream_prefix: str = 'saeapi-frame'
24+
poll_interval_s: float = 600.0
25+
1526

1627
class SaeApiConfig(BaseSettings):
1728
log_level: LogLevel = LogLevel.WARNING
18-
redis: RedisConfig
19-
prometheus_port: Annotated[int, Field(ge=1024, le=65536)] = 8000
29+
# Identifies this SAE instance; reflected in all output stream names
30+
instance_id: str
31+
# Local instance SAE Valkey, where we read video source frames from
32+
source_valkey: ValkeyConfig
33+
# Remote cloud backend Valkey, where we write all output to
34+
backend_valkey: ValkeyConfig
35+
event_reporting: EventReportingConfig = EventReportingConfig()
36+
frame_forwarding: FrameForwardingConfig
37+
prometheus_port: Annotated[int, Field(ge=1024, le=65535)] = 8000
2038

2139
model_config = SettingsConfigDict(env_nested_delimiter='__')
2240

2341
@classmethod
2442
def settings_customise_sources(cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings):
2543
YAML_LOCATION = os.environ.get('SETTINGS_FILE', 'settings.yaml')
26-
return (init_settings, env_settings, YamlConfigSettingsSource(settings_cls, yaml_file=YAML_LOCATION), file_secret_settings)
44+
return (init_settings, env_settings, YamlConfigSettingsSource(settings_cls, yaml_file=YAML_LOCATION), file_secret_settings)

saeapi/event_reporter.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import logging
2+
import time
3+
4+
from prometheus_client import Counter
5+
from visionapi.sae_pb2 import EventMessage
6+
from visionapi.common_pb2 import MessageType
7+
from visionlib.pipeline import ValkeyPublisher
8+
9+
from .config import SaeApiConfig
10+
11+
logger = logging.getLogger(__name__)
12+
13+
EVENT_MESSAGE_COUNTER = Counter('sae_api_event_message_counter', 'How many event messages have been published',
14+
labelnames=('event',))
15+
16+
17+
class EventReporter:
18+
'''Publishes lifecycle EventMessages about this SAE instance to the backend Valkey.'''
19+
20+
def __init__(self, config: SaeApiConfig) -> None:
21+
self._config = config
22+
self._stream_key = f'{config.event_reporting.output_stream_prefix}:{config.instance_id}'
23+
self._publish_ctx = ValkeyPublisher(config.backend_valkey.host, config.backend_valkey.port)
24+
self._publish = None
25+
26+
def __enter__(self):
27+
self._publish = self._publish_ctx.__enter__()
28+
return self
29+
30+
def __exit__(self, exc_type, exc_value, traceback):
31+
return self._publish_ctx.__exit__(exc_type, exc_value, traceback)
32+
33+
def report_startup(self):
34+
self.report(EventMessage.EventType.STARTUP)
35+
36+
def report_shutdown(self):
37+
self.report(EventMessage.EventType.SHUTDOWN)
38+
39+
def report(self, event_type: EventMessage.EventType):
40+
msg = EventMessage()
41+
msg.instance_id = self._config.instance_id
42+
msg.timestamp_utc_ms = int(time.time() * 1000)
43+
msg.event_type = event_type
44+
msg.type = MessageType.SAE_EVENT
45+
46+
self._publish(self._stream_key, msg.SerializeToString())
47+
48+
event_name = EventMessage.EventType.Name(event_type)
49+
EVENT_MESSAGE_COUNTER.labels(event=event_name).inc()
50+
logger.info(f'Published event message ({event_name}) to {self._stream_key}')

saeapi/frame_forwarder.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import logging
2+
3+
import pybase64
4+
import valkey
5+
from prometheus_client import Counter, Histogram
6+
from visionapi.common_pb2 import MessageType
7+
from visionapi.sae_pb2 import SaeMessage
8+
from visionlib.pipeline import ValkeyPublisher
9+
10+
from .config import SaeApiConfig
11+
12+
logger = logging.getLogger(__name__)
13+
14+
FRAMES_FORWARDED = Counter('sae_api_frames_forwarded', 'How many frames have been forwarded to the backend')
15+
FORWARD_CYCLE_DURATION = Histogram('sae_api_forward_cycle_duration', 'The time it takes to run one full frame forwarding cycle')
16+
17+
18+
class FrameForwarder:
19+
'''Periodically pulls the latest frame from every video source stream on the source Valkey
20+
and forwards a frame-only SaeMessage to the backend Valkey (one output stream per source).'''
21+
22+
def __init__(self, config: SaeApiConfig) -> None:
23+
self._config = config
24+
self._scan_pattern = f'{config.frame_forwarding.video_source_stream_prefix}:*'
25+
self._output_prefix = config.frame_forwarding.output_stream_prefix
26+
27+
self._source_client = None
28+
self._publish_ctx = ValkeyPublisher(config.backend_valkey.host, config.backend_valkey.port)
29+
self._publish = None
30+
31+
def __enter__(self):
32+
self._source_client = valkey.Valkey(self._config.source_valkey.host, self._config.source_valkey.port)
33+
self._publish = self._publish_ctx.__enter__()
34+
return self
35+
36+
def __exit__(self, exc_type, exc_value, traceback):
37+
try:
38+
self._source_client.close()
39+
except Exception as e:
40+
logger.warning('Error while closing source valkey client', exc_info=e)
41+
return self._publish_ctx.__exit__(exc_type, exc_value, traceback)
42+
43+
@FORWARD_CYCLE_DURATION.time()
44+
def forward_once(self):
45+
stream_keys = list(self._source_client.scan_iter(match=self._scan_pattern, _type='STREAM'))
46+
logger.debug(f'Discovered {len(stream_keys)} source stream(s) matching {self._scan_pattern}')
47+
48+
for stream_key in stream_keys:
49+
try:
50+
self._forward_stream(stream_key)
51+
except Exception as e:
52+
logger.warning(f'Error while forwarding latest frame from {stream_key!r}', exc_info=e)
53+
54+
def _forward_stream(self, stream_key: bytes):
55+
entries = self._source_client.xrevrange(stream_key, count=1)
56+
if not entries:
57+
return
58+
59+
_, fields = entries[0]
60+
proto_data = fields.get(b'proto_data_b64')
61+
if proto_data is not None:
62+
proto_data = pybase64.b64decode(proto_data, validate=True)
63+
else:
64+
proto_data = fields.get(b'proto_data')
65+
if proto_data is None:
66+
logger.warning(f'No proto data field found in latest entry of {stream_key!r}')
67+
return
68+
69+
sae_msg = SaeMessage()
70+
sae_msg.ParseFromString(proto_data)
71+
72+
if not sae_msg.HasField('frame'):
73+
logger.debug(f'Latest message in {stream_key!r} has no frame; skipping')
74+
return
75+
76+
out_msg = SaeMessage()
77+
out_msg.frame.CopyFrom(sae_msg.frame)
78+
out_msg.type = MessageType.SAE
79+
80+
source_id = stream_key.decode('utf-8').split(':', 1)[1]
81+
output_key = f'{self._output_prefix}:{source_id}'
82+
self._publish(output_key, out_msg.SerializeToString())
83+
84+
FRAMES_FORWARDED.inc()
85+
logger.debug(f'Forwarded latest frame from {stream_key!r} to {output_key}')

0 commit comments

Comments
 (0)