Skip to content

Commit 4023289

Browse files
committed
feat: (re-)enable timestamp+offset based pagination optimization
#53 enabled #559 disabled And this patch enables it for MySQL and Postgres but not Spanner.
1 parent 36e5683 commit 4023289

6 files changed

Lines changed: 286 additions & 83 deletions

File tree

syncserver/src/web/extractors/bso_query_params.rs

Lines changed: 72 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,6 @@ impl From<Offset> for params::Offset {
3232
impl FromStr for Offset {
3333
type Err = ParseIntError;
3434
fn from_str(s: &str) -> Result<Self, Self::Err> {
35-
// issue559: Disable ':' support for now: simply parse as i64 as
36-
// previously (it was u64 previously but i64's close enough)
37-
let result = Offset {
38-
timestamp: None,
39-
offset: s.parse::<u64>()?,
40-
};
41-
/*
4235
let result = match s.chars().position(|c| c == ':') {
4336
None => Offset {
4437
timestamp: None,
@@ -55,7 +48,6 @@ impl FromStr for Offset {
5548
}
5649
}
5750
};
58-
*/
5951
Ok(result)
6052
}
6153
}
@@ -201,36 +193,34 @@ impl FromRequest for BsoQueryParams {
201193
None,
202194
)
203195
})?;
204-
// issue559: Dead code (timestamp always None)
205-
/*
206-
if params.sort != Sorting::Index {
207-
if let Some(timestamp) = params.offset.as_ref().and_then(|offset| offset.timestamp)
208-
{
209-
let bound = timestamp.as_i64();
210-
if let Some(newer) = params.newer {
211-
if bound < newer.as_i64() {
212-
return Err(ValidationErrorKind::FromDetails(
213-
format!("Invalid Offset {} {}", bound, newer.as_i64()),
214-
RequestErrorLocation::QueryString,
215-
Some("newer".to_owned()),
216-
None,
217-
)
218-
.into());
219-
}
220-
} else if let Some(older) = params.older {
221-
if bound > older.as_i64() {
222-
return Err(ValidationErrorKind::FromDetails(
223-
"Invalid Offset".to_owned(),
224-
RequestErrorLocation::QueryString,
225-
Some("older".to_owned()),
226-
None,
227-
)
228-
.into());
229-
}
196+
197+
if params.sort != Sorting::Index
198+
&& let Some(timestamp) = params.offset.as_ref().and_then(|offset| offset.timestamp)
199+
{
200+
let bound = timestamp.as_i64();
201+
if let Some(newer) = params.newer {
202+
if bound < newer.as_i64() {
203+
return Err(ValidationErrorKind::FromDetails(
204+
format!("Invalid Offset {} {}", bound, newer.as_i64()),
205+
RequestErrorLocation::QueryString,
206+
Some("newer".to_owned()),
207+
None,
208+
)
209+
.into());
230210
}
211+
} else if let Some(older) = params.older
212+
&& bound > older.as_i64()
213+
{
214+
return Err(ValidationErrorKind::FromDetails(
215+
"Invalid Offset".to_owned(),
216+
RequestErrorLocation::QueryString,
217+
Some("older".to_owned()),
218+
None,
219+
)
220+
.into());
231221
}
232222
}
233-
*/
223+
234224
Ok(params)
235225
})
236226
}
@@ -288,19 +278,60 @@ mod tests {
288278
assert!(result.full);
289279
}
290280

281+
#[test]
282+
fn test_offset_bound_below_newer() {
283+
let state = make_state();
284+
let req = TestRequest::with_uri("/?sort=newest&newer=2.22&offset=1111:1")
285+
.data(state)
286+
.to_http_request();
287+
let result = block_on(BsoQueryParams::extract(&req));
288+
assert!(result.is_err());
289+
let resp: HttpResponse = result.err().unwrap().into();
290+
assert_eq!(resp.status(), 400);
291+
}
292+
293+
#[test]
294+
fn test_offset_bound_above_older() {
295+
let state = make_state();
296+
let req = TestRequest::with_uri("/?sort=newest&older=2.22&offset=5858:1")
297+
.data(state)
298+
.to_http_request();
299+
let result = block_on(BsoQueryParams::extract(&req));
300+
assert!(result.is_err());
301+
let resp: HttpResponse = result.err().unwrap().into();
302+
assert_eq!(resp.status(), 400);
303+
}
304+
305+
#[test]
306+
fn test_offset_bound_within_range() {
307+
let state = make_state();
308+
let req = TestRequest::with_uri("/?sort=newest&newer=1.23&older=5.43&offset=3838:1")
309+
.data(state)
310+
.to_http_request();
311+
let result = block_on(BsoQueryParams::extract(&req));
312+
assert!(result.is_ok());
313+
}
314+
315+
#[test]
316+
fn test_bound_validation_skipped_for_index_sort() {
317+
let state = make_state();
318+
let req = TestRequest::with_uri("/?sort=index&newer=2.22&offset=1111:1")
319+
.data(state)
320+
.to_http_request();
321+
let result = block_on(BsoQueryParams::extract(&req));
322+
assert!(result.is_ok());
323+
}
324+
291325
#[actix_rt::test]
292326
async fn test_offset() {
293327
let sample_offset = params::Offset {
294328
timestamp: Some(SyncTimestamp::default()),
295329
offset: 1234,
296330
};
297331

298-
let test_offset = Offset {
299-
timestamp: None,
300-
offset: sample_offset.offset,
301-
};
302-
303332
let offset_str = sample_offset.to_string();
304-
assert!(test_offset == Offset::from_str(&offset_str).unwrap())
333+
let parsed = Offset::from_str(&offset_str).unwrap();
334+
assert_eq!(parsed.offset, sample_offset.offset);
335+
assert_eq!(parsed.timestamp, sample_offset.timestamp,);
305336
}
306337
}

syncstorage-db-common/src/params.rs

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -68,27 +68,16 @@ pub struct Offset {
6868

6969
impl Display for Offset {
7070
fn fmt(&self, fmt: &mut Formatter) -> Result<(), fmt::Error> {
71-
// issue559: Disable ':' support for now.
72-
write!(fmt, "{}", self.offset)
73-
/*
7471
match self.timestamp {
75-
None => self.offset.to_string(),
76-
Some(ts) => format!("{}:{}", ts.as_i64(), self.offset),
72+
None => write!(fmt, "{}", self.offset),
73+
Some(ts) => write!(fmt, "{}:{}", ts.as_i64(), self.offset),
7774
}
78-
*/
7975
}
8076
}
8177

8278
impl FromStr for Offset {
8379
type Err = ParseIntError;
8480
fn from_str(s: &str) -> Result<Self, Self::Err> {
85-
// issue559: Disable ':' support for now: simply parse as i64 as
86-
// previously (it was u64 previously but i64's close enough)
87-
let result = Offset {
88-
timestamp: None,
89-
offset: s.parse::<u64>()?,
90-
};
91-
/*
9281
let result = match s.chars().position(|c| c == ':') {
9382
None => Offset {
9483
timestamp: None,
@@ -105,11 +94,64 @@ impl FromStr for Offset {
10594
}
10695
}
10796
};
108-
*/
10997
Ok(result)
11098
}
11199
}
112100

101+
#[cfg(test)]
102+
mod tests {
103+
use std::str::FromStr;
104+
105+
use super::Offset;
106+
use crate::util::SyncTimestamp;
107+
108+
#[test]
109+
fn offset_display_without_timestamp() {
110+
let offset = Offset {
111+
timestamp: None,
112+
offset: 50,
113+
};
114+
assert_eq!(offset.to_string(), "50");
115+
}
116+
117+
#[test]
118+
fn offset_display_with_timestamp() {
119+
let offset = Offset {
120+
timestamp: Some(SyncTimestamp::from_milliseconds(676760)),
121+
offset: 2,
122+
};
123+
assert_eq!(offset.to_string(), "676760:2");
124+
}
125+
126+
#[test]
127+
fn offset_without_timestamp_parsed() {
128+
let original = Offset {
129+
timestamp: None,
130+
offset: 99,
131+
};
132+
let parsed = Offset::from_str(&original.to_string()).unwrap();
133+
assert_eq!(parsed.offset, original.offset);
134+
assert!(parsed.timestamp.is_none());
135+
}
136+
137+
#[test]
138+
fn offset_with_timestamp_parsed() {
139+
let original = Offset {
140+
timestamp: Some(SyncTimestamp::from_milliseconds(71138383830)),
141+
offset: 3,
142+
};
143+
let parsed = Offset::from_str(&original.to_string()).unwrap();
144+
assert_eq!(parsed.offset, original.offset);
145+
assert_eq!(parsed.timestamp, original.timestamp);
146+
}
147+
148+
#[test]
149+
fn offset_fromstr_malformed_returns_error() {
150+
assert!(Offset::from_str("quux").is_err());
151+
assert!(Offset::from_str("wibble:buzz").is_err());
152+
}
153+
}
154+
113155
collection_data! {
114156
LockCollection {},
115157
DeleteCollection {},

syncstorage-db-common/src/util.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use diesel::{
1515
use serde::{Deserialize, Deserializer, Serialize, Serializer, ser};
1616

1717
use super::error::SyncstorageDbError;
18+
use crate::{Sorting, params};
1819

1920
/// Get the time since the UNIX epoch in milliseconds
2021
fn ms_since_epoch() -> i64 {
@@ -215,6 +216,39 @@ pub fn to_rfc3339(val: i64) -> Result<String, SyncstorageDbError> {
215216
)))
216217
}
217218

219+
/// Encode a timestamp and the number of rows to skip for BSO query pagination.
220+
pub fn encode_next_offset(
221+
sort: Sorting,
222+
prev_offset: u64,
223+
prev_timestamp: Option<i64>,
224+
modified_timestamps: &[i64],
225+
) -> String {
226+
if let Sorting::Index = sort {
227+
return (prev_offset + modified_timestamps.len() as u64).to_string();
228+
}
229+
if modified_timestamps.is_empty() {
230+
return prev_offset.to_string();
231+
}
232+
233+
let bound = *modified_timestamps.last().unwrap();
234+
let mut skip = 1usize;
235+
236+
skip += modified_timestamps[..modified_timestamps.len() - 1]
237+
.iter()
238+
.rev()
239+
.take_while(|&&m| m == bound)
240+
.count();
241+
if skip == modified_timestamps.len() && prev_timestamp == Some(bound) {
242+
skip += prev_offset as usize;
243+
}
244+
245+
params::Offset {
246+
timestamp: Some(SyncTimestamp::from_milliseconds(bound as u64)),
247+
offset: skip as u64,
248+
}
249+
.to_string()
250+
}
251+
218252
#[cfg(test)]
219253
mod tests {
220254
use std::error::Error;
@@ -262,4 +296,58 @@ mod tests {
262296
assert_eq!(zero, SyncTimestamp::from_i64(0).unwrap());
263297
assert_eq!(zero, SyncTimestamp::from_seconds(0.00));
264298
}
299+
300+
mod encode_next_offset_tests {
301+
use crate::Sorting;
302+
use crate::util::encode_next_offset;
303+
304+
#[test]
305+
fn index_sort_returns_numeric_offset() {
306+
let result = encode_next_offset(Sorting::Index, 50, None, &[19, 83, 747]);
307+
assert_eq!(result, "53");
308+
}
309+
310+
#[test]
311+
fn empty_modified_timestamps_returns_prev_offset() {
312+
let result = encode_next_offset(Sorting::Newest, 42, None, &[]);
313+
assert_eq!(result, "42");
314+
}
315+
316+
#[test]
317+
fn unique_last_timestamp_skip_is_one() {
318+
let result = encode_next_offset(Sorting::Newest, 0, None, &[5500, 4200, 3800]);
319+
assert_eq!(result, "3800:1");
320+
}
321+
322+
#[test]
323+
fn skip_counts_identical_tail_timestamps() {
324+
let result = encode_next_offset(Sorting::Newest, 0, None, &[5000, 3800, 3800]);
325+
assert_eq!(result, "3800:2");
326+
}
327+
328+
#[test]
329+
fn identical_timestamps_no_prev_bound_match() {
330+
let result = encode_next_offset(Sorting::Newest, 0, Some(2048), &[3800, 3800, 3800]);
331+
assert_eq!(result, "3800:3");
332+
}
333+
334+
#[test]
335+
fn identical_timestamps_with_matching_prev_bound_sums() {
336+
let result = encode_next_offset(Sorting::Newest, 2, Some(9000), &[9000, 9000, 9000]);
337+
assert_eq!(result, "9000:5");
338+
}
339+
340+
#[test]
341+
fn oldest_sort_works() {
342+
let result = encode_next_offset(Sorting::Oldest, 0, None, &[8900, 9000, 9100]);
343+
assert_eq!(result, "9100:1");
344+
}
345+
346+
#[test]
347+
fn none_sort_produces_timestamp_token() {
348+
// Sorting::None behaves like Newest
349+
let result = encode_next_offset(Sorting::None, 0, None, &[5500, 4200, 3800]);
350+
assert_eq!(result, "3800:1");
351+
}
352+
}
265353
}

0 commit comments

Comments
 (0)