Skip to content

Commit 081276c

Browse files
committed
feat: add payload offloading to GCS read/write paths
via a new payload_link column (currently Spanner only) Closes STOR-578 Closes STOR-579
1 parent 9237698 commit 081276c

23 files changed

Lines changed: 1217 additions & 108 deletions

File tree

Cargo.lock

Lines changed: 654 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

syncserver/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@ actix-http = "3"
3939
actix-rt = "2"
4040
actix-cors = "0.7"
4141
glean = { path = "../glean" }
42+
google-cloud-auth = "1.13"
43+
google-cloud-storage = "1.15"
4244
hawk = "5.0"
4345
mime = "0.3"
46+
wkt = { package = "google-cloud-wkt", version = "1.5" }
4447
# pin to 0.19: https://github.com/getsentry/sentry-rust/issues/277
4548
syncserver-common = { path = "../syncserver-common" }
4649
syncserver-db-common = { path = "../syncserver-db-common" }
@@ -60,6 +63,7 @@ validator_derive = "0.20"
6063
woothee = "0.13"
6164

6265
[dev-dependencies]
66+
httptest = "0.16"
6367
temp-env.workspace = true
6468
tokenserver-auth = { path = "../tokenserver-auth", features = ["test-support"] }
6569

syncserver/src/error.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ pub enum ApiErrorKind {
6969
#[error("{}", _0)]
7070
Db(DbError),
7171

72+
#[error("{}", _0)]
73+
GCS(#[from] google_cloud_storage::Error),
74+
7275
#[error("HAWK authentication error: {}", _0)]
7376
Hawk(HawkError),
7477

@@ -172,7 +175,7 @@ impl From<ApiErrorKind> for ApiError {
172175
let status = match &kind {
173176
ApiErrorKind::Db(error) => error.status,
174177
ApiErrorKind::Hawk(_) => StatusCode::UNAUTHORIZED,
175-
ApiErrorKind::NoServerState | ApiErrorKind::Internal(_) => {
178+
ApiErrorKind::NoServerState | ApiErrorKind::Internal(_) | ApiErrorKind::GCS(_) => {
176179
StatusCode::INTERNAL_SERVER_ERROR
177180
}
178181
ApiErrorKind::Validation(error) => error.status,
@@ -234,6 +237,7 @@ impl Serialize for ApiErrorKind {
234237
{
235238
match *self {
236239
ApiErrorKind::Db(ref error) => serialize_string_to_array(serializer, error),
240+
ApiErrorKind::GCS(ref error) => serialize_string_to_array(serializer, error),
237241
ApiErrorKind::Hawk(ref error) => serialize_string_to_array(serializer, error),
238242
ApiErrorKind::Internal(ref description) => {
239243
serialize_string_to_array(serializer, description)
@@ -268,6 +272,7 @@ impl From<DbError> for ApiError {
268272
}
269273
}
270274

275+
from_error!(google_cloud_storage::Error, ApiError, ApiErrorKind::GCS);
271276
from_error!(HawkError, ApiError, ApiErrorKind::Hawk);
272277
from_error!(ValidationError, ApiError, ApiErrorKind::Validation);
273278

syncserver/src/server/mod.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ pub struct ServerState {
6767
pub glean_logger: Arc<GleanEventsLogger>,
6868

6969
pub glean_enabled: bool,
70+
71+
/// Optional Google Cloud Storage bucket for off-loading BSO payloads.
72+
pub gcs_payload_bucket: Option<String>,
73+
74+
/// Collections whose BSO payloads are off-loaded to GCS.
75+
pub gcs_payload_offload_collections: Arc<Vec<String>>,
76+
77+
/// Override the GCS endpoint URL (for testing). When set the GCS client
78+
/// is built with `.with_endpoint(...)` + anonymous credentials.
79+
/// Debug-builds only; not available in release.
80+
#[cfg(debug_assertions)]
81+
pub gcs_endpoint: Option<String>,
7082
}
7183

7284
pub fn cfg_path(path: &str) -> String {
@@ -379,6 +391,11 @@ impl Server {
379391
app_channel: settings.environment.clone(),
380392
});
381393
let glean_enabled = settings.syncstorage.glean_enabled;
394+
let gcs_payload_bucket = settings.syncstorage.gcs_payload_bucket.clone();
395+
let gcs_payload_offload_collections =
396+
Arc::new(settings.syncstorage.gcs_payload_offload_collections.clone());
397+
#[cfg(debug_assertions)]
398+
let gcs_endpoint = settings.syncstorage.gcs_endpoint.clone();
382399
let worker_thread_count =
383400
calculate_worker_max_blocking_threads(settings.worker_max_blocking_threads);
384401
let limits = Arc::new(settings.syncstorage.limits);
@@ -426,6 +443,10 @@ impl Server {
426443
deadman: Arc::clone(&deadman),
427444
glean_logger: Arc::clone(&glean_logger),
428445
glean_enabled,
446+
gcs_payload_bucket: gcs_payload_bucket.clone(),
447+
gcs_payload_offload_collections: Arc::clone(&gcs_payload_offload_collections),
448+
#[cfg(debug_assertions)]
449+
gcs_endpoint: gcs_endpoint.clone(),
429450
};
430451

431452
build_app!(

syncserver/src/server/test.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ use chrono::offset::Utc;
1414
use hawk::{self, Credentials, Key, RequestBuilder};
1515
use hmac::{Hmac, KeyInit, Mac};
1616
use http::StatusCode;
17+
use httptest::{
18+
Expectation, Server,
19+
matchers::{all_of, contains, matches, request, url_decoded},
20+
responders::status_code,
21+
};
1722
use lazy_static::lazy_static;
1823
use serde::de::DeserializeOwned;
1924
use serde_json::{Value, json};
@@ -113,6 +118,11 @@ async fn get_test_state(settings: &Settings) -> ServerState {
113118
deadman: Arc::new(RwLock::new(Deadman::from(&settings.syncstorage))),
114119
glean_logger,
115120
glean_enabled: settings.syncstorage.glean_enabled,
121+
gcs_payload_bucket: settings.syncstorage.gcs_payload_bucket.clone(),
122+
gcs_payload_offload_collections: Arc::new(
123+
settings.syncstorage.gcs_payload_offload_collections.clone(),
124+
),
125+
gcs_endpoint: settings.syncstorage.gcs_endpoint.clone(),
116126
}
117127
}
118128

@@ -446,6 +456,7 @@ async fn post_collection() {
446456
id: "foo".to_string(),
447457
sortindex: Some(0),
448458
payload: Some("bar".to_string()),
459+
payload_link: None,
449460
ttl: Some(31_536_000),
450461
}]);
451462
let bytes =
@@ -892,3 +903,79 @@ async fn error_endpoint_logging_check() {
892903
)
893904
.await;
894905
}
906+
907+
#[actix_rt::test]
908+
async fn put_bso_offloads_to_gcs() {
909+
let mut settings = get_test_settings();
910+
if !settings.syncstorage.uses_spanner() {
911+
// TODO: should be Spanner only (for now)
912+
return;
913+
}
914+
915+
// Mock GCS: match the JSON multipart upload to bsos's bucket. The matcher
916+
// inspects the multipart body for `committed=false` metadata and a
917+
// `customTime` field; if either is missing the expectation goes
918+
// unsatisfied and Server::Drop panics.
919+
let server = Server::run();
920+
server.expect(
921+
Expectation::matching(all_of![
922+
request::method_path("POST", "/upload/storage/v1/b/test-bucket/o"),
923+
request::query(url_decoded(contains(("uploadType", "multipart")))),
924+
request::body(matches(r#""committed""#)),
925+
request::body(matches(r#""false""#)),
926+
request::body(matches(r#""customTime""#)),
927+
])
928+
.respond_with(
929+
status_code(200)
930+
.append_header("content-type", "application/json")
931+
.body(
932+
serde_json::json!({
933+
"name": "test-object",
934+
"bucket": "test-bucket",
935+
})
936+
.to_string(),
937+
),
938+
),
939+
);
940+
941+
settings.syncstorage.gcs_payload_bucket = Some("test-bucket".to_owned());
942+
settings.syncstorage.gcs_payload_offload_collections = vec!["bookmarks".to_owned()];
943+
settings.syncstorage.gcs_endpoint = Some(format!("http://{}", server.addr()));
944+
945+
let app = init_app!(settings).await;
946+
947+
let req = create_request(
948+
http::Method::PUT,
949+
"/1.5/42/storage/bookmarks/wibble",
950+
None,
951+
Some(json!({ "payload": "hello world" })),
952+
)
953+
.to_request();
954+
let resp = app
955+
.call(req)
956+
.await
957+
.expect("Could not get resp in put_bso_offloads_to_gcs");
958+
assert!(
959+
resp.response().status().is_success(),
960+
"put_bso failed: {:?}",
961+
resp.response()
962+
);
963+
964+
// mock shouldn't see tabs offloaded to GCS
965+
let req = create_request(
966+
http::Method::PUT,
967+
"/1.5/42/storage/tabs/wobble",
968+
None,
969+
Some(json!({ "payload": "hello world" })),
970+
)
971+
.to_request();
972+
let resp = app
973+
.call(req)
974+
.await
975+
.expect("Could not get resp in put_bso_offloads_to_gcs");
976+
assert!(
977+
resp.response().status().is_success(),
978+
"put_bso failed: {:?}",
979+
resp.response()
980+
);
981+
}

syncserver/src/web/extractors/batch_bso_body.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ pub struct BatchBsoBody {
1515
pub payload: Option<String>,
1616
#[validate(custom(function = "validate_body_bso_ttl"))]
1717
pub ttl: Option<u32>,
18+
/// Populated server-side after a successful GCS payload upload; never
19+
/// accepted from the client.
20+
#[serde(default, skip_deserializing)]
21+
pub payload_link: Option<String>,
1822
}
1923

2024
impl BatchBsoBody {
@@ -50,6 +54,7 @@ impl From<BatchBsoBody> for PostCollectionBso {
5054
id: b.id,
5155
sortindex: b.sortindex,
5256
payload: b.payload,
57+
payload_link: b.payload_link,
5358
ttl: b.ttl,
5459
}
5560
}

syncserver/src/web/extractors/test_utils.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ pub fn make_state() -> ServerState {
6868
deadman: Arc::new(RwLock::new(Deadman::default())),
6969
glean_logger,
7070
glean_enabled: syncstorage_settings.glean_enabled,
71+
gcs_payload_bucket: None,
72+
gcs_payload_offload_collections: Arc::new(Vec::new()),
73+
#[cfg(debug_assertions)]
74+
gcs_endpoint: None,
7175
}
7276
}
7377

0 commit comments

Comments
 (0)