commit 1de48346a94b188deadd5de29ffdd9a52652f052
parent 935916bce92eaed0844e284c7aba1b6cb1c4035e
Author: Christian Grothoff <christian@grothoff.org>
Date: Thu, 6 Aug 2026 00:10:07 +0200
fix statistics GC logic
Diffstat:
3 files changed, 148 insertions(+), 0 deletions(-)
diff --git a/src/backenddb/gc.sql b/src/backenddb/gc.sql
@@ -39,6 +39,11 @@ BEGIN
NULL;
WHEN undefined_function THEN
NULL;
+ WHEN OTHERS THEN
+ -- Do not abandon garbage collection for all remaining instances
+ -- (and the global tables) just because one instance failed.
+ RAISE WARNING 'Garbage collection failed for %: % (%)',
+ s, SQLERRM, SQLSTATE;
END;
COMMIT;
END LOOP;
diff --git a/src/backenddb/pg_statistics_helpers.sql b/src/backenddb/pg_statistics_helpers.sql
@@ -504,6 +504,22 @@ BEGIN
RAISE NOTICE 'combining % entries to representative % for slots [%-%)', my_sum.matches, my_sum.rep_serial_id, my_time - max_slot, my_time - min_slot;
+ -- The representative absorbs the deltas of all entries we are
+ -- about to remove, so any interval counter still using one of
+ -- them as its watermark must be moved onto the representative.
+ -- Without this, event_delimiter's ON DELETE RESTRICT aborts the
+ -- entire garbage collection run.
+ UPDATE merchant_statistic_interval_counter
+ SET event_delimiter = my_sum.rep_serial_id
+ WHERE imeta_serial_id = my_meta
+ AND event_delimiter IN
+ (SELECT nevent_serial_id
+ FROM merchant_statistic_counter_event
+ WHERE imeta_serial_id=my_meta
+ AND slot >= my_time - max_slot
+ AND slot < my_time - min_slot
+ AND nevent_serial_id > my_sum.rep_serial_id);
+
-- combine entries
DELETE FROM merchant_statistic_counter_event
WHERE imeta_serial_id=my_meta
@@ -610,6 +626,23 @@ BEGIN
my_total_frac = my_sum.total_frac % 100000000;
my_total_val = my_sum.total_val + my_sum.total_frac / 100000000;
+ -- The representative absorbs the deltas of all entries we are
+ -- about to remove, so any interval amount still using one of
+ -- them as its watermark must be moved onto the representative.
+ -- Without this, event_delimiter's ON DELETE RESTRICT aborts the
+ -- entire garbage collection run.
+ UPDATE merchant_statistic_interval_amount
+ SET event_delimiter = my_sum.rep_serial_id
+ WHERE imeta_serial_id = my_meta
+ AND event_delimiter IN
+ (SELECT aevent_serial_id
+ FROM merchant_statistic_amount_event
+ WHERE imeta_serial_id=my_meta
+ AND delta_curr = my_currency
+ AND slot >= my_time - max_slot
+ AND slot < my_time - min_slot
+ AND aevent_serial_id > my_sum.rep_serial_id);
+
-- combine entries
DELETE FROM merchant_statistic_amount_event
WHERE imeta_serial_id=my_meta
diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c
@@ -10085,6 +10085,115 @@ test_statistics_amount_slug_collected (const struct InstanceData *instance,
/**
+ * Determines how many interval events are stored for @a slug.
+ *
+ * @param instance the instance to look at.
+ * @param slug the statistic to count the events of.
+ * @param[out] count set to the number of events.
+ * @return 0 on success, 1 otherwise.
+ */
+static int
+statistics_count_counter_events (const struct InstanceData *instance,
+ const char *slug,
+ uint64_t *count)
+{
+ struct GNUNET_PQ_QueryParam params[] = {
+ GNUNET_PQ_query_param_string (slug),
+ GNUNET_PQ_query_param_end
+ };
+ struct GNUNET_PQ_ResultSpec rs[] = {
+ GNUNET_PQ_result_spec_uint64 ("num",
+ count),
+ GNUNET_PQ_result_spec_end
+ };
+
+ TEST_SET_INSTANCE (instance->instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ TEST_COND_RET_ON_FAIL (GNUNET_OK ==
+ GNUNET_PQ_prepare_anon (
+ pg->conn,
+ "SELECT COUNT(*) AS num"
+ " FROM merchant_statistic_counter_event"
+ " WHERE imeta_serial_id ="
+ " (SELECT imeta_serial_id"
+ " FROM merchant_statistic_interval_meta"
+ " WHERE slug=$1"
+ " AND stype='number')"),
+ "Preparing event count failed\n");
+ TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT ==
+ GNUNET_PQ_eval_prepared_singleton_select (pg->conn,
+ "",
+ params,
+ rs),
+ "Counting interval events failed\n");
+ return 0;
+}
+
+
+/**
+ * Tests that garbage collection survives compacting an interval event
+ * that an interval counter still uses as its watermark. Regression
+ * test: merchant_statistic_counter_gc() deletes all but the lowest
+ * serial of a window, which trips the ON DELETE RESTRICT foreign key
+ * of merchant_statistic_interval_counter.event_delimiter as soon as
+ * the watermark is not that lowest serial. merchant_do_gc() does not
+ * trap that error, so garbage collection was abandoned for all
+ * remaining instances.
+ *
+ * @param instance the instance to collect statistics for.
+ * @return 0 on success, 1 otherwise.
+ */
+static int
+test_statistics_counter_gc_delimiter (const struct InstanceData *instance)
+{
+ TEST_SET_INSTANCE (instance->instance.id,
+ GNUNET_DB_STATUS_SUCCESS_ONE_RESULT);
+ /* Two events in the same coarsening window, with the watermark of the
+ interval counter on the younger (higher serial) one. */
+ TEST_RET_ON_FAIL (statistics_exec_sql (
+ "DO $$\n"
+ "DECLARE\n"
+ " my_time INT8 := ROUND(EXTRACT(epoch FROM"
+ " CURRENT_TIMESTAMP(0)::TIMESTAMP))::INT8;\n"
+ " my_meta INT8;\n"
+ " my_first INT8;\n"
+ " my_second INT8;\n"
+ "BEGIN\n"
+ " SELECT imeta_serial_id\n"
+ " INTO my_meta\n"
+ " FROM merchant_statistic_interval_meta\n"
+ " WHERE slug='orders-created'\n"
+ " AND stype='number';\n"
+ " INSERT INTO merchant_statistic_counter_event\n"
+ " (imeta_serial_id, slot, delta)\n"
+ " VALUES (my_meta, my_time - 7000, 1)\n"
+ " RETURNING nevent_serial_id INTO my_first;\n"
+ " INSERT INTO merchant_statistic_counter_event\n"
+ " (imeta_serial_id, slot, delta)\n"
+ " VALUES (my_meta, my_time - 6900, 1)\n"
+ " RETURNING nevent_serial_id INTO my_second;\n"
+ " INSERT INTO merchant_statistic_interval_counter\n"
+ " (imeta_serial_id, range, event_delimiter,"
+ " cumulative_number)\n"
+ " VALUES (my_meta, 7200, my_second, 1);\n"
+ "END $$;"));
+ TEST_COND_RET_ON_FAIL (GNUNET_OK ==
+ TALER_MERCHANTDB_gc (pg),
+ "Garbage collection failed\n");
+ {
+ uint64_t num_events;
+
+ TEST_RET_ON_FAIL (statistics_count_counter_events (instance,
+ "orders-created",
+ &num_events));
+ TEST_COND_RET_ON_FAIL (1 == num_events,
+ "Garbage collection did not compact the events\n");
+ }
+ return 0;
+}
+
+
+/**
* Prepares for the statistics tests.
*
* @param[out] cls the closure to initialize.
@@ -10141,6 +10250,7 @@ run_test_statistics (struct TestStatistics_Closure *cls)
TEST_RET_ON_FAIL (test_statistics_amount_slug_collected (
&cls->instance,
"deposits-fees-paid"));
+ TEST_RET_ON_FAIL (test_statistics_counter_gc_delimiter (&cls->instance));
return 0;
}