Simplistic etcd replacement based on TinyRaft
Simplistic miniature etcd replacement based on TinyRaft
- Embeddable
- REST API only, gRPC is shit and will never be supported
- TinyRaft-based leader election
- Strict serializable transaction isolation,
confirmed by Jepsen tests
- Websocket-based cluster communication
- Supports a limited subset of etcd REST APIs
- With optional persistence
- CLI Usage
- CLI Client
- Options
- HTTP
- Persistence
- Clustering
- Embedded Usage
- About Persistence
- Supported etcd APIs
- /v3/kv/txn
- /v3/kv/put
- /v3/kv/range
- /v3/kv/deleterange
- /v3/lease/grant
- /v3/lease/keepalive
- /v3/lease/revoke or /v3/kv/lease/revoke
- /v3/maintenance/status
- Websocket-based watch APIs
- HTTP Error Codes
``
npm install antietcd
node_modules/.bin/antietcd \
[--cert ssl.crt] [--key ssl.key] [--port 12379] \
[--data data.gz] [--persist_interval 500] \
[--node_id node1 --cluster_key abcdef --cluster node1=http://localhost:12379,node2=http://localhost:12380,node3=http://localhost:12381]
[other options]
`
Antietcd doesn't background itself, so use systemd or start-stop-daemon to run it as a background service.
``
node_modules/.bin/anticli [OPTIONS] put
node_modules/.bin/anticli [OPTIONS] get
node_modules/.bin/anticli [OPTIONS] del
node_modules/.bin/anticli [OPTIONS] load [--with-lease] < dump.json
node_modules/.bin/anticli [OPTIONS] watch
For put, if is not specified, it will be read from STDIN.
Options:
- access: log all requests and incoming websocket messages.
- watch: log outgoing websocket watch messages.
-cluster: log clustering (TinyRaft) events.
`js
const AntiEtcd = require('antietcd');
// Configuration may contain all the same options like in CLI, without "--"
// Except that persist_filter should be a callback (key, value) => newValue
const srv = new AntiEtcd({ ...configuration });
// Start server
srv.start();
// Make a local API call in generic style:
let res = await srv.api('kv_txn'|'kv_range'|'kv_put'|'kv_deleterange'|'lease_grant'|'lease_revoke'|'lease_keepalive', { ...params });
// Or function-style:
res = await srv.txn(params);
res = await srv.range(params);
res = await srv.put(params);
res = await srv.deleterange(params);
res = await srv.lease_grant(params);
res = await srv.lease_revoke(params);
res = await srv.lease_keepalive(params);
// Error handling:
try
{
res = await srv.txn(params);
}
catch (e)
{
if (e instanceof AntiEtcd.RequestError)
{
// e.code is HTTP code
// e.message is error message
}
}
// Watch API:
const watch_id = await srv.create_watch(params, (message) => console.log(message));
await srv.cancel_watch(watch_id);
// Stop server
srv.stop();
`
Persistence is very simple: full database is dumped into JSON, gzipped and saved as file.
By default, it is written and fsynced on disk on every change, but it can be configured
to dump DB on disk at fixed intervals, for example, at most every 500 ms - of course,
at expense of slightly reduced crash resiliency (example: --persist_interval 500).
You can also specify a filter to exclude some data from persistence by using the option
--persist_filter ./filter.js. Persistence filter code example:
`js
function example_filter(cfg)
{
//
const prefix = cfg.exclude_keys;
if (!prefix)
{
return null;
}
return (key, value) =>
{
if (key.substr(0, prefix.length) == prefix)
{
// Skip all keys with prefix from persistence
return undefined;
}
if (key === '/statistics')
{
// Return
const decoded = JSON.parse(value);
return JSON.stringify({ ...decoded, unneeded_key: undefined });
}
return value;
};
}
module.exports = example_filter;
`
When --stale_read is enabled, Antietcd provides SERIALIZABLE transaction isolation level.
When it's disabled, the level is STRICT SERIALIZABLE.
Reconnected watchers don't receive all historical events individually, but they are guaranteed to receive
a consistent snapshot of keys in interest.
NOTE: key, value and range_end are always encoded in base64, like in original etcd.
Range requests are only supported across "directories" separated by /.
It means that in range requests key must always end with / and range_end must always0
end with , and that such request will return a whole subtree of keys.
Request:
`ts`
type TxnRequest = {
compare?: (
{ key: string, target: "MOD", mod_revision: number, result?: "LESS" }
| { key: string, target: "CREATE", create_revision: number, result?: "LESS" }
| { key: string, target: "VERSION", version: number, result?: "LESS" }
| { key: string, target: "LEASE", lease: string, result?: "LESS" }
| { key: string, target: "VALUE", value: string }
)[],
success?: (
{ request_put: PutRequest }
| { request_range: RangeRequest }
| { request_delete_range: DeleteRangeRequest }
)[],
failure?: (
{ request_put: PutRequest }
| { request_range: RangeRequest }
| { request_delete_range: DeleteRangeRequest }
)[],
serializable?: boolean,
}
serializable allows to serve read-only requests from follower even if stale_read is not enabled.
Response:
`ts`
type TxnResponse = {
header: { revision: number },
succeeded: boolean,
responses: (
{ response_put: PutResponse }
| { response_range: RangeResponse }
| { response_delete_range: DeleteRangeResponse }
)[],
}
Request:
`ts`
type PutRequest = {
key: string,
value: string,
lease?: string,
}
Other parameters are not supported: prev_kv, ignore_value, ignore_lease.
Response:
`ts`
type PutResponse = {
header: { revision: number },
}
Request:
`ts`
type RangeRequest = {
key: string,
range_end?: string,
keys_only?: boolean,
serializable?: boolean,
}
serializable allows to serve read-only requests from follower even if stale_read is not enabled.
Other parameters are not supported: revision, limit, sort_order, sort_target,
count_only, min_mod_revision, max_mod_revision, min_create_revision, max_create_revision.
Response:
`ts`
type RangeResponse = {
header: { revision: number },
kvs: { key: string }[] | {
key: string,
value: string,
lease?: string,
mod_revision: number,
}[],
}
Request:
`ts`
type DeleteRangeRequest = {
key: string,
range_end?: string,
}
Other parameters are not supported: prev_kv.
Response:
`ts`
type DeleteRangeResponse = {
header: { revision: number },
// number of deleted keys
deleted: number,
}
Request:
`ts`
type LeaseGrantRequest = {
ID?: string,
TTL: number,
}
Response:
`ts`
type LeaseGrantResponse = {
header: { revision: number },
ID: string,
TTL: number,
}
Request:
`ts`
type LeaseKeepaliveRequest = {
ID: string,
}
Response:
`ts`
type LeaseKeepaliveResponse = {
result: {
header: { revision: number },
ID: string,
TTL: number,
}
}
Request:
`ts`
type LeaseRevokeRequest = {
ID: string,
}
Response:
`ts`
type LeaseRevokeResponse = {
header: { revision: number },
}
Request:
`{}`
Response:
`ts`
type MaintenanceStatusResponse = {
header: {
member_id?: string,
revision: number,
compact_revision: number,
raft_term?: number,
},
version: string,
cluster?: { [string]: string },
leader?: string,
followers?: string[],
raftTerm?: string,
raftState?: 'leader'|'follower'|'candidate',
// dbSize actually reports process memory usage
dbSize: number,
}
Client-to-server message format:
`ts`
type ClientMessage =
{ create_request: {
key: string,
range_end?: string,
start_revision?: number,
watch_id?: string,
} }
| { cancel_request: {
watch_id: string,
} }
| { progress_request: {} }
Server-to-client message format:
`ts``
type ServerMessage = {
result: {
header: { revision: number },
watch_id: string,
created?: boolean,
canceled?: boolean,
compact_revision?: number,
events?: {
type: 'PUT'|'DELETE',
kv: {
key: string,
value: string,
lease?: string,
mod_revision: number,
},
}[],
}
} | { error: 'bad-json' } | { error: 'empty-message' }
- 400 for invalid requests
- 404 for unsupported API / URL not found
- 405 for non-POST request method
- 501 for unsupported API feature - non-directory range queries and so on
- 502 for server is stopping
- 503 for quorum-related errors - quorum not available and so on
Author: Vitaliy Filippov, 2024
License: Mozilla Public License 2.0
or Vitastor Network Public License 1.1