merchant

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

commit 6b0297d66a1a4e12085bf5209a66a273e9a58d39
parent 251fee0ec8f187b8bf0dc6e5fc10363c3fbb10a4
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed,  5 Aug 2026 22:24:26 +0200

fix statistics GC logic: properly compute intervals

Diffstat:
Msrc/backenddb/meson.build | 1+
Msrc/backenddb/pg_statistics_helpers.sql | 15++++++++++++---
Msrc/backenddb/test_merchantdb.c | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 230 insertions(+), 3 deletions(-)

diff --git a/src/backenddb/meson.build b/src/backenddb/meson.build @@ -250,6 +250,7 @@ test_merchantdb = executable( talerjson_dep, gnunetutil_dep, gnunetjson_dep, + gnunetpq_dep, json_dep, pq_dep, ], diff --git a/src/backenddb/pg_statistics_helpers.sql b/src/backenddb/pg_statistics_helpers.sql @@ -614,7 +614,7 @@ LANGUAGE plpgsql AS $$ DECLARE my_rec RECORD; - my_range TEXT; + my_range INTERVAL; my_now INT8; my_end INT8; BEGIN @@ -626,8 +626,17 @@ BEGIN ,ages[array_length(ages,1)] AS age FROM merchant_statistic_bucket_meta LOOP - my_range = '1 ' || my_rec.range::TEXT; - my_end = my_now - my_rec.age * EXTRACT(SECONDS FROM (SELECT my_range::INTERVAL)); -- age is given in multiples of the range (in seconds) + IF my_rec.range = 'quarter' + THEN + -- '1 quarter' is not a valid PostgreSQL interval + my_range = INTERVAL '3 months'; + ELSE + my_range = ('1 ' || my_rec.range::TEXT)::INTERVAL; + END IF; + -- age is given in multiples of the range; EPOCH (and not SECONDS, + -- which is merely the seconds *field* of the interval and thus zero + -- for all of our ranges) converts that range into seconds + my_end = my_now - my_rec.age * EXTRACT(EPOCH FROM my_range); IF my_rec.stype = 'amount' THEN DELETE diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -24,9 +24,13 @@ #include "microhttpd.h" #include <taler/taler_util.h> #include <taler/taler_json_lib.h> +#include <taler/taler_pq_lib.h> #include <taler/taler_signatures.h> #include "taler/taler_merchant_util.h" #include "merchantdb_lib.h" +#include "helper.h" +#include "merchant-database/gc.h" +#include "merchant-database/iterate_statistic_bucket_counters.h" #include "merchant-database/iterate_kyc_statuses.h" #include "merchant-database/insert_kyc_status.h" #include "merchant-database/delete_contract_terms.h" @@ -7724,6 +7728,218 @@ test_pending_webhooks (void) } +/* ********** Statistics ********** */ + + +/** + * Closure for the statistics tests. + */ +struct TestStatistics_Closure +{ + /** + * Instance the statistics are collected for. + */ + struct InstanceData instance; +}; + + +/** + * Closure for #count_bucket_counter_cb(). + */ +struct CountBuckets_Closure +{ + /** + * Number of bucket rows we were called with. + */ + unsigned int count; +}; + + +/** + * Runs @a sql directly on the database connection of the test. Used to + * set up statistics state that has no dedicated MERCHANTDB entry point + * (the statistics tables are only ever written by triggers). + * + * @param sql the SQL statement(s) to execute + * @return 0 on success, 1 otherwise. + */ +static int +statistics_exec_sql (const char *sql) +{ + struct GNUNET_PQ_ExecuteStatement es[] = { + GNUNET_PQ_make_execute (sql), + GNUNET_PQ_EXECUTE_STATEMENT_END + }; + + TEST_COND_RET_ON_FAIL (GNUNET_OK == + GNUNET_PQ_exec_statements (pg->conn, + es), + "Direct SQL execution failed\n"); + return 0; +} + + +/** + * Counts the bucket rows we are called for. + * + * @param cls a `struct CountBuckets_Closure *` + * @param description description of the statistic + * @param bucket_start start time of the bucket + * @param bucket_end end time of the bucket + * @param bucket_range range of the bucket + * @param cumulative_counter counter value + */ +static void +count_bucket_counter_cb (void *cls, + const char *description, + struct GNUNET_TIME_Timestamp bucket_start, + struct GNUNET_TIME_Timestamp bucket_end, + const char *bucket_range, + uint64_t cumulative_counter) +{ + struct CountBuckets_Closure *cbc = cls; + + (void) description; + (void) bucket_start; + (void) bucket_end; + (void) bucket_range; + (void) cumulative_counter; + cbc->count++; +} + + +/** + * Determines how many bucket rows exist for @a slug. + * + * @param instance the instance to look at. + * @param slug the statistic to count the buckets of. + * @param[out] count set to the number of bucket rows. + * @return 0 on success, 1 otherwise. + */ +static int +count_bucket_counters (const struct InstanceData *instance, + const char *slug, + unsigned int *count) +{ + struct CountBuckets_Closure cbc = { + .count = 0 + }; + + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + TEST_COND_RET_ON_FAIL (0 <= + TALER_MERCHANTDB_iterate_statistic_bucket_counters ( + pg, + instance->instance.id, + slug, + &count_bucket_counter_cb, + &cbc), + "Lookup of bucket statistics failed\n"); + *count = cbc.count; + return 0; +} + + +/** + * Tests that garbage collection does not throw away bucket statistics + * that are well within their retention age. Regression test: the + * retention cut-off in merchant_statistic_bucket_gc() used to be + * computed with EXTRACT(SECONDS FROM ...), which is the seconds *field* + * of the interval and thus zero for every range we use, so the cut-off + * degenerated to "now" and every bucket row was deleted on every run. + * + * @param instance the instance to collect statistics for. + * @return 0 on success, 1 otherwise. + */ +static int +test_statistics_bucket_gc (const struct InstanceData *instance) +{ + unsigned int before; + unsigned int after; + + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + TEST_RET_ON_FAIL (statistics_exec_sql ( + "CALL merchant_do_bump_number_stat" + " ('tokens-issued'" + " ,CURRENT_TIMESTAMP(0)::TIMESTAMP" + " ,1)")); + TEST_RET_ON_FAIL (count_bucket_counters (instance, + "tokens-issued", + &before)); + TEST_COND_RET_ON_FAIL (0 < before, + "Bumping a bucket statistic recorded nothing\n"); + TEST_COND_RET_ON_FAIL (GNUNET_OK == + TALER_MERCHANTDB_gc (pg), + "Garbage collection failed\n"); + TEST_RET_ON_FAIL (count_bucket_counters (instance, + "tokens-issued", + &after)); + TEST_COND_RET_ON_FAIL (before == after, + "Garbage collection deleted bucket statistics\n"); + return 0; +} + + +/** + * Prepares for the statistics tests. + * + * @param[out] cls the closure to initialize. + */ +static void +pre_test_statistics (struct TestStatistics_Closure *cls) +{ + make_instance ("test_inst_statistics", + &cls->instance); +} + + +/** + * Cleans up after the statistics tests. + * + * @param[in,out] cls the closure to clean up. + */ +static void +post_test_statistics (struct TestStatistics_Closure *cls) +{ + free_instance_data (&cls->instance); +} + + +/** + * Runs the statistics tests. + * + * @param cls the closure to use. + * @return 0 on success, 1 otherwise. + */ +static int +run_test_statistics (struct TestStatistics_Closure *cls) +{ + TEST_RET_ON_FAIL (test_insert_instance (&cls->instance, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_statistics_bucket_gc (&cls->instance)); + return 0; +} + + +/** + * Takes care of statistics testing. + * + * @return 0 on success, 1 otherwise. + */ +static int +test_statistics (void) +{ + struct TestStatistics_Closure test_cls; + int test_result; + + pre_test_statistics (&test_cls); + test_result = run_test_statistics (&test_cls); + post_test_statistics (&test_cls); + return test_result; +} + + /** * Function that runs all tests. * @@ -7743,6 +7959,7 @@ run_tests (void) TEST_RET_ON_FAIL (test_templates ()); TEST_RET_ON_FAIL (test_webhooks ()); TEST_RET_ON_FAIL (test_pending_webhooks ()); + TEST_RET_ON_FAIL (test_statistics ()); return 0; }