Skip to content

Commit c7899d1

Browse files
patriknwpvlugter
andauthored
feat: Skip backtracking when far behind,Accept PubSub events after idle (#614)
* feat: Accept PubSub events after idle * PubSub events are ignored if they are too far ahead of backtracking. * That means that they will always be ignored after an idle period. * This emits heartbeat events when the query is idle and thereby progress the backtracking timestamp in the PubSub filter. * base relative heartbeat timestamps on initial database time * previous query wall clock minus backtracking behind current time * including fake persistenceId corresponding to the slice * don't deduplicate heartbeat * mima filter * unique heartbeat persistenceId * filter heartbeats early in pubsub merge * feat: Skip backtracking when far behind --------- Co-authored-by: Peter Vlugter <[email protected]>
1 parent 56552e4 commit c7899d1

File tree

11 files changed

+531
-91
lines changed

11 files changed

+531
-91
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# PR #599 internals
2+
ProblemFilters.exclude[DirectMissingMethodProblem]("akka.persistence.r2dbc.internal.BySliceQuery#QueryState.copy")
3+
ProblemFilters.exclude[IncompatibleResultTypeProblem]("akka.persistence.r2dbc.internal.BySliceQuery#QueryState.copy*")
4+
ProblemFilters.exclude[DirectMissingMethodProblem]("akka.persistence.r2dbc.internal.BySliceQuery#QueryState.this")
5+
ProblemFilters.exclude[DirectMissingMethodProblem]("akka.persistence.r2dbc.internal.BySliceQuery#QueryState.apply")
6+
ProblemFilters.exclude[IncompatibleSignatureProblem]("akka.persistence.r2dbc.internal.BySliceQuery#QueryState.unapply")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# internals
2+
ProblemFilters.exclude[Problem]("akka.persistence.r2dbc.internal.BySliceQuery#QueryState*")

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

+143-66
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@
44

55
package akka.persistence.r2dbc.internal
66

7+
import java.time.Clock
8+
79
import scala.collection.immutable
810
import java.time.Instant
911
import java.time.{ Duration => JDuration }
12+
1013
import scala.annotation.tailrec
1114
import scala.concurrent.ExecutionContext
1215
import scala.concurrent.Future
1316
import scala.concurrent.duration.Duration
1417
import scala.concurrent.duration.FiniteDuration
18+
1519
import akka.NotUsed
1620
import akka.annotation.InternalApi
1721
import akka.persistence.query.Offset
@@ -42,7 +46,11 @@ import org.slf4j.Logger
4246
backtrackingExpectFiltered = 0,
4347
buckets = Buckets.empty,
4448
previous = TimestampOffset.Zero,
45-
previousBacktracking = TimestampOffset.Zero)
49+
previousBacktracking = TimestampOffset.Zero,
50+
startTimestamp = Instant.EPOCH,
51+
startWallClock = Instant.EPOCH,
52+
currentQueryWallClock = Instant.EPOCH,
53+
previousQueryWallClock = Instant.EPOCH)
4654
}
4755

4856
final case class QueryState(
@@ -57,24 +65,32 @@ import org.slf4j.Logger
5765
backtrackingExpectFiltered: Int,
5866
buckets: Buckets,
5967
previous: TimestampOffset,
60-
previousBacktracking: TimestampOffset) {
68+
previousBacktracking: TimestampOffset,
69+
startTimestamp: Instant,
70+
startWallClock: Instant,
71+
currentQueryWallClock: Instant,
72+
previousQueryWallClock: Instant) {
6173

6274
def backtracking: Boolean = backtrackingCount > 0
6375

6476
def currentOffset: TimestampOffset =
6577
if (backtracking) latestBacktracking
6678
else latest
6779

68-
def nextQueryFromTimestamp: Instant =
69-
if (backtracking) latestBacktracking.timestamp
70-
else latest.timestamp
80+
def nextQueryFromTimestamp(backtrackingWindow: JDuration): Instant =
81+
if (backtracking && latest.timestamp.minus(backtrackingWindow).isAfter(latestBacktracking.timestamp))
82+
latest.timestamp.minus(backtrackingWindow)
83+
else if (backtracking)
84+
latestBacktracking.timestamp
85+
else
86+
latest.timestamp
7187

7288
def nextQueryFromSeqNr: Option[Long] =
7389
if (backtracking) highestSeenSeqNr(previousBacktracking, latestBacktracking)
7490
else highestSeenSeqNr(previous, latest)
7591

76-
def nextQueryToTimestamp(atLeastNumberOfEvents: Int): Option[Instant] = {
77-
buckets.findTimeForLimit(nextQueryFromTimestamp, atLeastNumberOfEvents) match {
92+
def nextQueryToTimestamp(backtrackingWindow: JDuration, atLeastNumberOfEvents: Int): Option[Instant] = {
93+
buckets.findTimeForLimit(nextQueryFromTimestamp(backtrackingWindow), atLeastNumberOfEvents) match {
7894
case Some(t) =>
7995
if (backtracking)
8096
if (t.isAfter(latest.timestamp)) Some(latest.timestamp) else Some(t)
@@ -208,15 +224,18 @@ import org.slf4j.Logger
208224
dao: BySliceQuery.Dao[Row],
209225
createEnvelope: (TimestampOffset, Row) => Envelope,
210226
extractOffset: Envelope => TimestampOffset,
227+
createHeartbeat: Instant => Option[Envelope],
228+
clock: Clock,
211229
settings: R2dbcSettings,
212230
log: Logger)(implicit val ec: ExecutionContext) {
213231
import BySliceQuery._
214232
import TimestampOffset.toTimestampOffset
215233

216234
private val backtrackingWindow = JDuration.ofMillis(settings.querySettings.backtrackingWindow.toMillis)
217235
private val halfBacktrackingWindow = backtrackingWindow.dividedBy(2)
218-
private val firstBacktrackingQueryWindow =
219-
backtrackingWindow.plus(JDuration.ofMillis(settings.querySettings.backtrackingBehindCurrentTime.toMillis))
236+
private val backtrackingBehindCurrentTime =
237+
JDuration.ofMillis(settings.querySettings.backtrackingBehindCurrentTime.toMillis)
238+
private val firstBacktrackingQueryWindow = backtrackingWindow.plus(backtrackingBehindCurrentTime)
220239
private val eventBucketCountInterval = JDuration.ofSeconds(60)
221240

222241
def currentBySlices(
@@ -228,8 +247,12 @@ import org.slf4j.Logger
228247
filterEventsBeforeSnapshots: (String, Long, String) => Boolean = (_, _, _) => true): Source[Envelope, NotUsed] = {
229248
val initialOffset = toTimestampOffset(offset)
230249

231-
def nextOffset(state: QueryState, envelope: Envelope): QueryState =
232-
state.copy(latest = extractOffset(envelope), rowCount = state.rowCount + 1)
250+
def nextOffset(state: QueryState, envelope: Envelope): QueryState = {
251+
if (EnvelopeOrigin.isHeartbeatEvent(envelope))
252+
state
253+
else
254+
state.copy(latest = extractOffset(envelope), rowCount = state.rowCount + 1)
255+
}
233256

234257
def nextQuery(state: QueryState, endTimestamp: Instant): (QueryState, Option[Source[Envelope, NotUsed]]) = {
235258
// Note that we can't know how many events with the same timestamp that are filtered out
@@ -241,7 +264,7 @@ import org.slf4j.Logger
241264
val fromTimestamp = state.latest.timestamp
242265
val fromSeqNr = highestSeenSeqNr(state.previous, state.latest)
243266

244-
val toTimestamp = newState.nextQueryToTimestamp(settings.querySettings.bufferSize) match {
267+
val toTimestamp = newState.nextQueryToTimestamp(backtrackingWindow, settings.querySettings.bufferSize) match {
245268
case Some(t) =>
246269
if (t.isBefore(endTimestamp)) t else endTimestamp
247270
case None =>
@@ -333,45 +356,49 @@ import org.slf4j.Logger
333356
initialOffset.timestamp)
334357

335358
def nextOffset(state: QueryState, envelope: Envelope): QueryState = {
336-
val offset = extractOffset(envelope)
337-
if (state.backtracking) {
338-
if (offset.timestamp.isBefore(state.latestBacktracking.timestamp))
339-
throw new IllegalArgumentException(
340-
s"Unexpected offset [$offset] before latestBacktracking [${state.latestBacktracking}].")
341-
342-
val newSeenCount =
343-
if (offset.timestamp == state.latestBacktracking.timestamp &&
344-
highestSeenSeqNr(state.previousBacktracking, offset) ==
345-
highestSeenSeqNr(state.previousBacktracking, state.latestBacktracking))
346-
state.latestBacktrackingSeenCount + 1
347-
else 1
348-
349-
state.copy(
350-
latestBacktracking = offset,
351-
latestBacktrackingSeenCount = newSeenCount,
352-
rowCount = state.rowCount + 1)
359+
if (EnvelopeOrigin.isHeartbeatEvent(envelope))
360+
state
361+
else {
362+
val offset = extractOffset(envelope)
363+
if (state.backtracking) {
364+
if (offset.timestamp.isBefore(state.latestBacktracking.timestamp))
365+
throw new IllegalArgumentException(
366+
s"Unexpected offset [$offset] before latestBacktracking [${state.latestBacktracking}].")
367+
368+
val newSeenCount =
369+
if (offset.timestamp == state.latestBacktracking.timestamp &&
370+
highestSeenSeqNr(state.previousBacktracking, offset) ==
371+
highestSeenSeqNr(state.previousBacktracking, state.latestBacktracking))
372+
state.latestBacktrackingSeenCount + 1
373+
else 1
353374

354-
} else {
355-
if (offset.timestamp.isBefore(state.latest.timestamp))
356-
throw new IllegalArgumentException(s"Unexpected offset [$offset] before latest [${state.latest}].")
375+
state.copy(
376+
latestBacktracking = offset,
377+
latestBacktrackingSeenCount = newSeenCount,
378+
rowCount = state.rowCount + 1)
357379

358-
if (log.isDebugEnabled()) {
359-
if (state.latestBacktracking.seen.nonEmpty &&
360-
offset.timestamp.isAfter(state.latestBacktracking.timestamp.plus(firstBacktrackingQueryWindow)))
361-
log.debug(
362-
"{} next offset is outside the backtracking window, latestBacktracking: [{}], offset: [{}]",
363-
logPrefix,
364-
state.latestBacktracking,
365-
offset)
366-
}
380+
} else {
381+
if (offset.timestamp.isBefore(state.latest.timestamp))
382+
throw new IllegalArgumentException(s"Unexpected offset [$offset] before latest [${state.latest}].")
383+
384+
if (log.isDebugEnabled()) {
385+
if (state.latestBacktracking.seen.nonEmpty &&
386+
offset.timestamp.isAfter(state.latestBacktracking.timestamp.plus(firstBacktrackingQueryWindow)))
387+
log.debug(
388+
"{} next offset is outside the backtracking window, latestBacktracking: [{}], offset: [{}]",
389+
logPrefix,
390+
state.latestBacktracking,
391+
offset)
392+
}
367393

368-
state.copy(latest = offset, rowCount = state.rowCount + 1)
394+
state.copy(latest = offset, rowCount = state.rowCount + 1)
395+
}
369396
}
370397
}
371398

372399
def delayNextQuery(state: QueryState): Option[FiniteDuration] = {
373400
if (switchFromBacktracking(state)) {
374-
// switch from from backtracking immediately
401+
// switch from backtracking immediately
375402
None
376403
} else {
377404
val delay = ContinuousQuery.adjustNextDelay(
@@ -398,20 +425,38 @@ import org.slf4j.Logger
398425
state.backtracking && state.rowCount < settings.querySettings.bufferSize - state.backtrackingExpectFiltered
399426
}
400427

428+
def switchToBacktracking(state: QueryState, newIdleCount: Long): Boolean = {
429+
// Note that when starting the query with offset = NoOffset it will switch to backtracking immediately after
430+
// the first normal query because between(latestBacktracking.timestamp, latest.timestamp) > halfBacktrackingWindow
431+
432+
val qSettings = settings.querySettings
433+
434+
def disableBacktrackingWhenFarBehindCurrentWallClockTime: Boolean = {
435+
val aheadOfInitial =
436+
initialOffset == TimestampOffset.Zero || state.latestBacktracking.timestamp.isAfter(initialOffset.timestamp)
437+
val previousTimestamp =
438+
if (state.previous == TimestampOffset.Zero) state.latest.timestamp else state.previous.timestamp
439+
aheadOfInitial &&
440+
previousTimestamp.isBefore(clock.instant().minus(firstBacktrackingQueryWindow))
441+
}
442+
443+
qSettings.backtrackingEnabled &&
444+
!state.backtracking &&
445+
state.latest != TimestampOffset.Zero &&
446+
!disableBacktrackingWhenFarBehindCurrentWallClockTime &&
447+
(newIdleCount >= 5 ||
448+
state.rowCountSinceBacktracking + state.rowCount >= qSettings.bufferSize * 3 ||
449+
JDuration
450+
.between(state.latestBacktracking.timestamp, state.latest.timestamp)
451+
.compareTo(halfBacktrackingWindow) > 0)
452+
}
453+
401454
def nextQuery(state: QueryState): (QueryState, Option[Source[Envelope, NotUsed]]) = {
402455
val newIdleCount = if (state.rowCount == 0) state.idleCount + 1 else 0
456+
// only start tracking query wall clock (for heartbeats) after initial backtracking query
457+
val newQueryWallClock = if (state.latestBacktracking != TimestampOffset.Zero) clock.instant() else Instant.EPOCH
403458
val newState =
404-
if (settings.querySettings.backtrackingEnabled && !state.backtracking && state.latest != TimestampOffset.Zero &&
405-
(newIdleCount >= 5 ||
406-
state.rowCountSinceBacktracking + state.rowCount >= settings.querySettings.bufferSize * 3 ||
407-
JDuration
408-
.between(state.latestBacktracking.timestamp, state.latest.timestamp)
409-
.compareTo(halfBacktrackingWindow) > 0)) {
410-
// FIXME config for newIdleCount >= 5 and maybe something like `newIdleCount % 5 == 0`
411-
412-
// Note that when starting the query with offset = NoOffset it will switch to backtracking immediately after
413-
// the first normal query because between(latestBacktracking.timestamp, latest.timestamp) > halfBacktrackingWindow
414-
459+
if (switchToBacktracking(state, newIdleCount)) {
415460
// switching to backtracking
416461
val fromOffset =
417462
if (state.latestBacktracking == TimestampOffset.Zero)
@@ -426,15 +471,19 @@ import org.slf4j.Logger
426471
idleCount = newIdleCount,
427472
backtrackingCount = 1,
428473
latestBacktracking = fromOffset,
429-
backtrackingExpectFiltered = state.latestBacktrackingSeenCount)
474+
backtrackingExpectFiltered = state.latestBacktrackingSeenCount,
475+
currentQueryWallClock = newQueryWallClock,
476+
previousQueryWallClock = state.currentQueryWallClock)
430477
} else if (switchFromBacktracking(state)) {
431-
// switch from backtracking
478+
// switching from backtracking
432479
state.copy(
433480
rowCount = 0,
434481
rowCountSinceBacktracking = 0,
435482
queryCount = state.queryCount + 1,
436483
idleCount = newIdleCount,
437-
backtrackingCount = 0)
484+
backtrackingCount = 0,
485+
currentQueryWallClock = newQueryWallClock,
486+
previousQueryWallClock = state.currentQueryWallClock)
438487
} else {
439488
// continue
440489
val newBacktrackingCount = if (state.backtracking) state.backtrackingCount + 1 else 0
@@ -444,16 +493,18 @@ import org.slf4j.Logger
444493
queryCount = state.queryCount + 1,
445494
idleCount = newIdleCount,
446495
backtrackingCount = newBacktrackingCount,
447-
backtrackingExpectFiltered = state.latestBacktrackingSeenCount)
496+
backtrackingExpectFiltered = state.latestBacktrackingSeenCount,
497+
currentQueryWallClock = newQueryWallClock,
498+
previousQueryWallClock = state.currentQueryWallClock)
448499
}
449500

450501
val behindCurrentTime =
451502
if (newState.backtracking) settings.querySettings.backtrackingBehindCurrentTime
452503
else settings.querySettings.behindCurrentTime
453504

454-
val fromTimestamp = newState.nextQueryFromTimestamp
505+
val fromTimestamp = newState.nextQueryFromTimestamp(backtrackingWindow)
455506
val fromSeqNr = newState.nextQueryFromSeqNr
456-
val toTimestamp = newState.nextQueryToTimestamp(settings.querySettings.bufferSize)
507+
val toTimestamp = newState.nextQueryToTimestamp(backtrackingWindow, settings.querySettings.bufferSize)
457508

458509
if (log.isDebugEnabled()) {
459510
val backtrackingInfo =
@@ -501,12 +552,38 @@ import org.slf4j.Logger
501552
.via(deserializeAndAddOffset(newState.currentOffset)))
502553
}
503554

504-
ContinuousQuery[QueryState, Envelope](
505-
initialState = QueryState.empty.copy(latest = initialOffset),
506-
updateState = nextOffset,
507-
delayNextQuery = delayNextQuery,
508-
nextQuery = nextQuery,
509-
beforeQuery = beforeQuery(logPrefix, entityType, minSlice, maxSlice, _))
555+
def heartbeat(state: QueryState): Option[Envelope] = {
556+
if (state.idleCount >= 1 && state.previousQueryWallClock != Instant.EPOCH) {
557+
// using wall clock to measure duration since the start time (database timestamp) up to idle backtracking limit
558+
val timestamp = state.startTimestamp.plus(
559+
JDuration.between(state.startWallClock, state.previousQueryWallClock.minus(backtrackingBehindCurrentTime)))
560+
createHeartbeat(timestamp)
561+
} else
562+
None
563+
}
564+
565+
val nextHeartbeat: QueryState => Option[Envelope] =
566+
if (settings.journalPublishEvents) heartbeat else _ => None
567+
568+
val currentTimestamp =
569+
if (settings.useAppTimestamp) Future.successful(InstantFactory.now())
570+
else dao.currentDbTimestamp(minSlice)
571+
572+
Source
573+
.futureSource[Envelope, NotUsed] {
574+
currentTimestamp.map { currentTime =>
575+
val currentWallClock = clock.instant()
576+
ContinuousQuery[QueryState, Envelope](
577+
initialState = QueryState.empty
578+
.copy(latest = initialOffset, startTimestamp = currentTime, startWallClock = currentWallClock),
579+
updateState = nextOffset,
580+
delayNextQuery = delayNextQuery,
581+
nextQuery = nextQuery,
582+
beforeQuery = beforeQuery(logPrefix, entityType, minSlice, maxSlice, _),
583+
heartbeat = nextHeartbeat)
584+
}
585+
}
586+
.mapMaterializedValue(_ => NotUsed)
510587
}
511588

512589
private def beforeQuery(

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

+11-4
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ private[r2dbc] object ContinuousQuery {
2929
updateState: (S, T) => S,
3030
delayNextQuery: S => Option[FiniteDuration],
3131
nextQuery: S => (S, Option[Source[T, NotUsed]]),
32-
beforeQuery: S => Option[Future[S]] = (_: S) => None): Source[T, NotUsed] =
33-
Source.fromGraph(new ContinuousQuery[S, T](initialState, updateState, delayNextQuery, nextQuery, beforeQuery))
32+
beforeQuery: S => Option[Future[S]] = (_: S) => None,
33+
heartbeat: S => Option[T] = (_: S) => None): Source[T, NotUsed] =
34+
Source.fromGraph(
35+
new ContinuousQuery[S, T](initialState, updateState, delayNextQuery, nextQuery, beforeQuery, heartbeat))
3436

3537
private case object NextQuery
3638

@@ -68,7 +70,8 @@ final private[r2dbc] class ContinuousQuery[S, T](
6870
updateState: (S, T) => S,
6971
delayNextQuery: S => Option[FiniteDuration],
7072
nextQuery: S => (S, Option[Source[T, NotUsed]]),
71-
beforeQuery: S => Option[Future[S]])
73+
beforeQuery: S => Option[Future[S]],
74+
heartbeat: S => Option[T])
7275
extends GraphStage[SourceShape[T]] {
7376
import ContinuousQuery._
7477

@@ -149,8 +152,12 @@ final private[r2dbc] class ContinuousQuery[S, T](
149152
next()
150153
}
151154
})
155+
val sourceWithHeartbeat = heartbeat(newState) match {
156+
case None => source
157+
case Some(h) => Source.single(h).concat(source)
158+
}
152159
val graph = Source
153-
.fromGraph(source)
160+
.fromGraph(sourceWithHeartbeat)
154161
.to(sinkIn.sink)
155162
interpreter.subFusingMaterializer.materialize(graph)
156163
sinkIn.pull()

0 commit comments

Comments
 (0)