Skip to content

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: DeviceAgentInterface for cloud communication, PlatformInterface for hardware I/O, and ModbusInterface for Modbus devices
  • Instantiates the user's Application subclass 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.

Warning
Startup Dependencies

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_fp was 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:

  1. Calls main_loop() where the application reads sensors, runs control logic, updates UI elements, and publishes data.
  2. Commits any pending tag changes to the cloud.
  3. 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.

Information Circle
Loop Timing

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_at tag 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:

  1. The framework calls check_can_shutdown(). The application can return False to defer shutdown (e.g., if a critical operation is in progress).
  2. If shutdown proceeds, on_shutdown_at() is called, allowing the application to perform cleanup (close connections, flush buffers, save state).
  3. Final tag commit and UI log entry (if force_log_on_shutdown is enabled).
  4. 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

PhaseMethod/HookDescription
Initialisationrun_app()Parse args, create interfaces, instantiate app
Health check(automatic)Wait for DeviceAgent service
Config loading(automatic)Fetch deployment config from cloud or file
Setupsetup()One-time initialisation (subscriptions, UI, tags)
Ready(automatic)Set is_ready, start healthcheck server
Main loopmain_loop()Continuous execution at target period
Error handling(automatic)Catch exceptions, retry after 10s
Shutdowncheck_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:

  1. Token upgrade: Exchanges the invocation credentials for a full API access token.
  2. Subscription info: Fetches the processor's channel subscription configuration.
  3. Tags and UI initialisation: Loads existing tag state and UI element definitions.
  4. RPC initialisation: Sets up remote procedure call handlers if defined.
  5. 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 TypeHandler Method
New message on a subscribed channelon_message_create(channel, message)
Aggregate updated on a subscribed channelon_aggregate_update(channel, aggregate)
Scheduled timer firedon_schedule(schedule_name)
Application deployed or updatedon_deployment(deployment)
Channel synchronisedon_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:

  1. Commit tags: Any tag changes made during the handler are committed to the cloud.
  2. Publish invocation summary: A summary of the invocation (event type, duration, result) is published for observability.
  3. 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.

Information Circle
Cost Efficiency

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

PhaseMethod/HookDescription
Invocation(automatic)Lambda receives event from SNS/EventBridge
Pre-hook filterpre_hook()Fast rejection before any API calls
Setup(automatic)Token upgrade, tags/UI init, config injection
Post-setup filterpost_setup_hook()Rejection with full processor state available
Event dispatchon_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

AspectDocker ApplicationProcessor
Execution modelContinuous (long-running loop)Event-driven (single invocation)
Runs onDoovit / edge deviceAWS Lambda (cloud)
State persistenceIn-memory between iterationsStateless; use tags/channels for persistence
Hardware accessYes (PlatformInterface, ModbusInterface)No (cloud-only)
TriggerStarts at container bootSNS event, EventBridge schedule, deployment
Error recoveryAutomatic retry after 10sLambda retries per AWS configuration
Typical use caseSensor polling, device control, local logicAlerting, data transformation, cross-device orchestration

For implementation details, see Docker Application Class and Processor Application Class.