Skip to content

Commit 600a2a0

Browse files
authored
feat/fix: skip backtracking when far behind, accept pubsub events after idle (#96)
* feat/fix: skip backtracking when far behind, accept pubsub events after idle * docker-compose fix and actually request what we're expecting * pubsub backtracking spec
1 parent c33f655 commit 600a2a0

File tree

7 files changed

+516
-76
lines changed

7 files changed

+516
-76
lines changed

core/src/main/scala/akka/persistence/dynamodb/internal/BySliceQuery.scala

Lines changed: 142 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package akka.persistence.dynamodb.internal
66

7+
import java.time.Clock
78
import java.time.Instant
89
import java.time.{ Duration => JDuration }
910

@@ -26,7 +27,22 @@ import org.slf4j.Logger
2627

2728
object QueryState {
2829
val empty: QueryState =
29-
QueryState(TimestampOffset.Zero, 0, 0, 0, 0, backtrackingCount = 0, TimestampOffset.Zero, 0, 0)
30+
QueryState(
31+
latest = TimestampOffset.Zero,
32+
itemCount = 0,
33+
itemCountSinceBacktracking = 0,
34+
queryCount = 0,
35+
idleCount = 0,
36+
backtrackingCount = 0,
37+
latestBacktracking = TimestampOffset.Zero,
38+
latestBacktrackingSeenCount = 0,
39+
backtrackingExpectFiltered = 0,
40+
previous = TimestampOffset.Zero,
41+
previousBacktracking = TimestampOffset.Zero,
42+
startTimestamp = Instant.EPOCH,
43+
startWallClock = Instant.EPOCH,
44+
currentQueryWallClock = Instant.EPOCH,
45+
previousQueryWallClock = Instant.EPOCH)
3046
}
3147

3248
final case class QueryState(
@@ -38,17 +54,26 @@ import org.slf4j.Logger
3854
backtrackingCount: Int,
3955
latestBacktracking: TimestampOffset,
4056
latestBacktrackingSeenCount: Int,
41-
backtrackingExpectFiltered: Int) {
57+
backtrackingExpectFiltered: Int,
58+
previous: TimestampOffset,
59+
previousBacktracking: TimestampOffset,
60+
startTimestamp: Instant,
61+
startWallClock: Instant,
62+
currentQueryWallClock: Instant,
63+
previousQueryWallClock: Instant) {
4264

4365
def backtracking: Boolean = backtrackingCount > 0
4466

4567
def currentOffset: TimestampOffset =
4668
if (backtracking) latestBacktracking
4769
else latest
4870

49-
def nextQueryFromTimestamp: Instant =
50-
if (backtracking) latestBacktracking.timestamp
51-
else latest.timestamp
71+
def nextQueryFromTimestamp(backtrackingWindow: JDuration): Instant =
72+
if (backtracking) {
73+
if (latest.timestamp.minus(backtrackingWindow).isAfter(latestBacktracking.timestamp))
74+
latest.timestamp.minus(backtrackingWindow)
75+
else latestBacktracking.timestamp
76+
} else latest.timestamp
5277

5378
def nextQueryToTimestamp: Option[Instant] = {
5479
if (backtracking) Some(latest.timestamp)
@@ -81,15 +106,18 @@ import org.slf4j.Logger
81106
dao: BySliceQuery.Dao[Item],
82107
createEnvelope: (TimestampOffset, Item) => Envelope,
83108
extractOffset: Envelope => TimestampOffset,
109+
createHeartbeat: Instant => Option[Envelope],
110+
clock: Clock,
84111
settings: DynamoDBSettings,
85112
log: Logger)(implicit val ec: ExecutionContext) {
86113
import BySliceQuery._
87114
import TimestampOffset.toTimestampOffset
88115

89116
private val backtrackingWindow = JDuration.ofMillis(settings.querySettings.backtrackingWindow.toMillis)
90117
private val halfBacktrackingWindow = backtrackingWindow.dividedBy(2)
91-
private val firstBacktrackingQueryWindow =
92-
backtrackingWindow.plus(JDuration.ofMillis(settings.querySettings.backtrackingBehindCurrentTime.toMillis))
118+
private val backtrackingBehindCurrentTime =
119+
JDuration.ofMillis(settings.querySettings.backtrackingBehindCurrentTime.toMillis)
120+
private val firstBacktrackingQueryWindow = backtrackingWindow.plus(backtrackingBehindCurrentTime)
93121

94122
def currentBySlice(
95123
logPrefix: String,
@@ -99,15 +127,17 @@ import org.slf4j.Logger
99127
filterEventsBeforeSnapshots: (String, Long, String) => Boolean = (_, _, _) => true): Source[Envelope, NotUsed] = {
100128
val initialOffset = toTimestampOffset(offset)
101129

102-
def nextOffset(state: QueryState, envelope: Envelope): QueryState =
103-
state.copy(latest = extractOffset(envelope), itemCount = state.itemCount + 1)
130+
def nextOffset(state: QueryState, envelope: Envelope): QueryState = {
131+
if (EnvelopeOrigin.isHeartbeatEvent(envelope)) state
132+
else state.copy(latest = extractOffset(envelope), itemCount = state.itemCount + 1)
133+
}
104134

105135
def nextQuery(state: QueryState, endTimestamp: Instant): (QueryState, Option[Source[Envelope, NotUsed]]) = {
106136
// Note that we can't know how many events with the same timestamp that are filtered out
107137
// so continue until itemCount is 0. That means an extra query at the end to make sure there are no
108138
// more to fetch.
109139
if (state.queryCount == 0L || state.itemCount > 0) {
110-
val newState = state.copy(itemCount = 0, queryCount = state.queryCount + 1)
140+
val newState = state.copy(itemCount = 0, queryCount = state.queryCount + 1, previous = state.latest)
111141

112142
val toTimestamp = newState.nextQueryToTimestamp match {
113143
case Some(t) =>
@@ -176,41 +206,45 @@ import org.slf4j.Logger
176206
log.debug("Starting {} query from slice [{}], from time [{}].", logPrefix, slice, initialOffset.timestamp)
177207

178208
def nextOffset(state: QueryState, envelope: Envelope): QueryState = {
179-
val offset = extractOffset(envelope)
180-
if (state.backtracking) {
181-
if (offset.timestamp.isBefore(state.latestBacktracking.timestamp))
182-
throw new IllegalArgumentException(
183-
s"Unexpected offset [$offset] before latestBacktracking [${state.latestBacktracking}].")
209+
if (EnvelopeOrigin.isHeartbeatEvent(envelope)) state
210+
else {
211+
val offset = extractOffset(envelope)
212+
if (state.backtracking) {
213+
if (offset.timestamp.isBefore(state.latestBacktracking.timestamp))
214+
throw new IllegalArgumentException(
215+
s"Unexpected offset [$offset] before latestBacktracking [${state.latestBacktracking}].")
216+
217+
val newSeenCount =
218+
if (offset.timestamp == state.latestBacktracking.timestamp) state.latestBacktrackingSeenCount + 1
219+
else 1
184220

185-
val newSeenCount =
186-
if (offset.timestamp == state.latestBacktracking.timestamp) state.latestBacktrackingSeenCount + 1 else 1
187-
188-
state.copy(
189-
latestBacktracking = offset,
190-
latestBacktrackingSeenCount = newSeenCount,
191-
itemCount = state.itemCount + 1)
221+
state.copy(
222+
latestBacktracking = offset,
223+
latestBacktrackingSeenCount = newSeenCount,
224+
itemCount = state.itemCount + 1)
192225

193-
} else {
194-
if (offset.timestamp.isBefore(state.latest.timestamp))
195-
throw new IllegalArgumentException(s"Unexpected offset [$offset] before latest [${state.latest}].")
226+
} else {
227+
if (offset.timestamp.isBefore(state.latest.timestamp))
228+
throw new IllegalArgumentException(s"Unexpected offset [$offset] before latest [${state.latest}].")
229+
230+
if (log.isDebugEnabled()) {
231+
if (state.latestBacktracking.seen.nonEmpty &&
232+
offset.timestamp.isAfter(state.latestBacktracking.timestamp.plus(firstBacktrackingQueryWindow)))
233+
log.debug(
234+
"{} next offset is outside the backtracking window, latestBacktracking: [{}], offset: [{}]",
235+
logPrefix,
236+
state.latestBacktracking,
237+
offset)
238+
}
196239

197-
if (log.isDebugEnabled()) {
198-
if (state.latestBacktracking.seen.nonEmpty &&
199-
offset.timestamp.isAfter(state.latestBacktracking.timestamp.plus(firstBacktrackingQueryWindow)))
200-
log.debug(
201-
"{} next offset is outside the backtracking window, latestBacktracking: [{}], offset: [{}]",
202-
logPrefix,
203-
state.latestBacktracking,
204-
offset)
240+
state.copy(latest = offset, itemCount = state.itemCount + 1)
205241
}
206-
207-
state.copy(latest = offset, itemCount = state.itemCount + 1)
208242
}
209243
}
210244

211245
def delayNextQuery(state: QueryState): Option[FiniteDuration] = {
212246
if (switchFromBacktracking(state)) {
213-
// switch from from backtracking immediately
247+
// switch from backtracking immediately
214248
None
215249
} else {
216250
val delay = ContinuousQuery.adjustNextDelay(
@@ -236,20 +270,44 @@ import org.slf4j.Logger
236270
state.backtracking && state.itemCount < settings.querySettings.bufferSize - state.backtrackingExpectFiltered
237271
}
238272

273+
def switchToBacktracking(state: QueryState, newIdleCount: Long): Boolean = {
274+
// Note that when starting the query with offset = NoOffset, it will try to switch to
275+
// backtracking immediately after the first normal query because
276+
// between(latestBacktracking.timestamp, latest.timestamp) > halfBacktrackingWindow
277+
278+
val qSettings = settings.querySettings
279+
280+
def disableBacktrackingWhenFarBehindCurrentWallClockTime: Boolean = {
281+
val aheadOfInitial =
282+
initialOffset == TimestampOffset.Zero || state.latestBacktracking.timestamp.isAfter(initialOffset.timestamp)
283+
284+
val previousTimestamp =
285+
if (state.previous == TimestampOffset.Zero) state.latest.timestamp
286+
else state.previous.timestamp
287+
288+
aheadOfInitial && previousTimestamp.isBefore(clock.instant().minus(firstBacktrackingQueryWindow))
289+
}
290+
291+
qSettings.backtrackingEnabled &&
292+
!state.backtracking &&
293+
state.latest != TimestampOffset.Zero &&
294+
!disableBacktrackingWhenFarBehindCurrentWallClockTime &&
295+
(newIdleCount >= 5 || // FIXME config?
296+
state.itemCountSinceBacktracking + state.itemCount >= qSettings.bufferSize * 3 ||
297+
JDuration
298+
.between(state.latestBacktracking.timestamp, state.latest.timestamp)
299+
.compareTo(halfBacktrackingWindow) > 0)
300+
}
301+
239302
def nextQuery(state: QueryState): (QueryState, Option[Source[Envelope, NotUsed]]) = {
240303
val newIdleCount = if (state.itemCount == 0) state.idleCount + 1 else 0
241-
val newState =
242-
if (settings.querySettings.backtrackingEnabled && !state.backtracking && state.latest != TimestampOffset.Zero &&
243-
(newIdleCount >= 5 ||
244-
state.itemCountSinceBacktracking + state.itemCount >= settings.querySettings.bufferSize * 3 ||
245-
JDuration
246-
.between(state.latestBacktracking.timestamp, state.latest.timestamp)
247-
.compareTo(halfBacktrackingWindow) > 0)) {
248-
// FIXME config for newIdleCount >= 5 and maybe something like `newIdleCount % 5 == 0`
249-
250-
// Note that when starting the query with offset = NoOffset it will switch to backtracking immediately after
251-
// the first normal query because between(latestBacktracking.timestamp, latest.timestamp) > halfBacktrackingWindow
304+
// start tracking query wall clock for heartbeats after initial backtracking query
305+
val newQueryWallClock =
306+
if (state.latestBacktracking != TimestampOffset.Zero) clock.instant()
307+
else Instant.EPOCH
252308

309+
val newState =
310+
if (switchToBacktracking(state, newIdleCount)) {
253311
// switching to backtracking
254312
val fromOffset =
255313
if (state.latestBacktracking == TimestampOffset.Zero)
@@ -264,28 +322,34 @@ import org.slf4j.Logger
264322
idleCount = newIdleCount,
265323
backtrackingCount = 1,
266324
latestBacktracking = fromOffset,
267-
backtrackingExpectFiltered = state.latestBacktrackingSeenCount)
325+
backtrackingExpectFiltered = state.latestBacktrackingSeenCount,
326+
currentQueryWallClock = newQueryWallClock,
327+
previousQueryWallClock = state.currentQueryWallClock)
268328
} else if (switchFromBacktracking(state)) {
269-
// switch from backtracking
329+
// switching from backtracking
270330
state.copy(
271331
itemCount = 0,
272332
itemCountSinceBacktracking = 0,
273333
queryCount = state.queryCount + 1,
274334
idleCount = newIdleCount,
275-
backtrackingCount = 0)
335+
backtrackingCount = 0,
336+
currentQueryWallClock = newQueryWallClock,
337+
previousQueryWallClock = state.currentQueryWallClock)
276338
} else {
277-
// continue
339+
// continuing
278340
val newBacktrackingCount = if (state.backtracking) state.backtrackingCount + 1 else 0
279341
state.copy(
280342
itemCount = 0,
281343
itemCountSinceBacktracking = state.itemCountSinceBacktracking + state.itemCount,
282344
queryCount = state.queryCount + 1,
283345
idleCount = newIdleCount,
284346
backtrackingCount = newBacktrackingCount,
285-
backtrackingExpectFiltered = state.latestBacktrackingSeenCount)
347+
backtrackingExpectFiltered = state.latestBacktrackingSeenCount,
348+
currentQueryWallClock = newQueryWallClock,
349+
previousQueryWallClock = state.currentQueryWallClock)
286350
}
287351

288-
val fromTimestamp = newState.nextQueryFromTimestamp
352+
val fromTimestamp = newState.nextQueryFromTimestamp(backtrackingWindow)
289353
val toTimestamp = {
290354
val behindCurrentTime =
291355
if (newState.backtracking) settings.querySettings.backtrackingBehindCurrentTime
@@ -320,7 +384,11 @@ import org.slf4j.Logger
320384
else s"Found [${state.itemCount}] items in previous query.")
321385
}
322386

323-
newState ->
387+
val newStateWithPrevious =
388+
if (newState.backtracking) newState.copy(previousBacktracking = newState.latestBacktracking)
389+
else newState.copy(previous = newState.latest)
390+
391+
newStateWithPrevious ->
324392
Some(
325393
dao
326394
.itemsBySlice(entityType, slice, fromTimestamp, toTimestamp, backtracking = newState.backtracking)
@@ -330,12 +398,30 @@ import org.slf4j.Logger
330398
.via(deserializeAndAddOffset(newState.currentOffset)))
331399
}
332400

401+
def heartbeat(state: QueryState): Option[Envelope] = {
402+
if (state.idleCount >= 1 && state.previousQueryWallClock != Instant.EPOCH) {
403+
// use wall clock to measure duration since start, up to idle backtracking limit
404+
val timestamp = state.startTimestamp.plus(
405+
JDuration.between(state.startWallClock, state.previousQueryWallClock.minus(backtrackingBehindCurrentTime)))
406+
407+
createHeartbeat(timestamp)
408+
} else None
409+
}
410+
411+
val nextHeartbeat: QueryState => Option[Envelope] =
412+
if (settings.journalPublishEvents) heartbeat else _ => None
413+
414+
val currentTimestamp = InstantFactory.now() // Can we use DDB as a timestamp source?
415+
val currentWallClock = clock.instant()
416+
333417
ContinuousQuery[QueryState, Envelope](
334-
initialState = QueryState.empty.copy(latest = initialOffset),
418+
initialState = QueryState.empty
419+
.copy(latest = initialOffset, startTimestamp = currentTimestamp, startWallClock = currentWallClock),
335420
updateState = nextOffset,
336421
delayNextQuery = delayNextQuery,
337422
nextQuery = nextQuery,
338-
beforeQuery = _ => None)
423+
beforeQuery = _ => None,
424+
heartbeat = nextHeartbeat)
339425
}
340426

341427
private def deserializeAndAddOffset(timestampOffset: TimestampOffset): Flow[Item, Envelope, NotUsed] = {

core/src/main/scala/akka/persistence/dynamodb/internal/ContinuousQuery.scala

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,10 @@ private[dynamodb] object ContinuousQuery {
3030
updateState: (S, T) => S,
3131
delayNextQuery: S => Option[FiniteDuration],
3232
nextQuery: S => (S, Option[Source[T, NotUsed]]),
33-
beforeQuery: S => Option[Future[S]] = (_: S) => None): Source[T, NotUsed] =
34-
Source.fromGraph(new ContinuousQuery[S, T](initialState, updateState, delayNextQuery, nextQuery, beforeQuery))
33+
beforeQuery: S => Option[Future[S]] = (_: S) => None,
34+
heartbeat: S => Option[T] = (_: S) => None): Source[T, NotUsed] =
35+
Source.fromGraph(
36+
new ContinuousQuery[S, T](initialState, updateState, delayNextQuery, nextQuery, beforeQuery, heartbeat))
3537

3638
private case object NextQuery
3739

@@ -69,7 +71,8 @@ final private[dynamodb] class ContinuousQuery[S, T](
6971
updateState: (S, T) => S,
7072
delayNextQuery: S => Option[FiniteDuration],
7173
nextQuery: S => (S, Option[Source[T, NotUsed]]),
72-
beforeQuery: S => Option[Future[S]])
74+
beforeQuery: S => Option[Future[S]],
75+
heartbeat: S => Option[T])
7376
extends GraphStage[SourceShape[T]] {
7477
import ContinuousQuery._
7578

@@ -151,8 +154,14 @@ final private[dynamodb] class ContinuousQuery[S, T](
151154
next()
152155
}
153156
})
157+
158+
val sourceWithHeartbeat = heartbeat(newState) match {
159+
case None => source
160+
case Some(h) => Source.single(h).concat(source)
161+
}
162+
154163
val graph = Source
155-
.fromGraph(source)
164+
.fromGraph(sourceWithHeartbeat)
156165
.to(sinkIn.sink)
157166
interpreter.subFusingMaterializer.materialize(graph)
158167
sinkIn.pull()

core/src/main/scala/akka/persistence/dynamodb/internal/EnvelopeOrigin.scala

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import akka.persistence.query.typed.EventEnvelope
1616
val SourceBacktracking = "BT"
1717
val SourcePubSub = "PS"
1818
val SourceSnapshot = "SN"
19+
val SourceHeartbeat = "HB"
1920

2021
def fromQuery(env: EventEnvelope[_]): Boolean =
2122
env.source == SourceQuery
@@ -32,6 +33,15 @@ import akka.persistence.query.typed.EventEnvelope
3233
def fromSnapshot(env: EventEnvelope[_]): Boolean =
3334
env.source == SourceSnapshot
3435

36+
def fromHeartbeat(env: EventEnvelope[_]): Boolean =
37+
env.source == SourceHeartbeat
38+
39+
def isHeartbeatEvent(env: Any): Boolean =
40+
env match {
41+
case e: EventEnvelope[_] => fromHeartbeat(e)
42+
case _ => false
43+
}
44+
3545
def isFilteredEvent(env: Any): Boolean =
3646
env match {
3747
case e: EventEnvelope[_] => e.filtered

0 commit comments

Comments
 (0)