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:
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 ());