Application Lifecycle
Doover supports two application types, each with a distinct lifecycle. Docker applications run continuously on edge devices, while Processors execute as short-lived, event-driven cloud functions. This page covers both lifecycles in detail.
Docker Application Lifecycle
Docker applications are long-running processes that execute on Doovits or other Docker-capable edge devices. They follow a linear lifecycle: initialise, set up, loop, and shut down.
1. Initialisation
The lifecycle begins when run_app() is called, typically from the application's entry point. This function:
- Parses command-line arguments (agent ID, app key, gRPC address, config file path)
- Creates the core interface instances:
DeviceAgentInterfacefor cloud communication,PlatformInterfacefor hardware I/O, andModbusInterfacefor Modbus devices - Instantiates the user's
Applicationsubclass with these interfaces
from pydoover.docker import Application, run_app
class MyApp(Application):
pass # lifecycle methods defined below
if __name__ == "__main__":
run_app(MyApp)
The run_app() function is the standard entry point for all Docker applications. It handles argument parsing and interface creation so the application class can focus on business logic.
2. Health Check
Before proceeding, the application waits for the DeviceAgent service to become healthy. The DeviceAgent is a background service on the Doovit that manages cloud connectivity. The application polls its health endpoint until it responds successfully.
The wait timeout defaults to 300 seconds (dda_startup_timeout). If the DeviceAgent does not become healthy within this window, the application logs an error and retries.
The DeviceAgent must be running and healthy before any cloud communication can occur. If your application starts before the DeviceAgent (common during device boot), the health check ensures it waits rather than failing with connection errors.
3. Configuration Loading
Once the DeviceAgent is healthy, the application loads its deployment configuration. Configuration can come from two sources:
- Channel aggregate: The standard path. The application fetches its deployment config from the agent's configuration channel aggregate in the cloud.
- Local file: If a
config_fpwas provided (common during development), the application reads configuration from a local JSON file instead.
The configuration is parsed using the application's Schema subclass and made available as self.config.
4. Setup
The framework calls the user's setup() method. This is where the application performs one-time initialisation:
- Subscribe to channels for receiving commands or data from other agents
- Initialise state machines, PID controllers, or other stateful components
- Configure UI elements (variables, interactions, containers) for the web portal
- Register tag definitions and set initial values
- Set up Modbus register maps or platform I/O pin configurations
def setup(self):
# Subscribe to a command channel
self.device_agent.subscribe_to_channel("commands", self.on_command)
# Initialise a UI variable
self.temp_display = NumericVariable(
"Temperature",
units="C",
precision=1,
)
# Register a tag for persistent state
self.tags.register(Number(default=0.0, name="temperature"))
The setup() method runs once after configuration is loaded. It is the place to establish subscriptions, create UI elements, and prepare any state that the main loop will depend on.
5. Ready
After setup() returns, the framework sets the is_ready flag to True and the application enters the main loop. The healthcheck HTTP server (default port 49200) begins reporting the application as healthy, which signals to Docker and orchestration tools that the container is ready.
6. Main Loop
The application enters a continuous loop that calls the user's main_loop() method on each iteration. The loop runs at a target period controlled by loop_target_period (default: 1 second).
Each iteration:
- Calls
main_loop()where the application reads sensors, runs control logic, updates UI elements, and publishes data. - Commits any pending tag changes to the cloud.
- Sleeps for the remaining time in the target period (if the iteration finished early).
def main_loop(self):
# Read a sensor value from the platform interface
temp = self.platform_iface.read_analog(channel=0)
# Update the tag (will be committed at end of iteration)
self.tags.set("temperature", temp)
# Update the UI display
self.temp_display.update(temp)
The main_loop() method contains the application's core logic. It is called repeatedly at the target period. Tags are committed automatically after each iteration, so you do not need to call commit manually.
If a main_loop() iteration takes longer than loop_target_period, the next iteration starts immediately with no sleep. The framework logs a warning when iterations consistently exceed the target period.
7. Error Handling
If an unhandled exception occurs during main_loop(), the framework catches it, logs the full traceback, and waits 10 seconds before retrying. The application does not crash on a single loop error. This provides resilience against transient failures (network timeouts, sensor read errors, etc.).
If the error persists across multiple iterations, the logs will show repeated exceptions, which can be monitored through the Doover portal or Docker log output.
8. Shutdown
Shutdown can be triggered by:
- A
shutdown_attag set to a future timestamp. When the current time reaches or passes this timestamp, the framework initiates shutdown. - A system signal (SIGTERM, SIGINT) sent by Docker or the operating system during container stop.
The shutdown sequence:
- The framework calls
check_can_shutdown(). The application can returnFalseto defer shutdown (e.g., if a critical operation is in progress). - If shutdown proceeds,
on_shutdown_at()is called, allowing the application to perform cleanup (close connections, flush buffers, save state). - Final tag commit and UI log entry (if
force_log_on_shutdownis enabled). - The process exits.
def check_can_shutdown(self):
# Defer shutdown if a firmware update is in progress
if self.firmware_update_active:
return False
return True
def on_shutdown_at(self):
# Clean up resources before exit
self.modbus_iface.close()
self.log("Application shutting down gracefully")
These methods give the application control over the shutdown process. check_can_shutdown() can prevent premature shutdown during critical operations, and on_shutdown_at() handles final cleanup.
Docker Lifecycle Summary
| Phase | Method/Hook | Description |
|---|---|---|
| Initialisation | run_app() | Parse args, create interfaces, instantiate app |
| Health check | (automatic) | Wait for DeviceAgent service |
| Config loading | (automatic) | Fetch deployment config from cloud or file |
| Setup | setup() | One-time initialisation (subscriptions, UI, tags) |
| Ready | (automatic) | Set is_ready, start healthcheck server |
| Main loop | main_loop() | Continuous execution at target period |
| Error handling | (automatic) | Catch exceptions, retry after 10s |
| Shutdown | check_can_shutdown(), on_shutdown_at() | Graceful shutdown with cleanup |
Processor Lifecycle
Processors are short-lived, event-driven cloud functions that run on AWS Lambda. Each invocation handles a single event and then exits. The lifecycle is designed for fast execution with minimal overhead.
1. Invocation
A processor is invoked when an event arrives. Events are delivered through AWS infrastructure:
- SNS delivers channel events (new messages, aggregate updates)
- EventBridge delivers schedule events (cron-based triggers)
The Lambda handler receives the event payload and begins processing.
2. Pre-Hook Filter
Before any setup or API calls, the processor runs a pre-hook filter. This is a fast rejection gate that examines the raw event to determine whether processing should continue.
The pre-hook filter runs before any network calls, making it extremely cheap to skip irrelevant events. If the filter rejects the event, the processor exits immediately with a ProcessorSkipped result.
def pre_hook(self, event_type, event_data):
# Only process temperature channels
if event_data.get("channel_name") != "temperature":
return False # Skip this event
return True
The pre_hook() method provides early rejection without incurring API costs. Use it to filter out events that are clearly irrelevant to this processor.
3. Setup
If the pre-hook passes, the processor performs its setup sequence:
- Token upgrade: Exchanges the invocation credentials for a full API access token.
- Subscription info: Fetches the processor's channel subscription configuration.
- Tags and UI initialisation: Loads existing tag state and UI element definitions.
- RPC initialisation: Sets up remote procedure call handlers if defined.
- Deployment config injection: Fetches and parses the processor's deployment configuration using its Schema subclass.
This phase involves several API calls, which is why the pre-hook filter exists to avoid this cost for irrelevant events.
4. Post-Setup Filter
After setup completes, the processor runs a post-setup filter. This is a second rejection gate that has access to the fully initialised processor state, including tags, configuration, and API client.
def post_setup_hook(self, event_type, event_data):
# Skip if the processor is in maintenance mode
if self.tags.get("maintenance_mode"):
return False
return True
The post_setup_hook() can use tag values, configuration, and other state that was not available during the pre-hook. Use it for context-dependent filtering.
5. Event Dispatch
The processor routes the event to the appropriate handler method based on the event type:
| Event Type | Handler Method |
|---|---|
| New message on a subscribed channel | on_message_create(channel, message) |
| Aggregate updated on a subscribed channel | on_aggregate_update(channel, aggregate) |
| Scheduled timer fired | on_schedule(schedule_name) |
| Application deployed or updated | on_deployment(deployment) |
| Channel synchronised | on_channel_sync(channel) |
The handler method contains the processor's core logic. It can read and write channels, publish messages, update tags, send notifications, and interact with external services.
def on_message_create(self, channel, message):
temp = message.data.get("temperature")
if temp and temp > 40.0:
# Publish an alert to a different channel
self.data_client.publish_message(
channel_id=self.alert_channel_id,
data={"alert": "High temperature", "value": temp},
)
Handler methods receive the relevant channel and event data as arguments. The processor has full access to the Data API through self.data_client for reading and writing data.
6. Cleanup
After the handler returns, the processor performs cleanup:
- Commit tags: Any tag changes made during the handler are committed to the cloud.
- Publish invocation summary: A summary of the invocation (event type, duration, result) is published for observability.
- Close API client: The HTTP session is closed and resources are released.
Cleanup runs automatically. You do not need to call it manually.
7. Skip Handling
A processor invocation can be skipped (resulting in a ProcessorSkipped status) for several reasons:
- No handler: The event type has no corresponding handler method defined on the processor class.
- Self-loop prevention: The event was caused by this processor's own output, and self-loop detection is enabled.
- Filter rejection: The pre-hook or post-setup hook returned
False.
Skipped invocations are tracked separately from successful ones. They do not count as errors and are expected in normal operation. A processor that subscribes to a broad set of channels will typically skip the majority of events.
Pre-hook filtering is the most cost-effective way to handle high-volume event streams. Since it runs before any API calls, a skipped invocation consumes minimal Lambda execution time and no API quota.
Processor Lifecycle Summary
| Phase | Method/Hook | Description |
|---|---|---|
| Invocation | (automatic) | Lambda receives event from SNS/EventBridge |
| Pre-hook filter | pre_hook() | Fast rejection before any API calls |
| Setup | (automatic) | Token upgrade, tags/UI init, config injection |
| Post-setup filter | post_setup_hook() | Rejection with full processor state available |
| Event dispatch | on_message_create(), on_schedule(), etc. | Route event to the appropriate handler |
| Cleanup | (automatic) | Commit tags, publish summary, close client |
| Skip handling | (automatic) | Track ProcessorSkipped for no-ops |
Comparing the Two Lifecycles
| Aspect | Docker Application | Processor |
|---|---|---|
| Execution model | Continuous (long-running loop) | Event-driven (single invocation) |
| Runs on | Doovit / edge device | AWS Lambda (cloud) |
| State persistence | In-memory between iterations | Stateless; use tags/channels for persistence |
| Hardware access | Yes (PlatformInterface, ModbusInterface) | No (cloud-only) |
| Trigger | Starts at container boot | SNS event, EventBridge schedule, deployment |
| Error recovery | Automatic retry after 10s | Lambda retries per AWS configuration |
| Typical use case | Sensor polling, device control, local logic | Alerting, data transformation, cross-device orchestration |
For implementation details, see Docker Application Class and Processor Application Class.