Utiliser le contrôle de simultanéité optimiste (OCC)

Le contrôle de simultanéité optimiste (OCC) est une stratégie permettant de gérer les ressources partagées et d'éviter les pertes de mises à jour ou les conditions de concurrence lorsque plusieurs utilisateurs ou processus modifient la même ressource en même temps.

Par exemple, lorsque vous utilisez Identity and Access Management, une stratégie IAM est une ressource partagée associée à une ressource telle qu'un projet, un bucket ou un service. Sans contrôle de simultanéité, les modifications simultanées peuvent écraser les modifications récentes apportées aux stratégies, ce qui peut révoquer par inadvertance un accès légitime ou accorder des autorisations non autorisées.

Pour implémenter le contrôle de simultanéité optimiste, les systèmes utilisent généralement un numéro de version ou un champ etag (balise d'entité) sur la structure de la ressource.

Fonctionnement de l'OCC

Supposons que deux processus, A et B, tentent de mettre à jour une ressource partagée en même temps :

  1. Le processus A lit l'état actuel de la ressource.
  2. Le processus B lit le même état actuel.
  3. Le processus A modifie sa copie et la réécrit sur le serveur.
  4. Le processus B modifie sa copie et la réécrit sur le serveur.

Étant donné que le processus B écrase la ressource sans savoir que le processus A l'a déjà modifiée, les mises à jour du processus A sont perdues.

Le contrôle de simultanéité optimiste résout ce problème en introduisant une empreinte unique qui change chaque fois qu'une entité est modifiée. Dans de nombreux systèmes, tels qu'IAM, cette empreinte est un etag. Le serveur vérifie ce tag à chaque écriture :

  1. Lorsque vous lisez la ressource, le serveur renvoie un etag (une empreinte unique).
  2. Lorsque vous renvoyez la ressource modifiée, vous devez inclure l'etag d'origine.
  3. Si l'etag stocké ne correspond pas à l'etag que vous avez envoyé (ce qui signifie qu'un autre processus a modifié la ressource après que vous l'avez lue), l'opération d'écriture échoue avec une erreur ABORTED ou FAILED_PRECONDITION.

Cet échec indique au client qu'il doit relancer l'ensemble du processus : relire l'état mis à jour, réappliquer les modifications et réessayer l'écriture avec le nouvel etag.

Implémenter la boucle OCC

Le cœur d'une implémentation OCC est une boucle de nouvelle tentative qui lit, modifie et tente d'enregistrer la ressource, puis se ferme après une écriture réussie. Pour éviter les boucles infinies en cas de conflit élevé, configurez un nombre maximal raisonnable de tentatives.

Étapes de la boucle

Le tableau suivant décrit chaque étape d'une boucle de nouvelle tentative OCC :

Étape Action Exemple de mise en œuvre
Read (Lire) Récupérez l'état actuel de la ressource, y compris l'etag.
let mut policy = client
    .get_iam_policy()
    .set_resource(&secret_name)
    .send()
    .await?;
Modifier Appliquez les modifications à la structure locale.
policy.bindings.push(new_binding);
Rédiger et vérifier Essayez d'enregistrer la ressource modifiée à l'aide de l'etag existant, puis recherchez les codes d'erreur de simultanéité.
match client
    .set_iam_policy()
    .set_resource(&secret_name)
    .set_policy(policy)
    .send()
    .await
{
    Ok(p) => return Ok(p),
    Err(e) => { /* ... */ }
}
Réussite ou nouvel essai Si l'écriture réussit, quittez la boucle. Si elle échoue avec une erreur de simultanéité, incrémentez le compteur de nouvelles tentatives et poursuivez la boucle à partir de l'étape Read. -

Exemple

L'exemple suivant montre comment implémenter une boucle OCC qui met à jour une stratégie IAM sur une ressource de secret Secret Manager en accordant le rôle roles/secretmanager.secretAccessor à un membre.

Pour exécuter cet exemple, commencez par déclarer la dépendance dans votre fichier Cargo.toml :

cargo add google-cloud-secretmanager-v1

Ensuite, implémentez la boucle de nouvelle tentative OCC :

const ROLE: &str = "roles/secretmanager.secretAccessor";
const ATTEMPTS: u32 = 5;

let secret_name = format!("projects/{project_id}/secrets/{secret_id}");
let client = SecretManagerService::builder().build().await?;
for _attempt in 0..ATTEMPTS {
    let mut current = client
        .get_iam_policy()
        .set_resource(&secret_name)
        .send()
        .await?;

    match current.bindings.iter_mut().find(|b| b.role == ROLE) {
        None => current
            .bindings
            .push(Binding::new().set_role(ROLE).set_members([member])),
        Some(b) => {
            if b.members.iter().any(|m| m == member) {
                return Ok(current);
            }
            b.members.push(member.to_string());
        }
    };
    let updated = client
        .set_iam_policy()
        .set_resource(&secret_name)
        .set_policy(current)
        .set_update_mask(FieldMask::default().set_paths(["bindings"]))
        .send()
        .await;
    match updated {
        Ok(p) => return Ok(p),
        Err(e)
            if e.status().is_some_and(|s| {
                s.code == Code::Aborted || s.code == Code::FailedPrecondition
            }) =>
        {
            continue;
        }
        Err(e) => return Err(e.into()),
    }
}
anyhow::bail!("could not set IAM policy after {ATTEMPTS} attempts")

Compléter le code

Le code suivant montre la fonction complète avec toutes les importations requises :

use google_cloud_gax::error::rpc::Code;
use google_cloud_iam_v1::model::Binding;
use google_cloud_iam_v1::model::Policy;
use google_cloud_secretmanager_v1::client::SecretManagerService;
use google_cloud_wkt::FieldMask;

/// Executes an Optimistic Concurrency Control (OCC) loop to safely update a resource.
///
/// This function demonstrates the core Read-Modify-Write-Retry pattern. It uses the secret manager
/// service and a hard-coded role. The principles apply to any other service or role.
///
/// # Parameters
/// * `project_id` The Google Cloud Project ID (e.g., "my-project-123").
/// * `secret_id` The Google Cloud Project ID (e.g., "my-secret").
/// * `member` The member to add (e.g., "user:user@example.com").
///
/// # Returns
/// The new IAM policy.
pub async fn sample(project_id: &str, secret_id: &str, member: &str) -> anyhow::Result<Policy> {
    const ROLE: &str = "roles/secretmanager.secretAccessor";
    const ATTEMPTS: u32 = 5;

    let secret_name = format!("projects/{project_id}/secrets/{secret_id}");
    let client = SecretManagerService::builder().build().await?;
    for _attempt in 0..ATTEMPTS {
        let mut current = client
            .get_iam_policy()
            .set_resource(&secret_name)
            .send()
            .await?;

        match current.bindings.iter_mut().find(|b| b.role == ROLE) {
            None => current
                .bindings
                .push(Binding::new().set_role(ROLE).set_members([member])),
            Some(b) => {
                if b.members.iter().any(|m| m == member) {
                    return Ok(current);
                }
                b.members.push(member.to_string());
            }
        };
        let updated = client
            .set_iam_policy()
            .set_resource(&secret_name)
            .set_policy(current)
            .set_update_mask(FieldMask::default().set_paths(["bindings"]))
            .send()
            .await;
        match updated {
            Ok(p) => return Ok(p),
            Err(e)
                if e.status().is_some_and(|s| {
                    s.code == Code::Aborted || s.code == Code::FailedPrecondition
                }) =>
            {
                continue;
            }
            Err(e) => return Err(e.into()),
        }
    }
    anyhow::bail!("could not set IAM policy after {ATTEMPTS} attempts")
}