BareGit

Add group defaults for service scheduling

Allow groups to provide interval and timeout_second values that services
inherit when they do not override them. Update configuration examples and
tests, including scheduler tests for randomized startup order.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sun Sep 20 21:52:22 2026 -0700
Commit: 3fc5d3cb597427322fb58c32373596cc863ea63a

Changes

diff --git a/config.example.yaml b/config.example.yaml
index 9bb112e..c193f8e 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -9,22 +9,23 @@ database_path: status-tracker.sqlite
 # Groups are a sequence. Service IDs are mapping keys and must be unique.
 groups:
   - name: Public
+    # These values apply to services that do not override them.
+    interval:
+      value: 1
+      unit: minute
+    timeout_second: 5
     services:
       blog:
         name: Blog
         # Optional description shown below the service name.
         description: Personal website
         url: https://blog.example/
-        interval:
-          value: 1
-          unit: minute
         endpoint:
           protocol: HTTP
           # If set, only this exact response code is considered good.
           # expected_code: 200
           # If omitted, the service-level URL is used for HTTP probes.
           # url: https://blog.example/
-          timeout_second: 5
 
   - name: Infrastructure
     services:
diff --git a/prd.md b/prd.md
index 53c9d0b..b432697 100644
--- a/prd.md
+++ b/prd.md
@@ -59,6 +59,9 @@ A simple self-hosted service status tracker.
 
 - In the config file the endpoints are grouped with another level of
   grouping. This grouping is also shown in the web UI.
+- Groups may define `interval` and `timeout_second` defaults for their
+  services. Service values override group values; endpoint timeout values
+  remain more specific than group timeout values.
 - Stores status history in SQLite db.
 - Endpoint status: There should be four possible status: good, bad,
   other, and n/a. Right now we can reserve “other” for the case where
diff --git a/src/configuration.cpp b/src/configuration.cpp
index bdf7d55..5e8bda2 100644
--- a/src/configuration.cpp
+++ b/src/configuration.cpp
@@ -6,6 +6,7 @@
 #include <fstream>
 #include <iterator>
 #include <limits>
+#include <optional>
 #include <string_view>
 #include <unordered_set>
 #include <utility>
@@ -132,14 +133,18 @@ std::string lower(std::string value)
     return value;
 }
 
-mw::E<std::chrono::steady_clock::duration> parseInterval(
-    Node service, std::string_view context)
+mw::E<std::optional<std::chrono::steady_clock::duration>> optionalInterval(
+    Node node, std::string_view context)
 {
-    auto interval = child(service, "interval");
-    if(!interval.readable() || !interval.is_map())
+    auto interval = child(node, "interval");
+    if(!interval.readable())
+    {
+        return std::optional<std::chrono::steady_clock::duration>{};
+    }
+    if(!interval.is_map())
     {
         return std::unexpected(configError(
-            context, "missing interval map"));
+            context, "interval must be a map"));
     }
     ASSIGN_OR_RETURN(const auto value,
                      requiredInteger<std::int64_t>(interval, "value", context));
@@ -247,7 +252,11 @@ mw::E<EndpointConfig> parseEndpoint(Node endpoint, std::string_view context)
 }
 
 mw::E<ServiceConfig> parseService(Node service, std::string id,
-                                  std::string_view context)
+                                  std::string_view context,
+                                  std::optional<
+                                      std::chrono::steady_clock::duration>
+                                      group_interval,
+                                  std::optional<std::int64_t> group_timeout)
 {
     if(!service.is_map())
     {
@@ -283,14 +292,28 @@ mw::E<ServiceConfig> parseService(Node service, std::string id,
             return std::unexpected(timeout.error());
         }
     }
-    const auto timeout_value = timeout->value_or(5);
+    const auto timeout_value = timeout->value_or(
+        group_timeout.value_or(5));
     if(timeout_value <= 0)
     {
         return std::unexpected(configError(
             context, "timeout_second must be positive"));
     }
     result.timeout = std::chrono::seconds(timeout_value);
-    ASSIGN_OR_RETURN(result.interval, parseInterval(service, context));
+    ASSIGN_OR_RETURN(auto interval, optionalInterval(service, context));
+    if(interval)
+    {
+        result.interval = *interval;
+    }
+    else if(group_interval)
+    {
+        result.interval = *group_interval;
+    }
+    else
+    {
+        return std::unexpected(configError(
+            context, "missing interval map"));
+    }
     return result;
 }
 
@@ -377,6 +400,16 @@ mw::E<Configuration> parseTree(ryml::Tree& tree)
         const auto group_context = group_location + "." + group_name;
         ServiceGroupConfig group_config;
         group_config.name = std::move(group_name);
+        ASSIGN_OR_RETURN(auto group_interval,
+                         optionalInterval(group, group_context));
+        ASSIGN_OR_RETURN(auto group_timeout,
+                         optionalInteger<std::int64_t>(
+                             group, "timeout_second", group_context));
+        if(group_timeout && *group_timeout <= 0)
+        {
+            return std::unexpected(configError(
+                group_context, "timeout_second must be positive"));
+        }
         auto services = child(group, "services");
         if(!services.readable() || !services.is_map())
         {
@@ -399,7 +432,8 @@ mw::E<Configuration> parseTree(ryml::Tree& tree)
             }
             const auto context = group_context + "." + service_id;
             ASSIGN_OR_RETURN(auto service_config,
-                             parseService(service, service_id, context));
+                             parseService(service, service_id, context,
+                                          group_interval, group_timeout));
             if(!service_ids.insert(service_config.id).second)
             {
                 return std::unexpected(configError(
diff --git a/tests/configuration_test.cpp b/tests/configuration_test.cpp
index 93e40c2..4d30c03 100644
--- a/tests/configuration_test.cpp
+++ b/tests/configuration_test.cpp
@@ -40,6 +40,10 @@ TEST(Configuration, LoadsGroupedProtocolSettings)
         "socket_permission: 0o660\n"
         "groups:\n"
         "  - name: Public services\n"
+        "    interval:\n"
+        "      value: 5\n"
+        "      unit: minute\n"
+        "    timeout_second: 11\n"
         "    services:\n"
         "      blog:\n"
         "        name: Blog\n"
@@ -76,9 +80,6 @@ TEST(Configuration, LoadsGroupedProtocolSettings)
         "          payload: ping\n"
         "      gateway:\n"
         "        name: Gateway\n"
-        "        interval:\n"
-        "          value: 1\n"
-        "          unit: day\n"
         "        endpoint:\n"
         "          protocol: icmp\n"
         "          host: 192.0.2.1\n");
@@ -111,7 +112,9 @@ TEST(Configuration, LoadsGroupedProtocolSettings)
 
     ASSERT_TRUE(std::holds_alternative<UdpEndpoint>(services[2].endpoint));
     EXPECT_EQ(std::get<UdpEndpoint>(services[2].endpoint).payload, "ping");
-    EXPECT_EQ(services[3].interval, std::chrono::hours(24));
+    EXPECT_EQ(services[2].timeout, std::chrono::seconds(11));
+    EXPECT_EQ(services[3].interval, std::chrono::minutes(5));
+    EXPECT_EQ(services[3].timeout, std::chrono::seconds(11));
     ASSERT_TRUE(std::holds_alternative<IcmpEndpoint>(services[3].endpoint));
     EXPECT_TRUE(services[3].description.empty());
 }
@@ -148,6 +151,18 @@ TEST(Configuration, RejectsInvalidValues)
         "database_path: db\n"
         "groups:\n"
         "  - name: g\n"
+        "    interval: {value: 0, unit: minute}\n"
+        "    services: {}\n",
+        "worker_count: 1\n"
+        "database_path: db\n"
+        "groups:\n"
+        "  - name: g\n"
+        "    timeout_second: 0\n"
+        "    services: {}\n",
+        "worker_count: 1\n"
+        "database_path: db\n"
+        "groups:\n"
+        "  - name: g\n"
         "    services:\n"
         "      x:\n"
         "        name: x\n"
diff --git a/tests/scheduler_test.cpp b/tests/scheduler_test.cpp
index 1017acf..1d7d540 100644
--- a/tests/scheduler_test.cpp
+++ b/tests/scheduler_test.cpp
@@ -366,6 +366,23 @@ struct BlockingFactory
     }
 };
 
+struct FirstBlockingFactory
+{
+    std::shared_future<void> release;
+    std::promise<void>& started;
+    std::atomic<int>& calls;
+
+    mw::E<std::unique_ptr<ProbeInterface>> operator()(
+        [[maybe_unused]] const Service& service) const
+    {
+        if(calls.fetch_add(1) == 0)
+        {
+            return std::make_unique<BlockingProbe>(release, started);
+        }
+        return makeFake(service);
+    }
+};
+
 TEST(Scheduler, SkipsInFlightAndFinishesAcceptedWorkOnStop)
 {
     WatchingSource source;
@@ -410,26 +427,38 @@ TEST(Scheduler, WaitsForCapacityWithoutWaitingForNextInterval)
     std::stop_source stop;
     auto release = std::make_unique<std::promise<void>>();
     Configuration configuration{1, ":memory:", {{"Group", {
-        serviceConfig("a_slow"), serviceConfig("retry"),
-        serviceConfig("third")
+        serviceConfig("first"), serviceConfig("second")
     }}}};
-    auto scheduler = Scheduler::create(configuration, source, BlockingFactory{
-        release->get_future().share(), started, calls});
+    auto scheduler = Scheduler::create(
+        configuration, source, FirstBlockingFactory{
+            release->get_future().share(), started, calls});
     ASSERT_TRUE(scheduler);
     auto running = std::async(std::launch::async, &Scheduler::run,
                               scheduler->get(), stop.get_token());
     auto release_on_exit = std::move(release);
     StopOnExit cleanup{stop};
     ASSERT_EQ(ready.wait_for(1s), std::future_status::ready);
-    EXPECT_FALSE(source.waitForRecords("retry", 1, 20ms));
+    const auto* first = (*scheduler)->findService("first");
+    const auto* second = (*scheduler)->findService("second");
+    const auto deadline = Scheduler::Clock::now() + 1s;
+    while((!first->in_flight || !second->in_flight) &&
+          Scheduler::Clock::now() < deadline)
+    {
+        std::this_thread::yield();
+    }
+    ASSERT_TRUE(first->in_flight);
+    ASSERT_TRUE(second->in_flight);
+    EXPECT_FALSE(source.waitForRecords("first", 1, 20ms));
+    EXPECT_FALSE(source.waitForRecords("second", 1, 20ms));
     release_on_exit->set_value();
-    ASSERT_TRUE(source.waitForRecords("retry", 1));
-    ASSERT_TRUE(source.waitForRecords("third", 1));
+    ASSERT_TRUE(source.waitForRecords("first", 1));
+    ASSERT_TRUE(source.waitForRecords("second", 1));
     stop.request_stop();
     ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
     EXPECT_TRUE(running.get());
-    EXPECT_FALSE((*scheduler)->findService("retry")->in_flight.load());
-    EXPECT_EQ(calls.load(), 1);
+    EXPECT_FALSE(first->in_flight.load());
+    EXPECT_FALSE(second->in_flight.load());
+    EXPECT_EQ(calls.load(), 2);
 }
 
 TEST(Scheduler, CancelsPendingSubmissionOnStopOrError)
@@ -447,10 +476,10 @@ TEST(Scheduler, CancelsPendingSubmissionOnStopOrError)
         std::stop_source stop;
         auto release = std::make_unique<std::promise<void>>();
         Configuration configuration{1, ":memory:", {{"Group", {
-            serviceConfig("a_slow"), serviceConfig("pending")
+            serviceConfig("first"), serviceConfig("second")
         }}}};
         auto scheduler = Scheduler::create(
-            configuration, source, BlockingFactory{
+            configuration, source, FirstBlockingFactory{
                 release->get_future().share(), started, calls});
         ASSERT_TRUE(scheduler);
         auto running = std::async(std::launch::async, &Scheduler::run,
@@ -458,30 +487,36 @@ TEST(Scheduler, CancelsPendingSubmissionOnStopOrError)
         auto release_on_exit = std::move(release);
         StopOnExit cleanup{stop};
         ASSERT_EQ(ready.wait_for(1s), std::future_status::ready);
-        const auto* pending = (*scheduler)->findService("pending");
+        const auto* first = (*scheduler)->findService("first");
+        const auto* second = (*scheduler)->findService("second");
         const auto deadline = Scheduler::Clock::now() + 1s;
-        while(!pending->in_flight && Scheduler::Clock::now() < deadline)
+        while((!first->in_flight || !second->in_flight) &&
+              Scheduler::Clock::now() < deadline)
         {
             std::this_thread::yield();
         }
-        ASSERT_TRUE(pending->in_flight);
+        ASSERT_TRUE(first->in_flight);
+        ASSERT_TRUE(second->in_flight);
         if(!fail_save)
         {
             stop.request_stop();
             const auto cancelled_by = Scheduler::Clock::now() + 1s;
-            while(pending->in_flight &&
+            while(first->in_flight && second->in_flight &&
                   Scheduler::Clock::now() < cancelled_by)
             {
                 std::this_thread::yield();
             }
-            EXPECT_FALSE(pending->in_flight);
+            EXPECT_TRUE(!first->in_flight || !second->in_flight);
             EXPECT_EQ(running.wait_for(20ms), std::future_status::timeout);
         }
         release_on_exit->set_value();
         ASSERT_EQ(running.wait_for(1s), std::future_status::ready);
         EXPECT_EQ(running.get().has_value(), !fail_save);
-        EXPECT_FALSE(pending->in_flight);
-        EXPECT_FALSE(source.latest("pending").value());
+        EXPECT_FALSE(first->in_flight);
+        EXPECT_FALSE(second->in_flight);
+        const auto first_saved = source.latest("first").value().has_value();
+        const auto second_saved = source.latest("second").value().has_value();
+        EXPECT_EQ(first_saved + second_saved, fail_save ? 0 : 1);
         EXPECT_EQ(source.save_attempts.load(), 1);
     }
 }