Skip to content

Commit 5305982

Browse files
committed
publish compares the existing block file instead of writing a block it then discards
1 parent 1f753d7 commit 5305982

7 files changed

Lines changed: 166 additions & 32 deletions

File tree

bench/addressing_bench.cpp

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// recipe entry, nothing written: the skip that generation
99
// addressing gives up (it would rewrite the block)
1010
// content_dedup hash plus publish against an existing identical file:
11-
// the staged write, the refused link, the byte compare
11+
// the byte compare that answers the publication, no write
1212
// generation write under a counter name, no hash, always a fresh
1313
// file: what a generation-addressed commit does for every
1414
// dirty block, value-identical or not
@@ -120,8 +120,7 @@ namespace {
120120
}
121121

122122
// the dedup hit: hash plus publish where an identical file already
123-
// carries the name (the staged write, the refused link, the byte
124-
// compare); the staged temp file is discarded by publish itself
123+
// carries the name, which the compare against that file answers
125124
void content_dedup(benchmark::State &state) {
126125
size_t const len = block_len_of(state);
127126
bench_store bench;

include/privateer/block_store.hpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ namespace privateer {
4949

5050
// Publishes data under its digest, atomically. Returns true when a
5151
// new file was created, false when an identical file already carried
52-
// the name (dedup). A different file under the name is a fatal
53-
// hash_collision error. Thread-safe.
52+
// the name (dedup). A name that already has a file is answered by
53+
// comparing that file, so a duplicate writes nothing. A different
54+
// file under the name is a fatal hash_collision error. Thread-safe.
5455
result<bool> publish(block_digest const &name, std::span<std::byte const> data) const;
5556

5657
// path of the block file for a name, existing or not

include/privateer/file_util.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ namespace privateer {
2626
// it to observe what a durability path paid.
2727
extern std::atomic<uint64_t> sync_calls;
2828

29+
// Counts every staged backing file created. Tests reset and read it
30+
// to observe whether a path wrote a file at all.
31+
extern std::atomic<uint64_t> staged_files;
32+
2933
} // namespace detail_file_util
3034
#endif // PRIVATEER_TEST_HOOKS
3135

include/privateer/region.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ namespace privateer {
179179
uint64_t slots_hashed = 0;
180180
// hash matched the recipe entry, so no file was written
181181
uint64_t slots_skipped = 0;
182-
// new name, an identical block file already existed
182+
// new name whose block file already existed, so the compare against
183+
// that file replaced the write and nothing was written either
183184
uint64_t slots_deduped = 0;
184185
// new name, a new block file was written
185186
uint64_t slots_written = 0;

src/block_store.cpp

Lines changed: 63 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,22 @@ namespace privateer {
4949
}
5050
}
5151

52-
// byte-compares an existing block file against data
53-
result<bool> file_equals(fs::path const &path, std::span<std::byte const> data) {
52+
// what a block name already holds against the content being published
53+
enum struct existing_block : int {
54+
absent, // the name has no file
55+
equal, // the file holds this content, so it is already published
56+
differs, // the file holds other content under the same name
57+
};
58+
59+
// Byte-compares the file under path against data. A name with no file
60+
// is absent rather than an error, because writing one is what a
61+
// publisher does about it.
62+
result<existing_block> compare_block(fs::path const &path, std::span<std::byte const> data) {
5463
int const fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC);
5564
if (fd < 0) {
65+
if (errno == ENOENT) {
66+
return existing_block::absent;
67+
}
5668
return fail_errno(errc::io_error, "open block for the dedup compare");
5769
}
5870
struct stat st {};
@@ -63,7 +75,7 @@ namespace privateer {
6375
}
6476
if (std::cmp_not_equal(st.st_size, data.size())) {
6577
::close(fd);
66-
return false;
78+
return existing_block::differs;
6779
}
6880
std::vector<std::byte> buffer(std::min<size_t>(data.size(), size_t{1} << 20));
6981
size_t offset = 0;
@@ -80,14 +92,22 @@ namespace privateer {
8092
}
8193
if (got == 0 || std::memcmp(buffer.data(), data.data() + offset, static_cast<size_t>(got)) != 0) {
8294
::close(fd);
83-
return false;
95+
return existing_block::differs;
8496
}
8597
offset += static_cast<size_t>(got);
8698
}
8799
::close(fd);
88-
return true;
100+
return existing_block::equal;
89101
}
90102

103+
// How many times publish resolves the name before it gives up. Each
104+
// extra round needs another publisher to take the name in the window
105+
// between this one's compare and its link, and something to unlink
106+
// that file again before the next compare. A directory entry that
107+
// cannot be opened at all, a dangling symlink in a tampered store,
108+
// exhausts the rounds and reports io_error.
109+
constexpr unsigned publish_attempts = 4;
110+
91111
} // namespace
92112

93113
result<block_store> block_store::create(fs::path const &segment_dir, bool durable) {
@@ -147,28 +167,45 @@ namespace privateer {
147167
if (name.size == 0 || data.empty()) {
148168
return fail(errc::invalid_argument, "publish needs a name and content");
149169
}
150-
auto staged = staged_file::create_in(shard_path(name));
151-
if (!staged) {
152-
return std::unexpected{staged.error()};
153-
}
154-
if (auto written = staged->write(data); !written) {
155-
return std::unexpected{written.error()};
156-
}
157-
auto published = staged->publish(to_hex(name), publish_mode::fail_if_exists);
158-
if (!published) {
159-
return std::unexpected{published.error()};
160-
}
161-
if (*published) {
162-
return true;
163-
}
164-
auto equal = file_equals(block_path(name), data);
165-
if (!equal) {
166-
return std::unexpected{equal.error()};
167-
}
168-
if (!*equal) {
169-
return fail(errc::hash_collision, "existing block differs under the same name");
170+
fs::path const path = block_path(name);
171+
for (unsigned attempt = 0; attempt < publish_attempts; ++attempt) {
172+
// A file already under the name carries the content the name
173+
// stands for, so the compare is the whole publication and the
174+
// block is not written at all. Content that repeats is the
175+
// common case on a workload that copies a structure and changes
176+
// parts of it.
177+
auto const existing = compare_block(path, data);
178+
if (!existing) {
179+
return std::unexpected{existing.error()};
180+
}
181+
switch (*existing) {
182+
case existing_block::equal:
183+
return false;
184+
case existing_block::differs:
185+
return fail(errc::hash_collision, "existing block differs under the same name");
186+
case existing_block::absent:
187+
break; // the write below is what resolves it
188+
}
189+
auto staged = staged_file::create_in(shard_path(name));
190+
if (!staged) {
191+
return std::unexpected{staged.error()};
192+
}
193+
if (auto written = staged->write(data); !written) {
194+
return std::unexpected{written.error()};
195+
}
196+
auto const published = staged->publish(to_hex(name), publish_mode::fail_if_exists);
197+
if (!published) {
198+
return std::unexpected{published.error()};
199+
}
200+
if (*published) {
201+
return true;
202+
}
203+
// The atomic link is what resolves a race on one name, and this
204+
// caller lost it. The next round compares against the winner's
205+
// file, which is the dedup answer; only an unlink of that file
206+
// inside the same window sends the round back to the write.
170207
}
171-
return false;
208+
return fail(errc::io_error, "the block name neither opens nor accepts a link");
172209
}
173210

174211
result<> block_store::make_durable(std::span<block_digest const> names, sync_fan_out const &fan_out) {

src/file_util.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ namespace privateer {
1414
namespace detail_file_util {
1515

1616
std::atomic<uint64_t> sync_calls{0};
17+
std::atomic<uint64_t> staged_files{0};
1718

1819
} // namespace detail_file_util
1920
#endif
@@ -26,6 +27,12 @@ namespace privateer {
2627
#endif
2728
}
2829

30+
void count_staged_file() noexcept {
31+
#ifdef PRIVATEER_TEST_HOOKS
32+
detail_file_util::staged_files.fetch_add(1, std::memory_order_relaxed);
33+
#endif
34+
}
35+
2936
// fsync with EINTR retry
3037
int fsync_retry(int fd) noexcept {
3138
int rc;
@@ -101,6 +108,7 @@ namespace privateer {
101108
if (::access("/proc/self/fd", F_OK) == 0) {
102109
f.fd_ = fd;
103110
f.anonymous_ = true;
111+
count_staged_file();
104112
return f;
105113
}
106114
::close(fd);
@@ -122,6 +130,7 @@ namespace privateer {
122130
}
123131
f.fd_ = fd;
124132
f.temp_path_ = name.data();
133+
count_staged_file();
125134
return f;
126135
}
127136

test/block_store_tests.cpp

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
#include <thread>
2121
#include <vector>
2222

23+
#include <unistd.h>
24+
2325
using namespace privateer;
2426
namespace fs = std::filesystem;
2527

@@ -124,6 +126,87 @@ namespace {
124126
EXPECT_EQ(file_count(f.blocks_dir()), 1u);
125127
}
126128

129+
TEST(BlockStore, PublishWritesNothingForADuplicate) {
130+
store_fixture f;
131+
auto const data = content(std::string(1 << 16, 'q'));
132+
auto const name = name_of(data);
133+
134+
detail_file_util::staged_files.store(0);
135+
ASSERT_TRUE(f.store.publish(name, data));
136+
EXPECT_EQ(detail_file_util::staged_files.load(), 1u);
137+
138+
// the name has a file, so the compare answers the publication
139+
detail_file_util::staged_files.store(0);
140+
auto again = f.store.publish(name, data);
141+
ASSERT_TRUE(again.has_value()) << to_string(again.error());
142+
EXPECT_FALSE(*again);
143+
EXPECT_EQ(detail_file_util::staged_files.load(), 0u);
144+
EXPECT_EQ(read_file(f.store.block_path(name)).size(), data.size());
145+
}
146+
147+
TEST(BlockStore, PublishWritesNothingForACollision) {
148+
store_fixture f;
149+
auto const data = content("first content");
150+
auto const name = name_of(data);
151+
ASSERT_TRUE(f.store.publish(name, data));
152+
153+
detail_file_util::staged_files.store(0);
154+
auto collided = f.store.publish(name, content("other content"));
155+
ASSERT_FALSE(collided.has_value());
156+
EXPECT_EQ(collided.error().code, errc::hash_collision);
157+
EXPECT_EQ(detail_file_util::staged_files.load(), 0u);
158+
}
159+
160+
TEST(BlockStore, PublishSurvivesConcurrentUnlinks) {
161+
store_fixture f;
162+
auto const data = content(std::string(1 << 14, 'u'));
163+
auto const name = name_of(data);
164+
fs::path const path = f.store.block_path(name);
165+
166+
// A name that keeps disappearing under the publisher: every round is
167+
// either a compare against the file or a fresh write, and neither
168+
// leaves the store with a temp file or an error.
169+
std::atomic<bool> stop{false};
170+
std::thread unlinker{[&] {
171+
while (!stop.load()) {
172+
::unlink(path.c_str());
173+
std::this_thread::yield();
174+
}
175+
}};
176+
int created = 0;
177+
for (int round = 0; round < 500; ++round) {
178+
auto published = f.store.publish(name, data);
179+
if (!published) {
180+
stop.store(true);
181+
unlinker.join();
182+
FAIL() << to_string(published.error());
183+
}
184+
created += *published ? 1 : 0;
185+
}
186+
stop.store(true);
187+
unlinker.join();
188+
189+
EXPECT_GT(created, 0);
190+
for (auto const &shard : fs::directory_iterator{f.blocks_dir()}) {
191+
for (auto const &entry : fs::directory_iterator{shard}) {
192+
EXPECT_FALSE(entry.path().filename().string().starts_with(temp_name_prefix));
193+
}
194+
}
195+
}
196+
197+
TEST(BlockStore, PublishGivesUpOnANameThatCannotBeOpened) {
198+
store_fixture f;
199+
auto const data = content("content behind a broken name");
200+
auto const name = name_of(data);
201+
// A dangling symlink is a name whose entry refuses the link and whose
202+
// file cannot be opened, so no round of publish can resolve it.
203+
fs::create_symlink("nowhere", f.store.block_path(name));
204+
205+
auto published = f.store.publish(name, data);
206+
ASSERT_FALSE(published.has_value());
207+
EXPECT_EQ(published.error().code, errc::io_error);
208+
}
209+
127210
TEST(BlockStore, PublishDetectsAHashCollision) {
128211
store_fixture f;
129212
auto const data = content("first content");

0 commit comments

Comments
 (0)