[Spanner] Buffered mutations are committed twice on the multiplexed-session precommit-token retry (#9331 fix covers inline-begin mutation-only transactions only)
I maintainer di solito rispondono entro 1 giorno
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 48/100
Direzione di ricerca
Start in src/Operation.php around commit() and executeUpdateBatch(), then inspect src/Result.php around Result::createGenerator() and compare the Go client's multiplexed-session retry behavior. Reproduce the listed transaction shapes against the emulator and add regression coverage for mutation-only and statement-before-mutation transactions. Done means commits do not duplicate buffered mutations or raise ALREADY_EXISTS.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Inserting data using the mutation API inside a read-write transaction causes the record to be inserted twice, unless the transaction is an inline-begin transaction that buffers mutations and does nothing else. That one case was #9331, fixed by #9406. A transaction that runs any statement before buffering, or one begun explicitly with $database->transaction(), still fails on v2.11.0 with the same ALREADY_EXISTS.
I believe this is the precommit-token retry in Operation::commit(). On a multiplexed session the emulator answers the first Commit that carries no precommit token by buffering the request's mutations and returning a token instead of committing. The loop below then re-sends the same $commitRequest, mutations included, and the emulator writes them a second time.
The Go client retries this case without the mutations (spanner/transaction.go: "Retry if MultiplexedSessionRetry is present, without mutations"; performCommit(false)).
The commit has no token to carry because the emulator's ExecuteSql/ExecuteStreamingSql responses for these transactions contain an empty precommit_token message, and Result::createGenerator() keeps a token only when its bytes are present:
Operation::executeUpdateBatch() keeps the message object regardless, which is why a transaction that ran batch DML commits fine — its commit already carries a token and never enters the retry:
Shapes tried, counting the buffered row after the transaction:
| shape | result |
|---|---|
inline begin, insertBatch only, commit |
1 row (the case #9406 fixed) |
inline begin, executeUpdate then insertBatch, commit |
ALREADY_EXISTS, 0 rows |
inline begin, execute('SELECT 1') then insertBatch, commit |
ALREADY_EXISTS, 0 rows |
explicit $database->transaction(), insertBatch only, commit |
ALREADY_EXISTS, 0 rows |
explicit begin, executeUpdate then insertBatch, commit |
ALREADY_EXISTS, 0 rows |
any of the above with executeUpdateBatch in place of executeUpdate |
1 row |
insertOrUpdateBatch or replaceBatch in place of insertBatch, with a commit-timestamp column |
error: the pending commit timestamp cannot be read (the row is written twice and the second write reads the first) |
I think either of these would fix it, and both would be consistent with the other clients: on the precommit-token retry, send the commit without mutations; and in Result::createGenerator(), keep the precommit token whenever the message is present, not only when its bytes are non-empty, as executeUpdateBatch() already does. Happy to test a patch against the emulator.
Environment details
- OS: Linux (Docker, official
php:8.5-fpmimage) - PHP version: 8.5.8
- Package name and version: google/cloud-spanner v2.11.0 (also reproduced on v2.10.0); google/cloud-core v1.73.4
- Cloud Spanner emulator
gcr.io/cloud-spanner-emulator/emulator@sha256:c6f3402f2599684f295a0fdefb6fbbbfb18a0e43e309ff5456ccb452a4570a79(built 2026-09-15)
Code example
<?php
require __DIR__ . '/vendor/autoload.php';
use Google\Cloud\Spanner\SpannerClient;
use Google\Cloud\Spanner\Transaction;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
$projectId = getenv('DB_SPANNER_PROJECT_ID') ?: 'test-project';
$instanceId = getenv('DB_SPANNER_INSTANCE_ID') ?: 'test-instance';
$databaseId = getenv('DB_SPANNER_DATABASE_ID') ?: 'test-db';
$client = new SpannerClient([
'projectId' => $projectId,
'cacheItemPool' => new ArrayAdapter(),
]);
$instance = $client->instance($instanceId);
if (!$instance->exists()) {
$config = $client->instanceConfiguration('emulator-config');
$instance->create($config)->pollUntilComplete();
}
$database = $instance->database($databaseId);
$table = 'MutationTest';
if (!$database->exists()) {
$instance->createDatabase($databaseId, [
'statements' => [
"CREATE TABLE $table (id INT64 NOT NULL, name STRING(10)) PRIMARY KEY (id)",
],
])->pollUntilComplete();
}
$database->runTransaction(function (Transaction $tx) use ($table) {
// Any statement before the mutation is enough: DML here, a SELECT does the same.
$tx->executeUpdate("INSERT INTO $table (id, name) VALUES (@id, 'dml')", ['parameters' => ['id' => rand()]]);
$tx->insertBatch($table, [['id' => rand(), 'name' => 'test']]);
$tx->commit();
});
The above code will throw the following error, and neither row is in the table afterwards.
Fatal error: Uncaught Google\Cloud\Core\Exception\ConflictException: {
"message": "Table MutationTest: Row {Int64(1653590878)} already exists.",
"code": 6,
"status": "ALREADY_EXISTS",
"details": [
{
"@type": "google.spanner.ConstraintError",
"typeUrl": "google.spanner.ConstraintError",
"value": ""
}
]
} in /project/vendor/google/cloud-core/src/RequestProcessorTrait.php:149
- Lingua principale
- PHP
- Stelle
- 1.2k
- Fork
- 464
- Merge medio
- 2g 2h
- PR unite (30g)
- 84
Preparare l'ambiente
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di googleapis/google-cloud-php
-
type: feature request
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 75/100
googleapis/google-cloud-php#9716 · 11 reazioni ·
I maintainer di solito rispondono entro 1 giorno
-
[Spanner] Retries of UNAVAILABLE errors when resuming a result stream not working properlyForse già presa @Hectorhammett l’ha presa 4 giorni fa. Aperta
googleapis/google-cloud-php#9737 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 3/5 1-2 giorni Idoneità per principianti 65/100
googleapis/google-cloud-php#9725 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 5/5 Più di una settimana Idoneità per principianti 25/100
googleapis/google-cloud-php#9675 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 55/100
googleapis/google-cloud-php#9674 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di googleapis/google-cloud-php
Issue simili
-
Infrastructure: actions Module: zmscitizenapi Module: zmsentities php Type: Bug unit tests
Difficoltà 2/5 1-3 ore Idoneità per principianti 88/100
it-at-m/eappointment#3480 ·
I maintainer di solito rispondono entro 1 giorno
-
CI: composer install fails — league/flysystem 1.x blocked by security advisory GHSA-cxf4-7mrp-vvprApertadevops type: bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
I maintainer di solito rispondono entro 1 giorno
-
needs approval
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 88/100
I maintainer di solito rispondono entro 3 giorni
-
product / avatars product / self-hosted product / storage
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
appwrite/appwrite#13985 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
I maintainer di solito rispondono entro 1 giorno