Swap: chunked checkpoints for large buffers; scaled thresholds; single replay

- A checkpoint larger than one record (16 MiB) is written as CHKPT_BEGIN
  [size u64, CRC-32], CHKPT_DATA chunks and CHKPT_END. Replay assembles it
  and applies it only when complete with the size and CRC matching; an
  unfinished one is a torn tail (valid_bytes stops before it). Buffers
  over 16 MiB could not be checkpointed before: a lost record left their
  journal behind for good, and they were never compacted. Records are
  written as segments straight from the snapshot. Header version 2;
  version 1 journals are still read.
- Checkpoint thresholds scale with the buffer size (edits of half its
  size; the timer only after a sixteenth; compaction at twice its size;
  gap retries spaced by size), and a checkpoint that would compact is
  written straight into the compacted journal instead of being appended
  and then copied.
- The recovery prompt keeps the content it replayed for its preview and
  installs it on 'y', instead of replaying the journal a second time.
- A base file over 64 MiB gets its CRC from the writer thread before it
  creates the journal (it had none, so touching such a file made its
  journal unusable for recovery).
- kte_tests takes optional name substrings to run a subset of tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
This commit is contained in:
Claude
2026-09-24 09:16:04 -07:00
committed by Kyle Isom
parent fc15a58a2b
commit a33c1759e7
9 changed files with 491 additions and 96 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ cmake --build build --target kte_tests # test suite
cmake --build build --target kte_tests && ./build/kte_tests cmake --build build --target kte_tests && ./build/kte_tests
``` ```
There is no single-test runner; the test binary runs all tests. Tests use a minimal custom framework in `tests/Test.h` with `TEST()`, `ASSERT_EQ()`, `ASSERT_TRUE()`, `EXPECT_TRUE()` macros. The test binary runs all tests, or only those whose names contain one of its arguments (`./build/kte_tests Swap_ Undo_`). Tests use a minimal custom framework in `tests/Test.h` with `TEST()`, `ASSERT_EQ()`, `ASSERT_TRUE()`, `EXPECT_TRUE()` macros.
### Key CMake Options ### Key CMake Options
+18 -3
View File
@@ -395,6 +395,9 @@ Editor::CancelRecoveryPrompt()
pending_recovery_open_ = PendingOpen{}; pending_recovery_open_ = PendingOpen{};
pending_recovery_swap_path_.clear(); pending_recovery_swap_path_.clear();
pending_recovery_replay_err_.clear(); pending_recovery_replay_err_.clear();
std::string().swap(pending_recovery_content_);
pending_recovery_valid_bytes_ = 0;
pending_recovery_have_content_ = false;
} }
@@ -407,6 +410,9 @@ Editor::ResolveRecoveryPrompt(const bool yes)
const PendingOpen req = pending_recovery_open_; const PendingOpen req = pending_recovery_open_;
const std::string swp = pending_recovery_swap_path_; const std::string swp = pending_recovery_swap_path_;
const std::string rerr_s = pending_recovery_replay_err_; const std::string rerr_s = pending_recovery_replay_err_;
std::string content = std::move(pending_recovery_content_);
const bool have_content = pending_recovery_have_content_;
std::uint64_t valid_bytes = pending_recovery_valid_bytes_;
CancelRecoveryPrompt(); CancelRecoveryPrompt();
std::string err; std::string err;
@@ -421,12 +427,17 @@ Editor::ResolveRecoveryPrompt(const bool yes)
SetStatus("Recovery failed: no buffer"); SetStatus("Recovery failed: no buffer");
return false; return false;
} }
if (have_content) {
// The prompt already replayed the journal; install that.
b->replace_all_bytes(content);
std::string().swap(content);
} else {
std::string rerr; std::string rerr;
std::uint64_t valid_bytes = 0;
if (!kte::SwapManager::ReplayFile(*b, swp, rerr, &valid_bytes)) { if (!kte::SwapManager::ReplayFile(*b, swp, rerr, &valid_bytes)) {
SetStatus("Swap recovery failed: " + rerr); SetStatus("Swap recovery failed: " + rerr);
return false; return false;
} }
}
// This session keeps appending to the same journal. Drop a torn // This session keeps appending to the same journal. Drop a torn
// final record first, or new records would land behind it and the // final record first, or new records would land behind it and the
// journal could not be replayed after another crash. // journal could not be replayed after another crash.
@@ -581,8 +592,9 @@ Editor::process_pending_opens_()
if (tmp.OpenFromFile(req.path, oerr)) { if (tmp.OpenFromFile(req.path, oerr)) {
const std::string orig = buffer_bytes_via_views(tmp); const std::string orig = buffer_bytes_via_views(tmp);
std::string rerr; std::string rerr;
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) { std::uint64_t valid = 0;
const std::string rec = buffer_bytes_via_views(tmp); if (kte::SwapManager::ReplayFile(tmp, swp, rerr, &valid)) {
std::string rec = buffer_bytes_via_views(tmp);
if (rec == orig) { if (rec == orig) {
// Nothing to recover. Remove the journal rather than // Nothing to recover. Remove the journal rather than
// appending this session's records to it (it may end in a // appending this session's records to it (it may end in a
@@ -592,6 +604,9 @@ Editor::process_pending_opens_()
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard; pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
pending_recovery_open_ = req; pending_recovery_open_ = req;
pending_recovery_swap_path_ = swp; pending_recovery_swap_path_ = swp;
pending_recovery_content_ = std::move(rec);
pending_recovery_valid_bytes_ = valid;
pending_recovery_have_content_ = true;
StartPrompt(PromptKind::Confirm, "Recover", ""); StartPrompt(PromptKind::Confirm, "Recover", "");
SetStatus("Recover swap edits for " + req.path + "? (y/n, C-g cancel)"); SetStatus("Recover swap edits for " + req.path + "? (y/n, C-g cancel)");
return opened_any; return opened_any;
+5
View File
@@ -717,6 +717,11 @@ private:
PendingOpen pending_recovery_open_{}; PendingOpen pending_recovery_open_{};
std::string pending_recovery_swap_path_; std::string pending_recovery_swap_path_;
std::string pending_recovery_replay_err_; std::string pending_recovery_replay_err_;
// The recovered content the prompt offers, and how much of the journal
// produced it: "y" installs it instead of replaying the journal again.
std::string pending_recovery_content_;
std::uint64_t pending_recovery_valid_bytes_ = 0;
bool pending_recovery_have_content_ = false;
// GUI-only state (safe no-op in terminal builds) // GUI-only state (safe no-op in terminal builds)
bool file_picker_visible_ = false; bool file_picker_visible_ = false;
+229 -82
View File
@@ -27,13 +27,20 @@ namespace {
constexpr std::size_t kMaxRecordPayload = 0xFFFFFFu; constexpr std::size_t kMaxRecordPayload = 0xFFFFFFu;
// While a journal has a gap, request a recovery checkpoint at most this often. // While a journal has a gap, request a recovery checkpoint at most this often.
constexpr std::uint64_t kGapCheckpointIntervalNs = 1000000000ULL; constexpr std::uint64_t kGapCheckpointIntervalNs = 1000000000ULL;
// Base files up to this size get their CRC computed on open/save; larger ones
// on the writer thread (see compute_base). Adjustable for tests.
std::uint64_t g_sync_crc_limit = std::uint64_t{64} << 20;
// Checkpoints of buffers larger than this are spaced by buffer size.
constexpr std::size_t kLargeCheckpointBytes = std::size_t{16} << 20;
} // namespace } // namespace
static void remove_journal_unless_held(const std::string &path, bool locked_out); static void remove_journal_unless_held(const std::string &path, bool locked_out);
namespace { namespace {
constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'}; constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
constexpr std::uint32_t VERSION = 1; // Version 2 adds chunked checkpoints (CHKPT_BEGIN/DATA/END); version 1
// journals (which never contain them) are still read.
constexpr std::uint32_t VERSION = 2;
static std::string static std::string
@@ -866,15 +873,85 @@ SwapManager::close_ctx(JournalCtx &ctx)
} }
void
SwapManager::frame_pending(const Pending &p, RecordFrames &f)
{
// One record: [type u8][len u24][prefix][data][crc32 u32], CRC over all
// but itself.
auto frame = [&f](SwapRecType type, std::vector<std::uint8_t> prefix, const char *data, std::size_t n) {
std::vector<std::uint8_t> head(4);
head[0] = static_cast<std::uint8_t>(type);
put_u24_le(head.data() + 1, static_cast<std::uint32_t>(prefix.size() + n));
std::uint32_t c = crc32(head.data(), head.size(), 0);
if (!prefix.empty())
c = crc32(prefix.data(), prefix.size(), c);
if (n > 0)
c = crc32(reinterpret_cast<const std::uint8_t *>(data), n, c);
head.insert(head.end(), prefix.begin(), prefix.end());
f.owned.push_back(std::move(head));
f.segs.emplace_back(f.owned.back().data(), f.owned.back().size());
f.total += f.owned.back().size();
if (n > 0) {
f.segs.emplace_back(reinterpret_cast<const std::uint8_t *>(data), n);
f.total += n;
}
std::vector<std::uint8_t> crc(4);
crc[0] = static_cast<std::uint8_t>(c & 0xFFu);
crc[1] = static_cast<std::uint8_t>((c >> 8) & 0xFFu);
crc[2] = static_cast<std::uint8_t>((c >> 16) & 0xFFu);
crc[3] = static_cast<std::uint8_t>((c >> 24) & 0xFFu);
f.owned.push_back(std::move(crc));
f.segs.emplace_back(f.owned.back().data(), f.owned.back().size());
f.total += 4;
};
if (p.type != SwapRecType::CHKPT) {
frame(p.type, p.payload, nullptr, 0);
return;
}
const std::string &bytes = p.chkpt;
if (bytes.size() <= kMaxRecordPayload - 5) {
// v1 checkpoint: [encver u8=1][nbytes u32][bytes]
std::vector<std::uint8_t> prefix{1};
put_le32(prefix, static_cast<std::uint32_t>(bytes.size()));
frame(SwapRecType::CHKPT, std::move(prefix), bytes.data(), bytes.size());
return;
}
// Chunked: BEGIN, DATA..., END (see SwapRecType).
std::vector<std::uint8_t> begin{1};
std::uint8_t total[8];
put_le64(total, static_cast<std::uint64_t>(bytes.size()));
begin.insert(begin.end(), total, total + 8);
put_le32(begin, crc32(reinterpret_cast<const std::uint8_t *>(bytes.data()), bytes.size(), 0));
frame(SwapRecType::CHKPT_BEGIN, std::move(begin), nullptr, 0);
for (std::size_t off = 0; off < bytes.size(); off += kMaxRecordPayload) {
const std::size_t n = std::min(kMaxRecordPayload, bytes.size() - off);
frame(SwapRecType::CHKPT_DATA, {}, bytes.data() + off, n);
}
frame(SwapRecType::CHKPT_END, std::vector<std::uint8_t>{1}, nullptr, 0);
}
bool bool
SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err) SwapManager::write_frames(int fd, const RecordFrames &f)
{
for (const auto &[data, n]: f.segs) {
if (!write_full(fd, data, n))
return false;
}
return true;
}
bool
SwapManager::compact_to_checkpoint(JournalCtx &ctx, const RecordFrames &chkpt_record, std::string &err)
{ {
err.clear(); err.clear();
if (ctx.path.empty()) { if (ctx.path.empty()) {
err = "Compact failed: empty path"; err = "Compact failed: empty path";
return false; return false;
} }
if (chkpt_record.empty()) { if (chkpt_record.total == 0) {
err = "Compact failed: empty checkpoint record"; err = "Compact failed: empty checkpoint record";
return false; return false;
} }
@@ -912,7 +989,7 @@ SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8
(void) kte::syscall::Fchmod(tfd, 0600); (void) kte::syscall::Fchmod(tfd, 0600);
bool ok = write_header(tfd, ctx); bool ok = write_header(tfd, ctx);
if (ok) if (ok)
ok = write_full(tfd, chkpt_record.data(), chkpt_record.size()); ok = write_frames(tfd, chkpt_record);
if (ok) { if (ok) {
if (kte::syscall::Fsync(tfd) != 0) { if (kte::syscall::Fsync(tfd) != 0) {
int saved_errno = errno; int saved_errno = errno;
@@ -969,7 +1046,7 @@ SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8
} }
// Re-open for further appends. // Re-open for further appends.
ctx.approx_size_bytes = 64 + static_cast<std::uint64_t>(chkpt_record.size()); ctx.approx_size_bytes = 64 + static_cast<std::uint64_t>(chkpt_record.total);
return true; return true;
} }
@@ -1168,11 +1245,20 @@ SwapManager::maybe_request_checkpoint(Buffer &buf, const std::size_t approx_edit
const std::uint64_t now = now_ns(); const std::uint64_t now = now_ns();
if (ctx.last_chkpt_ns == 0) if (ctx.last_chkpt_ns == 0)
ctx.last_chkpt_ns = now; ctx.last_chkpt_ns = now;
const bool bytes_hit = (cfg.checkpoint_bytes > 0) && ( // A checkpoint writes the whole buffer. For a large one, checkpoint
ctx.edit_bytes_since_chkpt >= cfg.checkpoint_bytes); // after edits amounting to half its size (and on the timer only
// once edits reach a sixteenth), so the bytes written stay in
// proportion to the bytes edited; retry a gap less often too.
const std::size_t size = buf.ContentBytes();
const bool large = size > kLargeCheckpointBytes;
const std::size_t bytes_limit = std::max<std::size_t>(cfg.checkpoint_bytes, size / 2);
const bool bytes_hit = (cfg.checkpoint_bytes > 0) && ctx.edit_bytes_since_chkpt >= bytes_limit;
const bool time_hit = (cfg.checkpoint_interval_ms > 0) && const bool time_hit = (cfg.checkpoint_interval_ms > 0) &&
(((now - ctx.last_chkpt_ns) / 1000000ULL) >= cfg.checkpoint_interval_ms); (((now - ctx.last_chkpt_ns) / 1000000ULL) >= cfg.checkpoint_interval_ms) &&
const bool gap_hit = ctx.gap && (now - ctx.gap_chkpt_request_ns) >= kGapCheckpointIntervalNs; (!large || ctx.edit_bytes_since_chkpt >= size / 16);
const std::uint64_t gap_interval = kGapCheckpointIntervalNs *
(1 + size / kLargeCheckpointBytes);
const bool gap_hit = ctx.gap && (now - ctx.gap_chkpt_request_ns) >= gap_interval;
if (gap_hit) if (gap_hit)
ctx.gap_chkpt_request_ns = now; ctx.gap_chkpt_request_ns = now;
if (bytes_hit || time_hit || gap_hit) { if (bytes_hit || time_hit || gap_hit) {
@@ -1197,7 +1283,9 @@ SwapManager::RetryGapCheckpoints()
for (auto &[b, ctx]: journals_) { for (auto &[b, ctx]: journals_) {
if (!b || !ctx.gap || ctx.suspended || ctx.locked_out || ctx.gap_unfixable_reported) if (!b || !ctx.gap || ctx.suspended || ctx.locked_out || ctx.gap_unfixable_reported)
continue; continue;
if (now - ctx.gap_chkpt_request_ns < kGapCheckpointIntervalNs) const std::uint64_t interval = kGapCheckpointIntervalNs *
(1 + b->ContentBytes() / kLargeCheckpointBytes);
if (now - ctx.gap_chkpt_request_ns < interval)
continue; continue;
ctx.gap_chkpt_request_ns = now; ctx.gap_chkpt_request_ns = now;
due.push_back(b); due.push_back(b);
@@ -1238,41 +1326,12 @@ SwapManager::RecordCheckpoint(Buffer &buf, const bool urgent_flush)
return; return;
} }
// payload = encver(1) + nbytes(4) + bytes; larger buffers cannot be // Any size: the writer frames large checkpoints as several records.
// checkpointed in this record format (the writer would reject them), so
// check the size before paying for a snapshot.
const std::size_t nrows = buf.Nrows();
const std::size_t total_size = nrows ? buf.GetLineRange(nrows - 1).second : 0;
if (total_size > kMaxRecordPayload - 5) {
// If the journal has a gap, only a checkpoint could close it: say so
// once rather than silently dropping every later record.
bool report = false;
{
std::lock_guard<std::mutex> lg(mtx_);
auto it = journals_.find(&buf);
if (it != journals_.end() && it->second.gap && !it->second.gap_unfixable_reported) {
it->second.gap_unfixable_reported = true;
report = true;
}
}
if (report)
report_error("Swap journal lost a record and the buffer is too large to checkpoint; "
"crash recovery will not include edits made until the file is saved", &buf);
return;
}
const std::string bytes = snapshot_buffer_bytes(buf);
if (bytes.size() > kMaxRecordPayload - 5)
return;
Pending p; Pending p;
p.buf = &buf; p.buf = &buf;
p.type = SwapRecType::CHKPT; p.type = SwapRecType::CHKPT;
p.urgent_flush = urgent_flush; p.urgent_flush = urgent_flush;
// payload v1: [encver u8=1][nbytes u32][bytes] p.chkpt = snapshot_buffer_bytes(buf);
p.payload.push_back(1);
put_le32(p.payload, static_cast<std::uint32_t>(bytes.size()));
p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(bytes.data()),
reinterpret_cast<const std::uint8_t *>(bytes.data()) + bytes.size());
enqueue(std::move(p)); enqueue(std::move(p));
} }
@@ -1435,6 +1494,8 @@ SwapManager::process_one(const Pending &p)
} }
if (!ctxp) if (!ctxp)
return; return;
if (ctxp->fd < 0)
complete_base_crc(*ctxp);
std::string open_err; std::string open_err;
bool locked_out = false; bool locked_out = false;
if (!open_ctx(*ctxp, path, open_err, &locked_out)) { if (!open_ctx(*ctxp, path, open_err, &locked_out)) {
@@ -1473,32 +1534,26 @@ SwapManager::process_one(const Pending &p)
return; return;
} }
// Build record: [type u8][len u24][payload][crc32 u32] RecordFrames rec;
std::uint8_t len3[3]; frame_pending(p, rec);
put_u24_le(len3, static_cast<std::uint32_t>(p.payload.size()));
std::uint8_t head[4]; // A checkpoint that would trigger compaction goes straight into the
head[0] = static_cast<std::uint8_t>(p.type); // compacted journal instead of being appended and then copied there.
head[1] = len3[0]; // For a large buffer the thresholds scale with its size, so the cost
head[2] = len3[1]; // of compacting stays proportional to what was journaled since.
head[3] = len3[2]; const std::uint64_t compact_at = std::max<std::uint64_t>(compact_bytes, 2 * p.chkpt.size());
if (p.type == SwapRecType::CHKPT && compact_bytes > 0 &&
std::uint32_t c = 0; ctxp->approx_size_bytes + rec.total >= compact_at) {
c = crc32(head, sizeof(head), c); std::string compact_err;
if (!p.payload.empty()) if (compact_to_checkpoint(*ctxp, rec, compact_err)) {
c = crc32(p.payload.data(), p.payload.size(), c); std::lock_guard<std::mutex> lg(mtx_);
std::uint8_t crcbytes[4]; ctxp->gap = false;
crcbytes[0] = static_cast<std::uint8_t>(c & 0xFFu); ctxp->gap_notified = false;
crcbytes[1] = static_cast<std::uint8_t>((c >> 8) & 0xFFu); circuit_breaker_.RecordSuccess();
crcbytes[2] = static_cast<std::uint8_t>((c >> 16) & 0xFFu); return;
crcbytes[3] = static_cast<std::uint8_t>((c >> 24) & 0xFFu); }
report_error(compact_err, p.buf); // fall back to appending
std::vector<std::uint8_t> rec; }
rec.reserve(sizeof(head) + p.payload.size() + sizeof(crcbytes));
rec.insert(rec.end(), head, head + sizeof(head));
if (!p.payload.empty())
rec.insert(rec.end(), p.payload.begin(), p.payload.end());
rec.insert(rec.end(), crcbytes, crcbytes + sizeof(crcbytes));
// Remember where this record starts so a partial write can be undone; // Remember where this record starts so a partial write can be undone;
// otherwise later records would be appended behind the torn bytes. // otherwise later records would be appended behind the torn bytes.
@@ -1506,7 +1561,7 @@ SwapManager::process_one(const Pending &p)
const bool have_size = kte::syscall::Fstat(ctxp->fd, &before) == 0; const bool have_size = kte::syscall::Fstat(ctxp->fd, &before) == 0;
// Write (handle partial writes and check results) // Write (handle partial writes and check results)
bool ok = write_full(ctxp->fd, rec.data(), rec.size()); bool ok = write_frames(ctxp->fd, rec);
if (!ok) { if (!ok) {
int err = errno; int err = errno;
report_error("Failed to write swap record to '" + path + "': " + std::strerror(err), p.buf); report_error("Failed to write swap record to '" + path + "': " + std::strerror(err), p.buf);
@@ -1524,7 +1579,7 @@ SwapManager::process_one(const Pending &p)
ctxp->gap = false; ctxp->gap = false;
ctxp->gap_notified = false; ctxp->gap_notified = false;
} }
ctxp->approx_size_bytes += static_cast<std::uint64_t>(rec.size()); ctxp->approx_size_bytes += static_cast<std::uint64_t>(rec.total);
if (p.urgent_flush) { if (p.urgent_flush) {
if (kte::syscall::Fsync(ctxp->fd) != 0) { if (kte::syscall::Fsync(ctxp->fd) != 0) {
int err = errno; int err = errno;
@@ -1532,14 +1587,6 @@ SwapManager::process_one(const Pending &p)
} }
ctxp->last_fsync_ns = now_ns(); ctxp->last_fsync_ns = now_ns();
} }
if (p.type == SwapRecType::CHKPT && compact_bytes > 0 &&
ctxp->approx_size_bytes >= static_cast<std::uint64_t>(compact_bytes)) {
std::string compact_err;
if (!compact_to_checkpoint(*ctxp, rec, compact_err)) {
report_error(compact_err, p.buf);
// Note: compaction failure is not fatal, don't record circuit breaker failure
}
}
// Record success for circuit breaker // Record success for circuit breaker
{ {
@@ -1675,15 +1722,58 @@ SwapManager::file_crc32(const std::string &path, std::uint32_t &out)
} }
#if defined(KTE_TESTS)
void
SwapManager::SetSyncCrcLimitForTests(std::uint64_t bytes)
{
g_sync_crc_limit = bytes;
}
#endif
void
SwapManager::complete_base_crc(JournalCtx &ctx)
{
std::string file;
std::uint64_t size = 0;
std::int64_t mtime = 0;
{
std::lock_guard<std::mutex> lg(mtx_);
if (!ctx.has_base || ctx.has_base_crc || ctx.base_crc_file.empty())
return;
file = ctx.base_crc_file;
size = ctx.base_size;
mtime = ctx.base_mtime_ns;
}
// Off the lock: this reads the whole file. Keep the CRC only if the file
// is still the one whose size and mtime the journal records.
std::uint32_t crc = 0;
std::uint64_t size2 = 0;
std::int64_t mtime2 = 0;
const bool ok = file_crc32(file, crc) && stat_base(file, size2, mtime2) && size2 == size && mtime2 == mtime;
std::lock_guard<std::mutex> lg(mtx_);
if (ctx.base_crc_file != file || ctx.base_size != size || ctx.base_mtime_ns != mtime)
return; // the base changed meanwhile (save, rename)
ctx.base_crc_file.clear();
if (ok) {
ctx.has_base_crc = true;
ctx.base_crc = crc;
}
}
SwapManager::BaseId SwapManager::BaseId
SwapManager::compute_base(const std::string &file) SwapManager::compute_base(const std::string &file)
{ {
// Content CRCs are skipped for very large files (reading them again on // Reading a very large file again on every open and save is too slow
// every open and save is too slow); size and mtime still identify them. // for the main thread: its CRC is left to the writer thread, which
constexpr std::uint64_t kMaxCrcBytes = std::uint64_t{64} << 20; // computes it before creating the journal (see process_one).
const std::uint64_t kMaxCrcBytes = g_sync_crc_limit;
BaseId id; BaseId id;
id.has = stat_base(file, id.size, id.mtime_ns); id.has = stat_base(file, id.size, id.mtime_ns);
id.has_crc = id.has && id.size <= kMaxCrcBytes && file_crc32(file, id.crc); id.has_crc = id.has && id.size <= kMaxCrcBytes && file_crc32(file, id.crc);
if (id.has && !id.has_crc && id.size > kMaxCrcBytes)
id.crc_file = file;
return id; return id;
} }
@@ -1696,6 +1786,7 @@ SwapManager::apply_base(JournalCtx &ctx, const BaseId &id)
ctx.base_mtime_ns = id.mtime_ns; ctx.base_mtime_ns = id.mtime_ns;
ctx.has_base_crc = id.has_crc; ctx.has_base_crc = id.has_crc;
ctx.base_crc = id.crc; ctx.base_crc = id.crc;
ctx.base_crc_file = id.crc_file;
} }
@@ -1720,7 +1811,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
return false; return false;
} }
const std::uint32_t ver = read_le32(hdr + 8); const std::uint32_t ver = read_le32(hdr + 8);
if (ver != VERSION) { if (ver != 1 && ver != VERSION) {
err = "Unsupported swap version: " + std::to_string(ver); err = "Unsupported swap version: " + std::to_string(ver);
return false; return false;
} }
@@ -1739,15 +1830,27 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
} }
} restore{buf, prev_rec}; } restore{buf, prev_rec};
// A chunked checkpoint being assembled (see SwapRecType::CHKPT_BEGIN).
bool in_chkpt = false;
std::uint64_t chkpt_size = 0;
std::uint32_t chkpt_crc = 0;
std::string chkpt;
for (;;) { for (;;) {
// Everything before this offset replayed cleanly. // Everything before this offset replayed cleanly. Inside a chunked
// checkpoint, that is where the checkpoint began (it only counts
// once complete).
const std::streamoff record_start = in.tellg(); const std::streamoff record_start = in.tellg();
if (valid_bytes && record_start >= 0) if (valid_bytes && record_start >= 0 && !in_chkpt)
*valid_bytes = static_cast<std::uint64_t>(record_start); *valid_bytes = static_cast<std::uint64_t>(record_start);
std::uint8_t head[4]; std::uint8_t head[4];
in.read(reinterpret_cast<char *>(head), sizeof(head)); in.read(reinterpret_cast<char *>(head), sizeof(head));
const std::size_t got_head = static_cast<std::size_t>(in.gcount()); const std::size_t got_head = static_cast<std::size_t>(in.gcount());
if (got_head == 0 && in.eof()) { if (got_head == 0 && in.eof()) {
if (in_chkpt) {
err = "Swap file ends inside a checkpoint; recovered the records before it: " + swap_path;
kte::ErrorHandler::Instance().Warning("SwapManager", err, swap_path);
}
return true; // clean EOF return true; // clean EOF
} }
// A record cut short by end-of-file is the expected result of a crash or // A record cut short by end-of-file is the expected result of a crash or
@@ -1786,8 +1889,52 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
return false; return false;
} }
// Chunked checkpoint records; nothing else may come between them.
if (in_chkpt && type != SwapRecType::CHKPT_DATA && type != SwapRecType::CHKPT_END) {
err = "Swap file record inside a checkpoint: " + swap_path;
return false;
}
// Apply record // Apply record
switch (type) { switch (type) {
case SwapRecType::CHKPT_BEGIN: {
// [encver u8=1][total u64][crc32 u32]
if (payload.size() < 13 || payload[0] != 1) {
err = "Malformed CHKPT_BEGIN payload";
return false;
}
chkpt_size = 0;
for (int i = 7; i >= 0; --i)
chkpt_size = (chkpt_size << 8) | payload[1 + static_cast<std::size_t>(i)];
chkpt_crc = read_le32(payload.data() + 9);
chkpt.clear();
try {
chkpt.reserve(static_cast<std::size_t>(chkpt_size));
} catch (...) {
err = "Checkpoint too large to replay: " + std::to_string(chkpt_size) + " bytes";
return false;
}
in_chkpt = true;
break;
}
case SwapRecType::CHKPT_DATA:
if (!in_chkpt || chkpt.size() + payload.size() > chkpt_size) {
err = "Swap file checkpoint data out of place: " + swap_path;
return false;
}
chkpt.append(reinterpret_cast<const char *>(payload.data()), payload.size());
break;
case SwapRecType::CHKPT_END: {
if (!in_chkpt || chkpt.size() != chkpt_size ||
crc32(reinterpret_cast<const std::uint8_t *>(chkpt.data()), chkpt.size(), 0) != chkpt_crc) {
err = "Swap file checkpoint incomplete or corrupt: " + swap_path;
return false;
}
buf.replace_all_bytes(chkpt);
std::string().swap(chkpt);
in_chkpt = false;
break;
}
case SwapRecType::INS: { case SwapRecType::INS: {
std::size_t off = 0; std::size_t off = 0;
// INS payload: encver(1) + row(4) + col(4) + nbytes(4) + data(nbytes) // INS payload: encver(1) + row(4) + col(4) + nbytes(4) + data(nbytes)
+32 -1
View File
@@ -27,6 +27,13 @@ enum class SwapRecType : std::uint8_t {
SPLIT = 3, SPLIT = 3,
JOIN = 4, JOIN = 4,
META = 0xF0, META = 0xF0,
// A checkpoint too large for one record (a record's length field is 24
// bits) is written as BEGIN [encver u8][total u64][crc32 u32], DATA
// chunks (raw bytes), END [encver u8]. Replay applies it only at END,
// with the size and CRC-32 matching; an unfinished one is ignored.
CHKPT_BEGIN = 0xFB,
CHKPT_DATA = 0xFC,
CHKPT_END = 0xFD,
CHKPT = 0xFE, CHKPT = 0xFE,
}; };
@@ -149,6 +156,9 @@ public:
// Test-only hook to keep swap path logic centralized. // Test-only hook to keep swap path logic centralized.
// (Avoid duplicating naming rules in unit tests.) // (Avoid duplicating naming rules in unit tests.)
#ifdef KTE_TESTS #ifdef KTE_TESTS
// Base files larger than this get their CRC on the writer thread.
static void SetSyncCrcLimitForTests(std::uint64_t bytes);
static std::string ComputeSwapPathForTests(const Buffer &buf) static std::string ComputeSwapPathForTests(const Buffer &buf)
{ {
return ComputeSidecarPath(buf); return ComputeSidecarPath(buf);
@@ -239,16 +249,33 @@ private:
std::int64_t base_mtime_ns{0}; std::int64_t base_mtime_ns{0};
bool has_base_crc{false}; // CRC-32 of the base file's content bool has_base_crc{false}; // CRC-32 of the base file's content
std::uint32_t base_crc{0}; std::uint32_t base_crc{0};
// A large base file's CRC is computed by the writer thread before it
// creates the journal (see process_one), not on open/save.
std::string base_crc_file;
}; };
struct Pending { struct Pending {
Buffer *buf{nullptr}; Buffer *buf{nullptr};
SwapRecType type{SwapRecType::INS}; SwapRecType type{SwapRecType::INS};
std::vector<std::uint8_t> payload; // framed payload only std::vector<std::uint8_t> payload; // framed payload only
std::string chkpt; // CHKPT: the buffer's content (framed by the writer)
bool urgent_flush{false}; bool urgent_flush{false};
std::uint64_t seq{0}; std::uint64_t seq{0};
}; };
// A record (or, for a large checkpoint, several) as segments to write:
// small framing buffers owned here, and a reference to the checkpoint
// content (not copied).
struct RecordFrames {
std::deque<std::vector<std::uint8_t> > owned;
std::vector<std::pair<const std::uint8_t *, std::size_t> > segs;
std::size_t total = 0;
};
static void frame_pending(const Pending &p, RecordFrames &f);
static bool write_frames(int fd, const RecordFrames &f);
// Helpers // Helpers
static std::string ComputeSidecarPath(const Buffer &buf); static std::string ComputeSidecarPath(const Buffer &buf);
@@ -266,7 +293,7 @@ private:
static void close_ctx(JournalCtx &ctx); static void close_ctx(JournalCtx &ctx);
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, static bool compact_to_checkpoint(JournalCtx &ctx, const RecordFrames &chkpt_record,
std::string &err); std::string &err);
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0); static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
@@ -283,10 +310,14 @@ private:
std::int64_t mtime_ns{0}; std::int64_t mtime_ns{0};
bool has_crc{false}; bool has_crc{false};
std::uint32_t crc{0}; std::uint32_t crc{0};
std::string crc_file; // set when the CRC is deferred to the writer thread
}; };
static BaseId compute_base(const std::string &file); static BaseId compute_base(const std::string &file);
// Writer thread: compute a deferred base CRC (see BaseId::crc_file).
void complete_base_crc(JournalCtx &ctx);
static void apply_base(JournalCtx &ctx, const BaseId &id); static void apply_base(JournalCtx &ctx, const BaseId &id);
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v); static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
+18 -3
View File
@@ -526,6 +526,24 @@ before the fix (unless noted).
through matches in the regex replace prompt; Up/Down in a regex prompt through matches in the regex replace prompt; Up/Down in a regex prompt
use the regex. use the regex.
**Follow-up: journals for large buffers**
- Buffers over 16 MiB are checkpointed as CHKPT_BEGIN (size, CRC-32),
data chunks and CHKPT_END; replay applies the checkpoint only when it
is complete and its CRC matches, and treats an unfinished one as a
torn tail. Such buffers could not be checkpointed at all: a lost
record left the journal permanently behind and it was never
compacted. The journal header is now version 2 (version 1 journals
are still read; older kte builds will not read version 2).
- Checkpoint thresholds scale with the buffer: after edits amounting to
half its size, on the timer only after a sixteenth, compaction at
twice its size, gap retries spaced by size. A checkpoint that would
compact is written straight into the compacted journal.
- The recovery prompt keeps the content it replayed for the preview;
answering y installs it instead of replaying the journal again.
- The CRC of a base file over 64 MiB is computed by the journal writer
thread (it was skipped), so a touched but unchanged large file no
longer makes its journal unusable for recovery.
**Known limitations (not fixed)** **Known limitations (not fixed)**
- Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very - Catastrophic regex backtracking (e.g. `(a*)*b`) can still take very
long; std::regex has no time limit. long; std::regex has no time limit.
@@ -539,6 +557,3 @@ before the fix (unless noted).
session that is locked out is told at its first edit instead. session that is locked out is told at its first edit instead.
- A regex that matches rarely or not at all still scans the whole - A regex that matches rarely or not at all still scans the whole
buffer with std::regex (1.6 s on 105 MB). buffer with std::regex (1.6 s on 105 MB).
- A journal whose buffer exceeds 16 MiB cannot be checkpointed; after a
lost record such a journal stays incomplete until the file is saved
(reported to the user).
+16 -1
View File
@@ -1,17 +1,32 @@
#include "Test.h" #include "Test.h"
#include <iostream> #include <iostream>
#include <chrono> #include <chrono>
#include <string>
#include <vector>
// Usage: kte_tests [substring...] runs the tests whose names contain any of
// the substrings (all tests when none are given).
int int
main() main(int argc, char **argv)
{ {
using namespace std::chrono; using namespace std::chrono;
auto &reg = ktet::registry(); auto &reg = ktet::registry();
std::vector<std::string> filters(argv + 1, argv + argc);
auto selected = [&filters](const std::string &name) {
if (filters.empty())
return true;
for (const auto &f: filters)
if (name.find(f) != std::string::npos)
return true;
return false;
};
std::cout << "kte unit tests: " << reg.size() << " test(s)\n"; std::cout << "kte unit tests: " << reg.size() << " test(s)\n";
int failed = 0; int failed = 0;
auto t0 = steady_clock::now(); auto t0 = steady_clock::now();
for (const auto &tc: reg) { for (const auto &tc: reg) {
if (!selected(tc.name))
continue;
auto ts = steady_clock::now(); auto ts = steady_clock::now();
try { try {
tc.fn(); tc.fn();
+167
View File
@@ -18,6 +18,7 @@
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <fcntl.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include <random> #include <random>
@@ -2012,3 +2013,169 @@ TEST(Search_PrevAcrossBlocks)
} }
ASSERT_TRUE(h.Exec(CommandId::Refresh)); ASSERT_TRUE(h.Exec(CommandId::Refresh));
} }
namespace {
// A 20 MiB buffer: over the 16 MiB a single checkpoint record can hold.
std::string
big_text()
{
std::string t;
t.reserve(std::size_t{21} << 20);
for (int i = 0; t.size() < (std::size_t{20} << 20); ++i)
t += "line " + std::to_string(i) + " of a large buffer\n";
return t;
}
} // namespace
// Buffers over 16 MiB are checkpointed as BEGIN/DATA.../END records, which
// replay to the buffer; edits after the checkpoint apply on top.
TEST(Swap_LargeCheckpointReplays)
{
TempDir d("big_chkpt");
const std::string path = (d.path / "big.txt").string();
const std::string text = big_text();
{
std::ofstream o(path, std::ios::binary);
o << text;
}
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(b);
std::remove(swp.c_str());
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
b.insert_text(0, 0, "A");
sm.Checkpoint(&b);
b.insert_text(1, 0, "B");
sm.Flush(&b);
const std::string want = b.BytesForTests();
const std::string copy = (d.path / "copy.swp").string();
{
std::ofstream o(copy, std::ios::binary | std::ios::trunc);
o << slurp(swp);
}
// The journal holds the chunked checkpoint.
ASSERT_TRUE(std::filesystem::file_size(copy) > text.size());
Buffer r;
ASSERT_TRUE(r.OpenFromFile(path, err));
std::uint64_t valid = 0;
ASSERT_TRUE(kte::SwapManager::ReplayFile(r, copy, err, &valid));
ASSERT_TRUE(err.empty());
ASSERT_TRUE(r.BytesForTests() == want);
ASSERT_EQ(valid, (std::uint64_t) std::filesystem::file_size(copy));
// Torn inside the checkpoint's data: the records before it replay, and
// the checkpoint is not counted as valid.
std::filesystem::resize_file(copy, 64 + 1000 + (std::size_t{8} << 20));
Buffer t;
ASSERT_TRUE(t.OpenFromFile(path, err));
valid = 0;
ASSERT_TRUE(kte::SwapManager::ReplayFile(t, copy, err, &valid));
ASSERT_TRUE(!err.empty());
ASSERT_EQ(t.BytesForTests(), "A" + text);
ASSERT_TRUE(valid < 64 + 1000);
b.SetSwapRecorder(nullptr);
sm.Detach(&b, true);
}
// A lost record on a large buffer is closed by a checkpoint (it could not
// be before: the buffer was too large to checkpoint), and compaction
// rewrites the journal to that checkpoint alone.
TEST(Swap_LargeBufferGapResyncsAndCompacts)
{
TempDir d("big_gap");
const std::string path = (d.path / "big.txt").string();
const std::string text = big_text();
{
std::ofstream o(path, std::ios::binary);
o << text;
}
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(b);
std::filesystem::remove_all(swp);
kte::SwapManager sm;
kte::SwapConfig cfg;
cfg.compact_bytes = 1024;
sm.SetConfig(cfg);
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
// The journal cannot be opened: the record is lost.
std::filesystem::create_directories(swp);
b.insert_text(0, 0, "X");
sm.Flush(&b);
std::filesystem::remove_all(swp);
std::this_thread::sleep_for(std::chrono::milliseconds(2100)); // gap retry interval for 20 MiB
sm.RetryGapCheckpoints();
sm.Flush(&b);
b.insert_text(0, 1, "Y");
sm.Flush(&b);
const std::string copy = (d.path / "copy.swp").string();
{
std::ofstream o(copy, std::ios::binary | std::ios::trunc);
o << slurp(swp);
}
Buffer r;
ASSERT_TRUE(r.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(r, copy, err));
ASSERT_TRUE(r.BytesForTests() == b.BytesForTests());
// Compacted: header, the checkpoint (a few framing bytes over the
// content) and the one INS record after it.
ASSERT_TRUE(std::filesystem::file_size(copy) < text.size() + 4096);
b.SetSwapRecorder(nullptr);
sm.Detach(&b, true);
}
// A large base file's CRC is computed by the journal writer, not on open;
// the journal still records it, so a same-size, same-mtime change on disk is
// caught.
TEST(Swap_DeferredBaseCrc)
{
TempDir d("deferred_crc");
const std::string path = (d.path / "f.txt").string();
const std::string text(20000, 'a');
{
std::ofstream o(path, std::ios::binary);
o << text;
}
kte::SwapManager::SetSyncCrcLimitForTests(1024);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(b);
std::remove(swp.c_str());
kte::SwapManager sm;
sm.Attach(&b);
b.SetSwapRecorder(sm.RecorderFor(&b));
b.insert_text(0, 0, "X");
sm.Flush(&b);
kte::SwapManager::SetSyncCrcLimitForTests(std::uint64_t{64} << 20);
const std::string j = slurp(swp);
ASSERT_TRUE(j.size() >= 64);
ASSERT_TRUE((static_cast<unsigned char>(j[12]) & 2) != 0); // CRC recorded
ASSERT_TRUE(kte::SwapManager::JournalMatchesFile(swp, path));
// Touched (new mtime, same content): the CRC shows it is still the same
// file, so recovery can use the journal. Without a CRC (large files
// before this), any touch made the journal unusable.
struct timespec later[2];
later[0].tv_sec = later[1].tv_sec = ::time(nullptr) + 100;
later[0].tv_nsec = later[1].tv_nsec = 0;
ASSERT_TRUE(::utimensat(AT_FDCWD, path.c_str(), later, 0) == 0);
ASSERT_TRUE(kte::SwapManager::JournalMatchesFile(swp, path));
// Changed content with a new mtime: stale.
{
std::ofstream o(path, std::ios::binary | std::ios::trunc);
o << std::string(20000, 'b');
}
ASSERT_TRUE(!kte::SwapManager::JournalMatchesFile(swp, path));
b.SetSwapRecorder(nullptr);
sm.Detach(&b, true);
}
+1 -1
View File
@@ -123,7 +123,7 @@ TEST(SwapWriter_Header_Records_And_CRC)
static const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'}; static const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
for (int i = 0; i < 8; ++i) for (int i = 0; i < 8; ++i)
ASSERT_EQ(bytes[(std::size_t) i], magic[i]); ASSERT_EQ(bytes[(std::size_t) i], magic[i]);
ASSERT_EQ(read_le32(bytes.data() + 8), (std::uint32_t) 1); ASSERT_EQ(read_le32(bytes.data() + 8), (std::uint32_t) 2); // v2: chunked checkpoints
// flags bit 0: the base file's identity (size, mtime) is recorded, since // flags bit 0: the base file's identity (size, mtime) is recorded, since
// the buffer is file-backed; bit 1: its content CRC-32 too. // the buffer is file-backed; bit 1: its content CRC-32 too.
ASSERT_EQ(read_le32(bytes.data() + 12), (std::uint32_t) 3); ASSERT_EQ(read_le32(bytes.data() + 12), (std::uint32_t) 3);