refactor(quartz): simplify EventStoreProjection (-50 lines, -1 bug)
Review of the projection found one real bug and four redundancies:
Bug fix: handleInsert tracked `retained` inline as it walked filters,
flipping it to false when the slot self-evicted from a filter's cap.
But if filter A retained the slot (cap=null or with room) and filter B
later cap-evicted it, retained ended up false even though the slot was
still in A's set — leaking a slot into a per-filter set with no
byId/byAddress entry. Fix: compute retention after the loop with
`perFilter.values.any { it.contains(slot) }`.
Simplifications:
- Drop the redundant `ordered: SortedSet<Slot>` field. It was only
read by publish(); it's the deduped sorted union of perFilter
values. Compute it lazily at publish time instead. Removes the
field, all the ordered.add/ordered.remove calls, and one helper.
- Collapse `dropSlotIndexes` and `removeSlot` paths into
`removeIndexes` (id+address) + `removeSlot` (also clears per-filter
sets). The eviction loop calls removeIndexes; everywhere else
calls removeSlot.
- Drop the defensive `byAddress[address] === slot` identity check —
with byId as the primary index, removeSlot is only called once per
slot, and rekey only ever moves byId, never byAddress.
- Pull the iterate-and-drop pattern into a single `dropWhere(predicate)`
inline helper. handleVanish, applyDeleteByFilter, and
applyDeleteExpired all collapse to one-liners.
- Apply now returns Boolean from each branch and publish() runs once
at the end, instead of three separate `if-publish` blocks.
Net: 233 → 142 lines. Same behaviour, plus the retained bug fix.
17/17 projection tests + all other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
This commit is contained in:
+91
-142
@@ -125,31 +125,18 @@ class EventStoreProjection<T : Event>(
|
||||
/** Slots keyed by the *current* event id. Re-keyed when a replaceable / addressable handle takes a new version. */
|
||||
private val byId = HashMap<HexKey, Slot<T>>()
|
||||
|
||||
/**
|
||||
* Slots keyed by replaceable / addressable address for in-place
|
||||
* updates and supersession lookups. Plain replaceables (kind 0/3,
|
||||
* 10000-19999) live under an [Address] with an empty `dTag`,
|
||||
* matching how the SQLite store keys its `addressable_idx` /
|
||||
* `replaceable_idx`.
|
||||
*/
|
||||
/** Slots keyed by replaceable / addressable [Address] for in-place updates and supersession lookups. */
|
||||
private val byAddress = HashMap<Address, Slot<T>>()
|
||||
|
||||
/**
|
||||
* Per-filter capped sets. Each filter independently retains at most
|
||||
* `filter.limit` matches; a slot is "live" (visible in [items])
|
||||
* iff it appears in at least one of these sets. Identity-keyed —
|
||||
* [Filter] is `@Stable` but not a data class.
|
||||
* `filter.limit` matches; a slot is "live" iff it appears in at
|
||||
* least one of these sets. Identity-keyed — [Filter] is `@Stable`
|
||||
* but not a data class.
|
||||
*/
|
||||
private val perFilter: Map<Filter, java.util.SortedSet<Slot<T>>> =
|
||||
filters.associateWith { sortedSetOf(slotComparator()) }
|
||||
|
||||
/**
|
||||
* Sorted union view of every "live" slot. Maintained alongside
|
||||
* [perFilter] — a slot is added the first time any filter retains
|
||||
* it and removed once no filter still holds it.
|
||||
*/
|
||||
private val ordered: java.util.SortedSet<Slot<T>> = sortedSetOf(slotComparator())
|
||||
|
||||
/** Set when the seed has been written to [items], so callers can suspend until the projection is hot. */
|
||||
val ready: CompletableDeferred<Unit> = CompletableDeferred()
|
||||
|
||||
@@ -157,9 +144,9 @@ class EventStoreProjection<T : Event>(
|
||||
scope.launch {
|
||||
// `onSubscription` runs after the SharedFlow subscription is
|
||||
// active but before we pull any events — the buffer absorbs
|
||||
// any emissions that arrive while we seed, and we then drain
|
||||
// them as the collect proceeds. Doing the seed inside
|
||||
// `collect { }` instead would race with concurrent inserts.
|
||||
// emissions arriving during seed and drains them once collect
|
||||
// proceeds. Doing the seed inside `collect { }` would race
|
||||
// with concurrent inserts.
|
||||
store.events
|
||||
.onSubscription {
|
||||
seed()
|
||||
@@ -168,13 +155,11 @@ class EventStoreProjection<T : Event>(
|
||||
}
|
||||
|
||||
private suspend fun seed() {
|
||||
val initial = store.query<T>(filters)
|
||||
val now = nowProvider()
|
||||
for (event in initial) {
|
||||
// Stores don't filter expired events at query time, so we
|
||||
// do it here — otherwise an expired event would briefly
|
||||
// appear in [items] before the next deleteExpiredEvents()
|
||||
// sweep clears it.
|
||||
for (event in store.query<T>(filters)) {
|
||||
// Stores don't filter expired rows at query time, so do it
|
||||
// here — otherwise an expired event would briefly appear
|
||||
// in [items] before the next deleteExpiredEvents() sweep.
|
||||
if (event.isExpirationBefore(now)) continue
|
||||
applyInsert(event)
|
||||
yield()
|
||||
@@ -183,20 +168,24 @@ class EventStoreProjection<T : Event>(
|
||||
}
|
||||
|
||||
private fun apply(storeEvent: StoreEvent) {
|
||||
when (storeEvent) {
|
||||
is StoreEvent.Insert -> {
|
||||
if (applyInsert(storeEvent.event)) publish()
|
||||
}
|
||||
val changed =
|
||||
when (storeEvent) {
|
||||
is StoreEvent.Insert -> {
|
||||
applyInsert(storeEvent.event)
|
||||
}
|
||||
|
||||
is StoreEvent.DeleteByFilter -> {
|
||||
if (applyDeleteByFilter(storeEvent.filters)) publish()
|
||||
}
|
||||
is StoreEvent.DeleteByFilter -> {
|
||||
dropWhere { ev -> storeEvent.filters.any { it.match(ev) } }
|
||||
}
|
||||
|
||||
is StoreEvent.DeleteExpired -> {
|
||||
val cutoff = storeEvent.asOf ?: nowProvider()
|
||||
if (applyDeleteExpired(cutoff)) publish()
|
||||
// Store's sweep uses strict `<`; isExpirationBefore is
|
||||
// `<=`, so subtract 1 to match.
|
||||
is StoreEvent.DeleteExpired -> {
|
||||
val cutoff = (storeEvent.asOf ?: nowProvider()) - 1
|
||||
dropWhere { it.isExpirationBefore(cutoff) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) publish()
|
||||
}
|
||||
|
||||
private fun applyInsert(event: Event): Boolean {
|
||||
@@ -204,15 +193,14 @@ class EventStoreProjection<T : Event>(
|
||||
|
||||
var changed = false
|
||||
|
||||
// Apply NIP-09 / NIP-62 side effects of the event before we
|
||||
// consider matching the event itself against the filter — a
|
||||
// deletion event that arrives at the same instant as a
|
||||
// matching event still removes its targets.
|
||||
// NIP-09 / NIP-62 side effects come first — a deletion event
|
||||
// that arrives at the same instant as a matching event still
|
||||
// removes its targets.
|
||||
if (event is DeletionEvent) {
|
||||
if (handleDeletion(event)) changed = true
|
||||
}
|
||||
if (event is RequestToVanishEvent && event.shouldVanishFrom(relay)) {
|
||||
if (handleVanish(event)) changed = true
|
||||
if (dropWhere { ev -> ownerOf(ev) == event.pubKey && ev.createdAt < event.createdAt }) changed = true
|
||||
}
|
||||
|
||||
if (handleInsert(event)) changed = true
|
||||
@@ -220,37 +208,11 @@ class EventStoreProjection<T : Event>(
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun applyDeleteByFilter(rules: List<Filter>): Boolean {
|
||||
if (rules.isEmpty()) return false
|
||||
val targets = byId.values.filter { slot -> rules.any { it.match(slot.flow.value) } }
|
||||
if (targets.isEmpty()) return false
|
||||
var changed = false
|
||||
for (slot in targets) {
|
||||
if (removeSlot(slot)) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun applyDeleteExpired(asOf: Long): Boolean {
|
||||
// Store's sweep uses strict `<`; isExpirationBefore is `<=`,
|
||||
// so subtract 1 to match. (Resolution is 1 second; the
|
||||
// off-by-one in the rare equal-timestamp case lines up with
|
||||
// the store's behaviour.)
|
||||
val cutoff = asOf - 1
|
||||
val targets = byId.values.filter { it.flow.value.isExpirationBefore(cutoff) }
|
||||
if (targets.isEmpty()) return false
|
||||
var changed = false
|
||||
for (slot in targets) {
|
||||
if (removeSlot(slot)) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if processing the event caused membership of the
|
||||
* projection's [items] list to change. Returns false for in-place
|
||||
* supersession updates, NIP-01 tiebreaker rejections, and arrivals
|
||||
* that no filter matches.
|
||||
* Returns true if processing the event caused the projection's
|
||||
* membership to change. Returns false for in-place supersession
|
||||
* updates, NIP-01 tiebreaker rejections, and arrivals that no
|
||||
* filter matches.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun handleInsert(event: Event): Boolean {
|
||||
@@ -278,105 +240,98 @@ class EventStoreProjection<T : Event>(
|
||||
}
|
||||
|
||||
// Genuinely new slot. Offer it to every matching filter; if
|
||||
// any retains it, the slot becomes live.
|
||||
// any filter still holds it after cap-eviction, the slot
|
||||
// becomes live and gets indexed.
|
||||
var membershipChanged = false
|
||||
val slot = Slot(event as T)
|
||||
var retained = false
|
||||
for ((f, set) in perFilter) {
|
||||
if (!f.match(event)) continue
|
||||
set.add(slot)
|
||||
retained = true
|
||||
val cap = f.limit ?: continue
|
||||
while (set.size > cap) {
|
||||
val tail = set.last()
|
||||
set.remove(tail)
|
||||
if (tail === slot) {
|
||||
// We were evicted by our own filter before any
|
||||
// other filter got a chance to retain us — the
|
||||
// remaining loop iterations may still pick us up.
|
||||
retained = false
|
||||
} else if (perFilter.values.none { it.contains(tail) }) {
|
||||
// Tail no longer retained by any filter — drop it.
|
||||
dropSlotIndexes(tail)
|
||||
if (tail !== slot && perFilter.values.none { it.contains(tail) }) {
|
||||
// Tail no longer retained by any filter — fully drop.
|
||||
if (removeIndexes(tail)) membershipChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!retained) return false
|
||||
|
||||
byId[event.id] = slot
|
||||
if (address != null) byAddress[address] = slot
|
||||
ordered.add(slot)
|
||||
return true
|
||||
// The slot survived cap-eviction in at least one filter, so it
|
||||
// belongs in the indexes. Otherwise nothing was indexed and
|
||||
// the only membership effect is whatever evictions happened
|
||||
// along the way.
|
||||
if (perFilter.values.any { it.contains(slot) }) {
|
||||
byId[event.id] = slot
|
||||
if (address != null) byAddress[address] = slot
|
||||
membershipChanged = true
|
||||
}
|
||||
return membershipChanged
|
||||
}
|
||||
|
||||
private fun handleDeletion(deletion: DeletionEvent): Boolean {
|
||||
var changed = false
|
||||
|
||||
// NIP-09: delete by id, but only if the deletion's author owns
|
||||
// the target. For GiftWrap, the owner is the p-tag recipient.
|
||||
// NIP-09 by id, only if the deletion's author owns the target.
|
||||
// For GiftWrap, the owner is the p-tag recipient.
|
||||
for (id in deletion.deleteEventIds()) {
|
||||
val slot = byId[id] ?: continue
|
||||
val ev = slot.flow.value
|
||||
val owner = ownerPubKey(ev)
|
||||
if (owner == deletion.pubKey && removeSlot(slot)) changed = true
|
||||
if (ownerOf(slot.flow.value) == deletion.pubKey && removeSlot(slot)) changed = true
|
||||
}
|
||||
|
||||
// NIP-09: delete by address, only original author, only events
|
||||
// with `created_at <= deletion.created_at`. `addr` is already
|
||||
// an [Address] — same equals/hashCode as our index keys.
|
||||
// NIP-09 by address, only original author, only events with
|
||||
// `created_at <= deletion.created_at`. `addr` is already an
|
||||
// [Address] — same equals/hashCode as our index keys.
|
||||
for (addr in deletion.deleteAddresses()) {
|
||||
if (addr.pubKeyHex != deletion.pubKey) continue
|
||||
val slot = byAddress[addr] ?: continue
|
||||
if (slot.flow.value.createdAt <= deletion.createdAt) {
|
||||
if (removeSlot(slot)) changed = true
|
||||
}
|
||||
if (slot.flow.value.createdAt <= deletion.createdAt && removeSlot(slot)) changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
private fun handleVanish(vanish: RequestToVanishEvent): Boolean {
|
||||
/** Drop every live slot whose current event matches [predicate]. */
|
||||
private inline fun dropWhere(predicate: (Event) -> Boolean): Boolean {
|
||||
// Snapshot before mutating — removeSlot mutates byId.
|
||||
val targets = byId.values.filter { predicate(it.flow.value) }
|
||||
var changed = false
|
||||
// Snapshot first because removeSlot mutates byId.
|
||||
val targets =
|
||||
byId.values.filter {
|
||||
val ev = it.flow.value
|
||||
ownerPubKey(ev) == vanish.pubKey && ev.createdAt < vanish.createdAt
|
||||
}
|
||||
for (slot in targets) {
|
||||
if (removeSlot(slot)) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Drop a live slot from every index AND from each per-filter set. */
|
||||
/** Remove a live slot from every index AND from each per-filter set. */
|
||||
private fun removeSlot(slot: Slot<T>): Boolean {
|
||||
val removed = byId.remove(slot.flow.value.id) != null
|
||||
if (!removed) return false
|
||||
ordered.remove(slot)
|
||||
addressOf(slot.flow.value)?.let { address ->
|
||||
// Defensive: only clear the address index if this slot
|
||||
// still owns it.
|
||||
if (byAddress[address] === slot) byAddress.remove(address)
|
||||
}
|
||||
for (set in perFilter.values) set.remove(slot)
|
||||
return true
|
||||
return removeIndexes(slot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a slot from byId / byAddress / ordered without touching the
|
||||
* per-filter sets. Used when the per-filter eviction loop already
|
||||
* owns the bookkeeping for those sets.
|
||||
* Remove a slot from [byId] / [byAddress] without touching the
|
||||
* per-filter sets. Used by the per-filter eviction loop, which
|
||||
* already owns the bookkeeping for those.
|
||||
*/
|
||||
private fun dropSlotIndexes(slot: Slot<T>) {
|
||||
byId.remove(slot.flow.value.id)
|
||||
ordered.remove(slot)
|
||||
addressOf(slot.flow.value)?.let { address ->
|
||||
if (byAddress[address] === slot) byAddress.remove(address)
|
||||
}
|
||||
private fun removeIndexes(slot: Slot<T>): Boolean {
|
||||
val removed = byId.remove(slot.flow.value.id) != null
|
||||
if (!removed) return false
|
||||
addressOf(slot.flow.value)?.let(byAddress::remove)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
_items.value = ordered.map { it.flow }
|
||||
// Lazily compute the deduped sorted union from per-filter
|
||||
// sets. Cheaper than maintaining a separate `ordered` field
|
||||
// alongside every insert / remove.
|
||||
if (byId.isEmpty()) {
|
||||
_items.value = emptyList()
|
||||
return
|
||||
}
|
||||
val union = sortedSetOf(slotComparator<T>())
|
||||
for (set in perFilter.values) union.addAll(set)
|
||||
_items.value = union.map { it.flow }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,7 +341,6 @@ class EventStoreProjection<T : Event>(
|
||||
*/
|
||||
override fun close() {
|
||||
collectorJob.cancel()
|
||||
ordered.clear()
|
||||
byId.clear()
|
||||
byAddress.clear()
|
||||
for (set in perFilter.values) set.clear()
|
||||
@@ -397,8 +351,8 @@ class EventStoreProjection<T : Event>(
|
||||
* Internal slot. Each event added to the projection lives inside
|
||||
* one of these for as long as it survives. The sort key is frozen
|
||||
* at construction time — supersession in-place updates rewrite
|
||||
* `flow.value` but never the sort key, so the ordering inside
|
||||
* [ordered] / [perFilter] is stable across updates.
|
||||
* `flow.value` but never the sort key, so the position inside
|
||||
* each [perFilter] set is stable across updates.
|
||||
*/
|
||||
private class Slot<T : Event>(
|
||||
initial: T,
|
||||
@@ -409,11 +363,7 @@ class EventStoreProjection<T : Event>(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The lookup [Address] for replaceable / addressable
|
||||
* supersession. `null` for regular events (which only collide
|
||||
* on event id).
|
||||
*/
|
||||
/** [Address] for replaceable / addressable supersession; `null` for regular events. */
|
||||
private fun addressOf(event: Event): Address? =
|
||||
when {
|
||||
event is AddressableEvent -> event.address()
|
||||
@@ -422,10 +372,9 @@ class EventStoreProjection<T : Event>(
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-01 supersession tiebreaker. The new event wins iff:
|
||||
* - its `created_at` is strictly greater, OR
|
||||
* - the timestamps tie and its `id` is lexically smaller.
|
||||
* Otherwise the existing slot keeps its place.
|
||||
* NIP-01 supersession tiebreaker. The new event wins iff its
|
||||
* `created_at` is strictly greater, or the timestamps tie and
|
||||
* its `id` is lexically smaller.
|
||||
*/
|
||||
private fun supersedes(
|
||||
new: Event,
|
||||
@@ -442,13 +391,13 @@ class EventStoreProjection<T : Event>(
|
||||
* NIP-62 vanish target). For GiftWrap the owner is the p-tag
|
||||
* recipient; for everything else it's `event.pubKey`.
|
||||
*/
|
||||
private fun ownerPubKey(event: Event): HexKey = (event as? GiftWrapEvent)?.recipientPubKey() ?: event.pubKey
|
||||
private fun ownerOf(event: Event): HexKey = (event as? GiftWrapEvent)?.recipientPubKey() ?: event.pubKey
|
||||
|
||||
/**
|
||||
* created_at DESC, id ASC. The keys are snapshots taken at
|
||||
* insertion time, so the ordering of a slot never changes
|
||||
* after it joins the set. Distinct events have distinct ids,
|
||||
* so no third-key tiebreak is needed.
|
||||
* insertion time, so a slot's position never changes after it
|
||||
* joins a set. Distinct events have distinct ids, so no third
|
||||
* tiebreak is needed.
|
||||
*/
|
||||
private fun <T : Event> slotComparator(): Comparator<Slot<T>> =
|
||||
Comparator { a, b ->
|
||||
|
||||
Reference in New Issue
Block a user