merchant

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

commit 53a94687260c75ab3febf17f6f76c25bb7ff1eb3
parent d52b27c3fd88729312aef29b0850d59de94535fa
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed,  5 Aug 2026 23:23:55 +0200

only one lock per lock_uuid and product, re-posting the lock is supposed to update, not create another one

Diffstat:
Msrc/backenddb/insert_inventory_lock.c | 67++++++++++++++++++++++++-------------------------------------------
Msrc/backenddb/sql-schema/merchant-0043.sql | 19+++++++++++++++++++
Msrc/backenddb/sql-schema/meson.build | 2++
Msrc/backenddb/test_merchantdb.c | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 325 insertions(+), 43 deletions(-)

diff --git a/src/backenddb/insert_inventory_lock.c b/src/backenddb/insert_inventory_lock.c @@ -42,52 +42,33 @@ TALER_MERCHANTDB_insert_inventory_lock ( GNUNET_PQ_query_param_timestamp (&expiration_time), GNUNET_PQ_query_param_end }; + bool no_product = false; + bool insufficient_stock = false; + struct GNUNET_PQ_ResultSpec rs[] = { + GNUNET_PQ_result_spec_bool ("out_no_product", + &no_product), + GNUNET_PQ_result_spec_bool ("out_insufficient_stock", + &insufficient_stock), + GNUNET_PQ_result_spec_end + }; + enum GNUNET_DB_QueryStatus qs; GNUNET_assert (NULL != pg->current_merchant_id); GNUNET_assert (0 == strcmp (instance_id, pg->current_merchant_id)); TMH_PQ_prepare_anon (pg, - "WITH tmp AS" - " (SELECT" - " mi.product_serial" - " ,mi.total_stock" - " ,mi.total_stock_frac" - " ,mi.total_sold" - " ,mi.total_sold_frac" - " ,mi.total_lost" - " ,mi.total_lost_frac" - " ,mi.allow_fractional_quantity" - " FROM merchant_inventory mi" - " WHERE mi.product_id=$1)" - "INSERT INTO merchant_inventory_locks" - "(product_serial" - ",lock_uuid" - ",total_locked" - ",total_locked_frac" - ",expiration)" - " SELECT tmp.product_serial, $2, $3::INT8, $4::INT4, $5" - " FROM tmp" - " WHERE (tmp.allow_fractional_quantity OR $4 = 0)" - " AND (tmp.total_stock = 9223372036854775807" - " OR (" - " (tmp.total_stock::NUMERIC * 1000000" - " + tmp.total_stock_frac::NUMERIC)" - " - (tmp.total_sold::NUMERIC * 1000000" - " + tmp.total_sold_frac::NUMERIC)" - " - (tmp.total_lost::NUMERIC * 1000000" - " + tmp.total_lost_frac::NUMERIC)" - " >= " - " (($3::NUMERIC * 1000000) + $4::NUMERIC)" - " + (SELECT COALESCE(SUM(total_locked::NUMERIC * 1000000" - " + total_locked_frac::NUMERIC), 0)" - " FROM merchant_inventory_locks mil" - " WHERE mil.product_serial = tmp.product_serial)" - " + (SELECT COALESCE(SUM(total_locked::NUMERIC * 1000000" - " + total_locked_frac::NUMERIC), 0)" - " FROM merchant_order_locks mol" - " WHERE mol.product_serial = tmp.product_serial)" - " ))"); - return GNUNET_PQ_eval_prepared_non_select (pg->conn, - "", - params); + "SELECT" + " out_no_product" + " ,out_insufficient_stock" + " FROM merchant_do_insert_inventory_lock" + " ($1, $2, $3, $4, $5);"); + qs = GNUNET_PQ_eval_prepared_singleton_select (pg->conn, + "", + params, + rs); + if (qs <= 0) + return qs; + if (no_product || insufficient_stock) + return GNUNET_DB_STATUS_SUCCESS_NO_RESULTS; + return qs; } diff --git a/src/backenddb/sql-schema/merchant-0043.sql b/src/backenddb/sql-schema/merchant-0043.sql @@ -54,6 +54,25 @@ BEGIN ' UNIQUE (token_family_key_serial, h_contract_terms,' || ' blind_sig);', s); + -- A lock is identified by its UUID: re-posting a lock with a different + -- duration or quantity must update the existing lock for the same UUID + -- instead of stacking a second one. Without a unique constraint the + -- locks accumulated and the AFTER INSERT trigger counted each of them + -- towards merchant_inventory.total_locked. + -- Delete constraint-violating values first, keeping the most recently + -- written row (that is the quantity the client last asked for); the + -- AFTER DELETE trigger corrects total_locked for the removed rows. + + EXECUTE format('DELETE FROM %I.merchant_inventory_locks f' || + ' USING %I.merchant_inventory_locks d' || + ' WHERE f.product_serial = d.product_serial' || + ' AND f.lock_uuid = d.lock_uuid' || + ' AND f.ctid < d.ctid;', s, s); + EXECUTE format('ALTER TABLE %I.merchant_inventory_locks' || + ' ADD CONSTRAINT merchant_inventory_locks_product_lock_key' || + ' UNIQUE (product_serial, lock_uuid);', s); + + END $OUTER$; diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build @@ -43,6 +43,7 @@ sql_instance_procedures = [ '../iterate_statistic_interval_counters.sql', '../insert_deposit_to_transfer.sql', '../insert_product.sql', + '../insert_inventory_lock.sql', '../insert_issued_token.sql', '../insert_used_token.sql', '../insert_transfer_details.sql', @@ -123,6 +124,7 @@ generated_sql = [ ['merchant-0042.sql'], ['merchant-0043.sql'], ['merchant-0044.sql'], + ['merchant-0045.sql'], ] foreach g : generated_sql diff --git a/src/backenddb/test_merchantdb.c b/src/backenddb/test_merchantdb.c @@ -2092,6 +2092,285 @@ test_products (void) } +/* ********** Inventory locks ********** */ + + +/** + * Container for the data used by the inventory lock tests. + */ +struct TestLocks_Closure +{ + /** + * The instance to use for this test. + */ + struct InstanceData instance; + + /** + * The product we lock. + */ + struct ProductData product; +}; + + +/** + * Checks that @a product has exactly @a expected_locked units locked. + * + * @param instance the instance owning the product. + * @param product the product to inspect. + * @param expected_locked expected value of total_locked. + * @param expected_locked_frac expected value of total_locked_frac. + * @return 0 when successful, 1 otherwise. + */ +static int +test_product_locked (const struct InstanceData *instance, + const struct ProductData *product, + uint64_t expected_locked, + uint32_t expected_locked_frac) +{ + struct TALER_MERCHANTDB_ProductDetails pd; + size_t num_categories = 0; + uint64_t *categories = NULL; + int ret = 0; + + memset (&pd, + 0, + sizeof (pd)); + TEST_SET_INSTANCE (instance->instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_get_product (pg, + instance->instance.id, + product->id, + &pd, + &num_categories, + &categories), + "Lookup of locked product failed\n"); + GNUNET_free (categories); + if ( (expected_locked != pd.total_locked) || + (expected_locked_frac != pd.total_locked_frac) ) + { + GNUNET_break (0); + GNUNET_log (GNUNET_ERROR_TYPE_ERROR, + "Product `%s' has total_locked=%llu.%06u," + " expected %llu.%06u\n", + product->id, + (unsigned long long) pd.total_locked, + (unsigned int) pd.total_locked_frac, + (unsigned long long) expected_locked, + (unsigned int) expected_locked_frac); + ret = 1; + } + TALER_MERCHANTDB_product_details_free (&pd); + return ret; +} + + +/** + * Tests locking @a quantity units of @a product under @a uuid. + * + * @param instance the instance owning the product. + * @param product the product to lock. + * @param uuid identifier of the lock. + * @param quantity how many units to lock. + * @param expected_result the result we expect the db to return. + * @return 0 when successful, 1 otherwise. + */ +static int +test_insert_inventory_lock (const struct InstanceData *instance, + const struct ProductData *product, + const struct GNUNET_Uuid *uuid, + uint64_t quantity, + enum GNUNET_DB_QueryStatus expected_result) +{ + struct GNUNET_TIME_Timestamp expiration + = GNUNET_TIME_relative_to_timestamp (GNUNET_TIME_UNIT_WEEKS); + + TEST_SET_INSTANCE (instance->instance.id, + expected_result); + TEST_COND_RET_ON_FAIL (expected_result == + TALER_MERCHANTDB_insert_inventory_lock ( + pg, + instance->instance.id, + product->id, + uuid, + quantity, + 0, + expiration), + "Insert inventory lock returned unexpected status\n"); + return 0; +} + + +/** + * Sets up the data structures used in the inventory lock tests. + * + * @param cls the closure to fill with test data. + */ +static void +pre_test_locks (struct TestLocks_Closure *cls) +{ + make_instance ("test_inst_locks", + &cls->instance); + make_product ("test_locks_pd_0", + &cls->product); + cls->product.product.total_stock = 100; +} + + +/** + * Handles all teardown after testing. + * + * @param cls the closure containing memory to be freed. + */ +static void +post_test_locks (struct TestLocks_Closure *cls) +{ + free_instance_data (&cls->instance); + free_product_data (&cls->product); +} + + +/** + * Runs the tests for inventory locks. + * + * @param cls the container of the test data. + * @return 0 on success, 1 otherwise. + */ +static int +run_test_locks (struct TestLocks_Closure *cls) +{ + struct GNUNET_Uuid uuid1; + struct GNUNET_Uuid uuid2; + + memset (&uuid1, + 0, + sizeof (uuid1)); + memset (&uuid2, + 0, + sizeof (uuid2)); + uuid1.value[0] = 0x11111111; + uuid2.value[0] = 0x22222222; + TEST_RET_ON_FAIL (test_insert_instance (&cls->instance, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_insert_product (&cls->instance, + &cls->product, + 0, + NULL, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT, + false, + false, + -1)); + /* Lock 40 of the 100 units in stock */ + TEST_RET_ON_FAIL (test_insert_inventory_lock ( + &cls->instance, + &cls->product, + &uuid1, + 40, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_product_locked (&cls->instance, + &cls->product, + 40, + 0)); + /* Re-posting a lock for the same UUID *updates* the existing lock, + the locks must not stack up. */ + TEST_RET_ON_FAIL (test_insert_inventory_lock ( + &cls->instance, + &cls->product, + &uuid1, + 10, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_product_locked (&cls->instance, + &cls->product, + 10, + 0)); + /* A second lock competes with the first one for the remaining stock */ + TEST_RET_ON_FAIL (test_insert_inventory_lock ( + &cls->instance, + &cls->product, + &uuid2, + 91, + GNUNET_DB_STATUS_SUCCESS_NO_RESULTS)); + TEST_RET_ON_FAIL (test_product_locked (&cls->instance, + &cls->product, + 10, + 0)); + TEST_RET_ON_FAIL (test_insert_inventory_lock ( + &cls->instance, + &cls->product, + &uuid2, + 90, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_product_locked (&cls->instance, + &cls->product, + 100, + 0)); + /* Locking a quantity of zero releases the lock */ + TEST_RET_ON_FAIL (test_insert_inventory_lock ( + &cls->instance, + &cls->product, + &uuid1, + 0, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_product_locked (&cls->instance, + &cls->product, + 90, + 0)); + /* Unlocking an UUID that holds no lock is a no-op, not an error */ + TEST_RET_ON_FAIL (test_insert_inventory_lock ( + &cls->instance, + &cls->product, + &uuid1, + 0, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT)); + TEST_RET_ON_FAIL (test_product_locked (&cls->instance, + &cls->product, + 90, + 0)); + /* Explicitly dropping the remaining lock frees the stock again */ + TEST_SET_INSTANCE (cls->instance.instance.id, + GNUNET_DB_STATUS_SUCCESS_ONE_RESULT); + TEST_COND_RET_ON_FAIL (GNUNET_DB_STATUS_SUCCESS_ONE_RESULT == + TALER_MERCHANTDB_delete_inventory_lock (pg, + &uuid2), + "Unlock inventory failed\n"); + TEST_RET_ON_FAIL (test_product_locked (&cls->instance, + &cls->product, + 0, + 0)); + /* Locking an unknown product is reported as 'no results' */ + { + struct ProductData unknown = cls->product; + + unknown.id = "test_locks_pd_unknown"; + TEST_RET_ON_FAIL (test_insert_inventory_lock ( + &cls->instance, + &unknown, + &uuid1, + 1, + GNUNET_DB_STATUS_SUCCESS_NO_RESULTS)); + } + return 0; +} + + +/** + * Takes care of inventory lock testing. + * + * @return 0 on success, 1 otherwise. + */ +static int +test_inventory_locks (void) +{ + struct TestLocks_Closure test_cls; + int test_result; + + pre_test_locks (&test_cls); + test_result = run_test_locks (&test_cls); + post_test_locks (&test_cls); + return test_result; +} + + /* ********** Tokens ********** */ @@ -9070,6 +9349,7 @@ run_tests (void) { TEST_RET_ON_FAIL (test_instances ()); TEST_RET_ON_FAIL (test_products ()); + TEST_RET_ON_FAIL (test_inventory_locks ()); TEST_RET_ON_FAIL (test_tokens ()); TEST_RET_ON_FAIL (test_orders ()); TEST_RET_ON_FAIL (test_deposits ());