Skip to content

Commit

Permalink
fix(sync): prevent race condition by relying on autoincrement
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
max-nextcloud committed Oct 30, 2023
1 parent c297643 commit e9dcefc
Show file tree
Hide file tree
Showing 4 changed files with 35 additions and 24 deletions.
14 changes: 7 additions & 7 deletions cypress/e2e/api/SessionApi.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -173,7 +173,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.save(connection, { version: 1, autosaveContent: '# Heading 1', manualSave: true })
cy.downloadFile(filePath)
.its('data')
Expand All @@ -184,7 +184,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.save(connection, {
version: 1,
autosaveContent: '# Heading 1',
Expand Down Expand Up @@ -247,7 +247,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.save(connection, { version: 1, autosaveContent: '# Heading 1', manualSave: true })
cy.login(user)
cy.prepareSessionApi()
Expand All @@ -260,7 +260,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.save(connection, {
version: 1,
autosaveContent: '# Heading 1',
Expand Down Expand Up @@ -318,7 +318,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
Expand All @@ -335,7 +335,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 => {
Expand Down
17 changes: 15 additions & 2 deletions lib/Db/Step.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,19 @@
* @method setDocumentId(int $documentId): void
*/
class Step extends Entity implements JsonSerializable {

/*
* Transition:
* We now use the autoincrementing id as the version.
* In order to make sure that new steps always
* have a larger version than those that used the version field
* use the largest possible value for big int
*/
public const VERSION_STORED_IN_ID = 18446744073709551615;

public $id = null;
protected string $data = '';
protected int $version = 0;
protected int $version = VERSION_STORED_IN_ID;

Check failure on line 52 in lib/Db/Step.php

View workflow job for this annotation

GitHub Actions / Nextcloud

UndefinedConstant

lib/Db/Step.php:52:27: UndefinedConstant: Const VERSION_STORED_IN_ID is not defined (see https://psalm.dev/020)
protected int $sessionId = 0;
protected int $documentId = 0;

Expand All @@ -55,10 +65,13 @@ public function jsonSerialize(): array {
if (\json_last_error() !== JSON_ERROR_NONE) {
throw new \InvalidArgumentException('Failed to parse step data');
}
$version = $this->getVersion() == VERSION_STORED_IN_ID

Check failure on line 68 in lib/Db/Step.php

View workflow job for this annotation

GitHub Actions / Nextcloud

UndefinedConstant

lib/Db/Step.php:68:37: UndefinedConstant: Const VERSION_STORED_IN_ID is not defined (see https://psalm.dev/020)
? $this->getId()
: $this->getVersion();
return [
'id' => $this->getId(),
'data' => $jsonData,
'version' => $this->getVersion(),
'version' => $version,
'sessionId' => $this->getSessionId()
];
}
Expand Down
19 changes: 9 additions & 10 deletions lib/Db/StepMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,16 @@ public function __construct(IDBConnection $db) {
/**
* @return Step[]
*/
public function find(int $documentId, int $fromVersion, int $lastAckedVersion = null): array {
public function find(int $documentId, int $fromVersion): array {
/* @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);
Expand All @@ -57,19 +54,19 @@ public function find(int $documentId, int $fromVersion, int $lastAckedVersion =
public function getLatestVersion(int $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')
->executeQuery();

$data = $result->fetch();
if ($data === false) {
return null;
}

return $data['version'];
return $data['id'];
}

public function deleteAll(int $documentId): void {
Expand All @@ -79,11 +76,12 @@ public function deleteAll(int $documentId): void {
->executeStatement();
}

// not in use right now
public function deleteBeforeVersion(int $documentId, int $version): int {
$qb = $this->db->getQueryBuilder();
return $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();
}

Expand All @@ -92,6 +90,7 @@ public function deleteAfterVersion(int $documentId, int $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();
}
}
9 changes: 4 additions & 5 deletions lib/Service/DocumentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -263,15 +263,14 @@ private function insertSteps(Document $document, Session $session, array $steps,
try {
$stepsJson = json_encode($steps, JSON_THROW_ON_ERROR);
$stepsVersion = $this->stepMapper->getLatestVersion($document->getId());
$newVersion = $stepsVersion + count($steps);
$this->logger->debug("Adding steps to " . $document->getId() . ": bumping version from $stepsVersion to $newVersion");
$this->cache->set('document-version-' . $document->getId(), $newVersion);
$this->logger->debug("Adding steps to " . $document->getId() . ": bumping version from $stepsVersion");
$step = new Step();
$step->setData($stepsJson);
$step->setSessionId($session->getId());
$step->setDocumentId($document->getId());
$step->setVersion($newVersion);
$this->stepMapper->insert($step);
$step = $this->stepMapper->insert($step);
$newVersion = $step.id;

Check failure on line 272 in lib/Service/DocumentService.php

View workflow job for this annotation

GitHub Actions / Nextcloud

UndefinedConstant

lib/Service/DocumentService.php:272:24: UndefinedConstant: Const id is not defined (see https://psalm.dev/020)
$this->cache->set('document-version-' . $document->getId(), $newVersion);
// TODO write steps to cache for quicker reading
return $newVersion;

Check failure on line 275 in lib/Service/DocumentService.php

View workflow job for this annotation

GitHub Actions / Nextcloud

InvalidReturnStatement

lib/Service/DocumentService.php:275:11: InvalidReturnStatement: The inferred type 'string' does not match the declared return type 'int' for OCA\Text\Service\DocumentService::insertSteps (see https://psalm.dev/128)
} catch (\Throwable $e) {
Expand Down

0 comments on commit e9dcefc

Please sign in to comment.