PNCounter
A counter that can go up or down. Two GCounters under the hood — one for increments, one for decrements. The current value is the difference.
Converges to: inc.value - dec.value, where each half is a GCounter that only ever grows.
Worked example — live vote tally over two peers
PNCounter + Quilter is the natural fit for a vote tally: each peer owns its own slot, increments record upvotes, decrements record downvotes. Deltas propagate automatically; both replicas converge to the same net count.
/**
* Live vote tally over two peers using [PNCounter] + [Quilter].
*
* Each peer owns its own [ReplicaId] slot: increments record upvotes, decrements
* record downvotes. [Quilter] broadcasts deltas automatically; both replicas
* converge to the same net count regardless of which peer applied which vote.
*
* Alias for the `upvotes and downvotes from two peers converge to the correct net tally`
* test in `VoteTallyTest` — the backtick name can't be an `include-symbol` target.
*/
@Suppress("unused")
internal fun sampleVoteTally() = runTest(StandardTestDispatcher(), timeout = 5.seconds) {
val loom = InMemoryLoom()
val seamAlice = loom.host(Pattern("vote-tally"))
val seamBob = loom.join(InMemoryTag("bob"))
val cfg = QuilterConfig(expectVirtualTime = true)
val aliceTally = Quilter(seamAlice, PNCounter.ZERO, PNCounter.serializer(), backgroundScope, config = cfg)
val bobTally = Quilter(seamBob, PNCounter.ZERO, PNCounter.serializer(), backgroundScope, config = cfg)
kotlinx.coroutines.delay(1) // let collectors subscribe under StandardTestDispatcher
// Alice casts 3 upvotes for the post.
aliceTally.mutate { it.increment(aliceTally.replica, 3L) }
// Bob casts 1 upvote and then 1 downvote (changed his mind).
bobTally.mutate { it.increment(bobTally.replica, 1L) }
bobTally.mutate { it.decrement(bobTally.replica, 1L) }
// Alice adds another upvote concurrently.
aliceTally.mutate { it.increment(aliceTally.replica, 2L) }
kotlinx.coroutines.delay(10) // advance virtual time so all delta broadcasts deliver
// Both replicas converge to the same net tally: alice +3+2=5, bob +1−1=0 → total 5.
assertEquals(5L, aliceTally.state.value.value)
assertEquals(aliceTally.state.value.value, bobTally.state.value.value)
}
See the full test at VoteTallyTest.kt.
Merge rule
A PNCounter is two independent GCounters in a product lattice — one for increments (inc), one for decrements (dec). Joining two PNCounters joins each half separately. Idempotent, commutative, associative by the same argument that holds for GCounter.
value = inc.value - dec.value
There is no floor at zero. A replica can decrement without having incremented — value can go negative.
Code examples
Increment:
@Test
fun incrementRaisesValue() {
val pn = PNCounter.ZERO
val next = pn.piece(pn.increment(a, 3L))
assertEquals(3L, next.value)
}
Decrement:
@Test
fun decrementLowersValue() {
val pn = PNCounter.ZERO.piece(PNCounter.ZERO.increment(a, 5L))
val next = pn.piece(pn.decrement(a, 2L))
assertEquals(3L, next.value)
}
Concurrent increments from different replicas merge:
@Test
fun concurrentIncAndDecFromDifferentReplicasMerge() {
val zero = PNCounter.ZERO
val aInc = zero.piece(zero.increment(a, 10L))
val bDec = zero.piece(zero.decrement(b, 3L))
// Both sides merge; value = 10 - 3 = 7
val merged = aInc.piece(bDec)
assertEquals(7L, merged.value)
}
Value can go negative:
@Test
fun valueCanGoNegative() {
// The dec GCounter is independent — value = inc - dec, no floor at zero.
val pn = PNCounter.ZERO.piece(PNCounter.ZERO.decrement(a, 5L))
assertEquals(-5L, pn.value)
}
When to use
Need | Use |
|---|
Concurrent add/remove of an integer, any sign | PNCounter
|
Shared budget that must never go negative | BoundedCounter
|
Only ever counts up | GCounter
|
Last modified: 22 June 2026