1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! Disk-limit enforcement (HEADLINE test).
//!
//! Faithful port of JE's disk-limit machinery: refuse new user writes before
//! the disk fills so recovery stays possible, and resume once space is
//! reclaimed.
//!
//! JE refs:
//! - `je/cleaner/Cleaner.java` `recalcLogSizeStats` / `getDiskLimitViolation`
//! (the violation computation and cached volatile flag).
//! - `je/dbi/EnvironmentImpl.java` `checkDiskLimitViolation`.
//! - `je/Cursor.java` `checkUpdatesAllowed` (gates user writes; exempts
//! internal DBs via `dbImpl.getDbType().isInternal()`).
//!
//! Fail-pre (on `main`, before this feature): user writes succeed until the
//! real disk fills; `DiskLimitExceeded` is never returned. Pass-post: once
//! total log size exceeds `MAX_DISK` the next user write returns
//! `DiskLimitExceeded`; reads and aborts still work; freeing space resumes
//! writes; the cleaner's own writes are never blocked (it freed the space).
use noxu_db::{
DatabaseConfig, DatabaseEntry, Environment, EnvironmentConfig, NoxuError,
};
use tempfile::TempDir;
fn open(dir: &TempDir, max_disk: u64) -> Environment {
let cfg = EnvironmentConfig::new(dir.path().to_path_buf())
.with_allow_create(true)
.with_transactional(true)
// Small log files so the total log size crosses MAX_DISK quickly and
// so the cleaner has whole files to reclaim.
.with_log_file_max_bytes(64 * 1024)
// MAX_DISK is the absolute log-size cap. FREE_DISK off so the test is
// deterministic regardless of the host's actual free space.
.with_max_disk(max_disk)
.with_free_disk(0);
Environment::open(cfg).unwrap()
}
fn val(i: usize) -> DatabaseEntry {
// ~1 KiB values so a modest record count grows the log past the cap.
DatabaseEntry::from_bytes(&vec![(i & 0xff) as u8; 1024])
}
/// HEADLINE: write past MAX_DISK -> DiskLimitExceeded; reads + abort still
/// work over-limit; cleaner can still write (it frees space) -> writes resume.
#[test]
fn disk_limit_blocks_then_resumes() {
let dir = TempDir::new().unwrap();
// 256 KiB cap: a handful of 64 KiB log files.
let env = open(&dir, 256 * 1024);
let db_cfg =
DatabaseConfig::new().with_allow_create(true).with_transactional(true);
let db = env.open_database(None, "dl", &db_cfg).unwrap();
// Write until the disk limit blocks a user write. We refresh the cached
// violation state ourselves rather than wait for the background daemon
// (JE: the daemon refreshes on an interval; refresh_disk_limit forces it).
let mut blocked_at = None;
for i in 0..2000usize {
let key = DatabaseEntry::from_bytes(&(i as u64).to_be_bytes());
env.refresh_disk_limit().unwrap();
match db.put(&key, val(i)) {
Ok(()) => {}
Err(NoxuError::DiskLimitExceeded { used, limit }) => {
assert!(
used >= limit,
"violation should report used({used}) >= limit({limit})"
);
blocked_at = Some(i);
break;
}
Err(e) => panic!("unexpected error at {i}: {e}"),
}
}
let blocked_at = blocked_at.expect(
"expected a user write to be refused with DiskLimitExceeded once the \
log grew past MAX_DISK",
);
assert!(blocked_at > 0, "should have written some records first");
// The limit is still active: another user write is still refused.
let key = DatabaseEntry::from_bytes(&(blocked_at as u64).to_be_bytes());
assert!(
matches!(
db.put(&key, val(blocked_at)),
Err(NoxuError::DiskLimitExceeded { .. })
),
"writes must stay blocked while over the limit"
);
// Reads must still work while over-limit (JE: read-only ops are not gated).
let read_key = DatabaseEntry::from_bytes(&0u64.to_be_bytes());
let mut out = DatabaseEntry::new();
let s = db.get_into(None, &read_key, &mut out).unwrap();
assert!(s, "reads must work over-limit");
assert_eq!(out.data_opt().unwrap().len(), 1024);
// A transaction abort must still work over-limit (JE: abort is not gated;
// it frees, it does not consume the user write budget).
let txn = env.begin_transaction(None).unwrap();
// The put inside the txn is itself a user write and is refused...
assert!(matches!(
db.put_in(&txn, &read_key, val(1)),
Err(NoxuError::DiskLimitExceeded { .. })
));
// ...but aborting the txn still succeeds.
txn.abort().expect("abort must succeed while over the disk limit");
// Free space: delete records and run the cleaner. The cleaner's OWN writes
// (migrating live LNs, writing FileSummaryLNs to the internal utilization
// DB) must NOT be blocked by the limit, or it could never reclaim space.
// clean_log() succeeding here proves the internal-writes-exempt rule.
for i in 0..blocked_at {
let key = DatabaseEntry::from_bytes(&(i as u64).to_be_bytes());
// Deletes are also gated while over-limit, so we may need to clean
// first. Try the delete; ignore a disk-limit refusal and rely on the
// checkpoint+clean below to reclaim whole obsolete files.
let _ = db.delete(&key);
}
// Checkpoint flushes the tree so cleaned files become fully obsolete, then
// clean_log reclaims them (the cleaner refreshes the disk-limit state after
// its pass, JE Cleaner.manageDiskUsage -> freshenLogSizeStats).
let _ = env.checkpoint(None);
let _cleaned = env.clean_log().expect(
"cleaner must be able to write/delete while over the limit \
(internal-writes-exempt rule); otherwise it deadlocks",
);
env.refresh_disk_limit().unwrap();
// Writes must resume once we are back within the limit. If a single clean
// pass did not reclaim enough, drive a few more delete+clean cycles.
let mut resumed = false;
for round in 0..8 {
let key =
DatabaseEntry::from_bytes(&(10_000 + round as u64).to_be_bytes());
env.refresh_disk_limit().unwrap();
match db.put(&key, val(round)) {
Ok(()) => {
resumed = true;
break;
}
Err(NoxuError::DiskLimitExceeded { .. }) => {
// Still over-limit: reclaim more and retry.
for i in 0..blocked_at {
let k =
DatabaseEntry::from_bytes(&(i as u64).to_be_bytes());
let _ = db.delete(&k);
}
let _ = env.checkpoint(None);
let _ = env.clean_log();
}
other => panic!("unexpected on resume: {other:?}"),
}
}
assert!(
resumed,
"writes must resume after the cleaner reclaims space below MAX_DISK"
);
}
/// Default behaviour is unchanged: with MAX_DISK=0 and FREE_DISK=0 the tracker
/// is inert and writes are never refused.
#[test]
fn disabled_by_default_never_blocks() {
let dir = TempDir::new().unwrap();
let env = open(&dir, 0); // max_disk=0, free_disk=0 (from open())
let db_cfg =
DatabaseConfig::new().with_allow_create(true).with_transactional(true);
let db = env.open_database(None, "nolimit", &db_cfg).unwrap();
for i in 0..500usize {
let key = DatabaseEntry::from_bytes(&(i as u64).to_be_bytes());
env.refresh_disk_limit().unwrap();
db.put(&key, val(i)).unwrap();
}
}