merchant

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

commit 9458a7ddacc7b3939777fa9a5b802484451849a5
parent cafe3b2c93136904859a5ccbd54c1249beba0983
Author: Christian Grothoff <christian@grothoff.org>
Date:   Thu,  6 Aug 2026 00:13:34 +0200

fix statistics update to user proper INSERT ... ON CONFICT DO UPDATE-pattern

Diffstat:
Msrc/backenddb/pg_statistics_helpers.sql | 186+++++++++++++++++++++++++++++++++++--------------------------------------------
Msrc/backenddb/test_merchantdb.c | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 232 insertions(+), 104 deletions(-)

diff --git a/src/backenddb/pg_statistics_helpers.sql b/src/backenddb/pg_statistics_helpers.sql @@ -52,24 +52,19 @@ BEGIN INTO my_bucket_start FROM merchant.interval_to_start (in_timestamp, my_range); - UPDATE merchant_statistic_bucket_counter - SET cumulative_number = cumulative_number + in_delta - WHERE bmeta_serial_id=my_meta - AND bucket_start=my_bucket_start - AND bucket_range=my_range; - IF NOT FOUND - THEN - INSERT INTO merchant_statistic_bucket_counter - (bmeta_serial_id - ,bucket_start - ,bucket_range - ,cumulative_number - ) VALUES ( - my_meta - ,my_bucket_start - ,my_range - ,in_delta); - END IF; + INSERT INTO merchant_statistic_bucket_counter AS msbc + (bmeta_serial_id + ,bucket_start + ,bucket_range + ,cumulative_number + ) VALUES ( + my_meta + ,my_bucket_start + ,my_range + ,in_delta) + ON CONFLICT (bmeta_serial_id, bucket_start, bucket_range) + DO UPDATE SET + cumulative_number = msbc.cumulative_number + in_delta; END LOOP; CLOSE my_curs; END $$; @@ -112,41 +107,34 @@ BEGIN INTO my_bucket_start FROM merchant.interval_to_start (in_timestamp, my_range); - UPDATE merchant_statistic_bucket_amount - SET - cumulative_value = cumulative_value + (in_delta).val - + CASE - WHEN (in_delta).frac + cumulative_frac >= 100000000 - THEN 1 - ELSE 0 - END, - cumulative_frac = cumulative_frac + (in_delta).frac - - CASE - WHEN (in_delta).frac + cumulative_frac >= 100000000 - THEN 100000000 - ELSE 0 - END - WHERE bmeta_serial_id=my_meta - AND curr=(in_delta).curr - AND bucket_start=my_bucket_start - AND bucket_range=my_range; - IF NOT FOUND - THEN - INSERT INTO merchant_statistic_bucket_amount - (bmeta_serial_id - ,bucket_start - ,bucket_range - ,curr - ,cumulative_value - ,cumulative_frac - ) VALUES ( - my_meta - ,my_bucket_start - ,my_range - ,(in_delta).curr - ,(in_delta).val - ,(in_delta).frac); - END IF; + INSERT INTO merchant_statistic_bucket_amount AS msba + (bmeta_serial_id + ,bucket_start + ,bucket_range + ,curr + ,cumulative_value + ,cumulative_frac + ) VALUES ( + my_meta + ,my_bucket_start + ,my_range + ,(in_delta).curr + ,(in_delta).val + ,(in_delta).frac) + ON CONFLICT (bmeta_serial_id, curr, bucket_start, bucket_range) + DO UPDATE SET + cumulative_value = msba.cumulative_value + (in_delta).val + + CASE + WHEN (in_delta).frac + msba.cumulative_frac >= 100000000 + THEN 1 + ELSE 0 + END, + cumulative_frac = msba.cumulative_frac + (in_delta).frac + - CASE + WHEN (in_delta).frac + msba.cumulative_frac >= 100000000 + THEN 100000000 + ELSE 0 + END; END LOOP; CLOSE my_curs; END $$; @@ -223,23 +211,19 @@ BEGIN RETURNING nevent_serial_id INTO my_event; - UPDATE merchant_statistic_interval_counter - SET cumulative_number = cumulative_number + in_delta - WHERE imeta_serial_id = my_meta - AND range=my_rangex; - IF NOT FOUND - THEN - INSERT INTO merchant_statistic_interval_counter - (imeta_serial_id - ,range - ,event_delimiter - ,cumulative_number - ) VALUES ( - my_meta - ,my_rangex - ,my_event - ,in_delta); - END IF; + INSERT INTO merchant_statistic_interval_counter AS msic + (imeta_serial_id + ,range + ,event_delimiter + ,cumulative_number + ) VALUES ( + my_meta + ,my_rangex + ,my_event + ,in_delta) + ON CONFLICT (imeta_serial_id, range) + DO UPDATE SET + cumulative_number = msic.cumulative_number + in_delta; END $$; COMMENT ON PROCEDURE merchant_do_bump_number_interval_stat @@ -330,40 +314,34 @@ BEGIN RETURNING aevent_serial_id INTO my_event; - UPDATE merchant_statistic_interval_amount - SET - cumulative_value = cumulative_value + (in_delta).val - + CASE - WHEN (in_delta).frac + cumulative_frac >= 100000000 - THEN 1 - ELSE 0 - END, - cumulative_frac = cumulative_frac + (in_delta).frac - - CASE - WHEN (in_delta).frac + cumulative_frac >= 100000000 - THEN 100000000 - ELSE 0 - END - WHERE imeta_serial_id=my_meta - AND range=my_rangex - AND curr=(in_delta).curr; - IF NOT FOUND - THEN - INSERT INTO merchant_statistic_interval_amount - (imeta_serial_id - ,range - ,event_delimiter - ,curr - ,cumulative_value - ,cumulative_frac - ) VALUES ( - my_meta - ,my_rangex - ,my_event - ,(in_delta).curr - ,(in_delta).val - ,(in_delta).frac); - END IF; + INSERT INTO merchant_statistic_interval_amount AS msia + (imeta_serial_id + ,range + ,event_delimiter + ,curr + ,cumulative_value + ,cumulative_frac + ) VALUES ( + my_meta + ,my_rangex + ,my_event + ,(in_delta).curr + ,(in_delta).val + ,(in_delta).frac) + ON CONFLICT (imeta_serial_id, curr, range) + DO UPDATE SET + cumulative_value = msia.cumulative_value + (in_delta).val + + CASE + WHEN (in_delta).frac + msia.cumulative_frac >= 100000000 + THEN 1 + ELSE 0 + END, + cumulative_frac = msia.cumulative_frac + (in_delta).frac + - CASE + WHEN (in_delta).frac + msia.cumulative_frac >= 100000000 + THEN 100000000 + ELSE 0 + END; END $$; COMMENT ON PROCEDURE merchant_do_bump_amount_interval_stat IS 'Updates an amount statistic tracked over an interval'; diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -30,6 +30,7 @@ #include "merchantdb_lib.h" #include "helper.h" #include "merchant-database/gc.h" +#include "merchant-database/start.h" #include "merchant-database/iterate_statistic_bucket_amounts.h" #include "merchant-database/iterate_statistic_bucket_counters.h" #include "merchant-database/iterate_kyc_statuses.h" @@ -130,6 +131,12 @@ static int result; static struct TALER_MERCHANTDB_PostgresContext *pg; /** + * Configuration of the database we are testing. Used by tests that + * need a second, concurrent connection. + */ +static const struct GNUNET_CONFIGURATION_Handle *test_cfg; + +/** * @param test 0 on success, non-zero on failure */ #define TEST_WITH_FAIL_CLAUSE(test, on_fail) \ @@ -10248,6 +10255,147 @@ test_statistics_counter_gc_delimiter (const struct InstanceData *instance) /** + * SQL bumping the 'tokens-used' statistic by one. + */ +#define BUMP_TOKENS_USED \ + "CALL merchant_do_bump_number_stat" \ + " ('tokens-used'" \ + " ,CURRENT_TIMESTAMP(0)::TIMESTAMP" \ + " ,1)" + + +/** + * Determines the largest counter value stored for @a slug. + * + * @param instance the instance to look at. + * @param slug the statistic to look at. + * @param[out] value set to the largest bucket counter. + * @return 0 on success, 1 otherwise. + */ +static int +statistics_max_bucket_counter (const struct InstanceData *instance, + const char *slug, + uint64_t *value) +{ + 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", + value), + 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 COALESCE(MAX(cumulative_number),0) AS num" + " FROM merchant_statistic_bucket_counter" + " WHERE bmeta_serial_id =" + " (SELECT bmeta_serial_id" + " FROM merchant_statistic_bucket_meta" + " WHERE slug=$1" + " AND stype='number')"), + "Preparing counter lookup failed\n"); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs), + "Looking up bucket counter failed\n"); + return 0; +} + + +/** + * Bumps the 'tokens-used' statistic from a second process while this + * process still holds the very first (uncommitted) bump of the same + * bucket. Regression test: the bucket statistics used to be written + * as UPDATE-then-INSERT, so the second writer got a unique violation + * (which GNUnet maps to SUCCESS_NO_RESULTS) and its event was lost; + * in the pay path the very same pattern surfaces to the wallet as a + * bogus 409 on a perfectly valid payment. + * + * @param instance the instance to collect statistics for. + * @return 0 on success, 1 otherwise. + */ +static int +test_statistics_bucket_upsert_race (const struct InstanceData *instance) +{ + pid_t child; + int status; + uint64_t value; + + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + /* Make sure the bucket does not exist yet, only then do both writers + race to create it. */ + TEST_RET_ON_FAIL (statistics_exec_sql ( + "DELETE FROM merchant_statistic_bucket_counter" + " WHERE bmeta_serial_id =" + " (SELECT bmeta_serial_id" + " FROM merchant_statistic_bucket_meta" + " WHERE slug='tokens-used'" + " AND stype='number')")); + TEST_COND_RET_ON_FAIL (GNUNET_OK == + TALER_MERCHANTDB_start_read_committed ( + pg, + "bucket statistics upsert race"), + "Failed to start transaction\n"); + TEST_RET_ON_FAIL (statistics_exec_sql (BUMP_TOKENS_USED)); + child = fork (); + if (-1 == child) + { + TALER_MERCHANTDB_rollback (pg); + GNUNET_break (0); + return 1; + } + if (0 == child) + { + struct TALER_MERCHANTDB_PostgresContext *cpg; + int ret = 1; + + /* Second writer: blocks on the uncommitted row of the parent until + the parent commits below. */ + cpg = TALER_MERCHANTDB_connect (test_cfg); + if ( (NULL != cpg) && + (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_set_instance (cpg, + instance->instance.id)) ) + { + struct GNUNET_PQ_ExecuteStatement es[] = { + GNUNET_PQ_make_execute (BUMP_TOKENS_USED), + GNUNET_PQ_EXECUTE_STATEMENT_END + }; + + if (GNUNET_OK == + GNUNET_PQ_exec_statements (cpg->conn, + es)) + ret = 0; + } + _exit (ret); + } + sleep (1); /* give the child time to reach the conflicting INSERT */ + TEST_COND_RET_ON_FAIL (0 <= + TALER_MERCHANTDB_commit (pg), + "Failed to commit transaction\n"); + if (0 >= waitpid (child, + &status, + 0)) + sleep (2); /* cannot reap the child, just give it time to finish */ + TEST_RET_ON_FAIL (statistics_max_bucket_counter (instance, + "tokens-used", + &value)); + TEST_COND_RET_ON_FAIL (2 == value, + "Concurrent statistics bump was lost\n"); + return 0; +} + + +/** * Prepares for the statistics tests. * * @param[out] cls the closure to initialize. @@ -10306,6 +10454,7 @@ run_test_statistics (struct TestStatistics_Closure *cls) "deposits-fees-paid")); TEST_RET_ON_FAIL (test_statistics_counter_gc_delimiter (&cls->instance)); TEST_RET_ON_FAIL (test_statistics_dual_type_slug (&cls->instance)); + TEST_RET_ON_FAIL (test_statistics_bucket_upsert_race (&cls->instance)); return 0; } @@ -10365,6 +10514,7 @@ run (void *cls) { struct GNUNET_CONFIGURATION_Handle *cfg = cls; + test_cfg = cfg; if (NULL == (pg = TALER_MERCHANTDB_connect (cfg))) { result = 77;