4
4
5
5
package akka .persistence .dynamodb .internal
6
6
7
+ import java .time .Clock
7
8
import java .time .Instant
8
9
import java .time .{ Duration => JDuration }
9
10
@@ -26,7 +27,22 @@ import org.slf4j.Logger
26
27
27
28
object QueryState {
28
29
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 )
30
46
}
31
47
32
48
final case class QueryState (
@@ -38,17 +54,26 @@ import org.slf4j.Logger
38
54
backtrackingCount : Int ,
39
55
latestBacktracking : TimestampOffset ,
40
56
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 ) {
42
64
43
65
def backtracking : Boolean = backtrackingCount > 0
44
66
45
67
def currentOffset : TimestampOffset =
46
68
if (backtracking) latestBacktracking
47
69
else latest
48
70
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
52
77
53
78
def nextQueryToTimestamp : Option [Instant ] = {
54
79
if (backtracking) Some (latest.timestamp)
@@ -81,15 +106,18 @@ import org.slf4j.Logger
81
106
dao : BySliceQuery .Dao [Item ],
82
107
createEnvelope : (TimestampOffset , Item ) => Envelope ,
83
108
extractOffset : Envelope => TimestampOffset ,
109
+ createHeartbeat : Instant => Option [Envelope ],
110
+ clock : Clock ,
84
111
settings : DynamoDBSettings ,
85
112
log : Logger )(implicit val ec : ExecutionContext ) {
86
113
import BySliceQuery ._
87
114
import TimestampOffset .toTimestampOffset
88
115
89
116
private val backtrackingWindow = JDuration .ofMillis(settings.querySettings.backtrackingWindow.toMillis)
90
117
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)
93
121
94
122
def currentBySlice (
95
123
logPrefix : String ,
@@ -99,15 +127,17 @@ import org.slf4j.Logger
99
127
filterEventsBeforeSnapshots : (String , Long , String ) => Boolean = (_, _, _) => true ): Source [Envelope , NotUsed ] = {
100
128
val initialOffset = toTimestampOffset(offset)
101
129
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
+ }
104
134
105
135
def nextQuery (state : QueryState , endTimestamp : Instant ): (QueryState , Option [Source [Envelope , NotUsed ]]) = {
106
136
// Note that we can't know how many events with the same timestamp that are filtered out
107
137
// so continue until itemCount is 0. That means an extra query at the end to make sure there are no
108
138
// more to fetch.
109
139
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 )
111
141
112
142
val toTimestamp = newState.nextQueryToTimestamp match {
113
143
case Some (t) =>
@@ -176,41 +206,45 @@ import org.slf4j.Logger
176
206
log.debug(" Starting {} query from slice [{}], from time [{}]." , logPrefix, slice, initialOffset.timestamp)
177
207
178
208
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
184
220
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 )
192
225
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
+ }
196
239
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 )
205
241
}
206
-
207
- state.copy(latest = offset, itemCount = state.itemCount + 1 )
208
242
}
209
243
}
210
244
211
245
def delayNextQuery (state : QueryState ): Option [FiniteDuration ] = {
212
246
if (switchFromBacktracking(state)) {
213
- // switch from from backtracking immediately
247
+ // switch from backtracking immediately
214
248
None
215
249
} else {
216
250
val delay = ContinuousQuery .adjustNextDelay(
@@ -236,20 +270,44 @@ import org.slf4j.Logger
236
270
state.backtracking && state.itemCount < settings.querySettings.bufferSize - state.backtrackingExpectFiltered
237
271
}
238
272
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
+
239
302
def nextQuery (state : QueryState ): (QueryState , Option [Source [Envelope , NotUsed ]]) = {
240
303
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
252
308
309
+ val newState =
310
+ if (switchToBacktracking(state, newIdleCount)) {
253
311
// switching to backtracking
254
312
val fromOffset =
255
313
if (state.latestBacktracking == TimestampOffset .Zero )
@@ -264,28 +322,34 @@ import org.slf4j.Logger
264
322
idleCount = newIdleCount,
265
323
backtrackingCount = 1 ,
266
324
latestBacktracking = fromOffset,
267
- backtrackingExpectFiltered = state.latestBacktrackingSeenCount)
325
+ backtrackingExpectFiltered = state.latestBacktrackingSeenCount,
326
+ currentQueryWallClock = newQueryWallClock,
327
+ previousQueryWallClock = state.currentQueryWallClock)
268
328
} else if (switchFromBacktracking(state)) {
269
- // switch from backtracking
329
+ // switching from backtracking
270
330
state.copy(
271
331
itemCount = 0 ,
272
332
itemCountSinceBacktracking = 0 ,
273
333
queryCount = state.queryCount + 1 ,
274
334
idleCount = newIdleCount,
275
- backtrackingCount = 0 )
335
+ backtrackingCount = 0 ,
336
+ currentQueryWallClock = newQueryWallClock,
337
+ previousQueryWallClock = state.currentQueryWallClock)
276
338
} else {
277
- // continue
339
+ // continuing
278
340
val newBacktrackingCount = if (state.backtracking) state.backtrackingCount + 1 else 0
279
341
state.copy(
280
342
itemCount = 0 ,
281
343
itemCountSinceBacktracking = state.itemCountSinceBacktracking + state.itemCount,
282
344
queryCount = state.queryCount + 1 ,
283
345
idleCount = newIdleCount,
284
346
backtrackingCount = newBacktrackingCount,
285
- backtrackingExpectFiltered = state.latestBacktrackingSeenCount)
347
+ backtrackingExpectFiltered = state.latestBacktrackingSeenCount,
348
+ currentQueryWallClock = newQueryWallClock,
349
+ previousQueryWallClock = state.currentQueryWallClock)
286
350
}
287
351
288
- val fromTimestamp = newState.nextQueryFromTimestamp
352
+ val fromTimestamp = newState.nextQueryFromTimestamp(backtrackingWindow)
289
353
val toTimestamp = {
290
354
val behindCurrentTime =
291
355
if (newState.backtracking) settings.querySettings.backtrackingBehindCurrentTime
@@ -320,7 +384,11 @@ import org.slf4j.Logger
320
384
else s " Found [ ${state.itemCount}] items in previous query. " )
321
385
}
322
386
323
- newState ->
387
+ val newStateWithPrevious =
388
+ if (newState.backtracking) newState.copy(previousBacktracking = newState.latestBacktracking)
389
+ else newState.copy(previous = newState.latest)
390
+
391
+ newStateWithPrevious ->
324
392
Some (
325
393
dao
326
394
.itemsBySlice(entityType, slice, fromTimestamp, toTimestamp, backtracking = newState.backtracking)
@@ -330,12 +398,30 @@ import org.slf4j.Logger
330
398
.via(deserializeAndAddOffset(newState.currentOffset)))
331
399
}
332
400
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
+
333
417
ContinuousQuery [QueryState , Envelope ](
334
- initialState = QueryState .empty.copy(latest = initialOffset),
418
+ initialState = QueryState .empty
419
+ .copy(latest = initialOffset, startTimestamp = currentTimestamp, startWallClock = currentWallClock),
335
420
updateState = nextOffset,
336
421
delayNextQuery = delayNextQuery,
337
422
nextQuery = nextQuery,
338
- beforeQuery = _ => None )
423
+ beforeQuery = _ => None ,
424
+ heartbeat = nextHeartbeat)
339
425
}
340
426
341
427
private def deserializeAndAddOffset (timestampOffset : TimestampOffset ): Flow [Item , Envelope , NotUsed ] = {
0 commit comments