|
@@ -18,7 +18,8 @@ WorkflowController::WorkflowController(storage::StorageClient& storage,
|
|
|
WorkflowScheduler& scheduler,
|
|
WorkflowScheduler& scheduler,
|
|
|
DatabaseWatcher& db_watcher,
|
|
DatabaseWatcher& db_watcher,
|
|
|
auth::AccessControl& access,
|
|
auth::AccessControl& access,
|
|
|
- nodes::NodeStore& node_store)
|
|
|
|
|
|
|
+ nodes::NodeStore& node_store,
|
|
|
|
|
+ retention::RetentionService& retention)
|
|
|
: storage_(storage)
|
|
: storage_(storage)
|
|
|
, middleware_(middleware)
|
|
, middleware_(middleware)
|
|
|
, registry_(registry)
|
|
, registry_(registry)
|
|
@@ -27,7 +28,8 @@ WorkflowController::WorkflowController(storage::StorageClient& storage,
|
|
|
, scheduler_(scheduler)
|
|
, scheduler_(scheduler)
|
|
|
, db_watcher_(db_watcher)
|
|
, db_watcher_(db_watcher)
|
|
|
, access_(access)
|
|
, access_(access)
|
|
|
- , node_store_(node_store) {}
|
|
|
|
|
|
|
+ , node_store_(node_store)
|
|
|
|
|
+ , retention_(retention) {}
|
|
|
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
namespace {
|
|
@@ -102,6 +104,16 @@ void WorkflowController::registerRoutes(httplib::Server& server) {
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // What a retention change would cost, before it is made. Registered ahead
|
|
|
|
|
+ // of nothing in particular - httplib matches in registration order, and the
|
|
|
|
|
+ // bare GET above is anchored, so this needs its own entry rather than being
|
|
|
|
|
+ // swallowed by it.
|
|
|
|
|
+ server.Get(R"(/api/v1/workflows/([^/]+)/retention)", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ previewRetention(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
server.Post(R"(/api/v1/workflows/([^/]+)/execute)", [this](const httplib::Request& req, httplib::Response& res) {
|
|
server.Post(R"(/api/v1/workflows/([^/]+)/execute)", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
executeWorkflow(req, res, ctx);
|
|
executeWorkflow(req, res, ctx);
|
|
@@ -512,6 +524,20 @@ void WorkflowController::updateWorkflow(const httplib::Request& req, httplib::Re
|
|
|
// Get updated workflow
|
|
// Get updated workflow
|
|
|
auto workflow = storage_.get("workflows", id);
|
|
auto workflow = storage_.get("workflows", id);
|
|
|
if (workflow.ok()) {
|
|
if (workflow.ok()) {
|
|
|
|
|
+ // A retention that only governed future writes would be a setting
|
|
|
|
|
+ // about nothing - the data somebody wants gone is the data already
|
|
|
|
|
+ // there - so a change is applied to what exists too. Only on an
|
|
|
|
|
+ // actual change: restamping every execution on every autosave would
|
|
|
|
|
+ // rewrite the whole history several times a minute.
|
|
|
|
|
+ const auto before_retention = retention_.effectiveFor(existing);
|
|
|
|
|
+ const auto after_retention = retention_.effectiveFor(workflow.value());
|
|
|
|
|
+ const auto ttlOf = [](const std::optional<storage::Retention>& r) {
|
|
|
|
|
+ return r ? r->ttl_seconds : -1;
|
|
|
|
|
+ };
|
|
|
|
|
+ if (ttlOf(before_retention) != ttlOf(after_retention) && after_retention) {
|
|
|
|
|
+ retention_.apply(id, after_retention->ttl_seconds);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// Reconcile the scheduler in both directions. Registering on active
|
|
// Reconcile the scheduler in both directions. Registering on active
|
|
|
// but never unregistering on inactive let the stored flag and the
|
|
// but never unregistering on inactive let the stored flag and the
|
|
|
// scheduler drift apart, leaving a deactivated workflow still firing.
|
|
// scheduler drift apart, leaving a deactivated workflow still firing.
|
|
@@ -546,6 +572,53 @@ void WorkflowController::updateWorkflow(const httplib::Request& req, httplib::Re
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+void WorkflowController::previewRetention(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1];
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json workflow;
|
|
|
|
|
+ if (!loadAllowed(req, res, ctx, id, auth::Action::Read, workflow)) return;
|
|
|
|
|
+
|
|
|
|
|
+ // No ttlSeconds asked about means "what is in force now, and what does it
|
|
|
|
|
+ // cover" - the same numbers, measured against the current setting.
|
|
|
|
|
+ int64_t ttl_seconds = 0;
|
|
|
|
|
+ if (req.has_param("ttlSeconds")) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ ttl_seconds = std::stoll(req.get_param_value("ttlSeconds"));
|
|
|
|
|
+ } catch (const std::exception&) {
|
|
|
|
|
+ sendError(res, "ttlSeconds must be a whole number of seconds", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ttl_seconds < 0) {
|
|
|
|
|
+ sendError(res, "ttlSeconds cannot be negative. 0 means keep for ever", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (const auto current = retention_.effectiveFor(workflow)) {
|
|
|
|
|
+ ttl_seconds = current->ttl_seconds;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const auto preview = retention_.preview(id, ttl_seconds);
|
|
|
|
|
+ const auto impact = [](const retention::RetentionService::Impact& i) {
|
|
|
|
|
+ return nlohmann::json{{"total", i.total},
|
|
|
|
|
+ {"expiringNow", i.expiring_now},
|
|
|
|
|
+ {"oldestAgeMs", i.oldest_age_ms}};
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const auto current = retention_.effectiveFor(workflow);
|
|
|
|
|
+ sendJson(res, {
|
|
|
|
|
+ {"ttlSeconds", ttl_seconds},
|
|
|
|
|
+ {"current", current ? nlohmann::json{{"ttlSeconds", current->ttl_seconds},
|
|
|
|
|
+ {"inherited", current->inherited}}
|
|
|
|
|
+ : nlohmann::json(nullptr)},
|
|
|
|
|
+ {"executions", impact(preview.executions)},
|
|
|
|
|
+ {"documents", impact(preview.documents)},
|
|
|
|
|
+ // Said rather than left to be discovered: a file's expiry can only be
|
|
|
|
|
+ // set when it is uploaded, so a retention change does not reach the
|
|
|
|
|
+ // files a workflow has already stored.
|
|
|
|
|
+ {"filesReachable", preview.files_reachable},
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
void WorkflowController::deleteWorkflow(const httplib::Request& req, httplib::Response& res,
|
|
void WorkflowController::deleteWorkflow(const httplib::Request& req, httplib::Response& res,
|
|
|
const auth::AuthContext& ctx) {
|
|
const auth::AuthContext& ctx) {
|
|
|
std::string id = req.matches[1];
|
|
std::string id = req.matches[1];
|
|
@@ -564,10 +637,18 @@ void WorkflowController::deleteWorkflow(const httplib::Request& req, httplib::Re
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Its executions and its own storage outlive it otherwise: nothing pointed
|
|
|
|
|
+ // at them, and nothing deleted them. They are given a short expiry and clear
|
|
|
|
|
+ // themselves rather than being walked through here - a delete that has to
|
|
|
|
|
+ // remove ten thousand documents before the button responds is a delete
|
|
|
|
|
+ // people learn not to press.
|
|
|
|
|
+ retention_.retire(id);
|
|
|
|
|
+
|
|
|
ws_server_.broadcast("workflows.deleted", {{"id", id}});
|
|
ws_server_.broadcast("workflows.deleted", {{"id", id}});
|
|
|
|
|
|
|
|
LOG_INFO("Workflow deleted: {} by user {}", id, ctx.user_id);
|
|
LOG_INFO("Workflow deleted: {} by user {}", id, ctx.user_id);
|
|
|
- sendJson(res, {{"success", true}});
|
|
|
|
|
|
|
+ sendJson(res, {{"success", true},
|
|
|
|
|
+ {"dataRemovedIn", retention::RetentionService::kRetireTtlSeconds}});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
void WorkflowController::executeWorkflow(const httplib::Request& req, httplib::Response& res,
|
|
void WorkflowController::executeWorkflow(const httplib::Request& req, httplib::Response& res,
|