dvv-0.1.2.1: src/Data/DVV.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DeriveTraversable #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE ScopedTypeVariables #-}
{- |
Module : Data.DVV
Description : Dotted Version Vectors for causal tracking and conflict detection
Copyright : (c) James R. Thompson
License : BSD3
Stability : experimental
Dotted Version Vectors (DVVs) provide a mechanism for tracking causality
and detecting conflicts in distributed systems. This implementation supports
efficient synchronization, event recording, and conflict resolution.
-}
module Data.DVV (
-- * Core Types
Count,
Dot (..),
VersionVector,
mkVersionVector,
getVersionVectorCounts,
DVV (..),
-- * Operations
sync,
context,
event,
values,
prune,
reconcile,
lww,
-- * Analysis
size,
extractComponents,
)
where
import Algebra.PartialOrd (PartialOrd (..))
import Data.Function (on)
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as Map
import Data.Hashable (Hashable (..))
import Data.List (foldl', foldl1', sort, sortBy)
import Data.Maybe (fromMaybe)
import Data.Word (Word64)
import GHC.Generics (Generic)
-- | A logical actor's count, 1-indexed, positive only.
type Count = Word64
-- | A Dot is a pair of a replica ID and a logical counter.
data Dot actorID = Dot !actorID !Count
deriving stock (Eq, Show, Functor, Generic)
{- | Dots are partially ordered: two dots are comparable if and only if they
share the same actor ID, in which case they are ordered by their counter.
-}
instance (Eq actorID) => PartialOrd (Dot actorID) where
leq (Dot a1 c1) (Dot a2 c2) = a1 == a2 && c1 <= c2
comparable (Dot a1 _) (Dot a2 _) = a1 == a2
{- | Dot is a pair of an actor (identifier) and a logical counter,
but we hash using the actor value only.
-}
instance (Hashable actorID) => Hashable (Dot actorID) where
hashWithSalt salt (Dot actorID _) = hashWithSalt salt actorID
{- | A Version Vector is a mapping from actor IDs to their latest known counter.
It stores a list of counts to actors for efficient leq checks.
Requires keys to be Hashable.
-}
data VersionVector actorID = VersionVector
{ vvMap :: HashMap actorID Count
, vvDesc :: [(Count, actorID)]
}
deriving stock (Show, Generic)
instance (Eq actorID) => Eq (VersionVector actorID) where
(VersionVector m1 _) == (VersionVector m2 _) = m1 == m2
instance (Hashable actorID, Eq actorID) => PartialOrd (VersionVector actorID) where
leq (VersionVector _ sortedList) (VersionVector m2 _) =
all (\(c, a) -> c <= Map.findWithDefault 0 a m2) sortedList
-- | Helper to construct valid VersionVector
mkVersionVector :: HashMap actorID Count -> VersionVector actorID
mkVersionVector m =
VersionVector
m
( sortDesc fst $
map (\(a, c) -> (c, a)) (Map.toList m)
)
where
sortDesc keyFn = sortBy (\x y -> compare (keyFn y) (keyFn x))
-- | Helper to extract the map of counts from a VersionVector, we hide the other field accessors
getVersionVectorCounts :: VersionVector actorID -> HashMap actorID Count
getVersionVectorCounts (VersionVector m _) = m
{- | A Dotted Version Vector (DVV) consisting of a Causal History (Version Vector)
and a set of concurrent values associated with Dots.
Note: A Singleton has no causal history, its counter is implicitly 1.
@actorID@ is the type of actor IDs. Must be Hashable.
@value@ is the type of the value stored.
-}
data DVV actorID value
= EmptyDVV
| SingletonDVV !actorID !value
| DVV !(VersionVector actorID) !(HashMap (Dot actorID) value)
deriving stock (Generic)
instance (Hashable actorID, Eq value) => Eq (DVV actorID value) where
EmptyDVV == EmptyDVV = True
SingletonDVV a1 v1 == SingletonDVV a2 v2 = a1 == a2 && v1 == v2
DVV vv1 dots1 == DVV vv2 dots2 = vv1 == vv2 && dots1 == dots2
l == SingletonDVV a2 v2 = l == event EmptyDVV Nothing a2 v2
SingletonDVV a1 v1 == r = event EmptyDVV Nothing a1 v1 == r
_ == _ = False
instance
(Show actorID, Show value, Ord actorID, Hashable actorID) =>
Show (DVV actorID value)
where
show dvv =
case dvv of
EmptyDVV -> "([],[])"
_ ->
let (_, dots) = extractComponents dvv
ctx = getVersionVectorCounts (context dvv)
allActors = sort (Map.keys ctx)
dotsList = Map.toList dots
groupedValues a =
map snd $
sortBy (flip compare `on` (\(Dot _ c, _) -> c)) $
filter (\(Dot a' _, _) -> a' == a) dotsList
formattedEntries = [(a, Map.findWithDefault 0 a ctx, groupedValues a) | a <- allActors]
in show (formattedEntries, [] :: [value])
isSeenBy ::
(Ord actorID, Hashable actorID) =>
Dot actorID ->
DVV actorID value ->
Bool
isSeenBy (Dot i n) dvv =
case dvv of
EmptyDVV -> False
SingletonDVV a _ ->
-- A dot is seen by a singleton if it's the same dot (or 0)
Dot i n == Dot a 1
DVV vv dots ->
-- 1. Check if the counter is within the compacted summary
n <= Map.findWithDefault 0 i (vvMap vv)
||
-- 2. OR check if this specific dot is in the active set
Map.member (Dot i n) dots
-- | DVV partial order is defined by the partial order of their causal histories.
instance (Hashable actorID, Eq value, Ord actorID) => PartialOrd (DVV actorID value) where
leq EmptyDVV _ = True
leq _ EmptyDVV = False
leq (SingletonDVV i1 _) d2 = Dot i1 1 `isSeenBy` d2 -- counters start at 1
leq (DVV vv1 dots1) d2 =
let vv2 = case d2 of
DVV v _ -> v
_ -> mkVersionVector Map.empty -- EmptyDVV and SingletonDVV have no causal history
in leq vv1 vv2 && all (`isSeenBy` d2) (Map.keys dots1)
instance (Hashable actorID, Ord value, Ord actorID) => Ord (DVV actorID value) where
compare a b
| a == b = EQ
| leq a b = LT
| leq b a = GT
| otherwise =
let (vv1, _) = extractComponents a
(vv2, _) = extractComponents b
in if not (leq vv1 vv2)
then LT
else GT
-- | Extract all values currently in the DVV.
values :: DVV actorID value -> [value]
values EmptyDVV = []
values (SingletonDVV _ v) = [v]
values (DVV _ vals) = Map.elems vals
-- | Returns the causal context (Version Vector) of the DVV.
context ::
(Hashable actorID) =>
DVV actorID value ->
VersionVector actorID
context EmptyDVV = mkVersionVector Map.empty
context (SingletonDVV actor _) = mkVersionVector (Map.singleton actor 1)
context (DVV causalHistory vals) =
let finalMap = foldl' updateVec (vvMap causalHistory) (Map.keys vals)
in mkVersionVector finalMap
where
updateVec m (Dot actor counter) = Map.insertWith max actor counter m
-- | Safely extracts the components of any DVV state into a standard history map and values map.
extractComponents ::
(Hashable actorID) =>
DVV actorID value ->
(VersionVector actorID, HashMap (Dot actorID) value)
extractComponents EmptyDVV = (mkVersionVector Map.empty, Map.empty)
extractComponents (SingletonDVV actor val) = (mkVersionVector Map.empty, Map.singleton (Dot actor 1) val)
extractComponents (DVV vec vals) = (vec, vals)
-- | Synchronize (merge) two DVVs.
sync ::
(Hashable actorID, Ord value) =>
DVV actorID value ->
DVV actorID value ->
DVV actorID value
sync EmptyDVV d = d
sync d EmptyDVV = d
sync d1 d2 =
let (vL, valsL) = extractComponents d1
(vR, valsR) = extractComponents d2
in merge vL valsL vR valsR
-- | Internal merge logic for general DVV components.
merge ::
(Hashable actorID, Ord value) =>
VersionVector actorID ->
HashMap (Dot actorID) value ->
VersionVector actorID ->
HashMap (Dot actorID) value ->
DVV actorID value
merge vL valsL vR valsR =
let newVec = Map.unionWith max (vvMap vL) (vvMap vR)
-- Use unionWith to handle duplicate dots consistently (pick min for determinism)
candidates = Map.unionWith min valsL valsR
isActive (Dot actor counter) _ =
case Map.lookup actor newVec of
Nothing -> True
Just maxC -> counter > maxC
newVals = Map.filterWithKey isActive candidates
in case Map.toList newVals of
[] -> EmptyDVV
[(Dot actor 1, val)] | Map.null newVec -> SingletonDVV actor val
_ -> DVV (mkVersionVector newVec) newVals
-- | Record a new event (update).
event ::
(Hashable actorID) =>
-- | The current local DVV state
DVV actorID value ->
-- | The Context provided by the client (optional)
Maybe (VersionVector actorID) ->
-- | The Actor generating this event
actorID ->
-- | The new value
value ->
DVV actorID value
event currentState maybeContext actorID newValue =
let ctx = fromMaybe (mkVersionVector Map.empty) maybeContext
(causalHistory, vals) = extractComponents currentState
currentMax = Map.lookup actorID (vvMap (context currentState))
nextCount = maybe 1 (+ 1) currentMax
newDot = Dot actorID nextCount
filterOld (Dot i c) _ =
case Map.lookup i (vvMap ctx) of
Nothing -> True
Just cnt -> c > cnt
filteredVals = Map.filterWithKey filterOld vals
newVec = Map.unionWith max (vvMap causalHistory) (vvMap ctx)
finalVals = Map.insert newDot newValue filteredVals
in DVV (mkVersionVector newVec) finalVals
-- | Prune the causal history (Version Vector).
prune ::
-- | Threshold count for pruning
Count ->
-- | DVV to prune
DVV actorID value ->
DVV actorID value
prune _ EmptyDVV = EmptyDVV
prune _ s@(SingletonDVV _ _) = s
prune threshold (DVV causalHistory vals) =
let newVec = Map.filter (>= threshold) (vvMap causalHistory)
in DVV (mkVersionVector newVec) vals
-- | Reconcile multiple siblings into a single value using a merge strategy.
reconcile ::
(Hashable actorID) =>
-- | Merge function, take two values and deduce a new value (n.b. it's fine to just pick one of the inputs)
(value -> value -> value) ->
-- | The actor ID that will "own" the new reconciled event.
actorID ->
-- | The DVV to reconcile.
DVV actorID value ->
DVV actorID value
reconcile _ _ EmptyDVV = EmptyDVV
reconcile _ _ s@(SingletonDVV _ _) = s
reconcile mergeFn actorID dvv
| size dvv <= 1 = dvv
| otherwise =
let allVals = values dvv
-- Foldl1' is safe because of guard size > 1 implies >= 2 elements (at least 2, actually size 0/1 handled)
-- But size counts elements. Empty=0, Singleton=1, DVV may have 0 in map?
-- DVV constructor usually ensures non-empty map if used correctly, or use EmptyDVV.
-- But let's follow existing logic.
mergedVal = foldl1' mergeFn allVals
ctx = context dvv
in event dvv (Just ctx) actorID mergedVal
{- | Last-Write-Wins (LWW) reconciliation.
Reduces multiple concurrent values (siblings) into a single "winning" value based on the provided
comparison function. This operation creates a new event (dot) for the winning value that
supersedes all current values in the DVV.
If there are zero or one values, the DVV is returned unchanged.
-}
lww ::
(Hashable actorID) =>
-- | A function to compare two values. 'GT' indicates the first value wins.
(value -> value -> Ordering) ->
-- | The actor ID that will "own" the new reconciled event.
actorID ->
-- | The DVV to reconcile.
DVV actorID value ->
DVV actorID value
lww compareFn = reconcile pickBest
where
pickBest v1 v2 = case compareFn v1 v2 of
GT -> v1
_ -> v2
-- | Return the number of elements (siblings) in the DVV.
size :: DVV actorID value -> Int
size EmptyDVV = 0
size (SingletonDVV _ _) = 1
size (DVV _ vals) = Map.size vals