stack: service distribute (Dockerfile.dist + distribute_loop.py, harian DISTRIBUTE_HOUR)

This commit is contained in:
proitlab committed 2026-08-05 22:22:24 +07:00
1 parent f77681793f
commit 4e02701887
5 files changed
+130 -7

No files matched your search

+7
View File
@@ -51,6 +51,13 @@ SCAN_HOURS=0,8,16
# Skan sekali saat container start (tabel voters langsung ada; dashboard ⊥ 500)
SCAN_RUN_ON_START=1
# — Distribusi profit-share harian (service distribute) —
# Jam (lokal, TZ) run harian; key aktif BP wajib diisi untuk run nyata
# (⊥ dry-run). Disuntikkan ke container via interpolasi .env.
DISTRIBUTE_HOUR=10
DISTRIBUTE_MAX_ATTEMPTS=3
VEX_BP_PRIVATE_KEY=
# Kredensial mariadb stack — wajib konsisten antar-service (mariadb/web/scan)
MARIADB_ROOT_PASSWORD=root
MARIADB_DATABASE=databisnisid
+6 -5
View File
@@ -1,6 +1,6 @@
# AGENTS.md
Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (`distribute.py`) pays those voters daily: it signs `vex.token::transfer` actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake.
Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (`distribute.py`) pays those voters daily: it signs `vex.token::transfer` actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake. In the swarm stack the daily payout is driven by `distribute_loop.py` (service `distribute`), the scan equivalent of `scan_loop.py`.
## Run
@@ -10,10 +10,10 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list.
- Test MariaDB/MySQL via docker: `docker compose -f docker-compose.dev.yml up -d` (mariadb:11 container `databisnisid-mariadb`, localhost-only `127.0.0.1:3306`, db/user/pass `databisnisid`/`databisnis`/`databisnis`, named volume, healthcheck). Stop/remove with `docker compose -f docker-compose.dev.yml down`; wipe data with `docker compose -f docker-compose.dev.yml down -v`. Verify with `docker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'`. Smoke against the container:
`VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py`
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan image), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan` can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan and dist images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` once daily at `DISTRIBUTE_HOUR` default `10`, key `VEX_BP_PRIVATE_KEY` interpolated from `.env`). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute` can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py`.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, and `./venv/bin/python test_distribute.py` must all exit 0. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
## Key facts
@@ -43,8 +43,9 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd.
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`; shared by get_voters & dashboard.
- `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar.
- `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). Di stack produksi keduanya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
- `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan); image dari registry `git.proit.id/proitlab/databisnisid-*`; mariadb bind mount `/data/db/mariadb/databisnisid/data` + placement `node.hostname == server5.saltis.id`; network overlay `appnet`. `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`).
- `distribute_loop.py` — scheduler harian dalam container stack (service `distribute`): tunggu `DISTRIBUTE_HOUR` (lokal via `TZ`), panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba besok; ⊥ distribusi-awal saat start (snapshot bisa basi).
- `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). Di stack produksi ketiganya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
- `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan + distribute); image dari registry `git.proit.id/proitlab/databisnisid-*`; mariadb bind mount `/data/db/mariadb/databisnisid/data` + placement `node.hostname == server5.saltis.id`; network overlay `appnet`; `distribute` bawa `VEX_BP_PRIVATE_KEY` via interpolasi `.env`. `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`).
- `DESIGN.md` — Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono).
- `voters.db` — SQLite output (daily snapshot, gitignored in spirit).
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
# tzdata → TZ env (jadwal distribusi lokal, default Asia/Jakarta) bekerja di container
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY config.py db.py distribute.py distribute_loop.py ./
CMD ["python", "distribute_loop.py"]
+77
View File
@@ -0,0 +1,77 @@
"""Jadwal distribusi harian: jalankan distribute.py pada jam DISTRIBUTE_HOUR.
Menunggu hingga batas `DISTRIBUTE_HOUR` berikutnya (waktu lokal via `TZ`)
lalu memanggil `distribute.main()`. Loop tak pernah keluar — service dijaga
hidup oleh restart_policy swarm. Gagal distribusi dicatat lalu lanjut ke hari
berikutnya (⊥ membunuh loop). Tanpa `VEX_BP_PRIVATE_KEY` run nyata raise
RuntimeError yang jelas — ditangkap, dicetak, dicoba lagi besok.
Berbeda dengan scan: ⊥ ada "distribusi-awal" saat start (SCAN_RUN_ON_START) —
membayar di container start berisiko memakai snapshot basi; distribusi hanya
berjalan pada jam terjadwal.
Scheduler helper (`next_boundary`, `_wait_db`) sengaja diduplikasi dari
`scan_loop.py` (bukan di-import) karena kedua image Docker terpisah:
`Dockerfile.dist` ⊥ membawa `scan_loop.py`/`get_voters.py`.
"""
import os
import time
from datetime import datetime, timedelta
import db
import distribute
def next_boundary(now, hours):
"""datetime berikutnya pada jam-jam `hours` yang lebih besar dari `now`.
Sama dengan `scan_loop.next_boundary` (mirror agar image dist mandiri).
"""
for h in hours:
cand = now.replace(hour=h, minute=0, second=0, microsecond=0)
if cand > now:
return cand
# semua jam hari ini sudah lewat → besok jam pertama
nxt = now + timedelta(days=1)
return nxt.replace(hour=hours[0], minute=0, second=0, microsecond=0)
def _wait_db(timeout=120):
"""Tunggu basis data siap (mariadb baru boot) sebelum loop."""
start = time.monotonic()
while time.monotonic() - start < timeout:
try:
db.queryone('SELECT 1')
return
except Exception:
time.sleep(3)
raise RuntimeError('basis data tak siap setelah menunggu')
def _run_distribute(label):
print(f'[{label}] memulai distribusi...', flush=True)
try:
distribute.main()
except Exception as exc:
print(f'[Error] Distribusi gagal: {exc}', flush=True)
def main():
hour = int(os.getenv('DISTRIBUTE_HOUR', '10'))
if not 0 <= hour <= 23:
raise ValueError(f'DISTRIBUTE_HOUR tak valid: {hour!r}')
print(f'Jadwal distribusi: jam {hour} (waktu lokal)')
_wait_db()
while True:
now = datetime.now()
target = next_boundary(now, [hour])
wait = (target - now).total_seconds()
print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam '
f'sampai {target.isoformat(timespec="seconds")}', flush=True)
time.sleep(max(wait, 0))
_run_distribute('distribusi terjadwal')
if __name__ == '__main__':
main()
+25 -2
View File
@@ -1,7 +1,8 @@
# Stack Docker Swarm — deploy via `docker stack deploy -c docker-compose.yml databisnisid`
#
# 3 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5000),
# scan (skan get_voters.py pada SCAN_HOURS, waktu lokal TZ).
# 4 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5000),
# scan (skan get_voters.py pada SCAN_HOURS, waktu lokal TZ), dan
# distribute (pembayaran harian distribute.py pada DISTRIBUTE_HOUR).
#
# Catatan swarm:
# - `build:` hanya untuk `docker compose build`; `docker stack deploy` memakai `image:`
@@ -84,6 +85,28 @@ services:
restart_policy:
condition: any
distribute:
image: git.proit.id/proitlab/databisnisid-dist:latest
command: ["python", "distribute_loop.py"]
environment:
VEX_DB_BACKEND: mysql
VEX_DB_HOST: mariadb
VEX_DB_PORT: "3306"
VEX_DB_USER: ${MARIADB_USER:-databisnis}
VEX_DB_PASS: ${MARIADB_PASSWORD:-databisnis}
VEX_DB_NAME: ${MARIADB_DATABASE:-databisnisid}
TZ: ${TZ:-Asia/Jakarta}
DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10}
DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3}
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id}
VEX_BP_PRIVATE_KEY: ${VEX_BP_PRIVATE_KEY:-}
networks:
- appnet
deploy:
replicas: 1
restart_policy:
condition: any
networks:
appnet:
driver: overlay