Asciidoc (deliberately) does not provide direct layout controls;
however, since we are publishing directly from Asciidoc markup,
at times ''we do require such controls''
A recurring pain point is that sometimes additional (vertical) whitespace
is required for better structuring a body of text; in the past I used various
tricks that turned out to be not reliable... And through exprimentation I have
now worked out a ''canonical solution''
* Asciidoc provides a pre-defined page attribute `{nbsp}`
* when used in a separate Paragraph, this generates an ''empty paragraph'' as spacer
320 lines
21 KiB
Text
320 lines
21 KiB
Text
Layers -- Subsystems -- Lifecycle
|
||
=================================
|
||
:Author: Hermann Voßeler
|
||
:Email: <Ichthyostega@web.de>
|
||
:Date: 2018
|
||
:Toc:
|
||
|
||
|
||
Terminology
|
||
-----------
|
||
|
||
Layer::
|
||
A conceptual realm within the application in order to group related topics and to build
|
||
high-level structures in terms of low-level structures. Layers are located above/below
|
||
each other and may depend _solely_ on lower layers. The Application may be operated in
|
||
a partial layer configuration with only some lower layers present. Each layer deals
|
||
with distinct topics and has its own style. In Lumiera, we distinguish three layers
|
||
+
|
||
* Stage Layer -> Interaction
|
||
* Steam Layer -> Processing
|
||
* Vault Layer -> Data manipulation
|
||
|
||
Subsystem::
|
||
A runtime entity which serves as anchor point and framework to maintain a well defined lifecycle.
|
||
While layers are conceptual realms, subsystems can actually be started and stopped, and their
|
||
dependencies are represented as data structure. A subsystem typically starts one or several
|
||
primary components, which might spawn a dedicated thread and instantiate further components
|
||
and services.
|
||
|
||
Service::
|
||
A component within some subsystem is called a _Service_
|
||
+
|
||
--
|
||
* provided that it exposes an interface with an associated contract
|
||
(informal rules about usage pattern and expectations)
|
||
* and given that it accepts invocations from arbitrary other components
|
||
without mutual interdependency or hard coded knowledge about that other part.
|
||
--
|
||
+
|
||
The service lifecycle is tied to the lifecycle of the related subsystem; whenever the subsystem is ``up and running'',
|
||
any contained services can be accessed and used. Within Lumiera, there is no _service broker_ or any similar kind
|
||
of elaborate _service discovery_ -- rather, services are accessed *by name*, where ``name'' is the _type name_
|
||
of the service interface.
|
||
|
||
Dependency::
|
||
A usage relation at implementation level and thus a local prerequisite of an individual component. A
|
||
dependency is something we need in order to complete the task at hand, yet a dependency lies beyond that
|
||
task and is satisfied by means outside the scope and theme of this actual task. Consequently, a dependency
|
||
is not introduced or provided by the local task or part of the task, rather the task at hand is the reason
|
||
why some other entity dealing with it needs to _request_ or _pull_ that dependency in to accomplish the
|
||
task at hand. So essentially, dependencies are accessed on-demand. Dependencies might be satisfied by
|
||
other components or services, and typically the user (consumer) of a dependency just relies on the
|
||
corresponding interface and remains agnostic with respect to the dependency's actual implementation,
|
||
data or lifecycle details.
|
||
|
||
Subsystems
|
||
----------
|
||
As a coherent part of the application, a subsystem can be started into running state. Several subsystems
|
||
can reside within a single layer, which leads to rather tight coupling. We do not define boundaries between
|
||
subsystems in a strict way (as we do with layers) -- rather some component is associated with a subsystem
|
||
when it relies on services of the subsystem to be ``just available''. However, the grouping into subsystems
|
||
is often also a thematic one, and related to a specific abstraction level. To give an example, the Player
|
||
deals with _calculation streams,_ while the engine handles individual _render jobs,_ which together form
|
||
a calculation stream. So there is a considerable grey area in between. Any code related with defining and
|
||
then dispatching frame jobs needs at least some knowledge about the presence of calculation streams; yet
|
||
it depends and relies on the scheduling service of the engine. In the end, it remains a question of
|
||
architecture to keep those dependency chains ordered in a way to form a one-way route: when we start
|
||
the engine, it must not instantiate a component which _requires the player_ in order to be operative.
|
||
Yet we can not start the player without having started the engine beforehand; if we do, its services
|
||
will throw exceptions on first use, due to missing dependencies.
|
||
|
||
However, subsystems as such are not dynamically configured. This was a clear cut design decision (and the
|
||
result of a heated debate within the Lumiera team at that time). We do _not expect_ to load just some plug-in
|
||
dynamically, inserted via an UI-action at runtime, which then installs a new subsystem and hooks it into the
|
||
existing architecture. The flexibility lies in what we can do with the _contents_ of the session -- yet the
|
||
application itself is not conceived as set of Lego(TM) bricks. Rather, we identify a small number of coherent
|
||
parts of the application, each with its own theme, style, relations and contingencies.
|
||
|
||
Engine
|
||
~~~~~~
|
||
The Engine performs small pieces of work known as _render jobs,_ oriented towards a deadline,
|
||
without much knowledge about the purpose of those jobs, or their further interconnections.
|
||
And thus the purpose of the *Engine Subsystem* is to provide a thread pool and activate
|
||
the scheduling mechanism. Consequently, this subsystem belongs into the »Vault Layer«
|
||
|
||
_[yellow-background]#this part of the system is barely drafted as of 2020#_
|
||
|
||
Player
|
||
~~~~~~
|
||
The *PlayOut Subsystem* is located above the Engine and belongs into the »Steam Layer« -- and contrary
|
||
to the Engie (which handles individual jobs), the player creates and organises _calculation streams._
|
||
|
||
_[yellow-background]#as of 2020, the actual components to form the player need to be worked out#_ +
|
||
_^however, a fair amount of the services for dispatching streams into jobs has been prototyped^_
|
||
|
||
Session
|
||
~~~~~~~
|
||
The user performs editing activities within the »Session« -- which is a data structure with associated
|
||
methods for manipulation. There is a `Session` object and a `SessionManager` to load and save session
|
||
data and conduct the _session lifecycle._ However, all of this needs to be distinguished from the
|
||
*Session Subsystem* -- which in essence is a dispatcher thread to receive, enqueue and finally
|
||
trigger the _session commands,_ as sent from the GUI or the script runner. These activities are
|
||
conducted and controlled by the `SteamDispatcher`, which also cares for triggering the _Builder,_
|
||
whenever new commands have been dispatched. Moreover, when instantiating the `DispatcherLoop`,
|
||
also the `SessionCommand` façade is opened, which is the primary »Steam Layer« interface.footnote:[
|
||
Note the relation between Session-the-datastructure and Session-the-subsystem is rather indirect:
|
||
the _dispatching_ of commands is blocked, unless there is also a session-datastructure loaded
|
||
and fully configured. However, a running dispatcher loop is not a prerequisite for opening
|
||
a session -- just without a running dispatcher, commands will queue up and nothing else will happen.]
|
||
|
||
_[green]#as of 2020, this subsystem is operative and commands can be dispatched#_ +
|
||
_^...while the session data structure as such is mostly still a skeleton...^_
|
||
|
||
User Interface
|
||
~~~~~~~~~~~~~~
|
||
The Lumiera GUI is loaded as self-contained plug-in, which is the task of the *GUI Subsystem*.
|
||
As can be expected, this is a rather convoluted process, while the actual name of the UI plug-in module
|
||
to load is configured in the 'setup.ini', which has been evaluated earlier, in the application init phase.
|
||
However, as it stands, Lumiera is built with a GTK-3 interface, and within the corresponding plug-in module
|
||
`gtk_gui.lum`, the class `GtkLumiera` serves as top-level guard to carry on all further activities,
|
||
when triggered from within the subsystem lifecycle to run in a dedicated GUI thread. It will establish
|
||
the _UI backbone_ by activating the _UI-Bus_ and building the _UI Manager_ controlling the UI global context.
|
||
After these systems are established and connected, the GTK windows can be created and finally control is handed
|
||
over to the GTK (GIO) event loop. Whenever this loop terminates, be it regularly, or by exception, application
|
||
shutdown is initiated.
|
||
|
||
The GUI Subsystem is special, insofar it not only attaches to the session interface, but also opens a
|
||
_Layer separation interface_ oriented downwards, to be used by the lower layers. This interface -- known
|
||
as GUI Notification façade -- serves to populate the UI with actual content, to mark and animate the
|
||
tangible elements visible to and manipulated by the user in turn. It is outfitted with a cross-thread
|
||
dispatcher mechanism, to forward any invocation as message onto the UI-Bus. This setup allows the
|
||
lower layers to address the tangible parts in the UI based on their ID, which previously was given
|
||
alongside with the content when populating the structures.
|
||
|
||
_[green]#as of 2020, this backbone is established and connected in both directions#_ +
|
||
_^...while the large part of the actual widgets still remains to be built...^_
|
||
|
||
Script Runner
|
||
~~~~~~~~~~~~~
|
||
One of the most fundamental design decisions for Lumiera is that everything can be done without GUI.
|
||
Conceptually, this would allow to instantiate a script execution environment with appropriate bindings,
|
||
either to conduct operations on an existing session, or to build and render a session from scratch.
|
||
Alternatively, also a CLI-style shell-like interface is conceivable.
|
||
|
||
_[maroon orange-background]#this is a concept without any detailed planning as of 2020#_
|
||
|
||
Net Node
|
||
~~~~~~~~
|
||
In variation to the script runner concept, it is conceivable to send instructions to a Lumiera
|
||
instance over the net. Expanding on that idea, it would be possible to define a protocol to
|
||
distribute the session definitions to slave nodes and then to launch distributed render tasks.
|
||
Since Lumiera is built as a self-contained bundle, it is well suited to run within a containerised
|
||
environment. However, in the light of current trends towards container orchestration frameworks
|
||
like Kubernetes, we should refrain from building to much process management functionality into
|
||
the application itself.
|
||
|
||
_[aqua teal-background]#this is a mere idea, and certainly not a priority as of 2020#_
|
||
|
||
{nbsp}
|
||
|
||
|
||
|
||
Lifecycle
|
||
---------
|
||
Dependencies and abstraction through interfaces are ways to deal with complexity getting out of hand.
|
||
When done well, we can avoid adding _accidental complexity_ -- but essential complexity as such can not
|
||
be removed, yet with the help of abstractions it can be raised to another level.footnote:[Irony tags here.
|
||
There is a lot of hostility towards abstractions, because it is quite natural to conflate the abstraction
|
||
with the essential complexity it has to deal with. It seems compelling to kill the abstraction, in the hope
|
||
to kill the complexities as well -- a tremendously effective attitude, as it turns out, especially in practice...]
|
||
When components express their external needs by depending on an interface, the immediate tangling at the code level
|
||
is resolved. However, someone needs to implement that interface, and this other entity needs to be _available_.
|
||
The problem has been shifted, since it is now an architecture level challenge to get those dependency chains
|
||
satisfied. A clever way to circumvent this problem rather then to deal with it explicitly, is to rely on a
|
||
_lifecycle_ with several _phases._ This is the idea behind the subsystems and the subsystem runner.
|
||
|
||
. First we define an ordering between the subsystems. The most basic subsystem (the Engine) is started first.
|
||
. Within a subsystem, components may be mutually dependent. However, we establish a new rule, dictating that
|
||
during the _startup phase_ only local operations within a single component are allowed. Each component must
|
||
to be written in such a way, not to rely on the help of anything ``remote'' in order to get its inner workings
|
||
up and ready. The component may rely on its members and on other services it _created itself,_ or which it
|
||
_owns and manages._
|
||
. However, sometimes we _do need to rely_ on a more low-level service in another subsystem or in the
|
||
application core.footnote:[A typical example would be the reliance on threading, locking or application
|
||
configuration.] -- which then creates a hard dependency on _architecture level_
|
||
. Moreover, we ensure that all operational activity is generated by actual work tasks, and that such tasks
|
||
in turn may be initiated _solely through official interfaces._ Such interfaces are to be _opened explicitly_
|
||
at the end of the startup phase.
|
||
. In operational mode, any part of the system can now assume for its dependencies to be ``just available''.
|
||
. And finally we establish a further rule to _disallow extended clean-up._ Everything must be written in a
|
||
way such that when a task is done, it is _really done_ and settled. (Obviously there are some fine points
|
||
to consider here, like caching or elaborate buffer and I/O management). The rationale is that after leaving
|
||
the operational phase at the end of `main()` the application is able to unwind in any arbitrary way.
|
||
|
||
The problem with emergencies
|
||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||
This concept has a weak spot however: A catastrophic failure might cause any subsystem to break down immediately.
|
||
The handler within the subsystem's primary component will hopefully detect the corresponding exception and signal
|
||
emergency to the subsystem runner. Yet the working services of that subsystem are already gone at that point.
|
||
And even before other subsystems might get the (emergency) shutdown trigger, some working parts may be failing
|
||
catastrophically due to their dependencies being dragged away suddenly.
|
||
|
||
Lumiera is not written for exceptional resilience or high availability. Our attitude towards such failures can
|
||
be summarised as ``Let it crash''. And this is another rationale for the ruling against extended clean-up.
|
||
Any valuable work done by the user should be accepted and recorded persistently right away. Actions on the
|
||
session are logged, like in a database. The user may still save snapshots, but basically any actual change
|
||
is immediately recorded persistently. And thus we may crash without remorse.
|
||
|
||
Static initialisation and shutdown
|
||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||
A lot of fine points can be made about when precisely static objects in C\++ will be initialised or destroyed.
|
||
However, anything beyond the scope of `main()` is not meant to be used for regular application code. Extended
|
||
initialisation, dependency management and decommissioning -- when actually necessary -- should be part of the
|
||
application code proper.footnote:[this is established ``best practice'' for good reasons. The interplay of
|
||
static lifespan, various translation units and even dynamically loaded libraries together with shared access
|
||
tends to becomes intricate and insidious easily. And since, in theory, any static function could use some static
|
||
variable residing in another translation unit, it is always possible to construct a situation where objects
|
||
are accessed after being destroyed. Typically such objects do not even look especially ``dead'', since the
|
||
static storage remains in place and still holds possibly sane values. Static (global) variables, like raw
|
||
pointers, allow to subvert the deterministic automatic memory management, which otherwise is one of the
|
||
greatest strengths of C++. Whenever we find ourselves developing extended collaborative logic based on
|
||
several statics, we should consider to transform this logic into regular objects, which are easier to
|
||
test and to reason about. If it really can not be avoided to use such units of logic from a static
|
||
context, it should at least be packaged as a single object, plus we should ensure this logic can
|
||
only be accessed through a regular (non static) object as front-end. Packaged this way, the most
|
||
common and dangerous pitfalls with statics can be avoided.] And since Lumiera indeed allows
|
||
for link:{ldoc}/technical/library/Dependencies.html[lazily initialised dependencies], we
|
||
establish the policy that *destructors must not rely on dependencies*. In fact, they
|
||
should not do any tangible work at all, beyond releasing other resources.
|
||
|
||
anchor:lifecycle[]
|
||
|
||
Lifecycle Events
|
||
~~~~~~~~~~~~~~~~
|
||
The Application as a whole conducts a well defined lifecycle; whenever transitioning to the next phase,
|
||
a _Lifecycle Event_ is issued. Components may register a notification hook with the central _Lifecycle Manager_
|
||
(see 'include/lifecycle.h') to be invoked whenever a specific event is emitted. The process of registration
|
||
can be simplified by planting a static variable of type `vessel::LifecycleHook`.
|
||
|
||
WARNING: A callback enrolled this way needs to be callable at the respective point in the lifecycle,
|
||
otherwise the application will crash.
|
||
|
||
`ON_BASIC_INIT`::
|
||
Invoked as early as possible, somewhere in the static initialisation phase prior to entering `main()`.
|
||
In order to install a callback hook for this event, the client must plant a static variable somewhere.
|
||
|
||
`Voyage`::
|
||
This is the Lumiera »Application Object«. It is a singleton, and should be used by `main()` solely.
|
||
While not a lifecycle event in itself, it serves to bring up some very fundamental application services:
|
||
+
|
||
--
|
||
- the plug-in loader
|
||
- the application configuration
|
||
--
|
||
+
|
||
After starting those services within the `Voyage::init()` function,
|
||
the event `ON_GLOBAL_INIT` is emitted.
|
||
|
||
`ON_GLOBAL_INIT`::
|
||
When this event occurs, the start-up phase of the application has commenced. The command line was already
|
||
parsed and the basic application configuration is loaded, but the subsystems are not yet initialised.
|
||
|
||
`Subsys::start()`::
|
||
By evaluation of the command line, the application object determines what subsystems actually need to
|
||
be started; those will receive the `start()` call, prompting them to enter their startup phase, to
|
||
instantiate all service objects and open their business façade when ready
|
||
|
||
`ON_SESSION_START`::
|
||
When this hook is activated, the session implementation facilities are available and the corresponding
|
||
interfaces are already opened and accessible, but the session itself is completely pristine and empty.
|
||
Basic setup of the session can be performed at that point. Afterwards, the session contents will be
|
||
populated.
|
||
|
||
`ON_SESSION_INIT`::
|
||
At this point, all specific session content and configuration has already be loaded. Any subsystems
|
||
in need to build some indices or to establish additional wiring to keep track of the session's content
|
||
should register here.
|
||
|
||
`ON_SESSION_READY`::
|
||
Lifecycle hook to perform post loading tasks, which require an already completely usable and configured
|
||
session to be in place. When activated, the session is completely restored according to the defaulted or
|
||
persisted definition, and any access interfaces are already opened and enabled. Scripts and the GUI might
|
||
even be accessing the session in parallel already. Subsystems intending to perform additional processing
|
||
should register here, when requiring fully functional client side APIs. Examples would be statistics gathering,
|
||
validation or auto-correction of the session's contents.
|
||
|
||
`ON_SESSION_CLOSE`::
|
||
This event indicates to cease any activity relying on an opened and fully operative session.
|
||
When invoked, the session is still in fully operative state, all interfaces are open and the render engine
|
||
is available. However, after issuing this event, the session shutdown sequence will be initiated, by detaching
|
||
the engine interfaces and signalling the scheduler to cease running render jobs.
|
||
|
||
`ON_SESSION_END`::
|
||
This is the point to perform any state saving, deregistration or de-activation necessary before
|
||
an existing session can be brought down. When invoked, the session is still fully valid and functional,
|
||
but the GUI/external access has already been closed. Rendering tasks might be running beyond this point,
|
||
since the low-level session data is maintained by reference count.
|
||
|
||
`Subsys::triggerShutdown()`::
|
||
While not a clear cut lifecycle event, this call prompts any subsystem to close external interfaces
|
||
and cease any activity. Especially the GUI will signal the UI toolkit set to end the event loop and
|
||
then to destroy all windows and widgets.
|
||
|
||
`ON_GLOBAL_SHUTDOWN`::
|
||
Issued when the control flow is about to leave `main()` regularly to proceed into the shutdown and
|
||
unwinding phase. All subsystems have already signalled termination at that point. So this is the right
|
||
point to perform any non-trivial clean-up, since, on a language level, all service objects (especially
|
||
the singletons) are still alive, but all actual application activity has ceased.
|
||
|
||
`ON_EMERGENCY_EXIT`::
|
||
As notification of emergency shutdown, this event is issued _instead of_ `ON_GLOBAL_SHUTDOWN`, whenever
|
||
some subsystem collapsed irregularly with a top-level exception.
|
||
|
||
NOTE: all lifecycle hooks installed on those events are _blocking_. This is intentionally so, since any
|
||
lifecycle event is a breaking point, after which some assumptions can or can not be made further on.
|
||
However, care should be taken not to block unconditionally from within such a callback, since this
|
||
would freeze the whole application. Moreover, implementers should be careful not to make too much
|
||
assumptions regarding the actual thread of invocation; we only affirm that it will be _that specific_
|
||
thread responsible for bringing the global lifecycle ahead at this point.
|
||
|