Proposal: Replication Strategies in blockstack.js

This is a follow-up from the Developer Engineering meeting last week.

Background

For a variety of reasons, users need to be able to store different app data in different Gaia hubs. This can include reasons like:

  • Better fault tolerance: If one Gaia node goes down, others can still serve replicas
  • Better cost/performance trade-offs: Depending on the workload, different apps can make more efficient of use of different storage providers. For example, Amazon Glacier could be used to cheaply store large amounts of infrequently-accessed data, but would not be used for small data or data that is accessed frequently.
  • More flexible security model: By choosing where a particular app’s data lives, users can add custom authentication requirements on both the read and write paths without affecting other apps.

Proposal: Per-App Gaia Hubs

Right now, the user has one Gaia hub, and it takes a Bitcoin transaction to change it. This could be extended to allow the user to specify multiple Gaia hubs, but this is still too rigid—in the limit, the user may need to change their set of Gaia hubs each time they sign into a new application.

Instead of trying to put multiple Gaia hub URLs into a Blockstack ID’s zone file, the user would instead add one or more Gaia hub URLs to each application’s entry in her profile. These would be visible in the user’s apps listing.

Reading and writing to the user’s Gaia hubs would depend on a “replication strategy” set in the getFile() and putFile() methods. I recommend having a few built-in strategies, but importantly the API must allow the developer to specify a callback to implement a bespoke replication strategy. This would look like the following:

On the read path:

  • getFile(path, {replicationStrategy: "any"}): This returns data if any of the user’s app-specific Gaia hubs return data. The hubs are tried in-order until one succeeds. This would be the default strategy.
  • getFile(path, {replicationStrategy: "primary"}): This returns data if the first of the user’s app-specific Gaia hubs returns data. No other hubs are tried. This is meant to be used in conjunction with putFile with its "primary" strategy, in order to accommodate reads on data that may undergo write-bursts.
  • getFile(path, {replicationStrategy: "all"}): This only returns data if all of the user’s app-specific Gaia hubs return the same data.
  • getFile(path, {replicationStrategy: function (hubURLs: Array<string>) : Promise<Buffer>}): This uses a custom strategy, implemented as a callback that takes a list of the user’s Gaia hubs as input and returns a Promise to a Buffer that contains the data. The callback would throw an exception if it could not fetch data.

On the write path:

  • putFile(path, data, {replicationStrategy: "all"}): This only returns successfully if all of the user’s app-specific Gaia hubs acknowledge receipt of the data. This would be the default strategy.
  • putFile(path, data, {replicationStrategy: "primary"}): This only returns successfully if the first of the user’s app-specific Gaia hubs acknowledge receipt of the data. All Gaia hubs will be written to, but secondaries may silently fail. This strategy would be good for “burst” writes, where speed matters more than consistency or durability. It should be used in conjunction with getFile(path, {replicationStrategy: "primary"}). You would follow a burst of putFile calls with this strategy by a call to putFile with the "all" strategy.
  • putFile(path, data, {replicationStrategy: function (hubURLs: Array<string>) : Promise<Array<URLs>>}): This uses a custom strategy, implemented as a callback that takes a list of the user’s Gaia hubs as input and returns a Promise of a list of strings that each represent a URL to a data replica. The callback would throw an exception if it failed to replicate data somehow (implementation-defined).

Design Philosophy

The reason for making blockstack.js (and by extension, the application) responsible for replica placement and replica consistency is that it makes it straightforward to deal with partial failures in application-specific ways. For example, if I try to save a short-lived photo to three Gaia hubs in a Snapchat-like dapp, but only two writes succeed, the application may still consider the write “successful.” As another example, saving that same photo as my profile photo in a Facebook-like dapp would require successful acknowledgement from all three Gaia hubs in order to consider the write successful. The Gaia hub has no insight into the application’s needs; therefore the application must be responsible for driving replica placement and consistency on its own (i.e. via the callback interface).

I point this out because part of the previous discussions on how to let the user add multiple Gaia hubs revolved around making the Gaia hub “smarter” by being able to handle partial writes/reads on its own, and mask failures (or propagate them in some meaningful way). This line of thought was ultimately scrapped, because it lead to really complex implementations that are hard to reason about but get us no closer towards solving the problem than the strategy outlined in this proposal.

Implementation

User Profiles

The current profile structure “approximately” allows per-app Gaia hubs today:

$ blockstack-cli lookup judecnelson.id | jq '.profile.apps'
{
  "https://app.graphitedocs.com": "https://gaia.blockstack.org/hub/16YzkXKsYWZKypRcXk6vn4ETu1GBzoiZLw/",  
  "https://www.chat.hihermes.co": "https://gaia.blockstack.org/hub/16wcVWogB3U3GAaHMVRXgWX68mGdN25Xkp/",
  "https://www.stealthy.im": "https://gaia.blockstack.org/hub/1ERc9KRMnpG7x4v8mN8e2WW7viEVbZnpvr/",
  "http://publik.ykliao.com": "https://gaia.blockstack.org/hub/1GzmHhQuUP4aKmnwXLCEEyJ2Won4gZkpJP/",
}

With a few modifications, a user could instead have something more like this:

$ blockstack-cli lookup judecnelson.id | jq '.profile.apps'
{
  "https://app.graphitedocs.com": [
      "https://gaia.blockstack.org/hub/16YzkXKsYWZKypRcXk6vn4ETu1GBzoiZLw/",  
      "https://gaia.cs.princeton.edu/hub/16YzkXKsYWZKypRcXk6vn4ETu1GBzoiZLw/"
  ],
  "https://www.chat.hihermes.co": [
      "https://gaia.blockstack.org/hub/16wcVWogB3U3GAaHMVRXgWX68mGdN25Xkp/",
      "https://www.private-gaia-hubs.eu/hub/16wcVWogB3U3GAaHMVRXgWX68mGdN25Xkp/",
      "https://www.my-local-server.com/hub/16wcVWogB3U3GAaHMVRXgWX68mGdN25Xkp/"
  ],
  "https://www.stealthy.im": "https://gaia.blockstack.org/hub/1ERc9KRMnpG7x4v8mN8e2WW7viEVbZnpvr/",
  "http://publik.ykliao.com": "https://gaia.blockstack.org/hub/1GzmHhQuUP4aKmnwXLCEEyJ2Won4gZkpJP/",
}

The default strategies listed above would continue to work in applications that just have one Gaia hub, without requiring any application-level code changes.

Sign-in

The user would need a way to specify which Gaia hub(s) would be used to load and store data when they sign into the application.

Profile Editing

The user needs a way to add/remove Gaia hubs from their profile, independent of applications.

Miscellaneous

This problem is related to being able to explore a Gaia hub’s data and enumerate files. This will require extending the Gaia hub driver model to include a list() API call for enumerating previously-written files. This feature should be made available at around the same time as this proposal is implemented, because we’ll need a way to migrate data from one Gaia hub to another once the user can add/remove them at will.

CC @aaron @larry @jehunter5811 @yukan for your thoughts

EDIT 1: Add “primary” strategy to read and write paths, and remove “any” from the write path.

6 Likes

I like this approach — though I think the “any” replication strategy should really be “primary-only” — you can get into really bad data consistency trouble if you let the write succeed on any response.

I also like that we can use the application entries to implement this (rather than the zonefile) — though I guess this doesn’t speak to replication of the profile (which is supported in blockstack_client's profile lookups, but not in blockstack.js)

2 Likes

Will update to specify “primary” instead of “any”.

1 Like

I like this. I agree with Aaron about the “any” replication issue. Good call! I think the real power here is offering a default solution but also allowing developer extensibility and customization. Great write-up, @jude!

I’ve had a lot of conversations in Oslo about this very topic, so this is huge. Thanks!

2 Likes

I also like that we can use the application entries to implement this (rather than the zonefile) — though I guess this doesn’t speak to replication of the profile (which is supported in blockstack_client’s profile lookups, but not in blockstack.js)

I was thinking about this too. First, we should fix blockstack.js so it supports blockstack_client's original semantics where profile lookups try each zone file URL. Second, we should patch blockstack.js so it can fetch profiles from Blockstack explorers in the event that the zone file URLs don’t work.

In the future, I anticipate that a user’s app data will be available mainly from her Gaia hubs, but her profile(s) will be widely replicated to multiple Blockstack explorers. In addition, the $TTL field in the user’s zone file will be used to determine for how long these 3rd party replicas should cache profiles. I think blockstack.js should take advantage of this when looking up app data, so the user isn’t necessarily on the hook for ensuring that her public profile data is widely available (thus ensuring that the locations of her Gaia hubs are widely available).

I’m not sure what a blockstack explorer is in this case — is it something like a search index, which has a cached (possibly stale, though), image of all the profiles on the network?

I think the TTL in the zone file should control the zone file caching, rather than the profile caching. The profile caching can be (as it is today), controlled by the HTTP caching headers, though this is, by default, set really low, so that user profile updates appear nearly instantaneous. Is the concern here that you want something like a cache setting for the explorers, which ignores the (low) HTTP cache headers, and uses the zone file TTL instead?

I’m not sure what a blockstack explorer is in this case — is it something like a search index, which has a cached (possibly stale, though), image of all the profiles on the network?

Yeah, that’s it. Generally speaking, it’s any service that can serve a downstream profile replica.

My overall point is that if Gaia hubs are going to be discovered within profiles (not zone files), then it makes sense to try and ensure that profiles are as widely replicated as possible, much like how we do for zone files today. One way to do this to expose the cached copies in our search index to blockstack.js, although others are possible. How we do this is outside the scope of this proposal, though.

I think the TTL in the zone file should control the zone file caching, rather than the profile caching. The profile caching can be (as it is today), controlled by the HTTP caching headers, though this is, by default, set really low, so that user profile updates appear nearly instantaneous. Is the concern here that you want something like a cache setting for the explorers, which ignores the (low) HTTP cache headers, and uses the zone file TTL instead?

I think the TTL in the zone file should control the zone file caching, rather than the profile caching. The profile caching can be (as it is today), controlled by the HTTP caching headers, though this is, by default, set really low, so that user profile updates appear nearly instantaneous. Is the concern here that you want something like a cache setting for the explorers, which ignores the (low) HTTP cache headers, and uses the zone file TTL instead?

Two things. First, do we ever need to evict cached zone files? I think a search index can (and should) cache them forever internally, since they’re paired with a blockchain transaction. Since zone files are content-addressed, they can be cached downstream forever as well. The only thing that should be cached for a “short” amount of time (e.g. 5 minutes) is the name-to-zonefile mapping, since that changes no more than once every bitcoin block (10 minutes on average). Point is, I don’t think we’re gaining anything by treating the $TTL field in the zone file as a cache time-to-live for the zone file itself—we already know what the time-to-live for zone files are irrespective of this.

Second, I agree that HTTP cache control headers are the best mechanism to ensure that clients don’t hit search indexes too heavily when querying profiles. However, I think the user should be able to pass a hint to downstream search indexes to tell them how often their profiles are expected to change. I think the $TTL field in the zone file could be purposed to convey this hint, since (1) we’re not using it for the zone file’s time-to-live anyway, and (2) the user is in the best position to know what a good time-to-live for their profiles are already.

The search index isn’t required to honor the $TTL field when making HTTP cache headers, since the $TTL isn’t meant for search indexes. Instead, it’s meant for the clients to decide whether or not a given profile JWT replica is stale. If you consider the aforementioned long-term goal of trying to make as many downstream 3rd party replicas of each profile JWT as possible, and if you also consider that the client does not trust 3rd party replicas with correctness (only availability), then it must be the client’s responsibility to determine whether or not a profile JWT is fresh. In order to do this correctly, the profile owner must convey the profile time-to-live via the blockchain (i.e. a reliable communication channel outside the replica host’s control) in order to ensure that replica hosts can’t equivocate about the state of the profile. That’s why I proposed making the $TTL field in the zone file indicate the profile’s time-to-live.