Fix boards loading lists but never cards on FerretDB (10.22): drop the null from the card-scope $in so it stays index-backed.

Every board card query used `{ boardId: { $in: [board._id, board.subtasksDefaultBoardId] } }`,
and subtasksDefaultBoardId defaults to null. On FerretDB v1 (SQLite) a $in that
contains a non-string element (the null) does not push down at all, so the WHERE was
dropped and the whole cards collection was full-scanned + sjson-decoded on every
poll-and-diff cycle, across ~7 card cursors per board open. Lists and swimlanes use a
plain boardId equality (index-backed) and stayed fast — so on a big board the columns
appeared but the cards never did.

New helper models/lib/boardCardScope.js builds the scope WITHOUT null and prefers a
plain equality (index seek) when only the board itself is in scope, else an
all-string $in (which pushes down). Applied to every card-scope site in
server/publications/boards.js (8x), cardsWindow.js and cards.js.

Also flatten the lazy card-window selector (cardsWindow.js): the window was
`{ $and: [clientSelector, { boardId, archived }] }`, and FerretDB does not push down
a top-level $and, so boardId/archived were full-scanned too. Merge the board scope at
the top level (exactly equivalent when the client selector has no boardId/archived
key, which it does not — it is a per-list listId + swimlane selector; a collision
guard keeps $and otherwise), so boardId/archived push down.

The FerretDB fork side is hardened separately so a mixed $in (incl. null) also uses
the index. Test: tests/boardCardScope.test.cjs.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-07-22 20:05:32 +03:00
1 parent f47b7cb78a
commit a51afa5a1b
5 files changed
+133 -12

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
'use strict';
// The selector matching cards that belong to a board: the board itself, plus its
// subtasks-default board when one is configured.
//
// Why this helper exists (10.22 "boards load lists/columns but never cards"):
// the card queries used `{ boardId: { $in: [board._id, board.subtasksDefaultBoardId] } }`,
// and `subtasksDefaultBoardId` defaults to `null`. On FerretDB v1 (SQLite) a `$in`
// list that contains a non-string element (the `null`) does NOT push down at all,
// so the WHERE clause is dropped and the whole `cards` collection is full-scanned
// and sjson-decoded in Go on EVERY poll-and-diff cycle. Lists/swimlanes use a plain
// `boardId` equality, which is index-backed and fast — hence columns appear but
// cards never do. Build the id list WITHOUT null, and use a plain equality when
// only the board itself is in scope, so the card query stays index-backed like the
// lists selector.
function boardScopeIds(board) {
const ids = [];
if (board && typeof board._id === 'string' && board._id.length > 0) {
ids.push(board._id);
}
if (board && typeof board.subtasksDefaultBoardId === 'string' && board.subtasksDefaultBoardId.length > 0) {
ids.push(board.subtasksDefaultBoardId);
}
return ids;
}
// Returns a Mongo selector fragment `{ boardId: <id> }` (equality, index-backed)
// or `{ boardId: { $in: [id1, id2] } }` — never an $in that contains null. Spread
// it into a card selector: `{ ...boardCardScope(board), archived: false }`.
function boardCardScope(board) {
const ids = boardScopeIds(board);
if (ids.length <= 1) {
return { boardId: ids[0] };
}
return { boardId: { $in: ids } };
}
module.exports = { boardScopeIds, boardCardScope };
+9 -8
View File
@@ -22,6 +22,7 @@ const {
effectiveBoardCardsMode,
DEFAULT_LAZY_THRESHOLD,
} = require('/models/lib/cardsLoading');
const { boardCardScope } = require('/models/lib/boardCardScope');
// Card-loading mode (Admin Panel / Features): 'all' ships every card/checklist to
// minimongo; 'lazy' ships none (each list loads its visible window via the
@@ -524,7 +525,7 @@ publishComposite('board', async function(boardId, isArchived) {
if (mode === 'all') return false;
if (_boardCardCount === null) {
_boardCardCount = await Cards.find(
{ boardId: { $in: [board._id, board.subtasksDefaultBoardId] }, archived: isArchived },
{ ...boardCardScope(board), archived: isArchived },
).countAsync();
}
return effectiveBoardCardsMode('auto', _boardCardCount, globalLazyThreshold()) === 'lazy';
@@ -591,7 +592,7 @@ publishComposite('board', async function(boardId, isArchived) {
// published per visible window by `boardCardsWindow`, not here.
if (await boardIsLazy(board)) return null;
const cardSelector = {
boardId: { $in: [board._id, board.subtasksDefaultBoardId] },
...boardCardScope(board),
archived: isArchived,
};
@@ -721,7 +722,7 @@ publishComposite('board', async function(boardId, isArchived) {
{
async find(board) {
const cardSelector = {
boardId: { $in: [board._id, board.subtasksDefaultBoardId] },
...boardCardScope(board),
archived: isArchived,
};
@@ -759,7 +760,7 @@ publishComposite('board', async function(boardId, isArchived) {
{
async find(board) {
const cardSelector = {
boardId: { $in: [board._id, board.subtasksDefaultBoardId] },
...boardCardScope(board),
archived: isArchived,
};
@@ -783,7 +784,7 @@ publishComposite('board', async function(boardId, isArchived) {
{
async find(board) {
const cardSelector = {
boardId: { $in: [board._id, board.subtasksDefaultBoardId] },
...boardCardScope(board),
archived: isArchived,
};
@@ -807,7 +808,7 @@ publishComposite('board', async function(boardId, isArchived) {
{
async find(board) {
const cardSelector = {
boardId: { $in: [board._id, board.subtasksDefaultBoardId] },
...boardCardScope(board),
archived: isArchived,
};
@@ -832,7 +833,7 @@ publishComposite('board', async function(boardId, isArchived) {
{
async find(board) {
const cardSelector = {
boardId: { $in: [board._id, board.subtasksDefaultBoardId] },
...boardCardScope(board),
archived: isArchived,
};
@@ -856,7 +857,7 @@ publishComposite('board', async function(boardId, isArchived) {
{
async find(board) {
const cardSelector = {
boardId: { $in: [board._id, board.subtasksDefaultBoardId] },
...boardCardScope(board),
archived: isArchived,
};
+2 -1
View File
@@ -78,6 +78,7 @@ import { QueryErrors, QueryParams, Query } from '/config/query-classes';
import { CARD_TYPES } from '../../config/const';
import Org from "../../models/org";
import Team from "../../models/team";
const { boardCardScope } = require('/models/lib/boardCardScope');
Meteor.publish('card', async function(cardId) {
check(cardId, String);
@@ -261,7 +262,7 @@ Meteor.publish('archiveSidebar', async function(boardId, activeTab = 'cards', ca
}
const cardSelector = {
boardId: { $in: [board._id, board.subtasksDefaultBoardId] },
...boardCardScope(board),
archived: true,
};
+18 -3
View File
@@ -3,6 +3,7 @@ import { publishComposite } from 'meteor/reywood:publish-composite';
import Boards from '/models/boards';
import Cards from '/models/cards';
const { hasWhere } = require('/models/lib/mongoSelectorSafety');
const { boardCardScope } = require('/models/lib/boardCardScope');
const {
effectiveBoardCardsMode,
DEFAULT_LAZY_THRESHOLD,
@@ -48,8 +49,22 @@ publishComposite('boardCardsWindow', function(boardId, cardSelector, sort, limit
const safe = hasWhere(cardSelector) ? { _id: { $in: [] } } : cardSelector;
const sortOpt = sort || { sort: 1 };
// The window's card selector, ANDed with the board scope.
const windowSel = board => ({ $and: [safe, { boardId: board._id, archived: false }] });
// The window's card selector, scoped to the board. Merge the board scope with the
// client selector at the TOP level (rather than wrapping both in a `$and`) so
// `boardId`/`archived` push down to FerretDB v1 (SQLite)'s index: FerretDB does NOT
// push down a top-level `$and`, so the wrapped form full-scanned the whole `cards`
// table on every poll and the window never became ready — cards never loaded on a
// big (lazy) board (10.22). Merging is EXACTLY equivalent to the `$and` as long as
// the client selector has no own `boardId`/`archived` key (it does not — it is a
// per-list listId + swimlane selector); if it ever did, fall back to `$and` so the
// semantics stay correct. The in-Go filter remains the authority either way.
const safeCollides =
Object.prototype.hasOwnProperty.call(safe, 'boardId') ||
Object.prototype.hasOwnProperty.call(safe, 'archived');
const windowSel = board =>
safeCollides
? { $and: [safe, { boardId: board._id, archived: false }] }
: { ...safe, boardId: board._id, archived: false };
// The ids of the cards in this window. Used to publish the window's comments,
// attachments, checklists and checklist items with ONE cursor each
@@ -179,7 +194,7 @@ Meteor.publish('boardCardsLoadingMode', async function(boardId) {
const t = Number(Meteor.settings.public && Meteor.settings.public.cardsLoadingLazyThreshold);
const threshold = Number.isFinite(t) && t >= 0 ? t : DEFAULT_LAZY_THRESHOLD;
const count = await Cards.find(
{ boardId: { $in: [board._id, board.subtasksDefaultBoardId] }, archived: false },
{ ...boardCardScope(board), archived: false },
).countAsync();
lazy = effectiveBoardCardsMode('auto', count, threshold) === 'lazy';
}
+66
View File
@@ -0,0 +1,66 @@
'use strict';
// Tests for models/lib/boardCardScope.js and its use in the card publications.
//
// 10.22: boards loaded their lists/columns but never their CARDS on FerretDB v1
// (SQLite). Root cause: card queries used `{ boardId: { $in: [board._id,
// board.subtasksDefaultBoardId] } }`, and subtasksDefaultBoardId defaults to null;
// a $in that contains null does NOT push down on FerretDB, so the whole cards table
// was full-scanned + sjson-decoded on every poll while lists (plain equality) stayed
// index-backed. boardCardScope() builds the scope WITHOUT null and prefers equality.
// Run: node tests/boardCardScope.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { boardScopeIds, boardCardScope } = require('../models/lib/boardCardScope');
let passed = 0;
function check(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
check('a board with no subtasks-default board -> plain equality (index-backed)', () => {
assert.deepStrictEqual(boardScopeIds({ _id: 'B' }), ['B']);
assert.deepStrictEqual(boardCardScope({ _id: 'B' }), { boardId: 'B' });
});
check('null / undefined / "" subtasksDefaultBoardId is NEVER put in the $in', () => {
assert.deepStrictEqual(boardCardScope({ _id: 'B', subtasksDefaultBoardId: null }), { boardId: 'B' });
assert.deepStrictEqual(boardCardScope({ _id: 'B', subtasksDefaultBoardId: '' }), { boardId: 'B' });
assert.deepStrictEqual(boardCardScope({ _id: 'B' }), { boardId: 'B' });
});
check('a real subtasks-default board -> an all-string $in (still pushes down)', () => {
assert.deepStrictEqual(boardScopeIds({ _id: 'B', subtasksDefaultBoardId: 'S' }), ['B', 'S']);
assert.deepStrictEqual(boardCardScope({ _id: 'B', subtasksDefaultBoardId: 'S' }),
{ boardId: { $in: ['B', 'S'] } });
});
check('tolerates a missing/invalid board', () => {
assert.deepStrictEqual(boardScopeIds(undefined), []);
assert.deepStrictEqual(boardScopeIds({}), []);
assert.deepStrictEqual(boardCardScope({ _id: 'B', subtasksDefaultBoardId: 5 }), { boardId: 'B' });
});
// ── source guards: no card query still uses the null-containing $in ────────────
check('board/card publications use boardCardScope, not the null-in-$in literal', () => {
for (const rel of [
'server/publications/boards.js',
'server/publications/cardsWindow.js',
'server/publications/cards.js',
]) {
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
assert.ok(!/boardId: \{ \$in: \[board\._id, board\.subtasksDefaultBoardId\] \}/.test(src),
`${rel} must not use the null-containing $in card scope`);
assert.ok(/boardCardScope\(board\)/.test(src), `${rel} must use boardCardScope`);
}
});
check('the lazy window scopes the board at TOP level (not inside a $and)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'publications', 'cardsWindow.js'), 'utf8');
// The default (non-colliding) path merges the board scope at the top level so it pushes down.
assert.ok(/\{ \.\.\.safe, boardId: board\._id, archived: false \}/.test(src),
'the window selector merges boardId/archived at the top level');
assert.ok(/safeCollides/.test(src), 'a collision guard keeps $and semantics when needed');
});
console.log(`\nboardCardScope: ${passed} checks passed`);