Skip to content

Commit ea480b2

Browse files
committed
feat: add TimestampOrderChecker, out of order and duplicate timestamps
Windowed algorithms assume time moves forward. When it does not, nothing fails loudly: a window holds samples that never coexisted, a rate is computed over a negative interval, a duplicate is counted twice. Retries, buffers flushed after a reconnect, several producers writing into one topic and a clock stepped backwards all cause this, and the cheap defence is to check the order explicitly. Each timestamp is compared with the previous one and classified as INCREASING, DUPLICATE or BACKWARDS. The checker only reports, it does not reorder or drop anything, so lastTimestamp is always the most recent sample, not the largest. It also keeps the worst backwards step, which is what you need to size a reordering buffer. O(1) time per sample and O(1) state; non-finite timestamps are refused. Tests: the first timestamp is always accepted, an ordered stream has no violations, duplicates and backwards steps are counted separately, the worst step is the largest one and not the last one, the stream is not reordered, scan returns one verdict per timestamp, and reset restores the initial state. Co-authored-by: Oleksandr Klymenko <19151554+alxkm@users.noreply.github.com> Signed-off-by: alxkm <19151554+alxkm@users.noreply.github.com>
1 parent 8b19b87 commit ea480b2

2 files changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package com.thealgorithms.streaming;
2+
3+
/**
4+
* Watches the timestamps of a stream for samples that arrive out of order or twice.
5+
*
6+
* <p>Every windowed algorithm assumes that time moves forward. When it does not, nothing crashes:
7+
* the window simply holds samples that never coexisted, a rate is computed over a negative interval,
8+
* and a duplicate counts twice in an average. Retries, buffers flushed after a reconnect, several
9+
* producers writing into one topic and a clock stepped backwards all produce exactly that, and the
10+
* only cheap defence is to check the order explicitly.
11+
*
12+
* <p>Each timestamp is compared with the one immediately before it and classified as
13+
* {@link Order#INCREASING}, {@link Order#DUPLICATE} or {@link Order#BACKWARDS}. The checker reports,
14+
* it does not reorder or drop anything, so {@link #lastTimestamp()} is always the most recent sample
15+
* and not the largest one seen.
16+
*
17+
* <p>Besides the counters it keeps the worst backwards step, which is what a caller needs in order to
18+
* size a reordering buffer: a stream whose worst violation is 20 milliseconds can be repaired by
19+
* holding samples for 20 milliseconds, while one that jumps back an hour cannot be repaired at all.
20+
*
21+
* <h2>Usage</h2>
22+
*
23+
* <pre>{@code
24+
* TimestampOrderChecker checker = new TimestampOrderChecker();
25+
* for (double timestamp : timestamps) {
26+
* if (checker.accept(timestamp).isViolation()) {
27+
* quarantine(timestamp);
28+
* }
29+
* }
30+
* }</pre>
31+
*
32+
* <p>Each sample costs O(1) time and the checker keeps O(1) state. This class is not thread-safe.
33+
*
34+
* @see <a href="https://en.wikipedia.org/wiki/Monotonic_function">Monotonic function</a>
35+
*/
36+
public final class TimestampOrderChecker {
37+
38+
private double lastTimestamp;
39+
private double worstBackwardsStep;
40+
private boolean started;
41+
private long count;
42+
private long duplicateCount;
43+
private long backwardsCount;
44+
private Order lastOrder = Order.INCREASING;
45+
46+
/**
47+
* Feeds the timestamp of one sample.
48+
*
49+
* @param timestamp when the sample arrived
50+
* @return how this timestamp relates to the previous one; the first one is always
51+
* {@link Order#INCREASING}
52+
* @throws IllegalArgumentException if {@code timestamp} is NaN or infinite
53+
*/
54+
public Order accept(double timestamp) {
55+
if (!Double.isFinite(timestamp)) {
56+
throw new IllegalArgumentException("Timestamps must be finite, but was " + timestamp);
57+
}
58+
59+
Order order = Order.INCREASING;
60+
if (started) {
61+
if (timestamp == lastTimestamp) {
62+
order = Order.DUPLICATE;
63+
duplicateCount++;
64+
} else if (timestamp < lastTimestamp) {
65+
order = Order.BACKWARDS;
66+
backwardsCount++;
67+
worstBackwardsStep = Math.max(worstBackwardsStep, lastTimestamp - timestamp);
68+
}
69+
}
70+
71+
lastTimestamp = timestamp;
72+
started = true;
73+
count++;
74+
lastOrder = order;
75+
return order;
76+
}
77+
78+
/**
79+
* Runs the checker over a whole series of timestamps.
80+
*
81+
* @param timestamps the timestamps to inspect
82+
* @return a new array of the same length holding the verdict for every timestamp
83+
* @throws IllegalArgumentException if a timestamp is NaN or infinite
84+
* @throws NullPointerException if {@code timestamps} is {@code null}
85+
*/
86+
public Order[] scan(double[] timestamps) {
87+
Order[] verdicts = new Order[timestamps.length];
88+
for (int i = 0; i < timestamps.length; i++) {
89+
verdicts[i] = accept(timestamps[i]);
90+
}
91+
return verdicts;
92+
}
93+
94+
/**
95+
* Returns the verdict on the most recent timestamp.
96+
*
97+
* @return the last verdict, {@link Order#INCREASING} before the first timestamp
98+
*/
99+
public Order lastOrder() {
100+
return lastOrder;
101+
}
102+
103+
/**
104+
* Tells whether every timestamp so far moved forward.
105+
*
106+
* @return {@code true} if neither a duplicate nor a backwards step has been seen
107+
*/
108+
public boolean isOrdered() {
109+
return duplicateCount == 0 && backwardsCount == 0;
110+
}
111+
112+
/**
113+
* Returns the timestamp of the most recent sample, which is not necessarily the largest one seen.
114+
*
115+
* @return the last timestamp
116+
* @throws IllegalStateException if no timestamp has been accepted yet
117+
*/
118+
public double lastTimestamp() {
119+
if (!started) {
120+
throw new IllegalStateException("The checker has not seen any timestamp yet");
121+
}
122+
return lastTimestamp;
123+
}
124+
125+
/**
126+
* Returns the largest distance a timestamp ever jumped backwards, which is how long a reordering
127+
* buffer would have to hold a sample to repair this stream.
128+
*
129+
* @return the worst backwards step, {@code 0} if the stream never went backwards
130+
*/
131+
public double worstBackwardsStep() {
132+
return worstBackwardsStep;
133+
}
134+
135+
/**
136+
* Returns how many timestamps have been inspected since the last reset.
137+
*
138+
* @return the sample count
139+
*/
140+
public long count() {
141+
return count;
142+
}
143+
144+
/**
145+
* Returns how many timestamps repeated the one before them.
146+
*
147+
* @return the duplicate count
148+
*/
149+
public long duplicateCount() {
150+
return duplicateCount;
151+
}
152+
153+
/**
154+
* Returns how many timestamps were smaller than the one before them.
155+
*
156+
* @return the backwards count
157+
*/
158+
public long backwardsCount() {
159+
return backwardsCount;
160+
}
161+
162+
/**
163+
* Forgets every timestamp seen so far.
164+
*/
165+
public void reset() {
166+
lastTimestamp = 0.0;
167+
worstBackwardsStep = 0.0;
168+
started = false;
169+
count = 0;
170+
duplicateCount = 0;
171+
backwardsCount = 0;
172+
lastOrder = Order.INCREASING;
173+
}
174+
175+
@Override
176+
public String toString() {
177+
return "TimestampOrderChecker{samples=" + count + ", duplicates=" + duplicateCount + ", backwards=" + backwardsCount + ", worstStep=" + worstBackwardsStep + "}";
178+
}
179+
180+
/**
181+
* How a timestamp relates to the one before it.
182+
*/
183+
public enum Order {
184+
185+
/** The timestamp moved forward, which is the only healthy case. */
186+
INCREASING,
187+
188+
/** The timestamp repeats the previous one. */
189+
DUPLICATE,
190+
191+
/** The timestamp is older than the previous one. */
192+
BACKWARDS;
193+
194+
/**
195+
* Tells whether this verdict reports a broken timeline.
196+
*
197+
* @return {@code true} for {@link #DUPLICATE} and {@link #BACKWARDS}
198+
*/
199+
public boolean isViolation() {
200+
return this != INCREASING;
201+
}
202+
}
203+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package com.thealgorithms.streaming;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
6+
import static org.junit.jupiter.api.Assertions.assertTrue;
7+
8+
import com.thealgorithms.streaming.TimestampOrderChecker.Order;
9+
import org.junit.jupiter.api.Assertions;
10+
import org.junit.jupiter.api.DisplayName;
11+
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.params.ParameterizedTest;
13+
import org.junit.jupiter.params.provider.ValueSource;
14+
15+
class TimestampOrderCheckerTest {
16+
17+
@ParameterizedTest
18+
@ValueSource(doubles = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY})
19+
void rejectsNonFiniteTimestamps(double timestamp) {
20+
TimestampOrderChecker checker = new TimestampOrderChecker();
21+
assertThrows(IllegalArgumentException.class, () -> checker.accept(timestamp));
22+
}
23+
24+
@Test
25+
void startsEmpty() {
26+
TimestampOrderChecker checker = new TimestampOrderChecker();
27+
28+
assertEquals(0, checker.count());
29+
assertEquals(Order.INCREASING, checker.lastOrder());
30+
assertTrue(checker.isOrdered());
31+
assertEquals(0.0, checker.worstBackwardsStep());
32+
assertThrows(IllegalStateException.class, checker::lastTimestamp);
33+
}
34+
35+
@Test
36+
void theFirstTimestampIsAlwaysAccepted() {
37+
TimestampOrderChecker checker = new TimestampOrderChecker();
38+
39+
assertEquals(Order.INCREASING, checker.accept(-500.0));
40+
assertEquals(-500.0, checker.lastTimestamp());
41+
}
42+
43+
@Test
44+
void anOrderedStreamHasNoViolations() {
45+
TimestampOrderChecker checker = new TimestampOrderChecker();
46+
47+
for (int i = 0; i < 20; i++) {
48+
assertEquals(Order.INCREASING, checker.accept(i));
49+
}
50+
assertTrue(checker.isOrdered());
51+
assertEquals(20, checker.count());
52+
}
53+
54+
@Test
55+
void reportsADuplicate() {
56+
TimestampOrderChecker checker = new TimestampOrderChecker();
57+
checker.accept(10.0);
58+
59+
assertEquals(Order.DUPLICATE, checker.accept(10.0));
60+
assertEquals(1, checker.duplicateCount());
61+
assertEquals(0, checker.backwardsCount());
62+
assertFalse(checker.isOrdered());
63+
}
64+
65+
@Test
66+
void reportsAStepBackwards() {
67+
TimestampOrderChecker checker = new TimestampOrderChecker();
68+
checker.accept(10.0);
69+
70+
assertEquals(Order.BACKWARDS, checker.accept(7.5));
71+
assertEquals(1, checker.backwardsCount());
72+
assertEquals(2.5, checker.worstBackwardsStep());
73+
}
74+
75+
@Test
76+
@DisplayName("the worst backwards step is the largest one, not the last one")
77+
void keepsTheWorstBackwardsStep() {
78+
TimestampOrderChecker checker = new TimestampOrderChecker();
79+
80+
checker.scan(new double[] {100.0, 60.0, 61.0, 59.0, 200.0, 199.0});
81+
82+
assertEquals(3, checker.backwardsCount());
83+
assertEquals(40.0, checker.worstBackwardsStep());
84+
}
85+
86+
@Test
87+
@DisplayName("the checker reports without reordering, so the last timestamp is the most recent one")
88+
void doesNotReorderTheStream() {
89+
TimestampOrderChecker checker = new TimestampOrderChecker();
90+
91+
checker.accept(100.0);
92+
checker.accept(50.0);
93+
94+
assertEquals(50.0, checker.lastTimestamp());
95+
assertEquals(Order.INCREASING, checker.accept(60.0), "the next sample is compared with the previous one, not with the maximum");
96+
}
97+
98+
@Test
99+
void scanReportsOneVerdictPerTimestamp() {
100+
TimestampOrderChecker checker = new TimestampOrderChecker();
101+
102+
Order[] verdicts = checker.scan(new double[] {1.0, 2.0, 2.0, 1.5, 3.0});
103+
104+
Assertions.assertArrayEquals(new Order[] {Order.INCREASING, Order.INCREASING, Order.DUPLICATE, Order.BACKWARDS, Order.INCREASING}, verdicts);
105+
}
106+
107+
@Test
108+
void theVerdictKnowsWhetherItIsAViolation() {
109+
assertFalse(Order.INCREASING.isViolation());
110+
assertTrue(Order.DUPLICATE.isViolation());
111+
assertTrue(Order.BACKWARDS.isViolation());
112+
}
113+
114+
@Test
115+
void resetRestoresTheInitialState() {
116+
TimestampOrderChecker checker = new TimestampOrderChecker();
117+
checker.scan(new double[] {5.0, 4.0, 4.0});
118+
119+
checker.reset();
120+
121+
assertEquals(0, checker.count());
122+
assertEquals(0, checker.duplicateCount());
123+
assertEquals(0, checker.backwardsCount());
124+
assertEquals(0.0, checker.worstBackwardsStep());
125+
assertTrue(checker.isOrdered());
126+
assertThrows(IllegalStateException.class, checker::lastTimestamp);
127+
}
128+
129+
@Test
130+
void toStringMentionsTheState() {
131+
TimestampOrderChecker checker = new TimestampOrderChecker();
132+
checker.scan(new double[] {5.0, 4.0});
133+
134+
String text = checker.toString();
135+
136+
assertTrue(text.contains("TimestampOrderChecker"));
137+
assertTrue(text.contains("backwards=1"));
138+
}
139+
}

0 commit comments

Comments
 (0)