Cél és előfeltétel

Egy válasz elveszhet úgy is, hogy a feldolgozás már sikerült. A hívó ekkor újra elküldheti ugyanazt a kérést. Ebben a leckében azt tervezzük meg, hogyan ismerjük fel az ismétlést, és milyen eredményt adunk vissza rá. Előfeltétel az explicit állapotgép és a HashMap gyakorlata. A példák kis BTreeMap tárolót használnak, hogy a tesztek könnyen átláthatók maradjanak.

Az idempotencia a megnevezett művelet szerződésének tulajdonsága. Nem jelenti automatikusan, hogy minden végrehajtási lépés csak egyszer fut. A mostani modell azonos kérésazonosító és azonos tartalom esetén az előző eredményt adja, új üzleti módosítás nélkül. Ugyanaz az azonosító eltérő tartalommal ütközés, amelyet nem fedünk el „már kész” válasszal.

Azonosító és tartalom együtt ad jelentést

Az azonosító megválasztása a hívó és a feldolgozó közös szerződése. Ha minden újrapróbálás új azonosítót kap, a feldolgozó nem tudja ugyanannak a szándéknak felismerni. Ha viszont különböző műveletek osztoznak egy azonosítón, jogos kérést utasíthatunk el. Ezért a példák a tartalmat is megőrzik az azonosító mellett, nem pusztán egy „láttuk” halmazba írnak.

A memóriabeli tároló egyetlen folyamat egyetlen példányára vonatkozik. Újraindításkor elvész, két folyamat pedig nem osztozik rajta. Külső fizetés, e-mail vagy más távoli mellékhatás atomikus összekapcsolását sem oldja meg. A lecke nem tartós exactly-once garancia: a helyi ismétlésfelismerés és az állapotváltozás normál visszatérés melletti következetességét tanuljuk.

1. feladat: ismétlés vagy ütközés?

Készíts függvényt, amely egy szám megduplázott eredményét azonosító mellett eltárolja. Az első kérés új eredmény, az azonos tartalmú ismétlés régi eredmény legyen. Eltérő tartalom esetén a korábbi bejegyzés maradjon meg. A számolást u64-ben végezd u32 bemenetből, így ez a megduplázás nem csordul túl.

Megoldás
use std::collections::BTreeMap;
fn apply(store: &mut BTreeMap<u64, (u32, u64)>, id: u64, value: u32)
    -> Result<(u64, bool), &'static str>
{
    if let Some((previous, result)) = store.get(&id) {
        return if *previous == value { Ok((*result, false)) }
               else { Err("azonosito-utkozes") };
    }
    let result = u64::from(value) * 2;
    store.insert(id, (value, result));
    Ok((result, true))
}
fn main() {
    let mut store = BTreeMap::new();
    assert_eq!(apply(&mut store, 7, 21), Ok((42, true)));
    assert_eq!(apply(&mut store, 7, 21), Ok((42, false)));
    assert_eq!(apply(&mut store, 7, 22), Err("azonosito-utkozes"));
    assert_eq!(store.get(&7), Some(&(21, 42)));
    assert_eq!(store.len(), 1);
}

A bool itt azt jelenti, hogy most történt-e új feldolgozás, nem azt, hogy sikeres volt-e a függvény. A sikert a Result külön jelzi. Ez a megkülönböztetés a hívó számára is fontos: az ismétlésre adott régi eredmény szabályos siker, az ütköző tartalom viszont javítandó kérés. Az eredmény megőrzése azt is megakadályozza, hogy az ismételt válasz véletlenül más értéket kapjon.

2. feladat: előbb ellenőrizz, utána könyvelj

Készíts memóriabeli jóváírást. Ugyanaz az azonosító és összeg csak egyszer növelje az egyenleget. A túlcsorduló új jóváírás ne kerüljön a megjegyzett kérések közé, mert nem teljesült. Ellenőrizd az összeget és a tároló állapotát is; egyetlen visszaadott hiba kevés lenne a helyes megőrzés bizonyítására.

Megoldás
use std::collections::BTreeMap;
struct Ledger { balance: u64, seen: BTreeMap<String, u64> }
impl Ledger {
    fn credit(&mut self, id: &str, amount: u64) -> Result<bool, &'static str> {
        if let Some(old) = self.seen.get(id) {
            return if *old == amount { Ok(false) } else { Err("utkozes") };
        }
        let next = self.balance.checked_add(amount).ok_or("tulcsordulas")?;
        self.seen.insert(id.to_owned(), amount);
        self.balance = next;
        Ok(true)
    }
}
fn main() {
    let mut ledger = Ledger { balance: 10, seen: BTreeMap::new() };
    assert_eq!(ledger.credit("a", 5), Ok(true));
    assert_eq!(ledger.credit("a", 5), Ok(false));
    assert_eq!(ledger.credit("a", 6), Err("utkozes"));
    assert_eq!(ledger.balance, 15);
    assert_eq!(ledger.credit("b", u64::MAX), Err("tulcsordulas"));
    assert!(!ledger.seen.contains_key("b"));
    assert_eq!(ledger.balance, 15);
}

A függvény a normál hibaágakon minden módosítás előtt ellenőriz. Ettől még nincs lemeztranzakció vagy összeomlásbiztos köztes állapot. Egy másik rendszerben végzett mellékhatás és a seen bejegyzés mentése közötti rés külön megoldást kívánna. A példa csak a saját memóriabeli értékei felett rendelkezik, és az eredményeit ennek megfelelően nevezzük meg.

3. feladat: ugyanaz a lista többször

Dolgozz fel eseménylistát, amely ugyanazt a jóváírást ismételten tartalmazhatja. Minden külön azonosító egyszer számítson, de eltérő tartalmú ismétlés hibát adjon. Az üres lista nulla összeget eredményezzen. A feldolgozó helyi tárolót épít, ezért hibánál nem ad ki részleges sikeres végösszeget.

Megoldás
use std::collections::BTreeMap;
fn total(events: &[(&str, u64)]) -> Result<u64, &'static str> {
    let mut seen = BTreeMap::new();
    let mut sum = 0_u64;
    for (id, amount) in events {
        if let Some(previous) = seen.get(id) {
            if previous != amount { return Err("utkozes"); }
            continue;
        }
        sum = sum.checked_add(*amount).ok_or("tulcsordulas")?;
        seen.insert(*id, *amount);
    }
    Ok(sum)
}
fn main() {
    let events = [("a", 10), ("b", 20), ("a", 10)];
    assert_eq!(total(&events), Ok(30));
    assert_eq!(total(&events), Ok(30));
    assert_eq!(total(&[]), Ok(0));
    assert_eq!(total(&[("a", 10), ("a", 11)]), Err("utkozes"));
}

A két külön függvényhívás önállóan újraszámol ugyanabból az eseménylistából. Ez reprodukálható eredményt mutat, nem a hívások közötti tartós mellékhatás-elkerülést. A különbség lényeges: egy valódi külső jóváírást nem lehet pusztán a lista újbóli összegzésével biztonságosan megismételni. Előbb azt kell megtervezni, hol van az üzleti hatás és a kérésállapot közös tartós határa.

Megőrzési idő és a bizonytalan válasz

A seen tároló nem nőhet korlátlanul egy hosszú életű rendszerben. Ha viszont egy bejegyzést túl korán elfelejtesz, ugyanaz a régi kérés új műveletnek látszik. A megőrzési idő ezért az ismétlés elfogadott időablakával függ össze. A mai példák nem törölnek bejegyzést; a korlátot azért nevezzük meg, hogy ne legyen a kis modellből észrevétlenül korlátlan szolgáltatás.

A válasz hiánya nem bizonyítja a sikertelenséget. Ilyenkor az újrapróbálás csak akkor biztonságos, ha az ismétlés szerződése ezt megengedi. Egy stabil azonosító segíthet, de a hívónak az ismeretlen állapotot is meg kell tudnia őrizni. A „nem kaptam választ, tehát biztosan nem történt meg” következtetés a memóriabeli gyakorlaton túl már veszélyes tervezési hiba volna.

Az azonosítót és az eredményt a diagnosztikában is külön kezeld. Egy ismételt sikeres válasz nem új feldolgozási siker, ezért a két esemény számlálói eltérhetnek. A bool megnevezett jelentése ebben segít: a régi eredmény visszaadása ne növelje véletlenül az új üzleti hatások számát.

Elfogadási feltételek

  1. Azonos azonosító és tartalom ugyanazt az eredményt adja új egyenlegváltozás nélkül.
  2. Ütköző tartalom és túlcsordulás változatlan egyenleget hagy, a sikertelen új kérés nincs megjegyezve.
  3. Az ismétlődő listából harminc az összeg, az üresből nulla; a modell nem állít tartós vagy többfolyamatos garanciát.

Összefoglaló és továbblépés

A következő korlátos újrapróbálás leckében a hibafajták, a próbakeret és a várakozási terv kerül az ismétlés feltételei mellé. A retry önmagában nem oldja meg az azonosítás problémáját.