I was working with seemingly simple load job in Redshift. I was generating some data and wanted to store the output in separate table, just to persist it for debugging purposes. However, since the job could be reran if needed, I wanted to remove rows generated previously and add new ones. So I tried the following:
1 2 3 4 |
CREATE TABLE locks_tests ( market INT, value INT ); |
1 2 3 4 5 6 |
INSERT INTO locks_tests(market, value) VALUES (1, 1), (1, 2), (2, 1), (2, 2) ; |
This is our table. Let’s assume that job can generate data for market 1 or market 2. So the job for market 1 does this:
1 2 |
DELETE FROM locks_tests WHERE market = 1; INSERT INTO locks_tests VALUES (1, 1), (1,2); |
Similarly, job for market 2 does this:
1 2 |
DELETE FROM locks_tests WHERE market = 2; INSERT INTO locks_tests VALUES (2, 1), (2,2); |
As you can see, both jobs use the same table but access different rows.
Now the thing is, if you try running both transactions in parallel, most likely you will get an error that transactions collide and one of them had to be aborted.
I didn’t understand why it works this way as per my understanding there is nothing wrong. Either it should use table lock and one transaction should wait for the other, ot it should use some range locks and figure out that there is nothing wrong.
Unfortunately, it is not that simple. Since Redshift is based on Postgresql, we can reproduce the problem over there as well. For instance, see dba.stackexchange.
Okay, we know what the problem is, how to solve it? I considered locking tables explicitly with LOCK statement but I never find it nice. I checked if it is possible to control partitions of the table but it doesn’t seem to be allowed in Redshift. I thought about doing so called “poor men’s clustering” with multiple tables joined using single view, but I didn’t want to increase the complexity.
Finally, I decided to go with simple MVCC-like approach. Add new column to the table, indicating timestamp of inserted row:
1 2 3 4 5 |
CREATE TABLE locks_tests ( market INT, value INT, insert_time TIMESTAMP ); |
Now, when inserting data to the table, use this:
1 2 3 |
INSERT INTO locks_tests(market, value) VALUES (1, 1, getdate()) ; |
Finally, prepare one view extracting only latest data:
1 2 3 4 5 6 7 8 9 10 11 |
CREATE OR REPLACE VIEW locks_tests_view AS ( WITH max_timestamps AS ( SELECT market, MAX(insert_time) AS max_insert_time FROM locks_tests GROUP BY market ) SELECT C.* FROM locks_tests AS C JOIN max_timestamps AS T ON T.market = C.market WHERE C.insert_time = T.max_insert_time ); |
And now query the view instead of the original table. You can also run deletion job periodically if needed.