clboss/Boss/Mod/EarningsTracker.cpp
Ken Sedgwick cbc661457b
Some checks failed
Code Base Sanity Check / tests (push) Has been cancelled
Code Base Sanity Check / coverage (push) Has been cancelled
Code Base Sanity Check / build-clang (push) Has been cancelled
EarningsTracker: attribute rebalances from the move response, not a requester-keyed map
Completed rebalances were correlated back to their (source,
destination) through an in-memory map keyed only by the
RequestMoveFunds requester pointer. When one rebalancer issues several
moves at once they share a single requester (EarningsRebalancer uses
its module pointer; JitRebalancer uses nullptr), so each request
overwrote the previous map entry. As the moves completed, the first
response consumed and erased the shared entry -- booking against
whatever pair happened to be written last -- and every later response,
including the actually-successful one, hit the not-in-our-table path
and was silently dropped.

Effect: rebalance spend and volume were under-counted and sometimes
mis-attributed to the wrong peer. That also starved the
EarningsRebalancer refusing-to-throw-good-money-after-bad guard of
accurate expenditure data. JIT-dominated periods looked fine only
because those moves complete one at a time and rarely overlap; an
EarningsRebalancer-dominated period booked almost nothing.

Fix: ResponseMoveFunds now carries source and destination
directly. FundsMover/Runner populates them and EarningsTracker books
the move straight from the response. The requester-keyed pendings map,
the RequestMoveFunds subscription, and request_move_funds are
removed. This mirrors the existing XRebalanceAttribution path, which
already passes source/destination in its message.

Tests: ResponseMoveFunds constructions updated across the rebalancer
and earnings tests; test_earningstracker now issues two moves under
the same requester and asserts both are booked and correctly
attributed -- a regression guard for the collision. Full suite 85/85.
2026-06-19 09:21:07 -07:00

752 lines
22 KiB
C++

#include"Boss/Mod/EarningsTracker.hpp"
#include"Boss/Msg/CommandFail.hpp"
#include"Boss/Msg/CommandRequest.hpp"
#include"Boss/Msg/CommandResponse.hpp"
#include"Boss/Msg/DbResource.hpp"
#include"Boss/Msg/ForwardFee.hpp"
#include"Boss/Msg/ManifestCommand.hpp"
#include"Boss/Msg/Manifestation.hpp"
#include"Boss/Msg/ProvideStatus.hpp"
#include"Boss/Msg/RequestEarningsInfo.hpp"
#include"Boss/Msg/ResponseEarningsInfo.hpp"
#include"Boss/Msg/ResponseMoveFunds.hpp"
#include"Boss/Msg/SolicitStatus.hpp"
#include"Boss/Msg/XRebalanceAttribution.hpp"
#include"Boss/concurrent.hpp"
#include"Ev/Io.hpp"
#include"Json/Out.hpp"
#include"S/Bus.hpp"
#include"Sqlite3.hpp"
#include"Util/make_unique.hpp"
#include<cmath>
#include<iostream>
#include<unordered_set>
namespace {
struct EarningsData {
uint64_t in_earnings = 0;
uint64_t in_forwarded = 0;
uint64_t in_expenditures = 0;
uint64_t in_rebalanced = 0;
uint64_t out_earnings = 0;
uint64_t out_forwarded = 0;
uint64_t out_expenditures = 0;
uint64_t out_rebalanced = 0;
// Unmarshal from database fetch
static EarningsData from_row(Sqlite3::Row& r, size_t& ndx) {
return {
r.get<uint64_t>(ndx++),
r.get<uint64_t>(ndx++),
r.get<uint64_t>(ndx++),
r.get<uint64_t>(ndx++),
r.get<uint64_t>(ndx++),
r.get<uint64_t>(ndx++),
r.get<uint64_t>(ndx++),
r.get<uint64_t>(ndx++)
};
}
// Operator+= for accumulation
EarningsData& operator+=(const EarningsData& other) {
in_earnings += other.in_earnings;
in_forwarded += other.in_forwarded;
in_expenditures += other.in_expenditures;
in_rebalanced += other.in_rebalanced;
out_earnings += other.out_earnings;
out_forwarded += other.out_forwarded;
out_expenditures += other.out_expenditures;
out_rebalanced += other.out_rebalanced;
return *this;
}
template <typename T>
void to_json(Json::Detail::Object<T>& obj) const {
obj
.field("in_earnings", in_earnings)
.field("in_forwarded", in_forwarded)
.field("in_expenditures", in_expenditures)
.field("in_rebalanced", in_rebalanced)
.field("out_earnings", out_earnings)
.field("out_forwarded", out_forwarded)
.field("out_expenditures", out_expenditures)
.field("out_rebalanced", out_rebalanced);
}
};
}
namespace Boss { namespace Mod {
class EarningsTracker::Impl {
private:
S::Bus& bus;
std::function<double()> get_now;
Sqlite3::Db db;
void start() {
bus.subscribe<Msg::DbResource
>([this](Msg::DbResource const& r) {
db = r.db;
return init();
});
bus.subscribe<Msg::ForwardFee
>([this](Msg::ForwardFee const& f) {
return forward_fee(f.in_id, f.out_id, f.fee, f.amount);
});
bus.subscribe<Msg::ResponseMoveFunds
>([this](Msg::ResponseMoveFunds const& rsp) {
return response_move_funds(rsp);
});
bus.subscribe<Msg::XRebalanceAttribution
>([this](Msg::XRebalanceAttribution const& m) {
return xrebalance_attribution(m);
});
bus.subscribe<Msg::RequestEarningsInfo
>([this](Msg::RequestEarningsInfo const& req) {
return Boss::concurrent(request_earnings_info(req));
});
bus.subscribe<Msg::Manifestation
>([this](Msg::Manifestation const& _) {
return bus.raise(Msg::ManifestCommand{
"clboss-recent-earnings", "[days]",
"Show offchain_earnings_tracker data for the most recent {days} "
"(default 14 days).",
false
}) + bus.raise(Msg::ManifestCommand{
"clboss-earnings-history", "[nodeid]",
"Show earnings and expenditure history for {nodeid} "
"(default all nodes). Use nodeid=all to list per-node buckets.",
false
});
});
bus.subscribe<Msg::CommandRequest>([this](Msg::CommandRequest const& req) {
auto id = req.id;
auto paramfail = [this, id]() {
return bus.raise(Msg::CommandFail{
id, -32602,
"Parameter failure",
Json::Out::empty_object()
});
};
if (req.command == "clboss-recent-earnings") {
auto days = double(14.0);
auto days_j = Jsmn::Object();
auto params = req.params;
if (params.is_object()) {
if (params.size() > 1)
return paramfail();
if (params.size() == 1) {
if (!params.has("days"))
return paramfail();
days_j = params["days"];
}
} else if (params.is_array()) {
if (params.size() > 1)
return paramfail();
if (params.size() == 1)
days_j = params[0];
}
if (!days_j.is_null()) {
if (!days_j.is_number())
return paramfail();
days = (double) days_j;
}
return db.transact().then([this, id, days](Sqlite3::Tx tx) {
auto report = recent_earnings_report(tx, days);
tx.commit();
return bus.raise(Msg::CommandResponse{
id, report
});
});
} else if (req.command == "clboss-earnings-history") {
auto nodeid = std::string("");
auto nodeid_j = Jsmn::Object();
auto by_node = false;
auto params = req.params;
if (params.is_object()) {
if (params.size() > 1)
return paramfail();
if (params.size() == 1) {
if (!params.has("nodeid"))
return paramfail();
nodeid_j = params["nodeid"];
}
} else if (params.is_array()) {
if (params.size() > 1)
return paramfail();
if (params.size() == 1)
nodeid_j = params[0];
}
if (!nodeid_j.is_null()) {
if (!nodeid_j.is_string())
return paramfail();
nodeid = (std::string) nodeid_j;
}
if (nodeid == "all") {
by_node = true;
nodeid.clear();
}
return db.transact().then([this, id, nodeid, by_node](Sqlite3::Tx tx) {
auto report = earnings_history_report(tx, nodeid, by_node);
tx.commit();
return bus.raise(Msg::CommandResponse{
id, report
});
});
}
return Ev::lift();
});
bus.subscribe<Msg::SolicitStatus
>([this](Msg::SolicitStatus const& _) {
if (!db)
return Ev::lift();
return status();
});
}
Ev::Io<void> init() {
return db.transact().then([](Sqlite3::Tx tx) {
// NOTE - we can't just alter table here because we
// are changing the primary key.
// If we already have a bucket schema we're done
if (have_bucket_table(tx)) {
add_missing_columns(tx);
tx.commit();
return Ev::lift();
}
// Create the bucket table w/ a temp name
tx.query_execute(R"QRY(
CREATE TABLE IF NOT EXISTS EarningsTracker_New
( node TEXT NOT NULL
, time_bucket REAL NOT NULL
, in_earnings INTEGER NOT NULL
, in_forwarded INTEGER NOT NULL
, in_expenditures INTEGER NOT NULL
, in_rebalanced INTEGER NOT NULL
, out_earnings INTEGER NOT NULL
, out_forwarded INTEGER NOT NULL
, out_expenditures INTEGER NOT NULL
, out_rebalanced INTEGER NOT NULL
, PRIMARY KEY (node, time_bucket)
);
)QRY");
// If we have a legacy table migrate the data and lose the old table
if (have_legacy_table(tx)) {
tx.query_execute(R"QRY(
INSERT INTO EarningsTracker_New
( node, time_bucket, in_earnings, in_expenditures
, out_earnings, out_expenditures, in_forwarded
, in_rebalanced, out_forwarded, out_rebalanced)
SELECT node, 0, in_earnings, in_expenditures
, out_earnings, out_expenditures, 0, 0, 0, 0
FROM EarningsTracker;
DROP TABLE EarningsTracker;
)QRY");
}
// Move the new table to the official name and add the indexes
tx.query_execute(R"QRY(
ALTER TABLE EarningsTracker_New RENAME TO EarningsTracker;
CREATE INDEX IF NOT EXISTS
idx_earnings_tracker_node_time ON EarningsTracker (node, time_bucket);
CREATE INDEX IF NOT EXISTS
idx_earnings_tracker_time_node ON EarningsTracker (time_bucket, node);
)QRY");
tx.commit();
return Ev::lift();
});
// These statements revert the schema to before time buckets:
/*
CREATE TABLE IF NOT EXISTS EarningsTracker_Old
( node TEXT PRIMARY KEY
, in_earnings INTEGER NOT NULL
, in_expenditures INTEGER NOT NULL
, out_earnings INTEGER NOT NULL
, out_expenditures INTEGER NOT NULL
);
INSERT INTO EarningsTracker_Old
( node, in_earnings, in_expenditures
, out_earnings, out_expenditures)
SELECT node
, SUM(in_earnings), SUM(in_expenditures)
, SUM(out_earnings), SUM(out_expenditures)
FROM EarningsTracker
GROUP BY node;
DROP TABLE EarningsTracker;
ALTER TABLE EarningsTracker_Old RENAME TO EarningsTracker;
*/
}
static bool have_bucket_table(Sqlite3::Tx& tx) {
auto fetch = tx.query(R"QRY(
SELECT 1
FROM pragma_table_info('EarningsTracker')
WHERE name='time_bucket';
)QRY").execute();
return fetch.begin() != fetch.end();
}
static bool have_legacy_table(Sqlite3::Tx& tx) {
// Do we have a legacy table defined?
auto fetch = tx.query(R"QRY(
SELECT 1
FROM pragma_table_info('EarningsTracker')
WHERE name='node';
)QRY").execute();
return fetch.begin() != fetch.end();
}
static void add_missing_columns(Sqlite3::Tx& tx) {
std::unordered_set<std::string> existing_columns;
auto columns_query = tx.query("PRAGMA table_info(EarningsTracker);").execute();
for (auto& row : columns_query) {
existing_columns.insert(row.get<std::string>(1));
}
// These columns are new.
std::vector<std::pair<std::string, std::string>> columns_to_check = {
{"in_forwarded", "INTEGER NOT NULL DEFAULT 0"},
{"in_rebalanced", "INTEGER NOT NULL DEFAULT 0"},
{"out_forwarded", "INTEGER NOT NULL DEFAULT 0"},
{"out_rebalanced", "INTEGER NOT NULL DEFAULT 0"},
};
// Add missing columns
for (const auto& [col_name, col_def] : columns_to_check) {
if (existing_columns.find(col_name) == existing_columns.end()) {
std::string add_column_query =
"ALTER TABLE EarningsTracker ADD COLUMN "
+ col_name + " " + col_def + ";";
tx.query_execute(add_column_query);
}
}
}
/* Ensure the given node has an entry. */
void ensure(Sqlite3::Tx& tx, Ln::NodeId const& node, double bucket) {
tx.query(R"QRY(
INSERT OR IGNORE
INTO "EarningsTracker"
VALUES(:node, :bucket,
0, 0, 0, 0,
0, 0, 0, 0);
)QRY")
.bind(":node", std::string(node))
.bind(":bucket", bucket)
.execute()
;
}
Ev::Io<void> forward_fee( Ln::NodeId const& in
, Ln::NodeId const& out
, Ln::Amount fee
, Ln::Amount amount
) {
return db.transact().then([this, in, out, fee, amount
](Sqlite3::Tx tx) {
auto bucket = bucket_time(get_now());
ensure(tx, in, bucket);
ensure(tx, out, bucket);
tx.query(R"QRY(
UPDATE "EarningsTracker"
SET in_earnings = in_earnings + :fee,
in_forwarded = in_forwarded + :amount
WHERE node = :node
AND time_bucket = :bucket
;
)QRY")
.bind(":fee", fee.to_msat())
.bind(":amount", amount.to_msat())
.bind(":node", std::string(in))
.bind(":bucket", bucket)
.execute()
;
tx.query(R"QRY(
UPDATE "EarningsTracker"
SET out_earnings = out_earnings + :fee,
out_forwarded = out_forwarded + :amount
WHERE node = :node
AND time_bucket = :bucket
;
)QRY")
.bind(":fee", fee.to_msat())
.bind(":amount", amount.to_msat())
.bind(":node", std::string(out))
.bind(":bucket", bucket)
.execute()
;
tx.commit();
return Ev::lift();
});
}
Ev::Io<void>
response_move_funds(Boss::Msg::ResponseMoveFunds const& rsp) {
/* The response carries its own source/destination, so we
* attribute the move directly rather than correlating back
* to the request by `requester`. A `requester`-keyed map
* collides when one rebalancer issues several moves at once
* (they share the requester pointer): the first response to
* arrive consumed the shared entry and every later
* response -- including the actually-successful one -- was
* silently dropped. */
auto source = rsp.source;
auto destination = rsp.destination;
auto fee = rsp.fee_spent;
auto amount = rsp.amount_moved;
return db.transact().then([ this, source, destination
, fee, amount
](Sqlite3::Tx tx) {
auto bucket = bucket_time(get_now());
ensure(tx, source, bucket);
ensure(tx, destination, bucket);
/* Source gets in-expenditures since it gets more
* incoming capacity (for more earnings for the
* incoming direction). */
tx.query(R"QRY(
UPDATE "EarningsTracker"
SET in_expenditures = in_expenditures + :fee,
in_rebalanced = in_rebalanced + :amount
WHERE node = :node
AND time_bucket = :bucket
;
)QRY")
.bind(":node", std::string(source))
.bind(":bucket", bucket)
.bind(":fee", fee.to_msat())
.bind(":amount", amount.to_msat())
.execute()
;
/* Destination gets out-expenditures for same
* reason.
*/
tx.query(R"QRY(
UPDATE "EarningsTracker"
SET out_expenditures = out_expenditures + :fee,
out_rebalanced = out_rebalanced + :amount
WHERE node = :node
AND time_bucket = :bucket
;
)QRY")
.bind( ":node"
, std::string(destination)
)
.bind(":bucket", bucket)
.bind(":fee", fee.to_msat())
.bind(":amount", amount.to_msat())
.execute()
;
tx.commit();
return Ev::lift();
});
}
/* XMoveFunds-side analog of response_move_funds: the
* caller (Boss::Mod::XMoveFunds) already identified the
* (source_peer, dest_peer) for one successful sendpay part
* and packaged the per-part amount_moved / fee_spent, so we
* skip the pendings table entirely and run the same
* symmetric DB update. Same attribution rule as the
* FundsMover path: source peer gets in_expenditures /
* in_rebalanced (more inbound capacity), destination peer
* gets out_expenditures / out_rebalanced (more outbound).
*
* One bus message per successful part (an MPP-split
* clboss-xmovefunds invocation can produce several), so
* fees are recorded per-part rather than aggregated at the
* top-level call -- finer-grained, matches the per-part
* routing reality. */
Ev::Io<void>
xrebalance_attribution(Boss::Msg::XRebalanceAttribution const& m) {
auto source = m.source;
auto destination = m.destination;
auto fee = m.fee_spent;
auto amount = m.amount_moved;
return db.transact().then(
[this, source, destination, fee, amount]
(Sqlite3::Tx tx) {
auto bucket = bucket_time(get_now());
ensure(tx, source, bucket);
ensure(tx, destination, bucket);
tx.query(R"QRY(
UPDATE "EarningsTracker"
SET in_expenditures = in_expenditures + :fee,
in_rebalanced = in_rebalanced + :amount
WHERE node = :node
AND time_bucket = :bucket
;
)QRY")
.bind(":node", std::string(source))
.bind(":bucket", bucket)
.bind(":fee", fee.to_msat())
.bind(":amount", amount.to_msat())
.execute()
;
tx.query(R"QRY(
UPDATE "EarningsTracker"
SET out_expenditures = out_expenditures + :fee,
out_rebalanced = out_rebalanced + :amount
WHERE node = :node
AND time_bucket = :bucket
;
)QRY")
.bind(":node", std::string(destination))
.bind(":bucket", bucket)
.bind(":fee", fee.to_msat())
.bind(":amount", amount.to_msat())
.execute()
;
tx.commit();
return Ev::lift();
});
}
Ev::Io<void>
request_earnings_info(Msg::RequestEarningsInfo const& req) {
auto requester = req.requester;
auto node = req.node;
return db.transact().then([this, requester, node
](Sqlite3::Tx tx) {
auto fetch = tx.query(R"QRY(
SELECT SUM(in_earnings),
SUM(in_forwarded),
SUM(in_expenditures),
SUM(in_rebalanced),
SUM(out_earnings),
SUM(out_forwarded),
SUM(out_expenditures),
SUM(out_rebalanced)
FROM "EarningsTracker"
WHERE node = :node;
)QRY")
.bind(":node", std::string(node))
.execute()
;
EarningsData earnings;
if (fetch.begin() != fetch.end()) {
size_t ndx = 0;
earnings = EarningsData::from_row(*fetch.begin(), ndx);
}
tx.commit();
return bus.raise(Msg::ResponseEarningsInfo{
requester, node,
Ln::Amount::msat(earnings.in_earnings),
Ln::Amount::msat(earnings.in_expenditures),
Ln::Amount::msat(earnings.out_earnings),
Ln::Amount::msat(earnings.out_expenditures),
Ln::Amount::msat(earnings.in_forwarded),
Ln::Amount::msat(earnings.in_rebalanced),
Ln::Amount::msat(earnings.out_forwarded),
Ln::Amount::msat(earnings.out_rebalanced),
});
});
}
Ev::Io<void> status() {
return db.transact().then([this](Sqlite3::Tx tx) {
auto fetch = tx.query(R"QRY(
SELECT node,
SUM(in_earnings) AS total_in_earnings,
SUM(in_forwarded) AS total_in_forwarded,
SUM(in_expenditures) AS total_in_expenditures,
SUM(in_rebalanced) AS total_in_rebalanced,
SUM(out_earnings) AS total_out_earnings,
SUM(out_forwarded) AS total_out_forwarded,
SUM(out_expenditures) AS total_out_expenditures,
SUM(out_rebalanced) AS total_out_rebalanced
FROM "EarningsTracker"
GROUP BY node;
)QRY").execute();
EarningsData total_earnings;
auto out = Json::Out();
auto obj = out.start_object();
for (auto& r : fetch) {
size_t ndx = 0;
auto node = r.get<std::string>(ndx++);
auto earnings = EarningsData::from_row(r, ndx);
auto sub = obj.start_object(node);
earnings.to_json(sub);
sub.end_object();
total_earnings += earnings;
}
auto total = obj.start_object("total");
total_earnings.to_json(total);
total.end_object();
obj.end_object();
tx.commit();
return bus.raise(Msg::ProvideStatus{
"offchain_earnings_tracker",
std::move(out)
});
});
}
Json::Out recent_earnings_report(Sqlite3::Tx& tx, double days) {
auto cutoff = bucket_time(get_now()) - (days * 24 * 60 * 60);
auto fetch = tx.query(R"QRY(
SELECT node,
SUM(in_earnings) AS total_in_earnings,
SUM(in_forwarded) AS total_in_forwarded,
SUM(in_expenditures) AS total_in_expenditures,
SUM(in_rebalanced) AS total_in_rebalanced,
SUM(out_earnings) AS total_out_earnings,
SUM(out_forwarded) AS total_out_forwarded,
SUM(out_expenditures) AS total_out_expenditures,
SUM(out_rebalanced) AS total_out_rebalanced
FROM "EarningsTracker"
WHERE time_bucket >= :cutoff
GROUP BY node
ORDER BY (total_in_earnings - total_in_expenditures +
total_out_earnings - total_out_expenditures) DESC;
)QRY")
.bind(":cutoff", cutoff)
.execute()
;
EarningsData total_earnings;
auto out = Json::Out();
auto top = out.start_object();
auto recent = top.start_object("recent");
for (auto& r : fetch) {
size_t ndx = 0;
auto node = r.get<std::string>(ndx++);
auto earnings = EarningsData::from_row(r, ndx);
auto sub = recent.start_object(node);
earnings.to_json(sub);
sub.end_object();
total_earnings += earnings;
}
recent.end_object();
auto total = top.start_object("total");
total_earnings.to_json(total);
total.end_object();
top.end_object();
return out;
}
Json::Out earnings_history_report(Sqlite3::Tx& tx, std::string nodeid, bool by_node) {
std::string sql;
if (by_node) {
sql = R"QRY(
SELECT time_bucket,
node,
in_earnings,
in_forwarded,
in_expenditures,
in_rebalanced,
out_earnings,
out_forwarded,
out_expenditures,
out_rebalanced
FROM "EarningsTracker"
ORDER BY time_bucket, node;
)QRY";
} else if (nodeid.empty()) {
sql = R"QRY(
SELECT time_bucket,
SUM(in_earnings) AS total_in_earnings,
SUM(in_forwarded) AS total_in_forwarded,
SUM(in_expenditures) AS total_in_expenditures,
SUM(in_rebalanced) AS total_in_rebalanced,
SUM(out_earnings) AS total_out_earnings,
SUM(out_forwarded) AS total_out_forwarded,
SUM(out_expenditures) AS total_out_expenditures,
SUM(out_rebalanced) AS total_out_rebalanced
FROM "EarningsTracker"
GROUP BY time_bucket
ORDER BY time_bucket;
)QRY";
} else {
sql = R"QRY(
SELECT time_bucket,
in_earnings,
in_forwarded,
in_expenditures,
in_rebalanced,
out_earnings,
out_forwarded,
out_expenditures,
out_rebalanced
FROM "EarningsTracker"
WHERE node = :nodeid
ORDER BY time_bucket;
)QRY";
}
auto query = tx.query(sql.c_str());
if (!nodeid.empty()) {
query.bind(":nodeid", nodeid);
}
auto fetch = query.execute();
auto out = Json::Out();
auto top = out.start_object();
EarningsData total_earnings;
auto history = top.start_array("history");
for (auto& r : fetch) {
size_t ndx = 0;
auto bucket_time = r.get<double>(ndx++);
std::string row_node;
if (by_node) {
row_node = r.get<std::string>(ndx++);
}
auto earnings = EarningsData::from_row(r, ndx);
auto sub = history.start_object();
sub.field("bucket_time", static_cast<std::uint64_t>(bucket_time));
if (by_node)
sub.field("node", row_node);
earnings.to_json(sub);
sub.end_object();
total_earnings += earnings;
}
history.end_array();
auto total = top.start_object("total");
total_earnings.to_json(total);
total.end_object();
top.end_object();
return out;
}
public:
Impl() =delete;
Impl(Impl&&) =delete;
Impl(Impl const&) =delete;
explicit
Impl(S::Bus& bus_, std::function<double()> get_now_) : bus(bus_), get_now(std::move(get_now_)) { start(); }
};
EarningsTracker::EarningsTracker(EarningsTracker&&) =default;
EarningsTracker::~EarningsTracker() =default;
EarningsTracker::EarningsTracker(S::Bus& bus, std::function<double()> get_now_ )
: pimpl(Util::make_unique<Impl>(bus, get_now_)) { }
double EarningsTracker::bucket_time(double input_time) {
constexpr double seconds_per_day = 24 * 60 * 60;
double bucket_start = std::floor(input_time / seconds_per_day) * seconds_per_day;
return bucket_start;
}
}}