Zapis i odczyt zużycia
Trzy sposoby na zapisanie zdarzenia, dlaczego quantity musi być liczbą całkowitą, dlaczego zapis jest buforowany, okna półotwarte, odczyt agregatu i jak działa korekta w dzienniku, który nie pozwala na aktualizację.
Trzy drogi do środka
Z wnętrza kontenera
import { USAGE_MODULE, UsageModuleService } from "@zanreal/medusa-usage/modules/usage";
const usage = container.resolve<UsageModuleService>(USAGE_MODULE);
await usage.record({
meter: "wysylka_sms", // co zostało zużyte
subject: klient.id, // kto to zużył, jako nieprzezroczysty identyfikator, którego ta wtyczka nigdy nie rozwiązuje
quantity: 1, // ile, jako liczba całkowita najmniejszej jednostki tego licznika
});Z workflow, subscribera albo route'a
import { recordUsageWorkflow } from "@zanreal/medusa-usage/workflows";
await recordUsageWorkflow(container).run({
input: {
events: [
{
meter: "transfer_mb",
subject: subskrypcjaId,
quantity: 734_000_000, // bajty, nie megabajty - patrz niżej
occurredAt: transfer.zakonczonoO,
source: "brama-cdn",
properties: { region: "eu-central" },
idempotencyKey: transfer.id, // preferowany, ilekroć taki istnieje
},
],
},
});Ten workflow nie ma kroku kompensującego, i to nie jest przeoczenie. Dziennik jest tylko do dopisywania, więc nie ma operacji, która usuwa zdarzenie zużycia, żeby dało się je czymś cofnąć, a dodanie takiej operacji działałoby wprost przeciw tej samej własności, dla której ta wtyczka istnieje. Nic na tym nie tracisz: krok jest bezpieczny do ponowienia od zera, bo klucz jest wyprowadzony, więc workflow ponowiony po tym, jak zawiódł kolejny krok, zapisze to samo zużycie raz, a nie dwa razy.
Przez HTTP, dla producenta spoza tej Medusy
curl -X POST https://twoj-sklep/admin/usage/events \
-H "x-medusa-access-token: $ADMIN_API_KEY" \
-H "content-type: application/json" \
-d '{"events":[{"meter":"wysylka_sms","subject":"cus_01","quantity":1}]}'POST /admin/usage/events odpowiada 202, nie 201: w domyślnym trybie
buforowanym zdarzenia są przyjęte i wstawione do kolejki, jeszcze nie zapisane,
a pole written w odpowiedzi mówi, co się faktycznie stało. Trasa przyjmuje też
pojedyncze zdarzenie jako całe ciało żądania, dla producenta, który mierzy jedną
rzecz na żądanie i nie powinien musieć owijać jej w tablicę.
Odpowiedź niesie wyprowadzony klucz każdego zdarzenia, i to jest cały powód, dla którego klient w ogóle może to wywołanie ponowić: to samo ciało żądania daje te same klucze, więc klient, który ponowił połączenie po przekroczeniu czasu, dostaje z powrotem identyczne klucze, a dziennik nic na tym nie zyskuje po raz drugi.
Każda trasa pod /admin korzysta z własnego uwierzytelniania Medusy, i celowo
nie ma żadnej trasy przyjmującej zdarzenia bez autoryzacji. Producent-maszyna
jest tu pełnoprawnym wywołującym i używa klucza API panelu, który można
zrotować i unieważnić - nieautoryzowany sposób na zapis do danych wejściowych
rozliczenia byłby nieautoryzowanym sposobem na zmianę czyjegoś rachunku.
Dlaczego nie ma wbudowanego subscribera, i dlaczego to nie jest brakująca funkcja
Oczywistym udogodnieniem byłoby „zmapuj zdarzenie X Medusy na zdarzenie zużycia
Y w konfiguracji”. Nie da się tego zbudować, i to z powodu leżącego w sposobie,
w jaki Medusa ładuje subscribery, a nie w ograniczeniach tej wtyczki: Medusa
wiąże zdarzenia subscribera ze statycznego eksportu config, ewaluowanego w
momencie ładowania wtyczki - zanim istnieje kontener, a więc zanim istnieją
opcje tej wtyczki. Subscriber nie ma jak dowiedzieć się z konfiguracji, jakich
zdarzeń słuchać, bo w chwili wiązania konfiguracji jeszcze po prostu nie ma.
Więc to mapowanie mieszka w twoim projekcie, gdzie zajmuje trzy linijki i gdzie zresztą jego miejsce - tylko ty wiesz, co zamówienie albo przesyłka znaczy w jednostkach twoich własnych liczników:
// src/subscribers/policz-dostawy.ts
export default async function policzDostawy({ event, container }) {
await recordUsageWorkflow(container).run({
input: {
events: [
{
idempotencyKey: event.data.id,
meter: "dostawa",
quantity: 1,
subject: event.data.customer_id,
},
],
},
});
}
export const config = { event: "delivery.completed" };Dlaczego quantity musi być liczbą całkowitą
quantity jest walidowane jako bezpieczna liczba całkowita JavaScriptu, i to
jest kwestia arytmetyki, nie stylu. Suma liczb zmiennoprzecinkowych zależy od
kolejności, w jakiej dodawane są składniki - 0.1 + 0.2 w IEEE 754 nie wychodzi
0.3, i żadna inna suma dziesiętnych nie ma gwarancji, że zachowa się tak, jak
zwykła arytmetyka na papierze. Konsekwencja dla dziennika zużycia jest
konkretna: ten sam zbiór zdarzeń, zsumowany w innej kolejności tylko dlatego,
że sink akurat zwrócił wiersze w innej sekwencji, mógłby dać dwie różne sumy w
dwa różne dni, i obie byłyby równie obronne jako „suma tych wierszy”. Cena
policzona z takiej liczby nie jest obronna wcale.
Jeśli to, co mierzysz, jest z natury ułamkowe, zmierz mniejszą jednostkę zamiast większej ułamkowej - bajty zamiast megabajtów, milisekundy zamiast godzin, tysięczne kredytu zamiast kredytów - i zapisuj całkowitą liczbę tych mniejszych jednostek. Przelicz na jednostkę, którą czyta człowiek, dopiero w miejscu, gdzie decydujesz, ile jest to warte - czyli w swoim kodzie, nie w tym pakiecie: ten pakiet nigdy nie widzi „2,4 megabajta”, tylko tyle bajtów, ile zdecydowałeś się zgłosić.
Wbudowany sink do Postgresa przechowuje quantity jako numeric i sumuje je w
bazie danych, co jest dokładne przy dowolnym rozmiarze, jaki kolumna pomieści.
Odczyt sumy z powrotem aktywnie odmawia odpowiedzi, zamiast po cichu ją
zaokrąglić, kiedy suma przekroczyłaby 2^53 - punkt, za którym liczba
JavaScriptu przestaje reprezentować każdą liczbę całkowitą. Przybliżona liczba,
która trafia do wyceny, to dokładnie ta awaria, przed którą cały ten pakiet ma
chronić, więc jest odrzucana, zamiast zostać zwrócona tak, jakby była
wiarygodna.
Dlaczego zapis jest buforowany
record waliduje zdarzenie, wylicza jego klucz, wstawia je do bufora i wraca -
nie czeka na potwierdzenie od sinka. Paczka wychodzi do sinka, kiedy bufor się
zapełni (batchSize, domyślnie 500 zdarzeń) albo kiedy najstarsze czekające w
nim zdarzenie osiągnie flushIntervalMs (domyślnie 5 sekund) - co nastąpi
pierwsze.
To nie jest mikrooptymalizacja, tylko realia wdrożenia: instancja Medusy gadająca z zarządzanym Postgresem przez internet płaci realne milisekundy opóźnienia za każde zapytanie, a jedno zapytanie na każde pojedyncze zdarzenie zużycia narzuca sufit tego, ile w ogóle da się zmierzyć - i ten sufit nie ma nic wspólnego z tym, ile faktycznie masz ruchu.
Koszt, powiedziany równie wprost jak korzyść. Zdarzenia czekające w buforze
żyją wyłącznie w pamięci. SIGKILL je gubi. Ta ekspozycja jest ograniczona
przez trzy rzeczy działające razem - interwał zrzutu, rozmiar paczki i zrzut
przy eleganckim zatrzymaniu procesu - i celuje w bezpieczną stronę asymetrii, na
której ta wtyczka jest zbudowana: utrata kilku sekund zużycia to widocznie mała
liczba, nigdy ciche podwójne obciążenie. Jeśli nawet taka ograniczona strata
jest nie do przyjęcia dla tego, co mierzysz, ustaw flushMode: "immediate" i
płać zapytaniem przy każdym wywołaniu.
Dwie kolejne własności bufora warto znać:
- Przeciwciśnienie, nigdy porzucanie. Po osiągnięciu
maxBufferedEvents(domyślnie 10 000),recordzaczyna czekać na zrzut, zamiast dalej powiększać bufor. Jeśli ten zrzut zawiedzie, błąd dociera do wywołującego, który może bezpiecznie ponowić - bo klucz jest wyprowadzony i ponowienie nie może policzyć podwójnie. - Nieudana paczka wraca na początek kolejki, przed wszystkim, co przyjęto od tego czasu, więc sink, który się uporczywie wywala, nie może zagłodzić najstarszego zużycia i sprawić, że nigdy nie zostanie zapisane. Ponowienie wszystkiego, co sink zdążył zapisać, zanim zawiódł, jest zawsze bezpieczne.
Pod spodem tego wszystkiego co minutę działa zaplanowane zadanie o nazwie
usage-flush - jego harmonogram kontroluje zmienna środowiskowa
USAGE_FLUSH_CRON - jako zabezpieczenie dla procesu, który ucichł albo nigdy
niczego nie zapisał.
Odczyt zużycia
const migawka = await usage.aggregate({
meter: "wysylka_sms",
subject: klient.id,
from: new Date("2026-08-01T00:00:00Z"), // włącznie
to: new Date("2026-09-01T00:00:00Z"), // wyłącznie
});{
"version": 1,
"meter": "wysylka_sms",
"subject": "cus_01",
"from": "2026-08-01T00:00:00.000Z",
"to": "2026-09-01T00:00:00.000Z",
"properties": null,
"total": 42817,
"eventCount": 42817,
"firstOccurredAt": "2026-08-01T00:04:11.000Z",
"lastOccurredAt": "2026-08-31T23:51:07.000Z",
"digest": "usnap_9f2c...",
"sink": "postgres",
"computedAt": "2026-09-01T02:00:00.000Z",
}aggregate najpierw zrzuca bufor tego procesu, więc migawka wzięta chwilę po
zużyciu, które ma opisywać, nie gubi po cichu tego, co jeszcze nie zostało
zapisane. Przechowaj zwrócony obiekt obok tego, co z niego rozliczyłeś. Zadaj to
samo pytanie za rok i porównaj digest: równy oznacza, że dziennik za tą
liczbą jest bajt w bajt tym samym dziennikiem, co wtedy, gdy pytałeś pierwszy
raz, różny oznacza, że coś się zmieniło - a total zestawione z eventCount
mówi wtedy, czy zdarzenia dodano, usunięto czy przywrócono. sink i
computedAt celowo siedzą poza digestem, więc migawka odtworzona z dziennika
przeniesionego do innego sinka wciąż się zgadza.
Zdarzenia stojące za liczbą mają własny endpoint, na wypadek gdy ktoś zakwestionuje rachunek:
const strona = await usage.listEvents({ meter, subject, from, to, limit: 100 });Pokazanie komuś drugiej sumy, policzonej tak samo, niczego nie dowodzi co do poprawności pierwszej. Pokazanie mu pojedynczych wierszy, z których ta suma powstała - owszem.
Okna są półotwarte
Każde okno, jakie ta wtyczka przyjmuje, to [from, to) - from włącznie, to
wyłącznie - i to nie jest dowolna konwencja. Kolejne okresy sklejają się bez
nakładania właśnie dzięki temu: to sierpnia to from września, a zdarzenie,
które trafia dokładnie na tę granicę, jest policzone raz, we wrześniu. Okno
domknięte z obu stron policzyłoby to samo zdarzenie w obu miesiącach, co jest
dokładnie tą samą awarią podwójnego liczenia, przed którą cała ta wtyczka ma
chronić, tylko osiągniętą inną drogą.
Odwrócone albo zerowej długości okno jest odrzucane wprost, zamiast dostać
odpowiedź z total równym zero. Zero powstałe z literówki w dacie wygląda
dokładnie tak samo jak klient, który naprawdę niczego nie zużył, a ta wtyczka
nie odda ci liczby, która mogłaby znaczyć jedno albo drugie.
Korekty
Nigdy nie edytujesz zdarzenia zużycia - nie ma na to operacji. Jeśli coś zostało zapisane błędnie, dopisujesz jego odwrócenie:
await usage.record({
meter: "wysylka_sms",
subject: klient.id,
quantity: -12,
occurredAt: pierwotnaChwila,
properties: { correction_of: pierwotnyKlucz },
});Suma okna się zmienia, eventCount rośnie zamiast maleć, a digest się
zmienia - wszystkie trzy rzeczy są dokładnie tym, co powinien zobaczyć audytor
patrzący na dziennik. Wiersz zmieniony po cichu na miejscu nie pokazałby
niczego z tego, i to jest cały powód, dla którego edycja nie jest operacją,
jaką ta wtyczka oferuje.
Korekta, której occurredAt przypada wewnątrz okresu już zamkniętego, nie
zmienia tego, co ten okres wystawił - zamrożony wynik jest tym, co zostało
naliczone, a zamknięcie znaczy, że już się nie rusza. Sprawi za to, że
verifyPeriod przestanie się
zgadzać, i tak właśnie dowiadujesz się, że korekta przyszła spóźniona. Zobacz
Okresy rozliczeniowe,
co wtedy zrobić.
Kilka instancji Medusy naraz
Każdy proces buforuje swoje zdarzenia niezależnie, a aggregate zrzuca tylko
bufor tego procesu, który akurat obsłużył dane żądanie - nie ma jak sięgnąć do
pamięci innej instancji. Więc okno powinno być zamknięte przynajmniej na
flushIntervalMs przed wzięciem z niego migawki: rozliczenie wczorajszego
zużycia jakiś czas po północy jest w porządku, rozliczenie ostatnich pięciu
sekund - nie, bo część tych pięciu sekund może wciąż siedzieć w buforze innego
procesu.
To własność uruchamiania kilku procesów naraz, a nie ograniczenie samej tej wtyczki, i gorzej byłoby to przemilczeć niż powiedzieć wprost. Sama deduplikacja jest tym niedotknięta: klucze są globalne i niezależne od tego, który proces je wyliczył, więc sink trzyma dokładnie jeden wiersz na klucz, niezależnie od tego, ile procesów do niego pisało.