Dans notre cas d’usage, nous avons un traitement qui, dans le pire des cas, peut durer jusqu’à 5 minutes. Ce traitement est actuellement déclenché par une action synchrone, ce qui impacte l’expérience utilisateur.
Nous souhaitons le rendre asynchrone afin de restituer rapidement la main à l’utilisateur, tout en poursuivant le traitement en arrière-plan.
Dans ce contexte, existe-t-il une fonctionnalité standard permettant de :
désactiver temporairement l’édition des champs du formulaire concerné
empêcher l’exécution d’autres actions sur ce formulaire
L’objectif est d’éviter toute interaction concurrente ou incohérente pendant que le traitement asynchrone s’exécute.
Vaste sujet si on parle de lancer un traitement long depuis une session web qui peut potentiellement expirer avant la fin du traitement.
Choisir le bon mode de lancement du thread :
Il faut que l’action soit asynchrone en Isolated session si elle doit être indépendante de la session = avec ses propres instances d’objet, et surtout des droits du user isolés/clonés. L’utilisateur peut se déconnecter et la tache/thread pourra continuer avec ses propres ressources
Le type Standalone est un thread qui va créer des nouveaux objets, mais partage les droits Grant de session
Le type asynchronous est un thread qui est lancé par le même objet de session, donc partage les Grant et l’objet métier, utile pour une action front asynchrone simple/courte (pas un batch)
on peut aussi pour des cas spécifiques créer une action de type synchrone dont l’implémentation lance un job/runnable asynchrone via JobQueue.push
Code thread-safe
L’implémentation doit proscrire l’usage d’objets en provenance de la session qui peut vivre sa vie, expirer, l’objet changer de record… = donc il faut impérativement utiliser des instances d’objet séparées / dédiées au job asynchrone. Le pont entre la session et l’action/thread doit juste consister à lui passer le contexte de l’appel au démarrage (typiquement le nom de l’objet, le row_id…) mais ensuite le thread doit être le plus autonome possible comme si c’était une autre session.
Utiliser le AsyncTracker dans la méthode de l’action
Il permet de suivre côté front l’avancement de la tache longue, demander son arrêt, etc. sous forme de popup ou de toast minifié en bas à droite.
Blocage front
Ceci étant posé, votre besoin de blocage “front” nécessite de poser un verrou le temps du traitement partagé pour tous les utilisateurs :
Si ça concerne un enregistrement d’un objet métier, il faut le poser dans un champ de l’objet car il pourra être visible/modifiable par un admin métier si la tache ne termine pas = déverrouillage manuel
Le job asynchrone doit poser ce verrou en base au démarrage / et surtout son catch/finally doit le retirer
Ensuite il suffit de jouer avec les hooks de Simplicité pour limiter les accès si ce verrou existe
Le record sera non modifiable si verrouillé sauf pour un admin qui pourra retirer le verrou en cas de panne/arrêt tomcat.
Cleaning
En cas d’arrêt brutal de tomcat, ou sortie anormale du thread…, il peut rester des verrous persistants. Il faudra prévoir un tache cron ou au démarrage via PlatformHooks ajouter un SQL pour retirer les vieux verrous persistants de la base.
Je me permets de donner plus de contexte car il y s’y cache des subtilités tel qu’un validateAndSave() et peut être impacté par le lock à mettre en place.
J’ai mis ici dans la fonction run(), le pseudo code présent dans notre action actuelle.
public String testAsync(Action action, AsyncTracker tracker) {
//Tentative de lock
setReadOnly(true)
if (tracker.isRunning())
return null;
tracker.start();
JobQueue.push("testAsync", new Runnable() {
@Override
public void run() {
try {
String[] objBIds = //Query recuperant une liste de row_id d'un Objet B
ObjectB objB = (ObjectB) getGrant().getTmpObject("ObjectB");
synchronized (objB.getLock()) {
for (String objBId : objBIds) {
objB.resetValues();
if (objB.select(objBId)) {
try {
//traitement objB avec appels API vers autre backend
} catch (Exception e) {
AppLog.error();
}
}
}
}
//Traitement lié à ObjetA et l'action lancé
String lang = getGrant().getLang();
ObjectField xxxxFromField = action.getConfirmField(lang, "xxxxx");
String xxxxFromDate = xxxxFromField != null ? xxxxFromField.getValue() : null;
ObjectDB objA = getGrant().getTmpObject(this.getName());
synchronized (objA.getLock()) {
try {
objA.select(objAId);
objA.setFieldValue();
objA.getTool().validateAndSave();
//déclenche d'autres traitements (source de la durée plus ou moins longue de l'action, les 5 mins dans le worst case scenario) dans le postSave
} catch (ValidateException | SaveException e) {
AppLog.error();
}
}
}
catch (InterruptedException e) {
tracker.message("Interrupted");
Thread.currentThread().interrupt();
}
catch (Exception e) {
// Assign the error on current task
tracker.error(e.getMessage());
}
finally {
//Unlock Here
setReadOnly(false)
}
}
});
return null;
}
J’avais essayé avec la fonction setReadOnly() avec un succès mitigé (obligé de repasser par la liste de l’objet A pour unlock).
setReadOnly verrouille tous les accès à tous les records pour l’utilisateur dans sa session, donc ne bloque pas les autres utilisateurs (ou une autre de ses sessions).
il faut lire les paramètres de l’action UI tout suite (dans un final visible par le thread) et pas dans le thread lui-même qui ira les lire potentiellement dans 2h, avec peu de chance de les retrouver…
dans le thread = c’est contraire à la règle 2) de mon post précédent.
Vous chargez un objet dans la session de l’utilisateur via getGrant(), qui peut se déconnecter alors que le job n’est pas terminé = NPE assuré.
Il faut cloner le Grant pour qu’il travaille à part avec des instance d’objet à lui. Par exemple :
public String testAsync(Action action, AsyncTracker tracker) {
ObjectField xxxxFromField = action.getConfirmField(lang, "xxxxx");
final String xxxxFromDate = xxxxFromField != null ? xxxxFromField.getValue() : null;
...
final Grant clone = getGrant().clone("myjobTest", Globals.ENDPOINT_CRON);
// Attach front tracker to notify UI (while exists)
clone.setParameter(AsyncTracker.TRACKERS_PARAM, tracker);
...
JobQueue.push("testAsync", new Runnable() {
@Override
public void run() {
/* use only cloned Grant */
ObjectB objB = clone.getTmpObject("ObjectB");
...
}
}
Ensuite qu’elle est votre logique de verrouillage ? c’est assez confus.
Si vous voulez bloquer tous les utilisateurs ou juste l’utilisateur ?
Verrou d’une ligne ou des plusieurs lignes en base, verrou global à l’objet ? Plus rien ne peut être modifié sur la table… ?
Quel que soit votre verrou au final, il faudra utiliser un hook comme isUpdateEnable ou isActionEnable pour tester le verrou + droit utilisateur + instance concernée par le verrou… que vous positionnez qq part (sur les row_id impactés, ou global dans un system param en mémoire ou en base si ça concerne tout le monde…).
par exemple if (isUIInstance() && locked) return false; pour laisser passer les mises à jour de l’instance tmp du Job (tmp n’est pas une instance UI = the_ajax, panel, ref…).
Lorsque cette action est lancée, pour le record en question, nous souhaitons empecher tous les users de modifier le formulaire (isUpdateEnable doit renvoyer false) et également la possibilité de déclenché d’autres actions présentes sur le formulaire (isActionEnable) le temps du traitement asynchrone.
Ok donc il faut bien poser un verrou sur le record en question = ajouter un champ lock à votre objet métier. comme décrit dans ma première réponse.
En plus clair qq chose comme ça :
private void mylock(String rowId, String lock) {
Grant.getSystemAdmin().update("update mytable set mylock='"+lock+"' where row_id=" + rowId);
}
public String testAsync(Action action, AsyncTracker tracker) {
final String rowId = getRowId();
mylock(rowId, Tool.TRUE);
...
public void run() {
...
finally {
mylock(rowId, Tool.FALSE);
}
}
}
public boolean isUpdateEnable(String row[]) {
boolean locked = getFieldValue("mylock", row).equals(Tool.TRUE);
if (locked && isUIInstance() && !getGrant().hasResponsibility("MY_SUPER_ADMIN"))
return false;
return super.isUpdateEnable(row);
}
Ensuite comme déjà indiqué : il faudra mettre qq chose pour déverrouiller de la base si tomcat crash :
soit le champ est visible/accessible que pour “MY_SUPER_ADMIN” puis le faire via laUI
soit vous prévoyez de les retirer automatiquement dans les PlatformHooks au démarrage de l’instance tomcat (sql qui retire les verrous à Tool.TRUE = ‘1’ de la table)
Ayant réussi à implémenter grace à tes recommandations, je constate qu’il reste un point concernant l’UI :
Lock en place :
les champs reste modiffiables
les boutons Save et Save&Close restent visibles.
Si un user tente de cliquer sur Save :
les champs passent en readonly
les boutons sont retirés du formulaire
Comportement similaire avec les boutons d’actions “custom”.
Nous aurions préferé que les champs et boutons soit rendus inaccessibles sans avoir à tenter une modification ou de lancer une action.
L’action reload lancée manuellement permet de constater que le lock de l’UI est bien en place. J’ai fait une tentative de lancer du js à la fin de l’action return this.javascript("$ui.displayForm(null, 'myObject', " + myRowId + ", {nav:'add'})"); (pas dans le job bien sur) mais pour l’instant sans succès (est-ce faisable dans notre context ?). Mais bon, dans notre use case, c’est moins “génant” que la suite.
Une fois le job asynchrone terminé et le lock est rétiré :
les champs sont de nouveaux modiffiables
les boutons d’actions custom sont de nouveaux visibles
mais les boutons Save et Save&Close restent invisibles
Concernant le dernier point, les boutons ne reviennent que lorsque je change et reviens sur le formulaire même l’action de reload ne permet pas de les faire revenir.
Existe-t-il un moyen pour que les boutons Save et Save&Close se comportent comme les boutons d’actions customs lors du dévérouillage ?
A mon avis votre verrou est positionné trop tard / après le passage du hook isUpdateEnable qui n’est pas encore au courant.
Merci de fournir votre code et le paramétrage de votre action.
De rajouter les logs de debug dans votre code
Oui, comme n’importe quelle autre mise à jour concurrente.
Si un autre utilisateur lance le job, vous devrez aussi recharger le formulaire tant que c’est pas terminé. Les boutons save/close se rafraichissent pas tout seul, uniquement sur rechargement des méta-data, on peut le forcer par code front mais à quoi bon puisque l’utilisateur n’est plus sensé être sur cet écran pour faire autre chose :
…
Là vous êtes dans la demande inverse : dans un usage d’attente d’un traitement asynchrone, revenez à un usage synchrone simple, et dites à vos utilisateurs de lancer un autre onglet pour faire autre chose.
Votre action est déclarée asynchrone, le verrou est donc posé trop tard.
Passez simplement le paramétrage de votre action en “Synchrone” pour qu’elle pose le verrou avant de rendre la main à la UI et donc que l’initUpdate soit au courant du verrou avant de recharger le formulaire.
Votre réelle tache “asynchrone” est lancée via JobQueue.
Bizarre, je reproduis ce comportement, il doit y avoir un soucis pour minifier le tracker directement, la UI laisse effectivement un dialogue vide.
On va corriger cet usage.
En attendant essayez avec tracker.setMinified(false), c’est l’utilisateur qui devra le minifier.
UPDATE : ce sera corrigé en 6.2.19.
Le dialogue tardait à s’afficher (fade effect), du coup la demande de fermeture arrivait trop tôt.
Bonjour @Francois , existe-t-il une subitlité sur les actions synchrones avec job interne asynchrone qui ont une transition d’état ?
Je ne sais pas si c’est une coincidence mais sur 3 des actions que j’ai modifié seulement celle avec une transition d’état ne se “déclenche pas”.
La transition d’état a bien lieu mais tout ce qui est dans le code de l’action n’est pas executé. Par exemple la premiere ligne du code de l’action est une log que je ne retrouves pas dans les logs. Le composant front du tracker n’apparait pas non plus.
Si je retire AsyncTracker des parametres de la méthode de l’action, ça fonctionne comme avant (en synchrone).
Une Transition n’est effectivement pas comme une Action utilisateur, l’action qui est liée sert juste à afficher un bouton de transition qui lancera un update du statut + appels des callback paramétrés en synchrone. Une transition d’état n’a jamais été conçue pour lancer des taches longues/asynchrones. Donc la mécanique asynchrone via AsyncTracker n’existe pas dans ce contexte (l’invoke method de la transition ne doit pas trouver cette signature).
A mon avis :
il faut retirer l’action de votre transition, pour qu’elle soit similaire aux autres
et lui rajouter l’update de l’état qui ne sera pas fait tout seul