Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[10.x] Revival of the reverted changes in 10.25.0: firstOrCreate updateOrCreate improvement through createOrFirst + additional query tests #48637

Merged
merged 25 commits into from
Oct 12, 2023

Conversation

mpyw
Copy link
Contributor

@mpyw mpyw commented Oct 5, 2023

Prior Discussion

What Changes?

This PR unreverts #48531 and revives firstOrCreate updateOrCreate enhancement under createOrFirst. They were unreliable in previous versions. In high-load applications, unique key constraint errors would occur even when checking for the existence of a record beforehand. The purpose of refactoring with createOrFirst internally is to catch this and retry automatically.

Actual Implementation Changes

Coverages

  • Builder::createOrFirst by @mpyw
  • Builder::firstOrCreate by @mpyw
  • Builder::updateOrCreate by @mpyw
  • HasMany::createOrFirst by @mpyw
  • HasMany::firstOrCreate by @mpyw
  • HasMany::updateOrCreate by @mpyw
  • HasManyThrough::createOrFirst by @fuwasegu + @mpyw
  • HasManyThrough::firstOrCreate by @fuwasegu + @mpyw
  • HasManyThrough::updateOrCreate by @fuwasegu + @mpyw
  • BelongsToMany::createOrFirst by @mpyw
  • BelongsToMany::firstOrCreate by @mpyw
  • BelongsToMany::updateOrCreate by @mpyw

Notes

Some FIXME comments are remaining.

$model->getConnection()
    ->expects('select')
    // FIXME: duplicate conditions
    ->with('select * from "table" where ("attr" = ?) and ("attr" = ?) limit 1', ['foo', 'foo'], false)
    ->andReturn([[
        'id' => 123,
        'attr' => 'foo',
        'val' => 'bar',
        'created_at' => '2023-01-01 00:00:00',
        'updated_at' => '2023-01-01 00:00:00',
    ]]);

These indicate a problem with duplicate and useless conditions being given when retrying the SELECT, but do not significantly bother with the operation. Attempting to fix this using clone may yield some unexpected effects due to the Builder mutableness. Therefore, we'd like to recognize this as a known issue and leave it out of the scope of this PR.

@tonysm If you think you can fix this problem, could you please submit another PR? Thank you in advance.

@mpyw mpyw force-pushed the feat/create-or-first-optimization branch 4 times, most recently from 397211a to e4efdc3 Compare October 5, 2023 08:37
@mpyw mpyw force-pushed the feat/create-or-first-optimization branch from e4efdc3 to ebc9ba1 Compare October 5, 2023 08:48
@driesvints
Copy link
Member

I honestly suggest to just leave things be. I also don't know what this is fixing again?

@mpyw
Copy link
Contributor Author

mpyw commented Oct 5, 2023

@driesvints If you have any comments, please see #48567. I personally emailed @taylorotwell and asked him about the policy and he agreed that as long as we add a test it should be fine.


firstOrCreate updateOrCreate was unreliable in previous Laravel versions. In high-load applications, unique key constraint errors would occur even when checking for the existence of a record beforehand. The purpose of refactoring with createOrFirst internally is to catch this and retry automatically.

@mpyw mpyw force-pushed the feat/create-or-first-optimization branch 2 times, most recently from 40797ac to 877e32f Compare October 5, 2023 14:57
@mpyw mpyw force-pushed the feat/create-or-first-optimization branch from 877e32f to e5735c4 Compare October 5, 2023 15:17
@mpyw mpyw force-pushed the feat/create-or-first-optimization branch 2 times, most recently from 3141fb7 to 62455dd Compare October 5, 2023 15:43
@mpyw mpyw force-pushed the feat/create-or-first-optimization branch from a025850 to 30d4627 Compare October 6, 2023 04:51
@mpyw mpyw force-pushed the feat/create-or-first-optimization branch from 0c5ce5f to fab4ee3 Compare October 6, 2023 09:00
@mpyw mpyw marked this pull request as ready for review October 6, 2023 09:09
@mpyw
Copy link
Contributor Author

mpyw commented Oct 6, 2023

@taylorotwell @crynobone @driesvints @tonysm @ralphjsmit @ryanldy @trevorgehman @SakiTakamachi @fuwasegu Please review the changes. Thank you in advance.

@morloderex
Copy link
Contributor

morloderex commented Oct 6, 2023

@mpyw if we rely on a unique constraint and the model's attributes doesn't not include the primary key of the table. And the attributes used let's call it name in this example. Doesn't have a unique constraint on the table wouldn't this new behaviour of relying on a unique constraint violation not be a major breaking change, since the unique constraint would never actually occur from the database provider?

@mpyw
Copy link
Contributor Author

mpyw commented Oct 6, 2023

@morloderex This is not a breaking change. It works as follows.

  • Before
    • firstOrCreate() tries to retrieve through first() and calls create() if there is no result
    • updateOrCreate() tries to retrieve through first() and calls create() if there is no result, update() if there is a result.
  • After
    • firstOrCreate() tries to retrieve through first() and calls createOrFirst() if there is no result
    • updateOrCreate() tries to retrieve through first() and calls createOrFirst() if there is no result, update() if there is a result.

The create() is just changed to createOrFirst(), not that firstOrCreate() updateOrCreate() will not work without unique constraints in the future. It only means that firstOrCreate() and updateOrCreate() will work more reliably with unique constraints.

@trevorgehman
Copy link
Contributor

Doesn't have a unique constraint on the table wouldn't this new behaviour of relying on a unique constraint violation not be a major breaking change, since the unique constraint would never actually occur from the database provider?

Without a unique constraint on the table firstOrCreate() will work the same way as it does now.

@trevorgehman
Copy link
Contributor

trevorgehman commented Oct 6, 2023

I also don't know what this is fixing again?

@driesvints Essentially it solves race conditions. In high concurrency environments we can't rely on firstOrCreate() in its current form because between the first() and the create() calls, another process may have already created the record. So the create() call will throw a UniqueConstraintViolationException in that case.

We have for a long time implemented a workaround like this by extending the Builder:

public function firstOrCreate(array $attributes = [], array $values = [])
    {
        try {
            return parent::firstOrCreate($attributes, $values);
        } catch (UniqueConstraintViolationException $e) {
            return parent::first($attributes) ?? throw $e;
        }
    }

This strategy of catching UniqueConstraintViolationException is what createOrFirst() is already doing. So rather than having the same try/catch block in firstOrCreate() @mpyw is just replacing the create() call with createOrFirst():

public function createOrFirst(array $attributes = [], array $values = [])
{
try {
return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, $values)));
} catch (UniqueConstraintViolationException $e) {
return $this->useWritePdo()->where($attributes)->first() ?? throw $e;
}
}

@deleugpn
Copy link
Contributor

deleugpn commented Oct 7, 2023

I don't envy Taylor on this PR at all 😅

[✗] This is a significant change to one of Laravel's most important modules (Eloquent).
[✔️] But it seems like this was only reverted because Eloquent already had existing bugs that were somewhat "invisible". So in the long run the introduction of this change made Eloquent better.
[✔️] It is a convenient addition to the framework .
[✔️] There are many tests added which covers a large set of functionailty. It may be some duplicate tests, but having extra tests often have no downsides.
[✗] But ultimately as an end-user, if you need this, how hard is it to search/replace firstOrCreate() with createOrFirst() on your codebase?

@SakiTakamachi
Copy link
Contributor

@deleugpn

But ultimately as an end-user, if you need this, how hard is it to search/replace firstOrCreate() with createOrFirst() on your codebase?

I don't understand why it needs to be replaced.

@mpyw
Copy link
Contributor Author

mpyw commented Oct 7, 2023

@deleugpn You do not need to replace them.

  1. Many existing usages of firstOrCreate in Laravel likely occur in scenarios where occasionally something is not created, but most are already created. It operates by SELECT from a replica and then INSERT into the primary if it doesn’t exist. The only additional behavior is re-SELECT from the primary if there’s a collision' afterward. In prescribed situations, continuing to use firstOrCreate is entirely acceptable.
  2. Conversely, createOrFirst operates very efficiently in cases where most are not created, but some are. If you use this in scenario 1, not only does the attempt to INSERT unnecessarily burden the database, but it also negatively consumes the auto-increment number space.

Generally, continuing to use firstOrCreate should pose no issues. Therefore, you rarely need to – and likely should not – rewrite something previously written with firstOrCreate to use createOrFirst instead.

@trevorgehman
Copy link
Contributor

[✗] But ultimately as an end-user, if you need this, how hard is it to search/replace firstOrCreate() with createOrFirst() on your codebase?

They have different use-cases. If 95% of the time the record already exists, then firstOrCreate() makes more sense and will be more performant.

@mpyw
Copy link
Contributor Author

mpyw commented Oct 7, 2023

@trevorgehman Exactly the same opinion with you 😄

@mpyw
Copy link
Contributor Author

mpyw commented Oct 9, 2023

We also hope that this change will be covered in Laravel News. It is not a BC, but I think it is significant to let people know.

@mpyw
Copy link
Contributor Author

mpyw commented Oct 10, 2023

19f8b61: Some of afterCommit-related tests are fixed, so we merged the latest 10.x branch just in case to confirm all tests pass.

git merge upstream/10.x
Merge made by the 'ort' strategy.
 src/Illuminate/Console/GeneratorCommand.php                            |   9 ++++--
 src/Illuminate/Database/Concerns/BuildsQueries.php                     |  35 ++++++++++++++++++++++-
 src/Illuminate/Database/Concerns/ManagesTransactions.php               |  11 ++++----
 src/Illuminate/Database/DatabaseTransactionsManager.php                |  30 +-------------------
 src/Illuminate/Database/Eloquent/Model.php                             |   1 +
 src/Illuminate/Database/Query/Grammars/Grammar.php                     |   3 +-
 src/Illuminate/Foundation/Application.php                              |   2 +-
 src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php      |   2 +-
 src/Illuminate/Mail/Transport/SesTransport.php                         |   4 +--
 src/Illuminate/Mail/Transport/SesV2Transport.php                       |   4 +--
 src/Illuminate/Support/Str.php                                         |  19 ++++++++++++-
 src/Illuminate/View/Compilers/BladeCompiler.php                        |   4 +--
 tests/Database/DatabaseConnectionTest.php                              |  15 ++++++++++
 tests/Database/DatabaseEloquentModelTest.php                           | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/Database/DatabaseQueryBuilderTest.php                            |  20 +++++++++++++
 tests/Database/DatabaseQueryGrammarTest.php                            |  44 +++++++++++++++++++++++++++++
 tests/Integration/Console/GeneratorCommandTest.php                     |  28 ++++++++++++++++++
 tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php |  48 +++++++++++++++++++++++++++++++
 tests/Integration/Database/QueryBuilderTest.php                        |  38 +++++++++++++++++++++++++
 tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php           |  15 +++++-----
 tests/Mail/MailSesTransportTest.php                                    |  20 +++++++++++++
 tests/Mail/MailSesV2TransportTest.php                                  |  20 +++++++++++++
 tests/Support/SupportStrTest.php                                       |   2 ++
 23 files changed, 467 insertions(+), 57 deletions(-)
 create mode 100644 tests/Database/DatabaseQueryGrammarTest.php
 create mode 100644 tests/Integration/Console/GeneratorCommandTest.php

@mpyw mpyw changed the title [10.x] Revival of the reverted changes in 10.x: firstOrCreate updateOrCreate improvement through createOrFirst + additional query tests [10.x] Revival of the reverted changes in 10.25.0: firstOrCreate updateOrCreate improvement through createOrFirst + additional query tests Oct 10, 2023
@akr4m
Copy link
Contributor

akr4m commented Oct 10, 2023

Impressive refinement of firstOrCreate and updateOrCreate to handle high-load scenarios. Extensive testing bolsters confidence. Great work @mpyw! 👍

@taylorotwell taylorotwell merged commit 4b4b1e4 into laravel:10.x Oct 12, 2023
20 checks passed
@taylorotwell
Copy link
Member

Thanks - this is probably our last attempt at this. 😬

@tonysm
Copy link
Contributor

tonysm commented Oct 12, 2023

Great work on this @mpyw and @fuwasegu. It's looking good!

@mpyw mpyw deleted the feat/create-or-first-optimization branch October 12, 2023 15:53
mpyw added a commit to mpyw/laravel-retry-on-duplicate-key that referenced this pull request Oct 12, 2023
timacdonald pushed a commit to timacdonald/framework that referenced this pull request Oct 24, 2023
…pdateOrCreate` improvement through `createOrFirst` + additional query tests (laravel#48637)

* Revert "[10.x] Revert from using `createOrFirst` in other `*OrCreate` methods (laravel#48531)"

This reverts commit 408a3e3.

* test: 💍 Add `Builder::createOrFirst()` snapshot tests

* test: 💍 Add `Builder::firstOrCreate()` snapshot tests

* test: 💍 Add `Builder::updateOrCreate()` snapshot tests

* test: 💍 Add test stubs for `DatabaseEloquentHasManyTest`

* test: 💍 Add `HasMany::createOrFirst()` snapshot tests

* test: 💍 Add `HasMany::firstOrCreate()` snapshot tests

* test: 💍 Add `HasMany::updateOrCreate()` snapshot tests

* test: 💍 prepare HasManyThrough test

* test: 💍 Add HasManyThrough::CreateOrFirst() snapshot tests

* test: 💍 Add HasManyThrough::firstOrCreate() snapshot test

* test: 💍 Add HasManyThrough::updateOrCreate() snapshot test

* refactor: 💡 use createOrFirst in firstOrCreate

* test: 💍 fix test

* style: 💄 Apply StyleCI fixes

* docs: ✏️ Add missing FIXME comments

* refactor: 💡 Omit verbose arguments

* test: 💍 Rename `DatabaseEloquentHasManyThroughTest` with fixes

* test: 💍 Add `BelongsToMany::createOrFirst/firstOrCreate` tests

* test: 💍 Extract `DatabaseEloquentHasManyTest` cases with fixes

* test: 💍 Extract `DatabaseEloquentBuilderTest` cases with fixes

* test: 💍 refactoring

* test: 💍 Add `BelongsToMany::updateOrCreate` snapshot tests

---------

Co-authored-by: fuwasegu <admin@fuwasegu.com>
@nunomaduro
Copy link
Member

@mpyw Can you check this bug report? #49514.

@mpyw
Copy link
Contributor Author

mpyw commented Dec 29, 2023

@nunomaduro Okay, I'll check it out

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet