mirror of
https://github.com/facebook/rocksdb.git
synced 2026-11-06 02:52:35 -05:00
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0fe00d3db | ||
|
|
cbd61a3165 | ||
|
|
59633e36ed | ||
|
|
4707775ae9 | ||
|
|
46ce1a03a9 | ||
|
|
795f3bd61f | ||
|
|
330962bff6 | ||
|
|
e07ccc3528 | ||
|
|
224e849e8d | ||
|
|
c734b7cc60 | ||
|
|
60b34f30c4 | ||
|
|
02a2b3501d | ||
|
|
1dc4813d97 | ||
|
|
4af61efaf4 | ||
|
|
e0d549fbba | ||
|
|
c25dcbccba | ||
|
|
c99d5ec06e | ||
|
|
a4045b0a5f | ||
|
|
b874d2546e | ||
|
|
c1dbe0a6ac | ||
|
|
52d8574629 | ||
|
|
a12bd7d641 | ||
|
|
a69e9fdb7e | ||
|
|
fa8f4f1ef0 | ||
|
|
d534974711 | ||
|
|
cc8d9ea04d | ||
|
|
1fd2c202c7 | ||
|
|
c28b4f0e12 | ||
|
|
4e2fe35bbb | ||
|
|
b3b7668bf5 | ||
|
|
b12d3a6da6 | ||
|
|
8fb8fdd349 | ||
|
|
959fa315b1 | ||
|
|
f4fa2d2386 | ||
|
|
ebaa925006 | ||
|
|
3ae314446b | ||
|
|
6842ba8455 | ||
|
|
a2abe3cfd6 | ||
|
|
2e7cf42cda | ||
|
|
3cdf942192 | ||
|
|
d8fc727592 | ||
|
|
ff16ff23ed | ||
|
|
03e5a0b9a8 | ||
|
|
763401b595 | ||
|
|
c4941760c9 | ||
|
|
809ed26be5 | ||
|
|
13ea89f66b | ||
|
|
4c1ffedb1e | ||
|
|
7f89e5fb09 | ||
|
|
b374ba5968 |
No files matched your search
@@ -0,0 +1,27 @@
|
||||
// Shared markdown builder for AI review comment bodies.
|
||||
//
|
||||
// Usage:
|
||||
// const build = require('./build-ai-review-comment.js');
|
||||
// return build({ icon, headerTitle, triggerLine, responseBody, footerLines
|
||||
// });
|
||||
|
||||
module.exports = function buildAiReviewComment(
|
||||
{icon, headerTitle, triggerLine, responseBody, footerLines}) {
|
||||
return [
|
||||
`## ${icon} ${headerTitle}`,
|
||||
'',
|
||||
triggerLine,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
responseBody,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>ℹ️ About this response</summary>',
|
||||
'',
|
||||
...footerLines,
|
||||
'</details>',
|
||||
].join('\n');
|
||||
};
|
||||
@@ -7,11 +7,25 @@
|
||||
// Parameters:
|
||||
// executionFile - path to the JSON execution log from claude-code-base-action
|
||||
// conclusion - 'success' or 'failure' from the action output
|
||||
// meta - { trigger, headSha, reviewer, isQuery, isPartial }
|
||||
// meta - { trigger, autoMode, headSha, reviewer, isQuery, isPartial
|
||||
// }
|
||||
|
||||
const fs = require('fs');
|
||||
const buildComment = require('./build-ai-review-comment.js');
|
||||
|
||||
module.exports = function parseClaude({executionFile, conclusion, meta}) {
|
||||
function getTriggerLine() {
|
||||
if (meta.trigger !== 'auto') {
|
||||
return `*Requested by @${meta.reviewer}*`;
|
||||
}
|
||||
const shortSha = meta.headSha ? meta.headSha.substring(0, 7) : 'unknown';
|
||||
if (meta.autoMode === 'early') {
|
||||
return `*Auto-triggered after CI reached the early-review threshold — reviewing commit ${
|
||||
shortSha}*`;
|
||||
}
|
||||
return `*Auto-triggered after CI passed — reviewing commit ${shortSha}*`;
|
||||
}
|
||||
|
||||
let responseBody = '';
|
||||
|
||||
try {
|
||||
@@ -80,36 +94,25 @@ module.exports = function parseClaude({executionFile, conclusion, meta}) {
|
||||
const isPartial = !!meta.isPartial;
|
||||
const icon = isPartial ? '🟡' : (conclusion === 'success' ? '✅' : '⚠️');
|
||||
const headerTitle = meta.isQuery ? 'Claude Response' : 'Claude Code Review';
|
||||
const triggerLine = meta.trigger === 'auto' ?
|
||||
`*Auto-triggered after CI passed — reviewing commit ${
|
||||
meta.headSha.substring(0, 7)}*` :
|
||||
`*Requested by @${meta.reviewer}*`;
|
||||
const triggerLine = getTriggerLine();
|
||||
|
||||
return [
|
||||
`## ${icon} ${headerTitle}`,
|
||||
'',
|
||||
return buildComment({
|
||||
icon,
|
||||
headerTitle,
|
||||
triggerLine,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
responseBody,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>ℹ️ About this response</summary>',
|
||||
'',
|
||||
'Generated by [Claude Code](https://github.com/anthropics/claude-code-action).',
|
||||
'Review methodology: `claude_md/code_review.md`',
|
||||
'',
|
||||
'**Limitations:**',
|
||||
'- Claude may miss context from files not in the diff',
|
||||
'- Large PRs may be truncated',
|
||||
'- Always apply human judgment to AI suggestions',
|
||||
'',
|
||||
'**Commands:**',
|
||||
'- `/claude-review [context]` — Request a code review',
|
||||
'- `/claude-query <question>` — Ask about the PR or codebase',
|
||||
'</details>',
|
||||
].join('\n');
|
||||
footerLines: [
|
||||
'Generated by [Claude Code](https://github.com/anthropics/claude-code-action).',
|
||||
'Review methodology: `claude_md/code_review.md`',
|
||||
'',
|
||||
'**Limitations:**',
|
||||
'- Claude may miss context from files not in the diff',
|
||||
'- Large PRs may be truncated',
|
||||
'- Always apply human judgment to AI suggestions',
|
||||
'',
|
||||
'**Commands:**',
|
||||
'- `/claude-review [context]` — Request a code review',
|
||||
'- `/claude-query <question>` — Ask about the PR or codebase',
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
// Parse Codex review artifacts and produce a markdown review comment.
|
||||
//
|
||||
// Usage from actions/github-script:
|
||||
// const parse = require('./.github/scripts/parse-codex-review.js');
|
||||
// const markdown = parse({ responseFile, recoveryFile, findingsFile, logFile,
|
||||
// exitCode, meta });
|
||||
//
|
||||
// Parameters:
|
||||
// responseFile - path to Codex final response output
|
||||
// recoveryFile - path to recovery output formatted from review-findings.md
|
||||
// findingsFile - path to incremental findings file written during review
|
||||
// logFile - path to Codex stdout/stderr log
|
||||
// exitCode - Codex process exit code
|
||||
// meta - { trigger, autoMode, headSha, reviewer, isQuery, isPartial }
|
||||
|
||||
const fs = require('fs');
|
||||
const buildComment = require('./build-ai-review-comment.js');
|
||||
|
||||
module.exports = function parseCodex(
|
||||
{responseFile, recoveryFile, findingsFile, logFile, exitCode, meta}) {
|
||||
function getTriggerLine() {
|
||||
if (meta.trigger !== 'auto') {
|
||||
return `*Requested by @${meta.reviewer}*`;
|
||||
}
|
||||
const shortSha = meta.headSha ? meta.headSha.substring(0, 7) : 'unknown';
|
||||
if (meta.autoMode === 'early') {
|
||||
return `*Auto-triggered after CI reached the early-review threshold — reviewing commit ${
|
||||
shortSha}*`;
|
||||
}
|
||||
return `*Auto-triggered after CI passed — reviewing commit ${shortSha}*`;
|
||||
}
|
||||
|
||||
function readIfPresent(path) {
|
||||
if (!path || !fs.existsSync(path)) {
|
||||
return '';
|
||||
}
|
||||
const text = fs.readFileSync(path, 'utf8').trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
function tailFile(path, maxChars = 12000) {
|
||||
const text = readIfPresent(path);
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
if (text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(text.length - maxChars);
|
||||
}
|
||||
|
||||
let responseBody = '';
|
||||
const recovered = readIfPresent(recoveryFile);
|
||||
const direct = readIfPresent(responseFile);
|
||||
const findings = readIfPresent(findingsFile);
|
||||
|
||||
if (recovered) {
|
||||
responseBody = recovered;
|
||||
} else if (direct) {
|
||||
responseBody = direct;
|
||||
} else if (findings) {
|
||||
responseBody =
|
||||
'⚠️ **Review incomplete — Codex did not produce a final response.**\n\n' +
|
||||
'Below are the incremental findings recovered from `review-findings.md`.\n\n---\n\n' +
|
||||
findings;
|
||||
} else {
|
||||
const logTail = tailFile(logFile);
|
||||
responseBody = logTail ?
|
||||
`❌ **Codex review failed before producing findings.**\n\n\`\`\`\n${
|
||||
logTail}\n\`\`\`` :
|
||||
'❌ Codex review failed before producing any output.';
|
||||
}
|
||||
|
||||
const isPartial = !!meta.isPartial;
|
||||
const code = Number.parseInt(exitCode || '1', 10);
|
||||
const icon = isPartial ? '🟡' : (code === 0 ? '✅' : '⚠️');
|
||||
const triggerLine = getTriggerLine();
|
||||
|
||||
return buildComment({
|
||||
icon,
|
||||
headerTitle: meta.isQuery ? 'Codex Response' : 'Codex Code Review',
|
||||
triggerLine,
|
||||
responseBody,
|
||||
footerLines: [
|
||||
'Generated by Codex CLI.',
|
||||
'Review methodology: `claude_md/code_review.md`',
|
||||
'',
|
||||
'**Limitations:**',
|
||||
'- Codex may miss context from files not in the diff',
|
||||
'- Large PRs may be truncated',
|
||||
'- Always apply human judgment to AI suggestions',
|
||||
'',
|
||||
'**Commands:**',
|
||||
'- `/codex-review [context]` — Request a code review',
|
||||
'- `/codex-query <question>` — Ask about the PR or codebase',
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -14,9 +14,31 @@
|
||||
// marker - HTML comment marker for dedup (e.g. '<!-- claude-review -->')
|
||||
// If an existing comment with this marker is found, it is updated.
|
||||
// If not found, a new comment is created.
|
||||
// legacyMarkers - optional list of legacy markers that should be migrated by
|
||||
// being considered part of the same comment family.
|
||||
// prunePrefix - optional marker prefix whose older comments should be
|
||||
// superseded. If obsoleteTitle is set, older comments are
|
||||
// collapsed instead of deleted.
|
||||
// preserveLatest - optional count of active comments to keep when
|
||||
// prunePrefix is set.
|
||||
// obsoleteMarker - optional HTML marker used to detect already-obsolete
|
||||
// comments.
|
||||
// obsoleteTitle - optional heading to use when collapsing superseded
|
||||
// comments into a details block.
|
||||
|
||||
module.exports = async function postPrComment(
|
||||
{github, context, core, prNumber, body, marker}) {
|
||||
module.exports = async function postPrComment({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber,
|
||||
body,
|
||||
marker,
|
||||
legacyMarkers = [],
|
||||
prunePrefix = '',
|
||||
preserveLatest = 0,
|
||||
obsoleteMarker = '',
|
||||
obsoleteTitle = '',
|
||||
}) {
|
||||
if (!prNumber || !body) {
|
||||
core.warning('Missing prNumber or body; skipping comment.');
|
||||
return;
|
||||
@@ -28,30 +50,155 @@ module.exports = async function postPrComment(
|
||||
// Ensure marker is embedded in the body
|
||||
const markedBody = body.includes(marker) ? body : `${marker}\n${body}`;
|
||||
|
||||
// Search for existing comment with this marker
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
async function listComments() {
|
||||
return await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: markedBody,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
core.info(`Updated existing comment ${existing.id}`);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
}
|
||||
|
||||
function commentActivityTime(comment) {
|
||||
const timestamp =
|
||||
Date.parse(comment.updated_at || comment.created_at || '');
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function commentSortDescending(left, right) {
|
||||
const timeDelta = commentActivityTime(right) - commentActivityTime(left);
|
||||
if (timeDelta !== 0) {
|
||||
return timeDelta;
|
||||
}
|
||||
return Number(right.id || 0) - Number(left.id || 0);
|
||||
}
|
||||
|
||||
function isObsoleteComment(comment) {
|
||||
return !!obsoleteMarker && typeof comment.body === 'string' &&
|
||||
comment.body.includes(obsoleteMarker);
|
||||
}
|
||||
|
||||
function buildObsoleteBody(comment) {
|
||||
const originalBody =
|
||||
typeof comment.body === 'string' && comment.body.trim() ?
|
||||
comment.body :
|
||||
'*No original review body preserved.*';
|
||||
const title = obsoleteTitle || 'AI Review - OBSOLETE';
|
||||
return `${obsoleteMarker}\n## ${
|
||||
title}\n\n<details>\n<summary>Superseded by a newer AI review. Expand to see the original review.</summary>\n\n${
|
||||
originalBody}\n\n</details>`;
|
||||
}
|
||||
|
||||
async function deleteCommentIfPresent(comment, reason) {
|
||||
try {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id,
|
||||
});
|
||||
core.info(`${reason} ${comment.id}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`Comment ${comment.id} was already deleted by another run.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function supersedeCommentIfPresent(comment, reason) {
|
||||
if (!obsoleteTitle) {
|
||||
await deleteCommentIfPresent(comment, reason);
|
||||
return;
|
||||
}
|
||||
if (isObsoleteComment(comment)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id,
|
||||
body: buildObsoleteBody(comment),
|
||||
});
|
||||
core.info(`${reason} ${comment.id}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info(`Comment ${comment.id} was already deleted by another run.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let comments = await listComments();
|
||||
const existing = comments.find(
|
||||
comment =>
|
||||
typeof comment.body === 'string' && comment.body.includes(marker));
|
||||
let currentCommentId = null;
|
||||
|
||||
if (existing) {
|
||||
try {
|
||||
const response = await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: markedBody,
|
||||
});
|
||||
currentCommentId = existing.id;
|
||||
core.info(`Updated existing comment ${existing.id}`);
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
core.info(
|
||||
`Comment ${existing.id} disappeared before update; ` +
|
||||
'creating a fresh comment instead.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentCommentId) {
|
||||
const response = await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: markedBody,
|
||||
});
|
||||
currentCommentId = response.data.id;
|
||||
core.info('Created new PR comment');
|
||||
}
|
||||
|
||||
if (prunePrefix || legacyMarkers.length > 0) {
|
||||
comments = await listComments();
|
||||
const relatedComments = comments.filter(
|
||||
comment => comment.id !== currentCommentId &&
|
||||
typeof comment.body === 'string' &&
|
||||
((prunePrefix && comment.body.includes(prunePrefix)) ||
|
||||
legacyMarkers.some(
|
||||
legacyMarker => comment.body.includes(legacyMarker))));
|
||||
const activeRelatedComments =
|
||||
comments
|
||||
.filter(
|
||||
comment => typeof comment.body === 'string' &&
|
||||
!isObsoleteComment(comment) &&
|
||||
(comment.id === currentCommentId ||
|
||||
relatedComments.some(
|
||||
relatedComment => relatedComment.id === comment.id)))
|
||||
.sort(commentSortDescending);
|
||||
|
||||
const keep =
|
||||
new Set(activeRelatedComments.slice(0, Math.max(preserveLatest, 1))
|
||||
.map(comment => comment.id));
|
||||
|
||||
const supersedeCandidates = obsoleteTitle ?
|
||||
activeRelatedComments.filter(comment => !keep.has(comment.id)) :
|
||||
relatedComments.filter(comment => !keep.has(comment.id));
|
||||
|
||||
for (const comment of supersedeCandidates) {
|
||||
if (keep.has(comment.id)) {
|
||||
continue;
|
||||
}
|
||||
await supersedeCommentIfPresent(comment, 'Superseded old AI review');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const postPrComment = require('./post-pr-comment.js');
|
||||
|
||||
const OBSOLETE_MARKER = '<!-- claude-review-obsolete -->';
|
||||
const OBSOLETE_TITLE = 'Claude Code Review - OBSOLETE';
|
||||
|
||||
function makeComment(id, body, createdAt, updatedAt) {
|
||||
return {
|
||||
id,
|
||||
body,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt || createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(initialComments, options = {}) {
|
||||
let comments = initialComments.map(comment => ({...comment}));
|
||||
let nextCommentId = options.nextCommentId || 1000;
|
||||
let paginateCount = 0;
|
||||
|
||||
const calls = {
|
||||
update: [],
|
||||
create: [],
|
||||
delete: [],
|
||||
};
|
||||
|
||||
const github = {
|
||||
paginate: async () => {
|
||||
paginateCount++;
|
||||
if (options.onPaginate) {
|
||||
const updated = options.onPaginate({
|
||||
paginateCount,
|
||||
comments: comments.map(comment => ({...comment})),
|
||||
});
|
||||
if (updated) {
|
||||
comments = updated.map(comment => ({...comment}));
|
||||
}
|
||||
}
|
||||
return comments.map(comment => ({...comment}));
|
||||
},
|
||||
rest: {
|
||||
issues: {
|
||||
listComments: () => {
|
||||
throw new Error('listComments should only be used through paginate');
|
||||
},
|
||||
updateComment: async ({comment_id, body}) => {
|
||||
calls.update.push({comment_id, body});
|
||||
const error =
|
||||
options.updateErrors && options.updateErrors[comment_id];
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
const index =
|
||||
comments.findIndex(comment => comment.id === comment_id);
|
||||
if (index === -1) {
|
||||
const notFound = new Error(`Comment ${comment_id} not found`);
|
||||
notFound.status = 404;
|
||||
throw notFound;
|
||||
}
|
||||
const updated = {
|
||||
...comments[index],
|
||||
body,
|
||||
updated_at: options.updateTimestamp || '2026-04-24T00:00:00Z',
|
||||
};
|
||||
comments[index] = updated;
|
||||
return {data: {...updated}};
|
||||
},
|
||||
createComment: async ({issue_number, body}) => {
|
||||
calls.create.push({issue_number, body});
|
||||
const created = makeComment(
|
||||
nextCommentId++, body,
|
||||
options.createTimestamp || '2026-04-24T00:00:00Z');
|
||||
comments.push(created);
|
||||
return {data: {id: created.id}};
|
||||
},
|
||||
deleteComment: async ({comment_id}) => {
|
||||
calls.delete.push({comment_id});
|
||||
const error =
|
||||
options.deleteErrors && options.deleteErrors[comment_id];
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
comments = comments.filter(comment => comment.id !== comment_id);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
github,
|
||||
calls,
|
||||
getComments: () => comments.map(comment => ({...comment})),
|
||||
};
|
||||
}
|
||||
|
||||
function createCore() {
|
||||
return {
|
||||
info: () => {},
|
||||
warning: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function assertObsoleteComment(comment, originalBody) {
|
||||
assert.ok(comment);
|
||||
assert.match(comment.body, new RegExp(escapeRegExp(OBSOLETE_MARKER)));
|
||||
assert.match(comment.body, new RegExp(`## ${escapeRegExp(OBSOLETE_TITLE)}`));
|
||||
assert.match(comment.body, /<details>/);
|
||||
assert.match(comment.body, /Superseded by a newer AI review/);
|
||||
assert.match(comment.body, new RegExp(escapeRegExp(originalBody)));
|
||||
}
|
||||
|
||||
const context = {
|
||||
repo: {
|
||||
owner: 'facebook',
|
||||
repo: 'rocksdb',
|
||||
},
|
||||
};
|
||||
|
||||
test(
|
||||
'creates a fresh review comment and supersedes legacy comments',
|
||||
async () => {
|
||||
const harness = createHarness(
|
||||
[
|
||||
makeComment(
|
||||
1, '<!-- claude-review-auto -->\nold legacy', 'not-a-date'),
|
||||
makeComment(
|
||||
2, '<!-- claude-review-auto -->\nnew legacy',
|
||||
'2026-04-21T00:00:00Z'),
|
||||
],
|
||||
{
|
||||
updateTimestamp: '2026-04-24T00:00:00Z',
|
||||
});
|
||||
|
||||
await postPrComment({
|
||||
github: harness.github,
|
||||
context,
|
||||
core: createCore(),
|
||||
prNumber: 14659,
|
||||
body: 'review body',
|
||||
marker: '<!-- claude-review-auto-run-500 -->',
|
||||
legacyMarkers: ['<!-- claude-review-auto -->'],
|
||||
prunePrefix: '<!-- claude-review-auto-',
|
||||
preserveLatest: 1,
|
||||
obsoleteMarker: OBSOLETE_MARKER,
|
||||
obsoleteTitle: OBSOLETE_TITLE,
|
||||
});
|
||||
|
||||
assert.equal(harness.calls.create.length, 1);
|
||||
assert.deepEqual(
|
||||
harness.calls.update.map(call => call.comment_id), [2, 1]);
|
||||
assert.equal(harness.calls.delete.length, 0);
|
||||
|
||||
const comments = harness.getComments();
|
||||
assert.equal(comments.length, 3);
|
||||
assert.match(
|
||||
comments.find(comment => comment.id === 1000).body,
|
||||
/<!-- claude-review-auto-run-500 -->/);
|
||||
assertObsoleteComment(
|
||||
comments.find(comment => comment.id === 2),
|
||||
'<!-- claude-review-auto -->\nnew legacy');
|
||||
assertObsoleteComment(
|
||||
comments.find(comment => comment.id === 1),
|
||||
'<!-- claude-review-auto -->\nold legacy');
|
||||
});
|
||||
|
||||
test(
|
||||
'updates an exact-match comment and supersedes leftover legacy comments',
|
||||
async () => {
|
||||
const harness = createHarness(
|
||||
[
|
||||
makeComment(
|
||||
3, '<!-- claude-review-auto-abcdef0 -->\ncurrent body',
|
||||
'2026-04-24T00:00:00Z'),
|
||||
makeComment(
|
||||
4, '<!-- claude-review-auto -->\nlegacy body',
|
||||
'2026-04-20T00:00:00Z'),
|
||||
],
|
||||
{
|
||||
updateTimestamp: '2026-04-24T01:00:00Z',
|
||||
});
|
||||
|
||||
await postPrComment({
|
||||
github: harness.github,
|
||||
context,
|
||||
core: createCore(),
|
||||
prNumber: 14659,
|
||||
body: 'refreshed body',
|
||||
marker: '<!-- claude-review-auto-abcdef0 -->',
|
||||
legacyMarkers: ['<!-- claude-review-auto -->'],
|
||||
prunePrefix: '<!-- claude-review-auto-',
|
||||
preserveLatest: 1,
|
||||
obsoleteMarker: OBSOLETE_MARKER,
|
||||
obsoleteTitle: OBSOLETE_TITLE,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
harness.calls.update.map(call => call.comment_id), [3, 4]);
|
||||
assert.equal(harness.calls.create.length, 0);
|
||||
assert.equal(harness.calls.delete.length, 0);
|
||||
|
||||
const comments = harness.getComments();
|
||||
assert.match(
|
||||
comments.find(comment => comment.id === 3).body,
|
||||
/<!-- claude-review-auto-abcdef0 -->\nrefreshed body/);
|
||||
assertObsoleteComment(
|
||||
comments.find(comment => comment.id === 4),
|
||||
'<!-- claude-review-auto -->\nlegacy body');
|
||||
});
|
||||
|
||||
test(
|
||||
'creates a new comment if the target disappears before update',
|
||||
async () => {
|
||||
const missing = new Error('gone');
|
||||
missing.status = 404;
|
||||
const harness = createHarness(
|
||||
[
|
||||
makeComment(
|
||||
10, '<!-- claude-review-auto-abcdef0 -->\nold body',
|
||||
'2026-04-20T00:00:00Z'),
|
||||
],
|
||||
{
|
||||
updateErrors: {
|
||||
10: missing,
|
||||
},
|
||||
});
|
||||
|
||||
await postPrComment({
|
||||
github: harness.github,
|
||||
context,
|
||||
core: createCore(),
|
||||
prNumber: 14659,
|
||||
body: 'replacement body',
|
||||
marker: '<!-- claude-review-auto-abcdef0 -->',
|
||||
});
|
||||
|
||||
assert.equal(harness.calls.update.length, 1);
|
||||
assert.equal(harness.calls.create.length, 1);
|
||||
assert.match(
|
||||
harness.calls.create[0].body, /<!-- claude-review-auto-abcdef0 -->/);
|
||||
});
|
||||
|
||||
test(
|
||||
'ignores 404 when superseding a comment already deleted by another run',
|
||||
async () => {
|
||||
const missing = new Error('gone');
|
||||
missing.status = 404;
|
||||
const harness = createHarness(
|
||||
[
|
||||
makeComment(
|
||||
20, '<!-- claude-review-auto-oldest -->\noldest',
|
||||
'2026-04-20T00:00:00Z'),
|
||||
makeComment(
|
||||
21, '<!-- claude-review-auto-newer -->\nnewer',
|
||||
'2026-04-21T00:00:00Z'),
|
||||
],
|
||||
{
|
||||
nextCommentId: 30,
|
||||
createTimestamp: '2026-04-24T00:00:00Z',
|
||||
deleteErrors: {
|
||||
20: missing,
|
||||
},
|
||||
});
|
||||
|
||||
await postPrComment({
|
||||
github: harness.github,
|
||||
context,
|
||||
core: createCore(),
|
||||
prNumber: 14659,
|
||||
body: 'fresh body',
|
||||
marker: '<!-- claude-review-auto-latest -->',
|
||||
prunePrefix: '<!-- claude-review-auto-',
|
||||
preserveLatest: 1,
|
||||
obsoleteMarker: OBSOLETE_MARKER,
|
||||
obsoleteTitle: OBSOLETE_TITLE,
|
||||
});
|
||||
|
||||
assert.equal(harness.calls.create.length, 1);
|
||||
assert.equal(harness.calls.delete.length, 0);
|
||||
assert.deepEqual(
|
||||
harness.calls.update.map(call => call.comment_id), [21, 20]);
|
||||
|
||||
const comments = harness.getComments();
|
||||
assertObsoleteComment(
|
||||
comments.find(comment => comment.id === 21),
|
||||
'<!-- claude-review-auto-newer -->\nnewer');
|
||||
});
|
||||
|
||||
test(
|
||||
'supersedes the current review when a newer concurrent one appears',
|
||||
async () => {
|
||||
const harness = createHarness(
|
||||
[
|
||||
makeComment(
|
||||
40, '<!-- claude-review-auto-current -->\ncurrent',
|
||||
'2026-04-20T00:00:00Z'),
|
||||
makeComment(
|
||||
41, '<!-- claude-review-auto-older -->\nolder',
|
||||
'2026-04-19T00:00:00Z'),
|
||||
],
|
||||
{
|
||||
updateTimestamp: '2026-04-24T00:00:00Z',
|
||||
onPaginate: ({paginateCount, comments}) => {
|
||||
if (paginateCount !== 2) {
|
||||
return comments;
|
||||
}
|
||||
return comments.concat([
|
||||
makeComment(
|
||||
42, '<!-- claude-review-auto-newer -->\nnewer',
|
||||
'2026-04-25T00:00:00Z'),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
await postPrComment({
|
||||
github: harness.github,
|
||||
context,
|
||||
core: createCore(),
|
||||
prNumber: 14659,
|
||||
body: 'updated current body',
|
||||
marker: '<!-- claude-review-auto-current -->',
|
||||
prunePrefix: '<!-- claude-review-auto-',
|
||||
preserveLatest: 1,
|
||||
obsoleteMarker: OBSOLETE_MARKER,
|
||||
obsoleteTitle: OBSOLETE_TITLE,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
harness.calls.update.map(call => call.comment_id), [40, 40, 41]);
|
||||
assert.equal(harness.calls.delete.length, 0);
|
||||
|
||||
const comments = harness.getComments();
|
||||
assert.match(
|
||||
comments.find(comment => comment.id === 42).body,
|
||||
/<!-- claude-review-auto-newer -->\nnewer/);
|
||||
assertObsoleteComment(
|
||||
comments.find(comment => comment.id === 40),
|
||||
'<!-- claude-review-auto-current -->\nupdated current body');
|
||||
assertObsoleteComment(
|
||||
comments.find(comment => comment.id === 41),
|
||||
'<!-- claude-review-auto-older -->\nolder');
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,185 @@
|
||||
# Shared comment-posting workflow for AI reviews.
|
||||
|
||||
name: AI Review Comment
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
provider:
|
||||
description: Provider key, for example "claude" or "codex"
|
||||
required: true
|
||||
type: string
|
||||
result_artifact_name:
|
||||
description: Artifact name produced by the analysis workflow
|
||||
required: true
|
||||
type: string
|
||||
comment_file:
|
||||
description: Markdown comment file produced by the analysis workflow
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: true
|
||||
|
||||
- name: Download review artifact
|
||||
id: download
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.result_artifact_name }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.download.outcome == 'success'
|
||||
env:
|
||||
PROVIDER: ${{ inputs.provider }}
|
||||
COMMENT_FILE: ${{ inputs.comment_file }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(process.env.COMMENT_FILE) ||
|
||||
!fs.existsSync('pr_number.txt')) {
|
||||
core.info('No review results found; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const post = require('./.github/scripts/post-pr-comment.js');
|
||||
const provider = process.env.PROVIDER;
|
||||
const providerTitle = provider === 'codex' ? 'Codex' : 'Claude';
|
||||
const body = fs.readFileSync(process.env.COMMENT_FILE, 'utf8');
|
||||
const prNumber = parseInt(
|
||||
fs.readFileSync('pr_number.txt', 'utf8').trim(),
|
||||
10
|
||||
);
|
||||
const trigger = fs.existsSync('trigger_type.txt') ?
|
||||
fs.readFileSync('trigger_type.txt', 'utf8').trim() :
|
||||
'auto';
|
||||
const headSha = fs.existsSync('head_sha.txt') ?
|
||||
fs.readFileSync('head_sha.txt', 'utf8').trim() :
|
||||
'';
|
||||
const shortSha = headSha ? headSha.substring(0, 7) : '';
|
||||
const runId = process.env.RUN_ID;
|
||||
|
||||
let marker = '';
|
||||
let legacyMarkers = [];
|
||||
let prunePrefix = '';
|
||||
let preserveLatest = 0;
|
||||
let obsoleteMarker = '';
|
||||
let obsoleteTitle = '';
|
||||
|
||||
if (trigger === 'auto') {
|
||||
if (!shortSha) {
|
||||
core.warning(
|
||||
'Missing head_sha.txt for auto review; skipping comment.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
marker = `<!-- ${provider}-review-auto-run-${runId} -->`;
|
||||
legacyMarkers = [`<!-- ${provider}-review-auto -->`];
|
||||
prunePrefix = `<!-- ${provider}-review-auto-`;
|
||||
preserveLatest = 1;
|
||||
obsoleteMarker = `<!-- ${provider}-review-obsolete -->`;
|
||||
obsoleteTitle = `${providerTitle} Code Review - OBSOLETE`;
|
||||
} else {
|
||||
marker = `<!-- ${provider}-review-manual-run-${runId} -->`;
|
||||
}
|
||||
|
||||
await post({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber,
|
||||
body,
|
||||
marker,
|
||||
legacyMarkers,
|
||||
prunePrefix,
|
||||
preserveLatest,
|
||||
obsoleteMarker,
|
||||
obsoleteTitle,
|
||||
});
|
||||
|
||||
- name: Add reaction to trigger comment
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync('trigger_type.txt')) return;
|
||||
const trigger = fs.readFileSync('trigger_type.txt', 'utf8').trim();
|
||||
if (trigger !== 'manual') return;
|
||||
if (!fs.existsSync('comment_id.txt')) return;
|
||||
const commentId = parseInt(
|
||||
fs.readFileSync('comment_id.txt', 'utf8').trim(),
|
||||
10
|
||||
);
|
||||
if (!commentId) return;
|
||||
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: commentId,
|
||||
content: 'rocket'
|
||||
});
|
||||
|
||||
failure-notice:
|
||||
if: github.event.workflow_run.conclusion == 'failure'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifact for PR number
|
||||
id: download
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.result_artifact_name }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: React with failure
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync('comment_id.txt')) return;
|
||||
const commentId = parseInt(
|
||||
fs.readFileSync('comment_id.txt', 'utf8').trim(),
|
||||
10
|
||||
);
|
||||
if (!commentId) return;
|
||||
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: commentId,
|
||||
content: 'confused'
|
||||
});
|
||||
|
||||
unauthorized-notice:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'issue_comment' &&
|
||||
github.event.workflow_run.conclusion == 'skipped'
|
||||
steps:
|
||||
- name: Log skipped unauthorized request
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
core.info(
|
||||
'Analysis workflow was skipped, which usually means the ' +
|
||||
'issue_comment requester was not in the authorized list.'
|
||||
);
|
||||
@@ -1,12 +1,8 @@
|
||||
# Claude Code Review — Comment Posting Workflow
|
||||
#
|
||||
# Companion to claude-review.yml. Downloads the review artifact and posts
|
||||
# it as a PR comment. This workflow has write permissions but never runs
|
||||
# AI or processes untrusted code.
|
||||
#
|
||||
# Security: Separating analysis (has ANTHROPIC_API_KEY, no write perms)
|
||||
# from posting (has GITHUB_TOKEN write perms, no AI) prevents a crafted
|
||||
# PR from tricking Claude into exfiltrating tokens.
|
||||
# Thin wrapper around .github/workflows/ai-review-comment.yml so the provider
|
||||
# specific workflow name stays stable while the shared implementation lives in
|
||||
# one reusable workflow.
|
||||
|
||||
name: Post Claude Review Comment
|
||||
|
||||
@@ -20,127 +16,10 @@ permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
sparse-checkout-cone-mode: true
|
||||
|
||||
- name: Download review artifact
|
||||
id: download
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: claude-review-result
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync('claude-review-comment.md') || !fs.existsSync('pr_number.txt')) {
|
||||
core.info('No review results found; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = fs.readFileSync('claude-review-comment.md', 'utf8');
|
||||
const prNumber = parseInt(fs.readFileSync('pr_number.txt', 'utf8').trim());
|
||||
const trigger = fs.existsSync('trigger_type.txt')
|
||||
? fs.readFileSync('trigger_type.txt', 'utf8').trim()
|
||||
: 'auto';
|
||||
|
||||
// Use different markers for auto vs manual so they don't
|
||||
// overwrite each other. Auto-reviews update in place (one
|
||||
// per PR). Manual reviews always create new comments.
|
||||
const marker = trigger === 'auto'
|
||||
? '<!-- claude-review-auto -->'
|
||||
: `<!-- claude-review-manual-${Date.now()} -->`;
|
||||
|
||||
const post = require('./.github/scripts/post-pr-comment.js');
|
||||
|
||||
await post({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
prNumber,
|
||||
body,
|
||||
marker,
|
||||
});
|
||||
|
||||
- name: Add reaction to trigger comment
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync('trigger_type.txt')) return;
|
||||
const trigger = fs.readFileSync('trigger_type.txt', 'utf8').trim();
|
||||
if (trigger !== 'manual') return;
|
||||
if (!fs.existsSync('comment_id.txt')) return;
|
||||
const commentId = parseInt(fs.readFileSync('comment_id.txt', 'utf8').trim());
|
||||
if (!commentId) return;
|
||||
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: commentId,
|
||||
content: 'rocket'
|
||||
});
|
||||
|
||||
# Notify on failure (for manual triggers)
|
||||
failure-notice:
|
||||
if: github.event.workflow_run.conclusion == 'failure'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifact for PR number
|
||||
id: download
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: claude-review-result
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: React with failure
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync('comment_id.txt')) return;
|
||||
const commentId = parseInt(fs.readFileSync('comment_id.txt', 'utf8').trim());
|
||||
if (!commentId) return;
|
||||
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: commentId,
|
||||
content: 'confused'
|
||||
});
|
||||
|
||||
# Unauthorized user notice
|
||||
unauthorized-notice:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'issue_comment' &&
|
||||
github.event.workflow_run.conclusion == 'skipped'
|
||||
|
||||
steps:
|
||||
- name: Check if unauthorized
|
||||
id: check
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// This job only runs when the analysis workflow was skipped,
|
||||
// which happens when the user is not in the authorized list.
|
||||
// We cannot easily get the original comment from workflow_run,
|
||||
// so this is a best-effort notice via workflow logs.
|
||||
core.info('Analysis workflow was skipped — likely unauthorized user.');
|
||||
review-comment:
|
||||
uses: ./.github/workflows/ai-review-comment.yml
|
||||
with:
|
||||
provider: claude
|
||||
result_artifact_name: claude-review-result
|
||||
comment_file: claude-review-comment.md
|
||||
secrets: inherit
|
||||
@@ -1,20 +1,8 @@
|
||||
# Claude Code Review — Analysis Workflow
|
||||
#
|
||||
# This workflow runs Claude to review PRs. It produces a markdown review
|
||||
# as an artifact but does NOT post comments (no write permissions).
|
||||
# The companion workflow (claude-review-comment.yml) handles posting.
|
||||
#
|
||||
# Triggers:
|
||||
# 1. workflow_run: Auto-review after pr-jobs passes
|
||||
# 2. issue_comment: Manual /claude-review or /claude-query by maintainers
|
||||
# 3. workflow_dispatch: Manual testing
|
||||
#
|
||||
# Security:
|
||||
# - This job has contents:read ONLY (no PR write, no issue write)
|
||||
# - ANTHROPIC_API_KEY is used here but never coexists with write tokens
|
||||
# - Claude uses read-only tools (View, GlobTool, GrepTool) plus Write
|
||||
# (only for incremental findings file, not repo modifications)
|
||||
# - Review methodology: claude_md/code_review.md
|
||||
# Thin wrapper around .github/workflows/ai-review-analysis.yml so provider
|
||||
# specific triggers and workflow_dispatch inputs stay stable while the shared
|
||||
# implementation lives in one reusable workflow.
|
||||
|
||||
name: Claude Code Review
|
||||
|
||||
@@ -23,6 +11,11 @@ on:
|
||||
workflows: ["facebook/rocksdb/pr-jobs"]
|
||||
types: [completed]
|
||||
|
||||
# The early pull_request_target path is limited to same-repo PRs by the job
|
||||
# condition below. Fork PRs skip this path and rely on workflow_run instead.
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
@@ -33,560 +26,44 @@ on:
|
||||
required: true
|
||||
type: number
|
||||
model:
|
||||
description: Claude model to use
|
||||
description: Claude model to use (defaults to latest Opus)
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- claude-opus-4-6
|
||||
- claude-sonnet-4-20250514
|
||||
- claude-sonnet-4-6
|
||||
default: claude-opus-4-6
|
||||
thinking_budget:
|
||||
description: Override MAX_THINKING_TOKENS (blank = auto-classify, capped at 24000)
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# The shared analysis workflow polls check state, inspects prior runs, and
|
||||
# reads PR metadata. Keep those permissions read-only in the caller.
|
||||
actions: read
|
||||
checks: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# ======================== Auto Review ======================== #
|
||||
auto-review:
|
||||
name: Auto Claude Review
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
review:
|
||||
if: >-
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Get PR info
|
||||
id: pr_info
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
let prNumber = null;
|
||||
|
||||
const prs = context.payload.workflow_run.pull_requests;
|
||||
if (prs && prs.length > 0) {
|
||||
prNumber = prs[0].number;
|
||||
} else {
|
||||
// PR may not be registered yet if push and PR creation raced.
|
||||
// Retry up to 5 times with 10s delay.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
if (attempt > 0) {
|
||||
core.info(`PR not found for SHA ${headSha}, retrying in 10s (attempt ${attempt + 1}/5)...`);
|
||||
await new Promise(r => setTimeout(r, 10000));
|
||||
}
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100
|
||||
});
|
||||
const match = pulls.find(p => p.head.sha === headSha);
|
||||
if (match) {
|
||||
prNumber = match.number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
core.warning(
|
||||
`Could not find PR for SHA ${headSha} after retries. ` +
|
||||
`This can happen when the PR head advanced before this ` +
|
||||
`workflow_run-triggered job started. Skipping stale review run.`
|
||||
);
|
||||
core.setOutput('skip', 'true');
|
||||
core.setOutput('skip_reason', `stale_sha:${headSha}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('pr_number', prNumber.toString());
|
||||
core.setOutput('head_repo', pr.data.head.repo.clone_url);
|
||||
core.setOutput('head_ref', pr.data.head.ref);
|
||||
core.setOutput('head_sha', pr.data.head.sha);
|
||||
core.setOutput('base_sha', pr.data.base.sha);
|
||||
core.setOutput('base_ref', pr.data.base.ref);
|
||||
core.setOutput('pr_title', pr.data.title);
|
||||
core.setOutput('pr_body', pr.data.body || '');
|
||||
core.setOutput('pr_author', pr.data.user.login);
|
||||
|
||||
- name: Skip stale run
|
||||
if: steps.pr_info.outputs.skip == 'true'
|
||||
run: |-
|
||||
echo "Skipping stale auto-review run: ${{ steps.pr_info.outputs.skip_reason }}"
|
||||
|
||||
- name: Check for existing review
|
||||
if: steps.pr_info.outputs.skip != 'true'
|
||||
id: check_existing
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr_info.outputs.pr_number }}
|
||||
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
|
||||
with:
|
||||
script: |
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt(process.env.PR_NUMBER),
|
||||
per_page: 100
|
||||
});
|
||||
const shortSha = process.env.HEAD_SHA.substring(0, 7);
|
||||
const existing = comments.data.find(c =>
|
||||
c.body.includes('<!-- claude-review -->') &&
|
||||
c.body.includes(shortSha)
|
||||
);
|
||||
core.setOutput('skip', existing ? 'true' : 'false');
|
||||
|
||||
- name: Checkout base repository
|
||||
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.pr_info.outputs.base_ref }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate diff and prompt
|
||||
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
|
||||
env:
|
||||
HEAD_REPO: ${{ steps.pr_info.outputs.head_repo }}
|
||||
HEAD_REF: ${{ steps.pr_info.outputs.head_ref }}
|
||||
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
|
||||
BASE_SHA: ${{ steps.pr_info.outputs.base_sha }}
|
||||
PR_TITLE: ${{ steps.pr_info.outputs.pr_title }}
|
||||
PR_BODY: ${{ steps.pr_info.outputs.pr_body }}
|
||||
PR_AUTHOR: ${{ steps.pr_info.outputs.pr_author }}
|
||||
run: |
|
||||
git remote add fork "${HEAD_REPO}" || true
|
||||
git fetch fork "${HEAD_REF}" --depth=100
|
||||
|
||||
git diff "${BASE_SHA}...${HEAD_SHA}" > /tmp/pr.diff
|
||||
DIFF_STATS=$(git diff --stat "${BASE_SHA}...${HEAD_SHA}" | tail -1)
|
||||
|
||||
DIFF_SIZE=$(wc -c < /tmp/pr.diff)
|
||||
IS_TRUNCATED=false
|
||||
if [ "$DIFF_SIZE" -gt 100000 ]; then
|
||||
head -c 100000 /tmp/pr.diff > /tmp/pr_truncated.diff
|
||||
mv /tmp/pr_truncated.diff /tmp/pr.diff
|
||||
IS_TRUNCATED=true
|
||||
fi
|
||||
|
||||
cat claude_md/ci_review_prompt.md > /tmp/prompt.txt
|
||||
|
||||
cat >> /tmp/prompt.txt << PROMPT_EOF
|
||||
|
||||
## Pull Request Information
|
||||
- **Title:** ${PR_TITLE}
|
||||
- **Author:** ${PR_AUTHOR}
|
||||
- **Changes:** ${DIFF_STATS}
|
||||
PROMPT_EOF
|
||||
|
||||
if [ "${IS_TRUNCATED}" = "true" ]; then
|
||||
echo "- **Note:** Diff truncated due to size." >> /tmp/prompt.txt
|
||||
fi
|
||||
|
||||
printf '\n## PR Description\n' >> /tmp/prompt.txt
|
||||
printf '%s\n' "${PR_BODY}" >> /tmp/prompt.txt
|
||||
printf '\n---\n\n## Diff to Review\n\n' >> /tmp/prompt.txt
|
||||
cat /tmp/pr.diff >> /tmp/prompt.txt
|
||||
|
||||
- name: Run Claude
|
||||
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
|
||||
id: claude
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt_file: /tmp/prompt.txt
|
||||
model: claude-opus-4-6
|
||||
max_turns: "300"
|
||||
timeout_minutes: "60"
|
||||
allowed_tools: "View,GlobTool,GrepTool,Write,Task"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Recover partial review on turn limit
|
||||
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
|
||||
id: recover
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const log = JSON.parse(fs.readFileSync(process.env.EXECUTION_FILE, 'utf8'));
|
||||
const result = log.find(m => m.type === 'result');
|
||||
const hitLimit = result && result.subtype === 'error_max_turns';
|
||||
const hasFindings = fs.existsSync('review-findings.md');
|
||||
core.setOutput('hit_limit', hitLimit ? 'true' : 'false');
|
||||
core.setOutput('has_findings', hasFindings ? 'true' : 'false');
|
||||
core.setOutput('needs_recovery', (hitLimit && hasFindings) ? 'true' : 'false');
|
||||
|
||||
- name: Format partial findings
|
||||
if: >-
|
||||
steps.pr_info.outputs.skip != 'true' &&
|
||||
steps.check_existing.outputs.skip != 'true' &&
|
||||
steps.recover.outputs.needs_recovery == 'true'
|
||||
id: format_recovery
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt_file: claude_md/ci_recovery_prompt.md
|
||||
model: claude-sonnet-4-20250514
|
||||
max_turns: "10"
|
||||
allowed_tools: "View,GlobTool,GrepTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Build review comment
|
||||
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
|
||||
RECOVERY_FILE: ${{ steps.format_recovery.outputs.execution_file }}
|
||||
CONCLUSION: ${{ steps.claude.outputs.conclusion }}
|
||||
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
|
||||
NEEDS_RECOVERY: ${{ steps.recover.outputs.needs_recovery }}
|
||||
with:
|
||||
script: |
|
||||
const parse = require('./.github/scripts/parse-claude-review.js');
|
||||
const fs = require('fs');
|
||||
let execFile = process.env.EXECUTION_FILE;
|
||||
if (process.env.NEEDS_RECOVERY === 'true') {
|
||||
const rf = process.env.RECOVERY_FILE;
|
||||
if (rf && fs.existsSync(rf)) {
|
||||
execFile = rf;
|
||||
}
|
||||
}
|
||||
const body = parse({
|
||||
executionFile: execFile,
|
||||
conclusion: process.env.CONCLUSION,
|
||||
meta: {
|
||||
trigger: 'auto',
|
||||
headSha: process.env.HEAD_SHA,
|
||||
reviewer: '',
|
||||
isQuery: false,
|
||||
isPartial: process.env.NEEDS_RECOVERY === 'true',
|
||||
}
|
||||
});
|
||||
fs.writeFileSync('claude-review-comment.md', body);
|
||||
|
||||
- name: Save PR number
|
||||
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
|
||||
run: echo "${{ steps.pr_info.outputs.pr_number }}" > pr_number.txt
|
||||
|
||||
- name: Save trigger metadata
|
||||
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
|
||||
run: echo "auto" > trigger_type.txt
|
||||
|
||||
- name: Upload review artifact
|
||||
if: always() && steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-review-result
|
||||
path: |
|
||||
claude-review-comment.md
|
||||
pr_number.txt
|
||||
trigger_type.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload execution log
|
||||
if: always() && steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-review-execution-log
|
||||
path: ${{ steps.claude.outputs.execution_file }}
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload recovery execution log
|
||||
if: always() && steps.pr_info.outputs.skip != 'true' && steps.recover.outputs.needs_recovery == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-review-recovery-log
|
||||
path: ${{ steps.format_recovery.outputs.execution_file }}
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ======================== Manual Review ======================== #
|
||||
manual-review:
|
||||
name: Manual Claude Review
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(contains(github.event.comment.body, '/claude-review') || contains(github.event.comment.body, '/claude-query')) &&
|
||||
contains(fromJSON('["pdillinger", "jaykorean", "hx235", "dannyhchen", "joshkang97", "zaidoon1", "omkarhgawde", "xingbowang", "anand1976", "virajthakur", "nmk70", "archang19", "mszeszko-meta", "yoshinorim", "cbi42"]'), github.event.comment.user.login)
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Detect command type
|
||||
id: command
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
core.setOutput('type', 'review');
|
||||
core.setOutput('query', '');
|
||||
return;
|
||||
}
|
||||
const body = context.payload.comment.body;
|
||||
if (body.includes('/claude-review')) {
|
||||
core.setOutput('type', 'review');
|
||||
const match = body.match(/\/claude-review\s+([\s\S]*)/);
|
||||
core.setOutput('query', match ? match[1].trim() : '');
|
||||
} else if (body.includes('/claude-query')) {
|
||||
const match = body.match(/\/claude-query\s+([\s\S]*)/);
|
||||
const query = match ? match[1].trim() : '';
|
||||
if (!query) {
|
||||
core.setFailed('No query provided. Usage: /claude-query <your question>');
|
||||
return;
|
||||
}
|
||||
core.setOutput('type', 'query');
|
||||
core.setOutput('query', query);
|
||||
} else {
|
||||
core.setFailed('Unknown command');
|
||||
}
|
||||
|
||||
- name: Get PR details
|
||||
id: pr_info
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
? parseInt(process.env.INPUT_PR_NUMBER, 10)
|
||||
: context.issue.number;
|
||||
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
core.setOutput('pr_number', prNumber.toString());
|
||||
core.setOutput('head_repo', pr.data.head.repo.clone_url);
|
||||
core.setOutput('head_ref', pr.data.head.ref);
|
||||
core.setOutput('head_sha', pr.data.head.sha);
|
||||
core.setOutput('base_sha', pr.data.base.sha);
|
||||
core.setOutput('base_ref', pr.data.base.ref);
|
||||
core.setOutput('pr_title', pr.data.title);
|
||||
core.setOutput('pr_body', pr.data.body || '');
|
||||
core.setOutput('pr_author', pr.data.user.login);
|
||||
|
||||
- name: Checkout base repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.pr_info.outputs.base_ref }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate diff and prompt
|
||||
env:
|
||||
HEAD_REPO: ${{ steps.pr_info.outputs.head_repo }}
|
||||
HEAD_REF: ${{ steps.pr_info.outputs.head_ref }}
|
||||
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
|
||||
BASE_SHA: ${{ steps.pr_info.outputs.base_sha }}
|
||||
PR_TITLE: ${{ steps.pr_info.outputs.pr_title }}
|
||||
PR_BODY: ${{ steps.pr_info.outputs.pr_body }}
|
||||
PR_AUTHOR: ${{ steps.pr_info.outputs.pr_author }}
|
||||
DIFF_STATS: ""
|
||||
COMMAND_TYPE: ${{ steps.command.outputs.type }}
|
||||
ADDITIONAL_CONTEXT: ${{ steps.command.outputs.query }}
|
||||
USER_QUERY: ${{ steps.command.outputs.query }}
|
||||
run: |
|
||||
git remote add fork "${HEAD_REPO}" || true
|
||||
git fetch fork "${HEAD_REF}" --depth=100
|
||||
|
||||
git diff "${BASE_SHA}...${HEAD_SHA}" > /tmp/pr.diff
|
||||
DIFF_STATS=$(git diff --stat "${BASE_SHA}...${HEAD_SHA}" | tail -1)
|
||||
|
||||
DIFF_SIZE=$(wc -c < /tmp/pr.diff)
|
||||
IS_TRUNCATED=false
|
||||
if [ "$DIFF_SIZE" -gt 100000 ]; then
|
||||
head -c 100000 /tmp/pr.diff > /tmp/pr_truncated.diff
|
||||
mv /tmp/pr_truncated.diff /tmp/pr.diff
|
||||
IS_TRUNCATED=true
|
||||
fi
|
||||
|
||||
if [ "${COMMAND_TYPE}" = "query" ]; then
|
||||
# Query prompt
|
||||
cat claude_md/ci_query_prompt.md > /tmp/prompt.txt
|
||||
printf '\n---\n\n' >> /tmp/prompt.txt
|
||||
|
||||
cat >> /tmp/prompt.txt << PROMPT_EOF
|
||||
## Pull Request Context
|
||||
- **Title:** ${PR_TITLE}
|
||||
- **Author:** ${PR_AUTHOR}
|
||||
- **Changes:** ${DIFF_STATS}
|
||||
|
||||
## PR Description
|
||||
PROMPT_EOF
|
||||
printf '%s\n' "${PR_BODY}" >> /tmp/prompt.txt
|
||||
printf '\n## Question\n' >> /tmp/prompt.txt
|
||||
printf '%s\n' "${USER_QUERY}" >> /tmp/prompt.txt
|
||||
printf '\n---\n## PR Diff (for reference)\n' >> /tmp/prompt.txt
|
||||
cat /tmp/pr.diff >> /tmp/prompt.txt
|
||||
else
|
||||
# Review prompt
|
||||
cat claude_md/ci_review_prompt.md > /tmp/prompt.txt
|
||||
|
||||
cat >> /tmp/prompt.txt << PROMPT_EOF
|
||||
|
||||
## Pull Request Information
|
||||
- **Title:** ${PR_TITLE}
|
||||
- **Author:** ${PR_AUTHOR}
|
||||
- **Changes:** ${DIFF_STATS}
|
||||
PROMPT_EOF
|
||||
|
||||
if [ "${IS_TRUNCATED}" = "true" ]; then
|
||||
echo "- **Note:** Diff truncated due to size." >> /tmp/prompt.txt
|
||||
fi
|
||||
|
||||
printf '\n## PR Description\n' >> /tmp/prompt.txt
|
||||
printf '%s\n' "${PR_BODY}" >> /tmp/prompt.txt
|
||||
|
||||
if [ -n "${ADDITIONAL_CONTEXT}" ]; then
|
||||
printf '\n## Additional Instructions from Reviewer\n' >> /tmp/prompt.txt
|
||||
printf '%s\n' "${ADDITIONAL_CONTEXT}" >> /tmp/prompt.txt
|
||||
fi
|
||||
|
||||
printf '\n---\n\n## Diff to Review\n\n' >> /tmp/prompt.txt
|
||||
cat /tmp/pr.diff >> /tmp/prompt.txt
|
||||
fi
|
||||
|
||||
- name: Run Claude
|
||||
id: claude
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt_file: /tmp/prompt.txt
|
||||
model: ${{ github.event_name == 'workflow_dispatch' && inputs.model || 'claude-opus-4-6' }}
|
||||
max_turns: "300"
|
||||
timeout_minutes: "60"
|
||||
allowed_tools: "View,GlobTool,GrepTool,Write,Task"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Recover partial review on turn limit
|
||||
id: recover
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
|
||||
COMMAND_TYPE: ${{ steps.command.outputs.type }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
// Skip recovery for queries — only reviews use incremental findings
|
||||
if (process.env.COMMAND_TYPE === 'query') {
|
||||
core.setOutput('needs_recovery', 'false');
|
||||
return;
|
||||
}
|
||||
const log = JSON.parse(fs.readFileSync(process.env.EXECUTION_FILE, 'utf8'));
|
||||
const result = log.find(m => m.type === 'result');
|
||||
const hitLimit = result && result.subtype === 'error_max_turns';
|
||||
const hasFindings = fs.existsSync('review-findings.md');
|
||||
core.setOutput('hit_limit', hitLimit ? 'true' : 'false');
|
||||
core.setOutput('has_findings', hasFindings ? 'true' : 'false');
|
||||
core.setOutput('needs_recovery', (hitLimit && hasFindings) ? 'true' : 'false');
|
||||
|
||||
- name: Format partial findings
|
||||
if: steps.recover.outputs.needs_recovery == 'true'
|
||||
id: format_recovery
|
||||
uses: anthropics/claude-code-base-action@beta
|
||||
with:
|
||||
prompt_file: claude_md/ci_recovery_prompt.md
|
||||
model: claude-sonnet-4-20250514
|
||||
max_turns: "10"
|
||||
allowed_tools: "View,GlobTool,GrepTool"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Build review comment
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
|
||||
RECOVERY_FILE: ${{ steps.format_recovery.outputs.execution_file }}
|
||||
CONCLUSION: ${{ steps.claude.outputs.conclusion }}
|
||||
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
|
||||
REVIEWER: ${{ github.event_name == 'workflow_dispatch' && github.actor || github.event.comment.user.login }}
|
||||
COMMAND_TYPE: ${{ steps.command.outputs.type }}
|
||||
NEEDS_RECOVERY: ${{ steps.recover.outputs.needs_recovery }}
|
||||
with:
|
||||
script: |
|
||||
const parse = require('./.github/scripts/parse-claude-review.js');
|
||||
const fs = require('fs');
|
||||
const isReview = process.env.COMMAND_TYPE === 'review';
|
||||
let execFile = process.env.EXECUTION_FILE;
|
||||
if (process.env.NEEDS_RECOVERY === 'true') {
|
||||
const rf = process.env.RECOVERY_FILE;
|
||||
if (rf && fs.existsSync(rf)) {
|
||||
execFile = rf;
|
||||
}
|
||||
}
|
||||
const body = parse({
|
||||
executionFile: execFile,
|
||||
conclusion: process.env.CONCLUSION,
|
||||
meta: {
|
||||
trigger: 'manual',
|
||||
headSha: process.env.HEAD_SHA,
|
||||
reviewer: process.env.REVIEWER,
|
||||
isQuery: !isReview,
|
||||
isPartial: process.env.NEEDS_RECOVERY === 'true',
|
||||
}
|
||||
});
|
||||
fs.writeFileSync('claude-review-comment.md', body);
|
||||
|
||||
- name: Save PR number
|
||||
run: echo "${{ steps.pr_info.outputs.pr_number }}" > pr_number.txt
|
||||
|
||||
- name: Save trigger metadata
|
||||
env:
|
||||
REVIEWER: ${{ github.event_name == 'workflow_dispatch' && github.actor || github.event.comment.user.login }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
run: |
|
||||
echo "manual" > trigger_type.txt
|
||||
echo "${REVIEWER}" > reviewer.txt
|
||||
echo "${COMMENT_ID}" > comment_id.txt
|
||||
|
||||
- name: Upload review artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-review-result
|
||||
path: |
|
||||
claude-review-comment.md
|
||||
pr_number.txt
|
||||
trigger_type.txt
|
||||
reviewer.txt
|
||||
comment_id.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload execution log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-review-execution-log
|
||||
path: ${{ steps.claude.outputs.execution_file }}
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload recovery execution log
|
||||
if: always() && steps.recover.outputs.needs_recovery == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-review-recovery-log
|
||||
path: ${{ steps.format_recovery.outputs.execution_file }}
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
github.event_name != 'pull_request_target' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/ai-review-analysis.yml
|
||||
with:
|
||||
provider: claude
|
||||
display_name: Claude
|
||||
review_command: /claude-review
|
||||
query_command: /claude-query
|
||||
result_artifact_name: claude-review-result
|
||||
comment_file: claude-review-comment.md
|
||||
default_model: claude-opus-4-6
|
||||
selected_model: ${{ github.event_name == 'workflow_dispatch' && inputs.model || '' }}
|
||||
classifier_model: claude-sonnet-4-6
|
||||
recovery_model: claude-sonnet-4-6
|
||||
thinking_budget: ${{ github.event_name == 'workflow_dispatch' && inputs.thinking_budget || '' }}
|
||||
dispatch_pr_number: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || '' }}
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,25 @@
|
||||
# Codex Code Review — Comment Posting Workflow
|
||||
#
|
||||
# Thin wrapper around .github/workflows/ai-review-comment.yml so the provider
|
||||
# specific workflow name stays stable while the shared implementation lives in
|
||||
# one reusable workflow.
|
||||
|
||||
name: Post Codex Review Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Codex Code Review"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
review-comment:
|
||||
uses: ./.github/workflows/ai-review-comment.yml
|
||||
with:
|
||||
provider: codex
|
||||
result_artifact_name: codex-review-result
|
||||
comment_file: codex-review-comment.md
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,68 @@
|
||||
# Codex Code Review — Analysis Workflow
|
||||
#
|
||||
# Thin wrapper around .github/workflows/ai-review-analysis.yml so provider
|
||||
# specific triggers and workflow_dispatch inputs stay stable while the shared
|
||||
# implementation lives in one reusable workflow.
|
||||
|
||||
name: Codex Code Review
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["facebook/rocksdb/pr-jobs"]
|
||||
types: [completed]
|
||||
|
||||
# The early pull_request_target path is limited to same-repo PRs by the job
|
||||
# condition below. Fork PRs skip this path and rely on workflow_run instead.
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: PR number to review
|
||||
required: true
|
||||
type: number
|
||||
model:
|
||||
description: Codex model to use
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- gpt-5.5
|
||||
- gpt-5.3-codex
|
||||
- gpt-5.2-codex
|
||||
default: gpt-5.5
|
||||
thinking_budget:
|
||||
description: Override MAX_THINKING_TOKENS (blank = auto-classify)
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# The shared analysis workflow polls check state, inspects prior runs, and
|
||||
# reads PR metadata. Keep those permissions read-only in the caller.
|
||||
actions: read
|
||||
checks: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
review:
|
||||
if: >-
|
||||
github.event_name != 'pull_request_target' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/ai-review-analysis.yml
|
||||
with:
|
||||
provider: codex
|
||||
display_name: Codex
|
||||
review_command: /codex-review
|
||||
query_command: /codex-query
|
||||
result_artifact_name: codex-review-result
|
||||
comment_file: codex-review-comment.md
|
||||
default_model: gpt-5.5
|
||||
selected_model: ${{ github.event_name == 'workflow_dispatch' && inputs.model || '' }}
|
||||
thinking_budget: ${{ github.event_name == 'workflow_dispatch' && inputs.thinking_budget || '' }}
|
||||
dispatch_pr_number: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || '' }}
|
||||
secrets: inherit
|
||||
+21
@@ -1,6 +1,27 @@
|
||||
# Rocksdb Change Log
|
||||
> NOTE: Entries for next release do not go here. Follow instructions in `unreleased_history/README.txt`
|
||||
|
||||
## 11.2.0 (04/18/2026)
|
||||
### New Features
|
||||
* Added experimental `DBOptions::fast_sst_open` option. When enabled, RocksDB retrieves opaque file system metadata for SST files after flush, compaction, and external file ingestion, persists it in the MANIFEST, and passes it back to the file system on subsequent file opens to accelerate DB open time.
|
||||
* Added new option `min_tombstones_for_range_conversion` in `AdvancedColumnFamilyOptions`. When set to a non-zero value N, forward or reverse iteration will convert N or more contiguous point tombstones into a range tombstone in the mutable memtable. Future read operations will then be able to benefit from range tombstone optimizations. There are some limitations when it comes to table_filters, prefix_filters, and UDTs. See header comments for more details.
|
||||
* Added read-triggered compaction: a new column family option `read_triggered_compaction_threshold` (default 0, disabled) that marks SST files for compaction when their read frequency (`num_collapsible_entry_reads_sampled / file_size`) exceeds the threshold. This helps reduce read amplification for frequently-read ("hot") keys. A new DB option `max_compaction_trigger_wakeup_seconds` (default 43200s / 12 hours) controls the maximum interval for periodic compaction score re-evaluation, which is necessary for this feature to work on quiet (no-write) databases.
|
||||
|
||||
### Public API Changes
|
||||
* Added new `WideColumnBlobResolver` interface and `CompactionFilter::FilterV4()` method, including `ResolveColumn()` / `ResolveColumns()` helpers for lazy loading blob column values in wide-column entities during compaction. This allows compaction filters to resolve blob values on-demand, avoiding unnecessary I/O for blob columns they don't need to access.
|
||||
* Changed experimental feature `ExternalTableReader::Get` and `ExternalTableReader::MultiGet` to use `PinnableSlice` instead of `std::string` for output values, enabling zero-copy pinning. This will break existing implementations.
|
||||
* Added `SstFileReader::Get` and `SstFileReader::MultiGet` overloads that accept `PinnableSlice`/`std::vector<PinnableSlice>*`, enabling zero-copy reads when the underlying `TableReader` supports pinning.
|
||||
|
||||
### Behavior Changes
|
||||
* Prefix filter changes - when seeking to a key that is out of domain, and total_order_seek is false, total_order_seek is treated as if it were true. When prefix_same_as_start = true, now iterating past a key that is out of domain invalidates the iterator. The existing behavior does not check for InDomain in the DBIter, so Transform() can produce undefined behavior (e.g. key of size 3 on FixedPrefixTransform(4)).
|
||||
* Wide-column entities with blob-backed columns now use a new V2 on-disk encoding; older RocksDB versions that do not support wide-column blob separation will reject DBs or SSTs containing those entities.
|
||||
|
||||
### Bug Fixes
|
||||
* Fix blob garbage accounting for blob direct-write flushes so flush-time filtering and overwrite elision correctly register obsolete blob bytes in blob metadata.
|
||||
* Fix a memory accounting leak in IODispatcher where ReadIndex() moved block values out of ReadSet without releasing the associated prefetch memory, causing subsequent prefetches to be blocked when max_prefetch_memory_bytes was set.
|
||||
* Fix MultiScan to fall back to synchronous coalesced reads when async I/O is unsupported at runtime.
|
||||
|
||||
|
||||
## 11.1.0 (03/23/2026)
|
||||
### New Features
|
||||
* Add a new option `open_files_async`. The existing behavior is on DB open, we open all sst files and do basic validations. For very large DBs on remote filesystems with many ssts, this may take very long. This option performs these validations instead in the background. Open errors found by this async background task are surfaced as a new background error kAsyncFileOpen.
|
||||
|
||||
@@ -384,6 +384,10 @@ endif
|
||||
# TSAN doesn't work well with jemalloc. If we're compiling with TSAN, we should use regular malloc.
|
||||
ifdef COMPILE_WITH_TSAN
|
||||
DISABLE_JEMALLOC=1
|
||||
# Use a suppressions file instead of the process-wide TSAN default
|
||||
# suppressions hook, which belongs to the final application.
|
||||
TSAN_OPTIONS?=suppressions=$(CURDIR)/tools/tsan_suppressions.txt
|
||||
export TSAN_OPTIONS
|
||||
EXEC_LDFLAGS += -fsanitize=thread
|
||||
PLATFORM_CCFLAGS += -fsanitize=thread -fPIC -DFOLLY_SANITIZE_THREAD
|
||||
PLATFORM_CXXFLAGS += -fsanitize=thread -fPIC -DFOLLY_SANITIZE_THREAD
|
||||
@@ -1268,6 +1272,8 @@ check-format:
|
||||
build_tools/format-diff.sh -c
|
||||
|
||||
|
||||
# Crude alternative to setup-hooks: copies hooks into .git/hooks/ instead of
|
||||
# using core.hooksPath. The copies won't track changes to githooks/.
|
||||
install-hooks:
|
||||
@echo "Installing git hooks from githooks/..."
|
||||
@if [ -d githooks ]; then \
|
||||
@@ -1283,6 +1289,7 @@ install-hooks:
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Reverse of install-hooks (not needed if using setup-hooks / core.hooksPath).
|
||||
uninstall-hooks:
|
||||
@echo "Removing installed git hooks..."
|
||||
@for hook in githooks/*; do \
|
||||
|
||||
@@ -37,7 +37,7 @@ if [ "$?" != "1" ]; then
|
||||
BAD=1
|
||||
fi
|
||||
|
||||
git grep -n -P "[\x80-\xFF]" -- ':!docs' ':!*.md'
|
||||
LC_ALL=C git grep -n $'[\x80-\xff]' -- ':!docs' ':!*.md' ':!.github'
|
||||
if [ "$?" != "1" ]; then
|
||||
echo '^^^^ Use only ASCII characters in source files'
|
||||
BAD=1
|
||||
|
||||
@@ -7,6 +7,13 @@ set -euo pipefail
|
||||
|
||||
if ! command -v ruby >/dev/null 2>&1; then
|
||||
echo "ruby is required to validate GitHub Actions workflow YAML"
|
||||
echo "On CentOS Stream: sudo dnf install ruby rubygems rubygem-psych"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! ruby -e 'require "psych"' 2>/dev/null; then
|
||||
echo "ruby is installed but cannot load required library 'psych'"
|
||||
echo "On CentOS Stream: sudo dnf install rubygems rubygem-psych"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# PR Complexity Classifier — CI Triage Prompt
|
||||
|
||||
## Purpose
|
||||
Quickly classify a RocksDB pull request as **simple** or **complex** so the
|
||||
downstream reviewer can pick an appropriate thinking budget.
|
||||
|
||||
## Output Contract (STRICT)
|
||||
Your final response MUST be exactly one of these two lowercase tokens, on a
|
||||
line by itself, with no other text:
|
||||
|
||||
```
|
||||
simple
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
complex
|
||||
```
|
||||
|
||||
If you are unsure, output `complex`. The downstream review will use this token
|
||||
to set its thinking budget — when in doubt, prefer the more thorough budget.
|
||||
|
||||
## Classification Heuristics
|
||||
|
||||
Treat a PR as **simple** when ALL of the following hold:
|
||||
- Diff touches < ~200 lines of non-test code
|
||||
- No changes to public headers under `include/rocksdb/`
|
||||
- No changes to on-disk format (SST block layout, WAL record layout,
|
||||
manifest, options file format, version edit encoding)
|
||||
- No changes to compaction picker, write-path locking, recovery, or
|
||||
WAL/memtable lifecycle
|
||||
- No new public option, no new statistics counter, no new RPC/serialized
|
||||
message
|
||||
- Tests-only or docs-only changes
|
||||
- Trivial refactors (rename, comment, dead-code removal, lint fixes)
|
||||
- One-line bug fix with obvious root cause and a focused regression test
|
||||
|
||||
Treat a PR as **complex** when ANY of the following hold:
|
||||
- Touches files in `db/`, `db/compaction/`, `db/blob/`, `cache/`,
|
||||
`table/block_based/`, `file/`, `env/io_*` with > ~50 lines of logic change
|
||||
- Modifies thread-safety/synchronization (mutexes, atomics, memory ordering,
|
||||
refcounting, condvars)
|
||||
- Changes serialization or on-disk format
|
||||
- Changes public API surface in `include/rocksdb/`
|
||||
- Adds a new feature flag, option, or compaction style
|
||||
- Touches transactions (`utilities/transactions/`), backup/checkpoint,
|
||||
user-defined timestamps, or remote compaction
|
||||
- Cross-cutting refactor spanning > 5 directories
|
||||
|
||||
## How To Decide
|
||||
1. Read the diff summary and changed-file list provided below.
|
||||
2. Skim the diff hunks — focus on which subsystems are touched and whether
|
||||
the change is mechanical vs. semantic.
|
||||
3. Apply the heuristics above. Default to `complex` if the rules conflict.
|
||||
4. Output the single token. No preamble. No explanation.
|
||||
|
||||
## Tool Use
|
||||
You may use `View`, `GlobTool`, and `GrepTool` to peek at one or two files
|
||||
if the diff snippet alone is ambiguous, but keep this fast — this is a
|
||||
triage step, not a review. Do NOT spawn sub-agents.
|
||||
@@ -1,14 +1,54 @@
|
||||
Read review-findings.md. This file contains partial code
|
||||
review findings from a review session that ran out of turns before
|
||||
finishing. Format them into a clean final review comment using the
|
||||
output format defined in claude_md/code_review.md (Summary, Issues
|
||||
Found, Cross-Component Analysis, Positive Observations).
|
||||
Read review-findings.md. This file contains partial code review findings
|
||||
from a session that ran out of turns before finishing. Reformat them into
|
||||
a clean final review comment.
|
||||
|
||||
At the top of the review, add this notice:
|
||||
> **Partial review** -- the analysis was interrupted before all
|
||||
> perspectives were completed. The findings below cover the
|
||||
> perspectives that were finished. You can request a full re-review
|
||||
> with `/claude-review`.
|
||||
## Required Output Structure
|
||||
|
||||
Write ONLY the formatted review as your final response -- no
|
||||
commentary or explanation outside the review itself.
|
||||
Use this exact structure so the PR page stays scrollable:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
> **Partial review** — the analysis was interrupted before all perspectives
|
||||
> were completed. Findings below cover only the perspectives that finished.
|
||||
> You can request a fresh full review with `/claude-review`.
|
||||
|
||||
<!-- One or two sentences of overall assessment. -->
|
||||
|
||||
**High-severity findings (N):**
|
||||
- **[file.cc:123]** One-line description. <!-- repeat per HIGH finding -->
|
||||
|
||||
<!-- If no HIGH findings were salvaged, write: -->
|
||||
<!-- _No high-severity findings recovered._ -->
|
||||
|
||||
<details>
|
||||
<summary>Recovered findings (click to expand)</summary>
|
||||
|
||||
### Findings
|
||||
|
||||
#### :red_circle: HIGH
|
||||
... (H1, H2, ...)
|
||||
|
||||
#### :yellow_circle: MEDIUM
|
||||
... (M1, M2, ...)
|
||||
|
||||
#### :green_circle: LOW / NIT
|
||||
... (L1, L2, ...)
|
||||
|
||||
### Cross-Component Analysis
|
||||
<!-- Whatever cross-component results were captured. -->
|
||||
|
||||
### Positive Observations
|
||||
<!-- Optional. -->
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
## Rules
|
||||
- The `## Summary`, the partial-review notice, and the HIGH bullet list MUST
|
||||
stay outside the `<details>` block.
|
||||
- All per-finding detail and analysis MUST live inside the `<details>` block.
|
||||
- Do NOT nest `<details>` blocks.
|
||||
- Do NOT add any text after the closing `</details>` — the comment-builder
|
||||
appends its own footer.
|
||||
- Output ONLY the formatted review. No commentary, no explanation.
|
||||
@@ -481,6 +481,61 @@ Team lead synthesizes the debate into a consensus document:
|
||||
conclusion — not a record of the analysis process.
|
||||
- The reader should never see the reviewer arguing with itself.
|
||||
|
||||
**REQUIRED output structure (so the PR page stays scrollable):**
|
||||
|
||||
The final response (and contents of `review-findings.md`) MUST follow this
|
||||
exact structure. The summary appears first so reviewers can see HIGH findings
|
||||
at a glance; everything else is hidden behind a `<details>` block.
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
<!-- One or two sentences of overall assessment. -->
|
||||
|
||||
**High-severity findings (N):**
|
||||
- **[file.cc:123]** One-line description of the issue. <!-- repeat per HIGH finding -->
|
||||
|
||||
<!-- If there are NO high-severity findings, write exactly: -->
|
||||
<!-- _No high-severity findings._ -->
|
||||
|
||||
<details>
|
||||
<summary>Full review (click to expand)</summary>
|
||||
|
||||
### Findings
|
||||
|
||||
#### :red_circle: HIGH
|
||||
|
||||
##### H1. <Title> — `file.cc:123`
|
||||
- **Issue:** ...
|
||||
- **Root cause:** ...
|
||||
- **Suggested fix:** ...
|
||||
|
||||
#### :yellow_circle: MEDIUM
|
||||
... (same structure: M1, M2, ...)
|
||||
|
||||
#### :green_circle: LOW / NIT
|
||||
... (same structure: L1, L2, ...)
|
||||
|
||||
### Cross-Component Analysis
|
||||
<!-- Execution-context table and assumption stress-test results. -->
|
||||
|
||||
### Positive Observations
|
||||
<!-- Optional: good patterns, clever optimizations. -->
|
||||
|
||||
</details>
|
||||
```
|
||||
|
||||
Rules for this structure:
|
||||
- The top-level `## Summary` and the bullet list of HIGH findings MUST stay
|
||||
outside the `<details>` block — they are always visible.
|
||||
- Every detail (per-finding root cause, fix, debate outcomes, cross-component
|
||||
analysis, positive observations) MUST live inside the `<details>` block.
|
||||
- Do NOT nest a `<details>` inside another `<details>`.
|
||||
- Do NOT add any text after the closing `</details>` — the comment-builder
|
||||
appends its own footer.
|
||||
- If the review is partial (recovery path), still produce the summary block
|
||||
first, and put whatever was salvaged inside the `<details>` block.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
### Context Phase (must be completed before agents spawn)
|
||||
|
||||
@@ -7,6 +7,13 @@ DB_STRESS_CMD?=./db_stress
|
||||
|
||||
include common.mk
|
||||
|
||||
ifdef COMPILE_WITH_TSAN
|
||||
# Keep direct `make -f crash_test.mk COMPILE_WITH_TSAN=1 ...` runs
|
||||
# aligned with the main Makefile's TSAN runtime options.
|
||||
TSAN_OPTIONS?=suppressions=$(CURDIR)/tools/tsan_suppressions.txt
|
||||
export TSAN_OPTIONS
|
||||
endif
|
||||
|
||||
CRASHTEST_MAKE=$(MAKE) -f crash_test.mk
|
||||
CRASHTEST_PY=$(PYTHON) -u tools/db_crashtest.py --stress_cmd=$(DB_STRESS_CMD) --cleanup_cmd='$(DB_CLEANUP_CMD)' --destroy_db_initially=1
|
||||
|
||||
|
||||
@@ -54,13 +54,14 @@ class RoundRobinBlobFilePartitionStrategy : public BlobFilePartitionStrategy {
|
||||
};
|
||||
|
||||
struct DirectWriteCompressionState {
|
||||
CompressionOptions compression_opts;
|
||||
// `working_area` must be released before its owning compressor.
|
||||
std::unique_ptr<Compressor> compressor;
|
||||
Compressor::ManagedWorkingArea working_area;
|
||||
};
|
||||
|
||||
DirectWriteCompressionState& GetDirectWriteCompressionState(
|
||||
CompressionType compression) {
|
||||
CompressionType compression, const CompressionOptions& compression_opts) {
|
||||
assert(compression <= kLastBuiltinCompression);
|
||||
|
||||
static thread_local std::array<DirectWriteCompressionState,
|
||||
@@ -71,12 +72,15 @@ DirectWriteCompressionState& GetDirectWriteCompressionState(
|
||||
auto& compression_state =
|
||||
compression_states[static_cast<size_t>(compression)];
|
||||
if (compression != kNoCompression &&
|
||||
compression_state.compressor == nullptr) {
|
||||
// BDW v1 mirrors BlobFileBuilder by using the built-in compressor with the
|
||||
// default CompressionOptions only. Cache it per thread so repeated writes
|
||||
// do not allocate a new compressor and working area every time.
|
||||
(compression_state.compressor == nullptr ||
|
||||
compression_state.compression_opts != compression_opts)) {
|
||||
// BDW compression settings are mutable, so rebuild the per-thread cached
|
||||
// compressor when the latest published opts for this type change.
|
||||
compression_state.working_area = Compressor::ManagedWorkingArea{};
|
||||
compression_state.compressor.reset();
|
||||
compression_state.compression_opts = compression_opts;
|
||||
compression_state.compressor =
|
||||
GetBuiltinV2CompressionManager()->GetCompressor(CompressionOptions{},
|
||||
GetBuiltinV2CompressionManager()->GetCompressor(compression_opts,
|
||||
compression);
|
||||
if (compression_state.compressor != nullptr) {
|
||||
compression_state.working_area =
|
||||
@@ -390,15 +394,15 @@ bool BlobFilePartitionManager::MarkSealedFileGarbage(
|
||||
}
|
||||
|
||||
Status BlobFilePartitionManager::MaybePrepopulateBlobCache(
|
||||
const BlobDirectWriteSettings* settings, const Slice& original_value,
|
||||
const BlobDirectWriteSettings& settings, const Slice& original_value,
|
||||
uint64_t blob_file_number, uint64_t blob_offset) {
|
||||
if (settings == nullptr || settings->blob_cache == nullptr ||
|
||||
settings->prepopulate_blob_cache != PrepopulateBlobCache::kFlushOnly) {
|
||||
if (settings.blob_cache == nullptr ||
|
||||
settings.prepopulate_blob_cache != PrepopulateBlobCache::kFlushOnly) {
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
FullTypedCacheInterface<BlobContents, BlobContentsCreator> blob_cache{
|
||||
settings->blob_cache};
|
||||
settings.blob_cache};
|
||||
const OffsetableCacheKey base_cache_key(db_id_, db_session_id_,
|
||||
blob_file_number);
|
||||
const CacheKey cache_key = base_cache_key.WithOffset(blob_offset);
|
||||
@@ -419,7 +423,7 @@ Status BlobFilePartitionManager::WriteBlob(
|
||||
const WriteOptions& write_options, uint32_t column_family_id,
|
||||
CompressionType compression, const Slice& key, const Slice& value,
|
||||
uint64_t* blob_file_number, uint64_t* blob_offset, uint64_t* blob_size,
|
||||
const BlobDirectWriteSettings* settings, const uint32_t* partition_idx) {
|
||||
const BlobDirectWriteSettings& settings, const uint32_t* partition_idx) {
|
||||
assert(blob_file_number != nullptr);
|
||||
assert(blob_offset != nullptr);
|
||||
assert(blob_size != nullptr);
|
||||
@@ -431,7 +435,8 @@ Status BlobFilePartitionManager::WriteBlob(
|
||||
GrowableBuffer compressed_value;
|
||||
Slice write_value = value;
|
||||
if (compression != kNoCompression) {
|
||||
auto& compression_state = GetDirectWriteCompressionState(compression);
|
||||
auto& compression_state =
|
||||
GetDirectWriteCompressionState(compression, settings.compression_opts);
|
||||
if (compression_state.compressor == nullptr) {
|
||||
return Status::NotSupported(
|
||||
"Blob direct write compression type not supported");
|
||||
@@ -781,13 +786,19 @@ Status BlobFilePartitionManager::ResolveBlobDirectWriteIndex(
|
||||
}
|
||||
}
|
||||
|
||||
Status s = version != nullptr ? Status::Corruption("Invalid blob file number")
|
||||
: Status::NotFound();
|
||||
|
||||
if (blob_file_cache == nullptr) {
|
||||
return s;
|
||||
return version != nullptr ? Status::Corruption("Invalid blob file number")
|
||||
: Status::NotFound();
|
||||
}
|
||||
|
||||
if (read_options.read_tier == kBlockCacheTier) {
|
||||
// The direct-write fallback below may need to open the blob file reader,
|
||||
// which `kBlockCacheTier` forbids. Keep the normal Version-backed path
|
||||
// above eligible for cache-only hits.
|
||||
return Status::Incomplete("Cannot read blob(s): no disk I/O allowed");
|
||||
}
|
||||
|
||||
Status s;
|
||||
CacheHandleGuard<BlobFileReader> reader;
|
||||
s = blob_file_cache->GetBlobFileReader(read_options, blob_idx.file_number(),
|
||||
&reader,
|
||||
|
||||
@@ -77,7 +77,7 @@ class BlobFilePartitionManager {
|
||||
CompressionType compression, const Slice& key,
|
||||
const Slice& value, uint64_t* blob_file_number,
|
||||
uint64_t* blob_offset, uint64_t* blob_size,
|
||||
const BlobDirectWriteSettings* settings = nullptr,
|
||||
const BlobDirectWriteSettings& settings,
|
||||
const uint32_t* partition_idx = nullptr);
|
||||
|
||||
// Selects the partition to use for all blob-backed columns of one PutEntity
|
||||
@@ -239,8 +239,8 @@ class BlobFilePartitionManager {
|
||||
static bool MarkSealedFileGarbage(std::vector<SealedFile>* sealed_files,
|
||||
uint64_t file_number, uint64_t blob_count,
|
||||
uint64_t blob_bytes);
|
||||
// Optionally seeds the blob cache with the original uncompressed value.
|
||||
Status MaybePrepopulateBlobCache(const BlobDirectWriteSettings* settings,
|
||||
// Seeds the blob cache with the original uncompressed value when configured.
|
||||
Status MaybePrepopulateBlobCache(const BlobDirectWriteSettings& settings,
|
||||
const Slice& original_value,
|
||||
uint64_t blob_file_number,
|
||||
uint64_t blob_offset);
|
||||
|
||||
@@ -107,7 +107,7 @@ Status BlobWriteBatchTransformer::MaybePreprocessWideColumns(
|
||||
uint64_t blob_size = 0;
|
||||
Status s = partition_mgr->WriteBlob(
|
||||
write_options, column_family_id, settings.compression_type, key,
|
||||
column_value, &blob_file_number, &blob_offset, &blob_size, &settings,
|
||||
column_value, &blob_file_number, &blob_offset, &blob_size, settings,
|
||||
&entity_partition);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
@@ -169,7 +169,7 @@ Status BlobWriteBatchTransformer::PutCF(uint32_t column_family_id,
|
||||
|
||||
Status s = cached_partition_mgr_->WriteBlob(
|
||||
write_options_, column_family_id, settings.compression_type, key, value,
|
||||
&blob_file_number, &blob_offset, &blob_size, &settings);
|
||||
&blob_file_number, &blob_offset, &blob_size, settings);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
@@ -278,7 +278,7 @@ Status BlobWriteBatchTransformer::MergeCF(uint32_t column_family_id,
|
||||
Status BlobWriteBatchTransformer::PutBlobIndexCF(uint32_t column_family_id,
|
||||
const Slice& key,
|
||||
const Slice& value) {
|
||||
// Already a blob index — pass through unchanged.
|
||||
// Already a blob index -- pass through unchanged.
|
||||
return WriteBatchInternal::PutBlobIndex(output_batch_, column_family_id, key,
|
||||
value);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ struct BlobDirectWriteSettings {
|
||||
uint64_t min_blob_size = 0;
|
||||
// Compression to use for newly written blob records.
|
||||
CompressionType compression_type = kNoCompression;
|
||||
// Raw pointer — the Cache is owned by ColumnFamilyOptions and outlives all
|
||||
// Compression options for newly written blob records.
|
||||
CompressionOptions compression_opts;
|
||||
// Raw pointer -- the Cache is owned by ColumnFamilyOptions and outlives all
|
||||
// settings snapshots. Using raw avoids 2 atomic ref-count ops per Put().
|
||||
Cache* blob_cache = nullptr;
|
||||
// Blob-cache prepopulation policy for direct-write records.
|
||||
|
||||
@@ -1620,6 +1620,47 @@ TEST_P(DBBlobBasicIOErrorTest, GetBlob_IOError) {
|
||||
SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
}
|
||||
|
||||
TEST_P(DBBlobBasicIOErrorTest, GetEntityMergeWithBlobBaseIOError) {
|
||||
// Goal: verify GetEntity preserves injected blob-read IOErrors when merge
|
||||
// reads a blob-backed base value, instead of laundering them into Corruption.
|
||||
// The test writes a blob-backed base value plus a merge operand, then injects
|
||||
// an IOError at blob read time and checks both GetEntity and Get see it.
|
||||
Options options;
|
||||
options.env = fault_injection_env_.get();
|
||||
options.enable_blob_files = true;
|
||||
options.min_blob_size = 0;
|
||||
options.merge_operator = MergeOperators::CreateStringAppendOperator();
|
||||
|
||||
Reopen(options);
|
||||
|
||||
constexpr char key[] = "key";
|
||||
constexpr char base_value[] = "base_value";
|
||||
|
||||
ASSERT_OK(Put(key, base_value));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
ASSERT_OK(Merge(key, "merge_operand"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
SyncPoint::GetInstance()->SetCallBack(sync_point_, [this](void* /* arg */) {
|
||||
fault_injection_env_->SetFilesystemActive(false,
|
||||
Status::IOError(sync_point_));
|
||||
});
|
||||
SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
PinnableWideColumns entity_result;
|
||||
Status s = db_->GetEntity(ReadOptions(), db_->DefaultColumnFamily(), key,
|
||||
&entity_result);
|
||||
ASSERT_TRUE(s.IsIOError()) << "Expected IOError but got: " << s.ToString();
|
||||
|
||||
PinnableSlice get_result;
|
||||
s = db_->Get(ReadOptions(), db_->DefaultColumnFamily(), key, &get_result);
|
||||
ASSERT_TRUE(s.IsIOError()) << "Expected IOError but got: " << s.ToString();
|
||||
|
||||
SyncPoint::GetInstance()->DisableProcessing();
|
||||
SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
}
|
||||
|
||||
TEST_P(DBBlobBasicIOErrorMultiGetTest, MultiGetBlobs_IOError) {
|
||||
Options options = GetDefaultOptions();
|
||||
options.env = fault_injection_env_.get();
|
||||
|
||||
@@ -151,6 +151,48 @@ class DBBlobDirectWriteTest : public DBTestBase {
|
||||
return reader.ReadHeader(header);
|
||||
}
|
||||
|
||||
Status ReadBlobFileRecords(uint64_t blob_file_number, size_t num_records,
|
||||
std::vector<BlobLogRecord>* records) {
|
||||
assert(records != nullptr);
|
||||
records->clear();
|
||||
|
||||
std::unique_ptr<FSRandomAccessFile> file;
|
||||
FileOptions file_options;
|
||||
constexpr IODebugContext* dbg = nullptr;
|
||||
const std::string blob_file_path = BlobFileName(dbname_, blob_file_number);
|
||||
FileSystem* fs = env_->GetFileSystem().get();
|
||||
SystemClock* clock = env_->GetSystemClock().get();
|
||||
|
||||
Status s =
|
||||
fs->NewRandomAccessFile(blob_file_path, file_options, &file, dbg);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
std::unique_ptr<RandomAccessFileReader> file_reader(
|
||||
new RandomAccessFileReader(std::move(file), blob_file_path, clock));
|
||||
BlobLogSequentialReader reader(std::move(file_reader), clock,
|
||||
/*statistics=*/nullptr);
|
||||
|
||||
BlobLogHeader header;
|
||||
s = reader.ReadHeader(&header);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
records->reserve(num_records);
|
||||
for (size_t i = 0; i < num_records; ++i) {
|
||||
BlobLogRecord record;
|
||||
s = reader.ReadRecord(&record,
|
||||
BlobLogSequentialReader::kReadHeaderKeyBlob);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
records->push_back(std::move(record));
|
||||
}
|
||||
|
||||
return Status::OK();
|
||||
}
|
||||
CompressionType GetSupportedCompressedBlobCompression() {
|
||||
static constexpr std::array<CompressionType, 6> kCandidates{
|
||||
kSnappyCompression, kLZ4Compression, kZSTD,
|
||||
@@ -944,6 +986,70 @@ TEST_F(DBBlobDirectWriteTest,
|
||||
ASSERT_EQ(Get(third_key), third_value);
|
||||
}
|
||||
|
||||
TEST_F(DBBlobDirectWriteTest,
|
||||
DirectWriteCompressionOptionsUpdateRebuildsCachedCompressor) {
|
||||
#ifndef ZSTD
|
||||
ROCKSDB_GTEST_SKIP("This test requires ZSTD support");
|
||||
return;
|
||||
#else
|
||||
// Goal: verify BDW picks up dynamic blob_compression_opts updates instead of
|
||||
// reusing a stale per-thread compressor keyed only by compression type.
|
||||
// We toggle the ZSTD frame checksum flag on and off, then inspect the raw
|
||||
// blob records in one blob file. With identical inputs and level, enabling
|
||||
// checksum should add exactly 4 bytes to the stored compressed blob.
|
||||
Options options = GetDirectWriteOptions();
|
||||
options.blob_file_size = 1 << 20;
|
||||
options.blob_compression_type = kZSTD;
|
||||
options.blob_compression_opts.level = 1;
|
||||
options.blob_compression_opts.checksum = false;
|
||||
|
||||
Reopen(options);
|
||||
|
||||
const std::string first_key = "checksum_off_before_update";
|
||||
const std::string second_key = "checksum_on_after_update";
|
||||
const std::string third_key = "checksum_off_after_second_update";
|
||||
const std::string value(256, 'v');
|
||||
|
||||
ASSERT_OK(Put(first_key, value));
|
||||
ASSERT_EQ(Get(first_key), value);
|
||||
|
||||
ASSERT_OK(
|
||||
db_->SetOptions(db_->DefaultColumnFamily(),
|
||||
{{"blob_compression_opts", "{level=1;checksum=true}"}}));
|
||||
ASSERT_OK(Put(second_key, value));
|
||||
ASSERT_EQ(Get(second_key), value);
|
||||
|
||||
ASSERT_OK(
|
||||
db_->SetOptions(db_->DefaultColumnFamily(),
|
||||
{{"blob_compression_opts", "{level=1;checksum=false}"}}));
|
||||
ASSERT_OK(Put(third_key, value));
|
||||
ASSERT_EQ(Get(third_key), value);
|
||||
|
||||
ASSERT_OK(Flush());
|
||||
ASSERT_EQ(CountBlobFiles(), 1U);
|
||||
|
||||
const std::vector<uint64_t> blob_file_numbers = GetBlobFileNumbers();
|
||||
ASSERT_EQ(blob_file_numbers.size(), 1U);
|
||||
|
||||
std::vector<BlobLogRecord> records;
|
||||
ASSERT_OK(
|
||||
ReadBlobFileRecords(blob_file_numbers[0], /*num_records=*/3, &records));
|
||||
ASSERT_EQ(records.size(), 3U);
|
||||
ASSERT_EQ(records[0].key.ToString(), first_key);
|
||||
ASSERT_EQ(records[1].key.ToString(), second_key);
|
||||
ASSERT_EQ(records[2].key.ToString(), third_key);
|
||||
ASSERT_EQ(records[0].value.size(), records[2].value.size());
|
||||
ASSERT_EQ(records[1].value.size(), records[0].value.size() + 4);
|
||||
|
||||
Close();
|
||||
Reopen(options);
|
||||
|
||||
ASSERT_EQ(Get(first_key), value);
|
||||
ASSERT_EQ(Get(second_key), value);
|
||||
ASSERT_EQ(Get(third_key), value);
|
||||
#endif // ZSTD
|
||||
}
|
||||
|
||||
TEST_F(DBBlobDirectWriteTest, DirectWriteFailedBatchTrackedAsInitialGarbage) {
|
||||
Options options = GetDirectWriteOptions();
|
||||
options.blob_file_size = 1 << 20;
|
||||
|
||||
@@ -128,6 +128,16 @@ class DBBlobIndexTest : public DBTestBase {
|
||||
columns, s, is_blob_index, value_found);
|
||||
}
|
||||
|
||||
bool MaybeResolveMemtableBlobValueForTest(const Slice& key,
|
||||
const BlobFetcher* blob_fetcher,
|
||||
PinnableSlice* value,
|
||||
PinnableWideColumns* columns,
|
||||
Status* s, bool* is_blob_index,
|
||||
bool* value_found = nullptr) {
|
||||
return DBImpl::MaybeResolveMemtableBlobValue(
|
||||
key, blob_fetcher, value, columns, s, is_blob_index, value_found);
|
||||
}
|
||||
|
||||
Options GetTestOptions() {
|
||||
Options options;
|
||||
options.env = CurrentOptions().env;
|
||||
@@ -271,6 +281,55 @@ TEST_F(DBBlobIndexTest,
|
||||
ASSERT_EQ(static_cast<char>(BlobIndex::Type::kUnknown), blob_index.front());
|
||||
}
|
||||
|
||||
TEST_F(DBBlobIndexTest,
|
||||
MaybeResolveMemtableBlobValueWithoutFetcherFailsClosed) {
|
||||
// Goal: if a readonly/secondary memtable hit produces a blob-backed payload
|
||||
// but no BlobFetcher is available, the helper must fail closed instead of
|
||||
// handing raw blob-index bytes back to the caller as if they were the value.
|
||||
std::string blob_index;
|
||||
BlobIndex::EncodeBlob(&blob_index, /*file_number=*/123, /*offset=*/456,
|
||||
/*size=*/789, kNoCompression);
|
||||
|
||||
PinnableSlice value;
|
||||
value.GetSelf()->assign(blob_index.data(), blob_index.size());
|
||||
value.PinSelf();
|
||||
|
||||
Status s = Status::OK();
|
||||
bool is_blob_index = true;
|
||||
ASSERT_TRUE(MaybeResolveMemtableBlobValueForTest(
|
||||
Slice("key"), /*blob_fetcher=*/nullptr, &value, /*columns=*/nullptr, &s,
|
||||
&is_blob_index));
|
||||
|
||||
ASSERT_TRUE(s.IsNotSupported()) << s.ToString();
|
||||
ASSERT_TRUE(value.empty());
|
||||
ASSERT_FALSE(is_blob_index);
|
||||
}
|
||||
|
||||
TEST_F(DBBlobIndexTest, ReadOnlyGetImplReturnsBlobIndexWhenRequested) {
|
||||
// Goal: cover the internal read-only GetImpl contract when the caller
|
||||
// explicitly asks for raw blob-index bytes via `is_blob_index`. Recovery
|
||||
// keeps the blob index in the memtable, and the read-only path must preserve
|
||||
// the encoded index instead of eagerly resolving or rejecting it.
|
||||
Options options = GetTestOptions();
|
||||
|
||||
DestroyAndReopen(options);
|
||||
|
||||
std::string blob_index;
|
||||
BlobIndex::EncodeInlinedTTL(&blob_index, /*expiration=*/9876543210, "blob");
|
||||
|
||||
WriteBatch batch;
|
||||
ASSERT_OK(PutBlobIndex(&batch, "blob_key", blob_index));
|
||||
ASSERT_OK(Write(&batch));
|
||||
|
||||
Close();
|
||||
options.avoid_flush_during_recovery = true;
|
||||
ASSERT_OK(ReadOnlyReopen(options));
|
||||
|
||||
bool is_blob_index = false;
|
||||
ASSERT_EQ(blob_index, GetImpl("blob_key", &is_blob_index));
|
||||
ASSERT_TRUE(is_blob_index);
|
||||
}
|
||||
|
||||
class PlainBlobValueFilterV3 : public CompactionFilter {
|
||||
public:
|
||||
PlainBlobValueFilterV3(std::atomic<int>* filter_call_count,
|
||||
@@ -2138,7 +2197,7 @@ TEST_F(DBBlobIndexTest, EntityBlobFilterV3Remove) {
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
// Compact to trigger the filter — entity should be removed
|
||||
// Compact to trigger the filter -- entity should be removed
|
||||
ASSERT_OK(db_->CompactRange(CompactRangeOptions(), nullptr, nullptr));
|
||||
|
||||
// Key should be gone
|
||||
|
||||
+13
-14
@@ -612,7 +612,7 @@ ColumnFamilyData::ColumnFamilyData(
|
||||
const FileOptions* file_options, ColumnFamilySet* column_family_set,
|
||||
BlockCacheTracer* const block_cache_tracer,
|
||||
const std::shared_ptr<IOTracer>& io_tracer, const std::string& db_id,
|
||||
const std::string& db_session_id, bool read_only)
|
||||
const std::string& db_session_id, bool read_only, bool fast_sst_open)
|
||||
: id_(id),
|
||||
name_(name),
|
||||
dummy_versions_(_dummy_versions),
|
||||
@@ -672,7 +672,7 @@ ColumnFamilyData::ColumnFamilyData(
|
||||
new InternalStats(ioptions_.num_levels, ioptions_.clock, this));
|
||||
table_cache_.reset(new TableCache(ioptions_, file_options, _table_cache,
|
||||
block_cache_tracer, io_tracer,
|
||||
db_session_id));
|
||||
db_session_id, fast_sst_open));
|
||||
blob_file_cache_.reset(
|
||||
new BlobFileCache(_table_cache, &ioptions(), soptions(), id_,
|
||||
internal_stats_->GetBlobFileReadHist(), io_tracer));
|
||||
@@ -1809,16 +1809,14 @@ void ColumnFamilyData::RecoverEpochNumbers() {
|
||||
vstorage->RecoverEpochNumbers(this);
|
||||
}
|
||||
|
||||
ColumnFamilySet::ColumnFamilySet(const std::string& dbname,
|
||||
const ImmutableDBOptions* db_options,
|
||||
const FileOptions& file_options,
|
||||
Cache* table_cache,
|
||||
WriteBufferManager* _write_buffer_manager,
|
||||
WriteController* _write_controller,
|
||||
BlockCacheTracer* const block_cache_tracer,
|
||||
const std::shared_ptr<IOTracer>& io_tracer,
|
||||
const std::string& db_id,
|
||||
const std::string& db_session_id)
|
||||
ColumnFamilySet::ColumnFamilySet(
|
||||
const std::string& dbname, const ImmutableDBOptions* db_options,
|
||||
const FileOptions& file_options, Cache* table_cache,
|
||||
WriteBufferManager* _write_buffer_manager,
|
||||
WriteController* _write_controller,
|
||||
BlockCacheTracer* const block_cache_tracer,
|
||||
const std::shared_ptr<IOTracer>& io_tracer, const std::string& db_id,
|
||||
const std::string& db_session_id, bool fast_sst_open)
|
||||
: max_column_family_(0),
|
||||
file_options_(file_options),
|
||||
dummy_cfd_(new ColumnFamilyData(
|
||||
@@ -1835,7 +1833,8 @@ ColumnFamilySet::ColumnFamilySet(const std::string& dbname,
|
||||
block_cache_tracer_(block_cache_tracer),
|
||||
io_tracer_(io_tracer),
|
||||
db_id_(db_id),
|
||||
db_session_id_(db_session_id) {
|
||||
db_session_id_(db_session_id),
|
||||
fast_sst_open_(fast_sst_open) {
|
||||
// initialize linked list
|
||||
dummy_cfd_->prev_ = dummy_cfd_;
|
||||
dummy_cfd_->next_ = dummy_cfd_;
|
||||
@@ -1902,7 +1901,7 @@ ColumnFamilyData* ColumnFamilySet::CreateColumnFamily(
|
||||
ColumnFamilyData* new_cfd = new ColumnFamilyData(
|
||||
id, name, dummy_versions, table_cache_, write_buffer_manager_, options,
|
||||
*db_options_, &file_options_, this, block_cache_tracer_, io_tracer_,
|
||||
db_id_, db_session_id_, read_only);
|
||||
db_id_, db_session_id_, read_only, fast_sst_open_);
|
||||
column_families_.insert({name, id});
|
||||
column_family_data_.insert({id, new_cfd});
|
||||
auto ucmp = new_cfd->user_comparator();
|
||||
|
||||
+25
-9
@@ -619,16 +619,23 @@ class ColumnFamilyData {
|
||||
return ioptions_.cf_allow_ingest_behind || ioptions_.allow_ingest_behind;
|
||||
}
|
||||
|
||||
// Per-CF reader-writer lock that serializes IngestExternalFiles with range
|
||||
// tombstone conversion.
|
||||
port::RWMutex& GetIngestSstLock() { return ingest_sst_lock_; }
|
||||
|
||||
private:
|
||||
friend class ColumnFamilySet;
|
||||
ColumnFamilyData(
|
||||
uint32_t id, const std::string& name, Version* dummy_versions,
|
||||
Cache* table_cache, WriteBufferManager* write_buffer_manager,
|
||||
const ColumnFamilyOptions& options, const ImmutableDBOptions& db_options,
|
||||
const FileOptions* file_options, ColumnFamilySet* column_family_set,
|
||||
BlockCacheTracer* const block_cache_tracer,
|
||||
const std::shared_ptr<IOTracer>& io_tracer, const std::string& db_id,
|
||||
const std::string& db_session_id, bool read_only);
|
||||
ColumnFamilyData(uint32_t id, const std::string& name,
|
||||
Version* dummy_versions, Cache* table_cache,
|
||||
WriteBufferManager* write_buffer_manager,
|
||||
const ColumnFamilyOptions& options,
|
||||
const ImmutableDBOptions& db_options,
|
||||
const FileOptions* file_options,
|
||||
ColumnFamilySet* column_family_set,
|
||||
BlockCacheTracer* const block_cache_tracer,
|
||||
const std::shared_ptr<IOTracer>& io_tracer,
|
||||
const std::string& db_id, const std::string& db_session_id,
|
||||
bool read_only, bool fast_sst_open = false);
|
||||
|
||||
std::vector<std::string> GetDbPaths() const;
|
||||
|
||||
@@ -729,6 +736,10 @@ class ColumnFamilyData {
|
||||
bool mempurge_used_;
|
||||
|
||||
std::atomic<uint64_t> next_epoch_number_;
|
||||
|
||||
// Used to synchronize IngestExternalFile with range tombstone conversion. See
|
||||
// also Memtable::ingest_seqno_barrier_.
|
||||
port::RWMutex ingest_sst_lock_;
|
||||
};
|
||||
|
||||
// ColumnFamilySet has interesting thread-safety requirements
|
||||
@@ -774,7 +785,8 @@ class ColumnFamilySet {
|
||||
WriteController* _write_controller,
|
||||
BlockCacheTracer* const block_cache_tracer,
|
||||
const std::shared_ptr<IOTracer>& io_tracer,
|
||||
const std::string& db_id, const std::string& db_session_id);
|
||||
const std::string& db_id, const std::string& db_session_id,
|
||||
bool fast_sst_open = false);
|
||||
~ColumnFamilySet();
|
||||
|
||||
ColumnFamilyData* GetDefault() const;
|
||||
@@ -810,6 +822,9 @@ class ColumnFamilySet {
|
||||
|
||||
Cache* get_table_cache() { return table_cache_; }
|
||||
|
||||
bool GetFastSstOpen() const { return fast_sst_open_; }
|
||||
void SetFastSstOpen(bool v) { fast_sst_open_ = v; }
|
||||
|
||||
WriteBufferManager* write_buffer_manager() { return write_buffer_manager_; }
|
||||
|
||||
WriteController* write_controller() { return write_controller_; }
|
||||
@@ -858,6 +873,7 @@ class ColumnFamilySet {
|
||||
std::shared_ptr<IOTracer> io_tracer_;
|
||||
const std::string& db_id_;
|
||||
std::string db_session_id_;
|
||||
bool fast_sst_open_;
|
||||
};
|
||||
|
||||
// A wrapper for ColumnFamilySet that supports releasing DB mutex during each
|
||||
|
||||
@@ -734,6 +734,42 @@ TEST_P(ColumnFamilyTest, AddDrop) {
|
||||
std::vector<std::string>({"default", "four", "three"}));
|
||||
}
|
||||
|
||||
TEST_P(ColumnFamilyTest, EmptyNameRejected) {
|
||||
// Creating a column family with an empty-string name should be rejected with
|
||||
// InvalidArgument. Empty strings are reserved by various RocksDB APIs and
|
||||
// serialization formats to mean "unknown/unspecified column family" (e.g.
|
||||
// TablePropertiesCollectorFactory::Context::kUnknownColumnFamily, table
|
||||
// properties).
|
||||
//
|
||||
// Prior behavior was silently broken: CreateColumnFamily("") would return
|
||||
// OK and a usable handle, but the CF was not persisted in the manifest --
|
||||
// data written to it was lost on DB reopen.
|
||||
Open();
|
||||
ColumnFamilyHandle* handle = nullptr;
|
||||
Status s =
|
||||
db_->CreateColumnFamily(column_family_options_, std::string(), &handle);
|
||||
ASSERT_TRUE(s.IsInvalidArgument()) << s.ToString();
|
||||
ASSERT_EQ(handle, nullptr);
|
||||
|
||||
// Same via the bulk-by-names API. The first valid name should still get
|
||||
// created; the failure should come on the empty one.
|
||||
std::vector<ColumnFamilyHandle*> handles;
|
||||
s = db_->CreateColumnFamilies(column_family_options_,
|
||||
{"ok_name", "", "another"}, &handles);
|
||||
ASSERT_TRUE(s.IsInvalidArgument()) << s.ToString();
|
||||
// Clean up the partially-created handle ("ok_name") before the failure.
|
||||
ASSERT_EQ(handles.size(), 1U);
|
||||
ASSERT_OK(db_->DropColumnFamily(handles[0]));
|
||||
ASSERT_OK(db_->DestroyColumnFamilyHandle(handles[0]));
|
||||
handles.clear();
|
||||
|
||||
// Same via the bulk-by-descriptor API.
|
||||
s = db_->CreateColumnFamilies(
|
||||
{ColumnFamilyDescriptor("", column_family_options_)}, &handles);
|
||||
ASSERT_TRUE(s.IsInvalidArgument()) << s.ToString();
|
||||
ASSERT_TRUE(handles.empty());
|
||||
}
|
||||
|
||||
TEST_P(ColumnFamilyTest, BulkAddDrop) {
|
||||
constexpr int kNumCF = 1000;
|
||||
ColumnFamilyOptions cf_options;
|
||||
|
||||
@@ -75,7 +75,7 @@ class CompactionBlobResolver : public WideColumnBlobResolver {
|
||||
bool track_resolve_error_ = false;
|
||||
|
||||
// Cache for resolved blob values to avoid re-fetching. Uses a vector of
|
||||
// (column_index, PinnableSlice) pairs — typical entities have few blob
|
||||
// (column_index, PinnableSlice) pairs -- typical entities have few blob
|
||||
// columns (<5), making linear scan cheaper than hash map overhead.
|
||||
std::vector<std::pair<size_t, std::unique_ptr<PinnableSlice>>>
|
||||
resolved_cache_;
|
||||
|
||||
@@ -251,7 +251,7 @@ Compaction* FIFOCompactionPicker::PickSizeCompaction(
|
||||
// When using blob-aware sizing, use proportional estimation (same
|
||||
// principle as EstimateTotalDataForSST): each SST "owns"
|
||||
// effective_size / num_files of total data. This is an approximation
|
||||
// — individual SSTs may reference different amounts of blob data,
|
||||
// -- individual SSTs may reference different amounts of blob data,
|
||||
// but uniform distribution is a reasonable estimate for FIFO dropping.
|
||||
uint64_t remaining_size = effective_size;
|
||||
const uint64_t num_files = last_level_files.size();
|
||||
@@ -477,12 +477,12 @@ Compaction* FIFOCompactionPicker::PickIntraL0Compaction(
|
||||
if (fifo_opts.max_data_files_size == 0) {
|
||||
ROCKS_LOG_BUFFER(
|
||||
log_buffer,
|
||||
"[%s] FIFO kv-ratio compaction: skipping — "
|
||||
"[%s] FIFO kv-ratio compaction: skipping -- "
|
||||
"max_data_files_size is 0, cannot compute target file size. ",
|
||||
cf_name.c_str());
|
||||
} else if (fifo_opts.max_data_files_size < fifo_opts.max_table_files_size) {
|
||||
ROCKS_LOG_BUFFER(log_buffer,
|
||||
"[%s] FIFO kv-ratio compaction: skipping — "
|
||||
"[%s] FIFO kv-ratio compaction: skipping -- "
|
||||
"max_data_files_size (%" PRIu64
|
||||
") < max_table_files_size "
|
||||
"(%" PRIu64 ").",
|
||||
@@ -547,7 +547,7 @@ Compaction* FIFOCompactionPicker::PickRatioBasedIntraL0Compaction(
|
||||
for (int level = 1; level < vstorage->num_levels(); ++level) {
|
||||
if (!vstorage->LevelFiles(level).empty()) {
|
||||
ROCKS_LOG_BUFFER(log_buffer,
|
||||
"[%s] FIFO kv-ratio compaction: skipping — non-L0 "
|
||||
"[%s] FIFO kv-ratio compaction: skipping -- non-L0 "
|
||||
"level %d still has %" ROCKSDB_PRIszt
|
||||
" files (migration in progress)",
|
||||
cf_name.c_str(), level,
|
||||
@@ -587,7 +587,7 @@ Compaction* FIFOCompactionPicker::PickRatioBasedIntraL0Compaction(
|
||||
// once per flush or compaction completion, so no caching is needed.
|
||||
uint64_t target = 0;
|
||||
if (mutable_cf_options.max_compaction_bytes > 0) {
|
||||
// User explicitly set max_compaction_bytes — use it as target
|
||||
// User explicitly set max_compaction_bytes -- use it as target
|
||||
target = mutable_cf_options.max_compaction_bytes;
|
||||
} else {
|
||||
// Auto-calculate from capacity and observed SST/blob ratio
|
||||
@@ -642,7 +642,7 @@ Compaction* FIFOCompactionPicker::PickRatioBasedIntraL0Compaction(
|
||||
// of linear) write amplification.
|
||||
|
||||
// Build tier boundaries from smallest to largest.
|
||||
// Stop at 10KB minimum — SST files of most workloads are larger than
|
||||
// Stop at 10KB minimum -- SST files of most workloads are larger than
|
||||
// this, so lower boundaries would only waste CPU scanning L0 files.
|
||||
// Files smaller than the lowest boundary simply merge at that boundary.
|
||||
static constexpr uint64_t kMinTierBoundary = 10 * 1024; // 10KB
|
||||
@@ -651,7 +651,7 @@ Compaction* FIFOCompactionPicker::PickRatioBasedIntraL0Compaction(
|
||||
boundaries.push_back(b);
|
||||
}
|
||||
if (boundaries.empty()) {
|
||||
// target itself is below kMinTierBoundary — use target as the
|
||||
// target itself is below kMinTierBoundary -- use target as the
|
||||
// sole boundary so we can still compact at the target size.
|
||||
boundaries.push_back(target);
|
||||
}
|
||||
@@ -668,7 +668,7 @@ Compaction* FIFOCompactionPicker::PickRatioBasedIntraL0Compaction(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found a file < boundary — collect contiguous batch
|
||||
// Found a file < boundary -- collect contiguous batch
|
||||
std::vector<FileMetaData*> batch;
|
||||
uint64_t accumulated = 0;
|
||||
size_t pos = scan;
|
||||
@@ -713,7 +713,7 @@ Compaction* FIFOCompactionPicker::PickRatioBasedIntraL0Compaction(
|
||||
return c;
|
||||
}
|
||||
|
||||
// This batch wasn't enough — advance past it
|
||||
// This batch wasn't enough -- advance past it
|
||||
scan = pos;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,7 +1101,7 @@ TEST_F(CompactionPickerTest, ReadTriggeredSkipsLastLevel) {
|
||||
Add(0, 1U, "150", "200", kFileSize, 0, 500, 550);
|
||||
Add(4, 3U, "301", "350", kFileSize, 0, 101, 150);
|
||||
|
||||
// File 3 is at the last non-empty level — should NOT be marked for
|
||||
// File 3 is at the last non-empty level -- should NOT be marked for
|
||||
// read-triggered compaction. Bottommost file cleanup is handled
|
||||
// separately by ComputeBottommostFilesMarkedForCompaction().
|
||||
file_map_[3U].first->stats.num_collapsible_entry_reads_sampled.store(
|
||||
|
||||
@@ -846,11 +846,16 @@ static std::unordered_map<std::string, OptionTypeInfo>
|
||||
{offsetof(struct InternalStats::CompactionStats, count),
|
||||
OptionType::kUInt64T, OptionVerificationType::kNormal,
|
||||
OptionTypeFlags::kNone}},
|
||||
{"counts", OptionTypeInfo::Array<
|
||||
int, static_cast<int>(CompactionReason::kNumOfReasons)>(
|
||||
offsetof(struct InternalStats::CompactionStats, counts),
|
||||
OptionVerificationType::kNormal, OptionTypeFlags::kNone,
|
||||
{0, OptionType::kInt})},
|
||||
{"counts",
|
||||
OptionTypeInfo::Array<
|
||||
/* In release 11.2, a new compaction reason was added. This broken
|
||||
* the reader and writer. To unblock release 11.1, we temporarily
|
||||
* reduce the count array size to the old one. TODO add a proper
|
||||
* serialization and deserialization method. */
|
||||
int, static_cast<int>(CompactionReason::kNumOfReasons) - 1>(
|
||||
offsetof(struct InternalStats::CompactionStats, counts),
|
||||
OptionVerificationType::kNormal, OptionTypeFlags::kNone,
|
||||
{0, OptionType::kInt})},
|
||||
};
|
||||
|
||||
static std::unordered_map<std::string, OptionTypeInfo>
|
||||
|
||||
@@ -102,6 +102,757 @@ TEST_F(DBBasicTest, OpenWhenOpen) {
|
||||
ASSERT_TRUE(strstr(s.getState(), "lock ") != nullptr);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Helper that captures per-branch SkippedNoopEdit counts for tests.
|
||||
struct RecoveryOptimizationCounters {
|
||||
std::atomic<int> setup_dbid{0};
|
||||
std::atomic<int> per_cf{0};
|
||||
std::atomic<int> wal_deletion{0};
|
||||
std::atomic<int> next_file_number{0};
|
||||
|
||||
void Install() {
|
||||
auto* sp = ROCKSDB_NAMESPACE::SyncPoint::GetInstance();
|
||||
sp->SetCallBack("DBImpl::Recovery:SkippedNoopEdit:SetupDBId",
|
||||
[this](void*) { setup_dbid.fetch_add(1); });
|
||||
sp->SetCallBack("DBImpl::Recovery:SkippedNoopEdit:PerCF",
|
||||
[this](void*) { per_cf.fetch_add(1); });
|
||||
sp->SetCallBack("DBImpl::Recovery:SkippedNoopEdit:WalDeletion",
|
||||
[this](void*) { wal_deletion.fetch_add(1); });
|
||||
sp->SetCallBack("DBImpl::Recovery:SkippedNoopEdit:NextFileNumber",
|
||||
[this](void*) { next_file_number.fetch_add(1); });
|
||||
sp->EnableProcessing();
|
||||
}
|
||||
void Uninstall() {
|
||||
auto* sp = ROCKSDB_NAMESPACE::SyncPoint::GetInstance();
|
||||
sp->DisableProcessing();
|
||||
sp->ClearAllCallBacks();
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
// optimize_manifest_for_recovery=true: a clean reopen of a flushed DB must
|
||||
// append fewer individual records to the MANIFEST than the default-off path.
|
||||
// Verified by counting AddRecord calls (one per VersionEdit written to the
|
||||
// MANIFEST log).
|
||||
TEST_F(DBBasicTest,
|
||||
OptimizeManifestForRecoveryReducesManifestWritesOnCleanReopen) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
auto* sp = ROCKSDB_NAMESPACE::SyncPoint::GetInstance();
|
||||
|
||||
// Measure MANIFEST records with optimize_manifest_for_recovery OFF (default).
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k1", "v1"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
std::atomic<int> records_off{0};
|
||||
std::atomic<int> next_file_skips_off{0};
|
||||
sp->SetCallBack("VersionSet::ProcessManifestWrites:AddRecord",
|
||||
[&](void*) { records_off.fetch_add(1); });
|
||||
sp->SetCallBack("DBImpl::Recovery:SkippedNoopEdit:NextFileNumber",
|
||||
[&](void*) { next_file_skips_off.fetch_add(1); });
|
||||
sp->EnableProcessing();
|
||||
Reopen(options);
|
||||
sp->DisableProcessing();
|
||||
sp->ClearAllCallBacks();
|
||||
ASSERT_EQ("v1", Get("k1"));
|
||||
int off_count = records_off.load();
|
||||
ASSERT_GT(off_count, 0);
|
||||
ASSERT_EQ(0, next_file_skips_off.load());
|
||||
Close();
|
||||
|
||||
// Measure MANIFEST records with optimize_manifest_for_recovery ON.
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k2", "v2"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
std::atomic<int> records_on{0};
|
||||
std::atomic<int> next_file_skips_on{0};
|
||||
sp->SetCallBack("VersionSet::ProcessManifestWrites:AddRecord",
|
||||
[&](void*) { records_on.fetch_add(1); });
|
||||
sp->SetCallBack("DBImpl::Recovery:SkippedNoopEdit:NextFileNumber",
|
||||
[&](void*) { next_file_skips_on.fetch_add(1); });
|
||||
sp->EnableProcessing();
|
||||
Reopen(options);
|
||||
sp->DisableProcessing();
|
||||
sp->ClearAllCallBacks();
|
||||
ASSERT_EQ("v2", Get("k2"));
|
||||
int on_count = records_on.load();
|
||||
ASSERT_GT(next_file_skips_on.load(), 0);
|
||||
ASSERT_LT(on_count, off_count);
|
||||
}
|
||||
|
||||
// When the per-CF log_number actually advances, the per-CF skip must NOT
|
||||
// fire -- the edit carries real information and must be emitted.
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoveryEmitsPerCFWhenLogAdvances) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
// Write data and close WITHOUT flushing -- the WAL has un-replayed
|
||||
// records, so on reopen recovery flushes the memtable and the per-CF
|
||||
// edit's log_number must advance past the prior WAL.
|
||||
Close();
|
||||
|
||||
RecoveryOptimizationCounters counters;
|
||||
counters.Install();
|
||||
Reopen(options);
|
||||
counters.Uninstall();
|
||||
|
||||
ASSERT_EQ(0, counters.per_cf.load());
|
||||
ASSERT_EQ("v", Get("k"));
|
||||
}
|
||||
|
||||
// With track_and_verify_wals_in_manifest=true, the wal_deletion edit
|
||||
// must STILL be emitted (the DeleteWalsBefore record is required).
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoveryPreservesWalTracking) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
options.track_and_verify_wals_in_manifest = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
RecoveryOptimizationCounters counters;
|
||||
counters.Install();
|
||||
Reopen(options);
|
||||
counters.Uninstall();
|
||||
|
||||
ASSERT_EQ(0, counters.wal_deletion.load());
|
||||
}
|
||||
|
||||
// best_efforts_recovery requires a fresh MANIFEST + CURRENT to be
|
||||
// produced on every open (the salvage contract). Even with the option
|
||||
// on, none of the SkippedNoopEdit branches must fire under
|
||||
// best_efforts_recovery -- otherwise CURRENT can be left missing or
|
||||
// stale (regression caught by DBBasicTest.RecoverWithNoCurrentFile and
|
||||
// DBTest2.BestEffortsRecoveryWithSstUniqueIdVerification under an
|
||||
// option-on default).
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoveryDisabledByBestEffortsRecovery) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
options.best_efforts_recovery = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
RecoveryOptimizationCounters counters;
|
||||
counters.Install();
|
||||
Reopen(options);
|
||||
counters.Uninstall();
|
||||
|
||||
ASSERT_EQ(0, counters.setup_dbid.load());
|
||||
ASSERT_EQ(0, counters.per_cf.load());
|
||||
ASSERT_EQ(0, counters.wal_deletion.load());
|
||||
ASSERT_EQ(0, counters.next_file_number.load());
|
||||
ASSERT_EQ("v", Get("k"));
|
||||
}
|
||||
|
||||
// Multi-column-family coverage: with one CF flushed and another not,
|
||||
// the un-flushed CF's edit must still be emitted.
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoveryMultiCF) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
CreateAndReopenWithCF({"pikachu", "raichu"}, options);
|
||||
ASSERT_OK(Put(0, "k", "v0"));
|
||||
ASSERT_OK(Put(1, "k", "v1"));
|
||||
ASSERT_OK(Put(2, "k", "v2"));
|
||||
ASSERT_OK(Flush(0));
|
||||
// CF 1 and 2 keep dirty memtables -- recovery must emit their per-CF
|
||||
// edits (log_number advance from WAL replay).
|
||||
Close();
|
||||
|
||||
RecoveryOptimizationCounters counters;
|
||||
counters.Install();
|
||||
ReopenWithColumnFamilies({"default", "pikachu", "raichu"}, options);
|
||||
counters.Uninstall();
|
||||
|
||||
ASSERT_EQ("v0", Get(0, "k"));
|
||||
ASSERT_EQ("v1", Get(1, "k"));
|
||||
ASSERT_EQ("v2", Get(2, "k"));
|
||||
}
|
||||
|
||||
// MaybeUpdateNextFileNumber's seed value (next_file_number_) makes the
|
||||
// post-loop comparison trivially true on every clean recovery, causing a
|
||||
// no-op SetNextFile edit to be appended to the MANIFEST. With
|
||||
// optimize_manifest_for_recovery=true, that emission is gated and
|
||||
// the SkippedNoopEdit:NextFileNumber sync point fires in its place.
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoverySkipsNextFileNumber) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
RecoveryOptimizationCounters counters;
|
||||
counters.Install();
|
||||
Reopen(options);
|
||||
counters.Uninstall();
|
||||
|
||||
ASSERT_EQ(1, counters.next_file_number.load());
|
||||
}
|
||||
|
||||
// With option=true and a synthetic on-disk file whose number is at or
|
||||
// above next_file_number_, MaybeUpdateNextFileNumber MUST emit the
|
||||
// SetNextFile edit and advance the counter past the synthetic number.
|
||||
TEST_F(DBBasicTest,
|
||||
OptimizeManifestForRecoveryEmitsNextFileNumberWhenJustified) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
const uint64_t next_before_close =
|
||||
dbfull()->GetVersionSet()->current_next_file_number();
|
||||
Close();
|
||||
|
||||
// Drop an empty .sst with a number well above next_file_number into
|
||||
// the DB dir. MaybeUpdateNextFileNumber must observe it and advance.
|
||||
const uint64_t synthetic_number = next_before_close + 100;
|
||||
const std::string synthetic_sst =
|
||||
dbname_ + "/" + MakeTableFileName("", synthetic_number);
|
||||
ASSERT_OK(WriteStringToFile(env_, "" /*data*/, synthetic_sst,
|
||||
/*should_sync=*/true));
|
||||
|
||||
RecoveryOptimizationCounters counters;
|
||||
counters.Install();
|
||||
Reopen(options);
|
||||
counters.Uninstall();
|
||||
|
||||
ASSERT_EQ(0, counters.next_file_number.load());
|
||||
ASSERT_GT(dbfull()->GetVersionSet()->current_next_file_number(),
|
||||
synthetic_number);
|
||||
}
|
||||
|
||||
// optimize_manifest_for_recovery=true: after a clean Put + Flush + Close, the
|
||||
// next Open's min_log_number_to_keep equals (max_wal_before_close + 1),
|
||||
// proving the close-time MANIFEST write took effect.
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoveryAdvancesWalMarkersOnClose) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
uint64_t max_wal_before_close = 0;
|
||||
auto* sp = ROCKSDB_NAMESPACE::SyncPoint::GetInstance();
|
||||
sp->SetCallBack("DBImpl::CloseHelper:CapturedMaxWal", [&](void* arg) {
|
||||
max_wal_before_close = *static_cast<uint64_t*>(arg);
|
||||
});
|
||||
sp->EnableProcessing();
|
||||
Close();
|
||||
sp->DisableProcessing();
|
||||
sp->ClearAllCallBacks();
|
||||
ASSERT_GT(max_wal_before_close, 0u);
|
||||
|
||||
Reopen(options);
|
||||
ASSERT_EQ(max_wal_before_close + 1,
|
||||
dbfull()->GetVersionSet()->min_log_number_to_keep());
|
||||
ASSERT_EQ("v", Get("k"));
|
||||
}
|
||||
|
||||
// Shared-option matrix: default-off and disable-before-close should both fall
|
||||
// back to recovery-time MANIFEST work, while enable-through-close should avoid
|
||||
// MANIFEST appends on a clean reopen.
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoveryCleanReopenMatrix) {
|
||||
struct TestCase {
|
||||
const char* name;
|
||||
bool enable_on_open;
|
||||
bool disable_before_close;
|
||||
bool expect_close_write;
|
||||
};
|
||||
const TestCase test_cases[] = {
|
||||
{"default_off", false, false, false},
|
||||
{"enabled", true, false, true},
|
||||
{"disabled_before_close", true, true, false},
|
||||
};
|
||||
|
||||
auto* sp = ROCKSDB_NAMESPACE::SyncPoint::GetInstance();
|
||||
for (const auto& test_case : test_cases) {
|
||||
SCOPED_TRACE(test_case.name);
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = test_case.enable_on_open;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", test_case.name));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
if (test_case.disable_before_close) {
|
||||
ASSERT_OK(dbfull()->SetDBOptions(
|
||||
{{"optimize_manifest_for_recovery", "false"}}));
|
||||
}
|
||||
|
||||
std::atomic<int> entered{0};
|
||||
sp->SetCallBack("DBImpl::CloseHelper:WriteWalMarkersOnCloseEntered",
|
||||
[&](void*) { entered.fetch_add(1); });
|
||||
sp->EnableProcessing();
|
||||
Close();
|
||||
sp->DisableProcessing();
|
||||
sp->ClearAllCallBacks();
|
||||
|
||||
std::atomic<int> records{0};
|
||||
RecoveryOptimizationCounters counters;
|
||||
counters.Install();
|
||||
sp->SetCallBack("VersionSet::ProcessManifestWrites:AddRecord",
|
||||
[&](void*) { records.fetch_add(1); });
|
||||
Reopen(options);
|
||||
counters.Uninstall();
|
||||
|
||||
if (test_case.expect_close_write) {
|
||||
ASSERT_EQ(1, entered.load());
|
||||
ASSERT_EQ(0, records.load());
|
||||
ASSERT_GT(counters.per_cf.load(), 0);
|
||||
} else {
|
||||
ASSERT_EQ(0, entered.load());
|
||||
ASSERT_GT(records.load(), 0);
|
||||
}
|
||||
ASSERT_EQ(test_case.name, Get("k"));
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
// allow_2pc=true: even with the option on, the close-time write must NOT
|
||||
// advance MinLogNumberToKeep / DeleteWalsBefore (2pc requires the WAL to
|
||||
// remain replayable for uncommitted prepared transactions).
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoveryRespectsTwoPC) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
options.allow_2pc = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
const uint64_t min_log_before_close =
|
||||
dbfull()->GetVersionSet()->min_log_number_to_keep();
|
||||
Close();
|
||||
Reopen(options);
|
||||
ASSERT_EQ(min_log_before_close,
|
||||
dbfull()->GetVersionSet()->min_log_number_to_keep());
|
||||
ASSERT_EQ("v", Get("k"));
|
||||
}
|
||||
|
||||
// Mixed-CF emptiness: on reopen, recovery should still have MANIFEST work to
|
||||
// do for the dirty CF while skipping per-CF recovery edits for the empty CFs
|
||||
// whose markers were persisted at close.
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoverySkipsNonEmptyCF) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
CreateAndReopenWithCF({"empty_cf", "dirty_cf"}, options);
|
||||
ASSERT_OK(Put(1, "k", "v1"));
|
||||
ASSERT_OK(Flush(1));
|
||||
ASSERT_OK(Put(2, "k", "v2"));
|
||||
Close();
|
||||
|
||||
RecoveryOptimizationCounters counters;
|
||||
std::atomic<int> records{0};
|
||||
auto* sp = ROCKSDB_NAMESPACE::SyncPoint::GetInstance();
|
||||
counters.Install();
|
||||
sp->SetCallBack("VersionSet::ProcessManifestWrites:AddRecord",
|
||||
[&](void*) { records.fetch_add(1); });
|
||||
ReopenWithColumnFamilies({"default", "empty_cf", "dirty_cf"}, options);
|
||||
counters.Uninstall();
|
||||
|
||||
ASSERT_GT(counters.per_cf.load(), 0);
|
||||
ASSERT_GT(records.load(), 0);
|
||||
ASSERT_EQ("v1", Get(1, "k"));
|
||||
ASSERT_EQ("v2", Get(2, "k"));
|
||||
}
|
||||
|
||||
// track_and_verify_wals_in_manifest=true: the close-time write must leave
|
||||
// recovery with only the mandatory WAL-tracking MANIFEST work. The per-CF
|
||||
// recovery edits should still skip.
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoveryEmitsWalDeletionWhenTracking) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
options.track_and_verify_wals_in_manifest = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
RecoveryOptimizationCounters counters;
|
||||
std::atomic<int> records{0};
|
||||
auto* sp = ROCKSDB_NAMESPACE::SyncPoint::GetInstance();
|
||||
counters.Install();
|
||||
sp->SetCallBack("VersionSet::ProcessManifestWrites:AddRecord",
|
||||
[&](void*) { records.fetch_add(1); });
|
||||
Reopen(options);
|
||||
counters.Uninstall();
|
||||
|
||||
ASSERT_GT(counters.per_cf.load(), 0);
|
||||
ASSERT_EQ(1, records.load());
|
||||
ASSERT_GT(dbfull()->GetVersionSet()->GetWalSet().GetMinWalNumberToKeep(), 0u);
|
||||
ASSERT_EQ("v", Get("k"));
|
||||
}
|
||||
|
||||
// Regression for the close-time marker path: if a new WAL is created while an
|
||||
// otherwise-empty user CF stays empty, Close() must reserve the next file
|
||||
// number before persisting SetLogNumber(cur_wal + 1) for that CF.
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoveryImmediateCloseAfterWarmReopen) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
CreateAndReopenWithCF({"empty_cf"}, options);
|
||||
|
||||
ASSERT_OK(Put(0, "k", "v"));
|
||||
const uint64_t wal_before_switch = dbfull()->TEST_LogfileNumber();
|
||||
ASSERT_OK(dbfull()->TEST_SwitchMemtable());
|
||||
ASSERT_GT(dbfull()->TEST_LogfileNumber(), wal_before_switch);
|
||||
|
||||
const uint64_t expected_new_log_num = dbfull()->TEST_LogfileNumber() + 1;
|
||||
ASSERT_EQ(expected_new_log_num,
|
||||
dbfull()->GetVersionSet()->current_next_file_number());
|
||||
|
||||
Close();
|
||||
ReopenWithColumnFamilies({"default", "empty_cf"}, options);
|
||||
|
||||
ASSERT_GT(dbfull()->GetVersionSet()->current_next_file_number(),
|
||||
expected_new_log_num);
|
||||
ASSERT_EQ("v", Get(0, "k"));
|
||||
}
|
||||
|
||||
// Dropped-CF safety: dropped column families must NOT have markers written for
|
||||
// them at close time (the !IsDropped() guard protects against attaching the
|
||||
// global edit to a dropped CF, which would later trip MANIFEST replay
|
||||
// assertions).
|
||||
TEST_F(DBBasicTest, OptimizeManifestForRecoverySkipsDroppedCF) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
CreateAndReopenWithCF({"to_drop", "keeper"}, options);
|
||||
ASSERT_OK(Put(1, "k", "v1"));
|
||||
ASSERT_OK(Put(2, "k", "v2"));
|
||||
ASSERT_OK(Flush(1));
|
||||
ASSERT_OK(Flush(2));
|
||||
ASSERT_OK(db_->DropColumnFamily(handles_[1]));
|
||||
Close();
|
||||
|
||||
ReopenWithColumnFamilies({"default", "keeper"}, options);
|
||||
ASSERT_EQ("v2", Get(1, "k"));
|
||||
}
|
||||
|
||||
// reuse_manifest_on_open=true: the next LogAndApply after Recover must
|
||||
// append to the existing MANIFEST file instead of allocating a fresh
|
||||
// one. Force a write after reopen and verify the MANIFEST file number
|
||||
// stays unchanged.
|
||||
TEST_F(DBBasicTest, ReuseManifestOnOpenAppendsToExistingFile) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.reuse_manifest_on_open = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
Reopen(options);
|
||||
const uint64_t manifest_after_reopen =
|
||||
dbfull()->TEST_Current_Manifest_FileNo();
|
||||
ASSERT_OK(Put("k2", "v2"));
|
||||
ASSERT_OK(Flush());
|
||||
const uint64_t manifest_after_flush =
|
||||
dbfull()->TEST_Current_Manifest_FileNo();
|
||||
|
||||
EXPECT_EQ(manifest_after_reopen, manifest_after_flush);
|
||||
EXPECT_EQ("v", Get("k"));
|
||||
EXPECT_EQ("v2", Get("k2"));
|
||||
}
|
||||
|
||||
// Default off: ReopenManifestForAppend must NOT be invoked.
|
||||
TEST_F(DBBasicTest, ReuseManifestOnOpenDefaultOffSkipsReopen) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
// reuse_manifest_on_open defaults to false
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
std::atomic<int> reopened{0};
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"VersionSet::ReopenManifestForAppend:Reopened",
|
||||
[&](void* /*arg*/) { reopened.fetch_add(1); });
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
Reopen(options);
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->DisableProcessing();
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
|
||||
ASSERT_EQ(0, reopened.load());
|
||||
}
|
||||
|
||||
// Regression for the WritableFileWriter::filesize_=0 bug fixed via
|
||||
// constructor-time initial_file_size: after ReopenManifestForAppend binds a
|
||||
// writer to the existing MANIFEST, GetFileSize() must return the on-disk
|
||||
// size, not 0.
|
||||
// (If it returned 0, the size-limit check in ProcessManifestWrites would
|
||||
// compare against 0 and Close-time Truncate could shrink the file.)
|
||||
TEST_F(DBBasicTest, ReuseManifestOnOpenAdoptsOnDiskSize) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.reuse_manifest_on_open = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
// Capture the on-disk MANIFEST size before reopen.
|
||||
Reopen(options);
|
||||
const uint64_t manifest_no = dbfull()->TEST_Current_Manifest_FileNo();
|
||||
const std::string manifest_path = DescriptorFileName(dbname_, manifest_no);
|
||||
uint64_t on_disk_size = 0;
|
||||
ASSERT_OK(env_->GetFileSize(manifest_path, &on_disk_size));
|
||||
ASSERT_GT(on_disk_size, 0u);
|
||||
|
||||
// Force a write to flush the writer's buffer; verify Close doesn't
|
||||
// shrink the file (which would happen if Truncate(filesize_=0) ran).
|
||||
ASSERT_OK(Put("k2", "v2"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
uint64_t after_close_size = 0;
|
||||
ASSERT_OK(env_->GetFileSize(manifest_path, &after_close_size));
|
||||
EXPECT_GE(after_close_size, on_disk_size);
|
||||
}
|
||||
|
||||
// MANIFEST rotation continues to work under reuse: when the file grows
|
||||
// past tuned_max_manifest_file_size_, ProcessManifestWrites must rotate
|
||||
// to a fresh MANIFEST (same as legacy). Use the rotation SyncPoint to
|
||||
// observe rotation directly, since tuned_max_manifest_file_size_ is
|
||||
// auto-derived and not directly = max_manifest_file_size.
|
||||
TEST_F(DBBasicTest, ReuseManifestOnOpenStillRotatesOnSizeCap) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.reuse_manifest_on_open = true;
|
||||
options.max_manifest_file_size = 1;
|
||||
options.max_manifest_space_amp_pct = 0; // disable amp-based tuning
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
std::atomic<int> rotations{0};
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"VersionSet::ProcessManifestWrites:BeforeNewManifest",
|
||||
[&](void* /*arg*/) { rotations.fetch_add(1); });
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
Reopen(options);
|
||||
ASSERT_OK(Put("k2", "v2"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->DisableProcessing();
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
|
||||
// The Flush after Reopen must have triggered a rotation given the
|
||||
// tiny size cap -- proves the size-driven rotation path still runs
|
||||
// when descriptor_log_ is bound by reuse.
|
||||
EXPECT_GT(rotations.load(), 0);
|
||||
EXPECT_EQ("v", Get("k"));
|
||||
EXPECT_EQ("v2", Get("k2"));
|
||||
}
|
||||
|
||||
// Multi-CF reuse: append-mode MANIFEST must correctly handle edits
|
||||
// from multiple CFs after reopen.
|
||||
TEST_F(DBBasicTest, ReuseManifestOnOpenMultiCF) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.reuse_manifest_on_open = true;
|
||||
CreateAndReopenWithCF({"alpha", "beta"}, options);
|
||||
ASSERT_OK(Put(0, "k", "v0"));
|
||||
ASSERT_OK(Put(1, "k", "v1"));
|
||||
ASSERT_OK(Put(2, "k", "v2"));
|
||||
ASSERT_OK(Flush(0));
|
||||
ASSERT_OK(Flush(1));
|
||||
ASSERT_OK(Flush(2));
|
||||
Close();
|
||||
|
||||
ReopenWithColumnFamilies({"default", "alpha", "beta"}, options);
|
||||
// Trigger more writes post-reopen on each CF -- these get appended to
|
||||
// the reused MANIFEST.
|
||||
ASSERT_OK(Put(0, "k2", "v0b"));
|
||||
ASSERT_OK(Put(1, "k2", "v1b"));
|
||||
ASSERT_OK(Put(2, "k2", "v2b"));
|
||||
ASSERT_OK(Flush(0));
|
||||
ASSERT_OK(Flush(1));
|
||||
ASSERT_OK(Flush(2));
|
||||
|
||||
EXPECT_EQ("v0", Get(0, "k"));
|
||||
EXPECT_EQ("v1", Get(1, "k"));
|
||||
EXPECT_EQ("v2", Get(2, "k"));
|
||||
EXPECT_EQ("v0b", Get(0, "k2"));
|
||||
EXPECT_EQ("v1b", Get(1, "k2"));
|
||||
EXPECT_EQ("v2b", Get(2, "k2"));
|
||||
}
|
||||
|
||||
// Disabled under best_efforts_recovery: that mode rebuilds CURRENT and
|
||||
// MANIFEST as the side-effect of LogAndApplyForRecovery emitting an
|
||||
// edit; reusing the prior MANIFEST contradicts the salvage contract.
|
||||
TEST_F(DBBasicTest, ReuseManifestOnOpenDisabledByBestEffortsRecovery) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.reuse_manifest_on_open = true;
|
||||
options.best_efforts_recovery = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
std::atomic<int> reopened{0};
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"VersionSet::ReopenManifestForAppend:Reopened",
|
||||
[&](void* /*arg*/) { reopened.fetch_add(1); });
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
Reopen(options);
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->DisableProcessing();
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
|
||||
ASSERT_EQ(0, reopened.load());
|
||||
ASSERT_EQ("v", Get("k"));
|
||||
}
|
||||
|
||||
// Full-stack composition: optimize_manifest_for_recovery plus
|
||||
// reuse_manifest_on_open. Verifies that Close writes recovery markers,
|
||||
// Reopen skips clean-recovery MANIFEST edits, and the MANIFEST is reused
|
||||
// instead of recreated for the next metadata update.
|
||||
TEST_F(DBBasicTest, ReuseManifestOnOpenFullStackComposition) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.optimize_manifest_for_recovery = true;
|
||||
options.reuse_manifest_on_open = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
// Capture the MANIFEST file number before reopen.
|
||||
std::atomic<int> reopened{0};
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"VersionSet::ReopenManifestForAppend:Reopened",
|
||||
[&](void* /*arg*/) { reopened.fetch_add(1); });
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
Reopen(options);
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->DisableProcessing();
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
|
||||
// All three composing: reuse fired, data is intact, no fresh MANIFEST.
|
||||
EXPECT_EQ(1, reopened.load());
|
||||
EXPECT_EQ("v", Get("k"));
|
||||
}
|
||||
|
||||
// Direct unit test for WritableFileWriter's initial_file_size parameter:
|
||||
// verifies the visible size accessors report the existing on-disk size
|
||||
// immediately, rather than the constructor's zero default.
|
||||
TEST_F(DBBasicTest, WritableFileWriterInitialFileSizeAdoptsExistingSize) {
|
||||
Env* env = Env::Default();
|
||||
std::string fname = test::PerThreadDBPath("set_file_size_test");
|
||||
ASSERT_OK(env->CreateDirIfMissing(test::TmpDir(env)));
|
||||
|
||||
// Create a file with some bytes, close it.
|
||||
{
|
||||
std::unique_ptr<WritableFile> raw;
|
||||
ASSERT_OK(env->NewWritableFile(fname, &raw, EnvOptions()));
|
||||
ASSERT_OK(raw->Append("hello world"));
|
||||
ASSERT_OK(raw->Close());
|
||||
}
|
||||
|
||||
// Reopen and wrap in a WritableFileWriter without initial_file_size --
|
||||
// GetFileSize() should be 0 (the constructor's default).
|
||||
std::unique_ptr<FSWritableFile> fs_file;
|
||||
ASSERT_OK(env->GetFileSystem()->ReopenWritableFile(
|
||||
fname, FileOptions(), &fs_file, /*dbg=*/nullptr));
|
||||
std::unique_ptr<WritableFileWriter> writer(
|
||||
new WritableFileWriter(std::move(fs_file), fname, FileOptions()));
|
||||
EXPECT_EQ(0u, writer->GetFileSize());
|
||||
|
||||
// Reopen again and seed the writer's size accounting from the existing
|
||||
// bytes. GetFileSize and GetFlushedSize must reflect it immediately.
|
||||
ASSERT_OK(env->GetFileSystem()->ReopenWritableFile(
|
||||
fname, FileOptions(), &fs_file, /*dbg=*/nullptr));
|
||||
writer.reset(new WritableFileWriter(
|
||||
std::move(fs_file), fname, FileOptions(),
|
||||
/*clock=*/nullptr, /*io_tracer=*/nullptr, /*stats=*/nullptr,
|
||||
Histograms::HISTOGRAM_ENUM_MAX, /*listeners=*/{},
|
||||
/*file_checksum_gen_factory=*/nullptr,
|
||||
/*perform_data_verification=*/false,
|
||||
/*buffered_data_with_checksum=*/false,
|
||||
/*initial_file_size=*/11));
|
||||
EXPECT_EQ(11u, writer->GetFileSize());
|
||||
EXPECT_EQ(11u, writer->GetFlushedSize());
|
||||
|
||||
ASSERT_OK(env->DeleteFile(fname));
|
||||
}
|
||||
|
||||
// Tail corruption: appending garbage bytes to the MANIFEST after a
|
||||
// clean close must prevent reuse -- the physical size exceeds the
|
||||
// last valid record end.
|
||||
TEST_F(DBBasicTest, ReuseManifestOnOpenSkipsOnTailCorruption) {
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.reuse_manifest_on_open = true;
|
||||
DestroyAndReopen(options);
|
||||
ASSERT_OK(Put("k", "v"));
|
||||
ASSERT_OK(Flush());
|
||||
Close();
|
||||
|
||||
// Find the MANIFEST file and append garbage to it.
|
||||
std::string manifest_path;
|
||||
{
|
||||
std::vector<std::string> files;
|
||||
ASSERT_OK(env_->GetChildren(dbname_, &files));
|
||||
for (const auto& f : files) {
|
||||
uint64_t number;
|
||||
FileType type;
|
||||
if (ParseFileName(f, &number, &type) && type == kDescriptorFile) {
|
||||
manifest_path = dbname_ + "/" + f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
ASSERT_FALSE(manifest_path.empty());
|
||||
{
|
||||
std::string contents;
|
||||
ASSERT_OK(ReadFileToString(env_, manifest_path, &contents));
|
||||
contents.append("garbage!");
|
||||
ASSERT_OK(WriteStringToFile(env_, contents, manifest_path));
|
||||
}
|
||||
|
||||
std::atomic<int> reopened{0};
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"VersionSet::ReopenManifestForAppend:Reopened",
|
||||
[&](void* /*arg*/) { reopened.fetch_add(1); });
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
Reopen(options);
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->DisableProcessing();
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
|
||||
ASSERT_EQ(0, reopened.load());
|
||||
ASSERT_EQ("v", Get("k"));
|
||||
}
|
||||
|
||||
TEST_F(DBBasicTest, EnableDirectIOWithZeroBuf) {
|
||||
if (!IsDirectIOSupported()) {
|
||||
ROCKSDB_GTEST_BYPASS("Direct IO not supported");
|
||||
|
||||
@@ -542,7 +542,7 @@ TEST_F(DBBlockCacheTest, WarmCacheWithDataBlocksDuringCompaction) {
|
||||
EXPECT_TRUE(tracking_cache->HasPriority(Cache::Priority::BOTTOM));
|
||||
EXPECT_FALSE(tracking_cache->HasPriority(Cache::Priority::LOW));
|
||||
|
||||
// Compaction output is in cache — reads should have zero misses.
|
||||
// Compaction output is in cache -- reads should have zero misses.
|
||||
auto data_miss_before =
|
||||
options.statistics->getTickerCount(BLOCK_CACHE_DATA_MISS);
|
||||
ASSERT_EQ(value + "2", Get("key"));
|
||||
|
||||
+143
-5
@@ -11833,7 +11833,7 @@ TEST_F(DBCompactionTest, RoundRobinCleanCutWithSharedBoundary) {
|
||||
// 2. A post-verification step fails (injected here via sync point), setting
|
||||
// compact_->status to error while each subcompaction's status stays OK.
|
||||
// 3. SubcompactionState::Cleanup checks individual status (OK) and skips
|
||||
// ReleaseObsolete — the cache entries leak.
|
||||
// ReleaseObsolete -- the cache entries leak.
|
||||
// 4. FaultInjectionTestFS injects metadata read errors, causing GetChildren
|
||||
// to fail in FindObsoleteFiles.
|
||||
// 5. Close()'s FindObsoleteFiles also fails to find the orphan for the same
|
||||
@@ -11864,7 +11864,7 @@ TEST_F(DBCompactionTest, LeakedTableCacheEntryOnCompactionFailure) {
|
||||
// fail while individual subcompaction statuses stay OK (so Cleanup skips
|
||||
// ReleaseObsolete). The filesystem deactivation makes GetChildren fail
|
||||
// in FindObsoleteFiles, preventing the backstop from evicting the leaked
|
||||
// cache entries — matching the crash test's metadata read fault injection.
|
||||
// cache entries -- matching the crash test's metadata read fault injection.
|
||||
std::atomic<bool> inject_error{true};
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"CompactionJob::Run():AfterVerifyOutputFiles", [&](void* arg) {
|
||||
@@ -11876,7 +11876,7 @@ TEST_F(DBCompactionTest, LeakedTableCacheEntryOnCompactionFailure) {
|
||||
// Enable metadata read fault injection on the bg compaction thread after
|
||||
// the compaction job finishes but before FindObsoleteFiles runs. This
|
||||
// makes GetChildren fail (metadata read), matching crash test's
|
||||
// --open_metadata_read_fault_one_in=8. Only metadata reads fail —
|
||||
// --open_metadata_read_fault_one_in=8. Only metadata reads fail --
|
||||
// logging and other IO operations continue normally.
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"BackgroundCallCompaction:1", [&](void*) {
|
||||
@@ -11889,7 +11889,7 @@ TEST_F(DBCompactionTest, LeakedTableCacheEntryOnCompactionFailure) {
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
// Trigger compaction — fails after VerifyOutputFiles.
|
||||
// Trigger compaction -- fails after VerifyOutputFiles.
|
||||
Status s = dbfull()->TEST_CompactRange(0, nullptr, nullptr);
|
||||
ASSERT_NOK(s);
|
||||
|
||||
@@ -11971,7 +11971,7 @@ TEST_F(DBCompactionTest, LeakedTableCacheEntryOnInstallFailure) {
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
// Trigger compaction — Run() succeeds but Install() fails.
|
||||
// Trigger compaction -- Run() succeeds but Install() fails.
|
||||
Status s = dbfull()->TEST_CompactRange(0, nullptr, nullptr);
|
||||
ASSERT_NOK(s);
|
||||
|
||||
@@ -11993,6 +11993,144 @@ TEST_F(DBCompactionTest, LeakedTableCacheEntryOnInstallFailure) {
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
// Regression test for ReleaseObsolete failing to erase table cache entries
|
||||
// when concurrent readers hold references.
|
||||
// 1. Flush creates an L0 SST file with an entry in the table cache.
|
||||
// 2. A concurrent reader acquires a cache reference on the file (simulated
|
||||
// with a direct cache Lookup).
|
||||
// 3. ReleaseObsolete is called (simulating what PurgeObsoleteFiles does when
|
||||
// the file becomes obsolete). With the old code, this calls
|
||||
// ReleaseAndEraseIfLastRef which fails because of the concurrent ref.
|
||||
// 4. The concurrent reader releases its reference.
|
||||
// 5. Without the fix, the entry remains in the cache (leak). With the fix,
|
||||
// ReleaseObsolete's Erase() marked the entry Invisible, so it was cleaned
|
||||
// up when the concurrent ref was released.
|
||||
TEST_F(DBCompactionTest, ObsoleteFileTableCacheEntryWithConcurrentRef) {
|
||||
Options options = CurrentOptions();
|
||||
options.disable_auto_compactions = true;
|
||||
DestroyAndReopen(options);
|
||||
|
||||
// Create an L0 file.
|
||||
ASSERT_OK(Put("a", std::string(1024, 'x')));
|
||||
ASSERT_OK(Put("z", std::string(1024, 'x')));
|
||||
ASSERT_OK(Flush());
|
||||
ASSERT_EQ(NumTableFilesAtLevel(0), 1);
|
||||
|
||||
// Get the file number of the L0 file.
|
||||
std::vector<LiveFileMetaData> files;
|
||||
dbfull()->GetLiveFilesMetaData(&files);
|
||||
ASSERT_EQ(files.size(), 1);
|
||||
uint64_t target_file_number = files[0].file_number;
|
||||
|
||||
// Ensure the file is in the table cache by reading from it.
|
||||
ASSERT_EQ(Get("a"), std::string(1024, 'x'));
|
||||
|
||||
Cache* table_cache = dbfull()->TEST_table_cache();
|
||||
|
||||
// Simulate a concurrent reader acquiring a cache reference.
|
||||
Cache::Handle* concurrent_handle =
|
||||
TableCache::Lookup(table_cache, target_file_number);
|
||||
ASSERT_NE(concurrent_handle, nullptr);
|
||||
|
||||
// Call ReleaseObsolete directly -- this is what PurgeObsoleteFiles calls
|
||||
// when a file becomes obsolete. Pass nullptr for the handle to make
|
||||
// ReleaseObsolete do its own Lookup internally.
|
||||
TableCache::ReleaseObsolete(table_cache, target_file_number,
|
||||
/*handle=*/nullptr,
|
||||
/*uncache_aggressiveness=*/0);
|
||||
|
||||
// The concurrent reader releases its reference.
|
||||
table_cache->Release(concurrent_handle);
|
||||
|
||||
// With the fix, the entry should be gone: ReleaseObsolete called Erase()
|
||||
// which marked it Invisible, so it was freed when the concurrent ref was
|
||||
// released. Without the fix, the entry leaks here.
|
||||
Cache::Handle* leaked = TableCache::Lookup(table_cache, target_file_number);
|
||||
ASSERT_EQ(leaked, nullptr);
|
||||
}
|
||||
|
||||
// Regression test for the atomic flush path leaking table cache entries when
|
||||
// InstallMemtableAtomicFlushResults (MANIFEST write) fails.
|
||||
// 1. atomic_flush is enabled with two column families.
|
||||
// 2. Both CFs have data, a flush is triggered.
|
||||
// 3. FlushJob::Run() succeeds for both (files are added to table cache by
|
||||
// BuildTable), but write_manifest=false so no install happens in Run().
|
||||
// 4. The combined MANIFEST write (InstallMemtableAtomicFlushResults) fails
|
||||
// due to injected I/O error.
|
||||
// 5. Without the fix, the files remain in the table cache but are not in
|
||||
// any Version, causing TEST_VerifyNoObsoleteFilesCached to fire during
|
||||
// Close() (especially when metadata_read_fault_one_in disables the
|
||||
// FindObsoleteFiles backstop).
|
||||
TEST_F(DBCompactionTest, LeakedTableCacheEntryOnAtomicFlushInstallFailure) {
|
||||
auto fault_fs = std::make_shared<FaultInjectionTestFS>(env_->GetFileSystem());
|
||||
std::unique_ptr<Env> fault_env(NewCompositeEnv(fault_fs));
|
||||
|
||||
Options options = CurrentOptions();
|
||||
options.env = fault_env.get();
|
||||
options.atomic_flush = true;
|
||||
options.create_if_missing = true;
|
||||
options.disable_auto_compactions = true;
|
||||
// Enough buffer so auto-flush doesn't trigger prematurely.
|
||||
options.write_buffer_size = 64 << 20;
|
||||
DestroyAndReopen(options);
|
||||
|
||||
// Create a second column family.
|
||||
CreateAndReopenWithCF({"pikachu"}, options);
|
||||
ASSERT_EQ(2, handles_.size());
|
||||
|
||||
// Write data to both CFs.
|
||||
ASSERT_OK(Put(0, "key0", std::string(1024, 'a')));
|
||||
ASSERT_OK(Put(1, "key1", std::string(1024, 'b')));
|
||||
|
||||
// Inject a write error during the combined MANIFEST write that happens
|
||||
// inside InstallMemtableAtomicFlushResults (via LogAndApply).
|
||||
std::atomic<bool> inject_error{true};
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"VersionSet::LogAndApply:WriteManifest", [&](void*) {
|
||||
if (inject_error.exchange(false)) {
|
||||
fault_fs->SetThreadLocalErrorContext(
|
||||
FaultInjectionIOType::kWrite, /*seed=*/0, /*one_in=*/1,
|
||||
/*retryable=*/false, /*has_data_loss=*/false);
|
||||
fault_fs->EnableThreadLocalErrorInjection(
|
||||
FaultInjectionIOType::kWrite);
|
||||
}
|
||||
});
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
// Trigger atomic flush - Run() succeeds but MANIFEST write fails.
|
||||
FlushOptions flush_opts;
|
||||
flush_opts.wait = true;
|
||||
Status s = db_->Flush(flush_opts, handles_);
|
||||
ASSERT_NOK(s);
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->DisableProcessing();
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
|
||||
// Disable the write fault so Close() can proceed.
|
||||
fault_fs->DisableThreadLocalErrorInjection(FaultInjectionIOType::kWrite);
|
||||
|
||||
// Enable metadata read fault injection on the main thread so that
|
||||
// Close()'s FindObsoleteFiles full-scan backstop cannot find the orphan
|
||||
// files (simulating metadata_read_fault_one_in from the stress test).
|
||||
fault_fs->SetThreadLocalErrorContext(
|
||||
FaultInjectionIOType::kMetadataRead, /*seed=*/0, /*one_in=*/1,
|
||||
/*retryable=*/false, /*has_data_loss=*/false);
|
||||
fault_fs->EnableThreadLocalErrorInjection(
|
||||
FaultInjectionIOType::kMetadataRead);
|
||||
|
||||
// Destroy column family handles before closing.
|
||||
for (auto h : handles_) {
|
||||
ASSERT_OK(db_->DestroyColumnFamilyHandle(h));
|
||||
}
|
||||
handles_.clear();
|
||||
|
||||
// Close the DB. Without the fix, TEST_VerifyNoObsoleteFilesCached fires.
|
||||
s = db_->Close();
|
||||
ASSERT_OK(s);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(DBCompactionTest, VerifyFileChecksumOnCompactionOutput) {
|
||||
Options options = CurrentOptions();
|
||||
options.disable_auto_compactions = true;
|
||||
|
||||
+14
-3
@@ -1852,6 +1852,8 @@ TEST_F(DBFlushTest, MemPurgeCorrectLogNumberAndSSTFileCreation) {
|
||||
}
|
||||
}
|
||||
|
||||
ASSERT_OK(WaitForFlushCallbacks());
|
||||
|
||||
// Check that there was at least one mempurge
|
||||
uint32_t expected_min_mempurge_count = 1;
|
||||
// Check that there was no SST files created during flush.
|
||||
@@ -1872,6 +1874,8 @@ TEST_F(DBFlushTest, MemPurgeCorrectLogNumberAndSSTFileCreation) {
|
||||
ASSERT_EQ(Get(key), value);
|
||||
}
|
||||
|
||||
ASSERT_OK(WaitForFlushCallbacks());
|
||||
|
||||
// Check that there was at least one SST files created during flush.
|
||||
expected_sst_count = 1;
|
||||
EXPECT_GE(sst_count.load(), expected_sst_count);
|
||||
@@ -1891,6 +1895,9 @@ TEST_F(DBFlushTest, MemPurgeCorrectLogNumberAndSSTFileCreation) {
|
||||
// Extra check of database consistency.
|
||||
ASSERT_EQ(Get(key), value);
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->DisableProcessing();
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
|
||||
Close();
|
||||
}
|
||||
|
||||
@@ -3883,7 +3890,7 @@ TEST_F(DBFlushTest, LeakedTableCacheEntryOnFlushInstallFailure) {
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
// Trigger flush — BuildTable succeeds but LogAndApply fails.
|
||||
// Trigger flush -- BuildTable succeeds but LogAndApply fails.
|
||||
Status s = Flush();
|
||||
ASSERT_NOK(s);
|
||||
|
||||
@@ -3946,8 +3953,12 @@ TEST_F(DBFlushTest, FlushAfterReadPathRangeTombstoneInsertion) {
|
||||
// MarkImmutable), this returns false because the memtable is
|
||||
// already immutable. Without the fix, this succeeds and creates
|
||||
// an entry count mismatch.
|
||||
cfh->cfd()->mem()->AddLogicallyRedundantRangeTombstone(1 /* seq */, "b",
|
||||
"f");
|
||||
// Try to insert a range tombstone. With the fix (Construct after
|
||||
// MarkImmutable), this returns false because the memtable is
|
||||
// already immutable. Without the fix, this succeeds and creates
|
||||
// an entry count mismatch.
|
||||
cfh->cfd()->mem()->AddLogicallyRedundantRangeTombstone(
|
||||
1 /* seq */, "b", "f", cfh->cfd()->GetIngestSstLock());
|
||||
});
|
||||
SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
|
||||
+371
-19
@@ -15,6 +15,7 @@
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstdio>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
@@ -29,6 +30,7 @@
|
||||
|
||||
#include "db/arena_wrapped_db_iter.h"
|
||||
#include "db/attribute_group_iterator_impl.h"
|
||||
#include "db/blob/blob_fetcher.h"
|
||||
#include "db/blob/blob_file_partition_manager.h"
|
||||
#include "db/blob/blob_index.h"
|
||||
#include "db/builder.h"
|
||||
@@ -481,6 +483,18 @@ void DBImpl::WaitForBackgroundWork() {
|
||||
}
|
||||
}
|
||||
|
||||
void DBImpl::WaitForAsyncFileOpen() {
|
||||
if (!immutable_db_options_.open_files_async) {
|
||||
return;
|
||||
}
|
||||
InstrumentedMutexLock l(&mutex_);
|
||||
TEST_SYNC_POINT("DBImpl::WaitForAsyncFileOpen::BeforeWait");
|
||||
while (bg_async_file_open_state_ == AsyncFileOpenState::kScheduled &&
|
||||
!shutting_down_.load(std::memory_order_acquire)) {
|
||||
bg_cv_.Wait();
|
||||
}
|
||||
}
|
||||
|
||||
// Will lock the mutex_, will wait for completion if wait is true
|
||||
void DBImpl::CancelAllBackgroundWork(bool wait) {
|
||||
ROCKS_LOG_INFO(immutable_db_options_.info_log,
|
||||
@@ -636,6 +650,105 @@ void DBImpl::UnregisterBlobDirectWriteColumnFamily() {
|
||||
assert(false);
|
||||
}
|
||||
|
||||
Status DBImpl::MaybeWriteWalMarkersToManifestOnClose() {
|
||||
mutex_.AssertHeld();
|
||||
if (!mutable_db_options_.optimize_manifest_for_recovery ||
|
||||
!opened_successfully_ || versions_ == nullptr || logs_.empty()) {
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
TEST_SYNC_POINT("DBImpl::CloseHelper:WriteWalMarkersOnCloseEntered");
|
||||
const ReadOptions read_options(Env::IOActivity::kUnknown);
|
||||
const WriteOptions write_options(Env::IOActivity::kUnknown);
|
||||
uint64_t max_wal_number = logs_.back().number;
|
||||
TEST_SYNC_POINT_CALLBACK("DBImpl::CloseHelper:CapturedMaxWal",
|
||||
&max_wal_number);
|
||||
const uint64_t new_log_num = max_wal_number + 1;
|
||||
|
||||
// std::deque keeps VersionEdit pointers stable across emplace_back.
|
||||
std::deque<VersionEdit> edits;
|
||||
autovector<ColumnFamilyData*> cfds;
|
||||
autovector<autovector<VersionEdit*>> edit_lists;
|
||||
VersionEdit global_edit;
|
||||
bool persist_global_edit = false;
|
||||
bool reserved_log_number = false;
|
||||
|
||||
bool all_cfs_empty = true;
|
||||
for (auto* cfd : *versions_->GetColumnFamilySet()) {
|
||||
if (cfd->IsDropped()) {
|
||||
continue;
|
||||
}
|
||||
// cfd->IsEmpty() checks both mem and imm. An immutable memtable queued for
|
||||
// flush still pins an older WAL; advancing past it would drop unflushed
|
||||
// records on the next open.
|
||||
if (!cfd->IsEmpty()) {
|
||||
all_cfs_empty = false;
|
||||
continue;
|
||||
}
|
||||
if (new_log_num > cfd->GetLogNumber()) {
|
||||
if (!reserved_log_number) {
|
||||
const uint64_t next_file_number = versions_->current_next_file_number();
|
||||
if (next_file_number <= new_log_num) {
|
||||
versions_->FetchAddFileNumber(new_log_num + 1 - next_file_number);
|
||||
global_edit.SetNextFile(versions_->current_next_file_number());
|
||||
persist_global_edit = true;
|
||||
}
|
||||
reserved_log_number = true;
|
||||
}
|
||||
edits.emplace_back();
|
||||
VersionEdit& edit = edits.back();
|
||||
edit.SetLogNumber(new_log_num);
|
||||
edit.SetColumnFamily(cfd->GetID());
|
||||
cfds.push_back(cfd);
|
||||
edit_lists.emplace_back();
|
||||
edit_lists.back().push_back(&edit);
|
||||
}
|
||||
}
|
||||
|
||||
if (all_cfs_empty && !allow_2pc()) {
|
||||
if (new_log_num > versions_->min_log_number_to_keep()) {
|
||||
global_edit.SetMinLogNumberToKeep(new_log_num);
|
||||
persist_global_edit = true;
|
||||
}
|
||||
// Mirror the monotonicity guard in db_impl_files.cc:
|
||||
// PrecomputeWalDeletionEdit emits DeleteWalsBefore only when the
|
||||
// marker actually advances the WalSet's existing min. Skipping
|
||||
// this guard would let a stale-replay edit regress WalSet state.
|
||||
if (immutable_db_options_.track_and_verify_wals_in_manifest &&
|
||||
new_log_num > versions_->GetWalSet().GetMinWalNumberToKeep()) {
|
||||
global_edit.DeleteWalsBefore(new_log_num);
|
||||
persist_global_edit = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (persist_global_edit) {
|
||||
ColumnFamilyData* default_cfd =
|
||||
versions_->GetColumnFamilySet()->GetDefault();
|
||||
if (default_cfd && !default_cfd->IsDropped()) {
|
||||
bool attached_to_existing = false;
|
||||
for (size_t i = 0; i < cfds.size(); ++i) {
|
||||
if (cfds[i] == default_cfd) {
|
||||
edit_lists[i].push_back(&global_edit);
|
||||
attached_to_existing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!attached_to_existing) {
|
||||
cfds.push_back(default_cfd);
|
||||
edit_lists.emplace_back();
|
||||
edit_lists.back().push_back(&global_edit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cfds.empty()) {
|
||||
return Status::OK();
|
||||
}
|
||||
return versions_->LogAndApply(cfds, read_options, write_options, edit_lists,
|
||||
&mutex_, directories_.GetDbDir(),
|
||||
/*new_descriptor_log=*/false);
|
||||
}
|
||||
|
||||
Status DBImpl::CloseHelper() {
|
||||
// Guarantee that there is no background error recovery in progress before
|
||||
// continuing with the shutdown
|
||||
@@ -740,6 +853,17 @@ Status DBImpl::CloseHelper() {
|
||||
job_context.Clean();
|
||||
mutex_.Lock();
|
||||
}
|
||||
// Best-effort warm-reopen optimization: after obsolete-file cleanup and
|
||||
// before logs_.clear(), persist close-time WAL markers that can reduce
|
||||
// recovery work on the next open. This matters most when some CFs have no
|
||||
// unflushed writes and/or the newest WAL is empty, since recovery would
|
||||
// otherwise append marker-only MANIFEST edits on reopen.
|
||||
Status manifest_s = MaybeWriteWalMarkersToManifestOnClose();
|
||||
if (!manifest_s.ok()) {
|
||||
ROCKS_LOG_WARN(immutable_db_options_.info_log,
|
||||
"optimize_manifest_for_recovery close-time write failed: %s",
|
||||
manifest_s.ToString().c_str());
|
||||
}
|
||||
{
|
||||
InstrumentedMutexLock lock(&wal_write_mutex_);
|
||||
for (auto l : wals_to_free_) {
|
||||
@@ -1665,9 +1789,12 @@ Status DBImpl::SetDBOptions(
|
||||
: new_options.max_open_files - 10);
|
||||
// Potential table cache capacity change requires updating if table
|
||||
// handles should get pinned.
|
||||
versions_->GetColumnFamilySet()->SetFastSstOpen(
|
||||
new_options.fast_sst_open);
|
||||
for (auto cfd : *versions_->GetColumnFamilySet()) {
|
||||
if (!cfd->IsDropped()) {
|
||||
cfd->table_cache()->UpdateShouldPinTableHandles();
|
||||
cfd->table_cache()->SetFastSstOpen(new_options.fast_sst_open);
|
||||
}
|
||||
}
|
||||
wal_other_option_changed = mutable_db_options_.wal_bytes_per_sync !=
|
||||
@@ -2745,6 +2872,148 @@ Status DBImpl::ResolveDirectWriteWideColumns(const ReadOptions& read_options,
|
||||
return status;
|
||||
}
|
||||
|
||||
bool DBImpl::MaybeResolveMemtableBlobValue(const Slice& key,
|
||||
const BlobFetcher* blob_fetcher,
|
||||
PinnableSlice* value,
|
||||
PinnableWideColumns* columns,
|
||||
Status* s, bool* is_blob_index,
|
||||
bool* value_found) {
|
||||
if (!s->ok() || (!value && !columns)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto reset_outputs = [&]() {
|
||||
if (value != nullptr) {
|
||||
value->Reset();
|
||||
}
|
||||
if (columns != nullptr) {
|
||||
columns->Reset();
|
||||
}
|
||||
};
|
||||
auto clear_blob_state = [&]() {
|
||||
if (is_blob_index != nullptr) {
|
||||
*is_blob_index = false;
|
||||
}
|
||||
};
|
||||
|
||||
const bool needs_plain_value_resolution =
|
||||
is_blob_index != nullptr && *is_blob_index;
|
||||
const bool needs_wide_column_resolution =
|
||||
columns != nullptr && !columns->unresolved_blob_column_indices_.empty();
|
||||
if (!needs_plain_value_resolution && !needs_wide_column_resolution) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blob_fetcher == nullptr) {
|
||||
reset_outputs();
|
||||
*s = Status::NotSupported(
|
||||
"Encountered blob-backed memtable value without blob fetcher.");
|
||||
clear_blob_state();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (needs_plain_value_resolution) {
|
||||
Slice blob_index_slice;
|
||||
std::string blob_index_storage;
|
||||
if (value != nullptr) {
|
||||
if (value->size() > 0) {
|
||||
blob_index_slice = Slice(value->data(), value->size());
|
||||
} else {
|
||||
blob_index_slice = Slice(*(value->GetSelf()));
|
||||
}
|
||||
} else {
|
||||
assert(columns != nullptr);
|
||||
|
||||
const WideColumns& plain_value_columns = columns->columns();
|
||||
assert(plain_value_columns.size() == 1);
|
||||
assert(plain_value_columns.front().name() == kDefaultWideColumnName);
|
||||
blob_index_slice = plain_value_columns.front().value();
|
||||
}
|
||||
|
||||
PinnableSlice resolved_value;
|
||||
PinnableSlice* target = value != nullptr ? value : &resolved_value;
|
||||
if (value != nullptr) {
|
||||
// BlobIndex::DecodeFrom can retain Slices into the encoded bytes for
|
||||
// inlined blob indices, so take an owned copy before resetting `value`
|
||||
// and reusing the same PinnableSlice as the output target.
|
||||
blob_index_storage.assign(blob_index_slice.data(),
|
||||
blob_index_slice.size());
|
||||
blob_index_slice = Slice(blob_index_storage);
|
||||
value->Reset();
|
||||
}
|
||||
|
||||
*s = blob_fetcher->FetchBlob(key, blob_index_slice,
|
||||
nullptr /* prefetch_buffer */, target,
|
||||
nullptr /* bytes_read */);
|
||||
if (s->ok() && columns != nullptr) {
|
||||
columns->SetPlainValue(std::move(*target));
|
||||
} else if (!s->ok()) {
|
||||
reset_outputs();
|
||||
if (s->IsIncomplete() && value_found != nullptr) {
|
||||
*value_found = false;
|
||||
}
|
||||
}
|
||||
|
||||
clear_blob_state();
|
||||
return true;
|
||||
}
|
||||
|
||||
assert(columns != nullptr);
|
||||
|
||||
std::string resolved_entity;
|
||||
bool resolved = false;
|
||||
*s = WideColumnSerialization::ResolveEntityBlobColumns(
|
||||
columns->value_, key, blob_fetcher, nullptr /* prefetch_buffers */,
|
||||
resolved_entity, resolved, nullptr /* total_bytes_read */,
|
||||
nullptr /* num_blobs_resolved */);
|
||||
if (s->ok()) {
|
||||
assert(resolved);
|
||||
if (resolved) {
|
||||
*s = columns->SetWideColumnValue(std::move(resolved_entity));
|
||||
}
|
||||
}
|
||||
if (!s->ok()) {
|
||||
reset_outputs();
|
||||
if (s->IsIncomplete() && value_found != nullptr) {
|
||||
*value_found = false;
|
||||
}
|
||||
}
|
||||
|
||||
clear_blob_state();
|
||||
return true;
|
||||
}
|
||||
|
||||
void DBImpl::PostprocessMemtableValueRead(
|
||||
const Slice& key, const std::string* timestamp,
|
||||
bool resolve_blob_backed_memtable_value,
|
||||
const BlobFetcher* memtable_blob_fetcher, PinnableSlice* value,
|
||||
PinnableWideColumns* columns, Status* s, bool* is_blob_index,
|
||||
bool* value_found) {
|
||||
if (resolve_blob_backed_memtable_value) {
|
||||
std::string blob_lookup_key_storage;
|
||||
const bool value_resolved = MaybeResolveMemtableBlobValue(
|
||||
GetBlobLookupUserKey(key, timestamp, &blob_lookup_key_storage),
|
||||
memtable_blob_fetcher, value, columns, s, is_blob_index, value_found);
|
||||
if (!value_resolved && value != nullptr && s->ok()) {
|
||||
value->PinSelf();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (s->ok()) {
|
||||
if (value != nullptr) {
|
||||
value->PinSelf();
|
||||
}
|
||||
} else {
|
||||
if (value != nullptr) {
|
||||
value->Reset();
|
||||
}
|
||||
if (columns != nullptr) {
|
||||
columns->Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool DBImpl::MaybeResolveDirectWriteValue(
|
||||
const ReadOptions& read_options, const Slice& key,
|
||||
bool resolve_direct_write_value, const Version* current,
|
||||
@@ -2960,6 +3229,14 @@ Status DBImpl::GetImpl(const ReadOptions& read_options, const Slice& key,
|
||||
}
|
||||
const bool resolve_direct_write_value =
|
||||
partition_mgr != nullptr && (is_blob_ptr == &is_blob_index);
|
||||
std::optional<BlobFetcher> memtable_blob_fetcher;
|
||||
if (partition_mgr != nullptr) {
|
||||
memtable_blob_fetcher.emplace(sv->current, read_options,
|
||||
cfd->blob_file_cache(),
|
||||
/*allow_write_path_fallback=*/true);
|
||||
}
|
||||
const BlobFetcher* memtable_blob_fetcher_ptr =
|
||||
memtable_blob_fetcher ? &*memtable_blob_fetcher : nullptr;
|
||||
std::string blob_lookup_key_storage;
|
||||
auto get_blob_lookup_key = [&]() -> Slice {
|
||||
return GetBlobLookupUserKey(key, timestamp, &blob_lookup_key_storage);
|
||||
@@ -2987,19 +3264,21 @@ Status DBImpl::GetImpl(const ReadOptions& read_options, const Slice& key,
|
||||
get_impl_options.columns, timestamp, &s, &merge_context,
|
||||
&max_covering_tombstone_seq, read_options,
|
||||
false /* immutable_memtable */,
|
||||
get_impl_options.callback, is_blob_ptr)) {
|
||||
get_impl_options.callback, is_blob_ptr,
|
||||
/*do_merge=*/true, memtable_blob_fetcher_ptr)) {
|
||||
done = true;
|
||||
maybe_resolve_memtable_value();
|
||||
|
||||
RecordTick(stats_, MEMTABLE_HIT);
|
||||
} else if ((s.ok() || s.IsMergeInProgress()) &&
|
||||
sv->imm->Get(
|
||||
lkey,
|
||||
get_impl_options.value ? get_impl_options.value->GetSelf()
|
||||
: nullptr,
|
||||
get_impl_options.columns, timestamp, &s, &merge_context,
|
||||
&max_covering_tombstone_seq, read_options,
|
||||
get_impl_options.callback, is_blob_ptr)) {
|
||||
sv->imm->Get(lkey,
|
||||
get_impl_options.value
|
||||
? get_impl_options.value->GetSelf()
|
||||
: nullptr,
|
||||
get_impl_options.columns, timestamp, &s,
|
||||
&merge_context, &max_covering_tombstone_seq,
|
||||
read_options, get_impl_options.callback,
|
||||
is_blob_ptr, memtable_blob_fetcher_ptr)) {
|
||||
done = true;
|
||||
maybe_resolve_memtable_value();
|
||||
|
||||
@@ -3011,14 +3290,14 @@ Status DBImpl::GetImpl(const ReadOptions& read_options, const Slice& key,
|
||||
if (sv->mem->Get(lkey, /*value=*/nullptr, /*columns=*/nullptr,
|
||||
/*timestamp=*/nullptr, &s, &merge_context,
|
||||
&max_covering_tombstone_seq, read_options,
|
||||
false /* immutable_memtable */, nullptr, nullptr,
|
||||
false)) {
|
||||
false /* immutable_memtable */, nullptr, nullptr, false,
|
||||
memtable_blob_fetcher_ptr)) {
|
||||
done = true;
|
||||
RecordTick(stats_, MEMTABLE_HIT);
|
||||
} else if ((s.ok() || s.IsMergeInProgress()) &&
|
||||
sv->imm->GetMergeOperands(lkey, &s, &merge_context,
|
||||
&max_covering_tombstone_seq,
|
||||
read_options)) {
|
||||
sv->imm->GetMergeOperands(
|
||||
lkey, &s, &merge_context, &max_covering_tombstone_seq,
|
||||
read_options, memtable_blob_fetcher_ptr)) {
|
||||
done = true;
|
||||
RecordTick(stats_, MEMTABLE_HIT);
|
||||
}
|
||||
@@ -3714,6 +3993,14 @@ Status DBImpl::MultiGetImpl(
|
||||
(*sorted_keys)[start_key]->column_family);
|
||||
ColumnFamilyData* cfd = cfh->cfd();
|
||||
auto* partition_mgr = cfd->blob_partition_manager();
|
||||
std::optional<BlobFetcher> memtable_blob_fetcher;
|
||||
if (partition_mgr != nullptr) {
|
||||
memtable_blob_fetcher.emplace(super_version->current, read_options,
|
||||
cfd->blob_file_cache(),
|
||||
/*allow_write_path_fallback=*/true);
|
||||
}
|
||||
const BlobFetcher* memtable_blob_fetcher_ptr =
|
||||
memtable_blob_fetcher ? &*memtable_blob_fetcher : nullptr;
|
||||
// Clear the timestamps for returning results so that we can distinguish
|
||||
// between tombstone or key that has never been written
|
||||
for (size_t i = start_key; i < start_key + num_keys; ++i) {
|
||||
@@ -3760,9 +4047,11 @@ Status DBImpl::MultiGetImpl(
|
||||
has_unpersisted_data_.load(std::memory_order_relaxed));
|
||||
if (!skip_memtable) {
|
||||
super_version->mem->MultiGet(read_options, &range, callback,
|
||||
false /* immutable_memtable */);
|
||||
false /* immutable_memtable */,
|
||||
memtable_blob_fetcher_ptr);
|
||||
if (!range.empty()) {
|
||||
super_version->imm->MultiGet(read_options, &range, callback);
|
||||
super_version->imm->MultiGet(read_options, &range, callback,
|
||||
memtable_blob_fetcher_ptr);
|
||||
}
|
||||
if (!range.empty()) {
|
||||
uint64_t left = range.KeysLeft();
|
||||
@@ -4139,9 +4428,21 @@ Status DBImpl::CreateColumnFamilyImpl(const ReadOptions& read_options,
|
||||
const std::string& column_family_name,
|
||||
ColumnFamilyHandle** handle) {
|
||||
options_mutex_.AssertHeld();
|
||||
Status s;
|
||||
*handle = nullptr;
|
||||
|
||||
// The empty string is reserved as a "no/unknown column family name" sentinel
|
||||
// in various APIs and serialization formats (e.g.
|
||||
// TablePropertiesCollectorFactory::Context::kUnknownColumnFamily, table
|
||||
// properties), so reject it to avoid ambiguity. Note: prior to this check,
|
||||
// CreateColumnFamily("") would silently "succeed" but the CF was not
|
||||
// persisted in the manifest, causing data loss across DB reopens.
|
||||
if (column_family_name.empty()) {
|
||||
return Status::InvalidArgument(
|
||||
"Column family name cannot be the empty string");
|
||||
}
|
||||
|
||||
Status s;
|
||||
|
||||
DBOptions db_options =
|
||||
BuildDBOptions(immutable_db_options_, mutable_db_options_);
|
||||
s = ColumnFamilyData::ValidateOptions(db_options, cf_options);
|
||||
@@ -6197,12 +6498,20 @@ Status DBImpl::GetLatestSequenceForKey(
|
||||
|
||||
*seq = kMaxSequenceNumber;
|
||||
*found_record_for_key = false;
|
||||
std::optional<BlobFetcher> memtable_blob_fetcher;
|
||||
if (cfd->blob_partition_manager() != nullptr) {
|
||||
memtable_blob_fetcher.emplace(sv->current, read_options,
|
||||
cfd->blob_file_cache(),
|
||||
/*allow_write_path_fallback=*/true);
|
||||
}
|
||||
const BlobFetcher* memtable_blob_fetcher_ptr =
|
||||
memtable_blob_fetcher ? &*memtable_blob_fetcher : nullptr;
|
||||
|
||||
// Check if there is a record for this key in the latest memtable
|
||||
sv->mem->Get(lkey, /*value=*/nullptr, /*columns=*/nullptr, timestamp, &s,
|
||||
&merge_context, &max_covering_tombstone_seq, seq, read_options,
|
||||
false /* immutable_memtable */, nullptr /*read_callback*/,
|
||||
is_blob_index);
|
||||
is_blob_index, /*do_merge=*/true, memtable_blob_fetcher_ptr);
|
||||
|
||||
if (!(s.ok() || s.IsNotFound() || s.IsMergeInProgress())) {
|
||||
// unexpected error reading memtable.
|
||||
@@ -6235,7 +6544,8 @@ Status DBImpl::GetLatestSequenceForKey(
|
||||
// Check if there is a record for this key in the immutable memtables
|
||||
sv->imm->Get(lkey, /*value=*/nullptr, /*columns=*/nullptr, timestamp, &s,
|
||||
&merge_context, &max_covering_tombstone_seq, seq, read_options,
|
||||
nullptr /*read_callback*/, is_blob_index);
|
||||
nullptr /*read_callback*/, is_blob_index,
|
||||
memtable_blob_fetcher_ptr);
|
||||
|
||||
if (!(s.ok() || s.IsNotFound() || s.IsMergeInProgress())) {
|
||||
// unexpected error reading memtable.
|
||||
@@ -6268,7 +6578,7 @@ Status DBImpl::GetLatestSequenceForKey(
|
||||
sv->imm->GetFromHistory(lkey, /*value=*/nullptr, /*columns=*/nullptr,
|
||||
timestamp, &s, &merge_context,
|
||||
&max_covering_tombstone_seq, seq, read_options,
|
||||
is_blob_index);
|
||||
is_blob_index, memtable_blob_fetcher_ptr);
|
||||
|
||||
if (!(s.ok() || s.IsNotFound() || s.IsMergeInProgress())) {
|
||||
// unexpected error reading memtable.
|
||||
@@ -6490,6 +6800,19 @@ Status DBImpl::IngestExternalFiles(
|
||||
TEST_SYNC_POINT("DBImpl::IngestExternalFiles:BeforeJobsRun:0");
|
||||
TEST_SYNC_POINT("DBImpl::IngestExternalFiles:BeforeJobsRun:1");
|
||||
TEST_SYNC_POINT("DBImpl::AddFile:Start");
|
||||
|
||||
// Acquire per-CF ingest_sst_lock as ReadLocks (shared) BEFORE the DB
|
||||
// mutex so the lock-acquisition order is ingest_sst_lock -> DB mutex
|
||||
// throughout. Use readlock so we still allow concurrent ingestions.
|
||||
std::vector<std::unique_ptr<ReadLock>> ingest_read_locks;
|
||||
ingest_read_locks.reserve(num_cfs);
|
||||
for (size_t i = 0; i != num_cfs; ++i) {
|
||||
auto* cfd = ingestion_jobs[i].GetColumnFamilyData();
|
||||
if (!cfd->IsDropped()) {
|
||||
ingest_read_locks.emplace_back(
|
||||
std::make_unique<ReadLock>(&cfd->GetIngestSstLock()));
|
||||
}
|
||||
}
|
||||
{
|
||||
InstrumentedMutexLock l(&mutex_);
|
||||
TEST_SYNC_POINT("DBImpl::AddFile:MutexLock");
|
||||
@@ -6579,6 +6902,23 @@ Status DBImpl::IngestExternalFiles(
|
||||
ingestion_jobs[i].RegisterRange();
|
||||
}
|
||||
}
|
||||
// Now that Run() has assigned the actual seqno for each ingested file,
|
||||
// bump each affected memtable's ingest_seqno_barrier_ to that exact
|
||||
// value. We still hold the per-CF ingest_sst_lock as a ReadLock; any
|
||||
// concurrent conversion's TryWriteLock on the same lock fails, so no
|
||||
// converter can be reading or about to read the barrier while we
|
||||
// update it. After we release the ReadLocks at function exit, the
|
||||
// next conversion observes the new barrier and refuses any insert
|
||||
// with insert_seq < assigned.
|
||||
if (status.ok()) {
|
||||
for (size_t i = 0; i != num_cfs; ++i) {
|
||||
auto* cfd = ingestion_jobs[i].GetColumnFamilyData();
|
||||
SequenceNumber assigned = ingestion_jobs[i].MaxAssignedSequenceNumber();
|
||||
if (assigned > 0) {
|
||||
cfd->mem()->BumpIngestSeqnoBarrier(assigned);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status.ok()) {
|
||||
ReadOptions read_options;
|
||||
read_options.fill_cache = args[0].options.fill_cache;
|
||||
@@ -7240,6 +7580,7 @@ Status DBImpl::ReserveFileNumbersBeforeIngestion(
|
||||
Status DBImpl::GetCreationTimeOfOldestFile(uint64_t* creation_time) {
|
||||
if (mutable_db_options_.max_open_files == -1) {
|
||||
uint64_t oldest_time = std::numeric_limits<uint64_t>::max();
|
||||
std::once_flag waited_for_async_open;
|
||||
for (auto cfd : *versions_->GetColumnFamilySet()) {
|
||||
if (!cfd->IsDropped()) {
|
||||
uint64_t ctime;
|
||||
@@ -7247,6 +7588,17 @@ Status DBImpl::GetCreationTimeOfOldestFile(uint64_t* creation_time) {
|
||||
SuperVersion* sv = GetAndRefSuperVersion(cfd);
|
||||
Version* version = sv->current;
|
||||
version->GetCreationTimeOfOldestFile(&ctime);
|
||||
// For modern DBs, manifest carries file_creation_time and the
|
||||
// first call returns the real value. We only need to wait for
|
||||
// BGWorkAsyncFileOpen on legacy DBs whose manifest lacks
|
||||
// file_creation_time -- those rely on the pinned reader, which is
|
||||
// null until async file open completes.
|
||||
if (ctime == 0 && immutable_db_options_.open_files_async) {
|
||||
std::call_once(waited_for_async_open, [&]() {
|
||||
WaitForAsyncFileOpen();
|
||||
version->GetCreationTimeOfOldestFile(&ctime);
|
||||
});
|
||||
}
|
||||
ReturnAndCleanupSuperVersion(cfd, sv);
|
||||
}
|
||||
|
||||
|
||||
@@ -1096,6 +1096,7 @@ class DBImpl : public DB {
|
||||
superversions_to_free_queue_.push_back(sv);
|
||||
}
|
||||
|
||||
void EnableTrackPublishedSeqInSnapshotContext();
|
||||
void SetSnapshotChecker(SnapshotChecker* snapshot_checker);
|
||||
|
||||
// Fill JobContext with snapshot information needed by flush and compaction.
|
||||
@@ -1854,6 +1855,11 @@ class DBImpl : public DB {
|
||||
// Background work function for async file opening.
|
||||
static void BGWorkAsyncFileOpen(void* arg);
|
||||
|
||||
// Block the until any in-flight async file open work has
|
||||
// completed. No-op when open_files_async is false. Returns early if
|
||||
// shutdown begins.
|
||||
void WaitForAsyncFileOpen();
|
||||
|
||||
void InvokeWalFilterIfNeededOnColumnFamilyToWalNumberMap();
|
||||
|
||||
// Return true to proceed with current WAL record whose content is stored in
|
||||
@@ -1869,6 +1875,7 @@ class DBImpl : public DB {
|
||||
|
||||
private:
|
||||
friend class DB;
|
||||
friend class DBImplReadOnly;
|
||||
friend class DBImplSecondary;
|
||||
friend class ErrorHandler;
|
||||
friend class InternalStats;
|
||||
@@ -2743,6 +2750,8 @@ class DBImpl : public DB {
|
||||
|
||||
Status MaybeReleaseTimestampedSnapshotsAndCheck();
|
||||
|
||||
Status MaybeWriteWalMarkersToManifestOnClose();
|
||||
|
||||
Status CloseHelper();
|
||||
|
||||
void WaitForBackgroundWork();
|
||||
@@ -2944,6 +2953,29 @@ class DBImpl : public DB {
|
||||
bool resolve_direct_write_value, const Version* current,
|
||||
ColumnFamilyData* cfd, PinnableSlice* value, PinnableWideColumns* columns,
|
||||
Status* s, bool* is_blob_index, bool* value_found = nullptr);
|
||||
// Resolves memtable read results that still carry blob references through
|
||||
// either a raw blob-index payload in `value` or unresolved blob columns in
|
||||
// `columns`. Unlike the direct-write helper above, this path only depends on
|
||||
// a BlobFetcher and therefore works for read-only/secondary DBs.
|
||||
static bool MaybeResolveMemtableBlobValue(const Slice& key,
|
||||
const BlobFetcher* blob_fetcher,
|
||||
PinnableSlice* value,
|
||||
PinnableWideColumns* columns,
|
||||
Status* s, bool* is_blob_index,
|
||||
bool* value_found = nullptr);
|
||||
// Completes read-only/secondary memtable Get()/GetEntity() hits by resolving
|
||||
// blob-backed payloads when `resolve_blob_backed_memtable_value` is true,
|
||||
// pinning plain values on success, and clearing outputs on error. When the
|
||||
// caller explicitly requested raw blob indices via
|
||||
// `GetImplOptions::is_blob_index`, this helper leaves that payload
|
||||
// untouched. `memtable_blob_fetcher` may be null when blob support is
|
||||
// disabled for the column family.
|
||||
static void PostprocessMemtableValueRead(
|
||||
const Slice& key, const std::string* timestamp,
|
||||
bool resolve_blob_backed_memtable_value,
|
||||
const BlobFetcher* memtable_blob_fetcher, PinnableSlice* value,
|
||||
PinnableWideColumns* columns, Status* s, bool* is_blob_index,
|
||||
bool* value_found = nullptr);
|
||||
|
||||
template <typename IterType, typename ImplType,
|
||||
typename ErrorIteratorFuncType>
|
||||
@@ -3348,6 +3380,13 @@ class DBImpl : public DB {
|
||||
// Callback for compaction to check if a key is visible to a snapshot.
|
||||
// REQUIRES: mutex held
|
||||
std::unique_ptr<SnapshotChecker> snapshot_checker_;
|
||||
// When set, InitSnapshotContext() appends GetLastPublishedSequence() to the
|
||||
// job's snapshot_seqs (if not already present) so that flush/compaction
|
||||
// preserves the published-sequence boundary even when no explicit user
|
||||
// snapshot exists there. Unlike taking a real ManagedSnapshot, this just
|
||||
// pins the seqno into the snapshot list: no allocation, no snapshot list
|
||||
// mutation, and no SnapshotChecker.
|
||||
bool track_published_seq_in_snapshot_context_ = false;
|
||||
|
||||
// Callback for when the cached_recoverable_state_ is written to memtable
|
||||
// Only to be set during initialization
|
||||
|
||||
@@ -874,6 +874,24 @@ Status DBImpl::AtomicFlushMemTablesToOutputFiles(
|
||||
directories_.GetDbDir(), log_buffer);
|
||||
}
|
||||
|
||||
if (!s.ok()) {
|
||||
// If the atomic flush's combined MANIFEST write failed, the output files
|
||||
// are cached in the table cache (added by BuildTable during FlushJob::Run)
|
||||
// but never installed into any Version. Evict them now so that
|
||||
// TEST_VerifyNoObsoleteFilesCached does not fire during Close() when the
|
||||
// FindObsoleteFiles full-scan backstop is disabled by
|
||||
// metadata_read_fault_one_in.
|
||||
for (int i = 0; i != num_cfs; ++i) {
|
||||
if (exec_status[i].first && exec_status[i].second.ok() &&
|
||||
!switched_to_mempurge[i] && file_meta[i].fd.GetFileSize() > 0) {
|
||||
TableCache::ReleaseObsolete(
|
||||
cfds[i]->table_cache()->get_cache().get(),
|
||||
file_meta[i].fd.GetNumber(), nullptr /*handle*/,
|
||||
all_mutable_cf_options[i].uncache_aggressiveness);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i != num_cfs; ++i) {
|
||||
auto* mgr = cfds[i]->blob_partition_manager();
|
||||
if (mgr == nullptr || prepared_blob_generations[i] == 0) {
|
||||
@@ -5114,6 +5132,11 @@ void DBImpl::MarkAsGrabbedForPurge(uint64_t file_number) {
|
||||
files_grabbed_for_purge_.insert(file_number);
|
||||
}
|
||||
|
||||
void DBImpl::EnableTrackPublishedSeqInSnapshotContext() {
|
||||
InstrumentedMutexLock l(&mutex_);
|
||||
track_published_seq_in_snapshot_context_ = true;
|
||||
}
|
||||
|
||||
void DBImpl::SetSnapshotChecker(SnapshotChecker* snapshot_checker) {
|
||||
InstrumentedMutexLock l(&mutex_);
|
||||
// snapshot_checker_ should only set once. If we need to set it multiple
|
||||
@@ -5147,6 +5170,19 @@ void DBImpl::InitSnapshotContext(JobContext* job_context) {
|
||||
SequenceNumber earliest_write_conflict_snapshot = kMaxSequenceNumber;
|
||||
std::vector<SequenceNumber> snapshot_seqs =
|
||||
snapshots_.GetAll(&earliest_write_conflict_snapshot);
|
||||
|
||||
// The SnapshotChecker path above already adds a managed snapshot to
|
||||
// snapshot_seqs.
|
||||
if (track_published_seq_in_snapshot_context_ && !snapshot_checker) {
|
||||
// Pin the published-sequence boundary into snapshot_seqs without taking
|
||||
// a real snapshot. This prevents flush/compaction from collapsing a version
|
||||
// visible at the published boundary into a newer unpublished seqno.
|
||||
const SequenceNumber published_seq = GetLastPublishedSequence();
|
||||
if (snapshot_seqs.empty() || snapshot_seqs.back() < published_seq) {
|
||||
snapshot_seqs.push_back(published_seq);
|
||||
}
|
||||
}
|
||||
TEST_SYNC_POINT("DBImpl::InitSnapshotContext:BeforeInit");
|
||||
job_context->InitSnapshotContext(
|
||||
snapshot_checker, std::move(managed_snapshot),
|
||||
earliest_write_conflict_snapshot, std::move(snapshot_seqs));
|
||||
|
||||
+36
-12
@@ -1186,8 +1186,9 @@ std::set<std::string> DBImpl::CollectAllDBPaths() {
|
||||
|
||||
Status DBImpl::MaybeUpdateNextFileNumber(RecoveryContext* recovery_ctx) {
|
||||
mutex_.AssertHeld();
|
||||
uint64_t next_file_number = versions_->current_next_file_number();
|
||||
uint64_t largest_file_number = next_file_number;
|
||||
const uint64_t prev_next_file_number = versions_->current_next_file_number();
|
||||
uint64_t largest_file_number = prev_next_file_number;
|
||||
bool on_disk_file_advanced_counter = false;
|
||||
Status s;
|
||||
for (const auto& path : CollectAllDBPaths()) {
|
||||
std::vector<std::string> files;
|
||||
@@ -1201,7 +1202,15 @@ Status DBImpl::MaybeUpdateNextFileNumber(RecoveryContext* recovery_ctx) {
|
||||
if (!ParseFileName(fname, &number, &type)) {
|
||||
continue;
|
||||
}
|
||||
const std::string normalized_fpath = path + kFilePathSeparator + fname;
|
||||
std::string normalized_fpath = path;
|
||||
normalized_fpath += kFilePathSeparator;
|
||||
normalized_fpath.append(fname);
|
||||
// Use >= so a crashed-mid-allocation file at exactly
|
||||
// prev_next_file_number triggers the advance -- otherwise the next
|
||||
// NewFileNumber() would collide with it.
|
||||
if (number >= prev_next_file_number) {
|
||||
on_disk_file_advanced_counter = true;
|
||||
}
|
||||
largest_file_number = std::max(largest_file_number, number);
|
||||
if ((type == kTableFile || type == kBlobFile)) {
|
||||
recovery_ctx->existing_data_files_.push_back(normalized_fpath);
|
||||
@@ -1212,16 +1221,31 @@ Status DBImpl::MaybeUpdateNextFileNumber(RecoveryContext* recovery_ctx) {
|
||||
return s;
|
||||
}
|
||||
|
||||
if (largest_file_number >= next_file_number) {
|
||||
versions_->next_file_number_.store(largest_file_number + 1);
|
||||
}
|
||||
// Legacy bug: initializing largest_file_number from prev_next_file_number
|
||||
// made the post-loop check trivially true and emitted a noop SetNextFile
|
||||
// on every recovery.
|
||||
// Preserved verbatim by default (some tests pin file numbers); skipped
|
||||
// only when the option is on AND we're not in salvage mode (which
|
||||
// requires the MANIFEST rewrite this emit triggers).
|
||||
const bool optimize_manifest_for_recovery =
|
||||
mutable_db_options_.optimize_manifest_for_recovery &&
|
||||
!immutable_db_options_.best_efforts_recovery;
|
||||
|
||||
VersionEdit edit;
|
||||
edit.SetNextFile(versions_->next_file_number_.load());
|
||||
assert(versions_->GetColumnFamilySet());
|
||||
ColumnFamilyData* default_cfd = versions_->GetColumnFamilySet()->GetDefault();
|
||||
assert(default_cfd);
|
||||
recovery_ctx->UpdateVersionEdits(default_cfd, edit);
|
||||
if (!optimize_manifest_for_recovery || on_disk_file_advanced_counter) {
|
||||
if (largest_file_number >= prev_next_file_number) {
|
||||
versions_->next_file_number_.store(largest_file_number + 1);
|
||||
}
|
||||
TEST_SYNC_POINT("DBImpl::MaybeUpdateNextFileNumber:EmitEdit");
|
||||
VersionEdit edit;
|
||||
edit.SetNextFile(versions_->next_file_number_.load());
|
||||
assert(versions_->GetColumnFamilySet());
|
||||
ColumnFamilyData* default_cfd =
|
||||
versions_->GetColumnFamilySet()->GetDefault();
|
||||
assert(default_cfd);
|
||||
recovery_ctx->UpdateVersionEdits(default_cfd, edit);
|
||||
} else {
|
||||
TEST_SYNC_POINT("DBImpl::Recovery:SkippedNoopEdit:NextFileNumber");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
@@ -295,7 +295,8 @@ Status DB::OpenAsFollower(
|
||||
impl->versions_.reset(new ReactiveVersionSet(
|
||||
dbname, &impl->immutable_db_options_, impl->mutable_db_options_,
|
||||
impl->file_options_, impl->table_cache_.get(),
|
||||
impl->write_buffer_manager_, &impl->write_controller_, impl->io_tracer_));
|
||||
impl->write_buffer_manager_, &impl->write_controller_, impl->io_tracer_,
|
||||
impl->db_id_, impl->db_session_id_));
|
||||
impl->column_family_memtables_.reset(
|
||||
new ColumnFamilyMemTablesImpl(impl->versions_->GetColumnFamilySet()));
|
||||
impl->wal_in_db_path_ = impl->immutable_db_options_.IsWalDirSameAsDBPath();
|
||||
|
||||
+51
-11
@@ -680,8 +680,18 @@ Status DBImpl::Recover(
|
||||
} else if (immutable_db_options_.write_dbid_to_manifest && recovery_ctx) {
|
||||
VersionEdit edit;
|
||||
s = SetupDBId(write_options, read_only, is_new_db, is_retry, &edit);
|
||||
recovery_ctx->UpdateVersionEdits(
|
||||
versions_->GetColumnFamilySet()->GetDefault(), edit);
|
||||
// best_efforts_recovery rebuilds CURRENT/MANIFEST as the side-effect
|
||||
// of LogAndApplyForRecovery emitting an edit, so the optimization is
|
||||
// disabled there (see optimize_manifest_for_recovery doc).
|
||||
const bool optimize_manifest_for_recovery =
|
||||
mutable_db_options_.optimize_manifest_for_recovery &&
|
||||
!immutable_db_options_.best_efforts_recovery;
|
||||
if (!optimize_manifest_for_recovery || edit.HasDbId()) {
|
||||
recovery_ctx->UpdateVersionEdits(
|
||||
versions_->GetColumnFamilySet()->GetDefault(), edit);
|
||||
} else {
|
||||
TEST_SYNC_POINT("DBImpl::Recovery:SkippedNoopEdit:SetupDBId");
|
||||
}
|
||||
} else {
|
||||
s = SetupDBId(write_options, read_only, is_new_db, is_retry, nullptr);
|
||||
}
|
||||
@@ -770,6 +780,10 @@ Status DBImpl::Recover(
|
||||
// otherwise, in the future, if WAL tracking is enabled again,
|
||||
// since the WALs deleted when WAL tracking is disabled are not persisted
|
||||
// into MANIFEST, WAL check may fail.
|
||||
//
|
||||
// Intentionally NOT gated by optimize_manifest_for_recovery:
|
||||
// this edit is safety-critical for a future re-enable of WAL tracking,
|
||||
// even though it can look noop-ish under the current session.
|
||||
VersionEdit edit;
|
||||
WalNumber max_wal_number =
|
||||
versions_->GetWalSet().GetWals().rbegin()->first;
|
||||
@@ -983,7 +997,11 @@ Status DBImpl::InitPersistStatsColumnFamily() {
|
||||
|
||||
Status DBImpl::LogAndApplyForRecovery(const RecoveryContext& recovery_ctx) {
|
||||
mutex_.AssertHeld();
|
||||
assert(versions_->descriptor_log_ == nullptr);
|
||||
// descriptor_log_ is normally null after Recover, but when
|
||||
// reuse_manifest_on_open is set VersionSet::Recover may have already
|
||||
// bound a log::Writer to the existing MANIFEST for append.
|
||||
assert(versions_->descriptor_log_ == nullptr ||
|
||||
immutable_db_options_.reuse_manifest_on_open);
|
||||
const ReadOptions read_options(Env::IOActivity::kDBOpen);
|
||||
const WriteOptions write_options(Env::IOActivity::kDBOpen);
|
||||
|
||||
@@ -1870,25 +1888,47 @@ Status DBImpl::MaybeFlushFinalMemtableOrRestoreActiveLogFiles(
|
||||
versions_->MarkFileNumberUsed(max_wal_number + 1);
|
||||
assert(recovery_ctx != nullptr);
|
||||
|
||||
const bool optimize_manifest_for_recovery =
|
||||
mutable_db_options_.optimize_manifest_for_recovery &&
|
||||
!immutable_db_options_.best_efforts_recovery;
|
||||
for (auto* cfd : *versions_->GetColumnFamilySet()) {
|
||||
auto iter = version_edits->find(cfd->GetID());
|
||||
assert(iter != version_edits->end());
|
||||
recovery_ctx->UpdateVersionEdits(cfd, iter->second);
|
||||
const VersionEdit& cf_edit = iter->second;
|
||||
if (!optimize_manifest_for_recovery ||
|
||||
cf_edit.ShouldEmitPerColumnFamilyRecoveryEdit(
|
||||
cfd->GetLogNumber())) {
|
||||
recovery_ctx->UpdateVersionEdits(cfd, cf_edit);
|
||||
} else {
|
||||
TEST_SYNC_POINT("DBImpl::Recovery:SkippedNoopEdit:PerCF");
|
||||
}
|
||||
}
|
||||
|
||||
if (flushed || !data_seen) {
|
||||
const uint64_t new_min_log = max_wal_number + 1;
|
||||
VersionEdit wal_deletion;
|
||||
bool emit_wal_deletion = false;
|
||||
if (immutable_db_options_.track_and_verify_wals_in_manifest) {
|
||||
wal_deletion.DeleteWalsBefore(max_wal_number + 1);
|
||||
// Determining whether DeleteWalsBefore actually shrinks WalSet
|
||||
// membership requires WalSet state outside this site, so emit
|
||||
// unconditionally (pre-existing behavior).
|
||||
wal_deletion.DeleteWalsBefore(new_min_log);
|
||||
emit_wal_deletion = true;
|
||||
}
|
||||
if (!allow_2pc()) {
|
||||
// In non-2pc mode, flushing the memtables of the column families
|
||||
// means we can advance min_log_number_to_keep.
|
||||
wal_deletion.SetMinLogNumberToKeep(max_wal_number + 1);
|
||||
const bool min_log_advances =
|
||||
new_min_log > versions_->min_log_number_to_keep();
|
||||
if (!allow_2pc() &&
|
||||
(!optimize_manifest_for_recovery || min_log_advances)) {
|
||||
wal_deletion.SetMinLogNumberToKeep(new_min_log);
|
||||
emit_wal_deletion = true;
|
||||
}
|
||||
assert(versions_->GetColumnFamilySet() != nullptr);
|
||||
recovery_ctx->UpdateVersionEdits(
|
||||
versions_->GetColumnFamilySet()->GetDefault(), wal_deletion);
|
||||
if (!optimize_manifest_for_recovery || emit_wal_deletion) {
|
||||
recovery_ctx->UpdateVersionEdits(
|
||||
versions_->GetColumnFamilySet()->GetDefault(), wal_deletion);
|
||||
} else {
|
||||
TEST_SYNC_POINT("DBImpl::Recovery:SkippedNoopEdit:WalDeletion");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
#include "db/db_impl/db_impl_readonly.h"
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "db/arena_wrapped_db_iter.h"
|
||||
#include "db/blob/blob_fetcher.h"
|
||||
#include "db/db_impl/compacted_db_impl.h"
|
||||
#include "db/db_impl/db_impl.h"
|
||||
#include "db/manifest_ops.h"
|
||||
@@ -63,13 +66,25 @@ Status DBImplReadOnly::GetImpl(const ReadOptions& read_options,
|
||||
|
||||
const Comparator* ucmp = get_impl_options.column_family->GetComparator();
|
||||
assert(ucmp);
|
||||
std::string* ts =
|
||||
ucmp->timestamp_size() > 0 ? get_impl_options.timestamp : nullptr;
|
||||
SequenceNumber snapshot = versions_->LastSequence();
|
||||
GetWithTimestampReadCallback read_cb(snapshot);
|
||||
auto cfh = static_cast_with_check<ColumnFamilyHandleImpl>(
|
||||
get_impl_options.column_family);
|
||||
auto cfd = cfh->cfd();
|
||||
bool is_blob_index = false;
|
||||
bool* is_blob_ptr = get_impl_options.is_blob_index;
|
||||
std::string timestamp_storage;
|
||||
std::string* ts = nullptr;
|
||||
if (ucmp->timestamp_size() > 0) {
|
||||
ts = get_impl_options.timestamp != nullptr
|
||||
? get_impl_options.timestamp
|
||||
: (get_impl_options.get_value ? ×tamp_storage : nullptr);
|
||||
}
|
||||
if (!is_blob_ptr && get_impl_options.get_value) {
|
||||
is_blob_ptr = &is_blob_index;
|
||||
}
|
||||
const bool resolve_blob_backed_memtable_value =
|
||||
get_impl_options.get_value && (is_blob_ptr == &is_blob_index);
|
||||
if (tracer_) {
|
||||
InstrumentedMutexLock lock(&trace_mutex_);
|
||||
if (tracer_) {
|
||||
@@ -95,6 +110,18 @@ Status DBImplReadOnly::GetImpl(const ReadOptions& read_options,
|
||||
SequenceNumber max_covering_tombstone_seq = 0;
|
||||
LookupKey lkey(key, snapshot, read_options.timestamp);
|
||||
PERF_TIMER_STOP(get_snapshot_time);
|
||||
std::optional<BlobFetcher> memtable_blob_fetcher;
|
||||
if (cfd->ioptions().enable_blob_direct_write ||
|
||||
cfd->GetLatestMutableCFOptions().enable_blob_files) {
|
||||
// Recovered memtables can still contain older blob references after
|
||||
// mutable blob-file settings change, so keep blob resolution available
|
||||
// whenever either blob knob indicates it may be needed.
|
||||
memtable_blob_fetcher.emplace(super_version->current, read_options,
|
||||
cfd->blob_file_cache(),
|
||||
/*allow_write_path_fallback=*/true);
|
||||
}
|
||||
const BlobFetcher* memtable_blob_fetcher_ptr =
|
||||
memtable_blob_fetcher ? &*memtable_blob_fetcher : nullptr;
|
||||
|
||||
// Look up starts here
|
||||
if (super_version->mem->Get(
|
||||
@@ -102,11 +129,12 @@ Status DBImplReadOnly::GetImpl(const ReadOptions& read_options,
|
||||
get_impl_options.value ? get_impl_options.value->GetSelf() : nullptr,
|
||||
get_impl_options.columns, ts, &s, &merge_context,
|
||||
&max_covering_tombstone_seq, read_options,
|
||||
false /* immutable_memtable */, &read_cb,
|
||||
/*is_blob_index=*/nullptr, /*do_merge=*/get_impl_options.get_value)) {
|
||||
if (get_impl_options.value) {
|
||||
get_impl_options.value->PinSelf();
|
||||
}
|
||||
false /* immutable_memtable */, &read_cb, is_blob_ptr,
|
||||
/*do_merge=*/get_impl_options.get_value, memtable_blob_fetcher_ptr)) {
|
||||
DBImpl::PostprocessMemtableValueRead(
|
||||
key, ts, resolve_blob_backed_memtable_value, memtable_blob_fetcher_ptr,
|
||||
get_impl_options.value, get_impl_options.columns, &s, &is_blob_index,
|
||||
get_impl_options.value_found);
|
||||
RecordTick(stats_, MEMTABLE_HIT);
|
||||
} else {
|
||||
PERF_TIMER_GUARD(get_from_output_files_time);
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
#include "db/db_impl/db_impl_secondary.h"
|
||||
|
||||
#include <cinttypes>
|
||||
#include <optional>
|
||||
|
||||
#include "db/arena_wrapped_db_iter.h"
|
||||
#include "db/blob/blob_fetcher.h"
|
||||
#include "db/log_reader.h"
|
||||
#include "db/log_writer.h"
|
||||
#include "db/merge_context.h"
|
||||
@@ -363,13 +365,25 @@ Status DBImplSecondary::GetImpl(const ReadOptions& read_options,
|
||||
|
||||
const Comparator* ucmp = get_impl_options.column_family->GetComparator();
|
||||
assert(ucmp);
|
||||
std::string* ts =
|
||||
ucmp->timestamp_size() > 0 ? get_impl_options.timestamp : nullptr;
|
||||
SequenceNumber snapshot = versions_->LastSequence();
|
||||
GetWithTimestampReadCallback read_cb(snapshot);
|
||||
auto cfh = static_cast_with_check<ColumnFamilyHandleImpl>(
|
||||
get_impl_options.column_family);
|
||||
auto cfd = cfh->cfd();
|
||||
bool is_blob_index = false;
|
||||
bool* is_blob_ptr = get_impl_options.is_blob_index;
|
||||
std::string timestamp_storage;
|
||||
std::string* ts = nullptr;
|
||||
if (ucmp->timestamp_size() > 0) {
|
||||
ts = get_impl_options.timestamp != nullptr
|
||||
? get_impl_options.timestamp
|
||||
: (get_impl_options.get_value ? ×tamp_storage : nullptr);
|
||||
}
|
||||
if (!is_blob_ptr && get_impl_options.get_value) {
|
||||
is_blob_ptr = &is_blob_index;
|
||||
}
|
||||
const bool resolve_blob_backed_memtable_value =
|
||||
get_impl_options.get_value && (is_blob_ptr == &is_blob_index);
|
||||
if (tracer_) {
|
||||
InstrumentedMutexLock lock(&trace_mutex_);
|
||||
if (tracer_) {
|
||||
@@ -395,6 +409,18 @@ Status DBImplSecondary::GetImpl(const ReadOptions& read_options,
|
||||
LookupKey lkey(key, snapshot, read_options.timestamp);
|
||||
PERF_TIMER_STOP(get_snapshot_time);
|
||||
bool done = false;
|
||||
std::optional<BlobFetcher> memtable_blob_fetcher;
|
||||
if (cfd->ioptions().enable_blob_direct_write ||
|
||||
cfd->GetLatestMutableCFOptions().enable_blob_files) {
|
||||
// Catch-up can rebuild older blob references into memtables after mutable
|
||||
// blob-file settings change, so keep blob resolution available whenever
|
||||
// either blob knob indicates it may be needed.
|
||||
memtable_blob_fetcher.emplace(super_version->current, read_options,
|
||||
cfd->blob_file_cache(),
|
||||
/*allow_write_path_fallback=*/true);
|
||||
}
|
||||
const BlobFetcher* memtable_blob_fetcher_ptr =
|
||||
memtable_blob_fetcher ? &*memtable_blob_fetcher : nullptr;
|
||||
|
||||
// Look up starts here
|
||||
if (get_impl_options.get_value) {
|
||||
@@ -404,12 +430,14 @@ Status DBImplSecondary::GetImpl(const ReadOptions& read_options,
|
||||
: nullptr,
|
||||
get_impl_options.columns, ts, &s, &merge_context,
|
||||
&max_covering_tombstone_seq, read_options,
|
||||
false /* immutable_memtable */, &read_cb,
|
||||
/*is_blob_index=*/nullptr, /*do_merge=*/true)) {
|
||||
false /* immutable_memtable */, &read_cb, is_blob_ptr,
|
||||
/*do_merge=*/true, memtable_blob_fetcher_ptr)) {
|
||||
done = true;
|
||||
if (get_impl_options.value) {
|
||||
get_impl_options.value->PinSelf();
|
||||
}
|
||||
DBImpl::PostprocessMemtableValueRead(
|
||||
key, ts, resolve_blob_backed_memtable_value,
|
||||
memtable_blob_fetcher_ptr, get_impl_options.value,
|
||||
get_impl_options.columns, &s, &is_blob_index,
|
||||
get_impl_options.value_found);
|
||||
RecordTick(stats_, MEMTABLE_HIT);
|
||||
} else if ((s.ok() || s.IsMergeInProgress()) &&
|
||||
super_version->imm->Get(
|
||||
@@ -417,11 +445,14 @@ Status DBImplSecondary::GetImpl(const ReadOptions& read_options,
|
||||
get_impl_options.value ? get_impl_options.value->GetSelf()
|
||||
: nullptr,
|
||||
get_impl_options.columns, ts, &s, &merge_context,
|
||||
&max_covering_tombstone_seq, read_options, &read_cb)) {
|
||||
&max_covering_tombstone_seq, read_options, &read_cb,
|
||||
is_blob_ptr, memtable_blob_fetcher_ptr)) {
|
||||
done = true;
|
||||
if (get_impl_options.value) {
|
||||
get_impl_options.value->PinSelf();
|
||||
}
|
||||
DBImpl::PostprocessMemtableValueRead(
|
||||
key, ts, resolve_blob_backed_memtable_value,
|
||||
memtable_blob_fetcher_ptr, get_impl_options.value,
|
||||
get_impl_options.columns, &s, &is_blob_index,
|
||||
get_impl_options.value_found);
|
||||
RecordTick(stats_, MEMTABLE_HIT);
|
||||
}
|
||||
} else {
|
||||
@@ -433,13 +464,14 @@ Status DBImplSecondary::GetImpl(const ReadOptions& read_options,
|
||||
get_impl_options.columns, ts, &s, &merge_context,
|
||||
&max_covering_tombstone_seq, read_options,
|
||||
false /* immutable_memtable */, &read_cb,
|
||||
/*is_blob_index=*/nullptr, /*do_merge=*/false)) {
|
||||
/*is_blob_index=*/nullptr, /*do_merge=*/false,
|
||||
memtable_blob_fetcher_ptr)) {
|
||||
done = true;
|
||||
RecordTick(stats_, MEMTABLE_HIT);
|
||||
} else if ((s.ok() || s.IsMergeInProgress()) &&
|
||||
super_version->imm->GetMergeOperands(lkey, &s, &merge_context,
|
||||
&max_covering_tombstone_seq,
|
||||
read_options)) {
|
||||
super_version->imm->GetMergeOperands(
|
||||
lkey, &s, &merge_context, &max_covering_tombstone_seq,
|
||||
read_options, memtable_blob_fetcher_ptr)) {
|
||||
done = true;
|
||||
RecordTick(stats_, MEMTABLE_HIT);
|
||||
}
|
||||
@@ -457,7 +489,7 @@ Status DBImplSecondary::GetImpl(const ReadOptions& read_options,
|
||||
ts, &s, &merge_context, &max_covering_tombstone_seq, &pinned_iters_mgr,
|
||||
/*value_found*/ nullptr,
|
||||
/*key_exists*/ nullptr, /*seq*/ nullptr, &read_cb, /*is_blob*/ nullptr,
|
||||
/*do_merge*/ true);
|
||||
/*do_merge=*/get_impl_options.get_value);
|
||||
RecordTick(stats_, MEMTABLE_MISS);
|
||||
}
|
||||
{
|
||||
@@ -777,7 +809,8 @@ Status DBImplSecondary::OpenAsSecondaryImpl(
|
||||
impl->versions_.reset(new ReactiveVersionSet(
|
||||
dbname, &impl->immutable_db_options_, impl->mutable_db_options_,
|
||||
impl->file_options_, impl->table_cache_.get(),
|
||||
impl->write_buffer_manager_, &impl->write_controller_, impl->io_tracer_));
|
||||
impl->write_buffer_manager_, &impl->write_controller_, impl->io_tracer_,
|
||||
impl->db_id_, impl->db_session_id_));
|
||||
impl->column_family_memtables_.reset(
|
||||
new ColumnFamilyMemTablesImpl(impl->versions_->GetColumnFamilySet()));
|
||||
impl->wal_in_db_path_ = impl->immutable_db_options_.IsWalDirSameAsDBPath();
|
||||
@@ -1553,7 +1586,7 @@ Status DB::OpenAndCompact(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Open db As Secondary (skip WAL recovery — remote compaction only
|
||||
// 5. Open db As Secondary (skip WAL recovery -- remote compaction only
|
||||
// needs LSM state from MANIFEST, not memtable data from WAL replay)
|
||||
std::unique_ptr<DB> db;
|
||||
std::vector<ColumnFamilyHandle*> handles;
|
||||
|
||||
@@ -509,6 +509,7 @@ struct DBImpl::BlobDirectWriteContext {
|
||||
sv->cfd->ioptions().enable_blob_direct_write;
|
||||
settings.min_blob_size = sv->mutable_cf_options.min_blob_size;
|
||||
settings.compression_type = sv->mutable_cf_options.blob_compression_type;
|
||||
settings.compression_opts = sv->mutable_cf_options.blob_compression_opts;
|
||||
settings.blob_cache = sv->cfd->ioptions().blob_cache.get();
|
||||
settings.prepopulate_blob_cache =
|
||||
sv->mutable_cf_options.prepopulate_blob_cache;
|
||||
@@ -1545,6 +1546,10 @@ Status DBImpl::WriteImpl(
|
||||
// Note: if we are to resume after non-OK statuses we need to revisit how
|
||||
// we react to non-OK statuses here.
|
||||
if (w.status.ok()) { // Don't publish a partial batch write
|
||||
TEST_SYNC_POINT(
|
||||
"DBImpl::WriteImpl:AfterWBWIIngestBeforeSetLastSequence:pause");
|
||||
TEST_SYNC_POINT(
|
||||
"DBImpl::WriteImpl:AfterWBWIIngestBeforeSetLastSequence:resume");
|
||||
versions_->SetLastSequence(last_sequence);
|
||||
}
|
||||
}
|
||||
|
||||
+51
-54
@@ -76,7 +76,7 @@ DBIter::DBIter(Env* _env, const ReadOptions& read_options,
|
||||
user_comparator_(cmp),
|
||||
merge_operator_(ioptions.merge_operator.get()),
|
||||
iter_(iter),
|
||||
blob_reader_(
|
||||
blob_state_(
|
||||
version, read_options.read_tier, read_options.verify_checksums,
|
||||
read_options.fill_cache, read_options.io_activity,
|
||||
cfh ? cfh->cfd()->blob_file_cache() : nullptr,
|
||||
@@ -125,7 +125,6 @@ DBIter::DBIter(Env* _env, const ReadOptions& read_options,
|
||||
: 0),
|
||||
expose_blob_index_(expose_blob_index),
|
||||
allow_unprepared_value_(read_options.allow_unprepared_value),
|
||||
is_blob_(false),
|
||||
arena_mode_(arena_mode) {
|
||||
RecordTick(statistics_, NO_ITERATOR_CREATED);
|
||||
if (pin_thru_lifetime_) {
|
||||
@@ -178,7 +177,7 @@ Status DBIter::GetProperty(std::string prop_name, std::string* prop) {
|
||||
} else if (prop_name == "rocksdb.iterator.is-value-pinned") {
|
||||
if (valid_) {
|
||||
*prop = (pin_thru_lifetime_ && iter_.Valid() &&
|
||||
iter_.value().data() == value_columns_state_.value().data())
|
||||
iter_.value().data() == value_columns_state_->value().data())
|
||||
? "1"
|
||||
: "0";
|
||||
} else {
|
||||
@@ -308,24 +307,24 @@ bool DBIter::SetValueAndColumnsFromBlobImpl(const Slice& user_key,
|
||||
// has a write-path partition manager.
|
||||
const bool allow_write_path_fallback =
|
||||
cfh_ != nullptr && cfh_->cfd()->blob_partition_manager() != nullptr;
|
||||
const Status s = blob_reader_.RetrieveAndSetBlobValue(
|
||||
const Status s = blob_state_.mut()->reader.RetrieveAndSetBlobValue(
|
||||
user_key, blob_index, allow_write_path_fallback);
|
||||
if (!s.ok()) {
|
||||
status_ = s;
|
||||
valid_ = false;
|
||||
is_blob_ = false;
|
||||
blob_state_.mut()->is_blob = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
SetValueAndColumnsFromPlain(blob_reader_.GetBlobValue());
|
||||
SetValueAndColumnsFromPlain(blob_state_->reader.GetBlobValue());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DBIter::SetValueAndColumnsFromBlob(const Slice& user_key,
|
||||
const Slice& blob_index) {
|
||||
assert(!is_blob_);
|
||||
is_blob_ = true;
|
||||
assert(!blob_state_->is_blob);
|
||||
blob_state_.mut()->is_blob = true;
|
||||
|
||||
if (expose_blob_index_) {
|
||||
SetValueAndColumnsFromPlain(blob_index);
|
||||
@@ -333,11 +332,11 @@ bool DBIter::SetValueAndColumnsFromBlob(const Slice& user_key,
|
||||
}
|
||||
|
||||
if (allow_unprepared_value_) {
|
||||
assert(value_columns_state_.value().empty());
|
||||
assert(value_columns_state_.wide_columns().empty());
|
||||
assert(value_columns_state_->value().empty());
|
||||
assert(value_columns_state_->wide_columns().empty());
|
||||
|
||||
assert(lazy_blob_index_.empty());
|
||||
lazy_blob_index_ = blob_index;
|
||||
assert(blob_state_->lazy_blob_index.empty());
|
||||
blob_state_.mut()->lazy_blob_index = blob_index;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -346,7 +345,10 @@ bool DBIter::SetValueAndColumnsFromBlob(const Slice& user_key,
|
||||
}
|
||||
|
||||
bool DBIter::SetValueAndColumnsFromEntity(Slice slice) {
|
||||
auto& state = value_columns_state_;
|
||||
// Auto-marks dirty via mut() up front since every successful path below
|
||||
// populates wide_columns_ (and possibly the lazy entity/blob column
|
||||
// vectors).
|
||||
auto& state = *value_columns_state_.mut();
|
||||
state.AssertReadyForEntity();
|
||||
|
||||
// Fast path: if no blob columns, use the simpler Deserialize
|
||||
@@ -409,7 +411,7 @@ bool DBIter::SetValueAndColumnsFromEntity(Slice slice) {
|
||||
}
|
||||
|
||||
bool DBIter::MaterializeLazyEntityColumns() const {
|
||||
const auto& state = value_columns_state_;
|
||||
const auto& state = *value_columns_state_;
|
||||
if (state.lazy_entity_columns().empty() || !state.wide_columns().empty()) {
|
||||
return true;
|
||||
}
|
||||
@@ -420,7 +422,7 @@ bool DBIter::MaterializeLazyEntityColumns() const {
|
||||
}
|
||||
|
||||
DBIter* const mutable_this = const_cast<DBIter*>(this);
|
||||
auto& mutable_state = mutable_this->value_columns_state_;
|
||||
auto& mutable_state = *mutable_this->value_columns_state_.mut();
|
||||
WideColumns materialized_columns;
|
||||
materialized_columns.reserve(state.lazy_entity_columns().size());
|
||||
for (const auto& col : state.lazy_entity_columns()) {
|
||||
@@ -454,7 +456,7 @@ bool DBIter::SetValueAndColumnsFromMergeResult(const Status& merge_status,
|
||||
}
|
||||
|
||||
if (result_type == kTypeWideColumnEntity) {
|
||||
if (!SetValueAndColumnsFromEntity(value_columns_state_.saved_value())) {
|
||||
if (!SetValueAndColumnsFromEntity(value_columns_state_->saved_value())) {
|
||||
assert(!valid_);
|
||||
return false;
|
||||
}
|
||||
@@ -466,7 +468,7 @@ bool DBIter::SetValueAndColumnsFromMergeResult(const Status& merge_status,
|
||||
assert(result_type == kTypeValue);
|
||||
SetValueAndColumnsFromPlain(pinned_value_.data()
|
||||
? pinned_value_
|
||||
: value_columns_state_.saved_value());
|
||||
: value_columns_state_->saved_value());
|
||||
valid_ = true;
|
||||
return true;
|
||||
}
|
||||
@@ -474,17 +476,17 @@ bool DBIter::SetValueAndColumnsFromMergeResult(const Status& merge_status,
|
||||
bool DBIter::PrepareValue() {
|
||||
assert(valid_);
|
||||
|
||||
if (lazy_blob_index_.empty()) {
|
||||
if (blob_state_->lazy_blob_index.empty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
assert(allow_unprepared_value_);
|
||||
assert(is_blob_);
|
||||
assert(blob_state_->is_blob);
|
||||
|
||||
const bool result =
|
||||
SetValueAndColumnsFromBlobImpl(saved_key_.GetUserKey(), lazy_blob_index_);
|
||||
const bool result = SetValueAndColumnsFromBlobImpl(
|
||||
saved_key_.GetUserKey(), blob_state_->lazy_blob_index);
|
||||
|
||||
lazy_blob_index_.clear();
|
||||
blob_state_.mut()->lazy_blob_index.clear();
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -621,7 +623,7 @@ bool DBIter::FindNextUserEntryInternal(bool skipping_saved_key) {
|
||||
PERF_COUNTER_ADD(internal_delete_skipped_count, 1);
|
||||
MarkMemtableForFlushForPerOpTrigger(mem_hidden_op_scanned);
|
||||
// Track contiguous tombstones for range conversion.
|
||||
// Skip if outside seek prefix — the top-of-loop check
|
||||
// Skip if outside seek prefix -- the top-of-loop check
|
||||
// flushed any pending run, but we must also avoid starting
|
||||
// a new run outside the prefix.
|
||||
if (min_tombstones_for_range_conversion_ > 0 &&
|
||||
@@ -774,25 +776,18 @@ bool DBIter::FindNextUserEntryInternal(bool skipping_saved_key) {
|
||||
}
|
||||
} while (iter_.Valid());
|
||||
|
||||
// If we accumulated tombstones, insert the range tombstone. Use
|
||||
// iterate_upper_bound_ if within the seek prefix, otherwise fall back to
|
||||
// saved_key_ (the last tracked delete, covering n-1 deletes).
|
||||
// If we accumulated tombstones, use the last tracked tombstone as the
|
||||
// exclusive end key. It may be more optimal to use iterator upper bound if it
|
||||
// exists, but the current iterator API makes that dangerous as upper bound
|
||||
// points to user memory which is not guaranteed immutable.
|
||||
if (contiguous_tombstone_count_ > 0 && iter_.status().ok()) {
|
||||
if (iterate_upper_bound_ != nullptr && PrefixCheck(*iterate_upper_bound_)) {
|
||||
if (timestamp_size_ == 0) {
|
||||
MaybeInsertRangeTombstone(*iterate_upper_bound_);
|
||||
} else {
|
||||
// iterate_upper_bound_ is a plain user key without a timestamp
|
||||
// suffix. Pad with min timestamp so it sorts after all entries with
|
||||
// this user key, preserving the exclusive bound semantics.
|
||||
std::string end_key_with_ts;
|
||||
AppendKeyWithMinTimestamp(&end_key_with_ts, *iterate_upper_bound_,
|
||||
timestamp_size_);
|
||||
MaybeInsertRangeTombstone(end_key_with_ts);
|
||||
}
|
||||
} else if (prefix_.has_value()) {
|
||||
MaybeInsertRangeTombstone(saved_key_.GetUserKey());
|
||||
}
|
||||
// It is unsafe to use iter_.key() here even when iter_.Valid() and the key
|
||||
// is within the seek prefix. This is because memtable iterators are still
|
||||
// valid past the upper bound, but sst iterators are not. So iter_.key() can
|
||||
// point to a memtable entry that has skipped past real live entries in
|
||||
// ssts.
|
||||
assert(PrefixCheck(saved_key_.GetUserKey()));
|
||||
MaybeInsertRangeTombstone(saved_key_.GetUserKey());
|
||||
}
|
||||
ResetContiguousTombstoneTracking();
|
||||
|
||||
@@ -1102,7 +1097,7 @@ void DBIter::PrevInternal() {
|
||||
if (min_tombstones_for_range_conversion_ > 0 &&
|
||||
range_tomb_end_key_.Size() > 0 && timestamp_lb_ == nullptr) {
|
||||
if (!valid_ && found_visible && PrefixCheck(saved_key_without_ts)) {
|
||||
// Key was deleted and is within the seek prefix — track it.
|
||||
// Key was deleted and is within the seek prefix -- track it.
|
||||
TrackContiguousTombstone(saved_key_.GetUserKey(),
|
||||
/*always_update_first_key=*/true);
|
||||
} else if (valid_) {
|
||||
@@ -1146,7 +1141,7 @@ void DBIter::PrevInternal() {
|
||||
// Sets ikey_ to the last visible entry's internal key. When found_visible
|
||||
// is false, ikey_ is not updated and may contain stale data.
|
||||
// Sets found_visible to true if at least one entry passed the IsVisible()
|
||||
// check (seqno <= snapshot). When false, no entry was visible — the key
|
||||
// check (seqno <= snapshot). When false, no entry was visible -- the key
|
||||
// does not exist at this snapshot and should not be treated as a tombstone.
|
||||
// Returns false if an error occurred, and !status().ok() and !valid_.
|
||||
//
|
||||
@@ -1201,7 +1196,7 @@ bool DBIter::FindValueForCurrentKey(bool& found_visible) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Entry survived the visibility check — at least one visible version
|
||||
// Entry survived the visibility check -- at least one visible version
|
||||
// exists for this user key.
|
||||
found_visible = true;
|
||||
|
||||
@@ -1592,7 +1587,7 @@ bool DBIter::MergeWithNoBaseValue(const Slice& user_key) {
|
||||
merge_operator_, user_key, MergeHelper::kNoBaseValue,
|
||||
merge_context_.GetOperands(), logger_, statistics_, clock_,
|
||||
/* update_num_ops_stats */ true, /* op_failure_scope */ nullptr,
|
||||
&value_columns_state_.saved_value(), &pinned_value_, &result_type);
|
||||
&value_columns_state_.mut()->saved_value(), &pinned_value_, &result_type);
|
||||
return SetValueAndColumnsFromMergeResult(s, result_type);
|
||||
}
|
||||
|
||||
@@ -1605,13 +1600,13 @@ bool DBIter::MergeWithPlainBaseValue(const Slice& value,
|
||||
merge_operator_, user_key, MergeHelper::kPlainBaseValue, value,
|
||||
merge_context_.GetOperands(), logger_, statistics_, clock_,
|
||||
/* update_num_ops_stats */ true, /* op_failure_scope */ nullptr,
|
||||
&value_columns_state_.saved_value(), &pinned_value_, &result_type);
|
||||
&value_columns_state_.mut()->saved_value(), &pinned_value_, &result_type);
|
||||
return SetValueAndColumnsFromMergeResult(s, result_type);
|
||||
}
|
||||
|
||||
bool DBIter::MergeWithBlobBaseValue(const Slice& blob_index,
|
||||
const Slice& user_key) {
|
||||
assert(!is_blob_);
|
||||
assert(!blob_state_->is_blob);
|
||||
|
||||
if (expose_blob_index_) {
|
||||
status_ =
|
||||
@@ -1622,7 +1617,7 @@ bool DBIter::MergeWithBlobBaseValue(const Slice& blob_index,
|
||||
|
||||
const bool allow_write_path_fallback =
|
||||
cfh_ != nullptr && cfh_->cfd()->blob_partition_manager() != nullptr;
|
||||
const Status s = blob_reader_.RetrieveAndSetBlobValue(
|
||||
const Status s = blob_state_.mut()->reader.RetrieveAndSetBlobValue(
|
||||
user_key, blob_index, allow_write_path_fallback);
|
||||
if (!s.ok()) {
|
||||
status_ = s;
|
||||
@@ -1632,11 +1627,11 @@ bool DBIter::MergeWithBlobBaseValue(const Slice& blob_index,
|
||||
|
||||
valid_ = true;
|
||||
|
||||
if (!MergeWithPlainBaseValue(blob_reader_.GetBlobValue(), user_key)) {
|
||||
if (!MergeWithPlainBaseValue(blob_state_->reader.GetBlobValue(), user_key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
blob_reader_.ResetBlobValue();
|
||||
blob_state_.Reset();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1645,7 +1640,7 @@ bool DBIter::MergeWithWideColumnBaseValue(const Slice& entity,
|
||||
const Slice& user_key) {
|
||||
// Resolve V2 entity blob columns if present, since TimedFullMerge only
|
||||
// supports V1 format.
|
||||
BlobFetcher blob_fetcher = blob_reader_.CreateBlobFetcher();
|
||||
BlobFetcher blob_fetcher = blob_state_->reader.CreateBlobFetcher();
|
||||
std::string resolved_entity;
|
||||
Slice effective_entity;
|
||||
Status s_resolve = WideColumnSerialization::ResolveEntityForMerge(
|
||||
@@ -1664,7 +1659,7 @@ bool DBIter::MergeWithWideColumnBaseValue(const Slice& entity,
|
||||
merge_operator_, user_key, MergeHelper::kWideBaseValue, effective_entity,
|
||||
merge_context_.GetOperands(), logger_, statistics_, clock_,
|
||||
/* update_num_ops_stats */ true, /* op_failure_scope */ nullptr,
|
||||
&value_columns_state_.saved_value(), &pinned_value_, &result_type);
|
||||
&value_columns_state_.mut()->saved_value(), &pinned_value_, &result_type);
|
||||
return SetValueAndColumnsFromMergeResult(s, result_type);
|
||||
}
|
||||
|
||||
@@ -1788,7 +1783,7 @@ void DBIter::MaybeInsertRangeTombstone(const Slice& end_key) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert at the read sequence so the synthesized tombstone is visible only
|
||||
// Insert at the read sequence so the converted tombstone is visible only
|
||||
// to readers that could already observe the deletion run.
|
||||
SequenceNumber insert_seq = sequence_;
|
||||
|
||||
@@ -1819,8 +1814,10 @@ void DBIter::MaybeInsertRangeTombstone(const Slice& end_key) {
|
||||
}
|
||||
}
|
||||
|
||||
assert(cfh_ != nullptr);
|
||||
if (active_mem_->AddLogicallyRedundantRangeTombstone(
|
||||
insert_seq, range_tomb_first_key_.GetUserKey(), end_key)) {
|
||||
insert_seq, range_tomb_first_key_.GetUserKey(), end_key,
|
||||
cfh_->cfd()->GetIngestSstLock())) {
|
||||
RecordTick(statistics_, READ_PATH_RANGE_TOMBSTONES_INSERTED);
|
||||
ROCKS_LOG_DEBUG(logger_,
|
||||
"Inserted range tombstone [%s, %s) @ seq %" PRIu64
|
||||
|
||||
+35
-18
@@ -27,6 +27,7 @@
|
||||
#include "rocksdb/wide_columns.h"
|
||||
#include "table/iterator_wrapper.h"
|
||||
#include "util/autovector.h"
|
||||
#include "util/dirty_tracked.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
class BlobFileCache;
|
||||
@@ -195,14 +196,14 @@ class DBIter final : public Iterator {
|
||||
Slice value() const override {
|
||||
assert(valid_);
|
||||
|
||||
return value_columns_state_.value();
|
||||
return value_columns_state_->value();
|
||||
}
|
||||
|
||||
const WideColumns& columns() const override {
|
||||
assert(valid_);
|
||||
assert(!value_columns_state_.HasLazyEntityColumns() ||
|
||||
value_columns_state_.HasMaterializedColumns());
|
||||
return value_columns_state_.wide_columns();
|
||||
assert(!value_columns_state_->HasLazyEntityColumns() ||
|
||||
value_columns_state_->HasMaterializedColumns());
|
||||
return value_columns_state_->wide_columns();
|
||||
}
|
||||
|
||||
Status status() const override {
|
||||
@@ -225,7 +226,7 @@ class DBIter final : public Iterator {
|
||||
}
|
||||
bool IsBlob() const {
|
||||
assert(valid_);
|
||||
return is_blob_;
|
||||
return blob_state_->is_blob;
|
||||
}
|
||||
|
||||
Status GetProperty(std::string prop_name, std::string* prop) override;
|
||||
@@ -296,6 +297,20 @@ class DBIter final : public Iterator {
|
||||
BlobFileCache* blob_file_cache_;
|
||||
bool allow_write_path_fallback_;
|
||||
};
|
||||
struct BlobState {
|
||||
BlobReader reader;
|
||||
Slice lazy_blob_index;
|
||||
bool is_blob = false;
|
||||
|
||||
template <typename... Args>
|
||||
explicit BlobState(Args&&... args) : reader(std::forward<Args>(args)...) {}
|
||||
|
||||
void Reset() {
|
||||
reader.ResetBlobValue();
|
||||
lazy_blob_index.clear();
|
||||
is_blob = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Groups the current iterator result together with the backing storage and
|
||||
// lazy entity-resolution metadata it depends on. Resetting this object drops
|
||||
@@ -483,7 +498,9 @@ class DBIter final : public Iterator {
|
||||
}
|
||||
}
|
||||
|
||||
inline void ClearSavedValue() { value_columns_state_.ClearSavedValue(); }
|
||||
inline void ClearSavedValue() {
|
||||
value_columns_state_.mut()->ClearSavedValue();
|
||||
}
|
||||
|
||||
inline void ResetInternalKeysSkippedCounter() {
|
||||
local_stats_.skip_count_ += num_internal_keys_skipped_;
|
||||
@@ -508,7 +525,7 @@ class DBIter final : public Iterator {
|
||||
}
|
||||
|
||||
void SetValueAndColumnsFromPlain(const Slice& slice) {
|
||||
value_columns_state_.SetFromPlain(slice);
|
||||
value_columns_state_.mut()->SetFromPlain(slice);
|
||||
}
|
||||
|
||||
bool SetValueAndColumnsFromBlobImpl(const Slice& user_key,
|
||||
@@ -524,11 +541,7 @@ class DBIter final : public Iterator {
|
||||
|
||||
void ResetValueAndColumns() { value_columns_state_.Reset(); }
|
||||
|
||||
void ResetBlobData() {
|
||||
blob_reader_.ResetBlobValue();
|
||||
lazy_blob_index_.clear();
|
||||
is_blob_ = false;
|
||||
}
|
||||
void ResetBlobData() { blob_state_.Reset(); }
|
||||
|
||||
// The following methods perform the actual merge operation for the
|
||||
// no/plain/blob/wide-column base value cases.
|
||||
@@ -539,6 +552,12 @@ class DBIter final : public Iterator {
|
||||
bool MergeWithWideColumnBaseValue(const Slice& entity, const Slice& user_key);
|
||||
|
||||
bool PrepareValueInternal() {
|
||||
// Capture this before PrepareValue(): PrepareValue() updates the wrapper
|
||||
// state to "prepared" on success. We still call PrepareValue()
|
||||
// unconditionally to preserve its contract/error handling, but only need
|
||||
// to re-parse ikey_ when this call may have actually materialized the
|
||||
// underlying iterator value/key.
|
||||
const bool value_was_prepared = iter_.IsValuePrepared();
|
||||
if (!iter_.PrepareValue()) {
|
||||
assert(!iter_.status().ok());
|
||||
valid_ = false;
|
||||
@@ -548,7 +567,7 @@ class DBIter final : public Iterator {
|
||||
// lookup and index_iter_ could point to different block resulting
|
||||
// in ikey_ pointing to wrong key. So ikey_ needs to be updated in
|
||||
// case of Seek/Next calls to point to right key again.
|
||||
if (!ParseKey(&ikey_)) {
|
||||
if (!value_was_prepared && !ParseKey(&ikey_)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -570,7 +589,7 @@ class DBIter final : public Iterator {
|
||||
|
||||
// If enough contiguous tombstones have been tracked, insert a range
|
||||
// tombstone [first_key, end_key) into the mutable memtable.
|
||||
// end_key is the exclusive upper bound — typically the next live key.
|
||||
// end_key is the exclusive upper bound -- typically the next live key.
|
||||
void MaybeInsertRangeTombstone(const Slice& end_key);
|
||||
void ResetContiguousTombstoneTracking() {
|
||||
contiguous_tombstone_count_ = 0;
|
||||
@@ -644,7 +663,7 @@ class DBIter final : public Iterator {
|
||||
UserComparatorWrapper user_comparator_;
|
||||
const MergeOperator* const merge_operator_;
|
||||
IteratorWrapper iter_;
|
||||
BlobReader blob_reader_;
|
||||
DirtyTracked<BlobState> blob_state_;
|
||||
ReadCallback* read_callback_;
|
||||
// Max visible sequence number. It is normally the snapshot seq unless we have
|
||||
// uncommitted data in db as in WriteUnCommitted.
|
||||
@@ -661,7 +680,7 @@ class DBIter final : public Iterator {
|
||||
// kTypeValuePreferredSeqno entry, this is the write time specified by the
|
||||
// user.
|
||||
uint64_t saved_write_unix_time_;
|
||||
ValueColumnsState value_columns_state_;
|
||||
DirtyTracked<ValueColumnsState> value_columns_state_;
|
||||
Slice pinned_value_;
|
||||
Statistics* statistics_;
|
||||
uint64_t max_skip_;
|
||||
@@ -682,7 +701,6 @@ class DBIter final : public Iterator {
|
||||
std::optional<IterKey> prefix_;
|
||||
|
||||
Status status_;
|
||||
Slice lazy_blob_index_;
|
||||
|
||||
// List of operands for merge operator.
|
||||
MergeContext merge_context_;
|
||||
@@ -721,7 +739,6 @@ class DBIter final : public Iterator {
|
||||
// the stacked BlobDB implementation is used, false otherwise.
|
||||
bool expose_blob_index_;
|
||||
bool allow_unprepared_value_;
|
||||
bool is_blob_;
|
||||
bool arena_mode_;
|
||||
|
||||
IterKey range_tomb_first_key_;
|
||||
|
||||
+245
-106
@@ -4151,7 +4151,7 @@ TEST_P(DBIteratorTest, PrefixSameAsStartSeekToNonInDomainKey) {
|
||||
ASSERT_OK(Put("abc2", "v2"));
|
||||
ASSERT_OK(Put("abc3", "v3"));
|
||||
|
||||
// Seek to "ab" (2 bytes) — out-of-domain for FixedPrefixTransform(3).
|
||||
// Seek to "ab" (2 bytes) -- out-of-domain for FixedPrefixTransform(3).
|
||||
// ShouldSetPrefix returns false for out-of-domain targets, so no prefix
|
||||
// constraint is set. The seek should find "abc1" and iteration should
|
||||
// proceed without prefix_same_as_start enforcement.
|
||||
@@ -5583,13 +5583,13 @@ class ReadPathRangeTombstoneTest : public DBIteratorBaseTest,
|
||||
bool Forward() const { return GetParam(); }
|
||||
|
||||
void SetUp() override {
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
SyncPoint::GetInstance()->SetCallBack(
|
||||
"MemTable::AddLogicallyRedundantRangeTombstone:AddRange",
|
||||
[this](void* arg) {
|
||||
auto* range = static_cast<std::pair<Slice, Slice>*>(arg);
|
||||
inserted_ranges_.emplace_back(range->first.ToString(),
|
||||
range->second.ToString());
|
||||
attempted_insert_ranges_.emplace_back(range->first.ToString(),
|
||||
range->second.ToString());
|
||||
});
|
||||
SyncPoint::GetInstance()->EnableProcessing();
|
||||
}
|
||||
@@ -5633,9 +5633,9 @@ class ReadPathRangeTombstoneTest : public DBIteratorBaseTest,
|
||||
|
||||
void AssertRange(size_t idx, const std::string& start,
|
||||
const std::string& end) {
|
||||
ASSERT_LT(idx, inserted_ranges_.size());
|
||||
ASSERT_EQ(inserted_ranges_[idx].first, start);
|
||||
ASSERT_EQ(inserted_ranges_[idx].second, end);
|
||||
ASSERT_LT(idx, attempted_insert_ranges_.size());
|
||||
ASSERT_EQ(attempted_insert_ranges_[idx].first, start);
|
||||
ASSERT_EQ(attempted_insert_ranges_[idx].second, end);
|
||||
}
|
||||
|
||||
Slice MaxTimestamp(std::string* storage) const {
|
||||
@@ -5675,7 +5675,7 @@ class ReadPathRangeTombstoneTest : public DBIteratorBaseTest,
|
||||
<< iter->status().ToString();
|
||||
}
|
||||
|
||||
std::vector<std::pair<std::string, std::string>> inserted_ranges_;
|
||||
std::vector<std::pair<std::string, std::string>> attempted_insert_ranges_;
|
||||
};
|
||||
|
||||
INSTANTIATE_TEST_CASE_P(ReadPathRangeTombstoneTest, ReadPathRangeTombstoneTest,
|
||||
@@ -5698,19 +5698,28 @@ TEST_P(ReadPathRangeTombstoneTest, BasicInsertion) {
|
||||
|
||||
if (flush_before_read) {
|
||||
ASSERT_OK(Flush());
|
||||
// Memtable is empty after flush. AddLogicallyRedundantRangeTombstone
|
||||
// skips empty memtables.
|
||||
inserted_ranges_.clear();
|
||||
// After dropping the IsEmpty() fast-path in conversion, the now-empty
|
||||
// active memtable is a valid conversion target; the per-CF
|
||||
// ingest_sst_lock (held shared by ingestion) is the mechanism that
|
||||
// gates conversion vs ingestion, not memtable emptiness.
|
||||
attempted_insert_ranges_.clear();
|
||||
VerifyIteration({"a", "g", "h", "n"});
|
||||
ASSERT_EQ(inserted_ranges_.size(), 0);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 2);
|
||||
if (forward) {
|
||||
AssertRange(0, "b", "g");
|
||||
AssertRange(1, "i", "n");
|
||||
} else {
|
||||
AssertRange(0, "i", "n");
|
||||
AssertRange(1, "b", "g");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
|
||||
VerifyIteration({"a", "g", "h", "n"});
|
||||
|
||||
ASSERT_EQ(inserted_ranges_.size(), 2);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 2);
|
||||
if (forward) {
|
||||
AssertRange(0, "b", "g");
|
||||
AssertRange(1, "i", "n");
|
||||
@@ -5722,9 +5731,9 @@ TEST_P(ReadPathRangeTombstoneTest, BasicInsertion) {
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
2);
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
VerifyIteration({"a", "g", "h", "n"});
|
||||
ASSERT_EQ(inserted_ranges_.size(), 0);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 0);
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
2);
|
||||
@@ -5761,13 +5770,13 @@ TEST_P(ReadPathRangeTombstoneTest, MemtableSwitch) {
|
||||
/*flushed_point_dels=*/{},
|
||||
/*memtable_point_dels=*/{"b", "c", "d", "e", "f"});
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
SyncPoint::GetInstance()->SetCallBack(
|
||||
"MemTable::AddLogicallyRedundantRangeTombstone:AddRange",
|
||||
[this](void* arg) {
|
||||
auto* range = static_cast<std::pair<Slice, Slice>*>(arg);
|
||||
inserted_ranges_.emplace_back(range->first.ToString(),
|
||||
range->second.ToString());
|
||||
attempted_insert_ranges_.emplace_back(range->first.ToString(),
|
||||
range->second.ToString());
|
||||
auto* cfh =
|
||||
static_cast<ColumnFamilyHandleImpl*>(db_->DefaultColumnFamily());
|
||||
cfh->cfd()->mem()->MarkImmutable();
|
||||
@@ -5776,7 +5785,7 @@ TEST_P(ReadPathRangeTombstoneTest, MemtableSwitch) {
|
||||
|
||||
VerifyIteration({"a", "g", "h"});
|
||||
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
AssertRange(0, "b", "g");
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_DISCARDED),
|
||||
@@ -5809,13 +5818,15 @@ TEST_P(ReadPathRangeTombstoneTest, ExhaustedIteratorWithBounds) {
|
||||
VerifyIteration({"e", "f"}, ro);
|
||||
|
||||
// Both directions encounter two tombstone runs (a-d and g-j).
|
||||
ASSERT_EQ(inserted_ranges_.size(), 2);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 2);
|
||||
if (Forward()) {
|
||||
// Forward: sees a-d tombstones first → [a, e), then g-j → [g, z).
|
||||
// Forward: sees a-d tombstones first -> live e terminates -> [a, e).
|
||||
// Then g-j -> exhaustion past j, saved_key_="j" fallback -> [g, j),
|
||||
// covering g,h,i. j remains a point tombstone.
|
||||
AssertRange(0, "a", "e");
|
||||
AssertRange(1, "g", "z");
|
||||
AssertRange(1, "g", "j");
|
||||
} else {
|
||||
// Reverse: sees j-g tombstones first, then f,e live, then d-a → exhausts.
|
||||
// Reverse: sees j-g tombstones first, then f,e live, then d-a -> exhausts.
|
||||
AssertRange(0, "g", "z");
|
||||
AssertRange(1, "a", "e");
|
||||
}
|
||||
@@ -5830,9 +5841,9 @@ TEST_P(ReadPathRangeTombstoneTest, ExhaustedIteratorWithBounds) {
|
||||
ASSERT_EQ(Get("j"), "NOT_FOUND");
|
||||
|
||||
// Second read: range tombstones already in memtable, no new insertion.
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
VerifyIteration({"e", "f"}, ro);
|
||||
ASSERT_EQ(inserted_ranges_.size(), 0);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 0);
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
2);
|
||||
@@ -5851,13 +5862,24 @@ TEST_P(ReadPathRangeTombstoneTest, ExhaustedIteratorNoBounds) {
|
||||
|
||||
VerifyIteration({"e", "f"});
|
||||
|
||||
// Without bounds, only the a-d run (which has a live key boundary) gets
|
||||
// inserted. The g-j run at the end has no upper bound → dropped.
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
AssertRange(0, "a", "e");
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
1);
|
||||
if (Forward()) {
|
||||
// Forward: a-d run terminates at live e -> [a, e). g-j run exhausts
|
||||
// past j -> saved_key_ fallback -> [g, j), covering g,h,i with j as a
|
||||
// point tombstone.
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 2);
|
||||
AssertRange(0, "a", "e");
|
||||
AssertRange(1, "g", "j");
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
2);
|
||||
} else {
|
||||
// Reverse fallback path remains prefix-gated.
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
AssertRange(0, "a", "e");
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
1);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_P(ReadPathRangeTombstoneTest, DirectionChange) {
|
||||
@@ -5871,7 +5893,7 @@ TEST_P(ReadPathRangeTombstoneTest, DirectionChange) {
|
||||
/*flushed_point_dels=*/{"c", "d", "e"},
|
||||
/*memtable_point_dels=*/{"f", "g", "j", "k", "l", "m", "n"});
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
|
||||
{
|
||||
ReadOptions ro;
|
||||
@@ -5918,7 +5940,7 @@ TEST_P(ReadPathRangeTombstoneTest, DirectionChange) {
|
||||
ASSERT_OK(iter->status());
|
||||
}
|
||||
|
||||
ASSERT_EQ(inserted_ranges_.size(), 2);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 2);
|
||||
if (forward_first) {
|
||||
AssertRange(0, "c", "h");
|
||||
AssertRange(1, "j", "o");
|
||||
@@ -5948,11 +5970,11 @@ TEST_P(ReadPathRangeTombstoneTest, MixedDeleteAndSingleDelete) {
|
||||
ASSERT_OK(SingleDelete("c"));
|
||||
ASSERT_OK(SingleDelete("e"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
|
||||
VerifyIteration({"a", "g", "h"});
|
||||
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
AssertRange(0, "b", "g");
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
@@ -5982,11 +6004,11 @@ TEST_P(ReadPathRangeTombstoneTest, SingleDeleteOnlyRun) {
|
||||
ASSERT_OK(SingleDelete("e"));
|
||||
ASSERT_OK(SingleDelete("f"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
|
||||
VerifyIteration({"a", "g", "h"});
|
||||
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
AssertRange(0, "b", "g");
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
@@ -5997,7 +6019,7 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterDefaultReadOptions) {
|
||||
// total_order_seek=false (default) with prefix extractor. Even when the
|
||||
// visible scan contains a valid in-prefix tombstone run [ba, bc), read-path
|
||||
// range conversion is disabled in this legacy prefix mode, so no
|
||||
// synthesized memtable tombstone is inserted.
|
||||
// converted memtable tombstone is inserted.
|
||||
Options options = CurrentOptions();
|
||||
options.min_tombstones_for_range_conversion = 2;
|
||||
options.statistics = CreateDBStatistics();
|
||||
@@ -6017,7 +6039,7 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterDefaultReadOptions) {
|
||||
ASSERT_OK(Delete("ba"));
|
||||
ASSERT_OK(Delete("bb"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
auto it = std::unique_ptr<Iterator>(db_->NewIterator(ReadOptions()));
|
||||
if (Forward()) {
|
||||
it->Seek("b");
|
||||
@@ -6032,14 +6054,14 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterDefaultReadOptions) {
|
||||
}
|
||||
}
|
||||
ASSERT_OK(it->status());
|
||||
ASSERT_EQ(inserted_ranges_.size(), 0u);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 0u);
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
0u);
|
||||
}
|
||||
|
||||
TEST_P(ReadPathRangeTombstoneTest, PrefixFilterTotalOrderSeek) {
|
||||
// total_order_seek=true disables prefix filtering — all files visible.
|
||||
// total_order_seek=true disables prefix filtering -- all files visible.
|
||||
// The delete run spans from prefix 'b' into prefix 'c', terminated by
|
||||
// live key "cb" from L0. The tombstone [ba, cb) crosses prefix boundaries,
|
||||
// which is safe because total_order_seek makes all files visible.
|
||||
@@ -6081,14 +6103,14 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterTotalOrderSeek) {
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
// Memtable: contiguous deletes spanning prefixes 'b' and 'c'.
|
||||
// No live key between "bb" and "cb" — the run crosses prefix boundaries.
|
||||
// No live key between "bb" and "cb" -- the run crosses prefix boundaries.
|
||||
ASSERT_OK(put("aa", "below"));
|
||||
ASSERT_OK(del("ba"));
|
||||
ASSERT_OK(del("bb"));
|
||||
ASSERT_OK(del("ca"));
|
||||
ASSERT_OK(put("fa", "above"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
ReadOptions ro;
|
||||
ro.total_order_seek = true;
|
||||
if (use_udt) {
|
||||
@@ -6109,7 +6131,7 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterTotalOrderSeek) {
|
||||
}
|
||||
ASSERT_OK(it->status());
|
||||
// Tombstone crosses from prefix 'b' into 'c', terminated by live "cb".
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1u);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1u);
|
||||
if (use_udt) {
|
||||
AssertRange(0, std::string("ba") + ts, std::string("cb") + ts);
|
||||
} else {
|
||||
@@ -6121,7 +6143,7 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterTotalOrderSeek) {
|
||||
TEST_P(ReadPathRangeTombstoneTest, PrefixFilterPrefixSameAsStart) {
|
||||
// prefix_same_as_start=true: prefix filtering active, DBIter bounds the
|
||||
// scan to the seek prefix. An out-of-prefix tombstone ends the visible run,
|
||||
// but the synthesized range still stays within prefix by flushing to the
|
||||
// but the converted range still stays within prefix by flushing to the
|
||||
// last tracked in-prefix tombstone.
|
||||
// total_order_seek should not matter as we are guaranteed a total order view
|
||||
// within the prefix bounds.
|
||||
@@ -6169,7 +6191,7 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterPrefixSameAsStart) {
|
||||
ASSERT_OK(del("ca"));
|
||||
ASSERT_OK(put("fa", "above"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
ReadOptions ro;
|
||||
ro.prefix_same_as_start = true;
|
||||
ro.total_order_seek = total_order;
|
||||
@@ -6187,7 +6209,7 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterPrefixSameAsStart) {
|
||||
ASSERT_FALSE(it->Valid());
|
||||
}
|
||||
ASSERT_OK(it->status());
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1u);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1u);
|
||||
ASSERT_EQ(options.statistics->getTickerCount(
|
||||
READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
1u);
|
||||
@@ -6209,6 +6231,55 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterPrefixSameAsStart) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_P(ReadPathRangeTombstoneTest,
|
||||
PrefixFilterUpperBoundDoesNotCoverSkippedLiveKey) {
|
||||
if (!Forward()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Options options = CurrentOptions();
|
||||
options.min_tombstones_for_range_conversion = 3;
|
||||
options.statistics = CreateDBStatistics();
|
||||
options.disable_auto_compactions = true;
|
||||
options.prefix_extractor.reset(NewFixedPrefixTransform(1));
|
||||
BlockBasedTableOptions table_options;
|
||||
table_options.filter_policy.reset(NewBloomFilterPolicy(10));
|
||||
table_options.whole_key_filtering = false;
|
||||
options.table_factory.reset(NewBlockBasedTableFactory(table_options));
|
||||
DestroyAndReopen(options);
|
||||
|
||||
// Keep a live in-prefix key above the user upper bound in an SST so the
|
||||
// bounded prefix scan cannot return it, then leave a later same-prefix
|
||||
// memtable key available as the internal stop key. If cleanup uses that
|
||||
// late memtable key as the synthetic range end, it will cover "be".
|
||||
ASSERT_OK(Put("be", "live_b"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
ASSERT_OK(Delete("ba"));
|
||||
ASSERT_OK(Delete("bb"));
|
||||
ASSERT_OK(Delete("bc"));
|
||||
ASSERT_OK(Put("bz", "live_z"));
|
||||
|
||||
attempted_insert_ranges_.clear();
|
||||
ReadOptions ro;
|
||||
ro.prefix_same_as_start = true;
|
||||
std::string upper_str = "bd";
|
||||
Slice upper(upper_str);
|
||||
ro.iterate_upper_bound = &upper;
|
||||
auto iter = std::unique_ptr<Iterator>(db_->NewIterator(ro));
|
||||
|
||||
std::vector<std::string> keys;
|
||||
for (iter->Seek("ba"); iter->Valid(); iter->Next()) {
|
||||
keys.push_back(iter->key().ToString());
|
||||
}
|
||||
ASSERT_OK(iter->status());
|
||||
ASSERT_TRUE(keys.empty());
|
||||
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1u);
|
||||
|
||||
ASSERT_EQ(Get("be"), "live_b");
|
||||
}
|
||||
|
||||
TEST_P(ReadPathRangeTombstoneTest, PrefixFilterOutOfDomainSeek) {
|
||||
// With an out-of-domain seek target, prefix_same_as_start cannot establish
|
||||
// a seek-prefix bound. The iterator therefore behaves like an unrestricted
|
||||
@@ -6235,12 +6306,12 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterOutOfDomainSeek) {
|
||||
ASSERT_OK(Put("bbdd", "v2"));
|
||||
ASSERT_OK(Put("cccc", "v3"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
ReadOptions ro;
|
||||
ro.prefix_same_as_start = true;
|
||||
auto it = std::unique_ptr<Iterator>(db_->NewIterator(ro));
|
||||
if (Forward()) {
|
||||
// Seek with a 1-byte key — out-of-domain for FixedPrefixTransform(4).
|
||||
// Seek with a 1-byte key -- out-of-domain for FixedPrefixTransform(4).
|
||||
// prefix_same_as_start cannot set a prefix bound for this target.
|
||||
it->Seek("b");
|
||||
while (it->Valid()) {
|
||||
@@ -6254,7 +6325,7 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterOutOfDomainSeek) {
|
||||
}
|
||||
}
|
||||
ASSERT_OK(it->status());
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1u);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1u);
|
||||
AssertRange(0, "bbbb", "bbdd");
|
||||
}
|
||||
|
||||
@@ -6279,10 +6350,10 @@ TEST_P(ReadPathRangeTombstoneTest, TableFilterNotAllowed) {
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
// Keep the active memtable non-empty so the read path has somewhere to store
|
||||
// the synthesized memtable range tombstone.
|
||||
// the converted memtable range tombstone.
|
||||
ASSERT_OK(Put("zz", "tail_mem"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
{
|
||||
// First iterator sees the full SST set, so converting [a, c) into a
|
||||
// memtable range tombstone is safe.
|
||||
@@ -6295,7 +6366,7 @@ TEST_P(ReadPathRangeTombstoneTest, TableFilterNotAllowed) {
|
||||
ASSERT_OK(it->status());
|
||||
}
|
||||
|
||||
ASSERT_GE(inserted_ranges_.size(), 1u);
|
||||
ASSERT_GE(attempted_insert_ranges_.size(), 1u);
|
||||
AssertRange(0, "a", "c");
|
||||
|
||||
{
|
||||
@@ -6304,7 +6375,7 @@ TEST_P(ReadPathRangeTombstoneTest, TableFilterNotAllowed) {
|
||||
return props.num_entries != 2;
|
||||
};
|
||||
// Hiding the two-delete SST would otherwise leave this iterator with a
|
||||
// partial SST view plus the previously synthesized memtable tombstone,
|
||||
// partial SST view plus the previously converted memtable tombstone,
|
||||
// allowing hidden SST state to affect the filtered read result.
|
||||
AssertTableFilterRangeConversionRejected(filtered_ro);
|
||||
}
|
||||
@@ -6329,7 +6400,7 @@ TEST_P(ReadPathRangeTombstoneTest, SnapshotPredatesMemtable) {
|
||||
ASSERT_OK(Flush());
|
||||
ASSERT_OK(Put("y", "vy"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
|
||||
ReadOptions ro;
|
||||
ro.snapshot = snap;
|
||||
@@ -6402,7 +6473,7 @@ TEST_P(ReadPathRangeTombstoneTest, NoInsertionOnBlockCacheTierIncomplete) {
|
||||
|
||||
// No range tombstone should have been inserted despite meeting threshold,
|
||||
// because the iterator terminated with Incomplete (cache miss).
|
||||
ASSERT_EQ(inserted_ranges_.size(), 0);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 0);
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
0);
|
||||
@@ -6433,7 +6504,7 @@ TEST_P(ReadPathRangeTombstoneTest, SkipInsertionWhenCoveredByExistingRange) {
|
||||
ASSERT_OK(Delete(std::string(1, c)));
|
||||
}
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
ReadOptions ro;
|
||||
Slice upper("z");
|
||||
ro.iterate_upper_bound = &upper;
|
||||
@@ -6493,7 +6564,7 @@ TEST_P(ReadPathRangeTombstoneTest, UDTBasicScan) {
|
||||
ASSERT_EQ(keys, (std::vector<std::string>{"a", "g", "h"}));
|
||||
|
||||
// Range covers [b+ts, g+ts).
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
AssertRange(0, std::string("b") + ts, std::string("g") + ts);
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
@@ -6502,7 +6573,7 @@ TEST_P(ReadPathRangeTombstoneTest, UDTBasicScan) {
|
||||
|
||||
// Regression test: an older UDT read timestamp can hide newer live versions
|
||||
// inside a delete run. Range conversion must stay disabled in that case, or
|
||||
// the synthesized range tombstone will incorrectly hide those newer versions
|
||||
// the converted range tombstone will incorrectly hide those newer versions
|
||||
// for later max-timestamp reads.
|
||||
TEST_P(ReadPathRangeTombstoneTest, UDTOlderTimestampDisablesInsertion) {
|
||||
Options options = CurrentOptions();
|
||||
@@ -6535,12 +6606,12 @@ TEST_P(ReadPathRangeTombstoneTest, UDTOlderTimestampDisablesInsertion) {
|
||||
ASSERT_OK(db_->Put(WriteOptions(), "b", ts3_slice, "vb3"));
|
||||
ASSERT_OK(db_->Put(WriteOptions(), "c", ts3_slice, "vc3"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
ReadOptions old_ro;
|
||||
old_ro.timestamp = &ts2_slice;
|
||||
VerifyIteration({"d"}, old_ro);
|
||||
|
||||
ASSERT_EQ(inserted_ranges_.size(), 0u);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 0u);
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
0);
|
||||
@@ -6601,10 +6672,10 @@ TEST_P(ReadPathRangeTombstoneTest, ExhaustedWithUDT) {
|
||||
ASSERT_OK(iter->status());
|
||||
ASSERT_EQ(keys.size(), 4);
|
||||
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
if (Forward()) {
|
||||
// Forward exhaustion: range is [e+ts, z+min_ts).
|
||||
AssertRange(0, std::string("e") + ts, std::string("z") + min_ts);
|
||||
// Forward exhaustion past h: saved_key_="h"+ts fallback -> [e+ts, h+ts).
|
||||
AssertRange(0, std::string("e") + ts, std::string("h") + ts);
|
||||
} else {
|
||||
// Reverse exhaustion: range is [a+ts, e+ts).
|
||||
AssertRange(0, std::string("a") + ts, std::string("e") + ts);
|
||||
@@ -6615,9 +6686,10 @@ TEST_P(ReadPathRangeTombstoneTest, ExhaustedWithUDT) {
|
||||
}
|
||||
|
||||
TEST_P(ReadPathRangeTombstoneTest, SeekForPrevTombstone) {
|
||||
// SeekForPrev lands directly on tombstones. No upper bound, so forward
|
||||
// exhaustion drops the trailing tombstone run (no end key available).
|
||||
// Reverse: SeekForPrev("h") → Delete(h,g,f,e) → live "d" → range [e, h).
|
||||
// SeekForPrev lands directly on tombstones. No upper bound. Forward
|
||||
// exhaustion past h falls back to saved_key_="h" -> [e, h), covering
|
||||
// e,f,g with h as a point tombstone.
|
||||
// Reverse: SeekForPrev("h") -> Delete(h,g,f,e) -> live "d" -> range [e, h).
|
||||
for (bool use_udt : {false, true}) {
|
||||
SCOPED_TRACE(use_udt ? "with UDT" : "without UDT");
|
||||
Options options = CurrentOptions();
|
||||
@@ -6641,7 +6713,7 @@ TEST_P(ReadPathRangeTombstoneTest, SeekForPrevTombstone) {
|
||||
}
|
||||
SetupTestData('a', 'h', /*flushed_point_dels=*/{},
|
||||
/*memtable_point_dels=*/{"e", "f", "g", "h"}, ts_ptr);
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
|
||||
ReadOptions ro;
|
||||
if (use_udt) {
|
||||
@@ -6655,7 +6727,7 @@ TEST_P(ReadPathRangeTombstoneTest, SeekForPrevTombstone) {
|
||||
keys.push_back(iter->key().ToString());
|
||||
}
|
||||
} else {
|
||||
// SeekForPrev("h") lands on Delete(h), traverses g,f,e → finds "d".
|
||||
// SeekForPrev("h") lands on Delete(h), traverses g,f,e -> finds "d".
|
||||
for (iter->SeekForPrev("h"); iter->Valid(); iter->Prev()) {
|
||||
keys.push_back(iter->key().ToString());
|
||||
}
|
||||
@@ -6664,11 +6736,16 @@ TEST_P(ReadPathRangeTombstoneTest, SeekForPrevTombstone) {
|
||||
ASSERT_OK(iter->status());
|
||||
ASSERT_EQ(keys, (std::vector<std::string>{"a", "b", "c", "d"}));
|
||||
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
if (Forward()) {
|
||||
// No upper bound → trailing tombstone run has no end key → dropped.
|
||||
ASSERT_EQ(inserted_ranges_.size(), 0);
|
||||
// Forward exhausts past h -> saved_key_="h" fallback -> [e, h),
|
||||
// covering e,f,g with h as a point tombstone.
|
||||
if (use_udt) {
|
||||
AssertRange(0, std::string("e") + ts, std::string("h") + ts);
|
||||
} else {
|
||||
AssertRange(0, "e", "h");
|
||||
}
|
||||
} else {
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
if (use_udt) {
|
||||
std::string min_ts(sizeof(uint64_t), '\0');
|
||||
AssertRange(0, std::string("e") + ts, std::string("h") + min_ts);
|
||||
@@ -6678,15 +6755,18 @@ TEST_P(ReadPathRangeTombstoneTest, SeekForPrevTombstone) {
|
||||
}
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
Forward() ? 0 : 1);
|
||||
1);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_P(ReadPathRangeTombstoneTest, UpperBoundTombstone) {
|
||||
// iterate_upper_bound lands directly on tombstones. Both directions see
|
||||
// tombstones e-h and use upper bound "i" as end key for the range.
|
||||
// Forward: exhaustion at bound → [e, i). Reverse: SeekToLast delegates to
|
||||
// SeekForPrev("i") → [e, i).
|
||||
// iterate_upper_bound lands past the data. Both directions see tombstones
|
||||
// e-h. Forward exhausts naturally past h (no key in DB >= upper) so the
|
||||
// forward path falls back to saved_key_ ("h") as the exclusive end_key,
|
||||
// covering n-1 of n deletes -- [e, h). The remaining tombstone for h
|
||||
// stays as a point delete. Reverse captures the live key "d" before the
|
||||
// run and uses it via range_tomb_end_key_ -- [e, i) when SeekToLast
|
||||
// delegates to SeekForPrev("i").
|
||||
for (bool use_udt : {false, true}) {
|
||||
SCOPED_TRACE(use_udt ? "with UDT" : "without UDT");
|
||||
Options options = CurrentOptions();
|
||||
@@ -6710,7 +6790,7 @@ TEST_P(ReadPathRangeTombstoneTest, UpperBoundTombstone) {
|
||||
}
|
||||
SetupTestData('a', 'h', /*flushed_point_dels=*/{},
|
||||
/*memtable_point_dels=*/{"e", "f", "g", "h"}, ts_ptr);
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
|
||||
ReadOptions ro;
|
||||
if (use_udt) {
|
||||
@@ -6735,15 +6815,19 @@ TEST_P(ReadPathRangeTombstoneTest, UpperBoundTombstone) {
|
||||
ASSERT_OK(iter->status());
|
||||
ASSERT_EQ(keys, (std::vector<std::string>{"a", "b", "c", "d"}));
|
||||
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
if (use_udt) {
|
||||
// Forward end key: upper bound padded with min_ts.
|
||||
// Reverse end key: upper bound padded with max_ts (via
|
||||
// SetSavedKeyToSeekForPrevTarget).
|
||||
std::string end_ts(sizeof(uint64_t), Forward() ? '\0' : '\xff');
|
||||
AssertRange(0, std::string("e") + ts, std::string("i") + end_ts);
|
||||
if (Forward()) {
|
||||
// Forward end key: saved_key_ ("h") with the tombstone's own ts.
|
||||
AssertRange(0, std::string("e") + ts, std::string("h") + ts);
|
||||
} else {
|
||||
// Reverse end key: upper bound padded with max_ts (via
|
||||
// SetSavedKeyToSeekForPrevTarget).
|
||||
std::string end_ts(sizeof(uint64_t), '\xff');
|
||||
AssertRange(0, std::string("e") + ts, std::string("i") + end_ts);
|
||||
}
|
||||
} else {
|
||||
AssertRange(0, "e", "i");
|
||||
AssertRange(0, "e", Forward() ? "h" : "i");
|
||||
}
|
||||
ASSERT_EQ(
|
||||
options.statistics->getTickerCount(READ_PATH_RANGE_TOMBSTONES_INSERTED),
|
||||
@@ -6753,8 +6837,8 @@ TEST_P(ReadPathRangeTombstoneTest, UpperBoundTombstone) {
|
||||
|
||||
TEST_P(ReadPathRangeTombstoneTest, LowerBoundTruncatesReverse) {
|
||||
// Keys a-j, delete a-h. Lower bound "e" truncates reverse iteration
|
||||
// mid-tombstone-run. Forward: tombstones e-h ended by live key i → [e, i).
|
||||
// Reverse: tombstones h,g,f,e then lower_bound hit → flush [e, i).
|
||||
// mid-tombstone-run. Forward: tombstones e-h ended by live key i -> [e, i).
|
||||
// Reverse: tombstones h,g,f,e then lower_bound hit -> flush [e, i).
|
||||
for (bool use_udt : {false, true}) {
|
||||
SCOPED_TRACE(use_udt ? "with UDT" : "without UDT");
|
||||
Options options = CurrentOptions();
|
||||
@@ -6780,7 +6864,7 @@ TEST_P(ReadPathRangeTombstoneTest, LowerBoundTruncatesReverse) {
|
||||
'a', 'j', /*flushed_point_dels=*/{},
|
||||
/*memtable_point_dels=*/{"a", "b", "c", "d", "e", "f", "g", "h"},
|
||||
ts_ptr);
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
|
||||
ReadOptions ro;
|
||||
if (use_udt) {
|
||||
@@ -6809,7 +6893,7 @@ TEST_P(ReadPathRangeTombstoneTest, LowerBoundTruncatesReverse) {
|
||||
ASSERT_EQ(keys, (std::vector<std::string>{"i", "j"}));
|
||||
|
||||
// Both directions produce one range covering tombstones e-h.
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
if (use_udt) {
|
||||
AssertRange(0, std::string("e") + ts, std::string("i") + ts);
|
||||
} else {
|
||||
@@ -6860,11 +6944,11 @@ TEST_P(ReadPathRangeTombstoneTest,
|
||||
ASSERT_OK(Delete("c"));
|
||||
|
||||
// Iterate at the latest sequence. Both b and c hit the reseek path and
|
||||
// synthesize a range tombstone [b, d) for later readers at the same seq.
|
||||
inserted_ranges_.clear();
|
||||
// convert a range tombstone [b, d) for later readers at the same seq.
|
||||
attempted_insert_ranges_.clear();
|
||||
VerifyIteration({"a", "d"});
|
||||
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
AssertRange(0, "b", "d");
|
||||
|
||||
// Read with the earlier snapshot. The point deletes (seq 11-12) are not
|
||||
@@ -6891,14 +6975,14 @@ TEST_P(ReadPathRangeTombstoneTest, InvisibleKeysDontBreakTombstoneRun) {
|
||||
ASSERT_OK(Put("f", "vf"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
// Delete b, d — visible tombstones.
|
||||
// Delete b, d -- visible tombstones.
|
||||
ASSERT_OK(Delete("b"));
|
||||
ASSERT_OK(Delete("d"));
|
||||
|
||||
// Snapshot S. At S: a(live), b(del), d(del), f(live).
|
||||
const Snapshot* snap = db_->GetSnapshot();
|
||||
|
||||
// Write c, e AFTER the snapshot — invisible at S.
|
||||
// Write c, e AFTER the snapshot -- invisible at S.
|
||||
// At S the iterator sees: a(live), b(del), c(invis), d(del),
|
||||
// e(invis), f(live).
|
||||
ASSERT_OK(Put("c", "vc"));
|
||||
@@ -6906,17 +6990,71 @@ TEST_P(ReadPathRangeTombstoneTest, InvisibleKeysDontBreakTombstoneRun) {
|
||||
|
||||
ReadOptions ro;
|
||||
ro.snapshot = snap;
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
VerifyIteration({"a", "f"}, ro);
|
||||
|
||||
// Invisible keys c and e should not break the tombstone run.
|
||||
// 2 tombstones (b, d) ≥ threshold → range [b, f).
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
// 2 tombstones (b, d) >= threshold -> range [b, f).
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
AssertRange(0, "b", "f");
|
||||
|
||||
db_->ReleaseSnapshot(snap);
|
||||
}
|
||||
|
||||
// Regression test: a converted tombstone can land in the current memtable at
|
||||
// an older snapshot sequence than a live point already ingested into an older
|
||||
// L0 file. Latest point lookups must continue searching that older file when
|
||||
// its sequence range can still contain a newer point version.
|
||||
TEST_P(ReadPathRangeTombstoneTest, NewerPointInOlderFileStillVisible) {
|
||||
Options options = CurrentOptions();
|
||||
options.min_tombstones_for_range_conversion = 2;
|
||||
options.disable_auto_compactions = true;
|
||||
options.statistics = CreateDBStatistics();
|
||||
DestroyAndReopen(options);
|
||||
|
||||
ASSERT_OK(Put("b", "vb"));
|
||||
ASSERT_OK(Put("c", "vc"));
|
||||
ASSERT_OK(Put("d", "vd"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
ASSERT_OK(Delete("b"));
|
||||
ASSERT_OK(Delete("c"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
// Keep the active memtable older than the snapshot so the read path is
|
||||
// allowed to convert a tombstone into it later.
|
||||
ASSERT_OK(Put("z", "vz_anchor"));
|
||||
const Snapshot* snap = db_->GetSnapshot();
|
||||
|
||||
const std::string ingest_file = dbname_ + "_live_c.sst";
|
||||
{
|
||||
SstFileWriter writer(EnvOptions(), options);
|
||||
ASSERT_OK(writer.Open(ingest_file));
|
||||
ASSERT_OK(writer.Put("c", "vc_live"));
|
||||
ASSERT_OK(writer.Finish());
|
||||
}
|
||||
|
||||
IngestExternalFileOptions ifo;
|
||||
ifo.allow_blocking_flush = false;
|
||||
ASSERT_OK(db_->IngestExternalFile({ingest_file}, ifo));
|
||||
ASSERT_EQ(Get("c"), "vc_live");
|
||||
|
||||
attempted_insert_ranges_.clear();
|
||||
ReadOptions snap_ro;
|
||||
snap_ro.snapshot = snap;
|
||||
VerifyIteration({"d", "z"}, snap_ro);
|
||||
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
AssertRange(0, "b", "d");
|
||||
|
||||
const Snapshot* latest = db_->GetSnapshot();
|
||||
ASSERT_EQ(Get("c", latest), "vc_live");
|
||||
ASSERT_EQ(MultiGet({"c"}, latest), (std::vector<std::string>{"vc_live"}));
|
||||
|
||||
db_->ReleaseSnapshot(latest);
|
||||
db_->ReleaseSnapshot(snap);
|
||||
}
|
||||
|
||||
// Regression test: SeekToLast() after Seek() must clear stale saved_key_ to
|
||||
// avoid corrupting range tombstone tracking bounds.
|
||||
TEST_P(ReadPathRangeTombstoneTest, SeekToLastStaleSavedKey) {
|
||||
@@ -6959,7 +7097,8 @@ TEST_P(ReadPathRangeTombstoneTest, SeekToLastStaleSavedKey) {
|
||||
|
||||
TEST_P(ReadPathRangeTombstoneTest, SeekToLastTombstones) {
|
||||
if (Forward()) {
|
||||
ROCKSDB_GTEST_SKIP("SeekToLast tombstone materialization is reverse-only.");
|
||||
ROCKSDB_GTEST_BYPASS(
|
||||
"SeekToLast tombstone materialization is reverse-only.");
|
||||
}
|
||||
|
||||
Options options = CurrentOptions();
|
||||
@@ -6975,7 +7114,7 @@ TEST_P(ReadPathRangeTombstoneTest, SeekToLastTombstones) {
|
||||
ASSERT_OK(Delete("x"));
|
||||
ASSERT_OK(Delete("y"));
|
||||
auto iter = std::unique_ptr<Iterator>(db_->NewIterator(ReadOptions()));
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
|
||||
iter->Seek("a");
|
||||
ASSERT_TRUE(iter->Valid());
|
||||
@@ -6985,20 +7124,20 @@ TEST_P(ReadPathRangeTombstoneTest, SeekToLastTombstones) {
|
||||
ASSERT_TRUE(iter->Valid());
|
||||
ASSERT_OK(iter->status());
|
||||
ASSERT_EQ("z", iter->key().ToString());
|
||||
ASSERT_EQ(inserted_ranges_.size(), 0u);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 0u);
|
||||
|
||||
// Reverse iteration skips deleted x and y.
|
||||
iter->Prev();
|
||||
ASSERT_TRUE(iter->Valid());
|
||||
ASSERT_OK(iter->status());
|
||||
ASSERT_EQ("w", iter->key().ToString());
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1u);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1u);
|
||||
AssertRange(0, "x", "z");
|
||||
}
|
||||
|
||||
// Regression test for a crash-test pattern where the interior between two
|
||||
// point tombstones is hidden by a later DeleteRange. A latest iterator may
|
||||
// still synthesize a redundant range tombstone, but an older snapshot must
|
||||
// still convert a redundant range tombstone, but an older snapshot must
|
||||
// continue to see the pre-DeleteRange live key after iteration.
|
||||
TEST_P(ReadPathRangeTombstoneTest,
|
||||
RangeDeletedInteriorPreservesOlderSnapshots) {
|
||||
@@ -7028,11 +7167,11 @@ TEST_P(ReadPathRangeTombstoneTest,
|
||||
ASSERT_OK(
|
||||
db_->DeleteRange(WriteOptions(), db_->DefaultColumnFamily(), "c", "m"));
|
||||
|
||||
inserted_ranges_.clear();
|
||||
attempted_insert_ranges_.clear();
|
||||
VerifyIteration({"a", "n"});
|
||||
|
||||
// Materialize the redundant tombstone at the latest read sequence only.
|
||||
ASSERT_EQ(inserted_ranges_.size(), 1);
|
||||
ASSERT_EQ(attempted_insert_ranges_.size(), 1);
|
||||
AssertRange(0, "b", "n");
|
||||
|
||||
snap_value.clear();
|
||||
|
||||
+195
-2
@@ -7,9 +7,12 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file. See the AUTHORS file for names of contributors.
|
||||
|
||||
#include "db/blob/blob_index.h"
|
||||
#include "db/db_impl/db_impl_secondary.h"
|
||||
#include "db/db_test_util.h"
|
||||
#include "db/db_with_timestamp_test_util.h"
|
||||
#include "db/wide/wide_column_test_util.h"
|
||||
#include "db/write_batch_internal.h"
|
||||
#include "port/stack_trace.h"
|
||||
#include "rocksdb/utilities/transaction_db.h"
|
||||
#include "test_util/sync_point.h"
|
||||
@@ -357,8 +360,7 @@ TEST_F(DBSecondaryTest, GetMergeOperands) {
|
||||
const Status s = db_secondary_->GetMergeOperands(
|
||||
ReadOptions(), cfh, "k1", values.data(), &merge_operands_info,
|
||||
&number_of_operands);
|
||||
ASSERT_NOK(s);
|
||||
ASSERT_TRUE(s.IsMergeInProgress());
|
||||
ASSERT_OK(s);
|
||||
|
||||
ASSERT_EQ(number_of_operands, 4);
|
||||
ASSERT_EQ(values[0].ToString(), "v1");
|
||||
@@ -367,6 +369,197 @@ TEST_F(DBSecondaryTest, GetMergeOperands) {
|
||||
ASSERT_EQ(values[3].ToString(), "v4");
|
||||
}
|
||||
|
||||
TEST_F(DBSecondaryTest, GetMergeOperandsWithBlobBackedEntityDefaultColumn) {
|
||||
// Goal: exercise the secondary read path with a blob-backed V2 entity base
|
||||
// in SST and a newer merge operand applied through catch-up. The secondary
|
||||
// DB must resolve the blob-backed default column for both Get() and
|
||||
// GetMergeOperands() while combining the newer memtable merge with the older
|
||||
// SST-backed base entity.
|
||||
Options options = GetDefaultOptions();
|
||||
options.create_if_missing = true;
|
||||
options.enable_blob_files = true;
|
||||
options.min_blob_size = 50;
|
||||
options.disable_auto_compactions = true;
|
||||
options.merge_operator = MergeOperators::CreateStringAppendOperator("|");
|
||||
options.env = env_;
|
||||
Reopen(options);
|
||||
|
||||
const std::string key = "secondary_blob_entity";
|
||||
const std::string default_value(100, 'd');
|
||||
const std::string large_value(120, 'l');
|
||||
const std::string merge_operand = "suffix";
|
||||
const std::string expected_merged = default_value + "|" + merge_operand;
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"meta", "inline"}};
|
||||
|
||||
ASSERT_OK(
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
ASSERT_OK(Flush());
|
||||
ASSERT_OK(db_->CompactRange(CompactRangeOptions(), nullptr, nullptr));
|
||||
|
||||
options.max_open_files = -1;
|
||||
OpenSecondary(options);
|
||||
ASSERT_OK(db_->Merge(WriteOptions(), db_->DefaultColumnFamily(), key,
|
||||
merge_operand));
|
||||
ASSERT_OK(db_secondary_->TryCatchUpWithPrimary());
|
||||
|
||||
auto* cfh = db_secondary_->DefaultColumnFamily();
|
||||
|
||||
{
|
||||
PinnableSlice result;
|
||||
ASSERT_OK(db_secondary_->Get(ReadOptions(), cfh, key, &result));
|
||||
ASSERT_EQ(result, expected_merged);
|
||||
}
|
||||
|
||||
{
|
||||
GetMergeOperandsOptions get_merge_opts;
|
||||
get_merge_opts.expected_max_number_of_operands = 2;
|
||||
|
||||
std::array<PinnableSlice, 2> merge_operands;
|
||||
int number_of_operands = 0;
|
||||
|
||||
ASSERT_OK(db_secondary_->GetMergeOperands(
|
||||
ReadOptions(), cfh, key, merge_operands.data(), &get_merge_opts,
|
||||
&number_of_operands));
|
||||
ASSERT_EQ(number_of_operands, 2);
|
||||
ASSERT_EQ(merge_operands[0], default_value);
|
||||
ASSERT_EQ(merge_operands[1], merge_operand);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBSecondaryTest, GetImplReturnsBlobIndexWhenRequested) {
|
||||
// Goal: cover the internal secondary GetImpl contract when the caller
|
||||
// explicitly asks for raw blob-index bytes via `is_blob_index`. Catch-up
|
||||
// rebuilds the blob index into the secondary memtable, and this path must
|
||||
// preserve the encoded index instead of eagerly resolving or rejecting it.
|
||||
Options options = GetDefaultOptions();
|
||||
options.create_if_missing = true;
|
||||
options.env = env_;
|
||||
|
||||
Reopen(options);
|
||||
|
||||
options.max_open_files = -1;
|
||||
OpenSecondary(options);
|
||||
|
||||
std::string blob_index;
|
||||
BlobIndex::EncodeInlinedTTL(&blob_index, /*expiration=*/9876543210, "blob");
|
||||
|
||||
WriteBatch batch;
|
||||
ASSERT_OK(WriteBatchInternal::PutBlobIndex(
|
||||
&batch, db_->DefaultColumnFamily()->GetID(), "blob_key", blob_index));
|
||||
ASSERT_OK(db_->Write(WriteOptions(), &batch));
|
||||
ASSERT_OK(db_secondary_->TryCatchUpWithPrimary());
|
||||
|
||||
PinnableSlice value;
|
||||
bool is_blob_index = false;
|
||||
DBImpl::GetImplOptions get_impl_options;
|
||||
get_impl_options.column_family = db_secondary_->DefaultColumnFamily();
|
||||
get_impl_options.value = &value;
|
||||
get_impl_options.is_blob_index = &is_blob_index;
|
||||
|
||||
ASSERT_OK(db_secondary_full()->GetImpl(ReadOptions(), Slice("blob_key"),
|
||||
get_impl_options));
|
||||
ASSERT_TRUE(is_blob_index);
|
||||
ASSERT_EQ(value, blob_index);
|
||||
}
|
||||
|
||||
TEST_F(DBSecondaryTest,
|
||||
GetAndGetEntityWithBlobBackedDefaultColumnDirectWriteMemtable) {
|
||||
// Goal: cover the secondary memtable path after catch-up replays a blob
|
||||
// direct-write entity from the primary WAL. The test checks both `Get()`,
|
||||
// which must resolve the blob-backed default column, and `GetEntity()`,
|
||||
// which must eagerly resolve all unresolved blob columns instead of exposing
|
||||
// encoded blob indices to the caller.
|
||||
Options options =
|
||||
wide_column_test_util::GetDirectWriteOptions(GetDefaultOptions());
|
||||
options.create_if_missing = true;
|
||||
options.min_blob_size = 50;
|
||||
options.env = env_;
|
||||
|
||||
Reopen(options);
|
||||
|
||||
options.max_open_files = -1;
|
||||
OpenSecondary(options);
|
||||
|
||||
const std::string key = "secondary_direct_write_memtable_entity";
|
||||
const std::string default_value =
|
||||
wide_column_test_util::GenerateLargeValue(100, 'd');
|
||||
const std::string large_value =
|
||||
wide_column_test_util::GenerateLargeValue(120, 'l');
|
||||
const std::string small_value = wide_column_test_util::GenerateSmallValue();
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
|
||||
ASSERT_OK(
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
ASSERT_OK(db_secondary_->TryCatchUpWithPrimary());
|
||||
|
||||
auto* cfh = db_secondary_->DefaultColumnFamily();
|
||||
|
||||
{
|
||||
PinnableSlice result;
|
||||
ASSERT_OK(db_secondary_->Get(ReadOptions(), cfh, key, &result));
|
||||
ASSERT_EQ(result, default_value);
|
||||
}
|
||||
|
||||
{
|
||||
PinnableWideColumns result;
|
||||
ASSERT_OK(db_secondary_->GetEntity(ReadOptions(), cfh, key, &result));
|
||||
ASSERT_EQ(result.columns(), columns);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBSecondaryTest, SecondaryDirectWriteMemtableBlobBlockCacheTier) {
|
||||
// Goal: cover the secondary memtable path under kBlockCacheTier. Catch-up
|
||||
// rebuilds the blob-backed direct-write entity from WAL, so serving Get() or
|
||||
// GetEntity() would require blob I/O through the memtable resolution path,
|
||||
// which must return Incomplete instead of issuing that I/O.
|
||||
Options options =
|
||||
wide_column_test_util::GetDirectWriteOptions(GetDefaultOptions());
|
||||
options.create_if_missing = true;
|
||||
options.min_blob_size = 50;
|
||||
options.env = env_;
|
||||
|
||||
Reopen(options);
|
||||
|
||||
options.max_open_files = -1;
|
||||
OpenSecondary(options);
|
||||
|
||||
const std::string key = "secondary_direct_write_memtable_block_cache_tier";
|
||||
const std::string default_value =
|
||||
wide_column_test_util::GenerateLargeValue(100, 'd');
|
||||
const std::string large_value =
|
||||
wide_column_test_util::GenerateLargeValue(120, 'l');
|
||||
const std::string small_value = wide_column_test_util::GenerateSmallValue();
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
|
||||
ASSERT_OK(
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
ASSERT_OK(db_secondary_->TryCatchUpWithPrimary());
|
||||
|
||||
auto* cfh = db_secondary_->DefaultColumnFamily();
|
||||
ReadOptions read_opts;
|
||||
read_opts.read_tier = kBlockCacheTier;
|
||||
|
||||
{
|
||||
PinnableSlice result;
|
||||
const Status s = db_secondary_->Get(read_opts, cfh, key, &result);
|
||||
ASSERT_TRUE(s.IsIncomplete()) << s.ToString();
|
||||
ASSERT_TRUE(result.empty());
|
||||
}
|
||||
|
||||
{
|
||||
PinnableWideColumns result;
|
||||
const Status s = db_secondary_->GetEntity(read_opts, cfh, key, &result);
|
||||
ASSERT_TRUE(s.IsIncomplete()) << s.ToString();
|
||||
ASSERT_TRUE(result.columns().empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBSecondaryTest, InternalCompactionCompactedFiles) {
|
||||
Options options;
|
||||
options.env = env_;
|
||||
|
||||
@@ -8,14 +8,17 @@
|
||||
// found in the LICENSE file. See the AUTHORS file for names of contributors.
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "db/db_test_util.h"
|
||||
#include "port/port.h"
|
||||
#include "port/stack_trace.h"
|
||||
#include "rocksdb/advanced_compression.h"
|
||||
#include "rocksdb/db.h"
|
||||
#include "rocksdb/types.h"
|
||||
#include "rocksdb/utilities/object_registry.h"
|
||||
#include "rocksdb/utilities/table_properties_collectors.h"
|
||||
#include "table/format.h"
|
||||
#include "table/meta_blocks.h"
|
||||
@@ -51,6 +54,42 @@ void VerifyTableProperties(DB* db, uint64_t expected_entries_size) {
|
||||
|
||||
VerifySstUniqueIds(props);
|
||||
}
|
||||
|
||||
class ParseCompressionDisplayNameManager : public CompressionManagerWrapper {
|
||||
public:
|
||||
static constexpr const char* kCompatibilityName =
|
||||
"ParseCompressionDisplayNameManager";
|
||||
|
||||
ParseCompressionDisplayNameManager()
|
||||
: CompressionManagerWrapper(GetBuiltinV2CompressionManager()) {}
|
||||
|
||||
const char* Name() const override { return kCompatibilityName; }
|
||||
const char* CompatibilityName() const override { return kCompatibilityName; }
|
||||
|
||||
std::string CompressionTypeToString(CompressionType type) const override {
|
||||
if (type == kCustomCompression8A) {
|
||||
return "CustomAlpha";
|
||||
}
|
||||
if (type == kCustomCompression8B) {
|
||||
return "CustomBeta";
|
||||
}
|
||||
return CompressionManagerWrapper::CompressionTypeToString(type);
|
||||
}
|
||||
|
||||
static void Register() {
|
||||
static std::once_flag loaded;
|
||||
std::call_once(loaded, []() {
|
||||
auto& library = *ObjectLibrary::Default();
|
||||
library.AddFactory<CompressionManager>(
|
||||
kCompatibilityName, [](const std::string& /*uri*/,
|
||||
std::unique_ptr<CompressionManager>* guard,
|
||||
std::string* /*errmsg*/) {
|
||||
*guard = std::make_unique<ParseCompressionDisplayNameManager>();
|
||||
return guard->get();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
} // anonymous namespace
|
||||
|
||||
class DBTablePropertiesTest : public DBTestBase,
|
||||
@@ -812,6 +851,90 @@ TEST_F(DBTablePropertiesTest, KeyLargestSmallestSeqno) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBTablePropertiesTest, ParseCompressionNameForDisplay) {
|
||||
auto custom_mgr = std::make_shared<ParseCompressionDisplayNameManager>();
|
||||
|
||||
// Test empty string
|
||||
EXPECT_EQ("NoCompression", ParseCompressionNameForDisplay(""));
|
||||
|
||||
// Test old format (no semicolon)
|
||||
EXPECT_EQ("ZSTD", ParseCompressionNameForDisplay("ZSTD"));
|
||||
EXPECT_EQ("Snappy", ParseCompressionNameForDisplay("Snappy"));
|
||||
EXPECT_EQ("LZ4", ParseCompressionNameForDisplay("LZ4"));
|
||||
EXPECT_EQ("kZSTD", ParseCompressionNameForDisplay("kZSTD"));
|
||||
EXPECT_EQ("kSnappyCompression",
|
||||
ParseCompressionNameForDisplay("kSnappyCompression"));
|
||||
|
||||
// Test new format version 7 with ZSTD
|
||||
EXPECT_EQ("ZSTD", ParseCompressionNameForDisplay("zstd;07;"));
|
||||
EXPECT_EQ("ZSTD", ParseCompressionNameForDisplay("BuiltinV2;07;"));
|
||||
EXPECT_EQ("ZSTD", ParseCompressionNameForDisplay("builtin_v2;07;"));
|
||||
EXPECT_EQ("ZSTD", ParseCompressionNameForDisplay(";07;"));
|
||||
EXPECT_EQ("ZSTD", ParseCompressionNameForDisplay("custom_mgr;07;extra;"));
|
||||
|
||||
// Test new format with LZ4
|
||||
EXPECT_EQ("LZ4", ParseCompressionNameForDisplay("zstd;04;"));
|
||||
EXPECT_EQ("LZ4", ParseCompressionNameForDisplay("lz4;04;"));
|
||||
|
||||
// Test lowercase hex for reserved/custom values
|
||||
EXPECT_EQ("Reserved7F", ParseCompressionNameForDisplay("test;7f;"));
|
||||
EXPECT_EQ("Custom8A", ParseCompressionNameForDisplay("test;8a;"));
|
||||
|
||||
// Test multiple compression types
|
||||
EXPECT_EQ("LZ4,ZSTD", ParseCompressionNameForDisplay("test;0407;"));
|
||||
EXPECT_EQ(
|
||||
"ZSTD,LZ4",
|
||||
ParseCompressionNameForDisplay("test;0704;")); // sorted by appearance
|
||||
EXPECT_EQ("ZSTD", ParseCompressionNameForDisplay("test;0007;"));
|
||||
|
||||
// Test all standard compression types (01-07)
|
||||
EXPECT_EQ("NoCompression", ParseCompressionNameForDisplay("test;00;"));
|
||||
EXPECT_EQ("Snappy", ParseCompressionNameForDisplay("test;01;"));
|
||||
EXPECT_EQ("Zlib", ParseCompressionNameForDisplay("test;02;"));
|
||||
EXPECT_EQ("BZip2", ParseCompressionNameForDisplay("test;03;"));
|
||||
EXPECT_EQ("LZ4", ParseCompressionNameForDisplay("test;04;"));
|
||||
EXPECT_EQ("LZ4HC", ParseCompressionNameForDisplay("test;05;"));
|
||||
EXPECT_EQ("Xpress", ParseCompressionNameForDisplay("test;06;"));
|
||||
EXPECT_EQ("ZSTD", ParseCompressionNameForDisplay("test;07;"));
|
||||
|
||||
// Test custom/reserved types (>= 0x08)
|
||||
// 0x08-0x7F are Reserved, 0x80-0xFE are Custom
|
||||
EXPECT_EQ("Reserved08", ParseCompressionNameForDisplay("test;08;"));
|
||||
EXPECT_EQ("Custom80", ParseCompressionNameForDisplay("test;80;"));
|
||||
EXPECT_EQ("Reserved7F", ParseCompressionNameForDisplay("test;7F;"));
|
||||
EXPECT_EQ("CustomFE", ParseCompressionNameForDisplay("test;FE;"));
|
||||
EXPECT_EQ("CustomAlpha,CustomBeta",
|
||||
ParseCompressionNameForDisplay(
|
||||
"ParseCompressionDisplayNameManager;8A8B;", custom_mgr));
|
||||
EXPECT_EQ("CustomAlpha,LZ4,CustomBeta",
|
||||
ParseCompressionNameForDisplay(
|
||||
"ParseCompressionDisplayNameManager;8A048B;", custom_mgr));
|
||||
|
||||
ParseCompressionDisplayNameManager::Register();
|
||||
EXPECT_EQ("CustomAlpha", ParseCompressionNameForDisplay(
|
||||
"ParseCompressionDisplayNameManager;8A;"));
|
||||
|
||||
// Test DisableOption (0xFF) - filtered out
|
||||
EXPECT_EQ("NoCompression", ParseCompressionNameForDisplay("test;FF;"));
|
||||
|
||||
// Test NoCompression (empty hex field would be caught by validation, but test
|
||||
// "00")
|
||||
EXPECT_EQ("NoCompression", ParseCompressionNameForDisplay("test;00;"));
|
||||
EXPECT_EQ("NoCompression", ParseCompressionNameForDisplay("BuiltinV2;;"));
|
||||
EXPECT_EQ("NoCompression", ParseCompressionNameForDisplay("test;;"));
|
||||
|
||||
// Test three+ semicolons (future fields ignored)
|
||||
EXPECT_EQ("ZSTD", ParseCompressionNameForDisplay("test;07;extra;fields;"));
|
||||
|
||||
// Test malformed inputs -> "Unknown"
|
||||
EXPECT_EQ("Unknown", ParseCompressionNameForDisplay("only_one;"));
|
||||
EXPECT_EQ("Unknown", ParseCompressionNameForDisplay("bad;0;")); // odd length
|
||||
EXPECT_EQ("Unknown",
|
||||
ParseCompressionNameForDisplay("bad;0G;")); // invalid hex
|
||||
EXPECT_EQ("Unknown",
|
||||
ParseCompressionNameForDisplay("bad;GG;")); // invalid hex
|
||||
}
|
||||
|
||||
INSTANTIATE_TEST_CASE_P(DBTablePropertiesTest, DBTablePropertiesTest,
|
||||
::testing::Values("kCompactionStyleLevel",
|
||||
"kCompactionStyleUniversal"));
|
||||
|
||||
@@ -8203,6 +8203,98 @@ TEST_P(OpenFilesAsyncTest, BeforeRead) {
|
||||
}
|
||||
}
|
||||
|
||||
// For modern DBs (manifest carries file_creation_time),
|
||||
// GetCreationTimeOfOldestFile returns the real value without waiting for async
|
||||
// file open.
|
||||
TEST_P(OpenFilesAsyncTest, GetCreationTimeOfOldestFileSkipsWaitForModernDB) {
|
||||
if (max_open_files_ != -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
Options options = CurrentOptions();
|
||||
ASSERT_NO_FATAL_FAILURE(SetupData(options));
|
||||
|
||||
// If WaitForAsyncFileOpen is entered, the test fails -- modern DBs must
|
||||
// never need the wait.
|
||||
std::atomic<bool> waited{false};
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"DBImpl::WaitForAsyncFileOpen::BeforeWait",
|
||||
[&](void* /*arg*/) { waited.store(true); });
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
OpenTestDB(options);
|
||||
|
||||
uint64_t creation_time = std::numeric_limits<uint64_t>::max();
|
||||
ASSERT_OK(dbfull()->GetCreationTimeOfOldestFile(&creation_time));
|
||||
EXPECT_GT(creation_time, 0);
|
||||
EXPECT_LT(creation_time, std::numeric_limits<uint64_t>::max());
|
||||
EXPECT_FALSE(waited.load());
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->DisableProcessing();
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
Close();
|
||||
}
|
||||
|
||||
// For legacy DBs (manifest lacks file_creation_time, value comes from the
|
||||
// pinned reader), GetCreationTimeOfOldestFile must block until
|
||||
// BGWorkAsyncFileOpen pins the readers; otherwise it returns 0 (the "info
|
||||
// unavailable" sentinel) instead of the real value.
|
||||
TEST_P(OpenFilesAsyncTest,
|
||||
GetCreationTimeOfOldestFileBlocksOnAsyncOpenForLegacyDB) {
|
||||
if (max_open_files_ != -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
Options options = CurrentOptions();
|
||||
ASSERT_NO_FATAL_FAILURE(SetupData(options));
|
||||
|
||||
// Simulate a legacy DB: force TryGetFileCreationTime to return
|
||||
// kUnknownFileCreationTime until BGWorkAsyncFileOpen completes (i.e., what
|
||||
// would happen if the manifest had no file_creation_time and the pinned
|
||||
// reader was the only source).
|
||||
std::atomic<bool> async_open_done{false};
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"DBImpl::BGWorkAsyncFileOpen:Done",
|
||||
[&](void* /*arg*/) { async_open_done.store(true); });
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->SetCallBack(
|
||||
"Version::GetCreationTimeOfOldestFile::FileCreationTime", [&](void* arg) {
|
||||
if (!async_open_done.load()) {
|
||||
*static_cast<uint64_t*>(arg) = kUnknownFileCreationTime;
|
||||
}
|
||||
});
|
||||
|
||||
// Dependency chain: caller's first pass returns 0, enters
|
||||
// WaitForAsyncFileOpen -> main thread confirms wait -> main thread releases
|
||||
// async open -> background worker completes -> caller wakes -> second pass
|
||||
// sees the real value.
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->LoadDependency(
|
||||
{{"DBImpl::WaitForAsyncFileOpen::BeforeWait",
|
||||
"OpenFilesAsyncTest::CreationTime::CallerBlocked"},
|
||||
{"OpenFilesAsyncTest::CreationTime::CallerBlocked",
|
||||
"OpenFilesAsyncTest::CreationTime::ReleaseAsyncOpen"},
|
||||
{"OpenFilesAsyncTest::CreationTime::ReleaseAsyncOpen",
|
||||
"DBImpl::BGWorkAsyncFileOpen::Start"}});
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
OpenTestDB(options);
|
||||
|
||||
uint64_t creation_time = std::numeric_limits<uint64_t>::max();
|
||||
port::Thread caller([&]() {
|
||||
ASSERT_OK(dbfull()->GetCreationTimeOfOldestFile(&creation_time));
|
||||
});
|
||||
|
||||
TEST_SYNC_POINT("OpenFilesAsyncTest::CreationTime::CallerBlocked");
|
||||
TEST_SYNC_POINT("OpenFilesAsyncTest::CreationTime::ReleaseAsyncOpen");
|
||||
caller.join();
|
||||
|
||||
EXPECT_GT(creation_time, 0);
|
||||
EXPECT_LT(creation_time, std::numeric_limits<uint64_t>::max());
|
||||
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->DisableProcessing();
|
||||
ROCKSDB_NAMESPACE::SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
Close();
|
||||
}
|
||||
|
||||
TEST_P(OpenFilesAsyncTest, Shutdown) {
|
||||
Options options = CurrentOptions();
|
||||
ASSERT_NO_FATAL_FAILURE(SetupData(options));
|
||||
|
||||
+102
-2
@@ -107,7 +107,7 @@ TEST_F(DBTest2, ReadOnlyDBWalInDbPathInitialized) {
|
||||
ASSERT_OK(Put("key2", "value2"));
|
||||
Close();
|
||||
|
||||
// Reopen as read-only — wal_in_db_path_ must be properly initialized.
|
||||
// Reopen as read-only -- wal_in_db_path_ must be properly initialized.
|
||||
// Before the fix, closing this DB would read an uninitialized bool in
|
||||
// DeleteObsoleteFileImpl, which UBSan catches as undefined behavior.
|
||||
std::unique_ptr<DB> db_ptr;
|
||||
@@ -115,7 +115,7 @@ TEST_F(DBTest2, ReadOnlyDBWalInDbPathInitialized) {
|
||||
std::string value;
|
||||
ASSERT_OK(db_ptr->Get(ReadOptions(), "key1", &value));
|
||||
ASSERT_EQ("value1", value);
|
||||
// Close the read-only DB — this triggers PurgeObsoleteFiles which reads
|
||||
// Close the read-only DB -- this triggers PurgeObsoleteFiles which reads
|
||||
// wal_in_db_path_. Under UBSan, an uninitialized bool here would fail.
|
||||
db_ptr.reset();
|
||||
|
||||
@@ -7510,6 +7510,65 @@ TEST_F(DBTest2, GetLatestSeqAndTsForKey) {
|
||||
ASSERT_EQ(0, options.statistics->getTickerCount(GET_HIT_L0));
|
||||
}
|
||||
|
||||
TEST_F(DBTest2,
|
||||
GetLatestSequenceForKeyFromHistoryWithBlobBackedWideColumnEntity) {
|
||||
// Goal: exercise the memtable history lookup in GetLatestSequenceForKey()
|
||||
// after blob direct write stores a blob-backed V2 entity in a flushed
|
||||
// memtable. Using cache_only=true ensures the lookup succeeds only if the
|
||||
// history path can resolve the wide-column base value under the newer merge.
|
||||
Destroy(last_options_);
|
||||
|
||||
Options options = CurrentOptions();
|
||||
options.create_if_missing = true;
|
||||
options.enable_blob_files = true;
|
||||
options.enable_blob_direct_write = true;
|
||||
options.min_blob_size = 50;
|
||||
options.allow_concurrent_memtable_write = false;
|
||||
options.blob_direct_write_partitions = 1;
|
||||
options.max_write_buffer_size_to_maintain = 64 << 10;
|
||||
options.merge_operator = MergeOperators::CreateStringAppendOperator("|");
|
||||
|
||||
Reopen(options);
|
||||
|
||||
const std::string key = "history_blob_entity";
|
||||
const std::string default_value(100, 'd');
|
||||
const std::string merge_operand = "suffix";
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"meta", "inline"}};
|
||||
|
||||
ASSERT_OK(
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
ASSERT_OK(db_->Merge(WriteOptions(), db_->DefaultColumnFamily(), key,
|
||||
merge_operand));
|
||||
const SequenceNumber expected_seq = dbfull()->GetLatestSequenceNumber();
|
||||
|
||||
ASSERT_OK(Flush());
|
||||
ASSERT_FALSE(GetBlobFileNumbers().empty());
|
||||
|
||||
uint64_t num_immutable_memtables = 0;
|
||||
ASSERT_TRUE(db_->GetIntProperty(DB::Properties::kNumImmutableMemTable,
|
||||
&num_immutable_memtables));
|
||||
ASSERT_EQ(num_immutable_memtables, 0);
|
||||
|
||||
auto* cfhi = static_cast_with_check<ColumnFamilyHandleImpl>(
|
||||
dbfull()->DefaultColumnFamily());
|
||||
assert(cfhi);
|
||||
assert(cfhi->cfd());
|
||||
SuperVersion* sv = cfhi->cfd()->GetSuperVersion();
|
||||
|
||||
SequenceNumber seq = kMaxSequenceNumber;
|
||||
bool found_record_for_key = false;
|
||||
bool is_blob_index = false;
|
||||
const Status s = dbfull()->GetLatestSequenceForKey(
|
||||
sv, key, /*cache_only=*/true, /*lower_bound_seq=*/0, &seq,
|
||||
/*timestamp=*/nullptr, &found_record_for_key, &is_blob_index);
|
||||
|
||||
ASSERT_OK(s);
|
||||
ASSERT_TRUE(found_record_for_key);
|
||||
ASSERT_EQ(expected_seq, seq);
|
||||
ASSERT_FALSE(is_blob_index);
|
||||
}
|
||||
|
||||
#if defined(ZSTD)
|
||||
TEST_F(DBTest2, ZSTDChecksum) {
|
||||
// Verify that corruption during decompression is caught.
|
||||
@@ -8149,6 +8208,47 @@ TEST_F(DBTest2, FastSstOpenIngestion) {
|
||||
ASSERT_OK(DestroyDB(test_dbname, options));
|
||||
}
|
||||
|
||||
TEST_F(DBTest2, FastSstOpenDisableAfterMetadataPersisted) {
|
||||
// Verify that disabling fast_sst_open prevents previously-persisted
|
||||
// metadata from being passed to NewRandomAccessFile. This is critical
|
||||
// for cases where stale metadata (e.g. expired credentials) would
|
||||
// cause file open failures.
|
||||
auto test_fs = std::make_shared<FastOpenTestFS>(env_->GetFileSystem());
|
||||
std::unique_ptr<Env> test_env(new CompositeEnvWrapper(env_, test_fs));
|
||||
|
||||
Options options;
|
||||
options.env = test_env.get();
|
||||
options.fast_sst_open = true;
|
||||
options.create_if_missing = true;
|
||||
|
||||
std::string test_dbname = test::PerThreadDBPath("fast_open_disable_after");
|
||||
ASSERT_OK(DestroyDB(test_dbname, options));
|
||||
|
||||
// Open with fast_sst_open enabled, write and flush to persist metadata
|
||||
std::unique_ptr<DB> db;
|
||||
ASSERT_OK(DB::Open(options, test_dbname, &db));
|
||||
ASSERT_OK(db->Put(WriteOptions(), "key1", "value1"));
|
||||
ASSERT_OK(db->Flush(FlushOptions()));
|
||||
ASSERT_EQ(1, test_fs->GetMetadataRetrievedCount());
|
||||
db.reset();
|
||||
|
||||
// Reopen with fast_sst_open DISABLED -- metadata is in the MANIFEST
|
||||
// but should NOT be passed to the filesystem
|
||||
options.fast_sst_open = false;
|
||||
test_fs->ResetCounters();
|
||||
ASSERT_OK(DB::Open(options, test_dbname, &db));
|
||||
|
||||
std::string value;
|
||||
ASSERT_OK(db->Get(ReadOptions(), "key1", &value));
|
||||
ASSERT_EQ("value1", value);
|
||||
|
||||
// No metadata should have been passed despite being in the MANIFEST
|
||||
ASSERT_EQ(0, test_fs->GetMetadataPassedOnOpenCount());
|
||||
|
||||
db.reset();
|
||||
ASSERT_OK(DestroyDB(test_dbname, options));
|
||||
}
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
|
||||
+2
-2
@@ -218,7 +218,7 @@ TEST_P(IterKeySwapTest, SwapAndDestroy) {
|
||||
b_use_secondary] = GetParam();
|
||||
|
||||
std::string expected_a, expected_b;
|
||||
// Storage for pinned keys — must outlive the IterKeys.
|
||||
// Storage for pinned keys -- must outlive the IterKeys.
|
||||
std::string a_storage, b_storage;
|
||||
IterKey a;
|
||||
expected_a =
|
||||
@@ -236,7 +236,7 @@ TEST_P(IterKeySwapTest, SwapAndDestroy) {
|
||||
// After swap: a has b's old data, b has a's old data.
|
||||
ASSERT_EQ(a.GetUserKey().ToString(), expected_b);
|
||||
ASSERT_EQ(b.GetUserKey().ToString(), expected_a);
|
||||
} // b destroyed here — must not corrupt a's data
|
||||
} // b destroyed here -- must not corrupt a's data
|
||||
|
||||
// a must still hold valid data after b's destruction.
|
||||
ASSERT_EQ(a.GetUserKey().ToString(), expected_b);
|
||||
|
||||
@@ -266,6 +266,9 @@ class FaultInjectionTest
|
||||
CloseDB();
|
||||
env_->ResetState();
|
||||
Status s = DB::Open(options_, dbname_, &db_);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
assert(db_ != nullptr);
|
||||
return s;
|
||||
}
|
||||
|
||||
+3
-3
@@ -1696,7 +1696,7 @@ TEST_F(EventListenerTest, BackgroundJobPressure) {
|
||||
Env::Priority::LOW);
|
||||
sleeping_task.WaitUntilSleeping();
|
||||
|
||||
// Phase 1: No pressure — 3 SST files (below slowdown trigger=4).
|
||||
// Phase 1: No pressure -- 3 SST files (below slowdown trigger=4).
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ASSERT_OK(Put("k" + std::to_string(i), std::string(100, 'x')));
|
||||
ASSERT_OK(Flush());
|
||||
@@ -1714,7 +1714,7 @@ TEST_F(EventListenerTest, BackgroundJobPressure) {
|
||||
// MaybeScheduleFlushOrCompaction() runs before the pressure callback and
|
||||
// may schedule new flush work, making flush counts non-deterministic.
|
||||
|
||||
// Phase 2: Build pressure — flush past slowdown trigger (4 L0 SST files).
|
||||
// Phase 2: Build pressure -- flush past slowdown trigger (4 L0 SST files).
|
||||
// Compaction is blocked, so L0 SST files pile up.
|
||||
listener->Reset();
|
||||
{
|
||||
@@ -1750,7 +1750,7 @@ TEST_F(EventListenerTest, BackgroundJobPressure) {
|
||||
ASSERT_TRUE(found_compaction_scheduled);
|
||||
ASSERT_TRUE(found_high_proximity);
|
||||
|
||||
// Phase 3: Relieve pressure — unblock compaction, wait for completion.
|
||||
// Phase 3: Relieve pressure -- unblock compaction, wait for completion.
|
||||
listener->Reset();
|
||||
sleeping_task.WakeUp();
|
||||
sleeping_task.WaitUntilDone();
|
||||
|
||||
+1
-4
@@ -48,15 +48,12 @@ Reader::Reader(std::shared_ptr<Logger> info_log,
|
||||
first_record_read_(false),
|
||||
compression_type_(kNoCompression),
|
||||
compression_type_record_read_(false),
|
||||
uncompress_(nullptr),
|
||||
uncompress_(),
|
||||
hash_state_(nullptr),
|
||||
uncompress_hash_state_(nullptr) {}
|
||||
|
||||
Reader::~Reader() {
|
||||
delete[] backing_store_;
|
||||
if (uncompress_) {
|
||||
delete uncompress_;
|
||||
}
|
||||
if (hash_state_) {
|
||||
XXH3_freeState(hash_state_);
|
||||
}
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ class Reader {
|
||||
CompressionType compression_type_;
|
||||
// Track whether the compression type record has been read or not.
|
||||
bool compression_type_record_read_;
|
||||
StreamingUncompress* uncompress_;
|
||||
std::unique_ptr<StreamingUncompress> uncompress_;
|
||||
// Reusable uncompressed output buffer
|
||||
std::unique_ptr<char[]> uncompressed_buffer_;
|
||||
// Reusable uncompressed record
|
||||
|
||||
+3
-6
@@ -1224,11 +1224,11 @@ TEST_P(StreamingCompressionTest, Basic) {
|
||||
}
|
||||
CompressionOptions opts;
|
||||
constexpr uint32_t compression_format_version = 2;
|
||||
StreamingCompress* compress = StreamingCompress::Create(
|
||||
auto compress = StreamingCompress::Create(
|
||||
compression_type, opts, compression_format_version, kBlockSize);
|
||||
StreamingUncompress* uncompress = StreamingUncompress::Create(
|
||||
auto uncompress = StreamingUncompress::Create(
|
||||
compression_type, compression_format_version, kBlockSize);
|
||||
MemoryAllocator* allocator = new DefaultMemoryAllocator();
|
||||
std::unique_ptr<MemoryAllocator> allocator(new DefaultMemoryAllocator());
|
||||
std::string input_buffer = BigString("abc", input_size);
|
||||
std::vector<std::string> compressed_buffers;
|
||||
size_t remaining;
|
||||
@@ -1266,9 +1266,6 @@ TEST_P(StreamingCompressionTest, Basic) {
|
||||
} while (ret_val > 0 || output_pos == kBlockSize);
|
||||
}
|
||||
allocator->Deallocate((void*)uncompressed_output_buffer);
|
||||
delete allocator;
|
||||
delete compress;
|
||||
delete uncompress;
|
||||
// The final return value from uncompress() should be 0.
|
||||
ASSERT_EQ(ret_val, 0);
|
||||
ASSERT_EQ(input_buffer, uncompressed_buffer);
|
||||
|
||||
+4
-6
@@ -22,16 +22,17 @@ namespace ROCKSDB_NAMESPACE::log {
|
||||
|
||||
Writer::Writer(std::unique_ptr<WritableFileWriter>&& dest, uint64_t log_number,
|
||||
bool recycle_log_files, bool manual_flush,
|
||||
CompressionType compression_type, bool track_and_verify_wals)
|
||||
CompressionType compression_type, bool track_and_verify_wals,
|
||||
size_t initial_block_offset)
|
||||
: dest_(std::move(dest)),
|
||||
block_offset_(0),
|
||||
block_offset_(initial_block_offset),
|
||||
log_number_(log_number),
|
||||
recycle_log_files_(recycle_log_files),
|
||||
// Header size varies depending on whether we are recycling or not.
|
||||
header_size_(recycle_log_files ? kRecyclableHeaderSize : kHeaderSize),
|
||||
manual_flush_(manual_flush),
|
||||
compression_type_(compression_type),
|
||||
compress_(nullptr),
|
||||
compress_(),
|
||||
track_and_verify_wals_(track_and_verify_wals),
|
||||
last_seqno_recorded_(0) {
|
||||
for (uint8_t i = 0; i <= kMaxRecordType; i++) {
|
||||
@@ -47,9 +48,6 @@ Writer::~Writer() {
|
||||
if (dest_) {
|
||||
WriteBuffer(WriteOptions()).PermitUncheckedError();
|
||||
}
|
||||
if (compress_) {
|
||||
delete compress_;
|
||||
}
|
||||
ThreadStatusUtil::SetThreadOperation(cur_op_type);
|
||||
}
|
||||
|
||||
|
||||
+9
-4
@@ -75,15 +75,20 @@ namespace log {
|
||||
class Writer {
|
||||
public:
|
||||
// Create a writer that will append data to "*dest".
|
||||
// "*dest" must be initially empty.
|
||||
// "*dest" must remain live while this Writer is in use.
|
||||
// "*dest" must remain live while this Writer is in use. By default
|
||||
// "*dest" is expected to be empty (initial_block_offset = 0). When
|
||||
// resuming append into an existing log file (e.g.
|
||||
// VersionSet::ReopenManifestForAppend), pass the offset within the
|
||||
// current 32 KiB block at which writes should resume so that record
|
||||
// framing aligns to the existing block layout.
|
||||
// TODO(hx235): separate WAL related parameters from general `Reader`
|
||||
// parameters
|
||||
explicit Writer(std::unique_ptr<WritableFileWriter>&& dest,
|
||||
uint64_t log_number, bool recycle_log_files,
|
||||
bool manual_flush = false,
|
||||
CompressionType compressionType = kNoCompression,
|
||||
bool track_and_verify_wals = false);
|
||||
bool track_and_verify_wals = false,
|
||||
size_t initial_block_offset = 0);
|
||||
// No copying allowed
|
||||
Writer(const Writer&) = delete;
|
||||
void operator=(const Writer&) = delete;
|
||||
@@ -151,7 +156,7 @@ class Writer {
|
||||
|
||||
// Compression Type
|
||||
CompressionType compression_type_;
|
||||
StreamingCompress* compress_;
|
||||
std::unique_ptr<StreamingCompress> compress_;
|
||||
// Reusable compressed output buffer
|
||||
std::unique_ptr<char[]> compressed_buffer_;
|
||||
|
||||
|
||||
+118
-28
@@ -15,6 +15,7 @@
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
#include "db/blob/blob_fetcher.h"
|
||||
#include "db/blob/blob_file_partition_manager.h"
|
||||
#include "db/blob/blob_index.h"
|
||||
#include "db/dbformat.h"
|
||||
@@ -70,6 +71,67 @@ Status GetDefaultColumnBlobIndexSlice(Slice entity, Slice* blob_index_slice) {
|
||||
return status;
|
||||
}
|
||||
|
||||
Status PushWideColumnEntityDefaultOperand(const Slice& user_key,
|
||||
const Slice& entity,
|
||||
MergeContext* merge_context,
|
||||
bool operand_pinned,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
assert(merge_context != nullptr);
|
||||
|
||||
Slice entity_ref = entity;
|
||||
Slice value_of_default;
|
||||
Status status = WideColumnSerialization::GetValueOfDefaultColumn(
|
||||
entity_ref, value_of_default);
|
||||
if (status.ok()) {
|
||||
merge_context->PushOperand(value_of_default, operand_pinned);
|
||||
return status;
|
||||
}
|
||||
if (!status.IsNotSupported()) {
|
||||
return status;
|
||||
}
|
||||
if (blob_fetcher == nullptr) {
|
||||
return Status::Corruption(
|
||||
"Cannot resolve blob-backed default column without a blob fetcher");
|
||||
}
|
||||
|
||||
PinnableSlice resolved_default;
|
||||
bool resolved = false;
|
||||
status = WideColumnSerialization::GetValueOfDefaultColumnResolvingBlobs(
|
||||
entity, user_key, blob_fetcher, resolved_default, resolved);
|
||||
if (status.ok()) {
|
||||
// Resolved blob values are backed by this stack-local PinnableSlice, so
|
||||
// copy them into MergeContext instead of pinning their storage.
|
||||
merge_context->PushOperand(Slice(resolved_default), false);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
Status MergeWithWideColumnEntityBaseValue(
|
||||
const Slice& user_key, const Slice& entity,
|
||||
const MergeOperator* merge_operator, MergeContext* merge_context,
|
||||
Logger* logger, Statistics* statistics, SystemClock* clock,
|
||||
std::string* value, PinnableWideColumns* columns,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
assert(merge_context != nullptr);
|
||||
|
||||
std::string resolved_entity;
|
||||
Slice effective_entity;
|
||||
Status status = WideColumnSerialization::ResolveEntityForMerge(
|
||||
entity, user_key, blob_fetcher, nullptr /* prefetch_buffers */,
|
||||
resolved_entity, effective_entity);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
|
||||
// `op_failure_scope` (an output parameter) is not provided (set to nullptr)
|
||||
// since a failure must be propagated regardless of its value.
|
||||
return MergeHelper::TimedFullMerge(
|
||||
merge_operator, user_key, MergeHelper::kWideBaseValue, effective_entity,
|
||||
merge_context->GetOperands(), logger, statistics, clock,
|
||||
/* update_num_ops_stats */ true, /* op_failure_scope */ nullptr, value,
|
||||
columns);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ImmutableMemTableOptions::ImmutableMemTableOptions(
|
||||
@@ -918,12 +980,11 @@ void MemTable::ConstructFragmentedRangeTombstones() {
|
||||
}
|
||||
}
|
||||
|
||||
bool MemTable::AddLogicallyRedundantRangeTombstone(SequenceNumber seq,
|
||||
const Slice& start_key,
|
||||
const Slice& end_key) {
|
||||
// Fast path: skip if already immutable or empty. Some code paths (i.e.
|
||||
// ExternalFileIngestion) rely ensuring memtable is empty after flushing.
|
||||
if (is_immutable_.LoadRelaxed() || IsEmpty()) {
|
||||
bool MemTable::AddLogicallyRedundantRangeTombstone(
|
||||
SequenceNumber seq, const Slice& start_key, const Slice& end_key,
|
||||
port::RWMutex& ingest_sst_lock) {
|
||||
// Fast path: skip if already immutable.
|
||||
if (is_immutable_.LoadRelaxed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -939,6 +1000,33 @@ bool MemTable::AddLogicallyRedundantRangeTombstone(SequenceNumber seq,
|
||||
return false;
|
||||
}
|
||||
|
||||
// Range tombstone reads have an assumption that all levels below it have a
|
||||
// LOWER seqno than it, so it is safe to skip reading files. Normally, this is
|
||||
// true, but range tombstone conversion creates an exception.
|
||||
//
|
||||
// The inserted range tombstone uses iterator seqno. There are guards to
|
||||
// ensure that we only insert it if it is within current memtable's bounds,
|
||||
// BUT an external file ingestion can break that still, as the newly ingested
|
||||
// L0 file will be assigned a higher seqno than an earlier iterator.
|
||||
//
|
||||
// So the solution here is to use a RW lock + ingest seqno to gate range
|
||||
// tombstone conversions. An added side effect is also we can no longer insert
|
||||
// to memtables while a file ingestion is in progress, which is an expectation
|
||||
// of file ingestion. Note we expect this insertion to be rare, and we do not
|
||||
// want to limit concurrent external file ingestions so the conversion path
|
||||
// uses a write lock while the ingestion path uses a read lock.
|
||||
TryWriteLock ingest_wl(&ingest_sst_lock);
|
||||
if (!ingest_wl.OwnsLock()) {
|
||||
return false;
|
||||
}
|
||||
// After ingestion releases its WriteLock, an iterator with an older
|
||||
// snapshot could still try to convert a tombstone whose seq sits
|
||||
// below the just-ingested file's seq. The barrier persists past the end
|
||||
// of the ingestion that bumped it and refuses such inserts.
|
||||
if (seq < ingest_seqno_barrier_.LoadRelaxed()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
MemTablePostProcessInfo post_process_info;
|
||||
Status s = Add(seq, kTypeRangeDeletion, start_key, end_key,
|
||||
nullptr /* kv_prot_info */, true /* allow_concurrent */,
|
||||
@@ -950,6 +1038,12 @@ bool MemTable::AddLogicallyRedundantRangeTombstone(SequenceNumber seq,
|
||||
return false;
|
||||
}
|
||||
|
||||
void MemTable::BumpIngestSeqnoBarrier(SequenceNumber y) {
|
||||
if (ingest_seqno_barrier_.LoadRelaxed() < y) {
|
||||
ingest_seqno_barrier_.StoreRelaxed(y);
|
||||
}
|
||||
}
|
||||
|
||||
port::RWMutex* MemTable::GetLock(const Slice& key) {
|
||||
return &locks_[GetSliceRangedNPHash(key, locks_.size())];
|
||||
}
|
||||
@@ -1222,6 +1316,7 @@ struct Saver {
|
||||
bool inplace_update_support;
|
||||
bool do_merge;
|
||||
SystemClock* clock;
|
||||
const BlobFetcher* blob_fetcher;
|
||||
|
||||
ReadCallback* callback_;
|
||||
bool* is_blob_index;
|
||||
@@ -1392,27 +1487,17 @@ static bool SaveValue(void* arg, const char* entry) {
|
||||
// Preserve the value with the goal of returning it as part of
|
||||
// raw merge operands to the user
|
||||
|
||||
Slice value_of_default;
|
||||
*(s->status) = WideColumnSerialization::GetValueOfDefaultColumn(
|
||||
v, value_of_default);
|
||||
|
||||
if (s->status->ok()) {
|
||||
merge_context->PushOperand(
|
||||
value_of_default,
|
||||
s->inplace_update_support == false /* operand_pinned */);
|
||||
}
|
||||
*(s->status) = PushWideColumnEntityDefaultOperand(
|
||||
s->key->user_key(), v, merge_context,
|
||||
s->inplace_update_support == false /* operand_pinned */,
|
||||
s->blob_fetcher);
|
||||
} else if (*(s->merge_in_progress)) {
|
||||
assert(s->do_merge);
|
||||
|
||||
if (s->value || s->columns) {
|
||||
// `op_failure_scope` (an output parameter) is not provided (set
|
||||
// to nullptr) since a failure must be propagated regardless of
|
||||
// its value.
|
||||
*(s->status) = MergeHelper::TimedFullMerge(
|
||||
merge_operator, s->key->user_key(), MergeHelper::kWideBaseValue,
|
||||
v, merge_context->GetOperands(), s->logger, s->statistics,
|
||||
s->clock, /* update_num_ops_stats */ true,
|
||||
/* op_failure_scope */ nullptr, s->value, s->columns);
|
||||
*(s->status) = MergeWithWideColumnEntityBaseValue(
|
||||
s->key->user_key(), v, merge_operator, merge_context, s->logger,
|
||||
s->statistics, s->clock, s->value, s->columns, s->blob_fetcher);
|
||||
}
|
||||
} else if (s->value) {
|
||||
Slice value_of_default;
|
||||
@@ -1488,7 +1573,8 @@ bool MemTable::Get(const LookupKey& key, std::string* value,
|
||||
SequenceNumber* max_covering_tombstone_seq,
|
||||
SequenceNumber* seq, const ReadOptions& read_opts,
|
||||
bool immutable_memtable, ReadCallback* callback,
|
||||
bool* is_blob_index, bool do_merge) {
|
||||
bool* is_blob_index, bool do_merge,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
// The sequence number is updated synchronously in version_set.h
|
||||
if (IsEmpty()) {
|
||||
// Avoiding recording stats for speed.
|
||||
@@ -1546,7 +1632,7 @@ bool MemTable::Get(const LookupKey& key, std::string* value,
|
||||
}
|
||||
GetFromTable(key, *max_covering_tombstone_seq, do_merge, callback,
|
||||
is_blob_index, value, columns, timestamp, s, merge_context,
|
||||
seq, &found_final_value, &merge_in_progress);
|
||||
seq, &found_final_value, &merge_in_progress, blob_fetcher);
|
||||
}
|
||||
|
||||
// No change to value, since we have not yet found a Put/Delete
|
||||
@@ -1569,7 +1655,8 @@ void MemTable::GetFromTable(const LookupKey& key,
|
||||
PinnableWideColumns* columns,
|
||||
std::string* timestamp, Status* s,
|
||||
MergeContext* merge_context, SequenceNumber* seq,
|
||||
bool* found_final_value, bool* merge_in_progress) {
|
||||
bool* found_final_value, bool* merge_in_progress,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
Saver saver;
|
||||
saver.status = s;
|
||||
saver.found_final_value = found_final_value;
|
||||
@@ -1587,6 +1674,7 @@ void MemTable::GetFromTable(const LookupKey& key,
|
||||
saver.inplace_update_support = moptions_.inplace_update_support;
|
||||
saver.statistics = moptions_.statistics;
|
||||
saver.clock = clock_;
|
||||
saver.blob_fetcher = blob_fetcher;
|
||||
saver.callback_ = callback;
|
||||
saver.is_blob_index = is_blob_index;
|
||||
saver.do_merge = do_merge;
|
||||
@@ -1616,7 +1704,8 @@ Status MemTable::ValidateKey(const char* key, bool allow_data_in_errors) {
|
||||
}
|
||||
|
||||
void MemTable::MultiGet(const ReadOptions& read_options, MultiGetRange* range,
|
||||
ReadCallback* callback, bool immutable_memtable) {
|
||||
ReadCallback* callback, bool immutable_memtable,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
// The sequence number is updated synchronously in version_set.h
|
||||
if (IsEmpty()) {
|
||||
// Avoiding recording stats for speed.
|
||||
@@ -1708,6 +1797,7 @@ void MemTable::MultiGet(const ReadOptions& read_options, MultiGetRange* range,
|
||||
saver.inplace_update_support = moptions_.inplace_update_support;
|
||||
saver.statistics = moptions_.statistics;
|
||||
saver.clock = clock_;
|
||||
saver.blob_fetcher = blob_fetcher;
|
||||
saver.callback_ = callback;
|
||||
saver.is_blob_index = &iter->is_blob_index;
|
||||
saver.do_merge = true;
|
||||
@@ -1799,7 +1889,7 @@ void MemTable::MultiGet(const ReadOptions& read_options, MultiGetRange* range,
|
||||
*(iter->lkey), iter->max_covering_tombstone_seq, true, callback,
|
||||
&iter->is_blob_index, iter->value ? iter->value->GetSelf() : nullptr,
|
||||
iter->columns, iter->timestamp, iter->s, &(iter->merge_context),
|
||||
&dummy_seq, &found_final_value, &merge_in_progress);
|
||||
&dummy_seq, &found_final_value, &merge_in_progress, blob_fetcher);
|
||||
|
||||
if (!found_final_value && merge_in_progress) {
|
||||
if (iter->s->ok()) {
|
||||
|
||||
+37
-15
@@ -39,6 +39,7 @@
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
class BlobFetcher;
|
||||
class BlobFilePartitionManager;
|
||||
struct FlushJobInfo;
|
||||
class Mutex;
|
||||
@@ -236,25 +237,27 @@ class ReadOnlyMemTable {
|
||||
SequenceNumber* max_covering_tombstone_seq,
|
||||
SequenceNumber* seq, const ReadOptions& read_opts,
|
||||
bool immutable_memtable, ReadCallback* callback = nullptr,
|
||||
bool* is_blob_index = nullptr, bool do_merge = true) = 0;
|
||||
bool* is_blob_index = nullptr, bool do_merge = true,
|
||||
const BlobFetcher* blob_fetcher = nullptr) = 0;
|
||||
bool Get(const LookupKey& key, std::string* value,
|
||||
PinnableWideColumns* columns, std::string* timestamp, Status* s,
|
||||
MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq,
|
||||
const ReadOptions& read_opts, bool immutable_memtable,
|
||||
ReadCallback* callback = nullptr, bool* is_blob_index = nullptr,
|
||||
bool do_merge = true) {
|
||||
bool do_merge = true, const BlobFetcher* blob_fetcher = nullptr) {
|
||||
SequenceNumber seq;
|
||||
return Get(key, value, columns, timestamp, s, merge_context,
|
||||
max_covering_tombstone_seq, &seq, read_opts, immutable_memtable,
|
||||
callback, is_blob_index, do_merge);
|
||||
callback, is_blob_index, do_merge, blob_fetcher);
|
||||
}
|
||||
|
||||
// @param immutable_memtable Whether this memtable is immutable. Used
|
||||
// internally by NewRangeTombstoneIterator(). See comment above
|
||||
// NewRangeTombstoneIterator() for more detail.
|
||||
virtual void MultiGet(const ReadOptions& read_options, MultiGetRange* range,
|
||||
ReadCallback* callback, bool immutable_memtable) = 0;
|
||||
ReadCallback* callback, bool immutable_memtable,
|
||||
const BlobFetcher* blob_fetcher = nullptr) = 0;
|
||||
|
||||
// Get total number of entries in the mem table.
|
||||
// REQUIRES: external synchronization to prevent simultaneous
|
||||
@@ -320,7 +323,7 @@ class ReadOnlyMemTable {
|
||||
virtual uint64_t ApproximateOldestKeyTime() const = 0;
|
||||
|
||||
// Inserts a range tombstone [start_key, end_key) that is logically redundant
|
||||
// — it is derived from existing point tombstones observed during iteration
|
||||
// -- it is derived from existing point tombstones observed during iteration
|
||||
// and does not delete any data that isn't already deleted. This is a
|
||||
// best-effort optimization. It allows future reads to skip iterating over
|
||||
// continuous single deletion tombstones.
|
||||
@@ -328,11 +331,13 @@ class ReadOnlyMemTable {
|
||||
// Adding a range tombstone may fail if
|
||||
// - memtable switches to immutable state
|
||||
// - a range tombstone with the same key+seq already exists (duplicate insert)
|
||||
//
|
||||
// - the per-memtable ingest seqno barrier already exceeds `seq` (an
|
||||
// ingestion has committed an L0 file at a seq that this converted
|
||||
// tombstone would shadow) or an ingestion is in progress.
|
||||
// Returns true if the range tombstone was inserted, false if skipped.
|
||||
virtual bool AddLogicallyRedundantRangeTombstone(SequenceNumber /*seq*/,
|
||||
const Slice& /*start_key*/,
|
||||
const Slice& /*end_key*/) {
|
||||
virtual bool AddLogicallyRedundantRangeTombstone(
|
||||
SequenceNumber /*seq*/, const Slice& /*start_key*/,
|
||||
const Slice& /*end_key*/, port::RWMutex& /*ingest_sst_lock*/) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -686,10 +691,12 @@ class MemTable final : public ReadOnlyMemTable {
|
||||
SequenceNumber* max_covering_tombstone_seq, SequenceNumber* seq,
|
||||
const ReadOptions& read_opts, bool immutable_memtable,
|
||||
ReadCallback* callback = nullptr, bool* is_blob_index = nullptr,
|
||||
bool do_merge = true) override;
|
||||
bool do_merge = true,
|
||||
const BlobFetcher* blob_fetcher = nullptr) override;
|
||||
|
||||
void MultiGet(const ReadOptions& read_options, MultiGetRange* range,
|
||||
ReadCallback* callback, bool immutable_memtable) override;
|
||||
ReadCallback* callback, bool immutable_memtable,
|
||||
const BlobFetcher* blob_fetcher = nullptr) override;
|
||||
|
||||
// If `key` exists in current memtable with type value_type and the existing
|
||||
// value is at least as large as the new value, updates it in-place. Otherwise
|
||||
@@ -858,9 +865,19 @@ class MemTable final : public ReadOnlyMemTable {
|
||||
// SwitchMemtable() may fail.
|
||||
void ConstructFragmentedRangeTombstones();
|
||||
|
||||
bool AddLogicallyRedundantRangeTombstone(SequenceNumber seq,
|
||||
const Slice& start_key,
|
||||
const Slice& end_key) override;
|
||||
bool AddLogicallyRedundantRangeTombstone(
|
||||
SequenceNumber seq, const Slice& start_key, const Slice& end_key,
|
||||
port::RWMutex& ingest_sst_lock) override;
|
||||
|
||||
// Monotonically raises ingest_seqno_barrier_ to `y` (no-op if `y` is not
|
||||
// greater than the current value). The conversion's barrier check
|
||||
// (`seq < ingest_seqno_barrier_.LoadRelaxed()`) refuses converted
|
||||
// range tombstones that would shadow a just-installed L0 file.
|
||||
//
|
||||
// REQUIRES: DB mutex held by the caller. The DB mutex serializes all
|
||||
// callers, so the load-then-store pattern is race-free without needing
|
||||
// a CAS loop. Only IngestExternalFiles calls this.
|
||||
void BumpIngestSeqnoBarrier(SequenceNumber y);
|
||||
|
||||
bool IsFragmentedRangeTombstonesConstructed() const override {
|
||||
return fragmented_range_tombstone_list_.get() != nullptr ||
|
||||
@@ -922,6 +939,10 @@ class MemTable final : public ReadOnlyMemTable {
|
||||
// if not set.
|
||||
std::atomic<SequenceNumber> earliest_seqno_;
|
||||
|
||||
// Seqno of the latest ingested external SST. See also
|
||||
// ColumnFamilyData::ingest_sst_lock_.
|
||||
RelaxedAtomic<SequenceNumber> ingest_seqno_barrier_{0};
|
||||
|
||||
SequenceNumber creation_seq_;
|
||||
|
||||
// the earliest log containing a prepared section
|
||||
@@ -975,7 +996,8 @@ class MemTable final : public ReadOnlyMemTable {
|
||||
std::string* value, PinnableWideColumns* columns,
|
||||
std::string* timestamp, Status* s,
|
||||
MergeContext* merge_context, SequenceNumber* seq,
|
||||
bool* found_final_value, bool* merge_in_progress);
|
||||
bool* found_final_value, bool* merge_in_progress,
|
||||
const BlobFetcher* blob_fetcher);
|
||||
|
||||
// Always returns non-null and assumes certain pre-checks (e.g.,
|
||||
// is_range_del_table_empty_) are done. This is only valid during the lifetime
|
||||
|
||||
+18
-14
@@ -108,18 +108,19 @@ bool MemTableListVersion::Get(const LookupKey& key, std::string* value,
|
||||
MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq,
|
||||
SequenceNumber* seq, const ReadOptions& read_opts,
|
||||
ReadCallback* callback, bool* is_blob_index) {
|
||||
ReadCallback* callback, bool* is_blob_index,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
return GetFromList(&memlist_, key, value, columns, timestamp, s,
|
||||
merge_context, max_covering_tombstone_seq, seq, read_opts,
|
||||
callback, is_blob_index);
|
||||
callback, is_blob_index, blob_fetcher);
|
||||
}
|
||||
|
||||
void MemTableListVersion::MultiGet(const ReadOptions& read_options,
|
||||
MultiGetRange* range,
|
||||
ReadCallback* callback) {
|
||||
MultiGetRange* range, ReadCallback* callback,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
for (auto memtable : memlist_) {
|
||||
memtable->MultiGet(read_options, range, callback,
|
||||
true /* immutable_memtable */);
|
||||
true /* immutable_memtable */, blob_fetcher);
|
||||
if (range->empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -128,12 +129,13 @@ void MemTableListVersion::MultiGet(const ReadOptions& read_options,
|
||||
|
||||
bool MemTableListVersion::GetMergeOperands(
|
||||
const LookupKey& key, Status* s, MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq, const ReadOptions& read_opts) {
|
||||
SequenceNumber* max_covering_tombstone_seq, const ReadOptions& read_opts,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
for (ReadOnlyMemTable* memtable : memlist_) {
|
||||
bool done = memtable->Get(
|
||||
key, /*value=*/nullptr, /*columns=*/nullptr, /*timestamp=*/nullptr, s,
|
||||
merge_context, max_covering_tombstone_seq, read_opts,
|
||||
true /* immutable_memtable */, nullptr, nullptr, false);
|
||||
true /* immutable_memtable */, nullptr, nullptr, false, blob_fetcher);
|
||||
if (done) {
|
||||
return true;
|
||||
}
|
||||
@@ -145,10 +147,11 @@ bool MemTableListVersion::GetFromHistory(
|
||||
const LookupKey& key, std::string* value, PinnableWideColumns* columns,
|
||||
std::string* timestamp, Status* s, MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq, SequenceNumber* seq,
|
||||
const ReadOptions& read_opts, bool* is_blob_index) {
|
||||
const ReadOptions& read_opts, bool* is_blob_index,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
return GetFromList(&memlist_history_, key, value, columns, timestamp, s,
|
||||
merge_context, max_covering_tombstone_seq, seq, read_opts,
|
||||
nullptr /*read_callback*/, is_blob_index);
|
||||
nullptr /*read_callback*/, is_blob_index, blob_fetcher);
|
||||
}
|
||||
|
||||
bool MemTableListVersion::GetFromList(
|
||||
@@ -156,17 +159,18 @@ bool MemTableListVersion::GetFromList(
|
||||
std::string* value, PinnableWideColumns* columns, std::string* timestamp,
|
||||
Status* s, MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq, SequenceNumber* seq,
|
||||
const ReadOptions& read_opts, ReadCallback* callback, bool* is_blob_index) {
|
||||
const ReadOptions& read_opts, ReadCallback* callback, bool* is_blob_index,
|
||||
const BlobFetcher* blob_fetcher) {
|
||||
*seq = kMaxSequenceNumber;
|
||||
|
||||
for (auto& memtable : *list) {
|
||||
assert(memtable->IsFragmentedRangeTombstonesConstructed());
|
||||
SequenceNumber current_seq = kMaxSequenceNumber;
|
||||
|
||||
bool done =
|
||||
memtable->Get(key, value, columns, timestamp, s, merge_context,
|
||||
max_covering_tombstone_seq, ¤t_seq, read_opts,
|
||||
true /* immutable_memtable */, callback, is_blob_index);
|
||||
bool done = memtable->Get(key, value, columns, timestamp, s, merge_context,
|
||||
max_covering_tombstone_seq, ¤t_seq,
|
||||
read_opts, true /* immutable_memtable */,
|
||||
callback, is_blob_index, true, blob_fetcher);
|
||||
if (*seq == kMaxSequenceNumber) {
|
||||
// Store the most recent sequence number of any operation on this key.
|
||||
// Since we only care about the most recent change, we only need to
|
||||
|
||||
+16
-9
@@ -61,29 +61,33 @@ class MemTableListVersion {
|
||||
MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq, SequenceNumber* seq,
|
||||
const ReadOptions& read_opts, ReadCallback* callback = nullptr,
|
||||
bool* is_blob_index = nullptr);
|
||||
bool* is_blob_index = nullptr,
|
||||
const BlobFetcher* blob_fetcher = nullptr);
|
||||
|
||||
bool Get(const LookupKey& key, std::string* value,
|
||||
PinnableWideColumns* columns, std::string* timestamp, Status* s,
|
||||
MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq,
|
||||
const ReadOptions& read_opts, ReadCallback* callback = nullptr,
|
||||
bool* is_blob_index = nullptr) {
|
||||
bool* is_blob_index = nullptr,
|
||||
const BlobFetcher* blob_fetcher = nullptr) {
|
||||
SequenceNumber seq;
|
||||
return Get(key, value, columns, timestamp, s, merge_context,
|
||||
max_covering_tombstone_seq, &seq, read_opts, callback,
|
||||
is_blob_index);
|
||||
is_blob_index, blob_fetcher);
|
||||
}
|
||||
|
||||
void MultiGet(const ReadOptions& read_options, MultiGetRange* range,
|
||||
ReadCallback* callback);
|
||||
ReadCallback* callback,
|
||||
const BlobFetcher* blob_fetcher = nullptr);
|
||||
|
||||
// Returns all the merge operands corresponding to the key by searching all
|
||||
// memtables starting from the most recent one.
|
||||
bool GetMergeOperands(const LookupKey& key, Status* s,
|
||||
MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq,
|
||||
const ReadOptions& read_opts);
|
||||
const ReadOptions& read_opts,
|
||||
const BlobFetcher* blob_fetcher = nullptr);
|
||||
|
||||
// Similar to Get(), but searches the Memtable history of memtables that
|
||||
// have already been flushed. Should only be used from in-memory only
|
||||
@@ -94,17 +98,19 @@ class MemTableListVersion {
|
||||
Status* s, MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq,
|
||||
SequenceNumber* seq, const ReadOptions& read_opts,
|
||||
bool* is_blob_index = nullptr);
|
||||
bool* is_blob_index = nullptr,
|
||||
const BlobFetcher* blob_fetcher = nullptr);
|
||||
bool GetFromHistory(const LookupKey& key, std::string* value,
|
||||
PinnableWideColumns* columns, std::string* timestamp,
|
||||
Status* s, MergeContext* merge_context,
|
||||
SequenceNumber* max_covering_tombstone_seq,
|
||||
const ReadOptions& read_opts,
|
||||
bool* is_blob_index = nullptr) {
|
||||
bool* is_blob_index = nullptr,
|
||||
const BlobFetcher* blob_fetcher = nullptr) {
|
||||
SequenceNumber seq;
|
||||
return GetFromHistory(key, value, columns, timestamp, s, merge_context,
|
||||
max_covering_tombstone_seq, &seq, read_opts,
|
||||
is_blob_index);
|
||||
is_blob_index, blob_fetcher);
|
||||
}
|
||||
|
||||
Status AddRangeTombstoneIterators(const ReadOptions& read_opts, Arena* arena,
|
||||
@@ -188,7 +194,8 @@ class MemTableListVersion {
|
||||
SequenceNumber* max_covering_tombstone_seq,
|
||||
SequenceNumber* seq, const ReadOptions& read_opts,
|
||||
ReadCallback* callback = nullptr,
|
||||
bool* is_blob_index = nullptr);
|
||||
bool* is_blob_index = nullptr,
|
||||
const BlobFetcher* blob_fetcher = nullptr);
|
||||
|
||||
void AddMemTable(ReadOnlyMemTable* m);
|
||||
|
||||
|
||||
+10
-3
@@ -69,13 +69,14 @@ TableCache::TableCache(const ImmutableOptions& ioptions,
|
||||
const FileOptions* file_options, Cache* const cache,
|
||||
BlockCacheTracer* const block_cache_tracer,
|
||||
const std::shared_ptr<IOTracer>& io_tracer,
|
||||
const std::string& db_session_id)
|
||||
const std::string& db_session_id, bool fast_sst_open)
|
||||
: ioptions_(ioptions),
|
||||
file_options_(*file_options),
|
||||
cache_(cache),
|
||||
immortal_tables_(false),
|
||||
should_pin_table_handles_(cache_.get()->GetCapacity() >=
|
||||
kInfiniteCapacity),
|
||||
fast_sst_open_(fast_sst_open),
|
||||
block_cache_tracer_(block_cache_tracer),
|
||||
loader_mutex_(kLoadConcurency),
|
||||
io_tracer_(io_tracer),
|
||||
@@ -107,8 +108,11 @@ Status TableCache::GetTableReader(
|
||||
fopts.file_checksum_func_name = file_meta.file_checksum_func_name;
|
||||
// Pass file open metadata for fast SST open. Use a local copy since
|
||||
// fopts.file_metadata is a non-owning pointer and file_meta is const.
|
||||
// Only pass metadata when fast_sst_open is enabled; otherwise ignore
|
||||
// previously persisted metadata (e.g. stale filesystem credentials).
|
||||
std::string file_open_metadata_copy;
|
||||
if (!file_meta.file_open_metadata.empty()) {
|
||||
if (fast_sst_open_.load(std::memory_order_relaxed) &&
|
||||
!file_meta.file_open_metadata.empty()) {
|
||||
file_open_metadata_copy = file_meta.file_open_metadata;
|
||||
fopts.file_metadata = &file_open_metadata_copy;
|
||||
RecordTick(ioptions_.stats, FILE_OPEN_METADATA_PASSED);
|
||||
@@ -773,7 +777,10 @@ void TableCache::ReleaseObsolete(Cache* cache, uint64_t file_number,
|
||||
if (table_handle != nullptr) {
|
||||
TableReader* table_reader = typed_cache.Value(table_handle);
|
||||
table_reader->MarkObsolete(uncache_aggressiveness);
|
||||
typed_cache.ReleaseAndEraseIfLastRef(table_handle);
|
||||
// Mark the entry Invisible so that if concurrent readers hold references,
|
||||
// the entry will be erased when the last reference is released.
|
||||
cache->Erase(GetSliceForFileNumber(&file_number));
|
||||
typed_cache.Release(table_handle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -54,7 +54,7 @@ class TableCache {
|
||||
const FileOptions* storage_options, Cache* cache,
|
||||
BlockCacheTracer* const block_cache_tracer,
|
||||
const std::shared_ptr<IOTracer>& io_tracer,
|
||||
const std::string& db_session_id);
|
||||
const std::string& db_session_id, bool fast_sst_open = false);
|
||||
~TableCache();
|
||||
|
||||
// Cache interface for table cache
|
||||
@@ -270,6 +270,10 @@ class TableCache {
|
||||
cache_.get()->GetCapacity() >= kInfiniteCapacity;
|
||||
}
|
||||
|
||||
void SetFastSstOpen(bool enabled) {
|
||||
fast_sst_open_.store(enabled, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
private:
|
||||
// Build a table reader
|
||||
Status GetTableReader(const ReadOptions& ro, const FileOptions& file_options,
|
||||
@@ -312,6 +316,7 @@ class TableCache {
|
||||
std::string row_cache_id_;
|
||||
bool immortal_tables_;
|
||||
bool should_pin_table_handles_;
|
||||
std::atomic<bool> fast_sst_open_;
|
||||
BlockCacheTracer* const block_cache_tracer_;
|
||||
Striped<CacheAlignedWrapper<port::Mutex>> loader_mutex_;
|
||||
std::shared_ptr<IOTracer> io_tracer_;
|
||||
|
||||
+12
-1
@@ -30,7 +30,7 @@ PinnedTableReader& PinnedTableReader::operator=(
|
||||
TableReader* r = other.reader_.load(std::memory_order_acquire);
|
||||
// Only read handle_ when reader_ is non-null. Pin() writes handle_ before
|
||||
// reader_ (with release), so a non-null reader_ guarantees handle_ is stable.
|
||||
// If reader_ is null, Pin() may be in progress — avoid reading handle_.
|
||||
// If reader_ is null, Pin() may be in progress -- avoid reading handle_.
|
||||
handle_ = (r != nullptr) ? other.handle_ : nullptr;
|
||||
reader_.store(r, std::memory_order_release);
|
||||
return *this;
|
||||
@@ -109,6 +109,17 @@ Status FileMetaData::UpdateBoundaries(const Slice& key, const Slice& value,
|
||||
|
||||
void VersionEdit::Clear() { *this = VersionEdit(); }
|
||||
|
||||
bool VersionEdit::ShouldEmitPerColumnFamilyRecoveryEdit(
|
||||
uint64_t current_log_number) const {
|
||||
return (HasLogNumber() && GetLogNumber() > current_log_number) ||
|
||||
NumEntries() > 0 || HasComparatorName() || HasPrevLogNumber() ||
|
||||
HasNextFile() || HasMaxColumnFamily() || HasMinLogNumberToKeep() ||
|
||||
HasLastSequence() || !GetCompactCursors().empty() || HasDbId() ||
|
||||
IsColumnFamilyManipulation() || IsInAtomicGroup() ||
|
||||
HasFullHistoryTsLow() || HasPersistUserDefinedTimestamps() ||
|
||||
HasSubcompactionProgress();
|
||||
}
|
||||
|
||||
bool VersionEdit::EncodeTo(std::string* dst,
|
||||
std::optional<size_t> ts_sz) const {
|
||||
assert(!IsNoManifestWriteDummy());
|
||||
|
||||
@@ -1016,6 +1016,10 @@ class VersionEdit {
|
||||
subcompaction_progress_.Clear();
|
||||
}
|
||||
|
||||
// Recovery-time per-column-family edits only need to be written when they
|
||||
// advance the CF's log number or carry some other manifest state.
|
||||
bool ShouldEmitPerColumnFamilyRecoveryEdit(uint64_t current_log_number) const;
|
||||
|
||||
// return true on success.
|
||||
// `ts_sz` is the size in bytes for the user-defined timestamp contained in
|
||||
// a user key. This argument is optional because it's only required for
|
||||
|
||||
@@ -35,6 +35,7 @@ void VersionEditHandlerBase::Iterate(log::Reader& reader,
|
||||
VersionEdit edit;
|
||||
s = edit.DecodeFrom(record);
|
||||
if (s.ok()) {
|
||||
last_valid_record_end_ = reader.LastRecordEnd();
|
||||
s = read_buffer_.AddEdit(&edit);
|
||||
}
|
||||
if (s.ok()) {
|
||||
@@ -460,6 +461,7 @@ void VersionEditHandler::CheckIterationResult(const log::Reader& reader,
|
||||
if (s->ok()) {
|
||||
version_set_->manifest_file_size_ = reader.GetReadOffset();
|
||||
assert(version_set_->manifest_file_size_ > 0);
|
||||
version_set_->manifest_last_valid_record_end_ = last_valid_record_end_;
|
||||
version_set_->next_file_number_.store(version_edit_params_.GetNextFile() +
|
||||
1);
|
||||
SequenceNumber last_seq = version_edit_params_.GetLastSequence();
|
||||
|
||||
@@ -31,6 +31,8 @@ class VersionEditHandlerBase {
|
||||
|
||||
AtomicGroupReadBuffer& GetReadBuffer() { return read_buffer_; }
|
||||
|
||||
uint64_t GetLastValidRecordEnd() const { return last_valid_record_end_; }
|
||||
|
||||
protected:
|
||||
explicit VersionEditHandlerBase(const ReadOptions& read_options,
|
||||
uint64_t max_read_size)
|
||||
@@ -52,6 +54,10 @@ class VersionEditHandlerBase {
|
||||
|
||||
const ReadOptions& read_options_;
|
||||
|
||||
// File offset at the end of the last successfully completed and
|
||||
// decoded logical record.
|
||||
uint64_t last_valid_record_end_ = 0;
|
||||
|
||||
private:
|
||||
AtomicGroupReadBuffer read_buffer_;
|
||||
const uint64_t max_manifest_read_size_;
|
||||
|
||||
+129
-26
@@ -2243,8 +2243,10 @@ void Version::GetCreationTimeOfOldestFile(uint64_t* creation_time) {
|
||||
uint64_t oldest_time = std::numeric_limits<uint64_t>::max();
|
||||
for (int level = 0; level < storage_info_.num_non_empty_levels_; level++) {
|
||||
for (FileMetaData* meta : storage_info_.LevelFiles(level)) {
|
||||
assert(meta->fd.pinned_reader.Get() != nullptr);
|
||||
uint64_t file_creation_time = meta->TryGetFileCreationTime();
|
||||
TEST_SYNC_POINT_CALLBACK(
|
||||
"Version::GetCreationTimeOfOldestFile::FileCreationTime",
|
||||
&file_creation_time);
|
||||
if (file_creation_time == kUnknownFileCreationTime) {
|
||||
*creation_time = 0;
|
||||
return;
|
||||
@@ -4217,10 +4219,10 @@ void VersionStorageInfo::ComputeFilesMarkedForReadTriggeredCompaction(
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip files at the last non-empty level — there is no lower level to
|
||||
// Skip files at the last non-empty level -- there is no lower level to
|
||||
// compact into. Exception: L0 files are allowed even when L0 is the last
|
||||
// non-empty level, because in single-level universal or FIFO compaction, L0
|
||||
// files can be compacted together (L0 → L0).
|
||||
// files can be compacted together (L0 -> L0).
|
||||
int last_non_empty = num_non_empty_levels_ - 1;
|
||||
|
||||
for (int level = 0; level < num_levels(); level++) {
|
||||
@@ -5599,7 +5601,7 @@ VersionSet::VersionSet(
|
||||
: column_family_set_(new ColumnFamilySet(
|
||||
dbname, _db_options, storage_options, table_cache,
|
||||
write_buffer_manager, write_controller, block_cache_tracer, io_tracer,
|
||||
db_id, db_session_id)),
|
||||
db_id, db_session_id, mutable_db_options.fast_sst_open)),
|
||||
table_cache_(table_cache),
|
||||
env_(_db_options->env),
|
||||
fs_(_db_options->fs, io_tracer),
|
||||
@@ -5618,6 +5620,7 @@ VersionSet::VersionSet(
|
||||
prev_log_number_(0),
|
||||
current_version_number_(0),
|
||||
manifest_file_size_(0),
|
||||
manifest_last_valid_record_end_(0),
|
||||
last_compacted_manifest_file_size_(0),
|
||||
file_options_(storage_options),
|
||||
block_cache_tracer_(block_cache_tracer),
|
||||
@@ -5689,7 +5692,7 @@ Status VersionSet::Close(FSDirectory* db_dir, InstrumentedMutex* mu) {
|
||||
content_manifest_name, fs_->OptimizeForManifestRead(file_options_),
|
||||
&manifest_file, nullptr);
|
||||
if (!content_io_s.ok()) {
|
||||
// Surface I/O errors to the caller — users who call DB::Close() and
|
||||
// Surface I/O errors to the caller -- users who call DB::Close() and
|
||||
// check the status should know about filesystem problems.
|
||||
s = content_io_s;
|
||||
ROCKS_LOG_ERROR(db_options_->info_log,
|
||||
@@ -5728,6 +5731,8 @@ Status VersionSet::Close(FSDirectory* db_dir, InstrumentedMutex* mu) {
|
||||
// Manifest is healthy, no need to check again
|
||||
break;
|
||||
}
|
||||
RecordTick(db_options_->statistics.get(),
|
||||
MANIFEST_VALIDATION_FAILURE_COUNT);
|
||||
IOStatus corrupt_io_s =
|
||||
IOStatus::Corruption("MANIFEST content validation failed");
|
||||
IOErrorInfo io_error_info(corrupt_io_s, FileOperationType::kVerify,
|
||||
@@ -5739,7 +5744,7 @@ Status VersionSet::Close(FSDirectory* db_dir, InstrumentedMutex* mu) {
|
||||
corrupt_io_s.PermitUncheckedError();
|
||||
io_error_info.io_status.PermitUncheckedError();
|
||||
if (content_check == 0) {
|
||||
// First check failed — rewrite and verify again
|
||||
// First check failed -- rewrite and verify again
|
||||
ROCKS_LOG_ERROR(db_options_->info_log,
|
||||
"MANIFEST content verification on Close failed, "
|
||||
"filename %s, rewriting manifest\n",
|
||||
@@ -5750,7 +5755,7 @@ Status VersionSet::Close(FSDirectory* db_dir, InstrumentedMutex* mu) {
|
||||
s = LogAndApply(cfd, ReadOptions(), WriteOptions(), &recovery_edit, mu,
|
||||
db_dir);
|
||||
} else {
|
||||
// Rewritten manifest is also corrupt — likely a recurring filesystem
|
||||
// Rewritten manifest is also corrupt -- likely a recurring filesystem
|
||||
// issue. Surface it so DB::Close() callers can detect the problem.
|
||||
ROCKS_LOG_ERROR(db_options_->info_log,
|
||||
"MANIFEST content verification on Close failed again "
|
||||
@@ -5808,7 +5813,8 @@ void VersionSet::Reset() {
|
||||
// options.write_dbid_to_manifest is false (default).
|
||||
column_family_set_.reset(new ColumnFamilySet(
|
||||
dbname_, db_options_, file_options_, table_cache_, wbm, wc,
|
||||
block_cache_tracer_, io_tracer_, db_id_, db_session_id_));
|
||||
block_cache_tracer_, io_tracer_, db_id_, db_session_id_,
|
||||
column_family_set_->GetFastSstOpen()));
|
||||
}
|
||||
db_id_.clear();
|
||||
next_file_number_.store(2);
|
||||
@@ -5824,6 +5830,7 @@ void VersionSet::Reset() {
|
||||
current_version_number_ = 0;
|
||||
manifest_writers_.clear();
|
||||
manifest_file_size_ = 0;
|
||||
manifest_last_valid_record_end_ = 0;
|
||||
last_compacted_manifest_file_size_ = 0;
|
||||
TuneMaxManifestFileSize();
|
||||
obsolete_files_.clear();
|
||||
@@ -6158,11 +6165,7 @@ Status VersionSet::ProcessManifestWrites(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FileOptions opt_file_opts = fs_->OptimizeForManifestWrite(file_options_);
|
||||
// DB option (in file_options_) takes precedence when not kUnknown
|
||||
if (file_options_.temperature != Temperature::kUnknown) {
|
||||
opt_file_opts.temperature = file_options_.temperature;
|
||||
}
|
||||
FileOptions opt_file_opts = GetFileOptionsForManifestWrite();
|
||||
mu->Unlock();
|
||||
TEST_SYNC_POINT("VersionSet::LogAndApply:WriteManifestStart");
|
||||
TEST_SYNC_POINT_CALLBACK("VersionSet::LogAndApply:WriteManifest", nullptr);
|
||||
@@ -6197,16 +6200,9 @@ Status VersionSet::ProcessManifestWrites(
|
||||
io_s = NewWritableFile(fs_.get(), descriptor_fname, &descriptor_file,
|
||||
opt_file_opts);
|
||||
if (io_s.ok()) {
|
||||
descriptor_file->SetPreallocationBlockSize(manifest_preallocation_size);
|
||||
FileTypeSet tmp_set = db_options_->checksum_handoff_file_types;
|
||||
std::unique_ptr<WritableFileWriter> file_writer(new WritableFileWriter(
|
||||
std::move(descriptor_file), descriptor_fname, opt_file_opts, clock_,
|
||||
io_tracer_, nullptr, Histograms::HISTOGRAM_ENUM_MAX /* hist_type */,
|
||||
db_options_->listeners, nullptr,
|
||||
tmp_set.Contains(FileType::kDescriptorFile),
|
||||
tmp_set.Contains(FileType::kDescriptorFile)));
|
||||
new_desc_log_ptr.reset(
|
||||
new log::Writer(std::move(file_writer), 0, false));
|
||||
new_desc_log_ptr =
|
||||
CreateManifestWriter(std::move(descriptor_file), descriptor_fname,
|
||||
opt_file_opts, manifest_preallocation_size);
|
||||
raw_desc_log_ptr = new_desc_log_ptr.get();
|
||||
s = WriteCurrentStateToManifest(write_options, curr_state,
|
||||
wal_additions, raw_desc_log_ptr, io_s);
|
||||
@@ -6256,6 +6252,7 @@ Status VersionSet::ProcessManifestWrites(
|
||||
}
|
||||
++idx;
|
||||
#endif /* !NDEBUG */
|
||||
TEST_SYNC_POINT("VersionSet::ProcessManifestWrites:AddRecord");
|
||||
io_s = raw_desc_log_ptr->AddRecord(write_options, record);
|
||||
if (!io_s.ok()) {
|
||||
s = io_s;
|
||||
@@ -6677,6 +6674,106 @@ Status VersionSet::LogAndApplyHelper(ColumnFamilyData* cfd,
|
||||
return builder ? builder->Apply(edit) : Status::OK();
|
||||
}
|
||||
|
||||
FileOptions VersionSet::GetFileOptionsForManifestWrite() const {
|
||||
FileOptions opt_file_opts = fs_->OptimizeForManifestWrite(file_options_);
|
||||
// DB-configured temperature wins over the FS's default (when not kUnknown).
|
||||
if (file_options_.temperature != Temperature::kUnknown) {
|
||||
opt_file_opts.temperature = file_options_.temperature;
|
||||
}
|
||||
return opt_file_opts;
|
||||
}
|
||||
|
||||
std::unique_ptr<log::Writer> VersionSet::CreateManifestWriter(
|
||||
std::unique_ptr<FSWritableFile> file, const std::string& fname,
|
||||
const FileOptions& opts, uint64_t preallocation_size,
|
||||
uint64_t initial_file_size) const {
|
||||
file->SetPreallocationBlockSize(preallocation_size);
|
||||
FileTypeSet tmp_set = db_options_->checksum_handoff_file_types;
|
||||
auto file_writer = std::make_unique<WritableFileWriter>(
|
||||
std::move(file), fname, opts, clock_, io_tracer_,
|
||||
/*stats=*/nullptr, Histograms::HISTOGRAM_ENUM_MAX, db_options_->listeners,
|
||||
/*file_checksum_gen_factory=*/nullptr,
|
||||
tmp_set.Contains(FileType::kDescriptorFile),
|
||||
tmp_set.Contains(FileType::kDescriptorFile), initial_file_size);
|
||||
const size_t block_offset = initial_file_size % log::kBlockSize;
|
||||
return std::make_unique<log::Writer>(
|
||||
std::move(file_writer), /*log_number=*/0, /*recycle_log_files=*/false,
|
||||
/*manual_flush=*/false, kNoCompression,
|
||||
/*track_and_verify_wals=*/false, block_offset);
|
||||
}
|
||||
|
||||
Status VersionSet::ReopenManifestForAppend(const std::string& manifest_path) {
|
||||
assert(db_options_->reuse_manifest_on_open);
|
||||
assert(manifest_last_valid_record_end_ > 0);
|
||||
|
||||
// Disabled under best_efforts_recovery: that mode rebuilds the
|
||||
// MANIFEST + CURRENT from scratch via the side-effect of
|
||||
// LogAndApplyForRecovery emitting an edit. Reusing the (possibly
|
||||
// stale) prior MANIFEST contradicts the salvage contract.
|
||||
if (db_options_->best_efforts_recovery) {
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
FileOptions opt_file_opts = GetFileOptionsForManifestWrite();
|
||||
|
||||
// Bail if the on-disk size diverges from what Recover's Reader
|
||||
// consumed -- likely a torn tail from a prior crash; mid-block append
|
||||
// would mis-frame the record stream.
|
||||
uint64_t physical_size = 0;
|
||||
IOStatus stat_s = fs_->GetFileSize(manifest_path, IOOptions(), &physical_size,
|
||||
/*dbg=*/nullptr);
|
||||
if (!stat_s.ok() || physical_size != manifest_last_valid_record_end_) {
|
||||
ROCKS_LOG_WARN(db_options_->info_log,
|
||||
"reuse_manifest_on_open: physical size %" PRIu64
|
||||
" != last valid record end %" PRIu64
|
||||
" (tail corruption?); falling back to fresh MANIFEST",
|
||||
physical_size, manifest_last_valid_record_end_);
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
// Direct-writes path is hard to reason about for an already-populated
|
||||
// file (alignment + tail-pad). POSIX's OptimizeForManifestWrite forces
|
||||
// it off, but custom FileSystems may not.
|
||||
if (opt_file_opts.use_direct_writes) {
|
||||
ROCKS_LOG_WARN(db_options_->info_log,
|
||||
"reuse_manifest_on_open: direct writes enabled; "
|
||||
"falling back to fresh MANIFEST");
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
std::unique_ptr<FSWritableFile> manifest_file;
|
||||
IOStatus io_s = fs_->ReopenWritableFile(manifest_path, opt_file_opts,
|
||||
&manifest_file, /*dbg=*/nullptr);
|
||||
if (!io_s.ok()) {
|
||||
ROCKS_LOG_WARN(db_options_->info_log,
|
||||
"Failed to reopen MANIFEST for append: %s",
|
||||
io_s.ToString().c_str());
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
const uint64_t reopened_size =
|
||||
manifest_file->GetFileSize(opt_file_opts.io_options, /*dbg=*/nullptr);
|
||||
if (reopened_size != manifest_last_valid_record_end_) {
|
||||
ROCKS_LOG_WARN(db_options_->info_log,
|
||||
"reuse_manifest_on_open: reopened handle size %" PRIu64
|
||||
" != last valid record end %" PRIu64
|
||||
"; falling back to fresh MANIFEST",
|
||||
reopened_size, manifest_last_valid_record_end_);
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
descriptor_log_ = CreateManifestWriter(
|
||||
std::move(manifest_file), manifest_path, opt_file_opts,
|
||||
manifest_preallocation_size_, reopened_size);
|
||||
|
||||
ROCKS_LOG_INFO(db_options_->info_log,
|
||||
"Reusing existing MANIFEST file: %s (valid data size: %" PRIu64
|
||||
")",
|
||||
manifest_path.c_str(), manifest_last_valid_record_end_);
|
||||
TEST_SYNC_POINT("VersionSet::ReopenManifestForAppend:Reopened");
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
Status VersionSet::Recover(
|
||||
const std::vector<ColumnFamilyDescriptor>& column_families, bool read_only,
|
||||
std::string* db_id, bool no_error_if_files_missing, bool is_retry,
|
||||
@@ -6764,6 +6861,11 @@ Status VersionSet::Recover(
|
||||
}
|
||||
}
|
||||
|
||||
if (s.ok() && !read_only && db_options_->reuse_manifest_on_open &&
|
||||
manifest_last_valid_record_end_ > 0) {
|
||||
s = ReopenManifestForAppend(manifest_path);
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -8061,11 +8163,12 @@ ReactiveVersionSet::ReactiveVersionSet(
|
||||
const MutableDBOptions& mutable_db_options,
|
||||
const FileOptions& _file_options, Cache* table_cache,
|
||||
WriteBufferManager* write_buffer_manager, WriteController* write_controller,
|
||||
const std::shared_ptr<IOTracer>& io_tracer)
|
||||
const std::shared_ptr<IOTracer>& io_tracer, const std::string& db_id,
|
||||
const std::string& db_session_id)
|
||||
: VersionSet(dbname, imm_db_options, mutable_db_options, _file_options,
|
||||
table_cache, write_buffer_manager, write_controller,
|
||||
/*block_cache_tracer=*/nullptr, io_tracer, /*db_id*/ "",
|
||||
/*db_session_id*/ "", /*daily_offpeak_time_utc*/ "",
|
||||
/*block_cache_tracer=*/nullptr, io_tracer, db_id,
|
||||
db_session_id, /*daily_offpeak_time_utc*/ "",
|
||||
/*error_handler=*/nullptr, /*unchanging=*/false) {}
|
||||
|
||||
ReactiveVersionSet::~ReactiveVersionSet() = default;
|
||||
|
||||
+41
-1
@@ -1719,6 +1719,33 @@ class VersionSet {
|
||||
const std::unordered_map<uint32_t, MutableCFState>& curr_state,
|
||||
const VersionEdit& wal_additions, log::Writer* log, IOStatus& io_s);
|
||||
|
||||
// Reopen the existing MANIFEST file for append at the end of Recover()
|
||||
// when reuse_manifest_on_open is set, so the next LogAndApply appends
|
||||
// to it instead of creating a fresh MANIFEST. Falls back to OK
|
||||
// (descriptor_log_ stays null, next LogAndApply creates a fresh
|
||||
// MANIFEST as before) if ReopenWritableFile fails.
|
||||
Status ReopenManifestForAppend(const std::string& manifest_path);
|
||||
|
||||
// FileOptions for MANIFEST writes -- applies the FS's
|
||||
// OptimizeForManifestWrite tuning, then re-applies the user-configured
|
||||
// temperature so a custom FS can't override it.
|
||||
FileOptions GetFileOptionsForManifestWrite() const;
|
||||
|
||||
// Create a log::Writer over the given FSWritableFile with all standard
|
||||
// MANIFEST setup applied (preallocation block size, checksum-handoff
|
||||
// classification, listeners, etc.). preallocation_size should be a
|
||||
// snapshot of manifest_preallocation_size_ taken under the DB mutex
|
||||
// (the fresh-path call site reads the field before releasing mu).
|
||||
// When initial_file_size > 0 the writer is treated as resuming over
|
||||
// an already-populated file: WritableFileWriter is constructed with the
|
||||
// existing size so size accounting is correct, and the log writer's
|
||||
// initial_block_offset aligns to the existing file's tail within the
|
||||
// current 32 KiB block.
|
||||
std::unique_ptr<log::Writer> CreateManifestWriter(
|
||||
std::unique_ptr<FSWritableFile> file, const std::string& fname,
|
||||
const FileOptions& opts, uint64_t preallocation_size,
|
||||
uint64_t initial_file_size = 0) const;
|
||||
|
||||
void AppendVersion(ColumnFamilyData* column_family_data, Version* v);
|
||||
|
||||
ColumnFamilyData* CreateColumnFamily(const ColumnFamilyOptions& cf_options,
|
||||
@@ -1788,6 +1815,17 @@ class VersionSet {
|
||||
// Current size of manifest file
|
||||
uint64_t manifest_file_size_;
|
||||
|
||||
// File offset at the end of the last successfully completed logical
|
||||
// record during MANIFEST recovery. Unlike manifest_file_size_ (the
|
||||
// reader's I/O high-water mark, which includes any tolerated tail
|
||||
// garbage), this value points to the byte after the last valid record.
|
||||
// Used by ReopenManifestForAppend to detect intra-block tail
|
||||
// corruption that doesn't extend the physical file size.
|
||||
// manifest_file_size_ is kept separate because it is used for
|
||||
// rotation decisions (ProcessManifestWrites), close-time verification
|
||||
// (Close), and backup metadata.
|
||||
uint64_t manifest_last_valid_record_end_;
|
||||
|
||||
// Size of the populated manifest file last time it was re-written from
|
||||
// scratch.
|
||||
uint64_t last_compacted_manifest_file_size_;
|
||||
@@ -1860,7 +1898,9 @@ class ReactiveVersionSet : public VersionSet {
|
||||
const FileOptions& _file_options, Cache* table_cache,
|
||||
WriteBufferManager* write_buffer_manager,
|
||||
WriteController* write_controller,
|
||||
const std::shared_ptr<IOTracer>& io_tracer);
|
||||
const std::shared_ptr<IOTracer>& io_tracer,
|
||||
const std::string& db_id,
|
||||
const std::string& db_session_id);
|
||||
|
||||
~ReactiveVersionSet() override;
|
||||
|
||||
|
||||
+34
-2
@@ -20,6 +20,7 @@
|
||||
#include "rocksdb/advanced_options.h"
|
||||
#include "rocksdb/convenience.h"
|
||||
#include "rocksdb/file_system.h"
|
||||
#include "rocksdb/statistics.h"
|
||||
#include "table/block_based/block_based_table_factory.h"
|
||||
#include "table/mock_table.h"
|
||||
#include "table/unique_id_impl.h"
|
||||
@@ -1202,7 +1203,7 @@ class VersionSetTestBase {
|
||||
reactive_versions_ = std::make_shared<ReactiveVersionSet>(
|
||||
dbname_, &imm_db_options_, mutable_db_options_, env_options_,
|
||||
table_cache_.get(), &write_buffer_manager_, &write_controller_,
|
||||
nullptr);
|
||||
/*io_tracer=*/nullptr, /*db_id=*/"", /*db_session_id=*/"");
|
||||
imm_db_options_.db_paths.emplace_back(dbname_,
|
||||
std::numeric_limits<uint64_t>::max());
|
||||
}
|
||||
@@ -2412,6 +2413,8 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseClean) {
|
||||
// Enable content validation, perform normal operations, close.
|
||||
// Verify no manifest rotation (file number unchanged).
|
||||
NewDB();
|
||||
auto stats = CreateDBStatistics();
|
||||
imm_db_options_.statistics = stats;
|
||||
mutable_db_options_.verify_manifest_content_on_close = true;
|
||||
mutex_.Lock();
|
||||
versions_->UpdatedMutableDbOptions(mutable_db_options_, &mutex_);
|
||||
@@ -2437,6 +2440,9 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseClean) {
|
||||
// Verify content validation actually ran
|
||||
ASSERT_TRUE(content_validation_ran);
|
||||
|
||||
// No corruption -- counter should be zero
|
||||
ASSERT_EQ(0, stats->getTickerCount(MANIFEST_VALIDATION_FAILURE_COUNT));
|
||||
|
||||
// Manifest path should be unchanged (no rotation)
|
||||
std::string manifest_path_after;
|
||||
uint64_t manifest_file_number = 0;
|
||||
@@ -2452,6 +2458,8 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseCorruptRecord) {
|
||||
// Enable content validation, corrupt the manifest after closing the writer,
|
||||
// verify manifest rotation occurs and DB reopens cleanly.
|
||||
NewDB();
|
||||
auto stats = CreateDBStatistics();
|
||||
imm_db_options_.statistics = stats;
|
||||
mutable_db_options_.verify_manifest_content_on_close = true;
|
||||
mutex_.Lock();
|
||||
versions_->UpdatedMutableDbOptions(mutable_db_options_, &mutex_);
|
||||
@@ -2488,6 +2496,9 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseCorruptRecord) {
|
||||
CloseDB();
|
||||
SyncPoint::GetInstance()->DisableProcessing();
|
||||
|
||||
// Corruption detected once, rewrite succeeded
|
||||
ASSERT_EQ(1, stats->getTickerCount(MANIFEST_VALIDATION_FAILURE_COUNT));
|
||||
|
||||
// Manifest should have been rotated (new file number)
|
||||
std::string manifest_path_after;
|
||||
uint64_t manifest_file_number = 0;
|
||||
@@ -2502,8 +2513,10 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseCorruptRecord) {
|
||||
|
||||
TEST_F(VersionSetTest, ManifestContentValidationOnCloseDisabled) {
|
||||
// Default (option disabled), corrupt manifest after writer close,
|
||||
// verify no rotation occurred — corrupt manifest persists.
|
||||
// verify no rotation occurred -- corrupt manifest persists.
|
||||
NewDB();
|
||||
auto stats = CreateDBStatistics();
|
||||
imm_db_options_.statistics = stats;
|
||||
// verify_manifest_content_on_close defaults to false
|
||||
|
||||
VersionEdit edit;
|
||||
@@ -2525,6 +2538,9 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseDisabled) {
|
||||
|
||||
ASSERT_FALSE(content_validation_ran);
|
||||
|
||||
// Validation disabled -- counter should be zero
|
||||
ASSERT_EQ(0, stats->getTickerCount(MANIFEST_VALIDATION_FAILURE_COUNT));
|
||||
|
||||
// Manifest path should be unchanged (no rotation since validation is off)
|
||||
std::string manifest_path_after;
|
||||
uint64_t manifest_file_number = 0;
|
||||
@@ -2539,6 +2555,8 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseSizeCheckFails) {
|
||||
// Verify recovery happens via size-check path. Content validation still
|
||||
// runs afterward on the freshly rewritten manifest.
|
||||
NewDB();
|
||||
auto stats = CreateDBStatistics();
|
||||
imm_db_options_.statistics = stats;
|
||||
mutable_db_options_.verify_manifest_content_on_close = true;
|
||||
mutex_.Lock();
|
||||
versions_->UpdatedMutableDbOptions(mutable_db_options_, &mutex_);
|
||||
@@ -2566,6 +2584,10 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseSizeCheckFails) {
|
||||
ASSERT_NO_FATAL_FAILURE(CloseDB());
|
||||
SyncPoint::GetInstance()->DisableProcessing();
|
||||
|
||||
// Size check caught the issue; content validation on rewritten manifest
|
||||
// should pass -- no content validation failure recorded
|
||||
ASSERT_EQ(0, stats->getTickerCount(MANIFEST_VALIDATION_FAILURE_COUNT));
|
||||
|
||||
// Size check should have triggered rotation
|
||||
std::string manifest_path_after;
|
||||
uint64_t manifest_file_number = 0;
|
||||
@@ -2583,6 +2605,8 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseCorruptAfterRewrite) {
|
||||
// The loop should detect corruption twice: once triggering a rewrite, and
|
||||
// once reporting that the rewritten manifest is also corrupt.
|
||||
NewDB();
|
||||
auto stats = CreateDBStatistics();
|
||||
imm_db_options_.statistics = stats;
|
||||
mutable_db_options_.verify_manifest_content_on_close = true;
|
||||
mutex_.Lock();
|
||||
versions_->UpdatedMutableDbOptions(mutable_db_options_, &mutex_);
|
||||
@@ -2636,12 +2660,17 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseCorruptAfterRewrite) {
|
||||
|
||||
// OnIOError should have fired twice (once per corrupt detection)
|
||||
ASSERT_EQ(io_error_count, 2);
|
||||
|
||||
// Validation failure counter should match
|
||||
ASSERT_EQ(2, stats->getTickerCount(MANIFEST_VALIDATION_FAILURE_COUNT));
|
||||
}
|
||||
|
||||
TEST_F(VersionSetTest, ManifestContentValidationOnCloseOpenFails) {
|
||||
// Delete the manifest before content validation so it can't be opened.
|
||||
// Close() should surface the I/O error to the caller.
|
||||
NewDB();
|
||||
auto stats = CreateDBStatistics();
|
||||
imm_db_options_.statistics = stats;
|
||||
mutable_db_options_.verify_manifest_content_on_close = true;
|
||||
mutex_.Lock();
|
||||
versions_->UpdatedMutableDbOptions(mutable_db_options_, &mutex_);
|
||||
@@ -2667,6 +2696,9 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseOpenFails) {
|
||||
SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
|
||||
ASSERT_TRUE(close_s.IsIOError()) << close_s.ToString();
|
||||
|
||||
// File couldn't be opened -- no content validation ran
|
||||
ASSERT_EQ(0, stats->getTickerCount(MANIFEST_VALIDATION_FAILURE_COUNT));
|
||||
}
|
||||
|
||||
TEST_F(VersionStorageInfoTest, AddRangeDeletionCompensatedFileSize) {
|
||||
|
||||
@@ -30,6 +30,10 @@ class DBWideBasicTest : public DBTestBase {
|
||||
return wide_column_test_util::GetOptionsForBlobTest(GetDefaultOptions());
|
||||
}
|
||||
|
||||
Options GetDirectWriteOptions() {
|
||||
return wide_column_test_util::GetDirectWriteOptions(GetDefaultOptions());
|
||||
}
|
||||
|
||||
// Helper: runs the EntityBlobAfterFlush test logic with the given options.
|
||||
void RunEntityBlobAfterFlush(const Options& options);
|
||||
|
||||
@@ -2159,6 +2163,57 @@ TEST_F(DBWideBasicTest, MultiGetEntityWithBlobResolution) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBWideBasicTest,
|
||||
MultiGetBlobBackedEntityDirectWriteMemtableBatchLookup) {
|
||||
// Goal: force the memtable batch MultiGet optimization to read a direct-write
|
||||
// blob-backed entity from the active memtable. One key exercises merge
|
||||
// resolution against a blob-backed entity base, which requires Saver to carry
|
||||
// the BlobFetcher through the batched callback path. The second key proves
|
||||
// the non-merge default-column path still resolves correctly in the same
|
||||
// batched request.
|
||||
Options options = GetDirectWriteOptions();
|
||||
options.min_blob_size = 50;
|
||||
options.memtable_batch_lookup_optimization = true;
|
||||
options.merge_operator = MergeOperators::CreateStringAppendOperator("|");
|
||||
|
||||
DestroyAndReopen(options);
|
||||
|
||||
const std::string merge_key = "multiget_direct_write_merge_key";
|
||||
const std::string plain_key = "multiget_direct_write_plain_key";
|
||||
const std::string default_value = GenerateLargeValue(100, 'd');
|
||||
const std::string other_default_value = GenerateLargeValue(110, 'p');
|
||||
const std::string large_value = GenerateLargeValue(120, 'l');
|
||||
const std::string small_value = GenerateSmallValue();
|
||||
const std::string merge_operand = "tail";
|
||||
|
||||
WideColumns merge_columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
WideColumns plain_columns{{kDefaultWideColumnName, other_default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
|
||||
ASSERT_OK(db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(),
|
||||
merge_key, merge_columns));
|
||||
ASSERT_OK(db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(),
|
||||
plain_key, plain_columns));
|
||||
ASSERT_OK(db_->Merge(WriteOptions(), db_->DefaultColumnFamily(), merge_key,
|
||||
merge_operand));
|
||||
|
||||
std::array<Slice, 2> keys{{merge_key, plain_key}};
|
||||
std::array<PinnableSlice, 2> values;
|
||||
std::array<Status, 2> statuses;
|
||||
|
||||
db_->MultiGet(ReadOptions(), db_->DefaultColumnFamily(), keys.size(),
|
||||
keys.data(), values.data(), statuses.data());
|
||||
|
||||
ASSERT_OK(statuses[0]);
|
||||
ASSERT_EQ(values[0], default_value + "|" + merge_operand);
|
||||
|
||||
ASSERT_OK(statuses[1]);
|
||||
ASSERT_EQ(values[1], other_default_value);
|
||||
}
|
||||
|
||||
void DBWideBasicTest::RunEntityBlobAfterFlush(const Options& options) {
|
||||
Reopen(options);
|
||||
|
||||
@@ -2776,7 +2831,7 @@ TEST_F(DBWideBasicTest, EntityBlobBlockCacheTierGet) {
|
||||
// Reopen to clear any caches
|
||||
Reopen(options);
|
||||
|
||||
// Read with kBlockCacheTier — blob is not in cache
|
||||
// Read with kBlockCacheTier -- blob is not in cache
|
||||
ReadOptions read_opts;
|
||||
read_opts.read_tier = kBlockCacheTier;
|
||||
PinnableSlice result;
|
||||
@@ -2909,6 +2964,347 @@ TEST_F(DBWideBasicTest, MergeEntityWithBlobColumns) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBWideBasicTest, GetMergeOperandsWithBlobBackedEntityDefaultColumn) {
|
||||
// Goal: cover both GetMergeOperands code paths that read a compacted V2
|
||||
// wide-column entity whose default column was moved to a blob file. The
|
||||
// first read exercises the base-value path directly, then a merge operand is
|
||||
// added so the second read exercises the merge-plus-base path.
|
||||
Options options = GetBlobTestOptions();
|
||||
options.min_blob_size = 50;
|
||||
options.merge_operator = MergeOperators::CreateStringAppendOperator("|");
|
||||
|
||||
DestroyAndReopen(options);
|
||||
|
||||
const std::string key = "merge_operands_blob_entity";
|
||||
const std::string default_value = GenerateLargeValue(100, 'd');
|
||||
const std::string large_value = GenerateLargeValue(120, 'l');
|
||||
const std::string small_value = GenerateSmallValue();
|
||||
const std::string merge_operand = "suffix";
|
||||
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
ASSERT_OK(
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
ASSERT_OK(Flush());
|
||||
ASSERT_OK(db_->CompactRange(CompactRangeOptions(), nullptr, nullptr));
|
||||
ASSERT_FALSE(GetBlobFileNumbers().empty());
|
||||
|
||||
{
|
||||
// The compacted V2 entity becomes the first operand when there are no
|
||||
// newer merge operands above it.
|
||||
GetMergeOperandsOptions get_merge_opts;
|
||||
get_merge_opts.expected_max_number_of_operands = 1;
|
||||
|
||||
std::array<PinnableSlice, 2> merge_operands;
|
||||
int number_of_operands = 0;
|
||||
|
||||
ASSERT_OK(db_->GetMergeOperands(ReadOptions(), db_->DefaultColumnFamily(),
|
||||
key, merge_operands.data(), &get_merge_opts,
|
||||
&number_of_operands));
|
||||
ASSERT_EQ(number_of_operands, 1);
|
||||
ASSERT_EQ(merge_operands[0], default_value);
|
||||
}
|
||||
|
||||
ASSERT_OK(db_->Merge(WriteOptions(), db_->DefaultColumnFamily(), key,
|
||||
merge_operand));
|
||||
|
||||
{
|
||||
// After a merge is added, GetMergeOperands must still resolve the blob
|
||||
// backed base default column while traversing the older base entry below
|
||||
// the newer merge operand.
|
||||
GetMergeOperandsOptions get_merge_opts;
|
||||
get_merge_opts.expected_max_number_of_operands = 2;
|
||||
|
||||
std::array<PinnableSlice, 2> merge_operands;
|
||||
int number_of_operands = 0;
|
||||
|
||||
ASSERT_OK(db_->GetMergeOperands(ReadOptions(), db_->DefaultColumnFamily(),
|
||||
key, merge_operands.data(), &get_merge_opts,
|
||||
&number_of_operands));
|
||||
ASSERT_EQ(number_of_operands, 2);
|
||||
ASSERT_EQ(merge_operands[0], default_value);
|
||||
ASSERT_EQ(merge_operands[1], merge_operand);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBWideBasicTest,
|
||||
GetMergeOperandsWithBlobBackedEntityDefaultColumnDirectWriteMemtable) {
|
||||
// Goal: cover the pre-flush memtable path when blob direct write stores a
|
||||
// blob-backed V2 entity in memory. The first GetMergeOperands call exercises
|
||||
// the no-merge base-operand path. After adding a merge, DB::Get exercises
|
||||
// merge resolution against the same memtable entity, and a second
|
||||
// GetMergeOperands call exercises the merge-plus-base path.
|
||||
Options options = GetDirectWriteOptions();
|
||||
options.min_blob_size = 50;
|
||||
options.merge_operator = MergeOperators::CreateStringAppendOperator("|");
|
||||
|
||||
DestroyAndReopen(options);
|
||||
|
||||
const std::string key = "direct_write_merge_operands_blob_entity";
|
||||
const std::string default_value = GenerateLargeValue(100, 'd');
|
||||
const std::string large_value = GenerateLargeValue(120, 'l');
|
||||
const std::string small_value = GenerateSmallValue();
|
||||
const std::string merge_operand = "suffix";
|
||||
const std::string expected_merged = default_value + "|" + merge_operand;
|
||||
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
ASSERT_OK(
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
|
||||
{
|
||||
GetMergeOperandsOptions get_merge_opts;
|
||||
get_merge_opts.expected_max_number_of_operands = 1;
|
||||
|
||||
std::array<PinnableSlice, 2> merge_operands;
|
||||
int number_of_operands = 0;
|
||||
|
||||
ASSERT_OK(db_->GetMergeOperands(ReadOptions(), db_->DefaultColumnFamily(),
|
||||
key, merge_operands.data(), &get_merge_opts,
|
||||
&number_of_operands));
|
||||
ASSERT_EQ(number_of_operands, 1);
|
||||
ASSERT_EQ(merge_operands[0], default_value);
|
||||
}
|
||||
|
||||
ASSERT_OK(db_->Merge(WriteOptions(), db_->DefaultColumnFamily(), key,
|
||||
merge_operand));
|
||||
|
||||
{
|
||||
PinnableSlice result;
|
||||
ASSERT_OK(
|
||||
db_->Get(ReadOptions(), db_->DefaultColumnFamily(), key, &result));
|
||||
ASSERT_EQ(result, expected_merged);
|
||||
}
|
||||
|
||||
{
|
||||
GetMergeOperandsOptions get_merge_opts;
|
||||
get_merge_opts.expected_max_number_of_operands = 2;
|
||||
|
||||
std::array<PinnableSlice, 2> merge_operands;
|
||||
int number_of_operands = 0;
|
||||
|
||||
ASSERT_OK(db_->GetMergeOperands(ReadOptions(), db_->DefaultColumnFamily(),
|
||||
key, merge_operands.data(), &get_merge_opts,
|
||||
&number_of_operands));
|
||||
ASSERT_EQ(number_of_operands, 2);
|
||||
ASSERT_EQ(merge_operands[0], default_value);
|
||||
ASSERT_EQ(merge_operands[1], merge_operand);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBWideBasicTest,
|
||||
GetAndGetEntityWithBlobBackedDefaultColumnDirectWriteMemtableReadOnly) {
|
||||
// Goal: cover the read-only reopen path after writing a blob-backed
|
||||
// direct-write entity without manually flushing it first. The test checks
|
||||
// both `Get()`, which must resolve the default-column blob reference, and
|
||||
// `GetEntity()`, which must eagerly resolve the entity's unresolved blob
|
||||
// columns before returning them to the caller.
|
||||
Options options = GetDirectWriteOptions();
|
||||
options.min_blob_size = 50;
|
||||
|
||||
DestroyAndReopen(options);
|
||||
|
||||
const std::string key = "readonly_direct_write_memtable_entity";
|
||||
const std::string default_value = GenerateLargeValue(100, 'd');
|
||||
const std::string large_value = GenerateLargeValue(120, 'l');
|
||||
const std::string small_value = GenerateSmallValue();
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
|
||||
ASSERT_OK(
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
|
||||
Close();
|
||||
options.avoid_flush_during_recovery = true;
|
||||
ASSERT_OK(ReadOnlyReopen(options));
|
||||
|
||||
{
|
||||
PinnableSlice result;
|
||||
ASSERT_OK(
|
||||
db_->Get(ReadOptions(), db_->DefaultColumnFamily(), key, &result));
|
||||
ASSERT_EQ(result, default_value);
|
||||
}
|
||||
|
||||
{
|
||||
PinnableWideColumns result;
|
||||
ASSERT_OK(db_->GetEntity(ReadOptions(), db_->DefaultColumnFamily(), key,
|
||||
&result));
|
||||
ASSERT_EQ(result.columns(), columns);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBWideBasicTest, ReadOnlyDirectWriteMemtableBlobBlockCacheTier) {
|
||||
// Goal: cover the read-only memtable path under kBlockCacheTier. The test
|
||||
// keeps a blob-backed direct-write entity in WAL-backed recovery state so
|
||||
// resolving either Get() or GetEntity() would require blob I/O, which must
|
||||
// surface as Incomplete rather than silently reading the blob file.
|
||||
Options options = GetDirectWriteOptions();
|
||||
options.min_blob_size = 50;
|
||||
|
||||
DestroyAndReopen(options);
|
||||
|
||||
const std::string key = "readonly_direct_write_memtable_block_cache_tier";
|
||||
const std::string default_value = GenerateLargeValue(100, 'd');
|
||||
const std::string large_value = GenerateLargeValue(120, 'l');
|
||||
const std::string small_value = GenerateSmallValue();
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
|
||||
ASSERT_OK(
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
|
||||
Close();
|
||||
options.avoid_flush_during_recovery = true;
|
||||
ASSERT_OK(ReadOnlyReopen(options));
|
||||
|
||||
ReadOptions read_opts;
|
||||
read_opts.read_tier = kBlockCacheTier;
|
||||
|
||||
{
|
||||
PinnableSlice result;
|
||||
const Status s =
|
||||
db_->Get(read_opts, db_->DefaultColumnFamily(), key, &result);
|
||||
ASSERT_TRUE(s.IsIncomplete()) << s.ToString();
|
||||
ASSERT_TRUE(result.empty());
|
||||
}
|
||||
|
||||
{
|
||||
PinnableWideColumns result;
|
||||
const Status s =
|
||||
db_->GetEntity(read_opts, db_->DefaultColumnFamily(), key, &result);
|
||||
ASSERT_TRUE(s.IsIncomplete()) << s.ToString();
|
||||
ASSERT_TRUE(result.columns().empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBWideBasicTest,
|
||||
GetMergeOperandsWithBlobBackedEntityDefaultColumnReadOnly) {
|
||||
// Goal: exercise OpenForReadOnly on a blob-backed V2 entity base in SST with
|
||||
// a newer merge operand in a separate SST. The read-only DB must resolve the
|
||||
// blob-backed default column for both Get() and GetMergeOperands() without
|
||||
// relying on mutable memtable state.
|
||||
Options options = GetBlobTestOptions();
|
||||
options.min_blob_size = 50;
|
||||
options.merge_operator = MergeOperators::CreateStringAppendOperator("|");
|
||||
|
||||
DestroyAndReopen(options);
|
||||
|
||||
const std::string key = "readonly_merge_operands_blob_entity";
|
||||
const std::string default_value = GenerateLargeValue(100, 'd');
|
||||
const std::string large_value = GenerateLargeValue(120, 'l');
|
||||
const std::string small_value = GenerateSmallValue();
|
||||
const std::string merge_operand = "suffix";
|
||||
const std::string expected_merged = default_value + "|" + merge_operand;
|
||||
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
ASSERT_OK(
|
||||
db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), key, columns));
|
||||
ASSERT_OK(Flush());
|
||||
ASSERT_OK(db_->CompactRange(CompactRangeOptions(), nullptr, nullptr));
|
||||
ASSERT_FALSE(GetBlobFileNumbers().empty());
|
||||
|
||||
ASSERT_OK(db_->Merge(WriteOptions(), db_->DefaultColumnFamily(), key,
|
||||
merge_operand));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
Close();
|
||||
ASSERT_OK(ReadOnlyReopen(options));
|
||||
|
||||
{
|
||||
PinnableSlice result;
|
||||
ASSERT_OK(
|
||||
db_->Get(ReadOptions(), db_->DefaultColumnFamily(), key, &result));
|
||||
ASSERT_EQ(result, expected_merged);
|
||||
}
|
||||
|
||||
{
|
||||
GetMergeOperandsOptions get_merge_opts;
|
||||
get_merge_opts.expected_max_number_of_operands = 2;
|
||||
|
||||
std::array<PinnableSlice, 2> merge_operands;
|
||||
int number_of_operands = 0;
|
||||
|
||||
ASSERT_OK(db_->GetMergeOperands(ReadOptions(), db_->DefaultColumnFamily(),
|
||||
key, merge_operands.data(), &get_merge_opts,
|
||||
&number_of_operands));
|
||||
ASSERT_EQ(number_of_operands, 2);
|
||||
ASSERT_EQ(merge_operands[0], default_value);
|
||||
ASSERT_EQ(merge_operands[1], merge_operand);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBWideBasicTest,
|
||||
GetMergeOperandsWithBlobBackedEntityDefaultColumnBlockCacheTier) {
|
||||
// Goal: prove the SST-backed GetMergeOperands path propagates Incomplete
|
||||
// when resolving a blob-backed default column would require I/O. One key
|
||||
// covers the direct base-entity path, and the other covers the merge-plus-
|
||||
// base path with the merge operand flushed to a newer SST.
|
||||
Options options = GetBlobTestOptions();
|
||||
options.min_blob_size = 50;
|
||||
options.merge_operator = MergeOperators::CreateStringAppendOperator("|");
|
||||
|
||||
DestroyAndReopen(options);
|
||||
|
||||
const std::string base_key = "merge_operands_blob_entity_cache_base";
|
||||
const std::string merge_key = "merge_operands_blob_entity_cache_merge";
|
||||
const std::string default_value = GenerateLargeValue(100, 'd');
|
||||
const std::string large_value = GenerateLargeValue(120, 'l');
|
||||
const std::string small_value = GenerateSmallValue();
|
||||
|
||||
WideColumns columns{{kDefaultWideColumnName, default_value},
|
||||
{"col_large", large_value},
|
||||
{"col_small", small_value}};
|
||||
ASSERT_OK(db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(), base_key,
|
||||
columns));
|
||||
ASSERT_OK(db_->PutEntity(WriteOptions(), db_->DefaultColumnFamily(),
|
||||
merge_key, columns));
|
||||
ASSERT_OK(Flush());
|
||||
ASSERT_OK(db_->CompactRange(CompactRangeOptions(), nullptr, nullptr));
|
||||
|
||||
ASSERT_OK(db_->Merge(WriteOptions(), db_->DefaultColumnFamily(), merge_key,
|
||||
"tail"));
|
||||
ASSERT_OK(Flush());
|
||||
|
||||
// Reopen to clear any cached blob readers/values so kBlockCacheTier must
|
||||
// report Incomplete instead of succeeding through a warm cache.
|
||||
Reopen(options);
|
||||
|
||||
ReadOptions read_opts;
|
||||
read_opts.read_tier = kBlockCacheTier;
|
||||
|
||||
{
|
||||
GetMergeOperandsOptions get_merge_opts;
|
||||
get_merge_opts.expected_max_number_of_operands = 1;
|
||||
|
||||
std::array<PinnableSlice, 2> merge_operands;
|
||||
int number_of_operands = 0;
|
||||
|
||||
const Status s = db_->GetMergeOperands(
|
||||
read_opts, db_->DefaultColumnFamily(), base_key, merge_operands.data(),
|
||||
&get_merge_opts, &number_of_operands);
|
||||
ASSERT_TRUE(s.IsIncomplete()) << s.ToString();
|
||||
}
|
||||
|
||||
{
|
||||
GetMergeOperandsOptions get_merge_opts;
|
||||
get_merge_opts.expected_max_number_of_operands = 2;
|
||||
|
||||
std::array<PinnableSlice, 2> merge_operands;
|
||||
int number_of_operands = 0;
|
||||
|
||||
const Status s = db_->GetMergeOperands(
|
||||
read_opts, db_->DefaultColumnFamily(), merge_key, merge_operands.data(),
|
||||
&get_merge_opts, &number_of_operands);
|
||||
ASSERT_TRUE(s.IsIncomplete()) << s.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(DBWideBasicTest, MergeEntityWithBlobColumnsNoDefault) {
|
||||
// Test: Merge on a V2 entity without a default column. The merge result
|
||||
// should produce a valid entity with all original columns plus a default
|
||||
@@ -2998,7 +3394,7 @@ TEST_F(DBWideBasicTest, MergeEntityWithBlobColumnsBlockCacheTier) {
|
||||
// Reopen to clear caches
|
||||
Reopen(options);
|
||||
|
||||
// Read with kBlockCacheTier — blob not in cache, merge needs blob resolution
|
||||
// Read with kBlockCacheTier -- blob not in cache, merge needs blob resolution
|
||||
ReadOptions read_opts;
|
||||
read_opts.read_tier = kBlockCacheTier;
|
||||
PinnableSlice result;
|
||||
|
||||
@@ -59,7 +59,7 @@ Status ReadPathBlobResolver::ResolveColumn(size_t column_index,
|
||||
blob_resolver_util::FindBlobColumn(blob_columns_, column_index);
|
||||
|
||||
if (blob_index_ptr == nullptr) {
|
||||
// Inline column — return the value directly
|
||||
// Inline column -- return the value directly
|
||||
*resolved_value = (*columns_)[column_index].value();
|
||||
} else {
|
||||
// Check if already resolved
|
||||
|
||||
@@ -153,6 +153,8 @@ class WideColumnSerialization {
|
||||
// value() is initially the serialized BlobIndex bytes from the entity.
|
||||
// blob_columns: receives (column_index, blob_index) pairs identifying which
|
||||
// entries in `columns` are blob references and their decoded BlobIndex data.
|
||||
// For valid input, this must agree with HasBlobColumns() about whether blob
|
||||
// column references are present.
|
||||
static Status DeserializeV2(
|
||||
Slice& input, std::vector<WideColumn>& columns,
|
||||
std::vector<std::pair<size_t, BlobIndex>>& blob_columns);
|
||||
@@ -160,6 +162,8 @@ class WideColumnSerialization {
|
||||
// Check if the serialized entity has any blob column references.
|
||||
// Sets *has_blob_columns to true if version >= 2 and at least one column
|
||||
// has blob type; false otherwise.
|
||||
// For valid input, this must agree with DeserializeV2() about whether blob
|
||||
// column references are present.
|
||||
// Returns Status::Corruption on decode errors.
|
||||
static Status HasBlobColumns(const Slice& input, bool& has_blob_columns);
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ static BlobIndex MakeBlobIndex(uint64_t file_number, uint64_t offset,
|
||||
return bi;
|
||||
}
|
||||
|
||||
// Helper: V2 serialize → DeserializeV2 round-trip, returning
|
||||
// Helper: V2 serialize -> DeserializeV2 round-trip, returning
|
||||
// deserialized columns and blob column info.
|
||||
static void V2SerializeAndDeserialize(
|
||||
const std::vector<std::pair<std::string, std::string>>& columns,
|
||||
@@ -798,7 +798,7 @@ TEST_F(WideColumnSerializationTest, RandomizedSerializeDeserializeRoundTrip) {
|
||||
}
|
||||
}
|
||||
|
||||
// V2 serialize → DeserializeV2 round-trip
|
||||
// V2 serialize -> DeserializeV2 round-trip
|
||||
std::string serialized;
|
||||
std::vector<WideColumn> deserialized;
|
||||
std::vector<std::pair<size_t, BlobIndex>> blob_out;
|
||||
|
||||
@@ -279,7 +279,9 @@ DECLARE_bool(use_full_merge_v1);
|
||||
DECLARE_int32(sync_wal_one_in);
|
||||
DECLARE_bool(avoid_unnecessary_blocking_io);
|
||||
DECLARE_bool(write_dbid_to_manifest);
|
||||
DECLARE_bool(optimize_manifest_for_recovery);
|
||||
DECLARE_bool(write_identity_file);
|
||||
DECLARE_bool(reuse_manifest_on_open);
|
||||
DECLARE_bool(avoid_flush_during_recovery);
|
||||
DECLARE_bool(enforce_write_buffer_manager_during_recovery);
|
||||
DECLARE_uint64(max_write_batch_group_size_bytes);
|
||||
|
||||
@@ -197,10 +197,10 @@ class DbStressFSWrapper : public FileSystemWrapper {
|
||||
assert(!file_opts.file_checksum_func_name.empty());
|
||||
if (file_opts.file_checksum_func_name == kUnknownFileChecksumFuncName ||
|
||||
file_opts.file_checksum_func_name == kNoFileChecksumFuncName) {
|
||||
// No checksum available — checksum value must be empty
|
||||
// No checksum available -- checksum value must be empty
|
||||
assert(file_opts.file_checksum.empty());
|
||||
} else {
|
||||
// A real checksum function — checksum value must be present
|
||||
// A real checksum function -- checksum value must be present
|
||||
assert(!file_opts.file_checksum.empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,10 +1133,19 @@ DEFINE_bool(write_dbid_to_manifest,
|
||||
ROCKSDB_NAMESPACE::Options().write_dbid_to_manifest,
|
||||
"Write DB_ID to manifest");
|
||||
|
||||
DEFINE_bool(optimize_manifest_for_recovery,
|
||||
ROCKSDB_NAMESPACE::Options().optimize_manifest_for_recovery,
|
||||
"Reduce recovery work after a clean shutdown");
|
||||
|
||||
DEFINE_bool(write_identity_file,
|
||||
ROCKSDB_NAMESPACE::Options().write_identity_file,
|
||||
"Write DB_ID to IDENTITY file");
|
||||
|
||||
DEFINE_bool(reuse_manifest_on_open,
|
||||
ROCKSDB_NAMESPACE::Options().reuse_manifest_on_open,
|
||||
"Reopen existing MANIFEST for append after recovery instead of "
|
||||
"creating a fresh one");
|
||||
|
||||
DEFINE_bool(avoid_flush_during_recovery,
|
||||
ROCKSDB_NAMESPACE::Options().avoid_flush_during_recovery,
|
||||
"Avoid flush during recovery");
|
||||
|
||||
@@ -3747,6 +3747,10 @@ void StressTest::PrintEnv() const {
|
||||
static_cast<int>(FLAGS_avoid_unnecessary_blocking_io));
|
||||
fprintf(stdout, "Write DB ID to manifest : %d\n",
|
||||
static_cast<int>(FLAGS_write_dbid_to_manifest));
|
||||
fprintf(stdout, "Optimize manifest recovery: %d\n",
|
||||
static_cast<int>(FLAGS_optimize_manifest_for_recovery));
|
||||
fprintf(stdout, "Reuse manifest on open : %d\n",
|
||||
static_cast<int>(FLAGS_reuse_manifest_on_open));
|
||||
fprintf(stdout, "Max Write Batch Group Size: %" PRIu64 "\n",
|
||||
FLAGS_max_write_batch_group_size_bytes);
|
||||
fprintf(stdout, "Use dynamic level : %d\n",
|
||||
@@ -4666,7 +4670,9 @@ void InitializeOptionsFromFlags(
|
||||
options.manual_wal_flush = FLAGS_manual_wal_flush_one_in > 0 ? true : false;
|
||||
options.avoid_unnecessary_blocking_io = FLAGS_avoid_unnecessary_blocking_io;
|
||||
options.write_dbid_to_manifest = FLAGS_write_dbid_to_manifest;
|
||||
options.optimize_manifest_for_recovery = FLAGS_optimize_manifest_for_recovery;
|
||||
options.write_identity_file = FLAGS_write_identity_file;
|
||||
options.reuse_manifest_on_open = FLAGS_reuse_manifest_on_open;
|
||||
options.avoid_flush_during_recovery = FLAGS_avoid_flush_during_recovery;
|
||||
options.enforce_write_buffer_manager_during_recovery =
|
||||
FLAGS_enforce_write_buffer_manager_during_recovery;
|
||||
|
||||
@@ -2900,7 +2900,7 @@ class NonBatchedOpsStressTest : public StressTest {
|
||||
op_logs += "N";
|
||||
}
|
||||
|
||||
// backward scan — skip when backward iteration is not supported
|
||||
// backward scan -- skip when backward iteration is not supported
|
||||
if (FLAGS_test_backward_scan) {
|
||||
key_str = Key(ub - 1);
|
||||
iter->SeekForPrev(key_str);
|
||||
|
||||
@@ -88,7 +88,7 @@ hx235:
|
||||
|
||||
pdillinger:
|
||||
full_name: Peter Dillinger
|
||||
fbid: 513108
|
||||
fbid: 593944283
|
||||
|
||||
alanpaxton:
|
||||
full_name: Alan Paxton
|
||||
@@ -101,3 +101,7 @@ anand1976:
|
||||
|
||||
poojam23:
|
||||
full_name: Pooja Malik
|
||||
|
||||
joshkang97:
|
||||
full_name: Josh Kang
|
||||
fbid: 180489168460795
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Interpolation search for SST index blocks
|
||||
layout: post
|
||||
author: joshkang97
|
||||
category: blog
|
||||
---
|
||||
|
||||
For workloads with uniformly distributed keys, RocksDB now supports **interpolation search** for SST index blocks as an alternative to the default binary search.
|
||||
|
||||
## The idea
|
||||
|
||||
Binary search always splits the remaining range in half:
|
||||
|
||||
```
|
||||
mid = low + (high - low) / 2
|
||||
```
|
||||
|
||||
That's Θ(log n) probes regardless of the data. Interpolation search instead estimates where the target should land based on its value relative to the current boundaries:
|
||||
|
||||
```
|
||||
probe = low + (target - key[low]) * (high - low) / (key[high] - key[low])
|
||||
```
|
||||
|
||||
On uniformly distributed keys, that's expected O(log log n) probes. The canonical example: for an index block with restart keys `0, 1, 2, ..., 1023` and a seek for `900`, binary search needs about 10 hops; interpolation search lands on it in 1.
|
||||
|
||||
The catch is that pure interpolation search degrades to O(n) on badly skewed data.
|
||||
|
||||
## Turning a key into a number
|
||||
|
||||
The interpolation formula needs numeric values, but index keys are variable-length byte slices. RocksDB extracts a `uint64_t` per key by reading the first 8 bytes after the common prefix shared by the block's boundary keys, in big-endian, and zero-pads to the right if the remaining bytes are too short.
|
||||
|
||||
```cpp
|
||||
inline uint64_t ReadBe64FromKey(Slice s, bool is_user_key, size_t offset) {
|
||||
// ... strip internal seq/type bytes if needed ...
|
||||
if (s.size() - offset >= 8) {
|
||||
uint64_t val;
|
||||
memcpy(&val, s.data() + offset, sizeof(val));
|
||||
return port::kLittleEndian ? EndianSwapValue(val) : val;
|
||||
}
|
||||
// pad short tails with zeros on the right (preserves bytewise order)
|
||||
}
|
||||
```
|
||||
|
||||
Big-endian + zero-pad preserves bytewise ordering, so the linear interpolation formula stays consistent with the comparator. This is also why the feature requires `BytewiseComparator`.
|
||||
|
||||
Two distinct keys can still collapse to the same `uint64_t` once you go past the first 8 non-shared bytes. To avoid a divide-by-zero, we simply fall back to binary search in that case.
|
||||
|
||||
## How to enable it
|
||||
|
||||
To force interpolation search on every index block:
|
||||
|
||||
```cpp
|
||||
rocksdb::BlockBasedTableOptions table_options;
|
||||
table_options.index_block_search_type =
|
||||
rocksdb::BlockBasedTableOptions::kInterpolation;
|
||||
```
|
||||
|
||||
## kAuto: per-block selection
|
||||
|
||||
`kAuto` is the recommended way to use the feature. It chooses the search algorithm for each index block automatically, based on a uniformity hint written into the block footer at SST construction time:
|
||||
|
||||
```cpp
|
||||
table_options.index_block_search_type =
|
||||
rocksdb::BlockBasedTableOptions::kAuto;
|
||||
table_options.uniform_cv_threshold = 0.2;
|
||||
```
|
||||
|
||||
When `uniform_cv_threshold >= 0`, the SST writer scans each index block's restart keys and computes the **coefficient of variation (CV)** of the gaps between consecutive numeric key values:
|
||||
|
||||
```
|
||||
gap[i] = key_value[i + 1] - key_value[i]
|
||||
CV = stddev(gap) / mean(gap)
|
||||
```
|
||||
|
||||
Lower CV means the gaps are more uniform — and the more likely interpolation search will outperform binary search. The CV is computed incrementally with Welford's online algorithm, so the scan is one pass over the restart points.
|
||||
|
||||
If `CV < uniform_cv_threshold`, RocksDB sets an `is_uniform` bit in the block footer. At read time, `kAuto` resolves to `kInterpolation` only when that bit is set *and* the comparator is bytewise; otherwise it uses `kBinary`.
|
||||
|
||||
### Write overhead
|
||||
|
||||
Computing the `is_uniform` bit is a cheap operation as it is only computed for the index blocks in a SST file. CPU profiling of `db_bench -benchmarks=fillseq,compact -compression_type=none -disable_wal=1` attributes only ~0.08% of write-path CPU to `ScanForUniformity`.
|
||||
|
||||
After a few more releases, we plan to enable kAuto and uniform_cv_threshold by default.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Setup — populate a DB and force a single-level shape so all reads hit the same index structure, then measure point-read throughput:
|
||||
|
||||
```
|
||||
# Build a release binary
|
||||
make clean && DEBUG_LEVEL=0 make db_bench
|
||||
|
||||
# Load + compact, varying the index_shortening_mode
|
||||
./db_bench -benchmarks=fillrandom,compact \
|
||||
-index_shortening_mode=1
|
||||
|
||||
# Then point-read against the populated DB
|
||||
./db_bench -use_existing_db=true -benchmarks=readrandom \
|
||||
-index_block_search_type=binary_search # or interpolation_search / auto_search
|
||||
```
|
||||
|
||||
`index_shortening_mode=1` (`kShortenSeparators`) keeps the file's last index key intact, which preserves a roughly uniform numeric distribution for the benchmark.
|
||||
|
||||
Results, averaged over multiple runs:
|
||||
|
||||
| Mode | ops/s | vs `binary_search` |
|
||||
|---|---|---|
|
||||
| `binary_search` | 335,749 | baseline |
|
||||
| `interpolation_search` | 366,598 | **+9.2%** |
|
||||
| `auto_search` | 366,832 | **+9.2%** |
|
||||
|
||||
## Compatibility
|
||||
|
||||
The `is_uniform` bit reuses a previously-reserved bit in the data block footer. SSTs written by older RocksDB never set it and decode as `is_uniform = false`, so they read with binary search under `kAuto`. However, after the bit is set, if read by an older RocksDB version < 11.0.0, it will read it as a corruption error.
|
||||
|
||||
## Future work
|
||||
|
||||
Some future opportunities can involve extending interpolation search to data blocks, as well as supporting other comparators such as `ReverseBytewiseComparator`.
|
||||
Vendored
+2
-2
@@ -1892,7 +1892,7 @@ TEST_F(EnvPosixTest, SupportedOpsNoAsyncIOOnIOUringInitFailure) {
|
||||
int64_t thread_supported_ops2 = 0;
|
||||
std::thread t([&]() {
|
||||
fs->SupportedOps(thread_supported_ops);
|
||||
// Second call on the same thread — cached failure, no retry.
|
||||
// Second call on the same thread -- cached failure, no retry.
|
||||
fs->SupportedOps(thread_supported_ops2);
|
||||
});
|
||||
t.join();
|
||||
@@ -1903,7 +1903,7 @@ TEST_F(EnvPosixTest, SupportedOpsNoAsyncIOOnIOUringInitFailure) {
|
||||
// Second call should also lack kAsyncIO.
|
||||
ASSERT_EQ(thread_supported_ops2 & (1 << FSSupportedOps::kAsyncIO), 0);
|
||||
ASSERT_NE(thread_supported_ops2 & (1 << FSSupportedOps::kFSPrefetch), 0);
|
||||
// CreateIOUring must have been called exactly once — not retried.
|
||||
// CreateIOUring must have been called exactly once -- not retried.
|
||||
ASSERT_EQ(create_count, 1);
|
||||
|
||||
SyncPoint::GetInstance()->DisableProcessing();
|
||||
|
||||
+1
-1
@@ -221,7 +221,7 @@ IOStatus GenerateOneFileChecksum(
|
||||
std::unique_ptr<FSRandomAccessFile> r_file;
|
||||
FileOptions fopts = file_options;
|
||||
if (fopts.file_checksum.empty()) {
|
||||
// No expected checksum is known — this is a from-scratch computation.
|
||||
// No expected checksum is known -- this is a from-scratch computation.
|
||||
fopts.file_checksum_func_name = kNoFileChecksumFuncName;
|
||||
}
|
||||
io_s = fs->NewRandomAccessFile(file_path, fopts, &r_file, nullptr);
|
||||
|
||||
@@ -3899,7 +3899,7 @@ TEST_P(FSBufferPrefetchTest, FSBufferPrefetchStatsInternals) {
|
||||
&result, &s, for_compaction);
|
||||
// Platforms that don't have IO uring may not support async IO.
|
||||
// With the ReadAsync sync fallback, s will be OK even when async IO is
|
||||
// unavailable — detect by checking if the second buffer has an async read
|
||||
// unavailable -- detect by checking if the second buffer has an async read
|
||||
// in progress.
|
||||
if (use_async_prefetch && s.IsNotSupported()) {
|
||||
return;
|
||||
|
||||
@@ -169,6 +169,9 @@ class WritableFileWriter {
|
||||
Temperature temperature_;
|
||||
|
||||
public:
|
||||
// `initial_file_size` seeds size accounting when wrapping a reopened file
|
||||
// that will continue appending from its current end. Leave at 0 for newly
|
||||
// created files and reuse-from-offset-0 paths.
|
||||
WritableFileWriter(
|
||||
std::unique_ptr<FSWritableFile>&& file, const std::string& _file_name,
|
||||
const FileOptions& options, SystemClock* clock = nullptr,
|
||||
@@ -178,21 +181,21 @@ class WritableFileWriter {
|
||||
const std::vector<std::shared_ptr<EventListener>>& listeners = {},
|
||||
FileChecksumGenFactory* file_checksum_gen_factory = nullptr,
|
||||
bool perform_data_verification = false,
|
||||
bool buffered_data_with_checksum = false)
|
||||
bool buffered_data_with_checksum = false, uint64_t initial_file_size = 0)
|
||||
: file_name_(_file_name),
|
||||
writable_file_(std::move(file), io_tracer, _file_name),
|
||||
clock_(clock),
|
||||
buf_(),
|
||||
max_buffer_size_(options.writable_file_max_buffer_size),
|
||||
filesize_(0),
|
||||
flushed_size_(0),
|
||||
next_write_offset_(0),
|
||||
filesize_(initial_file_size),
|
||||
flushed_size_(initial_file_size),
|
||||
next_write_offset_(initial_file_size),
|
||||
pending_sync_(false),
|
||||
seen_error_(false),
|
||||
#ifndef NDEBUG
|
||||
seen_injected_error_(false),
|
||||
#endif // NDEBUG
|
||||
last_sync_size_(0),
|
||||
last_sync_size_(initial_file_size),
|
||||
bytes_per_sync_(options.bytes_per_sync),
|
||||
rate_limiter_(options.rate_limiter),
|
||||
stats_(stats),
|
||||
|
||||
+39
-67
@@ -1,89 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-push hook: auto-format changed code before pushing.
|
||||
# pre-push hook: block pushing on common mistakes.
|
||||
#
|
||||
# Runs `make format-auto` to apply clang-format to files changed relative to
|
||||
# the upstream merge base. If any files are reformatted, they are committed as
|
||||
# a new "format" commit and the push is retried automatically.
|
||||
# 1. Checks for untracked source files in tracked directories, which likely
|
||||
# need to be committed or removed before the pushed code will build.
|
||||
# 2. Runs `make check-format` to verify formatting.
|
||||
#
|
||||
# Install with:
|
||||
# make install-hooks
|
||||
# Automatically active via `make all` / `make check` (sets core.hooksPath).
|
||||
#
|
||||
# Skip with:
|
||||
# git push --no-verify
|
||||
|
||||
set -e
|
||||
|
||||
REMOTE="$1"
|
||||
URL="$2"
|
||||
|
||||
# Only act when we have a terminal (skip in CI / automated pushes)
|
||||
if [ ! -t 1 ] && [ -z "$ROCKSDB_FORMAT_HOOK" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read the refs being pushed from stdin
|
||||
PUSH_REFS=()
|
||||
while read local_ref local_oid remote_ref remote_oid; do
|
||||
PUSH_REFS+=("$local_ref:$remote_ref")
|
||||
done
|
||||
FAILED=0
|
||||
|
||||
# --- Check for untracked source files in tracked directories ---
|
||||
SUSPECT_FILES=$(git ls-files --others --exclude-standard \
|
||||
| grep -E '\.(cc|cpp|c|h|hpp|java|py|mk|sh)$' \
|
||||
| grep -v '^third-party/' \
|
||||
| while IFS= read -r f; do
|
||||
top_dir="${f%%/*}"
|
||||
if [ "$top_dir" = "$f" ] || [ -n "$(git ls-files "$top_dir/" | head -1)" ]; then
|
||||
echo "$f"
|
||||
fi
|
||||
done)
|
||||
|
||||
if [ -n "$SUSPECT_FILES" ]; then
|
||||
echo ""
|
||||
echo "==========================================================="
|
||||
echo " pre-push: untracked source files in tracked directories."
|
||||
echo " Push blocked. These may need to be committed, or the"
|
||||
echo " pushed code could fail to build:"
|
||||
echo ""
|
||||
echo "$SUSPECT_FILES" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo " Commit them, add to .gitignore, delete, or --no-verify."
|
||||
echo "==========================================================="
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# --- Check formatting ---
|
||||
echo "pre-push: running format check..."
|
||||
|
||||
# Save any uncommitted work so format-auto only touches committed code
|
||||
NEED_STASH=0
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
git stash push -q -m "pre-push-hook-$(date +%s)"
|
||||
NEED_STASH=1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
if [ "$NEED_STASH" -eq 1 ]; then
|
||||
git stash pop -q 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Run format check (check-only mode first to see if anything needs fixing)
|
||||
if make check-format 2>/dev/null; then
|
||||
echo "pre-push: formatting is clean."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "pre-push: formatting issues detected, auto-fixing..."
|
||||
make format-auto
|
||||
|
||||
# Check if format-auto actually changed anything
|
||||
if git diff --quiet; then
|
||||
echo "pre-push: formatting is clean after auto-fix."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create a new format commit
|
||||
git add -A
|
||||
git commit -m "format: auto-format code" --no-verify -q
|
||||
|
||||
# Build the refspecs using the current (post-format) branch HEAD
|
||||
REFSPECS=()
|
||||
for ref_pair in "${PUSH_REFS[@]}"; do
|
||||
remote_ref="${ref_pair#*:}"
|
||||
REFSPECS+=("HEAD:$remote_ref")
|
||||
done
|
||||
|
||||
# Retry push, skipping the hook to avoid recursion
|
||||
if git push --no-verify "$REMOTE" "${REFSPECS[@]}" 2>&1; then
|
||||
if ! make check-format; then
|
||||
echo ""
|
||||
echo "==========================================================="
|
||||
echo " pre-push: automatically formatted with a new commit and"
|
||||
echo " pushed. You can ignore the 'failed to push' message below"
|
||||
echo " — that is git cancelling the stale push; your code is"
|
||||
echo " already on the remote."
|
||||
echo " pre-push: formatting issues detected. Push blocked."
|
||||
echo " Run 'make format-auto' then amend your commit and retry."
|
||||
echo "==========================================================="
|
||||
exit 1
|
||||
FAILED=1
|
||||
else
|
||||
echo ""
|
||||
echo "==========================================================="
|
||||
echo " pre-push: format commit was added but push failed."
|
||||
echo " Please run 'git push' again manually."
|
||||
echo "==========================================================="
|
||||
exit 1
|
||||
echo "pre-push: formatting is clean."
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -152,7 +152,12 @@ class Cache : public Customizable {
|
||||
|
||||
// For helpers without SecondaryCache support
|
||||
explicit CacheItemHelper(CacheEntryRole _role, DeleterFn _del_cb = nullptr)
|
||||
: CacheItemHelper(_role, _del_cb, nullptr, nullptr, nullptr, this) {}
|
||||
: del_cb(_del_cb),
|
||||
size_cb(nullptr),
|
||||
saveto_cb(nullptr),
|
||||
create_cb(nullptr),
|
||||
role(_role),
|
||||
without_secondary_compat(this) {}
|
||||
|
||||
// For helpers with SecondaryCache support
|
||||
explicit CacheItemHelper(CacheEntryRole _role, DeleterFn _del_cb,
|
||||
|
||||
@@ -456,6 +456,13 @@ class CompressionManager
|
||||
// supported)
|
||||
virtual bool SupportsCompressionType(CompressionType type) const = 0;
|
||||
|
||||
// Return a human-readable name for the given compression type within this
|
||||
// CompressionManager's schema. The default implementation returns the
|
||||
// generic built-in name such as "Snappy", "ZSTD", "Reserved4F", or
|
||||
// "Custom8A". Override to provide more specific names for custom
|
||||
// compression types.
|
||||
virtual std::string CompressionTypeToString(CompressionType type) const;
|
||||
|
||||
// TODO: function to check compatibility with or sanitize CompressionOptions
|
||||
|
||||
// ************************* Compressor creation *********************** //
|
||||
@@ -656,6 +663,10 @@ class CompressionManagerWrapper : public CompressionManager {
|
||||
return wrapped_->SupportsCompressionType(type);
|
||||
}
|
||||
|
||||
std::string CompressionTypeToString(CompressionType type) const override {
|
||||
return wrapped_->CompressionTypeToString(type);
|
||||
}
|
||||
|
||||
std::unique_ptr<Compressor> GetCompressorForSST(
|
||||
const FilterBuildingContext& context, const CompressionOptions& opts,
|
||||
CompressionType preferred) override {
|
||||
|
||||
@@ -946,7 +946,7 @@ struct AdvancedColumnFamilyOptions {
|
||||
// (estimated_reads / file_size) exceeds this threshold. This helps reduce
|
||||
// read amplification for hot keys by compacting frequently-read files.
|
||||
//
|
||||
// Only "collapsible" reads are counted — lookups that return NotFound
|
||||
// Only "collapsible" reads are counted -- lookups that return NotFound
|
||||
// (bloom filter false positive), Delete/SingleDeletion (tombstone), or
|
||||
// Merge (partial result). These are reads where the file contributed no
|
||||
// final value and compaction would eliminate the wasted work.
|
||||
@@ -972,15 +972,15 @@ struct AdvancedColumnFamilyOptions {
|
||||
// r * S * B = 2 * (1 + F) * S
|
||||
// r = 2 * (1 + F) / B
|
||||
//
|
||||
// With F = 10, B = 4096: r = 22 / 4096 ≈ 0.005.
|
||||
// With F = 10, B = 4096: r = 22 / 4096 ~= 0.005.
|
||||
//
|
||||
// With a block-cache hit rate h (0 ≤ h < 1), each collapsible read
|
||||
// With a block-cache hit rate h (0 <= h < 1), each collapsible read
|
||||
// only costs (1 - h) * B bytes of actual disk IO, so:
|
||||
// r = 2 * (1 + F) / ((1 - h) * B)
|
||||
//
|
||||
// h = 0 → r ≈ 0.005
|
||||
// h = 0.5 → r ≈ 0.01
|
||||
// h = 0.9 → r ≈ 0.05
|
||||
// h = 0 -> r ~= 0.005
|
||||
// h = 0.5 -> r ~= 0.01
|
||||
// h = 0.9 -> r ~= 0.05
|
||||
//
|
||||
// A recommended starting point is 0.01, which avoids triggering
|
||||
// compactions that cost more IO than they save for most cache-friendly
|
||||
@@ -1419,8 +1419,18 @@ struct AdvancedColumnFamilyOptions {
|
||||
// (ReadOptions::total_order_seek / ReadOptions::auto_prefix_mode) nor
|
||||
// bounded by ReadOptions::prefix_same_as_start
|
||||
//
|
||||
// It also requires an active mutable memtable, and insertion is skipped when
|
||||
// that memtable is empty.
|
||||
// Even if the above restrictions are met, there are still scenarios where a
|
||||
// converted range tombstone may be discarded:
|
||||
// * The snapshot's active mutable memtable has already become immutable.
|
||||
// * The iterator's snapshot seq is below the active memtable's earliest
|
||||
// sequence number.
|
||||
// * A range tombstone covering [first_tombstone_key, next_live_key) is
|
||||
// already present in the memtable.
|
||||
// * A WritePrepared/WriteUnprepared transaction read callback is in use
|
||||
// and the snapshot seq is at or above its min uncommitted seq.
|
||||
// * An IngestExternalFile call is currently in flight on this column
|
||||
// family OR the inserted range tombstone seqno would be lower than the
|
||||
// ingested file seqno.
|
||||
//
|
||||
// Read-write iterators using ReadOptions::table_filter are rejected while
|
||||
// this option is enabled, see more details in ReadOptions::table_filter
|
||||
|
||||
@@ -287,6 +287,10 @@ class CompactionFilter : public Customizable {
|
||||
// may not realize there is a write conflict and may allow a Transaction to
|
||||
// Commit that should have failed. Instead, it is better to implement any
|
||||
// Merge filtering inside the MergeOperator.
|
||||
//
|
||||
// Note: Under WritePrepared, WriteUnprepared, and WriteCommitted
|
||||
// TransactionDB, this method is skipped for operands at or below the latest
|
||||
// published sequence, typically every operand in the input.
|
||||
virtual bool FilterMergeOperand(int /*level*/, const Slice& /*key*/,
|
||||
const Slice& /*operand*/) const {
|
||||
return false;
|
||||
@@ -416,7 +420,7 @@ class CompactionFilter : public Customizable {
|
||||
|
||||
// Returns true if the filter overrides FilterV4 and can handle
|
||||
// WideColumnBlobResolver (lazy blob loading). When false (default),
|
||||
// FilterV4 delegates to FilterV3 with fully resolved column values —
|
||||
// FilterV4 delegates to FilterV3 with fully resolved column values --
|
||||
// blob columns are eagerly fetched before the filter is called, ensuring
|
||||
// backward compatibility with FilterV3-only filters.
|
||||
//
|
||||
|
||||
@@ -1868,6 +1868,13 @@ class DB {
|
||||
// and may not have file_creation_time property. In both the cases
|
||||
// file_creation_time is considered 0 which means this API will return
|
||||
// creation_time = 0 as there wouldn't be a timestamp lower than 0.
|
||||
//
|
||||
// NOTE: When the DB was opened with open_files_async = true, this API may
|
||||
// block to wait for background SST file loading to complete, but only when
|
||||
// necessary (i.e., when a file's manifest does not carry file_creation_time
|
||||
// and the value must come from the SST reader). Modern DBs return the real
|
||||
// value immediately. It is possible to have an incomplete result if the DB
|
||||
// is closed while files are still being opened in the background.
|
||||
virtual Status GetCreationTimeOfOldestFile(uint64_t* creation_time) = 0;
|
||||
|
||||
// Note: this API is not yet consistent with WritePrepared transactions.
|
||||
|
||||
@@ -235,18 +235,21 @@ struct ExternalTableBuilderOptions {
|
||||
const std::string db_id;
|
||||
const std::string db_session_id;
|
||||
const TableFileCreationReason reason;
|
||||
const std::shared_ptr<FileSystem>& fs;
|
||||
|
||||
ExternalTableBuilderOptions(
|
||||
const ReadOptions& _read_options, const WriteOptions& _write_options,
|
||||
const std::shared_ptr<const SliceTransform>& _prefix_extractor,
|
||||
const Comparator* _comparator, const std::string& _column_family_name,
|
||||
const TableFileCreationReason _reason)
|
||||
const TableFileCreationReason _reason,
|
||||
const std::shared_ptr<FileSystem>& _fs)
|
||||
: read_options(_read_options),
|
||||
write_options(_write_options),
|
||||
prefix_extractor(_prefix_extractor),
|
||||
comparator(_comparator),
|
||||
column_family_name(_column_family_name),
|
||||
reason(_reason) {}
|
||||
reason(_reason),
|
||||
fs(_fs) {}
|
||||
};
|
||||
|
||||
class ExternalTableFactory : public Customizable {
|
||||
|
||||
@@ -556,7 +556,7 @@ struct IOErrorInfo {
|
||||
uint64_t offset;
|
||||
};
|
||||
|
||||
// EXPERIMENTAL — under active development, fields may change.
|
||||
// EXPERIMENTAL -- under active development, fields may change.
|
||||
// Point-in-time snapshot of background job pressure for one DB: how busy
|
||||
// compaction and flush are, and how close the DB is to write-stalling.
|
||||
struct BackgroundJobPressure {
|
||||
|
||||
@@ -1001,6 +1001,36 @@ struct DBOptions {
|
||||
// This option is mutable with SetDBOptions().
|
||||
bool verify_manifest_content_on_close = false;
|
||||
|
||||
// EXPERIMENTAL: If true, RocksDB can reduce recovery work after a clean
|
||||
// shutdown, which may reduce DB::Open latency on warm reopens, especially on
|
||||
// storage where metadata appends are expensive.
|
||||
//
|
||||
// Best-effort optimization: if it is disabled or unavailable, RocksDB falls
|
||||
// back to the standard recovery path.
|
||||
//
|
||||
// Temporary rollout / kill switch for an optimization that is intended to be
|
||||
// correct and eventually always enabled. Mutable via SetDBOptions().
|
||||
bool optimize_manifest_for_recovery = false;
|
||||
|
||||
// EXPERIMENTAL: If true, DB::Open can try to reuse the existing MANIFEST
|
||||
// for the first post-open metadata update instead of creating a fresh one.
|
||||
// This can reduce warm-open latency for DBs whose MANIFEST is expensive to
|
||||
// rebuild.
|
||||
//
|
||||
// Best-effort optimization: even when enabled, RocksDB may still create a
|
||||
// fresh MANIFEST if the FileSystem does not support reopening the existing
|
||||
// MANIFEST for append, or if RocksDB decides reuse is unsafe. That fallback
|
||||
// is normal behavior.
|
||||
//
|
||||
// With very small `max_manifest_file_size` settings, the reused MANIFEST can
|
||||
// still rotate earlier than expected after open, because RocksDB may keep a
|
||||
// conservative auto-tuned rotation threshold until it later refreshes its
|
||||
// compacted-size estimate.
|
||||
//
|
||||
// Temporary rollout / kill switch while this optimization is being
|
||||
// validated.
|
||||
bool reuse_manifest_on_open = false;
|
||||
|
||||
// This option mostly replaces max_manifest_file_size to control an auto-tuned
|
||||
// balance of manifest write amplification and space amplification. A new
|
||||
// manifest file is created with the "compacted" contents of the old one when
|
||||
@@ -2922,7 +2952,7 @@ struct SizeApproximationOptions {
|
||||
// blob file data in the key range. When enabled, the total blob file size
|
||||
// is prorated by the ratio of SST data in the range to the total SST data:
|
||||
//
|
||||
// blob_size_in_range ≈ total_blob_size * (sst_in_range / total_sst)
|
||||
// blob_size_in_range ~= total_blob_size * (sst_in_range / total_sst)
|
||||
//
|
||||
// Limitations of this approximation:
|
||||
// - Assumes blob data is distributed proportionally to SST data, which
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "rocksdb/options.h"
|
||||
#include "rocksdb/slice.h"
|
||||
#include "rocksdb/table_properties.h"
|
||||
#include "rocksdb/types.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
@@ -56,6 +57,12 @@ class SstFileReader {
|
||||
// capabilities like filling cache, read ahead are disabled.
|
||||
std::unique_ptr<Iterator> NewTableIterator();
|
||||
|
||||
// Parses a raw key returned by NewTableIterator().
|
||||
// The Slice fields in parsed_key point into raw_table_key and are valid only
|
||||
// while raw_table_key remains valid.
|
||||
Status ParseTableIteratorKey(const Slice& raw_table_key,
|
||||
ParsedEntryInfo* parsed_key) const;
|
||||
|
||||
std::shared_ptr<const TableProperties> GetTableProperties() const;
|
||||
|
||||
// Verifies whether there is corruption in this table.
|
||||
|
||||
@@ -600,6 +600,9 @@ enum Tickers : uint32_t {
|
||||
// via FileOptions::file_metadata (on DB open / table cache miss)
|
||||
FILE_OPEN_METADATA_PASSED,
|
||||
|
||||
// # of times MANIFEST content validation detected corruption on DB close
|
||||
MANIFEST_VALIDATION_FAILURE_COUNT,
|
||||
|
||||
TICKER_ENUM_MAX
|
||||
};
|
||||
|
||||
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user