Invalidation
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”เพิ่มบรรทัดเดียว cache::invalidate(&state.redis, &format!("cache:board:{board_id}")).await?; เข้าไปในทุก service function ของ boards/columns/cards/labels ที่เปลี่ยนสิ่งที่ GET /boards/:id คืนกลับมา: update_board, create_column, update_column, delete_column, create_card, update_card, delete_card, move_card, attach_label, detach_label
ก่อนหน้านั้นแต่ละตัวต้องปรับ plumbing เล็ก ๆ เหมือนกันก่อน — เปลี่ยน parameter แรกจาก db: &PgPool เป็น state: &AppState แล้วเพิ่ม let db = &state.db; เป็นบรรทัดแรก — เพื่อให้ function หยิบ state.redis มา invalidate ได้
สิบ function ได้ behavior จริงเพิ่มมาตัวละบรรทัดเดียว สิ่งที่ แต่ละตัวเขียนลง Postgres ไม่เปลี่ยนเลย เปลี่ยนแค่ว่าทันทีหลัง write สำเร็จ จะลบ cache tree ของ board ที่ write นั้นกระทบทิ้งด้วย
get_tree แบบ cache-aside จาก cache-reads ไม่มีทางรู้ได้เองว่าข้อมูลที่ cache ไว้เมื่อ 40 วินาทีก่อนเปลี่ยนไปแล้วหรือยัง — Redis key ไม่รู้ว่าใครเขียน หรือ stale ไปตั้งแต่เมื่อไหร่ แค่เก็บ JSON string ล่าสุดที่ได้รับไว้จนกว่า TTL จะหมด ต้องมีอะไรสักอย่างบอก cache ว่า “ค่านี้ผิดแล้ว” ในจังหวะที่เริ่มผิดพอดี และโค้ดเดียวที่รู้จังหวะนั้นก็คือตัว write เอง: update_column คือจุดเดียวในทั้งระบบที่รู้แน่นอนว่า title ของ column เปลี่ยนไปตอนไหน ดังนั้นนี่คือที่ที่ call invalidate อยู่ — หนึ่งอันต่อ mutating function หนึ่งตัว ทันทีหลัง write สำเร็จ ไม่ใช่ใน background job แยกต่างหากที่ต้องมานั่งเดาว่า cache ไหนต้องล้าง
ทั้งสิบ function นี้ต้องการ board_id เพื่อสร้าง invalidation key แต่ส่วนใหญ่เริ่มต้นมาด้วย id ที่แคบกว่า — column_id, card_id, label_id — จาก URL นี่ไม่ใช่ plumbing ใหม่ที่บทนี้เพิ่มเข้ามา — columns, cards, และ labels resolve board_id จาก id ที่แคบกว่าเหล่านั้นอยู่แล้วสำหรับเช็ค authorization boards_service::assert_member ของตัวเอง invalidation ก็แค่ใช้ board_id ตัวเดิมที่ function แต่ละตัวมีอยู่ในมืออยู่แล้วซ้ำอีกครั้ง — ไม่ใช่การ lookup ใหม่
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Delete-on-write invalidation (ตัวที่เราใช้) เทียบกับ write-through (อัปเดตค่าใน cache ตรง ๆ ทุกครั้งที่ write)
- ข้อดี:
cache::invalidateเป็นหนึ่งบรรทัด บรรทัดเดียวกันเป๊ะ ในทุก mutating function — ลบ key ทิ้ง แล้วปล่อยให้GETครั้งถัดไปสร้างขึ้นใหม่จากศูนย์ผ่านget_treecode path เดียวกับที่ทุก cache miss เดินผ่านอยู่แล้ว write-through หมายความว่าทั้งสิบ function นี้ต้องรู้วิธีสร้าง (หรือแก้) JSON blob ของBoardTreeที่ครบถ้วนถูกต้อง หลังจาก write แคบ ๆ ของตัวเองด้วย —update_columnจะต้องดึง tree ทั้งหมด แค่เพื่ออัปเดต title ของ column เดียวในสำเนาที่ cache ไว้ เปลี่ยนUPDATEแถวเดียวให้กลายเป็นการประกอบ N+1 แบบเดียวกับที่get_treeทำตอน miss อยู่แล้ว เพียงแต่ตอนนี้ผู้เขียนทุกคนต้องจ่ายแทนที่จะเป็นแค่ผู้อ่านคนถัดไป - ข้อเสีย: delete-on-write หมายความว่า read ครั้งถัดไปทันทีหลัง write ใด ๆ จะเป็น cache miss แน่นอน — การประกอบ
get_treeแบบเต็มราคาหนึ่งครั้ง หลีกเลี่ยงไม่ได้ สำหรับ request ไหนก็ตามที่มาถึงเป็นคนแรกหลัง mutation write-through จะทำให้ read ครั้งถัดไปนั้นยังเป็น cache hit อยู่ได้ แลกกับที่ผู้เขียนทุกคนต้องจ่ายต้นทุนการประกอบแทน สำหรับ TaskFlow read (การดู board) มีจำนวนมากกว่า write (การแก้ไข) อย่างมากในเวลาส่วนใหญ่ที่ board เปิดอยู่ ดังนั้น “read หนึ่งครั้งทันทีหลัง write ช้าลงนิดหน่อย” เป็นต้นทุนรวมที่เล็กกว่ามากเมื่อเทียบกับ “ทุก write ต้องทำงาน N+1 ไม่ว่าจะมีใครมาอ่านผลลัพธ์เร็ว ๆ นี้หรือไม่”
Invalidate ตอน attach_label/detach_label (ตัวที่เราใช้) เทียบกับข้าม cache invalidation ให้ label module ไปเลย
- ข้อดี:
Cardยังไม่มี fieldlabelsวันนี้ — JSON ของBoardTreeยังไม่มีข้อมูล label อยู่เลยตอนนี้ ดังนั้นการ attach หรือ detach label พูดตรง ๆ แล้วไม่ได้เปลี่ยนอะไรที่get_treeคืนกลับมาอยู่ตอนนี้ การ invalidate ไปก่อนเป็นทางเลือกเชิงป้องกันที่ตั้งใจ — ทันทีที่บทในอนาคตฝัง label ของแต่ละ card เข้าไปในColumnWithCards/BoardTree(ขั้นต่อไปที่เป็นธรรมชาติสำหรับ Kanban frontend)attach_label/detach_labelก็ invalidate key ที่ถูกต้องอยู่แล้ว — ไม่ต้องเพิ่มจุดเรียก invalidate ใหม่เลยสักจุด เพราะบทนี้วาง call ไว้ตรงจุดที่ write เกิดขึ้นแล้ว - ข้อเสีย: ตอนนี้ วันนี้ การเรียก invalidate สองจุดนี้ทำงานที่ไม่จำเป็นจริง ๆ อยู่เล็กน้อย — ลบ cache entry ทั้งที่ write ซึ่ง trigger การลบนั้นไม่ได้เปลี่ยนอะไรใน entry เลย ส่วน
create_label/delete_label(สอง write ของ label module ที่ไม่อยู่ในรายการของบทนี้) ข้าม invalidation ไปเลยด้วยเหตุผลเดียวกัน แค่ไม่มีข้อดีเรื่อง “future-proofing” — fieldname/colorของ label เองก็ไม่ได้อยู่ในBoardTreeเหมือนกัน และต่างจาก attach/detach ตรงที่ไม่มีอนาคตแบบไหนที่ field เหล่านั้นจะไปโผล่ในBoardTreeเลย — label เป็น metadata ระดับ board ไม่ใช่ส่วนหนึ่งของตำแหน่ง tree ของ card ตัวใดตัวหนึ่ง
ลงมือสร้าง
หัวข้อที่มีชื่อว่า “ลงมือสร้าง”1. boards/service.rs — update_board
หัวข้อที่มีชื่อว่า “1. boards/service.rs — update_board”pub async fn update_board( state: &AppState, user_id: Uuid, board_id: Uuid, title: String,) -> AppResult<Board> { let db = &state.db;
assert_member(db, user_id, board_id).await?; let board = repo::update_title(db, board_id, &title) .await? .ok_or(AppError::NotFound)?;
cache::invalidate(&state.redis, &format!("cache:board:{board_id}")).await?;
Ok(board)}delete_board ไม่ อยู่ในรายการนี้ และนั่นไม่ใช่ความพลาด — เมื่อลบ board chain ON DELETE CASCADE ของ boards.id (จาก schema) จะลบแถว board_members ที่ผูกอยู่ไปด้วย ดังนั้น assert_member — ที่ cache-reads ยืนยันแล้วว่ารันก่อนการอ่าน cache เสมอ — จะปฏิเสธทุก request ในอนาคตสำหรับ id ของ board นั้นก่อนที่ get_tree จะเข้าใกล้ Redis เลยด้วยซ้ำ entry cache:board:{id} ที่ stale ของ board ที่ถูกลบไปแล้ว ก็แค่นั่งรออยู่เฉย ๆ ไม่มีใครเข้าถึงได้ จนกว่า TTL จะหมดอายุ ไม่มีอะไรอ่านได้อยู่แล้ว จึงไม่ต้องล้างทิ้งแบบ explicit create_board ก็ไม่ต้อง invalidate เช่นกัน ด้วยเหตุผลด้านตรงข้าม — cache key ของ board ใหม่เอี่ยมยังไม่เคยถูกเขียนเลย จึงยังไม่มีอะไรให้ invalidate
อัปเดต handler update_board ใน boards/handlers.rs ให้ส่ง &state:
pub async fn update_board( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(board_id): Path<Uuid>, Json(body): Json<UpdateBoardRequest>,) -> AppResult<Json<Board>> { let board = service::update_board(&state, user_id, board_id, body.title).await?; Ok(Json(board))}2. columns/service.rs — create_column, update_column, delete_column
หัวข้อที่มีชื่อว่า “2. columns/service.rs — create_column, update_column, delete_column”pub async fn create_column( state: &AppState, user_id: Uuid, board_id: Uuid, title: String,) -> AppResult<Column> { let db = &state.db;
boards_service::assert_member(db, user_id, board_id).await?;
let position = repo::max_position(db, board_id).await?.unwrap_or(0.0) + 1.0; let column = repo::insert_column(db, Uuid::new_v4(), board_id, &title, position).await?;
cache::invalidate(&state.redis, &format!("cache:board:{board_id}")).await?;
Ok(column)}
pub async fn update_column( state: &AppState, user_id: Uuid, column_id: Uuid, title: String,) -> AppResult<Column> { let db = &state.db;
let column = repo::find_column(db, column_id) .await? .ok_or(AppError::NotFound)?; boards_service::assert_member(db, user_id, column.board_id).await?;
let updated = repo::update_title(db, column_id, &title) .await? .ok_or(AppError::NotFound)?;
cache::invalidate(&state.redis, &format!("cache:board:{}", column.board_id)).await?;
Ok(updated)}
pub async fn delete_column(state: &AppState, user_id: Uuid, column_id: Uuid) -> AppResult<()> { let db = &state.db;
let column = repo::find_column(db, column_id) .await? .ok_or(AppError::NotFound)?; boards_service::assert_member(db, user_id, column.board_id).await?;
if repo::delete_column(db, column_id).await? { cache::invalidate(&state.redis, &format!("cache:board:{}", column.board_id)).await?; Ok(()) } else { Err(AppError::NotFound) }}delete_column invalidate เฉพาะในกิ่ง if เท่านั้น — กิ่งที่แถวถูกลบจริง ๆ กิ่ง else (ที่ repo::delete_column ไม่ได้ลบอะไรเลยเพราะ id หายไปแล้ว) ไม่มี tree change ให้ invalidate จึงคืน AppError::NotFound เหมือนเดิมจาก columns โดยไม่เปลี่ยนแปลง
อัปเดตทั้งสาม handler ใน columns/handlers.rs ให้ส่ง &state แทน &state.db:
pub async fn create_column( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(board_id): Path<Uuid>, Json(body): Json<CreateColumnRequest>,) -> AppResult<(StatusCode, Json<Column>)> { let column = service::create_column(&state, user_id, board_id, body.title).await?; Ok((StatusCode::CREATED, Json(column)))}
pub async fn update_column( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(column_id): Path<Uuid>, Json(body): Json<UpdateColumnRequest>,) -> AppResult<Json<Column>> { let column = service::update_column(&state, user_id, column_id, body.title).await?; Ok(Json(column))}
pub async fn delete_column( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(column_id): Path<Uuid>,) -> AppResult<StatusCode> { service::delete_column(&state, user_id, column_id).await?; Ok(StatusCode::NO_CONTENT)}3. cards/service.rs — create_card, update_card, delete_card, move_card
หัวข้อที่มีชื่อว่า “3. cards/service.rs — create_card, update_card, delete_card, move_card”pub async fn create_card( state: &AppState, user_id: Uuid, column_id: Uuid, title: String, description: Option<String>,) -> AppResult<Card> { let db = &state.db;
let column = columns::repo::find_column(db, column_id) .await? .ok_or(AppError::NotFound)?; boards_service::assert_member(db, user_id, column.board_id).await?;
let position = repo::max_position(db, column_id).await?.unwrap_or(0.0) + 1.0; let card = repo::insert_card( db, Uuid::new_v4(), column_id, &title, description.as_deref(), position, ) .await?;
cache::invalidate(&state.redis, &format!("cache:board:{}", column.board_id)).await?;
Ok(card)}
pub async fn update_card( state: &AppState, user_id: Uuid, card_id: Uuid, title: Option<String>, description: Option<String>,) -> AppResult<Card> { let db = &state.db;
let card = repo::find_card(db, card_id) .await? .ok_or(AppError::NotFound)?; let board_id = card_board_id(db, &card).await?; boards_service::assert_member(db, user_id, board_id).await?;
let updated = repo::update_card(db, card_id, title.as_deref(), description.as_deref()) .await? .ok_or(AppError::NotFound)?;
cache::invalidate(&state.redis, &format!("cache:board:{board_id}")).await?;
Ok(updated)}
pub async fn delete_card(state: &AppState, user_id: Uuid, card_id: Uuid) -> AppResult<()> { let db = &state.db;
let card = repo::find_card(db, card_id) .await? .ok_or(AppError::NotFound)?; let board_id = card_board_id(db, &card).await?; boards_service::assert_member(db, user_id, board_id).await?;
if repo::delete_card(db, card_id).await? { cache::invalidate(&state.redis, &format!("cache:board:{board_id}")).await?; Ok(()) } else { Err(AppError::NotFound) }}get_card ไม่เปลี่ยนแปลง เพราะเป็น read ไม่ใช่ write จึงไม่มีอะไรให้ invalidate ส่วน card_board_id ที่เป็น helper สองชั้นแบบ private ก็ไม่เปลี่ยนเช่นกัน ยังรับ db: &PgPool เหมือนเดิม เพราะทำแค่อ่านอย่างเดียว
move_card รับ state: &AppState อยู่แล้ว — เป็นความต่างของ signature ที่ตั้งใจไว้ตัวเดียวที่ move-reorder เคยชี้ไว้ล่วงหน้า เพื่อให้การเพิ่มแบบนี้ในภายหลังไม่ต้องแตะ signature อีก เพิ่ม call invalidation ทันทีหลัง write ตรงจุดที่ comment ของบทนั้นทำเครื่องหมายไว้อยู่แล้ว:
let updated = repo::move_card(db, card_id, target_column_id, position) .await? .ok_or(AppError::NotFound)?;
cache::invalidate(&state.redis, &format!("cache:board:{source_board_id}")).await?;
// Module 7 wires realtime broadcast of card.moved here
Ok(updated)invalidate call เดียว ไม่ใช่สองครั้ง — move_card ปฏิเสธ target column ที่ board_id ต่างจาก source_board_id ก่อนที่จุดนี้จะรันเสียอีก ดังนั้น board ของ card ที่ถูกย้ายจึงเป็น board เดียวกันทั้งสองฝั่งของการย้ายเสมอ
อัปเดต create_card, update_card, และ delete_card ใน cards/handlers.rs ให้ส่ง &state (handler ของ move_card ส่งอยู่แล้ว จาก move-reorder):
pub async fn create_card( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(column_id): Path<Uuid>, Json(body): Json<CreateCardRequest>,) -> AppResult<(StatusCode, Json<Card>)> { let card = service::create_card(&state, user_id, column_id, body.title, body.description).await?; Ok((StatusCode::CREATED, Json(card)))}
pub async fn update_card( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(card_id): Path<Uuid>, Json(body): Json<UpdateCardRequest>,) -> AppResult<Json<Card>> { let card = service::update_card(&state, user_id, card_id, body.title, body.description).await?; Ok(Json(card))}
pub async fn delete_card( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path(card_id): Path<Uuid>,) -> AppResult<StatusCode> { service::delete_card(&state, user_id, card_id).await?; Ok(StatusCode::NO_CONTENT)}4. labels/service.rs — attach_label, detach_label
หัวข้อที่มีชื่อว่า “4. labels/service.rs — attach_label, detach_label”pub async fn attach_label( state: &AppState, user_id: Uuid, card_id: Uuid, label_id: Uuid,) -> AppResult<()> { let db = &state.db;
let card_board = card_board_id(db, card_id).await?; boards_service::assert_member(db, user_id, card_board).await?;
let label = repo::find_label(db, label_id) .await? .ok_or(AppError::NotFound)?; if label.board_id != card_board { return Err(AppError::NotFound); }
repo::attach(db, card_id, label_id).await?;
cache::invalidate(&state.redis, &format!("cache:board:{card_board}")).await?;
Ok(())}
pub async fn detach_label( state: &AppState, user_id: Uuid, card_id: Uuid, label_id: Uuid,) -> AppResult<()> { let db = &state.db;
let card_board = card_board_id(db, card_id).await?; boards_service::assert_member(db, user_id, card_board).await?;
if repo::detach(db, card_id, label_id).await? { cache::invalidate(&state.redis, &format!("cache:board:{card_board}")).await?; Ok(()) } else { Err(AppError::NotFound) }}create_label, list_labels, และ delete_label ไม่เปลี่ยนแปลง — ยังเป็น db: &PgPool ไม่มี invalidation — ด้วยเหตุผลที่ครอบคลุมใน Pros & cons: field ของ label เองไม่ได้อยู่ใน BoardTree วันนี้ และต่างจาก attach/detach ตรงที่ไม่มีความสัมพันธ์กับตำแหน่งของ card ที่จะดึงเข้าไปอยู่ใน BoardTree ในอนาคตด้วย
อัปเดต attach_label และ detach_label ใน labels/handlers.rs ให้ส่ง &state:
pub async fn attach_label( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path((card_id, label_id)): Path<(Uuid, Uuid)>,) -> AppResult<StatusCode> { service::attach_label(&state, user_id, card_id, label_id).await?; Ok(StatusCode::NO_CONTENT)}
pub async fn detach_label( State(state): State<AppState>, AuthUser(user_id): AuthUser, Path((card_id, label_id)): Path<(Uuid, Uuid)>,) -> AppResult<StatusCode> { service::detach_label(&state, user_id, card_id, label_id).await?; Ok(StatusCode::NO_CONTENT)}ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”cargo check -p apiใช้ $TOKEN และ $BOARD_ID เดิมจาก cache-reads เติม cache ด้วย GET แล้วยืนยันว่า key มีอยู่:
curl -s http://localhost:8080/boards/$BOARD_ID -H "Authorization: Bearer $TOKEN" > /dev/nulldocker compose exec redis redis-cli EXISTS cache:board:$BOARD_ID(integer) 1เปลี่ยนชื่อ board — write ที่โดน invalidation call ใหม่ของ update_board:
curl -s -X PATCH http://localhost:8080/boards/$BOARD_ID \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Sprint 12 (renamed)"}' > /dev/nullยืนยันว่า key หายไปทันที — ไม่ต้องรอ TTL 60 วินาที:
docker compose exec redis redis-cli EXISTS cache:board:$BOARD_ID(integer) 0ดึง board อีกครั้ง — จะเติม cache กลับมา และชื่อใหม่ก็พิสูจน์ว่าไม่ได้ serve ของ stale:
curl -s http://localhost:8080/boards/$BOARD_ID -H "Authorization: Bearer $TOKEN"{"id":"...","owner_id":"...","title":"Sprint 12 (renamed)","created_at":"...","columns":[]}สร้าง column — write ที่เข้าผ่าน id ที่แคบกว่า แต่ผล invalidation เหมือนกันบน board เดิม:
curl -s -X POST http://localhost:8080/boards/$BOARD_ID/columns \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"To Do"}' > /dev/null
docker compose exec redis redis-cli EXISTS cache:board:$BOARD_ID(integer) 0คุณเพิ่ม call cache::invalidate หนึ่งอันเข้าไปในแต่ละ service function ของ Module 5 ทั้งสิบตัวที่เปลี่ยน tree ของ board เปลี่ยน parameter แรกจาก db: &PgPool เป็น state: &AppState เพื่อให้แต่ละตัวเข้าถึง state.redis ได้ delete_board, create_board, create_label, delete_label, และทุก read ธรรมดา (list_boards, get_card, list_labels ฯลฯ) ไม่ถูกแตะเลย — ไม่ก็ยังไม่มีอะไรให้ invalidate หรือไม่ก็สิ่งที่พอจะ invalidate ได้ก็ไม่ได้เป็นส่วนหนึ่งของ BoardTree ตั้งแต่แรกอยู่แล้ว คุณยังเห็นด้วยว่าทำไม delete-on-write ถึงดีกว่า write-through ในกรณีนี้ — บรรทัดเดียวกันเป๊ะต่อ function เทียบกับผู้เขียนทุกคนต้องประกอบ tree ที่ cache ไว้ให้ถูกรูปร่างขึ้นมาใหม่ ทั้งที่อาจไม่มีใครมาอ่านอีกเลยก็ได้ บทถัดไป rate-limit เอา Redis มาใช้งานแบบที่สาม — ไม่ได้ cache ข้อมูลเลย แต่นับ request แทน เพื่อปกป้อง /auth/* จากการโจมตีแบบ brute-force