บทความเทคนิค

การบันทึกแบบ Crash-Safe ของ HotXLS: ไฟล์ Temp แบบ Staged ใน Delphi

การบันทึกที่ตายกลางคัน ไม่ว่าจะจากการรีบูตแบบบังคับ โปรเซสถูกฆ่า หรือดิสก์เต็มระหว่างเขียน แต่เดิมมีความหมายอย่างเดียวสำหรับฟอร์แมตที่สร้างขึ้นรอบการเขียนแบบ in-place คือ ไบต์ใดก็ตามที่ไปถึงดิสก์ก่อนการขัดจังหวะคือสิ่งที่คุณได้กลับมา และ workbook ที่ถูกตัดขาดกลางคันจะไม่เปิดขึ้นมาอีกต่อไป HotXLS ปิดโหมดความล้มเหลวนี้ด้วยเส้นทางการบันทึกแบบ crash-safe ที่ใช้กับทุกไฟล์ XLSX, ODS และ XLS แบบดั้งเดิมที่มันเขียน การเรียก SaveAs แต่ละครั้งเขียนไฟล์ใหม่ที่สมบูรณ์ลงในไฟล์ชั่วคราวที่สร้างไว้ข้างๆ ปลายทาง แล้วจึง commit มันด้วยการ rename แบบ atomic ครั้งเดียวคือ MoveFileExW จาก Windows API ดังนั้นการบันทึกที่ถูกขัดจังหวะทำได้แค่ล้มเหลวในการสร้างไฟล์ใหม่เท่านั้น มันไม่มีทางทำลายไฟล์ที่คุณมีอยู่แล้ว วินัยแบบ stage-แล้ว-swap เดียวกันนี้ทำงานอย่างสม่ำเสมอทั้งสอง save engine ของ HotXLS คือตัวเขียน BIFF8 เบื้องหลัง XLS แบบดั้งเดิม และตัวเขียน OOXML เบื้องหลัง XLSX และ ODS และเป็นรูปแบบที่ควรค่าแก่การนำไปใช้กับไฟล์ใดๆ ที่โค้ด Delphi ของคุณเองเขียนทับโดยตรง ไม่ว่าจะเป็นสเปรดชีตหรือไม่ก็ตาม

เกิดอะไรขึ้นถ้าการบันทึก workbook ถูกขัดจังหวะกลางคัน

คำตอบตรงไปตรงมาคือมันขึ้นอยู่ทั้งหมดกับวิธีที่ตัวเขียนแตะไฟล์ปลายทาง และการ implement ทั่วไป คือเปิดไฟล์เป้าหมายและ stream เนื้อหาใหม่เข้าไปตรงๆ ก็ใช้ได้ดีตราบเท่าที่ไม่มีอะไรผิดพลาดเลย ทันทีที่มีอะไรผิดพลาด เช่น การล่ม, การฆ่าโปรเซสแบบบังคับ, network share ที่หลุดกลางคัน ไฟล์บนดิสก์จะถูกทิ้งไว้ในสถานะกลางคันไม่ว่าจะเป็นสถานะไหนที่ตัวเขียนไปถึง ไดเรกทอรีกลาง ZIP ที่ไม่เคยถูกต่อท้ายสำหรับ XLSX หรือ ODS หรือ BIFF stream ที่ขาด record ที่ reader คาดหวังสำหรับ XLS แบบดั้งเดิม Excel ไม่ได้ซ่อมแซมสิ่งนั้นอย่างนุ่มนวล และไม่มี consumer อื่นใดที่คาดหวังไฟล์ที่สมบูรณ์ทำได้เช่นกัน ดังนั้นผลลัพธ์ในทางปฏิบัติคือ workbook ที่เปิดได้ดีเมื่อวานนี้ปฏิเสธที่จะเปิดในวันนี้

HotXLS จัด staged การบันทึกทุกครั้งไว้หลัง atomic swap เดียวได้อย่างไร

HotXLS ไม่เคยเปิดไฟล์ปลายทางเพื่อเขียนโดยตรงเลย สำหรับทั้งสามฟอร์แมตที่มันบันทึก ลำดับเป็นรูปแบบเดียวกันทุกครั้ง สร้างเอาต์พุตที่สมบูรณ์ที่ไหนสักแห่งซึ่งไม่ใช่ไฟล์ที่ผู้ใช้มีอยู่บนดิสก์แล้ว และย้ายมันเข้าไปแทนที่ก็ต่อเมื่อการสร้างนั้นสำเร็จอย่างสมบูรณ์เท่านั้น พูดให้เจาะจง SaveAs สร้างไฟล์ชั่วคราวว่างเปล่าในโฟลเดอร์เดียวกับ path เป้าหมาย เขียน workbook ใหม่ทั้งหมดลงในไฟล์ชั่วคราวนั้น และก็ต่อเมื่อการเขียนนั้นคืนค่ากลับมาโดยไม่มีข้อผิดพลาดเท่านั้น มันจึง commit ไฟล์ชั่วคราวทับปลายทางด้วยการ rename ครั้งเดียว ไม่มีอะไรในนี้ที่ต้องมี property ให้เปิดใช้งาน มันเป็นแค่สิ่งที่ SaveAs ทำสำหรับ path ไฟล์ธรรมดาในทุกการเรียก

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Report');
    Sheet.Cells[1, 1].Value := 'Nothing special to enable here';
    // If this call is interrupted, monthly-report.xlsx on disk stays
    // either the old version, complete, or the new version, complete
    if Book.SaveAs('monthly-report.xlsx', xlsxOpenXMLWorkbook) <> 1 then
      raise Exception.Create('Save failed, see Book.LastDiagnostic');
  finally
    Book.Free;
  end;
end;

วินัยเดียวกันนี้ใช้กับตัวเขียน XLS แบบดั้งเดิมด้วย ไม่ใช่แค่ตัวเขียน OOXML เท่านั้น และไฟล์ชั่วคราวทั้งสองยังใช้ข้อตกลงการตั้งชื่อร่วมกันด้วย ทั้งคู่เรียก Windows API ชื่อ GetTempFileNameW ด้วย prefix hxl ดังนั้นการบันทึกที่ถูกขัดจังหวะก่อนการล้างข้อมูลอาจทิ้งไฟล์แปลกปลอมไว้ด้วยชื่อคล้าย hxl4C2A.tmp ที่นั่งอยู่ข้างๆ workbook ของคุณ ไฟล์นั้นไม่ใช่ความเสียหาย มันคือหลักฐานว่ากลไกทำงานตามที่ออกแบบไว้เป๊ะ การเขียนที่ไม่สมบูรณ์หยุดอยู่ตรงนั้น และ workbook จริงของคุณไม่เคยถูกเปิดเพื่อเขียนตั้งแต่แรก การเห็นไฟล์แบบนั้นหลังการล่มปลอดภัยที่จะลบทิ้งและไม่มีอะไรต้องสืบสวนต่อ

ทำไมต้อง stage ไฟล์ temp ไว้ข้างๆ workbook แทนที่จะเป็น %TEMP%

คำตอบสั้นๆ คือการ rename ของ MoveFileExW จะเป็น atomic ก็ต่อเมื่อต้นทางและปลายทางอยู่บน volume เดียวกันเท่านั้น และวิธีที่มั่นใจที่สุดที่จะรับประกันสิ่งนั้นโดยไม่ต้องขอให้ผู้เรียกตั้งค่าอะไรเลย คือการดึงตำแหน่งของไฟล์ชั่วคราวมาจาก path ปลายทางเอง HotXLS คำนวณโฟลเดอร์ของเป้าหมายเองและส่งไดเรกทอรีนั้นตรงเข้า GetTempFileNameW ดังนั้นไฟล์ชั่วคราวจึงถูกสร้างบนไดรฟ์เดียวกัน volume เดียวกัน กับไฟล์ที่มันกำลังจะแทนที่เสมอ โดยอัตโนมัติ ในทุกการบันทึก หากไลบรารีนี้ stage การเขียนไว้ในโฟลเดอร์ temp ของระบบแทน path เป้าหมายบนไดรฟ์อื่นหรือ network volume ที่ map ไว้ ก็จะเปลี่ยนขั้นตอนสุดท้ายให้เป็นการดำเนินการข้าม volume ซึ่ง Windows API ปฏิเสธโดยตรงเลย หรือถ้าผู้เรียกเลือกเข้าร่วมอย่างชัดเจนด้วย flag เพิ่มเติมที่ HotXLS ไม่ได้ตั้งไว้ตรงนี้ ก็จะลดระดับลงเงียบๆ กลายเป็นการ copy แบบไม่ใช่ atomic ตามด้วยการลบ ซึ่งเปิดช่องขัดจังหวะขึ้นมาใหม่พอดีกับที่กลไกทั้งหมดนี้มีอยู่เพื่อปิด

ขั้นตอน commit: MoveFileExW, write-through และสิ่งที่เกิดขึ้นเมื่อล้มเหลว

ขั้นตอนสุดท้ายของทุกการบันทึกคือการเรียก Windows API เดียวเป๊ะ คือ MoveFileExW ซึ่งพก flag สองตัวที่แต่ละตัวทำงานต่างกัน MOVEFILE_REPLACE_EXISTING คือสิ่งที่อนุญาตให้การ rename ลงเอยที่ไฟล์ที่มีอยู่แล้วได้ ถ้าไม่มี flag นี้ การ rename ที่เจาะจง path ที่มีอยู่แล้วจะล้มเหลวทันที ซึ่งจะทำลายจุดประสงค์ทั้งหมดของการบันทึกที่ต้องการแทนที่ workbook ที่คุณมีอยู่แล้ว MOVEFILE_WRITE_THROUGH ครอบคลุมเรื่องความคงทน มันบอกฟังก์ชันไม่ให้คืนค่ากลับมาจนกว่าการย้ายจะเสร็จสมบูรณ์บนดิสก์จริงๆ แทนที่จะคืนค่าทันทีที่การ rename แค่ถูกจัดคิวไว้ ปิดช่อง race ที่แคบกว่าแต่ยังคงจริงอยู่ ซึ่งการล่มทันทีหลังจาก SaveAs คืนค่ากลับมาอาจยังจับ swap ได้ในระหว่างทำงาน ถ้าไฟล์ชั่วคราวสร้างไม่ได้ หรือการ rename สุดท้ายล้มเหลวด้วยเหตุผลใดก็ตาม (ปัญหาสิทธิ์, ปลายทางถูกล็อค, volume ไม่ตรงกัน) HotXLS จะลบไฟล์ชั่วคราวเองแทนที่จะทิ้งขยะไว้ และไฟล์ปลายทางจะถูกทิ้งไว้เหมือนเดิมทุกประการก่อนการเรียกนั้น

Result := Book.SaveAs(TargetPath, xlsxOpenXMLWorkbook);
if Result <> 1 then
begin
  // TargetPath on disk is unchanged; safe to retry, alert, or
  // fall back to a different path without touching prior output
  LogWriter.Write(Format('SaveAs failed (%d): %s',
    [Book.LastDiagnostic.Code, Book.LastDiagnostic.Message]));
  Exit(False);
end;

SaveAs เองยังคงข้อตกลงการคืนค่าที่ใช้ร่วมกันทั่วทั้ง HotXLS คือ หนึ่งเมื่อสำเร็จ ตัวเลขติดลบเมื่อล้มเหลว แต่ integer เปล่าๆ ไม่ได้บอกว่าทำไมการบันทึกถึงล้มเหลว และการปฏิบัติต่อทุกผลลัพธ์ติดลบแบบเดียวกันจะทิ้งข้อมูลที่นโยบายการ retry สามารถใช้ได้จริงไป property LastDiagnostic และ Diagnostics collection ที่กว้างกว่าเบื้องหลังมัน พกข้อความที่ HotXLS สร้างขึ้นภายใน แยกแยะไฟล์ temp ที่สร้างไม่ได้ออกจากการ rename ที่ Windows ปฏิเสธ งาน batch ที่ log Code และ Message ทุกครั้งที่ SaveAs ล้มเหลว จะสร้างหลักฐานที่คุณต้องการไว้พอดีสำหรับครั้งเดียวที่ลูกค้ารายงานว่าการบันทึกไม่ทำอะไรเลยอย่างเงียบๆ

XLS แบบดั้งเดิมจ่ายด้วยหน่วยความจำ ส่วน XLSX และ ODS จ่ายด้วยดิสก์

save engine ทั้งสองไปถึงผลลัพธ์แบบ crash-safe เดียวกันด้วยเส้นทางที่ต่างกัน และความแตกต่างนี้สำคัญถ้าคุณกำลังปรับแต่งตัวใดตัวหนึ่งอยู่แล้วสำหรับงาน batch ขนาดใหญ่ ตัวเขียน XLS แบบดั้งเดิมสร้าง OLE compound document ทั้งหมดในหน่วยความจำก่อน โดยใช้ structured storage ที่หนุนหลังด้วย memory handle และแค่ copy buffer ที่เสร็จแล้วนั้นออกไปยังไฟล์ชั่วคราวข้างเคียงในการเขียนครั้งเดียว เหตุผลใน source ของ HotXLS เองตรงไปตรงมา การสร้างไฟล์ทั้งหมดในหน่วยความจำก่อนคือสิ่งที่หยุดการบันทึกที่ล้มเหลวหรือถูกยกเลิกไม่ให้ตัดไฟล์ปลายทางทิ้งเลย ตัวเขียน XLSX และ ODS แทนที่จะทำแบบนั้น stream entry ของ ZIP เข้าไปในไฟล์ชั่วคราวขณะที่มันถูกสร้างขึ้น เป็นการ stage ระดับไฟล์แบบเดียวกันแต่มี memory profile ต่างกัน ถ้าคุณพึ่งพา StreamingWrite อยู่แล้วเพื่อให้การ export XLSX ขนาดใหญ่อยู่ภายในขีดจำกัดหน่วยความจำของ container คันโยกที่เทียบเท่าสำหรับการ export XLS แบบเก่าไม่มีอยู่ในรูปแบบเดียวกันนั้น การรับประกัน crash-safe ไม่มีเงื่อนไขไม่ว่ากรณีไหน แต่การ export .xls แบบเก่าที่ใหญ่มากๆ ยังคงถือเอาต์พุตทั้งหมดของมันไว้ใน RAM อยู่ดี ซึ่งเป็นการแลกเปลี่ยนที่ครอบคลุมลึกกว่าในบทความเรื่องการเขียนแบบ streaming สำหรับงาน batch บนเซิร์ฟเวอร์ของเรา

การนำรูปแบบเดียวกันไปใช้นอก HotXLS และขอบเขตที่การรับประกันสิ้นสุด

การนำรูปแบบนี้ไปใช้ส่วนใหญ่เป็นแค่เรื่องของการต่อสาย Windows API สองตัวเดียวกับที่ HotXLS พึ่งพาภายใน GetTempFileNameW ให้ไฟล์ว่างเปล่าที่มีชื่อไม่ซ้ำในโฟลเดอร์ที่คุณเลือก และ MoveFileExW commit การเขียนที่เสร็จแล้วของคุณทับปลายทางจริงในขั้นตอนเดียว รูทีนแบบย่อของรูทีนเดียวกันที่ HotXLS รันก่อนทุก SaveAs มีหน้าตาแบบนี้

function SaveFileAtomically(const Path: WideString; const Contents: TBytes): Boolean;
var
  Dir, TempName: WideString;
  Buffer: array[0..MAX_PATH] of WideChar;
  FS: TFileStream;
begin
  Result := False;
  Dir := ExtractFilePath(ExpandFileName(Path));
  FillChar(Buffer, SizeOf(Buffer), 0);
  if GetTempFileNameW(PWideChar(Dir), 'app', 0, @Buffer[0]) = 0 then
    Exit;
  TempName := PWideChar(@Buffer[0]);
  try
    FS := TFileStream.Create(TempName, fmCreate or fmShareExclusive);
    try
      FS.WriteBuffer(Contents[0], Length(Contents));
    finally
      FS.Free;
    end;
    Result := MoveFileExW(PWideChar(TempName), PWideChar(ExpandFileName(Path)),
      MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
  finally
    if not Result then
      DeleteFileW(PWideChar(TempName));
  end;
end;

การรับประกันนี้มีขอบเขตจริงที่ควรรู้ไว้ก่อนที่คุณจะพึ่งพามันแบบไม่ตั้งคำถาม การ stage สำเนาที่สมบูรณ์ก่อนแทนที่ต้นฉบับ หมายความว่าการบันทึกต้องการพื้นที่ดิสก์ชั่วคราวสำหรับทั้งไฟล์เก่าและไฟล์ใหม่ ประมาณสองเท่าของขนาด workbook ตลอดระยะเวลาการเขียน ซึ่งไม่มีปัญหาสำหรับรายงานทั่วไปแต่ควรตรวจสอบสำหรับการ export ขนาดหลายกิกะไบต์ที่รันกับ volume ที่เกือบเต็ม ไฟล์ชั่วคราวยังต้องลงเอยในโฟลเดอร์เดียวกับปลายทางด้วย ดังนั้นบัญชีใดก็ตามที่ HotXLS กำลังทำงานอยู่ภายใต้ต้องมีสิทธิ์สร้างไฟล์บนโฟลเดอร์นั้นโดยเฉพาะ ไม่ใช่แค่สิทธิ์เขียนทับไฟล์เดียวที่มันรู้จักอยู่แล้ว การติดตั้งที่ล็อคโฟลเดอร์ปลายทางให้แค่แก้ไขชื่อไฟล์ที่มีอยู่แล้วแบบ in-place แทนที่จะเป็นสิทธิ์เขียนระดับโฟลเดอร์ จะเห็น SaveAs ล้มเหลวที่ขั้นตอนไฟล์ temp แม้ว่าการเขียนตรงที่เทียบเท่ากันจะสำเร็จก็ตาม

มีขอบเขตอีกสองอย่างที่ควรชี้แจงให้ชัด ปลายทางบน network share หรือภายในโฟลเดอร์ที่ sync ด้วย OneDrive หรือ client คล้ายกันอาจมีพฤติกรรมต่างจาก NTFS ในเครื่อง แม้ว่า Windows จะยังรายงานว่ามันเป็น volume เดียวก็ตาม เพราะ filesystem driver ที่อยู่ด้านหน้ามันอาจไม่ implement การ rename แบบเดียวกัน ถ้าเป้าหมายการติดตั้งของคุณบันทึกข้าม network path คุ้มค่าที่จะทดสอบการขัดจังหวะแบบบังคับที่นั่นโดยเฉพาะ แทนที่จะสมมติว่าพฤติกรรมของดิสก์ในเครื่องนำมาใช้ได้เหมือนกัน และกลไกทั้งหมดนี้จำกัดขอบเขตอยู่แค่การบันทึกลงไฟล์ที่มีชื่อเท่านั้น เรียก SaveAs กับ TStream แทน แล้ว HotXLS จะเขียนเข้าไปในสตรีมใดก็ตามที่คุณส่งให้ตรงๆ โดยไม่มีไฟล์ปลายทางให้ stage หรือปกป้อง เพราะความคงทนของสตรีมนั้น (memory buffer, การอัปโหลดผ่านเครือข่าย, database blob) เป็นความรับผิดชอบของโค้ดคุณเองทั้งหมดตั้งแต่จุดนั้นเป็นต้นไป

รอบการตรวจสอบได้รับสิทธิ์พึ่งพาการรับประกันนี้พอดีในภายหลัง รวมถึงแบบที่สร้างไว้ในเวิร์กเบนช์สำหรับตรวจสอบและแปลง workbook ไฟล์ที่เปิดใหม่แล้วสั้นลงหรือขาดหายไปคือปัญหาการแปลงจริงที่ต้องตามหาต้นตอ ไม่ใช่การบันทึกที่ถูกขัดจังหวะกลางคันและทิ้งอะไรที่คลุมเครือไว้บนดิสก์เลย การเขียนแบบ staged ที่ crash-safe ถูกสร้างเข้าไปใน SaveAs สำหรับทุก workbook แบบ XLSX, ODS และ XLS แบบดั้งเดิมที่ผลิตโดยHotXLS Componentสำหรับ Delphi และ C++Builder โดยไม่ต้องตั้งค่าใดๆ เพื่อเปิดใช้งาน