İçeriğe geç

observe ve hold

observe, hold, extend, capture ve release bağlı katalogdadır. Her biri satırları genişletir (feeds), getRate’i çağırır, price()’ı çalıştırır, commit eder ve bir sonuç döner. Planı görmek için metering.plan.<aynı çağrı>: aynı imza, commit yok, { result, plan, lines } döner.

İki imza: tek satır ve çok satır.

metering.observe(meter, dims, quantity, ref, ctx, opts?)
metering.observe([{ meter, dims, quantity }, …], ref, ctx, opts?)

Çok satırlı bir observe tek, tutarlı bir gözlemdir: tek plan, tek commit, ya hep ya hiç. quantity negatif olmayan bir tamsayıdır; birim senin seçimindir (mesaj, token, dakika, koltuk-saniye, mikro-USD işlem tutarı).

Seçenek Anlamı Varsayılan
at Gözlem zamanı (epoch ms); getRate’e ve usage satırına gider Date.now()
usedSoFar getRate’inkini ezer; tek satırda sayı, çok satırda { [meter]: sayı } —
ifMissing getRate null dönerse: reject (hiçbir şey yazma, no_price) ya da free (0 ile say ve yaz) reject
feeds false: havuz genişletmesi yapma true
Durum Ne olur
Tarife var, fiyatı 0 Ücretsiz ve fiyatlı: sayılır, usage’a 0 yazılır, ledger’a yazılmaz
getRate → null, ifMissing: "reject" { ok: false, reason: "no_price", meter }; hiçbir şey yazılmaz
getRate → null, ifMissing: "free" 0 fiyatlı tek kademeli tarifeyle sayılır ve yazılır

no_price bir hata değil bir durumdur: fiyatı geç yayımlanan bir kalem (WhatsApp pencere fiyatları gibi) kuyruğa alınıp aynı at ile yeniden denenebilir (mesajlaşma örneği).

docs/src/snippets/plan-and-hold.test.ts
// Reserve: up to 3 SMS segments + 1 verification. The hold is a value; store it (e.g. on the OTP record).
const h = await metering.hold(
[
{ meter: "otp/sms", dims: { country: "TR" }, quantity: 3 },
{ meter: "otp/verify", quantity: 1 },
],
{ type: "otp_send", id: "otp_1" },
ctx,
);
if (!h.ok) throw new Error(h.reason);
h.upperBound; // 65000 + pool line's worst case (3 × 2000) = 71000
h.hold.holdId; // "otp_send:otp_1" → ledger: { op: "hold", amount: 71000, refId: "otp_send:otp_1:hold" }
// Resend: grow the same hold. Existing lines keep their tariff; getRate is asked only for new lines.
const e = await metering.extend(h.hold, [{ meter: "otp/sms", dims: { country: "TR" }, quantity: 1 }], ctx);
if (!e.ok) throw new Error(e.reason);
// ledger: { op: "extend", amount: 22000, refId: "otp_send:otp_1:extend:1" }
// Verified: capture what was sent (pool line follows its feeder), then release the rest.
const c = await metering.capture(
e.hold,
[
{ meter: "otp/sms", quantity: 2 },
{ meter: "otp/verify", quantity: 1 },
],
ctx,
);
if (!c.ok) throw new Error(c.reason);
c.charged; // 45000 → refIds "otp_send:otp_1:otp/sms:capture:1", "otp_send:otp_1:otp/verify:capture:1", …
const r = await metering.release(c.hold, ctx);
// ledger: { op: "release", amount: 93000 − 45000 = 48000, refId: "otp_send:otp_1:release" }
  • hold çok satırlı bir rezervasyondur. Her satırın tarifesi ve usedSoFar’ı hold’un içine gömülür; uygulama hold’u saklar (ör. OTP kaydının üstünde), kütüphane ledger’dan okumaz.
  • Her çağrı hold’un güncellenmiş hâlini döner (result.hold, A87). Sonraki çağrıya bu yeni değeri ver. Aynı eski hold ile retry aynı refId’yi üretir ve commit’te no-op olur.
  • Rezervasyon her satırın hold üst sınırıdır; havuz satırları da ayrılır. Hold charged: 0 döner: rezervasyon para hareketi değildir.
  • Bir hold’da her (meter, dims) için tek satır vardır (A101): aynı anahtarlı satırlar birleşir, havuz satırı besleyicilerini feeders[] olarak taşır. Hold her anahtarı bir kez yuvarlar: cumulative’de ayrı satırlı bir observe ile birebir aynıdır, per_event_up’ta yalnızca yuvarlama kadar (satır başına en fazla 2 mikro birim) farklıdır, iki yönde de.
  • extend hold’u büyütür: var olan (meter, dims) satırları kendi tarifesiyle büyür, getRate yalnızca yeni satırlar için çağrılır; havuz satırları besleyenlerini izler.
  • capture gerçekleşeni tahsil eder. Satır meter ile bulunur; aynı sayaç birden çok satırdaysa dims gerekir. Havuz satırları besleyenleriyle birlikte yakalanır. Kısmi ve tekrarlı capture serbesttir.
  • release kalan rezervasyonu (upperBound − captured) serbest bırakır ve hold’u kapatır.
docs/src/snippets/plan-and-hold.test.ts
// 1/3 micro-USD per unit. Three captures of 1 unit each cost what one observation of 3 costs.
const m = buildMetering().refs(["job"]).meter("gpu/ms").bind(memoryAdapters({ rates: { "gpu/ms": flat(1, 3) } }));
const h = await m.hold("gpu/ms", {}, 3, { type: "job", id: "j1" }, { accountId: "acme" });
if (!h.ok) throw new Error(h.reason);
let hold = h.hold;
const charged: number[] = [];
for (let i = 0; i < 3; i++) {
const c = await m.capture(hold, [{ meter: "gpu/ms", quantity: 1 }], { accountId: "acme" });
if (!c.ok) throw new Error(c.reason);
charged.push(c.charged);
hold = c.hold;
}
charged; // [1, 0, 0] — R(u, c+q) − R(u, c), not [1, 1, 1]

Kısmi capture’lar hold’u tek bir gözlem gibi yuvarlar: n. capture R(u, c+q) − R(u, c) tahsil eder; u hold anındaki usedSoFar, c o ana kadar yakalanan adettir (A88). Aksi hâlde per_event_up parçalı capture’da üst sınırı aşardı.

docs/src/snippets/plan-and-hold.test.ts
const h = await metering.hold("otp/verify", {}, 1, { type: "otp_send", id: "otp_2" }, ctx);
if (!h.ok) throw new Error(h.reason);
// Default: the tariff stored in the hold (5000). Pass { rate } to price at capture time instead.
const c = await metering.capture(h.hold, [{ meter: "otp/verify", quantity: 1 }], ctx, { rate: flat(4_000) });
// c.charged → 4000 (must still fit the hold's upper bound, else hold_exceeded)

Varsayılan olarak capture, hold’daki tarifeyi kullanır ve getRate’i tekrar çağırmaz: özel fiyat sona erse bile açık hold eski fiyatı korur (B20). Capture anının fiyatını isteyen { rate } verir: tek bir tarife ya da { [meter]: Rate }. Toplam yine hold’un üst sınırını aşamaz.

docs/src/snippets/plan-and-hold.test.ts
if (!r.ok) throw new Error(r.reason);
await metering.capture(r.hold, [{ meter: "otp/sms", quantity: 1 }], ctx); // { ok: false, reason: "hold_closed" }
await metering.capture(c.hold, [{ meter: "otp/sms", quantity: 3 }], ctx); // { ok: false, reason: "hold_exceeded", meter: "otp/sms" } (2 + 3 > 4)

hold_exceeded: bir satırda yakalanan adet ayrılanı aşıyor ya da toplam tutar üst sınırı aşıyor. hold_closed: hold release edilmiş (A86). İkisinde de hiçbir şey yazılmaz; önce extend et.

Başarı: { ok: true, charged, lines } (hold/capture’da ayrıca holdId, hold; hold’da upperBound). charged bu çağrının net para hareketidir: tahsilatlar, capture’lar ve düzeltmeler; rezervasyon değil.

reason Ne zaman Yazılan
no_price getRate null döndü, ifMissing: "reject" hiçbir şey
usage_required Tarife usedSoFar istiyor, verilmedi hiçbir şey
insufficient_credit commit InsufficientCredit fırlattı (isimle de eşleşir, RPC’den geçer) hiçbir şey
quota_exceeded usedSoFar + istenen > getRate’in limit’i, ya da commit QuotaExceeded fırlattı; cause: { meter, limit, used, requested } hiçbir şey
invalid_context Bağlam şemadan geçmedi, accountId string değil ya da hold başka hesabın hiçbir şey
invalid_dims Boyutlar sayacın şemasından geçmedi hiçbir şey
invalid_rate getRate’in ya da capture { rate }’in döndüğü tarife validateRate’ten geçmedi hiçbir şey
invalid_ref ref şekli yanlış ya da ref.type refs() içinde değil hiçbir şey
hold_exceeded Capture ayrılanı ya da üst sınırı aşıyor hiçbir şey
hold_closed Hold zaten release edilmiş hiçbir şey
commit_failed commit başka bir hata fırlattı; hata cause’ta commit’in atomikliğine bağlı: hiçbir şey olmalı

“Bakiye ≥ tutar” kütüphanede değildir. commit yetersiz bakiyede InsufficientCredit fırlatır, lib insufficient_credit döner. Hold ve extend de borç sayılır: prepaid bir commit ayrılabilir bakiyeyi aşan rezervasyonu reddeder. Postpaid = commit hiç fırlatmaz (B26).

Bakiye kapısı “ödeyebiliyor mu?” sorusunu yanıtlar; kota “hakkı var mı?” sorusunu. getRate rate ve usedSoFar’ın yanında aynı dönem için bir limit dönebilir (A107):

docs/src/snippets/plan-and-hold.test.ts
const metering = buildMetering()
.context<{ accountId: string; plan: "free" | "pro" }>()
.refs(["api"])
.meter("api/call", ["route"])
.getRate(async (meter, _dims, ctx, _at) => ({
rate: free,
usedSoFar: mem.store.used(ctx.accountId, meter), // this month's calls
limit: ctx.plan === "free" ? 10 : undefined, // the same period's cap; 0 = not in the plan
}))
.commit(mem.commit);
const ctx = { accountId: "acme", plan: "free" as const };
await metering.observe("api/call", { route: "/a" }, 9, { type: "api", id: "1" }, ctx); // ok: 9 of 10
const r = await metering.observe("api/call", { route: "/a" }, 4, { type: "api", id: "2" }, ctx);
// → { ok: false, reason: "quota_exceeded", meter: "api/call",
// cause: { meter: "api/call", limit: 10, used: 9, requested: 4 } } nothing written
  • Ya hep ya hiç. 9/10 iken gelen 4’lük istek tamamen reddedilir. Kısmi kullanım uygulamanın kararıdır: cause.limit − cause.used kalanı söyler.
  • Sayaç başına. Aynı sayacın satırları, boyutları farklı olsa da toplanır. limit: 0 = bu sayaç planda yok (no_price’tan farklı: fiyat var, hak yok).
  • Havuzlar. Havuz sayacının limit’i onu besleyen her kanalı sınırlar (“SMS + WhatsApp toplam 1.000, sonra dur”).
  • Atomik. Kütüphanenin ön kontrolü eşzamanlı iki isteği göremez: ikisi de 999’u okur. Bu yüzden limit usage satırına yazılır ve adapter onu commit’in transaction’ında yeniden kontrol eder, aşarsa QuotaExceeded fırlatır (adapter sözleşmesi).
  • Hold. hold ve extend ayrılan miktarı kontrol eder; capture asla reddedilmez (gönderilmiş bir SMS’in kaydı reddedilemez).
  • Koltuklar değil. Sayaçlar yalnızca artar. Koltuk gibi inip çıkan seviyelerin limiti uygulamadadır (A108):
examples/saas-seats/src/seats.ts
export class SeatRegistry {
#seats = new Map<string, number>();
/** Level changes, kept for `integrate()` at cycle close. */
readonly samples = new Map<string, Sample[]>();
constructor(private purchased: (accountId: string) => number) {}
current(accountId: string): number {
return this.#seats.get(accountId) ?? 0;
}
/** All or nothing: 9 of 10 used and 4 requested → refused, `remaining: 1` tells the UI what fits. */
add(accountId: string, count: number, at: number): AddSeats {
// in production: one DB transaction (SELECT … FOR UPDATE, or a Durable Object per account)
const current = this.current(accountId);
const purchased = this.purchased(accountId);
if (current + count > purchased) return { ok: false, reason: "seat_limit", purchased, current, remaining: purchased - current };
return this.#set(accountId, current + count, at);
}
remove(accountId: string, count: number, at: number): AddSeats {
return this.#set(accountId, Math.max(0, this.current(accountId) - count), at);
}
#set(accountId: string, seats: number, at: number): AddSeats {
this.#seats.set(accountId, seats);
const s = this.samples.get(accountId) ?? [];
s.push({ at, value: seats });
this.samples.set(accountId, s);
return { ok: true, seats };
}
}