mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
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:
1 parent
f47b7cb78a
commit
a51afa5a1b
5 files changed
+133
-12
No files matched your search
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
Reference in new issue
Block a user