From 07a404dfbcbb1abca423808b369ec551f1e0f730 Mon Sep 17 00:00:00 2001 From: hude Date: Tue, 2 Jun 2026 16:36:32 +0900 Subject: [PATCH 1/8] Switch update charging to instruction counters --- crates/vfs_canister/src/lib.rs | 39 ++++--- crates/vfs_canister/src/tests.rs | 42 +++++-- docs/DB_LIFECYCLE.md | 2 +- docs/payment.md | 6 +- extensions/wiki-clipper/manifest.json | 2 +- extensions/wiki-clipper/package-lock.json | 4 +- extensions/wiki-clipper/package.json | 2 +- scripts/smoke/local_canister_post_upgrade.sh | 2 +- wikibrowser/app/cycles/cycles-client.tsx | 11 +- .../app/dashboard/dashboard-client.tsx | 2 +- wikibrowser/app/home-ui.tsx | 106 +++++++++++------- wikibrowser/lib/cycles-wallet.ts | 73 +++++++----- wikibrowser/scripts/check-cycles.mjs | 13 ++- wikibrowser/scripts/check-dashboard.mjs | 36 +++++- 14 files changed, 219 insertions(+), 121 deletions(-) diff --git a/crates/vfs_canister/src/lib.rs b/crates/vfs_canister/src/lib.rs index d2ff10b7..9342ef45 100644 --- a/crates/vfs_canister/src/lib.rs +++ b/crates/vfs_canister/src/lib.rs @@ -15,6 +15,8 @@ use std::time::Duration; use candid::utils::decode_args; use candid::{CandidType, Decode, Deserialize, Nat, Principal, export_service}; #[cfg(not(test))] +use ic_cdk::api::PerformanceCounterType; +#[cfg(not(test))] use ic_cdk::call::Call; use ic_cdk::{init, post_upgrade, query, update}; #[cfg(target_arch = "wasm32")] @@ -66,6 +68,7 @@ const II_ALTERNATIVE_ORIGINS_BODY: &str = r#"{"alternativeOrigins":["https://wik const ICP_CLI_LOGIN_DISCOVERY_PATH: &str = "/.well-known/ic-cli-login"; const ICP_CLI_LOGIN_PATH: &str = "/login"; const ICP_CLI_LOGIN_HTML: &str = include_str!("icp_cli_login.html"); +const UPDATE_EXECUTION_BASE_CYCLES: u128 = 5_000_000; #[cfg(target_arch = "wasm32")] const INDEX_DB_MEMORY_ID: u16 = 10; @@ -1195,7 +1198,7 @@ thread_local! { static TEST_LAST_LEDGER_FROM: RefCell> = const { RefCell::new(None) }; static TEST_CALLER_PRINCIPAL: RefCell> = const { RefCell::new(None) }; static TEST_DATABASE_CYCLES_PURCHASE_APPLY_FAIL_ONCE: RefCell = const { RefCell::new(false) }; - static TEST_CYCLE_BALANCES: RefCell> = const { RefCell::new(Vec::new()) }; + static TEST_UPDATE_CHARGE_UNITS: RefCell> = const { RefCell::new(Vec::new()) }; } #[cfg(test)] @@ -1216,9 +1219,9 @@ fn set_next_ledger_transfer_from_outcome_for_test(outcome: LedgerTransferFromOut } #[cfg(test)] -fn set_cycle_balances_for_test(balances: Vec) { - TEST_CYCLE_BALANCES.with(|slot| { - slot.replace(balances); +fn set_update_charge_units_for_test(units: Vec) { + TEST_UPDATE_CHARGE_UNITS.with(|slot| { + slot.replace(units); }); } @@ -1230,7 +1233,7 @@ fn clear_ledger_transactions_for_test() { TEST_LEDGER_TRANSFER_FEES.with(|slot| { slot.borrow_mut().clear(); }); - TEST_CYCLE_BALANCES.with(|slot| { + TEST_UPDATE_CHARGE_UNITS.with(|slot| { slot.borrow_mut().clear(); }); } @@ -1397,24 +1400,26 @@ fn now_millis() -> i64 { } } -fn cycle_balance() -> u128 { +fn update_charge_units() -> u128 { #[cfg(test)] { - TEST_CYCLE_BALANCES.with(|slot| { - let mut balances = slot.borrow_mut(); - if balances.is_empty() { - 1_000_000_000_000 - } else { - balances.remove(0) - } + TEST_UPDATE_CHARGE_UNITS.with(|slot| { + let mut units = slot.borrow_mut(); + if units.is_empty() { 0 } else { units.remove(0) } }) } #[cfg(not(test))] { - ic_cdk::api::canister_cycle_balance() + u128::from(ic_cdk::api::performance_counter( + PerformanceCounterType::InstructionCounter, + )) } } +fn update_charge_cycles(before: u128, after: u128) -> u128 { + UPDATE_EXECUTION_BASE_CYCLES + after.saturating_sub(before) +} + fn now_nanos() -> u64 { #[cfg(test)] { @@ -1592,7 +1597,7 @@ where { let caller = caller_text(); let now = now_millis(); - let before_cycles = cycle_balance(); + let before_charge_units = update_charge_units(); SERVICE.with(|slot| { let borrowed = slot.borrow(); let service = borrowed @@ -1600,8 +1605,8 @@ where .ok_or_else(|| "wiki service is not initialized".to_string())?; let cycles_billing_config = authorize(service, &caller)?; let result = f(service, &caller, now); - let after_cycles = cycle_balance(); - let cycles_delta = before_cycles.saturating_sub(after_cycles); + let after_charge_units = update_charge_units(); + let cycles_delta = update_charge_cycles(before_charge_units, after_charge_units); if result.is_ok() && let Some(database_id) = database_id.as_deref() && let Err(error) = service.charge_database_update( diff --git a/crates/vfs_canister/src/tests.rs b/crates/vfs_canister/src/tests.rs index e50fff37..14a22f8f 100644 --- a/crates/vfs_canister/src/tests.rs +++ b/crates/vfs_canister/src/tests.rs @@ -36,10 +36,10 @@ use super::{ multi_edit_node, outgoing_links, parse_upgrade_cycles_billing_config_arg, purchase_database_cycles, query_context, query_index_sql_json, read_database_archive_chunk, read_node, read_node_context, rename_database, revoke_database_access, search_node_paths, - search_nodes, set_cycle_balances_for_test, set_next_ledger_transfer_from_outcome_for_test, - set_test_caller_principal_for_test, settle_database_storage_charges, source_evidence, status, - transfer_from_error_outcome, update_cycles_billing_config, write_database_restore_chunk, - write_node, write_nodes, + search_nodes, set_next_ledger_transfer_from_outcome_for_test, + set_test_caller_principal_for_test, set_update_charge_units_for_test, + settle_database_storage_charges, source_evidence, status, transfer_from_error_outcome, + update_cycles_billing_config, write_database_restore_chunk, write_node, write_nodes, }; fn install_test_service() { @@ -1334,8 +1334,9 @@ fn canister_list_databases_hides_deleted_databases() { } #[test] -fn write_nodes_skips_zero_charge_ledger_and_writes_nodes() { +fn write_nodes_records_instruction_charge_and_writes_nodes() { install_test_service(); + set_update_charge_units_for_test(vec![10_000, 10_321]); let results = write_nodes(WriteNodesRequest { database_id: "default".to_string(), @@ -1359,7 +1360,16 @@ fn write_nodes_skips_zero_charge_ledger_and_writes_nodes() { .expect("batch write should succeed"); assert_eq!(results.len(), 2); - assert!(database_charge_methods("default").is_empty()); + let entries = list_database_cycle_entries("default".to_string(), None, 20) + .expect("database cycles ledger should load") + .entries; + let charge = entries + .iter() + .find(|entry| entry.kind == "charge") + .expect("charge entry should exist"); + assert_eq!(charge.amount_cycles, -5_000_321); + assert_eq!(charge.cycles_delta, Some(5_000_321)); + assert_eq!(charge.method.as_deref(), Some("write_nodes")); assert!( read_node("default".to_string(), "/Wiki/batch-a.md".to_string()) .expect("read should succeed") @@ -1368,8 +1378,9 @@ fn write_nodes_skips_zero_charge_ledger_and_writes_nodes() { } #[test] -fn write_node_and_write_nodes_skip_zero_charge_ledger() { +fn write_node_and_write_nodes_record_instruction_charges() { install_test_service(); + set_update_charge_units_for_test(vec![7, 11, 13, 19]); write_node(WriteNodeRequest { database_id: "default".to_string(), @@ -1392,7 +1403,18 @@ fn write_node_and_write_nodes_skip_zero_charge_ledger() { }) .expect("batch write should succeed"); - assert!(database_charge_methods("default").is_empty()); + let entries = list_database_cycle_entries("default".to_string(), None, 20) + .expect("database cycles ledger should load") + .entries; + let charges = entries + .iter() + .filter(|entry| entry.kind == "charge") + .collect::>(); + assert_eq!(charges.len(), 2); + assert_eq!(charges[0].method.as_deref(), Some("write_node")); + assert_eq!(charges[0].cycles_delta, Some(5_000_004)); + assert_eq!(charges[1].method.as_deref(), Some("write_nodes")); + assert_eq!(charges[1].cycles_delta, Some(5_000_006)); } #[test] @@ -1405,7 +1427,7 @@ fn write_node_overdrawn_charge_consumes_balance_and_suspends_database() { .and_then(|database| database.cycles_balance) .expect("default database should have cycles balance"); - set_cycle_balances_for_test(vec![1_000_000_000_000, 0]); + set_update_charge_units_for_test(vec![0, 1_000_000_000_000]); let written = write_node(WriteNodeRequest { database_id: "default".to_string(), path: "/Wiki/overdrawn.md".to_string(), @@ -1439,7 +1461,7 @@ fn write_node_overdrawn_charge_consumes_balance_and_suspends_database() { .expect("charge entry should exist"); assert_eq!(charge.amount_cycles, -(before_balance as i64)); assert_eq!(charge.balance_after_cycles, 0); - assert_eq!(charge.cycles_delta, Some(1_000_000_000_000)); + assert_eq!(charge.cycles_delta, Some(1_000_005_000_000)); assert_eq!(charge.method.as_deref(), Some("write_node")); } diff --git a/docs/DB_LIFECYCLE.md b/docs/DB_LIFECYCLE.md index ec212ac7..c7638bc4 100644 --- a/docs/DB_LIFECYCLE.md +++ b/docs/DB_LIFECYCLE.md @@ -70,7 +70,7 @@ Successful DB update calls are charged after execution. The charge is raw cycle cycles_delta ``` -Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), controlled by `cycles_per_kinic`. Before a metered update, the caller role is checked first, then the DB cycles balance must be at least `min_update_cycles` and the DB must not be suspended. Non-members receive access errors without learning cycles state. If the post-update charge exceeds the DB cycles balance, the remaining DB cycles are fully charged, the balance becomes `0`, and the DB is suspended. +Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), controlled by `cycles_per_kinic`. Before a metered update, the caller role is checked first, then the DB cycles balance must be at least `min_update_cycles` and the DB must not be suspended. Non-members receive access errors without learning cycles state. Metered update charge uses the current message instruction counter delta plus the 13-node update execution base fee, not same-message canister balance deltas. If the post-update charge exceeds the DB cycles balance, the remaining DB cycles are fully charged, the balance becomes `0`, and the DB is suspended. Storage billing settles every 24h from a canister timer, with controller-only `settle_database_storage_charges()` as recovery path. Only active DBs are charged. The 13-node subnet rate is fixed at `127_000 cycles / GiB / sec`: diff --git a/docs/payment.md b/docs/payment.md index 98dd6843..71ca27aa 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -132,7 +132,7 @@ approved_allowance_e8s = payment_amount_e8s + ledger_fee_e8s approve の transaction fee は wallet 残高から別途支払われる。approve は現在 allowance を `expected_allowance` として渡し、30 分後に expire する。approve 後に purchase が失敗した場合、UI は approval が expire まで残る旨を error に含める。 -UI は `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID` と request canister ID が一致しない場合に拒否する。Plug は VFS canister と KINIC ledger canister を whitelist して接続する。OISY は ICRC wallet の call-canister 結果 certificate を検証し、method、canister、arg、reply を照合する。 +UI は `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID` と request canister ID が一致しない場合に拒否する。Plug は VFS canister と KINIC ledger canister を whitelist して接続する。OISY は接続確認後に signer popup を閉じ、購入時に再度 signer を開いて同じ owner であることを確認する。OISY は ICRC wallet の call-canister 結果 certificate を検証し、method、canister、arg、reply を照合する。 ## ICRC-21 consent @@ -154,11 +154,11 @@ metered update は実行前に `prepare_metered_update` で認可と残高確認 `check_database_write_cycles(database_id)` は anonymous caller を明示的に拒否し、writer 以上の role と cycles 利用可能状態を確認する。 -canister の metered update wrapper は、更新前後の canister cycle balance 差分を `cycles_delta` として計算する。更新関数が `Ok` を返した場合だけ、`charge_database_update` を実行する。 +canister の metered update wrapper は、更新前後の `performance_counter(InstructionCounter)` 差分に 13-node application subnet の update 実行 base fee `5_000_000 cycles` を加えた値を `cycles_delta` として計算する。`canister_cycle_balance` は同一 message 内の実行課金確定前の残高を返すため、update 本体の課金計測には使わない。更新関数が `Ok` を返した場合だけ、`charge_database_update` を実行する。 更新課金額は `cycles_delta` そのものである。`cycles_delta` が `i64` に収まらない場合は `cycle charge exceeds i64 limit` になる。`cycles_delta == 0` の場合、残高更新も ledger 記録も行わない。 -`charge_database_update` は現在残高より請求額が大きい場合、残高を全徴収して `balance_after_cycles = 0` にし、DB を suspended にする。ledger の `amount_cycles` は実際に徴収した cycles、`cycles_delta` は実測請求額を記録する。 +`charge_database_update` は現在残高より請求額が大きい場合、残高を全徴収して `balance_after_cycles = 0` にし、DB を suspended にする。ledger の `amount_cycles` は実際に徴収した cycles、`cycles_delta` は update 実行 base fee と instruction 差分から計算した請求額を記録する。 課金成功時は残高から実徴収額を引く。更新後残高が `min_update_cycles` 未満なら `suspended_at_ms` を課金時刻に設定し、それ以上なら `NULL` にする。ledger には以下を記録する。 diff --git a/extensions/wiki-clipper/manifest.json b/extensions/wiki-clipper/manifest.json index ca03ce77..807827b6 100644 --- a/extensions/wiki-clipper/manifest.json +++ b/extensions/wiki-clipper/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Kinic Wiki Clipper", - "version": "0.1.1", + "version": "0.1.2", "description": "Create Kinic Wiki pages from the current tab and export ChatGPT conversations.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs5287sDaurUdgrnxKcx/A5JjcRpUFrxb5qXEvtkiO2STfEVsprr+uUltmecOlbHzt/8n15LxXPOyl3C9S2ZyArZWgUbOy0o3cCTDwLM80KkMj+x0hHGwC5KGAjZqOWnyQsFe6jonlQuTUye70xFodZdDfgU0V7SMjmrCFArIno+8A+3aVdODpcCMSkwiWaAFmCbvXeVFcAn70tcI0rJ/GXgnksU4gqwL6+0S5ZvLvhc7xRLNRjK+j2YXTRxxrGKQJ1ongCgJSgWzzSnJVOJ3t4K7ZosBlCZV6p/qTrY/4V2LC/XACRPctnKgNmD/9rvedIZdCVBMBYrfPOMSbteZeQIDAQAB", "action": { diff --git a/extensions/wiki-clipper/package-lock.json b/extensions/wiki-clipper/package-lock.json index a45c36c5..aa8af3dd 100644 --- a/extensions/wiki-clipper/package-lock.json +++ b/extensions/wiki-clipper/package-lock.json @@ -1,12 +1,12 @@ { "name": "kinic-wiki-clipper", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kinic-wiki-clipper", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@icp-sdk/auth": "^5.0.0", "@icp-sdk/core": "^5.4.0", diff --git a/extensions/wiki-clipper/package.json b/extensions/wiki-clipper/package.json index 385da43d..122c94e1 100644 --- a/extensions/wiki-clipper/package.json +++ b/extensions/wiki-clipper/package.json @@ -1,6 +1,6 @@ { "name": "kinic-wiki-clipper", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "scripts": { diff --git a/scripts/smoke/local_canister_post_upgrade.sh b/scripts/smoke/local_canister_post_upgrade.sh index c7ac346d..0b909cc1 100755 --- a/scripts/smoke/local_canister_post_upgrade.sh +++ b/scripts/smoke/local_canister_post_upgrade.sh @@ -104,7 +104,7 @@ export ICP_ENVIRONMENT export KINIC_LEDGER_CANISTER_ID export SMOKE_CYCLE_PURCHASE_E8S -scripts/local/deploy_wiki.sh +scripts/local/deploy_wiki.sh "$@" CANISTER_ID="$(resolve_canister_id)" export CANISTER_ID export REPLICA_HOST diff --git a/wikibrowser/app/cycles/cycles-client.tsx b/wikibrowser/app/cycles/cycles-client.tsx index bfa5bbed..505b540a 100644 --- a/wikibrowser/app/cycles/cycles-client.tsx +++ b/wikibrowser/app/cycles/cycles-client.tsx @@ -4,8 +4,9 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { CheckCircle2, CircleAlert, Info, PlugZap, Wallet } from "lucide-react"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { parseKinicAmountE8sInput, parseCyclesTarget } from "@/lib/cycles-url"; import { connectOisyWallet, connectPlugWallet, purchaseCyclesWithOisy, purchaseCyclesWithPlug, type ConnectedOisyWallet, type ConnectedPlugWallet } from "@/lib/cycles-wallet"; import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; @@ -20,6 +21,7 @@ type CyclesClientProps = { }; export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesClientProps) { + const router = useRouter(); const [status, setStatus] = useState("idle"); const [message, setMessage] = useState(null); const [provider, setProvider] = useState(null); @@ -53,12 +55,6 @@ export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesCli : null; const purchaseDisabled = !selectedProvider || Boolean(error) || Boolean(amountError) || busy; - useEffect(() => { - return () => { - if (oisyWallet) void oisyWallet.wallet.disconnect(); - }; - }, [oisyWallet]); - async function connect(nextProvider: CyclesProvider) { setStatus("connecting"); setProvider(nextProvider); @@ -105,6 +101,7 @@ export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesCli ); if (selectedProvider === "oisy") setOisyWallet(null); setStatus("success"); + router.replace("/"); } catch (cause) { setMessage(cause instanceof Error ? cause.message : String(cause)); setStatus("error"); diff --git a/wikibrowser/app/dashboard/dashboard-client.tsx b/wikibrowser/app/dashboard/dashboard-client.tsx index bc4fd8c8..d693b07f 100644 --- a/wikibrowser/app/dashboard/dashboard-client.tsx +++ b/wikibrowser/app/dashboard/dashboard-client.tsx @@ -314,7 +314,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) ) : !databaseId ? (

Select a database to manage

-

Open the Database dashboard, then choose Access on a database row.

+

Open the Database dashboard, then choose Manage on a database row.

Open Database dashboard diff --git a/wikibrowser/app/home-ui.tsx b/wikibrowser/app/home-ui.tsx index 4ed7d25c..82b69557 100644 --- a/wikibrowser/app/home-ui.tsx +++ b/wikibrowser/app/home-ui.tsx @@ -1,9 +1,9 @@ "use client"; import Link from "next/link"; -import { BookOpen, Settings, Share2, Wallet } from "lucide-react"; +import { BookOpen, PackageSearch, Settings, Share2, Wallet } from "lucide-react"; import type { ReactNode } from "react"; -import { databaseCyclesView, databaseCyclesHref } from "@/lib/cycles-state"; +import { databaseCyclesView, databaseCyclesHref, formatCycles as formatCycleBalance, type DatabaseCycleView } from "@/lib/cycles-state"; import type { CyclesBillingConfig, DatabaseSummary } from "@/lib/types"; import { isRoutableDatabaseId, publicDatabasePath, xShareDatabaseHref } from "@/lib/share-links"; @@ -101,7 +101,7 @@ export function OfficialKinicWikiPanel() { - Access + Manage @@ -157,15 +157,17 @@ function DatabaseSection({ - + + - + - {mode === "member" ? : null} - + {mode === "member" ? : null} + {mode === "member" ? : null} + @@ -181,40 +183,38 @@ function DatabaseSection({ function DatabaseTableRow({ cyclesConfig, database, mode }: { cyclesConfig: CyclesBillingConfig | null; database: DatabaseRow; mode: "member" | "public" }) { const active = isActiveRoutableDatabase(database); + const cycles = databaseCyclesView(database, cyclesConfig); return ( + - + - + {mode === "member" ? ( + ) : null} + {mode === "member" ? ( + ) : null} ); @@ -222,38 +222,30 @@ function DatabaseTableRow({ cyclesConfig, database, mode }: { cyclesConfig: Cycl function DatabaseMobileCard({ cyclesConfig, database, mode }: { cyclesConfig: CyclesBillingConfig | null; database: DatabaseRow; mode: "member" | "public" }) { const active = isActiveRoutableDatabase(database); + const cycles = databaseCyclesView(database, cyclesConfig); return (

{database.name}

- {mode === "member" && database.publicReadable ? Public : null} + {database.publicReadable ? : null}
-

{database.databaseId}

+ - - - + + +
{active ? ( } label="Open" /> ) : null} - {active && mode === "member" && database.publicReadable ? ( - } label="Open public" /> - ) : null} + {mode === "member" && active ? } label="Registry" /> : null} {mode === "member" ? ( - active ? ( - - Registry - - ) : null - ) : null} - {mode === "member" ? ( - } label="Cycles" /> + } label="Top up" /> ) : null} {active && database.publicReadable ? : null} - } label="Access" /> + } label="Manage" />
); @@ -271,6 +263,10 @@ function ShareDatabaseLink({ database }: { database: DatabaseRow }) { ); } +function PublicBadge() { + return P; +} + function DatabaseActionLink({ ariaLabel, external = false, href, icon, label }: { ariaLabel?: string; external?: boolean; href: string; icon: ReactNode; label: string }) { const className = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-lg border border-line bg-white px-2.5 py-1.5 text-sm font-medium text-accent no-underline shadow-[0_4px_10px_#14142b0a] hover:border-accent hover:bg-accent hover:text-white"; @@ -290,11 +286,39 @@ function DatabaseActionLink({ ariaLabel, external = false, href, icon, label }: ); } +function databaseStatusSummary(database: DatabaseRow, cycles: DatabaseCycleView): string { + const databaseStatus = formatStatus(database.status); + if (database.status === "pending") return "Pending · Needs cycles"; + if (!cycles.configAvailable) return `${databaseStatus} · Cycles unknown`; + if (database.status !== "active") return databaseStatus; + if (cycles.state === "suspended") return "Suspended"; + if (cycles.state === "low-balance") return "Low cycles"; + return "Active"; +} + +function databaseCyclesBalanceSummary(database: DatabaseRow): string { + const balance = parseCyclesBalance(database.cyclesBalance); + return balance === null ? "-" : formatCycleBalance(balance); +} + +function parseCyclesBalance(value: string | null | undefined): bigint | null { + if (!value || !/^[0-9]+$/.test(value)) return null; + return BigInt(value); +} + +function formatStatus(value: string): string { + return value + .split(/[_-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1).toLowerCase()}`) + .join(" "); +} + function DatabaseCardMeta({ label, value }: { label: string; value: string }) { return (
{label}
-
{value}
+
{value}
); } @@ -337,7 +361,3 @@ function openDatabaseHref(database: DatabaseRow): string { const base = `/${encodeURIComponent(database.databaseId)}/Wiki`; return !database.member && database.publicReadable ? `${base}?read=anonymous` : base; } - -function openPublicDatabaseHref(database: DatabaseRow): string { - return publicDatabasePath(database.databaseId); -} diff --git a/wikibrowser/lib/cycles-wallet.ts b/wikibrowser/lib/cycles-wallet.ts index a88b587e..db8fee55 100644 --- a/wikibrowser/lib/cycles-wallet.ts +++ b/wikibrowser/lib/cycles-wallet.ts @@ -115,7 +115,6 @@ type LedgerAccount = { }; export type ConnectedOisyWallet = { - wallet: CyclesPurchaseIcrcWallet; owner: string; }; @@ -165,19 +164,30 @@ class CyclesPurchaseIcrcWallet extends IcrcWallet { } } -export async function connectOisyWallet(): Promise { - const wallet = await CyclesPurchaseIcrcWallet.connect({ +function openOisyWallet(): Promise { + return CyclesPurchaseIcrcWallet.connect({ url: process.env.NEXT_PUBLIC_OISY_SIGNER_URL ?? DEFAULT_OISY_SIGNER_URL, host: process.env.NEXT_PUBLIC_WIKI_IC_HOST ?? "https://icp0.io" }); +} + +async function safeDisconnectOisyWallet(wallet: CyclesPurchaseIcrcWallet): Promise { + try { + await wallet.disconnect(); + } catch { + // Cleanup failures must not hide connect, approve, or purchase errors. + } +} + +export async function connectOisyWallet(): Promise { + const wallet = await openOisyWallet(); try { const accounts = await wallet.accounts(); const account = accounts[0]; if (!account) throw new Error("OISY account not found"); - return { wallet, owner: account.owner }; - } catch (cause) { - await wallet.disconnect(); - throw cause; + return { owner: account.owner }; + } finally { + await safeDisconnectOisyWallet(wallet); } } @@ -195,26 +205,35 @@ export async function connectPlugWallet(): Promise { export async function purchaseCyclesWithOisy(request: CyclesPurchaseRequest, connection: ConnectedOisyWallet): Promise { const prepared = await prepareCyclesPurchase(request, connection.owner); - const approveBlockIndex = await connection.wallet.approve({ - owner: connection.owner, - ledgerCanisterId: prepared.kinicLedgerCanisterId, - params: approveParams(request.canisterId, prepared.approvedAllowanceE8s, prepared.currentAllowanceE8s, prepared.expiresAt), - options: { timeoutInMilliseconds: CALL_TIMEOUT_MS } - }); - const purchase = await purchaseAfterApprove( - () => oisyCallCyclesPurchase(connection.wallet, connection.owner, request.canisterId, prepared.purchaseRequest), - { approveBlockIndex: approveBlockIndex.toString(), expiresAt: prepared.expiresAt } - ); - return { - provider: "oisy", - approveBlockIndex: approveBlockIndex.toString(), - approvedAllowanceE8s: prepared.approvedAllowanceE8s.toString(), - purchasedCycles: formatRawCycles(BigInt(purchase.amountCycles)), - paymentAmountE8s: prepared.paymentAmountE8s.toString(), - transferFeeE8s: prepared.transferFeeE8s.toString(), - purchaseBlockIndex: purchase.blockIndex, - balanceCycles: purchase.balanceCycles ? formatRawCycles(BigInt(purchase.balanceCycles)) : null - }; + const wallet = await openOisyWallet(); + try { + const accounts = await wallet.accounts(); + const account = accounts[0]; + if (!account) throw new Error("OISY account not found"); + if (account.owner !== connection.owner) throw new Error("OISY owner changed; connect OISY again"); + const approveBlockIndex = await wallet.approve({ + owner: connection.owner, + ledgerCanisterId: prepared.kinicLedgerCanisterId, + params: approveParams(request.canisterId, prepared.approvedAllowanceE8s, prepared.currentAllowanceE8s, prepared.expiresAt), + options: { timeoutInMilliseconds: CALL_TIMEOUT_MS } + }); + const purchase = await purchaseAfterApprove( + () => oisyCallCyclesPurchase(wallet, connection.owner, request.canisterId, prepared.purchaseRequest), + { approveBlockIndex: approveBlockIndex.toString(), expiresAt: prepared.expiresAt } + ); + return { + provider: "oisy", + approveBlockIndex: approveBlockIndex.toString(), + approvedAllowanceE8s: prepared.approvedAllowanceE8s.toString(), + purchasedCycles: formatRawCycles(BigInt(purchase.amountCycles)), + paymentAmountE8s: prepared.paymentAmountE8s.toString(), + transferFeeE8s: prepared.transferFeeE8s.toString(), + purchaseBlockIndex: purchase.blockIndex, + balanceCycles: purchase.balanceCycles ? formatRawCycles(BigInt(purchase.balanceCycles)) : null + }; + } finally { + await safeDisconnectOisyWallet(wallet); + } } export async function purchaseCyclesWithPlug(request: CyclesPurchaseRequest, connection: ConnectedPlugWallet): Promise { diff --git a/wikibrowser/scripts/check-cycles.mjs b/wikibrowser/scripts/check-cycles.mjs index 502c7244..24ab903f 100644 --- a/wikibrowser/scripts/check-cycles.mjs +++ b/wikibrowser/scripts/check-cycles.mjs @@ -30,6 +30,7 @@ assert.match(client, /Connect OISY/); assert.match(client, /Connect Plug/); assert.match(client, /Purchase cycles with OISY/); assert.match(client, /Purchase cycles with Plug/); +assert.match(client, /router\.replace\("\/"\)/); assert.match(client, /const purchaseDisabled = !selectedProvider \|\| Boolean\(error\) \|\| Boolean\(amountError\) \|\| busy/); assert.match(client, /function WalletConnect/); assert.match(client, /onClick=\{\(\) => void purchase\(\)\}/); @@ -46,8 +47,11 @@ assert.match(wallet, /class CyclesPurchaseIcrcWallet extends IcrcWallet/); assert.match(wallet, /async callCyclesPurchase\(params: IcrcCallCanisterRequestParams\)/); assert.match(wallet, /export async function purchaseCyclesWithOisy\(request: CyclesPurchaseRequest, connection: ConnectedOisyWallet\)/); assert.match(wallet, /export async function purchaseCyclesWithPlug\(request: CyclesPurchaseRequest, connection: ConnectedPlugWallet\)/); -assert.match(connectOisy, /CyclesPurchaseIcrcWallet\.connect/); +assert.match(connectOisy, /openOisyWallet\(\)/); assert.match(connectOisy, /wallet\.accounts\(\)/); +assert.match(wallet, /async function safeDisconnectOisyWallet\(wallet: CyclesPurchaseIcrcWallet\): Promise/); +assert.match(wallet, /Cleanup failures must not hide connect, approve, or purchase errors\./); +assert.match(connectOisy, /safeDisconnectOisyWallet\(wallet\)/); assert.doesNotMatch(connectOisy, /getCyclesBillingConfig|previewDatabaseCyclesPurchase|whitelist/); assert.match(connectPlug, /plug\.requestConnect\(\{\s*host:/); assert.doesNotMatch(connectPlug, /getCyclesBillingConfig|previewDatabaseCyclesPurchase|whitelist/); @@ -80,7 +84,11 @@ assert.match(wallet, /expected_allowance: \[expectedAllowanceE8s\]/); assert.match(wallet, /expires_at: \[expiresAt\]/); assert.match(wallet, /APPROVE_EXPIRES_IN_MS = 30 \* 60 \* 1000/); assert.match(wallet, /assertConfiguredCyclesCanister\(request\.canisterId\)/); -assert.match(wallet, /oisyCallCyclesPurchase\(connection\.wallet, connection\.owner, request\.canisterId, prepared\.purchaseRequest\)/); +assert.match(purchaseOisy, /openOisyWallet\(\)/); +assert.match(purchaseOisy, /account\.owner !== connection\.owner/); +assert.match(purchaseOisy, /safeDisconnectOisyWallet\(wallet\)/); +assert.match(purchaseOisy, /purchaseAfterApprove/); +assert.match(wallet, /oisyCallCyclesPurchase\(wallet, connection\.owner, request\.canisterId, prepared\.purchaseRequest\)/); assert.match(wallet, /sender: owner/); assert.match(wallet, /wallet response sender mismatch/); assert.match(wallet, /contentMap|Certificate|requestIdOf/); @@ -193,6 +201,7 @@ const clientModule = loadTsModule( "../app/cycles/cycles-client.tsx", { "next/link": { __esModule: true, default: () => null }, + "next/navigation": { useRouter: () => ({ replace: () => undefined }) }, "lucide-react": { CheckCircle2: () => null, CircleAlert: () => null, diff --git a/wikibrowser/scripts/check-dashboard.mjs b/wikibrowser/scripts/check-dashboard.mjs index d36afbdf..6fd36d1e 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -150,8 +150,9 @@ assert.match(homeUi, /Public databases/); assert.match(homeUi, /No databases are linked to this principal\./); assert.match(homeUi, /No public databases are available\./); assert.match(homeUi, /publicError && mode === "public"/); -assert.match(homeUi, /Open public/); -assert.match(homeUi, /openPublicDatabaseHref/); +assert.match(homeUi, /PackageSearch/); +assert.doesNotMatch(homeUi, /Open public/); +assert.doesNotMatch(homeUi, /openPublicDatabaseHref/); assert.match(homeUi, /ShareDatabaseLink/); assert.match(homeUi, /Share2/); assert.match(homeUi, /xShareDatabaseHref/); @@ -159,11 +160,36 @@ assert.doesNotMatch(homeUi, /Archive/); assert.match(homeUi, /function isActiveRoutableDatabase\(database: DatabaseRow\): boolean/); assert.match(homeUi, /return database\.status === "active" && isRoutableDatabaseId\(database\.databaseId\);/); assert.match(homeUi, /const active = isActiveRoutableDatabase\(database\);/); -assert.match(homeUi, / - {mode === "member" ? : null} {mode === "member" ? : null} @@ -203,11 +203,6 @@ function DatabaseTableRow({ cyclesConfig, database, mode }: { cyclesConfig: Cycl - {mode === "member" ? ( - - ) : null} {mode === "member" ? ( - {mode === "member" ? : null} @@ -188,7 +187,13 @@ function DatabaseTableRow({ cyclesConfig, database, mode }: { cyclesConfig: Cycl @@ -197,11 +202,6 @@ function DatabaseTableRow({ cyclesConfig, database, mode }: { cyclesConfig: Cycl - {mode === "member" ? (
DatabaseNameID Role StatusLogical sizeSize Cycles Open ShareSkillsAccessRegistryTop upManage
{database.name} - {database.databaseId} - {mode === "member" && database.publicReadable ? Public : null} + {database.publicReadable ? : null}
{database.databaseId} {database.role}{database.status}{databaseStatusSummary(database, cycles)} {formatBytes(database.logicalSizeBytes)}{databaseCyclesView(database, cyclesConfig).summary}{databaseCyclesBalanceSummary(database)}
{active ? } label="Open" /> : -} - {active && mode === "member" && database.publicReadable ? } label="Open public" /> : null}
{active && database.publicReadable ? : -} -
- {active ? ( - - Registry - - ) : null} - } label="Cycles" /> -
+ {active ? } label="Registry" /> : -} +
+ } label="Top up" /> - } label="Access" /> + } label="Manage" />
Access<\/th>/); +assert.match(homeUi, /Name<\/th>/); +assert.match(homeUi, /ID<\/th>/); +assert.match(homeUi, /Status<\/th>/); +assert.match(homeUi, /Size<\/th>/); +assert.match(homeUi, /Cycles<\/th>/); +assert.match(homeUi, /Registry<\/th>/); +assert.match(homeUi, /Top up<\/th>/); +assert.match(homeUi, /Manage<\/th>/); +assert.doesNotMatch(homeUi, /Database<\/th>/); +assert.doesNotMatch(homeUi, /Logical size/); +assert.match(homeUi, //); +assert.match(homeUi, /function PublicBadge\(\)/); +assert.match(homeUi, /function databaseStatusSummary\(database: DatabaseRow, cycles: DatabaseCycleView\): string/); +assert.match(homeUi, /if \(database\.status !== "active"\) return databaseStatus/); +assert.match(homeUi, /return "Suspended"/); +assert.match(homeUi, /return "Low cycles"/); +assert.match(homeUi, /return "Active"/); +assert.match(homeUi, /return "Pending · Needs cycles"/); +assert.doesNotMatch(homeUi, /Active · Suspended/); +assert.doesNotMatch(homeUi, /Active · Low cycles/); +assert.match(homeUi, /Cycles unknown/); +assert.match(homeUi, /function databaseCyclesBalanceSummary\(database: DatabaseRow\): string/); +assert.match(homeUi, /formatCycleBalance\(balance\)/); +assert.doesNotMatch(homeUi, /databaseCyclesView\(database, cyclesConfig\)\.summary/); assert.match(homeUi, /\/dashboard\/\$\{encodeURIComponent\(database\.databaseId\)\}/); -assert.match(homeUi, /active && mode === "member" && database\.publicReadable/); +assert.doesNotMatch(homeUi, /active && mode === "member" && database\.publicReadable/); assert.match(homeUi, /active && database\.publicReadable \? /); -assert.match(homeUi, /active \? \(\n\s+/); +assert.match(homeUi, /label="Registry"/); +assert.match(homeUi, /label="Top up"/); +assert.doesNotMatch(homeUi, /\n]*label="Cycles"/); assert.match(homeUi, /href=\{`\/dashboard\/\$\{encodeURIComponent\(databaseId\)\}`\}/); assert.match(homeUi, /Manage reservation/); assert.doesNotMatch(homeUi, /href=\{`\/\$\{encodeURIComponent\(databaseId\)\}\/Wiki`\}/); From b64b7a4531168eb7fad9b8de3628590d162ca94f Mon Sep 17 00:00:00 2001 From: hude Date: Tue, 2 Jun 2026 17:04:43 +0900 Subject: [PATCH 2/8] Remove registry from home database list --- wikibrowser/app/home-ui.tsx | 20 +++++++------------- wikibrowser/scripts/check-dashboard.mjs | 8 +++++--- wikibrowser/scripts/check-skill-registry.mjs | 2 +- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/wikibrowser/app/home-ui.tsx b/wikibrowser/app/home-ui.tsx index 82b69557..69d215f2 100644 --- a/wikibrowser/app/home-ui.tsx +++ b/wikibrowser/app/home-ui.tsx @@ -1,9 +1,10 @@ "use client"; import Link from "next/link"; -import { BookOpen, PackageSearch, Settings, Share2, Wallet } from "lucide-react"; +import { BookOpen, Settings, Share2, Wallet } from "lucide-react"; import type { ReactNode } from "react"; -import { databaseCyclesView, databaseCyclesHref, formatCycles as formatCycleBalance, type DatabaseCycleView } from "@/lib/cycles-state"; +import { formatCycles as formatCycleBalance } from "@/lib/cycles"; +import { databaseCyclesView, databaseCyclesHref, type DatabaseCycleView } from "@/lib/cycles-state"; import type { CyclesBillingConfig, DatabaseSummary } from "@/lib/types"; import { isRoutableDatabaseId, publicDatabasePath, xShareDatabaseHref } from "@/lib/share-links"; @@ -77,7 +78,7 @@ export function DatabaseBody({ } return (
- +
); @@ -120,7 +121,7 @@ function DatabaseSection({ title }: { cyclesConfig: CyclesBillingConfig | null; - description: string; + description?: string; emptyMessage: string; mode: "member" | "public"; publicError?: string | null; @@ -132,7 +133,7 @@ function DatabaseSection({ return (
{showTitle ?

{title}

: null} - {showTitle ?

{description}

: null} + {showTitle && description ?

{description}

: null} {publicError && mode === "public" ?

{publicError}

: null}

{emptyMessage}

@@ -143,7 +144,7 @@ function DatabaseSection({ {showTitle ? (

{title}

-

{description}

+ {description ?

{description}

: null} {publicError && mode === "public" ?

{publicError}

: null}
) : null} @@ -165,7 +166,6 @@ function DatabaseSection({
Cycles Open ShareRegistryTop upManage
{active && database.publicReadable ? : -} - {active ? } label="Registry" /> : -} - } label="Top up" /> @@ -240,7 +235,6 @@ function DatabaseMobileCard({ cyclesConfig, database, mode }: { cyclesConfig: Cy {active ? ( } label="Open" /> ) : null} - {mode === "member" && active ? } label="Registry" /> : null} {mode === "member" ? ( } label="Top up" /> ) : null} diff --git a/wikibrowser/scripts/check-dashboard.mjs b/wikibrowser/scripts/check-dashboard.mjs index 6fd36d1e..0f61c05b 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -147,10 +147,11 @@ assert.match(homeUi, /publicDatabasePath\(OFFICIAL_KINIC_WIKI_DATABASE_ID\)/); assert.match(homeUi, /\/dashboard\/\$\{encodeURIComponent\(OFFICIAL_KINIC_WIKI_DATABASE_ID\)\}/); assert.match(homeUi, /My databases/); assert.match(homeUi, /Public databases/); +assert.doesNotMatch(homeUi, /Databases where your signed-in principal has a direct role\./); assert.match(homeUi, /No databases are linked to this principal\./); assert.match(homeUi, /No public databases are available\./); assert.match(homeUi, /publicError && mode === "public"/); -assert.match(homeUi, /PackageSearch/); +assert.doesNotMatch(homeUi, /PackageSearch/); assert.doesNotMatch(homeUi, /Open public/); assert.doesNotMatch(homeUi, /openPublicDatabaseHref/); assert.match(homeUi, /ShareDatabaseLink/); @@ -165,7 +166,7 @@ assert.match(homeUi, /ID<\/th>/); assert.match(homeUi, /Status<\/th>/); assert.match(homeUi, /Size<\/th>/); assert.match(homeUi, /Cycles<\/th>/); -assert.match(homeUi, /Registry<\/th>/); +assert.doesNotMatch(homeUi, /Registry<\/th>/); assert.match(homeUi, /Top up<\/th>/); assert.match(homeUi, /Manage<\/th>/); assert.doesNotMatch(homeUi, /Database<\/th>/); @@ -183,11 +184,12 @@ assert.doesNotMatch(homeUi, /Active · Low cycles/); assert.match(homeUi, /Cycles unknown/); assert.match(homeUi, /function databaseCyclesBalanceSummary\(database: DatabaseRow\): string/); assert.match(homeUi, /formatCycleBalance\(balance\)/); +assert.doesNotMatch(homeUi, /formatCycleBalance\(balance\) \+ " cycles"/); assert.doesNotMatch(homeUi, /databaseCyclesView\(database, cyclesConfig\)\.summary/); assert.match(homeUi, /\/dashboard\/\$\{encodeURIComponent\(database\.databaseId\)\}/); assert.doesNotMatch(homeUi, /active && mode === "member" && database\.publicReadable/); assert.match(homeUi, /active && database\.publicReadable \? /); -assert.match(homeUi, /label="Registry"/); +assert.doesNotMatch(homeUi, /label="Registry"/); assert.match(homeUi, /label="Top up"/); assert.doesNotMatch(homeUi, /\n]*label="Cycles"/); assert.match(homeUi, /href=\{`\/dashboard\/\$\{encodeURIComponent\(databaseId\)\}`\}/); diff --git a/wikibrowser/scripts/check-skill-registry.mjs b/wikibrowser/scripts/check-skill-registry.mjs index 8b350417..45bbd0da 100644 --- a/wikibrowser/scripts/check-skill-registry.mjs +++ b/wikibrowser/scripts/check-skill-registry.mjs @@ -147,7 +147,7 @@ assert.match(client, /previewProposal:[\s\S]*previewApplyProposalDiff[\s\S]*fals assert.match(client, /const databases = await listDatabasesAuthenticated\(canisterId, activeIdentity\)/); assert.match(client, /setCyclesConfig\(await getCyclesBillingConfig\(canisterId\)\)/); assert.doesNotMatch(client, /const \[databases, config\] = await Promise\.all/); -assert.match(homeUi, /href=\{`\/skills\/\$\{encodeURIComponent\(database\.databaseId\)\}`\}/); +assert.doesNotMatch(homeUi, /href=\{`\/skills\/\$\{encodeURIComponent\(database\.databaseId\)\}`\}/); assert.match(homePage, /DatabaseBody/); assert.match(dashboardClient, /href=\{`\/skills\/\$\{encodeURIComponent\(databaseId\)\}`\}/); assert.doesNotMatch(inspector, /skill-manifest/); From 32524ab0586c4298e2176fab4e3fabd1c073d72f Mon Sep 17 00:00:00 2001 From: hude Date: Tue, 2 Jun 2026 17:24:45 +0900 Subject: [PATCH 3/8] Move database open action into name links --- wikibrowser/app/home-ui.tsx | 29 +++++++++++++++---------- wikibrowser/scripts/check-dashboard.mjs | 3 +++ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/wikibrowser/app/home-ui.tsx b/wikibrowser/app/home-ui.tsx index 69d215f2..5c7550aa 100644 --- a/wikibrowser/app/home-ui.tsx +++ b/wikibrowser/app/home-ui.tsx @@ -164,7 +164,6 @@ function DatabaseSection({ Status Size CyclesOpen ShareTop upManage
- {database.name} + {active ? ( + + {database.name} + + ) : ( + {database.name} + )} {database.publicReadable ? : null}
{databaseStatusSummary(database, cycles)} {formatBytes(database.logicalSizeBytes)} {databaseCyclesBalanceSummary(database)} -
- {active ? } label="Open" /> : -} -
-
{active && database.publicReadable ? : -} @@ -221,7 +221,15 @@ function DatabaseMobileCard({ cyclesConfig, database, mode }: { cyclesConfig: Cy return (
-

{database.name}

+

+ {active ? ( + + {database.name} + + ) : ( + {database.name} + )} +

{database.publicReadable ? : null}
@@ -232,9 +240,6 @@ function DatabaseMobileCard({ cyclesConfig, database, mode }: { cyclesConfig: Cy
- {active ? ( - } label="Open" /> - ) : null} {mode === "member" ? ( } label="Top up" /> ) : null} @@ -263,7 +268,7 @@ function PublicBadge() { function DatabaseActionLink({ ariaLabel, external = false, href, icon, label }: { ariaLabel?: string; external?: boolean; href: string; icon: ReactNode; label: string }) { const className = - "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-lg border border-line bg-white px-2.5 py-1.5 text-sm font-medium text-accent no-underline shadow-[0_4px_10px_#14142b0a] hover:border-accent hover:bg-accent hover:text-white"; + "inline-flex min-h-9 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border border-line bg-white px-2.5 py-1.5 text-sm font-medium text-accent no-underline shadow-[0_4px_10px_#14142b0a] hover:border-accent hover:bg-accent hover:text-white"; if (external) { return ( diff --git a/wikibrowser/scripts/check-dashboard.mjs b/wikibrowser/scripts/check-dashboard.mjs index 0f61c05b..6ddae8bd 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -166,6 +166,7 @@ assert.match(homeUi, /
ID<\/th>/); assert.match(homeUi, /Status<\/th>/); assert.match(homeUi, /Size<\/th>/); assert.match(homeUi, /Cycles<\/th>/); +assert.doesNotMatch(homeUi, /Open<\/th>/); assert.doesNotMatch(homeUi, /Registry<\/th>/); assert.match(homeUi, /Top up<\/th>/); assert.match(homeUi, /Manage<\/th>/); @@ -186,6 +187,8 @@ assert.match(homeUi, /function databaseCyclesBalanceSummary\(database: DatabaseR assert.match(homeUi, /formatCycleBalance\(balance\)/); assert.doesNotMatch(homeUi, /formatCycleBalance\(balance\) \+ " cycles"/); assert.doesNotMatch(homeUi, /databaseCyclesView\(database, cyclesConfig\)\.summary/); +assert.match(homeUi, //); +assert.doesNotMatch(homeUi, /} label="Open" \/>/); assert.match(homeUi, /\/dashboard\/\$\{encodeURIComponent\(database\.databaseId\)\}/); assert.doesNotMatch(homeUi, /active && mode === "member" && database\.publicReadable/); assert.match(homeUi, /active && database\.publicReadable \? /); From 1042e3f17c070543adf4179398cbe5f2bdc11976 Mon Sep 17 00:00:00 2001 From: hude Date: Wed, 3 Jun 2026 09:46:12 +0900 Subject: [PATCH 4/8] Optimize database charge accounting and logical size checks --- crates/vfs_runtime/src/lib.rs | 70 ++++++---- crates/vfs_runtime/tests/database_service.rs | 130 ++++++++++++++++++- crates/vfs_store/src/fs_store.rs | 40 ++++++ crates/vfs_store/src/sqlite.rs | 4 +- crates/vfs_store/tests/fs_store_basic.rs | 44 +++++++ wikibrowser/app/home-ui.tsx | 1 - 6 files changed, 258 insertions(+), 31 deletions(-) diff --git a/crates/vfs_runtime/src/lib.rs b/crates/vfs_runtime/src/lib.rs index 6ef75447..0ef24d6c 100644 --- a/crates/vfs_runtime/src/lib.rs +++ b/crates/vfs_runtime/src/lib.rs @@ -5,7 +5,7 @@ mod sqlite; use std::collections::BTreeMap; #[cfg(not(target_arch = "wasm32"))] -use std::fs::{File, OpenOptions, create_dir_all, metadata, remove_file}; +use std::fs::{File, OpenOptions, create_dir_all, remove_file}; #[cfg(not(target_arch = "wasm32"))] use std::io::{Read, Seek, SeekFrom, Write}; #[cfg(not(target_arch = "wasm32"))] @@ -2326,17 +2326,7 @@ impl VfsService { } fn database_size(&self, meta: &DatabaseMeta) -> Result { - #[cfg(not(target_arch = "wasm32"))] - { - file_size(&meta.db_file_name) - } - #[cfg(target_arch = "wasm32")] - { - (self.database_handle)(meta.mount_id)? - .refresh_checksum_chunk(u64::MAX) - .map(|report| report.db_size) - .map_err(|error| error.to_string()) - } + self.database_store(meta)?.logical_size_bytes() } fn database_export_chunk( @@ -3869,6 +3859,11 @@ struct DatabaseCharge<'a> { computed_charge: i64, } +struct AppliedDatabaseCharge { + paid_cycles: i64, + balance_after_cycles: i64, +} + struct StorageChargeInput<'a> { database_id: &'a str, caller: &'a str, @@ -4024,17 +4019,14 @@ fn charge_database_update_in_tx( tx: &Transaction<'_>, charge: DatabaseCharge<'_>, ) -> Result<(), String> { - let balance = database_balance_for_update(tx, charge.database_id)?; - let paid_cycles = balance.max(0).min(charge.computed_charge); - let next = balance.max(0) - paid_cycles; - update_database_cycles_balance(tx, charge.database_id, next, charge.config, charge.now)?; + let applied = apply_database_update_charge(tx, &charge)?; insert_database_ledger( tx, DatabaseLedgerInsert { database_id: charge.database_id, kind: "charge", - amount_cycles: -paid_cycles, - balance_after_cycles: next, + amount_cycles: -applied.paid_cycles, + balance_after_cycles: applied.balance_after_cycles, payment_amount_e8s: None, caller: charge.caller, method: Some(charge.method), @@ -4047,6 +4039,41 @@ fn charge_database_update_in_tx( Ok(()) } +fn apply_database_update_charge( + tx: &Transaction<'_>, + charge: &DatabaseCharge<'_>, +) -> Result { + let min = cycles_to_i64(charge.config.min_update_cycles)?; + tx.query_row( + "WITH charge_input AS MATERIALIZED ( + SELECT min(max(balance_cycles, 0), ?2) AS paid_cycles, + max(balance_cycles, 0) - min(max(balance_cycles, 0), ?2) + AS balance_after_cycles + FROM database_cycle_accounts + WHERE database_id = ?1 + ) + UPDATE database_cycle_accounts + SET balance_cycles = (SELECT balance_after_cycles FROM charge_input), + suspended_at_ms = CASE + WHEN (SELECT balance_after_cycles FROM charge_input) >= ?3 THEN NULL + ELSE ?4 + END, + updated_at_ms = ?4 + WHERE database_id = ?1 AND EXISTS (SELECT 1 FROM charge_input) + RETURNING (SELECT paid_cycles FROM charge_input), balance_cycles", + params![charge.database_id, charge.computed_charge, min, charge.now], + |row| { + Ok(AppliedDatabaseCharge { + paid_cycles: crate::sqlite::row_get(row, 0)?, + balance_after_cycles: crate::sqlite::row_get(row, 1)?, + }) + }, + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("database cycles account not found: {}", charge.database_id)) +} + fn compute_update_charge(cycles_delta: u128) -> Result { i64::try_from(cycles_delta).map_err(|_| "cycle charge exceeds i64 limit".to_string()) } @@ -4950,13 +4977,6 @@ fn role_allows(role: DatabaseRole, required_role: RequiredRole) -> bool { } } -#[cfg(not(target_arch = "wasm32"))] -fn file_size(path: &str) -> Result { - metadata(path) - .map(|metadata| metadata.len()) - .map_err(|error| error.to_string()) -} - #[cfg(test)] mod tests { use std::path::Path; diff --git a/crates/vfs_runtime/tests/database_service.rs b/crates/vfs_runtime/tests/database_service.rs index 52eaa1cb..b9864cda 100644 --- a/crates/vfs_runtime/tests/database_service.rs +++ b/crates/vfs_runtime/tests/database_service.rs @@ -13,9 +13,9 @@ use vfs_runtime::{ }; use vfs_types::{ AppendNodeRequest, CyclesBillingConfigUpdate, DatabaseRole, DatabaseStatus, - DeleteDatabaseRequest, DeleteNodeRequest, KINIC_LEDGER_FEE_E8S, MkdirNodeRequest, - MoveNodeRequest, NodeKind, OpsAnswerSessionCheckRequest, OpsAnswerSessionRequest, - SearchNodesRequest, SearchPreviewMode, SourceRunSessionCheckRequest, + DeleteDatabaseRequest, DeleteNodeRequest, EditNodeRequest, KINIC_LEDGER_FEE_E8S, + MkdirNodeRequest, MoveNodeRequest, NodeKind, OpsAnswerSessionCheckRequest, + OpsAnswerSessionRequest, SearchNodesRequest, SearchPreviewMode, SourceRunSessionCheckRequest, UrlIngestTriggerSessionCheckRequest, UrlIngestTriggerSessionRequest, WriteNodeRequest, WriteSourceForGenerationRequest, }; @@ -2330,6 +2330,20 @@ fn zero_cycle_charge_skips_cycle_ledger() { assert_eq!(entries[1].amount_cycles, -(purchased_cycles as i64)); } +#[test] +fn charge_database_update_reports_missing_cycle_account() { + let (service, _) = service_with_root(); + let config = service + .cycles_billing_config() + .expect("cycles config should load"); + + let error = service + .charge_database_update(&config, "missing", "owner", "write_node", 1, 1) + .expect_err("missing cycle account should fail"); + + assert!(error.contains("database cycles account not found: missing")); +} + #[test] fn creates_databases_with_unique_mount_ids() { let service = service(); @@ -2720,6 +2734,92 @@ fn tracks_logical_size_and_does_not_reuse_deleted_slots() { ); } +#[test] +fn logical_size_refreshes_after_node_mutations() { + let (service, root) = service_with_root(); + service + .create_database("alpha", "owner", 1) + .expect("alpha should create"); + + let written = service + .write_node( + "owner", + WriteNodeRequest { + database_id: "alpha".to_string(), + path: "/Wiki/a.md".to_string(), + kind: NodeKind::File, + content: "alpha body".to_string(), + metadata_json: "{}".to_string(), + expected_etag: None, + }, + 2, + ) + .expect("write should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); + + let appended = service + .append_node( + "owner", + AppendNodeRequest { + database_id: "alpha".to_string(), + path: "/Wiki/a.md".to_string(), + content: "beta body".to_string(), + expected_etag: Some(written.node.etag), + separator: Some("\n".to_string()), + metadata_json: None, + kind: None, + }, + 3, + ) + .expect("append should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); + + let edited = service + .edit_node( + "owner", + EditNodeRequest { + database_id: "alpha".to_string(), + path: "/Wiki/a.md".to_string(), + old_text: "beta body".to_string(), + new_text: "gamma body".to_string(), + expected_etag: Some(appended.node.etag), + replace_all: false, + }, + 4, + ) + .expect("edit should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); + + let moved = service + .move_node( + "owner", + MoveNodeRequest { + database_id: "alpha".to_string(), + from_path: "/Wiki/a.md".to_string(), + to_path: "/Wiki/b.md".to_string(), + expected_etag: Some(edited.node.etag), + overwrite: false, + }, + 5, + ) + .expect("move should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); + + service + .delete_node( + "owner", + DeleteNodeRequest { + database_id: "alpha".to_string(), + path: "/Wiki/b.md".to_string(), + expected_etag: Some(moved.node.etag), + expected_folder_index_etag: None, + }, + 6, + ) + .expect("delete should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); +} + #[test] fn delete_database_allows_missing_file_but_rejects_other_remove_errors() { let (service, root) = service_with_root(); @@ -2756,6 +2856,30 @@ fn delete_database_allows_missing_file_but_rejects_other_remove_errors() { assert_eq!(database_index_row(&root, "remove_error").0, "active"); } +#[test] +fn begin_database_archive_rejects_missing_database_file_without_recreating_it() { + let (service, root) = service_with_root(); + service + .create_database("alpha", "owner", 1) + .expect("database should create"); + let db_file_name = service + .list_databases() + .expect("databases should load") + .into_iter() + .find(|meta| meta.database_id == "alpha") + .expect("database meta should exist") + .db_file_name; + std::fs::remove_file(&db_file_name).expect("database file should delete"); + + let error = service + .begin_database_archive("alpha", "owner", 2) + .expect_err("missing database file should fail archive"); + + assert!(!error.is_empty()); + assert_eq!(database_index_row(&root, "alpha").0, "active"); + assert!(!PathBuf::from(db_file_name).exists()); +} + #[test] fn begin_database_archive_updates_updated_at_ms() { let (service, root) = service_with_root(); diff --git a/crates/vfs_store/src/fs_store.rs b/crates/vfs_store/src/fs_store.rs index 490aeba6..37b2ce17 100644 --- a/crates/vfs_store/src/fs_store.rs +++ b/crates/vfs_store/src/fs_store.rs @@ -9,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet}; #[cfg(not(target_arch = "wasm32"))] use std::path::{Path, PathBuf}; +#[cfg(not(target_arch = "wasm32"))] +use crate::sqlite::OpenFlags; use crate::sqlite::{Connection, OptionalExtension, Transaction, params}; #[cfg(target_arch = "wasm32")] use ic_sqlite_vfs::{DbError, DbHandle}; @@ -166,6 +168,24 @@ impl FsStore { }) } + pub fn logical_size_bytes(&self) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let conn = Connection::open_with_flags( + &self.database_path, + OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| error.to_string())?; + logical_size_bytes_for_conn(&conn) + } + #[cfg(target_arch = "wasm32")] + { + self.read_conn(logical_size_bytes_for_conn) + } + } + pub fn read_node(&self, path: &str) -> Result, String> { let normalized = normalize_node_path(path, false)?; self.read_conn(|conn| load_node(conn, &normalized)) @@ -1417,6 +1437,26 @@ fn count_nodes(conn: &Connection, kind: &str) -> Result { u64::try_from(count).map_err(|error| error.to_string()) } +fn logical_size_bytes_for_conn(conn: &Connection) -> Result { + let page_count = conn + .query_row("PRAGMA page_count", params![], |row| { + crate::sqlite::row_get::(row, 0) + }) + .map_err(|error| error.to_string())?; + let page_size = conn + .query_row("PRAGMA page_size", params![], |row| { + crate::sqlite::row_get::(row, 0) + }) + .map_err(|error| error.to_string())?; + let page_count = + u64::try_from(page_count).map_err(|_| "SQLite page_count is negative".to_string())?; + let page_size = + u64::try_from(page_size).map_err(|_| "SQLite page_size is negative".to_string())?; + page_count + .checked_mul(page_size) + .ok_or_else(|| "SQLite logical size exceeds u64".to_string()) +} + fn normalize_list_children_path(path: &str) -> Result { let trimmed = if path.len() > 1 && path.ends_with('/') { &path[..path.len() - 1] diff --git a/crates/vfs_store/src/sqlite.rs b/crates/vfs_store/src/sqlite.rs index 133d56aa..a93212c0 100644 --- a/crates/vfs_store/src/sqlite.rs +++ b/crates/vfs_store/src/sqlite.rs @@ -4,8 +4,8 @@ #[cfg(not(target_arch = "wasm32"))] pub(crate) use rusqlite::{ - Connection, Error, OptionalExtension, Params, Result, Row, Statement, Transaction, params, - params_from_iter, + Connection, Error, OpenFlags, OptionalExtension, Params, Result, Row, Statement, Transaction, + params, params_from_iter, }; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/vfs_store/tests/fs_store_basic.rs b/crates/vfs_store/tests/fs_store_basic.rs index 3ca5f0ac..33801274 100644 --- a/crates/vfs_store/tests/fs_store_basic.rs +++ b/crates/vfs_store/tests/fs_store_basic.rs @@ -17,6 +17,50 @@ fn new_store() -> (tempfile::TempDir, FsStore) { (dir, store) } +#[test] +fn logical_size_bytes_rejects_missing_database_without_creating_file() { + let dir = tempdir().expect("temp dir should exist"); + let database_path = dir.path().join("missing.sqlite3"); + let store = FsStore::new(database_path.clone()); + + let error = store + .logical_size_bytes() + .expect_err("missing database should fail"); + + assert!(!error.is_empty()); + assert!(!database_path.exists()); +} + +#[test] +fn logical_size_bytes_uses_sqlite_page_size() { + let (_dir, store) = new_store(); + let database_path = store.database_path().to_path_buf(); + let empty_size = store + .logical_size_bytes() + .expect("empty logical size should load"); + + assert!(empty_size > 0); + assert_eq!( + empty_size, + std::fs::metadata(&database_path) + .expect("database file should exist") + .len() + ); + + write_file(&store, "/Wiki/size.md", None, 10); + let written_size = store + .logical_size_bytes() + .expect("written logical size should load"); + + assert!(written_size >= empty_size); + assert_eq!( + written_size, + std::fs::metadata(database_path) + .expect("database file should exist") + .len() + ); +} + fn old_fs_schema_store() -> (tempfile::TempDir, FsStore) { let dir = tempdir().expect("temp dir should exist"); let database_path = dir.path().join("wiki.sqlite3"); diff --git a/wikibrowser/app/home-ui.tsx b/wikibrowser/app/home-ui.tsx index 5c7550aa..b9a82d13 100644 --- a/wikibrowser/app/home-ui.tsx +++ b/wikibrowser/app/home-ui.tsx @@ -93,7 +93,6 @@ export function OfficialKinicWikiPanel() {

{OFFICIAL_KINIC_WIKI_DATABASE_NAME}

A canister-backed file-system wiki for agent memory: structured paths, raw sources, links, search, and safe edits.

Use the Chrome extension to capture ChatGPT conversations and active web pages into the same database.

-

{OFFICIAL_KINIC_WIKI_DATABASE_ID}

From 47de84eb534c6aaabdf757028ec8c339f35bfb2b Mon Sep 17 00:00:00 2001 From: hude Date: Wed, 3 Jun 2026 11:14:39 +0900 Subject: [PATCH 5/8] Add storage billing benchmarks and metered grant charging --- crates/vfs_canister/src/benches/mod.rs | 22 ++- crates/vfs_canister/src/benches/scale.rs | 170 +++++++++++++++++- crates/vfs_canister/src/lib.rs | 46 +++-- crates/vfs_canister/src/tests.rs | 126 +++++++++++-- .../vfs_canister/src/tests_sync_contract.rs | 14 +- docs/DB_LIFECYCLE.md | 2 +- docs/payment.md | 23 ++- 7 files changed, 363 insertions(+), 40 deletions(-) diff --git a/crates/vfs_canister/src/benches/mod.rs b/crates/vfs_canister/src/benches/mod.rs index 4d293406..3cada6f1 100644 --- a/crates/vfs_canister/src/benches/mod.rs +++ b/crates/vfs_canister/src/benches/mod.rs @@ -6,7 +6,7 @@ mod scale; use canbench_rs::{BenchResult, bench}; use scale::{ BenchCase, FETCH_UPDATED_COUNT, run_append, run_export_snapshot, run_fetch_updates, run_move, - run_search, run_write, + run_search, run_storage_billing, run_write, }; use vfs_types::SearchPreviewMode; @@ -121,3 +121,23 @@ scale_bench!( 1_000, FETCH_UPDATED_COUNT ); + +macro_rules! storage_billing_bench { + ($fn_name:ident, $n:expr) => { + #[bench(raw)] + fn $fn_name() -> BenchResult { + run_storage_billing(BenchCase { + bench_name: stringify!($fn_name), + operation: "storage_billing", + n: $n, + updated_count: 0, + preview_mode: SearchPreviewMode::None, + }) + } + }; +} + +storage_billing_bench!(storage_billing_n1, 1); +storage_billing_bench!(storage_billing_n10, 10); +storage_billing_bench!(storage_billing_n100, 100); +storage_billing_bench!(storage_billing_n1000, 1_000); diff --git a/crates/vfs_canister/src/benches/scale.rs b/crates/vfs_canister/src/benches/scale.rs index 1de9e618..b53cc083 100644 --- a/crates/vfs_canister/src/benches/scale.rs +++ b/crates/vfs_canister/src/benches/scale.rs @@ -7,10 +7,14 @@ use std::hint::black_box; use canbench_rs::{BenchResult, bench_fn, bench_scope}; use serde_json::to_vec; +use vfs_runtime::{ + CyclesPendingLedgerDetailsInput, DatabaseCyclesPurchaseWithLedgerDetails, + STORAGE_BILLING_INTERVAL_MS, +}; use vfs_types::{ - AppendNodeRequest, ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, - MkdirNodeRequest, MoveNodeRequest, NodeKind, SearchNodesRequest, SearchPreviewMode, - WriteNodeRequest, + AppendNodeRequest, DeleteDatabaseRequest, ExportSnapshotRequest, ExportSnapshotResponse, + FetchUpdatesRequest, MkdirNodeRequest, MoveNodeRequest, NodeKind, SearchNodesRequest, + SearchPreviewMode, WriteNodeRequest, }; use crate::{ @@ -27,6 +31,8 @@ const SEARCH_HIT_INTERVAL: usize = 5; const BENCH_QUERY: &str = "bench-needle"; const SHAPE_ID: &str = "uniform_depth4_content256_hits20pct"; const CERTIFICATION_STATUS: &str = "not_implemented"; +const STORAGE_BILLING_DATABASE_PREFIX: &str = "storage-billing-"; +const STORAGE_BILLING_PAYMENT_E8S: u64 = 10_000; pub(super) const FETCH_UPDATED_COUNT: usize = 10; @@ -215,6 +221,127 @@ fn emit_metadata(case: BenchCase, metrics: &SnapshotMetrics) { ); } +fn emit_storage_billing_metadata(case: BenchCase) { + ic_cdk::eprintln!( + "CANBENCH_META {{\"bench_name\":\"{}\",\"operation\":\"{}\",\"preview_mode\":\"none\",\"n\":{},\"node_count\":{},\"depth\":0,\"content_size\":{},\"updated_count\":0,\"snapshot_node_count\":0,\"snapshot_bytes\":0,\"shape\":\"storage_billing_active_dbs\",\"certificate_generation\":\"{}\",\"stable_memory_touch_bytes\":null}}", + case.bench_name, + case.operation, + case.n, + case.n, + CONTENT_SIZE, + CERTIFICATION_STATUS + ); +} + +fn storage_billing_database_id(case: BenchCase, index: usize) -> String { + format!("{STORAGE_BILLING_DATABASE_PREFIX}{:06}-{index:06}", case.n) +} + +fn is_storage_billing_bench_database(database_id: &str) -> bool { + database_id == BENCH_DATABASE_ID || database_id.starts_with(STORAGE_BILLING_DATABASE_PREFIX) +} + +fn ensure_storage_billing_service() { + let initialized = SERVICE.with(|slot| slot.borrow().is_some()); + if !initialized { + initialize_service_with_config(None).expect("bench service should initialize"); + } +} + +fn delete_existing_storage_billing_bench_databases(caller: &str) { + with_service(|service| { + let database_ids = service + .list_databases()? + .into_iter() + .map(|meta| meta.database_id) + .filter(|database_id| is_storage_billing_bench_database(database_id)) + .collect::>(); + for (index, database_id) in database_ids.into_iter().enumerate() { + service.delete_database( + DeleteDatabaseRequest { database_id }, + caller, + 1_000 + i64::try_from(index).unwrap_or(i64::MAX), + )?; + } + Ok(()) + }) + .expect("bench storage billing databases should reset"); +} + +fn fund_database_for_storage_billing(database_id: &str, caller: &str, now: i64) { + with_service(|service| { + let start = service.begin_database_cycles_purchase_with_ledger_details( + DatabaseCyclesPurchaseWithLedgerDetails { + database_id, + caller, + payment_amount_e8s: STORAGE_BILLING_PAYMENT_E8S, + min_expected_cycles: 0, + ledger: CyclesPendingLedgerDetailsInput { + from_owner: caller, + from_subaccount: None, + to_owner: "canister", + to_subaccount: None, + ledger_fee_e8s: 0, + ledger_created_at_time_ns: 1_000_000, + }, + now, + }, + )?; + service.complete_database_cycles_purchase_ledger_transfer( + start.operation_id, + database_id, + caller, + start.amount_cycles, + start.operation_id, + )?; + service.apply_database_cycles_purchase( + start.operation_id, + database_id, + caller, + start.amount_cycles, + start.operation_id, + now + 1, + )?; + Ok(()) + }) + .expect("bench database should be funded"); +} + +fn seed_storage_billing_databases(case: BenchCase) { + ensure_storage_billing_service(); + let caller = caller_text(); + delete_existing_storage_billing_bench_databases(&caller); + for index in 0..case.n { + let database_id = storage_billing_database_id(case, index); + with_service(|service| { + service.create_database( + &database_id, + &caller, + 10_000 + i64::try_from(index).unwrap_or(i64::MAX), + )?; + service.write_node( + &caller, + WriteNodeRequest { + database_id: database_id.clone(), + path: "/Wiki/storage-billing.md".to_string(), + kind: NodeKind::File, + content: node_content(index, true), + metadata_json: "{}".to_string(), + expected_etag: None, + }, + 20_000 + i64::try_from(index).unwrap_or(i64::MAX), + )?; + Ok(()) + }) + .expect("bench storage billing database should seed"); + fund_database_for_storage_billing( + &database_id, + &caller, + 30_000 + i64::try_from(index).unwrap_or(i64::MAX), + ); + } +} + pub(super) fn run_write(case: BenchCase) -> BenchResult { let prefix = bench_prefix(case); seed_dataset(case, &prefix); @@ -364,9 +491,26 @@ pub(super) fn run_fetch_updates(case: BenchCase) -> BenchResult { }) } +pub(super) fn run_storage_billing(case: BenchCase) -> BenchResult { + seed_storage_billing_databases(case); + emit_storage_billing_metadata(case); + bench_fn(|| { + let _scope = bench_scope("storage_billing_call"); + with_service(|service| { + service + .settle_database_storage_charges("canister", 30_000 + STORAGE_BILLING_INTERVAL_MS) + }) + .expect("bench storage billing should settle"); + black_box(()); + }) +} + #[cfg(test)] mod tests { - use super::{parent_folder_paths, snapshot_json_bytes}; + use super::{ + BenchCase, is_storage_billing_bench_database, parent_folder_paths, snapshot_json_bytes, + storage_billing_database_id, + }; use vfs_types::{ExportSnapshotResponse, Node, NodeKind}; #[test] @@ -406,4 +550,22 @@ mod tests { .len() ); } + + #[test] + fn storage_billing_database_id_marks_only_bench_databases() { + let database_id = storage_billing_database_id( + BenchCase { + bench_name: "storage_billing_n10", + operation: "storage_billing", + n: 10, + updated_count: 0, + preview_mode: vfs_types::SearchPreviewMode::None, + }, + 3, + ); + assert_eq!(database_id, "storage-billing-000010-000003"); + assert!(is_storage_billing_bench_database(&database_id)); + assert!(is_storage_billing_bench_database("canbench")); + assert!(!is_storage_billing_bench_database("user-db")); + } } diff --git a/crates/vfs_canister/src/lib.rs b/crates/vfs_canister/src/lib.rs index 9342ef45..e06ba9c3 100644 --- a/crates/vfs_canister/src/lib.rs +++ b/crates/vfs_canister/src/lib.rs @@ -69,6 +69,7 @@ const ICP_CLI_LOGIN_DISCOVERY_PATH: &str = "/.well-known/ic-cli-login"; const ICP_CLI_LOGIN_PATH: &str = "/login"; const ICP_CLI_LOGIN_HTML: &str = include_str!("icp_cli_login.html"); const UPDATE_EXECUTION_BASE_CYCLES: u128 = 5_000_000; +const UPDATE_ACCOUNTING_OVERHEAD_CYCLES: u128 = 15_000_000; #[cfg(target_arch = "wasm32")] const INDEX_DB_MEMORY_ID: u16 = 10; @@ -241,6 +242,7 @@ enum TransferFromError { GenericError { error_code: Nat, message: String }, } +#[cfg(not(feature = "canbench-rs"))] #[init] fn init_hook(config: CyclesBillingConfig) { initialize_or_trap(Some(config)); @@ -248,6 +250,14 @@ fn init_hook(config: CyclesBillingConfig) { schedule_storage_billing_timer(); } +#[cfg(feature = "canbench-rs")] +#[init] +fn init_hook() { + initialize_or_trap(None); + certify_http_responses(); + schedule_storage_billing_timer(); +} + #[post_upgrade] fn post_upgrade_hook() { let config = @@ -369,7 +379,7 @@ fn grant_database_access( principal: String, role: DatabaseRole, ) -> Result<(), String> { - with_role_unmetered_update( + with_role_metered_update( "grant_database_access", Some(database_id.clone()), RequiredRole::Owner, @@ -1416,8 +1426,14 @@ fn update_charge_units() -> u128 { } } -fn update_charge_cycles(before: u128, after: u128) -> u128 { - UPDATE_EXECUTION_BASE_CYCLES + after.saturating_sub(before) +fn update_charge_cycles(before: u128, after: u128) -> Result { + let instruction_delta = after + .checked_sub(before) + .ok_or_else(|| "instruction counter decreased during update".to_string())?; + UPDATE_EXECUTION_BASE_CYCLES + .checked_add(UPDATE_ACCOUNTING_OVERHEAD_CYCLES) + .and_then(|base| base.checked_add(instruction_delta)) + .ok_or_else(|| "cycle charge overflow".to_string()) } fn now_nanos() -> u64 { @@ -1606,19 +1622,23 @@ where let cycles_billing_config = authorize(service, &caller)?; let result = f(service, &caller, now); let after_charge_units = update_charge_units(); - let cycles_delta = update_charge_cycles(before_charge_units, after_charge_units); if result.is_ok() && let Some(database_id) = database_id.as_deref() - && let Err(error) = service.charge_database_update( - &cycles_billing_config, - database_id, - &caller, - method, - cycles_delta, - now, - ) { - ic_cdk::trap(format!("cycles charge failed after update: {error}")); + let charge_result = update_charge_cycles(before_charge_units, after_charge_units) + .and_then(|cycles_delta| { + service.charge_database_update( + &cycles_billing_config, + database_id, + &caller, + method, + cycles_delta, + now, + ) + }); + if let Err(error) = charge_result { + ic_cdk::trap(format!("cycles charge failed after update: {error}")); + } } result }) diff --git a/crates/vfs_canister/src/tests.rs b/crates/vfs_canister/src/tests.rs index 14a22f8f..9ab3cc61 100644 --- a/crates/vfs_canister/src/tests.rs +++ b/crates/vfs_canister/src/tests.rs @@ -39,7 +39,8 @@ use super::{ search_nodes, set_next_ledger_transfer_from_outcome_for_test, set_test_caller_principal_for_test, set_update_charge_units_for_test, settle_database_storage_charges, source_evidence, status, transfer_from_error_outcome, - update_cycles_billing_config, write_database_restore_chunk, write_node, write_nodes, + update_charge_cycles, update_cycles_billing_config, write_database_restore_chunk, write_node, + write_nodes, }; fn install_test_service() { @@ -1168,7 +1169,7 @@ fn purchase_database_cycles_rejects_unknown_and_deleted_database() { } fn database_charge_methods(database_id: &str) -> Vec { - list_database_cycle_entries(database_id.to_string(), None, 20) + list_database_cycle_entries(database_id.to_string(), None, 100) .expect("database cycles ledger should load") .entries .into_iter() @@ -1247,7 +1248,7 @@ fn install_low_balance_default_service() { .update_cycles_billing_config( CyclesBillingConfigUpdate { cycles_per_kinic: 1_000, - min_update_cycles: 2_000_000, + min_update_cycles: 3_000_000_000, }, &test_billing_authority_principal().to_text(), ) @@ -1333,6 +1334,22 @@ fn canister_list_databases_hides_deleted_databases() { assert!(summaries.is_empty()); } +#[test] +fn update_charge_cycles_checks_counter_order_and_overflow() { + assert_eq!( + update_charge_cycles(10, 11).expect("charge should compute"), + 20_000_001 + ); + assert_eq!( + update_charge_cycles(11, 10).expect_err("decreased counter should fail"), + "instruction counter decreased during update" + ); + assert_eq!( + update_charge_cycles(0, u128::MAX).expect_err("overflow should fail"), + "cycle charge overflow" + ); +} + #[test] fn write_nodes_records_instruction_charge_and_writes_nodes() { install_test_service(); @@ -1367,8 +1384,8 @@ fn write_nodes_records_instruction_charge_and_writes_nodes() { .iter() .find(|entry| entry.kind == "charge") .expect("charge entry should exist"); - assert_eq!(charge.amount_cycles, -5_000_321); - assert_eq!(charge.cycles_delta, Some(5_000_321)); + assert_eq!(charge.amount_cycles, -20_000_321); + assert_eq!(charge.cycles_delta, Some(20_000_321)); assert_eq!(charge.method.as_deref(), Some("write_nodes")); assert!( read_node("default".to_string(), "/Wiki/batch-a.md".to_string()) @@ -1412,9 +1429,9 @@ fn write_node_and_write_nodes_record_instruction_charges() { .collect::>(); assert_eq!(charges.len(), 2); assert_eq!(charges[0].method.as_deref(), Some("write_node")); - assert_eq!(charges[0].cycles_delta, Some(5_000_004)); + assert_eq!(charges[0].cycles_delta, Some(20_000_004)); assert_eq!(charges[1].method.as_deref(), Some("write_nodes")); - assert_eq!(charges[1].cycles_delta, Some(5_000_006)); + assert_eq!(charges[1].cycles_delta, Some(20_000_006)); } #[test] @@ -1461,10 +1478,32 @@ fn write_node_overdrawn_charge_consumes_balance_and_suspends_database() { .expect("charge entry should exist"); assert_eq!(charge.amount_cycles, -(before_balance as i64)); assert_eq!(charge.balance_after_cycles, 0); - assert_eq!(charge.cycles_delta, Some(1_000_005_000_000)); + assert_eq!(charge.cycles_delta, Some(1_000_020_000_000)); assert_eq!(charge.method.as_deref(), Some("write_node")); } +#[test] +fn failed_update_keeps_original_error_when_instruction_counter_decreases() { + install_test_service(); + set_update_charge_units_for_test(vec![20, 10]); + + let error = write_node(WriteNodeRequest { + database_id: "default".to_string(), + path: "/Wiki/stale.md".to_string(), + kind: NodeKind::File, + content: "stale write".to_string(), + metadata_json: "{}".to_string(), + expected_etag: Some("stale".to_string()), + }) + .expect_err("stale etag write should fail"); + + assert!(error.contains("etag")); + let entries = list_database_cycle_entries("default".to_string(), None, 20) + .expect("database cycles ledger should load") + .entries; + assert!(entries.iter().all(|entry| entry.kind != "charge")); +} + #[test] fn write_nodes_rejects_low_database_cycles_balance() { install_unfunded_default_service(); @@ -1536,9 +1575,17 @@ fn suspended_database_rejects_metered_mutations() { } #[test] -fn suspended_database_allows_owner_management_operations() { +fn suspended_database_rejects_grant_but_allows_unmetered_owner_management_operations() { install_suspended_default_service(); + let grant = grant_database_access( + "default".to_string(), + "aaaaa-aa".to_string(), + DatabaseRole::Reader, + ) + .expect_err("suspended database should reject metered grant"); + assert!(grant.contains("database cycles are suspended")); + rename_database(RenameDatabaseRequest { database_id: "default".to_string(), name: "Suspended rename".to_string(), @@ -1549,9 +1596,17 @@ fn suspended_database_allows_owner_management_operations() { } #[test] -fn low_balance_database_allows_owner_revoke_and_delete() { +fn low_balance_database_rejects_grant_but_allows_revoke_and_delete() { install_low_balance_default_service(); + let grant = grant_database_access( + "default".to_string(), + "aaaaa-aa".to_string(), + DatabaseRole::Reader, + ) + .expect_err("low-balance database should reject metered grant"); + assert!(grant.contains("database cycles balance is too low")); + revoke_database_access( "default".to_string(), test_billing_authority_principal().to_text(), @@ -1781,6 +1836,37 @@ fn canister_rename_database_requires_owner() { assert_eq!(summaries[0].name, "Renamed default"); } +#[test] +fn grant_database_access_records_instruction_charge_and_grants_role() { + install_test_service(); + set_update_charge_units_for_test(vec![100, 125]); + let principal = Principal::self_authenticating([42]).to_text(); + + grant_database_access( + "default".to_string(), + principal.clone(), + DatabaseRole::Reader, + ) + .expect("reader should grant"); + + let entries = list_database_cycle_entries("default".to_string(), None, 20) + .expect("database cycles ledger should load") + .entries; + let charge = entries + .iter() + .find(|entry| entry.kind == "charge") + .expect("grant charge should record"); + assert_eq!(charge.method.as_deref(), Some("grant_database_access")); + assert_eq!(charge.cycles_delta, Some(20_000_025)); + let members = + list_database_members("default".to_string()).expect("database members should load"); + assert!( + members + .iter() + .any(|member| member.principal == principal && member.role == DatabaseRole::Reader) + ); +} + #[test] fn grant_database_access_rejects_invalid_principal() { install_test_service(); @@ -1793,6 +1879,25 @@ fn grant_database_access_rejects_invalid_principal() { .expect_err("invalid principal should fail"); assert!(error.contains("invalid principal")); + assert!(database_charge_methods("default").is_empty()); +} + +#[test] +fn grant_database_access_rejects_non_owner_without_charge() { + install_test_service(); + + { + let _caller = AuthenticatedCallerGuard::install(); + let error = grant_database_access( + "default".to_string(), + "aaaaa-aa".to_string(), + DatabaseRole::Reader, + ) + .expect_err("non-owner grant should fail"); + assert!(error.contains("principal has no access")); + } + + assert!(database_charge_methods("default").is_empty()); } #[test] @@ -1815,6 +1920,7 @@ fn grant_database_access_rejects_member_limit() { ) .expect_err("member cap should reject new member"); assert!(error.contains("too many database members")); + assert_eq!(database_charge_methods("default").len(), 30); grant_database_access( "default".to_string(), diff --git a/crates/vfs_canister/src/tests_sync_contract.rs b/crates/vfs_canister/src/tests_sync_contract.rs index 5d423ac9..03065195 100644 --- a/crates/vfs_canister/src/tests_sync_contract.rs +++ b/crates/vfs_canister/src/tests_sync_contract.rs @@ -15,6 +15,9 @@ use super::{ }; use ic_http_certification::CERTIFICATE_EXPRESSION_HEADER_NAME; +const TEST_CYCLE_PURCHASE_E8S: u64 = 10_000_000; +const TEST_CYCLE_PURCHASE_CYCLES: u64 = 23_450_000_000; + fn install_test_service() { let dir = tempdir().expect("tempdir should create"); let root = dir.keep(); @@ -26,20 +29,25 @@ fn install_test_service() { .create_database("default", "2vxsx-fae", 1_700_000_000_000) .expect("default database should create"); service - .begin_database_cycles_purchase("default", "2vxsx-fae", 1_000_000, 1_700_000_000_001) + .begin_database_cycles_purchase( + "default", + "2vxsx-fae", + TEST_CYCLE_PURCHASE_E8S, + 1_700_000_000_001, + ) .and_then(|operation_id| { service.complete_database_cycles_purchase_ledger_transfer( operation_id, "default", "2vxsx-fae", - 2_345_000_000, + TEST_CYCLE_PURCHASE_CYCLES, 1, )?; service.apply_database_cycles_purchase( operation_id, "default", "2vxsx-fae", - 2_345_000_000, + TEST_CYCLE_PURCHASE_CYCLES, 1, 1_700_000_000_001, ) diff --git a/docs/DB_LIFECYCLE.md b/docs/DB_LIFECYCLE.md index c7638bc4..4cf00faa 100644 --- a/docs/DB_LIFECYCLE.md +++ b/docs/DB_LIFECYCLE.md @@ -70,7 +70,7 @@ Successful DB update calls are charged after execution. The charge is raw cycle cycles_delta ``` -Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), controlled by `cycles_per_kinic`. Before a metered update, the caller role is checked first, then the DB cycles balance must be at least `min_update_cycles` and the DB must not be suspended. Non-members receive access errors without learning cycles state. Metered update charge uses the current message instruction counter delta plus the 13-node update execution base fee, not same-message canister balance deltas. If the post-update charge exceeds the DB cycles balance, the remaining DB cycles are fully charged, the balance becomes `0`, and the DB is suspended. +Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), controlled by `cycles_per_kinic`. Before a metered update, the caller role is checked first, then the DB cycles balance must be at least `min_update_cycles` and the DB must not be suspended. Non-members receive access errors without learning cycles state. Metered updates include content mutations and `grant_database_access`; successful grant calls record `method = "grant_database_access"` in the charge ledger. The IC 13-node execution fee model is `5_000_000 cycles + 1 cycle per executed Wasm instruction`, but metered DB billing charges `20_000_000 cycles + 1 cycle per measured instruction`. The extra `15_000_000` cycles covers local-canister-measured internal accounting overhead from the post-update `charge_database_update` index DB writes. Measurement uses the current message instruction counter delta, not same-message canister balance deltas. If the IC fee table, target subnet type, or accounting overhead changes, update `UPDATE_EXECUTION_BASE_CYCLES`, `UPDATE_ACCOUNTING_OVERHEAD_CYCLES`, and this billing documentation together. If the post-update charge exceeds the DB cycles balance, the remaining DB cycles are fully charged, the balance becomes `0`, and the DB is suspended. Storage billing settles every 24h from a canister timer, with controller-only `settle_database_storage_charges()` as recovery path. Only active DBs are charged. The 13-node subnet rate is fixed at `127_000 cycles / GiB / sec`: diff --git a/docs/payment.md b/docs/payment.md index 71ca27aa..b60a0067 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -55,7 +55,7 @@ cycles = payment_amount_e8s * cycles_per_kinic / 10^KINIC_DECIMALS 購入実行時の現行 `cycles_per_kinic` で確定する。UI や CLI は同じ計算で見積 cycles を作り、`min_expected_cycles` として request に含める。ledger 転送前の再計算結果が `min_expected_cycles` 未満なら拒否する。 -購入は ledger 転送前に以下を拒否する。 +購入 request の公開型は `payment_amount_e8s: nat64` と `min_expected_cycles: nat64` だが、SQLite に保存する値は `i64` 範囲に制限する。購入は ledger 転送前に以下を拒否する。 - `payment_amount_e8s` が `0` - `payment_amount_e8s` が `i64` に収まらない @@ -68,6 +68,7 @@ cycles = payment_amount_e8s * cycles_per_kinic / 10^KINIC_DECIMALS - DB に owner が存在しない - cycles account が存在しない - pending DB に `cycles_purchase` pending operation が既にある +- 同一 caller の `cycles_purchase` pending operation が既にある - 換算後 cycles が `min_expected_cycles` 未満 - 現在残高 + pending cycles + 購入 cycles が overflow する @@ -105,7 +106,7 @@ ledger が `Duplicate` error を返し、`duplicate_of` が `u64` に変換で | `method` | `purchase_database_cycles` | | `ledger_block_index` | ledger transfer block index、または `Duplicate.duplicate_of` | -ledger が `BadFee` error を返し、`expected_fee` が `u64` に変換できる場合、canister は pending operation 削除を試行し、`icrc2_transfer_from failed: BadFee expected fee ...; re-approve with the current ledger fee and retry` を返す。その他の明示的 ledger error でも pending operation 削除を試行し、caller へ ledger error を返す。この場合 cycles ledger は増えない。 +ledger が `BadFee` error を返し、`expected_fee` が `u64` に変換できる場合、canister は pending operation 削除を試行し、`icrc2_transfer_from failed: BadFee expected fee ...; re-approve with the current ledger fee and retry` を返す。その他の明示的 ledger error でも pending operation 削除を試行し、caller へ ledger error を返す。この場合 cycles ledger は増えず、DB 予約と cycles account は維持される。 ledger call、response decode、または結果判定が曖昧な場合、canister は `operation_status = "ambiguous"` の pending operation と DB reservation を保持する。cycles ledger は増えない。caller には operation ID を含む billing authority review required error を返す。v1 では repair / cancel API は提供しない。 @@ -122,7 +123,7 @@ browser `/cycles` は approve 後の purchase failure でも通常の error 表 `database_id` は必須で、`[a-zA-Z0-9_-]+` のみ許可される。`kinic` は初期入力値であり、購入額は UI 上で編集できる。未指定時の初期入力は `1`。 -KINIC 入力は正の数だけ許可する。小数は最大 8 桁、e8s 換算後は `u64::MAX` 以下でなければならない。 +KINIC 入力は正の数だけ許可する。小数は最大 8 桁、URL/UI parser 上の e8s 換算値は `u64::MAX` 以下でなければならない。購入直前の wallet flow は canister 実効上限として `payment_amount_e8s <= i64::MAX` と `amount_cycles <= i64::MAX` も確認する。 OISY と Plug の wallet flow は、購入直前に canister config を取得する。承認 allowance は次である。 @@ -154,11 +155,13 @@ metered update は実行前に `prepare_metered_update` で認可と残高確認 `check_database_write_cycles(database_id)` は anonymous caller を明示的に拒否し、writer 以上の role と cycles 利用可能状態を確認する。 -canister の metered update wrapper は、更新前後の `performance_counter(InstructionCounter)` 差分に 13-node application subnet の update 実行 base fee `5_000_000 cycles` を加えた値を `cycles_delta` として計算する。`canister_cycle_balance` は同一 message 内の実行課金確定前の残高を返すため、update 本体の課金計測には使わない。更新関数が `Ok` を返した場合だけ、`charge_database_update` を実行する。 +`grant_database_access` は owner role を要求する metered update である。suspended または `min_update_cycles` 未満の DB では grant も拒否される。成功時の charge ledger は `method = "grant_database_access"` を記録する。 -更新課金額は `cycles_delta` そのものである。`cycles_delta` が `i64` に収まらない場合は `cycle charge exceeds i64 limit` になる。`cycles_delta == 0` の場合、残高更新も ledger 記録も行わない。 +canister の metered update wrapper は、更新前後の `performance_counter(InstructionCounter)` 差分に metered DB billing base charge `20_000_000 cycles` を加えた値を `cycles_delta` として計算する。IC の 13-node execution fee model は `5_000_000 cycles + 1 cycle per executed Wasm instruction` だが、DB billing は `5_000_000` cycles の execution base と `15_000_000` cycles の internal accounting overhead を合わせて請求する。overhead は local canister 実測で `charge_database_update` 後の index DB 書込をカバーする目的である。これは実際の canister 残高差分ではなく、DB 内部請求値である。`canister_cycle_balance` は同一 message 内の実行課金確定前の残高を返すため、update 本体の課金計測には使わない。更新関数が `Ok` を返した場合だけ、`charge_database_update` を実行する。 -`charge_database_update` は現在残高より請求額が大きい場合、残高を全徴収して `balance_after_cycles = 0` にし、DB を suspended にする。ledger の `amount_cycles` は実際に徴収した cycles、`cycles_delta` は update 実行 base fee と instruction 差分から計算した請求額を記録する。 +wrapper 経由の成功 update では `cycles_delta` に base fee が含まれるため、通常 0 にはならない。`charge_database_update` に直接 `cycles_delta = 0` を渡した場合だけ、残高更新も ledger 記録も行わない。`cycles_delta` が `i64` に収まらない場合は `cycle charge exceeds i64 limit` になる。 + +`charge_database_update` は現在残高より請求額が大きい場合、残高を全徴収して `balance_after_cycles = 0` にし、DB を suspended にする。ledger の `amount_cycles` は実際に徴収した cycles、`cycles_delta` は metered DB billing base charge と instruction 差分から計算した請求額を記録する。 課金成功時は残高から実徴収額を引く。更新後残高が `min_update_cycles` 未満なら `suspended_at_ms` を課金時刻に設定し、それ以上なら `NULL` にする。ledger には以下を記録する。 @@ -221,7 +224,7 @@ Pending cycles purchase は owner、billing authority、payer が `list_database ## Ledger 結果と no-credit -`purchase_database_cycles` は `icrc2_transfer_from` が `Ok(block_index)` を返し、local activation/apply も完了した場合だけ内部 cycles 残高へ credit する。明確な ledger error は pending operation を削除し、残高と ledger entry を変更しない。 +`purchase_database_cycles` は `icrc2_transfer_from` が `Ok(block_index)` を返し、local activation/apply も完了した場合だけ内部 cycles 残高へ credit する。明確な ledger error は pending operation を削除し、残高と ledger entry を変更しない。pending DB で明確な ledger error が起きた場合も、DB 予約は残り、再 approve / retry できる。 inter-canister call 失敗や response decode 失敗など ledger 結果が曖昧な場合、canister は `ambiguous` pending operation と DB reservation を保持し、cycles ledger と cycles 残高は更新しない。pending は確認専用であり、v1 では repair / cancel API はない。 @@ -239,7 +242,11 @@ ledger transfer 成功後に pending DB activation や cycles apply が失敗し - `database_cycle_ledger` - `database_cycle_accounts` - `database_members` -- restore/session 系 rows +- `database_restore_chunks` +- `database_restore_sessions` +- `url_ingest_trigger_sessions` +- `ops_answer_sessions` +- `source_run_sessions` - `databases` 残った cycles balance は返金されず破棄される。 From 1abb3712dc7483401c00ccebea86059fd3a7c9ad Mon Sep 17 00:00:00 2001 From: hude Date: Wed, 3 Jun 2026 11:50:17 +0900 Subject: [PATCH 6/8] Add batched storage billing settlement --- crates/vfs_canister/src/benches/mod.rs | 8 +- crates/vfs_canister/src/benches/scale.rs | 15 +- crates/vfs_canister/src/lib.rs | 56 ++- crates/vfs_canister/src/tests.rs | 45 +- crates/vfs_canister/vfs.did | 28 +- .../migrations/index_db/011_to_latest.sql | 8 + .../index_db/fresh_index_schema.sql | 8 + crates/vfs_runtime/src/lib.rs | 458 +++++++++++++++++- crates/vfs_types/src/fs.rs | 15 + docs/DB_LIFECYCLE.md | 4 +- docs/payment.md | 8 +- wikibrowser/lib/vfs-idl.ts | 11 +- wikibrowser/scripts/candid-shapes.mjs | 27 +- wikibrowser/scripts/check-candid-drift.mjs | 6 +- wikibrowser/scripts/generate-vfs-idl.mjs | 11 +- 15 files changed, 649 insertions(+), 59 deletions(-) diff --git a/crates/vfs_canister/src/benches/mod.rs b/crates/vfs_canister/src/benches/mod.rs index 3cada6f1..cafbda6a 100644 --- a/crates/vfs_canister/src/benches/mod.rs +++ b/crates/vfs_canister/src/benches/mod.rs @@ -137,7 +137,7 @@ macro_rules! storage_billing_bench { }; } -storage_billing_bench!(storage_billing_n1, 1); -storage_billing_bench!(storage_billing_n10, 10); -storage_billing_bench!(storage_billing_n100, 100); -storage_billing_bench!(storage_billing_n1000, 1_000); +storage_billing_bench!(storage_billing_batch_n1, 1); +storage_billing_bench!(storage_billing_batch_n10, 10); +storage_billing_bench!(storage_billing_batch_n100, 100); +storage_billing_bench!(storage_billing_batch_n1000, 1_000); diff --git a/crates/vfs_canister/src/benches/scale.rs b/crates/vfs_canister/src/benches/scale.rs index b53cc083..6b04e120 100644 --- a/crates/vfs_canister/src/benches/scale.rs +++ b/crates/vfs_canister/src/benches/scale.rs @@ -14,7 +14,7 @@ use vfs_runtime::{ use vfs_types::{ AppendNodeRequest, DeleteDatabaseRequest, ExportSnapshotRequest, ExportSnapshotResponse, FetchUpdatesRequest, MkdirNodeRequest, MoveNodeRequest, NodeKind, SearchNodesRequest, - SearchPreviewMode, WriteNodeRequest, + SearchPreviewMode, StorageBillingBatchRequest, WriteNodeRequest, }; use crate::{ @@ -223,7 +223,7 @@ fn emit_metadata(case: BenchCase, metrics: &SnapshotMetrics) { fn emit_storage_billing_metadata(case: BenchCase) { ic_cdk::eprintln!( - "CANBENCH_META {{\"bench_name\":\"{}\",\"operation\":\"{}\",\"preview_mode\":\"none\",\"n\":{},\"node_count\":{},\"depth\":0,\"content_size\":{},\"updated_count\":0,\"snapshot_node_count\":0,\"snapshot_bytes\":0,\"shape\":\"storage_billing_active_dbs\",\"certificate_generation\":\"{}\",\"stable_memory_touch_bytes\":null}}", + "CANBENCH_META {{\"bench_name\":\"{}\",\"operation\":\"{}\",\"preview_mode\":\"none\",\"n\":{},\"node_count\":{},\"depth\":0,\"content_size\":{},\"updated_count\":0,\"snapshot_node_count\":0,\"snapshot_bytes\":0,\"shape\":\"storage_billing_batch_limit100_active_dbs\",\"certificate_generation\":\"{}\",\"stable_memory_touch_bytes\":null}}", case.bench_name, case.operation, case.n, @@ -498,7 +498,14 @@ pub(super) fn run_storage_billing(case: BenchCase) -> BenchResult { let _scope = bench_scope("storage_billing_call"); with_service(|service| { service - .settle_database_storage_charges("canister", 30_000 + STORAGE_BILLING_INTERVAL_MS) + .settle_database_storage_charges_batch( + "canister", + StorageBillingBatchRequest { + cursor_mount_id: None, + limit: Some(100), + }, + 30_000 + STORAGE_BILLING_INTERVAL_MS, + ) }) .expect("bench storage billing should settle"); black_box(()); @@ -555,7 +562,7 @@ mod tests { fn storage_billing_database_id_marks_only_bench_databases() { let database_id = storage_billing_database_id( BenchCase { - bench_name: "storage_billing_n10", + bench_name: "storage_billing_batch_n10", operation: "storage_billing", n: 10, updated_count: 0, diff --git a/crates/vfs_canister/src/lib.rs b/crates/vfs_canister/src/lib.rs index e06ba9c3..2916e432 100644 --- a/crates/vfs_canister/src/lib.rs +++ b/crates/vfs_canister/src/lib.rs @@ -20,7 +20,7 @@ use ic_cdk::api::PerformanceCounterType; use ic_cdk::call::Call; use ic_cdk::{init, post_upgrade, query, update}; #[cfg(target_arch = "wasm32")] -use ic_cdk_timers::set_timer_interval; +use ic_cdk_timers::{set_timer, set_timer_interval}; use ic_http_certification::{ CERTIFICATE_EXPRESSION_HEADER_NAME, DefaultCelBuilder, DefaultResponseCertification, HttpCertification, HttpCertificationPath, HttpCertificationTree, HttpCertificationTreeEntry, @@ -54,7 +54,8 @@ use vfs_types::{ OpsAnswerSessionCheckResult, OpsAnswerSessionRequest, OutgoingLinksRequest, QueryContext, QueryContextRequest, RenameDatabaseRequest, SearchNodeHit, SearchNodePathsRequest, SearchNodesRequest, SourceEvidence, SourceEvidenceRequest, SourceRunSessionCheckRequest, - Status, UrlIngestTriggerSessionCheckRequest, UrlIngestTriggerSessionRequest, WriteNodeRequest, + Status, StorageBillingBatchRequest, StorageBillingBatchResult, + UrlIngestTriggerSessionCheckRequest, UrlIngestTriggerSessionRequest, WriteNodeRequest, WriteNodeResult, WriteNodesRequest, WriteSourceForGenerationRequest, WriteSourceForGenerationResult, kinic_base_units_per_token, }; @@ -71,6 +72,10 @@ const ICP_CLI_LOGIN_HTML: &str = include_str!("icp_cli_login.html"); const UPDATE_EXECUTION_BASE_CYCLES: u128 = 5_000_000; const UPDATE_ACCOUNTING_OVERHEAD_CYCLES: u128 = 15_000_000; #[cfg(target_arch = "wasm32")] +const STORAGE_BILLING_TIMER_BATCHES_PER_MESSAGE: u32 = 6; +#[cfg(target_arch = "wasm32")] +const STORAGE_BILLING_CONTINUATION_DELAY_MS: u64 = 1; +#[cfg(target_arch = "wasm32")] const INDEX_DB_MEMORY_ID: u16 = 10; #[derive(Clone, Debug, CandidType, Deserialize)] @@ -650,10 +655,16 @@ fn query_index_sql_json(sql: String, limit: u32) -> Result Result<(), String> { +fn settle_database_storage_charges_batch( + request: StorageBillingBatchRequest, +) -> Result { require_controller_caller()?; with_service(|service| { - service.settle_database_storage_charges(&canister_principal().to_text(), now_millis()) + service.settle_database_storage_charges_batch( + &canister_principal().to_text(), + request, + now_millis(), + ) }) } @@ -1082,13 +1093,40 @@ fn schedule_storage_billing_timer() { { let interval_ms = u64::try_from(STORAGE_BILLING_INTERVAL_MS).unwrap_or(24 * 60 * 60 * 1000); set_timer_interval(Duration::from_millis(interval_ms), || async { - if let Err(error) = with_service(|service| { - service - .settle_database_storage_charges(&canister_principal().to_text(), now_millis()) - }) { + run_storage_billing_timer_batches(); + }); + } +} + +#[cfg(target_arch = "wasm32")] +fn run_storage_billing_timer_batches() { + let mut should_continue = false; + for _ in 0..STORAGE_BILLING_TIMER_BATCHES_PER_MESSAGE { + match with_service(|service| { + service.settle_database_storage_charges_timer_batch( + &canister_principal().to_text(), + now_millis(), + ) + }) { + Ok(result) => { + should_continue = result.next_cursor_mount_id.is_some(); + if !should_continue { + break; + } + } + Err(error) => { ic_cdk::println!("storage billing settle failed: {error}"); + return; } - }); + } + } + if should_continue { + set_timer( + Duration::from_millis(STORAGE_BILLING_CONTINUATION_DELAY_MS), + async { + run_storage_billing_timer_batches(); + }, + ); } } diff --git a/crates/vfs_canister/src/tests.rs b/crates/vfs_canister/src/tests.rs index 9ab3cc61..5421bd90 100644 --- a/crates/vfs_canister/src/tests.rs +++ b/crates/vfs_canister/src/tests.rs @@ -17,7 +17,8 @@ use vfs_types::{ ListNodesRequest, MkdirNodeRequest, MoveNodeRequest, MultiEdit, MultiEditNodeRequest, NodeContextRequest, NodeEntryKind, NodeKind, OutgoingLinksRequest, QueryContextRequest, RenameDatabaseRequest, SearchNodePathsRequest, SearchNodesRequest, SearchPreviewMode, - SourceEvidenceRequest, WriteNodeItem, WriteNodeRequest, WriteNodesRequest, + SourceEvidenceRequest, StorageBillingBatchRequest, WriteNodeItem, WriteNodeRequest, + WriteNodesRequest, }; use super::{ @@ -38,7 +39,7 @@ use super::{ read_node, read_node_context, rename_database, revoke_database_access, search_node_paths, search_nodes, set_next_ledger_transfer_from_outcome_for_test, set_test_caller_principal_for_test, set_update_charge_units_for_test, - settle_database_storage_charges, source_evidence, status, transfer_from_error_outcome, + settle_database_storage_charges_batch, source_evidence, status, transfer_from_error_outcome, update_charge_cycles, update_cycles_billing_config, write_database_restore_chunk, write_node, write_nodes, }; @@ -229,6 +230,38 @@ fn controller_can_query_index_sql_json() { ); } +#[test] +fn controller_can_settle_database_storage_charges_batch() { + install_test_service(); + SERVICE.with(|slot| { + slot.borrow() + .as_ref() + .expect("service should install") + .write_node( + "2vxsx-fae", + WriteNodeRequest { + database_id: "default".to_string(), + path: "/Wiki/storage.md".to_string(), + kind: NodeKind::File, + content: "storage billing".to_string(), + metadata_json: "{}".to_string(), + expected_etag: None, + }, + 1_700_000_000_002, + ) + .expect("storage node should write"); + }); + set_test_caller_principal_for_test(Principal::management_canister()); + + let result = settle_database_storage_charges_batch(StorageBillingBatchRequest { + cursor_mount_id: None, + limit: Some(100), + }) + .expect("controller should settle storage billing batch"); + + assert_eq!(result.processed_databases, 1); +} + #[test] fn index_sql_json_rejects_non_controller_callers() { install_test_service(); @@ -243,13 +276,17 @@ fn index_sql_json_rejects_non_controller_callers() { } #[test] -fn settle_database_storage_charges_rejects_non_controller_callers() { +fn settle_database_storage_charges_batch_rejects_non_controller_callers() { install_test_service(); let non_controller = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai") .expect("valid non-controller principal"); set_test_caller_principal_for_test(non_controller); - let error = settle_database_storage_charges().expect_err("non-controller should reject"); + let error = settle_database_storage_charges_batch(StorageBillingBatchRequest { + cursor_mount_id: None, + limit: None, + }) + .expect_err("non-controller should reject"); assert!(error.contains("caller is not a canister controller")); } diff --git a/crates/vfs_canister/vfs.did b/crates/vfs_canister/vfs.did index 6fa24ffa..75b4b3ec 100644 --- a/crates/vfs_canister/vfs.did +++ b/crates/vfs_canister/vfs.did @@ -358,10 +358,11 @@ type Result_23 = variant { Ok : DatabaseArchiveChunk; Err : text }; type Result_24 = variant { Ok : opt Node; Err : text }; type Result_25 = variant { Ok : opt NodeContext; Err : text }; type Result_26 = variant { Ok : vec SearchNodeHit; Err : text }; -type Result_27 = variant { Ok : SourceEvidence; Err : text }; -type Result_28 = variant { Ok : vec WriteNodeResult; Err : text }; -type Result_29 = variant { Ok : WriteSourceForGenerationResult; Err : text }; +type Result_27 = variant { Ok : StorageBillingBatchResult; Err : text }; +type Result_28 = variant { Ok : SourceEvidence; Err : text }; +type Result_29 = variant { Ok : vec WriteNodeResult; Err : text }; type Result_3 = variant { Ok : OpsAnswerSessionCheckResult; Err : text }; +type Result_30 = variant { Ok : WriteSourceForGenerationResult; Err : text }; type Result_4 = variant { Ok : CreateDatabaseResult; Err : text }; type Result_5 = variant { Ok : DeleteNodeResult; Err : text }; type Result_6 = variant { Ok : EditNodeResult; Err : text }; @@ -413,6 +414,17 @@ type SourceRunSessionCheckRequest = record { database_id : text; }; type Status = record { source_count : nat64; file_count : nat64 }; +type StorageBillingBatchRequest = record { + limit : opt nat32; + cursor_mount_id : opt nat16; +}; +type StorageBillingBatchResult = record { + paid_cycles : nat64; + suspended_databases : nat32; + next_cursor_mount_id : opt nat16; + charged_databases : nat32; + processed_databases : nat32; +}; type UrlIngestTriggerSessionCheckRequest = record { request_path : text; session_nonce : text; @@ -511,14 +523,16 @@ service : (CyclesBillingConfig) -> { revoke_database_access : (text, text) -> (Result_1); search_node_paths : (SearchNodePathsRequest) -> (Result_26) query; search_nodes : (SearchNodesRequest) -> (Result_26) query; - settle_database_storage_charges : () -> (Result_1); - source_evidence : (SourceEvidenceRequest) -> (Result_27) query; + settle_database_storage_charges_batch : (StorageBillingBatchRequest) -> ( + Result_27, + ); + source_evidence : (SourceEvidenceRequest) -> (Result_28) query; status : (text) -> (Status) query; update_cycles_billing_config : (CyclesBillingConfigUpdate) -> (Result_1); write_database_restore_chunk : (DatabaseRestoreChunkRequest) -> (Result_1); write_node : (WriteNodeRequest) -> (Result); - write_nodes : (WriteNodesRequest) -> (Result_28); + write_nodes : (WriteNodesRequest) -> (Result_29); write_source_for_generation : (WriteSourceForGenerationRequest) -> ( - Result_29, + Result_30, ); } diff --git a/crates/vfs_runtime/migrations/index_db/011_to_latest.sql b/crates/vfs_runtime/migrations/index_db/011_to_latest.sql index fc21b218..40810db2 100644 --- a/crates/vfs_runtime/migrations/index_db/011_to_latest.sql +++ b/crates/vfs_runtime/migrations/index_db/011_to_latest.sql @@ -53,6 +53,14 @@ CREATE TABLE cycles_billing_config ( value TEXT NOT NULL ); +CREATE TABLE storage_billing_state ( + key TEXT PRIMARY KEY, + cursor_mount_id INTEGER, + billing_now_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + CHECK (key = 'timer') +); + INSERT INTO database_cycle_accounts (database_id, balance_cycles, suspended_at_ms, storage_charged_at_ms, created_at_ms, updated_at_ms) diff --git a/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql b/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql index c8912093..fc65cc66 100644 --- a/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql +++ b/crates/vfs_runtime/migrations/index_db/fresh_index_schema.sql @@ -158,3 +158,11 @@ CREATE TABLE cycles_billing_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); + +CREATE TABLE storage_billing_state ( + key TEXT PRIMARY KEY, + cursor_mount_id INTEGER, + billing_now_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + CHECK (key = 'timer') +); diff --git a/crates/vfs_runtime/src/lib.rs b/crates/vfs_runtime/src/lib.rs index 0ef24d6c..1f0a44a0 100644 --- a/crates/vfs_runtime/src/lib.rs +++ b/crates/vfs_runtime/src/lib.rs @@ -30,7 +30,8 @@ use vfs_types::{ OpsAnswerSessionCheckRequest, OpsAnswerSessionCheckResult, OpsAnswerSessionRequest, OutgoingLinksRequest, QueryContext, QueryContextRequest, SearchNodeHit, SearchNodePathsRequest, SearchNodesRequest, SourceEvidence, SourceEvidenceRequest, SourceRunSessionCheckRequest, - Status, UrlIngestTriggerSessionCheckRequest, UrlIngestTriggerSessionRequest, WriteNodeRequest, + Status, StorageBillingBatchRequest, StorageBillingBatchResult, + UrlIngestTriggerSessionCheckRequest, UrlIngestTriggerSessionRequest, WriteNodeRequest, WriteNodeResult, WriteNodesRequest, WriteSourceForGenerationRequest, WriteSourceForGenerationResult, kinic_base_units_per_token, }; @@ -69,6 +70,8 @@ const INDEX_SCHEMA_VERSION_STORAGE_BILLING: &str = "database_index:023_storage_b const INDEX_SCHEMA_VERSION_DIRECT_CYCLES: &str = concat!("database_index:024_", "direct_cycles"); const INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX: &str = "database_index:025_cycles_pending_ledger_block_index"; +const INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH: &str = + "database_index:026_storage_billing_batch"; const PENDING_DATABASE_MOUNT_ID: u16 = 0; const DATABASE_SCHEMA_VERSION: &str = "vfs_store:current"; const MIN_DATABASE_MOUNT_ID: u16 = 11; @@ -91,6 +94,8 @@ pub const DEFAULT_CYCLES_PER_KINIC: u64 = 234_500_000_000; pub const DEFAULT_MIN_UPDATE_CYCLES: u64 = 1_000_000; pub const STORAGE_BILLING_INTERVAL_MS: i64 = 24 * 60 * 60 * 1000; pub const STORAGE_CYCLES_PER_GIB_SECOND: u128 = 127_000; +const DEFAULT_STORAGE_BILLING_BATCH_LIMIT: u32 = 100; +const MAX_STORAGE_BILLING_BATCH_LIMIT: u32 = 100; const GIB_BYTES: u128 = 1024 * 1024 * 1024; const MAX_DATABASE_NAME_CHARS: usize = 80; const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; @@ -257,9 +262,53 @@ impl VfsService { }) } - pub fn settle_database_storage_charges(&self, caller: &str, now: i64) -> Result<(), String> { - let measured = self - .read_index(load_active_databases_for_storage_billing)? + pub fn settle_database_storage_charges_batch( + &self, + caller: &str, + request: StorageBillingBatchRequest, + now: i64, + ) -> Result { + let limit = storage_billing_batch_limit(request.limit); + let cursor = request.cursor_mount_id.unwrap_or(0); + let batch = self.read_index(|conn| { + load_active_databases_for_storage_billing_batch(conn, cursor, limit) + })?; + self.settle_database_storage_billing_batch(caller, batch, now) + } + + pub fn settle_database_storage_charges_timer_batch( + &self, + caller: &str, + now: i64, + ) -> Result { + let state = self.write_index(|tx| load_or_create_storage_billing_timer_state(tx, now))?; + let batch = self.read_index(|conn| { + load_active_databases_for_storage_billing_batch( + conn, + state.cursor_mount_id.unwrap_or(0), + DEFAULT_STORAGE_BILLING_BATCH_LIMIT, + ) + })?; + let result = self.settle_database_storage_billing_batch(caller, batch, state.billing_now_ms)?; + self.write_index(|tx| { + if let Some(cursor) = result.next_cursor_mount_id { + update_storage_billing_timer_state(tx, Some(cursor), state.billing_now_ms, now)?; + } else { + clear_storage_billing_timer_state(tx)?; + } + Ok(()) + })?; + Ok(result) + } + + fn settle_database_storage_billing_batch( + &self, + caller: &str, + batch: StorageBillingDatabaseBatch, + now: i64, + ) -> Result { + let measured = batch + .databases .into_iter() .map(|meta| { let size = self.database_size(&meta)?; @@ -268,8 +317,15 @@ impl VfsService { .collect::, String>>()?; self.write_index(|tx| { let config = load_cycles_billing_config(tx)?; + let mut result = StorageBillingBatchResult { + processed_databases: 0, + charged_databases: 0, + suspended_databases: 0, + paid_cycles: 0, + next_cursor_mount_id: batch.next_cursor_mount_id, + }; for (database_id, size_bytes) in measured { - settle_database_storage_charge_in_tx( + let outcome = settle_database_storage_charge_in_tx( tx, StorageChargeInput { database_id: &database_id, @@ -279,8 +335,19 @@ impl VfsService { config: &config, }, )?; + result.processed_databases += 1; + if outcome.charged { + result.charged_databases += 1; + } + if outcome.suspended { + result.suspended_databases += 1; + } + result.paid_cycles = result + .paid_cycles + .checked_add(outcome.paid_cycles) + .ok_or_else(|| "storage billing paid cycles overflow".to_string())?; } - Ok(()) + Ok(result) }) } @@ -2583,6 +2650,7 @@ fn run_index_migrations_in_tx_for_upgrade( enum IndexSchemaState { Latest, + StorageBillingBatchUpgrade, Mainnet011, } @@ -2592,6 +2660,10 @@ fn ensure_existing_index_schema_is_latest( ) -> Result<(), String> { match classify_existing_index_schema_state(conn)? { IndexSchemaState::Latest => validate_index_schema(conn), + IndexSchemaState::StorageBillingBatchUpgrade => { + apply_storage_billing_batch_index_migration(conn)?; + validate_index_schema(conn) + } IndexSchemaState::Mainnet011 => { let config = config .ok_or_else(|| "cycles config required for first cycles upgrade".to_string())?; @@ -2628,9 +2700,12 @@ fn classify_existing_index_schema_state( "unsupported partial billing index schema: table {table} already exists" )); } - if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX)? { + if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH)? { return Ok(IndexSchemaState::Latest); } + if migration_applied_tx(conn, INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX)? { + return Ok(IndexSchemaState::StorageBillingBatchUpgrade); + } if !migration_applied_tx(conn, INDEX_SCHEMA_VERSION_SOURCE_RUN_SESSIONS)? { return Err(format!( "unsupported index schema: missing migration {INDEX_SCHEMA_VERSION_SOURCE_RUN_SESSIONS}" @@ -2666,6 +2741,22 @@ fn apply_mainnet_011_to_latest_index_migration( Ok(()) } +fn apply_storage_billing_batch_index_migration(conn: &Transaction<'_>) -> Result<(), String> { + conn.execute( + "CREATE TABLE storage_billing_state ( + key TEXT PRIMARY KEY, + cursor_mount_id INTEGER, + billing_now_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + CHECK (key = 'timer') + )", + params![], + ) + .map_err(|error| error.to_string())?; + insert_schema_migration_now(conn, INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH)?; + Ok(()) +} + fn create_schema_migrations(conn: &Transaction<'_>) -> Result<(), String> { conn.execute( "CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)", @@ -2791,6 +2882,7 @@ const INDEX_SCHEMA_VERSIONS: &[&str] = &[ INDEX_SCHEMA_VERSION_STORAGE_BILLING, INDEX_SCHEMA_VERSION_DIRECT_CYCLES, INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX, + INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH, ]; const INDEX_SCHEMA_TABLES_WITHOUT_MIGRATIONS: &[&str] = &[ @@ -2806,6 +2898,7 @@ const INDEX_SCHEMA_TABLES_WITHOUT_MIGRATIONS: &[&str] = &[ "database_cycle_ledger", "database_cycle_pending_operations", "cycles_billing_config", + "storage_billing_state", ]; const POST_011_INDEX_SCHEMA_VERSIONS: &[&str] = &[ @@ -2823,6 +2916,7 @@ const POST_011_INDEX_SCHEMA_VERSIONS: &[&str] = &[ INDEX_SCHEMA_VERSION_STORAGE_BILLING, INDEX_SCHEMA_VERSION_DIRECT_CYCLES, INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX, + INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH, ]; const POST_011_INDEX_SCHEMA_TABLES: &[&str] = &[ @@ -2830,6 +2924,7 @@ const POST_011_INDEX_SCHEMA_TABLES: &[&str] = &[ "database_cycle_ledger", "database_cycle_pending_operations", "cycles_billing_config", + "storage_billing_state", ]; fn validate_pre_billing_index_schema(conn: &Transaction<'_>) -> Result<(), String> { @@ -2962,6 +3057,7 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { "database_cycle_ledger", "database_cycle_pending_operations", "cycles_billing_config", + "storage_billing_state", ] { if !tx_sqlite_master_entry_exists(conn, "table", table)? { return Err(format!("unsupported index schema: missing table {table}")); @@ -3038,6 +3134,15 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { "created_at_ms", ][..], ), + ( + "storage_billing_state", + &[ + "key", + "cursor_mount_id", + "billing_now_ms", + "updated_at_ms", + ][..], + ), ] { for column in columns { if !index_column_exists(conn, table, column)? { @@ -3872,6 +3977,22 @@ struct StorageChargeInput<'a> { config: &'a CyclesBillingConfig, } +struct StorageBillingDatabaseBatch { + databases: Vec, + next_cursor_mount_id: Option, +} + +struct StorageBillingTimerState { + cursor_mount_id: Option, + billing_now_ms: i64, +} + +struct StorageChargeOutcome { + charged: bool, + suspended: bool, + paid_cycles: u64, +} + struct StorageCycleAccount { balance_cycles: i64, suspended_at_ms: Option, @@ -3925,7 +4046,7 @@ fn insert_database_ledger( fn settle_database_storage_charge_in_tx( tx: &Transaction<'_>, input: StorageChargeInput<'_>, -) -> Result<(), String> { +) -> Result { let account = load_storage_cycle_account(tx, input.database_id)?; update_database_logical_size(tx, input.database_id, input.size_bytes)?; let Some(charged_at_ms) = account.storage_charged_at_ms else { @@ -3937,11 +4058,19 @@ fn settle_database_storage_charge_in_tx( input.now, input.now, )?; - return Ok(()); + return Ok(StorageChargeOutcome { + charged: false, + suspended: false, + paid_cycles: 0, + }); }; let elapsed_ms = input.now.saturating_sub(charged_at_ms); if elapsed_ms < STORAGE_BILLING_INTERVAL_MS { - return Ok(()); + return Ok(StorageChargeOutcome { + charged: false, + suspended: false, + paid_cycles: 0, + }); } let storage_cycles = compute_storage_charge_cycles(input.size_bytes, elapsed_ms)?; if storage_cycles == 0 { @@ -3953,7 +4082,11 @@ fn settle_database_storage_charge_in_tx( input.now, input.now, )?; - return Ok(()); + return Ok(StorageChargeOutcome { + charged: false, + suspended: false, + paid_cycles: 0, + }); } let charge_cycles = i64::try_from(storage_cycles) .map_err(|_| "storage charge exceeds i64 limit".to_string())?; @@ -4012,7 +4145,11 @@ fn settle_database_storage_charge_in_tx( }, )?; } - Ok(()) + Ok(StorageChargeOutcome { + charged: paid_cycles > 0, + suspended: newly_suspended, + paid_cycles: u64::try_from(paid_cycles).unwrap_or(0), + }) } fn charge_database_update_in_tx( @@ -4787,13 +4924,49 @@ fn load_databases(conn: &Connection) -> Result, String> { .map_err(|error| error.to_string()) } +fn load_active_databases_for_storage_billing_batch( + conn: &Connection, + cursor_mount_id: u16, + limit: u32, +) -> Result { + let fetch_limit = i64::from(limit.saturating_add(1)); + let mut stmt = conn.prepare( + "SELECT database_id, name, db_file_name, active_mount_id, schema_version, logical_size_bytes, status + FROM databases + WHERE status = 'active' + AND active_mount_id IS NOT NULL + AND mount_id > ?1 + ORDER BY mount_id ASC + LIMIT ?2", + ) + .map_err(|error| error.to_string())?; + let mut databases = crate::sqlite::query_map( + &mut stmt, + params![i64::from(cursor_mount_id), fetch_limit], + map_database_meta, + ) + .map_err(|error| error.to_string())?; + let next_cursor_mount_id = if databases.len() > limit as usize { + databases.pop(); + databases.last().map(|meta| meta.mount_id) + } else { + None + }; + Ok(StorageBillingDatabaseBatch { + databases, + next_cursor_mount_id, + }) +} + +#[cfg(test)] fn load_active_databases_for_storage_billing( conn: &Connection, ) -> Result, String> { let mut stmt = conn.prepare( "SELECT database_id, name, db_file_name, active_mount_id, schema_version, logical_size_bytes, status FROM databases - WHERE active_mount_id IS NOT NULL + WHERE status = 'active' + AND active_mount_id IS NOT NULL ORDER BY mount_id ASC", ) .map_err(|error| error.to_string())?; @@ -4801,6 +4974,75 @@ fn load_active_databases_for_storage_billing( .map_err(|error| error.to_string()) } +fn storage_billing_batch_limit(limit: Option) -> u32 { + limit + .unwrap_or(DEFAULT_STORAGE_BILLING_BATCH_LIMIT) + .clamp(1, MAX_STORAGE_BILLING_BATCH_LIMIT) +} + +fn load_or_create_storage_billing_timer_state( + tx: &Transaction<'_>, + now: i64, +) -> Result { + let existing = tx + .query_row( + "SELECT cursor_mount_id, billing_now_ms + FROM storage_billing_state + WHERE key = 'timer'", + params![], + |row| { + let cursor: Option = crate::sqlite::row_get(row, 0)?; + Ok(StorageBillingTimerState { + cursor_mount_id: cursor.map(mount_id_from_db).transpose()?, + billing_now_ms: crate::sqlite::row_get(row, 1)?, + }) + }, + ) + .optional() + .map_err(|error| error.to_string())?; + if let Some(state) = existing { + return Ok(state); + } + update_storage_billing_timer_state(tx, None, now, now)?; + Ok(StorageBillingTimerState { + cursor_mount_id: None, + billing_now_ms: now, + }) +} + +fn update_storage_billing_timer_state( + tx: &Transaction<'_>, + cursor_mount_id: Option, + billing_now_ms: i64, + updated_at_ms: i64, +) -> Result<(), String> { + tx.execute( + "INSERT INTO storage_billing_state + (key, cursor_mount_id, billing_now_ms, updated_at_ms) + VALUES ('timer', ?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET + cursor_mount_id = excluded.cursor_mount_id, + billing_now_ms = excluded.billing_now_ms, + updated_at_ms = excluded.updated_at_ms", + params![ + crate::sqlite::nullable_integer_value(cursor_mount_id.map(i64::from)), + billing_now_ms, + updated_at_ms + ], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn clear_storage_billing_timer_state(tx: &Transaction<'_>) -> Result<(), String> { + tx.execute( + "DELETE FROM storage_billing_state WHERE key = 'timer'", + params![], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + fn load_database_infos(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( @@ -5798,8 +6040,9 @@ mod tests { for (database_id, status, mount_id) in [ ("active", "active", Some(11_i64)), ("pending", "pending", Some(12_i64)), - ("archived", "archived", Some(13_i64)), - ("restoring", "restoring", Some(14_i64)), + ("archiving", "archiving", Some(13_i64)), + ("archived", "archived", Some(14_i64)), + ("restoring", "restoring", Some(15_i64)), ("deleted", "deleted", None), ] { service @@ -5824,10 +6067,168 @@ mod tests { .map(|meta| meta.database_id) .collect::>(); - assert_eq!( - database_ids, - vec!["active", "pending", "archived", "restoring"] + assert_eq!(database_ids, vec!["active"]); + } + + #[test] + fn storage_billing_batch_clamps_limits_and_paginates() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), ); + service + .run_index_migrations() + .expect("index migrations should run"); + for index in 0..101 { + seed_storage_billing_database(&service, &format!("db-{index:03}"), index); + } + + let first = service + .settle_database_storage_charges_batch( + "canister", + StorageBillingBatchRequest { + cursor_mount_id: None, + limit: None, + }, + STORAGE_BILLING_INTERVAL_MS, + ) + .expect("first batch should settle"); + assert_eq!(first.processed_databases, 100); + assert_eq!(first.charged_databases, 100); + assert_eq!(first.suspended_databases, 0); + assert_eq!(first.next_cursor_mount_id, Some(110)); + + let second = service + .settle_database_storage_charges_batch( + "canister", + StorageBillingBatchRequest { + cursor_mount_id: first.next_cursor_mount_id, + limit: Some(500), + }, + STORAGE_BILLING_INTERVAL_MS, + ) + .expect("second batch should settle"); + assert_eq!(second.processed_databases, 1); + assert_eq!(second.charged_databases, 1); + assert_eq!(second.next_cursor_mount_id, None); + + let limited = service + .settle_database_storage_charges_batch( + "canister", + StorageBillingBatchRequest { + cursor_mount_id: None, + limit: Some(0), + }, + STORAGE_BILLING_INTERVAL_MS * 2, + ) + .expect("limited batch should settle"); + assert_eq!(limited.processed_databases, 1); + assert_eq!(limited.next_cursor_mount_id, Some(11)); + } + + #[test] + fn storage_billing_batch_filters_non_active_mounted_databases() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), + ); + service + .run_index_migrations() + .expect("index migrations should run"); + seed_storage_billing_database(&service, "active", 0); + for (database_id, status, mount_id) in [ + ("pending", "pending", 100_i64), + ("archiving", "archiving", 101_i64), + ("archived", "archived", 102_i64), + ("restoring", "restoring", 103_i64), + ] { + service + .write_index(|tx| { + tx.execute( + "INSERT INTO databases + (database_id, name, db_file_name, mount_id, active_mount_id, status, + schema_version, logical_size_bytes, created_at_ms, updated_at_ms) + VALUES (?1, ?1, ?1, ?3, ?3, ?2, ?4, 0, 0, 0)", + params![database_id, status, mount_id, DATABASE_SCHEMA_VERSION], + ) + .map_err(|error| error.to_string())?; + Ok(()) + }) + .expect("non-active mounted row should insert"); + } + + let result = service + .settle_database_storage_charges_batch( + "canister", + StorageBillingBatchRequest { + cursor_mount_id: None, + limit: None, + }, + STORAGE_BILLING_INTERVAL_MS, + ) + .expect("batch should settle"); + + assert_eq!(result.processed_databases, 1); + assert_eq!(result.charged_databases, 1); + assert_eq!(result.next_cursor_mount_id, None); + } + + #[test] + fn storage_billing_timer_state_reuses_billing_time_across_batches() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), + ); + service + .run_index_migrations() + .expect("index migrations should run"); + for index in 0..101 { + seed_storage_billing_database(&service, &format!("db-{index:03}"), index); + } + + let first = service + .settle_database_storage_charges_timer_batch("canister", STORAGE_BILLING_INTERVAL_MS) + .expect("first timer batch should settle"); + assert_eq!(first.processed_databases, 100); + assert_eq!(first.next_cursor_mount_id, Some(110)); + let second = service + .settle_database_storage_charges_timer_batch( + "canister", + STORAGE_BILLING_INTERVAL_MS * 10, + ) + .expect("second timer batch should settle"); + assert_eq!(second.processed_databases, 1); + assert_eq!(second.next_cursor_mount_id, None); + + let (logical_size_bytes, cycles_delta): (i64, i64) = service + .read_index(|conn| { + let logical_size_bytes = conn + .query_row( + "SELECT logical_size_bytes FROM databases WHERE database_id = 'db-100'", + params![], + |row| crate::sqlite::row_get(row, 0), + ) + .map_err(|error| error.to_string())?; + let cycles_delta = conn + .query_row( + "SELECT cycles_delta FROM database_cycle_ledger + WHERE database_id = 'db-100' AND kind = 'storage_charge'", + params![], + |row| crate::sqlite::row_get(row, 0), + ) + .map_err(|error| error.to_string())?; + Ok((logical_size_bytes, cycles_delta)) + }) + .expect("timer billed row should load"); + let expected = compute_storage_charge_cycles( + logical_size_bytes as u64, + STORAGE_BILLING_INTERVAL_MS, + ) + .expect("expected storage cycles should compute"); + assert_eq!(cycles_delta, expected as i64); } fn storage_test_account_and_ledger( @@ -5888,4 +6289,25 @@ mod tests { }) .expect("test database account should update"); } + + fn seed_storage_billing_database(service: &VfsService, database_id: &str, index: usize) { + service + .create_database(database_id, "owner", 0) + .expect("database should create"); + service + .write_node( + "owner", + WriteNodeRequest { + database_id: database_id.to_string(), + path: "/Wiki/storage.md".to_string(), + kind: NodeKind::File, + content: format!("storage billing payload {index}"), + metadata_json: "{}".to_string(), + expected_etag: None, + }, + 1, + ) + .expect("storage node should write"); + set_test_database_balance(service, database_id, 1_000_000_000); + } } diff --git a/crates/vfs_types/src/fs.rs b/crates/vfs_types/src/fs.rs index 393b6418..8e00d445 100644 --- a/crates/vfs_types/src/fs.rs +++ b/crates/vfs_types/src/fs.rs @@ -124,6 +124,21 @@ pub struct DatabaseCyclesPendingPurchase { pub required_action: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct StorageBillingBatchRequest { + pub cursor_mount_id: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] +pub struct StorageBillingBatchResult { + pub processed_databases: u32, + pub charged_databases: u32, + pub suspended_databases: u32, + pub paid_cycles: u64, + pub next_cursor_mount_id: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] pub struct IndexSqlJsonQueryResult { pub rows: Vec, diff --git a/docs/DB_LIFECYCLE.md b/docs/DB_LIFECYCLE.md index 4cf00faa..342793bc 100644 --- a/docs/DB_LIFECYCLE.md +++ b/docs/DB_LIFECYCLE.md @@ -72,13 +72,13 @@ cycles_delta Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), controlled by `cycles_per_kinic`. Before a metered update, the caller role is checked first, then the DB cycles balance must be at least `min_update_cycles` and the DB must not be suspended. Non-members receive access errors without learning cycles state. Metered updates include content mutations and `grant_database_access`; successful grant calls record `method = "grant_database_access"` in the charge ledger. The IC 13-node execution fee model is `5_000_000 cycles + 1 cycle per executed Wasm instruction`, but metered DB billing charges `20_000_000 cycles + 1 cycle per measured instruction`. The extra `15_000_000` cycles covers local-canister-measured internal accounting overhead from the post-update `charge_database_update` index DB writes. Measurement uses the current message instruction counter delta, not same-message canister balance deltas. If the IC fee table, target subnet type, or accounting overhead changes, update `UPDATE_EXECUTION_BASE_CYCLES`, `UPDATE_ACCOUNTING_OVERHEAD_CYCLES`, and this billing documentation together. If the post-update charge exceeds the DB cycles balance, the remaining DB cycles are fully charged, the balance becomes `0`, and the DB is suspended. -Storage billing settles every 24h from a canister timer, with controller-only `settle_database_storage_charges()` as recovery path. Only active DBs are charged. The 13-node subnet rate is fixed at `127_000 cycles / GiB / sec`: +Storage billing settles every 24h from a canister timer, with controller-only `settle_database_storage_charges_batch(request)` as recovery path. Only mounted DBs with `status = active` are charged. The batch cursor is an exclusive `mount_id` cursor: pass `cursor_mount_id` to resume after that mount, and pass `limit` as `1..=100`; omitted `limit` defaults to `100`. The 13-node subnet rate is fixed at `127_000 cycles / GiB / sec`: ```text storage_cycles = logical_size_bytes * elapsed_seconds * 127_000 / 2^30 ``` -Storage charges write `kind = "storage_charge"` ledger entries for actually collected cycles. Insufficient-balance unpaid cycles are not carried forward or tracked as debt in v1. The residual cost above the remaining balance is forgiven as subsidy/suspension policy, the remaining balance is consumed, and the DB is suspended. The next settle uses the updated cursor. Debt tracking is a separate follow-up. +Storage charges write `kind = "storage_charge"` ledger entries for actually collected cycles. Insufficient-balance unpaid cycles are not carried forward or tracked as debt in v1. The residual cost above the remaining balance is forgiven as subsidy/suspension policy, the remaining balance is consumed, and the DB is suspended. Timer settlement persists `cursor_mount_id` and a fixed `billing_now_ms` in the index DB, processes up to six 100-DB batches per message, and schedules a short continuation timer while `next_cursor_mount_id` remains. The same `billing_now_ms` is reused until the run finishes, so DBs in one run do not receive different elapsed times. Settlement execution overhead allocation and index DB byte billing are outside this flow. `database_cycle_ledger` is the cycles source of truth. Successful charged update calls are recorded there directly. Ledger-backed cycle purchase entries store ledger block indexes in `ledger_block_index`. diff --git a/docs/payment.md b/docs/payment.md index b60a0067..1a0dd4f5 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -179,7 +179,11 @@ wrapper 経由の成功 update では `cycles_delta` に base fee が含まれ ## ストレージ課金 -ストレージ課金は active DB だけ対象である。canister timer は 24h ごとに `settle_database_storage_charges` を呼ぶ。controller は同じ entrypoint を手動実行できる。 +ストレージ課金は `status = active` かつ `active_mount_id IS NOT NULL` の DB だけ対象である。archiving、restoring、archived、pending は対象外である。canister timer は 24h ごとに batch settlement を開始する。controller は `settle_database_storage_charges_batch(request)` を手動実行できる。 + +`StorageBillingBatchRequest` は `cursor_mount_id: opt nat16` と `limit: opt nat32` を持つ。cursor は exclusive な `mount_id` cursor であり、取得条件は `mount_id > cursor`、順序は `ORDER BY mount_id ASC` である。`limit = null` は `100`、`limit = 0` は `1`、`limit > 100` は `100` に clamp される。結果は `processed_databases`、`charged_databases`、`suspended_databases`、`paid_cycles`、`next_cursor_mount_id` を返す。続きがある場合、`next_cursor_mount_id` は最後に処理した `mount_id` になる。終端では `null` になる。 + +timer は index DB の singleton state に `cursor_mount_id`、`billing_now_ms`、`updated_at_ms` を保存する。新規 billing run 開始時に `billing_now_ms = now` を固定し、継続 batch では同じ時刻を使う。1 timer message は最大 6 batch まで処理する。続きがあれば短い continuation timer を登録する。 各 active DB について現在の DB size を測定し、`logical_size_bytes` を更新する。`storage_charged_at_ms` が `NULL` の場合は課金せず、課金カーソルを現在時刻に設定する。 @@ -197,6 +201,8 @@ storage_cycles = logical_size_bytes * elapsed_seconds * 127_000 / 2^30 1 GiB を 24h 保持した場合の課金は `10_972_800_000 cycles`。10 MiB を 24h 保持した場合の課金は `107_156_250 cycles`。 +settlement 実行コストの DB 按分課金と index DB bytes の storage 課金は未実装である。 + ## 履歴と権限 `list_database_cycle_entries(database_id, cursor, limit)` は cycles ledger を entry ID 昇順で返す。`limit` は `1..=100` に clamp される。`next_cursor` は取得件数が limit を超えた場合に返る。 diff --git a/wikibrowser/lib/vfs-idl.ts b/wikibrowser/lib/vfs-idl.ts index 0fcbc31a..0f7acce6 100644 --- a/wikibrowser/lib/vfs-idl.ts +++ b/wikibrowser/lib/vfs-idl.ts @@ -262,6 +262,14 @@ export const idlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { namespace: idl.Opt(idl.Text) }); const SourceEvidenceRequest = idl.Record({ node_path: idl.Text, database_id: idl.Text }); + const StorageBillingBatchRequest = idl.Record({ limit: idl.Opt(idl.Nat32), cursor_mount_id: idl.Opt(idl.Nat16) }); + const StorageBillingBatchResult = idl.Record({ + paid_cycles: idl.Nat64, + suspended_databases: idl.Nat32, + next_cursor_mount_id: idl.Opt(idl.Nat16), + charged_databases: idl.Nat32, + processed_databases: idl.Nat32 + }); const ResultNode = idl.Variant({ Ok: idl.Opt(Node), Err: idl.Text }); const ResultChildren = idl.Variant({ Ok: idl.Vec(ChildNode), Err: idl.Text }); const ResultLinks = idl.Variant({ Ok: idl.Vec(LinkEdge), Err: idl.Text }); @@ -269,6 +277,7 @@ export const idlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { const ResultSearch = idl.Variant({ Ok: idl.Vec(SearchNodeHit), Err: idl.Text }); const ResultQueryContext = idl.Variant({ Ok: QueryContext, Err: idl.Text }); const ResultSourceEvidence = idl.Variant({ Ok: SourceEvidence, Err: idl.Text }); + const ResultStorageBillingBatch = idl.Variant({ Ok: StorageBillingBatchResult, Err: idl.Text }); const ResultCreateDatabase = idl.Variant({ Ok: CreateDatabaseResult, Err: idl.Text }); const ResultCyclesBillingConfig = idl.Variant({ Ok: CyclesBillingConfig, Err: idl.Text }); const ResultCyclesPurchase = idl.Variant({ Ok: CyclesPurchaseResult, Err: idl.Text }); @@ -324,7 +333,7 @@ export const idlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { rename_database: idl.Func([RenameDatabaseRequest], [ResultUnit], []), search_node_paths: idl.Func([SearchNodePathsRequest], [ResultSearch], ["query"]), search_nodes: idl.Func([SearchNodesRequest], [ResultSearch], ["query"]), - settle_database_storage_charges: idl.Func([], [ResultUnit], []), + settle_database_storage_charges_batch: idl.Func([StorageBillingBatchRequest], [ResultStorageBillingBatch], []), source_evidence: idl.Func([SourceEvidenceRequest], [ResultSourceEvidence], ["query"]), update_cycles_billing_config: idl.Func([CyclesBillingConfigUpdate], [ResultUnit], []), purchase_database_cycles: idl.Func([DatabaseCyclesPurchaseRequest], [ResultCyclesPurchase], []), diff --git a/wikibrowser/scripts/candid-shapes.mjs b/wikibrowser/scripts/candid-shapes.mjs index f64ec744..76d52db0 100644 --- a/wikibrowser/scripts/candid-shapes.mjs +++ b/wikibrowser/scripts/candid-shapes.mjs @@ -343,6 +343,10 @@ export const expectedTypes = { ResultNodeContext: { kind: "variant", cases: { Ok: "opt NodeContext", Err: "text" } }, ResultQueryContext: { kind: "variant", cases: { Ok: "QueryContext", Err: "text" } }, ResultSearch: { kind: "variant", cases: { Ok: "vec SearchNodeHit", Err: "text" } }, + ResultStorageBillingBatch: { + kind: "variant", + cases: { Ok: "StorageBillingBatchResult", Err: "text" } + }, ResultSourceEvidence: { kind: "variant", cases: { Ok: "SourceEvidence", Err: "text" } }, ResultOpsAnswerSessionCheck: { kind: "variant", @@ -407,7 +411,21 @@ export const expectedTypes = { raw_href: "text" } }, - SourceEvidenceRequest: { kind: "record", fields: { node_path: "text", database_id: "text" } } + SourceEvidenceRequest: { kind: "record", fields: { node_path: "text", database_id: "text" } }, + StorageBillingBatchRequest: { + kind: "record", + fields: { limit: "opt nat32", cursor_mount_id: "opt nat16" } + }, + StorageBillingBatchResult: { + kind: "record", + fields: { + paid_cycles: "nat64", + suspended_databases: "nat32", + next_cursor_mount_id: "opt nat16", + charged_databases: "nat32", + processed_databases: "nat32" + } + } }; export const didTypeAliases = { @@ -432,9 +450,10 @@ export const didTypeAliases = { ResultNodeContext: "Result_25", ResultQueryContext: "Result_21", ResultSearch: "Result_26", - ResultSourceEvidence: "Result_27", + ResultStorageBillingBatch: "Result_27", + ResultSourceEvidence: "Result_28", ResultOpsAnswerSessionCheck: "Result_3", - ResultWriteSourceForGeneration: "Result_29" + ResultWriteSourceForGeneration: "Result_30" }; export const expectedMethods = { @@ -472,7 +491,7 @@ export const expectedMethods = { search_node_paths: { input: ["SearchNodePathsRequest"], output: "ResultSearch", mode: "query" }, search_nodes: { input: ["SearchNodesRequest"], output: "ResultSearch", mode: "query" }, source_evidence: { input: ["SourceEvidenceRequest"], output: "ResultSourceEvidence", mode: "query" }, - settle_database_storage_charges: { input: [], output: "ResultUnit", mode: "update" }, + settle_database_storage_charges_batch: { input: ["StorageBillingBatchRequest"], output: "ResultStorageBillingBatch", mode: "update" }, update_cycles_billing_config: { input: ["CyclesBillingConfigUpdate"], output: "ResultUnit", mode: "update" }, purchase_database_cycles: { input: ["DatabaseCyclesPurchaseRequest"], output: "ResultCyclesPurchase", mode: "update" }, write_node: { input: ["WriteNodeRequest"], output: "ResultWriteNode", mode: "update" }, diff --git a/wikibrowser/scripts/check-candid-drift.mjs b/wikibrowser/scripts/check-candid-drift.mjs index 0be5f69e..36711aad 100644 --- a/wikibrowser/scripts/check-candid-drift.mjs +++ b/wikibrowser/scripts/check-candid-drift.mjs @@ -172,6 +172,7 @@ function normalizeIdlShape(value) { .replace(/^Int64$/, "int64") .replace(/^Nat64$/, "nat64") .replace(/^Nat32$/, "nat32") + .replace(/^Nat16$/, "nat16") .replace(/^Nat8$/, "nat8") .replace(/^Nat$/, "nat") .replace(/^Float32$/, "float32") @@ -224,9 +225,10 @@ function normalizeResultAlias(value) { if (normalized === "Result_24") return "ResultNode"; if (normalized === "Result_25") return "ResultNodeContext"; if (normalized === "Result_26") return "ResultSearch"; - if (normalized === "Result_27") return "ResultSourceEvidence"; + if (normalized === "Result_27") return "ResultStorageBillingBatch"; + if (normalized === "Result_28") return "ResultSourceEvidence"; if (normalized === "Result_3") return "ResultOpsAnswerSessionCheck"; - if (normalized === "Result_29") return "ResultWriteSourceForGeneration"; + if (normalized === "Result_30") return "ResultWriteSourceForGeneration"; if (normalized === "Result_9") return "ResultCyclesBillingConfig"; if (normalized === "Result") return "ResultWriteNode"; return normalized; diff --git a/wikibrowser/scripts/generate-vfs-idl.mjs b/wikibrowser/scripts/generate-vfs-idl.mjs index 73117d6e..9f808679 100644 --- a/wikibrowser/scripts/generate-vfs-idl.mjs +++ b/wikibrowser/scripts/generate-vfs-idl.mjs @@ -90,6 +90,8 @@ const typeOrder = [ "SearchNodesRequest", "QueryContextRequest", "SourceEvidenceRequest", + "StorageBillingBatchRequest", + "StorageBillingBatchResult", "ResultNode", "ResultChildren", "ResultLinks", @@ -97,6 +99,7 @@ const typeOrder = [ "ResultSearch", "ResultQueryContext", "ResultSourceEvidence", + "ResultStorageBillingBatch", "ResultCreateDatabase", "ResultCyclesBillingConfig", "ResultCyclesPurchase", @@ -152,7 +155,7 @@ const methodOrder = [ "rename_database", "search_node_paths", "search_nodes", - "settle_database_storage_charges", + "settle_database_storage_charges_batch", "source_evidence", "update_cycles_billing_config", "purchase_database_cycles", @@ -254,6 +257,7 @@ function shapeToIdl(shape) { int64: "idl.Int64", nat: "idl.Nat", nat8: "idl.Nat8", + nat16: "idl.Nat16", nat32: "idl.Nat32", nat64: "idl.Nat64", null: "idl.Null", @@ -372,9 +376,10 @@ function normalizeResultAlias(value) { if (normalized === "Result_24") return "ResultNode"; if (normalized === "Result_25") return "ResultNodeContext"; if (normalized === "Result_26") return "ResultSearch"; - if (normalized === "Result_27") return "ResultSourceEvidence"; + if (normalized === "Result_27") return "ResultStorageBillingBatch"; + if (normalized === "Result_28") return "ResultSourceEvidence"; if (normalized === "Result_3") return "ResultOpsAnswerSessionCheck"; - if (normalized === "Result_29") return "ResultWriteSourceForGeneration"; + if (normalized === "Result_30") return "ResultWriteSourceForGeneration"; if (normalized === "Result_9") return "ResultCyclesBillingConfig"; if (normalized === "Result") return "ResultWriteNode"; return normalized; From 5f4dc4a8ea2e8e1a8be12385b248e88c160ac1a6 Mon Sep 17 00:00:00 2001 From: hude Date: Wed, 3 Jun 2026 12:41:05 +0900 Subject: [PATCH 7/8] Use cached logical sizes for storage billing --- crates/vfs_canister/src/benches/scale.rs | 22 +- crates/vfs_runtime/src/lib.rs | 204 +++++++++++++----- docs/DB_LIFECYCLE.md | 4 +- docs/payment.md | 6 +- .../scripts/check-candid-drift.mjs | 2 +- extensions/wiki-clipper/src/content-ui.tsx | 9 +- .../wiki-clipper/tests/settings.test.mjs | 2 + 7 files changed, 181 insertions(+), 68 deletions(-) diff --git a/crates/vfs_canister/src/benches/scale.rs b/crates/vfs_canister/src/benches/scale.rs index 6b04e120..087a3c9a 100644 --- a/crates/vfs_canister/src/benches/scale.rs +++ b/crates/vfs_canister/src/benches/scale.rs @@ -222,13 +222,15 @@ fn emit_metadata(case: BenchCase, metrics: &SnapshotMetrics) { } fn emit_storage_billing_metadata(case: BenchCase) { + let batch_limit = case.n.min(1_000); ic_cdk::eprintln!( - "CANBENCH_META {{\"bench_name\":\"{}\",\"operation\":\"{}\",\"preview_mode\":\"none\",\"n\":{},\"node_count\":{},\"depth\":0,\"content_size\":{},\"updated_count\":0,\"snapshot_node_count\":0,\"snapshot_bytes\":0,\"shape\":\"storage_billing_batch_limit100_active_dbs\",\"certificate_generation\":\"{}\",\"stable_memory_touch_bytes\":null}}", + "CANBENCH_META {{\"bench_name\":\"{}\",\"operation\":\"{}\",\"preview_mode\":\"none\",\"n\":{},\"node_count\":{},\"depth\":0,\"content_size\":{},\"updated_count\":0,\"snapshot_node_count\":0,\"snapshot_bytes\":0,\"shape\":\"storage_billing_batch_limit{}_active_dbs\",\"certificate_generation\":\"{}\",\"stable_memory_touch_bytes\":null}}", case.bench_name, case.operation, case.n, case.n, CONTENT_SIZE, + batch_limit, CERTIFICATION_STATUS ); } @@ -494,18 +496,18 @@ pub(super) fn run_fetch_updates(case: BenchCase) -> BenchResult { pub(super) fn run_storage_billing(case: BenchCase) -> BenchResult { seed_storage_billing_databases(case); emit_storage_billing_metadata(case); + let batch_limit = case.n.min(1_000) as u32; bench_fn(|| { let _scope = bench_scope("storage_billing_call"); with_service(|service| { - service - .settle_database_storage_charges_batch( - "canister", - StorageBillingBatchRequest { - cursor_mount_id: None, - limit: Some(100), - }, - 30_000 + STORAGE_BILLING_INTERVAL_MS, - ) + service.settle_database_storage_charges_batch( + "canister", + StorageBillingBatchRequest { + cursor_mount_id: None, + limit: Some(batch_limit), + }, + 30_000 + STORAGE_BILLING_INTERVAL_MS, + ) }) .expect("bench storage billing should settle"); black_box(()); diff --git a/crates/vfs_runtime/src/lib.rs b/crates/vfs_runtime/src/lib.rs index 1f0a44a0..57f77c58 100644 --- a/crates/vfs_runtime/src/lib.rs +++ b/crates/vfs_runtime/src/lib.rs @@ -70,8 +70,7 @@ const INDEX_SCHEMA_VERSION_STORAGE_BILLING: &str = "database_index:023_storage_b const INDEX_SCHEMA_VERSION_DIRECT_CYCLES: &str = concat!("database_index:024_", "direct_cycles"); const INDEX_SCHEMA_VERSION_CYCLES_PENDING_LEDGER_BLOCK_INDEX: &str = "database_index:025_cycles_pending_ledger_block_index"; -const INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH: &str = - "database_index:026_storage_billing_batch"; +const INDEX_SCHEMA_VERSION_STORAGE_BILLING_BATCH: &str = "database_index:026_storage_billing_batch"; const PENDING_DATABASE_MOUNT_ID: u16 = 0; const DATABASE_SCHEMA_VERSION: &str = "vfs_store:current"; const MIN_DATABASE_MOUNT_ID: u16 = 11; @@ -95,7 +94,8 @@ pub const DEFAULT_MIN_UPDATE_CYCLES: u64 = 1_000_000; pub const STORAGE_BILLING_INTERVAL_MS: i64 = 24 * 60 * 60 * 1000; pub const STORAGE_CYCLES_PER_GIB_SECOND: u128 = 127_000; const DEFAULT_STORAGE_BILLING_BATCH_LIMIT: u32 = 100; -const MAX_STORAGE_BILLING_BATCH_LIMIT: u32 = 100; +const MAX_STORAGE_BILLING_BATCH_LIMIT: u32 = 1_000; +const TIMER_STORAGE_BILLING_BATCH_LIMIT: u32 = 1_000; const GIB_BYTES: u128 = 1024 * 1024 * 1024; const MAX_DATABASE_NAME_CHARS: usize = 80; const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; @@ -286,10 +286,11 @@ impl VfsService { load_active_databases_for_storage_billing_batch( conn, state.cursor_mount_id.unwrap_or(0), - DEFAULT_STORAGE_BILLING_BATCH_LIMIT, + TIMER_STORAGE_BILLING_BATCH_LIMIT, ) })?; - let result = self.settle_database_storage_billing_batch(caller, batch, state.billing_now_ms)?; + let result = + self.settle_database_storage_billing_batch(caller, batch, state.billing_now_ms)?; self.write_index(|tx| { if let Some(cursor) = result.next_cursor_mount_id { update_storage_billing_timer_state(tx, Some(cursor), state.billing_now_ms, now)?; @@ -307,14 +308,8 @@ impl VfsService { batch: StorageBillingDatabaseBatch, now: i64, ) -> Result { - let measured = batch - .databases - .into_iter() - .map(|meta| { - let size = self.database_size(&meta)?; - Ok((meta.database_id, size)) - }) - .collect::, String>>()?; + let next_cursor_mount_id = batch.next_cursor_mount_id; + let databases = batch.databases; self.write_index(|tx| { let config = load_cycles_billing_config(tx)?; let mut result = StorageBillingBatchResult { @@ -322,15 +317,15 @@ impl VfsService { charged_databases: 0, suspended_databases: 0, paid_cycles: 0, - next_cursor_mount_id: batch.next_cursor_mount_id, + next_cursor_mount_id, }; - for (database_id, size_bytes) in measured { + for meta in databases { let outcome = settle_database_storage_charge_in_tx( tx, StorageChargeInput { - database_id: &database_id, + database_id: &meta.database_id, caller, - size_bytes, + size_bytes: meta.logical_size_bytes, now, config: &config, }, @@ -3136,12 +3131,7 @@ fn validate_index_schema(conn: &Transaction<'_>) -> Result<(), String> { ), ( "storage_billing_state", - &[ - "key", - "cursor_mount_id", - "billing_now_ms", - "updated_at_ms", - ][..], + &["key", "cursor_mount_id", "billing_now_ms", "updated_at_ms"][..], ), ] { for column in columns { @@ -3624,21 +3614,6 @@ fn load_storage_cycle_account( .ok_or_else(|| format!("database cycles account not found: {database_id}")) } -fn update_database_logical_size( - conn: &Transaction<'_>, - database_id: &str, - size_bytes: u64, -) -> Result<(), String> { - conn.execute( - "UPDATE databases - SET logical_size_bytes = ?2 - WHERE database_id = ?1", - params![database_id, i64::try_from(size_bytes).unwrap_or(i64::MAX)], - ) - .map_err(|error| error.to_string())?; - Ok(()) -} - fn update_database_storage_account( conn: &Transaction<'_>, database_id: &str, @@ -4048,7 +4023,6 @@ fn settle_database_storage_charge_in_tx( input: StorageChargeInput<'_>, ) -> Result { let account = load_storage_cycle_account(tx, input.database_id)?; - update_database_logical_size(tx, input.database_id, input.size_bytes)?; let Some(charged_at_ms) = account.storage_charged_at_ms else { update_database_storage_account( tx, @@ -6175,6 +6149,99 @@ mod tests { assert_eq!(result.next_cursor_mount_id, None); } + #[test] + fn storage_billing_batch_clamps_manual_limit_to_thousand() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), + ); + service + .run_index_migrations() + .expect("index migrations should run"); + for index in 0..1001 { + seed_storage_billing_index_database( + &service, + &format!("db-{index:04}"), + MIN_DATABASE_MOUNT_ID + index as u16, + GIB_BYTES as i64, + ); + } + + let result = service + .settle_database_storage_charges_batch( + "canister", + StorageBillingBatchRequest { + cursor_mount_id: None, + limit: Some(100_000), + }, + STORAGE_BILLING_INTERVAL_MS, + ) + .expect("oversized batch should settle at max limit"); + + assert_eq!(result.processed_databases, 1000); + assert_eq!(result.next_cursor_mount_id, Some(1010)); + } + + #[test] + fn storage_billing_batch_uses_cached_logical_size_without_opening_database() { + let dir = tempdir().expect("tempdir should create"); + let service = VfsService::new( + dir.path().join("index.sqlite3"), + dir.path().join("databases"), + ); + service + .run_index_migrations() + .expect("index migrations should run"); + seed_storage_billing_database(&service, "cached-size", 0); + let cached_size = GIB_BYTES as i64; + let meta = service + .database_meta("cached-size") + .expect("database metadata should load"); + service + .write_index(|tx| { + tx.execute( + "UPDATE databases + SET logical_size_bytes = ?2 + WHERE database_id = ?1", + params!["cached-size", cached_size], + ) + .map_err(|error| error.to_string())?; + Ok(()) + }) + .expect("cached logical size should update"); + remove_file(&meta.db_file_name).expect("test database file should be removed"); + + let result = service + .settle_database_storage_charges_batch( + "canister", + StorageBillingBatchRequest { + cursor_mount_id: None, + limit: None, + }, + STORAGE_BILLING_INTERVAL_MS, + ) + .expect("storage billing should use cached logical size"); + + assert_eq!(result.processed_databases, 1); + assert_eq!(result.charged_databases, 1); + let cycles_delta: i64 = service + .read_index(|conn| { + conn.query_row( + "SELECT cycles_delta + FROM database_cycle_ledger + WHERE database_id = 'cached-size' AND kind = 'storage_charge'", + params![], + |row| crate::sqlite::row_get(row, 0), + ) + .map_err(|error| error.to_string()) + }) + .expect("storage charge ledger should load"); + let expected = compute_storage_charge_cycles(cached_size as u64, STORAGE_BILLING_INTERVAL_MS) + .expect("expected storage cycles should compute"); + assert_eq!(cycles_delta, expected as i64); + } + #[test] fn storage_billing_timer_state_reuses_billing_time_across_batches() { let dir = tempdir().expect("tempdir should create"); @@ -6185,15 +6252,20 @@ mod tests { service .run_index_migrations() .expect("index migrations should run"); - for index in 0..101 { - seed_storage_billing_database(&service, &format!("db-{index:03}"), index); + for index in 0..1001 { + seed_storage_billing_index_database( + &service, + &format!("db-{index:04}"), + MIN_DATABASE_MOUNT_ID + index as u16, + GIB_BYTES as i64, + ); } let first = service .settle_database_storage_charges_timer_batch("canister", STORAGE_BILLING_INTERVAL_MS) .expect("first timer batch should settle"); - assert_eq!(first.processed_databases, 100); - assert_eq!(first.next_cursor_mount_id, Some(110)); + assert_eq!(first.processed_databases, 1000); + assert_eq!(first.next_cursor_mount_id, Some(1010)); let second = service .settle_database_storage_charges_timer_batch( "canister", @@ -6207,7 +6279,7 @@ mod tests { .read_index(|conn| { let logical_size_bytes = conn .query_row( - "SELECT logical_size_bytes FROM databases WHERE database_id = 'db-100'", + "SELECT logical_size_bytes FROM databases WHERE database_id = 'db-1000'", params![], |row| crate::sqlite::row_get(row, 0), ) @@ -6215,7 +6287,7 @@ mod tests { let cycles_delta = conn .query_row( "SELECT cycles_delta FROM database_cycle_ledger - WHERE database_id = 'db-100' AND kind = 'storage_charge'", + WHERE database_id = 'db-1000' AND kind = 'storage_charge'", params![], |row| crate::sqlite::row_get(row, 0), ) @@ -6223,11 +6295,9 @@ mod tests { Ok((logical_size_bytes, cycles_delta)) }) .expect("timer billed row should load"); - let expected = compute_storage_charge_cycles( - logical_size_bytes as u64, - STORAGE_BILLING_INTERVAL_MS, - ) - .expect("expected storage cycles should compute"); + let expected = + compute_storage_charge_cycles(logical_size_bytes as u64, STORAGE_BILLING_INTERVAL_MS) + .expect("expected storage cycles should compute"); assert_eq!(cycles_delta, expected as i64); } @@ -6310,4 +6380,38 @@ mod tests { .expect("storage node should write"); set_test_database_balance(service, database_id, 1_000_000_000); } + + fn seed_storage_billing_index_database( + service: &VfsService, + database_id: &str, + mount_id: u16, + logical_size_bytes: i64, + ) { + service + .write_index(|tx| { + tx.execute( + "INSERT INTO databases + (database_id, name, db_file_name, mount_id, active_mount_id, status, + schema_version, logical_size_bytes, created_at_ms, updated_at_ms) + VALUES (?1, ?1, ?1, ?2, ?2, 'active', ?3, ?4, 0, 0)", + params![ + database_id, + i64::from(mount_id), + DATABASE_SCHEMA_VERSION, + logical_size_bytes, + ], + ) + .map_err(|error| error.to_string())?; + tx.execute( + "INSERT INTO database_cycle_accounts + (database_id, balance_cycles, suspended_at_ms, storage_charged_at_ms, + created_at_ms, updated_at_ms) + VALUES (?1, ?2, NULL, 0, 0, 0)", + params![database_id, 1_000_000_000_000_i64], + ) + .map_err(|error| error.to_string())?; + Ok(()) + }) + .expect("storage billing index database should insert"); + } } diff --git a/docs/DB_LIFECYCLE.md b/docs/DB_LIFECYCLE.md index 342793bc..39cece64 100644 --- a/docs/DB_LIFECYCLE.md +++ b/docs/DB_LIFECYCLE.md @@ -72,13 +72,13 @@ cycles_delta Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), controlled by `cycles_per_kinic`. Before a metered update, the caller role is checked first, then the DB cycles balance must be at least `min_update_cycles` and the DB must not be suspended. Non-members receive access errors without learning cycles state. Metered updates include content mutations and `grant_database_access`; successful grant calls record `method = "grant_database_access"` in the charge ledger. The IC 13-node execution fee model is `5_000_000 cycles + 1 cycle per executed Wasm instruction`, but metered DB billing charges `20_000_000 cycles + 1 cycle per measured instruction`. The extra `15_000_000` cycles covers local-canister-measured internal accounting overhead from the post-update `charge_database_update` index DB writes. Measurement uses the current message instruction counter delta, not same-message canister balance deltas. If the IC fee table, target subnet type, or accounting overhead changes, update `UPDATE_EXECUTION_BASE_CYCLES`, `UPDATE_ACCOUNTING_OVERHEAD_CYCLES`, and this billing documentation together. If the post-update charge exceeds the DB cycles balance, the remaining DB cycles are fully charged, the balance becomes `0`, and the DB is suspended. -Storage billing settles every 24h from a canister timer, with controller-only `settle_database_storage_charges_batch(request)` as recovery path. Only mounted DBs with `status = active` are charged. The batch cursor is an exclusive `mount_id` cursor: pass `cursor_mount_id` to resume after that mount, and pass `limit` as `1..=100`; omitted `limit` defaults to `100`. The 13-node subnet rate is fixed at `127_000 cycles / GiB / sec`: +Storage billing settles every 24h from a canister timer, with controller-only `settle_database_storage_charges_batch(request)` as recovery path. Only mounted DBs with `status = active` are charged. The batch cursor is an exclusive `mount_id` cursor: pass `cursor_mount_id` to resume after that mount, and pass `limit` as `1..=1000`; omitted `limit` defaults to `100`. The 13-node subnet rate is fixed at `127_000 cycles / GiB / sec`: ```text storage_cycles = logical_size_bytes * elapsed_seconds * 127_000 / 2^30 ``` -Storage charges write `kind = "storage_charge"` ledger entries for actually collected cycles. Insufficient-balance unpaid cycles are not carried forward or tracked as debt in v1. The residual cost above the remaining balance is forgiven as subsidy/suspension policy, the remaining balance is consumed, and the DB is suspended. Timer settlement persists `cursor_mount_id` and a fixed `billing_now_ms` in the index DB, processes up to six 100-DB batches per message, and schedules a short continuation timer while `next_cursor_mount_id` remains. The same `billing_now_ms` is reused until the run finishes, so DBs in one run do not receive different elapsed times. Settlement execution overhead allocation and index DB byte billing are outside this flow. +Storage charges use the latest `logical_size_bytes` stored in the index DB and write `kind = "storage_charge"` ledger entries for actually collected cycles. Settlement does not open every DB to remeasure size; write/update/archive paths keep `logical_size_bytes` current enough for billing. Insufficient-balance unpaid cycles are not carried forward or tracked as debt in v1. The residual cost above the remaining balance is forgiven as subsidy/suspension policy, the remaining balance is consumed, and the DB is suspended. Timer settlement persists `cursor_mount_id` and a fixed `billing_now_ms` in the index DB, processes up to six 1000-DB batches per message, and schedules a short continuation timer while `next_cursor_mount_id` remains. The same `billing_now_ms` is reused until the run finishes, so DBs in one run do not receive different elapsed times. Settlement execution overhead allocation and index DB byte billing are outside this flow. `database_cycle_ledger` is the cycles source of truth. Successful charged update calls are recorded there directly. Ledger-backed cycle purchase entries store ledger block indexes in `ledger_block_index`. diff --git a/docs/payment.md b/docs/payment.md index 1a0dd4f5..5a8810a4 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -181,11 +181,11 @@ wrapper 経由の成功 update では `cycles_delta` に base fee が含まれ ストレージ課金は `status = active` かつ `active_mount_id IS NOT NULL` の DB だけ対象である。archiving、restoring、archived、pending は対象外である。canister timer は 24h ごとに batch settlement を開始する。controller は `settle_database_storage_charges_batch(request)` を手動実行できる。 -`StorageBillingBatchRequest` は `cursor_mount_id: opt nat16` と `limit: opt nat32` を持つ。cursor は exclusive な `mount_id` cursor であり、取得条件は `mount_id > cursor`、順序は `ORDER BY mount_id ASC` である。`limit = null` は `100`、`limit = 0` は `1`、`limit > 100` は `100` に clamp される。結果は `processed_databases`、`charged_databases`、`suspended_databases`、`paid_cycles`、`next_cursor_mount_id` を返す。続きがある場合、`next_cursor_mount_id` は最後に処理した `mount_id` になる。終端では `null` になる。 +`StorageBillingBatchRequest` は `cursor_mount_id: opt nat16` と `limit: opt nat32` を持つ。cursor は exclusive な `mount_id` cursor であり、取得条件は `mount_id > cursor`、順序は `ORDER BY mount_id ASC` である。`limit = null` は `100`、`limit = 0` は `1`、`limit > 1000` は `1000` に clamp される。結果は `processed_databases`、`charged_databases`、`suspended_databases`、`paid_cycles`、`next_cursor_mount_id` を返す。続きがある場合、`next_cursor_mount_id` は最後に処理した `mount_id` になる。終端では `null` になる。 -timer は index DB の singleton state に `cursor_mount_id`、`billing_now_ms`、`updated_at_ms` を保存する。新規 billing run 開始時に `billing_now_ms = now` を固定し、継続 batch では同じ時刻を使う。1 timer message は最大 6 batch まで処理する。続きがあれば短い continuation timer を登録する。 +timer は index DB の singleton state に `cursor_mount_id`、`billing_now_ms`、`updated_at_ms` を保存する。新規 billing run 開始時に `billing_now_ms = now` を固定し、継続 batch では同じ時刻を使う。timer の内部 batch limit は `1000` で、1 timer message は最大 6 batch まで処理する。続きがあれば短い continuation timer を登録する。 -各 active DB について現在の DB size を測定し、`logical_size_bytes` を更新する。`storage_charged_at_ms` が `NULL` の場合は課金せず、課金カーソルを現在時刻に設定する。 +各 active DB は index DB に保存済みの `logical_size_bytes` で課金する。settlement は全 DB を開いて現在 size を再測定しない。`logical_size_bytes` は write/update/archive 開始など既存の size refresh 経路で更新される。`storage_charged_at_ms` が `NULL` の場合は課金せず、課金カーソルを現在時刻に設定する。 前回課金時刻から 24h 未満の場合は何もしない。24h 以上経過した場合、次の式で課金 cycles を計算する。 diff --git a/extensions/wiki-clipper/scripts/check-candid-drift.mjs b/extensions/wiki-clipper/scripts/check-candid-drift.mjs index ec8ec7b4..797600a9 100644 --- a/extensions/wiki-clipper/scripts/check-candid-drift.mjs +++ b/extensions/wiki-clipper/scripts/check-candid-drift.mjs @@ -197,7 +197,7 @@ function normalizeDidResult(value) { if (normalized === "Result_16") return "ResultDatabases"; if (normalized === "Result_18") return "ResultMkdirNode"; if (normalized === "Result_24") return "ResultNode"; - if (normalized === "Result_29") return "ResultWriteSourceForGeneration"; + if (normalized === "Result_30") return "ResultWriteSourceForGeneration"; if (normalized === "Result") return "ResultWriteNode"; return normalized; } diff --git a/extensions/wiki-clipper/src/content-ui.tsx b/extensions/wiki-clipper/src/content-ui.tsx index b7411e3b..26ff339e 100644 --- a/extensions/wiki-clipper/src/content-ui.tsx +++ b/extensions/wiki-clipper/src/content-ui.tsx @@ -206,9 +206,14 @@ async function refreshDatabases() { try { databaseStatus.value = "loading"; const response = await send({ type: "list-writable-databases" }); - databases.value = response.result || []; + const selectableDatabases = (response.result || []).filter((database) => database.writeCyclesAvailable !== false); + databases.value = selectableDatabases; databaseStatus.value = databases.value.length > 0 ? "ready" : "empty"; - if (databases.value.length > 0 && !databases.value.some((database) => database.databaseId === config.value.databaseId)) { + if (databases.value.length === 0) { + if (config.value.databaseId) await saveDatabase(""); + return; + } + if (!databases.value.some((database) => database.databaseId === config.value.databaseId)) { await saveDatabase(databases.value[0].databaseId); } } catch { diff --git a/extensions/wiki-clipper/tests/settings.test.mjs b/extensions/wiki-clipper/tests/settings.test.mjs index 4411baca..b011d46a 100644 --- a/extensions/wiki-clipper/tests/settings.test.mjs +++ b/extensions/wiki-clipper/tests/settings.test.mjs @@ -51,6 +51,8 @@ test("settings and ChatGPT export use Kinic brand colors", () => { assert.match(contentUi, /type: "list-writable-databases"/); assert.match(contentUi, /type: "save-config"/); assert.match(contentUi, /