Scalable replication and change listening for CouchDB
Spiegel was designed to provide scalable replication and change listening for Quizster, a photo-based feedback and submission system. Without Spiegel, a lot of complicated logic would need to exist in the Quizster application layer.
- Scalable Replication: The _replicator database is a powerful tool, but in certain cases it does not scale well. Consider the example where we have users posting blog entries. Let's assume that we want to use PouchDB to sync data between the client and CouchDB. Let's also assume a design of a DB per user and an all_blog_posts database that stores the blog posts from all the users. In this design, we'd want to replicate all our user DBs to the all_blog_posts DB. At first glance, the obvious choice would be to use the _replicator database to perform these replications, but the big gotcha is that continuous replications via the _replicator database require a dedicated DB connection. Therefore, if we have say 10,000 users then we would need 10,000 concurrent database connections for these replications even though at any given time there may be at most 100 users making changes to their posts simultaneously. We can prevent this greedy use of resources by only replicating databases when a change occurs.
- Real-time Replication Between Clusters: The built-in clustering in CouchDB 2 isn't designed to be used across different regions of the world. Spiegel tracks changes in real-time and then only schedules replications for databases that have changed. You can therefore use Spiegel to efficiently keep clusters, located in different regions of the world, in sync.
- Scalable Change Listening: Let's assume that we have some routine that we want to run whenever there are changes, e.g. we want to calculate metrics using a series of views and then store these metrics in a database doc for quick retrieval later. We'd need to write a lot of boilerplate code to listen to _changes feeds for many databases, handle fault tolerance and support true scalability. Instead, we can provide a simple way of configuring a backend to use a user-defined API to execute these routines.
- Supports any number of instances of each process for scalability. This way, you can add instances (e.g. via docker) to support any load
- Is fault tolerant and gracefully handles network issues, crashed database instances or other transient issues.
{
type: 'replicator',
source: '<couch-url>', // e.g. https://user@db.example.com:6984. Passwords maintained via
// passwords config
target: '<couch-url>',
filter: '<filter>',
query_params: '<query-params>'
// ... any other params accepted by the _replicate API:
// (http://docs.couchdb.org/en/2.1.1/api/server/common.html#replicate)
// The following attributes are automatically populated and managed by Spiegel
dirty: '<true>|<false>',
updated_at: '<ISO Timestamp>',
locked_at: '<ISO Timestamp>|<null>'
}
Notes:
- If a replication fails, e.g. due to a transient error, it will be retried
- If a replication process is abruptly terminated, e.g. due to a replicator process being restarted, the replicator will eventually be considered stalled and will be retried.
- Replicator docs are not design docs, therefore the id of a replicator doc can be anything that doesn't begin with
_design/
{
type: 'on_change',
db_name: '<reg-ex>', // Matches against a DB name
// on_change only applies if this condition is met
if: {
'<attr-1>': '<reg-ex>',
'<attr-2>': '<reg-ex>',
...
},
url: '<api-url>', // e.g. https://user@api.example.com. Passwords maintained via
// passwords config
// Parameters passed to API call
params: {
foo: 'bar',
change: '$change' // can use $change for change doc
db_name: '$db_name' // $db_name is the name of matching DB
},
method: '<POST|PUT|GET|DELETE>'
block: <true|false>, // API request must resolve before moving on
debounce: <true|false> // Duplicate API requests as identifed by URL and params are ignored
}
Notes:
- CouchDB can replay changes so your on_change rule must be idempotent, meaning that it can be run repeatedly or even run with an older change without causing harm.
- If an API request fails with a non-200 status code, it will be retried until it succeeds
- If an API request is abruptly terminated, e.g. due to a change-listener process being restarted, the change-listener will eventually be considered stalled and will be retried.
- on_change docs are not design docs, therefore the id of an on_change doc can be anything that doesn't begin with
_design/
{
_id: 'spiegel_cl_<db-name>', // Namespaced to prevent conflict with replicators and
// reserved ids like '_users'
type: 'change_listener',
db_name: '<db-name>',
dirty: '<true>|<false>',
updated_at: '<ISO-timestamp>',
locked_at: '<ISO-timestamp>|<null>',
last_seq: '<last-seq>' // Used to keep track of the current place in the _changes feed for
// the target DB
}
{
_id: '<global-name>',
type: 'global',
value: '<value>'
}
- Listen to
_global_changes/_changes?since=lastSeq&limit=batchSize
, filtered by thesieve
view. Creates or dirties any replicators that match the DB name. Creates or dirties any change_listeners that match the DB name. - Every
saveSeqAfterSeconds
the process saves thelastSeq
global. This value is then used to start any new Update Listeners at the last sequence number so that they don't have to start from the beginning.
- Lock the next dirty change_listener
- Listen to a batch of
_changes
for the db_name specified in the change_listener - For all matching on_changes, issue API requests. If
block=true|undefined
then wait for response before moving on to next change. Ifdebounce=true
then use Debouncer with respect to API URL and params so that back-to-back duplicate requests are only executed once. - Update
change_listener.last_seq
so that subsequent processing can resume from where we left off - Attempt to clean and unlock change_listener. If there is a conflict, e.g. because the change_listener was dirtied from another change, just unlock the change_listener and leave it dirty so that the change_listener can be reprocessed.
- Every
checkStalledSeconds
check for any change_listeners that have stalled for at leastretryAfterSeconds
, i.e. are still locked and then unlock these change_listeners. A change_listener can stall in certain error cases, including when a process locks the change_listener and then restarts/crashes before unlocking the change_listener.
- Lock the next dirty replicator
- Perform the replication by making a request to CouchDB's
_replicate
API - Attempt to clean and unlock replicator. If there is a conflict, e.g. because the replicator was dirtied when a change occurred since the replication, just unlock the replicator and leave it dirty so that the replicator can be reprocessed.
- Every
checkStalledSeconds
check for any replicators that have stalled for at leastretryAfterSeconds
, i.e. are still locked and then unlock these replicators. A replicator can stall in certain error cases, including when a process locks the replicator and then restarts/crashes before unlocking the replicator.
The passwords config specifies the passwords so that they don't have to be stored in the on_change and replicator docs.
{
'<host>': {
'<username1>': '<password1>',
'<username2>': '<password2>'
// ...
},
'<host2>': {
'<username3>': '<password3>'
// ...
}
// ...
}