Skip to content

Instantly share code, notes, and snippets.

@naholyr
Created February 21, 2020 19:00
Show Gist options
  • Save naholyr/f995b059685a82546a337d7e500693a2 to your computer and use it in GitHub Desktop.
Save naholyr/f995b059685a82546a337d7e500693a2 to your computer and use it in GitHub Desktop.

Race conditions

Cause

Elles peuvent se produire quand on lance N requêtes dont seul un résultat nous intéresse. Le problème survient quand on peut avoir ces N requêtes en parallèle, typiquement parce qu'on ne peut pas annuler la requête précédente avant dans lancer une autre.

Par exemple si je lance 3 "getProducts" d'affilé, et que le premier répond en 1000 ms, le second en 800 ms et le troisième en 900ms, alors je recevrai dans cet ordre :

  • résultat 2
  • résultat 3
  • résultat 1

En général, sans protection, je vais faire les mises à jour au moment où je reçois les résultats, et l'utilisateur aura alors le mauvais affichage final (c'était le résultat 3 qui m'intéressait) 😥

Solution

On peut soit :

  • annuler la requête en cours s'il y en a une, avant d'en lancer une autre → pas de souci !
  • si c'est impossible, il faut se donner le moyen d'identifier à quelle requête appartient tel résultat, ainsi comme on sait quelle est la dernière requête envoyée, lorsqu'on reçoit un résultat si on peut savoir s'il est effectivement le résultat de cette dernière requête, alors on peut tout simplement ignorer les autres

Dans notre exemple :

  • requête 1
  • requête 2
  • requête 3 ← c'est la dernière requête
  • résultat 2 → lié à requête 2 → ignoré
  • résultat 3 → lié à requête 3 → traité
  • résultat 1 → lié à requête 1 → ignoré

Peu importe l'ordre dans lequel les résultats arrivent, on peut les traiter ou les ignorer de manière pertinente.

Implémentation

  • On garde une référence vers la notion de "dernière requête"
  • Lors du démarrage d'une requête, on met dans cette référence une valeur qui représente de manière unique cette requête
  • Lorsque la requête se termine, on vérifie si la valeur qui est dans cette référence correspond toujours à notre identifiant de requête
    • si non, c'est qu'une autre requête s'est intercalée et on ignore le résultat
    • si oui, c'est le résultat attendu, on traite le résultat

Dans notre exemple de "getProducts" une valeur qui permet d'identifier uniquement la requête est typiquement la catégorie (voir la modification sur ProductList.js), et on peut implémenter un algorithme similaire dans notre useAsync aussi bien que dans Redux (et c'est vraiment utile dans la vraie vie 😇).

const category = useSelector(state => state.app.currentCategory);
const asyncFunction = React.useCallback(
category ? () => getProducts(category) : null,
[category]
);
const [error, loading, products] = useAsync(asyncFunction, category);
import React from "react";
// Declared globally to share reference between calls
const initialStatus = {
loading: true,
result: null,
error: null
};
const useAsync = (asyncFunction, requestId = null) => {
const [{ loading, result, error }, setStatus] = React.useState(initialStatus);
const requestIdRef = React.useRef();
React.useEffect(() => {
if (!asyncFunction) return; // skip when no valid asyncFunction is provided
// start
requestIdRef.current = requestId; // store reference of starting query
setStatus(initialStatus); // reset status
asyncFunction()
.then(result => {
// is current query is the latest (using requestId)?
if (requestId !== requestIdRef.current) {
// Race condition: ignore
console.warn("Race condition!", {
requestId,
requestIdRef
});
} else {
// No race condition: store result
setStatus({ loading: false, result });
}
})
.catch(error => setStatus({ loading: false, error }));
return () => {
// TODO cancel request?
};
}, [asyncFunction, requestId]);
return [error, loading, result];
};
export default useAsync;
@@ -7,19 +7,34 @@
error: null
};
-const useAsync = asyncFunction => {
+const useAsync = (asyncFunction, requestId = null) => {
const [{ loading, result, error }, setStatus] = React.useState(initialStatus);
+ const requestIdRef = React.useRef();
React.useEffect(() => {
+ if (!asyncFunction) return; // skip when no valid asyncFunction is provided
// start
+ requestIdRef.current = requestId; // store reference of starting query
setStatus(initialStatus); // reset status
asyncFunction()
- .then(result => setStatus({ loading: false, result }))
+ .then(result => {
+ // is current query is the latest (using requestId)?
+ if (requestId !== requestIdRef.current) {
+ // Race condition: ignore
+ console.warn("Race condition!", {
+ requestId,
+ requestIdRef
+ });
+ } else {
+ // No race condition: store result
+ setStatus({ loading: false, result });
+ }
+ })
.catch(error => setStatus({ loading: false, error }));
return () => {
// TODO cancel request?
};
- }, [asyncFunction]);
+ }, [asyncFunction, requestId]);
return [error, loading, result];
};
/**
* Le principe est toujours le même, on a donc besoin:
* - d'une donnée identifiant la requête
* - d'attacher cette donnée au résultat
* - d'ignorer le résultat lorsque les deux données ne matchent pas
*
* Cette logique peut être généralisée dans un helper, par exemple dans notre "asyncActionCreatorGenerator" on pourrait
* passer en 3e paramètre un "requestIdGenerator" qui serait appelé (avec ...params par exemple) pour implémenter cette
* logique dans l'action creator.
*/
// Côté dispatch
// start: on attache un identifiant à la requête
dispatch({ type: 'START', meta: { requestId } })
asyncFunction()
// résultat: on rattache le même identifiant à la réponse
.then(resut => dispatch({ type: 'SUCCESS', payload: result, meta: { requestId }}))
.catch(error => dispatch({ type: 'ERROR', error, meta: { requestId } }))
// Côté reducer
switch (action.type) {
// start: on reçoit l'identifiant de la requête, on le stocke comme étant la "dernière requête"
case 'START':
return { ...state, loading: true, requestId: action.meta.requestId }
// résultat: on ignore le résultat s'il n'est pas celui de la dernière requête
case 'SUCCESS':
if (action.meta.requestId !== state.requestId) return state;
return { ...state, loading: false, result: action.payload, error: null, requestId: null }
case 'ERROR':
if (action.meta.requestId !== state.requestId) return state;
return { ...state, loading: false, result: null, error: action.error, requestId: null }
default:
return state
}
// Exemple dans asyncActionCreatorFactory
// generator (helper)
export const asyncActionCreatorFactory = (asyncFunction, prefix, requestIdGenerator) => {
// action creator
return (...params) => {
const meta = { requestId: requestIdGenerator(...params) }
// action (intercepted by thunkMiddleware)
return dispatch => {
dispatch({ type: `${prefix}_START`, meta });
return asyncFunction(...params)
.then(cart => dispatch({ type: `${prefix}_SUCCESS`, payload: { result: cart }, meta }))
.catch(error => dispatch({ type: `${prefix}_ERROR`, error, meta }));
};
};
};
// Pas d'exemple dans app.actions.js ou cart.actions.js car les appels d'API n'ont pas de paramètres, donc pas identifiables, donc dernier résultat reçu = ok
// Mais imaginons un "products.actions.js":
// la catégorie des produits demandés est un bon identifiant de requête
export const fetchProducts = asyncActionCreatorGenerator(getProducts, 'FETCH_PRODUCTS', category => category)
// dans le reducer on rajouter le stockage de cet id, et le skip des résultats comme vu juste au-dessus
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment