Preskoči na sadržaj

Nastanak zapisa i početak njegovog važenja treba razdvojiti

Carinska faktura zamrznuta je kao HTML u trenutku izdavanja, kupon odštampan bez mreže važi tek kad ga sistem potvrdi, a bijela tabla čuva se kao objekti koji se mogu uređivati.

6 min čitanja7 sekcija

Gradio sam tri sistema u nepovezanim oblastima: 2020. godine sistem za carinsko fakturisanje, 2022. terminale za novčane doznake i 2023. platformu za sastanke. U svakom je trebalo donijeti istu odluku.

Proizvodi, timovi i stackovi bili su različiti, ali se dizajn svaki put sveo na isto pitanje: od kada ovaj zapis važi? Trenutak u kojem je upisan i trenutak od kojeg obavezuje nisu uvijek isti.

Većina modela podataka to ne pita. Red koji postoji smatra se stvarnim. To funkcioniše dok se trenutak nastanka i trenutak važenja ne razdvoje. Tada model zakaže, a da se ne javi nijedna greška.

01

Kad izmjena šablona promijeni stare dokumente

Svaki sistem koji izdaje dokumente prije ili kasnije naleti na ovo, a većina naleti prekasno.

Šabloni se mijenjaju: ispravi se raspored, ažurira pravna napomena ili zamijeni logo. Onda neko otvori dokument izdat prije dvije godine i vidi ovogodišnji šablon, s ovogodišnjim pravnim tekstom, na zapisu koji je potpisan pod drugim uslovima.

Nije se javila nijedna greška i nijedan test nije pao. Dokument se jednostavno promijenio, a arhiva više nije pokazivala ono što je izdato.

Isti problem javlja se svugdje gdje se nastanak i važenje mogu razići, na primjer kod kupona odštampanog bez mreže ili kod table koja postoji samo kao screenshot.

02

Carinske fakture: dokument se zamrzava u trenutku izdavanja

Zahtjev za carinsko fakturisanje bio je apsolutan: izdata faktura nikad se ne smije promijeniti.

Uobičajeno rješenje je čuvati podatke fakture i renderovati dokument kad ga neko zatraži. To je prirodan dizajn, ali ovdje ne radi, jer dokument koji se renderuje na zahtjev u trenutku čitanja zavisi od tri stvari: podataka, šablona i koda za renderovanje. Ako se promijeni bilo šta od toga, mijenja se i svaki stari dokument.

Zato sistem u trenutku izdavanja renderuje dokument i čuva ga kao nepromjenjiv HTML. Kasnije izmjene šablona ne diraju ništa što je već izdato.

U Node.js-u i PostgreSQL-u to izgleda otprilike ovako:

snippet.js
JavaScript · 24 linije
async function issueInvoice(db, invoiceId) {  return db.tx(async (t) => {    const invoice = await t.one(      'SELECT * FROM invoices WHERE id = $1 FOR UPDATE',      [invoiceId],    )    if (invoice.issued_at) throw new Error('Invoice already issued')     const template = await t.one(      'SELECT * FROM invoice_templates WHERE active',    )    const html = renderInvoice(invoice, template)     await t.none(      `INSERT INTO issued_invoices (invoice_id, html, issued_at)       VALUES ($1, $2, now())`,      [invoiceId, html],    )    await t.none(      'UPDATE invoices SET issued_at = now() WHERE id = $1',      [invoiceId],    )  })}

Izdata faktura se onda čita tako što se vrati sačuvani HTML, a renderInvoice se više nikad ne poziva.

Tako je definicija dokumenta odvojena od izdatog dokumenta, pa se šablon može dalje mijenjati, a historija ostaje netaknuta.

Druga korist lako se previdi: zamrznuti dokument više ne zavisi od toga hoće li kod za renderovanje i ubuduće biti ispravan. Da se dokumenti renderuju na zahtjev, greška koja se sljedeće godine uvuče u formatiranje datuma retroaktivno bi izmijenila svaku fakturu koja je ikad izdata. Kad je dokument zamrznut, buduća greška može oštetiti samo buduće dokumente.

Nepromjenjivost koja postoji samo u kodu aplikacije je konvencija, a nju može prekršiti već sljedeća osoba koja pokrene skriptu za migraciju. Ako želite da to bude pravilo, neka izmjenu odbije sama baza:

query.sql
SQL · 9 linija
CREATE FUNCTION reject_change() RETURNS trigger AS $$BEGIN  RAISE EXCEPTION 'issued invoices cannot be changed';END;$$ LANGUAGE plpgsql; CREATE TRIGGER issued_invoices_immutableBEFORE UPDATE OR DELETE ON issued_invoicesFOR EACH ROW EXECUTE FUNCTION reject_change();
03

Kuponi za novčane doznake: odštampani bez mreže, važeći tek kad sistem potvrdi

Terminali za novčane doznake imali su suprotan problem. Primalac podiže gotovinu na benzinskoj pumpi pomoću koda s kupona. Terminal mora raditi i kad mreža ne radi, ali kupon isplaćen dvaput jedina je greška od koje se gubi stvarni novac.

Ako su nastanak i važenje isti događaj, nema dobrog izlaza. Ili terminal odbija štampati bez veze i prestaje biti koristan baš tamo gdje je veza najlošija, ili štampa kupon koji se odmah može isplatiti, pa ste napravili način da se isplati novac za koji centralni sistem još ne zna.

Kad se ta dva događaja razdvoje, dilema nestaje. Kupon se odštampa, ali još ne važi, a važeći postaje kad to sistem potvrdi: automatski kad se veza vrati ili ručno preko operatera, kojem sistem javlja da postoji neaktiviran kupon.

Na terminalu, u Kotlinu, to su dva stanja i postupak koji se pokrene kad se mreža vrati:

snippet.kt
Kotlin · 29 linija
enum class CouponState { PRINTED, VALID } data class Coupon(    val code: String,    val state: CouponState,    val printedAt: Instant,    val validatedAt: Instant? = null,) class CouponSync(    private val dao: CouponDao,    private val api: CouponApi,) {    // printing only records that the coupon exists    fun print(code: String): Coupon =        Coupon(code, CouponState.PRINTED, Instant.now())            .also { dao.insert(it) }     // validity comes from the system, never from the terminal alone    suspend fun validatePending() {        dao.findByState(CouponState.PRINTED).forEach { coupon ->            api.register(coupon.code)            dao.markValid(coupon.code, Instant.now())        }    }     // what the operator is shown    fun pending(): List<Coupon> = dao.findByState(CouponState.PRINTED)}

U ovom kodu je važno to što je međustanje sačuvano i vidljivo. Sistem zna da je kupon nastao, a još nije potvrđen, pa se to stanje ne može izgubiti a da niko ne primijeti. Kupon koji je odštampan, a još ne važi, sistemu je poznat i ima operatera koji se njime bavi.

Te dvije greške nisu jednako skupe i to odlučuje na koju stranu pogriješiti: bolje je da kupon ostane neupotrebljiv nego da se isplati dvaput. Neupotrebljiv kupon je neprijatnost koju neko može riješiti, a kupon isplaćen dvaput je novac kojeg više nema.

04

Bijele table sa sastanaka: čuvaju se objekti umjesto slike

Platformi za sastanke trebala je bijela tabla s kojom se može raditi i nakon što sastanak završi. Uobičajeno rješenje je screenshot: kad se sesija završi, canvas se sačuva kao slika. Tabla tada postoji, u smislu da o njoj postoji zapis, ali samo kao slika, a sliku možete samo gledati.

Zato se tabla čuva kao skup strukturiranih objekata umjesto kao obična slika, a svaka linija, oblik i komad teksta zadržava svoja svojstva. Tablu je kasnije moguće ponovo učitati i nastaviti uređivati.

S Fabric.js-om razlika je jedan poziv metode:

a picture of the board: viewable, not editable
JavaScript · 11 linija
// a picture of the board: viewable, not editableconst image = canvas.toDataURL({ format: 'png' }) // the board itself: every object with its propertiesconst board = canvas.toJSON()await api.put(`/meetings/${meetingId}/board`, board) // next time, the same board, still editableconst saved = await api.get(`/meetings/${meetingId}/board`)await canvas.loadFromJSON(saved)canvas.requestRenderAll()
05

Isto pitanje: čemu služi sačuvani zapis

Na prvi pogled faktura i tabla idu u suprotnim smjerovima. Faktura se zamrzava kao renderovan dokument, a tabla ne.

Ipak, u oba slučaja pitanje je bilo čemu sačuvani zapis služi. Izdata faktura služi kao dokaz šta je izdato, pa sistem čuva tačno ono što je izdato i ništa to ne smije ponovo izračunati iz drugih podataka. Tabla služi da se rad nastavi, pa sistem čuva objekte s kojima se radi umjesto njihove slike. Kupon tome dodaje i vrijeme: zapis prvo postoji, a važi tek kasnije, i sistem mora znati u kojem je od ta dva stanja.

Šta ga obično uništiŠta se čuva
Fakturapromjena šablonarenderovan dokument, zamrznut pri izdavanju
Kuponizgubljena vezakupon, odštampan, ali ne važi dok sistem ne potvrdi
Bijela tablazatvorena sesijaobjekti, a ne njihova slika

U sva tri proizvoda zapis je ostao čitav i nakon događaja koji ga obično uništi.

06

Pitanja koja sada postavljam

Kad god sistem proizvodi nešto na šta će se neko kasnije oslanjati, prije dizajniranja tabele pitam četiri stvari:

  1. Može li se ono što zapis prikazuje promijeniti nakon što nastane? Ako se izvodi u trenutku čitanja iz bilo čega što se mijenja, recimo iz šablona, koda ili referentnih podataka, onda može, htjeli vi to ili ne.
  2. Treba li? Faktura ne treba. Tabla treba ostati uređiva. Nijedan od ta dva odgovora nije default: svaki treba izabrati.
  3. Može li zapis postojati prije nego što počne važiti? Ako može, to su dva stanja i svako mora biti posebno zapisano, umjesto jednog reda i optimistične pretpostavke.
  4. Šta zapis prebacuje iz jednog stanja u drugo i može li iko vidjeti zapise koji su između? Ako stanje čekanja niko ne vidi, zapisi se u njemu gube, a da to niko ne sazna.
07

Šta bih uradio drugačije

Na carinskom sistemu zamrzavanje izdatog dokumenta bilo je ispravno, ali nepotpuno, jer šablon nikad nismo verzionisali. Nijedna izdata faktura nije se mogla promijeniti, ali nije postojao zapis o tome koja ju je verzija šablona napravila, ni ko je i kada mijenjao šablon.

Snapshot je riješio hitno pitanje, a pitanje revizije ostavio otvorenim. Popravka je mala ako se uradi na početku: šabloni trebaju biti append-only, kao i izdati dokumenti, a svaki izdati dokument treba upućivati na verziju šablona iz koje je nastao.

query.sql
SQL · 10 linija
CREATE TABLE invoice_template_versions (  id          bigserial PRIMARY KEY,  body        text        NOT NULL,  changed_by  text        NOT NULL,  changed_at  timestamptz NOT NULL DEFAULT now()); ALTER TABLE issued_invoices  ADD COLUMN template_version_id bigint  REFERENCES invoice_template_versions (id);

Kad je dokument zamrznut, a šablon verzionisan, arhiva pokazuje i šta je izdato i zašto taj dokument izgleda baš tako.

Kako je nastao ovaj tekstTri angažmana: carinsko fakturisanje, terminali za prekogranične novčane doznake i white-label platforma za sastanke namijenjena zdravstvu i finansijama.Pročitajte više o projektu →Povezana uslugaSistemi za fakturisanje i carinuPogledajte uslugu →Sljedeći tekstZašto sam dozvole za fajlove modelirao u Neo4j-u umjesto u SQL tabelamaPročitajte →

Blog

Ako radite na nečemu sličnom, javite nam se s detaljima.

Prvi poziv traje 30 minuta i ni na šta vas ne obavezuje.

Opišite nam svoj projekatcontact@microtech.consulting