Skip to content

Commit 363e08b

Browse files
committed
Fixed race condition in skip_delete_sample() in AlleleCount and VariantStats classes
Now skipped samples are saved to the metadata as unique keys - one per sample - rather than writing all skipped samples to a single key.
1 parent b3f2716 commit 363e08b

6 files changed

Lines changed: 130 additions & 104 deletions

File tree

apis/python/tests/test_delete.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,17 @@ def test_delete_samples_skip_aggregate_stats(tmp_path, stats_v3_dataset):
102102
assert "second" not in sample_names
103103
assert "third" in sample_names
104104

105-
# Verify skipped_delete_samples metadata on allele_count and variant_stats
105+
# Verify per-sample skipped deletion key is recorded on allele_count and variant_stats
106106
for array_name in ["allele_count", "variant_stats"]:
107107
with tiledb.open(os.path.join(uri, array_name), "r") as arr:
108-
meta = arr.meta["skipped_delete_samples"]
109-
assert b"second" in meta
108+
assert "skipped_delete_sample:second" in arr.meta
110109

111110

112111
@skip_if_no_bcftools
113112
def test_delete_samples_skip_aggregate_stats_accumulates(
114113
tmp_path, stats_v3_dataset
115114
):
116-
"""Verify skipped_delete_samples metadata accumulates across deletions."""
115+
"""Verify per-sample skipped deletion keys are recorded across multiple deletions."""
117116
uri = os.path.join(tmp_path, "stats_test")
118117

119118
ds = tiledbvcf.Dataset(uri=uri, mode="w")
@@ -124,8 +123,8 @@ def test_delete_samples_skip_aggregate_stats_accumulates(
124123

125124
for array_name in ["allele_count", "variant_stats"]:
126125
with tiledb.open(os.path.join(uri, array_name), "r") as arr:
127-
meta = arr.meta["skipped_delete_samples"]
128-
assert meta == b"second,fifth"
126+
assert "skipped_delete_sample:second" in arr.meta
127+
assert "skipped_delete_sample:fifth" in arr.meta
129128

130129

131130
def test_delete_samples_read_mode_raises(tmp_path):

libtiledbvcf/src/stats/allele_count.cc

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -220,30 +220,37 @@ void AlleleCount::skip_delete_sample(
220220
return;
221221
}
222222

223-
// Read existing skipped_delete_samples metadata
224-
std::string existing;
225-
{
226-
Array read_array(*ctx, uri, TILEDB_READ);
227-
const void* value = nullptr;
228-
tiledb_datatype_t type = TILEDB_ANY;
229-
uint32_t num = 0;
230-
read_array.get_metadata("skipped_delete_samples", &type, &num, &value);
231-
if (value != nullptr && num > 0) {
232-
existing = std::string(static_cast<const char*>(value), num);
233-
}
234-
}
223+
// Write one key per sample to avoid a read-modify-write race condition.
224+
// Concurrent callers write to different keys so no coordination is needed.
225+
std::string key = "skipped_delete_sample:" + sample;
226+
Array write_array(*ctx, uri, TILEDB_WRITE);
227+
write_array.put_metadata(key, TILEDB_STRING_ASCII, 0, nullptr);
235228

236-
// Build updated CSV
237-
std::string csv = existing.empty() ? sample : existing + "," + sample;
229+
LOG_INFO("[AlleleCount] Recorded skipped deletion for sample {}", sample);
230+
}
238231

239-
// Write updated metadata
240-
{
241-
Array write_array(*ctx, uri, TILEDB_WRITE);
242-
write_array.put_metadata(
243-
"skipped_delete_samples", TILEDB_STRING_ASCII, csv.size(), csv.c_str());
232+
std::vector<std::string> AlleleCount::get_skipped_delete_samples(
233+
std::shared_ptr<Context> ctx, const Group& group) {
234+
auto uri = AlleleCount::group_uri(group);
235+
if (uri.empty()) {
236+
return {};
237+
}
238+
239+
const std::string prefix = "skipped_delete_sample:";
240+
std::vector<std::string> samples;
241+
Array array(*ctx, uri, TILEDB_READ);
242+
uint64_t n = array.metadata_num();
243+
for (uint64_t i = 0; i < n; i++) {
244+
std::string key;
245+
tiledb_datatype_t type;
246+
uint32_t num;
247+
const void* value;
248+
array.get_metadata_from_index(i, &key, &type, &num, &value);
249+
if (key.rfind(prefix, 0) == 0) {
250+
samples.push_back(key.substr(prefix.size()));
251+
}
244252
}
245-
246-
LOG_INFO("[AlleleCount] Recorded skipped deletion for sample {}", sample);
253+
return samples;
247254
}
248255

249256
void AlleleCount::consolidate_commits(

libtiledbvcf/src/stats/allele_count.h

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,9 @@ class AlleleCount {
120120
/**
121121
* @brief Record a skipped sample deletion in array metadata.
122122
*
123-
* Reads existing "skipped_deletions" metadata (if any), appends the sample
124-
* name, and writes back the updated CSV.
123+
* Writes a per-sample metadata key "skipped_delete_sample:<sample>" to the
124+
* array. Using one key per sample avoids the read-modify-write race condition
125+
* that would arise from accumulating a single CSV value.
125126
*
126127
* @param ctx TileDB context
127128
* @param group TileDB-VCF dataset group
@@ -132,6 +133,19 @@ class AlleleCount {
132133
const Group& group,
133134
const std::string& sample);
134135

136+
/**
137+
* @brief Return the list of samples whose deletion was skipped.
138+
*
139+
* Enumerates all array metadata keys with the prefix
140+
* "skipped_delete_sample:" and returns the sample name suffixes.
141+
*
142+
* @param ctx TileDB context
143+
* @param group TileDB-VCF dataset group
144+
* @return Vector of sample names recorded as skipped
145+
*/
146+
static std::vector<std::string> get_skipped_delete_samples(
147+
std::shared_ptr<Context> ctx, const Group& group);
148+
135149
/**
136150
* @brief Consolidate commits
137151
*

libtiledbvcf/src/stats/variant_stats.cc

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -318,30 +318,37 @@ void VariantStats::skip_delete_sample(
318318
return;
319319
}
320320

321-
// Read existing skipped_delete_samples metadata
322-
std::string existing;
323-
{
324-
Array read_array(*ctx, uri, TILEDB_READ);
325-
const void* value = nullptr;
326-
tiledb_datatype_t type = TILEDB_ANY;
327-
uint32_t num = 0;
328-
read_array.get_metadata("skipped_delete_samples", &type, &num, &value);
329-
if (value != nullptr && num > 0) {
330-
existing = std::string(static_cast<const char*>(value), num);
331-
}
332-
}
321+
// Write one key per sample to avoid a read-modify-write race condition.
322+
// Concurrent callers write to different keys so no coordination is needed.
323+
std::string key = "skipped_delete_sample:" + sample;
324+
Array write_array(*ctx, uri, TILEDB_WRITE);
325+
write_array.put_metadata(key, TILEDB_STRING_ASCII, 0, nullptr);
333326

334-
// Build updated CSV
335-
std::string csv = existing.empty() ? sample : existing + "," + sample;
327+
LOG_INFO("[VariantStats] Recorded skipped deletion for sample {}", sample);
328+
}
336329

337-
// Write updated metadata
338-
{
339-
Array write_array(*ctx, uri, TILEDB_WRITE);
340-
write_array.put_metadata(
341-
"skipped_delete_samples", TILEDB_STRING_ASCII, csv.size(), csv.c_str());
330+
std::vector<std::string> VariantStats::get_skipped_delete_samples(
331+
std::shared_ptr<Context> ctx, const Group& group) {
332+
auto uri = VariantStats::group_uri(group);
333+
if (uri.empty()) {
334+
return {};
335+
}
336+
337+
const std::string prefix = "skipped_delete_sample:";
338+
std::vector<std::string> samples;
339+
Array array(*ctx, uri, TILEDB_READ);
340+
uint64_t n = array.metadata_num();
341+
for (uint64_t i = 0; i < n; i++) {
342+
std::string key;
343+
tiledb_datatype_t type;
344+
uint32_t num;
345+
const void* value;
346+
array.get_metadata_from_index(i, &key, &type, &num, &value);
347+
if (key.rfind(prefix, 0) == 0) {
348+
samples.push_back(key.substr(prefix.size()));
349+
}
342350
}
343-
344-
LOG_INFO("[VariantStats] Recorded skipped deletion for sample {}", sample);
351+
return samples;
345352
}
346353

347354
void VariantStats::consolidate_commits(

libtiledbvcf/src/stats/variant_stats.h

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,9 @@ class VariantStats {
125125
/**
126126
* @brief Record a skipped sample deletion in array metadata.
127127
*
128-
* Reads existing "skipped_deletions" metadata (if any), appends the sample
129-
* name, and writes back the updated CSV.
128+
* Writes a per-sample metadata key "skipped_delete_sample:<sample>" to the
129+
* array. Using one key per sample avoids the read-modify-write race condition
130+
* that would arise from accumulating a single CSV value.
130131
*
131132
* @param ctx TileDB context
132133
* @param group TileDB-VCF dataset group
@@ -137,6 +138,19 @@ class VariantStats {
137138
const Group& group,
138139
const std::string& sample);
139140

141+
/**
142+
* @brief Return the list of samples whose deletion was skipped.
143+
*
144+
* Enumerates all array metadata keys with the prefix
145+
* "skipped_delete_sample:" and returns the sample name suffixes.
146+
*
147+
* @param ctx TileDB context
148+
* @param group TileDB-VCF dataset group
149+
* @return Vector of sample names recorded as skipped
150+
*/
151+
static std::vector<std::string> get_skipped_delete_samples(
152+
std::shared_ptr<Context> ctx, const Group& group);
153+
140154
/**
141155
* @brief Consolidate commits
142156
*

libtiledbvcf/test/src/unit-vcf-delete.cc

Lines changed: 37 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@
3434

3535
#include "dataset/tiledbvcfdataset.h"
3636
#include "read/reader.h"
37+
#include "stats/allele_count.h"
38+
#include "stats/variant_stats.h"
3739
#include "utils/logger_public.h"
3840
#include "write/writer.h"
3941

42+
#include <algorithm>
4043
#include <cstring>
4144
#include <fstream>
4245
#include <iostream>
@@ -312,28 +315,20 @@ TEST_CASE(
312315

313316
// Verify skipped_delete_samples metadata on allele_count
314317
{
315-
Array array(ctx, dataset_uri + "/allele_count", TILEDB_READ);
316-
const void* value = nullptr;
317-
tiledb_datatype_t type = TILEDB_ANY;
318-
uint32_t num = 0;
319-
array.get_metadata("skipped_delete_samples", &type, &num, &value);
320-
REQUIRE(value != nullptr);
321-
REQUIRE(type == TILEDB_STRING_ASCII);
322-
std::string meta(static_cast<const char*>(value), num);
323-
REQUIRE(meta == sample_name);
318+
auto ctx_ptr = std::make_shared<tiledb::Context>();
319+
tiledb::Group group(*ctx_ptr, dataset_uri, TILEDB_READ);
320+
auto skipped = AlleleCount::get_skipped_delete_samples(ctx_ptr, group);
321+
REQUIRE(skipped.size() == 1);
322+
REQUIRE(skipped[0] == sample_name);
324323
}
325324

326325
// Verify skipped_delete_samples metadata on variant_stats
327326
{
328-
Array array(ctx, dataset_uri + "/variant_stats", TILEDB_READ);
329-
const void* value = nullptr;
330-
tiledb_datatype_t type = TILEDB_ANY;
331-
uint32_t num = 0;
332-
array.get_metadata("skipped_delete_samples", &type, &num, &value);
333-
REQUIRE(value != nullptr);
334-
REQUIRE(type == TILEDB_STRING_ASCII);
335-
std::string meta(static_cast<const char*>(value), num);
336-
REQUIRE(meta == sample_name);
327+
auto ctx_ptr = std::make_shared<tiledb::Context>();
328+
tiledb::Group group(*ctx_ptr, dataset_uri, TILEDB_READ);
329+
auto skipped = VariantStats::get_skipped_delete_samples(ctx_ptr, group);
330+
REQUIRE(skipped.size() == 1);
331+
REQUIRE(skipped[0] == sample_name);
337332
}
338333

339334
if (vfs.is_dir(dataset_uri)) {
@@ -384,26 +379,20 @@ TEST_CASE(
384379
dataset.delete_samples(dataset_uri, {"G1"}, {}, true);
385380
}
386381

387-
// Verify metadata is "G1" on both arrays
382+
// Verify metadata records G1 on both arrays
388383
{
389-
Array ac_array(ctx, dataset_uri + "/allele_count", TILEDB_READ);
390-
const void* value = nullptr;
391-
tiledb_datatype_t type = TILEDB_ANY;
392-
uint32_t num = 0;
393-
ac_array.get_metadata("skipped_delete_samples", &type, &num, &value);
394-
REQUIRE(value != nullptr);
395-
std::string meta(static_cast<const char*>(value), num);
396-
REQUIRE(meta == "G1");
384+
auto ctx_ptr = std::make_shared<tiledb::Context>();
385+
tiledb::Group group(*ctx_ptr, dataset_uri, TILEDB_READ);
386+
auto skipped = AlleleCount::get_skipped_delete_samples(ctx_ptr, group);
387+
REQUIRE(skipped.size() == 1);
388+
REQUIRE(skipped[0] == "G1");
397389
}
398390
{
399-
Array vs_array(ctx, dataset_uri + "/variant_stats", TILEDB_READ);
400-
const void* value = nullptr;
401-
tiledb_datatype_t type = TILEDB_ANY;
402-
uint32_t num = 0;
403-
vs_array.get_metadata("skipped_delete_samples", &type, &num, &value);
404-
REQUIRE(value != nullptr);
405-
std::string meta(static_cast<const char*>(value), num);
406-
REQUIRE(meta == "G1");
391+
auto ctx_ptr = std::make_shared<tiledb::Context>();
392+
tiledb::Group group(*ctx_ptr, dataset_uri, TILEDB_READ);
393+
auto skipped = VariantStats::get_skipped_delete_samples(ctx_ptr, group);
394+
REQUIRE(skipped.size() == 1);
395+
REQUIRE(skipped[0] == "G1");
407396
}
408397

409398
// Delete G2 with skip_aggregate_stats=true
@@ -413,26 +402,22 @@ TEST_CASE(
413402
dataset.delete_samples(dataset_uri, {"G2"}, {}, true);
414403
}
415404

416-
// Verify metadata accumulated to "G1,G2" on both arrays
405+
// Verify metadata records both G1 and G2 on both arrays
417406
{
418-
Array ac_array(ctx, dataset_uri + "/allele_count", TILEDB_READ);
419-
const void* value = nullptr;
420-
tiledb_datatype_t type = TILEDB_ANY;
421-
uint32_t num = 0;
422-
ac_array.get_metadata("skipped_delete_samples", &type, &num, &value);
423-
REQUIRE(value != nullptr);
424-
std::string meta(static_cast<const char*>(value), num);
425-
REQUIRE(meta == "G1,G2");
407+
auto ctx_ptr = std::make_shared<tiledb::Context>();
408+
tiledb::Group group(*ctx_ptr, dataset_uri, TILEDB_READ);
409+
auto skipped = AlleleCount::get_skipped_delete_samples(ctx_ptr, group);
410+
REQUIRE(skipped.size() == 2);
411+
REQUIRE(std::find(skipped.begin(), skipped.end(), "G1") != skipped.end());
412+
REQUIRE(std::find(skipped.begin(), skipped.end(), "G2") != skipped.end());
426413
}
427414
{
428-
Array vs_array(ctx, dataset_uri + "/variant_stats", TILEDB_READ);
429-
const void* value = nullptr;
430-
tiledb_datatype_t type = TILEDB_ANY;
431-
uint32_t num = 0;
432-
vs_array.get_metadata("skipped_delete_samples", &type, &num, &value);
433-
REQUIRE(value != nullptr);
434-
std::string meta(static_cast<const char*>(value), num);
435-
REQUIRE(meta == "G1,G2");
415+
auto ctx_ptr = std::make_shared<tiledb::Context>();
416+
tiledb::Group group(*ctx_ptr, dataset_uri, TILEDB_READ);
417+
auto skipped = VariantStats::get_skipped_delete_samples(ctx_ptr, group);
418+
REQUIRE(skipped.size() == 2);
419+
REQUIRE(std::find(skipped.begin(), skipped.end(), "G1") != skipped.end());
420+
REQUIRE(std::find(skipped.begin(), skipped.end(), "G2") != skipped.end());
436421
}
437422

438423
// Verify G3 still present, G1 and G2 are gone

0 commit comments

Comments
 (0)