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
147 changes: 59 additions & 88 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
deploy:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 40
timeout-minutes: 95
env:
IMAGE: inference:${{ github.sha }}
REMOTE_DIR: backend
Expand Down Expand Up @@ -74,57 +74,24 @@ jobs:
set -o pipefail
docker save "$IMAGE" | gzip | ssh vps 'gunzip | sudo -n docker load'

- name: Preserve current model and 72-hour history
# Снимок курсора и 72-часовых входов должен завершиться до смены
# inference:current. При ошибке новый образ не запускается.
- name: Preserve equipment window
run: |
ssh vps "REMOTE_DIR=$REMOTE_DIR bash -s" <<'EOF'
set -euo pipefail
cd "$HOME/$REMOTE_DIR"
backup="$HOME/$REMOTE_DIR/model-backups/before-full-no2021"
umask 077
mkdir -p "$backup"
if [ -f "$backup/SHA256SUMS" ]; then
echo "existing model-switch backup verified"
cd "$backup"
sha256sum -c SHA256SUMS
exit 0
fi

sudo -n docker image inspect inference:current >/dev/null
sudo -n docker tag inference:current inference:rollback-before-full-no2021
sudo -n docker image inspect -f '{{.Id}}' inference:current > "$backup/previous-image-id.txt"
ssh vps "sudo -n env SHA=${{ github.sha }} REMOTE_DIR=\$HOME/$REMOTE_DIR bash -s" \
< deploy/backup-equipment-window.sh

container=$(sudo -n docker compose ps -q inference)
created=0
if [ -z "$container" ]; then
container=$(sudo -n docker create inference:current)
created=1
fi
sudo -n docker cp "$container:/app/data/best_model.cbm" "$backup/previous_model.cbm.tmp"
if [ "$created" -eq 0 ]; then
sudo -n docker cp "$container:/app/runtime/events_cursor.json" "$backup/events_cursor.json.tmp"
else
sudo -n docker rm "$container" >/dev/null
fi
sudo -n chown "$USER" "$backup"/*.tmp
mv "$backup/previous_model.cbm.tmp" "$backup/previous_model.cbm"
if [ -f "$backup/events_cursor.json.tmp" ]; then
mv "$backup/events_cursor.json.tmp" "$backup/events_cursor.json"
fi

db=$(sed -n 's/^POSTGRES_DB=//p' .env)
dbuser=$(sed -n 's/^POSTGRES_USER=//p' .env)
sudo -n docker compose exec -T postgres psql -X -q -v ON_ERROR_STOP=1 \
-U "$dbuser" -d "$db" -c \
"COPY (SELECT id, event_id, channel_id, ts, is_alarm, raw_value FROM events WHERE ts >= now() - interval '72 hours' ORDER BY ts, id) TO STDOUT WITH (FORMAT csv, HEADER true)" \
| gzip -c > "$backup/events-72h.csv.gz.tmp"
mv "$backup/events-72h.csv.gz.tmp" "$backup/events-72h.csv.gz"
cd "$backup"
sha256sum previous_model.cbm previous-image-id.txt events-72h.csv.gz \
> SHA256SUMS
if [ -f events_cursor.json ]; then
sha256sum events_cursor.json >> SHA256SUMS
# Прежний current — точка отката. Повторный прогон того же sha его
# не трогает, иначе previous совпал бы с новым образом.
- name: Tag previous
run: |
ssh vps "SHA=${{ github.sha }} bash -s" <<'EOF'
set -euo pipefail
old=$(sudo -n docker image inspect -f '{{.Id}}' inference:current 2>/dev/null || true)
new=$(sudo -n docker image inspect -f '{{.Id}}' "inference:$SHA")
if [ -n "$old" ] && [ "$old" != "$new" ]; then
sudo -n docker tag inference:current inference:previous
fi
sha256sum -c SHA256SUMS
EOF

# Стабильный тег, а не переменная в .env: деплой бэкенда переписывает
Expand All @@ -136,70 +103,74 @@ jobs:
sudo -n docker tag "inference:$SHA" inference:current
EOF

- name: Restart service
- name: Restart services
run: |
ssh vps "REMOTE_DIR=$REMOTE_DIR bash -s" <<'EOF'
set -euo pipefail
cd "$HOME/$REMOTE_DIR" 2>/dev/null || {
echo "::notice::~/$REMOTE_DIR is missing: image loaded, nothing to restart"
exit 0
}
services=$(sudo -n docker compose config --services) || {
all=$(sudo -n docker compose config --services) || {
echo "::error::docker compose config failed in ~/$REMOTE_DIR"
exit 1
}
# Сервис и гранты добавляет бэкенд (HACK-136). До него образ
# просто лежит на VPS и ничего не запускает.
if ! grep -qx inference <<<"$services"; then
echo "::notice::backend compose has no 'inference' service yet (HACK-136): image loaded, not started"
# Все сервисы на образе inference:current: модель поломок и
# 4-классовая. Сервисы и гранты добавляет бэкенд (HACK-136).
services=$(grep -xE 'inference(-[a-z0-9]+)?' <<<"$all" || true)
if [ -z "$services" ]; then
echo "::notice::backend compose has no inference services yet (HACK-136): image loaded, not started"
exit 0
fi
rollback() {
echo "::error::new inference failed; restoring previous image"
sudo -n docker tag inference:rollback-before-full-no2021 inference:current
sudo -n docker compose up -d --no-deps --force-recreate inference
rollback_deadline=$((SECONDS + 900))
while :; do
rollback_id=$(sudo -n docker compose ps -q inference)
rollback_status=$(sudo -n docker inspect -f '{{.State.Health.Status}}' "$rollback_id" 2>/dev/null || echo missing)
echo "rollback inference: $rollback_status"
[ "$rollback_status" = healthy ] && return 0
[ $SECONDS -ge $rollback_deadline ] && return 1
sleep 15
echo "services: $(tr '\n' ' ' <<<"$services")"

# Прогрев окон за 3 дня — минуты; ждём healthy, а не running.
wait_healthy() {
local deadline=$((SECONDS + 2100)) service id status
for service in $services; do
while :; do
id=$(sudo -n docker compose ps -q "$service")
status=$(sudo -n docker inspect -f '{{.State.Health.Status}}' "$id" 2>/dev/null || echo missing)
echo "$service: $status"
[ "$status" = healthy ] && break
if [ "$status" = unhealthy ] || [ $SECONDS -ge $deadline ]; then
echo "::error::$service is not healthy ($status)"
sudo -n docker compose logs --tail 200 "$service" || true
return 1
fi
sleep 15
done
done
}

if ! sudo -n docker compose up -d --no-deps --force-recreate inference; then
rollback
rollback() {
if ! sudo -n docker image inspect inference:previous >/dev/null 2>&1; then
echo "::error::no inference:previous image, nothing to roll back to"
return 1
fi
echo "::error::new inference failed; restoring inference:previous"
sudo -n docker tag inference:previous inference:current
sudo -n docker compose up -d --no-deps --force-recreate $services
wait_healthy
}

if ! sudo -n docker compose up -d --no-deps --force-recreate $services || ! wait_healthy; then
rollback || true
exit 1
fi

# Прогрев окон за 3 дня — минуты; ждём healthy, а не running.
deadline=$((SECONDS + 900))
while :; do
id=$(sudo -n docker compose ps -q inference)
status=$(sudo -n docker inspect -f '{{.State.Health.Status}}' "$id" 2>/dev/null || echo missing)
echo "inference: $status"
[ "$status" = healthy ] && break
if [ "$status" = unhealthy ] || [ $SECONDS -ge $deadline ]; then
echo "::error::inference is not healthy ($status)"
sudo -n docker compose logs --tail 200 inference || true
rollback
exit 1
fi
sleep 15
for service in $services; do
sudo -n docker compose logs --tail 20 "$service"
done
sudo -n docker compose logs --tail 20 inference
EOF

# После рестарта: старый образ уже не держит контейнер. Хранятся текущий
# sha и один предыдущий для отката (образ 2.3 ГБ).
# После рестарта: старый образ уже не держит контейнер. Хранятся current,
# previous для отката и образ до full-no2021 (образ 2.3 ГБ).
- name: Prune old images
run: |
ssh vps "SHA=${{ github.sha }} bash -s" <<'EOF'
set -euo pipefail
sudo -n docker image ls inference --format '{{.CreatedAt}}\t{{.Tag}}' \
| { grep -vP "\t(current|$SHA|rollback-before-full-no2021)\$" || true; } | sort -r | tail -n +2 | cut -f2 \
| { grep -vP "\t(current|previous|$SHA|rollback-before-full-no2021)\$" || true; } | cut -f2 \
| while read -r tag; do sudo -n docker image rm "inference:$tag" >/dev/null || true; done
sudo -n docker image prune -f >/dev/null
EOF
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ RUN useradd --system --uid 10001 --no-create-home inference \
COPY app.py config.yml ./
COPY inference/ inference/
COPY data/best_model.cbm data/best_model.cbm
COPY data/incident4/ data/incident4/
COPY data/feature_encoding.json data/feature_encoding.json

USER inference
VOLUME /app/runtime

# start-period покрывает загрузку модели и прогрев окон за 3 дня.
HEALTHCHECK --interval=30s --timeout=10s --start-period=10m --retries=3 \
HEALTHCHECK --interval=30s --timeout=10s --start-period=35m --retries=3 \
CMD ["python", "-m", "inference.health"]

CMD ["python", "-m", "inference.runner"]
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -774,9 +774,9 @@ docker build -t inference:local .
docker run --rm -v inference-runtime:/app/runtime -e PGHOST=... -e PGUSER=inference -e PGPASSWORD=... inference:local
```

CI (`ci.yml`): `pytest` на Python 3.12 x86_64 со встроенным Postgres, `docker build`, smoke образа. Деплой (`deploy.yml`, push в `main` или вручную): после CI образ едет на VPS по SSH как `inference:current`, хранятся текущий и один предыдущий `inference:<sha>` для отката. Перед переключением сохраняются предыдущая модель, курсор и события за 72 часа в `~/backend/model-backups/before-full-no2021/` вместе с SHA-256; прежний образ получает тег `inference:rollback-before-full-no2021`. Новая версия прогревает окно из `events`, не из файла старой модели. При отказе healthcheck сценарий возвращает старый образ. Секреты: `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`.
CI (`ci.yml`): `pytest` на Python 3.12 x86_64 со встроенным Postgres, `docker build`, smoke образа. Деплой (`deploy.yml`, push в `main` или вручную): после CI образ едет на VPS по SSH; перед сменой `inference:current` сохраняется [снимок окна оборудования](docs/equipment-window-snapshot.md). Прежний `current` получает тег `inference:previous`. Перезапускаются все сервисы compose с именем `inference` или `inference-<суффикс>`, сценарий ждёт `healthy` у каждого. Новая версия прогревает окно из `events`. Если хоть один не поднялся, `inference:previous` снова становится `current` и все сервисы пересоздаются. Бэкап перед full-no2021 (`~/backend/model-backups/before-full-no2021/`, образ `inference:rollback-before-full-no2021`) не удаляется. Секреты: `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`.

Ручной откат на VPS: `sudo docker tag inference:rollback-before-full-no2021 inference:current`, затем из `~/backend` выполнить `sudo docker compose up -d --no-deps inference` и дождаться `healthy`. Снимок событий нужен для разбора и возможной конвертации состояния; штатный прогрев читает события непосредственно из PostgreSQL.
Ручной откат на VPS: `sudo docker tag inference:previous inference:current`, затем из `~/backend` выполнить `sudo docker compose up -d --no-deps --force-recreate inference` (и остальные `inference-*`) и дождаться `healthy`. Откат на модель до full-no2021: то же с тегом `inference:rollback-before-full-no2021`.

Тесты: SQL-часть поднимает встроенный Postgres (`pgserver`) без Docker. Если пакета нет, эти тесты пропускаются.

Expand Down
15 changes: 14 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,10 @@ def compile_model_features(
)
continue

if feature == "hours_since_prev_target":
specs.append(FeatureSpec(kind="prev_target"))
continue

specs.append(
FeatureSpec(
kind="static",
Expand Down Expand Up @@ -1365,6 +1369,7 @@ def _build_feature_row(
self,
sensor_id: int,
at: datetime,
hours_since_prev_target: float | None = None,
) -> list[Any]:
if (
sensor_id
Expand Down Expand Up @@ -1513,6 +1518,9 @@ def _build_feature_row(
at.isoweekday()
)

elif spec.kind == "prev_target":
value = hours_since_prev_target

elif spec.kind == "one_hot":
source = metadata[spec.name]
value = int(
Expand Down Expand Up @@ -1559,6 +1567,7 @@ def _explain(
row: list[Any],
raw_prediction: float,
horizon_hours: float,
top_n: int = 10,
) -> dict[str, Any]:
started = (
time.perf_counter()
Expand Down Expand Up @@ -1611,7 +1620,7 @@ def _explain(
)
),
reverse=True,
)[:10]
)[:top_n]

top = []

Expand Down Expand Up @@ -1790,6 +1799,9 @@ def predict(
self._build_feature_row(
sensor_id=sensor_id,
at=timestamp,
hours_since_prev_target=payload.get(
"hours_since_prev_target"
),
)
)

Expand Down Expand Up @@ -1874,6 +1886,7 @@ def predict(
horizon_hours=(
horizon_hours
),
top_n=(11 if payload.get("_incident_report") else 10),
)
)

Expand Down
15 changes: 15 additions & 0 deletions data/incident4/incident_by_sensor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"Датчик дыма": "FIRE",
"Ручной извещатель": "FIRE",
"Тепловой датчик": "FIRE",
"Состояние УИР-Р": "FIRE",
"Состояние насоса": "FLOOD",
"Датчик затопления": "FLOOD",
"Газовый датчик": "GAS",
"Датчик движения": "INTRUSION",
"КД Дверь": "INTRUSION",
"КД Люк": "INTRUSION",
"КД АВ": "INTRUSION",
"Стекло": "INTRUSION",
"9-секционный люк": "INTRUSION"
}
10 changes: 10 additions & 0 deletions data/incident4/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"model": "model_500.cbm",
"sha256": "178c874ca373a5c48327d4a221379ea9ee166480385393425b96dd5002ce21f9",
"target_seed_sha256": "3161acd2ab38454f009aeb2b088ba53d7c673603ea9306aad7d5b4c87ae05deb",
"target_seed_source_rows": 259283128,
"trees": 500,
"features": 177,
"horizon_hours": 30,
"classes": ["FIRE", "FLOOD", "GAS", "INTRUSION"]
}
Binary file added data/incident4/model_500.cbm
Binary file not shown.
1 change: 1 addition & 0 deletions data/incident4/target_seed.json

Large diffs are not rendered by default.

Loading
Loading