Appwrite Database + Appwrite Functions: Ensuring consistent pagination under concurrent writes
0 reputation · 10 Mar 2021, 21:34 UTC
0 reputation · 10 Mar 2021, 21:34 UTC
Goal: Retrieve all documents from a collection in a deterministic order while using an Appwrite Function to process each batch, without missing or duplicating entries when writes occur between batches.
Constraints: The Database listDocuments endpoint only offers limit and offset, with a hard limit of 100 documents per request; offset‑based pagination is sensitive to concurrent inserts, updates, or deletes, which can shift the offset window and cause duplicates or gaps. Appwrite does not provide native cursor or keyset pagination, and the Server SDK inside Functions exposes only the limit/offset helpers.
Questions: Is it advisable to implement manual keyset pagination inside the Function using a monotonic field (e.g., timestamp or auto‑increment ID) to avoid offset‑sensitivity? Given the 100‑document ceiling, what batch size balances latency and consistency for large collections under moderate write traffic? Should the Function tolerate occasional duplicates/skips and rely on client‑side deduplication, or enforce strict server‑side ordering?
34775 reputation · 11 Mar 2021, 00:34 UTC
$createdAt) with $id as a tiebreaker, and use Query.greaterThan on the last seen key instead of Query.offset. This prevents concurrent inserts before the cursor from shifting the window.$id) so duplicates are harmless. If a true snapshot is required, capture a high-water mark (max $createdAt) once, then scan only documents at or below that mark.// Inside an Appwrite Function (Node.js Server SDK)
const PAGE_SIZE = 100;
// Persisted cursor: { createdAt: '...', id: 'doc123' }
let cursor = await loadCursor(); // from a state document
while (true) {
const queries = [
Query.limit(PAGE_SIZE),
Query.orderAsc('$createdAt'),
Query.orderAsc('$id')
];
if (cursor) {
queries.push(Query.greaterThan('$createdAt', cursor.createdAt));
queries.push(Query.greaterThanEqual('$id', cursor.id));
}
const page = await databases.listDocuments(DB_ID, COLLECTION, queries);
if (page.documents.length === 0) break;
for (const doc of page.documents) {
await processIdempotently(doc); // upsert by $id
}
const last = page.documents[page.documents.length - 1];
cursor = { createdAt: last.$createdAt, id: last.$id };
await saveCursor(cursor);
}$createdAt earlier than the cursor are not re-scanned; deletes before the cursor simply shrink the remaining set.$createdAt has limited resolution, and bulk writes often share the same timestamp. Adding $id as a secondary sort guarantees a total order and prevents skipped documents.Keyset pagination cannot provide a transactional snapshot. Documents updated to move ahead of the cursor, or inserted ahead of it during the scan, may be picked up or missed depending on timing. The pragmatic approach:
$id in a target collection, or call an idempotent external API).$createdAt once before the loop, then add Query.lessThanEqual('$createdAt', maxCreatedAt) to every page query. Documents created after that mark are handled in the next run.$id values equals the expected set with no duplicates (accounting for the high-water mark if used).Query.greaterThan and orderAsc exist in the installed Server SDK version.What are the exact Appwrite server and Server SDK versions in your environment? The 100-document cap and the availability of specific Query helpers vary by version, and confirming them avoids runtime surprises.
Use comments to ask for clarification. Post a solution as an answer.
34,775 reputation · 11 Mar 2021, 04:57 UTC
While the earlier answer outlines the core pattern, a subtle point is the eventual‑consistency model of Appwrite’s database. A write that lands just after a batch is fetched can still appear in the next page if it shares the same createdAt value. Using the internal $id (a monotonic, globally unique identifier) as a secondary sort key mitigates this: Query.orderAsc('$createdAt'); Query.orderAsc('$id'); and Query.greaterThan('$createdAt', lastCreated); Query.greaterThan('$id', lastId); ensure the cursor advances past the exact document that was processed last, even if another document has an identical timestamp.
Persist the cursor in a dedicated state document (or a key‑value store) so that the Function can resume precisely where it left off after a retry or a new invocation. This also aids in incremental processing and makes the logic idempotent.
To validate the approach, run a controlled test: populate 200 docs, start the Function with a 50‑doc batch size, concurrently insert 20 docs, and verify that every $id appears exactly once in the log.