Skip to content

Commit 2eab34c

Browse files
Minipadaclaude
andcommitted
fix(dc_bridge): carry Measurement custom keys onto File metadata Records
A Measurement's `custom_key_str_list` keys reached its Records but not the Uploader's `file_status`/`group_complete` rows, which were built from a fixed field set — so the Records and the Files of one Measurement ended up labelled differently, with nothing logged. A Record now names its custom keys in a `custom_keys` field, which is the only way the Uploader can tell `site` from a measured field. Those keys are appended to every File metadata row, including retention's shed rows. Dropped rather than written, in both cases from every row kind so a File and its group marker can't disagree: - a key naming a field the Uploader computes itself (`storage_type`, `size`, …) — the Uploader's value is kept and the Bridge warns; - `name`, `id` and `robot_name`, which the rows already carry as `group_name`, `robot_id` and `robot_name` — re-emitting `id` would put a robot identifier in whatever an `id` column happens to be. A Measurement with no custom keys produces the rows it did before. Closes #419 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oGKFjBD4WKxMHYv3iKXBo Signed-off-by: David Bensoussan <d.bensoussan@proton.me>
1 parent 0ed5a64 commit 2eab34c

11 files changed

Lines changed: 266 additions & 1 deletion

File tree

dc_bridge/include/dc_bridge/uploader/group.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ struct FileGroup
3636
std::string group_name;
3737
std::optional<std::string> robot_name;
3838
std::optional<nlohmann::json> robot_id;
39+
/// The emitting Measurement's `custom_key_str_list` keys and their values, read from the
40+
/// Record's `custom_keys` declaration (#419). The Uploader carries them onto its File
41+
/// metadata Records so a Measurement's Files and its Records are labelled the same way.
42+
std::map<std::string, nlohmann::json> custom_keys;
3943
std::vector<FileRef> files; ///< deterministic (local-path-sorted) order.
4044
};
4145

dc_bridge/include/dc_bridge/uploader/status.hpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ nlohmann::json shed_row(const FileGroup& group, const FileRef& file, const Stora
5252
/// ADR-0005's group completion marker.
5353
nlohmann::json group_complete_row(const FileGroup& group);
5454

55+
/// True for a field name the rows above compute themselves. A Measurement custom key
56+
/// (#419) with such a name is dropped rather than overwriting the Uploader's own value —
57+
/// and dropped from every row kind, not just the ones that happen to carry that field, so
58+
/// a File and the group marker covering it never disagree about what a key means.
59+
/// The Record's own `name`, `id` and `robot_name` are dropped too but are not reserved:
60+
/// the rows already carry those values (as `group_name`, `robot_id`, `robot_name`), so
61+
/// there is no disagreement to report.
62+
bool is_reserved_field(const std::string& name);
63+
5564
} // namespace status
5665
} // namespace dc_bridge::uploader
5766

dc_bridge/include/dc_bridge/uploader/uploader.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ struct ProcessSummary
6363
/// log line rather than by diffing the object store.
6464
std::size_t thumbnails = 0;
6565
std::size_t thumbnails_failed = 0;
66+
/// Custom keys (#419) the rows could not carry because they name a field the Uploader
67+
/// emits itself. Reported so the collision is visible in the Bridge's log instead of
68+
/// being the silent drop this whole feature exists to remove.
69+
std::vector<std::string> dropped_custom_keys;
6670
};
6771

6872
/// Thrown by process_record when a Record couldn't be fully processed. `Incomplete` is

dc_bridge/src/bridge_node.cpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,20 @@ void BridgeNode::run_uploader_worker(std::string forward_host, std::uint16_t for
682682
item->tag.c_str(), summary.files, summary.verified, summary.missing, summary.deleted,
683683
static_cast<int>(summary.group_complete));
684684
}
685+
// A custom key naming a field the Uploader emits itself keeps the Uploader's value
686+
// (#419); saying so here is the difference between a defined rule and a silent drop.
687+
if (!summary.dropped_custom_keys.empty())
688+
{
689+
std::string joined;
690+
for (const auto& key : summary.dropped_custom_keys)
691+
{
692+
joined += (joined.empty() ? "" : ", ") + key;
693+
}
694+
RCLCPP_WARN(this->get_logger(),
695+
"uploader: group '%s': custom key(s) %s name fields the File metadata Records already carry — "
696+
"the Uploader's own values are kept and the custom values are not written",
697+
item->tag.c_str(), joined.c_str());
698+
}
685699
// Previews are best-effort and never fail an upload (#256), so a File the operator
686700
// asked to have one and didn't get is only visible here. Warn rather than info:
687701
// silently shipping galleries with no previews is the failure mode worth surfacing.

dc_bridge/src/uploader/group.cpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,26 @@ FileGroup parse_file_group(const nlohmann::json& payload, const std::string& fal
120120
{
121121
group.robot_id = *id_it;
122122
}
123+
// Top level only: a Group-merged Record namespaces its members' fields under their
124+
// `group_key`, so those keys are no longer the Measurement's own labelling.
125+
auto declared_it = payload.find("custom_keys");
126+
if (declared_it != payload.end() && declared_it->is_array())
127+
{
128+
for (const auto& name : *declared_it)
129+
{
130+
if (!name.is_string())
131+
{
132+
continue;
133+
}
134+
const std::string key = name.get<std::string>();
135+
auto value_it = payload.find(key);
136+
if (key.empty() || value_it == payload.end())
137+
{
138+
continue;
139+
}
140+
group.custom_keys.emplace(key, *value_it);
141+
}
142+
}
123143
}
124144
for (auto& [local_path, file] : files)
125145
{

dc_bridge/src/uploader/status.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,48 @@
44
#include "dc_bridge/uploader/status.hpp"
55

66
#include <chrono>
7+
#include <set>
78

89
namespace dc_bridge::uploader::status
910
{
1011

1112
namespace
1213
{
1314

15+
// Fields the rows compute themselves. A custom key naming one of these means the
16+
// Measurement and the Uploader disagree about what the name stands for.
17+
const std::set<std::string>& computed_fields()
18+
{
19+
static const std::set<std::string> fields{
20+
"kind", "group_name", "robot_id", "local_path", "remote_path", "storage_type",
21+
"updated_at", "uploaded", "deleted", "on_filesystem", "duration", "content_type",
22+
"size", "complete", "files", "file_count", "thumbnail_path",
23+
};
24+
return fields;
25+
}
26+
27+
// Record keys the rows already carry, verbatim or under a Humble-era name of their own
28+
// (`name` → group_name, `id` → robot_id). Also dropped, but not a collision: the value is
29+
// in the row already, so re-emitting it would only put it in a second column.
30+
const std::set<std::string>& payload_derived_fields()
31+
{
32+
static const std::set<std::string> keys{ "name", "id", "robot_name" };
33+
return keys;
34+
}
35+
36+
// Appends the emitting Measurement's custom keys (#419). Called last, so a key that names
37+
// a field the row already carries is dropped instead of overwriting it.
38+
void apply_custom_keys(nlohmann::json& row, const FileGroup& group)
39+
{
40+
for (const auto& [key, value] : group.custom_keys)
41+
{
42+
if (!computed_fields().count(key) && !payload_derived_fields().count(key))
43+
{
44+
row[key] = value;
45+
}
46+
}
47+
}
48+
1449
double unix_now()
1550
{
1651
using namespace std::chrono;
@@ -61,6 +96,7 @@ nlohmann::json uploaded_row(const FileGroup& group, const FileRef& file, const S
6196
// same way by a consumer.
6297
row["thumbnail_path"] = storage.url_prefix + storage.object_key(*thumbnail_path);
6398
}
99+
apply_custom_keys(row, group);
64100
return row;
65101
}
66102

@@ -71,6 +107,7 @@ nlohmann::json missing_row(const FileGroup& group, const FileRef& file, const St
71107
row["uploaded"] = false;
72108
row["on_filesystem"] = false;
73109
row["deleted"] = false;
110+
apply_custom_keys(row, group);
74111
return row;
75112
}
76113

@@ -81,6 +118,7 @@ nlohmann::json deleted_row(const FileGroup& group, const FileRef& file, const St
81118
row["uploaded"] = true;
82119
row["on_filesystem"] = false;
83120
row["deleted"] = true;
121+
apply_custom_keys(row, group);
84122
return row;
85123
}
86124

@@ -91,6 +129,7 @@ nlohmann::json shed_row(const FileGroup& group, const FileRef& file, const Stora
91129
row["uploaded"] = false;
92130
row["on_filesystem"] = false;
93131
row["deleted"] = true;
132+
apply_custom_keys(row, group);
94133
return row;
95134
}
96135

@@ -116,7 +155,13 @@ nlohmann::json group_complete_row(const FileGroup& group)
116155
}
117156
row["files"] = std::move(files);
118157
row["updated_at"] = unix_now();
158+
apply_custom_keys(row, group);
119159
return row;
120160
}
121161

162+
bool is_reserved_field(const std::string& name)
163+
{
164+
return computed_fields().count(name) > 0;
165+
}
166+
122167
} // namespace dc_bridge::uploader::status

dc_bridge/src/uploader/uploader.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,13 @@ ProcessSummary Uploader::process_record(const nlohmann::json& payload, const std
449449
{
450450
return summary;
451451
}
452+
for (const auto& [key, value] : group.custom_keys)
453+
{
454+
if (status::is_reserved_field(key))
455+
{
456+
summary.dropped_custom_keys.push_back(key);
457+
}
458+
}
452459

453460
std::vector<std::string> failures;
454461
std::vector<const FileRef*> verified_files;

dc_bridge/test/uploader_test.cpp

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,107 @@ TEST(Uploader, MetadataRecordHasHumbleShape)
258258
EXPECT_EQ(marker["files"][0]["local_path"], local);
259259
}
260260

261+
// The payloads below carry what measurement.hpp's addCustomKeys() writes for
262+
// `custom_key_str_list: [site, fleet]`: the values inline, plus a `custom_keys`
263+
// declaration naming which of the Record's keys are labelling rather than data.
264+
TEST(Uploader, CustomKeysReachEveryFileMetadataRow)
265+
{
266+
Fixture fx({ "minio" });
267+
auto local = fx.write_file("img.jpg", JPEG_BYTES);
268+
auto payload = camera_payload(local, { "minio" });
269+
payload["site"] = "warehouse-3";
270+
payload["fleet"] = "eu-west";
271+
payload["custom_keys"] = json::array({ "site", "fleet" });
272+
273+
auto up = fx.uploader(true); // delete_when_sent, so the deleted rows are emitted too
274+
auto rows = std::make_shared<std::vector<json>>();
275+
auto summary = up.process_record(payload, "dc.measurement.camera", collect_rows(rows));
276+
277+
EXPECT_TRUE(summary.dropped_custom_keys.empty());
278+
auto file_rows = rows_of_kind(*rows, "file_status");
279+
EXPECT_EQ(file_rows.size(), 2u); // uploaded + deleted
280+
for (const auto& row : file_rows)
281+
{
282+
EXPECT_EQ(row["site"], "warehouse-3");
283+
EXPECT_EQ(row["fleet"], "eu-west");
284+
}
285+
auto marker = rows_of_kind(*rows, "group_complete")[0];
286+
EXPECT_EQ(marker["site"], "warehouse-3");
287+
EXPECT_EQ(marker["fleet"], "eu-west");
288+
}
289+
290+
// `custom_key_str_list: ["robot_name", "id"]` is what every demo configures, and the rows
291+
// have carried both since Humble — as `robot_name` and `robot_id`. Re-emitting the Record's
292+
// own `id` alongside would put a robot identifier in whatever an `id` column happens to be.
293+
TEST(Uploader, CustomKeysAlreadyCarriedUnderTheirOwnColumnAreNotRepeated)
294+
{
295+
Fixture fx({ "minio" });
296+
auto local = fx.write_file("img.jpg", JPEG_BYTES);
297+
auto payload = camera_payload(local, { "minio" });
298+
payload["custom_keys"] = json::array({ "robot_name", "id" });
299+
300+
auto up = fx.uploader(false);
301+
auto rows = std::make_shared<std::vector<json>>();
302+
auto summary = up.process_record(payload, "dc.measurement.camera", collect_rows(rows));
303+
304+
auto row = rows_of_kind(*rows, "file_status")[0];
305+
EXPECT_EQ(row["robot_id"], "r1");
306+
EXPECT_EQ(row["robot_name"], "robot1");
307+
EXPECT_FALSE(row.contains("id"));
308+
EXPECT_FALSE(rows_of_kind(*rows, "group_complete")[0].contains("id"));
309+
// Both values are in the row already, so neither is a collision to report.
310+
EXPECT_TRUE(summary.dropped_custom_keys.empty());
311+
}
312+
313+
TEST(Uploader, CustomKeyNamingAnUploaderFieldNeverOverwritesIt)
314+
{
315+
Fixture fx({ "minio" });
316+
auto local = fx.write_file("img.jpg", JPEG_BYTES);
317+
auto payload = camera_payload(local, { "minio" });
318+
payload["storage_type"] = "not-minio";
319+
payload["site"] = "warehouse-3";
320+
payload["custom_keys"] = json::array({ "storage_type", "site" });
321+
322+
auto up = fx.uploader(false);
323+
auto rows = std::make_shared<std::vector<json>>();
324+
auto summary = up.process_record(payload, "dc.measurement.camera", collect_rows(rows));
325+
326+
auto row = rows_of_kind(*rows, "file_status")[0];
327+
EXPECT_EQ(row["storage_type"], "minio"); // the Uploader's own value, not the custom one
328+
EXPECT_EQ(row["site"], "warehouse-3");
329+
// Dropped from every row kind, including the ones that don't carry the field themselves,
330+
// so a File and its group marker can't end up disagreeing about what `storage_type` means.
331+
auto marker = rows_of_kind(*rows, "group_complete")[0];
332+
EXPECT_FALSE(marker.contains("storage_type"));
333+
EXPECT_EQ(marker["site"], "warehouse-3");
334+
EXPECT_EQ(summary.dropped_custom_keys, (std::vector<std::string>{ "storage_type" }));
335+
}
336+
337+
TEST(Uploader, NoCustomKeysKeepsTheRowsUnchanged)
338+
{
339+
Fixture fx({ "minio" });
340+
auto local = fx.write_file("img.jpg", JPEG_BYTES);
341+
auto up = fx.uploader(false);
342+
auto rows = std::make_shared<std::vector<json>>();
343+
up.process_record(camera_payload(local, { "minio" }), "dc.measurement.camera", collect_rows(rows));
344+
345+
auto key_set = [](const json& row) {
346+
std::set<std::string> keys;
347+
for (auto it = row.begin(); it != row.end(); ++it)
348+
{
349+
keys.insert(it.key());
350+
}
351+
return keys;
352+
};
353+
EXPECT_EQ(key_set(rows_of_kind(*rows, "file_status")[0]),
354+
(std::set<std::string>{ "kind", "group_name", "robot_name", "robot_id", "local_path", "remote_path",
355+
"storage_type", "updated_at", "uploaded", "on_filesystem", "deleted",
356+
"content_type", "size" }));
357+
EXPECT_EQ(key_set(rows_of_kind(*rows, "group_complete")[0]),
358+
(std::set<std::string>{ "kind", "group_name", "robot_name", "robot_id", "complete", "file_count", "files",
359+
"updated_at" }));
360+
}
361+
261362
TEST(Uploader, VerifyFailureRetriedWithoutSecondUpload)
262363
{
263364
Fixture fx({ "minio" });

dc_measurements/include/dc_measurements/measurement.hpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,7 @@ class Measurement : public dc_core::Measurement
511511
{
512512
RCLCPP_ERROR_STREAM(logger_, "Error parsing JSON when adding custom keys: " << data.dump());
513513
}
514+
json declared = json::array();
514515
for (auto& param : custom_keys_)
515516
{
516517
auto key = param["key"].get<std::string>();
@@ -521,7 +522,12 @@ class Measurement : public dc_core::Measurement
521522
json data_custom = { { key, value } };
522523
data.update(data_custom);
523524
}
525+
declared.push_back(key);
524526
}
527+
// Names the keys that are this Measurement's labelling rather than its data, so the
528+
// Bridge's Uploader can carry them onto the File metadata Records too (#419) — it
529+
// has no other way to tell `site` from a measured field.
530+
data["custom_keys"] = std::move(declared);
525531
msg.data = data.dump(-1, ' ', true);
526532
}
527533
}

dc_measurements/test/test_measurement_dummy.cpp

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@ class MeasurementDummyTest : public ::testing::Test
4646
boost::replace_all(data_str, "'", "\"");
4747
nlohmann::json data_json = nlohmann::json::parse(data_str);
4848
RCLCPP_INFO_STREAM(ms_node_->get_logger(), "Value: " << data_str);
49+
dummy_record_ = data_json;
4950
dummy_message_ = data_json["message"].get<nlohmann::json::string_t>();
5051
dummy_callback_ = true;
5152
}
5253

5354
std::shared_ptr<measurement_server::MeasurementServer> ms_node_;
5455
rclcpp::Subscription<dc_interfaces::msg::StringStamped>::SharedPtr sub_data_;
5556
std::string dummy_message_;
57+
nlohmann::json dummy_record_;
5658

5759
public:
5860
bool dummy_callback_{ false };
@@ -76,6 +78,44 @@ TEST_F(MeasurementDummyTest, DummyDataCorrect)
7678
EXPECT_EQ(dummy_message_, message);
7779
}
7880

81+
// A Record with custom keys names them, so the Bridge's Uploader can carry the same
82+
// labelling onto the Measurement's File metadata Records (#419).
83+
TEST_F(MeasurementDummyTest, CustomKeysAreDeclaredInTheRecord)
84+
{
85+
ms_node_->declare_parameter("dummy.plugin", std::string("dc_measurements/Dummy"));
86+
ms_node_->declare_parameter("dummy.topic_output", std::string("/dc/measurement/dummy"));
87+
ms_node_->declare_parameter("dummy.record", std::string("{\"message\": \"My message\"}"));
88+
ms_node_->declare_parameter("custom_key_str_list", std::vector<std::string>{ "site" });
89+
ms_node_->declare_parameter("custom_keys_str.site.name", std::string("site"));
90+
ms_node_->declare_parameter("custom_keys_str.site.value", std::string("warehouse-3"));
91+
92+
startLifecycleNode();
93+
94+
while (!dummy_callback_)
95+
{
96+
rclcpp::spin_some(ms_node_->get_node_base_interface());
97+
}
98+
99+
EXPECT_EQ(dummy_record_["site"], "warehouse-3");
100+
EXPECT_EQ(dummy_record_["custom_keys"], nlohmann::json::array({ "site" }));
101+
}
102+
103+
TEST_F(MeasurementDummyTest, NoCustomKeysLeavesTheRecordUntouched)
104+
{
105+
ms_node_->declare_parameter("dummy.plugin", std::string("dc_measurements/Dummy"));
106+
ms_node_->declare_parameter("dummy.topic_output", std::string("/dc/measurement/dummy"));
107+
ms_node_->declare_parameter("dummy.record", std::string("{\"message\": \"My message\"}"));
108+
109+
startLifecycleNode();
110+
111+
while (!dummy_callback_)
112+
{
113+
rclcpp::spin_some(ms_node_->get_node_base_interface());
114+
}
115+
116+
EXPECT_FALSE(dummy_record_.contains("custom_keys"));
117+
}
118+
79119
TEST_F(MeasurementDummyTest, DummyDataIncorrect)
80120
{
81121
int polling_interval = 50;

0 commit comments

Comments
 (0)