Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions design/2025-10-persistent-replica-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
# Deterministic Projection System

TODO: Safing policy. Intent revision issuers should always run against the
entire input revisions, not deltas. The safing policy determines when the
reducers arm. This stops intent issuers running during catch up, and only after
catch up. Hm i'm not sure this is correct. The members patching policies style
revision issuer should be what an intent policy looks like, and the output
replica should really be the replica that is safed. It would seem strange to
convert an intent replica into soemthing like this thoug. No it is fine. Intent
replicas always produce recallable effects and model a desired state. Which
outcome replicas map to the real world. Intent replicas do not produce commands
for outcome replicas to implement, but instead an overall desired state for them
to implement. In this sense, the safing policy is not for the intent replica,
it's for the outcome replica. So we really have an Obligation replica as well as
an intent replica. Where Obligations are one shot and Intents produce a steady
state.

TODO: When policy. Intent revision issuers have an evaluation policy

TODO: Intent replica throttle indicator to help the scheduler choose which
replicas to sacrifice in a CPU exhaustion event.

TODO: Explicitly mention that revisions and deltas can safely be held in memory
on different workers. since they are immutable.

TODO: Focus on explaining the Replica as the core concept, and revise
terminology to say snapshot instead of revision and just say "delta stream"
instead of whatever we use for that. Just call it a state snapshot.

TODO: How to handle replica version changes. Any change to the schema is going
to cause problems for dependencies. Code changes matter less but obviously
migration does need to happen. Code changes can effect the contract version
though if e.g. scope changes massively so it can't just be a schema version.

TODO: Rename replica -> projection + projection node

The persistent replica runtime is a deterministic incremental computation
environment.

Background: https://github.com/the-draupnir-project/Draupnir/issues/979

The matrix-protection-suite has evolved to around a "revision issuers" concept
to provide deterministic, incremental computation and data-flow in order to
manage complex interactions and resource intensive operations.

![](./images/2025-10-draupnir-data-flow.png)

This abstraction has not been generalized and is ad-hoc. And the
matrix-protection-suite had specific and constrained design goals in its early
development in order to build a new core for the draupnir project:

- Revision issuers are entirely in-memory structures. There are no asynchronous
methods to provide data for persistent storage.
- Attempts to persist specific revision issuers has required for backing stores
to be written that are in reality a downstream consumer of the deltas produced
by revisions outside of startup time.
- The backing stores are not generalized either and have virtually no support
for rollbacks in failed updates etc.
- Transactions between revision issuers are not formalized and it is possible
for revision issuers to become unsynchronised, if a delta fails to process or
is dropped, that effect is permanent until the system is restarted.
- Further the lack of "transaction" complicates persistence of revisions issuers
that are not sources of non-deterministic input e.g. the PolicyRoomRevisions
were persisted, we'd want to be confident that all deltas from the source
revision (room state) were applied in order.

Most importantly, the matrix-protection-suite was designed to be used entirely
within one JS process. Using workers was never a consideration. This holds the
Draupnir project back from being able to use more centralized service
architectures. Which would be required to provide a popular service.

Comment on lines +66 to +71

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably think a little bit more about this. Imagine a ban being added to CME and what that would look like for d4all. ROom state -> policy room -> and then an explosion of individual policy list revision issuers that need updating. Does it make sense for these to have individual replica actors just for the partition data? Or can we do something smarter to manage the workload?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm this is actually quite terrible. to think about, it's mostly all the same computation, the same delta through the stack until the protection layer. but idk maybe it's ok

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think ti's fine, we just probably don't want one replica actor per partition, just a minimum of one replica actor per replica. And they can just do all the partitions themselves. And if there is a high number of partitions we can just add more actors to deal with the workload.

## Proposal

We propose that all computation in Draupnir to be expressed through _replica
actors_ using a new framework called the persistent replica runtime. This
transition would happen incrementally, rather than as a focussed effort like the
transition to the matrix-protection-suite and Draupnir 2.0.

This would give us the following advantages:

- Restart can be immediate and instantaneous.
- Workload can be balanced across worker threads using actor migration.
- All core computation is deterministic and reproducible, easing development and
testing.
- All data-flow interactions are explicit and auditable.
- Effects and effect handlers are first-class, providing not only pre-viewable
and auditable consequences for protections, but for all side-effects.
- The system is fudamentally fault tolerant: all computation is incremental,
transactional, and deterministic. And can be restarted at the last delta.

_Revision issuers_ become _persistent replicas_. The live in-memory instance of
a _revision issuer_ becomes a _replica actor_. A meta-object is introduced to
describe the schema of the revisions and the logic of the actor called a
_replica description_.

Persistence is implemented by a storage backend that takes the _replica
description_ and creates tables or documents from them.

A scheduler is introduced that schedules _replica actors_ across worker threads
in Node.JS.

## Concepts

### Projection node

Projection nodes are immutable snapshots that accurately reflect a system state
at the point the node was created.

Projection nodes can reduce input _deltas_ into an _output_ delta, which can
then be used to produce another projection node that reflects a new system
state.

### Projection delta

Deltas represent the outcome of a computation related to a projection node that
provide a reproducible transformation of the projection state.

Delta's have their own identity, a delta ID and backlink to a previous delta.

#### Source delta

Source deltas are special because they encapsulate the source of non-determinism
into the deterministic system. All computation in the system is caused by source
deltas.

### Provenance data

Each delta and projection node needs to include information about upstream
inputs it depends on.

Each delta refers to the single source projection node that is the direct cause
for the current data flow.

Each projection node refers to the current transient source projection nodes
that have been used to derive the current state.

### Projection node reducer

A projection node reducer is a deterministic function that takes a projection
node and an input delta and produces a new output delta specific to the
projection node.

### Projection definition

A projection definition defines how to build projections from partition data,
projection node reducers, and what the inputs to the projection are.

### Rebuild delta

A rebuild delta is a specific kind of delta that is the direct result of a
change in implementation of a projection, ie a change to the projection
definition. It may be important to issue a rebuild delta when bugs that caused
projections to produce faulty data are fixed.

The change in projection definition is used as the source delta within a rebuild
delta.

### Partitioning

When projection definitions are combined with partition data when instantiated.

replicas are partitioned by a key. This key is usually something like a Matrix
room ID, or the user identifier of a Draupnir instance.

### Intent projection (effect)

A intent projection is a special kind of projection that produces an intention
to see some outcome enacted. These intents are then carried out by outcome
projections (effect handlers).

Enabled protections should be intent projections.

### Outcome projection (effect handler)

An outcome replica is a special kind of projection that records whether outcomes
have been issued for intents. The semantic meaning of the outcome is not
relevant, e.g. failure to ban a user is still an outcome that needs to be
recorded and is the outcome of the intent.

## Semantics

### Actor migration

Actors need to be transportable across node.js worker threads in order to
balance CPU.

### Preemption

replica actor client code will be interruptive in order to gracefully handle
shutdown and migration of actors.

This will be achieved by abusing I/O operations to signal interrupts. CPU bound
actor code will still have to cooperatively check for an interrupt signal.

### Garbage collection

Replica dependents will formally be tracked. Transitive dependencies will also
need to be considered for rollbacks to be successful. replicas will only be able
to discard an old revision when all transitive dependents have successfully
migrated to a superseding revision.
Comment on lines +195 to +200

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make it clear that "revisions" are really materialized views of deltas. And that garbage collection involves a compaction of deltas into a revision and changing which revision the delta stream is now based on.

Comment on lines +195 to +200

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, we need to also think about how some replicas interact with this. Like some protections and capabilities may no longer be relevant to keep around? Especially protections that have been instantiated with the intent to observe their effects and nothing more... it needs some thought.


### Scheduling

The scheduler is responsible for starting replica actors and migrating them when
CPU becomes exhausted in worker threads. The scheduler gives each actor a
controller that signals interrupts. The store is expected to also receive the
controller so that it can preempt on I/O.

The scheduler will have to be communicated `os.loadavg()` from each worker
thread and use this to trigger actor migration. Realistically we can probably
only move actors from high utilisation to low utilisation and have some strategy
that tries to identify and isolate the cpu intensive actors for reporting.
Comment on lines +202 to +212

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're moving to having the actors work per replica rather than replica partition. Then it probably makes more sense that when the system comes under excessive load, we bias towards latency and start batching reducer operations so that the IO from switching partitions eases.

However, because there are critical operations, this needs more thought. Particularly for e.g. spam joins. We can respond to that with application specific protections (join wave short circuit) or just by prioritising resource allocation somehow to bias things like member ban sync and the policy flow above all else.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idk if we really need to do this, it's kind of not our fault that matrix is stupid here. And we should instead have application specific protections that just neuter all new users as was planned with draupnir already and policy server.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need to decide and formalise the way we do prioritise operations and also handle load though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are partially guarded in that technically revision reducers can be run lazily.. but it's not clear how we define the circumstances that force them to run.... for policy application, membership changes and room activity are possible triggers for sure. So maybe optimisation includes figuring out the triggers for reducer evaluation so that everything can actually be lazy by derfault.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would work because it's not actually urgent to remove anyone from a room until they attempt activity in that room. I'm not sure how it'd apply to other things though... but if we can make defining triggers part of the replica description then it'll be ok.

@Gnuxie Gnuxie Oct 18, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, we can tell the difference by watching if the backlog size is going DOWN or it is stable or going up.

No we can't, because the window we get from sync to matrix is probably going to be constant

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so PolicyRoomReplica is very obviously giong to have a high fan out in draupnir4all to each PolicyListReplica. Which is going to cause a nightmare. I think .

The thinking is that we can make evaluation of reducers for PolicyListReplica partitions lazy if we can make a principle for draupnir4all that ALL protection activity is bounded by protected room activity. That's to say there are no protections that require policies to eagerly evaluate in the absence of room activity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ServerBanSync being reactive rather than proactive due to lazy evaluation will be ok with policy servers. Since we can direct policy check into room activity even before the event gets checked.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can make this more natural to think about if we can encode "when" evaluation should take place as part of the replica description/partition data. And to be honest, this needs to be defined on dependants rather than on the replica itself since otherwise it's anti modular. In this sense, replicas then don't run at all unless they have a downstream consumer that needs them now. I think this relationship is seperate to data flow.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scheduling evaluation is always provided by non deterministic sources and as far as i can tell this flow is actually backwards. But then i'm not sure how the scheduling works for "control" replicas that act on non determinstic sources get scheduled and trigger evaluation idk how this works we need to figure it out


### Crashing

What happens when an actor crashes?

#### Poisoned delta

If the revision reducer crashes when processing a delta, and continues to crash
when re-attempted, the delta will be skipped with an explicit acknowledgement.
This signifies a bug in the implementation of the revision reducer and is really
a critical issue. It's important not to bring down the whole system, but quite
honestly the existence of a reducer bug in critical areas can be as bad as that.
Protections obviously can have faulty reducers and don't effect other
functionality.

#### Other issues

The actor should be restarted with exponential backoff.

### Messaging

How do deltas get communicated to dependencies? especially if the in-memory
actors can crash and be unreliable for message delivery.

The deltas get written to the persistent store and a message is sent to the
other threads about the write so that the delta can be read back from the store
and propagated to dependencies.
Comment on lines +232 to +239

@Gnuxie Gnuxie Oct 16, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After delta-write, we can just push the entire delta to the dependencies as part of the hint message. The delta has provancne data so each dependency will know if it has missed a delta from that and check. This saves us a little bit of latency.


## Non-deterministic inputs

The PRR needs non-deterministic inputs in order to do anything useful, such as
matrix events, commands, and time scheduling.

These are provided by _source replicas_.

### Source replica

A source replica is a system or client provided replica with no dependencies
that produces deltas. Source replicas provide no data in their records, only the
deltas are significant.

### Time source replica

Some protections in Draupnir have time-scheduled background tasks, such as
DraupnirNews.

These should be implemented by having a revision reducer that accepts a tick
from a time service.

The time source replica is a system provided source replica that persists
records about when to send ticks to other replicas. Each replica that requires
time scheduling is given a adaptor replica to bridge them to the time source. So
that they are not informed about every single tick.
Binary file added design/images/2025-10-draupnir-data-flow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.