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
8 changes: 8 additions & 0 deletions api/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,12 @@ export const AppDataSource = new DataSource({
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
migrationsTableName: 'typeorm_migrations',
// Pool config: without this, pg creates a new connection per request and
// never returns it, exhausting Postgres's max_connections under load.
poolSize: 20,
extra: {
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
},
});
49 changes: 49 additions & 0 deletions api/src/health/db-pool-health.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { DataSource } from 'typeorm';
import { Logger } from '@nestjs/common';

const logger = new Logger('DbPoolHealth');
const SLOW_ACQUIRE_THRESHOLD_MS = 500;

export interface DbPoolStats {
activeConnections: number;
idleConnections: number;
waitingCount: number;
}

/**
* Reads pg Pool stats off the underlying node-postgres driver so callers can
* expose DB_POOL_ACTIVE_CONNECTIONS / DB_POOL_IDLE_CONNECTIONS metrics.
*
* Not yet wired into HealthController — add a call to this alongside the
* existing checks once poolSize is configured in data-source.ts.
*/
export function getDbPoolStats(dataSource: DataSource): DbPoolStats {
// node-postgres exposes totalCount/idleCount/waitingCount on the driver's
// underlying pool; TypeORM's postgres driver stores it as `master`.
const pool = (dataSource.driver as unknown as { master?: any }).master;

return {
activeConnections: (pool?.totalCount ?? 0) - (pool?.idleCount ?? 0),
idleConnections: pool?.idleCount ?? 0,
waitingCount: pool?.waitingCount ?? 0,
};
}

/**
* Wrap a connection-acquiring call to log when it takes longer than
* SLOW_ACQUIRE_THRESHOLD_MS (500ms) — a signal the pool is saturated.
*/
export async function withSlowAcquireLogging<T>(
label: string,
acquire: () => Promise<T>,
): Promise<T> {
const start = Date.now();
try {
return await acquire();
} finally {
const elapsed = Date.now() - start;
if (elapsed > SLOW_ACQUIRE_THRESHOLD_MS) {
logger.warn(`slow connection acquisition for ${label}: ${elapsed}ms`);
}
}
}
76 changes: 76 additions & 0 deletions api/src/marketplace/marketplace-cleanup.cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { MarketplaceService } from './marketplace.service';
import { StellarKeypairService } from '../stellar/stellar-keypair.service';
import { MetricsService } from '../metrics/metrics.service';

/**
* Issue: expired marketplace offers accumulate in contract storage because
* clean_expired_offers() requires a manual, gas-paying caller. This cron
* automates that call on an hourly schedule using the admin keypair.
*
* NOTE: not yet wired into MarketplaceModule providers — add it there and
* inject the Soroban RPC client used by MarketplaceService before enabling.
*/
@Injectable()
export class MarketplaceCleanupCron {
private readonly logger = new Logger(MarketplaceCleanupCron.name);
private readonly BATCH_SIZE = 100;
private readonly PENDING_ALERT_THRESHOLD = 1000;

constructor(
private readonly config: ConfigService,
private readonly marketplaceService: MarketplaceService,
private readonly adminKeypair: StellarKeypairService,
private readonly metrics: MetricsService,
) {}

@Cron(CronExpression.EVERY_HOUR)
async cleanExpiredOffers(): Promise<void> {
const now = Math.floor(Date.now() / 1000);
let removed = 0;
let gasSpent = 0;

try {
const activeOfferIds = await this.marketplaceService.getActiveOfferIds();
const expiredIds = await this.filterExpired(activeOfferIds, now);

if (expiredIds.length > this.PENDING_ALERT_THRESHOLD) {
this.logger.error(
`marketplace cleanup backlog alert: ${expiredIds.length} expired offers pending (threshold ${this.PENDING_ALERT_THRESHOLD})`,
);
}

for (let i = 0; i < expiredIds.length; i += this.BATCH_SIZE) {
const batch = expiredIds.slice(i, i + this.BATCH_SIZE);
const startId = batch[0];

const result = await this.marketplaceService.cleanExpiredOffers(
startId,
batch.length,
this.adminKeypair,
);

removed += result.removedCount;
gasSpent += result.feeCharged;
}

this.metrics.increment('marketplace.cleanup.offers_removed', removed);
this.metrics.increment('marketplace.cleanup.gas_spent', gasSpent);
this.logger.log(
`marketplace cleanup: removed=${removed} gasSpent=${gasSpent}`,
);
} catch (err) {
this.logger.error('marketplace cleanup cron failed', err as Error);
}
}

private async filterExpired(
offerIds: string[],
now: number,
): Promise<string[]> {
const offers = await this.marketplaceService.getOffersByIds(offerIds);
return offers.filter((o) => o.expiresAt != null && o.expiresAt < now).map((o) => o.id);

Check failure on line 74 in api/src/marketplace/marketplace-cleanup.cron.ts

View workflow job for this annotation

GitHub Actions / Backend API (NestJS)

Replace `.filter((o)·=>·o.expiresAt·!=·null·&&·o.expiresAt·<·now)` with `⏎······.filter((o)·=>·o.expiresAt·!=·null·&&·o.expiresAt·<·now)⏎······`
}
}
38 changes: 38 additions & 0 deletions api/src/projects/ipfs-upload-retry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import axios, { AxiosError } from 'axios';
import { uploadToIpfsWithRetry } from './ipfs-upload-retry.util';

function axiosErrorWithStatus(status: number): AxiosError {
const err = new Error(`Request failed with status ${status}`) as AxiosError;
err.isAxiosError = true;
err.response = { status } as AxiosError['response'];
return err;
}

describe('uploadToIpfsWithRetry', () => {
beforeAll(() => {
jest.spyOn(axios, 'isAxiosError').mockImplementation(

Check failure on line 13 in api/src/projects/ipfs-upload-retry.spec.ts

View workflow job for this annotation

GitHub Actions / Backend API (NestJS)

Replace `.spyOn(axios,·'isAxiosError')` with `⏎······.spyOn(axios,·'isAxiosError')⏎······`
(payload: unknown): payload is AxiosError =>

Check failure on line 14 in api/src/projects/ipfs-upload-retry.spec.ts

View workflow job for this annotation

GitHub Actions / Backend API (NestJS)

Replace `······` with `········`
(payload as AxiosError)?.isAxiosError === true,

Check failure on line 15 in api/src/projects/ipfs-upload-retry.spec.ts

View workflow job for this annotation

GitHub Actions / Backend API (NestJS)

Insert `··`
);

Check failure on line 16 in api/src/projects/ipfs-upload-retry.spec.ts

View workflow job for this annotation

GitHub Actions / Backend API (NestJS)

Insert `··`
});

it('retries on 503 twice then succeeds on the third attempt', async () => {
const fn = jest
.fn()
.mockRejectedValueOnce(axiosErrorWithStatus(503))
.mockRejectedValueOnce(axiosErrorWithStatus(503))
.mockResolvedValueOnce({ IpfsHash: 'bafy123' });

const result = await uploadToIpfsWithRetry(fn);

expect(result).toEqual({ IpfsHash: 'bafy123' });
expect(fn).toHaveBeenCalledTimes(3);
});

it('fails immediately on 400 without retrying', async () => {
const fn = jest.fn().mockRejectedValueOnce(axiosErrorWithStatus(400));

await expect(uploadToIpfsWithRetry(fn)).rejects.toBeTruthy();
expect(fn).toHaveBeenCalledTimes(1);
});
});
49 changes: 49 additions & 0 deletions api/src/projects/ipfs-upload-retry.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import axios, { AxiosError } from 'axios';
import { Logger } from '@nestjs/common';

const logger = new Logger('IpfsUploadRetry');

const RETRYABLE_STATUS_CODES = new Set([429, 503]);
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 100;

function isRetryable(err: unknown): boolean {
if (!axios.isAxiosError(err)) return true; // network/timeout errors: retry
const status = (err as AxiosError).response?.status;
if (status === 400 || status === 401) return false;
if (status == null) return true; // network timeout, no response
return RETRYABLE_STATUS_CODES.has(status);
}

/**
* Issue #359 follow-up: Pinata uploads had no retry, so a single transient
* 503 lost the project submission. Mirrors the exponential-backoff pattern
* used in StellarService.submitTransactionWithRetry (100ms, 200ms, 400ms).
*
* Not yet wired into ProjectsService.uploadToIpfs — swap the raw axios.post
* call there for uploadToIpfsWithRetry() and add the pending_uploads
* fallback table/background job described in the issue.
*/
export async function uploadToIpfsWithRetry<T>(
requestFn: () => Promise<T>,
maxRetries = MAX_RETRIES,
): Promise<T> {
let attempt = 0;

while (true) {
try {
return await requestFn();
} catch (err) {
if (!isRetryable(err) || attempt >= maxRetries) {
throw err;
}

const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
logger.warn(
`Pinata upload failed, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`,
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
attempt += 1;
}
}
}
46 changes: 46 additions & 0 deletions api/test/db-pool.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DataSource } from 'typeorm';

/**
* Integration test for issue: TypeORM had no pool config, so 50 concurrent
* requests exhausted Postgres's max_connections=100. Verifies 100 concurrent
* queries all succeed while the pool stays capped at poolSize (20).
*
* Requires a reachable Postgres (DATABASE_URL) — not run as part of unit
* test suite; intended for `npm run test:integration` once wired up.
*/
describe('DB connection pool under concurrent load', () => {
let dataSource: DataSource;

beforeAll(async () => {
dataSource = new DataSource({
type: 'postgres',
url:
process.env['DATABASE_URL'] ??
'postgresql://postgres:postgres@localhost:5432/carbonchain',
poolSize: 20,
extra: {
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
},
});
await dataSource.initialize();
});

afterAll(async () => {
await dataSource.destroy();
});

it('handles 100 concurrent queries with at most 20 pooled connections', async () => {
const queries = Array.from({ length: 100 }, () =>
dataSource.query('SELECT 1'),
);

const results = await Promise.all(queries);

expect(results).toHaveLength(100);

const pool = (dataSource.driver as unknown as { master?: any }).master;
expect(pool.totalCount).toBeLessThanOrEqual(20);
});
});
2 changes: 2 additions & 0 deletions contracts/credit_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub const MIN_CREDIT_UNIT: i128 = 100_000;

pub mod errors;
pub mod events;
pub mod migrations;
pub mod storage;
#[cfg(feature = "testutils")]
pub mod test_helpers;
Expand Down Expand Up @@ -86,6 +87,7 @@ impl CreditRegistry {
set_admin(&env, &admin);
set_retirement_contract(&env, &retirement_contract);
set_required_approvals(&env, required_approvals);
crate::storage::set_version(&env, crate::migrations::CURRENT_VERSION);
ContractInitialized {
admin: admin.clone(),
retirement_contract: retirement_contract.clone(),
Expand Down
69 changes: 69 additions & 0 deletions contracts/credit_registry/src/migrations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! Sequential contract version migrations, run via `migrate(admin, target_version)`.
//!
//! Not yet wired into lib.rs's public contract impl — add a `migrate` entry
//! point there that calls `run_migrations` after checking `admin.require_auth()`
//! against the stored admin address.

use crate::errors::CarbonChainError;
use crate::storage::{get_version, set_version};
use soroban_sdk::Env;

pub const CURRENT_VERSION: u32 = 1;

/// Runs each migration step in order from the stored version up to
/// `target_version`. Each step is idempotent-safe to re-run because it only
/// executes when `get_version(env) == step - 1`.
pub fn run_migrations(env: &Env, target_version: u32) -> Result<(), CarbonChainError> {
let mut current = get_version(env);

if target_version < current {
return Err(CarbonChainError::InvalidApprovalThreshold);
}

while current < target_version {
match current {
0 => migrate_v0_to_v1(env),
1 => migrate_v1_to_v2(env),
_ => break,
}
current += 1;
set_version(env, current);
}

Ok(())
}

fn migrate_v0_to_v1(_env: &Env) {
// v1 introduced explicit version tracking; no data transformation needed.
}

/// Example v1 -> v2 migration: CreditMetadata gained a new optional field.
/// Existing persistent entries are left untouched since the new field reads
/// as `None`/default until each credit is next written.
fn migrate_v1_to_v2(_env: &Env) {}

#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::Env;

#[test]
fn v1_to_v2_migration_bumps_version_without_touching_existing_credits() {
let env = Env::default();
set_version(&env, 1);

run_migrations(&env, 2).unwrap();

assert_eq!(get_version(&env), 2);
}

#[test]
fn migrate_is_a_no_op_when_already_at_target_version() {
let env = Env::default();
set_version(&env, CURRENT_VERSION);

run_migrations(&env, CURRENT_VERSION).unwrap();

assert_eq!(get_version(&env), CURRENT_VERSION);
}
}
8 changes: 8 additions & 0 deletions contracts/credit_registry/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ pub const MIN_TTL: u32 = 6_307_200;
/// Threshold below which TTL is extended (half of MIN_TTL).
pub const TTL_THRESHOLD: u32 = MIN_TTL / 2;

pub fn set_version(env: &Env, version: u32) {
env.storage().instance().set(&DataKey::Version, &version);
}

pub fn get_version(env: &Env) -> u32 {
env.storage().instance().get(&DataKey::Version).unwrap_or(0)
}

pub fn set_admin(env: &Env, admin: &Address) {
env.storage().instance().set(&DataKey::Admin, admin);
}
Expand Down
2 changes: 2 additions & 0 deletions contracts/credit_registry/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ pub struct AuditLogEntry {
#[derive(Clone)]
#[contracttype]
pub enum DataKey {
/// Contract schema version, used by migrate() to run sequential upgrades.
Version,
Admin,
VerifierSet,
Credit(BytesN<32>),
Expand Down
Loading
Loading