merchant

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

commit d67d0a862d7a23e7d9a3f15d0920b1ba6450d7a6
parent 3bca078e5fe5730303f4bb32ab1b886c912748c2
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed,  5 Aug 2026 23:56:48 +0200

address FIXME: make order deletion atomic

Diffstat:
Msrc/backenddb/delete_order.c | 53+++++++++++++++++++++--------------------------------
Asrc/backenddb/delete_order.sql | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/sql-schema/meson.build | 1+
Msrc/backenddb/test_merchantdb.c | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 211 insertions(+), 32 deletions(-)

diff --git a/src/backenddb/delete_order.c b/src/backenddb/delete_order.c @@ -38,44 +38,33 @@ TALER_MERCHANTDB_delete_order ( GNUNET_PQ_query_param_bool (force), GNUNET_PQ_query_param_end }; - struct GNUNET_PQ_QueryParam params2[] = { - GNUNET_PQ_query_param_string (order_id), - GNUNET_PQ_query_param_end + bool deleted; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_bool ("deleted", + &deleted), + GNUNET_PQ_result_spec_end }; enum GNUNET_DB_QueryStatus qs; - enum GNUNET_DB_QueryStatus qs2; GNUNET_assert (NULL != pg->current_merchant_id); GNUNET_assert (0 == strcmp (instance_id, pg->current_merchant_id)); - // FIXME: replace two statements by stored procedure! + /* Note: this MUST be a single stored procedure: deleting the order and + the associated contract terms in two separate statements would run in + two separate transactions (the caller does not open one), and a failure + between them would orphan the contract terms. */ TMH_PQ_prepare_anon (pg, - "WITH mc AS" - "(SELECT paid" - " FROM merchant_contract_terms" - " WHERE order_id=$1) " - "DELETE" - " FROM merchant_orders mo" - " WHERE order_id=$1" - " AND ( (pay_deadline < $2)" - " OR (NOT EXISTS (SELECT paid FROM mc))" - " OR ($3 AND (FALSE=(SELECT paid FROM mc))) );"); - qs = GNUNET_PQ_eval_prepared_non_select (pg->conn, - "", - params); - if ( (qs < 0) || (! force) ) + "SELECT" + " out_deleted AS deleted" + " FROM merchant_do_delete_order" + "($1,$2,$3);"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs); + if (qs <= 0) return qs; - TMH_PQ_prepare_anon (pg, - "DELETE" - " FROM merchant_contract_terms" - " WHERE order_id=$1" - " AND NOT paid;"); - qs2 = GNUNET_PQ_eval_prepared_non_select (pg->conn, - "", - params2); - if (qs2 < 0) - return qs2; - if (qs2 > 0) - return qs2; - return qs; + return deleted + ? GNUNET_DB_STATUS_SUCCESS_ONE_RESULT + : GNUNET_DB_STATUS_SUCCESS_NO_RESULTS; } diff --git a/src/backenddb/delete_order.sql b/src/backenddb/delete_order.sql @@ -0,0 +1,70 @@ +-- +-- This file is part of TALER +-- Copyright (C) 2026 Taler Systems SA +-- +-- TALER is free software; you can redistribute it and/or modify it under the +-- terms of the GNU General Public License as published by the Free Software +-- Foundation; either version 3, or (at your option) any later version. +-- +-- TALER is distributed in the hope that it will be useful, but WITHOUT ANY +-- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +-- A PARTICULAR PURPOSE. See the GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License along with +-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +-- + +DROP FUNCTION IF EXISTS merchant_do_delete_order; +CREATE FUNCTION merchant_do_delete_order ( + IN in_order_id TEXT, + IN in_now INT8, + IN in_force BOOLEAN, + OUT out_deleted BOOLEAN) +LANGUAGE plpgsql +AS $$ +DECLARE + my_paid BOOLEAN; + my_claimed BOOLEAN; +BEGIN + +-- An order that was claimed by a wallet has a row in +-- merchant_contract_terms; the row in merchant_orders (if any) then +-- merely holds the not-yet-claimed proposal. Both deletions must +-- happen together, or a failure between them would leave the contract +-- terms behind without the order. Doing both in one stored procedure +-- gives us that atomicity even though the caller runs in autocommit. +SELECT paid + INTO my_paid + FROM merchant_contract_terms + WHERE order_id=in_order_id + FOR UPDATE; +my_claimed = FOUND; + +DELETE FROM merchant_orders + WHERE order_id=in_order_id + AND ( (pay_deadline < in_now) + OR (NOT my_claimed) + OR (in_force AND (NOT my_paid)) ); +out_deleted = FOUND; + +IF NOT in_force +THEN + RETURN; +END IF; + +DELETE FROM merchant_contract_terms + WHERE order_id=in_order_id + AND NOT paid; +IF FOUND +THEN + out_deleted = TRUE; +END IF; + +END $$; + +COMMENT ON FUNCTION merchant_do_delete_order(TEXT, INT8, BOOLEAN) + IS 'Deletes an order. The unclaimed proposal is removed if the pay deadline' + ' has passed, if the order was never claimed, or (with in_force) if it' + ' was claimed but not paid. With in_force the claimed-but-unpaid' + ' contract terms are removed in the same transaction. Returns' + ' out_deleted=TRUE if anything was deleted.'; diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build @@ -35,6 +35,7 @@ sql_instance_procedures = [ '../pg_statistics_helpers.sql', '../pg_do_handle_inventory_changes.sql', '../pg_do_handle_category_changes.sql', + '../delete_order.sql', '../delete_product.sql', '../delete_unit.sql', '../insert_unit.sql', diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -3333,6 +3333,113 @@ test_delete_order (const struct InstanceData *instance, /** + * Runs @a sql directly on the database connection of the test. Used to + * install the fault-injection trigger of #test_delete_order_atomic(). + * + * @param sql the SQL statement(s) to execute + * @return 0 on success, 1 otherwise. + */ +static int +orders_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; +} + + +/** + * Tests that a forced order deletion removes the row in + * merchant_orders and the row in merchant_contract_terms atomically. + * + * The caller of TALER_MERCHANTDB_delete_order() does not open a + * transaction, so doing this in two statements would run them in two + * separate autocommit transactions: the first would stay committed + * when the second one fails, leaving orphaned contract terms behind. + * We provoke exactly that by installing a trigger that makes the + * deletion of the contract terms fail, and then check that the order + * itself survived. + * + * @param instance the instance to delete the order from. + * @param order the order to delete; must be claimed and unpaid. + * @return 0 on success, 1 otherwise. + */ +static int +test_delete_order_atomic (const struct InstanceData *instance, + const struct OrderData *order) +{ + struct TALER_MerchantPostDataHashP h_post_data; + + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + TEST_RET_ON_FAIL (orders_exec_sql ( + "CREATE FUNCTION test_block_ct_delete ()" + " RETURNS TRIGGER" + " LANGUAGE plpgsql" + " AS $$" + " BEGIN" + " RAISE EXCEPTION" + " 'simulated failure deleting contract terms';" + " END $$;" + "CREATE TRIGGER test_block_ct_delete_trigger" + " BEFORE DELETE ON merchant_contract_terms" + " FOR EACH ROW" + " EXECUTE FUNCTION test_block_ct_delete ();")); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_HARD_ERROR == + TALER_MERCHANTDB_delete_order (pg, + instance->instance.id, + order->id, + true), + "Deleting the order should have failed\n"); + TEST_RET_ON_FAIL (orders_exec_sql ( + "DROP TRIGGER test_block_ct_delete_trigger" + " ON merchant_contract_terms;" + "DROP FUNCTION test_block_ct_delete ();")); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_get_order (pg, + instance->instance.id, + order->id, + NULL, + &h_post_data, + NULL), + "Order was deleted even though deleting its" + " contract terms failed\n"); + /* Without the trigger in the way, both rows must now go. */ + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_delete_order (pg, + instance->instance.id, + order->id, + true), + "Forced deletion of a claimed, unpaid order failed\n"); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == + TALER_MERCHANTDB_get_order (pg, + instance->instance.id, + order->id, + NULL, + &h_post_data, + NULL), + "Order survived its deletion\n"); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == + TALER_MERCHANTDB_get_contract_terms ( + pg, + instance->instance.id, + order->id, + NULL, + NULL, + NULL), + "Contract terms survived the order deletion\n"); + return 0; +} + + +/** * Test inserting contract terms for an order. * * @param instance the instance. @@ -3995,6 +4102,18 @@ run_test_orders (struct TestOrders_Closure *cls) TEST_RET_ON_FAIL (test_delete_order (&cls->instance, &cls->orders[2], GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + + /* Deleting an order and its contract terms must be atomic. + orders[1] was fully removed above, so we can re-create it, this + time claimed (=> with contract terms) and unpaid. */ + TEST_RET_ON_FAIL (test_insert_order (&cls->instance, + &cls->orders[1], + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_insert_contract_terms (&cls->instance, + &cls->orders[1], + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_delete_order_atomic (&cls->instance, + &cls->orders[1])); return 0; }