Managed background workers¶
Register Python callables in LclFastAPI(background_workers={"name": callable}).
The framework copies the mapping and validates its names and executables during
construction, without starting any resource. Names are Python identifiers;
default, controller and service are reserved. Configuration supplies parameters,
never executable entry points or external commands.
Each enabled callable owns a dedicated thread, asyncio Runner and configuration
Frame. An asynchronous entry executes on that thread's event loop. A synchronous
entry executes outside the loop and can use context.run(awaitable) for its local
asynchronous operations. Do not call run() from an asynchronous entry or another
thread. No work is placed in FastAPI's shared thread pool.
Configuration¶
# Managed background tasks
background_worker.default.enabled: True
background_worker.default.auto_restart: True
background_worker.inventory.auto_restart: False
background_worker.heartbeat.schedule.seconds: 0.5
# Logging and file destinations
logger.file.inventory.filename: f"{app.name}.inventory.{worker_pid}.log"
logger.file.heartbeat.filename: f"{app.name}.heartbeat.{worker_pid}.log"
The two default switches are bundled defaults. Each named worker overrides an individual leaf; both switches strictly require Boolean values. Other nested settings are ordinary LCL bindings, read using their full qualified names. Unregistered configuration cannot start a worker. Disabled registrations remain visible in observations but do not start threads or open their dedicated file.
A nonempty registry requires one effective API worker, even when all registrations
are disabled. When a larger server.workers count is requested, startup validates
configuration, inspects the application in a short-lived import process, warns and
uses one worker. The probe does not enter business lifespan and does not preload
business modules into the master. A count already equal to one, or forced to one
by hot reload, needs no additional import probe. The file is never rewritten.
Status and health report the effective count. Import-time business side effects
can therefore run in a probe as well as an actual API worker; initialize resources
inside lifespan instead of module import.
Each thread loads its own configuration at startup with the launch-time CLI
overrides. Every execution attempt gets a fresh derived scope; automatic restarts
do not reload configuration. Nested use_lcl_frame() scopes temporarily change
get_config() lookup in that thread. Frames and their lazy caches never cross
event loops. Use a complete service restart to apply configuration changes.
Resources and entry points¶
The exported BackgroundWorkerContext provides:
name,appand the optional business-lifespanstatemapping.logger, routed to this worker's named file, andget_config(key).stop_event, a thread-safe cooperative stop signal.run(awaitable)for synchronous entries on the owning thread's Runner.submit_to_service(async_callable), returning a concurrent Future whose factory runs on the API loop under the API's configuration context.
Use app.state for business resources and ordinary locks for thread-safe shared
objects. Loop-bound clients and connection pools must be used on their owning API
loop. Synchronous workers wait on the submitted Future; asynchronous workers use
await asyncio.wrap_future(future). Await every submitted operation before leaving
the worker. The framework also drains actual submitted API Tasks, including their
cancellation cleanup, before closing an attempt's Frame, restarting that worker,
or tearing down business resources. Cancelling a Future alone is not that barrier.
An API-loop callback must not synchronously wait for the submitting
worker; that would deadlock. Submitted callbacks must themselves be nonblocking.
import asyncio
from lcl_fastapi import BackgroundWorkerContext, LclFastAPI, use_lcl_frame
async def inventory(context: BackgroundWorkerContext) -> None:
async def read_count() -> int:
return len(context.app.state.catalog)
count = await asyncio.wrap_future(context.submit_to_service(read_count))
async with use_lcl_frame(values={"batch.count": count}) as frame:
assert await frame.get("batch.count") == count
context.logger.info("inventory count=%s", count)
def heartbeat(context: BackgroundWorkerContext) -> None:
while not context.stop_event.wait(1):
context.logger.info("heartbeat")
service = LclFastAPI(background_workers={"inventory": inventory, "heartbeat": heartbeat})
Initialize app.state.catalog in a business lifespan before running this service;
the composed example provides the complete runnable
application, configuration and verifier. Configure inventory's auto_restart=False
for a finite task. The default True treats any return as a restartable worker exit.
Threads keep blocking I/O and worker event loops away from the API event loop. They still share the process, GIL, CPU, memory and any application locks; arbitrary CPU-bound or GIL-holding code can affect API responsiveness. This is not process isolation. Background work starts without an HTTP request ID or request context.
Restart, shutdown and observations¶
Business lifespan initializes first, then all enabled background thread resources must initialize before API startup completes. A configuration/resource initialization failure fails service startup and cleans already-started threads. Startup also requires a successful initial journal write; inaccessible lifecycle storage fails startup before any background entry runs. An entry failure is recorded and governed by auto_restart. With that switch enabled, both normal returns and Exceptions wait one second before another attempt. The wait is interruptible. With it disabled, completed or failed status remains observable. Process-exit BaseExceptions end the affected thread rather than becoming HTTP errors.
Normal completion and its restart use INFO. Exception exits use ERROR; their scheduled restarts use WARNING. Controller events contain worker name, attempt, status and reason, while detailed task tracebacks go to the background file. Short-lived executions are journaled rather than inferred from sampling, so normal rapid exits are not lost between health refreshes. The controller is the sole writer of its controller log; the API publishes ordered, identity-scoped JSONL events in the private runtime directory. Those journals are removed after the controller consumes their final events and verifies process exit.
On stop or reload, the framework prevents further restarts, sets every stop_event, and cancels active asynchronous entries. It waits for thread cleanup before business lifespan teardown and finally logger flushing. Synchronous entries must cooperate; asynchronous entries must not indefinitely suppress cancellation. The API loop remains available for resource cleanup submissions during this wait.
After server.graceful_timeout_seconds, the controller logs the timeout and
terminates the entire API process, checking its creation-time identity before
signaling. A normal service stop does not restart it; hot reload replaces it.
The controller starts its own retirement deadline at stop/reload, independently
of journal delivery. Runtime journal publication failure is logged immediately,
stops further background executions and requests service shutdown. A failed
journal channel cannot disable the controller's process retirement deadline.
If stop/reload is already retiring the worker, publication failure preserves that
operation rather than converting reload into a whole-service stop. Controller
deadlines begin with the native retirement request and use a monotonic clock;
repeated requests and late journal records cannot extend them.
The already-reported publication error is not rethrown into business teardown;
cooperative shutdown still closes business resources normally.
Forced termination cannot guarantee worker cleanup, business teardown, transactions
or API log flushing. The local stop command can report its graceful deadline
before forced retirement has finished; inspect status afterward. Controller event
delivery is best effort after an abrupt process or machine crash.
health.service.background_workers and each status worker observation report
per-name status, attempts and restarts. Disabled entries have zero counters.
These are thread observations, not extra API workers or Snowflake leases. logs
includes their actual upstream file paths and follows the existing eventual
consistency and rotation contract.
File routing¶
logger.file.<worker_name> configures a worker's file; absent filenames default to
<app.name>.<worker_name>.<worker_pid>.log. Files inherit logger.file.default and
retain native levels, rotation and directory semantics. API and background records
share the one process-wide lclang queue writer. Do not open another
use_logger_handler scope from a worker.
With background registration, records receive source namespaces
lcl_fastapi.api.<original> or lcl_fastapi.background.<worker>.<original> before
the writer. Native logger-name filters are qualified for the matching role, keeping
default API/background files separate while preserving business prefixes and call
locations. A worker's get_logger() calls follow its context automatically; service
bridge callbacks use API logging context. Unregistered additional sinks remain API
sinks. The controller file is never opened by an API/background thread.