feat: serve message fetches from the cache

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-10-07 22:20:13 -04:00
parent b1e92e5b07
commit 5e458c5bc8
7 changed files with 261 additions and 4 deletions
+51 -4
View File
@@ -173,14 +173,33 @@ class CachedService extends ServiceBase
public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array
{
// served from the cache in 5.2c
return $this->live()->entityFetchBulk(...$identifiers);
return iterator_to_array($this->entityFetchStream(...$identifiers), true);
}
/**
* Fetch messages from the cache; messages that are not cached are fetched from the
* server and cached, mailboxes never harmonized are read from the server.
*/
public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator
{
// served from the cache in 5.2c
return $this->live()->entityFetchStream(...$identifiers);
$byMailbox = [];
foreach ($identifiers as $identifier) {
if ($identifier->provider() !== $this->provider() || (string) $identifier->service() !== $this->serviceId()) {
throw new \InvalidArgumentException('Entity identifier does not belong to this service: ' . $identifier);
}
$byMailbox[(string) $identifier->collection()][] = (int) $identifier->entity();
}
foreach ($byMailbox as $mailbox => $uids) {
$uidValidity = $this->mailboxStore->state($this->serviceId(), (string) $mailbox)['uidValidity'];
$entities = $uidValidity === null
? $this->liveMail()->entityFetch((string) $mailbox, ...$uids)
: $this->hydrate((string) $mailbox, (int) $uidValidity, $uids);
foreach ($entities as $entity) {
yield $entity->urn() => $entity;
}
}
}
/**
@@ -289,6 +308,9 @@ class CachedService extends ServiceBase
$missing[] = $uid;
continue;
}
if ($entity->getProperties()->getIncompleteSections() !== []) {
$this->completeSections($mailbox, $uidValidity, $entity);
}
$entities[$uid] = $entity;
}
@@ -307,6 +329,31 @@ class CachedService extends ServiceBase
}
}
/**
* Fetch the full content of sections cut off at ingest and store the completed message.
*
* Content is immutable, so the meta document and change sequence are untouched. A
* failure leaves the cut off text in place; it is tried again on the next read.
*/
private function completeSections(string $mailbox, int $uidValidity, EntityResource $entity): void
{
$properties = $entity->getProperties();
$uid = (int) $entity->identifier();
try {
$sections = $this->liveMail()->messageSections($mailbox, $uid, ...$properties->getIncompleteSections());
if ($sections === []) {
return;
}
foreach ($sections as $partId => $content) {
$properties->completeSection((string) $partId, $content);
}
$this->fileStore->write($this->tenantId(), $this->serviceId(), $mailbox, $uidValidity, $uid, $entity->toCacheContent());
} catch (\Throwable) {
// keep the cut off text; retried on the next read
}
}
/**
* An entity from its meta document and message.json; null when the content is missing or outdated.
*/
+36
View File
@@ -139,6 +139,25 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
return $this;
}
/**
* Replace the (cut off) content of a body section with its full content and clear its incomplete mark.
*
* @return bool whether the section exists in the body
*/
public function completeSection(string $partId, string $content): bool
{
if (!isset($this->data[static::PROPERTY_BODY]) || !is_array($this->data[static::PROPERTY_BODY])) {
return false;
}
$found = self::replaceSectionContent($this->data[static::PROPERTY_BODY], $partId, $content);
if ($found) {
$this->incompleteSections = array_values(array_diff($this->incompleteSections, [$partId]));
}
return $found;
}
/**
* Serialise the fields the meta store needs for list, filter and sort.
*
@@ -209,6 +228,23 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
return $this;
}
private static function replaceSectionContent(array &$part, string $partId, string $content): bool
{
if ((string) ($part['partId'] ?? '') === $partId) {
$part['content'] = $content;
return true;
}
foreach ($part['subParts'] ?? [] as $index => $child) {
if (is_array($child) && self::replaceSectionContent($child, $partId, $content)) {
$part['subParts'][$index] = $child;
return true;
}
}
return false;
}
private function cachePreview(int $length = 200): string
{
$text = $this->getBodyTextPlain();