stack: service distribute (Dockerfile.dist + distribute_loop.py, harian DISTRIBUTE_HOUR)
This commit is contained in:
1 parent
f77681793f
commit
4e02701887
5 files changed
+130
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user