Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions docs/pr-164.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Add resource_usage_logs table to SQLite

Closes #164

## What

Introduces a dedicated `resource_usage_logs` table that records per-transaction
CPU, memory, and fee-parameter snapshots for any tracked Soroban contract.

Previously, resource data was tied exclusively to TTL-extension transactions
in `extension_history`. This new table is transaction-agnostic, making it
usable by any future code path that wants to persist resource consumption
without performing an extension.

## Schema

New table added to `src/db/schema.sql`:

```sql
CREATE TABLE IF NOT EXISTS resource_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contract_id TEXT NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
cpu_insns INTEGER NOT NULL,
mem_bytes INTEGER NOT NULL,
fee_instructions INTEGER,
fee_read_ledger_entries INTEGER,
fee_write_ledger_entries INTEGER,
fee_read_bytes INTEGER,
fee_write_bytes INTEGER,
fee_transaction_size INTEGER,
fee_historical_ledger INTEGER,
fee_rent_ledger INTEGER,
fee_refundable INTEGER,
recorded_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_resource_usage_logs_contract_id ON resource_usage_logs(contract_id);
CREATE INDEX IF NOT EXISTS idx_resource_usage_logs_recorded_at ON resource_usage_logs(recorded_at DESC);
```

All fee columns are nullable — not every RPC response includes a full fee breakdown.
`cpu_insns` and `mem_bytes` are required as they are always present in a simulation result.

A migration file (`src/db/migrations/001_resource_usage_logs.sql`) is also
included so that existing `sorokeep.db` files are upgraded automatically on
next startup via the `Migrator`.

## Repository API (`src/db/repositories.ts`)

Three new exported functions following the exact patterns used by the rest
of the file:

| Function | Description |
|---|---|
| `insertResourceUsageLog(db, log)` | Insert a new log entry; returns the new row id |
| `getResourceUsageLogs(db, contractId, options?)` | Query logs newest-first; supports `limit` and `since` filters |
| `getLatestResourceUsageLog(db, contractId)` | Convenience helper — returns the single most-recent row |

## Tests

Written **before** the implementation (TDD):

`tests/db/resource_usage_logs.test.ts` — 20 tests covering:

- Schema: table existence, index existence, foreign-key enforcement
- `insertResourceUsageLog`: happy path, null fee columns, duplicate entries
allowed, invalid `contract_id` rejected, zero values, caller-supplied
timestamp
- `getResourceUsageLogs`: empty result, DESC ordering, per-contract isolation,
`limit` option, negative-limit guard, `since` filter
- `getLatestResourceUsageLog`: empty result, most-recent selection, cross-contract isolation

## Checklist

- [x] Tests written first (red → green)
- [x] Schema migration runs on a fresh in-memory DB
- [x] All 20 new tests pass
- [x] Full test suite passes (495 tests, 0 failures)
- [x] No existing tests broken
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/alerts/pagerduty.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { AlertEvent } from "./types.js";
import { getLogger } from "../logging/index.js";

Expand Down
2 changes: 0 additions & 2 deletions src/core/rent_projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,6 @@ export interface RentProjectionResult {

/** A single window result within a multi-window projection. */
export type RentWindowProjection = RentProjectionResult;
// intentionally identical to RentProjectionResult for now;
// kept as a distinct type for future extensibility

/** Result of a 30/60/90-day multi-window projection. */
export interface RentWindowsResult {
Expand Down
1 change: 1 addition & 0 deletions src/core/vault.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { getLogger } from "../logging/index.js";

const logger = getLogger().child({ component: "VaultResolver" });
Expand Down
28 changes: 28 additions & 0 deletions src/db/migrations/001_resource_usage_logs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Migration 001: add resource_usage_logs table (issue #164)
--
-- Stores per-transaction CPU, memory, and fee-parameter snapshots so that
-- operators can track resource consumption over time without relying on
-- extension_history (which is specific to TTL-extension transactions).

CREATE TABLE IF NOT EXISTS resource_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contract_id TEXT NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
cpu_insns INTEGER NOT NULL,
mem_bytes INTEGER NOT NULL,
fee_instructions INTEGER,
fee_read_ledger_entries INTEGER,
fee_write_ledger_entries INTEGER,
fee_read_bytes INTEGER,
fee_write_bytes INTEGER,
fee_transaction_size INTEGER,
fee_historical_ledger INTEGER,
fee_rent_ledger INTEGER,
fee_refundable INTEGER,
recorded_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_resource_usage_logs_contract_id
ON resource_usage_logs(contract_id);

CREATE INDEX IF NOT EXISTS idx_resource_usage_logs_recorded_at
ON resource_usage_logs(recorded_at DESC);
152 changes: 150 additions & 2 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export function hasUnresolvedAlert(db: Database.Database, alertConfigId: number,
SELECT 1 FROM alerts_fired
WHERE alert_config_id = ? AND contract_entry_id = ? AND resolved = 0
LIMIT 1
`).get(alertConfigId, entryId);
`).get(alertConfigId, entryId) as { 1: number } | undefined;
return row !== undefined;
}

Expand Down Expand Up @@ -1024,7 +1024,7 @@ export function hasUnresolvedResourceAlert(
SELECT 1 FROM resource_alerts_fired
WHERE resource_alert_config_id = ? AND resource_type = ? AND resolved = 0
LIMIT 1
`).get(configId, resourceType);
`).get(configId, resourceType) as { 1: number } | undefined;
return !!result;
}

Expand All @@ -1042,3 +1042,151 @@ export function hasUnresolvedResourceAlert(
if (!row || typeof row.usage_percent === "undefined") return false;
return row.usage_percent >= currentUsagePercent;
}

// ─── Resource Usage Logs (issue #164) ────────────────────────────────────────

/**
* A single resource-usage snapshot captured per Soroban transaction.
* All fee columns are nullable because not every RPC response includes the
* full fee breakdown.
*/
export interface ResourceUsageLog {
id: number;
contract_id: string;
cpu_insns: number;
mem_bytes: number;
fee_instructions: number | null;
fee_read_ledger_entries: number | null;
fee_write_ledger_entries: number | null;
fee_read_bytes: number | null;
fee_write_bytes: number | null;
fee_transaction_size: number | null;
fee_historical_ledger: number | null;
fee_rent_ledger: number | null;
fee_refundable: number | null;
recorded_at: string;
}

/**
* Insert a new resource-usage log entry.
*
* @returns The auto-assigned row id of the inserted entry.
*/
export function insertResourceUsageLog(
db: Database.Database,
log: {
contract_id: string;
cpu_insns: number;
mem_bytes: number;
fee_instructions?: number | null;
fee_read_ledger_entries?: number | null;
fee_write_ledger_entries?: number | null;
fee_read_bytes?: number | null;
fee_write_bytes?: number | null;
fee_transaction_size?: number | null;
fee_historical_ledger?: number | null;
fee_rent_ledger?: number | null;
fee_refundable?: number | null;
/** Optional ISO-8601 timestamp; defaults to CURRENT_TIMESTAMP when omitted. */
recorded_at?: string;
},
): number {
const result = db.prepare(`
INSERT INTO resource_usage_logs (
contract_id,
cpu_insns,
mem_bytes,
fee_instructions,
fee_read_ledger_entries,
fee_write_ledger_entries,
fee_read_bytes,
fee_write_bytes,
fee_transaction_size,
fee_historical_ledger,
fee_rent_ledger,
fee_refundable,
recorded_at
) VALUES (
@contract_id,
@cpu_insns,
@mem_bytes,
@fee_instructions,
@fee_read_ledger_entries,
@fee_write_ledger_entries,
@fee_read_bytes,
@fee_write_bytes,
@fee_transaction_size,
@fee_historical_ledger,
@fee_rent_ledger,
@fee_refundable,
COALESCE(@recorded_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`).run({
contract_id: log.contract_id,
cpu_insns: log.cpu_insns,
mem_bytes: log.mem_bytes,
fee_instructions: log.fee_instructions ?? null,
fee_read_ledger_entries: log.fee_read_ledger_entries ?? null,
fee_write_ledger_entries: log.fee_write_ledger_entries ?? null,
fee_read_bytes: log.fee_read_bytes ?? null,
fee_write_bytes: log.fee_write_bytes ?? null,
fee_transaction_size: log.fee_transaction_size ?? null,
fee_historical_ledger: log.fee_historical_ledger ?? null,
fee_rent_ledger: log.fee_rent_ledger ?? null,
fee_refundable: log.fee_refundable ?? null,
recorded_at: log.recorded_at ?? null,
});

return result.lastInsertRowid as number;
}

/**
* Return resource-usage logs for a contract, newest first.
*
* @param options.limit Maximum number of rows to return (must be ≥ 0).
* @param options.since ISO-8601 lower-bound for recorded_at (inclusive).
*/
export function getResourceUsageLogs(
db: Database.Database,
contractId: string,
options: { limit?: number; since?: string } = {},
): ResourceUsageLog[] {
const { limit, since } = options;

if (limit !== undefined && limit < 0) {
throw new Error("limit must be non-negative");
}

const conditions: string[] = ["contract_id = @contractId"];
if (since !== undefined) {
conditions.push("recorded_at >= @since");
}

const where = conditions.join(" AND ");
const limitClause = limit !== undefined ? `LIMIT ${limit}` : "";

return db.prepare(`
SELECT *
FROM resource_usage_logs
WHERE ${where}
ORDER BY recorded_at DESC, id DESC
${limitClause}
`).all({ contractId, since: since ?? null }) as ResourceUsageLog[];
}

/**
* Return the single most-recent resource-usage log for a contract,
* or `undefined` if none exist.
*/
export function getLatestResourceUsageLog(
db: Database.Database,
contractId: string,
): ResourceUsageLog | undefined {
return db.prepare(`
SELECT *
FROM resource_usage_logs
WHERE contract_id = ?
ORDER BY recorded_at DESC, id DESC
LIMIT 1
`).get(contractId) as ResourceUsageLog | undefined;
}
Loading
Loading