Joint European Torus

The Joint European Torus (JET) more than doubled the amount of fusion energy produced in a single “shot” – breaking the 1997 record.

Dependency Injection

  • By Artem V. Shamsutdinov
  • Aprl 21st, 2022

A key factor in Turbase (and thus AIRport) adoption will be the ease of writing applications. One key feature that needs to be done in a developer friendly way is Dependency Injection.

Current Implementation

Internal AIRport dependency injection is done "on the stack", with tokens. First there is a library level tokens.ts file that defines dependency injection tokens for a library:

import {domain} from '@airport/di'
import {IMyClass} from './MyClass'
import {IMyOtherClass} from './MyOtherClass'

const airport = domain('air')
const myLibrary = airport.lib('myLibrary')

const MY_CLASS = myLibrary.token<IMyClass>('MY_CLASS')
const MY_OTHER_CLASS = myLibrary.token<IMyOtherClass>('MY_OTHER_CLASS')

Each injectable class is defined in its own file (with it's interface) sometimes being defined in the same file.

import { container, DI} from '@airport/di'
import { MY_CLASS, MY_OTHER_CLASS } from './tokens'

export interface IMyClass {

    myMethod(): Promise<void>

}

export class MyClass implements IMyClass {

    myMethod(): Promise<void> {
        const myOtherClass = await container(this).get(MY_OTHER_CLASS)

        await myOtherClass.myOtherMethod()
    }

}
DI.set(MY_CLASS, MyClass)

This gives AIRport the ability to upgrade the framework and applications without having to reload them. AIRport may be running on a device for a long period of time. During that time patches may be made to AIRport. Critical patches will have be installed on the device AIRport runs on. Requiring a full reload of the framework or the running applications will necessarily consume battery power on the device and may be percieved as being very intrusive and inconvenient. With "on the stack" injection a patch with changed classes may be downloaded on to the devices and then executed via an additional script tag. That patch will simply re-register the changed classes with the dependency injection library. The DI library will then flip the switch to the new classes at the most opportune time ( whenever there are no active transactions running on the system).

Token based injection allows to accomplish that with relative ease while still allowing for typesafety (with each token specifying the interface of the class it retrieves).

Making it developer friendly

However it is not fair to make an average developer learn this specialized dependency injection system - even more things to learn and remember on top of learning a new framework. Thus, I've come up with a wrapper for this system, which makes dependency injection seamless.

Property injection

In AIRport token based injection is done on the stack. That is dependencies get loaded only when they are accessed. The main benefit of this approach is that it allows to replace a part of @Injectable()s without having to replace the objects they dependend on. It also minimizes the risk of circular dependency lock between injected objects.

Access to injected objects can be done via object properties and also done on the stack, at the same time. This is because a property can be hidden behind a "get propertyName()" call. The new dependency injection wrapper works by automatically converting object properties which are annotated with @Inject() decorator. Here is what the above example looks like in the wrapped version:

import { Inject } from '@airport/di'
import { IMyOtherClass } from './MyOtherClass'

export interface IMyClass {

    myMethod(): Promise<void>

}

@Injectable()
class MyClass implements IMyClass {

    @Inject()
    myOtherClass: IMyOtherClass

    myMethod(): Promise<void> {
        await myOtherClass.myOtherMethod()
    }

}

How it works

@Injectable() decorator is now used to distinguish injectable classes from other classes. @Api() classes are automatically injectable. @Inject() decorator is used to distinguish injected properties from other ones.

Every AIRport application is already pre-processed to generate Query and Object interfaces from the DLL objects. This same process will now examine all of non-DDL and non-generated sources and create injection descriptors from them:

import {domain} from '@airport/di'
import {IMyClass, MyClass} from './MyClass'
import {IMyOtherClass, MyOtherClass} from './MyOtherClass'

const airport = domain('air')
const myLibrary = airport.lib('myLibrary')

const MY_CLASS = myLibrary.token<IMyClass>({
    class: MyClass,
    interface: 'IMyClass',
    token: 'MY_CLASS'
})
const MY_OTHER_CLASS = myLibrary.token<IMyOtherClass>({
    class: MyOtherClass,
    interface: 'IMyOtherClass',
    token: 'MY_OTHER_CLASS'
})

MY_CLASS.setDependencies({
    myOtherClass: MY_OTHER_CLASS
})

tokens.ts will now become generated by the system. All class interfaces for @Inject()ed classes will also be generated (and won't have to be created manually).

The token's descriptors will now keep track of it's dependencies.These will now be added to a JSON descriptor for that token (in tokens.ts). That file will also allow the AIRport preprocessor to process dependencies from other projects.

When an object of a particular class is first retrieved by the dependency injection system, all of the properities defined in the 'setDepenencies' descriptor are replaced with getters that syncronously retrieve the needed dependency. A separate method (get${PROPERTY_NAME}Async) is also provided to retireve the dependency asyncronously, in case its retrieval needs to block while other resources load. When that objects, getter/async method is called, the class is retrieved from the descriptor and instantiated.

If a class has asyncronous initialization logic an "async initialize()" method must be defined in that class. It is called only once per Class (see below for @Injectable() object pools). If it has not been called yet and a syncronous getter is used the retrieve an object of that class, a runtime error is thrown.

Transaction tracking

I'm currently working on tracking transactions across applications and another benefit that getter based dependency injection system brings is ability to track transactions.

As described previously transactions can run across multiple appilcations. This means that (at least in environments that support multiple parallel transactions) for any given application, multiple transactions can be in-flight at any given point in time. This means that an application must be able to pass the correct Transaction Id for every DAO.save call it performs. This in turn means that there must be tracking of what DAO calls are made from which @Api() invocation.

Why this Really matters

Support for multiple concurrent transactions is critical to AIRport adoption. AIRport is meant to be run both on the client (for private data) and on the server (for shared data), with seamless integration. You write the code once and it automatically both works on SqLite in wasm and on CockroachDb with geographical sharding, while putting the read load on ScyllaDb and Vespa (or any other stack with additional adapters). Ability to do this will allow for hybrid applications to quickly be written, and existing centralized applications will be able to to plugin and offer decentralization where it matters - for family and group user conversations and planning.

Injectable object pool

This can be accomplished by assigning a dedicated object (on an @Injectable() class) to a given @Api() call thread. Every time a getter is invoked to retrieve an @Injectable() object that object is requested in the pool of objects of it's type, if there is a free one. If there isn't, a new one is created and returned.

The reason for creation of new instances of @Injectable() classes is to store "thread local" information in them. The only other way to do it in client-side/browser JavaScript environment is to pass it though as an input parameter for all of the methods between the initial @Api() call and the internal DAO modification methods. Doing so is very strait forward and explains to the developers what is going on "under the hood" but places un-due burden of wiring in a context property that the developer has no need for.

Having given it some thought I'm planning on keeping this manual option of passing Transaction ID in as an input parameter. This option will documented an available for developers to use, if they choose to do so.

However, for the bulk of the developers I'm expecting the "it magically works" method to be more useful (at least after they understand how it actually works).

In the convenient method of doing things, every @Injectable() object in the injection chain is created (or re-used from it's unused pool of objets) specifically for the purpose of serving that particular @Api() request. This means that all of the state stored in these objects is scoped to that @Api() request and effectively acts as a "Thread Local" scope. It is on that scope that the Transaction ID associated with the @Api() call is stored. Once a database operation call is finally passed into the framework (to the framework adapter that is running in the App VM/Iframe) the framework accesses this state in the base DAO object (that is extended by the App's DAO objects).

Additional uses of Request scoped @Injectables()

Currently all @Injectable() classes must be stateless. This is because they might be swapped out for a newer version when a bug-fix patch is processed (again, as explained previously, reloading a couple of dozen iframes and loading all of their source as well as the source of the framework in the main frame could drain the device battery and can hinder AIRport adoption).

Having Request scoped @Injectables() solves this issue. Since AIRport Apps are already preprocessed all member state can be automatically put into a central store while providing instance variables as an API (via getters). Also, @Request() and @Session() scoped beans can be implemented on top of this.