max / makenotwork
6 files changed,
+246 insertions,
-13 deletions
| @@ -24,7 +24,7 @@ pub(crate) mod auth; | |||
| 24 | 24 | pub mod waitlist; | |
| 25 | 25 | pub(crate) mod blog_posts; | |
| 26 | 26 | pub(crate) mod license_keys; | |
| 27 | - | pub(crate) mod synckit; | |
| 27 | + | pub mod synckit; // pub so the integration test crate can exercise compaction directly | |
| 28 | 28 | pub mod synckit_billing; | |
| 29 | 29 | pub(crate) mod oauth; | |
| 30 | 30 | pub(crate) mod promo_codes; |
| @@ -274,6 +274,92 @@ pub async fn confirm_developer_blob( | |||
| 274 | 274 | Ok(BlobConfirm::Stored) | |
| 275 | 275 | } | |
| 276 | 276 | ||
| 277 | + | /// Outcome of a blob delete. Terminal for one call. | |
| 278 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 279 | + | pub enum BlobDelete { | |
| 280 | + | /// Row removed; S3 object dead-lettered; usage counters refunded. | |
| 281 | + | Deleted { size_bytes: i64 }, | |
| 282 | + | /// No blob with that hash for this `(app, user)` — idempotent no-op. | |
| 283 | + | NotFound, | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | /// Delete a blob by hash for a user within an app. Atomic in one transaction: | |
| 287 | + | /// removes the `sync_blobs` row, refunds the developer-billing counters, and | |
| 288 | + | /// dead-letters the S3 object via `pending_s3_deletions`. This is the sole | |
| 289 | + | /// shrink path for storage usage that doesn't wait on the weekly drift job — | |
| 290 | + | /// and because the row delete, the counter refund, and the S3-delete enqueue | |
| 291 | + | /// commit together, a delete can never leave `bytes_stored` overstating reality | |
| 292 | + | /// or orphan the object on a mid-operation crash. | |
| 293 | + | /// | |
| 294 | + | /// Internal (first-party) apps keep no counter — `confirm_internal_blob` sums | |
| 295 | + | /// usage straight from `sync_blobs` — so for them the counter UPDATEs match no | |
| 296 | + | /// row and are harmless no-ops; removing the row is the whole refund. Idempotent: | |
| 297 | + | /// deleting an absent blob returns `NotFound` with nothing written. | |
| 298 | + | #[tracing::instrument(skip_all)] | |
| 299 | + | pub async fn delete_sync_blob( | |
| 300 | + | pool: &PgPool, | |
| 301 | + | app_id: SyncAppId, | |
| 302 | + | user_id: UserId, | |
| 303 | + | hash: &str, | |
| 304 | + | ) -> Result<BlobDelete> { | |
| 305 | + | let mut tx = pool.begin().await?; | |
| 306 | + | ||
| 307 | + | // Remove the row, capturing what we need to refund counters and dead-letter | |
| 308 | + | // the object. SyncKit blob keys are per-`(app, user, hash)` (no cross-user | |
| 309 | + | // dedup), so deleting this row's object frees only this user's copy. | |
| 310 | + | let row: Option<(i64, String, String)> = sqlx::query_as( | |
| 311 | + | "DELETE FROM sync_blobs WHERE app_id = $1 AND user_id = $2 AND hash = $3 | |
| 312 | + | RETURNING size_bytes, s3_key, key", | |
| 313 | + | ) | |
| 314 | + | .bind(app_id) | |
| 315 | + | .bind(user_id) | |
| 316 | + | .bind(hash) | |
| 317 | + | .fetch_optional(&mut *tx) | |
| 318 | + | .await?; | |
| 319 | + | ||
| 320 | + | let Some((size_bytes, s3_key, key)) = row else { | |
| 321 | + | tx.commit().await?; | |
| 322 | + | return Ok(BlobDelete::NotFound); | |
| 323 | + | }; | |
| 324 | + | ||
| 325 | + | // Refund the developer-billing counters. `GREATEST(_, 0)` floors at zero so | |
| 326 | + | // a drifted counter can never go negative; internal apps have no usage row | |
| 327 | + | // and these UPDATEs touch nothing. | |
| 328 | + | sqlx::query( | |
| 329 | + | "UPDATE sync_app_usage_current | |
| 330 | + | SET bytes_stored = GREATEST(bytes_stored - $2, 0), updated_at = NOW() | |
| 331 | + | WHERE app_id = $1", | |
| 332 | + | ) | |
| 333 | + | .bind(app_id) | |
| 334 | + | .bind(size_bytes) | |
| 335 | + | .execute(&mut *tx) | |
| 336 | + | .await?; | |
| 337 | + | sqlx::query( | |
| 338 | + | "UPDATE sync_key_usage_current | |
| 339 | + | SET bytes_stored = GREATEST(bytes_stored - $3, 0), updated_at = NOW() | |
| 340 | + | WHERE app_id = $1 AND key = $2", | |
| 341 | + | ) | |
| 342 | + | .bind(app_id) | |
| 343 | + | .bind(&key) | |
| 344 | + | .bind(size_bytes) | |
| 345 | + | .execute(&mut *tx) | |
| 346 | + | .await?; | |
| 347 | + | ||
| 348 | + | // Dead-letter the object in the SAME tx as the row delete + refund (model: | |
| 349 | + | // `versions::delete_version`). After commit the row is gone, so the key is | |
| 350 | + | // non-live and the deletion worker (`retry_pending_s3_deletions`, which | |
| 351 | + | // routes `bucket = "synckit"` to `synckit_s3`) can act on it. | |
| 352 | + | crate::db::pending_s3_deletions::enqueue_deletions( | |
| 353 | + | &mut *tx, | |
| 354 | + | &[(s3_key, "synckit".to_string())], | |
| 355 | + | "synckit_blob_delete", | |
| 356 | + | ) | |
| 357 | + | .await?; | |
| 358 | + | ||
| 359 | + | tx.commit().await?; | |
| 360 | + | Ok(BlobDelete::Deleted { size_bytes }) | |
| 361 | + | } | |
| 362 | + | ||
| 277 | 363 | /// Count devices registered for a user/app pair. | |
| 278 | 364 | #[tracing::instrument(skip_all)] | |
| 279 | 365 | pub async fn count_sync_devices( |
| @@ -123,7 +123,7 @@ pub async fn prune_sync_log(pool: &PgPool, retain_days: i64) -> Result<u64> { | |||
| 123 | 123 | return Ok(0); | |
| 124 | 124 | } | |
| 125 | 125 | let result = sqlx::query( | |
| 126 | - | "DELETE FROM sync_log WHERE created_at < NOW() - make_interval(days => $1)", | |
| 126 | + | "DELETE FROM sync_log WHERE created_at < NOW() - make_interval(days => $1::int)", | |
| 127 | 127 | ) | |
| 128 | 128 | .bind(retain_days) | |
| 129 | 129 | .execute(pool) | |
| @@ -187,7 +187,7 @@ pub async fn compact_sync_log( | |||
| 187 | 187 | DELETE FROM sync_log | |
| 188 | 188 | WHERE app_id = $1 AND user_id = $2 | |
| 189 | 189 | AND seq <= $3 | |
| 190 | - | AND created_at < NOW() - make_interval(days => $4) | |
| 190 | + | AND created_at < NOW() - make_interval(days => $4::int) | |
| 191 | 191 | "#, | |
| 192 | 192 | ) | |
| 193 | 193 | .bind(app_id) | |
| @@ -211,13 +211,19 @@ pub async fn compact_all_sync_logs(pool: &PgPool, min_age_days: i64) -> Result<u | |||
| 211 | 211 | return Ok(0); | |
| 212 | 212 | } | |
| 213 | 213 | ||
| 214 | - | // Find (app_id, user_id) pairs where compaction is possible: | |
| 215 | - | // all devices have pulled past seq 0, and there are old entries to delete. | |
| 214 | + | // Find (app_id, user_id) pairs where compaction is safe: EVERY registered | |
| 215 | + | // device has pulled past the compaction floor. We deliberately do NOT filter | |
| 216 | + | // `last_pulled_seq > 0` in the WHERE — a freshly-registered device sits at | |
| 217 | + | // seq 0 until its first pull, and excluding it would let us delete entries | |
| 218 | + | // it has never seen, so its first pull-from-0 would silently miss changes. | |
| 219 | + | // Including every device means a single not-yet-pulled device pins MIN to 0 | |
| 220 | + | // and the `HAVING MIN > 0` skips the pair entirely until it catches up. A | |
| 221 | + | // truly-abandoned never-pulled device is bounded by `prune_sync_log`'s | |
| 222 | + | // retention backstop, not this cursor path. | |
| 216 | 223 | let pairs: Vec<(SyncAppId, UserId, i64)> = sqlx::query_as( | |
| 217 | 224 | r#" | |
| 218 | 225 | SELECT app_id, user_id, MIN(last_pulled_seq) AS min_seq | |
| 219 | 226 | FROM sync_devices | |
| 220 | - | WHERE last_pulled_seq > 0 | |
| 221 | 227 | GROUP BY app_id, user_id | |
| 222 | 228 | HAVING MIN(last_pulled_seq) > 0 | |
| 223 | 229 | "#, | |
| @@ -232,7 +238,7 @@ pub async fn compact_all_sync_logs(pool: &PgPool, min_age_days: i64) -> Result<u | |||
| 232 | 238 | DELETE FROM sync_log | |
| 233 | 239 | WHERE app_id = $1 AND user_id = $2 | |
| 234 | 240 | AND seq <= $3 | |
| 235 | - | AND created_at < NOW() - make_interval(days => $4) | |
| 241 | + | AND created_at < NOW() - make_interval(days => $4::int) | |
| 236 | 242 | "#, | |
| 237 | 243 | ) | |
| 238 | 244 | .bind(app_id) |
| @@ -267,9 +267,26 @@ pub(super) async fn blob_download_url( | |||
| 267 | 267 | Ok(Json(BlobDownloadUrlResponse { download_url }).into_response()) | |
| 268 | 268 | } | |
| 269 | 269 | ||
| 270 | - | // NOTE: No active blob-delete API path was found in v1. Storage counters can | |
| 271 | - | // only grow (until period rollover or manual reset via reset_period_usage, | |
| 272 | - | // which only resets egress, not storage). The weekly drift correction job in | |
| 273 | - | // `db::synckit_billing::recalculate_synckit_app_storage` reconciles | |
| 274 | - | // `bytes_stored` against `sync_blobs` and is the only way storage can shrink | |
| 275 | - | // without DB intervention. Known limitation; revisit when blob deletion ships. | |
| 270 | + | /// Delete a blob by hash for the authenticated user. | |
| 271 | + | /// | |
| 272 | + | /// Frees storage immediately: the row, the S3 object, and the usage counters | |
| 273 | + | /// are released in one atomic step (see `db::synckit::delete_sync_blob`). This | |
| 274 | + | /// is the live shrink path that the weekly drift job used to be the only source | |
| 275 | + | /// of. Allowed regardless of billing status — a user must always be able to | |
| 276 | + | /// reclaim space, even on a lapsed subscription. Idempotent: deleting a hash | |
| 277 | + | /// that isn't stored returns 204. | |
| 278 | + | #[utoipa::path(delete, path = "/api/v1/sync/blobs/{hash}", tag = "SyncKit", | |
| 279 | + | params(("hash" = String, Path, description = "Content hash of the blob to delete")), | |
| 280 | + | responses((status = 204, description = "Blob deleted (or already absent)")), | |
| 281 | + | security(("bearer" = [])), | |
| 282 | + | )] | |
| 283 | + | #[tracing::instrument(skip_all, name = "synckit::blob_delete")] | |
| 284 | + | pub(super) async fn blob_delete( | |
| 285 | + | State(state): State<AppState>, | |
| 286 | + | sync_user: SyncUser, | |
| 287 | + | axum::extract::Path(hash): axum::extract::Path<String>, | |
| 288 | + | ) -> Result<Response> { | |
| 289 | + | validation::validate_sync_blob_hash(&hash)?; | |
| 290 | + | db::synckit::delete_sync_blob(&state.db, sync_user.app_id, sync_user.user_id, &hash).await?; | |
| 291 | + | Ok(StatusCode::NO_CONTENT.into_response()) | |
| 292 | + | } |
| @@ -609,6 +609,8 @@ pub fn synckit_routes(synckit_jwt_secret: Option<std::sync::Arc<String>>) -> Csr | |||
| 609 | 609 | .route("/api/v1/sync/blobs/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload)) | |
| 610 | 610 | .route("/api/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url)) | |
| 611 | 611 | .route("/api/v1/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url)) | |
| 612 | + | .route("/api/sync/blobs/{hash}", delete_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_delete)) | |
| 613 | + | .route("/api/v1/sync/blobs/{hash}", delete_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_delete)) | |
| 612 | 614 | // Per-app rate limit (inner layer runs first): prevents one developer's | |
| 613 | 615 | // app from starving other apps. Extracts app ID from JWT payload. | |
| 614 | 616 | .route_layer(GovernorLayer { |
| @@ -225,3 +225,125 @@ async fn confirm_uses_authoritative_object_size_not_client_claim() { | |||
| 225 | 225 | let body: serde_json::Value = resp.json(); | |
| 226 | 226 | assert_eq!(body["reason"], "storage_limit_reached"); | |
| 227 | 227 | } | |
| 228 | + | ||
| 229 | + | #[tokio::test] | |
| 230 | + | async fn delete_blob_removes_row_dead_letters_object_and_frees_space() { | |
| 231 | + | let (mut h, blobs) = harness_with_blobs().await; | |
| 232 | + | let user_id = h.signup("paid_del", "paid_del@example.com", "Password1!").await; | |
| 233 | + | let (app_id, _api_key) = create_internal_app(&h.db, user_id).await; | |
| 234 | + | seed_subscription(&h.db, user_id, app_id, "active", 1500).await; // 1500-byte cap | |
| 235 | + | auth_as(&mut h, user_id, app_id, "user-key"); | |
| 236 | + | ||
| 237 | + | // Fill most of the cap with one 1000-byte blob. | |
| 238 | + | let hash = fake_hash(0x06); | |
| 239 | + | let resp = put_and_confirm(&mut h, &blobs, app_id, user_id, &hash, 1000, 1000).await; | |
| 240 | + | assert_eq!(resp.status, 204, "first confirm should succeed: {}", resp.text); | |
| 241 | + | ||
| 242 | + | // A second 1000-byte blob would breach the 1500 cap (1000 + 1000 > 1500). | |
| 243 | + | let hash2 = fake_hash(0x07); | |
| 244 | + | let resp = put_and_confirm(&mut h, &blobs, app_id, user_id, &hash2, 1000, 1000).await; | |
| 245 | + | assert_eq!(resp.status, 402, "second confirm should breach the cap: {}", resp.text); | |
| 246 | + | ||
| 247 | + | // Delete the first blob. | |
| 248 | + | let s3_key = format!("{app_id}/{user_id}/{hash}"); | |
| 249 | + | let resp = h.client.delete(&format!("/api/sync/blobs/{hash}")).await; | |
| 250 | + | assert_eq!(resp.status, 204, "delete should succeed: {}", resp.text); | |
| 251 | + | ||
| 252 | + | // Row is gone. | |
| 253 | + | let remaining: i64 = sqlx::query_scalar( | |
| 254 | + | "SELECT COUNT(*) FROM sync_blobs WHERE app_id = $1 AND user_id = $2 AND hash = $3", | |
| 255 | + | ) | |
| 256 | + | .bind(app_id) | |
| 257 | + | .bind(user_id) | |
| 258 | + | .bind(&hash) | |
| 259 | + | .fetch_one(&h.db) | |
| 260 | + | .await | |
| 261 | + | .unwrap(); | |
| 262 | + | assert_eq!(remaining, 0, "deleted blob row must be gone"); | |
| 263 | + | ||
| 264 | + | // The S3 object was dead-lettered for the synckit bucket, not dropped. | |
| 265 | + | let dead_lettered: i64 = sqlx::query_scalar( | |
| 266 | + | "SELECT COUNT(*) FROM pending_s3_deletions | |
| 267 | + | WHERE s3_key = $1 AND bucket = 'synckit' AND source = 'synckit_blob_delete'", | |
| 268 | + | ) | |
| 269 | + | .bind(&s3_key) | |
| 270 | + | .fetch_one(&h.db) | |
| 271 | + | .await | |
| 272 | + | .unwrap(); | |
| 273 | + | assert_eq!(dead_lettered, 1, "deleted object must be enqueued to the dead-letter ladder"); | |
| 274 | + | ||
| 275 | + | // Space is freed: the previously-rejected second blob now fits (internal | |
| 276 | + | // usage is summed from sync_blobs, so removing the row shrinks usage). | |
| 277 | + | let resp = put_and_confirm(&mut h, &blobs, app_id, user_id, &hash2, 1000, 1000).await; | |
| 278 | + | assert_eq!(resp.status, 204, "after delete, the second blob should fit: {}", resp.text); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | #[tokio::test] | |
| 282 | + | async fn compaction_waits_for_a_freshly_registered_device() { | |
| 283 | + | // Regression: a new device sits at last_pulled_seq = 0 until its first pull. | |
| 284 | + | // Compaction must NOT delete log entries below the other devices' cursors | |
| 285 | + | // while a never-pulled device exists, or that device's first pull-from-0 | |
| 286 | + | // would silently miss the compacted-away changes. | |
| 287 | + | let (mut h, _blobs) = harness_with_blobs().await; | |
| 288 | + | let user_id = h.signup("compact_dev", "compact_dev@example.com", "Password1!").await; | |
| 289 | + | let (app_id, _api_key) = create_internal_app(&h.db, user_id).await; | |
| 290 | + | ||
| 291 | + | // Device A has pulled; device B is freshly registered (seq 0). | |
| 292 | + | let dev_a: uuid::Uuid = sqlx::query_scalar( | |
| 293 | + | "INSERT INTO sync_devices (app_id, user_id, device_name, platform) | |
| 294 | + | VALUES ($1, $2, 'A', 'test') RETURNING id", | |
| 295 | + | ) | |
| 296 | + | .bind(app_id).bind(user_id).fetch_one(&h.db).await.unwrap(); | |
| 297 | + | let _dev_b: uuid::Uuid = sqlx::query_scalar( | |
| 298 | + | "INSERT INTO sync_devices (app_id, user_id, device_name, platform) | |
| 299 | + | VALUES ($1, $2, 'B', 'test') RETURNING id", | |
| 300 | + | ) | |
| 301 | + | .bind(app_id).bind(user_id).fetch_one(&h.db).await.unwrap(); | |
| 302 | + | ||
| 303 | + | // One old log entry (backdated well past the 7-day compaction margin). | |
| 304 | + | let seq: i64 = sqlx::query_scalar( | |
| 305 | + | "INSERT INTO sync_log | |
| 306 | + | (app_id, user_id, device_id, table_name, operation, row_id, client_timestamp, created_at) | |
| 307 | + | VALUES ($1, $2, $3, 'tasks', 'INSERT', 'r1', NOW(), NOW() - INTERVAL '30 days') | |
| 308 | + | RETURNING seq", | |
| 309 | + | ) | |
| 310 | + | .bind(app_id).bind(user_id).bind(dev_a).fetch_one(&h.db).await.unwrap(); | |
| 311 | + | ||
| 312 | + | // Device A has pulled past the entry; device B has not pulled at all. | |
| 313 | + | sqlx::query("UPDATE sync_devices SET last_pulled_seq = $1 WHERE id = $2") | |
| 314 | + | .bind(seq).bind(dev_a).execute(&h.db).await.unwrap(); | |
| 315 | + | ||
| 316 | + | // With device B pinned at 0, compaction must delete nothing. | |
| 317 | + | let deleted = makenotwork::db::synckit::compact_all_sync_logs(&h.db, 7).await.unwrap(); | |
| 318 | + | assert_eq!(deleted, 0, "compaction must wait for the never-pulled device"); | |
| 319 | + | let present: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync_log WHERE seq = $1") | |
| 320 | + | .bind(seq).fetch_one(&h.db).await.unwrap(); | |
| 321 | + | assert_eq!(present, 1, "log entry must survive while a new device sits at seq 0"); | |
| 322 | + | ||
| 323 | + | // Once device B catches up, the same entry is safely compactable. | |
| 324 | + | sqlx::query("UPDATE sync_devices SET last_pulled_seq = $1 WHERE device_name = 'B'") | |
| 325 | + | .bind(seq).execute(&h.db).await.unwrap(); | |
| 326 | + | let deleted = makenotwork::db::synckit::compact_all_sync_logs(&h.db, 7).await.unwrap(); | |
| 327 | + | assert_eq!(deleted, 1, "after every device caught up, the old entry compacts"); | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | #[tokio::test] | |
| 331 | + | async fn delete_absent_blob_is_idempotent_no_op() { | |
| 332 | + | let (mut h, _blobs) = harness_with_blobs().await; | |
| 333 | + | let user_id = h.signup("paid_del2", "paid_del2@example.com", "Password1!").await; | |
| 334 | + | let (app_id, _api_key) = create_internal_app(&h.db, user_id).await; | |
| 335 | + | seed_subscription(&h.db, user_id, app_id, "active", GIB).await; | |
| 336 | + | auth_as(&mut h, user_id, app_id, "user-key"); | |
| 337 | + | ||
| 338 | + | // Deleting a hash that was never stored is a 204 no-op, and enqueues nothing. | |
| 339 | + | let resp = h.client.delete(&format!("/api/sync/blobs/{}", fake_hash(0x08))).await; | |
| 340 | + | assert_eq!(resp.status, 204, "deleting an absent blob should be a 204 no-op: {}", resp.text); | |
| 341 | + | ||
| 342 | + | let enqueued: i64 = sqlx::query_scalar( | |
| 343 | + | "SELECT COUNT(*) FROM pending_s3_deletions WHERE source = 'synckit_blob_delete'", | |
| 344 | + | ) | |
| 345 | + | .fetch_one(&h.db) | |
| 346 | + | .await | |
| 347 | + | .unwrap(); | |
| 348 | + | assert_eq!(enqueued, 0, "no-op delete must not dead-letter anything"); | |
| 349 | + | } |