← Back
Editing: MetaService.php
<?php declare(strict_types=1); /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Notes\Service; use OCA\Notes\Db\Meta; use OCA\Notes\Db\MetaMapper; /** MetaService. * * The MetaService maintains information about notes that cannot be gathered * from Nextcloud middleware. * * Background: we want to minimize the transfered data size during * synchronization with mobile clients. Therefore, the full note is only sent * to the client if it was updated since last synchronization. For this * purpose, we need to know at which time a file's content was changed. * Unfortunately, Nextcloud does not save this information. Important: the * filemtime is not sufficient for this, since a file's content can be changed * without changing it's filemtime! * * Therefore, the Notes app maintains this information on its own. It is saved * in the database table `notes_meta`. To be honest, we do not store the exact * changed time, but a time `t` that is at some point between the real changed * time and next synchronization time. However, this is totally sufficient * for this purpose. * * Therefore, on synchronization, the method `MetaService::getAll` is called. * It generates an ETag for each note and compares it with the ETag from the * `notes_meta` database table in order to detect changes (or creates an entry * if not existent). If there are changes, the ETag is updated and `LastUpdate` * is set to the current time. The ETag is a hash over all note attributes * (except content, see below). * * But in order to further speed up synchronization, the content is not * compared every time (this would be very expensive!). Instead, a file event * listener invalidates the meta entry on every relevant file change. As a * consequence, a new entry in `notes_meta` is created on the next * synchronization. * * Hence, instead of using the real content for generating the note's ETag, it * uses a "content ETag" which is a hash over the content. Additionally to the * file events, this "content ETag" is updated if Nextcloud's "file ETag" has * changed (but again, the "file ETag" is just an indicator, since it is not a * hash over the content). * * All in all, this has some complexity, but we can speed up synchronization * with this approach! :-) */ class MetaService { private MetaMapper $metaMapper; private Util $util; public function __construct(MetaMapper $metaMapper, Util $util) { $this->metaMapper = $metaMapper; $this->util = $util; } public function deleteByNote(int $id) : void { $this->metaMapper->deleteByNote($id); } public function getAll(string $userId, array $notes, bool $forceUpdate = false) : array { // load data $metas = $this->metaMapper->getAll($userId); $metas = $this->getIndexedArray($metas, 'fileId'); $result = []; // delete obsolete notes foreach ($metas as $id => $meta) { if (!array_key_exists($id, $notes)) { // DELETE obsolete notes $this->metaMapper->delete($meta); } } $insertErrorCount = 0; // insert/update changes foreach ($notes as $id => $note) { if (!array_key_exists($id, $metas)) { // INSERT new notes $meta = $this->createMeta($userId, $note, function () use (&$insertErrorCount) { $insertErrorCount++; }); } else { // UPDATE changed notes $meta = $metas[$id]; if ($this->updateIfNeeded($meta, $note, $forceUpdate)) { $this->metaMapper->update($meta); } } $result[$id] = new MetaNote($note, $meta); } if ($insertErrorCount) { if ($insertErrorCount == count($notes)) { $this->util->logger->warning( 'Database failed inserting Meta objects for all ' . $insertErrorCount . ' notes. ' . 'If this happens consistently, there is a problem with your database.', ); } else { $this->util->logger->warning( 'Database failed inserting Meta objects for ' . $insertErrorCount . ' times.', ); } } return $result; } public function update(string $userId, Note $note) : Meta { $meta = null; try { $meta = $this->metaMapper->findById($userId, $note->getId()); } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { } if ($meta === null) { $meta = $this->createMeta($userId, $note); } elseif ($this->updateIfNeeded($meta, $note, true)) { $this->metaMapper->update($meta); } return $meta; } private function getIndexedArray(array $data, string $property) : array { $property = ucfirst($property); $getter = 'get' . $property; $result = []; foreach ($data as $entity) { $result[$entity->$getter()] = $entity; } return $result; } private function createMeta(string $userId, Note $note, ?callable $onError = null) : Meta { $meta = new Meta(); $meta->setUserId($userId); $meta->setFileId($note->getId()); $meta->setLastUpdate(time()); $this->updateIfNeeded($meta, $note, true); try { $this->metaMapper->insert($meta); } catch (\Throwable $e) { // It's likely that a concurrent request created this entry, too. // We can ignore this, since the result should be the same. // But we log it for being able to detect other problems. // (If this happens often, this may cause performance problems.) $loglevel = 'warning'; if ($onError) { $onError(); $loglevel = 'debug'; } $this->util->logger->$loglevel( 'Could not insert meta data for note ' . $note->getId(), [ 'exception' => $e ] ); } return $meta; } private function updateIfNeeded(Meta &$meta, Note $note, bool $forceUpdate) : bool { $generateContentEtag = $forceUpdate || !$meta->getContentEtag(); $fileEtag = $note->getFileEtag(); // a changed File-ETag is an indicator for changed content if ($fileEtag !== $meta->getFileEtag()) { $meta->setFileEtag($fileEtag); $generateContentEtag = true; } // generate new Content-ETag if ($generateContentEtag) { $contentEtag = $this->generateContentEtag($note); // this is expensive if ($contentEtag !== $meta->getContentEtag()) { $meta->setContentEtag($contentEtag); } } // always update ETag based on meta data (not content!) $etag = $this->generateEtag($meta, $note); if ($etag !== $meta->getEtag()) { $meta->setEtag($etag); $meta->setLastUpdate(time()); } return !empty($meta->getUpdatedFields()); } // warning: this is expensive private function generateContentEtag(Note $note) : string { try { return Util::retryIfLocked(function () use ($note) { return md5($note->getContent()); }, 3); } catch (\Throwable $t) { $this->util->logger->error( 'Could not generate Content Etag for note ' . $note->getId(), [ 'exception' => $t ] ); return ''; } } // this is not expensive, since we use the content ETag instead of the content itself private function generateEtag(Meta &$meta, Note $note) : string { $data = [ $note->getId(), $note->getTitle(), $note->getModified(), $note->getCategory(), $note->getFavorite(), $note->getReadOnly(), $meta->getContentEtag(), ]; return md5(json_encode($data)); } }
Save File
Cancel