| Safe Haskell | Safe-Inferred |
|---|---|
| Language | GHC2021 |
Hydra.Network.Etcd
Contents
Description
Implements a Hydra network component using etcd.
While implementing a basic broadcast protocol over a distributed key-value store is quite an overkill, the Raft consensus of etcd provides our application with a crash-recovery fault-tolerant "atomic broadcast" out-of-the-box. As a nice side-effect, the network layer becomes very introspectable, while it would also support features like TLS or service discovery.
The component installs, starts and configures an $sel:etcd:EtcdLog instance and connects
to it using a GRPC client. We can only write and read from the cluster while
connected to the majority cluster.
Broadcasting is implemented using put to some well-known key, while message
delivery is done by using watch on the same msg prefix. We keep a last known
revision, also stored on disk, to start watch with that revision (+1) and
only deliver messages that were not seen before. In case we are not connected
to our $sel:etcd:EtcdLog instance or not enough peers (= on a minority cluster), we
retry sending, but also store messages to broadcast in a PersistentQueue,
which makes the node resilient against crashes while sending.
Connectivity and compatibility with other nodes on the cluster is tracked using the key-value service as well:
- network connectivity is determined by being able to fetch the member list
- peer connectivity is tracked (best effort, not authorized) using an entry at 'alive-<advertise>' keys with individual leases and repeated keep-alives
- each node compare-and-swaps its
$sel:version:EtcdLoginto a key of the same name to check compatibility (not updatable)
Note that the etcd cluster is configured to compact revisions down to 1000 every 5 minutes. This prevents infinite growth of the key-value store, but also limits how long a node can be disconnected without missing out. 1000 should be more than enough for our use-case as the Hydra protocol will not advance unless all participants are present.
Synopsis
- withEtcdNetwork :: (ToCBOR msg, FromCBOR msg) => Tracer IO EtcdLog -> ProtocolVersion -> NetworkConfiguration -> NetworkComponent IO msg msg ()
- connParams :: Tracer IO EtcdLog -> Maybe Timeout -> ConnParams
- grpcServer :: NetworkConfiguration -> Server
- getClientPort :: NetworkConfiguration -> PortNumber
- peerPortToClientPort :: PortNumber -> PortNumber
- checkVersion :: Tracer IO EtcdLog -> Connection -> ProtocolVersion -> NetworkCallback msg IO -> IO ()
- broadcastMessages :: Tracer IO EtcdLog -> NetworkConfiguration -> Host -> PersistentQueue IO msg -> IO ()
- queryInitialModRev :: Tracer IO EtcdLog -> NetworkConfiguration -> Host -> IO Int64
- putMessage :: Tracer IO EtcdLog -> Connection -> Host -> TVar IO Int64 -> ByteString -> IO ()
- batchValue :: [ByteString] -> ByteString
- waitMessages :: forall msg. FromCBOR msg => Tracer IO EtcdLog -> Connection -> FilePath -> NetworkCallback msg IO -> IO ()
- data LastKnownRevisionException
- getLastKnownRevision :: MonadIO m => FilePath -> m Natural
- putLastKnownRevision :: MonadIO m => FilePath -> Natural -> m ()
- pollConnectivity :: Tracer IO EtcdLog -> Connection -> Host -> NetworkCallback msg IO -> IO ()
- isTransientGrpcError :: GrpcError -> Bool
- retryableEtcdError :: SomeException -> Maybe Text
- withGrpcContext :: MonadCatch m => Text -> m a -> m a
- withProcessInterrupt :: (MonadIO m, MonadThrow m) => ProcessConfig stdin stdout stderr -> (Process stdin stdout stderr -> m a) -> m a
- data PersistentQueue m a = PersistentQueue {}
- newPersistentQueue :: (MonadLabelledSTM m, MonadIO m, FromCBOR a, MonadCatch m, MonadFail m) => Tracer IO EtcdLog -> FilePath -> Natural -> m (PersistentQueue m a)
- writePersistentQueue :: (ToCBOR a, MonadSTM m, MonadIO m) => Tracer IO EtcdLog -> PersistentQueue m a -> a -> m ()
- peekPersistentQueue :: MonadSTM m => PersistentQueue m a -> m a
- tryPeekPersistentQueue :: MonadSTM m => PersistentQueue m a -> m (Maybe a)
- peekBatchPersistentQueue :: MonadSTM m => PersistentQueue m a -> Int -> Int -> m [(a, ByteString)]
- nextPendingBatch :: MonadSTM m => TVar m (Maybe [(a, ByteString)]) -> PersistentQueue m a -> Int -> Int -> m (Maybe [(a, ByteString)])
- popBatchPersistentQueue :: (MonadSTM m, MonadIO m) => Tracer IO EtcdLog -> PersistentQueue m a -> [(a, ByteString)] -> m ()
- removeQueueFile :: MonadIO m => Tracer IO EtcdLog -> FilePath -> Natural -> m ()
- data EtcdLog
- = EtcdLog {
- etcd :: Value
- | Reconnecting
- | BroadcastFailed {
- reason :: Text
- | FailedToDecodeLog { }
- | FailedToDecodeValue { }
- | CreatedLease { }
- | LowLeaseTTL { }
- | NoKeepAliveResponse
- | MatchingProtocolVersion { }
- | WatchMessagesStartRevision { }
- | WatchMessagesFallbackTo { }
- | WatchFailed {
- reason :: Text
- | BroadcastDeduped { }
- | PersistentQueueLoadFailed {
- reason :: Text
- | PersistentQueueFull
- | PersistentQueueDeleteFailed { }
- = EtcdLog {
Documentation
withEtcdNetwork :: (ToCBOR msg, FromCBOR msg) => Tracer IO EtcdLog -> ProtocolVersion -> NetworkConfiguration -> NetworkComponent IO msg msg () Source #
Concrete network component that broadcasts messages to an etcd cluster and listens for incoming messages.
grpcServer :: NetworkConfiguration -> Server Source #
getClientPort :: NetworkConfiguration -> PortNumber Source #
Get the client port corresponding to a listen address.
The client port used by the started etcd port is offset by the same amount as the listen address is offset by the default port 5001. This will result in the default client port 2379 be used by default still.
peerPortToClientPort :: PortNumber -> PortNumber Source #
Derive the etcd client port from a configured peer (listen) port.
Exposed separately from getClientPort so test fixtures can pre-allocate
both the peer and the derived client port without constructing a full
NetworkConfiguration. Keep this and getClientPort in lockstep — any
change to the offset must happen here, in one place.
checkVersion :: Tracer IO EtcdLog -> Connection -> ProtocolVersion -> NetworkCallback msg IO -> IO () Source #
Check and write version on etcd cluster. This will retry until we are on a
majority cluster and succeed. If the version does not match a corresponding
Connectivity message is sent via NetworkCallback.
Arguments
| :: Tracer IO EtcdLog | |
| -> NetworkConfiguration | |
| -> Host | Used to identify sender. |
| -> PersistentQueue IO msg | |
| -> IO () |
Broadcast messages from a queue to the etcd cluster.
Retries on failure to putMessage in case we are on a minority cluster or
when the grpc call timeouts.
Idempotent under transient GrpcDeadlineExceeded: putMessage uses an
etcd transaction conditioned on the key's current mod_revision matching
the last revision we successfully wrote. If a deadline-exceeded retry
arrives at etcd after the original request already committed, the compare
fails server-side (the mod_revision has advanced), the failure branch's
range tells us what the new revision is, and we move on without writing a
second time. So a retry whose original committed creates zero extra etcd
revisions and the watcher on each peer sees exactly one event per logical
broadcast. Same key namespace as master ('msg-<host>'), no disk growth.
queryInitialModRev :: Tracer IO EtcdLog -> NetworkConfiguration -> Host -> IO Int64 Source #
Query etcd for the current mod_revision of this peer's broadcast key.
Returns 0 if the key does not yet exist. Used by broadcastMessages to
seed its in-memory baseline so subsequent compare mod_revision checks
match etcd's reality from the very first putMessage.
Arguments
| :: Tracer IO EtcdLog | |
| -> Connection | Connection provided (and recycled) by |
| -> Host | Used to identify sender. |
| -> TVar IO Int64 | The peer's last observed |
| -> ByteString | Value to write, a batch of serialized messages (see |
| -> IO () |
Broadcast a message to the etcd cluster.
Wraps the etcd put in a Txn:
- compare:
mod_revision(key) == lastModRev - success:
put(key, value) - failure:
range(key)(so we can learn the actualmod_revisionif our compare failed, e.g. because a previous attempt of ours committed server-side after returningGrpcDeadlineExceededto the client)
lastModRevVar is updated in both branches: from the response header on
a successful put, from the range result on a compare failure. Retries of
the same msg after a deadline-exceeded converge to a single effective
revision rather than producing duplicate deliveries. Because
broadcastMessages seeds lastModRev from etcd at startup
(queryInitialModRev), a compare failure unambiguously means "this
peer's earlier attempt already wrote a later revision than we have
recorded" — i.e. the message is already delivered and the caller can pop.
Cluster-reset behaviour (intentionally fatal): if the compare fails
and the range branch returns no kvs, the etcd cluster has lost the
key we wrote against — either the data dir was wiped or the cluster
was replaced underneath us. We do not silently reseed: this is a
node-level event that should surface, not be papered over. putMessage
calls fail, which kills the broadcast loop, propagates up to take
down the node, and on restart queryInitialModRev re-seeds
lastModRev from whatever state etcd actually has.
batchValue :: [ByteString] -> ByteString Source #
Assemble the etcd value for a batch of already-serialized messages: a
CBOR list reusing each message's encoding verbatim. Uses the same
indefinite-length list format as cardano-binary's list instances, so the
value decodes as [msg] on the receiving side.
waitMessages :: forall msg. FromCBOR msg => Tracer IO EtcdLog -> Connection -> FilePath -> NetworkCallback msg IO -> IO () Source #
Fetch and wait for messages from the etcd cluster.
data LastKnownRevisionException Source #
The persisted watch revision could not be read.
Fatal on purpose, and deliberately not a retryableEtcdError: re-reading the
same bad file cannot succeed, and carrying on from revision 0 would rewind the
watch to the beginning of history. Deleting the file is the fix, so say so.
Constructors
| InvalidLastKnownRevision FilePath | The file exists but does not hold a revision. |
| UnreadableLastKnownRevision FilePath String | The file exists but could not be read. |
Instances
| Exception LastKnownRevisionException Source # | |
Defined in Hydra.Network.Etcd | |
| Show LastKnownRevisionException Source # | |
Defined in Hydra.Network.Etcd | |
| Eq LastKnownRevisionException Source # | |
Defined in Hydra.Network.Etcd Methods (==) :: LastKnownRevisionException -> LastKnownRevisionException -> Bool Source # (/=) :: LastKnownRevisionException -> LastKnownRevisionException -> Bool Source # | |
putLastKnownRevision :: MonadIO m => FilePath -> Natural -> m () Source #
Record the revision, atomically.
NOTE: Written to a temporary file and renamed into place, because a plain
encodeFile is not atomic: killing the node mid-write (which happens
routinely, both in tests and on restart) would otherwise leave a truncated
file that getLastKnownRevision cannot parse.
NOTE: The rename is not fsynced, so an OS crash can still land it ahead of the
data blocks. Closing that window costs an fsync per call, and this runs once
per watch response, i.e. per received broadcast. The resulting file is instead
handled on the next start, by LastKnownRevisionException and its hint.
Write a well-known key to indicate being alive, keep it alive using a lease and poll other peers entries to yield connectivity events. While doing so, overall network connectivity is determined from the ability to read/write to the cluster.
isTransientGrpcError :: GrpcError -> Bool Source #
Predicate for gRPC errors that we treat as transient — connection blips or etcd-side disruption from which a retry is expected to recover. Anything outside this set is escalated by re-throwing.
GrpcNotFound is included specifically for lease loss: when etcd's RAFT
leader changes under network stress, in-flight leases are revoked, and the
next operation referencing one ('pollConnectivity.writeAlive') comes back
with NOT_FOUND. The recovery is to mark the network as disconnected and let
the outer loop recreate the lease, not crash the node.
retryableEtcdError :: SomeException -> Maybe Text Source #
Classify a failure talking to our local etcd, giving a reason to trace when retrying is the right response.
Connection-level failures qualify: etcd is a subprocess we started and
connParams reconnects, so a lost connection is a blip rather than a reason
to take the node down. Those arrive either as HTTP2Error (straight from the
http2 client, e.g. the SETTINGS rate limit of #2817) or ServerDisconnected
(grapesy's wrapper when a call outlives its connection). Everything else
escalates, including putMessages cluster-reset fail and
LastKnownRevisionException.
withGrpcContext :: MonadCatch m => Text -> m a -> m a Source #
Add context to the grpcErrorMessage of any GrpcException raised.
withProcessInterrupt :: (MonadIO m, MonadThrow m) => ProcessConfig stdin stdout stderr -> (Process stdin stdout stderr -> m a) -> m a Source #
Like withProcessTerm, but sends first SIGINT and only SIGTERM if not
stopped within 5 seconds.
Persistent queue
data PersistentQueue m a Source #
Queue elements carry the item's CBOR serialization, produced once on write and reused for both the on-disk file and the etcd value, so broadcasting does not serialize twice.
Constructors
| PersistentQueue | |
newPersistentQueue :: (MonadLabelledSTM m, MonadIO m, FromCBOR a, MonadCatch m, MonadFail m) => Tracer IO EtcdLog -> FilePath -> Natural -> m (PersistentQueue m a) Source #
Create a new persistent queue at file path and given capacity.
writePersistentQueue :: (ToCBOR a, MonadSTM m, MonadIO m) => Tracer IO EtcdLog -> PersistentQueue m a -> a -> m () Source #
Write a value to the queue, blocking if the queue is full.
peekPersistentQueue :: MonadSTM m => PersistentQueue m a -> m a Source #
Get the next value from the queue without removing it, blocking if the queue is empty.
tryPeekPersistentQueue :: MonadSTM m => PersistentQueue m a -> m (Maybe a) Source #
Like peekPersistentQueue, but returns Nothing instead of blocking
when the queue is empty.
peekBatchPersistentQueue :: MonadSTM m => PersistentQueue m a -> Int -> Int -> m [(a, ByteString)] Source #
Get all pending values and their serializations, up to the given count
and total byte limits, blocking until at least one is available. Values
are not removed; use popBatchPersistentQueue after they were sent.
nextPendingBatch :: MonadSTM m => TVar m (Maybe [(a, ByteString)]) -> PersistentQueue m a -> Int -> Int -> m (Maybe [(a, ByteString)]) Source #
Get the batch to broadcast next: the batch already in flight if there is
one, otherwise a fresh one peeked from the queue (and recorded as in
flight). Returns Nothing when nothing is pending. The caller must clear
the in-flight var after popping a successfully sent batch.
Pinning the in-flight batch across transient retries matters for the
compare-fail dedup in putMessage: a put can commit server-side while the
client sees e.g. GrpcDeadlineExceeded. The retry must send (and
afterwards pop) exactly the content of the committed attempt. Re-peeking
on retry could pick up messages enqueued in the meantime; the dedup branch
would then declare the grown batch delivered and the never-sent tail would
be popped and lost.
popBatchPersistentQueue :: (MonadSTM m, MonadIO m) => Tracer IO EtcdLog -> PersistentQueue m a -> [(a, ByteString)] -> m () Source #
Remove a batch previously returned by peekBatchPersistentQueue. Pops
unconditionally, one item per batch entry: this thread is the sole
consumer, so the queue head still holds exactly the peeked items (an
item-matching guard could only silently no-op and wedge the queue, see
#2742).
removeQueueFile :: MonadIO m => Tracer IO EtcdLog -> FilePath -> Natural -> m () Source #
Delete the backing file of a popped queue item. Failing to delete is traced but not fatal: the message was already broadcast, so a leftover file only means it may be re-broadcast after a restart (at-least-once delivery, same as the crash-recovery path).
Tracing
Constructors
| EtcdLog | |
Fields
| |
| Reconnecting | |
| BroadcastFailed | |
Fields
| |
| FailedToDecodeLog | |
| FailedToDecodeValue | |
| CreatedLease | |
| LowLeaseTTL | |
Fields | |
| NoKeepAliveResponse | |
| MatchingProtocolVersion | |
Fields | |
| WatchMessagesStartRevision | |
Fields | |
| WatchMessagesFallbackTo | |
Fields | |
| WatchFailed | The watch stream failed; it is restarted from the last known revision. |
Fields
| |
| BroadcastDeduped | The etcd transaction wrapping a broadcast |
Fields | |
| PersistentQueueLoadFailed | Failed to load persisted queue items from disk on startup. The queue starts empty; any in-flight messages from before the crash are lost. |
Fields
| |
| PersistentQueueFull | The persistent queue has reached capacity. The calling thread will block until the broadcast loop drains at least one item. |
| PersistentQueueDeleteFailed | Failed to delete the backing file of an already-broadcast item. The queue keeps operating; the leftover file only means the message may be re-broadcast after a restart. |