Feature: [docgen] generate documents in an async queue

The documents are now generated in a queue, using symfony messenger. This queue should be configured:

```yaml
# app/config/messenger.yaml
framework:
    messenger:
        # reset services after consuming messages
        # reset_on_message: true

        failure_transport: failed

        transports:
            # https://symfony.com/doc/current/messenger.html#transport-configuration
            async: '%env(MESSENGER_TRANSPORT_DSN)%'
            priority:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
            failed: 'doctrine://default?queue_name=failed'

        routing:
            # ... other messages
            'Chill\DocGeneratorBundle\Service\Messenger\RequestGenerationMessage': priority
```

`StoredObject`s now have additionnal properties:

* status (pending, failure, ready (by default) ), which explain if the document is generated;
* a generationTrialCounter, which is incremented on each generation trial, which prevent each generation more than 5 times;

The generator computation is moved from the `DocGenTemplateController` to a `Generator` (implementing `GeneratorInterface`. 

There are new methods to `Context` which allow to normalize/denormalize context data to/from a messenger's `Message`.
This commit is contained in:
2023-02-28 15:25:47 +00:00
parent 27f13e0dd1
commit a16244a3f5
40 changed files with 1050 additions and 177 deletions

View File

@@ -0,0 +1,39 @@
<?php
namespace Chill\DocStoreBundle\Controller;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
class StoredObjectApiController
{
private Security $security;
public function __construct(Security $security)
{
$this->security = $security;
}
/**
* @Route("/api/1.0/doc-store/stored-object/{uuid}/is-ready")
*/
public function isDocumentReady(StoredObject $storedObject): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException();
}
return new JsonResponse(
[
'id' => $storedObject->getId(),
'filename' => $storedObject->getFilename(),
'status' => $storedObject->getStatus(),
'type' => $storedObject->getType(),
]
);
}
}

View File

@@ -41,12 +41,6 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
use TrackCreationTrait;
/**
* @ORM\Column(type="datetime", name="creation_date")
* @Serializer\Groups({"read", "write"})
*/
private DateTimeInterface $creationDate;
/**
* @ORM\Column(type="json", name="datas")
* @Serializer\Groups({"read", "write"})
@@ -87,7 +81,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
private string $title = '';
/**
* @ORM\Column(type="text", name="type")
* @ORM\Column(type="text", name="type", options={"default": ""})
* @Serializer\Groups({"read", "write"})
*/
private string $type = '';
@@ -105,9 +99,20 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
/**
* @ORM\Column(type="text", options={"default": "ready"})
* @Serializer\Groups({"read"})
*/
private string $status;
/**
* Store the number of times a generation has been tryied for this StoredObject.
*
* This is a workaround, as generation consume lot of memory, and out-of-memory errors
* are not handled by messenger.
*
* @ORM\Column(type="integer", options={"default": 0})
*/
private int $generationTrialsCounter = 0;
/**
* @param StoredObject::STATUS_* $status
*/
@@ -117,8 +122,16 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
$this->status = $status;
}
public function addGenerationTrial(): self
{
$this->generationTrialsCounter++;
return $this;
}
/**
* @Serializer\Groups({"read", "write"})
* @deprecated
*/
public function getCreationDate(): DateTime
{
@@ -135,6 +148,11 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
return $this->filename;
}
public function getGenerationTrialsCounter(): int
{
return $this->generationTrialsCounter;
}
public function getId(): ?int
{
return $this->id;
@@ -158,6 +176,9 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
return $this->getFilename();
}
/**
* @return StoredObject::STATUS_*
*/
public function getStatus(): string
{
return $this->status;
@@ -185,6 +206,7 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
/**
* @Serializer\Groups({"write"})
* @deprecated
*/
public function setCreationDate(DateTime $creationDate): self
{
@@ -244,4 +266,30 @@ class StoredObject implements AsyncFileInterface, Document, TrackCreationInterfa
return $this;
}
public function getTemplate(): ?DocGeneratorTemplate
{
return $this->template;
}
public function hasTemplate(): bool
{
return null !== $this->template;
}
public function setTemplate(?DocGeneratorTemplate $template): StoredObject
{
$this->template = $template;
return $this;
}
public function isPending(): bool
{
return self::STATUS_PENDING === $this->getStatus();
}
public function isFailure(): bool
{
return self::STATUS_FAILURE === $this->getStatus();
}
}

View File

@@ -1,7 +1,8 @@
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue";
import {createApp} from "vue";
import {StoredObject} from "../../types";
import {StoredObject, StoredObjectStatusChange} from "../../types";
import {is_object_ready} from "../../vuejs/StoredObjectButton/helpers";
const i18n = _createI18n({});
@@ -19,7 +20,7 @@ window.addEventListener('DOMContentLoaded', function (e) {
};
const
storedObject = JSON.parse(datasets.storedObject),
storedObject = JSON.parse(datasets.storedObject) as StoredObject,
filename = datasets.filename,
canEdit = datasets.canEdit === '1',
small = datasets.small === '1'
@@ -27,7 +28,20 @@ window.addEventListener('DOMContentLoaded', function (e) {
return { storedObject, filename, canEdit, small };
},
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small"></document-action-buttons-group>',
template: '<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
methods: {
onStoredObjectStatusChange: function(newStatus: StoredObjectStatusChange): void {
this.$data.storedObject.status = newStatus.status;
this.$data.storedObject.filename = newStatus.filename;
this.$data.storedObject.type = newStatus.type;
// remove eventual div which inform pending status
document.querySelectorAll(`[data-docgen-is-pending="${this.$data.storedObject.id}"]`)
.forEach(function(el) {
el.remove();
});
}
}
});
app.use(i18n).mount(el);

View File

@@ -1,5 +1,7 @@
import {DateTime} from "../../../ChillMainBundle/Resources/public/types";
export type StoredObjectStatus = "ready"|"failure"|"pending";
export interface StoredObject {
id: number,
@@ -13,7 +15,15 @@ export interface StoredObject {
keyInfos: object,
title: string,
type: string,
uuid: string
uuid: string,
status: StoredObjectStatus,
}
export interface StoredObjectStatusChange {
id: number,
filename: string,
status: StoredObjectStatus,
type: string,
}
/**

View File

@@ -1,5 +1,5 @@
<template>
<div class="dropdown">
<div v-if="'ready' === props.storedObject.status" class="dropdown">
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, small: props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
@@ -15,16 +15,27 @@
</li>
</ul>
</div>
<div v-else-if="'pending' === props.storedObject.status">
<div class="btn btn-outline-info">Génération en cours</div>
</div>
<div v-else-if="'failure' === props.storedObject.status">
<div class="btn btn-outline-danger">La génération a échoué</div>
</div>
</template>
<script lang="ts" setup>
import {onMounted} from "vue";
import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
import {is_extension_editable, is_extension_viewable} from "./StoredObjectButton/helpers";
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../types";
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
import {
StoredObject,
StoredObjectStatusChange,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject,
@@ -48,6 +59,10 @@ interface DocumentActionButtonsGroupConfig {
executeBeforeLeave?: WopiEditButtonExecutableBeforeLeaveFunction,
}
const emit = defineEmits<{
(e: 'onStoredObjectStatusChange', newStatus: StoredObjectStatusChange): void
}>();
const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
small: false,
canEdit: true,
@@ -56,6 +71,51 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
returnPath: window.location.pathname + window.location.search + window.location.hash,
});
/**
* counter for the number of times that we check for a new status
*/
let tryiesForReady = 0;
/**
* how many times we may check for a new status, once loaded
*/
const maxTryiesForReady = 120;
const checkForReady = function(): void {
if (
'ready' === props.storedObject.status
|| 'failure' === props.storedObject.status
// stop reloading if the page stays opened for a long time
|| tryiesForReady > maxTryiesForReady
) {
return;
}
tryiesForReady = tryiesForReady + 1;
setTimeout(onObjectNewStatusCallback, 5000);
};
const onObjectNewStatusCallback = async function(): Promise<void> {
const new_status = await is_object_ready(props.storedObject);
if (props.storedObject.status !== new_status.status) {
emit('onStoredObjectStatusChange', new_status);
return Promise.resolve();
} else if ('failure' === new_status.status) {
return Promise.resolve();
}
if ('ready' !== new_status.status) {
// we check for new status, unless it is ready
checkForReady();
}
return Promise.resolve();
};
onMounted(() => {
checkForReady();
})
</script>
<style scoped>

View File

@@ -1,3 +1,4 @@
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange} from "../../types";
const MIMES_EDIT = new Set([
'application/vnd.ms-powerpoint',
@@ -168,6 +169,18 @@ async function download_and_decrypt_doc(urlGenerator: string, keyData: JsonWebKe
}
}
async function is_object_ready(storedObject: StoredObject): Promise<StoredObjectStatusChange>
{
const new_status_response = await window
.fetch( `/api/1.0/doc-store/stored-object/${storedObject.uuid}/is-ready`);
if (!new_status_response.ok) {
throw new Error("could not fetch the new status");
}
return await new_status_response.json();
}
export {
build_convert_link,
build_download_info_link,
@@ -176,4 +189,5 @@ export {
download_doc,
is_extension_editable,
is_extension_viewable,
is_object_ready,
};

View File

@@ -9,11 +9,6 @@
<dt>{{ 'Title'|trans }}</dt>
<dd>{{ document.title }}</dd>
{% if document.scope is not null %}
<dt>{{ 'Scope' | trans }}</dt>
<dd>{{ document.scope.name | localize_translatable_string }}</dd>
{% endif %}
<dt>{{ 'Category'|trans }}</dt>
<dd>{{ document.category.name|localize_translatable_string }}</dd>

View File

@@ -5,18 +5,25 @@
<div class="item-bloc">
<div class="item-row">
<div class="item-col" style="width: unset">
{% if document.object.isPending %}
<div class="badge text-bg-info" data-docgen-is-pending="{{ document.object.id }}">{{ 'docgen.Doc generation is pending'|trans }}</div>
{% elseif document.object.isFailure %}
<div class="badge text-bg-warning">{{ 'docgen.Doc generation failed'|trans }}</div>
{% endif %}
<div class="denomination h2">
{{ document.title }}
</div>
<div>
{{ mm.mimeIcon(document.object.type) }}
</div>
{% if document.object.type is not empty %}
<div>
{{ mm.mimeIcon(document.object.type) }}
</div>
{% endif %}
<div>
<p>{{ document.category.name|localize_translatable_string }}</p>
</div>
{% if document.template is not null %}
{% if document.object.hasTemplate %}
<div>
<p>{{ document.template.name.fr }}</p>
<p>{{ document.object.template.name|localize_translatable_string }}</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\DocStore;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230227161327 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a generation counter on doc store';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object ADD generationTrialsCounter INT DEFAULT 0 NOT NULL;');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_doc.stored_object DROP generationTrialsCounter');
}
}