Try   HackMD

OctoFarm V2 state management

Glossary

  • Redux a state management pattern as well as a library in Javascript
  • State a singleton which carries a snapshot with the context of an entity at a certain point in time. This singleton can be transformed or overwritten using atomic actions.
  • Actions transformation operators on a state, potentially causing it to change
  • Selectors filters or reduction operators to get a part of a state, or all of it
  • Reduction retrieving a scope of the state to get only the data needed for a certain observer

Introduction

I've previously covered the NestJS API envisioned for OctoFarm in https://hackmd.io/@BL3YyzEaQX-wcYkdWAt0ew/SkGTeVgfO. Now I will cover the state management, persistence and consistency of updating OctoPrint state in OctoFarm.

First, I'll cover how state is produced and what is currently inconsistent. After that we'll discuss how to manage and persist state. Finally we discuss recurring state updates on a scheduler with a debuggable backoff mechanism.

Asynchronous state producers

The WebSocket connections need to be set-up and validated before any further sub-state can be reduced. For example, if a printer is failing to connect over Serial, querying the bed temperature will result in outdated state. Therefore, the state 'serial-timeout' should hide the printer statistics last known. However, that does not mean we should drop/nullify that sub-state (bed/room/nozzle temperature). This simply means that a state reducer should hide the value or mark it as frozen until serial connection is restored.
This is where a produced state should carry consistent knowledge about how to reduce it (this is called 'cleaning' in API V1, here we call it 'selecting' or 'reducing'). Also the state should come with operators to mold it ('actions').

Setting up the WebSocket connection state

OctoPrint provides multiple types of transport, one in particular is WebSocket (WS) which can autonomously push updates about the monitored 3D printer back to OctoFarm. However, this WS transport needs to be setup first and once its setup it needs to be consistent. It is known that OctoPrint Global API key results in the Websocket connection being setup but it's completely silent. Therefore, we'd like to analyze on how we can really construct a connection state which is consistent and valid.
This needs API query work to be done on OctoPrint before accessing the WebSocket:

  • Check the OP settings API to retrieve the settings with Global API Key embedded.
  • If the API connection fails, the Key is invalid or the connection is faulty (FQDN/url-format/OP-power-down)
  • Check if the provided API key is the same as the OP Global API Key.
  • If so, mark conncetion state as failed on API Key being global and exit. If not, continue.
  • Check if the API key provides enough Groups (a.k.a. Roles). We need Admin and Operator roles, although Operator should be enough to operate.
  • Throw Group/Permission error if the conditions are not met and mark state as failing on permissions
  • If valid, we check if we can change CORS. If so, do that call.
  • Report CORS change failure or report Host as connected.
  • Now proceed with connecting to WebSocket peacefully and sure that you've done everything you can to verify validity!

The connecting state comprises multiple sub-states indicating this sequential step of verification. We can then query this connection state and provide visual feedback or errors to the user based on presentation logic. For example, wrong API key could have a light-red colored, connection authentication errors could be dark-red, wrong groups/permissions could be orange, and a slow connection could be yellow, green for everything OK, etc.
Also, we can make an action to reset ourselves to a certain sub-state. Maybe the API key was changed within OP? Maybe the user got groups removed? You should adjust in a consistent manner, but never do more than necessary and keep all adjustments within the state.

We can model the connection state with the previous knowledge. Do note, the following model may contain properties which overlap and some are orthogonal. Therefore a single state is not valid (enum type) and would lead to inconsistencies. Therefore, it's best to model it in explicit states, also for debugging purposes.

interface PrinterConnectionStateModel {
    apiKeyProvided: boolean;      // Key is truthy
    apiKeyValid: boolean;         // Local validation passed
    apiKeyAccepted: boolean;      // Remote 200 OK
    apiKeyIsGlobal: boolean;      // User is in deep shit
    userHasRequiredRoles: boolean;// Roles are Admin|Operators at the least.
    corsEnabled: boolean;         // Optional as CORS is only required in browser
    websocketConnected: boolean;  // Websocket is in connected state
    websocketHealthy: boolean;    // We are receiving printer data
}

We will take this model and apply it next.

State manager and reducers - the redux pattern

Explaining what the redux pattern is, how it helps managing state and what immutability can do for us.
https://redux.js.org/faq/immutable-data#redux-faq-immutable-data
Big disadvantage is that code footprint (often) increases in size and complexity because of it. I will show this to you with the code snippet below.

Immutable data management ultimately makes data handling safer.

The take-away is that doing state changes explicitly and in repeatable fashion can introduce debugging and reproduction help. This is huge. A timeline to scroll through whilst seeing your state tracking back it's such a cool feature of Redux Devtools (Chromium extension). Such a thing can help us greatly into testing what went wrong in a certain environment. I think it's called 'State Replay'. (Definitely a great tool for VueJS as well!)

Now let's our head out of the clouds, and put our money where our mouth is. How do we do this state mumbo jumbo? I'll give an example.

Example time
Imagine a printer temperature state which is shared across multiple services, service A and B. Lets just assume service A reassigns the printer state because it is convinced it is completely inconsistent. Suddenly, service B is handling an old object reference which is not the same as service A anymore. This state has become invalid.
How do we fix this? We should use 'read-only' state copies to check our state by fetching a frozen copy of the sub-state printer connection. We are reducing our application state to a specific printer connection (reduction) by a selector, like a printer ID. Now when service A wants to update the state, it calls a overwrite/patch action to perform the update on the sub-state. Service B will always query the state at it's own will, but we can also notify it of the change (subscription). We've created a central state producer for decentral state consumers.

Now, let's take a look of what an immutable state can look like. In the following code snippet we are explicitly trying to adjust the corsEnabled sub-state property after our API Key process has completed correctly:

// We will assume for brevity that this state belongs to one specific printer
// It is therefore already reduced for simplicity
class SpecificPrinterConnectionState {
    state: PrinterConnectionStateModel;
    
    constructor(
        private octoPrintClientService: OctoPrintClientService
    ) {
    }
    
    initState() {
        if (!this.state) {
            this.state = {
                // Defaults
            };
        }
        else {
            throw Error("Can't initialize already known state.");
        }
    }
    
    private patchState(adjustments: Partial<PrinterConnectionStateModel>) {
        const newState = {
            ...this.state,
            ...adjustments
        };
        // Calculate differences
        // ...
        // Log the action
        // ...
        
        // Act
        this.state = newState;
    }
    
    getState(reduction: any) {
        return Object.freeze(this.state.filter(...)); // Reduce
    }
    
    isApiKeyStateAccepted() {
        return !this.state || 
            this.state.apiKeyProvided 
                && this.state.apiKeyAccepted 
                && this.state.apiKeyIsGlobal === false;
    }
    
    userHasRequiredRoles() {
        // Alternatively we could scan the permissions, but that's much more work.
        return !this.state || 
            this.state.apiKeyAccepted 
                && this.state.userHasRequiredRoles;
    }
    
    async setCorsEnabled(value: boolean = true): Promise<void> {
        if(this.userHasRequiredRoles()) {
            return this.octoPrintClientService
                .setCORSEnabled()
                .then(_ => {
                    this.patchState({
                        corsEnabled: true
                    });
                }, error => {
                    // Analyze the state
                    // ...
                    // Conclude how the error 'resets' the state
                    this.patchState({
                        corsEnabled: false,
                        websocketConnected: false,
                        websocketHealthy: false
                    });
                    // rethrow the error or let Promise.reject do the work
                });
        }
    }
}

Available NPM packages

Well, not a lot.

This implements a redux-like pattern without further dependency, but not more. Nice? Sure. Useful? Eh.
https://github.com/danmt/state-mgmt-sample

This implements NGRX, something which I am not fan of. Also, its probably buggy:
https://github.com/derekkite/ngrx-nest

So, it's up to us to 'do it our way', but hey at least we have comparison in React/Angular/Vue and we have resources to learn from!

Recurring state updates

This section covers how we will act on synchronizing and recurring state updates with asynchronous state producers like OctoPrint WebSocket.

State cleaners vs state persistence

Discuss the advantage of persisting state from a top-to-bottom or bottom-to-top approach. Differences in storing data in that database first, or memory first.

How to keep state consistent with respect to remote state changes.

In-memory, cache and database

Keeping state, tracking state and persisting state.

Agenda, bull, redis and node-cron/scheduler

In this section we cover how agenda, bull, redis and node-cron can help us fix different kinds of problems for recurring/one-shot types of jobs. We first define what jobs we expect and then we allocate or discuss them within the different contexts.

Dealing with timeout backoff and unblocking calls

Discuss how to manage scheduled jobs and being able not fire off new ones