commit 8027c7c743e0e7b5d117808c7dd50bde50ee2465
parent 168ba0117bbdefdc61fcaa51115897cd9ff37c4b
Author: Florian Dold <dold@taler.net>
Date: Wed, 19 Aug 2026 21:41:58 +0200
wallet-core: page transaction metadata with stable cursors
Diffstat:
2 files changed, 180 insertions(+), 79 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-transactions.ts b/packages/taler-harness/src/integrationtests/test-wallet-transactions.ts
@@ -24,6 +24,7 @@ import {
j2s,
TalerMerchantApi,
TransactionIdStr,
+ TransactionType,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import {
@@ -190,30 +191,128 @@ export async function runWalletTransactionsTest(t: GlobalTestState) {
{},
);
- const paged: string[] = [];
- let offset: TransactionIdStr | undefined = undefined;
- while (true) {
- const req: GetTransactionsV2Request = { limit: 1 };
- if (offset != null) {
- req.offsetTransactionId = offset;
+ const collectPages = async (limit: 1 | -1): Promise<string[]> => {
+ const paged: string[] = [];
+ let offset: TransactionIdStr | undefined = undefined;
+ while (true) {
+ const req: GetTransactionsV2Request = { limit };
+ if (offset != null) {
+ req.offsetTransactionId = offset;
+ }
+ const page = await walletClient.call(
+ WalletApiOperation.GetTransactionsV2,
+ req,
+ );
+ if (page.transactions.length === 0) {
+ break;
+ }
+ for (const tx of page.transactions) {
+ paged.push(tx.transactionId);
+ }
+ offset = page.transactions[page.transactions.length - 1].transactionId;
}
- const page = await walletClient.call(
- WalletApiOperation.GetTransactionsV2,
- req,
- );
- if (page.transactions.length === 0) {
- break;
- }
- for (const tx of page.transactions) {
- paged.push(tx.transactionId);
- }
- offset = page.transactions[page.transactions.length - 1].transactionId;
- }
+ return paged;
+ };
t.assertDeepEqual(
- paged,
+ await collectPages(1),
all.transactions.map((x) => x.transactionId),
);
+ t.assertDeepEqual(
+ await collectPages(-1),
+ all.transactions.map((x) => x.transactionId).reverse(),
+ );
+ }
+
+ {
+ // If an offset is deleted between page requests, its timestamp fallback
+ // must not skip any surviving transaction in either direction.
+ const beforeDelete = await walletClient.call(
+ WalletApiOperation.GetTransactionsV2,
+ {},
+ );
+ const deletedIndex = beforeDelete.transactions.findIndex(
+ (tx, i) =>
+ tx.type === TransactionType.PeerPullCredit &&
+ i > 0 &&
+ i < beforeDelete.transactions.length - 1,
+ );
+ t.assertTrue(deletedIndex > 0);
+ const deletedOffset = beforeDelete.transactions[deletedIndex];
+ const forwardPrefix = await walletClient.call(
+ WalletApiOperation.GetTransactionsV2,
+ { limit: deletedIndex + 1 },
+ );
+ const backwardPrefix = await walletClient.call(
+ WalletApiOperation.GetTransactionsV2,
+ { limit: -(beforeDelete.transactions.length - deletedIndex) },
+ );
+ t.assertDeepEqual(
+ forwardPrefix.transactions[forwardPrefix.transactions.length - 1]
+ .transactionId,
+ deletedOffset.transactionId,
+ );
+ t.assertDeepEqual(
+ backwardPrefix.transactions[backwardPrefix.transactions.length - 1]
+ .transactionId,
+ deletedOffset.transactionId,
+ );
+ await walletClient.call(WalletApiOperation.DeleteTransaction, {
+ transactionId: deletedOffset.transactionId,
+ });
+
+ const hasDeletedTimestamp = (
+ tx: (typeof beforeDelete.transactions)[number],
+ ): boolean =>
+ AbsoluteTime.cmp(
+ AbsoluteTime.fromPreciseTimestamp(tx.timestamp),
+ AbsoluteTime.fromPreciseTimestamp(deletedOffset.timestamp),
+ ) === 0;
+ const timestampRunStart =
+ beforeDelete.transactions.findIndex(hasDeletedTimestamp);
+ let timestampRunEnd = timestampRunStart;
+ while (
+ timestampRunEnd + 1 < beforeDelete.transactions.length &&
+ hasDeletedTimestamp(beforeDelete.transactions[timestampRunEnd + 1])
+ ) {
+ timestampRunEnd++;
+ }
+
+ const pageLimit = beforeDelete.transactions.length * 2;
+ const forward = await walletClient.call(
+ WalletApiOperation.GetTransactionsV2,
+ {
+ offsetTransactionId: deletedOffset.transactionId,
+ offsetTimestamp: deletedOffset.timestamp,
+ limit: pageLimit,
+ },
+ );
+ const expectedForward = beforeDelete.transactions
+ .slice(timestampRunStart)
+ .filter((tx) => tx.transactionId !== deletedOffset.transactionId)
+ .map((tx) => tx.transactionId);
+ t.assertDeepEqual(
+ forward.transactions.map((tx) => tx.transactionId),
+ expectedForward,
+ );
+
+ const backward = await walletClient.call(
+ WalletApiOperation.GetTransactionsV2,
+ {
+ offsetTransactionId: deletedOffset.transactionId,
+ offsetTimestamp: deletedOffset.timestamp,
+ limit: -pageLimit,
+ },
+ );
+ const expectedBackward = beforeDelete.transactions
+ .slice(0, timestampRunEnd + 1)
+ .filter((tx) => tx.transactionId !== deletedOffset.transactionId)
+ .map((tx) => tx.transactionId)
+ .reverse();
+ t.assertDeepEqual(
+ backward.transactions.map((tx) => tx.transactionId),
+ expectedBackward,
+ );
}
}
diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts
@@ -59,6 +59,7 @@ import {
timestampPreciseToDb,
} from "./db-common.js";
import { WalletTransactionMeta } from "./db-common.js";
+import { WalletTransactionMetaCursor } from "./db-common.js";
import { DepositTransactionContext } from "./deposits.js";
import { DenomLossTransactionContext } from "./exchanges.js";
import {
@@ -433,60 +434,61 @@ function sortTransactions(
AbsoluteTime.fromPreciseTimestamp(h1.timestamp),
AbsoluteTime.fromPreciseTimestamp(h2.timestamp),
);
- // If the timestamp is exactly the same, order by transaction type.
+ // Match the unique database cursor order for equal timestamps.
if (tsCmp === 0) {
- return Math.sign(txOrder[h1.type] - txOrder[h2.type]);
+ const idCmp =
+ h1.transactionId < h2.transactionId
+ ? -1
+ : h1.transactionId > h2.transactionId
+ ? 1
+ : 0;
+ return sortSign * idCmp;
}
return sortSign * tsCmp;
};
transactions.sort(txCmp);
}
-async function findOffsetTransaction(
+async function findOffsetCursor(
tx: WalletDbTransaction,
req?: GetTransactionsV2Request,
-): Promise<WalletTransactionMeta | undefined> {
- let forwards = req?.limit == null || req.limit >= 0;
- let closestTimestamp: DbPreciseTimestamp | undefined = undefined;
+): Promise<WalletTransactionMetaCursor | undefined> {
+ const forwards = req?.limit == null || req.limit >= 0;
if (req?.offsetTransactionId) {
const res = await tx.getTransactionMeta(req.offsetTransactionId);
if (res) {
- return res;
+ return { timestamp: res.timestamp, transactionId: res.transactionId };
}
- if (req.offsetTimestamp) {
- closestTimestamp = timestampPreciseToDb(req.offsetTimestamp);
- } else {
+ if (!req.offsetTimestamp) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_TRANSACTION_NOT_FOUND,
{ transactionId: req.offsetTransactionId },
`offset transaction ${req.offsetTransactionId} not found and no offset timestamp specified`,
);
}
- } else if (req?.offsetTimestamp) {
- const dbStamp = timestampPreciseToDb(req.offsetTimestamp);
- const res = await tx.getTransactionMetaAtTimestamp(dbStamp);
- if (res) {
- return res;
- }
- closestTimestamp = timestampPreciseToDb(req.offsetTimestamp);
- } else {
+ }
+ if (!req?.offsetTimestamp) {
return undefined;
}
-
- // We didn't find a precise offset transaction.
- // This must mean that it was deleted.
- // Depending on the direction, find the prev/next
- // transaction and use it as an offset.
-
- if (forwards) {
- // We don't want to skip transactions in pagination,
- // so get the transaction before the timestamp
-
- return await tx.getTransactionMetaBefore(closestTimestamp);
- } else {
- // Likewise, get the transaction after the timestamp
- return await tx.getTransactionMetaAfter(closestTimestamp);
+ const timestamp = timestampPreciseToDb(req.offsetTimestamp);
+ if (!req.offsetTransactionId) {
+ const atTimestamp = await tx.getTransactionMetaAtTimestamp(timestamp);
+ if (atTimestamp) {
+ return {
+ timestamp: atTimestamp.timestamp,
+ transactionId: atTimestamp.transactionId,
+ };
+ }
}
+ // No live transaction is available to anchor the cursor. Conservatively
+ // replay the whole run at this timestamp: use its low edge when paging
+ // forward and its high edge when paging backward. This guarantees that no
+ // surviving transaction is skipped, at the cost of possibly returning one
+ // from the preceding page again.
+ return {
+ timestamp,
+ transactionId: forwards ? "" : "\uffff",
+ };
}
export async function getTransactionsV2(
@@ -508,9 +510,9 @@ export async function getTransactionsV2(
transactionsRequest?.limit != null
? Math.abs(transactionsRequest.limit)
: undefined;
- let offsetMtx = await findOffsetTransaction(tx, transactionsRequest);
+ let cursor = await findOffsetCursor(tx, transactionsRequest);
- if (limit == null && offsetMtx == null) {
+ if (limit == null && cursor == null) {
// Fast path for returning *everything* that matches the filter.
// FIXME: We could use the DB for filtering here
const res = await tx.listTransactionMetaByStatus({
@@ -518,36 +520,36 @@ export async function getTransactionsV2(
});
await addFiltered(wex, tx, transactionsRequest, resultTransactions, res);
} else {
- // Slow implementation. Doing it properly would require using cursors,
- // which are also slow in IndexedDB.
- //
- // The list is walked rather than paged through the timestamp index,
- // because that index is not unique: a bound on the timestamp alone
- // cannot separate two transactions that share one, so paging on it
- // skips the rest of a run that a page ended in the middle of.
- const res = await tx.listTransactionMetaByTimestamp({});
- if (!forwards) {
- res.reverse();
+ if (limit === 0) {
+ return;
}
- let start: number;
- if (offsetMtx != null) {
- const needleTxId = offsetMtx.transactionId;
- const offsetIdx = res.findIndex((x) => x.transactionId === needleTxId);
- if (offsetIdx < 0) {
- throw Error("offset transaction not found");
+ while (limit == null || resultTransactions.length < limit) {
+ const remaining =
+ limit == null ? 128 : Math.max(1, limit - resultTransactions.length);
+ const pageSize = Math.min(256, Math.max(32, remaining * 2));
+ const page = await tx.listTransactionMetaPage({
+ cursor,
+ direction: forwards ? "forward" : "backward",
+ limit: pageSize,
+ });
+ if (page.length === 0) {
+ break;
}
- // The offset transaction was part of the previous page.
- start = offsetIdx + 1;
- } else {
- start = 0;
- }
- for (let i = start; i < res.length; i++) {
- if (limit != null && resultTransactions.length >= limit) {
+ await addFiltered(
+ wex,
+ tx,
+ transactionsRequest,
+ resultTransactions,
+ page,
+ );
+ const last = page[page.length - 1];
+ cursor = {
+ timestamp: last.timestamp,
+ transactionId: last.transactionId,
+ };
+ if (page.length < pageSize) {
break;
}
- await addFiltered(wex, tx, transactionsRequest, resultTransactions, [
- res[i],
- ]);
}
}
});