TaskTonic Services & Singleton Architecture

When building complex, asynchronous applications, the need often arises for central components that must be shared across multiple subtasks (Tonics) without corrupting each other's data streams or internal states. In traditional concurrency models, this inevitably leads to global variables or complex, manually written Singleton patterns riddled with error-prone thread-locks.
TaskTonic solves this fundamentally through the runtime architecture of the ttLiquid metaclass (__ttLiquidMeta), which seamlessly integrates the Singleton pattern at the framework level.
1. What is a Service?
A Service in TaskTonic is a specialized ttTonic class that is managed as a strict Singleton. The framework's metaclass guarantees that exactly one instance of the Service exists throughout the entire lifecycle of the application (within the scope of the active ttLedger).
When any Tonic attempts to instantiate a Service, the following mechanism is triggered:
- First Call (Creation): The metaclass intercepts the call, constructs the unique instance via
super().__call__(), executes__init__()and_tt_post_init_action()exactly once, and registers the Service in the centralttLedgerunder its unique service name. - Subsequent Calls (Access): The framework intercepts the creation attempt, identifies the already registered instance in the ledger, and immediately returns this existing reference. The
__init__()constructor is not executed again.
2. Architectural Use Cases
You should use the Service pattern exclusively for central resources that must be unique and shared across the entire application:
- Database Managers: A central connection pool (
DatabaseService). - Hardware Interfaces: A single-point-of-entry for serial ports or USB controllers to prevent data corruption from concurrent writes.
- Shared State Spaces: Central storage facilities like a
DigitalTwin(built on top ofttStore). - Network/API Sockets: Shared HTTP/REST clients or TCP/IP handlers that need to centrally manage authentication tokens and rate-limiting.
3. Implementing a Service
Building a Service requires a strict separation between one-time configuration parameters (prefixed with srv_) and per-access parameters (prefixed with ctxt_).
Step 1: Class-level Identification
A Service defines itself by setting the class attribute _tt_is_service to a unique string identifier. This is the key the ttLedger uses to register and look up the Singleton.
Step 2: __init__ (One-time Setup)
The constructor is executed exclusively during the very first instantiation of the Service.
- Capture parameters here that are crucial for the initial setup (e.g.,
srv_db_url). - Strict Framework Rule: You must always accept
**kwargsand pass them through tosuper().__init__(**kwargs)to avoid breaking internal bootstrapping and context routing.
Step 3: _tt_init_service_base (Per-Access Hook)
Unlike the constructor, _tt_init_service_base is executed by the metaclass upon every access to the Service (including the very first creation). This is the hook where the Service discovers who is currently calling it.
- The first positional parameter the framework provides is
base(the Tonic invoking the Service). - Capture dynamic parameters here (e.g.,
ctxt_access_level).
🚨 Crucial for Thread-Safety: Registration via the Queue
When a Tonic calls the Service, _tt_init_service_base is executed within the thread of the calling component. If the Service runs on its own Catalyst (and thus its own OS thread), mutating Service attributes directly inside this method is a direct violation of TaskTonic's thread-safety guarantees!
The Golden Rule: Use _tt_init_service_base exclusively to place an asynchronous Command Sparkle (ttsc__) onto the Service's own queue. Let the Service handle the administration in its own thread scope.
4. Consuming a Service
A consumer Tonic interacts with a Service by simply instantiating the class. The framework handles the de-duplication behind the scenes.
Python
from TaskTonic import ttTonic
class DataAnalyzer(ttTonic):
def ttse__on_start(self):
# Request the database. Provide srv_ args in case we are the first.
# Provide ctxt_ args specific to our own session.
self.database = SharedDatabaseService(
srv_db_url="postgresql://localhost:5432/prod",
ctxt_access_level="write"
)
def ttsc__process_measurement(self, measurement_data):
# Communicate via the service's asynchronous command queue
self.database.ttsc__write_record(measurement_data)
5. Advanced Pattern: Decoupling via Service Base Classes
In a clean software architecture, you often want to decouple components from specific implementation details. For example: a Tonic needs a ConnectionService, but it shouldn't matter to that Tonic whether this runs via an IpConnectionService or a BluetoothConnectionService.
TaskTonic supports this by allowing you to define an abstract base class as the Service interface, while you start the concrete implementation under the exact same service name in the ttFormula.
1. Define the Interface (The Base Class)
Python
from TaskTonic import ttCatalyst
class ConnectionService(ttCatalyst):
""" The universal contract class. Consumers will instantiate this class. """
_tt_is_service = "central_connection_service"
def ttsc__send_packet(self, payload):
pass
2. Build the Concrete Implementation
Python
class IpConnectionService(ConnectionService):
""" The actual network implementation. """
def __init__(self, srv_host, srv_port, **kwargs):
super().__init__(**kwargs)
self.host = srv_host
self.port = srv_port
self.to_state("disconnected")
def ttsc_disconnected__send_packet(self, payload):
self.log("Error: Cannot send data, socket is closed!")
def ttsc_connected__send_packet(self, payload):
# Low-level IP write logic here...
self.log(f"Data sent to {self.host}: {payload}")
3. The Binding in the Formula and Consumer
The consumer remains completely decoupled and only requests the base interface. The ttFormula determines at startup which concrete variant is loaded into the ledger.
Python
class ProductionTonic(ttTonic):
def ttse__on_start(self):
# Request the SERVICE via the abstract base class
self.network = ConnectionService()
self.network.ttsc__send_packet("Test message")
class MyApplication(ttFormula):
def creating_starting_tonics(self):
# 1. Start the CONCRETE service first.
# This registers itself under 'central_connection_service'
IpConnectionService(srv_host="10.0.0.15", srv_port=8080)
# 2. Start the consumers.
ProductionTonic()
6. Lifecycle, Dependency Tracing, and Teardown
Services feature a unique, automated cleanup mechanism tied to their active users. However, complex applications often involve Services that use other Services (e.g., an IP Logger that uses a Socket Handler, which in turn uses a Selector Service).
The Circular Dependency Problem
If services blindly wait for their servicing_bases (clients) to drop to zero before shutting down, System Services can easily form circular deadlocks. For instance, Service A uses Service B, and Service B uses Service A. Neither will ever reach zero clients, creating "zombies" in the Ledger that prevent the application from shutting down cleanly.
The Solution: Service Dependency Tracing
To solve this, TaskTonic implements Service Dependency Tracing via the _tt_depending_services mechanism.
When a Service creates a child Tonic or requests another Service, the framework invisibly tags that child with the name of the parent Service. This creates an execution context "stamboom" (family tree).
Graceful Top-Down Teardown Flow
Because of this tagging, the framework knows exactly the difference between a "real application client" (like a UI widget or MainApp) and an "internal worker" (like a nested socket).
When a consumer Tonic ends its lifecycle and calls finish(), a top-down cascade effect triggers:
-
Notification: The finishing consumer notifies the Services it used that it is disconnecting.
-
De-registration: The Service intercepts this and removes the consumer from its unique
servicing_basesSet. -
Smart Teardown Check: The Service runs the internal system sparkle
_ttss__on_service_base_removed(). Instead of simply counting the list length, it iterates over the remaining clients and checks their tags. -
The Decision: It asks: "Are the remaining clients real applications, or are they just internal workers marked with my own service name?"
-
The Closure: If no real clients are left, the Service concludes its job is done. It automatically triggers
self.finish(), stopping its state machine, deregistering from the Ledger, and firing attsc__finish()command down to all its internal children/infusions to clean up OS resources.
(Note: If you need to perform custom administration cleanup when a client disconnects, you can still override the optional user-level hook ttse__on_service_base_removed(self, removed_base_id, bases_left) in your Service class).