Skip to content

Commit 11d26ca

Browse files
authored
Merge pull request #3922 from nextcloud/backport/3908/stable26
[stable26] Avoid cleanup on close to not run into race conditions with other sessions on the same document
2 parents 7671721 + b38032e commit 11d26ca

File tree

10 files changed

+101
-74
lines changed

10 files changed

+101
-74
lines changed

cypress/e2e/SessionApi.spec.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,8 @@ describe('The session Api', function() {
350350
})
351351

352352
// Failed with a probability of ~ 50% initially
353-
it('ignores steps stored after close cleaned up', function() {
353+
// Skipped for now since the behaviour chanced by not cleaning up the state on close/create
354+
it.skip('ignores steps stored after close cleaned up', function() {
354355
cy.pushAndClose({ connection, steps: [messages.update], version })
355356
cy.createTextSession(undefined, { filePath: '', shareToken })
356357
.then(con => {

cypress/e2e/sync.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,14 @@ describe('Sync', () => {
9595
.should('include', 'after the lost connection')
9696
})
9797

98-
it('passes the doc content from one session to the next', () => {
98+
it.only('passes the doc content from one session to the next', () => {
9999
cy.closeFile()
100100
cy.intercept({ method: 'PUT', url: '**/apps/text/session/create' })
101101
.as('create')
102102
cy.openTestFile()
103103
cy.wait('@create', { timeout: 10000 })
104104
.its('response.body')
105-
.should('have.property', 'content')
105+
.should('have.property', 'documentState')
106106
.should('not.be.empty')
107107
cy.getContent().find('h2').should('contain', 'Hello world')
108108
cy.getContent().find('li').should('contain', 'Saving the doc saves the doc state')

lib/Controller/PublicSessionController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ protected function isPasswordProtected(): bool {
6666
* @NoAdminRequired
6767
* @PublicPage
6868
*/
69-
public function create(string $token, string $file = null, $guestName = null, bool $forceRecreate = false): DataResponse {
70-
return $this->apiService->create(null, $file, $token, $guestName, $forceRecreate);
69+
public function create(string $token, string $file = null, $guestName = null): DataResponse {
70+
return $this->apiService->create(null, $file, $token, $guestName);
7171
}
7272

7373
/**

lib/Controller/SessionController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ public function __construct(string $appName, IRequest $request, ApiService $apiS
5353
/**
5454
* @NoAdminRequired
5555
*/
56-
public function create(int $fileId = null, string $file = null, bool $forceRecreate = false): DataResponse {
57-
return $this->apiService->create($fileId, $file, null, null, $forceRecreate);
56+
public function create(int $fileId = null, string $file = null): DataResponse {
57+
return $this->apiService->create($fileId, $file, null, null);
5858
}
5959

6060
/**

lib/Cron/Cleanup.php

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@
2828

2929
namespace OCA\Text\Cron;
3030

31-
use OCA\Text\Db\Session;
32-
use OCA\Text\Exception\DocumentHasUnsavedChangesException;
3331
use OCA\Text\Service\DocumentService;
3432
use OCA\Text\Service\AttachmentService;
3533
use OCA\Text\Service\SessionService;
@@ -57,29 +55,25 @@ public function __construct(ITimeFactory $time,
5755
}
5856

5957
protected function run($argument) {
60-
$this->logger->debug('Run cleanup job for text sessions');
61-
$inactive = $this->sessionService->findAllInactive();
62-
/** @var Session $session */
63-
foreach ($inactive as $session) {
64-
$activeSessions = $this->sessionService->getActiveSessions($session->getDocumentId());
65-
if (count($activeSessions) > 0) {
58+
$this->logger->debug('Run cleanup job for text documents');
59+
$documents = $this->documentService->getAll();
60+
foreach ($documents as $document) {
61+
$allSessions = $this->sessionService->getAllSessions($document->getId());
62+
if (count($allSessions) > 0) {
63+
// Do not reset if there are any sessions left
64+
// Inactive sessions will get removed further down and will trigger a reset next time
6665
continue;
6766
}
6867

69-
$this->documentService->unlock($session->getDocumentId());
70-
71-
try {
72-
$this->logger->debug('Resetting document ' . $session->getDocumentId() . '');
73-
$this->documentService->resetDocument($session->getDocumentId());
74-
$this->attachmentService->cleanupAttachments($session->getDocumentId());
75-
$this->logger->debug('Resetting document ' . $session->getDocumentId() . '');
76-
} catch (DocumentHasUnsavedChangesException $e) {
77-
$this->logger->info('Document ' . $session->getDocumentId() . ' has not been reset, as it has unsaved changes');
78-
} catch (\Throwable $e) {
79-
$this->logger->error('Document ' . $session->getDocumentId() . ' has not been reset, as an error occured', ['exception' => $e]);
68+
if ($this->documentService->hasUnsavedChanges($document)) {
69+
continue;
8070
}
71+
72+
$this->documentService->resetDocument($document->getId());
8173
}
82-
$removedSessions = $this->sessionService->removeInactiveSessions(null);
74+
75+
$this->logger->debug('Run cleanup job for text sessions');
76+
$removedSessions = $this->sessionService->removeInactiveSessionsWithoutSteps(null);
8377
$this->logger->debug('Removed ' . $removedSessions . ' inactive sessions');
8478
}
8579
}

lib/Db/DocumentMapper.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,13 @@ public function find($documentId): Document {
5454
}
5555
return Document::fromRow($data);
5656
}
57+
58+
public function findAll(): array {
59+
$qb = $this->db->getQueryBuilder();
60+
$result = $qb->select('*')
61+
->from($this->getTableName())
62+
->execute();
63+
64+
return $this->findEntities($qb);
65+
}
5766
}

lib/Db/SessionMapper.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public function findAllInactive() {
9999
return $this->findEntities($qb);
100100
}
101101

102-
public function deleteInactive($documentId = -1) {
102+
public function deleteInactiveWithoutSteps($documentId = -1) {
103103
$qb = $this->db->getQueryBuilder();
104104
$qb->select('session_id')
105105
->from('text_steps');

lib/Service/ApiService.php

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
use OC\Files\Node\File;
3232
use OCA\Files_Sharing\SharedStorage;
3333
use OCA\Text\AppInfo\Application;
34-
use OCA\Text\Exception\DocumentHasUnsavedChangesException;
3534
use OCA\Text\Exception\DocumentSaveConflictException;
3635
use OCP\AppFramework\Db\DoesNotExistException;
3736
use OCP\AppFramework\Http;
@@ -72,7 +71,7 @@ public function __construct(IRequest $request,
7271
$this->l10n = $l10n;
7372
}
7473

75-
public function create($fileId = null, $filePath = null, $token = null, $guestName = null, bool $forceRecreate = false): DataResponse {
74+
public function create($fileId = null, $filePath = null, $token = null, $guestName = null): DataResponse {
7675
try {
7776
/** @var File $file */
7877
if ($token) {
@@ -112,18 +111,16 @@ public function create($fileId = null, $filePath = null, $token = null, $guestNa
112111

113112
$readOnly = $this->documentService->isReadOnly($file, $token);
114113

115-
$this->sessionService->removeInactiveSessions($file->getId());
116-
$remainingSessions = $this->sessionService->getAllSessions($file->getId());
117-
$freshSession = false;
118-
if ($forceRecreate || count($remainingSessions) === 0) {
119-
$freshSession = true;
120-
try {
121-
$this->documentService->resetDocument($file->getId(), $forceRecreate);
122-
} catch (DocumentHasUnsavedChangesException $e) {
123-
}
124-
}
114+
$this->sessionService->removeInactiveSessionsWithoutSteps($file->getId());
115+
$document = $this->documentService->getDocument($file);
116+
$freshSession = $document === null;
125117

126-
$document = $this->documentService->createDocument($file);
118+
if ($freshSession) {
119+
$this->logger->info('Create new document of ' . $file->getId());
120+
$document = $this->documentService->createDocument($file);
121+
} else {
122+
$this->logger->info('Keep previous document of ' . $file->getId());
123+
}
127124
} catch (Exception $e) {
128125
$this->logger->error($e->getMessage(), ['exception' => $e]);
129126
return new DataResponse('Failed to create the document session', 500);
@@ -132,20 +129,22 @@ public function create($fileId = null, $filePath = null, $token = null, $guestNa
132129
$session = $this->sessionService->initSession($document->getId(), $guestName);
133130

134131
if ($freshSession) {
135-
$this->logger->debug('Starting a fresh session');
132+
$this->logger->debug('Starting a fresh editing session for ' . $file->getId());
136133
$documentState = null;
137134
$content = $this->loadContent($file);
138135
} else {
139-
$this->logger->debug('Loading existing session');
136+
$this->logger->debug('Loading existing session for ' . $file->getId());
140137
$content = null;
141138
try {
142139
$stateFile = $this->documentService->getStateFile($document->getId());
143140
$documentState = $stateFile->getContent();
144141
} catch (NotFoundException $e) {
142+
$this->logger->debug('State file not found for ' . $file->getId());
145143
$documentState = ''; // no state saved yet.
146144
// If there are no steps yet we might still need the content.
147145
$steps = $this->documentService->getSteps($document->getId(), 0);
148146
if (empty($steps)) {
147+
$this->logger->debug('Empty steps, loading content for ' . $file->getId());
149148
$content = $this->loadContent($file);
150149
}
151150
}
@@ -173,14 +172,10 @@ public function create($fileId = null, $filePath = null, $token = null, $guestNa
173172

174173
public function close($documentId, $sessionId, $sessionToken): DataResponse {
175174
$this->sessionService->closeSession($documentId, $sessionId, $sessionToken);
176-
$this->sessionService->removeInactiveSessions($documentId);
175+
$this->sessionService->removeInactiveSessionsWithoutSteps($documentId);
177176
$activeSessions = $this->sessionService->getActiveSessions($documentId);
178177
if (count($activeSessions) === 0) {
179-
try {
180-
$this->documentService->resetDocument($documentId);
181-
$this->attachmentService->cleanupAttachments($documentId);
182-
} catch (DocumentHasUnsavedChangesException $e) {
183-
}
178+
$this->documentService->unlock($documentId);
184179
}
185180
return new DataResponse([]);
186181
}

lib/Service/DocumentService.php

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ public function __construct(DocumentMapper $documentMapper, StepMapper $stepMapp
112112
}
113113
}
114114

115+
public function getDocument(File $file): ?Document {
116+
try {
117+
return $this->documentMapper->find($file->getId());
118+
} catch (DoesNotExistException|NotFoundException $e) {
119+
return null;
120+
}
121+
}
122+
115123
/**
116124
* @param File $file
117125
* @return Entity
@@ -127,7 +135,7 @@ public function createDocument(File $file): Document {
127135
// This way the user can still resolve conflicts in the editor view
128136
$stepsVersion = $this->stepMapper->getLatestVersion($document->getId());
129137
if ($stepsVersion && ($document->getLastSavedVersion() !== $stepsVersion)) {
130-
$this->logger->debug('Unsaved steps but collission with file, continue collaborative editing');
138+
$this->logger->debug('Unsaved steps, continue collaborative editing');
131139
return $document;
132140
}
133141
return $document;
@@ -258,6 +266,7 @@ private function insertSteps($documentId, $sessionId, $steps, $version): int {
258266
}
259267
$stepsVersion = $this->stepMapper->getLatestVersion($document->getId());
260268
$newVersion = $stepsVersion + count($steps);
269+
$this->logger->debug("Adding steps to $documentId: bumping version from $stepsVersion to $newVersion");
261270
$this->cache->set('document-version-' . $document->getId(), $newVersion);
262271
$step = new Step();
263272
$step->setData($stepsJson);
@@ -332,6 +341,22 @@ public function autosave(?File $file, int $documentId, int $version, ?string $au
332341
return $document;
333342
}
334343

344+
if (empty($autoaveDocument)) {
345+
$this->logger->debug('Saving empty document', [
346+
'requestVersion' => $version,
347+
'requestAutosaveDocument' => $autoaveDocument,
348+
'requestDocumentState' => $documentState,
349+
'document' => $document->jsonSerialize(),
350+
'fileSizeBeforeSave' => $file->getSize(),
351+
'steps' => array_map(function (Step $step) {
352+
return $step->jsonSerialize();
353+
}, $this->stepMapper->find($documentId, 0)),
354+
'sessions' => array_map(function (Session $session) {
355+
return $session->jsonSerialize();
356+
}, $this->sessionMapper->findAll($documentId))
357+
]);
358+
}
359+
335360
$this->cache->set('document-save-lock-' . $documentId, true, 10);
336361
try {
337362
$this->lockManager->runInScope(new LockContext(
@@ -344,46 +369,49 @@ public function autosave(?File $file, int $documentId, int $version, ?string $au
344369
$this->writeDocumentState($file->getId(), $documentState);
345370
}
346371
});
372+
$document->setLastSavedVersion($stepsVersion);
373+
$document->setLastSavedVersionTime(time());
374+
$document->setLastSavedVersionEtag($file->getEtag());
375+
$this->documentMapper->update($document);
347376
} catch (LockedException $e) {
348377
// Ignore lock since it might occur when multiple people save at the same time
349378
return $document;
379+
} finally {
380+
$this->cache->remove('document-save-lock-' . $documentId);
350381
}
351-
$document->setLastSavedVersion($stepsVersion);
352-
$document->setLastSavedVersionTime(time());
353-
$document->setLastSavedVersionEtag($file->getEtag());
354-
$this->documentMapper->update($document);
355-
$this->cache->remove('document-save-lock-' . $documentId);
356382
return $document;
357383
}
358384

359385
/**
360-
* @param $documentId
361-
* @param bool $force
362386
* @throws DocumentHasUnsavedChangesException
387+
* @throws Exception
388+
* @throws NotPermittedException
363389
*/
364390
public function resetDocument(int $documentId, bool $force = false): void {
365391
try {
366-
$this->unlock($documentId);
367-
368392
$document = $this->documentMapper->find($documentId);
369-
370-
if ($force || !$this->hasUnsavedChanges($document)) {
371-
$this->stepMapper->deleteAll($documentId);
372-
$this->sessionMapper->deleteByDocumentId($documentId);
373-
$this->documentMapper->delete($document);
374-
375-
try {
376-
$this->getStateFile($documentId)->delete();
377-
} catch (NotFoundException $e) {
378-
} catch (NotPermittedException $e) {
379-
}
380-
} elseif ($this->hasUnsavedChanges($document)) {
393+
if (!$force && $this->hasUnsavedChanges($document)) {
394+
$this->logger->debug('did not reset document for ' . $documentId);
381395
throw new DocumentHasUnsavedChangesException('Did not reset document, as it has unsaved changes');
382396
}
383-
} catch (DoesNotExistException $e) {
397+
398+
$this->unlock($documentId);
399+
400+
$this->stepMapper->deleteAll($documentId);
401+
$this->sessionMapper->deleteByDocumentId($documentId);
402+
$this->documentMapper->delete($document);
403+
404+
$this->getStateFile($documentId)->delete();
405+
$this->logger->debug('document reset for ' . $documentId);
406+
} catch (DoesNotExistException|NotFoundException $e) {
407+
// Ignore if document not found or state file not found
384408
}
385409
}
386410

411+
public function getAll() {
412+
return $this->documentMapper->findAll();
413+
}
414+
387415
/**
388416
* @param Session $session
389417
* @param $shareToken

lib/Service/SessionService.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,9 @@ public function findAllInactive() {
146146
return $this->sessionMapper->findAllInactive();
147147
}
148148

149-
public function removeInactiveSessions($documentId = -1) {
149+
public function removeInactiveSessionsWithoutSteps($documentId = -1) {
150150
// No need to clear the cache here as we already set a TTL
151-
return $this->sessionMapper->deleteInactive($documentId);
151+
return $this->sessionMapper->deleteInactiveWithoutSteps($documentId);
152152
}
153153

154154
/**

0 commit comments

Comments
 (0)