Now that I have interfaces and reference implementations for a Blob Store and Workers, we can try to wire them up to our tracer bullet prototype.

Event Sourced Spreadsheet Data Tracer Bullet Development
×
Event Sourced Spreadsheet Data Tracer Bullet Development

The aim here is to see whether the pieces fit together properly. Do the interfaces need to be tweaked? Does the existing structure extend gracefully?

If it’s not right, we should fix it now, while change is still cheap.

InfiniSheet Workers
×
InfiniSheet Workers

We have a wiring diagram from last time. Let’s see how it goes.

EventLog and WorkerHost

How EventLog interacts with WorkerHost to trigger workflows is implementation specific. There’s no point trying to shoehorn some kind of one size fits all interface into EventLog. Wiring everything together is the client’s responsibility, so we don’t lose anything by excluding it from the EventLog interface.

We’ve already decided that our SimpleEventLog reference implementation will use a PostMessageWorkerHost to explicitly send messages to a Worker. Here’s what that looks like.

export class SimpleEventLog<T extends LogEntry> implements EventLog<T> {
  constructor(workerHost?: PostMessageWorkerHost<PendingWorkflowMessage>)

  private sendPendingWorkflowMessage(workflow: WorkflowId, sequenceId: SequenceId) {
    if (this.workerHost) {
      const message: PendingWorkflowMessage = 
        { type: 'PendingWorkflowMessage', sequenceId, workflow }
      this.workerHost.postMessage(message);
    }
  }

  workerHost?: PostMessageWorkerHost<PendingWorkflowMessage> | undefined;
}

SimpleEventLog works with any PostMessageWorkerHost implementation that supports PendingWorkflowMessage. Usually this will be an instance of SimpleWorkerHost.

Event Sourced Spreadsheet Data

Our wiring diagram shows two instances of EventSourcedSpreadsheetData, one host side, one worker side. We need to be able to instantiate EventSourcedSpreadsheetData with either a WorkerHost, an InfiniSheetWorker or undefined (if there are no local workflows).

export class EventSourcedSpreadsheetData implements SpreadsheetData<EventSourcedSnapshot> {
  constructor (eventLog: EventLog<SpreadsheetLogEntry>, blobStore: BlobStore<unknown>, 
    workerOrHost?: WorkerHost<PendingWorkflowMessage> | InfiniSheetWorker<PendingWorkflowMessage>)
}

Which in turn means we need some way to distinguish between WorkerHost and InfiniSheetWorker at runtime. The cleanest way is to add a type predicate method to WorkerHost and InfiniSheetWorker.

  isWorker(): this is InfiniSheetWorker<MessageT>

Then you can write code like this in EventSourcedSpreadsheetData

export class EventSourcedSpreadsheetData implements SpreadsheetData<EventSourcedSnapshot> {
  constructor (eventLog: EventLog<SpreadsheetLogEntry>, blobStore: BlobStore<unknown>, 
    workerOrHost?: WorkerHost<PendingWorkflowMessage> | InfiniSheetWorker<PendingWorkflowMessage>) {

  if (workerOrHost?.isWorker()) {
    workerOrHost.onReceiveMessage = (message: PendingWorkflowMessage) => { this.onReceiveMessage(message); }
  }
}

Misgivings

This already feels wrong. Why did I decide to use an instance of EventSourcedSpreadsheetData in both host and worker? Well, because it was the easiest approach. All the code for loading the state of a spreadsheet from an EventLog lives there.

That’s an implementation reason. Does it make conceptual sense? Why does the worker need an implementation of the SpreadsheetData interface?

It doesn’t. It works at a lower level. I’m mixing multiple concerns in the same class.

I already have the problem that EventSourcedSpreadsheetData is getting too big to manage. I can’t keep throwing everything into the same module.

If I had separate EventSourcedSpreadsheetData and EventSourcedSpreadsheetWorkflow classes, I could get rid of the ugly runtime checks. EventSourcedSpreadsheetData requires a WorkerHost, EventSourcedSpreadsheetWorkflow requires a Worker. I just need to factor out the common code.

Refactoring EventSourcedSpreadsheetData

I split the existing code into three separate components. All the common low level code lives in EventSourcedSpreadsheetEngine, which EventSourcedSpreadsheetData and EventSourcedSpreadsheetWorkflow inherit from.

I chose this structure to make the refactoring as simple as possible. It’s mostly a case of deciding which declarations and methods go in which source file. It’s easy to move things around if I get the initial split wrong. Once things settle down, it might make more sense for EventSourcedSpreadsheetEngine to be a member of EventSourcedSpreadsheetData and EventSourcedSpreadsheetWorkflow, rather than a base class.

The only awkward bit of the refactoring is that syncLogs belongs in EventSourcedSpreadsheetEngine but calls notifyListeners which depends on the implementation in EventSourcedSpreadsheetData. The easy way out was to declare notifyListeners as an abstract method in EventSourcedSpreadsheetEngine.

export abstract class EventSourcedSpreadsheetEngine {
  constructor (eventLog: EventLog<SpreadsheetLogEntry>, blobStore: BlobStore<unknown>) {
    this.isInSyncLogs = false;
    this.eventLog = eventLog;
    this.blobStore = blobStore;
    this.content = {
      endSequenceId: 0n,
      logSegment: { startSequenceId: 0n, entries: [] },
      loadStatus: ok(false),
      rowCount: 0,
      colCount: 0
    }
  }

  protected syncLogs(): void { ... }

  protected abstract notifyListeners(): void

  protected eventLog: EventLog<SpreadsheetLogEntry>;
  protected blobStore: BlobStore<unknown>;
  protected content: EventSourcedSnapshotContent;
  private isInSyncLogs: boolean;
}

Now EventSourcedSpreadsheetData can concentrate on implementing the SpreadsheetData interface, using the in-memory representation of the spreadsheet provided by EventSourcedSpreadsheetEngine.

export class EventSourcedSpreadsheetData extends EventSourcedSpreadsheetEngine 
                                         implements SpreadsheetData<EventSourcedSnapshot> {
  constructor (eventLog: EventLog<SpreadsheetLogEntry>, blobStore: BlobStore<unknown>, 
               workerHost?: WorkerHost<PendingWorkflowMessage>) {
    super(eventLog, blobStore);

    this.intervalId = undefined;
    this.workerHost = workerHost;
    this.listeners = [];

    this.syncLogs();
  }

  ...

  protected notifyListeners() {
    for (const listener of this.listeners)
      listener();
  }

  protected workerHost?: WorkerHost<PendingWorkflowMessage> | undefined;
  private intervalId: ReturnType<typeof setInterval> | undefined;
  private listeners: (() => void)[];
}

That leaves EventSourcedSpreadsheetWorkflow as pretty much a blank slate waiting to be filled in.

export class EventSourcedSpreadsheetWorkflow  extends EventSourcedSpreadsheetEngine {
  constructor (eventLog: EventLog<SpreadsheetLogEntry>, blobStore: BlobStore<unknown>, 
               worker: InfiniSheetWorker<PendingWorkflowMessage>) {
    super(eventLog, blobStore);

    this.worker = worker;

    worker.onReceiveMessage = 
      (message: PendingWorkflowMessage) => { this.onReceiveMessage(message); }
  }

  protected notifyListeners(): void {}

  private onReceiveMessage(_message: PendingWorkflowMessage): void {
  }

  protected worker: InfiniSheetWorker<PendingWorkflowMessage>;
}

There’s a clean separation between host and worker. Which means there’s no need for the type predicate in the worker interfaces.

TypeScript Interlude

Ironically, the presence of the type predicate was the only thing stopping TypeScript from throwing its toys out of the pram. Currently, there’s nothing in the WorkerHost interface because I haven’t figured out what will be needed yet. I know that I’ll have some kind of worker host implementation without an explicit postMessage, but not how that will surface in the interface, if at all.

Once I removed the type predicate, I was left with an interface with literally nothing in it.

export interface WorkerHost<MessageT extends WorkerMessage> { 
}

That makes TypeScript very unhappy. First, there’s an error because we have an unused generic parameter. If you get round that, you get an error when passing SimplWorkerHost to the EventSourcedSpreadsheetData constructor which expects a WorkerHost. TypeScript complains that the two interfaces have “no properties in common”. Which I guess is technically true because WorkerHost has no properties at all.

I tried hacking something in to keep TypeScript happy and ended up with this monstrosity.

export interface WorkerHost<MessageT extends WorkerMessage> { 
  /** @internal */
  __messageType: MessageT | null;
}

I also need to declare and initialize the junk property in SimpleWorkerHost. In the end, I went back to keeping a pointless type predicate as the least ugly workaround. I can remove it once I’ve found something useful to add. If it turns out there isn’t anything, I can ditch WorkerHost completely.

export interface WorkerHost<MessageT extends WorkerMessage> { 
  isHost(): this is WorkerHost<MessageT>
}

Unit Test

The EventSourcedSpreadsheetData unit test was the first opportunity to wire everything up. Each test starts with a one-liner that creates the instance to test. I extracted that into a separate creator function as there’s significantly more work to do.

function creator() {
  const blobStore = new SimpleBlobStore;
  const worker = new SimpleWorker<PendingWorkflowMessage>;
  const host = new SimpleWorkerHost(worker);
  const eventLog = new SimpleEventLog<SpreadsheetLogEntry>(workerHost);

  new EventSourcedSpreadsheetWorkflow(eventLog, blobStore, worker);

  return new EventSourcedSpreadsheetData(eventLog, blobStore, host);
}

There’s now six components to connect, rather than just two. Straightforward enough apart from the EventSourcedSpreadsheetWorkflow instance. It looks like it’s unused. However, the constructor connects it to the worker’s onReceiveMessage. The worker is referenced by the host which in turn is referenced by the returned EventSourcedSpreadsheetData.

I couldn’t come up with a more obvious alternative without making the code more complex. Eventually, I expect it will be hidden behind some higher level utility function.

Event Source Sync Story

My other existing example is the Storybook “Event Source Sync” story. This simulates two separate spreadsheet clients connected to a common backend accessed over a network with significant latency. Gratifyingly, all the pieces fit together cleanly.

// Backend
const blobStore = new SimpleBlobStore;
const worker = new SimpleWorker<PendingWorkflowMessage>;
const workerHost = new SimpleWorkerHost(worker);
const eventLog = new SimpleEventLog<SpreadsheetLogEntry>(workerHost);
new EventSourcedSpreadsheetWorkflow(eventLog, blobStore, worker);

// Client A
const delayEventLogA = new DelayEventLog(eventLog);
const eventSourcedDataA = new EventSourcedSpreadsheetData(delayEventLogA, blobStore);

// Client B
const delayEventLogB = new DelayEventLog(eventLog);
const eventSourcedDataB = new EventSourcedSpreadsheetData(delayEventLogB, blobStore);

On the backend we have a blob store and event log connected to a simple worker host feeding a workflow instance. Each client is represented by an EventSourcedSpreadsheetData that accesses the backend event log and blob store with simulated network delay. I don’t have a delay wrapper for blob stores yet but it will be easy enough to add once I have a real snapshot implementation.

Next Time

I have all the components wired up but the new pieces aren’t doing anything yet. SimpleEventLog needs to trigger snapshot workflows, and EventSourcedSpreadsheetWorkflow needs to process them. Once I have snapshots being created, EventSourcedSpreadsheetEngine will need to start reading them.

However, before I can do any of that, I need a real in-memory representation of spreadsheet data to load into. I can then serialize it into a blob as a first stab at creating a snapshot.

We’ll tackle that next time.