Async workers¶
Status: ๐ง Partial โ Most async workers are implemented as documented (turns, heartbeat, rate-limit, planetary, siege, nexus generation, ARIA decay, bounties). Re-verified 2026-08-04: found and corrected a real gap โ ยง Election scheduling and tally omitted the recurring-election auto-scheduler sweep entirely (described elections as purely admin-initiated); also fixed a stale
galaxy_service.pycitation (file no longer exists โ the field lives inmodels/region.py). Other entries spot-checked clean.
The gameserver runs as a single FastAPI process. The target topology is lazy-on-read for most periodic work, supplemented by a small number of in-process asyncio background tasks. Heavy scheduled jobs (cron, celery, apscheduler) are not part of the target topology unless explicitly noted below.
This page inventories every recurring responsibility, the trigger that drives it, and where the implementation lives.
Topology overview¶
| Mechanism | Use case | Source |
|---|---|---|
| Lazy-on-read | Per-player counters that decay/grow with wall-clock time (turns). The recompute happens the first time the player's record is read after the threshold is crossed. | Per-service (see below) |
In-process asyncio.create_task |
Continuous loops bound to the FastAPI process lifetime (websocket cleanup, rate-limit cleanup, the NPC scheduler's own sweep loop โ which now also drives weekly personal/faction/ARIA reputation decay, correcting this page's older lazy-on-read framing for those two below). | services/gameserver/src/main.py, services/gameserver/src/middleware/rate_limit.py, services/gameserver/src/services/scheduler/core_loop.py |
FastAPI BackgroundTasks |
One-shot, request-scoped follow-up work (PayPal webhook handling, Nexus generation). | Per-route |
| External webhook | Third-party-driven events (PayPal subscription state changes). | services/gameserver/src/api/routes/paypal.py |
| Admin-triggered | Operator-initiated maintenance tasks (force market recompute, rebuild Central Nexus). | Admin routes |
The gameserver does not run apscheduler, celery, or a worker queue. Where downstream environments need a hard cron schedule (e.g. nightly reputation decay across millions of players), the target is to add an external scheduler that calls a privileged admin endpoint, rather than introducing a worker process.
Inventory¶
Turn regeneration¶
Trigger. Lazy-on-read.
Cadence. Daily โ turns reset 24 hours after the last reset, computed from Player.turn_reset_at.
Responsibility. When a player action reads their current turn balance, RankingService.refresh_daily_turns checks whether turn_reset_at is older than the daily window. If so, it recomputes the player's max turns (rank bonus included) and refills.
Idempotency. Idempotent โ the recompute is keyed off turn_reset_at, so a second call within the same window is a no-op.
Source. services/gameserver/src/services/ranking_service.py
The target is explicitly not a background tick. A player who never logs in does not have their turns refilled; they receive the new allotment the next time they act.
WebSocket heartbeat cleanup¶
Trigger. In-process asyncio.create_task started at FastAPI startup.
Cadence. Every 30 seconds.
Responsibility. Disconnect WebSocket clients whose last heartbeat is more than 300 seconds old.
Idempotency. Idempotent โ already-disconnected clients are skipped.
Source. services/gameserver/src/main.py (_heartbeat_cleanup_loop), services/gameserver/src/services/websocket_service.py
Rate-limit table cleanup¶
Trigger. In-process asyncio.create_task started by the rate-limit middleware on first use.
Cadence. Every 60 seconds.
Responsibility. Evict expired entries from the in-memory rate-limit windows.
Idempotency. Idempotent.
Source. services/gameserver/src/middleware/rate_limit.py
Personal reputation decay¶
Trigger. โ
Shipped, scheduler-wired โ NOT lazy-on-read (corrected 2026-08-07; superseded the section's older lazy-on-read framing). Driven by the NPC scheduler's core_loop.py sweep, coarse-gated every WEEKLY_DECAY_CHECK_SECONDS (15 min) so the advisory lock + Galaxy.state read aren't taken every tick; _run_weekly_decay_sync (services/gameserver/src/services/scheduler/reputation_team_sweeps.py) is the real gate โ a durable canonical-week anchor in the oldest Galaxy.state row (_WEEKLY_DECAY_STATE_KEY), advanced only inside the same locked transaction as the decay itself, so a restart neither skips nor double-applies a week.
Cadence. Weekly (canonical week) โ 5 points of decay applied per week toward 0 (alignment regresses to neutral).
Responsibility. PersonalReputationService.apply_weekly_decay reads personal_reputation and reduces magnitude by 5, never crossing 0; called once per canonical week for every eligible player from the sweep above, fully synchronously (no asyncio.run bridge over the shared async engine โ that pattern previously caused an async-pool-poisoning bug, since fixed).
Idempotency. Idempotent โ the whole transaction (lock + anchor read + personal/faction/ARIA decay batches + anchor advance) commits or rolls back together; a crash mid-batch retries next wake with at worst one extra convergent decay step.
Source. services/gameserver/src/services/personal_reputation_service.py (the decay math); services/gameserver/src/services/scheduler/reputation_team_sweeps.py (_run_weekly_decay_sync, the scheduler gate); services/gameserver/src/services/scheduler/core_loop.py (the 15-min pre-filter wiring it into the NPC scheduler loop).
Faction reputation decay¶
Trigger. โ
Shipped, scheduler-wired โ same _run_weekly_decay_sync sweep as Personal reputation decay above, not the standalone lazy-on-read path this section previously described (corrected 2026-08-07).
Cadence. Weekly (canonical week), alongside personal/ARIA decay in the same locked transaction; per-faction magnitude configured on the Faction model (reputation_decay_rate).
Responsibility. FactionService.apply_reputation_decay reduces a player's reputation toward each faction's reputation_neutral baseline.
Idempotency. Idempotent โ see Personal reputation decay above; same durable week-anchor transaction covers this batch too.
Source. services/gameserver/src/services/faction_service.py; services/gameserver/src/services/scheduler/reputation_team_sweeps.py
Market price updates¶
Trigger. Admin-triggered + read-side recompute.
Cadence. Per-station, every Station.market_update_frequency hours (default 6).
Responsibility. Each port computes current_price from base_price, supply level, demand level, and active price modifiers. The target read-path checks last_market_update and recomputes lazily when stale; admins can force a system-wide recompute via POST /admin/ports/update-stock-levels.
Idempotency. Idempotent โ the recompute is deterministic given stock levels.
Source. services/gameserver/src/services/economy_analytics_service.py, services/gameserver/src/services/realtime_market_service.py, services/gameserver/src/api/routes/admin_comprehensive.py
Planetary production tick¶
Trigger. Lazy-on-read.
Cadence. Hourly (production rates are per-hour).
Responsibility. When a player reads their planet, PlanetaryService computes elapsed time since last_production and adds the appropriate fuel/organics/equipment/research output. Population growth uses the same trigger.
Idempotency. Idempotent โ driven off last_production.
Source. services/gameserver/src/services/planetary_service.py (_calculate_production_rates, last_production)
Siege detection and effects¶
Trigger. Lazy-on-read โ siege state is recomputed when the planet is touched.
Cadence. Per-action.
Responsibility. PlanetaryService.check_and_update_siege and apply_siege_effects set/clear under_siege, deal periodic damage during a siege, and apply morale changes.
Idempotency. Idempotent โ keyed off siege_started_at.
Source. services/gameserver/src/services/planetary_service.py
Election scheduling and tally¶
Trigger. Both: a governor can manually schedule an election via POST /regions/my-region/elections, and a scheduled sweep (economy_governance_sweeps.py's governance sweep, Phase 0) auto-creates the next recurring election for any active region whose previous election ended โฅ election_frequency_days ago (or, if it has never held one, gauged from Region.created_at) and that has no in-flight (PENDING/ACTIVE) election. Corrected 2026-08-04 โ this auto-scheduler was previously undocumented on this page, which described elections as purely admin-initiated.
Cadence. election_frequency_days is configurable per-region, default 90, CheckConstraint clamped to [30, 365] (models/region.py:124, not galaxy_service.py โ that file no longer exists in the gameserver). The auto-created election opens PENDING with voting_opens_at set a lead-window ahead so citizens can self-nominate before Phase 1 flips it ACTIVE and locks the candidate list.
Responsibility. A manually-created election (born ACTIVE) registers as in-flight, so the auto-scheduler never duplicates it. Tally happens lazily on read of the election results endpoint after voting_ends_at.
Idempotency. Tally is idempotent โ once the result is persisted, subsequent reads return the stored result. The auto-scheduler is idempotent per-region via the in-flight-election check.
Source. services/gameserver/src/services/regional_governance_service.py, services/gameserver/src/services/scheduler/economy_governance_sweeps.py (recurring-election auto-scheduler), services/gameserver/src/models/region.py (RegionalElection, election_frequency_days)
Treaty expiry¶
Trigger. Lazy-on-read.
Cadence. Driven by each treaty's expires_at.
Responsibility. Treaty reads check expires_at and mark the treaty inactive if past the threshold. Effects of expiry (e.g. lifting a non-aggression pact) are applied when downstream code reads the treaty.
Idempotency. Idempotent.
Source. services/gameserver/src/services/regional_governance_service.py, services/gameserver/src/models/region.py (RegionalTreaty)
Subscription billing renewal¶
Trigger. External webhook from PayPal.
Cadence. Per-subscription billing cycle (PayPal-driven).
Responsibility. POST /paypal/webhooks/paypal receives BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED, BILLING.SUBSCRIPTION.CANCELLED, etc., and updates User.subscription_status and User.subscription_expires_at (or the equivalent on Region). The handler runs in a BackgroundTasks follow-up to keep the webhook fast.
Idempotency. Idempotent โ webhooks are deduped by event ID; replayed events are no-ops.
Source. services/gameserver/src/api/routes/paypal.py, services/gameserver/src/services/paypal_service.py
There is no in-process renewal poller; PayPal is the source of truth for subscription state.
Subscription expiry sweep¶
Trigger. Lazy-on-read on each request that depends on is_galactic_citizen.
Cadence. Per-request.
Responsibility. When a request that requires citizenship is served, the auth layer checks User.subscription_expires_at. If expired, citizenship is dropped and downstream gates fail.
Idempotency. Idempotent.
Source. services/gameserver/src/auth/dependencies.py, services/gameserver/src/models/user.py
Auto-bounty placement¶
Trigger. Event-driven โ fires on personal-reputation transitions across thresholds.
Cadence. Per personal-reputation change.
Responsibility. When personal_reputation crosses -500, -750, or -1000, BountyService adds a system bounty (5k / 25k / 100k credits) to the player's settings.bounties. The check runs during the request that caused the reputation change.
Idempotency. Idempotent โ system bounties are keyed by threshold; re-emitting the same threshold replaces the existing entry.
Source. services/gameserver/src/services/bounty_service.py (BOUNTY_THRESHOLDS, _get_system_bounties)
ARIA inactivity decay¶
Trigger. Lazy-on-read.
Cadence. Daily โ 1 point of relationship decay per day inactive.
Responsibility. aria_personal_intelligence_service.apply_inactivity_decay reads Player.aria_relationship_score, computes days inactive from last_game_login, and decays the score by min(days_inactive, current_score).
Idempotency. Needs a marker on the player; the target is to update last_game_login immediately after decay so a second call within the same day is a no-op.
Source. services/gameserver/src/services/aria_personal_intelligence_service.py
ARIA market intelligence decay¶
Trigger. Lazy-on-read per (player, sector) pair.
Cadence. 5% decay per day, applied compounding-ly when intelligence is read.
Responsibility. Reduces prediction_confidence and intelligence_quality on ARIAMarketIntelligence rows that haven't been refreshed.
Idempotency. Idempotent โ driven off last_observation_at.
Source. services/gameserver/src/services/aria_personal_intelligence_service.py (_decay_sector_intelligence)
AI recommendation expiry¶
Trigger. Lazy-on-read.
Cadence. Per AIRecommendation.expires_at.
Responsibility. Reads filter by expires_at > now(); expired rows are excluded and may be reaped by the next admin sweep.
Idempotency. Idempotent.
Source. services/gameserver/src/models/ai_trading.py, services/gameserver/src/services/ai_trading_service.py
Refresh-token expiry¶
Trigger. Lazy-on-read on POST /auth/refresh.
Cadence. Per-request.
Responsibility. Tokens past expires_at are rejected. Cleanup of expired rows happens during admin security cleanup.
Idempotency. Idempotent.
Source. services/gameserver/src/models/refresh_token.py, services/gameserver/src/auth/jwt.py
Audit log retention¶
Trigger. Admin-triggered.
Cadence. On demand via POST /admin/security/cleanup.
Responsibility. Purge audit-log entries older than the configured retention horizon. The target is operator-driven, not automatic.
Idempotency. Idempotent.
Source. services/gameserver/src/api/routes/admin_comprehensive.py, services/gameserver/src/services/audit_service.py
Sector / galaxy event scheduling¶
Trigger. Admin-authored events with start_time / end_time.
Cadence. Per event.
Responsibility. Reads of a sector or galaxy include the active events whose started_at <= now() < expires_at. Activation and deactivation are driven by admin endpoints (POST /admin/events/{id}/activate, POST /admin/events/{id}/deactivate); the read path filters by time range.
Idempotency. Idempotent.
Source. services/gameserver/src/api/routes/events.py, services/gameserver/src/models/game_event.py
Warp-tunnel collapse¶
Trigger. Lazy-on-read of artificial tunnels.
Cadence. Per-traversal โ expected_lifetime is checked when a ship attempts traversal.
Responsibility. If expected_lifetime is past, the tunnel transitions to COLLAPSED and traversal fails.
Idempotency. Idempotent.
Source. services/gameserver/src/models/warp_tunnel.py
Central Nexus generation¶
Trigger. Admin-triggered, runs as a FastAPI BackgroundTasks follow-up.
Cadence. On demand.
Responsibility. POST /nexus/generate schedules NexusGenerationService.generate_nexus to run after the response. The endpoint immediately returns a job-id; clients poll /nexus/status.
Idempotency. Not idempotent โ generates a fresh nexus each call. Production should gate the endpoint to prevent concurrent runs.
Source. services/gameserver/src/api/routes/nexus.py, services/gameserver/src/services/nexus_generation_service.py
Default admin user creation¶
Trigger. FastAPI startup hook.
Cadence. Once per process.
Responsibility. Ensure a default admin exists; idempotently creates one from environment configuration if absent.
Idempotency. Idempotent.
Source. services/gameserver/src/main.py, services/gameserver/src/auth/admin.py (create_default_admin)
Cross-cutting properties¶
- No external scheduler is required for normal operation. Every recurring responsibility is either lazy-on-read, request-scoped, or admin-triggered. The two long-running asyncio tasks (heartbeat, rate-limit cleanup) are tied to the FastAPI process lifetime.
- Idempotency is mandatory. Every recompute uses a "last applied at" marker so re-running is safe. Tasks that don't have one (e.g. ARIA inactivity decay's per-day gate) need to add one before they're called from anywhere but the read path that knows it's the first read of the day.
- Backfills go through admin endpoints. Where a one-time bulk operation is needed (e.g. after an outage), the target is an admin-only POST that the operator triggers, not a process that runs continuously.
- Multi-process safety. Lazy-on-read recomputes inside a request can race with a parallel request for the same row. Services that mutate during recompute (turn refresh, planetary production) should hold a row-level lock (
SELECT ... FOR UPDATE) before applying the delta. The bounty service models this correctly (bounty_service.pylocks the placer row before placing a bounty).