This file documents the service files that live directly in devplacepy/services/ (background task queue, AI correction/modifier, presence, live view relay, and the BaseService/ServiceManager base machinery). Claude Code loads it automatically whenever a file directly under devplacepy/services/ (or any of its subdirectories) is read or edited - domain-specific services (Devii, containers, gateway, jobs, audit, telegram, email, gitea, news, bot, messaging, xmlrpc, dbapi, pubsub, game) have their own more specific nested CLAUDE.md files.
Background task queue (services/background.py)
Generic, lightweight, fire-and-forget offload for non-critical side-effects so they leave the request path. The singleton background (from devplacepy.services.background import background) wraps one in-process asyncio.Queue drained by a single consumer task. The whole API is one call: background.submit(fn, *args, **kwargs) enqueues a synchronous callable and returns immediately (put_nowait). The consumer runs each callable inside its own try/except, so a failing task is logged and never kills the loop; FIFO order is preserved.
- Per-worker, not lock-gated (the reason it is NOT a
JobService/BaseService). Producers run in every uvicorn worker, and process memory is not shared, so the drain must run wherever requests are handled.main.pystartup()callsawait background.start()for every worker (inside theif not DEVPLACE_DISABLE_SERVICESguard, outside theacquire_service_lock()branch);shutdown()callsawait background.stop(). ABaseServicesupervisor only runs in the lock owner, which would strand records produced by the other worker. TheJobServicequeue is also wrong here: it is DB-backed, so deferring a tiny audit insert would add writes instead of removing them. - Inline fallback (load-bearing). When the consumer is not running - tests with
DEVPLACE_DISABLE_SERVICES=1, unit tests, request-less bootstrap, or a full queue -submitrunsfninline and synchronously. This keeps audit/notification/XP writes deterministic and immediately visible to the test suite (which asserts audit rows right after an action) while production defers them. No test changes are needed. - Best-effort durability (intentional). In-memory only;
stop()drains whatever remains synchronously so a graceful shutdown loses nothing, but a hard crash drops unflushed items. This matches audit/notifications already being best-effort (the recorder never raises). Do not put response-critical or money-touching work on it. - Capture plain data, never the
Request. Its lifecycle ends with the response - build the row/payload synchronously on the request thread and submit only the resulting dict/scalars. - Consumers today (deferred at their canonical choke points, so callers need no change):
- Audit -
services/audit/record.py_writebuilds the row + links synchronously, generates theuid/created_ateagerly sorecord()still returns the real uid, thenbackground.submit(_persist, row, links)does the twostoreinserts off-thread. - XP/rewards -
utils.award_rewardsdefers its body viabackground.submit(_apply_rewards, ...), so every XP award (posts/comments/projects/gists/follow/votes) leaves the request path. - Notifications -
utils.create_notification(the single notification funnel) defers viabackground.submit(_deliver_notification, ...), so every in-app insert + push schedule + audit for a notification (vote/follow/comment/mention/message/badge/level) runs on the consumer. - Mention fan-out -
utils.create_mention_notificationsdefers its whole body (_deliver_mention_notifications): theextract_mentionsregex + the@-username lookup query + the per-user loop all run off-thread (it is called on every post/gist/project/comment/message create). - Issue-comment admin fan-out -
routers/issues/comment.pydefers the_notify_adminsloop (admin lookup + per-admin notify) viabackground.submit, after the synchronous Gitea call.
- Audit -
- Because the reward and notification funnels self-defer, request handlers just call
award_rewards/create_notificationdirectly - do NOT wrap them inbackground.submit(that double-queues). The pattern for any NEW side-effect: do the user-visible write inline, then call the self-deferring funnel (orbackground.submit(...)a one-off). - Feature/first-use badges ride the same self-deferring model: call
utils.track_action(user_uid, action[, target])inline at a feature's success point (do NOT wrap it - itbackground.submits itself), whereactionis a key inutils.ACHIEVEMENTS(action -> [(threshold, badge), ...], threshold 1 = first use;UNIQUE_ACTIONSuserecord_unique_activityfor "N distinct things" likedocs.read). Source-derivable milestones (counts of existing tables) instead go incheck_milestone_badges/_COUNT_MILESTONES. Badge metadata +grouplive inBADGE_CATALOG; the profile shows a grouped earned/locked Achievements showcase viabuild_achievements. - Deliberately NOT deferred (would break correctness): cache invalidations (
clear_user_cache/clear_unread_cache/clear_messages_cache/bump_cache_version) must run before the response so the next read is fresh (and they are microsecond version bumps); the vote/reaction count aggregation feeds the AJAX response body; and synchronous external calls whose result the response needs or must surface on failure (the Gitea comment/status calls; file/thumbnail writes whose returned URL must already exist) belong in an async JobService (durable + retryable), not this fire-and-forget queue.
AI content correction (services/correction.py)
Opt-in, default off, server-side. When a user turns it on (profile AI content correction block, owner-only, saved at POST /profile/{username}/ai-correction), the prose they author is rewritten by the AI gateway. The per-user ai_correction_sync flag (0 = background default, 1 = sync) picks the apply mode: background rewrites the stored fields a moment after the write (never slows the request); sync makes the HTTP response wait until the correction is applied.
- Sync must never block the event loop (the self-deadlock trap). The correction POSTs to the in-process gateway (
INTERNAL_GATEWAY_URL=localhost:{PORT}), and the hooked content helpers are synchronous on the loop thread, so a blocking inline call self-deadlocks the (single) worker against its own gateway request - on a single worker that loop is the only thing that can serve the gateway request it is waiting on, so a blocking inline call deadlocks the worker until the httpx timeout, then fail-softs (server hangs, no correction). The fix: sync runs_run_correctionon the dedicatedAI_APPLY_EXECUTORthread pool (correction.py, 4 workers) vialoop.run_in_executor(loop stays free, and the default executor - shared withasyncio.to_threadpassword hashing at login/signup - is never occupied by blocking AI HTTP calls), stashes the future onrequest.scope[PENDING_SCOPE_KEY], and theawait_pending_correctionsmiddleware inmain.pyawaits it after the handler. Never reintroduce a blocking inline correction on the loop thread. - Field registry is the single source of truth.
CORRECTABLE_FIELDS: dict[str, tuple[str, ...]]maps each correctable table to its prose columns:posts->(title, content),projects/gists->(title, description),comments/messages->(content,),users->(bio,).gists.source_code, project files, and Gitea issues are intentionally excluded - code and external systems are never corrected. - Choke entrypoint.
schedule_correction(user, table, uid, request=None)is the only thing handlers call (every hook passesrequest). It is a no-op unless: a user dict is present,tableis in the registry,user["ai_correction_enabled"]is truthy, and the user has a non-emptyapi_key. In sync mode (user["ai_correction_sync"]) it calls_run_inline_awaited, which - only when arequest.scopeand a running event loop exist - submits_run_correctiontoloop.run_in_executor(AI_APPLY_EXECUTOR, ...)(off the loop thread, on the dedicated AI pool) and stashes the future onrequest.scope[PENDING_SCOPE_KEY]for the middleware to await; if there is no request/loop it returns False and falls back to background. In background mode (default) itbackground.submits_run_correction(per-worker queue; also runs inline underDEVPLACE_DISABLE_SERVICES=1). The same_run_correctionworker function runs in all cases. - The hooks (covers UI + REST + Devii + devRant in one place):
content.create_content_item(posts/projects/gists),content.edit_content_item,content.create_comment_record,content.edit_comment_record,services/messaging/persist.persist_message(DMs - sender is the user),routers/profile/index.update_profile(bio), and the two devRant direct-update edit paths (routers/devrant/rants.edit_rant,routers/devrant/comments.edit_comment). devRant create paths route through the shared content cores, so they are already hooked. Code-only and external paths (project files, Gitea) are deliberately not hooked. - The gateway call is fail-soft.
correct_text(api_key, prompt, text)is synchronous (runs on the background worker thread), POSTs toINTERNAL_GATEWAY_URLwithmodel=INTERNAL_MODELviastealth.stealth_sync_client, authenticated with the user's ownBearerapi_key (per-user attribution). It returns the ORIGINAL text on any error, empty output, or suspiciously large output (len > len(text) * MAX_GROWTH_FACTOR + 200, rejecting hallucinated expansion)._run_correctionreads the row back, corrects each registry field that has non-blank text, writes only changed fields viatable.update(updates, ["uid"])without touchingupdated_at(auto-correction is not a user edit) or the slug (slugs are permanent), andclear_user_cache(user_uid)whentable == "users"so the corrected bio re-caches. - Per-user usage aggregation (only on success).
correct_textreturns(text, usage);usageis parsed from the gateway'sX-Gateway-*response headers (_usage_from_headers) whenever the upstream call returned 200 (cost was incurred, even if the corrected output was rejected), elseNoneon any failure._usage_from_headerscaptures the token and cost headers PLUS the timing headersX-Gateway-Upstream-Latency-MsandX-Gateway-Total-Latency-Ms(asupstream_latency_ms/total_latency_ms), so each call's timing is metered._run_correctionaccumulates the per-fieldusageinto onetotalsdict and, whentotals["calls"] > 0, makes ONE call todatabase.add_correction_usage(user_uid, totals)(a singletotalsdict, not positional args) - so a 2-field content item is a single aggregated write, and a failed/empty correction records nothing.add_correction_usage(andadd_modifier_usage) delegate to the shareddatabase._add_usage(usage_table, user_uid, totals): a single atomicINSERT ... ON CONFLICT(user_uid) DO UPDATE SET col = col + excluded.colupsert against thecorrection_usagetable (per-user running SUMS:calls/prompt_tokens/completion_tokens/total_tokens/cost_usd/upstream_latency_ms/total_latency_ms/updated_at, unique indexidx_correction_usage_user, the two latency columns REAL default 0.0, all ensured ininit_db). It is a derived counter table (NOT inSOFT_DELETE_TABLES, likegateway_usage_ledger) and is deliberately separate fromusersso accumulating never invalidates the auth/user cache.database.get_correction_usage(user_uid)(via_get_usage) returns the stored sums PLUS computed averages:avg_tokens(total_tokens/calls),avg_upstream_latency_ms,avg_total_latency_ms,avg_tokens_per_second(completion_tokens over total upstream seconds), andavg_cost_usd. - Profile display:
routers/profile/usage._correction_usage(uid, include_cost)shapes it via the shared_usage_view(data, include_cost)(mirrors_ai_quota);profile/index.pybuilds it only foris_owner or viewer_is_adminand passesinclude_cost=viewer_is_admin, exposed as thecorrection_usagedict on the context andProfileOut. The view surfaces the sums plus averages -avg_tokens(avg tokens/call),avg_latency_ms(avg upstream latency),avg_total_latency_ms,avg_tokens_per_second(avg speed), andtotal_time_s(total upstream seconds) - rendered as extra tiles on the card. Financial gating: tokens/call-count and the performance tiles show to the owner and admins; the dollarcost_usdandavg_cost_usdkeys are present ONLY whenviewer_is_admin, in BOTH the HTML card (templates/profile.html,.correction-usage-*) and therespond(..., model=ProfileOut)JSON (same rule as_ai_quota'sspent_usd- hiding it in the template alone would leak it to a member fetching their own profile as JSON). The card renders only whencorrection_usage.calls> 0. - Import-cycle discipline.
correction.pyimports onlystealth,config,database.get_table/add_correction_usage, andservices.background.backgroundat module top;clear_user_cacheis imported lazily inside_run_correction. Never importcontentorutilsat module top. - Settings live on
users: three columnsai_correction_enabled(0/1),ai_correction_sync(0/1, default 0 = background), andai_correction_prompt(text, defaultconfig.DEFAULT_CORRECTION_PROMPT), ensured indatabase.backfill_api_keys()(the user column-ensure block run byinit_db) and seeded born-live inutils._create_account. The edit route is the owner-or-admin leafPOST /profile/{username}/ai-correction(routers/profile/ai_correction.py,AiCorrectionForm{enabled, sync, prompt}, audit keyprofile.ai_correction). The owner-only values are exposed on the profile page context andProfileOut(ai_correction_enabled/ai_correction_sync/ai_correction_prompt, gated byis_owner), the UI block lives inprofile.html(owner-only: enable checkbox, Apply mode select, prompt textarea) wired bystatic/js/AiCorrection.js(app.aiCorrection), and Devii drives it via the owner-scopedai_correction_get/ai_correction_settools (services/devii/ai_correction/,handler="ai_correction",requires_auth=True, not confirm-gated - it is a reversible per-user toggle;ai_correction_setacceptsenabled, optionalsync, optionalprompt).
AI modifier (services/ai_modifier.py, services/ai_context.py)
A sibling of AI content correction that runs only on an explicit inline directive. The engine reuses the correction plumbing wholesale (CORRECTABLE_FIELDS, PENDING_SCOPE_KEY, gateway_complete, the sync/background apply-mode mechanics, the per-user usage upsert) and differs only in the trigger and the apply-mode/enabled defaults: it is enabled by default and synchronous by default, and it runs ONLY where an authored prose field contains an inline @ai <instruction> directive.
- The
@aigate is the whole difference.has_ai_directive(text)matches the regex@ai\s+\S(case-insensitive).schedule_modification(user, table, uid, request=None)is a no-op unless a user is present,tableis inCORRECTABLE_FIELDS,user["ai_modifier_enabled"]is truthy, the user has anapi_key, AND at least one of the table's registry fields actually contains an@aidirective._run_modificationre-checks the gate per field, so untriggered fields are never sent to the gateway and never metered. Triggerless writes cost nothing. The configured prompt (defaultconfig.DEFAULT_MODIFIER_PROMPT= "Execute what is behind@ai(the prompt) and replace that part including@ai") tells the model to execute the instruction and replace the marked part including the@aimarker. - Total reuse of the correction layer.
CORRECTABLE_FIELDS,PENDING_SCOPE_KEY,gateway_complete, the sync/background apply-mode mechanics (_run_inline_awaited->loop.run_in_executor+request.scope[PENDING_SCOPE_KEY]awaited by theawait_pending_correctionsmiddleware inmain.py, the same self-deadlock-avoiding path), and the per-user usage upsert pattern are all imported from / mirrorservices/correction.py.modify_text(api_key, prompt, text, context="")composes a modifier system message and calls the sharedgateway_complete. The same hooks fire it:profile/index.update_profilecalls bothschedule_correctionandschedule_modification, and the content/comment/messaging cores invoke it alongside correction, so it covers the web UI, REST, devRant, and Devii in one place. Code and source files are never modified (sameCORRECTABLE_FIELDSregistry,gists.source_code/project files/Gitea excluded).schedule_modificationis hooked alongsideschedule_correctionat the same content/comment/messaging/profile entrypoints. - Context-aware (modifier only, not correction). Unlike correction, the modifier gives the model a grounding context block so an
@aiinstruction can reason about who is asking and what it is attached to.services/ai_context.pybuild_context(table, uid, row, user_uid) -> strassembles it;_run_modificationbuilds it lazily once per row (only after a field is confirmed to contain@ai, so triggerless writes do no extra queries) and passes it to every field'smodify_text, which appends it to the system message under a# Context (use it to inform the result; never echo this block)header. The block (fail-soft, length-capped, each part wrapped in try/except so a failed lookup never blocks the modification) has three parts:- date -
Today is DD/MM/YYYY on the DevPlace developer network. - author/stats - the author's username, role, level, stars, post count (
get_user_post_count), leaderboard rank (get_user_rank), follower count (get_follow_counts), member-since date, and bio (cappedMAX_BIO). - location/thread - table-specific: a comment gets the target post/project/gist/news title + excerpt and the parent comment it replies to; a post gets its topic and attached project; a project/gist gets its sibling title/description and, for gists, the language +
source_code(truncated, marked "reference only" - this is read context, the modifier still never WRITESsource_code); a direct message gets the recipient and the lastMAX_THREAD_MESSAGESmessages of the conversation (each truncated). All excerpts are length-capped (MAX_*constants) to bound token cost; the added context tokens are billed and metered like any other input via the usage stats. So@ai answer the question abovein a comment,@ai write my bio from my stats, or@ai reply to thisin a DM all work. The message-thread query binds a param named:current(NOT:self, which collides withdb.query's bound-method argument).
- date -
- Defaults differ: enabled ON by default and apply mode defaults to synchronous (
ai_modifier_synccolumn defaults to 1), the inverse of correction. - Per-user usage (only on success). Same plumbing as correction:
_usage_from_headerscaptures the token, cost, and timing headers (X-Gateway-Upstream-Latency-Ms/X-Gateway-Total-Latency-Ms);_run_modificationaccumulates per-field gateway usage into atotalsdict and, whentotals["calls"] > 0, makes ONEdatabase.add_modifier_usage(user_uid, totals)call (a singletotalsdict) that delegates to the shareddatabase._add_usageatomicINSERT ... ON CONFLICT DO UPDATE SET col = col + excluded.colupsert into themodifier_usagetable (running SUMScalls/token/cost_usd/upstream_latency_ms/total_latency_mstotals, the two latency columns REAL default 0.0, separate fromusersso it never busts the auth cache, NOT inSOFT_DELETE_TABLES, ensured ininit_db).database.get_modifier_usage(user_uid)(via_get_usage) returns the sums plus the same computed averages as correction (avg_tokens,avg_upstream_latency_ms,avg_total_latency_ms,avg_tokens_per_second,avg_cost_usd). - Settings live on
users: three columnsai_modifier_enabled(0/1, default 1),ai_modifier_sync(0/1, default 1 = sync), andai_modifier_prompt(text, defaultconfig.DEFAULT_MODIFIER_PROMPT), ensured indatabase.backfill_api_keys()(the user column-ensure block run byinit_db) and seeded born-live inutils._create_account. Because the feature is on by default,backfill_api_keys()ALSO runs awith db:UPDATE users SET ... WHERE ... IS NULLso EXISTING accounts inherit the on/sync defaults (a freshlycreate_column_by_example-added column is NULL on existing rows, which would read as disabled - the backfill is what makes "on by default" true for everyone, not just new accounts). Thewith db:wrapper is load-bearing: a rawdb.querywrite that does not commit holds the SQLite write lock. The edit route is the owner-or-admin leafPOST /profile/{username}/ai-modifier(routers/profile/ai_modifier.py,AiModifierForm{enabled, sync, prompt}, audit keyprofile.ai_modifier). The owner-only values are exposed on the profile page context andProfileOut(ai_modifier_enabled/ai_modifier_sync/ai_modifier_prompt, gated byis_owner);modifier_usageis built byrouters/profile/usage._modifier_usage(uid, include_cost=viewer_is_admin)(via the shared_usage_view) foris_owner or viewer_is_admin, surfacing the sums plus the performance averages (avg tokens/call, avg latency, avg tokens/sec, total time) likecorrection_usage; the dollarcost_usdandavg_cost_usdare present only whenviewer_is_admin, in both HTML and JSON. The UI block lives inprofile.html(owner-only: enable checkbox, Apply mode select defaulting to sync, prompt textarea, plus the usage card reusing the.correction-usage-*classes with the extra performance tiles) wired bystatic/js/AiModifier.js(app.aiModifier). Devii drives it via the owner-scopedai_modifier_get/ai_modifier_settools (services/devii/ai_modifier/,handler="ai_modifier",requires_auth=True, not confirm-gated;ai_modifier_setacceptsenabled, optionalsync, optionalprompt).
Background services base machinery (services/base.py, services/manager.py)
devplacepy/services/ provides a generic framework for running background async services alongside the FastAPI server. BaseService provides the async run loop, a deque(maxlen=20) log buffer, and graceful cancellation; ServiceManager is a singleton that registers, starts, and stops services. Services are fully managed from the Services admin tab (/admin/services): start/stop, enable-on-boot, run-now, edit parameters, clear logs, adjustable log buffer size, with live status. All of this is generic - a new service gets it for free by declaring its config and implementing run_once.
This section covers only the shared machinery. The individual services built on top of it live in their own subdirectories with their own nested CLAUDE.md: NewsService (services/news/, see devplacepy/services/news/CLAUDE.md), GatewayService and provider/model routing (services/openai_gateway/, see devplacepy/services/openai_gateway/CLAUDE.md), DeviiService (services/devii/, see devplacepy/services/devii/CLAUDE.md), and the bot fleet service (services/bot/, see devplacepy/services/bot/CLAUDE.md).
DB-backed state (correct across workers)
In production (make prod, 2 workers) only the lock-holding worker runs services, but an admin request can hit either worker. State therefore lives in the DB, not process memory:
- Desired/config state in
site_settings(viaget_setting/set_setting):service_<name>_enabled("0"/"1"- also the boot flag),service_<name>_command("<verb>:<counter>", verbsrun/clear),service_<name>_log_size, plus each declared config field's own key. - Observed state in the
service_statetable (one row per service):status,last_run,next_run,started_at,heartbeat,logs(JSON),updated_at. Written by the supervising worker, read by any worker. The heartbeat persists everyPERSIST_SECONDS(8s, under the 15sSTALE_SECONDSliveness window) and caches the row id so a persist is one UPDATE, not afind_one+ UPDATE.collect_metrics()runs on its own slowerMETRICS_SECONDScadence (15s default, per-class overridable -AuditServiceuses 300s because its metric is aCOUNT(*)over the ever-growing audit table); between refreshes the last snapshot is re-persisted, and aforcepersist (transitions, run-now) always recomputes. Keep expensive aggregates out of the 1s tick: put them incollect_metricsand, if still heavy, raise the subclassMETRICS_SECONDS.
main.py registers every service in all workers (so describe_all() works anywhere) but only the lock worker calls service_manager.supervise().
ConfigField (services/base.py)
Declarative parameter spec. type in int/str/url/text/password/bool/select. coerce(raw) validates and types (raises ValueError, never silent); read() is the lenient runtime reader (falls back to default); spec() renders the field for the template/JSON (secrets masked, never sent). Optional minimum/maximum/options/help/secret. group="..." lets the detail page render fields as sections (built-in fields are grouped "General"/"Advanced").
BaseService (services/base.py)
Abstract class for all services:
name,interval_key(defaultservice_<name>_interval; a subclass may point it at an existing key),min_interval.config_fields- class-level list ofConfigField. The framework prepends built-inenabledand interval fields and appendslog_size;all_fields()returns the full set.get_config()returns a typed dict from settings;is_enabled(),current_interval()(floored tomin_interval).log(message)- writes tolog_bufferand standardlogging.run_once()- abstract; override with actual work. Read parameters viaself.get_config().- Reconciling loop (
_run_loop/_tick, ~1s tick): honorsenabled, runsrun_once()when due or on aruncommand, handlesclear, rebuilds the log deque iflog_sizechanged, and persists observed state (throttled, force on transitions). Start/stop/run/clear from any worker are just DB writes the loop reacts to within ~1s - services are never hard-restarted. - Lifecycle hooks
async on_enable()/async on_disable()- called on transition to enabled / disabled (andon_disableon shutdown). Override for long-running resources that outlive onerun_once(e.g. the bot fleet);run_oncethen becomes a periodic reconcile/metrics tick. collect_metrics() -> dict- generic live metrics. Return{"stats": [{"label", "value"}], "table": {"columns": [...], "rows": [[...]]}}. Persisted toservice_state.metricseach tick and rendered live in the Services tab (stat cards + table) byServiceMonitor.js. Default returns{}.default_enabledclass flag (defaultTrue) - setFalsefor opt-in services so they never auto-start on boot (the bots service uses this).title/descriptionclass attrs - shown in the admin UI; every service should set adescriptionof what it does.
Admin UI (routers/admin/services.py): /admin/services is a slim index (services.html, an admin-table of name/description/status/uptime/last-run/next-run/interval) where each row links to /admin/services/{name}. The detail page (service_detail.html) holds everything for one service - header with controls (start/stop/run/clear), and [data-tabs] tabs Overview (meta + metrics), Configuration (the config form rendered as a <fieldset> per field_groups entry), Logs. ServiceMonitor.js is dual-mode (polls /admin/services/data for the index, /admin/services/{name}/data for the detail, via the shared Poller; its config-save POST goes through Http.sendForm) and shares one update(root, svc) over data-* hooks; tabs use the generic reusable static/js/Tabs.js ([data-tabs]/[data-tab]/[data-tab-pane], wired in Application.js). Adding a service still needs zero UI code.
describe()- builds the dict the UI consumes:title,description, derived display status (stoppedwhen disabled,runningwith fresh heartbeat,stalledwhen enabled but heartbeat older thanSTALE_SECONDS), meta, logs, metrics, flatfields, andfield_groups(ordered{name, fields}sections) read fromservice_state+ settings.
ServiceManager (services/manager.py)
Singleton that manages all registered services:
register(service)- add a servicedescribe_all()->list[dict](per-servicedescribe())set_enabled(name, bool)- Start/Stop (writes the enabled flag)send_command(name, "run"|"clear")- one-shot command channelsave_config(name, form)- strict validation via eachConfigField; blank secret = keep existing; returns{ok, errors}supervise()- start the reconciling task per service (lock worker only)shutdown_all()- cancel tasks on server shutdown
Adding a new service
- Create
devplacepy/services/your_service.pywith a class extendingBaseService. Declareconfig_fields(and setinterval_key/min_intervalif reusing an existing key); read them inrun_onceviaself.get_config(). - Override
async def run_once(self) -> None. - Register in
main.pystartup event (outside the lock block, so all workers know the roster):from devplacepy.services.your_service import YourService service_manager.register(YourService()) - The service appears automatically on
/admin/serviceswith start/stop, enable-on-boot, run-now, a generic config form, clear-logs, live status, and the log tail - no template, JS, or router changes needed.
CLI
devplace news clear # Delete all news from local database
devplace devii reset-quota <username> # Reset one user's rolling 24h AI quota
devplace devii reset-quota --guests # Reset every guest quota
devplace devii reset-quota --all # Reset every quota (users and guests)
Multi-worker concurrency (preferred rules)
uvicorn --workers N = N independent processes sharing only the filesystem and SQLite DB. Module-global caches/counters are per-process, so a local clear() is invisible to siblings. Full reference: admin docs Production -> Multi-worker and concurrency (templates/docs/production-concurrency.html). Enforce these:
- In-process caches are never authoritative across workers. Coordinate invalidation through the
cache_stateversion table indatabase.py: callbump_cache_version(name)at every write path andsync_local_cache(name, cache)before every read path. Existing names:auth(guards_user_cache; bumped byclear_user_cache/clear_session_cacheon logout, ban, role/password change - a version bump makes EVERY worker clear its WHOLE_user_cache, so display-only refreshes (XP/level inaward_xp, AI-corrected bio) callclear_user_cache(uid, propagate=False)for a local pop without the global bump; identity/authz changes (logout, ban, role, password, api-key/token revoke) MUST keep the default propagate=True),settings(guards_settings_cache; bumped byset_setting/clear_settings_cache),relations(_relations_cache),customizations(_customizations_cache),notif_prefs(_notification_prefs_cache),gateway_routing(_ROUTING_CACHE), andadmins(guards_admins_cache, which memoizesget_admin_uids()/get_primary_admin_uid()). The_cache_version_cache(ttl=1) caches the version reads themselves, so the bump is seen by other workers within ~1s on their next read (the originating worker is immediate); the version read/bump fail open (logged, never raise). When you add a per-process cache whose staleness matters, give it a name and wire both calls - do not invent a second invalidation mechanism. - Admin-set cache invariant (load-bearing).
_admins_cachemakesget_admin_uids()/get_primary_admin_uid()(hit on the projects-listing visibility filter,_owner_is_admin, and every primary-admin gate:/dbapi, backup-archive download) a dict lookup instead of a per-callSELECT. It is NOT the authorization gate -is_admin(user)/require_adminread the role off the user object (independently invalidated viaclear_user_cache), so a stale admin set cannot grant access. But any code that writesusers.roleMUST calldatabase.invalidate_admins_cache()(clears local + bumps theadminsversion), exactly like the soft-delete andwith db:rules. Current role-write sites all do:routers/admin/users.py(role change),cli.py(role set), andutils._create_account(first user -> Admin). Omitting the call leaves the admin set stale for up to the 300s TTL. - Deterministic / eventual caches skip version-sync on purpose. Two caches need no
cache_statename because correctness does not depend on cross-worker freshness: (1)docs_prose._render_markdownis an@lru_cacheon the static prose source string (pure function of template content, so identical on every worker; changes only on deploy), and (2)main._home_cache(TTLCache ttl=60) memoizes the guest/blocks - the featured-news block (identical for everyone) and the latest-posts block only for the no-block case; a viewer with a non-empty block set bypasses the cache and is computed fresh, so the original block-filter semantics are preserved byte-for-byte and there is zero cross-user leakage. Worst case is a soft-deleted/edited public post lingering on/for <=60s. Use a plainTTLCache/lru_cache(no version name) ONLY when the value is deterministic or its staleness is purely cosmetic; anything whose staleness affects correctness or permissions MUST use the version-sync pattern above. - Authoritative state lives in the DB, memory is only a short-TTL accelerator (sessions, the Devii 24h cap via
devii_usage_ledger, cost counters). - Global rates/quotas are worker-count-aware or DB-backed. The rate limiter enforces
ceil(limit / DEVPLACE_WEB_WORKERS)per process so the aggregate matches the configured value with no per-request write.DEVPLACE_WEB_WORKERSMUST equal the real--workers(set inmake prod=2 and the Dockerfile=2; default 1 for dev). Update it whenever you change worker count. - First-boot side effects are flock-serialized and idempotent.
init_db()runs under a blockingflockondevplace-init.lock(the seed-if-missing inserts would otherwise race to duplicate rows);ensure_certificates()early-returns when keys exist and otherwise generates under.vapid.lock. Any new run-once-at-startup work follows the same pattern (flock + exists-check /INSERT OR IGNORE). - Run-once background work stays behind the services lock, never per-worker: gate it on
service_manager.owns_lock()(held viadevplace-services.lock), as news/bots/Devii hub do.
Performance caches (request-path)
Read-mostly aggregates that were recomputed per request or per vote sit behind short per-worker TTLCaches (devplacepy/cache.py). These are DISPLAY caches: staleness up to the TTL is accepted by design, so none of them uses the cross-worker cache_state versioning (that is reserved for correctness-critical caches like auth/settings/admins). The registry:
| Cache | Where | Key | TTL | Invalidation |
|---|---|---|---|---|
_authors_cache |
database/ranking.py |
ranked/rank_map |
60s | TTL only - update_target_stars deliberately does NOT clear it per vote anymore (the old per-vote clear() forced a full UNION-JOIN ranking recompute on the next feed/leaderboard/landing request and never propagated cross-worker anyway) |
_stars_cache |
database/ranking.py |
user uid | 15s | TTL + clear_user_stars(owner_uid) from content.apply_vote, so a user's own total updates immediately on the voting worker |
_leaderboard_cache |
services/game/store/farm.py |
top:{limit} |
15s | TTL only (the farm scan + Python farm_score sort runs at most once per 15s per worker) |
_projects_cache |
templating.py |
user uid | 10s | TTL + clear_user_projects_cache(uid) from the content.py project create/delete choke points (in-function import - templating imports content at module level). jinja_user_projects also filters deleted_at=None now (the composer dropdown previously listed soft-deleted projects) |
Rules: a new hot read-path aggregate follows this exact pattern (module-level TTLCache, 10-15s, clear_* helper only when a same-worker write must be visible immediately); never reintroduce a whole-cache clear() on a per-event write path; profile projects/gists loads are tab-gated in routers/profile/index.py (tab == X or wants_json(request) - the JSON ProfileOut keeps serializing both, HTML tabs that do not render them get []).
Live view relay (services/live_view_relay.py)
LiveViewRelayService is a second lock-owner pub/sub bridge (default-enabled, 1s interval, registered in main.py after NotificationRelayService) that replaces the per-client HTTP polling on the admin live views with server push, computing a snapshot only for topics that currently have subscribers. Each tick it reads pubsub.topics() (the hub's live subscription set, which converges on the lock owner), matches each concrete subscribed topic against the VIEWS registry (a list of (compiled_regex, compute_callable, min_interval_seconds)), throttles per topic via a time.monotonic() map, computes the payload, and publishes it on the same topic. The compute callables (async, lazy-importing to avoid cycles, defensive try/except -> skip) reproduce the exact payload shape the matching HTTP endpoint already returns, so the frontend render code is unchanged - only the data-arrival path is added. Registry:
| Topic | Cadence | Payload (same as endpoint) |
|---|---|---|
container.list |
4s | {instances} (admin all-instances, _decorate) |
project.{slug}.containers |
3s | {instances} (per-project, slug resolved via resolve_by_slug) |
container.{uid}.detail |
4s | {instance, events, schedules, stats, runtime} |
container.{uid}.logs |
3s | {logs} (backend log tail 400) |
fleet.bots |
2s | bot frames payload (routers/admin/bots._frames_payload) |
admin.services |
5s | {services} (service_manager.describe_all()) |
admin.services.{name} |
5s | {service} |
admin.ai-usage.{hours} |
15s | build_analytics(hours) (hours parsed from the topic) |
admin.backups |
8s | routers/admin/backups._dashboard(can_download=False) (storage, backups, schedules, metrics) |
All these topics are admin-only by pub/sub policy (non-public, non-user.{uid} -> privileged required), matching the admin-only pages. Frontend monitors (ContainerInstance, ContainerList, ContainerManager, BotMonitor, ServiceMonitor, AiUsageMonitor, BackupMonitor) each window.app.pubsub.subscribe(topic, render) in their init and keep a lengthened HTTP poll (15-30s) as initial-load + fallback - the relay drives liveness at the cadence above. AiUsageMonitor re-subscribes (unsubscribe old, subscribe new) when the window-hours selector changes, since hours is in the topic.
Container topics never broadcast private-project instances: container.list publishes only public-project rows with partial: true (ContainerList.merge updates by uid, never removes, so private rows from the authoritative HTTP poll survive), and project.{slug}.containers / container.{uid}.detail / container.{uid}.logs skip private-project targets entirely (owners fall back to their HTTP polls).
admin.backups is the one view with a per-viewer field: the backup download_url is restricted to the primary administrator at every endpoint, so the relay publishes the snapshot with can_download=False (the bus broadcasts one payload to every admin subscriber, and is therefore treated as another endpoint that must withhold it). BackupMonitor derives canDownload from its authoritative per-viewer HTTP poll only (never from a pushed frame) and builds the /admin/backups/{uid}/download URL client-side; the download route itself stays primary-admin-gated.
To add a new admin live view: add one (regex, compute, interval) row to VIEWS whose compute returns the endpoint payload, and have the frontend subscribe to the topic while keeping a fallback poll. Reuse this subscriber-gated snapshot pattern for any future "several admins watch a periodically-recomputed server snapshot" surface; keep durable/ordered/stateful/raw-I/O channels (messages, Devii, SEO/DeepSearch progress, container PTY) OFF pub/sub. See pubreport.md for the full migration analysis.
Notification relay (services/notification_relay.py)
Live toasts ride the in_app channel (no new channel). NotificationRelayService is a lock-owner BaseService (registered in main.py, default-enabled, 1s interval) modeled on MessageRelay: it primes a watermark to MAX(notifications.id) on first tick, then each tick selects notifications WHERE id > watermark and publishes {uid, type, message, target_url} to the per-recipient pub/sub topic user.{user_uid}.notifications. It runs only on the service lock owner, which is exactly where every pub/sub WS subscriber converges (non-owner /pubsub/ws closes 4013), so the in-process services.pubsub.publish reaches the recipient's browser. Because the notifications row exists only when the in_app channel is enabled, a live toast fires exactly for the notifications a user has enabled - the relay needs no preference lookup of its own. The watermark always advances (boot-priming + every-tick) so a newly-connected subscriber never gets a backlog flood.
Frontend: base.html exposes the viewer uid as <body data-user-uid>, static/js/LiveNotifications.js (app.liveNotifications, constructed with this.pubsub/this.toast) subscribes to that topic and calls app.toast.show(message, {type:"info", ms, url}). The toast click reproduces a bell-item click exactly: it targets GET /notifications/open/{uid} (which marks the row read and redirects to its target_url), not the bare target_url - the relay publishes uid for this. AppToast gained a generic click action via _action(options): options.onClick (a function) or options.url (navigate); either adds the .dp-toast-link pointer cursor, so any caller can attach a click action. DMs toast too (type message is not excluded). Reuse this relay-on-the-lock-owner pattern for any future "live mirror of a DB-persisted, per-user event."
Unread-count badges ride the same relay. The same NotificationRelayService tick, after publishing the toast rows, also publishes the recipient's fresh unread counts {notifications, messages} to user.{uid}.counts (computed with a direct DB query on the lock owner, never the per-worker _unread_cache, which could be stale there). Because a new DM also inserts a message-type notification row through persist_message -> create_notification, this one publish point covers both new notifications and new messages, so the header badge bumps instantly. static/js/CounterManager.js (app.counters, constructed with this.pubsub) subscribes to user.{uid}.counts and applies the pushed counts; its HTTP poll of /notifications/counts stays as a 60s reconciliation fallback (covers decrements on read and any missed frame). Read events still clear the per-worker cache as before.
Online presence (services/presence.py, services/presence_relay.py)
Online status is a single users.last_seen UTC-ISO column (ensured in database.backfill_api_keys), never a new table and never a per-request insert. The design is dictated by the multi-worker architecture: a profile of user X renders on any worker, so "is X online" needs cross-worker state, and pub/sub is in-process only (a non-owner worker cannot publish to the lock owner), leaving SQLite as the sole shared medium. So presence is a heavily-throttled in-place UPDATE.
Write path (all workers): main.py's track_presence HTTP middleware resolves the cached current user on every non-/static, non-/avatar request and calls presence.touch(uid). touch keeps a per-worker in-memory _last_write: dict[uid -> monotonic] and writes users.last_seen (via database.set_last_seen) only when the last write for that uid is older than config.PRESENCE_WRITE_SECONDS (= PRESENCE_TIMEOUT_SECONDS // 2). So continuous browsing is a dict lookup; a write happens at most ~once per half-window per active user per worker, and the row is updated in place (zero growth). It deliberately does not call clear_user_cache (that would defeat the 300s auth cache; the stale cached self-row is irrelevant since presence of other users is always read from a fresh row).
Read path (any worker): presence.is_online(user_row) = now - last_seen < PRESENCE_TIMEOUT_SECONDS (env DEVPLACE_PRESENCE_TIMEOUT_SECONDS, default 60). Profile (routers/profile/index.py -> profile_online) and messages (routers/messages.py seed) read last_seen off the user row they already loaded - no extra query. Exposed as the Jinja global is_online(user) (templating.py), on UserOut.last_seen and ProfileOut.profile_online. This is the only cross-worker-correct approach here because pub/sub is in-process.
Live path (lock owner only), change-only + hysteresis: PresenceRelayService (services/presence_relay.py, BaseService, default-enabled, 2s tick, registered in main.py) is a sibling of NotificationRelayService/LiveViewRelayService. Each tick it recomputes ONE global online set self._online (dot-subscribed uids batch-read via get_users_by_uids + roster candidates) and drives BOTH the per-user dots and the feed roster from that one set, so they can never disagree. The set uses hysteresis via presence.stays_online(elapsed, was_online): a user becomes online at PRESENCE_TIMEOUT_SECONDS but only drops after + PRESENCE_ONLINE_MARGIN_SECONDS (env DEVPLACE_PRESENCE_ONLINE_MARGIN_SECONDS, default 20) - quick to go online, slow (grace margin) to go offline - which kills boundary flicker for a user hovering near the timeout. It publishes {online, last_seen} to public.presence.{uid} only when a topic's online bool changed OR the topic is newly subscribed (first-seen) - never on a fixed interval, so a steady page emits nothing after the initial frame (self._published[topic] -> bool, pruned to active topics). public.presence.{uid} is subscribable by any logged-in user (pubsub/policy.py allows public.*); guests fall back to the server-rendered initial state. The one-directional grace also means dots and roster stay consistent across viewers (the shared self._online is the single authority). To keep it lightweight the relay reads all due users in one batched get_users_by_uids per tick.
Frontend: static/js/PresenceManager.js (app.presence, constructed with this.pubsub in Application.js, mirroring LocalTime/CounterManager) scans [data-presence-uid] elements, subscribes each uid to public.presence.{uid} (deduped per uid, so a repeated author is one subscription), and treats each frame's online flag as authoritative (entry.online), toggling the online class + (for data-presence-label elements) the "online / last seen X / offline" text. Because the relay is change-only and authoritative, a live-subscribed dot is not expired by the client clock (no false-offline flicker for an active user whose last_seen the client cannot see advancing); the 20s last_seen staleness timer only applies to entries that never received a frame (guests / degraded). The window is read from <body data-presence-timeout>. Both the profile .profile-presence dot and the messages #messages-presence span carry data-presence-uid/data-presence-last-seen.
Online-now roster (feed): the same relay maintains ONE shared topic public.presence.roster, republished only when the SET of online uids changes (a frozenset compare, so pure reordering never republishes). services/presence.py online_users(limit) (strict, feed initial render) and online_candidates(limit) (grace window, relay hysteresis) both go through database.get_online_users(cutoff_iso, limit), which reads users with last_seen >= cutoff via the idx_users_last_seen index (the one place presence is queried by last_seen; config.PRESENCE_ONLINE_LIMIT, env DEVPLACE_PRESENCE_ONLINE_LIMIT, default 30). The list is ordered ALPHABETICALLY by username (get_online_users order_by=["username"] + case-insensitive presence.sort_by_username), NOT by recency, so avatars keep a stable position and do not needlessly reshuffle as people's last_seen ticks. routers/feed.py puts online_users on the context (FeedOut.online_users) and feed.html renders the initial Online now panel as a .sidebar-section at the bottom of the left feed sidebar (aside.sidebar-card); static/js/OnlineUsers.js (app.onlineUsers) subscribes to public.presence.roster and re-renders the avatar list + count live. Roster avatars use a plain green .presence-dot with NO data-presence-uid (list membership IS the presence, so no per-user subscription - the relay drops a user from the roster when they go offline).
Avatar presence dot (sitewide, DRY): a small corner dot on every user avatar (green online, muted grey offline) comes from ONE reusable partial templates/_presence_dot.html - <span class="presence-dot" data-presence-uid data-presence-last-seen> guarded on _user.get('uid') (a partial-dict author, e.g. the issues includes, renders no dot). It carries no data-presence-label, so PresenceManager colours it with zero extra JS. It is included by the shared avatar partial templates/_avatar_link.html (its .user-avatar-link anchor is the positioning host, covering ~19 sites) and by the handful of raw-<img class="avatar-img"> sites wrapped in a positioned <span class="avatar-badge"> (the two base.html nav avatars, the profile.html hero + followers list, the messages.html conversation list). CSS in static/css/base.css (.user-avatar-link/.avatar-badge position:relative;display:inline-flex, .presence-dot sized 30% of the avatar clamped 8-14px with a --bg-card ring, .online -> --success), so it is proportional and responsive at every avatar size with no per-size class. database/follows.py get_follow_list now carries last_seen in its trimmed dict so the followers/following dots resolve (all other author dicts are full get_users_by_uids rows). dp-avatar (AppAvatar.js) is docs-demo only (no real user avatars) and is intentionally out of scope. Reuse _presence_dot.html + the .avatar-badge wrapper for any new avatar surface - never hand-roll a presence dot.
Messaging refactor: the old presence was per-worker and WS-connect-based (message_hub.is_online/last_seen, _announce_presence, the WS presence frame) and broke with >1 worker. That display path was removed; message_hub keeps only its socket connection tracking for message delivery. The messages header presence is now the shared PresenceManager, so chat presence is finally cross-worker correct. Never re-implement WS-connect presence - reuse presence.is_online, the public.presence.{uid} topic, and PresenceManager.