0

I encountered a very strange behaviour and am hoping the experts here can help me explain why the phenomenon occurs.

I have the following table and function definitions in PostgreSQL:

CREATE TABLE test_table (
    "Id" text PRIMARY KEY,
    "Counter" int NOT NULL
);

CREATE UNIQUE INDEX idx_test_table_id ON test_table("Id");

CREATE OR REPLACE FUNCTION public.test_func(id text)
RETURNS int
AS $$
DECLARE counter int;
BEGIN
    INSERT INTO public.test_table
    VALUES (id, 2)
    ON CONFLICT ("Id")
    DO UPDATE SET "Counter" = public.test_table."Counter" + 1
    RETURNING "Counter" - 1
    INTO counter;
        
    RETURN counter;
END
$$
LANGUAGE plpgsql;

I have a test client that calls the function in a loop, asynchronously, and using the same ID.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;

namespace Sandbox
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public async Task TestMethod1()
        {
            int id = new Random().Next();

            IList<Task> tasks = new List<Task>();

            for (int i = 0; i < 80; i++)
            {
                tasks.Add(ExecutePgFunctionAsync(id.ToString()));
            }

            await Task.WhenAll(tasks.ToArray());
        }

        private async Task ExecutePgFunctionAsync(string id)
        {
            NpgsqlConnection conn = new NpgsqlConnection("Database=sandbox;Host=localhost;Password=runsmarter;Pooling=True;Port=12000;Timeout=15;Username=postgres;Include Error Detail=True");
            await conn.OpenAsync();

            using (NpgsqlCommand command = new NpgsqlCommand("test_func", conn))
            {
                try
                {
                    command.CommandType = CommandType.StoredProcedure;
                    command.Parameters.AddWithValue("id", id);
                    await command.ExecuteNonQueryAsync();
                }
                finally
                {
                    await command.Connection.CloseAsync();
                }
            }
        }
    }
}

Using the above definitions, everything is fine. However, if I change the unique index to:

CREATE UNIQUE INDEX idx_test_table_id ON test_table(LOWER("Id"));

I will begin to get the following error sporadically:

Npgsql.PostgresException: 23505: duplicate key value violates unique constraint "idx_test_table_id"

If I further add LOWER() to the conflict condition, i.e.:

ON CONFLICT (LOWER("Id"))

The error changes to:

Npgsql.PostgresException: 23505: duplicate key value violates unique constraint "test_table_pkey"

Why do these errors occur?

Addendum

I re-ran the original code almost as-is on a fresh installation of Visual Studio and PostgreSQL. I added a catch clause to ExecutePgFunctionAsync() in the hope of providing more diagnostic data.

With:

enter image description here

I get the following exception:

enter image description here

And the table is in the following state at the time of exception (please note the value of Counter will vary across runs):

enter image description here

With:

enter image description here

I get the following exception:

enter image description here

And the table is in the following state at the time of exception:

enter image description here

Zephyr
  • 83
  • 6
  • Either you are wrong, and there are some upper case letters in the database, or you have a corrupted index. Find the rows it is complaining about! See if a `REINDEX` fixes the problem. – Laurenz Albe Sep 03 '21 at 19:18
  • This cannot be the function you are calling. You would get `ERROR: invalid reference to FROM-clause entry for table "test_table" LINE 15: SET "Counter" = public.test_table."Counter" + 1` (You cannot schema-qualify the table name there.) Please provide the function you actually execute. Maybe you messed up your [`search_path`](https://stackoverflow.com/a/9067777/939860) setting and there is another instance of the function `test_func()` in another schema? Or you messed up with a manual edit to hide real names? – Erwin Brandstetter Sep 03 '21 at 21:05
  • The final lines of your post don't add up. If you actually change the function to use `ON CONFLICT (LOWER("Id"))`, you cannot get the unique violation from the PK. Any such duplicate would be caught by `LOWER("Id")` as well. It cannot be as you report it. – Erwin Brandstetter Sep 03 '21 at 22:18
  • Thank you for the edit that removes DROP SCHEMA. That was an oversight on my part. – Zephyr Sep 04 '21 at 03:56
  • I have added some screenshots to capture my observations. – Zephyr Sep 04 '21 at 04:06
  • Have you run `REINDEX` on the table? Also not clear to me why you are using text for an integer value? – Adrian Klaver Sep 04 '21 at 17:50
  • You claim that you have only integer values developed from *integer-converted string* (btw: a very very bad idea) , however Postgres seems to disagree; I would bet on Postgres. **So test that claim**. Since upper case and lower case representation of digits is the same try `select * from test_table where upper(id) <> lower(id)` or an alternative a regular expresion `select * from test_table where id !~ '^\d+$`. If either returns anything then you have falsified your claim. If nothing returned you have verified your claim, but your index on lower(id) serves no purpose. – Belayer Sep 04 '21 at 20:44
  • I appreciate your notes regarding the field type, but please understand that my question seeks to clarify why the errors occur, and why they occur even when ``LOWER()`` should have no effect on the field value (e.g. integer strings). Also, the query produced no results. – Zephyr Sep 04 '21 at 22:26
  • @AdrianKlaver Unfortunately ``REINDEX`` does not prevent the errors from occurring. – Zephyr Sep 04 '21 at 22:27
  • So explain this: "... using the same ID within each run."? – Adrian Klaver Sep 04 '21 at 23:49
  • On each run of the test case, a single random number is generated. The string version of that number is then passed in as ID to all invocations of the DB function. No longer addressing your question, but the expectation is that the first transaction will INSERT and all subsequent transactions will UPDATE. – Zephyr Sep 05 '21 at 00:09

1 Answers1

0

Not really an answer, just some ideas that are too long to fit in a comment:

  1. I set up a test case locally and could not get a duplicate error even when running up to 10,000 inserts. Granted I was not using Npgsql, just an anonymous function in psql. My guess is that this is maybe the reason I succeeded, no async. More on that below.

  2. From here On CONFLICT:

INSERT with an ON CONFLICT DO UPDATE clause is a “deterministic” statement. This means that the command will not be allowed to affect any single existing row more than once; a cardinality violation error will be raised when this situation arises. Rows proposed for insertion should not duplicate each other in terms of attributes constrained by an arbiter index or constraint.

  1. Also from the above link:

conflict_target can perform unique index inference. When performing inference, it consists of one or more index_column_name columns and/or index_expression expressions, and an optional index_predicate. All table_name unique indexes that, without regard to order, contain exactly the conflict_target-specified columns/expressions are inferred (chosen) as arbiter indexes.

  1. My suspicion is that the async nature of the inserts is tripping 2) and/or 3). Especially since there are two UNIQUE indexes on "Id". Though that does not explain why LOWER("Id") seems to be a problem.

  2. I am still not sure what the the purpose is of using text field for an integer value? Nor why if the values are all going to be integers you would use LOWER("Id")?

  3. To have a better chance at getting a definitive answer I would suggest that you post to the pgsql-general mailing list. Though be prepared for questions on the points in 5).

Adrian Klaver
  • 15,886
  • 2
  • 17
  • 28
  • Thank you for your suggestions, I will examine the linked section more closely once I'm home. To provide a response for 5) - the field type is text in order to facilitate the use of ``LOWER()``, which produces the errors in the first place; I use integer values to further illustrate the oddity of the situation, that the errors persist even when ``LOWER()`` has no effect on the field value. I agree that the field type would not be ideal in other circumstances. – Zephyr Sep 05 '21 at 19:13