From 23b3ccf6c3aee005bf158f97b50b4956a3c449ee Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 30 Oct 2023 20:31:10 +0100 Subject: [PATCH] fix(sync): prevent race condition by relying on autoincrement Prevent a possible race condition when two clients add steps at the same time. See #4600. Rely on the autoincrementing id in order to provide a canonical order that steps can be retrieved in. When two clients push steps at the same time the entries receive destinct ids that increment. So if another client fetches steps in between it will see the smaller id as the version of the fetched step and fetch the other step later on. Transition: In the future we can drop the version column entirely but currently there are still steps stored in the database that make use of the old column. So we need to transition away from that. In order to find entries that are newer than version x we select those that have both a version and an id larger than x. Entries of the new format are newer than any entry of the old format. So we set their version to the largest possible value. This way they will always fulfill the version condition and the condition on the id is more strict and therefore effective. For the old format the version will be smaller than the id as it's incremented per document while the id is unique accross documents. Therefore the version condition is the more strict one and effective. The only scenario where the version might be larger than the id would be if there's very few documents in the database and they have had a lot of steps stored in single database entries. Signed-off-by: Max Signed-off-by: Jonas --- cypress/e2e/SessionApi.spec.js | 14 +++++++------- lib/Db/Step.php | 16 ++++++++++++++-- lib/Db/StepMapper.php | 19 +++++++++---------- lib/Service/DocumentService.php | 9 ++++----- 4 files changed, 34 insertions(+), 24 deletions(-) diff --git a/cypress/e2e/SessionApi.spec.js b/cypress/e2e/SessionApi.spec.js index 179f5b966f1..06f3a72fb62 100644 --- a/cypress/e2e/SessionApi.spec.js +++ b/cypress/e2e/SessionApi.spec.js @@ -115,7 +115,7 @@ describe('The session Api', function() { const version = 0 cy.pushSteps({ connection, steps, version }) .its('version') - .should('eql', 1) + .should('be.at.least', 1) cy.syncSteps(connection) .its('steps[0].data') .should('eql', steps) @@ -170,7 +170,7 @@ describe('The session Api', function() { it('saves', function() { cy.pushSteps({ connection, steps: [messages.update], version }) .its('version') - .should('eql', 1) + .should('be.at.least', 1) cy.syncSteps(connection, { version: 1, autosaveContent: '# Heading 1', manualSave: true }) cy.downloadFile(filePath) .its('data') @@ -181,7 +181,7 @@ describe('The session Api', function() { const documentState = 'Base64 encoded string' cy.pushSteps({ connection, steps: [messages.update], version }) .its('version') - .should('eql', 1) + .should('be.at.least', 1) cy.syncSteps(connection, { version: 1, autosaveContent: '# Heading 1', @@ -244,7 +244,7 @@ describe('The session Api', function() { it('saves public', function() { cy.pushSteps({ connection, steps: [messages.update], version }) .its('version') - .should('eql', 1) + .should('be.at.least', 1) cy.syncSteps(connection, { version: 1, autosaveContent: '# Heading 1', manualSave: true }) cy.login(user) cy.prepareSessionApi() @@ -257,7 +257,7 @@ describe('The session Api', function() { const documentState = 'Base64 encoded string' cy.pushSteps({ connection, steps: [messages.update], version }) .its('version') - .should('eql', 1) + .should('be.at.least', 1) cy.syncSteps(connection, { version: 1, autosaveContent: '# Heading 1', @@ -315,7 +315,7 @@ describe('The session Api', function() { let joining cy.pushSteps({ connection, steps: [messages.update], version }) .its('version') - .should('eql', 1) + .should('be.at.least', 1) cy.createTextSession(undefined, { filePath: '', shareToken }) .then(con => { joining = con @@ -332,7 +332,7 @@ describe('The session Api', function() { cy.log('Initial user pushes steps') cy.pushSteps({ connection, steps: [messages.update], version }) .its('version') - .should('eql', 1) + .should('be.at.least', 1) cy.log('Other user creates session') cy.createTextSession(undefined, { filePath: '', shareToken }) .then(con => { diff --git a/lib/Db/Step.php b/lib/Db/Step.php index 92ae39d2d08..0898f515ee9 100644 --- a/lib/Db/Step.php +++ b/lib/Db/Step.php @@ -37,8 +37,17 @@ * @method setDocumentId(int $documentId): void */ class Step extends Entity implements JsonSerializable { + + /* + * Transition: We now use the auto-incrementing id as the version. + * To ensure that new steps always have a larger version than those that + * used the version field, use the largest possible value for BIGINT. + */ + public const VERSION_STORED_IN_ID = 18446744073709551615; + + public $id = null; protected string $data = ''; - protected int $version = 0; + protected int $version = self::VERSION_STORED_IN_ID; protected int $sessionId = 0; protected int $documentId = 0; @@ -54,10 +63,13 @@ public function jsonSerialize(): array { if (\json_last_error() !== JSON_ERROR_NONE) { throw new \InvalidArgumentException('Failed to parse step data'); } + $version = $this->version === self::VERSION_STORED_IN_ID + ? $this->id + : $this->getVersion(); return [ 'id' => $this->id, 'data' => $jsonData, - 'version' => $this->version, + 'version' => $version, 'sessionId' => $this->sessionId ]; } diff --git a/lib/Db/StepMapper.php b/lib/Db/StepMapper.php index 85838c36fc8..39cb1baea37 100644 --- a/lib/Db/StepMapper.php +++ b/lib/Db/StepMapper.php @@ -33,19 +33,16 @@ public function __construct(IDBConnection $db) { parent::__construct($db, 'text_steps', Step::class); } - public function find($documentId, $fromVersion, $lastAckedVersion = null) { + public function find($documentId, $fromVersion) { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) - ->andWhere($qb->expr()->gt('version', $qb->createNamedParameter($fromVersion))); - if ($lastAckedVersion) { - $qb->andWhere($qb->expr()->lte('version', $qb->createNamedParameter($lastAckedVersion))); - } + ->andWhere($qb->expr()->gt('version', $qb->createNamedParameter($fromVersion))) + ->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($fromVersion))); $qb ->setMaxResults(1000) - ->orderBy('version') ->orderBy('id'); return $this->findEntities($qb); @@ -54,11 +51,11 @@ public function find($documentId, $fromVersion, $lastAckedVersion = null) { public function getLatestVersion($documentId): ?int { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); - $result = $qb->select('version') + $result = $qb->select('id') ->from($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) ->setMaxResults(1) - ->orderBy('version', 'DESC') + ->orderBy('id', 'DESC') ->execute(); $data = $result->fetch(); @@ -66,7 +63,7 @@ public function getLatestVersion($documentId): ?int { return null; } - return $data['version']; + return $data['id']; } public function deleteAll($documentId): void { @@ -76,11 +73,12 @@ public function deleteAll($documentId): void { ->executeStatement(); } + // not in use right now public function deleteBeforeVersion($documentId, $version): void { $qb = $this->db->getQueryBuilder(); $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) - ->andWhere($qb->expr()->lte('version', $qb->createNamedParameter($version))) + ->andWhere($qb->expr()->lte('id', $qb->createNamedParameter($version))) ->executeStatement(); } @@ -89,6 +87,7 @@ public function deleteAfterVersion($documentId, $version): int { return $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) ->andWhere($qb->expr()->gt('version', $qb->createNamedParameter($version))) + ->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($version))) ->executeStatement(); } } diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index b8cd862ccb8..e1304eb419c 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -274,15 +274,14 @@ private function insertSteps($documentId, $sessionId, $steps, $version): int { throw new InvalidArgumentException('Failed to encode steps'); } $stepsVersion = $this->stepMapper->getLatestVersion($document->getId()); - $newVersion = $stepsVersion + count($steps); - $this->logger->debug("Adding steps to $documentId: bumping version from $stepsVersion to $newVersion"); - $this->cache->set('document-version-' . $document->getId(), $newVersion); $step = new Step(); $step->setData($stepsJson); $step->setSessionId($sessionId); $step->setDocumentId($documentId); - $step->setVersion($newVersion); - $this->stepMapper->insert($step); + $step = $this->stepMapper->insert($step); + $newVersion = $step->getId(); + $this->logger->debug("Adding steps to " . $documentId . ": bumping version from $stepsVersion to $newVersion"); + $this->cache->set('document-version-' . $documentId, $newVersion); // TODO write steps to cache for quicker reading return $newVersion; } catch (DoesNotExistException $e) {