Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in
[`CLAUDE.md`](./CLAUDE.md) → "Release Versioning".

## [0.32.0] — unreleased

- Add the `set_dataset_metadata` tail slot to the data-source runtime host
vtable (`DataSourceRuntimeHostView::setDatasetMetadata`, forwarded by
`DatasetIngestHostView`): a loader attaches one descriptive JSON object about
the loaded artifact for the host to display generically. Deliberately
disjoint from `attach_source_record` — observations about the artifact,
never request identity; a refused document has no effect on ingestion,
caching, or completion. Old hosts negotiate via `struct_size` as usual.

## [0.31.1]

- Drop fmt from `pj_base`'s `time_format.cpp` (`snprintf` zero-padding, byte-identical
Expand Down
1 change: 1 addition & 0 deletions pj_base/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ if(PJ_BUILD_TESTS)
tests/push_message_test.cpp
tests/attach_source_record_test.cpp
tests/ingest_completion_test.cpp
tests/dataset_metadata_test.cpp
tests/notify_available_topics_test.cpp
tests/dataset_ingest_view_test.cpp
tests/descriptor_import_extension_test.cpp
Expand Down
25 changes: 25 additions & 0 deletions pj_base/include/pj_base/data_source_protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,31 @@ typedef struct PJ_data_source_runtime_host_vtable_t {
* PJ_HAS_TAIL_SLOT. Tail slot.
*/
bool (*complete_ingest)(void* ctx, const PJ_ingest_completion_t* completion, PJ_error_t* out_error) PJ_NOEXCEPT;

/**
* [stream-thread] Attach a descriptive metadata document to the dataset this
* ingest context produces: one UTF-8 JSON OBJECT of facts about the loaded
* artifact (e.g. embedded manifests, recording timestamps, file summary
* figures) for the host to display generically. This is presentation-side
* provenance, deliberately disjoint from attach_source_record: a source
* record is the reproducible REQUEST identity that drives caching and
* restore; this document is observations ABOUT the artifact and never
* affects restore routing, cache keys, or trust.
*
* The host copies the bytes during the call. Each successful call replaces
* the whole document for this ingest; "{}" clears it. Callable at any point
* while the ingest context is live — before, between, or after pushes; the
* host publishes the last accepted document when the dataset's data commits
* and discards it if the ingest is discarded.
*
* The host bounds the document (byte length, nesting depth, node count are
* host policy) and rejects anything malformed, non-object, or over bounds:
* false + error, with NO effect on sample ingestion, completion, or cache
* eligibility — a refused document only means no metadata. Hosts that
* predate this slot never see metadata; gate with PJ_HAS_TAIL_SLOT. Tail
* slot.
*/
bool (*set_dataset_metadata)(void* ctx, PJ_string_view_t metadata_json, PJ_error_t* out_error) PJ_NOEXCEPT;
} PJ_data_source_runtime_host_vtable_t;

/** Fat pointer pairing a runtime host context with its vtable. */
Expand Down
15 changes: 15 additions & 0 deletions pj_base/include/pj_base/sdk/data_source_host_views.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,15 @@ class DataSourceRuntimeHostView {
sdk::IngestOutcome outcome, Span<const std::string_view> requested_topics,
PJ_ingest_completion_flags_t flags = PJ_INGEST_COMPLETION_FLAG_NONE) const;

/// Attach a descriptive metadata document (one UTF-8 JSON object) to the
/// dataset this ingest produces, for the host to display generically
/// (set_dataset_metadata tail slot). Observations ABOUT the loaded artifact
/// — never request identity: caching and restore stay attachSourceRecord's
/// business, and a refused document has no effect on ingestion. Each
/// successful call replaces the whole document; "{}" clears it. Old hosts
/// (no slot) return an error; the plugin proceeds without metadata.
[[nodiscard]] Status setDatasetMetadata(std::string_view metadata_json) const;

/// Push a message via a deferred FetchMessageData callable. The DataSource
/// hands the host a callable that produces the payload bytes when invoked.
/// The host applies the active ObjectIngestPolicy (resolved via the
Expand Down Expand Up @@ -467,6 +476,12 @@ class DatasetIngestHostView {
return host_.completeIngest(outcome, requested_topics, flags);
}

/// Attach a descriptive metadata document to this ingest's dataset. See
/// DataSourceRuntimeHostView::setDatasetMetadata for the full contract.
[[nodiscard]] Status setDatasetMetadata(std::string_view metadata_json) const {
return host_.setDatasetMetadata(metadata_json);
}

/// Rider forward of the runtime host's thread-safe stop request: a provider
/// cancelling a finite download must call this BEFORE joining producers —
/// it is what wakes a producer blocked on lossless capture backpressure.
Expand Down
14 changes: 14 additions & 0 deletions pj_base/src/data_source_host_views.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ Status DataSourceRuntimeHostView::attachSourceRecord(std::string_view descriptor
return okStatus();
}

Status DataSourceRuntimeHostView::setDatasetMetadata(std::string_view metadata_json) const {
if (!valid()) {
return unexpected(std::string("runtime host is not bound"));
}
if (!PJ_HAS_TAIL_SLOT(PJ_data_source_runtime_host_vtable_t, host_.vtable, set_dataset_metadata)) {
return unexpected(std::string("runtime host does not expose set_dataset_metadata"));
}
PJ_error_t err{};
if (!host_.vtable->set_dataset_metadata(host_.ctx, sdk::toAbiString(metadata_json), &err)) {
return unexpected(errorToString(err));
}
return okStatus();
}

Status DataSourceRuntimeHostView::completeIngest(
sdk::IngestOutcome outcome, Span<const std::string_view> requested_topics,
PJ_ingest_completion_flags_t flags) const {
Expand Down
5 changes: 4 additions & 1 deletion pj_base/tests/abi_layout_sentinels_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,10 @@ static_assert(sizeof(PJ_ingest_completion_t) == 32, "completion struct size (upd
static_assert(
offsetof(PJ_data_source_runtime_host_vtable_t, complete_ingest) == 112, "complete_ingest tail slot pinned");
static_assert(
sizeof(PJ_data_source_runtime_host_vtable_t) == 120, "Runtime host vtable size (update deliberately on append)");
offsetof(PJ_data_source_runtime_host_vtable_t, set_dataset_metadata) == 120,
"set_dataset_metadata tail slot pinned");
static_assert(
sizeof(PJ_data_source_runtime_host_vtable_t) == 128, "Runtime host vtable size (update deliberately on append)");

// --- Write-host vtables (ABI-APPENDABLE within v4) --------------------------
static_assert(offsetof(PJ_source_write_host_vtable_t, abi_version) == 0, "source write host prefix pinned");
Expand Down
122 changes: 122 additions & 0 deletions pj_base/tests/dataset_metadata_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2026 Davide Faconti
// SPDX-License-Identifier: Apache-2.0

// Tests for the set_dataset_metadata tail slot (0.32.0):
//
// 1. DataSourceRuntimeHostView::setDatasetMetadata flows the borrowed JSON
// through the slot; the host copies during the call and owns the bytes
// afterwards. Repeat calls reach the host again (replace semantics are
// the host's business; the wrapper never dedupes).
// 2. Old-host negotiation: short struct_size / NULL slot yields an explicit
// error and the plugin proceeds without metadata; struct_size alone
// gates (a stale non-null pointer past the reported size is never read).
// 3. A host rejection (false + error) surfaces the host's reason verbatim.
// 4. DatasetIngestHostView forwards to the same slot.

#include <gtest/gtest.h>

#include <cstdio>
#include <string>
#include <vector>

#include "pj_base/data_source_protocol.h"
#include "pj_base/sdk/data_source_host_views.hpp"

namespace {

// Mock runtime host — copies the borrowed document during the call, exactly
// as a real host must.
class MockRuntimeHost {
public:
MockRuntimeHost() {
vtable_.protocol_version = 1;
vtable_.struct_size = sizeof(PJ_data_source_runtime_host_vtable_t);
vtable_.set_dataset_metadata = &MockRuntimeHost::setMetadataThunk;
host_.ctx = this;
host_.vtable = &vtable_;
}

void dropSetDatasetMetadata() {
vtable_.set_dataset_metadata = nullptr;
vtable_.struct_size = offsetof(PJ_data_source_runtime_host_vtable_t, set_dataset_metadata);
}

void shrinkStructSizeOnly() {
vtable_.struct_size = offsetof(PJ_data_source_runtime_host_vtable_t, set_dataset_metadata);
}

PJ::DataSourceRuntimeHostView view() const {
return PJ::DataSourceRuntimeHostView(host_);
}

int call_count = 0;
std::vector<std::string> received;
std::string reject_reason; // non-empty: the thunk refuses with this reason

private:
static bool setMetadataThunk(void* ctx, PJ_string_view_t metadata_json, PJ_error_t* err) noexcept {
auto* self = static_cast<MockRuntimeHost*>(ctx);
self->call_count++;
self->received.emplace_back(metadata_json.data, metadata_json.size);
if (!self->reject_reason.empty()) {
if (err != nullptr) {
std::snprintf(err->message, sizeof(err->message), "%s", self->reject_reason.c_str());
}
return false;
}
return true;
}

PJ_data_source_runtime_host_vtable_t vtable_{};
PJ_data_source_runtime_host_t host_{};
};

TEST(DatasetMetadataTest, DocumentFlowsThroughAndReplacesOnRepeat) {
MockRuntimeHost host;
auto status = host.view().setDatasetMetadata(R"({"file":{"messages":42}})");
ASSERT_TRUE(status) << status.error();
status = host.view().setDatasetMetadata("{}");
ASSERT_TRUE(status) << status.error();
EXPECT_EQ(host.call_count, 2);
EXPECT_EQ(host.received, (std::vector<std::string>{R"({"file":{"messages":42}})", "{}"}));
}

TEST(DatasetMetadataTest, ReturnsErrorWhenSlotMissing) {
MockRuntimeHost host;
host.dropSetDatasetMetadata();
auto status = host.view().setDatasetMetadata("{}");
EXPECT_FALSE(status); // explicit: the plugin proceeds without metadata
EXPECT_EQ(host.call_count, 0);
}

TEST(DatasetMetadataTest, ShortStructSizeAloneGatesTheSlot) {
MockRuntimeHost host;
host.shrinkStructSizeOnly(); // stale non-null pointer past the reported size
auto status = host.view().setDatasetMetadata("{}");
EXPECT_FALSE(status);
EXPECT_EQ(host.call_count, 0);
}

TEST(DatasetMetadataTest, HostRejectionSurfacesReason) {
MockRuntimeHost host;
host.reject_reason = "document exceeds host bounds";
auto status = host.view().setDatasetMetadata(R"({"huge":true})");
ASSERT_FALSE(status);
EXPECT_NE(status.error().find("document exceeds host bounds"), std::string::npos);
}

TEST(DatasetMetadataTest, UnboundHostReportsNotBound) {
PJ::DataSourceRuntimeHostView view;
auto status = view.setDatasetMetadata("{}");
ASSERT_FALSE(status);
EXPECT_NE(status.error().find("not bound"), std::string::npos);
}

TEST(DatasetMetadataTest, DatasetIngestViewForwards) {
MockRuntimeHost host;
auto status = host.view().datasetIngest().setDatasetMetadata(R"({"origin":"file"})");
ASSERT_TRUE(status) << status.error();
EXPECT_EQ(host.received, (std::vector<std::string>{R"({"origin":"file"})"}));
}

} // namespace
1 change: 1 addition & 0 deletions pj_plugins/docs/data-source-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ Access via `runtimeHost()`. Use this for lifecycle coordination and diagnostics.
| `pushMessage(handle, timestamp, fetch_message_data)` | Push a message through a parser binding via a deferred fetcher callable; the host invokes it per the active ObjectIngestPolicy (eager/lazy). |
| `notifyAvailableTopics(topics)` | Advertise the full set of topics you *can* stream but have not subscribed, so the host lists and a-priori classifies them with no data flowing. See *Per-topic pause* below. |
| `attachSourceRecord(descriptor_json)` | On the stream thread, declare the request descriptor for the host's source cache. The host stores the bytes verbatim and derives its cache key. Last attach before this context's first `pushMessage` wins; byte-identical repeats before ingest are idempotent. Errors after ingest begins or on hosts that predate the slot never affect ingest. See [Source-cache attachment](ARCHITECTURE.md#source-cache-attachment). |
| `setDatasetMetadata(metadata_json)` | Attach one descriptive JSON object about the loaded artifact (embedded manifests, recording timestamps, file facts) for the host to display generically. Observations, never request identity: it does not affect caching, restore, or trust. Each successful call replaces the whole document; `"{}"` clears it. Old hosts (no slot) return an error and the load proceeds without metadata. |

## Optional Features

Expand Down
2 changes: 2 additions & 0 deletions pj_plugins/tests/data_source_library_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ PJ_data_source_runtime_host_t makeRuntimeHost(bool with_encodings) {
.notify_available_topics = nullptr,
.attach_source_record = nullptr,
.complete_ingest = nullptr,
.set_dataset_metadata = nullptr,
};
static const PJ_data_source_runtime_host_vtable_t no_enc_vt = {
.protocol_version = 1,
Expand All @@ -134,6 +135,7 @@ PJ_data_source_runtime_host_t makeRuntimeHost(bool with_encodings) {
.notify_available_topics = nullptr,
.attach_source_record = nullptr,
.complete_ingest = nullptr,
.set_dataset_metadata = nullptr,
};
return PJ_data_source_runtime_host_t{
.ctx = reinterpret_cast<void*>(0x2),
Expand Down
1 change: 1 addition & 0 deletions pj_plugins/tests/file_source_integration_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ PJ_data_source_runtime_host_t makeRuntimeHost(RuntimeHostState* state) {
.notify_available_topics = nullptr,
.attach_source_record = nullptr,
.complete_ingest = nullptr,
.set_dataset_metadata = nullptr,
};
return PJ_data_source_runtime_host_t{.ctx = state, .vtable = &vtable};
}
Expand Down
Loading