Deleting an instance
An instance is six stores
The event log is not the instance. Recovery reads the snapshot and replays later events.
lookup follows an alias. find reads index rows. The sweeper fires timeout rows. The
lock row is the lease other nodes wait on. Any one of those left behind is still the instance.
deleteEventsTo only shortens the log so a snapshot can stand in for the history it replaced.
Cluster leases are not per instance and are not part of delete.
Truncate is not delete
After deleteEventsTo through the snapshot sequence, constructing the runtime again still
returns Paid. fsm.delete removes the log and the snapshot. The id can be reused:
append with expectedSeqNr 0 is sequence 1, a new history.
{
enum OrderState derives Finite:
case Pending, Paid
enum OrderEvent derives Finite:
case Pay
import OrderState.*, OrderEvent.*
val machine = Machine(
assembly[OrderState, OrderEvent](
Pending via Pay to Paid
)
)
val orderId: OrderId = "order-delete-1"
ZIO.scoped {
for
events <- InMemoryEventStore.make[OrderId, OrderState, OrderEvent]()
index <- InMemoryInstanceIndex.make[OrderId]
layers = ZLayer.succeed[EventStore[OrderId, OrderState, OrderEvent]](events) ++
ZLayer.succeed[InstanceIndex[OrderId]](index) ++
TimeoutStrategy.fiber[OrderId] ++
LockingStrategy.optimistic[OrderId]
_ <- events.append(orderId, Pay, 0L)
_ <- events.saveSnapshot(FSMSnapshot(orderId, Paid, 1L, Instant.EPOCH))
_ <- events.deleteEventsTo(orderId, 1L)
truncated <- FSMRuntime.readState(orderId, machine, Pending).provide(layers)
_ <- ZIO
.scoped {
FSMRuntime(
orderId,
machine,
Pending,
AliasExtractor.none[OrderState],
IndexExtractor.none[OrderState],
).flatMap(_.delete)
}
.provide(layers)
deleted <- FSMRuntime.readState(orderId, machine, Pending).provide(layers)
restarted <- events.append(orderId, Pay, 0L)
yield (truncated.map(_.toString), deleted.isEmpty, restarted)
}.asDoc
}(Some(Paid),true,1)Do not hydrate in order to delete
FSMRuntime.delete[Id, S, E](id) wipes an id that is not open on this node. It does not
construct a runtime. Constructing one rebinds aliases, re-arms timeouts, and can write a
sequence-0 snapshot for a timed initial leaf.
InstanceIndex is required, even when this id has no aliases. Provide the index the app
writes to (InMemoryInstanceIndex, PostgresInstanceIndex, or IndexedDbInstanceIndex).
Leaving it out would compile a delete that keeps aliases and index rows.
The environment is EventStore, TimeoutStrategy, LockingStrategy, and InstanceIndex:
| Service | What delete does with it |
|---|---|
EventStore[Id, S, E] | Deletes the log and the snapshot together |
TimeoutStrategy[Id] | Purges timeouts. Store failures fail the delete. cancel on Goto stays best-effort |
InstanceIndex[Id] | unbindInstance: aliases and index rows |
LockingStrategy[Id] | Optimistic has no row. Distributed deletes the lock row for any holder |
S and E are the same pins as readState.
{
enum OrderState derives Finite:
case Pending, Paid
enum OrderEvent derives Finite:
case Pay
import OrderEvent.*
val orderId: OrderId = "order-delete-2"
val campaign = Alias("campaign", "camp-42")
(for
events <- InMemoryEventStore.make[OrderId, OrderState, OrderEvent]()
index <- InMemoryInstanceIndex.make[OrderId]
timeouts <- InMemoryTimeoutStore.make[OrderId]
lock <- InMemoryFSMInstanceLock.make[OrderId]
now <- Clock.instant
strategy <- DistributedLockingStrategy.make(lock)
_ <- events.append(orderId, Pay, 0L)
_ <- events.saveSnapshot(FSMSnapshot(orderId, OrderState.Paid, 1L, now))
_ <- index.bind(campaign, orderId)
_ <- index.bindIndexes(Chunk(IndexKey("assignee", "me")), orderId, IndexMeta("Paid", now, now, None))
_ <- timeouts.schedule(orderId, "tick", 1, 1L, now.plusMillis(50))
_ <- lock.tryAcquire(orderId, "other-node", 30.seconds, now)
_ <- FSMRuntime
.delete[OrderId, OrderState, OrderEvent](orderId)
.provide(
ZLayer.succeed[EventStore[OrderId, OrderState, OrderEvent]](events),
ZLayer.succeed[InstanceIndex[OrderId]](index),
ZLayer.succeed[TimeoutStrategy[OrderId]](DurableTimeoutStrategy.make(timeouts)),
ZLayer.succeed[LockingStrategy[OrderId]](strategy),
)
snap <- events.loadSnapshot(orderId)
alias <- index.resolve(campaign)
keys <- index.indexesOf(orderId)
timer <- timeouts.get(orderId)
held <- lock.get(orderId, now)
seq <- events.highestSequenceNr(orderId)
yield (snap.isEmpty, alias.isEmpty, keys.isEmpty, timer.isEmpty, held.isEmpty, seq)).asDoc
}(true,true,true,true,true,0)Two doors
| Call | When |
|---|---|
fsm.delete | You have the runtime. Stops it first and waits for in-flight .producing, so a later send cannot append. In-memory append ignores expectedSeqNr and would recreate the log if the runtime were still running. |
FSMRuntime.delete[Id, S, E](id) | Nothing is open on this node. Same wipe. Does not stop some other runtime object. If one is open, call delete on it. |
Wipe order: purge timeouts, unbind aliases and index rows, delete the log and snapshot, then
force-release the lock row (any holder, including an expired row). Each step succeeds when the
id is already gone, so retry finishes a crash between steps. This is not one transaction across
every table. Events and the snapshot commit together. Aliases and index rows commit together
inside unbindInstance. LockedFSMRuntime also drops the lock it holds outside the inner
strategy, which is often optimistic.
Delete does not take InstanceMailbox. The permit is one per id, and FSMRuntime.session
already holds it around the block, so fsm.delete there is same-node exclusive with the
sweeper. Next to a sweeper from outside a session:
mailbox.run(id)(FSMRuntime.delete[OrderId, OrderState, OrderEvent](id))
Delete does not wait out another node's withLock. That critical section can append once.
The lock row is still removed. Delete does not touch cluster leases.