Hi everyone in HTTP!
Last fall we solicited feedback on the Braid State Synchronization proposal [draft, slides], which I'd summarize as:
"We're enthusiastic about the general work, but the proposal is too high-level. Break the spec up into multiple independent specs, and work bottom-up. Focus on concrete 'bits-on-the-wire'."
Versioning of HTTP Resources
draft-toomim-httpbis-versions
https://datatracker.ietf.org/doc/html/draft-toomim-httpbis-versions-00
| Subject: | New Version Notification for draft-toomim-httpbis-versions-00.txt |
|---|---|
| Date: | Mon, 08 Jul 2024 11:02:11 -0700 |
| From: | interne...@ietf.org |
| To: | Michael Toomim <too...@gmail.com> |
We've got divergent discussion threads that I'm merging together.
First, Peter Van Hardenberg (of Ink & Switch, Local-First, and Automerge) wrote this initial review of the draft. He's cc'd, and we can respond in this thread.
------------------------
-- Peter Van Hardenberg: --
------------------------
Hi Michael,
I'm also merging Pierre Chapuis' comments into this thread.
Pierre is cc'd, and we can respond here.
------------------
-- Pierre Chapuis: --
------------------
Hello everyone,
Peter, thank you for your interest! I'm excited that you are
bringing up performance for discussion! There's a lot to say on
that, and I give an overview below:
== Compression & Performance ==
First, let me correct a big misinterpretation— this work
absolutely prioritizes high-performance, realtime
data synchronization. It should support thousands of mutations per
second. Our implementations are higher-performance
than Automerge, for instance. I regularly work today with a doc
composed of 110,000 edits. It loads instantly, thanks to some
great Version-Types we've designed.
The Version-Type (in the proposed Version-Type header) is the way
you get performance increases. The key to performance is managing
history growth. You manage that by finding a pattern in history,
and then compressing or ignoring history. You can express those
patterns as a Version-Type spec. (There's a robust theory behind
this called Time Machines.)
I apologize that this wasn't clear in the draft -00. I thought this would be an advanced feature that people wouldn't comment on for a bit — but am pleasantly surprised to hear your interest in it! I will be adding more clarity to the spec on Version-Types, and already have begun doing so in github:
https://github.com/braid-org/braid-spec/blob/master/draft-toomim-httpbis-versions-01.txt#L885
I'd also encourage you to check out this sketch of how to bake RLE into HTTP Header Compression:
https://braid.org/meeting-69/header-compression
https://braid.org/video/https://invisiblecollege.s3.us-west-1.amazonaws.com/braid-meeting-69.mp4#4166
In any case, keep in mind that at this stage, we need to know
only whether there is interest in this area of work — not
whether this particular spec meets your needs. If we adopt this
work into the HTTP WG, we will get a chance to change or rewrite
any part of the spec. This spec is just a starting point to get
discussion going. So think of this as a problem statement rather
than a solution statement.
== PUTs ==
As for PUTs, I suspect you might be thinking about HTTP/1.0 where each PUT might require a new TCP connection with its own TLS handshake. But keep in mind that with HTTP/2 and 3, all HTTP semantics are expressed in binary, and a PUT is usually just a single packet! This is just as efficient as any hand-rolled protocol you have, and it has the advantage of being interoperable with all of HTTP.
== History Retention ==
This versioning model supports Time Machines— the beauty of which is that peers become free to independently choose how much history to store. An archival peer can store the full history. A light client can store just the latest version (see the amazing Simpleton client, which needs zero history).
So each peer can choose how much history to store. If a peer doesn't have enough history to merge an edit, it can simply request that history from another peer. In this draft, you do so by requesting a GET with both Version and Parents headers specified.
== Signatures & Validation ==
This is out of scope for this proposal on versions. However, (a)
there are some Version-Types that double as signatures. When this
happens, it can be specified by authoring a Version-Type spec to
articulate the new constraint. And (b) this is a generally
important area of work that I encourage.
Cheers!
Michael
Rory, thanks for these excellent thoughts! It's exciting to see other people digging into the versioning problem with us. :)
Responses:
== Versioning with ETag ==
You make a good point that ETag headers, like the proposed
Version header, are opaque strings that can be formatted to
express additional information if we want to. This is true for
both ETag and Version:
ETag: "Sat, 6 Jul 2024 07:28:00 GMT"
Version: "Sat, 6 Jul 2024 07:28:00 GMT"
ETag: "v1.0.2"
Version: "v1.0.2"
We propose articulating the structure of these version ids using a Version-Type header. You could, for instance, use "Version-Type: date" for the first example, and "Version-Type: semver" for the second.
The main problem with ETag, though, is that it marks *unique content* rather than *unique time*. If you mutate the state of the resource from "foo" to "bar" and then back to "foo", you'll revert to the same ETag, even though this is at a different point in time. This breaks collaborative editing algorithms.
Finally, I'll note that your claim that ETags don't have to be sensitive to content-encoding is only true for *weak* ETags. Strong ETags must change whenever the byte sequence of the response body changes. This means they should be sensitive to content-encoding. RFC9110 is also explicit that they depend on content-type:
> A strong validator might change for reasons other than a change to the representation data, such as when a semantically significant part of the representation metadata is changed (e.g., Content-Type)
https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.1
Consider the case where a user edits a markdown resource:
PUT /foo
Content-Type: text/markdown
Version: "mike-99"
# This is a markdown file
Hello world!
And the server then shares this as HTML:
GET /foo
Accept: application/html
HTTP/1.1 200 OK
Content-Type: application/html
Version: "mike-99"
<html>
<body>
<h1>This is a markdown file</h1>
<p>Hello world!</p>
</body>
</html>
Using the Version header, we're able to express that these are two representations of the resource at the same point in time. You can't do this with a strong ETag.
== Version and Parents headers ==
I think there's been a miscommunication here. The reason there are multiple version IDs in the Parents header is for edits that happen *in parallel*, not for edits that happen in sequence. This is to represent a version DAG:
a <-- oldest version
/ \
b c
\ /
d <-- current version
In this example, the current version "d" would have:
Parents: "b", "c"
This is not allowed:
Parents: "d", "b"
Because of this language in the spec:
For any two version IDs A and B that are specified in a Version or
Parents header, A cannot be a descendent of B or vice versa. The
ordering of version IDs within the header carries no meaning.
Good question!
== Client-generated Version IDs on PUT ==
Yes, there would be a problem if two clients generate the same version IDs for two different PUTs. Then the versions would not be unique!
However, requiring the server to generate versions is only one possible solution— and is a solution that requires a server. We also want to support distributed p2p systems, which don't have servers.
In these systems, it's quite common for clients to generate version IDs. There are two common ways to solve this problem:
Does this all make sense?
Again, good questions, and I am glad to see this interest in the topic! I think we can do a lot with it!
Michael
Peter, I just wrote up an explicit example of how to compress four PUTs into 7 bytes. Check out the new section 5.1 here:
https://github.com/braid-org/braid-spec/blob/master/draft-toomim-httpbis-versions-01.txt#L945
These four puts compress down to 0.0146% of their original size,
at least in theory. Note that said compression scheme isn't fully
specified in this draft — the focus of this draft is just to
gather interest in working on a versioning system that makes such
compression possible. The actual compression schemes would be
future work.
--You received this message because you are subscribed to the Google Groups "Braid" group.To unsubscribe from this group and stop receiving emails from it, send an email to braid-http+...@googlegroups.com.To view this discussion on the web visit https://groups.google.com/d/msgid/braid-http/d713500c-c4db-4bf8-8096-edb0b5ff1751%40gmail.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/braid-http/fdce4502-6888-4c27-bb14-c239e19e8771%40app.fastmail.com.
Yes, it works great for collaborative editing. I use it every day
in production. It's very fast. We send a PUT per keystroke. I
should show you a demo. It's real. :)
It's not true that an HTTP PUT induces more load on the server
than a WebSocket message. They are equivalent. Consider that both
H2 and WebSocket are TCP streams that stay persistently open. The
only difference between these two streams is how the data is
formatted. They don't impact how/when the server loads the
resource from disk into ram. It's true that HTTP requests often
contain a session ID in a cookie on each request, whereas a
WebSocket might only send that when the user logs in/out, but that
header gets compressed down with H2 header compression and isn't a
significant performance problem.
Perhaps you're thinking about old-style threaded web servers?
Those have a lot of overhead per request, because a 4mb OS thread
has to be allocated to each request. But those don't support
persistent connections (like WebSockets) at all. That's why
everyone's moved to evented servers, like nodejs, which make
persistent connections cheap, whether formatted as a WebSocket
message stream or a H2 message stream.
Pierre, thanks for these juicy thoughts!
First, your work on HTTP Feeds looks very relevant to 104
Multiresponse idea (Section 2.4.4) that is slated for a
future spec. I'd love to hear your thoughts!
As for vector clocks, what you have just suggested is very interesting— that is a way to express the same History, with a different Version-Type! (Albeit, one that bends the constraint that parents' cannot be ancestors of each other that we've been discussing elsewhere.)
Although this contains the same history as the Vector-Clock example, I would not call these version IDs themselves Vector-Clocks. One cool thing about a Vector-Clock is that you can compare any two vector clocks in O(peers) time, without doing any O(n) traversals of the historical time DAG! If you have a small set of peers, this lets you compare order in O(1) time. This means you don't need to specify any parents at all in your messages. All ordering can be deduced from the Version header alone. That's kinda cool, for the systems that need that.
Michael
Good point! I've clarified the spec on merging by adding a reference to [Merge-Types].
Thank you!
Peter and Martin,
I've hit "publish" on the explanation for how to compress history
with Version-Types:
https://datatracker.ietf.org/doc/html/draft-toomim-httpbis-versions#section-5.1
You can simply review these sections (5.1 and 5.2) instead of the long list of links below. Does this address your concerns?
Thanks,
Michael
Well, the main reason for the Parents: header is to convey the shape of the historical time DAG.
The scheme you propose here does not give *quite* the full
information on grandparents. Consider these two time DAGs:
DAG 1 has parents and grandparents:
a1 a2 | | | | b1 b2 \ / cDAG 2 has the grandparenting flipped:
a1 a2 \ / / \ b1 b2 \ / c
These are different DAGs, but they'd produce the same Parents: header in your scheme:
Parents: "b1", "b2":1; "a1", "a2":2
So I don't think this scheme is yet an improvement.
If your goal is to have a convenient way to request a span of history, might I suggest doing a HEAD request on the span:
HEAD /foo Parents: "a1", "a2" Version: "c"
The server will respond with an outline of the time DAG:
HTTP/1.1 104 Multiresponse HTTP/1.1 200 OK Parents: "a1" Version: "b1" HTTP/1.1 200 OK Parents: "a2" Version: "b2" HTTP/1.1 200 OK Parents: "b1", "b2" Version: "c"
This lets you access the DAG without downloading any patches or resource contents.
Cheers!
Michael
Martin, thank you for engaging! It's valuable getting input on data synchronization from the author of Designing Data-Intensive Applications.
I believe we are addressing complementary aspects of the state
synchronization problem, rather than "different approaches."
Specifically: you're focusing on how peers reconcile and route an
E2EE history of blobs; whereas I'm working on the structure within
those blobs.
Our work could integrate as follows:
Envelope 1: Your work (routing protocol)
Routing Headers
Message Type
Message 2: My work (message structure)
Headers
Version 3: This versioning spec
Parents 3: This versioning spec
Range
Body
New Value
An Envelope is a structured container within a protocol that
encapsulates opaque data across multiple hops. It's a feature of
SMTP, for instance, that allows mail to go through relays, while
GPG-encrypting the contents, and is also how BCC works. In my
understanding, you are working on a routing & reconciliation
protocol for envelopes that maintain content opacity.
In my view, your work could be expressed in an extension to HTTP
that allows HTTP messages to route through multiple peers. Some
HTTP extensions already implement this concept implicitly. For
example, OHAI (Oblivious HTTP) uses an encapsulated request that
functions like an envelope — it includes routing information
(gateway and target URLs), encrypts contents, and has authenticity
elements (like a nonce). This enables OHAI to separate client
identity from request content, facilitating privacy-preserving
routing.
Our efforts complement each other. Any synchronization protocol requires both message routing and message contents. However, I've noticed you use "synchronization protocol" to refer to just the reconciliation part:
> I prefer to think about the sync protocol as a reconciliation between two sets of blobs, where the two peers are figuring out which blobs they need to send to each other so that, at the end, they both have all the blobs.
I propose using "synchronization" to encompass the entire
problem, including the CRDT bits. CRDTs are crucial to
synchronization as they define how peers merge parallel edits in
synchrony.
Martin Kleppmann wrote:
- If you want to atomically update multiple objects, would your approach require multiple PUTs? Is there a risk of some of them being applied and some being dropped if the network drops at an inopportune moment? In our approach we simply encode multiple updates into a single blob. An example for wanting atomicity: say you want to attach a comment as an annotation to a span of text. In Automerge you would do that by attaching a comment ID as a mark to a range of characters, and then in a separate object you would map the comment ID to a JSON object with the details of the comment (author, text, timestamp, reply thread, etc).
Yes, we want to support atomic mutations (e.g. "transactions")
across objects.
I'd like to pick a more difficult example, though, because
annotations to spans of text do not intrinsically require two
atomic writes to create. The annotation's "attachment" can just be
a single field that points to a span of text at a version ID, such
as {version: ["x72h"], range: "text 44:70"}. Then you don't need
an intermediate object, don't mutate the text CRDT, and end up
with just one object to mutate.
Perhaps a stronger example is a Bank Account transfer. Suppose
Bob wants to send $10 to Alice. We will debit -$100 from Bob's
account, and credit +$100 to Alice's. Bob and Alice sign their
mutations from different computers, and send them over the
network:
PUT /bob Version: "transaction-9bx38" Content-Range: json .account.balance Content-Length: 3 110 PUT /alice Version: "transaction-9bx38" Content-Range: json .account.balance Content-Length: 2 90
You propose enforcing atomicity by encoding both PUT messages within the same opaque envelope, which would eliminate scenarios where some peers have one message, but not the other. Unfortunately, this requires both mutations to be created atomically on the same computer. If Alice and Bob sign and send their transactions from separate peers, they will have separate envelopes, and we still have an atomicity problem to contend with.
A more general way to address atomicity is via Versioning +
Validation. Atomicity is about time, Versioning specifies time,
and Validation can mark a version invalid until all parts of the
transaction are available. In our case, Bob and Alice:
I believe Validated Versioning provides a more expressive
framework for atomicity than Multi-Message Envelopes. We can do
this with PUTs, if we extend them with a versioning spec (e.g. in
this thread) and a validation spec (TBD).
- How would the HTTP-style requests map to a p2p setting? The PUT … syntax seems to suggest an asymmetric, client-server style relationship between the communicating nodes. I know you said that Braid was p2p-compatible, but maybe the HTTP-style syntax just puts me so much in a client-server mindset that it's not obvious to me how it translates to communication between peers.
This might be easier to understand visually, so I just recorded
this video:
I hope that's helpful. It was my first time trying that. I'm happy to clarify anything that I hand-waved. The resources I used are here:
1. https://braid.org/antimatter#viz2
2. https://braid.org/antimatter#messages-summary
Why prescribe the HTTP-style message format and not just let each CRDT implementation define its own serialisation format that is optimised for its features?
My work makes CRDTs and OT interoperable. We now have a common
protocol that any CRDT and OT algorithm can use, while
independently optimizing their own features. (Yes, this is the
Time Machine architecture that unifies OT and CRDT, which I am
writing up, and your new paper cites a draft of.) Part of this is
a general representation of time, specified in terms of Events and
Versions, with a "version-type" that enables optimizations,
without coupling implementations to each other's data structures.
This versioning idea is in the spec for this thread, and is
awaiting peer-review from experts like you.
This common protocol for any CRDT or OT algorithm has many
benefits. (1) It allows us to build CRDT algorithms that support
multiple merge-types. (See Seph's reference-crdts work.) (2) It
allows implementations to implement optimizations independently,
while still guaranteeing consistency. (Consider EG-Walker. Each
peer can implement a walker and its internal format differently.)
(3) It allows implementations to summarize or prune some ranges of
history independently, while still guaranteeing full consistency
for merges through other ranges of time (like with antimatter),
and (4) to request various ranges of history from one another in a
standard way if they have dropped information that they want back.
(5) It allows these operations to be implemented by generic
infrastructure, such as CDNs, caches, and backup devices, without
requiring them to implement any specific CRDT or OT algorithm. (6)
We can also build debugging and programming tools that are able to
inspect and support this history without knowing about a
particular CRDT or OT algorithm. See the braid-chrome
devtools panel as an example.
The goal is interoperability. It results in better performance,
tools, and infrastructure; along with more widespread usage. This
gets even better when we interoperate with HTTP.
I guess one thing that your approach supports is that when in unencrypted mode, the server could generate a snapshot of the document rather than serving a log of the edit history. However, our blob approach allows that too: a server that is able to interpret the contents of the blobs can also compress them into a snapshot and serve it when required (we sometimes call this a "shallow clone" by analogy to the similar concept in Git). But that is an optional extension to the protocol; the core protocol can still work on uninterpreted blobs.
Yes, there are important use-cases for both needs. However, one
man's "core" is another man's "optional." May I propose the
neutral principle of *separation of concerns*? The
serialization/envelope/routing/reconciliation can be a separate
concern from the message formatting. We don't need to agree on
which concern is more "core." It's up to implementations to choose
which specs they want to implement.
Thank you, again. This discussion has been quite valuable to me. I hope you find value in it, as well!
Michael