Skip to content

Commit d169445

Browse files
authored
[#265] feat: 쿼리 단위 메모리 제한 구현
* feat: add query-level memory limit (OOM killer) (#265) * test(init): include max_query_memory_bytes in config mock expectation * fix: apply CodeRabbit review - task-local tracker, FileSystem metadata, decode budget
1 parent 5c5ce50 commit d169445

9 files changed

Lines changed: 597 additions & 11 deletions

File tree

src/common/fs.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ pub trait FileSystem {
1414
async fn write_file(&self, path: &str, content: &[u8]) -> io::Result<()>;
1515
async fn read_dir(&self, path: &str) -> io::Result<Vec<FileSystemEntry>>;
1616
async fn read(&self, path: &Path) -> io::Result<Vec<u8>>;
17+
/// 파일의 크기(bytes)를 반환합니다. (#265)
18+
/// `read_segment_rows`가 파일 전체를 메모리로 읽기 전에 예산을 확보하는 데 사용합니다.
19+
async fn metadata(&self, path: &Path) -> io::Result<u64>;
1720
}
1821

1922
pub struct RealFileSystem;
@@ -45,4 +48,9 @@ impl FileSystem for RealFileSystem {
4548
async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
4649
tokio::fs::read(path).await
4750
}
51+
52+
async fn metadata(&self, path: &Path) -> io::Result<u64> {
53+
let metadata = tokio::fs::metadata(path).await?;
54+
Ok(metadata.len())
55+
}
4856
}

src/config/launch_config.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ pub struct LaunchConfig {
1717
pub wal_directory: String,
1818
pub wal_segment_size: u32,
1919
pub wal_extension: String,
20+
21+
/// 쿼리 수준 메모리 상한 (bytes) (#265).
22+
/// SELECT/UPDATE/DELETE 실행 중 추정 메모리가 이를 넘으면
23+
/// 에러를 반환해 쿼리를 강제로 중단합니다.
24+
/// `0`이면 비활성 (OOM killer 작동 안 함).
25+
/// 기존 설정 파일에 이 필드가 없어도 동작하도록 serde default 적용.
26+
#[serde(default = "default_max_query_memory_bytes")]
27+
pub max_query_memory_bytes: u64,
28+
}
29+
30+
/// 새 설정 필드가 사용자 TOML에서 빠져 있을 때의 기본값 (#265).
31+
/// 이 관리가 없으면 기존 rrdb.config를 가진 사용자는 업데이트 시 설정 로드가 실패합니다.
32+
fn default_max_query_memory_bytes() -> u64 {
33+
128 * 1024 * 1024
2034
}
2135

2236
#[allow(clippy::derivable_impls)]
@@ -40,6 +54,8 @@ impl std::default::Default for LaunchConfig {
4054
.to_string(),
4155
wal_segment_size: 1024 * 1024 * 16, // 16MB 세그먼트 사이즈
4256
wal_extension: DEFAULT_WAL_EXTENSION.to_string(),
57+
// 기본 128MB. 사용자가 메모리 퍼센트를 조절하면 TOML에서 설정.
58+
max_query_memory_bytes: 128 * 1024 * 1024,
4359
}
4460
}
4561
}

src/engine/actions/dml/scan.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::engine::ast::dml::plan::select::scan::IndexScanPlan;
1010
use crate::engine::ast::types::TableName;
1111
use crate::engine::encoder::schema_encoder::StorageEncoder;
1212
use crate::engine::row_buffer::{ROW_FRAME_LIVE, RowBufferWrite, encode_live_row_frames};
13-
use crate::engine::schema::row::TableDataRow;
13+
use crate::engine::schema::row::{ESTIMATED_FIELD_OVERHEAD, TableDataRow};
1414
use crate::errors;
1515
use crate::errors::execute_error::ExecuteError;
1616

@@ -60,6 +60,17 @@ impl DBEngine {
6060
}
6161
};
6262

63+
// 메모리 예산 추적 (#265): 전체 세그먼트 로드 후
64+
// 자신의 반환 결과(Vec<(RowLocation, TableDataRow)>) 크기를 reserve.
65+
if let Some(tracker) = self.query_memory().await {
66+
let bytes: u64 = rows
67+
.iter()
68+
.filter_map(|row| row.as_ref())
69+
.map(|row| row.estimated_bytes() + ESTIMATED_FIELD_OVERHEAD)
70+
.sum();
71+
tracker.reserve(bytes)?;
72+
}
73+
6374
let live = rows
6475
.into_iter()
6576
.enumerate()
@@ -307,6 +318,20 @@ impl DBEngine {
307318
&self,
308319
segment_path: &Path,
309320
) -> errors::Result<Vec<Option<TableDataRow>>> {
321+
// 파일 전체를 메모리로 읽기 전에 예산을 미리 확보 (#265).
322+
// `tokio::fs::read`가 들어올 파일 크만큼을 추적해
323+
// 상한 초과 시 실제 할당 이전에 거부합니다.
324+
// `self.file_system.metadata()`를 사용해 주입된 mock으로
325+
// metadata 오류와 메모리 예약을 검증할 수 있게 합니다 (CodeRabbit).
326+
if let Some(tracker) = self.query_memory().await {
327+
let file_size = match self.file_system.metadata(segment_path).await {
328+
Ok(size) => size,
329+
Err(error) if error.kind() == IOErrorKind::NotFound => 0,
330+
Err(error) => return Err(ExecuteError::wrap(error.to_string())),
331+
};
332+
tracker.reserve(file_size)?;
333+
}
334+
310335
let content = match tokio::fs::read(segment_path).await {
311336
Ok(content) => content,
312337
Err(error) if error.kind() == IOErrorKind::NotFound => return Ok(Vec::new()),
@@ -343,6 +368,13 @@ impl DBEngine {
343368
if tombstoned {
344369
rows.push(None);
345370
} else {
371+
// 역직렬화 전에 프레임 디코드 예산을 추가로 reserve (#265).
372+
// 파일 크기는 원본 바이트인데 디코드 결과는 틀이 크게
373+
// 날 수 있으므로, 보수적으로 프레임 크기만큼을
374+
// 추가 예약하여 상한 초과 시 디코드 전에 거부합니다.
375+
if let Some(tracker) = self.query_memory().await {
376+
tracker.reserve(frame_len as u64)?;
377+
}
346378
let row = encoder
347379
.decode::<TableDataRow>(&content[offset..offset + frame_len])
348380
.map_err(|error| {
@@ -596,7 +628,13 @@ impl DBEngine {
596628
})?;
597629

598630
match all_rows.get(row_index) {
599-
Some(Some(row)) => result.push((RowLocation { row_index }, row.clone())),
631+
Some(Some(row)) => {
632+
// 메모리 예산 추적 (#265): 결과 행이 앱드될 때마다 크기를 reserve.
633+
if let Some(tracker) = self.query_memory().await {
634+
tracker.reserve(row.estimated_bytes() + ESTIMATED_FIELD_OVERHEAD)?;
635+
}
636+
result.push((RowLocation { row_index }, row.clone()));
637+
}
600638
Some(None) | None => {
601639
return Err(ExecuteError::wrap(format!(
602640
"index '{}' is out of sync with table data; drop and recreate the index",

src/engine/actions/dml/select.rs

Lines changed: 210 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -634,12 +634,25 @@ mod tests {
634634
use crate::engine::{DBEngine, SharedWALManager};
635635

636636
async fn build_test_engine(test_name: &str) -> (DBEngine, SharedWALManager) {
637+
build_test_engine_with_memory_limit(test_name, 128 * 1024 * 1024).await
638+
}
639+
640+
async fn build_test_engine_with_memory_limit(
641+
test_name: &str,
642+
max_query_memory_bytes: u64,
643+
) -> (DBEngine, SharedWALManager) {
637644
let base_path = PathBuf::from("target").join(test_name);
638-
if base_path.exists() {
639-
tokio::fs::remove_dir_all(&base_path).await.unwrap();
645+
// 동기 `Path::exists()`는 metadata 오류를 `false`로 처리해 정리를 건너뛸 수
646+
// 있으므로, 비동기 `tokio::fs::metadata`로 NotFound만 무시하고 다른 오류는
647+
// 테스트를 실패시킵니다 (CodeRabbit).
648+
match tokio::fs::metadata(&base_path).await {
649+
Ok(_) => tokio::fs::remove_dir_all(&base_path).await.unwrap(),
650+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
651+
Err(error) => panic!("metadata check failed for {base_path:?}: {error}"),
640652
}
641653

642-
let config = LaunchConfig::default_for_base_path(&base_path);
654+
let mut config = LaunchConfig::default_for_base_path(&base_path);
655+
config.max_query_memory_bytes = max_query_memory_bytes;
643656
tokio::fs::create_dir_all(&config.data_directory)
644657
.await
645658
.unwrap();
@@ -821,7 +834,11 @@ mod tests {
821834
.await
822835
.unwrap_or_else(|error| panic!("{sql} failed: {error}"));
823836

824-
assert_eq!(result.rows.len(), expected, "unexpected row count for {sql}");
837+
assert_eq!(
838+
result.rows.len(),
839+
expected,
840+
"unexpected row count for {sql}"
841+
);
825842
}
826843
}
827844

@@ -846,7 +863,195 @@ mod tests {
846863
("select id from key_value offset 1;", 2),
847864
] {
848865
let result = execute_sql(&engine, wal.clone(), sql).await.unwrap();
849-
assert_eq!(result.rows.len(), expected, "unexpected row count for {sql}");
866+
assert_eq!(
867+
result.rows.len(),
868+
expected,
869+
"unexpected row count for {sql}"
870+
);
850871
}
851872
}
873+
874+
/// 트래킹 상한을 넘기는 크기의 문자열 값을 가진 행을 삽입합니다.
875+
/// `size_bytes` 이상의 문자열이 들어가도록 패딩합니다.
876+
async fn insert_big_string(engine: &DBEngine, wal: SharedWALManager, size_bytes: usize) {
877+
let value = "x".repeat(size_bytes);
878+
engine
879+
.insert(
880+
InsertQuery::builder()
881+
.set_into_table(TableName::new(
882+
Some("rrdb".to_string()),
883+
"key_value".to_string(),
884+
))
885+
.set_columns(vec!["id".to_string()])
886+
.set_values(vec![InsertValue {
887+
list: vec![Some(SQLExpression::String(value))],
888+
}])
889+
.build(),
890+
wal,
891+
)
892+
.await
893+
.unwrap();
894+
}
895+
896+
/// 메모리 예산을 매우 낮게 설정하면, SELECT가
897+
/// 행을 메모리로 로드하는 순간 강제 중단됩니다 (#265).
898+
#[tokio::test]
899+
async fn select_over_memory_limit_is_killed() {
900+
let (engine, wal) = build_test_engine_with_memory_limit("test_select_oom_kill", 1024).await;
901+
902+
execute_sql(&engine, wal.clone(), "create database rrdb;")
903+
.await
904+
.unwrap();
905+
execute_sql(
906+
&engine,
907+
wal.clone(),
908+
"create table key_value (id varchar(65536));",
909+
)
910+
.await
911+
.unwrap();
912+
913+
// 행 하나만 로드해도 예산(1KB)를 넘는 크기(4KB 문자열).
914+
insert_big_string(&engine, wal.clone(), 4096).await;
915+
916+
let result = execute_sql(&engine, wal, "select id from key_value;")
917+
.await
918+
.unwrap_err();
919+
920+
let message = result.to_string();
921+
assert!(
922+
message.contains("query memory limit exceeded"),
923+
"expected memory limit error, got: {message}"
924+
);
925+
}
926+
927+
/// `max_query_memory_bytes = 0`이면 OOM killer가 비활성입니다 (#265).
928+
/// 크기의 행을 로드해도 에러 없이 정상 동작합니다.
929+
#[tokio::test]
930+
async fn select_with_disabled_memory_limit_succeeds() {
931+
let (engine, wal) =
932+
build_test_engine_with_memory_limit("test_select_oom_disabled", 0).await;
933+
934+
execute_sql(&engine, wal.clone(), "create database rrdb;")
935+
.await
936+
.unwrap();
937+
execute_sql(
938+
&engine,
939+
wal.clone(),
940+
"create table key_value (id varchar(65536));",
941+
)
942+
.await
943+
.unwrap();
944+
945+
insert_big_string(&engine, wal.clone(), 4096).await;
946+
947+
let result = execute_sql(&engine, wal, "select id from key_value;")
948+
.await
949+
.unwrap();
950+
951+
assert_eq!(result.rows.len(), 1);
952+
}
953+
954+
/// 예산을 넘지 않는 손상된 실행은 정상 동작해야 합니다 (#265).
955+
#[tokio::test]
956+
async fn select_within_memory_limit_succeeds() {
957+
let (engine, wal) =
958+
build_test_engine_with_memory_limit("test_select_oom_within", 1024 * 1024).await;
959+
960+
execute_sql(&engine, wal.clone(), "create database rrdb;")
961+
.await
962+
.unwrap();
963+
execute_sql(
964+
&engine,
965+
wal.clone(),
966+
"create table key_value (id varchar(65536));",
967+
)
968+
.await
969+
.unwrap();
970+
971+
insert_big_string(&engine, wal.clone(), 4096).await;
972+
973+
let result = execute_sql(&engine, wal, "select id from key_value;")
974+
.await
975+
.unwrap();
976+
977+
assert_eq!(result.rows.len(), 1);
978+
}
979+
980+
/// 지정한 테이블에 큰 문자열 행을 삽입합니다.
981+
async fn insert_big_string_into(
982+
engine: &DBEngine,
983+
wal: SharedWALManager,
984+
table: &str,
985+
size_bytes: usize,
986+
) {
987+
let value = "x".repeat(size_bytes);
988+
engine
989+
.insert(
990+
InsertQuery::builder()
991+
.set_into_table(TableName::new(Some("rrdb".to_string()), table.to_string()))
992+
.set_columns(vec!["id".to_string()])
993+
.set_values(vec![InsertValue {
994+
list: vec![Some(SQLExpression::String(value))],
995+
}])
996+
.build(),
997+
wal,
998+
)
999+
.await
1000+
.unwrap();
1001+
}
1002+
1003+
/// 동시에 실행되는 두 쿼리는 서로의 메모리 예산을 방해하지 않아야 합니다 (#265).
1004+
///
1005+
/// CodeRabbit #1: 이전 구현(공유 RwLock 슬롯)은 두 `process_query`가 동시에
1006+
/// 실행되면 한쪽이 다른 쪽의 tracker를 덮어쓰거나, 한쪽이 스캔을 마치기 전에
1007+
/// 슬롯을 클리어해서 나머지가 예산 없이 실행될 수 있었습니다.
1008+
///
1009+
/// task-local로 전환한 후에는 각 쿼리가 자기 tracker를 가지므로,
1010+
/// 큰 쿼리(예산 초과 → 에러)와 작은 쿼리(예산 내 → 성공)를 동시에 실행해도
1011+
/// 서로의 결과에 영향을 주지 않아야 합니다.
1012+
#[tokio::test]
1013+
async fn concurrent_queries_do_not_interfere_with_each_others_budget() {
1014+
let (engine, wal) =
1015+
build_test_engine_with_memory_limit("test_select_oom_concurrent", 2048).await;
1016+
1017+
execute_sql(&engine, wal.clone(), "create database rrdb;")
1018+
.await
1019+
.unwrap();
1020+
execute_sql(
1021+
&engine,
1022+
wal.clone(),
1023+
"create table small_t (id varchar(65536));",
1024+
)
1025+
.await
1026+
.unwrap();
1027+
execute_sql(
1028+
&engine,
1029+
wal.clone(),
1030+
"create table big_t (id varchar(65536));",
1031+
)
1032+
.await
1033+
.unwrap();
1034+
1035+
// small_t: 예산(2KB) 안에 들어오는 작은 값
1036+
// big_t: 예산을 넘기는 큰 값 (4KB 문자열)
1037+
insert_big_string_into(&engine, wal.clone(), "small_t", 256).await;
1038+
insert_big_string_into(&engine, wal.clone(), "big_t", 4096).await;
1039+
1040+
// 동시 실행: big 쿼리는 실패, small 쿼리는 성공해야 함
1041+
let (big_result, small_result) = tokio::join!(
1042+
execute_sql(&engine, wal.clone(), "select id from big_t;"),
1043+
execute_sql(&engine, wal.clone(), "select id from small_t;"),
1044+
);
1045+
1046+
let big_error = big_result.unwrap_err();
1047+
assert!(
1048+
big_error
1049+
.to_string()
1050+
.contains("query memory limit exceeded"),
1051+
"big query should be killed, got: {big_error}"
1052+
);
1053+
1054+
let small_rows = small_result.unwrap();
1055+
assert_eq!(small_rows.rows.len(), 1, "small query should still succeed");
1056+
}
8521057
}

src/engine/initialize.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ wal_enabled = true
262262
wal_directory = "/var/lib/rrdb/wal"
263263
wal_segment_size = 16777216
264264
wal_extension = "log"
265+
max_query_memory_bytes = 134217728
265266
"##;
266267

267268
struct TestCase {

0 commit comments

Comments
 (0)