diff --git a/CHANGELOG.md b/CHANGELOG.md index e5a482fa..abf92632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 3d552d69..e1b3075d 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -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 diff --git a/pj_base/include/pj_base/data_source_protocol.h b/pj_base/include/pj_base/data_source_protocol.h index 0d1efc97..a75851c6 100644 --- a/pj_base/include/pj_base/data_source_protocol.h +++ b/pj_base/include/pj_base/data_source_protocol.h @@ -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. */ diff --git a/pj_base/include/pj_base/sdk/data_source_host_views.hpp b/pj_base/include/pj_base/sdk/data_source_host_views.hpp index 0347af68..f935c505 100644 --- a/pj_base/include/pj_base/sdk/data_source_host_views.hpp +++ b/pj_base/include/pj_base/sdk/data_source_host_views.hpp @@ -224,6 +224,15 @@ class DataSourceRuntimeHostView { sdk::IngestOutcome outcome, Span 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 @@ -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. diff --git a/pj_base/src/data_source_host_views.cpp b/pj_base/src/data_source_host_views.cpp index bc45979c..141e11f8 100644 --- a/pj_base/src/data_source_host_views.cpp +++ b/pj_base/src/data_source_host_views.cpp @@ -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 requested_topics, PJ_ingest_completion_flags_t flags) const { diff --git a/pj_base/tests/abi_layout_sentinels_test.cpp b/pj_base/tests/abi_layout_sentinels_test.cpp index e3658bae..7d332c72 100644 --- a/pj_base/tests/abi_layout_sentinels_test.cpp +++ b/pj_base/tests/abi_layout_sentinels_test.cpp @@ -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"); diff --git a/pj_base/tests/dataset_metadata_test.cpp b/pj_base/tests/dataset_metadata_test.cpp new file mode 100644 index 00000000..081cb5b0 --- /dev/null +++ b/pj_base/tests/dataset_metadata_test.cpp @@ -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 + +#include +#include +#include + +#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 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(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{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{R"({"origin":"file"})"})); +} + +} // namespace diff --git a/pj_plugins/docs/data-source-guide.md b/pj_plugins/docs/data-source-guide.md index 0492a2e6..c11754cb 100644 --- a/pj_plugins/docs/data-source-guide.md +++ b/pj_plugins/docs/data-source-guide.md @@ -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 diff --git a/pj_plugins/tests/data_source_library_test.cpp b/pj_plugins/tests/data_source_library_test.cpp index 519ebf2b..9cbea54b 100644 --- a/pj_plugins/tests/data_source_library_test.cpp +++ b/pj_plugins/tests/data_source_library_test.cpp @@ -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, @@ -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(0x2), diff --git a/pj_plugins/tests/file_source_integration_test.cpp b/pj_plugins/tests/file_source_integration_test.cpp index acedc80b..a2667446 100644 --- a/pj_plugins/tests/file_source_integration_test.cpp +++ b/pj_plugins/tests/file_source_integration_test.cpp @@ -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}; }