OADA / OADA/server

Proposal change to support: stream processing, server-supported tree puts, PATCH, and replacement of objects in resources

Open
#80 7 comments 0 reactions 1 assignee View on GitHub

@tomohiroarakawa is already working on this.

Since Feb 25, 2021.

enhancement
Dominant language
TypeScript
Stars
15
Forks
43
PR merge metrics
No merged PRs in 30d

Description

Over the past several years, a few items around the change feed structure have cropped up that we should think about implementing. I'm making this issue to bring all those conversations back to together into one thread so we can decide what we want to do. This is 10x longer than I thought it would be, but I think that's because the same solution solves a lot of otherwise unrelated goals.

First, a change document for a resource with linked children used to look something like this before we batched changes:

{
  body: {
    _rev: 47,
    link_key: {
      _id: 'resources/child1',
      _rev: 3,
      foo: "bar"
  }
}

It was simply taking each of the changes up the graph and constructing them back into the original tree. In that case, resources/child1 had the original write of foo: "bar", and that gave it rev 3, then the parent resource got that rev: 3 when it updated to its own rev 47.

When we batched changes together, so that one change may have overwritten another, we contemplated sticking with the tree structure and just merging all the batched changes together into one "net" change. We ultimately decided against keeping the big merged tree in favor of just putting each individual resource change into an array, which I like and agree with. In that case, the above change document now looks like this:

[ 
  {
    path: '',
    resource_id: 'resources/parent1',
    type: 'merge',
    body: {   
      _rev: 47, 
      link_key: { _rev: 3 } 
    },
  },
  { 
    path: '/linkkey',
    resource_id: 'resources/child1',
    type: 'merge',
    body: {
      _rev: 3,
      foo: 'bar'
    }
  }
]     

However, we did hit upon a rather elegant feature of the idempotent merges, namely that they can be composed into net changes by simply performing the same merge operation on the change document bodies that you would to the resources themselves. This lets you selectively compress a change stream, say for replication to an outside service, and it just feels cleaner in general. Simple composability is better than non-composability.

The trouble was, any non-idempotent-merge sort of change (by which I mean the "delete" type of change doc) would break that feature. The "delete" type change always felt a bit hacky, but we just threw it in there at the time so delete worked. I would love to find a way to clean that up by eliminating the need to have 2 different types of change documents and just reduce everything to the elegant ordered stream of idempotent merges.

So that leads me to

Goal 1: eliminate the "delete" change doc so everything is a composable ordered stream of idempotent merge operations.

If you stick with me through the end of this manifesto, there is a single proposal that will address all the goals simultaneously. I'm just working my way up to it.

Goal 2: Fix broken stream processing from change feeds

If you want to stream process documents based solely on the change feed, you cannot do it today and you should be able to. The problem is that the change only tells you what is now in the resource, not what used to be there before the change. For example, if your service is maintaining a running sum of data points, and you get a new change document showing that a 5 has been added to the list at key foo, you obviously need to add 5 to your running sum.

However, you do not know if there used to be a 3 at that foo key before the write. If there was, you also need to subtract 3 from your sum. The only way to learn about the previous value of 3 currently is to either

  1. start at rev 1 and recreate the document by reapplying the change feeds all the way up to the rev before the current one, or
  2. walk backwards through the change feed until you find the first change document with a foo key in the body (that was the last value of it).

This is inefficient and seems like we should be able to do better. The simplest answer is that the information about what was overwritten should be available either in the change document itself, or as a separate feed of "stuff that was overwritten."

It turns out that if we can represent all changes as a net idempotent merge, then we can easily compute the net change required to revert the document from any _rev to a previous one. This net change would definitionally contain the data that was overwritten. If a stream processing service had access to the regular change, and this reverse-change, it would have a very easily accessible way of knowing what was overwritten, or if this was a brand-new data point, or what got deleted, etc.

For example, if this is the resource before a change:

{
  _id: 'resources/1',
  _rev: 13,
  _type: 'application/json',
  foo: 'bar'
}

and someone make this change to rev 13 to get to rev 14:

{
  body: {
    _rev: 14,
    foo: 'baz'
  }
} 

then the reverse-change to get from _rev 14 to _rev 13 would be

{
  body: {
    _rev: 13,
    foo: 'bar'
  }
}

That looks pretty clean to me, and from a coding perspective, you already have code looking for the foo key in the forward change in order to add it to your running sum in my example, so it would be a piece of cake to add an independent piece of code to also look for foo in the reverse-change and subtract whatever it finds there.

In our current change feed, if the forward change is a delete, the reverse-change is just a merge with the deleted data. If the forward change is a merge, the reverse change could be multiple deletes and possibly a merge. This creates a messy-ness that there would not be a clean 1-to-1 mapping between forward and reverse changes. This will be addressed in Goal 3.

There are a few downside to reverse-changes:

  • it burdens the write process to have to pull the actual existing data for the resource out of the database first in order to diff it before writing it back.
  • it doubles the size of the change feed, because deleting a large amount of data will cause it to show up twice: once for the initial write, and once in the eventual reverse-change for its deletion.
  • Large JSON resources can already have slowness issues, this would compound that problem.

I feel like some clever algorithmic magic can reasonably alleviate most of those burdens if they really become an optimization issue, but they do exist. Sounds like a good paper or two...

In my opinion, the fact that a reverse-change could both solve the stream processing problem and enable you to easily walk back-and-forth through a change feed like a doubly-linked-list is reason enough to consider adoption. It also has the benefit of being backwards compatible: adding the reverse-change doesn't break things that look for the forward change, it just enables you to fix the services for all those edge cases that the service authors (i.e. me) forgot about or didn't want to deal with at the time.

Goal 3: 1-to-1 mapping between forward and reverse-changes and PATCH

json-patch exists and people use it. I think we all think it would be nice to enable it in OADA via the usual PATCH verb. However, the way json-patch represents changes as potentially a series of actions means that a single PATCH today could result in multiple change documents. It would be substantially better to get a single PATCH request mapping to a single change document.

One approach would be to get rid of our idempotent merge change documents and replace them with json-patch. Unfortunately, for the person writing code to do stream processing from the change feed, this would be a nightmare to handle. Right now you can just look for keys you care about in a single json document: with JSON patch, you'd have a near-infinite number of possible ways that a change could be represented and it would be extremely difficult to figure out all the ways that the key you care about could have changed. For that reason, I don't think this is a viable option at all.

Another approach would be to just solve the "2 types of changes" dilemna, since we already proposed above that all series of idempotent merge operations are composable into net changes. Get rid of the "delete" type and make it part of a "merge", and we're there. Bonus, we could probably utilize a json-patch library to just figure out a single "net" idempotent merge document to represent whatever changes might have happened and then just make PATCH forward to PUT internally just like POST does.

To do this, we must add the ability to specify in a "merge" change that one or more keys should be deleted. We've rejected in the past the idea of using null at the key like arangodb because it complicates your ability to put an actual null at that key. However, putting an object there with a reserved key inside that means "delete me" would work, even if it doesn't quite look as clean as null:

{
  a: { _delete: true }
  b: { _delete: true }
}

That clearly means "delete keys a and b when you "merge" in this change. With that model, you could finally send a single request to delete multiple keys.

It also has the advantage that you can also specify things to write in the same document:

{
  body: {
    a: { _delete: true },
    b: { _delete: true },
    c: "hello"
  }
}

That quite clearly means "delete keys a and b and upsert the value "hello" into c.

Now, we have a great 1-to-one mapping between forward and reverse changes. If the resource before that change looked like this:

{
  a: "val-a",
  b: "val-b",
  c: "world",
}

then the reverse change from the forward change above would clearly be just:

{
  body: {
    a: "val-a",
    b: "val-b",
    c: "world"
  }
}

Any code listening to that change feed and receiving those 2 change docs could easily do whatever stream processing they want because all the info they need is now there, and it is really easy to write code to just hone in on changes to a specific part of a resource.

The downside is, this would be the first "reserved" key we would be introducing into the JSON bodies that isn't at the top level (i.e. _id, _rev, _type, and _meta are already reserved at the top level). Which brings me to Goal 4.

Goal 4: enable single-request create-and-link for resources via tree-put

I think we all agree we'd like to enable some form of tree-put on the server side. The most basic thing to support is at least the ability to put to a link and have it create a resource and link it at the same time. The PUT body would look something like this:

{
  alink: {
    _type: 'application/json',
    _rev: 0,
    foo: "bar"
  }
}

If there was no resource linked at key alink in the parent document, the server would create a new resource with the body { foo: "bar" } and create a versioned link to it under the parent at the key alink. Omit _rev, and it's a non-versioned link. Make 2 simultaneous requests, and the first one create the resource and the second one just writes directly to the created resource as the second rev.

If this is what we'd like, then we are already extending the reserved _ keys from the top-level of a PUT body down to any level, and therefore adding one more reserved key of _delete would not actually be anything different than what we plan to do anyway.

Speaking of reducing the number of requests needed to do things in OADA, we also would all prefer to have some sort of "replace" or "set" functionality so we can make a particular key exactly equal to some value. Which brings up Goal 5.

Goal 5: Enable "replace" for keys in a resource

If you have this resource:

{
  a: 1,
  b: 2,
  c: { 
    hello: "world"
  }
}

And you would like to change the a key to 7, delete the b key, and set the c key to be exactly { foo: "bar" }. To accomplish this today, you would have to do:

DELETE resources/id/b
DELETE resources/id/c
PUT {
  a: 7,
  c: { foo: "bar" }
}

In order to explain my next part of this proposal, consider how this would look up to this point with our newly proposed change document structure using _delete. This would result in 3 idempotent merge change docs:

[
  { 
    b: { _delete: true }
  },
  {
    c: { _delete: true }
  },
  {
    a: 7,
    c: { foo: "bar" }
  }
]

Mentally just merge all those change documents together in your head. They would look like this:

{
  a: 7,
  b: { _delete: true },
  c: {
    _delete: true,
    foo: "bar"
  }
}

Now, pretend you didn't know what the _delete under the c key means. You can easily reason there are only 2 possibilities: it either means put foo: "bar" into the c key and then delete c, or it means first delete what's in c and put foo: "bar" there. Since the first one would be a meaningless no-op (why bother writing to something that is destined for deletion), only the second one makes sense.

As a bonus, to completely replace an existing resource with new data would now be really easy:

{
  _delete: true,
  foo: "bar"
}

That means "first delete the stuff at this resource, then put foo: "bar" there instead.

Therefore, adding only the _delete concept, we are now able to represent any arbitrary net change by a simple, repeated merging of change documents together.

For completeness, there is an edge case where a sequence of change documents end up putting in keys and then deleting them and then putting them back in, but that's easily handled by just making sure you do it in order for overlapping keys, but you can do it in parallel for any non-overlapping keys. i.e. if the first change writes to c, and the second change deletes c, the net document cannot have any of the deleted keys inside the final { _delete: true } object. It still applies the same algorithm to compose changes as the actual writes to resources, it's just not quite as easy to visualize those if someone hasn't considered them before.

Conclusion

Adding this single concept allows us to meet all the goals listed here. It is completely backward compatible with any systems doing PUTs. Given minimal training, I think just about anybody could understand what's going on here and recognize what the change document means at a glance. It retains the simplicity of watching for keys you care about among a stream of things you might not care about. It makes sure you can figure out what was possibly overwritten by any given write.

The primary ramification of enabling this would be that the change feeds would start containing _delete and stop containing type: "delete". Therefore, anything trying to track deletes from a change feed would have to be updated. It also has ramifications in potential slowdown of writes from doing pre-retrieval+diff, and it will double the storage requirements of the change feed.

With that said,

The final proposal

1: Add _delete as outlined above.
2: Include a reverse key in the change document

at the same level as body:

[
  { 
    path: '',
    resource_id: 'resources/id',
    body: {
      _rev: 3,
      a: "foo",
    },
    reverse: {
      _rev: 2,
      a: { _delete: true }
    },
  }
]

That means: to get from rev 2 to 3, make the "a" key equal "foo", and a was added new in rev 3. To get back from rev 3 to 2, delete a.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.