merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

commit 1de48346a94b188deadd5de29ffdd9a52652f052
parent 935916bce92eaed0844e284c7aba1b6cb1c4035e
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 00:10:07 +0200

fix statistics GC logic

Diffstat:
Msrc/backenddb/gc.sql | 5+++++
Msrc/backenddb/pg_statistics_helpers.sql | 33+++++++++++++++++++++++++++++++++
Msrc/backenddb/test_merchantdb.c | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; }