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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add lifecycle `type` and RFC3339 `last_run` support to `LifecycleInfo`, [PR-131](https://github.com/reductstore/reduct-cpp/pull/131)
- Add lifecycle `compress` action support and rename lifecycle age field from `max_age` to `older_than` to match the ReductStore API, [PR-130](https://github.com/reductstore/reduct-cpp/pull/130)
- Add lifecycle policy API support, [PR-128](https://github.com/reductstore/reduct-cpp/pull/128)

Expand Down
10 changes: 6 additions & 4 deletions src/reduct/client.h
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,12 @@ class IClient {
enum class LifecycleMode { kEnabled, kDisabled, kDryRun };

struct LifecycleInfo {
std::string name; // Lifecycle name
LifecycleMode mode = LifecycleMode::kEnabled; // Lifecycle mode
bool is_provisioned; // Lifecycle is provisioned
bool is_running; // Lifecycle worker is running
std::string name; // Lifecycle name
LifecycleType type = LifecycleType::kDelete; // Lifecycle action type
LifecycleMode mode = LifecycleMode::kEnabled; // Lifecycle mode
bool is_provisioned; // Lifecycle is provisioned
bool is_running; // Lifecycle worker is running
std::optional<Time> last_run = std::nullopt; // Last lifecycle run timestamp (UTC)

auto operator<=>(const LifecycleInfo&) const = default;
};
Expand Down
11 changes: 11 additions & 0 deletions src/reduct/internal/serialisation.cc
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,15 @@ Result<std::vector<IClient::LifecycleInfo>> ParseLifecycleList(const nlohmann::j
for (const auto& lifecycle : json_lifecycles) {
lifecycle_list.push_back(IClient::LifecycleInfo{
.name = lifecycle.at("name"),
.type = lifecycle.contains("type") ? ParseLifecycleType(lifecycle.at("type"))
: IClient::LifecycleType::kDelete,
.mode = lifecycle.contains("mode") ? ParseLifecycleMode(lifecycle.at("mode"))
: IClient::LifecycleMode::kEnabled,
.is_provisioned = lifecycle.at("is_provisioned"),
.is_running = lifecycle.at("is_running"),
.last_run = lifecycle.contains("last_run") && !lifecycle.at("last_run").is_null()
? std::optional<IClient::Time>{parse_iso8601_utc(lifecycle.at("last_run").get<std::string>())}
: std::nullopt,
});
}
} catch (const std::exception& ex) {
Expand Down Expand Up @@ -357,10 +362,16 @@ Result<IClient::FullLifecycleInfo> ParseFullLifecycleInfo(const nlohmann::json&
try {
info.info = IClient::LifecycleInfo{
.name = data.at("info").at("name"),
.type = data.at("info").contains("type") ? ParseLifecycleType(data.at("info").at("type"))
: IClient::LifecycleType::kDelete,
.mode = data.at("info").contains("mode") ? ParseLifecycleMode(data.at("info").at("mode"))
: IClient::LifecycleMode::kEnabled,
.is_provisioned = data.at("info").at("is_provisioned"),
.is_running = data.at("info").at("is_running"),
.last_run = data.at("info").contains("last_run") && !data.at("info").at("last_run").is_null()
? std::optional<IClient::Time>{
parse_iso8601_utc(data.at("info").at("last_run").get<std::string>())}
: std::nullopt,
};

const auto& settings = data.at("settings");
Expand Down
44 changes: 32 additions & 12 deletions src/reduct/internal/time_parse.h
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// Copyright 2025 ReductSoftware UG

#ifndef REDUCTCPP_TIME_PARSE_H
#define REDUCTCPP_TIME_PARSE_H

#include <chrono>
#include <ctime>
#include <iomanip>
#include <sstream>
Expand All @@ -10,24 +12,23 @@

namespace reduct {

// Parse ISO8601 like "2024-10-11T13:45:30Z" or "2024-10-11T13:45:30"
// Parse ISO8601 like "2024-10-11T13:45:30Z" or "2024-10-11T13:45:30.123456Z"
inline std::chrono::system_clock::time_point parse_iso8601_utc(const std::string& iso_str) {
// Must end with 'Z' or 'z'
if (iso_str.empty() || (iso_str.back() != 'Z' && iso_str.back() != 'z')) {
throw std::runtime_error("Invalid timestamp (missing 'Z'): " + iso_str);
}

// Remove trailing 'Z' and fractional part (if present)
std::string clean = iso_str.substr(0, iso_str.size() - 1);
std::size_t dot_pos = clean.find('.');
if (dot_pos != std::string::npos) {
clean = clean.substr(0, dot_pos); // strip everything after '.'
}
const std::string clean = iso_str.substr(0, iso_str.size() - 1);
const std::size_t dot_pos = clean.find('.');
const std::string time_part = clean.substr(0, dot_pos);

std::tm tm = {};
std::istringstream ss(clean);

std::istringstream ss(time_part);
ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
if (ss.fail()) {
throw std::runtime_error("Invalid timestamp format: " + iso_str);
}

#if defined(_WIN32)
std::time_t time = _mkgmtime(&tm); // Windows
Expand All @@ -39,11 +40,30 @@ inline std::chrono::system_clock::time_point parse_iso8601_utc(const std::string
throw std::runtime_error("Invalid time value: " + iso_str);
}

return std::chrono::system_clock::from_time_t(time);
auto parsed_time = std::chrono::system_clock::from_time_t(time);

if (dot_pos == std::string::npos) {
return parsed_time;
}

auto fraction = clean.substr(dot_pos + 1);
if (fraction.empty()) {
throw std::runtime_error("Invalid fractional timestamp: " + iso_str);
}

if (fraction.size() > 6) {
fraction.resize(6);
} else {
fraction.append(6 - fraction.size(), '0');
}

if (fraction.find_first_not_of("0123456789") != std::string::npos) {
throw std::runtime_error("Invalid fractional timestamp: " + iso_str);
}

return parsed_time + std::chrono::microseconds(std::stoll(fraction));
}

} // namespace reduct

#define REDUCTCPP_TIME_PARSE_H

#endif // REDUCTCPP_TIME_PARSE_H
85 changes: 77 additions & 8 deletions tests/reduct/lifecycle_api_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

#include <catch2/catch.hpp>

#include <algorithm>
#include <chrono>

#include <nlohmann/json.hpp>

#include "fixture.h"
#include "reduct/client.h"
#include "reduct/internal/serialisation.h"

using reduct::Error;
using reduct::IClient;
Expand All @@ -30,6 +36,20 @@ TEST_CASE("reduct::Client should get list of lifecycles", "[lifecycle_api][1_20]
for (const auto& lifecycle : lifecycles) {
REQUIRE_FALSE(lifecycle.name.starts_with("test_lifecycle"));
}

auto settings = DefaultSettings();
settings.type = IClient::LifecycleType::kCompress;
REQUIRE(ctx.client->CreateLifecycle("test_lifecycle", settings) == Error::kOk);

auto [updated_lifecycles, err_2] = ctx.client->GetLifecycleList();
REQUIRE(err_2 == Error::kOk);

auto it = std::find_if(updated_lifecycles.begin(), updated_lifecycles.end(), [](const auto& lifecycle) {
return lifecycle.name == "test_lifecycle";
});
REQUIRE(it != updated_lifecycles.end());
REQUIRE(it->type == IClient::LifecycleType::kCompress);
REQUIRE_FALSE(it->last_run.has_value());
}

TEST_CASE("reduct::Client should create a lifecycle", "[lifecycle_api][1_20]") {
Expand All @@ -42,13 +62,12 @@ TEST_CASE("reduct::Client should create a lifecycle", "[lifecycle_api][1_20]") {

auto [lifecycle, err_2] = ctx.client->GetLifecycle("test_lifecycle");
REQUIRE(err_2 == Error::kOk);
REQUIRE(lifecycle.info == IClient::LifecycleInfo{
.name = "test_lifecycle",
.mode = IClient::LifecycleMode::kEnabled,
.is_provisioned = false,
.is_running = true,
});

REQUIRE(lifecycle.info.name == "test_lifecycle");
REQUIRE(lifecycle.info.type == IClient::LifecycleType::kCompress);
REQUIRE(lifecycle.info.mode == IClient::LifecycleMode::kEnabled);
REQUIRE_FALSE(lifecycle.info.is_provisioned);
REQUIRE(lifecycle.info.is_running);
REQUIRE_FALSE(lifecycle.info.last_run.has_value());
REQUIRE(lifecycle.settings == settings);

SECTION("Conflict") {
Expand Down Expand Up @@ -128,4 +147,54 @@ TEST_CASE("reduct::Client should set lifecycle when condition", "[lifecycle_api]
auto [lifecycle, err_2] = ctx.client->GetLifecycle("test_lifecycle");
REQUIRE(err_2 == Error::kOk);
REQUIRE(lifecycle.settings.when == settings.when);
}
}

TEST_CASE("reduct::Client should parse lifecycle type and RFC3339 last_run", "[lifecycle_api][unit]") {
auto lifecycle_list_json = nlohmann::json::parse(R"({
"lifecycles": [
{
"name": "test_lifecycle",
"type": "compress",
"mode": "enabled",
"is_provisioned": false,
"is_running": true,
"last_run": "2026-06-15T06:44:40.123456Z"
}
]
})");

auto [lifecycles, list_err] = reduct::internal::ParseLifecycleList(lifecycle_list_json);
REQUIRE(list_err == Error::kOk);
REQUIRE(lifecycles.size() == 1);
REQUIRE(lifecycles[0].type == IClient::LifecycleType::kCompress);
REQUIRE(lifecycles[0].last_run.has_value());
REQUIRE(std::chrono::duration_cast<std::chrono::microseconds>(lifecycles[0].last_run->time_since_epoch()).count() %
1000000 == 123456);

auto full_lifecycle_json = nlohmann::json::parse(R"({
"info": {
"name": "test_lifecycle",
"type": "compress",
"mode": "enabled",
"is_provisioned": false,
"is_running": true,
"last_run": "2026-06-15T06:44:40.654321Z"
},
"settings": {
"type": "compress",
"bucket": "test_bucket_1",
"entries": ["entry-1"],
"older_than": "1h",
"mode": "enabled"
}
})");

auto [full_lifecycle, full_err] = reduct::internal::ParseFullLifecycleInfo(full_lifecycle_json);
REQUIRE(full_err == Error::kOk);
REQUIRE(full_lifecycle.info.type == IClient::LifecycleType::kCompress);
REQUIRE(full_lifecycle.info.last_run.has_value());
REQUIRE(std::chrono::duration_cast<std::chrono::microseconds>(
full_lifecycle.info.last_run->time_since_epoch())
.count() %
1000000 == 654321);
}
Loading