qpdb / mentat

A persistent, relational store inspired by Datomic and DataScript.
https://mentat.rs/
Apache License 2.0
52 stars 2 forks source link

Representing transformation events and timelines #200

Open gburd opened 4 years ago

gburd commented 4 years ago

A synchronized set of Mentat instances collaborate to build a linear timeline of states. They do so by rebasing and/or merging their changes into the remote primary timeline.

Each instance is able to perform a set of operations — assertion, retraction, excision, and schema addition/alteration — that introduce coordination points in this timeline.

Before we discuss those, it's worth mentioning that there are other operations that only affect whether clients can proceed:

  1. Base format changes (#587) — e.g., the introduction of a new type — can lock out clients; older clients won't be able to interpret the wire format.
  2. Non-backwards-compatible schema changes should prevent a remote timeline from being merged locally until the client upgrades; doing so would prevent the local client from operating on its own data.

Most of a client's operations are point-in-time:

  1. Retraction (and change, which is retraction + assertion) represents an ordered state change as a datom. There is a conceptual division here between the state before and after the change is transacted; the retraction is only meaningful if it happens-after the assertion of the datom to be retracted. This is not true for assertion; Mentat transactions that consist only of assertions can be applied in any order.
  2. Schema additions and changes similarly happen at a point on the timeline; other assertions can't use the vocabulary until it has been asserted, and some states on the timeline are invalid when viewed through the lens of a later altered vocabulary — e.g., altering the cardinality of an attribute.

The presence of one of these operations in a branch of history is important when considering merges and rebases. We must make sure that retractions and assertions are sequenced, and ensure that schema changes occur before data changes that depend on them.

One operation — excision (#21) — affects an existing region of timeline. An excision marker is placed in the local timeline and implies the removal of data from the device's local log.

Given that we want all clients to have the same materialized state after reaching the same point in the transaction log, it's crucial that excision processing is reproducible.

For a single client system, this is relatively simple: there is only one timeline, and all writes are fast-forward.

For a multi-client system, where a timeline might diverge and merge back together:

timeline-diverge

we must define which of the three timeline segments — shared history, new-left, new-right — are impacted by an excision on left or right. This definition must produce the same outcome when applied by either client.

Let's look at an example.

Imagine that two clients, A and B, both know about a URL in a browser's history, http://example.com/. This has ID 123. At point T1 it's linked to three visits: 200, 201, 202. Visits are component entities.

Now A and B diverge. A adds a visit: 300. B excises all visits for 123.

This is a classic syncing problem: what happens after a merge?

Usually one of the following occurs:

Various approaches are used to try to make some of these operations durable, such as recording tombstones. That's really tricky.

One of the reasons it's tricky is that it's not clear what the deletion means, because the excision operation (in Firefox Sync, at least) isn't pinned to a point on a timeline — it floats at an instant in time or an order of interaction with the server, and that is very woolly indeed.

The most obvious meaning for excision is that it applies along the current parent 'route' back to the origin. B's excision doesn't apply to A's new data. If B merges first, its excision is already recorded when A comes to merge or rebase. If A merges first, B knows how to rewrite its excision to apply only to earlier data.

(Indeed, B might well automatically record the excision only for the merged data — {:db/excise 123, :db.excise/beforeT <last parent>} — and just directly drop non-merged excised datoms as if they had never been written. This is reminiscent of Mercurial's phases.)

This alone isn't enough. If A transacted like this:

{:page/url "http://example.com/"
 :page/visit {:visit/at 1234567890123}}

the actual datoms recorded would be:

[:db/add 123 :page/visit 300 tx]
[:db/add 300 :visit/at 1234567890123 tx]
[:db/add tx :db/txInstant 1234567999999]

B excised everything about 123, and here we wouldn't have reasserted the URL, and so the URL would be excised, leaving 300 as a visit to an unknown page. A process of reintroduction (or storing redundant data) might be necessary, or perhaps that behavior is actually desirable; it depends on how the data is modeled and how specific the retraction is.