Skip to content

Commit 82f03fa

Browse files
authored
LogDb: read-only http gateway (opendata-oss#499)
Adds read-only option for the HTTP server, which relies on LogDbReader.
1 parent d860852 commit 82f03fa

8 files changed

Lines changed: 506 additions & 90 deletions

File tree

log/src/main.rs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ use std::sync::Arc;
99
use clap::Parser;
1010
use tracing_subscriber::EnvFilter;
1111

12-
use log::LogDb;
1312
use log::server::{CliArgs, LogServer, LogServerConfig};
13+
use log::{LogDb, LogDbReader};
1414

1515
#[tokio::main]
1616
async fn main() {
@@ -24,22 +24,28 @@ async fn main() {
2424
// Parse CLI arguments
2525
let args = CliArgs::parse();
2626

27-
// Create log configuration
28-
let log_config = args.to_log_config();
2927
let server_config = LogServerConfig::from(&args);
3028

3129
// Install the metrics-rs recorder early so that slatedb metrics registered
32-
// during LogDb::open() are captured by the prometheus exporter.
30+
// during open are captured by the prometheus exporter.
3331
let recorder = metrics_exporter_prometheus::PrometheusBuilder::new().build_recorder();
3432
let metrics_handle = recorder.handle();
3533
let _ = metrics::set_global_recorder(recorder);
3634

37-
tracing::info!("Opening log with config: {:?}", log_config);
35+
// Open a read-only gateway or a full read-write server depending on the flag.
36+
let server = if args.read_only {
37+
let reader_config = args.to_reader_config();
38+
tracing::info!("Opening log reader with config: {:?}", reader_config);
39+
let reader = LogDbReader::open(reader_config)
40+
.await
41+
.expect("Failed to open log reader");
42+
LogServer::new_read_only(Arc::new(reader), server_config, metrics_handle)
43+
} else {
44+
let log_config = args.to_log_config();
45+
tracing::info!("Opening log with config: {:?}", log_config);
46+
let log = LogDb::open(log_config).await.expect("Failed to open log");
47+
LogServer::new(Arc::new(log), server_config, metrics_handle)
48+
};
3849

39-
// Open the log
40-
let log = LogDb::open(log_config).await.expect("Failed to open log");
41-
42-
// Create and run the server
43-
let server = LogServer::new(Arc::new(log), server_config, metrics_handle);
4450
server.run().await;
4551
}

log/src/reader.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,20 @@ impl LogDbReader {
825825
})
826826
}
827827

828+
/// Verifies the storage backend is reachable with a single key lookup.
829+
///
830+
/// Mirrors [`LogDb::check_storage`](crate::LogDb::check_storage) for the
831+
/// read-only path: it reads the sequence block key, which confirms storage
832+
/// is responding without scanning or listing data. Used by the HTTP
833+
/// server's readiness probe when running as a read-only gateway.
834+
#[cfg(feature = "http-server")]
835+
pub(crate) async fn check_storage(&self) -> Result<()> {
836+
let seq_key = Bytes::from_static(&crate::serde::SEQ_BLOCK_KEY);
837+
let view = self.read_view.read().await;
838+
let _ = view.storage.get(seq_key).await?;
839+
Ok(())
840+
}
841+
828842
/// Closes the reader, stopping the background refresh task.
829843
///
830844
/// This method consumes `self` and gracefully shuts down the background

log/src/server/config.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use common::storage::config::{
66
AwsObjectStoreConfig, LocalObjectStoreConfig, ObjectStoreConfig, SlateDbStorageConfig,
77
};
88

9-
use crate::Config;
9+
use crate::{Config, ReaderConfig};
1010

1111
/// CLI arguments for the log server.
1212
#[derive(Debug, Parser)]
@@ -32,12 +32,20 @@ pub struct CliArgs {
3232
/// AWS region for S3 storage.
3333
#[arg(long, default_value = "us-east-1")]
3434
pub s3_region: String,
35+
36+
/// Run as a read-only gateway backed by a `LogDbReader`.
37+
///
38+
/// In this mode the server serves only read routes (scan, keys, segments,
39+
/// count) and the append route is not registered.
40+
#[arg(long, default_value = "false")]
41+
pub read_only: bool,
3542
}
3643

3744
impl CliArgs {
38-
/// Convert CLI args to log configuration.
39-
pub fn to_log_config(&self) -> Config {
40-
let storage = if self.in_memory {
45+
/// Build the storage configuration shared by the read-write and read-only
46+
/// paths from the CLI flags.
47+
fn build_storage_config(&self) -> StorageConfig {
48+
if self.in_memory {
4149
StorageConfig::InMemory
4250
} else if let Some(bucket) = &self.s3_bucket {
4351
// S3 storage
@@ -62,10 +70,21 @@ impl CliArgs {
6270
block_cache: None,
6371
meta_cache: None,
6472
})
65-
};
73+
}
74+
}
6675

76+
/// Convert CLI args to log configuration (read-write mode).
77+
pub fn to_log_config(&self) -> Config {
6778
Config {
68-
storage,
79+
storage: self.build_storage_config(),
80+
..Default::default()
81+
}
82+
}
83+
84+
/// Convert CLI args to reader configuration (read-only mode).
85+
pub fn to_reader_config(&self) -> ReaderConfig {
86+
ReaderConfig {
87+
storage: self.build_storage_config(),
6988
..Default::default()
7089
}
7190
}
@@ -103,6 +122,7 @@ mod tests {
103122
in_memory: true,
104123
s3_bucket: None,
105124
s3_region: "us-east-1".to_string(),
125+
read_only: false,
106126
};
107127

108128
// when
@@ -121,6 +141,7 @@ mod tests {
121141
in_memory: false,
122142
s3_bucket: None,
123143
s3_region: "us-east-1".to_string(),
144+
read_only: false,
124145
};
125146

126147
// when
@@ -147,6 +168,7 @@ mod tests {
147168
in_memory: false,
148169
s3_bucket: Some("my-bucket".to_string()),
149170
s3_region: "us-west-2".to_string(),
171+
read_only: false,
150172
};
151173

152174
// when
@@ -174,6 +196,7 @@ mod tests {
174196
in_memory: true,
175197
s3_bucket: None,
176198
s3_region: "us-east-1".to_string(),
199+
read_only: false,
177200
};
178201

179202
// when

log/src/server/handlers.rs

Lines changed: 121 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! Per RFC 0004, handlers support both binary protobuf (`application/protobuf`)
44
//! and ProtoJSON (`application/protobuf+json`) formats.
55
6+
use std::ops::RangeBounds;
67
use std::sync::Arc;
78
use std::time::Duration;
89

@@ -15,25 +16,121 @@ use axum::http::HeaderMap;
1516
use super::error::ApiError;
1617
use super::metrics::Metrics;
1718
use super::proto::{
18-
AppendResponse, CountResponse, KeysResponse, ScanResponse, Segment, SegmentsResponse, Value,
19+
AppendResponse, CountResponse, KeysResponse, ScanResponse, Segment as ProtoSegment,
20+
SegmentsResponse, Value,
1921
};
2022
use super::request::{AppendRequest, CountParams, ListKeysParams, ListSegmentsParams, ScanParams};
2123
use super::response::{ApiResponse, ResponseFormat, to_api_response};
22-
use crate::LogDb;
24+
use crate::error::AppendError;
2325
use crate::reader::LogRead;
26+
use crate::{
27+
AppendOutput, AppendResult, LogDb, LogDbReader, LogIterator, LogKeyIterator, Record, Segment,
28+
SegmentId, Sequence,
29+
};
30+
31+
/// Storage backend behind the HTTP server.
32+
///
33+
/// A full read-write [`LogDb`] exposes every route; a [`LogDbReader`] backs a
34+
/// read-only gateway where the append route is not registered (see
35+
/// [`LogServer`](super::http::LogServer)). Read methods delegate to the
36+
/// [`LogRead`] implementation shared by both variants; write methods are only
37+
/// meaningful on the read-write variant.
38+
#[derive(Clone)]
39+
pub(crate) enum LogBackend {
40+
/// Full read-write log.
41+
ReadWrite(Arc<LogDb>),
42+
/// Read-only view that periodically discovers data written elsewhere.
43+
ReadOnly(Arc<LogDbReader>),
44+
}
45+
46+
impl LogBackend {
47+
/// Appends records to the log.
48+
///
49+
/// Errors on a read-only backend. The append route is not registered in
50+
/// read-only mode, so this arm is a defensive guard rather than a reachable
51+
/// code path.
52+
pub async fn try_append(&self, records: Vec<Record>) -> AppendResult<AppendOutput> {
53+
match self {
54+
Self::ReadWrite(log) => log.try_append(records).await,
55+
Self::ReadOnly(_) => Err(AppendError::Storage("log gateway is read-only".to_string())),
56+
}
57+
}
58+
59+
/// Flushes pending writes. A no-op on a read-only backend.
60+
pub async fn flush(&self) -> crate::Result<()> {
61+
match self {
62+
Self::ReadWrite(log) => log.flush().await,
63+
Self::ReadOnly(_) => Ok(()),
64+
}
65+
}
66+
67+
/// Verifies the storage backend is reachable (readiness probe).
68+
pub async fn check_storage(&self) -> crate::Result<()> {
69+
match self {
70+
Self::ReadWrite(log) => log.check_storage().await,
71+
Self::ReadOnly(reader) => reader.check_storage().await,
72+
}
73+
}
74+
75+
/// Scans entries for a key within a sequence range.
76+
pub async fn scan(
77+
&self,
78+
key: Bytes,
79+
seq_range: impl RangeBounds<Sequence> + Send,
80+
) -> crate::Result<LogIterator> {
81+
match self {
82+
Self::ReadWrite(log) => log.scan(key, seq_range).await,
83+
Self::ReadOnly(reader) => reader.scan(key, seq_range).await,
84+
}
85+
}
86+
87+
/// Counts entries for a key within a sequence range.
88+
pub async fn count(
89+
&self,
90+
key: Bytes,
91+
seq_range: impl RangeBounds<Sequence> + Send,
92+
) -> crate::Result<u64> {
93+
match self {
94+
Self::ReadWrite(log) => log.count(key, seq_range).await,
95+
Self::ReadOnly(reader) => reader.count(key, seq_range).await,
96+
}
97+
}
98+
99+
/// Lists distinct keys within a segment range.
100+
pub async fn list_keys(
101+
&self,
102+
segment_range: impl RangeBounds<SegmentId> + Send,
103+
) -> crate::Result<LogKeyIterator> {
104+
match self {
105+
Self::ReadWrite(log) => log.list_keys(segment_range).await,
106+
Self::ReadOnly(reader) => reader.list_keys(segment_range).await,
107+
}
108+
}
109+
110+
/// Lists segments overlapping a sequence range.
111+
pub async fn list_segments(
112+
&self,
113+
seq_range: impl RangeBounds<Sequence> + Send,
114+
) -> crate::Result<Vec<Segment>> {
115+
match self {
116+
Self::ReadWrite(log) => log.list_segments(seq_range).await,
117+
Self::ReadOnly(reader) => reader.list_segments(seq_range).await,
118+
}
119+
}
120+
}
24121

25122
/// Shared application state.
26123
#[derive(Clone)]
27-
pub struct AppState {
28-
pub log: Arc<LogDb>,
124+
pub(crate) struct AppState {
125+
pub log: LogBackend,
29126
pub metrics: Arc<Metrics>,
30127
}
31128

32129
/// Handle POST /api/v1/log/append
33130
///
34131
/// Supports both `Content-Type: application/protobuf` and `Content-Type: application/protobuf+json`.
35132
/// Returns response in format matching the `Accept` header.
36-
pub async fn handle_append(
133+
pub(crate) async fn handle_append(
37134
State(state): State<AppState>,
38135
headers: HeaderMap,
39136
body: Bytes,
@@ -70,7 +167,7 @@ pub async fn handle_append(
70167
///
71168
/// Returns response in format matching the `Accept` header.
72169
/// Supports long-polling via `follow=true` and `timeout_ms` parameters.
73-
pub async fn handle_scan(
170+
pub(crate) async fn handle_scan(
74171
State(state): State<AppState>,
75172
headers: HeaderMap,
76173
Query(params): Query<ScanParams>,
@@ -172,7 +269,7 @@ async fn scan_entries(
172269
/// Handle GET /api/v1/log/keys
173270
///
174271
/// Returns response in format matching the `Accept` header.
175-
pub async fn handle_list_keys(
272+
pub(crate) async fn handle_list_keys(
176273
State(state): State<AppState>,
177274
headers: HeaderMap,
178275
Query(params): Query<ListKeysParams>,
@@ -198,7 +295,7 @@ pub async fn handle_list_keys(
198295
/// Handle GET /api/v1/log/segments
199296
///
200297
/// Returns response in format matching the `Accept` header.
201-
pub async fn handle_list_segments(
298+
pub(crate) async fn handle_list_segments(
202299
State(state): State<AppState>,
203300
headers: HeaderMap,
204301
Query(params): Query<ListSegmentsParams>,
@@ -207,9 +304,9 @@ pub async fn handle_list_segments(
207304
let seq_range = params.seq_range();
208305

209306
let segments = state.log.list_segments(seq_range).await?;
210-
let segment_entries: Vec<Segment> = segments
307+
let segment_entries: Vec<ProtoSegment> = segments
211308
.into_iter()
212-
.map(|s| Segment {
309+
.map(|s| ProtoSegment {
213310
id: s.id,
214311
start_seq: s.start_seq,
215312
start_time_ms: s.start_time_ms,
@@ -223,7 +320,7 @@ pub async fn handle_list_segments(
223320
/// Handle GET /api/v1/log/count
224321
///
225322
/// Returns response in format matching the `Accept` header.
226-
pub async fn handle_count(
323+
pub(crate) async fn handle_count(
227324
State(state): State<AppState>,
228325
headers: HeaderMap,
229326
Query(params): Query<CountParams>,
@@ -239,22 +336,24 @@ pub async fn handle_count(
239336
}
240337

241338
/// Handle GET /metrics
242-
pub async fn handle_metrics(State(state): State<AppState>) -> String {
339+
pub(crate) async fn handle_metrics(State(state): State<AppState>) -> String {
243340
state.metrics.encode()
244341
}
245342

246343
/// Handle GET /-/healthy
247344
///
248345
/// Returns 200 OK if the service is running.
249-
pub async fn handle_healthy() -> (axum::http::StatusCode, &'static str) {
346+
pub(crate) async fn handle_healthy() -> (axum::http::StatusCode, &'static str) {
250347
(axum::http::StatusCode::OK, "OK")
251348
}
252349

253350
/// Handle GET /-/ready
254351
///
255352
/// Returns 200 OK if the service is ready to serve requests.
256353
/// Performs a lightweight storage check to verify the log backend is accessible.
257-
pub async fn handle_ready(State(state): State<AppState>) -> (axum::http::StatusCode, &'static str) {
354+
pub(crate) async fn handle_ready(
355+
State(state): State<AppState>,
356+
) -> (axum::http::StatusCode, &'static str) {
258357
// Verify storage is accessible with a lightweight read operation.
259358
// This reads the sequence block key, which verifies the storage backend
260359
// is responding without scanning or listing data.
@@ -293,7 +392,10 @@ mod tests {
293392
// given
294393
let log = Arc::new(LogDb::open(test_config()).await.unwrap());
295394
let metrics = Arc::new(Metrics::new());
296-
let state = AppState { log, metrics };
395+
let state = AppState {
396+
log: LogBackend::ReadWrite(log),
397+
metrics,
398+
};
297399

298400
// when
299401
let (status, body) = handle_ready(State(state)).await;
@@ -418,7 +520,10 @@ mod tests {
418520
let storage = Arc::new(ToggleFailStorage::new());
419521
let log = Arc::new(LogDb::new(storage.clone()).await.unwrap());
420522
let metrics = Arc::new(Metrics::new());
421-
let state = AppState { log, metrics };
523+
let state = AppState {
524+
log: LogBackend::ReadWrite(log),
525+
metrics,
526+
};
422527

423528
// Configure storage to fail after initialization
424529
storage.set_failing(true);

0 commit comments

Comments
 (0)