From e3be7bab582e83ae248d3eb3f1b3cb791956c8e0 Mon Sep 17 00:00:00 2001 From: Justin Hiemstra Date: Thu, 11 Dec 2025 18:47:11 +0000 Subject: [PATCH] Add API for sweep line over MPAs --- src/lotman.cpp | 37 +++ src/lotman.h | 66 ++++++ src/lotman_internal.cpp | 147 ++++++++++++ src/lotman_internal.h | 38 +++ test/main.cpp | 513 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 797 insertions(+), 4 deletions(-) diff --git a/src/lotman.cpp b/src/lotman.cpp index 4d5e358..9a09d25 100644 --- a/src/lotman.cpp +++ b/src/lotman.cpp @@ -1718,3 +1718,40 @@ int lotman_get_context_int(const char *key, int *output, char **err_msg) { return -1; } } + +int lotman_get_max_mpas_for_period(int64_t start_ms, int64_t end_ms, bool include_deletion, char **output, + char **err_msg) { + try { + // Call internal function + auto [result, error] = lotman::get_max_mpas_for_period_internal(start_ms, end_ms, include_deletion); + + // Check for errors from internal function + if (!error.empty()) { + if (err_msg) { + *err_msg = strdup(error.c_str()); + } + return -1; + } + + // Build output JSON + json output_obj; + output_obj["start_ms"] = start_ms; + output_obj["end_ms"] = end_ms; + output_obj["include_deletion"] = include_deletion; + output_obj["max_dedicated_GB"] = result.max_dedicated_GB; + output_obj["max_opportunistic_GB"] = result.max_opportunistic_GB; + output_obj["max_combined_GB"] = result.max_combined_GB; + output_obj["max_num_objects"] = result.max_num_objects; + + // Convert to string and allocate output + std::string output_str = output_obj.dump(); + *output = strdup(output_str.c_str()); + + return 0; + } catch (std::exception &exc) { + if (err_msg) { + *err_msg = strdup(exc.what()); + } + return -1; + } +} diff --git a/src/lotman.h b/src/lotman.h index dac2023..c92e2af 100644 --- a/src/lotman.h +++ b/src/lotman.h @@ -936,6 +936,72 @@ int lotman_get_context_int(const char *key, int *output, char **err_msg); A reference to a char array that can store any error messages. */ +int lotman_get_max_mpas_for_period(int64_t start_ms, int64_t end_ms, bool include_deletion, char **output, + char **err_msg); +/** + DESCRIPTION: A function for determining the maximum summed Management Policy Attributes (MPAs) + across all overlapping lots during a specified time period. This function uses a sweep line + algorithm to efficiently calculate the peak resource allocation at any point during the period. + This is useful for capacity planning and scheduling systems that need to determine available + space for new lot allocations, e.g. "Can I create a lot with 50GB dedicated storage from time + A to time B without overcommitting resources?" + + RETURNS: Returns 0 on success. Any other values indicate an error. + + INPUTS: + start_ms: + A Unix timestamp in milliseconds indicating the start of the query period (inclusive). + + end_ms: + A Unix timestamp in milliseconds indicating the end of the query period (inclusive). + Must be greater than start_ms or the function will return an error. + + include_deletion: + A boolean indicating which lot endpoint to consider: + - When false: lots are considered active until their expiration_time + - When true: lots are considered active until their deletion_time + For most capacity planning scenarios, false is recommended since expired lots may still + consume resources even if they become opportunistic. + + output: + A reference to a char * that will be allocated and populated with a JSON string containing + the results. The caller is responsible for freeing this memory. + + err_msg: + A reference to a char array that can store any error messages. + + Output JSON Specification: + The output JSON contains both the query parameters (for logging/debugging) and the results: +{ + "start_ms": , + "end_ms": , + "include_deletion": , + "max_dedicated_GB": , + "max_opportunistic_GB": , + "max_combined_GB": , + "max_num_objects": +} + + Notes: + - max_dedicated_GB represents the maximum cumulative storage Lotman has dedicated to lots during the + specified period + - max_combined_GB sums over both opportunistic and dedicated storage, representing the total maximum storage + Lotman has allocated to lots during the specified period + - max_opportunistic_GB and max_combined_GB may be produced by different sets of overlapping lots + - If no lots overlap the specified period, all maximum values will be 0.0 + + Example: + If the query period contains three overlapping lots: + - Lot A: 10GB dedicated, 5GB opportunistic + - Lot B: 8GB dedicated, 0GB opportunistic + - Lot C: 7GB dedicated, 3GB opportunistic + + When all three overlap in time with the overall query interval: + - max_dedicated_GB = 25.0 (10 + 8 + 7) + - max_opportunistic_GB = 8.0 (5 + 0 + 3) + - max_combined_GB = 33.0 (25 + 8, the sum of total dedicated plus total opportunistic) +*/ + // int lotman_get_matching_lots(const char *criteria_JSON, char ***output, char **err_msg); // int lotman_check_db_health(char **err_msg); // Should eventually check that data structure conforms to expectations. // If there's a cycle or a non-self-parent root, something is wrong diff --git a/src/lotman_internal.cpp b/src/lotman_internal.cpp index 6b3a7a1..90ea0db 100644 --- a/src/lotman_internal.cpp +++ b/src/lotman_internal.cpp @@ -2182,6 +2182,153 @@ bool lotman::Checks::will_be_orphaned(const std::string <BR, const std::string return false; } +/** + * Implementation of sweep line algorithm for finding maximum MPAs during a time period. + * + * This implements the classic sweep line algorithm for interval scheduling problems. + * See: https://www.geeksforgeeks.org/maximum-number-of-overlapping-intervals/ + * + * The algorithm works by: + * 1. Creating "events" for each lot's start (creation) and end (expiration/deletion) + * 2. Sorting all events by time + * 3. Sweeping through events chronologically, tracking current resource usage with deltas + * that correspond to each event's attributes + * 4. Recording the maximum usage observed at any point + * + * Key semantic: Lot lifetimes are INCLUSIVE intervals [creation_time, end_time]. + * A lot is active at both its start and end timestamps. Therefore, we schedule + * removal events at end_time + 1 (the first moment the lot is no longer active). + */ + +std::pair lotman::get_max_mpas_for_period_internal(int64_t start_ms, int64_t end_ms, + bool include_deletion) { + // Validate input + if (start_ms >= end_ms) { + return {{0.0, 0.0, 0.0, 0}, "Error: start_ms must be less than end_ms"}; + } + + auto &storage = lotman::db::StorageManager::get_storage(); + + // Determine which time field to use for lot end time + using MPA = lotman::db::ManagementPolicyAttributes; + using Parent = lotman::db::Parent; + using namespace sqlite_orm; + + // Query lots that overlap with the period, filtering to only ROOT lots. + // + // IMPORTANT: We only count root lots (self-parent lots) to avoid double-counting in hierarchies. + // A root lot is one where the lot has only itself as a parent in the parents table. + // Child lots consume quota from their parents, so counting both would be incorrect. + // + // For example, if parent_lot has 5GB and child_lot (child of parent_lot) has 3GB, + // the maximum capacity usage should be 5GB (from the parent), not 8GB (parent + child). + // + // Overlap condition for inclusive intervals: creation_time <= end_ms AND end_time >= start_ms + // This correctly handles all overlap cases including point-in-time overlaps at boundaries. + // + // Root lot condition: EXISTS exactly one parent record WHERE parent = lot_name + // We use a SQL subquery to identify root lots directly in the database for optimal performance. + std::string time_field = include_deletion ? "deletion_time" : "expiration_time"; + std::string query = "SELECT mpa.lot_name, mpa.dedicated_GB, mpa.opportunistic_GB, mpa.max_num_objects, " + " mpa.creation_time, mpa." + + time_field + + " " + "FROM management_policy_attributes mpa " + "WHERE mpa.creation_time <= ? AND mpa." + + time_field + + " >= ? " + " AND mpa.lot_name IN ( " + " SELECT p.lot_name " + " FROM parents p " + " WHERE p.lot_name = p.parent " + " GROUP BY p.lot_name " + " HAVING COUNT(*) = 1 " + " )"; + + std::map> query_int_map{{end_ms, {1}}, {start_ms, {2}}}; + auto rp = lotman::db::SQL_get_matches_multi_col(query, 6, std::map>(), query_int_map); + + if (!rp.second.empty()) { + return {{0.0, 0.0, 0.0, 0}, "Database query failed: " + rp.second}; + } + + auto &lots = rp.first; + + // If no root lots overlap, return zeros with no error + if (lots.empty()) { + return {{0.0, 0.0, 0.0, 0}, ""}; + } + + // Event structure for sweep line algorithm + struct Event { + int64_t time; + double ded_delta; // Change in dedicated storage + double opp_delta; // Change in opportunistic storage + int64_t obj_delta; // Change in object count + bool is_start; // true for creation event, false for expiration/deletion event + }; + + std::vector events; + events.reserve(lots.size() * 2); // Each lot creates at most 2 events + + // Build event list from query results + // Each row contains: [lot_name, dedicated_GB, opportunistic_GB, max_num_objects, creation_time, end_time] + for (const auto &lot_row : lots) { + // Parse query results from string vector (columns 0-5) + // lot_row[0] = lot_name (string, not used in sweep line) + double dedicated = std::stod(lot_row[1]); // dedicated_GB + double opportunistic = std::stod(lot_row[2]); // opportunistic_GB + int64_t objects = std::stoll(lot_row[3]); // max_num_objects + int64_t creation = std::stoll(lot_row[4]); // creation_time + int64_t end_time = std::stoll(lot_row[5]); // expiration_time or deletion_time + + // Clamp lot start to query range (if lot starts before start_ms, treat as starting at start_ms) + int64_t effective_start = std::max(start_ms, creation); + + // Add creation/start event at the lot's effective start time + events.push_back({effective_start, dedicated, opportunistic, objects, true}); + + // Add expiration/deletion event AFTER the lot ends (since lot is active through end_time inclusive) + // Only add if the lot ends before the query period ends + if (end_time < end_ms) { + // Schedule removal at end_time + 1 (first moment lot is no longer active) + events.push_back({end_time + 1, -dedicated, -opportunistic, -objects, false}); + } + // If end_time >= end_ms, the lot extends beyond our query range, so no removal event needed + } + + // Sort events chronologically, with start events before end events at the same timestamp + std::sort(events.begin(), events.end(), [](const Event &a, const Event &b) { + if (a.time != b.time) { + return a.time < b.time; + } + // At same time, process starts before ends (true sorts before false) + // This ensures we correctly handle simultaneous creation/expiration events + return a.is_start > b.is_start; + }); + + // Sweep through events chronologically, tracking current and maximum resource usage + double current_ded = 0.0, current_opp = 0.0, current_combined = 0.0; + double max_ded = 0.0, max_opp = 0.0, max_combined = 0.0; + int64_t current_obj = 0, max_obj = 0; + + for (const auto &event : events) { + // Update current resource usage based on event deltas + current_ded += event.ded_delta; + current_opp += event.opp_delta; + current_obj += event.obj_delta; + current_combined = current_ded + current_opp; + + // Track the maximum values observed at any point + max_ded = std::max(max_ded, current_ded); + max_opp = std::max(max_opp, current_opp); + max_combined = std::max(max_combined, current_combined); + max_obj = std::max(max_obj, current_obj); + } + + return {{max_ded, max_opp, max_combined, max_obj}, ""}; +} + void lotman::Context::set_caller(const std::string caller) { m_caller = std::make_shared(caller); } diff --git a/src/lotman_internal.h b/src/lotman_internal.h index 88287e8..ca8e4c3 100644 --- a/src/lotman_internal.h +++ b/src/lotman_internal.h @@ -424,4 +424,42 @@ class Checks { // a parent/child, which should update data for the child static bool will_be_orphaned(const std::string <BR, const std::string &child); }; + +/** + * Result structure for maximum MPA queries. + */ +struct MaxMPAResult { + double max_dedicated_GB; + double max_opportunistic_GB; + double max_combined_GB; + int64_t max_num_objects; +}; + +/** + * Calculate maximum Management Policy Attributes (MPAs) during a time period using sweep line algorithm. + * + * This function implements a sweep line algorithm to efficiently find the maximum resource usage + * across all overlapping lots during a specified time period. The algorithm is based on the + * classic interval scheduling problem solution described at: + * https://www.geeksforgeeks.org/maximum-number-of-overlapping-intervals/ + * + * Time Complexity: O(n log n) where n is the number of lots overlapping the query period + * Space Complexity: O(n) for the event list + * + * IMPORTANT: Lot lifetimes are treated as INCLUSIVE intervals [creation_time, end_time]. + * A lot is considered active at both its creation_time and its end_time (expiration or deletion). + * This means a lot with creation_time=100 and expiration_time=200 is active during the entire + * range [100, 200], including both endpoints. + * + * @param start_ms Start of the query period in milliseconds since Unix epoch (inclusive) + * @param end_ms End of the query period in milliseconds since Unix epoch (inclusive) + * @param include_deletion If true, use deletion_time as lot end; if false, use expiration_time + * @return A pair containing: + * - first: MaxMPAResult struct with all maximum values + * - second: Error message string (empty on success, descriptive message on error) + * On error, all numeric values in the result struct are set to 0. + */ +std::pair get_max_mpas_for_period_internal(int64_t start_ms, int64_t end_ms, + bool include_deletion); + } // namespace lotman diff --git a/test/main.cpp b/test/main.cpp index 8cb7211..9ef279a 100644 --- a/test/main.cpp +++ b/test/main.cpp @@ -82,19 +82,20 @@ class LotManTest : public ::testing::Test { } // Helper to add default lot - MUST be created before any other lots + // Times are chosen to not overlap with test cases (which use times 50-600) void addDefaultLot() { const char *default_lot = R"({ "lot_name": "default", - "owner": "owner2", + "owner": "owner1", "parents": ["default"], "paths": [{"path": "/default/paths", "recursive": true}], "management_policy_attrs": { "dedicated_GB": 5, "opportunistic_GB": 2.5, "max_num_objects": 100, - "creation_time": 123, - "expiration_time": 234, - "deletion_time": 345 + "creation_time": 1000, + "expiration_time": 2000, + "deletion_time": 3000 } })"; addLot(default_lot); @@ -1965,6 +1966,510 @@ TEST_F(LotManTest, PathTrailingSlashNormalizationTest) { ASSERT_EQ(paths_json.size(), 1); EXPECT_EQ(paths_json[0]["path"], "/no/slash/here/"); } + +// Test: get_max_mpas_for_period - Single lot within period +TEST_F(LotManTest, MaxMPAsForPeriod_SingleLot) { + addDefaultLot(); + + const char *lot = R"({ + "lot_name": "testlot1", + "owner": "owner1", + "parents": ["testlot1"], + "management_policy_attrs": { + "dedicated_GB": 10.0, + "opportunistic_GB": 5.0, + "max_num_objects": 1000, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + addLot(lot); + + // Query period that contains the lot + char *raw_output = nullptr; + char *raw_err = nullptr; + int rv = lotman_get_max_mpas_for_period(50, 250, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + json result = json::parse(output.get()); + EXPECT_EQ(result["start_ms"], 50); + EXPECT_EQ(result["end_ms"], 250); + EXPECT_EQ(result["include_deletion"], false); + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 10.0); + EXPECT_DOUBLE_EQ(result["max_opportunistic_GB"], 5.0); + EXPECT_DOUBLE_EQ(result["max_combined_GB"], 15.0); + EXPECT_EQ(result["max_num_objects"], 1000); +} + +// Test: Multiple overlapping lots +TEST_F(LotManTest, MaxMPAsForPeriod_MultipleOverlapping) { + addDefaultLot(); + + // Three lots that overlap during [150, 220] + // Note that default lot does NOT overlap in time with this period. + const char *testlot1 = R"({ + "lot_name": "testlot1", + "owner": "owner1", + "parents": ["testlot1"], + "management_policy_attrs": { + "dedicated_GB": 10.0, + "opportunistic_GB": 5.0, + "max_num_objects": 1000, + "creation_time": 100, + "expiration_time": 250, + "deletion_time": 300 + } + })"; + + const char *testlot2 = R"({ + "lot_name": "testlot2", + "owner": "owner1", + "parents": ["testlot2"], + "management_policy_attrs": { + "dedicated_GB": 8.0, + "opportunistic_GB": 3.0, + "max_num_objects": 500, + "creation_time": 120, + "expiration_time": 230, + "deletion_time": 280 + } + })"; + + const char *testlot3 = R"({ + "lot_name": "testlot3", + "owner": "owner1", + "parents": ["testlot3"], + "management_policy_attrs": { + "dedicated_GB": 7.0, + "opportunistic_GB": 2.0, + "max_num_objects": 300, + "creation_time": 150, + "expiration_time": 220, + "deletion_time": 270 + } + })"; + + addLot(testlot1); + addLot(testlot2); + addLot(testlot3); + + // Query period where all three overlap + char *raw_output = nullptr; + char *raw_err = nullptr; + int rv = lotman_get_max_mpas_for_period(100, 300, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + json result = json::parse(output.get()); + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 25.0); // 10 + 8 + 7 + EXPECT_DOUBLE_EQ(result["max_opportunistic_GB"], 10.0); // 5 + 3 + 2 + EXPECT_DOUBLE_EQ(result["max_combined_GB"], 35.0); // 25 + 10 + EXPECT_EQ(result["max_num_objects"], 1800); // 1000 + 500 + 300 +} + +// Test: Multiple lots where max cumulative values and dedicated values occur at +// different times from different lots +TEST_F(LotManTest, MaxMPAsForPeriod_DifferentMaximums) { + addDefaultLot(); + + // Three lots with high dedicated, low opportunistic, overlapping early + const char *testlot1 = R"({ + "lot_name": "testlot1", + "owner": "owner1", + "parents": ["testlot1"], + "management_policy_attrs": { + "dedicated_GB": 10.0, + "opportunistic_GB": 0.0, + "max_num_objects": 1000, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 250 + } + })"; + + const char *testlot2 = R"({ + "lot_name": "testlot2", + "owner": "owner1", + "parents": ["testlot2"], + "management_policy_attrs": { + "dedicated_GB": 8.0, + "opportunistic_GB": 0.0, + "max_num_objects": 500, + "creation_time": 120, + "expiration_time": 220, + "deletion_time": 270 + } + })"; + + const char *testlot3 = R"({ + "lot_name": "testlot3", + "owner": "owner1", + "parents": ["testlot3"], + "management_policy_attrs": { + "dedicated_GB": 7.0, + "opportunistic_GB": 0.0, + "max_num_objects": 300, + "creation_time": 130, + "expiration_time": 210, + "deletion_time": 260 + } + })"; + + // One lot with low dedicated, high opportunistic, later in timeline + const char *testlot4 = R"({ + "lot_name": "testlot4", + "owner": "owner1", + "parents": ["testlot4"], + "management_policy_attrs": { + "dedicated_GB": 2.0, + "opportunistic_GB": 30.0, + "max_num_objects": 100, + "creation_time": 300, + "expiration_time": 400, + "deletion_time": 450 + } + })"; + + addLot(testlot1); + addLot(testlot2); + addLot(testlot3); + addLot(testlot4); + + // Query entire period + char *raw_output = nullptr; + char *raw_err = nullptr; + int rv = lotman_get_max_mpas_for_period(100, 500, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + json result = json::parse(output.get()); + // max_dedicated occurs when testlot1+testlot2+testlot3 overlap (130-200) + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 25.0); // 10 + 8 + 7 + // max_opportunistic occurs when only testlot4 is active (300-400) + EXPECT_DOUBLE_EQ(result["max_opportunistic_GB"], 30.0); // just testlot4 + // max_combined is testlot4's total since 32 > 25 + EXPECT_DOUBLE_EQ(result["max_combined_GB"], 32.0); // 2 + 30 from testlot4 + // max_objects occurs when testlot1+testlot2+testlot3 overlap + EXPECT_EQ(result["max_num_objects"], 1800); // 1000 + 500 + 300 +} + +// Test: Lots spanning beyond query range +TEST_F(LotManTest, MaxMPAsForPeriod_LotsSpanBeyondRange) { + addDefaultLot(); + + const char *lot = R"({ + "lot_name": "testlot1", + "owner": "owner1", + "parents": ["testlot1"], + "management_policy_attrs": { + "dedicated_GB": 10.0, + "opportunistic_GB": 5.0, + "max_num_objects": 1000, + "creation_time": 50, + "expiration_time": 500, + "deletion_time": 600 + } + })"; + addLot(lot); + + // Query a window within the lot's lifetime + char *raw_output = nullptr; + char *raw_err = nullptr; + int rv = lotman_get_max_mpas_for_period(100, 200, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + json result = json::parse(output.get()); + // Lot extends throughout the entire query period + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 10.0); + EXPECT_DOUBLE_EQ(result["max_opportunistic_GB"], 5.0); + EXPECT_DOUBLE_EQ(result["max_combined_GB"], 15.0); + EXPECT_EQ(result["max_num_objects"], 1000); +} + +// Test: include_deletion parameter (expiration vs deletion) +TEST_F(LotManTest, MaxMPAsForPeriod_IncludeDeletion) { + addDefaultLot(); + + const char *lot = R"({ + "lot_name": "testlot1", + "owner": "owner1", + "parents": ["testlot1"], + "management_policy_attrs": { + "dedicated_GB": 10.0, + "opportunistic_GB": 5.0, + "max_num_objects": 1000, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + addLot(lot); + + // Query with include_deletion=false (uses expiration_time=200) + char *raw_output = nullptr; + char *raw_err = nullptr; + int rv = lotman_get_max_mpas_for_period(100, 250, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + json result = json::parse(output.get()); + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 10.0); + + // Query with include_deletion=true (uses deletion_time=300) + raw_output = nullptr; + raw_err = nullptr; + rv = lotman_get_max_mpas_for_period(200, 250, true, &raw_output, &raw_err); + output.reset(raw_output); + err_msg.reset(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + result = json::parse(output.get()); + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 10.0); // Still active with deletion endpoint + EXPECT_EQ(result["include_deletion"], true); +} + +// Test: Error case - start_ms >= end_ms +TEST_F(LotManTest, MaxMPAsForPeriod_InvalidTimeRange) { + addDefaultLot(); + + char *raw_output = nullptr; + char *raw_err = nullptr; + + // start_ms == end_ms should error + int rv = lotman_get_max_mpas_for_period(100, 100, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + EXPECT_NE(rv, 0); + EXPECT_NE(err_msg.get(), nullptr); + + // start_ms > end_ms should also error + raw_output = nullptr; + raw_err = nullptr; + rv = lotman_get_max_mpas_for_period(200, 100, false, &raw_output, &raw_err); + output.reset(raw_output); + err_msg.reset(raw_err); + EXPECT_NE(rv, 0); + EXPECT_NE(err_msg.get(), nullptr); +} + +// Test: No lots overlap the period (should return zeros) +TEST_F(LotManTest, MaxMPAsForPeriod_NoOverlappingLots) { + addDefaultLot(); + + const char *lot = R"({ + "lot_name": "testlot1", + "owner": "owner1", + "parents": ["testlot1"], + "management_policy_attrs": { + "dedicated_GB": 10.0, + "opportunistic_GB": 5.0, + "max_num_objects": 1000, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + addLot(lot); + + // Query period that doesn't overlap with any lot + char *raw_output = nullptr; + char *raw_err = nullptr; + int rv = lotman_get_max_mpas_for_period(400, 500, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + json result = json::parse(output.get()); + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 0.0); + EXPECT_DOUBLE_EQ(result["max_opportunistic_GB"], 0.0); + EXPECT_DOUBLE_EQ(result["max_combined_GB"], 0.0); + EXPECT_EQ(result["max_num_objects"], 0); +} + +// Test: Simultaneous events (lots created/expired at same time) +TEST_F(LotManTest, MaxMPAsForPeriod_SimultaneousEvents) { + addDefaultLot(); + + // Two lots with identical creation times + const char *testlot1 = R"({ + "lot_name": "testlot1", + "owner": "owner1", + "parents": ["testlot1"], + "management_policy_attrs": { + "dedicated_GB": 10.0, + "opportunistic_GB": 5.0, + "max_num_objects": 1000, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + + const char *testlot2 = R"({ + "lot_name": "testlot2", + "owner": "owner1", + "parents": ["testlot2"], + "management_policy_attrs": { + "dedicated_GB": 8.0, + "opportunistic_GB": 3.0, + "max_num_objects": 500, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + + addLot(testlot1); + addLot(testlot2); + + char *raw_output = nullptr; + char *raw_err = nullptr; + int rv = lotman_get_max_mpas_for_period(100, 200, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + json result = json::parse(output.get()); + // Both lots should be counted together + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 18.0); // 10 + 8 + EXPECT_DOUBLE_EQ(result["max_opportunistic_GB"], 8.0); // 5 + 3 + EXPECT_DOUBLE_EQ(result["max_combined_GB"], 26.0); // 18 + 8 + EXPECT_EQ(result["max_num_objects"], 1500); // 1000 + 500 +} + +// Test: Lot hierarchies should only count root lots, not children +TEST_F(LotManTest, MaxMPAsForPeriod_LotHierarchy) { + addDefaultLot(); + + // Create parent lot (root) with 5GB dedicated + const char *parent_lot = R"({ + "lot_name": "parent_lot", + "owner": "owner1", + "parents": ["parent_lot"], + "management_policy_attrs": { + "dedicated_GB": 5.0, + "opportunistic_GB": 2.0, + "max_num_objects": 1000, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + + // Create child lot with 3GB dedicated (child of parent_lot) + const char *child_lot = R"({ + "lot_name": "child_lot", + "owner": "owner1", + "parents": ["parent_lot"], + "management_policy_attrs": { + "dedicated_GB": 3.0, + "opportunistic_GB": 1.0, + "max_num_objects": 500, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + + addLot(parent_lot); + addLot(child_lot); + + // Query during overlap period + char *raw_output = nullptr; + char *raw_err = nullptr; + int rv = lotman_get_max_mpas_for_period(100, 200, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + json result = json::parse(output.get()); + + // Should only count the parent lot (5GB), not parent + child (8GB) + // Child's allocation counts toward parent's quota + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 5.0) << "Should only count root lot, not child"; + EXPECT_DOUBLE_EQ(result["max_opportunistic_GB"], 2.0); + EXPECT_DOUBLE_EQ(result["max_combined_GB"], 7.0); // 5 + 2 + EXPECT_EQ(result["max_num_objects"], 1000); +} + +// Test: Multiple independent root lots should be summed +TEST_F(LotManTest, MaxMPAsForPeriod_MultipleRoots) { + addDefaultLot(); + + // Create first root lot with 5GB + const char *root1 = R"({ + "lot_name": "root1", + "owner": "owner1", + "parents": ["root1"], + "management_policy_attrs": { + "dedicated_GB": 5.0, + "opportunistic_GB": 2.0, + "max_num_objects": 1000, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + + // Create second root lot with 8GB + const char *root2 = R"({ + "lot_name": "root2", + "owner": "owner1", + "parents": ["root2"], + "management_policy_attrs": { + "dedicated_GB": 8.0, + "opportunistic_GB": 3.0, + "max_num_objects": 2000, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + + // Create child of root1 with 3GB + const char *child1 = R"({ + "lot_name": "child1", + "owner": "owner1", + "parents": ["root1"], + "management_policy_attrs": { + "dedicated_GB": 3.0, + "opportunistic_GB": 1.0, + "max_num_objects": 500, + "creation_time": 100, + "expiration_time": 200, + "deletion_time": 300 + } + })"; + + addLot(root1); + addLot(root2); + addLot(child1); + + // Query during overlap period + char *raw_output = nullptr; + char *raw_err = nullptr; + int rv = lotman_get_max_mpas_for_period(100, 200, false, &raw_output, &raw_err); + UniqueCString output(raw_output); + UniqueCString err_msg(raw_err); + ASSERT_EQ(rv, 0) << err_msg.get(); + + json result = json::parse(output.get()); + + // Should count both root lots (5 + 8 = 13GB), but not the child + EXPECT_DOUBLE_EQ(result["max_dedicated_GB"], 13.0) << "Should count both roots but not child"; + EXPECT_DOUBLE_EQ(result["max_opportunistic_GB"], 5.0); // 2 + 3 + EXPECT_DOUBLE_EQ(result["max_combined_GB"], 18.0); // 13 + 5 + EXPECT_EQ(result["max_num_objects"], 3000); // 1000 + 2000 +} + } // namespace int main(int argc, char **argv) {