mirror of
https://github.com/facebook/rocksdb.git
synced 2026-11-06 19:12:36 -05:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8600f85f6 | ||
|
|
1f910525a7 | ||
|
|
5239c4caeb | ||
|
|
2e21612c3e | ||
|
|
a12bd7d641 | ||
|
|
a69e9fdb7e | ||
|
|
fa8f4f1ef0 | ||
|
|
d534974711 | ||
|
|
cc8d9ea04d | ||
|
|
1fd2c202c7 | ||
|
|
c28b4f0e12 | ||
|
|
4e2fe35bbb | ||
|
|
b3b7668bf5 | ||
|
|
b12d3a6da6 | ||
|
|
5ba6eb5497 | ||
|
|
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)
|
||||
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
|
||||
@@ -41,6 +41,9 @@ cpp_library_wrapper(name="rocksdb_lib", srcs=[
|
||||
"db/blob/blob_source.cc",
|
||||
"db/blob/blob_write_batch_transformer.cc",
|
||||
"db/blob/prefetch_buffer_collection.cc",
|
||||
"db/blog/blog_format.cc",
|
||||
"db/blog/blog_reader.cc",
|
||||
"db/blog/blog_writer.cc",
|
||||
"db/builder.cc",
|
||||
"db/c.cc",
|
||||
"db/coalescing_iterator.cc",
|
||||
@@ -4592,6 +4595,18 @@ cpp_unittest_wrapper(name="block_test",
|
||||
extra_compiler_flags=[])
|
||||
|
||||
|
||||
cpp_unittest_wrapper(name="blog_format_test",
|
||||
srcs=["db/blog/blog_format_test.cc"],
|
||||
deps=[":rocksdb_test_lib"],
|
||||
extra_compiler_flags=[])
|
||||
|
||||
|
||||
cpp_unittest_wrapper(name="blog_writer_test",
|
||||
srcs=["db/blog/blog_writer_test.cc"],
|
||||
deps=[":rocksdb_test_lib"],
|
||||
extra_compiler_flags=[])
|
||||
|
||||
|
||||
cpp_unittest_wrapper(name="bloom_test",
|
||||
srcs=["util/bloom_test.cc"],
|
||||
deps=[":rocksdb_test_lib"],
|
||||
|
||||
@@ -743,6 +743,9 @@ set(SOURCES
|
||||
db/blob/blob_source.cc
|
||||
db/blob/blob_write_batch_transformer.cc
|
||||
db/blob/prefetch_buffer_collection.cc
|
||||
db/blog/blog_format.cc
|
||||
db/blog/blog_reader.cc
|
||||
db/blog/blog_writer.cc
|
||||
db/builder.cc
|
||||
db/c.cc
|
||||
db/coalescing_iterator.cc
|
||||
@@ -1431,6 +1434,8 @@ if(WITH_TESTS)
|
||||
db/blob/db_blob_compaction_test.cc
|
||||
db/blob/db_blob_corruption_test.cc
|
||||
db/blob/db_blob_index_test.cc
|
||||
db/blog/blog_format_test.cc
|
||||
db/blog/blog_writer_test.cc
|
||||
db/column_family_test.cc
|
||||
db/compact_files_test.cc
|
||||
db/compaction/clipping_iterator_test.cc
|
||||
|
||||
+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.
|
||||
|
||||
@@ -1268,6 +1268,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 +1285,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 \
|
||||
@@ -1533,6 +1536,12 @@ db_logical_block_size_cache_test: $(OBJ_DIR)/db/db_logical_block_size_cache_test
|
||||
db_blob_index_test: $(OBJ_DIR)/db/blob/db_blob_index_test.o $(TEST_LIBRARY) $(LIBRARY)
|
||||
$(AM_LINK)
|
||||
|
||||
blog_format_test: $(OBJ_DIR)/db/blog/blog_format_test.o $(TEST_LIBRARY) $(LIBRARY)
|
||||
$(AM_LINK)
|
||||
|
||||
blog_writer_test: $(OBJ_DIR)/db/blog/blog_writer_test.o $(TEST_LIBRARY) $(LIBRARY)
|
||||
$(AM_LINK)
|
||||
|
||||
db_block_cache_test: $(OBJ_DIR)/db/db_block_cache_test.o $(TEST_LIBRARY) $(LIBRARY)
|
||||
$(AM_LINK)
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Blog File Format Specification - RocksDB</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; max-width: 960px; margin: 40px auto; padding: 0 20px; color: #222; line-height: 1.6; }
|
||||
h1 { border-bottom: 3px solid #333; padding-bottom: 8px; }
|
||||
h2 { border-bottom: 2px solid #999; padding-bottom: 4px; margin-top: 40px; }
|
||||
h3 { margin-top: 30px; }
|
||||
pre { background: #f4f4f4; border: 1px solid #ddd; border-radius: 4px; padding: 12px 16px; overflow-x: auto; font-size: 13px; line-height: 1.5; }
|
||||
code { background: #f0f0f0; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
|
||||
th, td { border: 1px solid #bbb; padding: 8px 12px; text-align: left; vertical-align: top; }
|
||||
th { background: #e8e8e8; font-weight: 600; }
|
||||
.diagram { background: #fafafa; border: 1px solid #ccc; border-radius: 4px; padding: 16px; margin: 16px 0; font-family: 'Courier New', monospace; font-size: 13px; white-space: pre; overflow-x: auto; line-height: 1.4; }
|
||||
.note { background: #fff8e1; border-left: 4px solid #ffc107; padding: 10px 14px; margin: 14px 0; }
|
||||
.decision { background: #e8f5e9; border-left: 4px solid #4caf50; padding: 10px 14px; margin: 14px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>"Blog" File Format Specification</h1>
|
||||
<p><em>A unified file format for WAL, Blob, and related data in RocksDB</em></p>
|
||||
<p>Status: Implementation in progress | Format name: "blog" (portmanteau of "blob" + "log")</p>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2>Vocabulary</h2>
|
||||
|
||||
<table>
|
||||
<tr><th>Term</th><th>Scope</th><th>Definition</th></tr>
|
||||
<tr><td><strong>Record</strong></td><td>Blog file</td><td>A top-level unit in the blog file body: blob record, WriteBatch record, preamble-start record, footer record.</td></tr>
|
||||
<tr><td><strong>Operation (op)</strong></td><td>WriteBatch</td><td>An entry within a WriteBatch: Put op, Delete op, Merge op, etc.</td></tr>
|
||||
<tr><td><strong>Compact format</strong></td><td>Record</td><td>A record with varint length ≤ 3 bytes. Omits the type byte (type from header).</td></tr>
|
||||
<tr><td><strong>Full format</strong></td><td>Record</td><td>A record with varint length > 3 bytes. Includes explicit type, pre-payload compression type, and prefix checksum.</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s1">File-Level Structure</h2>
|
||||
|
||||
<p>A blog file consists of three regions: a <strong>fixed header</strong> with a <strong>property section</strong>, a <strong>body</strong> of records, and an optional <strong>footer</strong> composed of footer records.</p>
|
||||
|
||||
<div class="diagram">
|
||||
+---------------------------------------------------------------------+
|
||||
| FILE HEADER (40 bytes fixed) |
|
||||
| 96-bit magic, schema version, checksum type, compact record type, |
|
||||
| flags, escape sequence, reserved, property section size, checksum |
|
||||
+---------------------------------------------------------------------+
|
||||
| HEADER PROPERTY SECTION |
|
||||
| Named key-value pairs + 5-byte trailer (compression + checksum) |
|
||||
| Followed by arbitrarily long padding (0x00 or 0xFF) |
|
||||
+---------------------------------------------------------------------+
|
||||
| FILE BODY |
|
||||
| Sequence of records, each starting with the escape sequence. |
|
||||
| Record types: blob, WriteBatch, preamble-start, footer records. |
|
||||
+---------------------------------------------------------------------+
|
||||
| FILE FOOTER (optional) |
|
||||
| Footer index records (0xFD), footer properties record (0xFE), |
|
||||
| footer locator record (0xFF) -- must be last. |
|
||||
+---------------------------------------------------------------------+</div>
|
||||
|
||||
<h3>Fixed Header (40 bytes)</h3>
|
||||
|
||||
<table>
|
||||
<tr><th>Offset</th><th>Size</th><th>Field</th></tr>
|
||||
<tr><td>0</td><td>12</td><td>File magic: <code>kBlogFileMagic</code> -- opaque 12 bytes ("Blog" ASCII + XXH3_64bits("Blog"))</td></tr>
|
||||
<tr><td>12</td><td>1</td><td>Schema version (currently 0). Newer versions are rejected by older readers.</td></tr>
|
||||
<tr><td>13</td><td>1</td><td>Checksum type (<code>ChecksumType</code> enum: 0=none, 1=CRC32c, 4=XXH3). Configured via <code>DBOptions::blog_checksum</code>.</td></tr>
|
||||
<tr><td>14</td><td>1</td><td>Compact record type (<code>BlogRecordType</code> -- type for all compact-format records)</td></tr>
|
||||
<tr><td>15</td><td>1</td><td>Flags: bit 0 = <code>kBlogFileRecycled</code> (file may contain trailing stale data)</td></tr>
|
||||
<tr><td>16</td><td>10</td><td>Escape sequence: bytes [0-5] random (byte[0] not 0x00/0xFF), bytes [6-9] = lower32(XXH3_64bits_withSeed(bytes[0-5], kBlogEscapeSeqSeed)). First 4 bytes also serve as incarnation ID (little-endian via DecodeFixed32).</td></tr>
|
||||
<tr><td>26</td><td>6</td><td>Reserved (zero)</td></tr>
|
||||
<tr><td>32</td><td>4</td><td>Property section size (uint32 LE) -- includes 5-byte trailer</td></tr>
|
||||
<tr><td>36</td><td>4</td><td>Fixed header checksum -- covers bytes [0, 36)</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Header Property Section</h3>
|
||||
|
||||
<p>Immediately follows the fixed header. Contains named key-value pairs encoded as:</p>
|
||||
<pre>[name_len: varint32] [name: name_len bytes] [value_len: varint32] [value: value_len bytes] ...</pre>
|
||||
<p>Followed by a 5-byte trailer: <code>[compression_type: 1B][checksum: 4B]</code> (same as block-based table). The checksum covers the property data + compression_type, with a context checksum modifier using the incarnation ID at offset <code>kBlogFileFixedHeaderSize</code>.</p>
|
||||
|
||||
<p>After the property section, arbitrarily long padding (0x00 or 0xFF, differing from the last meaningful byte) aligns the first record's escape sequence. This supports stronger alignment requirements (flush/sync boundaries) or recycling files of all zeros.</p>
|
||||
|
||||
<h3>Property Name Convention</h3>
|
||||
<table>
|
||||
<tr><th>First letter</th><th>Meaning</th></tr>
|
||||
<tr><td>Uppercase (e.g. <code>"CompressionCompatibilityName"</code>)</td><td><strong>Required</strong> -- reader must recognize or reject the file</td></tr>
|
||||
<tr><td>Lowercase (e.g. <code>"role"</code>)</td><td><strong>Ignorable</strong> -- reader may skip unrecognized properties</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Known Header Properties</h3>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Required?</th><th>Description</th></tr>
|
||||
<tr><td><code>"CompressionCompatibilityName"</code></td><td>Required (if any compression)</td><td>Compression manager compatibility name (e.g. "builtin_v2")</td></tr>
|
||||
<tr><td><code>"WriteBatchStreamingCompressionType"</code></td><td>Required (if streaming active)</td><td>2-digit hex of CompressionType for streaming WriteBatch compression (e.g. "07" for ZSTD). Presence means WriteBatch records with <code>kStreamingCompressionSentinel</code> (0x7F) trailer are part of a cross-record streaming context.</td></tr>
|
||||
<tr><td><code>"TimestampSize"</code></td><td>Required (if >0)</td><td>Size of user timestamps</td></tr>
|
||||
<tr><td><code>"PredecessorWalInfo"</code></td><td>Required (if tracking)</td><td>Predecessor WAL metadata for verification</td></tr>
|
||||
<tr><td><code>"role"</code></td><td>Ignorable</td><td>File purpose hint: "wal", "blob", "rlog"</td></tr>
|
||||
<tr><td><code>"compressionSettings"</code></td><td>Ignorable</td><td>Human-readable compression settings for debugging</td></tr>
|
||||
<tr><td><code>"creationTime"</code></td><td>Ignorable</td><td>Unix timestamp of file creation</td></tr>
|
||||
<tr><td><code>"creator"</code></td><td>Ignorable</td><td>Host/process identifier for debugging</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s2">Escape Sequences</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Decision:</strong> 10-byte escape sequence: 6 random bytes + 4 derived bytes. First byte not 0x00 nor 0xFF (distinguishable from padding). Bytes [6-9] = lower32(XXH3_64bits_withSeed(bytes[0-5], kBlogEscapeSeqSeed)), enabling heuristic detection without knowing the header.
|
||||
</div>
|
||||
|
||||
<p>Escape sequences must be <strong>32-bit aligned</strong> in the file. The first 4 bytes (little-endian) serve as the <strong>incarnation ID</strong> for context checksums.</p>
|
||||
|
||||
<h3>Primary Roles</h3>
|
||||
<ul>
|
||||
<li><strong>WAL recycling detection:</strong> When recovery encounters undecodable data, scan for our escape sequence. If not found, the data is stale/recycled (different random sequence). The <code>kBlogFileRecycled</code> flag in the header indicates recycling is expected.</li>
|
||||
<li><strong>Non-linear recovery:</strong> After corruption, scan forward for the next escape sequence to resume recovery (<code>kSkipAnyCorruptedRecords</code>).</li>
|
||||
<li><strong>Record boundary discovery:</strong> Find record boundaries from any file position.</li>
|
||||
</ul>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s3">Record Types and Formats</h2>
|
||||
|
||||
<h3>Record Type Values</h3>
|
||||
<table>
|
||||
<tr><th>Value</th><th>Name</th><th>Usage</th></tr>
|
||||
<tr><td colspan="3"><em>Body record types (0x01-0x7F)</em></td></tr>
|
||||
<tr><td>0x01</td><td><code>kBlogBlobRecord</code></td><td>Blob data (typically a separated value)</td></tr>
|
||||
<tr><td>0x02</td><td><code>kBlogPreambleStartRecord</code></td><td>Preamble marker (stub)</td></tr>
|
||||
<tr><td>0x03</td><td><code>kBlogWriteBatchRecord</code></td><td>Serialized WriteBatch</td></tr>
|
||||
<tr><td>0x04-0x7F</td><td></td><td><em>Reserved for future body record types</em></td></tr>
|
||||
<tr><td colspan="3"><em>Footer record types (0xF0-0xFF, in expected order of occurrence with gaps)</em></td></tr>
|
||||
<tr><td>0xF0</td><td><code>kBlogFooterIndexRecord</code></td><td>Large footer metadata (sparse index)</td></tr>
|
||||
<tr><td>0xF1-0xF3</td><td></td><td><em>Reserved for future footer data records</em></td></tr>
|
||||
<tr><td>0xF4-0xF7</td><td></td><td><em>Reserved for future footer data records</em></td></tr>
|
||||
<tr><td>0xF8</td><td><code>kBlogFooterPropertiesRecord</code></td><td>Small footer metadata (named properties)</td></tr>
|
||||
<tr><td>0xF9-0xFB</td><td></td><td><em>Reserved</em></td></tr>
|
||||
<tr><td>0xFC</td><td><code>kBlogFooterFileChecksumInfo</code></td><td>Full file checksum state dump. Allows inferring the file checksum from just reading the tail. Placeholder for future use.</td></tr>
|
||||
<tr><td>0xFD-0xFE</td><td></td><td><em>Reserved</em></td></tr>
|
||||
<tr><td>0xFF</td><td><code>kBlogFooterLocatorRecord</code></td><td>Footer locator with relative offsets. Must be the last record in the file.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Length Semantics</h3>
|
||||
<ul>
|
||||
<li><code>length = 0</code>: No payload and no 5-byte trailer. Record is escape_seq + prefix fields + padding.</li>
|
||||
<li><code>length > 0</code>: Payload of <code>length</code> bytes followed by 5-byte trailer.</li>
|
||||
<li><code>length = 2^63 - 1</code>: Unspecified size. Reader scans forward for next escape sequence.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Compact Record Format (varint length ≤ 2 bytes, default record type)</h3>
|
||||
<div class="diagram">
|
||||
[escape_seq: 10B] [varint length: 1-2B]
|
||||
[payload: length bytes]
|
||||
[compression_type: 1B] [checksum: 4B]
|
||||
[padding: 0+ B]</div>
|
||||
<p>No type byte -- type comes from the header's <code>compact_record_type</code> field. Used for payloads < 16 KiB.</p>
|
||||
|
||||
<h3>Full Record Format (varint length > 2 bytes, or forced)</h3>
|
||||
<div class="diagram">
|
||||
[escape_seq: 10B] [varint length: 3+B] [type: 1B] [compression_type: 1B]
|
||||
[prefix_checksum: 4B]
|
||||
[payload: length bytes] (if length > 0)
|
||||
[compression_type: 1B] [checksum: 4B] (if length > 0)
|
||||
[padding: 0+ B]</div>
|
||||
<p>The <code>prefix_checksum</code> covers (varint + type + compression_type), with a context checksum modifier for defense-in-depth. Validates the length before attempting a potentially large read. The pre-payload <code>compression_type</code> enables streaming decompression without seeking to the trailer.</p>
|
||||
|
||||
<p>Records that don't match the compact_record_type (e.g. footer records, preamble-start) always use full format by encoding the varint with ≥ 3 bytes (irregular encoding).</p>
|
||||
|
||||
<p>All checksums in blog files (fixed header, property section trailer, record prefix, record trailer) use context checksum modifiers incorporating the incarnation ID, providing defense-in-depth for misplaced data detection even with <code>kNoChecksum</code>.</p>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s4">Compression</h2>
|
||||
|
||||
<h3>Per-Record Compression</h3>
|
||||
<p>Each record's trailer <code>compression_type</code> byte indicates the compression algorithm used for that record's payload. The actual type comes from <code>Compressor::CompressBlock</code> output, not the configured type. This is the same approach used by SST block-based table builders.</p>
|
||||
|
||||
<h3>Streaming Compression (WriteBatch records only)</h3>
|
||||
<p>When the header property <code>WriteBatchStreamingCompressionType</code> is set, WriteBatch record payloads may use streaming compression across records. Such records use <code>kStreamingCompressionSentinel</code> (0x7F) as their trailer's compression_type byte, indicating "compressed using the streaming algorithm declared in the header."</p>
|
||||
|
||||
<p>A record with <code>kNoCompression</code> (0x00) in the trailer remains individually uncompressed even when streaming is active, enabling dynamic on/off switching based on compression effectiveness.</p>
|
||||
|
||||
<p>Non-WriteBatch records (blob records, footer records) are always outside the streaming context and use per-record compression, keeping blob data randomly readable.</p>
|
||||
|
||||
<div class="note">
|
||||
Currently only ZSTD is supported for streaming compression. The <code>WriteBatchStreamingCompressionType</code> property is required (uppercase first letter), so older readers that don't understand it will reject the file rather than misinterpret streaming-compressed payloads.
|
||||
</div>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s5">Padding Scheme</h2>
|
||||
|
||||
<p>Padding fills the gap between a record's trailer (or the header property section) and the next escape sequence. The padding byte value is chosen to <strong>differ from the last meaningful byte</strong>, enabling unambiguous scanning.</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Last meaningful byte</th><th>Pad with</th><th>Mandatory?</th></tr>
|
||||
<tr><td><code>0x00</code></td><td><code>0xFF</code></td><td>Yes -- at least 1 byte required</td></tr>
|
||||
<tr><td><code>0xFF</code></td><td><code>0x00</code></td><td>Yes -- at least 1 byte required</td></tr>
|
||||
<tr><td>Anything else</td><td><code>0x00</code> (writer's choice)</td><td>Only if needed for alignment</td></tr>
|
||||
</table>
|
||||
|
||||
<p>Padding may be arbitrarily long for stronger alignment requirements (e.g. flush/sync boundaries) or for recycling a file of all zeros. The reader skips padding by scanning forward over 0x00/0xFF bytes in 4-byte-aligned chunks until finding a byte that is neither, which is the first byte of the next escape sequence (guaranteed to be neither 0x00 nor 0xFF).</p>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s6">Context Checksums</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Decision (adopted from SST format_version=6):</strong> Each record's checksum incorporates a 32-bit <strong>incarnation ID</strong> (first 4 bytes of the escape sequence). Uses <code>ChecksumModifierForContext(incarnation_id, record_offset)</code> -- the same mechanism as block-based SST files.
|
||||
</div>
|
||||
|
||||
<p>Context checksums provide defense-in-depth for recycling detection and general data integrity verification with zero per-record overhead.</p>
|
||||
|
||||
<p>The checksum type is configured from the CF's <code>BlockBasedTableOptions::checksum</code> setting (for blob files) or the default CF's setting (for WAL files). This is a reasonable but hopefully temporary way of configuring blog file internal checksums; a dedicated option may be added later.</p>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s7">Footer</h2>
|
||||
|
||||
<p>The footer is composed of <strong>regular records</strong> using the full record format, maintaining simple forward parsability. A file is cleanly sealed when a valid footer locator record is the last record. Footer records appear in the order listed below.</p>
|
||||
|
||||
<ol>
|
||||
<li><strong>Footer index records</strong> (0xF0): Large metadata like sparse indexes for range queries. Zero or more per file.</li>
|
||||
<li><strong>Footer properties record</strong> (0xF8): Named properties (same encoding as header properties, but the full record format's own trailer provides integrity). At most one per file.</li>
|
||||
<li><strong>Footer file checksum info</strong> (0xFC): Placeholder for future use. Would contain a dump of the full file checksum state, allowing the file checksum to be inferred from just reading the tail of the file.</li>
|
||||
<li><strong>Footer locator record</strong> (0xFF): Must be the <strong>last record</strong>. Contains relative offsets (in 4-byte units) to other footer records, sequential relative to each other in reverse order.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Footer Locator Payload</h3>
|
||||
<pre>[record_type: 1B] [relative_offset_in_4B_units: uint32 LE] ...</pre>
|
||||
<p>First entry: distance from locator's escape_seq backward to the preceding footer record. Subsequent: from the previous entry backward to the next.</p>
|
||||
|
||||
<h3>Known Footer Properties</h3>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Description</th></tr>
|
||||
<tr><td><code>"blobCount"</code></td><td>Number of blob records</td></tr>
|
||||
<tr><td><code>"totalBlobBytes"</code></td><td>Total uncompressed blob payload bytes</td></tr>
|
||||
<tr><td><code>"totalBlobBytesCompressed"</code></td><td>Total compressed blob payload bytes on disk</td></tr>
|
||||
<tr><td><code>"totalWriteBatchBytes"</code></td><td>Total uncompressed WriteBatch payload bytes</td></tr>
|
||||
<tr><td><code>"totalWriteBatchBytesCompressed"</code></td><td>Total compressed WriteBatch payload bytes on disk</td></tr>
|
||||
<tr><td><code>"minSequence"</code></td><td>Minimum sequence number</td></tr>
|
||||
<tr><td><code>"maxSequence"</code></td><td>Maximum sequence number</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s8">WAL Recovery Modes</h2>
|
||||
|
||||
<p>The escape sequence is the <strong>primary mechanism</strong> for detecting WAL recycling and for non-linear data recovery. The <code>kBlogFileRecycled</code> flag in the header indicates the file may contain trailing stale data from a previous incarnation.</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th style="width:22%">Mode</th>
|
||||
<th style="width:48%">Behavior on Undecodable Data</th>
|
||||
<th style="width:30%">Escape Sequence Role</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>kAbsoluteConsistency</code></td>
|
||||
<td>Error on any failure, including incomplete trailing records. If recycled and no escape sequence found in trailing data, accept as clean end.</td>
|
||||
<td>Distinguishes stale recycled data from corruption.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>kTolerateCorruptedTailRecords</code></td>
|
||||
<td>Incomplete trailing record (Incomplete status): tolerate, stop at last complete record. Corruption: scan for escape sequence. Not found = clean end (recycled or preallocated). Found with valid record beyond = mid-file corruption (error).</td>
|
||||
<td>Distinguishes tail damage from mid-file corruption.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>kPointInTimeRecovery</code> (default)</td>
|
||||
<td>Stop at first inconsistency. Incomplete trailing records tolerated. Recycled trailing data accepted.</td>
|
||||
<td>Diagnostic improvement.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>kSkipAnyCorruptedRecords</code></td>
|
||||
<td>On corruption, scan forward for next escape sequence. If found, try to decode and verify; resume if valid. If not found, stop.</td>
|
||||
<td>Essential for record discovery after corruption.</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s9">WriteBatch Blob Preamble</h2>
|
||||
|
||||
<p>A <strong>blob preamble</strong> is a sequence of blob records preceded by a preamble-start record (type 0x02, full format, length=0) and followed by the associated WriteBatch record. The blob records contain separated values referenced by <code>file_number = 0</code> blob references in the WriteBatch.</p>
|
||||
|
||||
<p>This functionality is stubbed in the current implementation: the preamble-start record type is defined and can be written/read, but the full preamble flow (in-memory serialization, blob reference encoding) is deferred.</p>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s10">Replication Log Column Family (ShallowL0Store)</h2>
|
||||
|
||||
<p>Deferred. The blog format supports a "rlog" role hint for replication log storage, with sparse indexes in footer records and in-memory index maintenance. See design notes in the original proposal.</p>
|
||||
|
||||
<!-- ======================================================================= -->
|
||||
<h2 id="s11">Implementation Status</h2>
|
||||
|
||||
<h3>Resolved (from original Open Questions)</h3>
|
||||
<table>
|
||||
<tr><th>Question</th><th>Resolution</th></tr>
|
||||
<tr><td>Escape sequence size</td><td>10 bytes: 6 random + 4 derived. First byte not 0x00/0xFF.</td></tr>
|
||||
<tr><td>Header/footer encoding</td><td>40-byte fixed header + named property section with 5-byte trailer. Footer uses regular full-format records: index (0xF0), properties (0xF8), file checksum info (0xFC, placeholder), locator (0xFF). Numbered in expected occurrence order with gaps for future types.</td></tr>
|
||||
<tr><td>Preamble-start record format</td><td>Full format, length=0, type 0x02. Minimal -- just the type tag.</td></tr>
|
||||
<tr><td>WriteBatch record framing</td><td>Same compact/full format as blob records, type 0x03.</td></tr>
|
||||
<tr><td>Compact vs full format</td><td>Compact: varint ≤ 2B (payload < 16 KiB), matches compact_record_type, no type byte. Full: varint > 2B (possibly irregular), includes type + pre-payload compression_type + prefix_checksum with context modifier.</td></tr>
|
||||
<tr><td>Checksum context</td><td>All checksums (fixed header, property section, record prefix, record trailer) use context checksum modifiers with the incarnation ID, ensuring nonzero stored values even with <code>kNoChecksum</code>.</td></tr>
|
||||
<tr><td>Schema version</td><td>Starts at 0. Newer versions are rejected by older readers (returns <code>NotSupported</code>). Checksum is verified before schema version is checked.</td></tr>
|
||||
<tr><td>Streaming compression sentinel</td><td><code>kStreamingCompressionSentinel = 0x7F</code> defined in <code>CompressionType</code> enum (compression_type.h). Used in blog record trailers for streaming-compressed WriteBatch payloads.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>DB Options</h3>
|
||||
<ul>
|
||||
<li><code>use_blog_format_for_wals</code> (bool, default false): New WAL files use blog format.</li>
|
||||
<li><code>use_blog_format_for_blobs</code> (bool, default false): New blob files use blog format.</li>
|
||||
<li><code>blog_checksum</code> (<code>ChecksumType</code>, default <code>kXXH3</code>): Checksum algorithm for blog format files. <code>ChecksumType</code> enum moved to <code>include/rocksdb/checksum_type.h</code> (from <code>table.h</code>) for use by both table and DB options.</li>
|
||||
</ul>
|
||||
<p>All are immutable <code>DBOptions</code>. Existing legacy WAL and blob files are always readable regardless of these settings.</p>
|
||||
|
||||
<h3>Compression Manager Integration</h3>
|
||||
<ul>
|
||||
<li><strong>Blog blob files</strong> use the CF's configured compression manager (via <code>MutableCFOptions::compression_manager</code>). Legacy blob files continue to use the builtin manager for backward compatibility.</li>
|
||||
<li><strong>Blog WAL files</strong> use per-record compression via <code>Compressor::CompressBlock</code>. The actual <code>CompressionType</code> from <code>CompressBlock</code> output is stored in the record trailer.</li>
|
||||
<li>Streaming compression infrastructure exists (<code>WriteBatchStreamingCompressionType</code> property, <code>kStreamingCompressionSentinel</code> 0x7F trailer value) but is not yet enabled by default.</li>
|
||||
</ul>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)
|
||||
|
||||
+169
-44
@@ -14,6 +14,8 @@
|
||||
#include "db/blob/blob_log_format.h"
|
||||
#include "db/blob/blob_log_writer.h"
|
||||
#include "db/blob/blob_source.h"
|
||||
#include "db/blog/blog_format.h"
|
||||
#include "db/blog/blog_writer.h"
|
||||
#include "db/event_helpers.h"
|
||||
#include "db/version_set.h"
|
||||
#include "file/filename.h"
|
||||
@@ -67,10 +69,13 @@ BlobFileBuilder::BlobFileBuilder(
|
||||
min_blob_size_(mutable_cf_options->min_blob_size),
|
||||
blob_file_size_(mutable_cf_options->blob_file_size),
|
||||
blob_compression_type_(mutable_cf_options->blob_compression_type),
|
||||
// TODO with schema change: support custom compression manager and options
|
||||
// such as max_compressed_bytes_per_kb
|
||||
// NOTE: returns nullptr for kNoCompression
|
||||
blob_compressor_(GetBuiltinV2CompressionManager()->GetCompressor(
|
||||
// Blog format uses the CF's compression manager; legacy format must
|
||||
// use the builtin for compatibility.
|
||||
blob_compression_manager_(immutable_options->use_blog_format_for_blobs &&
|
||||
mutable_cf_options->compression_manager
|
||||
? mutable_cf_options->compression_manager
|
||||
: GetBuiltinV2CompressionManager()),
|
||||
blob_compressor_(blob_compression_manager_->GetCompressor(
|
||||
mutable_cf_options->blob_compression_opts, blob_compression_type_)),
|
||||
blob_compressor_wa_(blob_compressor_
|
||||
? blob_compressor_->ObtainWorkingArea()
|
||||
@@ -123,8 +128,10 @@ Status BlobFileBuilder::Add(const Slice& key, const Slice& value,
|
||||
Slice blob = value;
|
||||
GrowableBuffer compressed_blob;
|
||||
|
||||
{
|
||||
const Status s = CompressBlobIfNeeded(&blob, &compressed_blob);
|
||||
if (!blog_writer_) {
|
||||
// Legacy format: compress before writing (compression type is implicit
|
||||
// from the configured blob_compression_type_).
|
||||
const Status s = LegacyCompressBlob(&blob, &compressed_blob);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
@@ -132,10 +139,13 @@ Status BlobFileBuilder::Add(const Slice& key, const Slice& value,
|
||||
|
||||
uint64_t blob_file_number = 0;
|
||||
uint64_t blob_offset = 0;
|
||||
uint64_t blob_on_disk_size = 0;
|
||||
CompressionType actual_compression_type = blob_compression_type_;
|
||||
|
||||
{
|
||||
const Status s =
|
||||
WriteBlobToFile(key, blob, &blob_file_number, &blob_offset);
|
||||
WriteBlobToFile(key, blob, &blob_file_number, &blob_offset,
|
||||
&blob_on_disk_size, &actual_compression_type);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
@@ -158,8 +168,8 @@ Status BlobFileBuilder::Add(const Slice& key, const Slice& value,
|
||||
}
|
||||
}
|
||||
|
||||
BlobIndex::EncodeBlob(blob_index, blob_file_number, blob_offset, blob.size(),
|
||||
blob_compression_type_);
|
||||
BlobIndex::EncodeBlob(blob_index, blob_file_number, blob_offset,
|
||||
blob_on_disk_size, actual_compression_type);
|
||||
|
||||
return Status::OK();
|
||||
}
|
||||
@@ -172,7 +182,9 @@ Status BlobFileBuilder::Finish() {
|
||||
return CloseBlobFile();
|
||||
}
|
||||
|
||||
bool BlobFileBuilder::IsBlobFileOpen() const { return !!writer_; }
|
||||
bool BlobFileBuilder::IsBlobFileOpen() const {
|
||||
return !!writer_ || !!blog_writer_;
|
||||
}
|
||||
|
||||
Status BlobFileBuilder::OpenBlobFileIfNeeded() {
|
||||
if (IsBlobFileOpen()) {
|
||||
@@ -231,37 +243,73 @@ Status BlobFileBuilder::OpenBlobFileIfNeeded() {
|
||||
immutable_options_->file_checksum_gen_factory.get(),
|
||||
tmp_set.Contains(FileType::kBlobFile), false));
|
||||
|
||||
constexpr bool do_flush = false;
|
||||
blob_file_number_ = blob_file_number;
|
||||
|
||||
std::unique_ptr<BlobLogWriter> blob_log_writer(new BlobLogWriter(
|
||||
std::move(file_writer), immutable_options_->clock, statistics,
|
||||
blob_file_number, immutable_options_->use_fsync, do_flush));
|
||||
if (immutable_options_->use_blog_format_for_blobs) {
|
||||
// Blog format path
|
||||
BlogFileHeader blog_header;
|
||||
blog_header.checksum_type = immutable_options_->blog_checksum;
|
||||
blog_header.compact_record_type = kBlogBlobRecord;
|
||||
blog_header.GenerateFromUniqueId(db_id_, db_session_id_, blob_file_number);
|
||||
blog_header.SetProperty(kBlogPropRole, "blob");
|
||||
if (!immutable_options_->db_host_id.empty()) {
|
||||
blog_header.SetProperty(kBlogPropDbHostId,
|
||||
immutable_options_->db_host_id);
|
||||
}
|
||||
blog_header.SetUint32Property(kBlogPropColumnFamilyId, column_family_id_);
|
||||
if (!column_family_name_.empty()) {
|
||||
blog_header.SetProperty(kBlogPropColumnFamilyName, column_family_name_);
|
||||
}
|
||||
if (blob_compressor_) {
|
||||
blog_header.SetProperty(kBlogPropCompressionCompatibilityName,
|
||||
blob_compression_manager_->CompatibilityName());
|
||||
}
|
||||
|
||||
constexpr bool has_ttl = false;
|
||||
constexpr ExpirationRange expiration_range;
|
||||
blog_writer_ =
|
||||
std::make_unique<BlogFileWriter>(std::move(file_writer), blog_header);
|
||||
|
||||
BlobLogHeader header(column_family_id_, blob_compression_type_, has_ttl,
|
||||
expiration_range);
|
||||
|
||||
{
|
||||
Status s = blob_log_writer->WriteHeader(*write_options_, header);
|
||||
Status s = blog_writer_->WriteHeader(*write_options_);
|
||||
|
||||
TEST_SYNC_POINT_CALLBACK(
|
||||
"BlobFileBuilder::OpenBlobFileIfNeeded:WriteHeader", &s);
|
||||
"BlobFileBuilder::OpenBlobFileIfNeeded:WriteBlogHeader", &s);
|
||||
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy blob log format path
|
||||
constexpr bool do_flush = false;
|
||||
|
||||
writer_ = std::move(blob_log_writer);
|
||||
std::unique_ptr<BlobLogWriter> blob_log_writer(
|
||||
new BlobLogWriter(std::move(file_writer), immutable_options_->clock,
|
||||
statistics, immutable_options_->use_fsync, do_flush));
|
||||
|
||||
constexpr bool has_ttl = false;
|
||||
constexpr ExpirationRange expiration_range;
|
||||
|
||||
BlobLogHeader header(column_family_id_, blob_compression_type_, has_ttl,
|
||||
expiration_range);
|
||||
|
||||
{
|
||||
Status s = blob_log_writer->WriteHeader(*write_options_, header);
|
||||
|
||||
TEST_SYNC_POINT_CALLBACK(
|
||||
"BlobFileBuilder::OpenBlobFileIfNeeded:WriteHeader", &s);
|
||||
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
writer_ = std::move(blob_log_writer);
|
||||
}
|
||||
|
||||
assert(IsBlobFileOpen());
|
||||
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
Status BlobFileBuilder::CompressBlobIfNeeded(
|
||||
Status BlobFileBuilder::LegacyCompressBlob(
|
||||
Slice* blob, GrowableBuffer* compressed_blob) const {
|
||||
assert(blob);
|
||||
assert(compressed_blob);
|
||||
@@ -290,17 +338,48 @@ Status BlobFileBuilder::CompressBlobIfNeeded(
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
Status BlobFileBuilder::WriteBlobToFile(const Slice& key, const Slice& blob,
|
||||
uint64_t* blob_file_number,
|
||||
uint64_t* blob_offset) {
|
||||
Status BlobFileBuilder::WriteBlobToFile(
|
||||
const Slice& key, const Slice& blob, uint64_t* blob_file_number,
|
||||
uint64_t* blob_offset, uint64_t* blob_on_disk_size,
|
||||
CompressionType* actual_compression_type) {
|
||||
assert(IsBlobFileOpen());
|
||||
assert(blob_file_number);
|
||||
assert(blob_offset);
|
||||
assert(blob_on_disk_size);
|
||||
assert(actual_compression_type);
|
||||
|
||||
uint64_t key_offset = 0;
|
||||
Status s;
|
||||
|
||||
Status s =
|
||||
writer_->AddRecord(*write_options_, key, blob, &key_offset, blob_offset);
|
||||
if (blog_writer_) {
|
||||
// Blog format: compress here using CompressBlock (like SST) so we
|
||||
// capture the actual compression type used in the record trailer.
|
||||
CompressionType actual_type = kNoCompression;
|
||||
GrowableBuffer compressed;
|
||||
if (blob_compressor_) {
|
||||
StopWatch stop_watch(immutable_options_->clock, immutable_options_->stats,
|
||||
BLOB_DB_COMPRESSION_MICROS);
|
||||
size_t max_compressed_size = blob.size();
|
||||
compressed.ResetForSize(max_compressed_size);
|
||||
s = blob_compressor_->CompressBlock(blob, compressed.data(),
|
||||
&compressed.MutableSize(),
|
||||
&actual_type, &blob_compressor_wa_);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
Slice write_data =
|
||||
(actual_type != kNoCompression) ? Slice(compressed) : blob;
|
||||
s = blog_writer_->AddBlobRecord(*write_options_, write_data, actual_type,
|
||||
blob_offset);
|
||||
*blob_on_disk_size = write_data.size();
|
||||
*actual_compression_type = actual_type;
|
||||
} else {
|
||||
uint64_t key_offset = 0;
|
||||
s = writer_->AddRecord(*write_options_, key, blob, &key_offset,
|
||||
blob_offset);
|
||||
*blob_on_disk_size = blob.size(); // legacy: already compressed by caller
|
||||
*actual_compression_type = blob_compression_type_;
|
||||
}
|
||||
|
||||
TEST_SYNC_POINT_CALLBACK("BlobFileBuilder::WriteBlobToFile:AddRecord", &s);
|
||||
|
||||
@@ -308,10 +387,16 @@ Status BlobFileBuilder::WriteBlobToFile(const Slice& key, const Slice& blob,
|
||||
return s;
|
||||
}
|
||||
|
||||
*blob_file_number = writer_->get_log_number();
|
||||
*blob_file_number = blob_file_number_;
|
||||
|
||||
++blob_count_;
|
||||
blob_bytes_ += BlobLogRecord::kHeaderSize + key.size() + blob.size();
|
||||
if (blog_writer_) {
|
||||
// Blog format: blob_bytes_ tracks uncompressed payload size only
|
||||
// (no legacy record header, no key).
|
||||
blob_bytes_ += blob.size();
|
||||
} else {
|
||||
blob_bytes_ += BlobLogRecord::kHeaderSize + key.size() + blob.size();
|
||||
}
|
||||
|
||||
return Status::OK();
|
||||
}
|
||||
@@ -319,22 +404,60 @@ Status BlobFileBuilder::WriteBlobToFile(const Slice& key, const Slice& blob,
|
||||
Status BlobFileBuilder::CloseBlobFile() {
|
||||
assert(IsBlobFileOpen());
|
||||
|
||||
BlobLogFooter footer;
|
||||
footer.blob_count = blob_count_;
|
||||
|
||||
std::string checksum_method;
|
||||
std::string checksum_value;
|
||||
Status s;
|
||||
|
||||
Status s = writer_->AppendFooter(*write_options_, footer, &checksum_method,
|
||||
&checksum_value);
|
||||
if (blog_writer_) {
|
||||
// Blog format: write footer properties and locator records.
|
||||
BlogFileFooterProperties footer_props;
|
||||
footer_props.SetBlobCount(blob_count_);
|
||||
footer_props.SetTotalBlobBytes(blob_bytes_);
|
||||
|
||||
TEST_SYNC_POINT_CALLBACK("BlobFileBuilder::WriteBlobToFile:AppendFooter", &s);
|
||||
uint64_t props_offset = blog_writer_->current_offset();
|
||||
s = blog_writer_->AddFooterPropertiesRecord(*write_options_, footer_props);
|
||||
|
||||
if (s.ok()) {
|
||||
BlogFileFooterLocator locator;
|
||||
uint64_t locator_offset = blog_writer_->current_offset();
|
||||
locator.entries.push_back(
|
||||
{kBlogFooterPropertiesRecord,
|
||||
static_cast<uint32_t>((locator_offset - props_offset) / 4)});
|
||||
s = blog_writer_->AddFooterLocatorRecord(*write_options_, locator);
|
||||
}
|
||||
|
||||
if (s.ok()) {
|
||||
s = blog_writer_->Sync(*write_options_);
|
||||
}
|
||||
if (s.ok()) {
|
||||
s = blog_writer_->Close(*write_options_);
|
||||
}
|
||||
if (s.ok()) {
|
||||
std::string method = blog_writer_->file()->GetFileChecksumFuncName();
|
||||
if (method != kUnknownFileChecksumFuncName) {
|
||||
checksum_method = std::move(method);
|
||||
}
|
||||
std::string value = blog_writer_->file()->GetFileChecksum();
|
||||
if (value != kUnknownFileChecksum) {
|
||||
checksum_value = std::move(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BlobLogFooter footer;
|
||||
footer.blob_count = blob_count_;
|
||||
|
||||
s = writer_->LegacyAppendFooterAndClose(*write_options_, footer,
|
||||
&checksum_method, &checksum_value);
|
||||
}
|
||||
|
||||
TEST_SYNC_POINT_CALLBACK(
|
||||
"BlobFileBuilder::WriteBlobToFile:LegacyAppendFooterAndClose", &s);
|
||||
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
const uint64_t blob_file_number = writer_->get_log_number();
|
||||
const uint64_t blob_file_number = blob_file_number_;
|
||||
|
||||
if (blob_callback_) {
|
||||
s = blob_callback_->OnBlobFileCompleted(
|
||||
@@ -356,6 +479,7 @@ Status BlobFileBuilder::CloseBlobFile() {
|
||||
blob_count_, blob_bytes_);
|
||||
|
||||
writer_.reset();
|
||||
blog_writer_.reset();
|
||||
blob_count_ = 0;
|
||||
blob_bytes_ = 0;
|
||||
|
||||
@@ -365,7 +489,8 @@ Status BlobFileBuilder::CloseBlobFile() {
|
||||
Status BlobFileBuilder::CloseBlobFileIfNeeded() {
|
||||
assert(IsBlobFileOpen());
|
||||
|
||||
const WritableFileWriter* const file_writer = writer_->file();
|
||||
const WritableFileWriter* const file_writer =
|
||||
blog_writer_ ? blog_writer_->file() : writer_->file();
|
||||
assert(file_writer);
|
||||
|
||||
if (file_writer->GetFileSize() < blob_file_size_) {
|
||||
@@ -384,13 +509,13 @@ void BlobFileBuilder::Abandon(const Status& s) {
|
||||
// Blob files. So we can ignore the below error.
|
||||
blob_callback_
|
||||
->OnBlobFileCompleted(blob_file_paths_->back(), column_family_name_,
|
||||
job_id_, writer_->get_log_number(),
|
||||
creation_reason_, s, "", "", blob_count_,
|
||||
blob_bytes_)
|
||||
job_id_, blob_file_number_, creation_reason_, s,
|
||||
"", "", blob_count_, blob_bytes_)
|
||||
.PermitUncheckedError();
|
||||
}
|
||||
|
||||
writer_.reset();
|
||||
blog_writer_.reset();
|
||||
blob_count_ = 0;
|
||||
blob_bytes_ = 0;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ class BlobLogWriter;
|
||||
class IOTracer;
|
||||
class BlobFileCompletionCallback;
|
||||
|
||||
class BlogFileWriter;
|
||||
|
||||
class BlobFileBuilder {
|
||||
public:
|
||||
BlobFileBuilder(VersionSet* versions, FileSystem* fs,
|
||||
@@ -78,10 +80,11 @@ class BlobFileBuilder {
|
||||
private:
|
||||
bool IsBlobFileOpen() const;
|
||||
Status OpenBlobFileIfNeeded();
|
||||
Status CompressBlobIfNeeded(Slice* blob,
|
||||
GrowableBuffer* compressed_blob) const;
|
||||
Status LegacyCompressBlob(Slice* blob, GrowableBuffer* compressed_blob) const;
|
||||
Status WriteBlobToFile(const Slice& key, const Slice& blob,
|
||||
uint64_t* blob_file_number, uint64_t* blob_offset);
|
||||
uint64_t* blob_file_number, uint64_t* blob_offset,
|
||||
uint64_t* blob_on_disk_size,
|
||||
CompressionType* actual_compression_type);
|
||||
Status CloseBlobFile();
|
||||
Status CloseBlobFileIfNeeded();
|
||||
|
||||
@@ -94,6 +97,7 @@ class BlobFileBuilder {
|
||||
uint64_t min_blob_size_;
|
||||
uint64_t blob_file_size_;
|
||||
CompressionType blob_compression_type_;
|
||||
std::shared_ptr<CompressionManager> blob_compression_manager_;
|
||||
std::unique_ptr<Compressor> blob_compressor_;
|
||||
mutable Compressor::ManagedWorkingArea blob_compressor_wa_;
|
||||
PrepopulateBlobCache prepopulate_blob_cache_;
|
||||
@@ -111,6 +115,8 @@ class BlobFileBuilder {
|
||||
std::vector<std::string>* blob_file_paths_;
|
||||
std::vector<BlobFileAddition>* blob_file_additions_;
|
||||
std::unique_ptr<BlobLogWriter> writer_;
|
||||
std::unique_ptr<BlogFileWriter> blog_writer_;
|
||||
uint64_t blob_file_number_ = 0;
|
||||
uint64_t blob_count_;
|
||||
uint64_t blob_bytes_;
|
||||
};
|
||||
|
||||
@@ -598,7 +598,7 @@ INSTANTIATE_TEST_CASE_P(
|
||||
"BlobFileBuilder::OpenBlobFileIfNeeded:NewWritableFile",
|
||||
"BlobFileBuilder::OpenBlobFileIfNeeded:WriteHeader",
|
||||
"BlobFileBuilder::WriteBlobToFile:AddRecord",
|
||||
"BlobFileBuilder::WriteBlobToFile:AppendFooter"}));
|
||||
"BlobFileBuilder::WriteBlobToFile:LegacyAppendFooterAndClose"}));
|
||||
|
||||
TEST_P(BlobFileBuilderIOErrorTest, IOError) {
|
||||
// Simulate an I/O error during the specified step of Add()
|
||||
|
||||
@@ -48,8 +48,7 @@ void WriteBlobFile(uint32_t column_family_id,
|
||||
constexpr bool do_flush = false;
|
||||
|
||||
BlobLogWriter blob_log_writer(std::move(file_writer), immutable_options.clock,
|
||||
statistics, blob_file_number, use_fsync,
|
||||
do_flush);
|
||||
statistics, use_fsync, do_flush);
|
||||
|
||||
constexpr bool has_ttl = false;
|
||||
constexpr ExpirationRange expiration_range;
|
||||
@@ -77,8 +76,8 @@ void WriteBlobFile(uint32_t column_family_id,
|
||||
std::string checksum_method;
|
||||
std::string checksum_value;
|
||||
|
||||
ASSERT_OK(blob_log_writer.AppendFooter(WriteOptions(), footer,
|
||||
&checksum_method, &checksum_value));
|
||||
ASSERT_OK(blob_log_writer.LegacyAppendFooterAndClose(
|
||||
WriteOptions(), footer, &checksum_method, &checksum_value));
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
@@ -222,8 +221,7 @@ TEST_F(BlobFileCacheTest, RefreshBlobFileReaderPrefersLargestObservedFileSize) {
|
||||
constexpr bool use_fsync = false;
|
||||
constexpr bool do_flush = false;
|
||||
BlobLogWriter blob_log_writer(std::move(file_writer), immutable_options.clock,
|
||||
statistics, blob_file_number, use_fsync,
|
||||
do_flush);
|
||||
statistics, use_fsync, do_flush);
|
||||
|
||||
constexpr bool has_ttl = false;
|
||||
constexpr ExpirationRange expiration_range;
|
||||
|
||||
@@ -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 =
|
||||
@@ -210,10 +214,10 @@ Status BlobFilePartitionManager::OpenNewBlobFile(Partition* partition,
|
||||
|
||||
// This only drains WritableFileWriter's buffered bytes so readers can see
|
||||
// each appended record promptly. Durability still comes from SyncAllOpenFiles
|
||||
// or AppendFooter(), both of which call Sync().
|
||||
// or LegacyAppendFooterAndClose(), both of which call Sync().
|
||||
constexpr bool kDoFlushEachRecord = true;
|
||||
auto blob_log_writer = std::make_unique<BlobLogWriter>(
|
||||
std::move(file_writer), clock_, statistics_, blob_file_number, use_fsync_,
|
||||
std::move(file_writer), clock_, statistics_, use_fsync_,
|
||||
kDoFlushEachRecord);
|
||||
|
||||
constexpr bool has_ttl = false;
|
||||
@@ -296,8 +300,8 @@ Status BlobFilePartitionManager::FinalizeBlobFile(
|
||||
|
||||
std::string checksum_method;
|
||||
std::string checksum_value;
|
||||
Status s = writer->AppendFooter(write_options, footer, &checksum_method,
|
||||
&checksum_value);
|
||||
Status s = writer->LegacyAppendFooterAndClose(
|
||||
write_options, footer, &checksum_method, &checksum_value);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
+179
-64
@@ -5,11 +5,14 @@
|
||||
|
||||
#include "db/blob/blob_file_reader.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "db/blob/blob_contents.h"
|
||||
#include "db/blob/blob_log_format.h"
|
||||
#include "db/blog/blog_format.h"
|
||||
#include "file/file_prefetch_buffer.h"
|
||||
#include "file/filename.h"
|
||||
#include "monitoring/statistics_impl.h"
|
||||
@@ -51,37 +54,30 @@ Status BlobFileReader::Create(
|
||||
|
||||
Statistics* const statistics = immutable_options.stats;
|
||||
|
||||
CompressionType compression_type = kNoCompression;
|
||||
// Construct with defaults; ReadHeader will detect blog vs legacy format
|
||||
// and populate the appropriate fields.
|
||||
blob_file_reader->reset(
|
||||
new BlobFileReader(std::move(file_reader), file_size, kNoCompression,
|
||||
/*decompressor=*/nullptr, immutable_options.clock,
|
||||
statistics, /*has_footer=*/false));
|
||||
|
||||
{
|
||||
const Status s =
|
||||
ReadHeader(file_reader.get(), read_options, column_family_id,
|
||||
statistics, &compression_type);
|
||||
(*blob_file_reader)->ReadHeader(read_options, column_family_id);
|
||||
if (!s.ok()) {
|
||||
blob_file_reader->reset();
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip_footer_validation) {
|
||||
const Status s =
|
||||
ReadFooter(file_reader.get(), read_options, file_size, statistics);
|
||||
const Status s = (*blob_file_reader)->ReadFooter(read_options);
|
||||
if (!s.ok()) {
|
||||
blob_file_reader->reset();
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
std::shared_ptr<Decompressor> decompressor;
|
||||
if (compression_type != kNoCompression) {
|
||||
// The blob format has always used compression format 2
|
||||
decompressor = GetBuiltinV2CompressionManager()->GetDecompressorOptimizeFor(
|
||||
compression_type);
|
||||
}
|
||||
|
||||
blob_file_reader->reset(
|
||||
new BlobFileReader(std::move(file_reader), file_size, compression_type,
|
||||
std::move(decompressor), immutable_options.clock,
|
||||
statistics, !skip_footer_validation));
|
||||
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
@@ -155,13 +151,17 @@ Status BlobFileReader::OpenFile(
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
Status BlobFileReader::ReadHeader(const RandomAccessFileReader* file_reader,
|
||||
const ReadOptions& read_options,
|
||||
uint32_t column_family_id,
|
||||
Statistics* statistics,
|
||||
CompressionType* compression_type) {
|
||||
assert(file_reader);
|
||||
assert(compression_type);
|
||||
Status BlobFileReader::ReadHeader(const ReadOptions& read_options,
|
||||
uint32_t column_family_id) {
|
||||
assert(file_reader_);
|
||||
|
||||
// Read ~4 KiB from the start to cover both legacy (30B) and blog (40B
|
||||
// fixed + property section) headers in a single I/O. This is enough for
|
||||
// typical blog headers; only if the property section exceeds ~4 KiB do
|
||||
// we need a second read.
|
||||
constexpr size_t kInitialReadSize = 4096;
|
||||
const size_t read_size =
|
||||
std::min(static_cast<uint64_t>(kInitialReadSize), file_size_);
|
||||
|
||||
Slice header_slice;
|
||||
Buffer buf;
|
||||
@@ -170,12 +170,9 @@ Status BlobFileReader::ReadHeader(const RandomAccessFileReader* file_reader,
|
||||
{
|
||||
TEST_SYNC_POINT("BlobFileReader::ReadHeader:ReadFromFile");
|
||||
|
||||
constexpr uint64_t read_offset = 0;
|
||||
constexpr size_t read_size = BlobLogHeader::kSize;
|
||||
|
||||
const Status s =
|
||||
ReadFromFile(file_reader, read_options, read_offset, read_size,
|
||||
statistics, &header_slice, &buf, &aligned_buf);
|
||||
ReadFromFile(file_reader_.get(), read_options, 0, read_size,
|
||||
statistics_, &header_slice, &buf, &aligned_buf);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
@@ -184,10 +181,58 @@ Status BlobFileReader::ReadHeader(const RandomAccessFileReader* file_reader,
|
||||
&header_slice);
|
||||
}
|
||||
|
||||
// Detect blog format by checking the 12-byte magic.
|
||||
if (BlogFileHeader::IsBlogFormat(header_slice.data(), header_slice.size())) {
|
||||
// Try to decode the blog header from the buffer we already read.
|
||||
// DecodeFromBuffer handles the case where the property section
|
||||
// extends beyond the buffer.
|
||||
BlogFileHeader blog_header;
|
||||
Slice buf_slice = header_slice;
|
||||
size_t additional_needed = 0;
|
||||
Status s = blog_header.DecodeFromBuffer(&buf_slice, &additional_needed);
|
||||
|
||||
if (s.IsIncomplete()) {
|
||||
// Need more data. Read the rest and retry.
|
||||
Slice extra_slice;
|
||||
Buffer extra_buf;
|
||||
AlignedBuf extra_aligned;
|
||||
s = ReadFromFile(file_reader_.get(), read_options, header_slice.size(),
|
||||
additional_needed, statistics_, &extra_slice, &extra_buf,
|
||||
&extra_aligned);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
std::string extended;
|
||||
extended.append(header_slice.data(), header_slice.size());
|
||||
extended.append(extra_slice.data(), extra_slice.size());
|
||||
Slice extended_slice(extended);
|
||||
s = blog_header.DecodeFromBuffer(&extended_slice, nullptr);
|
||||
}
|
||||
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
is_blog_format_ = true;
|
||||
blog_checksum_type_ = blog_header.checksum_type;
|
||||
blog_incarnation_id_ = blog_header.incarnation_id();
|
||||
memcpy(blog_escape_sequence_, blog_header.escape_sequence,
|
||||
kBlogEscapeSequenceSize);
|
||||
compression_type_ = kNoCompression;
|
||||
decompressor_ = GetBuiltinV2CompressionManager()->GetDecompressor();
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
// Legacy blob format. The initial read already covers the 30-byte header.
|
||||
if (header_slice.size() < BlobLogHeader::kSize) {
|
||||
return Status::Corruption("Blob file header too small");
|
||||
}
|
||||
|
||||
Slice legacy_slice(header_slice.data(), BlobLogHeader::kSize);
|
||||
BlobLogHeader header;
|
||||
|
||||
{
|
||||
const Status s = header.DecodeFrom(header_slice);
|
||||
const Status s = header.DecodeFrom(legacy_slice);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
@@ -203,16 +248,62 @@ Status BlobFileReader::ReadHeader(const RandomAccessFileReader* file_reader,
|
||||
return Status::Corruption("Column family ID mismatch");
|
||||
}
|
||||
|
||||
*compression_type = header.compression;
|
||||
compression_type_ = header.compression;
|
||||
if (compression_type_ != kNoCompression) {
|
||||
decompressor_ =
|
||||
GetBuiltinV2CompressionManager()->GetDecompressorOptimizeFor(
|
||||
compression_type_);
|
||||
}
|
||||
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
Status BlobFileReader::ReadFooter(const RandomAccessFileReader* file_reader,
|
||||
const ReadOptions& read_options,
|
||||
uint64_t file_size, Statistics* statistics) {
|
||||
assert(file_size >= BlobLogHeader::kSize + BlobLogFooter::kSize);
|
||||
assert(file_reader);
|
||||
Status BlobFileReader::ReadFooter(const ReadOptions& read_options) {
|
||||
assert(file_reader_);
|
||||
|
||||
if (is_blog_format_) {
|
||||
// Blog format: verify the file was cleanly sealed by scanning backward
|
||||
// from EOF for the last record's escape sequence. Read the trailing
|
||||
// ~4 KiB in a single I/O and scan within the buffer.
|
||||
if (file_size_ < kBlogFileFixedHeaderSize + kBlogEscapeSequenceSize) {
|
||||
return Status::Corruption("Blog blob file too small for footer");
|
||||
}
|
||||
|
||||
constexpr size_t kTrailingReadSize = 4096;
|
||||
const uint64_t min_offset = kBlogFileFixedHeaderSize;
|
||||
// Round trailing_offset up to 4-byte alignment so buffer positions
|
||||
// and file positions share the same alignment grid.
|
||||
const uint64_t trailing_offset =
|
||||
(file_size_ -
|
||||
std::min(static_cast<uint64_t>(kTrailingReadSize),
|
||||
file_size_ - min_offset) +
|
||||
3) &
|
||||
~uint64_t{3};
|
||||
const size_t trailing_size =
|
||||
static_cast<size_t>(file_size_ - trailing_offset);
|
||||
|
||||
Slice trailing_slice;
|
||||
Buffer trailing_buf;
|
||||
AlignedBuf trailing_aligned;
|
||||
Status s = ReadFromFile(file_reader_.get(), read_options, trailing_offset,
|
||||
trailing_size, statistics_, &trailing_slice,
|
||||
&trailing_buf, &trailing_aligned);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
if (VerifyBlogFooterLocator(trailing_slice.data(), trailing_size,
|
||||
trailing_offset, blog_escape_sequence_,
|
||||
blog_checksum_type_, blog_incarnation_id_)) {
|
||||
has_footer_ = true;
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
return Status::Corruption("Blog blob file: no footer locator record found");
|
||||
}
|
||||
|
||||
// Legacy blob format.
|
||||
assert(file_size_ >= BlobLogHeader::kSize + BlobLogFooter::kSize);
|
||||
|
||||
Slice footer_slice;
|
||||
Buffer buf;
|
||||
@@ -221,12 +312,12 @@ Status BlobFileReader::ReadFooter(const RandomAccessFileReader* file_reader,
|
||||
{
|
||||
TEST_SYNC_POINT("BlobFileReader::ReadFooter:ReadFromFile");
|
||||
|
||||
const uint64_t read_offset = file_size - BlobLogFooter::kSize;
|
||||
const uint64_t read_offset = file_size_ - BlobLogFooter::kSize;
|
||||
constexpr size_t read_size = BlobLogFooter::kSize;
|
||||
|
||||
const Status s =
|
||||
ReadFromFile(file_reader, read_options, read_offset, read_size,
|
||||
statistics, &footer_slice, &buf, &aligned_buf);
|
||||
ReadFromFile(file_reader_.get(), read_options, read_offset, read_size,
|
||||
statistics_, &footer_slice, &buf, &aligned_buf);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
@@ -250,6 +341,7 @@ Status BlobFileReader::ReadFooter(const RandomAccessFileReader* file_reader,
|
||||
return Status::Corruption("Unexpected TTL blob file");
|
||||
}
|
||||
|
||||
has_footer_ = true;
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
@@ -323,30 +415,40 @@ Status BlobFileReader::GetBlob(
|
||||
std::unique_ptr<BlobContents>* result, uint64_t* bytes_read) const {
|
||||
assert(result);
|
||||
|
||||
const uint64_t key_size = user_key.size();
|
||||
// Compute the read region. Both formats store the blob value at `offset`
|
||||
// with size `value_size`. The difference:
|
||||
// - Legacy: if verifying checksums, also read the record header (before
|
||||
// the value) which contains key + CRC.
|
||||
// - Blog: if verifying checksums, also read the 5-byte trailer (after
|
||||
// the value) which contains compression_type + checksum.
|
||||
uint64_t adjustment_before = 0; // bytes before the value to include
|
||||
uint64_t adjustment_after = 0; // bytes after the value to include
|
||||
|
||||
if (!IsValidBlobOffset(offset, key_size, value_size, file_size_,
|
||||
has_footer_)) {
|
||||
return Status::Corruption("Invalid blob offset");
|
||||
if (is_blog_format_) {
|
||||
if (read_options.verify_checksums) {
|
||||
adjustment_after = kBlogBlockTrailerSize;
|
||||
}
|
||||
} else {
|
||||
const uint64_t key_size = user_key.size();
|
||||
if (!IsValidBlobOffset(offset, key_size, value_size, file_size_,
|
||||
has_footer_)) {
|
||||
return Status::Corruption("Invalid blob offset");
|
||||
}
|
||||
if (compression_type != compression_type_) {
|
||||
return Status::Corruption("Compression type mismatch when reading blob");
|
||||
}
|
||||
if (read_options.verify_checksums) {
|
||||
adjustment_before =
|
||||
BlobLogRecord::CalculateAdjustmentForRecordHeader(key_size);
|
||||
}
|
||||
}
|
||||
|
||||
if (compression_type != compression_type_) {
|
||||
return Status::Corruption("Compression type mismatch when reading blob");
|
||||
}
|
||||
|
||||
// Note: if verify_checksum is set, we read the entire blob record to be able
|
||||
// to perform the verification; otherwise, we just read the blob itself. Since
|
||||
// the offset in BlobIndex actually points to the blob value, we need to make
|
||||
// an adjustment in the former case.
|
||||
const uint64_t adjustment =
|
||||
read_options.verify_checksums
|
||||
? BlobLogRecord::CalculateAdjustmentForRecordHeader(key_size)
|
||||
: 0;
|
||||
assert(offset >= adjustment);
|
||||
|
||||
const uint64_t record_offset = offset - adjustment;
|
||||
const uint64_t record_size = value_size + adjustment;
|
||||
assert(offset >= adjustment_before);
|
||||
const uint64_t record_offset = offset - adjustment_before;
|
||||
const uint64_t record_size =
|
||||
adjustment_before + value_size + adjustment_after;
|
||||
|
||||
// Read the data: try prefetch buffer first, then fall back to file read.
|
||||
Slice record_slice;
|
||||
Buffer buf;
|
||||
AlignedBuf aligned_buf;
|
||||
@@ -388,17 +490,30 @@ Status BlobFileReader::GetBlob(
|
||||
TEST_SYNC_POINT_CALLBACK("BlobFileReader::GetBlob:TamperWithResult",
|
||||
&record_slice);
|
||||
|
||||
// Verify checksum (format-specific).
|
||||
CompressionType actual_comp_type = compression_type;
|
||||
if (read_options.verify_checksums) {
|
||||
const Status s = VerifyBlob(record_slice, user_key, value_size);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
if (is_blog_format_) {
|
||||
const Status s = VerifyBlogRecordTrailer(
|
||||
blog_checksum_type_, record_slice.data(),
|
||||
static_cast<size_t>(value_size), blog_incarnation_id_, offset,
|
||||
&actual_comp_type);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
} else {
|
||||
const Status s = VerifyBlob(record_slice, user_key, value_size);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Slice value_slice(record_slice.data() + adjustment, value_size);
|
||||
// Extract the value slice (format-specific offset within the read region).
|
||||
const Slice value_slice(record_slice.data() + adjustment_before, value_size);
|
||||
|
||||
{
|
||||
const Status s = UncompressBlobIfNeeded(value_slice, compression_type,
|
||||
const Status s = UncompressBlobIfNeeded(value_slice, actual_comp_type,
|
||||
decompressor_.get(), allocator,
|
||||
clock_, statistics_, result);
|
||||
if (!s.ok()) {
|
||||
|
||||
@@ -93,14 +93,15 @@ class BlobFileReader {
|
||||
std::unique_ptr<RandomAccessFileReader>* file_reader,
|
||||
bool skip_footer_size_check);
|
||||
|
||||
static Status ReadHeader(const RandomAccessFileReader* file_reader,
|
||||
const ReadOptions& read_options,
|
||||
uint32_t column_family_id, Statistics* statistics,
|
||||
CompressionType* compression_type);
|
||||
// Read and validate the file header. Detects blog format vs legacy format,
|
||||
// setting is_blog_format_ and related fields accordingly. For legacy format,
|
||||
// validates column_family_id and sets compression_type_.
|
||||
Status ReadHeader(const ReadOptions& read_options, uint32_t column_family_id);
|
||||
|
||||
static Status ReadFooter(const RandomAccessFileReader* file_reader,
|
||||
const ReadOptions& read_options, uint64_t file_size,
|
||||
Statistics* statistics);
|
||||
// Validate the file footer. For legacy format, reads and validates the
|
||||
// BlobLogFooter. For blog format, scans backward for the footer locator
|
||||
// record to verify the file was cleanly sealed.
|
||||
Status ReadFooter(const ReadOptions& read_options);
|
||||
|
||||
using Buffer = std::unique_ptr<char[]>;
|
||||
|
||||
@@ -129,6 +130,13 @@ class BlobFileReader {
|
||||
Statistics* statistics_;
|
||||
// False when the reader was opened before the blob file footer was written.
|
||||
bool has_footer_;
|
||||
// True when the file uses blog format instead of legacy blob log format.
|
||||
bool is_blog_format_ = false;
|
||||
// For blog format: fields from the file header used for record checksum
|
||||
// verification and footer validation.
|
||||
uint32_t blog_incarnation_id_ = 0;
|
||||
ChecksumType blog_checksum_type_ = kNoChecksum;
|
||||
char blog_escape_sequence_[10] = {}; // kBlogEscapeSequenceSize
|
||||
};
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
@@ -57,8 +57,7 @@ void WriteBlobFile(const ImmutableOptions& immutable_options,
|
||||
constexpr bool do_flush = false;
|
||||
|
||||
BlobLogWriter blob_log_writer(std::move(file_writer), immutable_options.clock,
|
||||
statistics, blob_file_number, use_fsync,
|
||||
do_flush);
|
||||
statistics, use_fsync, do_flush);
|
||||
|
||||
BlobLogHeader header(column_family_id, compression, has_ttl,
|
||||
expiration_range_header);
|
||||
@@ -98,8 +97,8 @@ void WriteBlobFile(const ImmutableOptions& immutable_options,
|
||||
|
||||
std::string checksum_method;
|
||||
std::string checksum_value;
|
||||
ASSERT_OK(blob_log_writer.AppendFooter(WriteOptions(), footer,
|
||||
&checksum_method, &checksum_value));
|
||||
ASSERT_OK(blob_log_writer.LegacyAppendFooterAndClose(
|
||||
WriteOptions(), footer, &checksum_method, &checksum_value));
|
||||
}
|
||||
|
||||
// Creates a test blob file with a single blob in it. Note: this method
|
||||
@@ -465,7 +464,7 @@ TEST_F(BlobFileReaderTest, Malformed) {
|
||||
|
||||
BlobLogWriter blob_log_writer(std::move(file_writer),
|
||||
immutable_options.clock, statistics,
|
||||
blob_file_number, use_fsync, do_flush);
|
||||
use_fsync, do_flush);
|
||||
|
||||
BlobLogHeader header(column_family_id, kNoCompression, has_ttl,
|
||||
expiration_range);
|
||||
|
||||
@@ -20,12 +20,10 @@ namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
BlobLogWriter::BlobLogWriter(std::unique_ptr<WritableFileWriter>&& dest,
|
||||
SystemClock* clock, Statistics* statistics,
|
||||
uint64_t log_number, bool use_fs, bool do_flush,
|
||||
uint64_t boffset)
|
||||
bool use_fs, bool do_flush, uint64_t boffset)
|
||||
: dest_(std::move(dest)),
|
||||
clock_(clock),
|
||||
statistics_(statistics),
|
||||
log_number_(log_number),
|
||||
block_offset_(boffset),
|
||||
use_fsync_(use_fs),
|
||||
do_flush_(do_flush),
|
||||
@@ -74,10 +72,9 @@ Status BlobLogWriter::WriteHeader(const WriteOptions& write_options,
|
||||
return s;
|
||||
}
|
||||
|
||||
Status BlobLogWriter::AppendFooter(const WriteOptions& write_options,
|
||||
BlobLogFooter& footer,
|
||||
std::string* checksum_method,
|
||||
std::string* checksum_value) {
|
||||
Status BlobLogWriter::LegacyAppendFooterAndClose(
|
||||
const WriteOptions& write_options, BlobLogFooter& footer,
|
||||
std::string* checksum_method, std::string* checksum_value) {
|
||||
assert(block_offset_ != 0);
|
||||
assert(last_elem_type_ == kEtFileHdr || last_elem_type_ == kEtRecord);
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ class BlobLogWriter {
|
||||
// "*dest" must be initially empty.
|
||||
// "*dest" must remain live while this BlobLogWriter is in use.
|
||||
BlobLogWriter(std::unique_ptr<WritableFileWriter>&& dest, SystemClock* clock,
|
||||
Statistics* statistics, uint64_t log_number, bool use_fsync,
|
||||
bool do_flush, uint64_t boffset = 0);
|
||||
Statistics* statistics, bool use_fsync, bool do_flush,
|
||||
uint64_t boffset = 0);
|
||||
// No copying allowed
|
||||
BlobLogWriter(const BlobLogWriter&) = delete;
|
||||
BlobLogWriter& operator=(const BlobLogWriter&) = delete;
|
||||
@@ -56,9 +56,10 @@ class BlobLogWriter {
|
||||
const Slice& val, uint64_t* key_offset,
|
||||
uint64_t* blob_offset);
|
||||
|
||||
Status AppendFooter(const WriteOptions& write_options, BlobLogFooter& footer,
|
||||
std::string* checksum_method,
|
||||
std::string* checksum_value);
|
||||
Status LegacyAppendFooterAndClose(const WriteOptions& write_options,
|
||||
BlobLogFooter& footer,
|
||||
std::string* checksum_method,
|
||||
std::string* checksum_value);
|
||||
|
||||
Status WriteHeader(const WriteOptions& write_options, BlobLogHeader& header);
|
||||
|
||||
@@ -66,15 +67,12 @@ class BlobLogWriter {
|
||||
|
||||
const WritableFileWriter* file() const { return dest_.get(); }
|
||||
|
||||
uint64_t get_log_number() const { return log_number_; }
|
||||
|
||||
Status Sync(const WriteOptions& write_options);
|
||||
|
||||
private:
|
||||
std::unique_ptr<WritableFileWriter> dest_;
|
||||
SystemClock* clock_;
|
||||
Statistics* statistics_;
|
||||
uint64_t log_number_;
|
||||
uint64_t block_offset_; // Current offset in block
|
||||
bool use_fsync_;
|
||||
bool do_flush_;
|
||||
|
||||
@@ -59,8 +59,7 @@ void WriteBlobFile(const ImmutableOptions& immutable_options,
|
||||
constexpr bool do_flush = false;
|
||||
|
||||
BlobLogWriter blob_log_writer(std::move(file_writer), immutable_options.clock,
|
||||
statistics, blob_file_number, use_fsync,
|
||||
do_flush);
|
||||
statistics, use_fsync, do_flush);
|
||||
|
||||
BlobLogHeader header(column_family_id, compression, has_ttl,
|
||||
expiration_range_header);
|
||||
@@ -100,8 +99,8 @@ void WriteBlobFile(const ImmutableOptions& immutable_options,
|
||||
|
||||
std::string checksum_method;
|
||||
std::string checksum_value;
|
||||
ASSERT_OK(blob_log_writer.AppendFooter(WriteOptions(), footer,
|
||||
&checksum_method, &checksum_value));
|
||||
ASSERT_OK(blob_log_writer.LegacyAppendFooterAndClose(
|
||||
WriteOptions(), footer, &checksum_method, &checksum_value));
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ struct BlobDirectWriteSettings {
|
||||
uint64_t min_blob_size = 0;
|
||||
// Compression to use for newly written blob records.
|
||||
CompressionType compression_type = kNoCompression;
|
||||
// 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
// This source code is licensed under both the GPLv2 (found in the
|
||||
// COPYING file in the root directory) and Apache 2.0 License
|
||||
// (found in the LICENSE.Apache file in the root directory).
|
||||
|
||||
#include "db/blog/blog_format.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
|
||||
#include "env/unique_id_gen.h"
|
||||
#include "table/format.h"
|
||||
#include "table/unique_id_impl.h"
|
||||
#include "util/coding.h"
|
||||
#include "util/fastrange.h"
|
||||
#include "util/xxhash.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
// --- Property encoding/decoding ---
|
||||
|
||||
void EncodeBlogProperties(const BlogPropertyMap& props, std::string* dst) {
|
||||
for (const auto& [name, value] : props) {
|
||||
PutLengthPrefixedSlice(dst, name);
|
||||
PutLengthPrefixedSlice(dst, value);
|
||||
}
|
||||
}
|
||||
|
||||
Status DecodeBlogProperties(Slice* input, BlogPropertyMap* props) {
|
||||
while (input->size() > 0) {
|
||||
Slice name_slice;
|
||||
Slice value_slice;
|
||||
if (!GetLengthPrefixedSlice(input, &name_slice)) {
|
||||
return Status::Corruption("Blog properties: truncated property name");
|
||||
}
|
||||
if (!GetLengthPrefixedSlice(input, &value_slice)) {
|
||||
return Status::Corruption("Blog properties: truncated property value");
|
||||
}
|
||||
props->emplace_back(name_slice.ToString(), value_slice.ToString());
|
||||
}
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
// --- BlogFileHeader ---
|
||||
|
||||
uint32_t BlogFileHeader::incarnation_id() const {
|
||||
return DecodeFixed32(escape_sequence);
|
||||
}
|
||||
|
||||
void BlogFileHeader::EncodeTo(std::string* dst) const {
|
||||
// --- Fixed header (40 bytes) ---
|
||||
// Bytes [0-11]: File magic
|
||||
dst->append(kBlogFileMagic, kBlogFileMagicSize);
|
||||
// Byte [12]: Schema version
|
||||
dst->push_back(static_cast<char>(schema_version));
|
||||
// Byte [13]: Checksum type
|
||||
dst->push_back(static_cast<char>(checksum_type));
|
||||
// Byte [14]: Compact record type
|
||||
dst->push_back(static_cast<char>(compact_record_type));
|
||||
// Byte [15]: Flags
|
||||
dst->push_back(static_cast<char>(flags));
|
||||
// Bytes [16-25]: Escape sequence
|
||||
dst->append(escape_sequence, kBlogEscapeSequenceSize);
|
||||
// Bytes [26-31]: Reserved
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
dst->push_back(0);
|
||||
}
|
||||
|
||||
// Encode properties into a temporary buffer to measure size.
|
||||
std::string prop_buf;
|
||||
EncodeBlogProperties(properties, &prop_buf);
|
||||
char prop_comp_type = static_cast<char>(kNoCompression);
|
||||
|
||||
// Build property section: [properties][compression_type][checksum]
|
||||
uint32_t prop_checksum = ComputeBuiltinChecksumWithLastByte(
|
||||
checksum_type, prop_buf.data(), prop_buf.size(), prop_comp_type);
|
||||
prop_checksum +=
|
||||
ChecksumModifierForContext(incarnation_id(), kBlogFileFixedHeaderSize);
|
||||
std::string prop_section = std::move(prop_buf);
|
||||
prop_section.push_back(prop_comp_type);
|
||||
PutFixed32(&prop_section, prop_checksum);
|
||||
|
||||
// Bytes [32-35]: Property section size (trailer included, no padding)
|
||||
PutFixed32(dst, static_cast<uint32_t>(prop_section.size()));
|
||||
|
||||
// Bytes [36-39]: Fixed header checksum (covers bytes [0, 36))
|
||||
// Uses context checksum with incarnation_id at offset 0 for
|
||||
// defense-in-depth (misplaced data detection).
|
||||
assert(dst->size() == 36);
|
||||
uint32_t header_checksum =
|
||||
ComputeBuiltinChecksum(checksum_type, dst->data(), dst->size()) +
|
||||
ChecksumModifierForContext(incarnation_id(), 0);
|
||||
PutFixed32(dst, header_checksum);
|
||||
|
||||
// Property section follows the fixed header
|
||||
assert(dst->size() == kBlogFileFixedHeaderSize);
|
||||
dst->append(prop_section);
|
||||
}
|
||||
|
||||
Status BlogFileHeader::DecodeFrom(Slice* input) {
|
||||
if (input->size() < kBlogFileFixedHeaderSize) {
|
||||
return Status::Corruption("Blog header too short: have " +
|
||||
std::to_string(input->size()) + " bytes, need " +
|
||||
std::to_string(kBlogFileFixedHeaderSize));
|
||||
}
|
||||
|
||||
const char* p = input->data();
|
||||
|
||||
// Verify magic
|
||||
if (memcmp(p, kBlogFileMagic, kBlogFileMagicSize) != 0) {
|
||||
return Status::Corruption("Blog header: bad magic (first 4 bytes: 0x" +
|
||||
Slice(p, 4).ToString(true) + ")");
|
||||
}
|
||||
|
||||
// Parse fields needed for checksum computation before verifying.
|
||||
checksum_type = static_cast<ChecksumType>(p[13]);
|
||||
memcpy(escape_sequence, p + 16, kBlogEscapeSequenceSize);
|
||||
|
||||
// Verify fixed header checksum FIRST, before trusting any other fields.
|
||||
uint32_t prop_section_size = DecodeFixed32(p + 32);
|
||||
uint32_t stored_header_checksum = DecodeFixed32(p + 36);
|
||||
uint32_t computed_header_checksum =
|
||||
ComputeBuiltinChecksum(checksum_type, p, 36) +
|
||||
ChecksumModifierForContext(incarnation_id(), 0);
|
||||
if (stored_header_checksum != computed_header_checksum) {
|
||||
return Status::Corruption(
|
||||
"Blog header: fixed header checksum mismatch (stored: 0x" +
|
||||
Slice(p + 36, 4).ToString(true) + ", computed: 0x" +
|
||||
Slice(reinterpret_cast<const char*>(&computed_header_checksum), 4)
|
||||
.ToString(true) +
|
||||
")");
|
||||
}
|
||||
|
||||
// Now that checksum is verified, validate the remaining fields.
|
||||
schema_version = static_cast<uint8_t>(p[12]);
|
||||
if (schema_version > kBlogCurrentSchemaVersion) {
|
||||
return Status::NotSupported("Blog header: schema version " +
|
||||
std::to_string(schema_version) +
|
||||
" is newer than supported version " +
|
||||
std::to_string(kBlogCurrentSchemaVersion));
|
||||
}
|
||||
compact_record_type = static_cast<BlogRecordType>(p[14]);
|
||||
flags = static_cast<uint8_t>(p[15]);
|
||||
|
||||
if (!VerifyEscapeSequence(escape_sequence)) {
|
||||
return Status::Corruption(
|
||||
"Blog header: escape sequence derived bytes mismatch (first byte: 0x" +
|
||||
Slice(escape_sequence, 1).ToString(true) + ")");
|
||||
}
|
||||
|
||||
// Advance past fixed header
|
||||
input->remove_prefix(kBlogFileFixedHeaderSize);
|
||||
|
||||
// Read property section
|
||||
if (input->size() < prop_section_size) {
|
||||
return Status::Corruption("Blog header: truncated property section (have " +
|
||||
std::to_string(input->size()) + " bytes, need " +
|
||||
std::to_string(prop_section_size) + ")");
|
||||
}
|
||||
|
||||
if (prop_section_size < kBlogBlockTrailerSize) {
|
||||
return Status::Corruption("Blog header: property section too small (" +
|
||||
std::to_string(prop_section_size) +
|
||||
" bytes, min " +
|
||||
std::to_string(kBlogBlockTrailerSize) + ")");
|
||||
}
|
||||
|
||||
// Property section layout: [properties][comp_type:1B][checksum:4B]
|
||||
const char* prop_data = input->data();
|
||||
size_t prop_data_size = prop_section_size - kBlogBlockTrailerSize;
|
||||
char prop_comp_type = prop_data[prop_data_size];
|
||||
uint32_t stored_prop_checksum = DecodeFixed32(prop_data + prop_data_size + 1);
|
||||
uint32_t computed_prop_checksum = ComputeBuiltinChecksumWithLastByte(
|
||||
checksum_type, prop_data, prop_data_size, prop_comp_type);
|
||||
computed_prop_checksum +=
|
||||
ChecksumModifierForContext(incarnation_id(), kBlogFileFixedHeaderSize);
|
||||
if (stored_prop_checksum != computed_prop_checksum) {
|
||||
return Status::Corruption(
|
||||
"Blog header: property section checksum mismatch (prop_size=" +
|
||||
std::to_string(prop_data_size) + ", comp_type=0x" +
|
||||
Slice(&prop_comp_type, 1).ToString(true) + ")");
|
||||
}
|
||||
|
||||
// Decode properties
|
||||
Slice prop_slice(prop_data, prop_data_size);
|
||||
properties.clear();
|
||||
Status s = DecodeBlogProperties(&prop_slice, &properties);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Check for unrecognized required properties
|
||||
for (const auto& [name, value] : properties) {
|
||||
if (IsBlogRequiredProperty(name)) {
|
||||
if (name != kBlogPropCompressionCompatibilityName &&
|
||||
name != kBlogPropTimestampSize &&
|
||||
name != kBlogPropPredecessorWalInfo &&
|
||||
name != kBlogPropWriteBatchStreamingCompressionType) {
|
||||
return Status::NotSupported(
|
||||
"Blog header: unrecognized required property: \"" + name + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input->remove_prefix(prop_section_size);
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
void BlogFileHeader::SetEscapeSequenceFromU64(uint64_t v) {
|
||||
// Byte 0: derived from top 4 bytes via FastRange32 into [1, 254],
|
||||
// avoiding 0x00 and 0xFF (which are padding bytes).
|
||||
uint32_t top = static_cast<uint32_t>(v >> 32);
|
||||
escape_sequence[0] = static_cast<char>(FastRange32(top, 254) + 1);
|
||||
assert(escape_sequence[0] != '\0');
|
||||
assert(escape_sequence[0] != '\xFF');
|
||||
|
||||
// Bytes 1-5: lowest 5 bytes as-is (little-endian).
|
||||
EncodeFixed32(escape_sequence + 1, static_cast<uint32_t>(v));
|
||||
escape_sequence[5] = static_cast<char>(v >> 32);
|
||||
// (byte at index 5 overlaps with top byte used for FastRange32, but
|
||||
// that's fine -- it's just input entropy, not the derived byte 0)
|
||||
|
||||
// Bytes 6-9: XXH3-derived suffix for heuristic detection. This would enable
|
||||
// us to scan through a file missing its header and, with some hashing effort,
|
||||
// detect escape sequences with high accuracy (expect 1 false positive per
|
||||
// ~16GB; further validation/pruning can be done trying to parse records
|
||||
// and/or picking the most common candidate escape sequence).
|
||||
uint64_t derived = XXH3_64bits_withSeed(
|
||||
escape_sequence, kBlogEscapeSeqRandomPartSize, kBlogEscapeSeqSeed);
|
||||
EncodeFixed32(escape_sequence + kBlogEscapeSeqRandomPartSize,
|
||||
static_cast<uint32_t>(derived));
|
||||
}
|
||||
|
||||
void BlogFileHeader::GenerateFromUniqueId(const std::string& db_id,
|
||||
const std::string& db_session_id,
|
||||
uint64_t file_number) {
|
||||
if (db_id.empty() || db_session_id.empty() || file_number == 0) {
|
||||
GenerateRandomFields();
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive escape sequence from the same unique ID scheme as SST files.
|
||||
UniqueId64x2 unique_id;
|
||||
Status s =
|
||||
GetSstInternalUniqueId(db_id, db_session_id, file_number, &unique_id);
|
||||
if (!s.ok()) {
|
||||
GenerateRandomFields();
|
||||
return;
|
||||
}
|
||||
|
||||
// The internal unique ID is intentionally not fully random/mixed. Mix it
|
||||
// so that it is.
|
||||
InternalUniqueIdToExternal(&unique_id);
|
||||
|
||||
SetEscapeSequenceFromU64(unique_id[0]);
|
||||
|
||||
// Store the inputs as ignorable header properties for debugging/tracking.
|
||||
SetProperty(kBlogPropDbId, db_id);
|
||||
SetProperty(kBlogPropDbSessionId, db_session_id);
|
||||
SetUint64Property(kBlogPropFileNumber, file_number);
|
||||
}
|
||||
|
||||
void BlogFileHeader::GenerateRandomFields() {
|
||||
uint64_t rand_a;
|
||||
uint64_t rand_b;
|
||||
GenerateRawUniqueId(&rand_a, &rand_b);
|
||||
// Use rand_a for escape sequence, rand_b is unused extra entropy.
|
||||
(void)rand_b;
|
||||
SetEscapeSequenceFromU64(rand_a);
|
||||
}
|
||||
|
||||
bool BlogFileHeader::VerifyEscapeSequence(const char* seq) {
|
||||
uint8_t first = static_cast<uint8_t>(seq[0]);
|
||||
if (first == 0x00 || first == 0xFF) {
|
||||
return false;
|
||||
}
|
||||
// Verify derived part
|
||||
uint64_t derived = XXH3_64bits_withSeed(seq, kBlogEscapeSeqRandomPartSize,
|
||||
kBlogEscapeSeqSeed);
|
||||
uint32_t expected = static_cast<uint32_t>(derived);
|
||||
uint32_t actual = DecodeFixed32(seq + kBlogEscapeSeqRandomPartSize);
|
||||
return expected == actual;
|
||||
}
|
||||
|
||||
void BlogFileHeader::SetProperty(const std::string& name,
|
||||
const std::string& value) {
|
||||
for (auto& [k, v] : properties) {
|
||||
if (k == name) {
|
||||
v = value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
properties.emplace_back(name, value);
|
||||
}
|
||||
|
||||
std::string BlogFileHeader::GetProperty(const std::string& name) const {
|
||||
for (const auto& [k, v] : properties) {
|
||||
if (k == name) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
bool BlogFileHeader::HasProperty(const std::string& name) const {
|
||||
for (const auto& [k, v] : properties) {
|
||||
if (k == name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void BlogFileHeader::SetUint64Property(const std::string& name,
|
||||
uint64_t value) {
|
||||
std::string encoded;
|
||||
PutVarint64(&encoded, value);
|
||||
SetProperty(name, encoded);
|
||||
}
|
||||
|
||||
void BlogFileHeader::SetUint32Property(const std::string& name,
|
||||
uint32_t value) {
|
||||
std::string encoded;
|
||||
PutVarint32(&encoded, value);
|
||||
SetProperty(name, encoded);
|
||||
}
|
||||
|
||||
bool BlogFileHeader::GetUint64Property(const std::string& name,
|
||||
uint64_t* value) const {
|
||||
std::string raw = GetProperty(name);
|
||||
if (raw.empty()) {
|
||||
return false;
|
||||
}
|
||||
Slice s(raw);
|
||||
return GetVarint64(&s, value);
|
||||
}
|
||||
|
||||
bool BlogFileHeader::GetUint32Property(const std::string& name,
|
||||
uint32_t* value) const {
|
||||
std::string raw = GetProperty(name);
|
||||
if (raw.empty()) {
|
||||
return false;
|
||||
}
|
||||
Slice s(raw);
|
||||
return GetVarint32(&s, value);
|
||||
}
|
||||
|
||||
Status BlogFileHeader::DecodeFromBuffer(Slice* buffer,
|
||||
size_t* additional_bytes_needed) {
|
||||
if (buffer->size() < kBlogFileFixedHeaderSize) {
|
||||
return Status::Corruption("Blog header: buffer too small for fixed header");
|
||||
}
|
||||
|
||||
// Check if the full header (fixed + property section) fits in the buffer.
|
||||
uint32_t prop_section_size = DecodeFixed32(buffer->data() + 32);
|
||||
size_t total_header_size = kBlogFileFixedHeaderSize + prop_section_size;
|
||||
|
||||
if (buffer->size() < total_header_size) {
|
||||
if (additional_bytes_needed) {
|
||||
*additional_bytes_needed = total_header_size - buffer->size();
|
||||
}
|
||||
return Status::Incomplete(
|
||||
"Blog header: need more data for property section",
|
||||
std::to_string(total_header_size));
|
||||
}
|
||||
|
||||
// Full header is available. Decode it.
|
||||
Slice header_slice(buffer->data(), total_header_size);
|
||||
Status s = DecodeFrom(&header_slice);
|
||||
if (s.ok()) {
|
||||
buffer->remove_prefix(total_header_size);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// --- Footer locator verification ---
|
||||
|
||||
bool VerifyBlogFooterLocator(const char* buffer, size_t buffer_size,
|
||||
uint64_t buffer_file_offset,
|
||||
const char* expected_escape_seq,
|
||||
ChecksumType checksum_type,
|
||||
uint32_t incarnation_id) {
|
||||
// Scan backward in 4-byte steps for the escape sequence.
|
||||
// Buffer must start at a 4-byte-aligned file offset.
|
||||
for (size_t pos = buffer_size; pos >= 4; pos -= 4) {
|
||||
size_t esc_start = pos - 4;
|
||||
uint8_t first_byte = static_cast<uint8_t>(buffer[esc_start]);
|
||||
if (first_byte == 0x00 || first_byte == 0xFF) {
|
||||
continue; // padding byte
|
||||
}
|
||||
// Check if this is our escape sequence
|
||||
if (esc_start + kBlogEscapeSequenceSize > buffer_size ||
|
||||
memcmp(buffer + esc_start, expected_escape_seq,
|
||||
kBlogEscapeSequenceSize) != 0) {
|
||||
continue; // not our escape sequence
|
||||
}
|
||||
|
||||
// Found escape sequence. Parse the full-format record that follows.
|
||||
// Full format: [escape_seq:10B] [varint length:4+B] [type:1B]
|
||||
// [compression_type:1B] [prefix_checksum:4B]
|
||||
// [payload:length B] [comp:1B] [checksum:4B]
|
||||
const char* rec = buffer + esc_start + kBlogEscapeSequenceSize;
|
||||
size_t remaining = buffer_size - esc_start - kBlogEscapeSequenceSize;
|
||||
|
||||
// Parse varint length (must be >= 4 bytes for full format)
|
||||
Slice varint_slice(rec, remaining);
|
||||
uint64_t length = 0;
|
||||
const char* varint_end = GetVarint64Ptr(rec, rec + remaining, &length);
|
||||
if (!varint_end) {
|
||||
continue; // truncated varint
|
||||
}
|
||||
size_t varint_len = static_cast<size_t>(varint_end - rec);
|
||||
if (varint_len <= kBlogCompactVarintMaxBytes) {
|
||||
continue; // not full format
|
||||
}
|
||||
|
||||
// Need type(1) + comp(1) + prefix_checksum(4) after varint
|
||||
size_t after_varint = remaining - varint_len;
|
||||
if (after_varint < 6) {
|
||||
continue; // truncated prefix
|
||||
}
|
||||
|
||||
BlogRecordType type =
|
||||
static_cast<BlogRecordType>(static_cast<uint8_t>(varint_end[0]));
|
||||
if (type != kBlogFooterLocatorRecord) {
|
||||
continue; // not a footer locator
|
||||
}
|
||||
|
||||
// Verify prefix checksum over (varint + type + compression_type)
|
||||
size_t prefix_data_size = varint_len + 2; // varint + type + comp
|
||||
uint64_t esc_file_offset = buffer_file_offset + esc_start;
|
||||
uint32_t stored_prefix_checksum = DecodeFixed32(varint_end + 2);
|
||||
uint32_t computed_prefix_checksum =
|
||||
ComputeBuiltinChecksum(checksum_type, rec, prefix_data_size) +
|
||||
ChecksumModifierForContext(incarnation_id, esc_file_offset);
|
||||
if (stored_prefix_checksum != computed_prefix_checksum) {
|
||||
continue; // prefix checksum mismatch
|
||||
}
|
||||
|
||||
// If length > 0, verify the payload trailer checksum
|
||||
if (length > 0) {
|
||||
size_t after_prefix = after_varint - 6; // after type+comp+prefix_cksum
|
||||
if (after_prefix < length + kBlogBlockTrailerSize) {
|
||||
continue; // truncated payload or trailer
|
||||
}
|
||||
const char* payload = varint_end + 6;
|
||||
uint64_t payload_file_offset = buffer_file_offset + esc_start +
|
||||
kBlogEscapeSequenceSize + varint_len + 6;
|
||||
CompressionType actual_comp;
|
||||
Status s = VerifyBlogRecordTrailer(
|
||||
checksum_type, payload, static_cast<size_t>(length), incarnation_id,
|
||||
payload_file_offset, &actual_comp);
|
||||
if (!s.ok()) {
|
||||
continue; // trailer checksum mismatch
|
||||
}
|
||||
}
|
||||
|
||||
// Valid footer locator record found.
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- BlogFileFooterLocator ---
|
||||
|
||||
void BlogFileFooterLocator::EncodeTo(std::string* dst) const {
|
||||
for (const auto& entry : entries) {
|
||||
dst->push_back(static_cast<char>(entry.record_type));
|
||||
PutFixed32(dst, entry.relative_offset_4B);
|
||||
}
|
||||
}
|
||||
|
||||
Status BlogFileFooterLocator::DecodeFrom(const Slice& input) {
|
||||
Slice s = input;
|
||||
entries.clear();
|
||||
while (s.size() >= 5) { // 1 byte type + 4 bytes offset
|
||||
entries.push_back(
|
||||
{.record_type = static_cast<BlogRecordType>(static_cast<uint8_t>(s[0])),
|
||||
.relative_offset_4B = DecodeFixed32(s.data() + 1)});
|
||||
s.remove_prefix(5);
|
||||
}
|
||||
if (s.size() != 0) {
|
||||
return Status::Corruption(
|
||||
"Blog footer locator: trailing bytes after entries");
|
||||
}
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
// --- BlogFileFooterProperties ---
|
||||
|
||||
void BlogFileFooterProperties::SetBlobCount(uint64_t count) {
|
||||
std::string val;
|
||||
PutVarint64(&val, count);
|
||||
properties.emplace_back("blobCount", std::move(val));
|
||||
}
|
||||
|
||||
void BlogFileFooterProperties::SetTotalBlobBytes(uint64_t bytes) {
|
||||
std::string val;
|
||||
PutVarint64(&val, bytes);
|
||||
properties.emplace_back("totalBlobBytes", std::move(val));
|
||||
}
|
||||
|
||||
void BlogFileFooterProperties::SetSequenceRange(uint64_t min_seq,
|
||||
uint64_t max_seq) {
|
||||
std::string min_val;
|
||||
PutVarint64(&min_val, min_seq);
|
||||
properties.emplace_back("minSequence", std::move(min_val));
|
||||
std::string max_val;
|
||||
PutVarint64(&max_val, max_seq);
|
||||
properties.emplace_back("maxSequence", std::move(max_val));
|
||||
}
|
||||
|
||||
void BlogFileFooterProperties::EncodeTo(std::string* dst) const {
|
||||
EncodeBlogProperties(properties, dst);
|
||||
}
|
||||
|
||||
Status BlogFileFooterProperties::DecodeFrom(const Slice& input) {
|
||||
Slice s = input;
|
||||
properties.clear();
|
||||
return DecodeBlogProperties(&s, &properties);
|
||||
}
|
||||
|
||||
// --- Padding ---
|
||||
|
||||
void ComputeBlogPaddingParams(uint8_t last_meaningful_byte,
|
||||
size_t current_offset, uint8_t* pad_byte,
|
||||
size_t* pad_count) {
|
||||
// Determine how many bytes needed to reach next 4-byte alignment
|
||||
size_t remainder = current_offset % 4;
|
||||
size_t needed = (remainder == 0) ? 0 : (4 - remainder);
|
||||
|
||||
// Choose pad byte to differ from last_meaningful_byte
|
||||
if (last_meaningful_byte == 0x00) {
|
||||
*pad_byte = 0xFF;
|
||||
// Must have at least 1 byte of padding to distinguish from checksum
|
||||
if (needed == 0) {
|
||||
needed = 4;
|
||||
}
|
||||
} else if (last_meaningful_byte == 0xFF) {
|
||||
*pad_byte = 0x00;
|
||||
if (needed == 0) {
|
||||
needed = 4;
|
||||
}
|
||||
} else {
|
||||
*pad_byte = 0x00; // Writer's choice; 0x00 is conventional
|
||||
}
|
||||
*pad_count = needed;
|
||||
}
|
||||
|
||||
void ComputeBlogPadding(uint8_t last_meaningful_byte, size_t current_offset,
|
||||
std::string* dst) {
|
||||
uint8_t pad_byte;
|
||||
size_t pad_count;
|
||||
ComputeBlogPaddingParams(last_meaningful_byte, current_offset, &pad_byte,
|
||||
&pad_count);
|
||||
dst->append(pad_count, static_cast<char>(pad_byte));
|
||||
}
|
||||
|
||||
// --- Checksum ---
|
||||
|
||||
uint32_t ComputeBlogRecordChecksum(ChecksumType type, const char* data,
|
||||
size_t data_size, char compression_type_byte,
|
||||
uint32_t base_context_checksum,
|
||||
uint64_t record_offset) {
|
||||
uint32_t checksum = ComputeBuiltinChecksumWithLastByte(type, data, data_size,
|
||||
compression_type_byte);
|
||||
checksum += ChecksumModifierForContext(base_context_checksum, record_offset);
|
||||
return checksum;
|
||||
}
|
||||
|
||||
// --- Record trailer verification ---
|
||||
|
||||
Status VerifyBlogRecordTrailer(ChecksumType checksum_type, const char* payload,
|
||||
size_t payload_size, uint32_t incarnation_id,
|
||||
uint64_t payload_file_offset,
|
||||
CompressionType* actual_compression_type) {
|
||||
// Trailer is the 5 bytes immediately after the payload:
|
||||
// [compression_type: 1B][checksum: 4B]
|
||||
const char* trailer = payload + payload_size;
|
||||
char comp_byte = trailer[0];
|
||||
uint32_t stored_checksum = DecodeFixed32(trailer + 1);
|
||||
uint32_t computed_checksum =
|
||||
ComputeBlogRecordChecksum(checksum_type, payload, payload_size, comp_byte,
|
||||
incarnation_id, payload_file_offset);
|
||||
if (stored_checksum != computed_checksum) {
|
||||
return Status::Corruption("Blog record checksum mismatch");
|
||||
}
|
||||
if (actual_compression_type) {
|
||||
*actual_compression_type =
|
||||
static_cast<CompressionType>(static_cast<uint8_t>(comp_byte));
|
||||
}
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
// --- Irregular varint ---
|
||||
|
||||
void PutBlogIrregularVarint64(std::string* dst, uint64_t value,
|
||||
size_t min_encoded_bytes) {
|
||||
// Encode as standard varint, then pad with trailing zero-data bytes
|
||||
// to reach min_encoded_bytes. The result decodes to the same value.
|
||||
char buf[10];
|
||||
char* end = EncodeVarint64(buf, value);
|
||||
size_t natural_len = static_cast<size_t>(end - buf);
|
||||
|
||||
if (natural_len >= min_encoded_bytes) {
|
||||
dst->append(buf, natural_len);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the continuation bit on what was the terminal byte, then append
|
||||
// (extra - 1) continuation bytes (0x80) and one terminal byte (0x00).
|
||||
size_t extra = min_encoded_bytes - natural_len;
|
||||
buf[natural_len - 1] |= static_cast<char>(0x80);
|
||||
dst->append(buf, natural_len);
|
||||
for (size_t i = 0; i + 1 < extra; ++i) {
|
||||
dst->push_back(static_cast<char>(0x80));
|
||||
}
|
||||
dst->push_back(static_cast<char>(0x00));
|
||||
}
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
@@ -0,0 +1,299 @@
|
||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
// This source code is licensed under both the GPLv2 (found in the
|
||||
// COPYING file in the root directory) and Apache 2.0 License
|
||||
// (found in the LICENSE.Apache file in the root directory).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "rocksdb/rocksdb_namespace.h"
|
||||
#include "rocksdb/slice.h"
|
||||
#include "rocksdb/status.h"
|
||||
#include "rocksdb/table.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
// Record types used in blog file body and footer.
|
||||
// Types 0x01-0x7F are for body records.
|
||||
// Types 0xF0-0xFF are for footer records (in expected order of occurrence,
|
||||
// with gaps for future additions).
|
||||
enum BlogRecordType : uint8_t {
|
||||
kBlogBlobRecord = 0x01,
|
||||
kBlogPreambleStartRecord = 0x02,
|
||||
kBlogWriteBatchRecord = 0x03,
|
||||
// 0x04-0x7F: reserved for future body record types
|
||||
|
||||
kBlogFooterIndexRecord = 0xF0, // sparse index or similar large metadata
|
||||
// 0xF1-0xF3: reserved for future footer data records
|
||||
kBlogFooterPropertiesRecord = 0xF8, // small metadata (named properties)
|
||||
// 0xF9-0xFB: reserved
|
||||
kBlogFooterFileChecksumInfo = 0xFC, // full file checksum state dump, so
|
||||
// the file checksum can be inferred
|
||||
// from just reading the tail
|
||||
// 0xFD-0xFE: reserved
|
||||
kBlogFooterLocatorRecord = 0xFF, // relative offsets to other footer
|
||||
// records; must be last
|
||||
};
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
// 12-byte file magic: "Blog" (ASCII) followed by XXH3_64bits("Blog", 4).
|
||||
// Used as an opaque identifier for blog format files.
|
||||
static constexpr char kBlogFileMagic[12] = {
|
||||
'\x42', '\x6C', '\x6F', '\x67', // "Blog"
|
||||
'\x9F', '\xFC', '\x41', '\x52', '\xF7', '\xF2', '\x2C', '\x49' // XXH3_64
|
||||
};
|
||||
static constexpr size_t kBlogFileMagicSize = 12;
|
||||
|
||||
// Fixed header size in bytes (before property section).
|
||||
static constexpr size_t kBlogFileFixedHeaderSize = 40;
|
||||
|
||||
// Escape sequence: 6 random bytes + 4 derived bytes.
|
||||
static constexpr size_t kBlogEscapeSequenceSize = 10;
|
||||
static constexpr size_t kBlogEscapeSeqRandomPartSize = 6;
|
||||
|
||||
// Seed for deriving bytes [6-9] of escape sequence via
|
||||
// XXH3_64bits_withSeed(random_6_bytes, 6, kBlogEscapeSeqSeed).
|
||||
// Chosen arbitrarily; must never change once the format is released.
|
||||
static constexpr uint64_t kBlogEscapeSeqSeed =
|
||||
0x52636B73426C6F67ULL; // "RcksBlg\0"
|
||||
|
||||
// Sentinel for unspecified-size records. Encodes to a 9-byte varint,
|
||||
// which always triggers the full record format.
|
||||
static constexpr uint64_t kBlogUnspecifiedSize = (uint64_t{1} << 63) - 1;
|
||||
|
||||
// Maximum payload size that encodes to a compact-format varint (<= 2 bytes).
|
||||
// varint of 16383 (0x3FFF) uses 2 bytes; 16384 uses 3 bytes.
|
||||
// Records larger than this get full format with prefix checksum protection.
|
||||
static constexpr uint64_t kBlogMaxCompactPayloadSize = (1u << 14) - 1;
|
||||
|
||||
// Compact format varint threshold: varints of this many bytes or fewer
|
||||
// use compact format (no type byte, no prefix checksum).
|
||||
static constexpr size_t kBlogCompactVarintMaxBytes = 2;
|
||||
|
||||
// Block trailer size: 1 byte compression_type + 4 bytes checksum.
|
||||
static constexpr size_t kBlogBlockTrailerSize = 5;
|
||||
|
||||
// kStreamingCompressionSentinel (0x7F) is defined in compression_type.h
|
||||
// and used in blog record trailers to indicate streaming compression.
|
||||
// A record with kNoCompression in the trailer remains individually
|
||||
// uncompressed even when streaming is active (dynamic on/off).
|
||||
|
||||
// --- Named Properties ---
|
||||
|
||||
// Named property: string key -> string value.
|
||||
// Case convention: Uppercase first letter = required (reader must recognize
|
||||
// or reject the file). Lowercase first letter = ignorable.
|
||||
using BlogPropertyMap = std::vector<std::pair<std::string, std::string>>;
|
||||
|
||||
// Encode properties as a sequence of length-prefixed name/value pairs.
|
||||
// No count prefix; section is terminated by reaching the end of the slice.
|
||||
void EncodeBlogProperties(const BlogPropertyMap& props, std::string* dst);
|
||||
|
||||
// Decode properties from a slice, consuming all bytes.
|
||||
Status DecodeBlogProperties(Slice* input, BlogPropertyMap* props);
|
||||
|
||||
// Returns true if a property name starts with an uppercase letter,
|
||||
// meaning the reader must recognize it or reject the file.
|
||||
inline bool IsBlogRequiredProperty(const Slice& name) {
|
||||
return name.size() > 0 && name[0] >= 'A' && name[0] <= 'Z';
|
||||
}
|
||||
|
||||
// Known header property names
|
||||
static constexpr const char* kBlogPropCompressionCompatibilityName =
|
||||
"CompressionCompatibilityName";
|
||||
static constexpr const char* kBlogPropTimestampSize = "TimestampSize";
|
||||
static constexpr const char* kBlogPropPredecessorWalInfo = "PredecessorWalInfo";
|
||||
// Required (uppercase) when streaming compression is active for WriteBatch
|
||||
// records. Value is a 2-digit hex encoding of the CompressionType used
|
||||
// (e.g. "07" for kZSTD). The presence of this property means WriteBatch
|
||||
// record payloads with kStreamingCompressionSentinel in their trailer
|
||||
// are part of a streaming compression context that spans across records.
|
||||
static constexpr const char* kBlogPropWriteBatchStreamingCompressionType =
|
||||
"WriteBatchStreamingCompressionType";
|
||||
static constexpr const char* kBlogPropRole = "role";
|
||||
static constexpr const char* kBlogPropCompressionSettings =
|
||||
"compressionSettings";
|
||||
static constexpr const char* kBlogPropCreationTime = "creationTime";
|
||||
static constexpr const char* kBlogPropCreator = "creator";
|
||||
// Ignorable properties for file identity and diagnostics.
|
||||
static constexpr const char* kBlogPropDbId = "dbId";
|
||||
static constexpr const char* kBlogPropDbSessionId = "dbSessionId";
|
||||
static constexpr const char* kBlogPropFileNumber = "fileNumber";
|
||||
static constexpr const char* kBlogPropDbHostId = "dbHostId";
|
||||
static constexpr const char* kBlogPropColumnFamilyId = "columnFamilyId";
|
||||
static constexpr const char* kBlogPropColumnFamilyName = "columnFamilyName";
|
||||
|
||||
// --- BlogFileHeader ---
|
||||
|
||||
// Bit flags for BlogFileHeader::flags (byte [15] in the fixed header).
|
||||
enum BlogFileHeaderFlags : uint8_t {
|
||||
kBlogFileRecycled = 0x01, // File may contain trailing stale data from a
|
||||
// previous incarnation. Recovery should tolerate
|
||||
// trailing data that doesn't match our escape
|
||||
// sequence (recycled/stale, not corruption).
|
||||
};
|
||||
|
||||
static constexpr uint8_t kBlogCurrentSchemaVersion = 0;
|
||||
|
||||
struct BlogFileHeader {
|
||||
uint8_t schema_version = kBlogCurrentSchemaVersion;
|
||||
ChecksumType checksum_type = kXXH3;
|
||||
BlogRecordType compact_record_type = kBlogBlobRecord;
|
||||
uint8_t flags = 0; // BlogFileHeaderFlags, byte [15] in fixed header
|
||||
// 10-byte escape sequence: bytes [0-5] random (byte[0] not 0x00/0xFF),
|
||||
// bytes [6-9] = lower32(XXH3_64bits_withSeed(bytes[0-5],
|
||||
// kBlogEscapeSeqSeed)).
|
||||
char escape_sequence[kBlogEscapeSequenceSize] = {};
|
||||
|
||||
bool is_recycled() const { return flags & kBlogFileRecycled; }
|
||||
|
||||
BlogPropertyMap properties;
|
||||
|
||||
// Incarnation ID = first 4 bytes of escape_sequence (little-endian via
|
||||
// DecodeFixed32). Guaranteed nonzero since byte[0] is not 0x00.
|
||||
uint32_t incarnation_id() const;
|
||||
|
||||
// Encode the full header (fixed prefix + property section + checksums)
|
||||
// into dst.
|
||||
void EncodeTo(std::string* dst) const;
|
||||
|
||||
// Decode from input. Consumes the bytes read.
|
||||
Status DecodeFrom(Slice* input);
|
||||
|
||||
// Generate escape_sequence from the file's unique ID (db_id, db_session_id,
|
||||
// file_number), using the same scheme as SST internal unique IDs. Falls back
|
||||
// to GenerateRandomFields() if any input is empty/zero. Also sets the
|
||||
// ignorable dbId/dbSessionId/fileNumber header properties.
|
||||
void GenerateFromUniqueId(const std::string& db_id,
|
||||
const std::string& db_session_id,
|
||||
uint64_t file_number);
|
||||
|
||||
// Generate escape_sequence from random bytes. Appropriate when unique ID
|
||||
// inputs are not available.
|
||||
void GenerateRandomFields();
|
||||
|
||||
private:
|
||||
// Populate escape_sequence from a 64 bits of entropy.
|
||||
void SetEscapeSequenceFromU64(uint64_t v);
|
||||
|
||||
public:
|
||||
// Verify that the escape sequence's derived bytes match.
|
||||
static bool VerifyEscapeSequence(const char* seq);
|
||||
|
||||
// Check if a buffer starts with the blog file magic number.
|
||||
static bool IsBlogFormat(const char* data, size_t size) {
|
||||
return size >= kBlogFileMagicSize &&
|
||||
memcmp(data, kBlogFileMagic, kBlogFileMagicSize) == 0;
|
||||
}
|
||||
|
||||
// Decode a blog file header from a buffer that may or may not contain
|
||||
// the full header. Returns OK and consumes the header bytes on success.
|
||||
// If the buffer contains the fixed header but not the full property
|
||||
// section, returns the number of additional bytes needed in
|
||||
// *additional_bytes_needed and returns Status::Incomplete().
|
||||
// This enables single-read header parsing from a ~4 KiB buffer.
|
||||
Status DecodeFromBuffer(Slice* buffer, size_t* additional_bytes_needed);
|
||||
|
||||
// Raw property helpers (string values)
|
||||
void SetProperty(const std::string& name, const std::string& value);
|
||||
std::string GetProperty(const std::string& name) const;
|
||||
|
||||
// Typed property setters/getters for properties that aren't natively strings.
|
||||
void SetUint64Property(const std::string& name, uint64_t value);
|
||||
void SetUint32Property(const std::string& name, uint32_t value);
|
||||
bool GetUint64Property(const std::string& name, uint64_t* value) const;
|
||||
bool GetUint32Property(const std::string& name, uint32_t* value) const;
|
||||
bool HasProperty(const std::string& name) const;
|
||||
};
|
||||
|
||||
// --- Footer types ---
|
||||
|
||||
// Entry in a footer locator record. Offsets are in units of 4 bytes,
|
||||
// sequential relative to each other in reverse order of footer records.
|
||||
// First entry: offset from locator's escape_seq to the preceding footer
|
||||
// record's escape_seq. Subsequent entries: offset from the previous
|
||||
// entry's escape_seq to the next earlier footer record's escape_seq.
|
||||
// Verify that a blog file's trailing data contains a valid footer locator
|
||||
// record. Scans backward from the end of the buffer for the escape sequence,
|
||||
// then parses and verifies the full record (varint length, type, prefix
|
||||
// checksum, payload, trailer checksum). The buffer must start at a 4-byte-
|
||||
// aligned file offset. Returns true if a valid footer locator record is found.
|
||||
bool VerifyBlogFooterLocator(const char* buffer, size_t buffer_size,
|
||||
uint64_t buffer_file_offset,
|
||||
const char* expected_escape_seq,
|
||||
ChecksumType checksum_type,
|
||||
uint32_t incarnation_id);
|
||||
|
||||
struct BlogFooterLocatorEntry {
|
||||
BlogRecordType record_type;
|
||||
uint32_t relative_offset_4B;
|
||||
};
|
||||
|
||||
struct BlogFileFooterLocator {
|
||||
std::vector<BlogFooterLocatorEntry> entries;
|
||||
|
||||
// Encode as the payload of a footer locator record.
|
||||
void EncodeTo(std::string* dst) const;
|
||||
Status DecodeFrom(const Slice& input);
|
||||
};
|
||||
|
||||
struct BlogFileFooterProperties {
|
||||
BlogPropertyMap properties;
|
||||
|
||||
// Convenience setters that encode values into the property map.
|
||||
void SetBlobCount(uint64_t count);
|
||||
void SetTotalBlobBytes(uint64_t bytes);
|
||||
void SetSequenceRange(uint64_t min_seq, uint64_t max_seq);
|
||||
|
||||
// Encode as named properties (no 5-byte trailer; that comes from
|
||||
// the enclosing full record format).
|
||||
void EncodeTo(std::string* dst) const;
|
||||
Status DecodeFrom(const Slice& input);
|
||||
};
|
||||
|
||||
// --- Padding helpers ---
|
||||
|
||||
// Compute padding needed to reach the next 4-byte-aligned offset,
|
||||
// choosing the pad byte value to differ from last_meaningful_byte.
|
||||
// Appends padding bytes to dst.
|
||||
void ComputeBlogPadding(uint8_t last_meaningful_byte, size_t current_offset,
|
||||
std::string* dst);
|
||||
|
||||
// Same as above, but returns the pad byte value and count without
|
||||
// allocating. For use on hot paths.
|
||||
void ComputeBlogPaddingParams(uint8_t last_meaningful_byte,
|
||||
size_t current_offset, uint8_t* pad_byte,
|
||||
size_t* pad_count);
|
||||
|
||||
// --- Checksum helpers ---
|
||||
|
||||
// Compute a context-aware checksum over data + compression_type byte,
|
||||
// suitable for a blog record trailer. Uses the same algorithm and context
|
||||
// checksum approach as block-based SST files.
|
||||
uint32_t ComputeBlogRecordChecksum(ChecksumType type, const char* data,
|
||||
size_t data_size, char compression_type_byte,
|
||||
uint32_t base_context_checksum,
|
||||
uint64_t record_offset);
|
||||
|
||||
// Verify a blog record's 5-byte trailer (compression_type + checksum).
|
||||
// payload points to the record payload of payload_size bytes; the trailer
|
||||
// immediately follows. Returns OK if checksum matches, Corruption otherwise.
|
||||
// On success, *actual_compression_type is set from the trailer.
|
||||
Status VerifyBlogRecordTrailer(ChecksumType checksum_type, const char* payload,
|
||||
size_t payload_size, uint32_t incarnation_id,
|
||||
uint64_t payload_file_offset,
|
||||
CompressionType* actual_compression_type);
|
||||
|
||||
// Encode an irregular varint: a value encoded with more bytes than the
|
||||
// minimum required, ensuring varint length > 3 bytes to trigger full
|
||||
// record format. The value is encoded correctly and will decode to the
|
||||
// same value via standard GetVarint64.
|
||||
void PutBlogIrregularVarint64(std::string* dst, uint64_t value,
|
||||
size_t min_encoded_bytes);
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
@@ -0,0 +1,539 @@
|
||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
// This source code is licensed under both the GPLv2 (found in the
|
||||
// COPYING file in the root directory) and Apache 2.0 License
|
||||
// (found in the LICENSE.Apache file in the root directory).
|
||||
|
||||
#include "db/blog/blog_format.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "table/format.h"
|
||||
#include "test_util/testharness.h"
|
||||
#include "util/coding.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
class BlogFormatTest : public testing::Test {};
|
||||
|
||||
// --- Property encoding/decoding ---
|
||||
|
||||
TEST_F(BlogFormatTest, EncodeDecodePropertiesEmpty) {
|
||||
BlogPropertyMap props;
|
||||
std::string encoded;
|
||||
EncodeBlogProperties(props, &encoded);
|
||||
ASSERT_EQ(encoded.size(), 0u);
|
||||
|
||||
BlogPropertyMap decoded;
|
||||
Slice input(encoded);
|
||||
ASSERT_OK(DecodeBlogProperties(&input, &decoded));
|
||||
ASSERT_TRUE(decoded.empty());
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, EncodeDecodePropertiesMultiple) {
|
||||
BlogPropertyMap props = {
|
||||
{"CompressionCompatibilityName", "zstd"},
|
||||
{"role", "wal"},
|
||||
{"compressionSettings", "level=3"},
|
||||
};
|
||||
std::string encoded;
|
||||
EncodeBlogProperties(props, &encoded);
|
||||
ASSERT_GT(encoded.size(), 0u);
|
||||
|
||||
BlogPropertyMap decoded;
|
||||
Slice input(encoded);
|
||||
ASSERT_OK(DecodeBlogProperties(&input, &decoded));
|
||||
ASSERT_EQ(decoded.size(), 3u);
|
||||
ASSERT_EQ(decoded[0].first, "CompressionCompatibilityName");
|
||||
ASSERT_EQ(decoded[0].second, "zstd");
|
||||
ASSERT_EQ(decoded[1].first, "role");
|
||||
ASSERT_EQ(decoded[1].second, "wal");
|
||||
ASSERT_EQ(decoded[2].first, "compressionSettings");
|
||||
ASSERT_EQ(decoded[2].second, "level=3");
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, DecodePropertiesTruncated) {
|
||||
// Write a name but no value
|
||||
std::string encoded;
|
||||
PutLengthPrefixedSlice(&encoded, "key");
|
||||
// Truncate before value
|
||||
BlogPropertyMap decoded;
|
||||
Slice input(encoded);
|
||||
ASSERT_TRUE(DecodeBlogProperties(&input, &decoded).IsCorruption());
|
||||
}
|
||||
|
||||
// --- IsBlogRequiredProperty ---
|
||||
|
||||
TEST_F(BlogFormatTest, IsBlogRequiredProperty) {
|
||||
ASSERT_TRUE(IsBlogRequiredProperty("CompressionCompatibilityName"));
|
||||
ASSERT_TRUE(IsBlogRequiredProperty("TimestampSize"));
|
||||
ASSERT_TRUE(IsBlogRequiredProperty("A"));
|
||||
ASSERT_FALSE(IsBlogRequiredProperty("role"));
|
||||
ASSERT_FALSE(IsBlogRequiredProperty("compressionSettings"));
|
||||
ASSERT_FALSE(IsBlogRequiredProperty("a"));
|
||||
ASSERT_FALSE(IsBlogRequiredProperty(""));
|
||||
}
|
||||
|
||||
// --- Escape sequence generation ---
|
||||
|
||||
TEST_F(BlogFormatTest, GenerateRandomFieldsConstraints) {
|
||||
// Generate many escape sequences and verify constraints
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
|
||||
// First byte must not be 0x00 or 0xFF
|
||||
uint8_t first = static_cast<uint8_t>(header.escape_sequence[0]);
|
||||
ASSERT_NE(first, 0x00u) << "iteration " << i;
|
||||
ASSERT_NE(first, 0xFFu) << "iteration " << i;
|
||||
|
||||
// Derived bytes must match
|
||||
ASSERT_TRUE(BlogFileHeader::VerifyEscapeSequence(header.escape_sequence))
|
||||
<< "iteration " << i;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, IncarnationIdNonzero) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
// incarnation_id is first 4 bytes little-endian; since byte[0] is not 0,
|
||||
// the incarnation_id must be nonzero.
|
||||
ASSERT_NE(header.incarnation_id(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, VerifyEscapeSequenceRejectsInvalid) {
|
||||
char seq[kBlogEscapeSequenceSize] = {};
|
||||
// All zeros: first byte is 0x00 -> invalid
|
||||
ASSERT_FALSE(BlogFileHeader::VerifyEscapeSequence(seq));
|
||||
|
||||
// Valid first byte but wrong derived part
|
||||
seq[0] = 0x42;
|
||||
seq[1] = 0x13;
|
||||
// Derived part is random garbage, likely won't match
|
||||
ASSERT_FALSE(BlogFileHeader::VerifyEscapeSequence(seq));
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeuristicEscapeSequenceDetection) {
|
||||
// Generate a valid escape sequence and verify we can detect it
|
||||
// heuristically (by checking derived bytes against random part).
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
|
||||
// VerifyEscapeSequence does exactly this heuristic check
|
||||
ASSERT_TRUE(BlogFileHeader::VerifyEscapeSequence(header.escape_sequence));
|
||||
|
||||
// Corrupt one random byte -> should fail verification
|
||||
char corrupted[kBlogEscapeSequenceSize];
|
||||
memcpy(corrupted, header.escape_sequence, kBlogEscapeSequenceSize);
|
||||
corrupted[2] ^= 0x42;
|
||||
// Might still pass if the corruption happens to produce a valid derived
|
||||
// part, but astronomically unlikely
|
||||
// (We don't ASSERT_FALSE here because of the tiny probability)
|
||||
}
|
||||
|
||||
// --- BlogFileHeader encode/decode ---
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderEncodeDecodeMinimal) {
|
||||
BlogFileHeader header;
|
||||
header.checksum_type = kXXH3;
|
||||
header.compact_record_type = kBlogWriteBatchRecord;
|
||||
header.GenerateRandomFields();
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
ASSERT_GE(encoded.size(), kBlogFileFixedHeaderSize);
|
||||
|
||||
BlogFileHeader decoded;
|
||||
Slice input(encoded);
|
||||
ASSERT_OK(decoded.DecodeFrom(&input));
|
||||
|
||||
ASSERT_EQ(decoded.schema_version, header.schema_version);
|
||||
ASSERT_EQ(decoded.checksum_type, header.checksum_type);
|
||||
ASSERT_EQ(decoded.compact_record_type, header.compact_record_type);
|
||||
ASSERT_EQ(memcmp(decoded.escape_sequence, header.escape_sequence,
|
||||
kBlogEscapeSequenceSize),
|
||||
0);
|
||||
ASSERT_TRUE(decoded.properties.empty());
|
||||
ASSERT_EQ(input.size(), 0u); // all bytes consumed
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderEncodeDecodeWithProperties) {
|
||||
BlogFileHeader header;
|
||||
header.checksum_type = kCRC32c;
|
||||
header.compact_record_type = kBlogBlobRecord;
|
||||
header.GenerateRandomFields();
|
||||
header.SetProperty("CompressionCompatibilityName", "lz4");
|
||||
header.SetProperty("role", "blob");
|
||||
header.SetProperty("compressionSettings", "level=1");
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
|
||||
BlogFileHeader decoded;
|
||||
Slice input(encoded);
|
||||
ASSERT_OK(decoded.DecodeFrom(&input));
|
||||
|
||||
ASSERT_EQ(decoded.GetProperty("CompressionCompatibilityName"), "lz4");
|
||||
ASSERT_EQ(decoded.GetProperty("role"), "blob");
|
||||
ASSERT_EQ(decoded.GetProperty("compressionSettings"), "level=1");
|
||||
ASSERT_EQ(input.size(), 0u);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderRejectsUnknownRequiredProperty) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
header.SetProperty("FutureRequired", "something");
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
|
||||
BlogFileHeader decoded;
|
||||
Slice input(encoded);
|
||||
Status s = decoded.DecodeFrom(&input);
|
||||
ASSERT_TRUE(s.IsNotSupported()) << s.ToString();
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderRejectsNewerSchemaVersion) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
|
||||
// Tamper with schema version byte (offset 12) to a future version.
|
||||
// Must also recompute the fixed header checksum (bytes [36-39]) with
|
||||
// context modifier using the incarnation_id.
|
||||
encoded[12] = static_cast<char>(kBlogCurrentSchemaVersion + 1);
|
||||
uint32_t new_checksum =
|
||||
ComputeBuiltinChecksum(header.checksum_type, encoded.data(), 36) +
|
||||
ChecksumModifierForContext(header.incarnation_id(), 0);
|
||||
EncodeFixed32(&encoded[36], new_checksum);
|
||||
|
||||
BlogFileHeader decoded;
|
||||
Slice input(encoded);
|
||||
Status s = decoded.DecodeFrom(&input);
|
||||
ASSERT_TRUE(s.IsNotSupported()) << s.ToString();
|
||||
ASSERT_NE(s.ToString().find("newer than supported"), std::string::npos)
|
||||
<< s.ToString();
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderCurrentSchemaVersionAccepted) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
ASSERT_EQ(header.schema_version, kBlogCurrentSchemaVersion);
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
|
||||
BlogFileHeader decoded;
|
||||
Slice input(encoded);
|
||||
ASSERT_OK(decoded.DecodeFrom(&input));
|
||||
ASSERT_EQ(decoded.schema_version, kBlogCurrentSchemaVersion);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, TypedPropertyRoundTrip) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
header.SetUint64Property("fileNumber", 12345678ULL);
|
||||
header.SetUint32Property("columnFamilyId", 42);
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
|
||||
BlogFileHeader decoded;
|
||||
Slice input(encoded);
|
||||
ASSERT_OK(decoded.DecodeFrom(&input));
|
||||
|
||||
uint64_t fn = 0;
|
||||
ASSERT_TRUE(decoded.GetUint64Property("fileNumber", &fn));
|
||||
ASSERT_EQ(fn, 12345678ULL);
|
||||
|
||||
uint32_t cf_id = 0;
|
||||
ASSERT_TRUE(decoded.GetUint32Property("columnFamilyId", &cf_id));
|
||||
ASSERT_EQ(cf_id, 42u);
|
||||
|
||||
// Missing property returns false
|
||||
uint64_t missing = 0;
|
||||
ASSERT_FALSE(decoded.GetUint64Property("nonexistent", &missing));
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderAcceptsUnknownIgnorableProperty) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
header.SetProperty("futureIgnorable", "something");
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
|
||||
BlogFileHeader decoded;
|
||||
Slice input(encoded);
|
||||
ASSERT_OK(decoded.DecodeFrom(&input));
|
||||
ASSERT_EQ(decoded.GetProperty("futureIgnorable"), "something");
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderChecksumCorruption) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
|
||||
// Corrupt a byte in the fixed header
|
||||
encoded[14] ^= 0xFF;
|
||||
|
||||
BlogFileHeader decoded;
|
||||
Slice input(encoded);
|
||||
ASSERT_TRUE(decoded.DecodeFrom(&input).IsCorruption());
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderPropertyChecksumCorruption) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
header.SetProperty("role", "wal");
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
|
||||
// Corrupt a byte in the property section (after fixed header)
|
||||
if (encoded.size() > kBlogFileFixedHeaderSize + 2) {
|
||||
encoded[kBlogFileFixedHeaderSize + 2] ^= 0xFF;
|
||||
}
|
||||
|
||||
BlogFileHeader decoded;
|
||||
Slice input(encoded);
|
||||
Status s = decoded.DecodeFrom(&input);
|
||||
ASSERT_TRUE(s.IsCorruption()) << s.ToString();
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderSetPropertyOverwrites) {
|
||||
BlogFileHeader header;
|
||||
header.SetProperty("role", "wal");
|
||||
ASSERT_EQ(header.GetProperty("role"), "wal");
|
||||
|
||||
header.SetProperty("role", "blob");
|
||||
ASSERT_EQ(header.GetProperty("role"), "blob");
|
||||
// Should not have duplicate entries
|
||||
int count = 0;
|
||||
for (const auto& [k, v] : header.properties) {
|
||||
if (k == "role") {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
ASSERT_EQ(count, 1);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, HeaderMagicValues) {
|
||||
BlogFileHeader header;
|
||||
header.GenerateRandomFields();
|
||||
|
||||
std::string encoded;
|
||||
header.EncodeTo(&encoded);
|
||||
|
||||
// Verify magic bytes
|
||||
ASSERT_GE(encoded.size(), kBlogFileMagicSize);
|
||||
ASSERT_EQ(memcmp(encoded.data(), kBlogFileMagic, kBlogFileMagicSize), 0);
|
||||
}
|
||||
|
||||
// --- Footer locator ---
|
||||
|
||||
TEST_F(BlogFormatTest, FooterLocatorEncodeDecodeEmpty) {
|
||||
BlogFileFooterLocator locator;
|
||||
|
||||
std::string encoded;
|
||||
locator.EncodeTo(&encoded);
|
||||
ASSERT_EQ(encoded.size(), 0u);
|
||||
|
||||
BlogFileFooterLocator decoded;
|
||||
ASSERT_OK(decoded.DecodeFrom(Slice(encoded)));
|
||||
ASSERT_TRUE(decoded.entries.empty());
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, FooterLocatorEncodeDecodeMultiple) {
|
||||
BlogFileFooterLocator locator;
|
||||
locator.entries.push_back({kBlogFooterPropertiesRecord, 50});
|
||||
locator.entries.push_back({kBlogFooterIndexRecord, 85});
|
||||
|
||||
std::string encoded;
|
||||
locator.EncodeTo(&encoded);
|
||||
ASSERT_EQ(encoded.size(), 10u); // 2 * (1 + 4)
|
||||
|
||||
BlogFileFooterLocator decoded;
|
||||
ASSERT_OK(decoded.DecodeFrom(Slice(encoded)));
|
||||
ASSERT_EQ(decoded.entries.size(), 2u);
|
||||
ASSERT_EQ(decoded.entries[0].record_type, kBlogFooterPropertiesRecord);
|
||||
ASSERT_EQ(decoded.entries[0].relative_offset_4B, 50u);
|
||||
ASSERT_EQ(decoded.entries[1].record_type, kBlogFooterIndexRecord);
|
||||
ASSERT_EQ(decoded.entries[1].relative_offset_4B, 85u);
|
||||
}
|
||||
|
||||
// --- Footer properties ---
|
||||
|
||||
TEST_F(BlogFormatTest, FooterPropertiesEncodeDecodeRoundTrip) {
|
||||
BlogFileFooterProperties props;
|
||||
props.SetBlobCount(42);
|
||||
props.SetTotalBlobBytes(123456);
|
||||
props.SetSequenceRange(100, 200);
|
||||
|
||||
std::string encoded;
|
||||
props.EncodeTo(&encoded);
|
||||
|
||||
BlogFileFooterProperties decoded;
|
||||
ASSERT_OK(decoded.DecodeFrom(Slice(encoded)));
|
||||
ASSERT_EQ(decoded.properties.size(), 4u);
|
||||
}
|
||||
|
||||
// --- Padding ---
|
||||
|
||||
TEST_F(BlogFormatTest, PaddingLastByteZero) {
|
||||
// When last byte is 0x00, must pad with 0xFF, at least 1 byte
|
||||
std::string pad;
|
||||
// At offset 0 (already aligned), still need at least 1 byte
|
||||
ComputeBlogPadding(0x00, 0, &pad);
|
||||
ASSERT_GE(pad.size(), 1u);
|
||||
for (char c : pad) {
|
||||
ASSERT_EQ(static_cast<uint8_t>(c), 0xFFu);
|
||||
}
|
||||
// Result must be 4-byte aligned
|
||||
ASSERT_EQ(pad.size() % 4, 0u);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, PaddingLastByteFF) {
|
||||
// When last byte is 0xFF, must pad with 0x00, at least 1 byte
|
||||
std::string pad;
|
||||
ComputeBlogPadding(0xFF, 0, &pad);
|
||||
ASSERT_GE(pad.size(), 1u);
|
||||
for (char c : pad) {
|
||||
ASSERT_EQ(static_cast<uint8_t>(c), 0x00u);
|
||||
}
|
||||
ASSERT_EQ(pad.size() % 4, 0u);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, PaddingLastByteOther) {
|
||||
// When last byte is something else, pad only if needed for alignment
|
||||
std::string pad;
|
||||
ComputeBlogPadding(0x42, 0, &pad);
|
||||
// Already aligned, no padding needed
|
||||
ASSERT_EQ(pad.size(), 0u);
|
||||
|
||||
pad.clear();
|
||||
ComputeBlogPadding(0x42, 1, &pad);
|
||||
// Need 3 bytes to reach alignment
|
||||
ASSERT_EQ(pad.size(), 3u);
|
||||
for (char c : pad) {
|
||||
ASSERT_EQ(static_cast<uint8_t>(c), 0x00u);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, PaddingAlignment) {
|
||||
for (size_t offset = 0; offset < 8; ++offset) {
|
||||
std::string pad;
|
||||
ComputeBlogPadding(0x42, offset, &pad);
|
||||
size_t after = offset + pad.size();
|
||||
ASSERT_EQ(after % 4, 0u) << "offset=" << offset;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Irregular varint ---
|
||||
|
||||
TEST_F(BlogFormatTest, IrregularVarintZero) {
|
||||
std::string encoded;
|
||||
PutBlogIrregularVarint64(&encoded, 0, 4);
|
||||
ASSERT_GE(encoded.size(), 4u);
|
||||
|
||||
// Must decode back to 0
|
||||
Slice input(encoded);
|
||||
uint64_t value;
|
||||
ASSERT_TRUE(GetVarint64(&input, &value));
|
||||
ASSERT_EQ(value, 0u);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, IrregularVarintSmallValue) {
|
||||
std::string encoded;
|
||||
PutBlogIrregularVarint64(&encoded, 42, 4);
|
||||
ASSERT_GE(encoded.size(), 4u);
|
||||
|
||||
Slice input(encoded);
|
||||
uint64_t value;
|
||||
ASSERT_TRUE(GetVarint64(&input, &value));
|
||||
ASSERT_EQ(value, 42u);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, IrregularVarintLargeValue) {
|
||||
// A value that naturally needs 3 bytes, padded to 5
|
||||
uint64_t original = 100000;
|
||||
std::string encoded;
|
||||
PutBlogIrregularVarint64(&encoded, original, 5);
|
||||
ASSERT_GE(encoded.size(), 5u);
|
||||
|
||||
Slice input(encoded);
|
||||
uint64_t value;
|
||||
ASSERT_TRUE(GetVarint64(&input, &value));
|
||||
ASSERT_EQ(value, original);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, IrregularVarintNaturallyLargeEnough) {
|
||||
// A value that naturally needs 5+ bytes should not be padded further
|
||||
uint64_t original = 1ULL << 35;
|
||||
std::string encoded;
|
||||
PutBlogIrregularVarint64(&encoded, original, 4);
|
||||
// Natural encoding is 6 bytes, already >= 4
|
||||
ASSERT_EQ(encoded.size(), 6u);
|
||||
|
||||
Slice input(encoded);
|
||||
uint64_t value;
|
||||
ASSERT_TRUE(GetVarint64(&input, &value));
|
||||
ASSERT_EQ(value, original);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, UnspecifiedSizeVarint) {
|
||||
std::string encoded;
|
||||
PutVarint64(&encoded, kBlogUnspecifiedSize);
|
||||
// 2^63 - 1 should encode to 9 bytes (63 bits / 7 = 9)
|
||||
ASSERT_EQ(encoded.size(), 9u);
|
||||
|
||||
Slice input(encoded);
|
||||
uint64_t value;
|
||||
ASSERT_TRUE(GetVarint64(&input, &value));
|
||||
ASSERT_EQ(value, kBlogUnspecifiedSize);
|
||||
}
|
||||
|
||||
// --- Checksum ---
|
||||
|
||||
TEST_F(BlogFormatTest, ComputeBlogRecordChecksumBasic) {
|
||||
const char* data = "hello world";
|
||||
size_t size = strlen(data);
|
||||
|
||||
// With no context (incarnation_id = 0), should match standard checksum
|
||||
uint32_t cksum = ComputeBlogRecordChecksum(
|
||||
kXXH3, data, size, static_cast<char>(kNoCompression), 0, 0);
|
||||
uint32_t expected = ComputeBuiltinChecksumWithLastByte(
|
||||
kXXH3, data, size, static_cast<char>(kNoCompression));
|
||||
ASSERT_EQ(cksum, expected);
|
||||
}
|
||||
|
||||
TEST_F(BlogFormatTest, ComputeBlogRecordChecksumWithContext) {
|
||||
const char* data = "hello world";
|
||||
size_t size = strlen(data);
|
||||
|
||||
uint32_t cksum1 = ComputeBlogRecordChecksum(
|
||||
kXXH3, data, size, static_cast<char>(kNoCompression), 0x12345678, 100);
|
||||
uint32_t cksum2 = ComputeBlogRecordChecksum(
|
||||
kXXH3, data, size, static_cast<char>(kNoCompression), 0x12345678, 200);
|
||||
|
||||
// Different offsets should produce different checksums
|
||||
ASSERT_NE(cksum1, cksum2);
|
||||
|
||||
// Different incarnation IDs should produce different checksums
|
||||
uint32_t cksum3 = ComputeBlogRecordChecksum(
|
||||
kXXH3, data, size, static_cast<char>(kNoCompression), 0xABCDEF01, 100);
|
||||
ASSERT_NE(cksum1, cksum3);
|
||||
}
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
ROCKSDB_NAMESPACE::port::InstallStackTraceHandler();
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
// This source code is licensed under both the GPLv2 (found in the
|
||||
// COPYING file in the root directory) and Apache 2.0 License
|
||||
// (found in the LICENSE.Apache file in the root directory).
|
||||
|
||||
#include "db/blog/blog_reader.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
|
||||
#include "table/format.h"
|
||||
#include "util/coding.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
BlogFileReader::BlogFileReader(std::unique_ptr<SequentialFileReader>&& file,
|
||||
Reporter* reporter, bool verify_checksums)
|
||||
: seq_file_(std::move(file)),
|
||||
reporter_(reporter),
|
||||
verify_checksums_(verify_checksums) {}
|
||||
|
||||
Status BlogFileReader::ReadHeader(BlogFileHeader* header) {
|
||||
assert(!header_read_);
|
||||
|
||||
// Read the fixed header to get property_section_size
|
||||
std::string fixed_buf;
|
||||
Slice fixed_slice;
|
||||
Status s = ReadBytes(kBlogFileFixedHeaderSize, &fixed_slice, &fixed_buf);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Peek at property_section_size from bytes [32-35]
|
||||
uint32_t prop_section_size = DecodeFixed32(fixed_slice.data() + 32);
|
||||
|
||||
// Read the property section
|
||||
std::string prop_buf;
|
||||
Slice prop_slice;
|
||||
s = ReadBytes(prop_section_size, &prop_slice, &prop_buf);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Combine into a single buffer for decoding
|
||||
std::string full_header;
|
||||
full_header.append(fixed_slice.data(), fixed_slice.size());
|
||||
full_header.append(prop_slice.data(), prop_slice.size());
|
||||
|
||||
Slice input(full_header);
|
||||
s = header->DecodeFrom(&input);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
header_ = *header;
|
||||
header_read_ = true;
|
||||
|
||||
// Skip padding after header to reach the first record's escape sequence.
|
||||
// Padding may be arbitrarily long (for stronger alignment or recycled
|
||||
// files of all zeros). EOF here is fine (header-only file, no records).
|
||||
s = SkipPadding();
|
||||
if (!s.ok() && !s.IsNotFound()) {
|
||||
return s;
|
||||
}
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
Status BlogFileReader::ReadRecord(BlogRecordType* type, Slice* payload,
|
||||
std::string* scratch, uint64_t* record_offset,
|
||||
CompressionType* record_compression_type) {
|
||||
if (!header_read_) {
|
||||
return Status::Corruption("Blog reader: header not read");
|
||||
}
|
||||
if (eof_) {
|
||||
return Status::NotFound();
|
||||
}
|
||||
|
||||
Status s;
|
||||
scratch->clear();
|
||||
|
||||
// Read escape sequence. If we have a pending prefix from header padding
|
||||
// skip or inter-record padding skip, use it as the first 4 bytes.
|
||||
std::string esc_buf;
|
||||
if (!pending_escape_prefix_.empty()) {
|
||||
esc_buf = std::move(pending_escape_prefix_);
|
||||
pending_escape_prefix_.clear();
|
||||
Slice rest_slice;
|
||||
std::string rest_buf;
|
||||
s = ReadBytes(kBlogEscapeSequenceSize - 4, &rest_slice, &rest_buf);
|
||||
if (!s.ok()) {
|
||||
eof_ = true;
|
||||
// Short read after we had a pending prefix = incomplete trailing record
|
||||
return Status::Incomplete("Blog reader: incomplete escape sequence");
|
||||
}
|
||||
esc_buf.append(rest_slice.data(), rest_slice.size());
|
||||
} else {
|
||||
Slice esc_slice;
|
||||
s = ReadBytes(kBlogEscapeSequenceSize, &esc_slice, &esc_buf);
|
||||
if (!s.ok()) {
|
||||
eof_ = true;
|
||||
return Status::NotFound(); // clean EOF
|
||||
}
|
||||
if (esc_slice.data() != esc_buf.data()) {
|
||||
esc_buf.assign(esc_slice.data(), esc_slice.size());
|
||||
}
|
||||
}
|
||||
|
||||
// Verify escape sequence matches
|
||||
if (memcmp(esc_buf.data(), header_.escape_sequence,
|
||||
kBlogEscapeSequenceSize) != 0) {
|
||||
return ReportCorruption(kBlogEscapeSequenceSize,
|
||||
"escape sequence mismatch");
|
||||
}
|
||||
|
||||
uint64_t rec_offset = offset_ - kBlogEscapeSequenceSize;
|
||||
if (record_offset) {
|
||||
*record_offset = rec_offset;
|
||||
}
|
||||
last_record_offset_ = rec_offset;
|
||||
|
||||
// Read varint length. scratch receives the raw varint bytes for use
|
||||
// in prefix checksum verification (avoids fragile re-encoding).
|
||||
uint64_t length = 0;
|
||||
s = ReadVarint64(&length, scratch);
|
||||
if (!s.ok()) {
|
||||
eof_ = true;
|
||||
return Status::Incomplete("Blog reader: incomplete varint");
|
||||
}
|
||||
size_t varint_len = scratch->size();
|
||||
|
||||
bool is_full_format = (varint_len > kBlogCompactVarintMaxBytes);
|
||||
|
||||
BlogRecordType rec_type;
|
||||
std::string prefix_rest_buf;
|
||||
|
||||
if (is_full_format) {
|
||||
// Full format: read type + compression_type + prefix_checksum
|
||||
Slice prefix_rest_slice;
|
||||
s = ReadBytes(6, &prefix_rest_slice, &prefix_rest_buf); // 1+1+4
|
||||
if (!s.ok()) {
|
||||
eof_ = true;
|
||||
return Status::Incomplete("Blog reader: incomplete full record prefix");
|
||||
}
|
||||
|
||||
rec_type =
|
||||
static_cast<BlogRecordType>(static_cast<uint8_t>(prefix_rest_slice[0]));
|
||||
|
||||
if (verify_checksums_) {
|
||||
// Build the prefix data from the original varint bytes (in scratch)
|
||||
// plus type and compression_type bytes.
|
||||
scratch->push_back(prefix_rest_slice[0]); // type
|
||||
scratch->push_back(prefix_rest_slice[1]); // comp_type
|
||||
|
||||
uint32_t stored_prefix_checksum =
|
||||
DecodeFixed32(prefix_rest_slice.data() + 2);
|
||||
uint32_t computed_prefix_checksum =
|
||||
ComputeBuiltinChecksum(header_.checksum_type, scratch->data(),
|
||||
scratch->size()) +
|
||||
ChecksumModifierForContext(header_.incarnation_id(), rec_offset);
|
||||
if (stored_prefix_checksum != computed_prefix_checksum) {
|
||||
return ReportCorruption(varint_len + 6, "prefix checksum mismatch");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rec_type = header_.compact_record_type;
|
||||
}
|
||||
|
||||
*type = rec_type;
|
||||
|
||||
if (length == 0) {
|
||||
payload->clear();
|
||||
if (record_compression_type) {
|
||||
*record_compression_type = kNoCompression;
|
||||
}
|
||||
s = SkipPadding();
|
||||
if (!s.ok() && !s.IsNotFound()) {
|
||||
return s;
|
||||
}
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
if (length == kBlogUnspecifiedSize) {
|
||||
return ReportCorruption(0, "unspecified-size records not yet implemented");
|
||||
}
|
||||
|
||||
// Read payload
|
||||
scratch->clear();
|
||||
Slice payload_slice;
|
||||
s = ReadBytes(static_cast<size_t>(length), &payload_slice, scratch);
|
||||
if (!s.ok()) {
|
||||
eof_ = true;
|
||||
return Status::Incomplete("Blog reader: incomplete payload");
|
||||
}
|
||||
if (payload_slice.data() != scratch->data()) {
|
||||
scratch->assign(payload_slice.data(), payload_slice.size());
|
||||
}
|
||||
|
||||
// Read 5-byte trailer
|
||||
Slice trailer_slice;
|
||||
std::string trailer_buf;
|
||||
s = ReadBytes(kBlogBlockTrailerSize, &trailer_slice, &trailer_buf);
|
||||
if (!s.ok()) {
|
||||
eof_ = true;
|
||||
return Status::Incomplete("Blog reader: incomplete trailer");
|
||||
}
|
||||
|
||||
char trailer_comp_type = trailer_slice[0];
|
||||
uint32_t stored_checksum = DecodeFixed32(trailer_slice.data() + 1);
|
||||
|
||||
if (verify_checksums_) {
|
||||
uint32_t computed_checksum = ComputeBlogRecordChecksum(
|
||||
header_.checksum_type, scratch->data(), scratch->size(),
|
||||
trailer_comp_type, header_.incarnation_id(),
|
||||
offset_ - kBlogBlockTrailerSize - length);
|
||||
if (stored_checksum != computed_checksum) {
|
||||
return ReportCorruption(
|
||||
static_cast<size_t>(length) + kBlogBlockTrailerSize,
|
||||
"record checksum mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
*payload = Slice(*scratch);
|
||||
if (record_compression_type) {
|
||||
*record_compression_type =
|
||||
static_cast<CompressionType>(static_cast<uint8_t>(trailer_comp_type));
|
||||
}
|
||||
|
||||
// Skip padding to next record.
|
||||
s = SkipPadding();
|
||||
if (!s.ok() && !s.IsNotFound()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
Status BlogFileReader::ScanForEscapeSequence(uint64_t* found_offset) {
|
||||
// Scan forward 4 bytes at a time (escape sequences are 32-bit aligned).
|
||||
// Check first 4 bytes of escape sequence, then verify remaining 6 bytes.
|
||||
uint32_t first4;
|
||||
memcpy(&first4, header_.escape_sequence, 4);
|
||||
|
||||
std::string buf;
|
||||
// Align to 4-byte boundary first
|
||||
size_t remainder = offset_ % 4;
|
||||
if (remainder != 0) {
|
||||
size_t skip = 4 - remainder;
|
||||
Slice skip_slice;
|
||||
Status s = ReadBytes(skip, &skip_slice, &buf);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
while (!eof_) {
|
||||
Slice chunk_slice;
|
||||
Status s = ReadBytes(4, &chunk_slice, &buf);
|
||||
if (!s.ok()) {
|
||||
if (eof_) {
|
||||
return Status::NotFound("escape sequence not found before EOF");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
uint32_t candidate;
|
||||
memcpy(&candidate, chunk_slice.data(), 4);
|
||||
if (candidate == first4) {
|
||||
// Potential match - verify remaining 6 bytes
|
||||
uint64_t candidate_offset = offset_ - 4;
|
||||
Slice rest_slice;
|
||||
std::string rest_buf;
|
||||
s = ReadBytes(kBlogEscapeSequenceSize - 4, &rest_slice, &rest_buf);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
if (memcmp(rest_slice.data(), header_.escape_sequence + 4,
|
||||
kBlogEscapeSequenceSize - 4) == 0) {
|
||||
*found_offset = candidate_offset;
|
||||
return Status::OK();
|
||||
}
|
||||
// False match, continue scanning. But we consumed 6 extra bytes that
|
||||
// might not be aligned. Re-align.
|
||||
remainder = offset_ % 4;
|
||||
if (remainder != 0) {
|
||||
size_t skip = 4 - remainder;
|
||||
Slice skip_s;
|
||||
s = ReadBytes(skip, &skip_s, &buf);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Status::NotFound("escape sequence not found before EOF");
|
||||
}
|
||||
|
||||
Status BlogFileReader::SkipPadding() {
|
||||
// Skip arbitrarily long padding (0x00 or 0xFF bytes). Padding is
|
||||
// 32-bit aligned. Since the escape sequence's first byte is guaranteed
|
||||
// to be neither 0x00 nor 0xFF, we align to 4 bytes then scan 4-byte
|
||||
// chunks until we find one that starts with a non-padding byte.
|
||||
size_t remainder = offset_ % 4;
|
||||
if (remainder != 0) {
|
||||
Slice skip_slice;
|
||||
std::string skip_buf;
|
||||
Status s = ReadBytes(4 - remainder, &skip_slice, &skip_buf);
|
||||
if (!s.ok()) {
|
||||
if (eof_) {
|
||||
return Status::NotFound();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
Slice chunk_slice;
|
||||
std::string chunk_buf;
|
||||
Status s = ReadBytes(4, &chunk_slice, &chunk_buf);
|
||||
if (!s.ok()) {
|
||||
if (eof_) {
|
||||
return Status::NotFound();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
uint8_t first = static_cast<uint8_t>(chunk_slice[0]);
|
||||
if (first != 0x00 && first != 0xFF) {
|
||||
// Found the start of the next escape sequence.
|
||||
pending_escape_prefix_.assign(chunk_slice.data(), 4);
|
||||
return Status::OK();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Status BlogFileReader::ReadBytes(size_t n, Slice* result,
|
||||
std::string* scratch) {
|
||||
scratch->resize(n);
|
||||
Slice read_result;
|
||||
IOStatus ios =
|
||||
seq_file_->Read(n, &read_result, scratch->data(), Env::IO_TOTAL);
|
||||
if (!ios.ok()) {
|
||||
return static_cast<Status>(ios);
|
||||
}
|
||||
if (read_result.size() < n) {
|
||||
eof_ = true;
|
||||
// Return Incomplete -- the caller (ReadRecord) determines whether
|
||||
// this is an acceptable incomplete tail vs. genuine corruption.
|
||||
return Status::Incomplete("Blog reader", "unexpected EOF");
|
||||
}
|
||||
offset_ += n;
|
||||
*result = Slice(scratch->data(), n);
|
||||
return Status::OK();
|
||||
}
|
||||
|
||||
// FIXME? Consider a better encoding that could be processed more efficiently
|
||||
Status BlogFileReader::ReadVarint64(uint64_t* value, std::string* scratch) {
|
||||
// Varints can be up to 10 bytes. Read one byte at a time.
|
||||
// The raw varint bytes are appended to scratch so the caller can use
|
||||
// them for checksum verification without re-encoding.
|
||||
scratch->clear();
|
||||
std::string byte_buf;
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
Slice byte_slice;
|
||||
Status s = ReadBytes(1, &byte_slice, &byte_buf);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
scratch->push_back(byte_slice[0]);
|
||||
// Check if this is the terminal byte (high bit not set)
|
||||
if ((static_cast<uint8_t>(byte_slice[0]) & 0x80) == 0) {
|
||||
Slice varint_slice(*scratch);
|
||||
if (!GetVarint64(&varint_slice, value)) {
|
||||
return ReportCorruption(scratch->size(), "invalid varint");
|
||||
}
|
||||
return Status::OK();
|
||||
}
|
||||
}
|
||||
return ReportCorruption(10, "varint too long");
|
||||
}
|
||||
|
||||
Status BlogFileReader::ReportCorruption(size_t bytes, const std::string& msg) {
|
||||
Status s = Status::Corruption("Blog reader", msg);
|
||||
if (reporter_) {
|
||||
reporter_->Corruption(bytes, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
// This source code is licensed under both the GPLv2 (found in the
|
||||
// COPYING file in the root directory) and Apache 2.0 License
|
||||
// (found in the LICENSE.Apache file in the root directory).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "db/blog/blog_format.h"
|
||||
#include "file/random_access_file_reader.h"
|
||||
#include "file/sequence_file_reader.h"
|
||||
#include "rocksdb/slice.h"
|
||||
#include "rocksdb/status.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
// Reads blog-format files sequentially (WAL replay) or at random offsets
|
||||
// (blob lookups). Supports footer reading by backward scanning.
|
||||
class BlogFileReader {
|
||||
public:
|
||||
// Error reporter, same pattern as log::Reader::Reporter.
|
||||
class Reporter {
|
||||
public:
|
||||
virtual ~Reporter() = default;
|
||||
virtual void Corruption(size_t bytes, const Status& status) = 0;
|
||||
};
|
||||
|
||||
// Construct for sequential reading (WAL replay, forward scan).
|
||||
BlogFileReader(std::unique_ptr<SequentialFileReader>&& file,
|
||||
Reporter* reporter, bool verify_checksums);
|
||||
|
||||
~BlogFileReader() = default;
|
||||
|
||||
// Take ownership of a reporter (e.g., an adapter wrapping another type).
|
||||
void SetOwnedReporter(std::unique_ptr<Reporter> reporter) {
|
||||
owned_reporter_ = std::move(reporter);
|
||||
reporter_ = owned_reporter_.get();
|
||||
}
|
||||
|
||||
// Read and validate the file header. Must be called first.
|
||||
Status ReadHeader(BlogFileHeader* header);
|
||||
|
||||
// Read the next record sequentially. Returns:
|
||||
// OK - record read successfully
|
||||
// NotFound - clean EOF (no more records, only padding/zeros remain)
|
||||
// Incomplete - partial record at tail (crash mid-write)
|
||||
// Corruption - data integrity error
|
||||
// On OK, *type is the record type and *payload contains the payload.
|
||||
// *scratch is used as backing storage for payload.
|
||||
Status ReadRecord(BlogRecordType* type, Slice* payload, std::string* scratch,
|
||||
uint64_t* record_offset = nullptr,
|
||||
CompressionType* record_compression_type = nullptr);
|
||||
|
||||
// Scan forward from the current position for the next occurrence of
|
||||
// the file's escape sequence. Used for WAL recovery and unspecified-
|
||||
// size record parsing.
|
||||
// On success, *found_offset is the file offset of the escape sequence.
|
||||
Status ScanForEscapeSequence(uint64_t* found_offset);
|
||||
|
||||
bool IsEOF() const { return eof_; }
|
||||
uint64_t LastRecordOffset() const { return last_record_offset_; }
|
||||
uint64_t current_offset() const { return offset_; }
|
||||
const BlogFileHeader& header() const { return header_; }
|
||||
|
||||
private:
|
||||
// Skip arbitrarily long padding (0x00 or 0xFF bytes) after a record or
|
||||
// header. Aligns to 4-byte boundary first, then scans 4-byte chunks
|
||||
// until finding one whose first byte is not 0x00/0xFF. Stashes that
|
||||
// chunk in pending_escape_prefix_ for the next ReadRecord call.
|
||||
// Returns OK on success, NotFound on clean EOF (no more records),
|
||||
// or an error Status on I/O failure.
|
||||
Status SkipPadding();
|
||||
|
||||
// Read exactly n bytes sequentially. Returns Corruption on short read.
|
||||
Status ReadBytes(size_t n, Slice* result, std::string* scratch);
|
||||
|
||||
// Read a varint64 from the sequential stream.
|
||||
Status ReadVarint64(uint64_t* value, std::string* scratch);
|
||||
|
||||
// Report corruption and return a Corruption status.
|
||||
Status ReportCorruption(size_t bytes, const std::string& msg);
|
||||
|
||||
std::unique_ptr<SequentialFileReader> seq_file_;
|
||||
Reporter* reporter_;
|
||||
std::unique_ptr<Reporter> owned_reporter_; // optional, for adapter lifetime
|
||||
bool verify_checksums_;
|
||||
BlogFileHeader header_;
|
||||
bool header_read_ = false;
|
||||
bool eof_ = false;
|
||||
uint64_t offset_ = 0;
|
||||
uint64_t last_record_offset_ = 0;
|
||||
|
||||
// First 4 bytes of escape sequence consumed during header padding skip.
|
||||
// ReadRecord uses these as the beginning of the first escape sequence.
|
||||
std::string pending_escape_prefix_;
|
||||
};
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
// This source code is licensed under both the GPLv2 (found in the
|
||||
// COPYING file in the root directory) and Apache 2.0 License
|
||||
// (found in the LICENSE.Apache file in the root directory).
|
||||
|
||||
#include "db/blog/blog_writer.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
#include "table/format.h"
|
||||
#include "util/coding.h"
|
||||
#include "util/string_util.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
BlogFileWriter::BlogFileWriter(std::unique_ptr<WritableFileWriter>&& dest,
|
||||
const BlogFileHeader& header, bool manual_flush)
|
||||
: dest_(std::move(dest)), header_(header), manual_flush_(manual_flush) {}
|
||||
|
||||
BlogFileWriter::~BlogFileWriter() = default;
|
||||
|
||||
IOStatus BlogFileWriter::WriteHeader(const WriteOptions& wo) {
|
||||
assert(!header_written_);
|
||||
std::string buf;
|
||||
header_.EncodeTo(&buf);
|
||||
// Pad after header properties using the same padding scheme as records.
|
||||
uint8_t last_byte = static_cast<uint8_t>(buf.back());
|
||||
ComputeBlogPadding(last_byte, buf.size(), &buf);
|
||||
IOStatus s = EmitBytes(wo, buf);
|
||||
if (s.ok()) {
|
||||
header_written_ = true;
|
||||
}
|
||||
if (s.ok() && !manual_flush_) {
|
||||
IOOptions opts;
|
||||
s = WritableFileWriter::PrepareIOOptions(wo, opts);
|
||||
if (s.ok()) {
|
||||
s = dest_->Flush(opts);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::AddBlobRecord(const WriteOptions& wo,
|
||||
const Slice& payload,
|
||||
CompressionType comp_type,
|
||||
uint64_t* blob_offset) {
|
||||
return AddRecord(wo, kBlogBlobRecord, payload, comp_type, blob_offset);
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::AddWriteBatchRecord(const WriteOptions& wo,
|
||||
const Slice& wb_data,
|
||||
CompressionType comp_type) {
|
||||
// Stub: WriteBatch record writing is not yet integrated (WAL integration
|
||||
// deferred). The record format is the same as blob records with type 0x03.
|
||||
return AddRecord(wo, kBlogWriteBatchRecord, wb_data, comp_type, nullptr);
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::AddPreambleStartRecord(const WriteOptions& wo) {
|
||||
return AddRecord(wo, kBlogPreambleStartRecord, Slice(), kNoCompression,
|
||||
nullptr, /*force_full=*/true);
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::AddFooterIndexRecord(const WriteOptions& wo,
|
||||
const Slice& index_data) {
|
||||
return AddRecord(wo, kBlogFooterIndexRecord, index_data, kNoCompression,
|
||||
nullptr, /*force_full=*/true);
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::AddFooterPropertiesRecord(
|
||||
const WriteOptions& wo, const BlogFileFooterProperties& props) {
|
||||
std::string payload;
|
||||
props.EncodeTo(&payload);
|
||||
return AddRecord(wo, kBlogFooterPropertiesRecord, payload, kNoCompression,
|
||||
nullptr, /*force_full=*/true);
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::AddFooterLocatorRecord(
|
||||
const WriteOptions& wo, const BlogFileFooterLocator& locator) {
|
||||
std::string payload;
|
||||
locator.EncodeTo(&payload);
|
||||
return AddRecord(wo, kBlogFooterLocatorRecord, payload, kNoCompression,
|
||||
nullptr, /*force_full=*/true);
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::WriteBuffer(const WriteOptions& wo) {
|
||||
IOOptions opts;
|
||||
IOStatus s = WritableFileWriter::PrepareIOOptions(wo, opts);
|
||||
if (s.ok()) {
|
||||
s = dest_->Flush(opts);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::Sync(const WriteOptions& wo, bool use_fsync) {
|
||||
IOOptions opts;
|
||||
IOStatus s = WritableFileWriter::PrepareIOOptions(wo, opts);
|
||||
if (s.ok()) {
|
||||
s = dest_->Sync(opts, use_fsync);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::Close(const WriteOptions& wo) {
|
||||
IOOptions opts;
|
||||
IOStatus s = WritableFileWriter::PrepareIOOptions(wo, opts);
|
||||
if (s.ok()) {
|
||||
s = dest_->Close(opts);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::AddRecord(const WriteOptions& wo, BlogRecordType type,
|
||||
const Slice& payload,
|
||||
CompressionType comp_type,
|
||||
uint64_t* payload_offset, bool force_full) {
|
||||
assert(header_written_);
|
||||
|
||||
// Determine varint length of payload size
|
||||
uint16_t varint_len = VarintLength(payload.size());
|
||||
|
||||
// Use compact format when:
|
||||
// 1. varint fits in <= kBlogCompactVarintMaxBytes (payload < ~16 KiB)
|
||||
// 2. record type matches the header's compact_record_type
|
||||
// 3. not forced to full format (e.g. footer/preamble records)
|
||||
bool use_compact =
|
||||
!force_full && (varint_len <= kBlogCompactVarintMaxBytes) &&
|
||||
(type == header_.compact_record_type) && (payload.size() > 0);
|
||||
|
||||
IOStatus s;
|
||||
if (use_compact) {
|
||||
s = EmitCompactRecord(wo, payload, comp_type, payload_offset);
|
||||
} else {
|
||||
s = EmitFullRecord(wo, type, payload, comp_type, payload_offset);
|
||||
}
|
||||
|
||||
if (s.ok() && !manual_flush_) {
|
||||
IOOptions opts;
|
||||
s = WritableFileWriter::PrepareIOOptions(wo, opts);
|
||||
if (s.ok()) {
|
||||
s = dest_->Flush(opts);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::EmitCompactRecord(const WriteOptions& wo,
|
||||
const Slice& payload,
|
||||
CompressionType comp_type,
|
||||
uint64_t* payload_offset) {
|
||||
// Compact format:
|
||||
// [escape_seq: 10B] [varint length: 1-3B]
|
||||
// [payload: length bytes]
|
||||
// [compression_type: 1B] [checksum: 4B]
|
||||
// [padding: 0+B]
|
||||
assert(payload.size() > 0); // length=0 compact records not expected
|
||||
|
||||
std::string prefix;
|
||||
// Escape sequence
|
||||
prefix.append(header_.escape_sequence, kBlogEscapeSequenceSize);
|
||||
// Varint length
|
||||
PutVarint64(&prefix, payload.size());
|
||||
|
||||
IOStatus s = EmitBytes(wo, prefix);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Record payload offset for caller
|
||||
if (payload_offset) {
|
||||
*payload_offset = offset_;
|
||||
}
|
||||
|
||||
// Payload
|
||||
s = EmitBytes(wo, payload);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// 5-byte trailer: compression_type + checksum
|
||||
char trailer[kBlogBlockTrailerSize];
|
||||
trailer[0] = static_cast<char>(comp_type);
|
||||
uint32_t checksum = ComputeBlogRecordChecksum(
|
||||
header_.checksum_type, payload.data(), payload.size(), trailer[0],
|
||||
header_.incarnation_id(),
|
||||
offset_ - payload.size()); // record offset = start of payload
|
||||
EncodeFixed32(trailer + 1, checksum);
|
||||
s = EmitBytes(wo, trailer, kBlogBlockTrailerSize);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Padding (stack-allocated, no heap allocation)
|
||||
uint8_t last_byte = static_cast<uint8_t>(trailer[kBlogBlockTrailerSize - 1]);
|
||||
uint8_t pad_byte;
|
||||
size_t pad_count;
|
||||
ComputeBlogPaddingParams(last_byte, offset_, &pad_byte, &pad_count);
|
||||
if (pad_count > 0) {
|
||||
char pad_buf[4];
|
||||
memset(pad_buf, pad_byte, pad_count);
|
||||
s = EmitBytes(wo, pad_buf, pad_count);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::EmitFullRecord(const WriteOptions& wo,
|
||||
BlogRecordType type,
|
||||
const Slice& payload,
|
||||
CompressionType comp_type,
|
||||
uint64_t* payload_offset) {
|
||||
// Full format:
|
||||
// [escape_seq: 10B] [varint length: 4+B] [type: 1B] [compression_type: 1B]
|
||||
// [prefix_checksum: 4B]
|
||||
// (if length > 0):
|
||||
// [payload: length bytes]
|
||||
// [compression_type: 1B] [checksum: 4B]
|
||||
// [padding: 0+B]
|
||||
|
||||
std::string prefix;
|
||||
// Escape sequence
|
||||
prefix.append(header_.escape_sequence, kBlogEscapeSequenceSize);
|
||||
|
||||
// Varint length (must be > kBlogCompactVarintMaxBytes to trigger full format)
|
||||
constexpr size_t kFullVarintMinBytes = kBlogCompactVarintMaxBytes + 1;
|
||||
size_t varint_start = prefix.size();
|
||||
if (payload.size() == 0) {
|
||||
PutBlogIrregularVarint64(&prefix, 0, kFullVarintMinBytes);
|
||||
} else {
|
||||
uint16_t natural_len = VarintLength(payload.size());
|
||||
if (natural_len >= kFullVarintMinBytes) {
|
||||
PutVarint64(&prefix, payload.size());
|
||||
} else {
|
||||
PutBlogIrregularVarint64(&prefix, payload.size(), kFullVarintMinBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Type byte
|
||||
prefix.push_back(static_cast<char>(type));
|
||||
// Pre-payload compression type
|
||||
prefix.push_back(static_cast<char>(comp_type));
|
||||
|
||||
// Prefix checksum covers (varint length + type + compression_type),
|
||||
// with context modifier for defense-in-depth.
|
||||
const char* prefix_data = prefix.data() + varint_start;
|
||||
size_t prefix_data_size = prefix.size() - varint_start;
|
||||
uint32_t prefix_checksum =
|
||||
ComputeBuiltinChecksum(header_.checksum_type, prefix_data,
|
||||
prefix_data_size) +
|
||||
ChecksumModifierForContext(header_.incarnation_id(), offset_);
|
||||
PutFixed32(&prefix, prefix_checksum);
|
||||
|
||||
IOStatus s = EmitBytes(wo, prefix);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Track the last meaningful byte for padding computation.
|
||||
// EncodeFixed32 is little-endian: last byte written = (value >> 24) & 0xFF.
|
||||
uint8_t last_byte;
|
||||
|
||||
if (payload.size() > 0) {
|
||||
// Record payload offset for caller
|
||||
if (payload_offset) {
|
||||
*payload_offset = offset_;
|
||||
}
|
||||
|
||||
// Payload
|
||||
s = EmitBytes(wo, payload);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// 5-byte trailer: compression_type + checksum
|
||||
char trailer[kBlogBlockTrailerSize];
|
||||
trailer[0] = static_cast<char>(comp_type);
|
||||
uint32_t checksum = ComputeBlogRecordChecksum(
|
||||
header_.checksum_type, payload.data(), payload.size(), trailer[0],
|
||||
header_.incarnation_id(), offset_ - payload.size());
|
||||
EncodeFixed32(trailer + 1, checksum);
|
||||
s = EmitBytes(wo, trailer, kBlogBlockTrailerSize);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
last_byte = static_cast<uint8_t>(trailer[kBlogBlockTrailerSize - 1]);
|
||||
} else {
|
||||
// No payload, no trailer. Last meaningful byte is the last byte of
|
||||
// prefix_checksum (little-endian).
|
||||
last_byte = static_cast<uint8_t>(prefix_checksum >> 24);
|
||||
}
|
||||
|
||||
uint8_t pad_byte;
|
||||
size_t pad_count;
|
||||
ComputeBlogPaddingParams(last_byte, offset_, &pad_byte, &pad_count);
|
||||
if (pad_count > 0) {
|
||||
char pad_buf[4];
|
||||
memset(pad_buf, pad_byte, pad_count);
|
||||
s = EmitBytes(wo, pad_buf, pad_count);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::EmitBytes(const WriteOptions& wo, const Slice& data) {
|
||||
return EmitBytes(wo, data.data(), data.size());
|
||||
}
|
||||
|
||||
IOStatus BlogFileWriter::EmitBytes(const WriteOptions& wo, const char* data,
|
||||
size_t len) {
|
||||
IOOptions opts;
|
||||
IOStatus s = WritableFileWriter::PrepareIOOptions(wo, opts);
|
||||
if (!s.ok()) {
|
||||
return s;
|
||||
}
|
||||
s = dest_->Append(opts, Slice(data, len));
|
||||
if (s.ok()) {
|
||||
offset_ += len;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
// This source code is licensed under both the GPLv2 (found in the
|
||||
// COPYING file in the root directory) and Apache 2.0 License
|
||||
// (found in the LICENSE.Apache file in the root directory).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "db/blog/blog_format.h"
|
||||
#include "file/writable_file_writer.h"
|
||||
#include "rocksdb/io_status.h"
|
||||
#include "rocksdb/options.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
// Writes blog-format files (WAL or blob). The caller constructs a
|
||||
// BlogFileHeader, calls WriteHeader(), then adds records via
|
||||
// AddBlobRecord/AddWriteBatchRecord, and optionally appends footer records
|
||||
// before closing.
|
||||
//
|
||||
// Format selection (compact vs full) is automatic based on payload size and
|
||||
// record type. See blog_format.h for the wire format details.
|
||||
class BlogFileWriter {
|
||||
public:
|
||||
BlogFileWriter(std::unique_ptr<WritableFileWriter>&& dest,
|
||||
const BlogFileHeader& header, bool manual_flush = false);
|
||||
~BlogFileWriter();
|
||||
|
||||
// Write the file header. Must be called exactly once, before any records.
|
||||
IOStatus WriteHeader(const WriteOptions& wo);
|
||||
|
||||
// Write a blob record. Selects compact or full format automatically.
|
||||
// On success, *blob_offset is the file offset of the payload start.
|
||||
IOStatus AddBlobRecord(const WriteOptions& wo, const Slice& payload,
|
||||
CompressionType comp_type, uint64_t* blob_offset);
|
||||
|
||||
// Write a WriteBatch record. Selects compact or full format automatically.
|
||||
IOStatus AddWriteBatchRecord(const WriteOptions& wo, const Slice& wb_data,
|
||||
CompressionType comp_type = kNoCompression);
|
||||
|
||||
// Write a preamble-start record (stub). Uses full format with length=0.
|
||||
IOStatus AddPreambleStartRecord(const WriteOptions& wo);
|
||||
|
||||
// Write a footer index record (full format).
|
||||
IOStatus AddFooterIndexRecord(const WriteOptions& wo,
|
||||
const Slice& index_data);
|
||||
|
||||
// Write a footer properties record (full format).
|
||||
IOStatus AddFooterPropertiesRecord(const WriteOptions& wo,
|
||||
const BlogFileFooterProperties& props);
|
||||
|
||||
// Write a footer locator record (full format). Must be the last record.
|
||||
IOStatus AddFooterLocatorRecord(const WriteOptions& wo,
|
||||
const BlogFileFooterLocator& locator);
|
||||
|
||||
IOStatus WriteBuffer(const WriteOptions& wo);
|
||||
IOStatus Sync(const WriteOptions& wo, bool use_fsync = false);
|
||||
IOStatus Close(const WriteOptions& wo);
|
||||
|
||||
uint64_t current_offset() const { return offset_; }
|
||||
WritableFileWriter* file() { return dest_.get(); }
|
||||
const BlogFileHeader& header() const { return header_; }
|
||||
|
||||
private:
|
||||
// Write a record. Chooses compact format when varint fits in <= 3 bytes
|
||||
// and type matches compact_record_type, unless force_full is true.
|
||||
IOStatus AddRecord(const WriteOptions& wo, BlogRecordType type,
|
||||
const Slice& payload, CompressionType comp_type,
|
||||
uint64_t* payload_offset, bool force_full = false);
|
||||
|
||||
// Emit a compact-format record.
|
||||
IOStatus EmitCompactRecord(const WriteOptions& wo, const Slice& payload,
|
||||
CompressionType comp_type,
|
||||
uint64_t* payload_offset);
|
||||
|
||||
// Emit a full-format record.
|
||||
IOStatus EmitFullRecord(const WriteOptions& wo, BlogRecordType type,
|
||||
const Slice& payload, CompressionType comp_type,
|
||||
uint64_t* payload_offset);
|
||||
|
||||
// Append raw bytes to the file and advance offset_.
|
||||
IOStatus EmitBytes(const WriteOptions& wo, const Slice& data);
|
||||
IOStatus EmitBytes(const WriteOptions& wo, const char* data, size_t len);
|
||||
|
||||
std::unique_ptr<WritableFileWriter> dest_;
|
||||
BlogFileHeader header_;
|
||||
uint64_t offset_ = 0;
|
||||
bool manual_flush_;
|
||||
bool header_written_ = false;
|
||||
};
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
@@ -0,0 +1,452 @@
|
||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
// This source code is licensed under both the GPLv2 (found in the
|
||||
// COPYING file in the root directory) and Apache 2.0 License
|
||||
// (found in the LICENSE.Apache file in the root directory).
|
||||
|
||||
#include "db/blog/blog_writer.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "db/blog/blog_reader.h"
|
||||
#include "file/sequence_file_reader.h"
|
||||
#include "file/writable_file_writer.h"
|
||||
#include "rocksdb/write_buffer_manager.h"
|
||||
#include "test_util/testharness.h"
|
||||
#include "test_util/testutil.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
class BlogWriterTest : public testing::Test {
|
||||
protected:
|
||||
// Shared contents buffer: StringSink writes here, StringSource reads from it.
|
||||
Slice reader_contents_;
|
||||
test::StringSink* sink_;
|
||||
|
||||
BlogWriterTest() : sink_(new test::StringSink(&reader_contents_)) {}
|
||||
|
||||
// Create a writer with the given header.
|
||||
std::unique_ptr<BlogFileWriter> MakeWriter(const BlogFileHeader& header) {
|
||||
std::unique_ptr<FSWritableFile> sink_holder(sink_);
|
||||
std::unique_ptr<WritableFileWriter> file_writer(new WritableFileWriter(
|
||||
std::move(sink_holder), "" /* file name */, FileOptions()));
|
||||
return std::make_unique<BlogFileWriter>(std::move(file_writer), header);
|
||||
}
|
||||
|
||||
std::unique_ptr<BlogFileReader> MakeReaderWithReporter(
|
||||
BlogFileReader::Reporter* r) {
|
||||
return MakeReaderImpl(r);
|
||||
}
|
||||
|
||||
// Create a reader over the data written so far.
|
||||
std::unique_ptr<BlogFileReader> MakeReader() {
|
||||
return MakeReaderImpl(nullptr);
|
||||
}
|
||||
|
||||
std::unique_ptr<BlogFileReader> MakeReaderImpl(
|
||||
BlogFileReader::Reporter* reporter) {
|
||||
// Create an FSSequentialFile that reads from reader_contents_
|
||||
class MemSequentialFile : public FSSequentialFile {
|
||||
public:
|
||||
Slice& contents_;
|
||||
size_t pos_ = 0;
|
||||
explicit MemSequentialFile(Slice& contents) : contents_(contents) {}
|
||||
IOStatus Read(size_t n, const IOOptions& /*opts*/, Slice* result,
|
||||
char* scratch, IODebugContext* /*dbg*/) override {
|
||||
size_t avail = contents_.size() - pos_;
|
||||
if (n > avail) {
|
||||
n = avail;
|
||||
}
|
||||
if (n > 0) {
|
||||
memcpy(scratch, contents_.data() + pos_, n);
|
||||
pos_ += n;
|
||||
}
|
||||
*result = Slice(scratch, n);
|
||||
return IOStatus::OK();
|
||||
}
|
||||
IOStatus Skip(uint64_t n) override {
|
||||
pos_ += static_cast<size_t>(n);
|
||||
return IOStatus::OK();
|
||||
}
|
||||
};
|
||||
|
||||
auto* source = new MemSequentialFile(reader_contents_);
|
||||
std::unique_ptr<FSSequentialFile> source_holder(source);
|
||||
std::unique_ptr<SequentialFileReader> file_reader(
|
||||
new SequentialFileReader(std::move(source_holder), "" /* file name */));
|
||||
return std::make_unique<BlogFileReader>(std::move(file_reader), reporter,
|
||||
true /* verify_checksums */);
|
||||
}
|
||||
|
||||
// Helper to create a default header for blob files.
|
||||
BlogFileHeader MakeBlobHeader() {
|
||||
BlogFileHeader header;
|
||||
header.checksum_type = kXXH3;
|
||||
header.compact_record_type = kBlogBlobRecord;
|
||||
header.GenerateRandomFields();
|
||||
return header;
|
||||
}
|
||||
|
||||
// Helper to create a default header for WAL files.
|
||||
BlogFileHeader MakeWalHeader() {
|
||||
BlogFileHeader header;
|
||||
header.checksum_type = kXXH3;
|
||||
header.compact_record_type = kBlogWriteBatchRecord;
|
||||
header.GenerateRandomFields();
|
||||
header.SetProperty("role", "wal");
|
||||
return header;
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(BlogWriterTest, WriteAndReadHeader) {
|
||||
auto header = MakeBlobHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
auto reader = MakeReader();
|
||||
BlogFileHeader read_header;
|
||||
ASSERT_OK(reader->ReadHeader(&read_header));
|
||||
|
||||
ASSERT_EQ(read_header.schema_version, header.schema_version);
|
||||
ASSERT_EQ(read_header.checksum_type, header.checksum_type);
|
||||
ASSERT_EQ(read_header.compact_record_type, header.compact_record_type);
|
||||
ASSERT_EQ(memcmp(read_header.escape_sequence, header.escape_sequence,
|
||||
kBlogEscapeSequenceSize),
|
||||
0);
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, WriteSingleBlobRecord) {
|
||||
auto header = MakeBlobHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
std::string blob_data(100, 'A');
|
||||
uint64_t blob_offset = 0;
|
||||
ASSERT_OK(writer->AddBlobRecord(wo, blob_data, kNoCompression, &blob_offset));
|
||||
ASSERT_GT(blob_offset, 0u);
|
||||
|
||||
// Verify alignment: offset after record should be 4-byte aligned
|
||||
ASSERT_EQ(writer->current_offset() % 4, 0u);
|
||||
|
||||
// Read it back
|
||||
auto reader = MakeReader();
|
||||
BlogFileHeader read_header;
|
||||
ASSERT_OK(reader->ReadHeader(&read_header));
|
||||
|
||||
BlogRecordType type;
|
||||
Slice payload;
|
||||
std::string scratch;
|
||||
uint64_t rec_offset;
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch, &rec_offset));
|
||||
ASSERT_EQ(type, kBlogBlobRecord);
|
||||
ASSERT_EQ(payload.size(), 100u);
|
||||
ASSERT_EQ(payload.ToString(), blob_data);
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, WriteMultipleBlobRecords) {
|
||||
auto header = MakeBlobHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
std::vector<std::string> blobs = {"hello", std::string(1000, 'X'), "short",
|
||||
std::string(500, 'Y')};
|
||||
for (const auto& blob : blobs) {
|
||||
uint64_t offset;
|
||||
ASSERT_OK(writer->AddBlobRecord(wo, blob, kNoCompression, &offset));
|
||||
}
|
||||
|
||||
auto reader = MakeReader();
|
||||
BlogFileHeader rh;
|
||||
ASSERT_OK(reader->ReadHeader(&rh));
|
||||
|
||||
for (const auto& expected : blobs) {
|
||||
BlogRecordType type;
|
||||
Slice payload;
|
||||
std::string scratch;
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogBlobRecord);
|
||||
ASSERT_EQ(payload.ToString(), expected);
|
||||
}
|
||||
|
||||
// No more records
|
||||
BlogRecordType type;
|
||||
Slice payload;
|
||||
std::string scratch;
|
||||
ASSERT_TRUE(reader->ReadRecord(&type, &payload, &scratch).IsNotFound());
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, WriteWriteBatchRecord) {
|
||||
auto header = MakeWalHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
std::string wb_data(200, 'W');
|
||||
ASSERT_OK(writer->AddWriteBatchRecord(wo, wb_data));
|
||||
|
||||
auto reader = MakeReader();
|
||||
BlogFileHeader rh;
|
||||
ASSERT_OK(reader->ReadHeader(&rh));
|
||||
|
||||
BlogRecordType type;
|
||||
Slice payload;
|
||||
std::string scratch;
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogWriteBatchRecord);
|
||||
ASSERT_EQ(payload.ToString(), wb_data);
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, CompactVsFullFormat) {
|
||||
auto header = MakeBlobHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
// Small blob (< 2 MiB) should use compact format (kBlogBlobRecord matches
|
||||
// compact_record_type)
|
||||
std::string small_blob(100, 'S');
|
||||
uint64_t offset1;
|
||||
ASSERT_OK(writer->AddBlobRecord(wo, small_blob, kNoCompression, &offset1));
|
||||
|
||||
// Large blob (> 2 MiB) should use full format
|
||||
std::string large_blob(3 * 1024 * 1024, 'L');
|
||||
uint64_t offset2;
|
||||
ASSERT_OK(writer->AddBlobRecord(wo, large_blob, kNoCompression, &offset2));
|
||||
|
||||
// Read both back
|
||||
auto reader = MakeReader();
|
||||
BlogFileHeader rh;
|
||||
ASSERT_OK(reader->ReadHeader(&rh));
|
||||
|
||||
BlogRecordType type;
|
||||
Slice payload;
|
||||
std::string scratch;
|
||||
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogBlobRecord);
|
||||
ASSERT_EQ(payload.size(), small_blob.size());
|
||||
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogBlobRecord);
|
||||
ASSERT_EQ(payload.size(), large_blob.size());
|
||||
ASSERT_EQ(payload, Slice(large_blob));
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, PreambleStartRecord) {
|
||||
auto header = MakeWalHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
uint64_t pre_offset = writer->current_offset();
|
||||
ASSERT_EQ(pre_offset % 4, 0u) << "Pre-record offset not aligned";
|
||||
ASSERT_OK(writer->AddPreambleStartRecord(wo));
|
||||
uint64_t post_offset = writer->current_offset();
|
||||
ASSERT_EQ(post_offset % 4, 0u) << "Post-record offset not aligned";
|
||||
|
||||
// Use a reporter to capture errors
|
||||
class TestReporter : public BlogFileReader::Reporter {
|
||||
public:
|
||||
std::string last_msg;
|
||||
void Corruption(size_t /*bytes*/, const Status& s) override {
|
||||
last_msg = s.ToString();
|
||||
}
|
||||
};
|
||||
TestReporter reporter;
|
||||
|
||||
auto reader = MakeReaderWithReporter(&reporter);
|
||||
BlogFileHeader rh;
|
||||
ASSERT_OK(reader->ReadHeader(&rh));
|
||||
|
||||
BlogRecordType type;
|
||||
Slice payload;
|
||||
std::string scratch;
|
||||
Status rs = reader->ReadRecord(&type, &payload, &scratch);
|
||||
if (!rs.ok()) {
|
||||
fprintf(stderr, "ReadRecord failed: %s, Reporter: %s, EOF=%d\n",
|
||||
rs.ToString().c_str(), reporter.last_msg.c_str(), reader->IsEOF());
|
||||
}
|
||||
ASSERT_OK(rs);
|
||||
ASSERT_EQ(type, kBlogPreambleStartRecord);
|
||||
ASSERT_TRUE(payload.empty());
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, FooterRecords) {
|
||||
auto header = MakeBlobHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
// Write a blob record first
|
||||
std::string blob_data(50, 'B');
|
||||
uint64_t blob_offset;
|
||||
ASSERT_OK(writer->AddBlobRecord(wo, blob_data, kNoCompression, &blob_offset));
|
||||
|
||||
// Write footer properties
|
||||
uint64_t props_offset = writer->current_offset();
|
||||
BlogFileFooterProperties props;
|
||||
props.SetBlobCount(1);
|
||||
props.SetTotalBlobBytes(50);
|
||||
ASSERT_OK(writer->AddFooterPropertiesRecord(wo, props));
|
||||
|
||||
// Write footer locator
|
||||
BlogFileFooterLocator locator;
|
||||
uint64_t locator_offset = writer->current_offset();
|
||||
locator.entries.push_back(
|
||||
{kBlogFooterPropertiesRecord,
|
||||
static_cast<uint32_t>((locator_offset - props_offset) / 4)});
|
||||
ASSERT_OK(writer->AddFooterLocatorRecord(wo, locator));
|
||||
|
||||
// Read all records
|
||||
auto reader = MakeReader();
|
||||
BlogFileHeader rh;
|
||||
ASSERT_OK(reader->ReadHeader(&rh));
|
||||
|
||||
BlogRecordType type;
|
||||
Slice payload;
|
||||
std::string scratch;
|
||||
|
||||
// Blob record
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogBlobRecord);
|
||||
|
||||
// Footer properties record
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogFooterPropertiesRecord);
|
||||
BlogFileFooterProperties read_props;
|
||||
ASSERT_OK(read_props.DecodeFrom(payload));
|
||||
ASSERT_EQ(read_props.properties.size(), 2u);
|
||||
|
||||
// Footer locator record
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogFooterLocatorRecord);
|
||||
BlogFileFooterLocator read_locator;
|
||||
ASSERT_OK(read_locator.DecodeFrom(payload));
|
||||
ASSERT_EQ(read_locator.entries.size(), 1u);
|
||||
ASSERT_EQ(read_locator.entries[0].record_type, kBlogFooterPropertiesRecord);
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, ChecksumCorruptionDetected) {
|
||||
auto header = MakeBlobHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
std::string blob_data(100, 'C');
|
||||
uint64_t blob_offset;
|
||||
ASSERT_OK(writer->AddBlobRecord(wo, blob_data, kNoCompression, &blob_offset));
|
||||
|
||||
// Corrupt one byte in the blob payload
|
||||
std::string& contents = sink_->contents_;
|
||||
ASSERT_GT(contents.size(), blob_offset + 10);
|
||||
contents[blob_offset + 10] ^= 0xFF;
|
||||
|
||||
// Reading should detect the corruption
|
||||
auto reader = MakeReader();
|
||||
BlogFileHeader rh;
|
||||
ASSERT_OK(reader->ReadHeader(&rh));
|
||||
|
||||
BlogRecordType type;
|
||||
Slice payload;
|
||||
std::string scratch;
|
||||
ASSERT_TRUE(reader->ReadRecord(&type, &payload, &scratch).IsCorruption());
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, MixedRecordTypes) {
|
||||
// Use blob compact type but also write WriteBatch records (full format)
|
||||
auto header = MakeBlobHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
// Blob (compact format)
|
||||
std::string blob(50, 'B');
|
||||
uint64_t blob_offset;
|
||||
ASSERT_OK(writer->AddBlobRecord(wo, blob, kNoCompression, &blob_offset));
|
||||
|
||||
// WriteBatch (full format since it doesn't match compact_record_type)
|
||||
std::string wb(60, 'W');
|
||||
ASSERT_OK(writer->AddWriteBatchRecord(wo, wb));
|
||||
|
||||
// Another blob (compact)
|
||||
std::string blob2(70, 'D');
|
||||
uint64_t blob2_offset;
|
||||
ASSERT_OK(writer->AddBlobRecord(wo, blob2, kNoCompression, &blob2_offset));
|
||||
|
||||
// Read all back
|
||||
auto reader = MakeReader();
|
||||
BlogFileHeader rh;
|
||||
ASSERT_OK(reader->ReadHeader(&rh));
|
||||
|
||||
BlogRecordType type;
|
||||
Slice payload;
|
||||
std::string scratch;
|
||||
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogBlobRecord);
|
||||
ASSERT_EQ(payload.size(), 50u);
|
||||
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogWriteBatchRecord);
|
||||
ASSERT_EQ(payload.size(), 60u);
|
||||
|
||||
ASSERT_OK(reader->ReadRecord(&type, &payload, &scratch));
|
||||
ASSERT_EQ(type, kBlogBlobRecord);
|
||||
ASSERT_EQ(payload.size(), 70u);
|
||||
|
||||
ASSERT_TRUE(reader->ReadRecord(&type, &payload, &scratch).IsNotFound());
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, AlignmentInvariant) {
|
||||
auto header = MakeBlobHeader();
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
// Write records of various sizes and verify alignment is maintained
|
||||
for (int size = 1; size <= 200; ++size) {
|
||||
std::string data(size, static_cast<char>(size & 0xFF));
|
||||
uint64_t offset;
|
||||
ASSERT_OK(writer->AddBlobRecord(wo, data, kNoCompression, &offset));
|
||||
ASSERT_EQ(writer->current_offset() % 4, 0u)
|
||||
<< "Alignment violated after record of size " << size;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(BlogWriterTest, HeaderWithProperties) {
|
||||
auto header = MakeBlobHeader();
|
||||
header.SetProperty("CompressionCompatibilityName", "zstd");
|
||||
header.SetProperty("role", "blob");
|
||||
header.SetProperty("compressionSettings", "level=3");
|
||||
auto writer = MakeWriter(header);
|
||||
|
||||
WriteOptions wo;
|
||||
ASSERT_OK(writer->WriteHeader(wo));
|
||||
|
||||
auto reader = MakeReader();
|
||||
BlogFileHeader rh;
|
||||
ASSERT_OK(reader->ReadHeader(&rh));
|
||||
ASSERT_EQ(rh.GetProperty("CompressionCompatibilityName"), "zstd");
|
||||
ASSERT_EQ(rh.GetProperty("role"), "blob");
|
||||
ASSERT_EQ(rh.GetProperty("compressionSettings"), "level=3");
|
||||
}
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
ROCKSDB_NAMESPACE::port::InstallStackTraceHandler();
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -9057,10 +9057,11 @@ class DBCompactionTestBlobError
|
||||
std::string sync_point_;
|
||||
};
|
||||
|
||||
INSTANTIATE_TEST_CASE_P(DBCompactionTestBlobError, DBCompactionTestBlobError,
|
||||
::testing::ValuesIn(std::vector<std::string>{
|
||||
"BlobFileBuilder::WriteBlobToFile:AddRecord",
|
||||
"BlobFileBuilder::WriteBlobToFile:AppendFooter"}));
|
||||
INSTANTIATE_TEST_CASE_P(
|
||||
DBCompactionTestBlobError, DBCompactionTestBlobError,
|
||||
::testing::ValuesIn(std::vector<std::string>{
|
||||
"BlobFileBuilder::WriteBlobToFile:AddRecord",
|
||||
"BlobFileBuilder::WriteBlobToFile:LegacyAppendFooterAndClose"}));
|
||||
|
||||
TEST_P(DBCompactionTestBlobError, CompactionError) {
|
||||
Options options = CurrentOptions();
|
||||
@@ -11993,6 +11994,62 @@ 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);
|
||||
}
|
||||
|
||||
TEST_F(DBCompactionTest, VerifyFileChecksumOnCompactionOutput) {
|
||||
Options options = CurrentOptions();
|
||||
options.disable_auto_compactions = true;
|
||||
|
||||
+11
-6
@@ -2507,10 +2507,11 @@ class DBFlushTestBlobError : public DBFlushTest,
|
||||
std::string sync_point_;
|
||||
};
|
||||
|
||||
INSTANTIATE_TEST_CASE_P(DBFlushTestBlobError, DBFlushTestBlobError,
|
||||
::testing::ValuesIn(std::vector<std::string>{
|
||||
"BlobFileBuilder::WriteBlobToFile:AddRecord",
|
||||
"BlobFileBuilder::WriteBlobToFile:AppendFooter"}));
|
||||
INSTANTIATE_TEST_CASE_P(
|
||||
DBFlushTestBlobError, DBFlushTestBlobError,
|
||||
::testing::ValuesIn(std::vector<std::string>{
|
||||
"BlobFileBuilder::WriteBlobToFile:AddRecord",
|
||||
"BlobFileBuilder::WriteBlobToFile:LegacyAppendFooterAndClose"}));
|
||||
|
||||
TEST_P(DBFlushTestBlobError, FlushError) {
|
||||
Options options;
|
||||
@@ -3946,8 +3947,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();
|
||||
|
||||
|
||||
@@ -1665,9 +1665,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 !=
|
||||
@@ -6490,6 +6493,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 +6595,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+47
-50
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
+34
-17
@@ -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;
|
||||
@@ -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_;
|
||||
|
||||
+233
-94
@@ -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,11 +5818,13 @@ 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.
|
||||
AssertRange(0, "g", "z");
|
||||
@@ -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,7 +6054,7 @@ 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);
|
||||
@@ -6088,7 +6110,7 @@ TEST_P(ReadPathRangeTombstoneTest, PrefixFilterTotalOrderSeek) {
|
||||
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,7 +6306,7 @@ 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));
|
||||
@@ -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,8 +6686,9 @@ 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).
|
||||
// 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");
|
||||
@@ -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) {
|
||||
@@ -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),
|
||||
@@ -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
|
||||
@@ -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);
|
||||
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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -8149,6 +8149,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) {
|
||||
|
||||
+5
-4
@@ -900,10 +900,11 @@ class DBRecoveryTestBlobError
|
||||
std::string sync_point_;
|
||||
};
|
||||
|
||||
INSTANTIATE_TEST_CASE_P(DBRecoveryTestBlobError, DBRecoveryTestBlobError,
|
||||
::testing::ValuesIn(std::vector<std::string>{
|
||||
"BlobFileBuilder::WriteBlobToFile:AddRecord",
|
||||
"BlobFileBuilder::WriteBlobToFile:AppendFooter"}));
|
||||
INSTANTIATE_TEST_CASE_P(
|
||||
DBRecoveryTestBlobError, DBRecoveryTestBlobError,
|
||||
::testing::ValuesIn(std::vector<std::string>{
|
||||
"BlobFileBuilder::WriteBlobToFile:AddRecord",
|
||||
"BlobFileBuilder::WriteBlobToFile:LegacyAppendFooterAndClose"}));
|
||||
|
||||
TEST_P(DBRecoveryTestBlobError, RecoverWithBlobError) {
|
||||
// Write a value. Note that blob files are not actually enabled at this point.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+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);
|
||||
|
||||
+1
-4
@@ -31,7 +31,7 @@ Writer::Writer(std::unique_ptr<WritableFileWriter>&& dest, uint64_t log_number,
|
||||
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 +47,6 @@ Writer::~Writer() {
|
||||
if (dest_) {
|
||||
WriteBuffer(WriteOptions()).PermitUncheckedError();
|
||||
}
|
||||
if (compress_) {
|
||||
delete compress_;
|
||||
}
|
||||
ThreadStatusUtil::SetThreadOperation(cur_op_type);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -151,7 +151,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_;
|
||||
|
||||
|
||||
+38
-6
@@ -918,12 +918,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 +938,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 +976,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())];
|
||||
}
|
||||
|
||||
+23
-7
@@ -328,11 +328,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;
|
||||
}
|
||||
|
||||
@@ -858,9 +860,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 +934,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
|
||||
|
||||
@@ -52,8 +52,7 @@ void WriteFooterlessBlobFile(const ImmutableOptions& immutable_options,
|
||||
constexpr bool use_fsync = false;
|
||||
constexpr bool do_flush = false;
|
||||
BlobLogWriter blob_log_writer(std::move(file_writer), immutable_options.clock,
|
||||
statistics, blob_file_number, use_fsync,
|
||||
do_flush);
|
||||
statistics, use_fsync, do_flush);
|
||||
|
||||
constexpr bool has_ttl = false;
|
||||
constexpr ExpirationRange expiration_range;
|
||||
|
||||
+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_;
|
||||
|
||||
+5
-2
@@ -5599,7 +5599,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),
|
||||
@@ -5728,6 +5728,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,
|
||||
@@ -5808,7 +5810,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);
|
||||
|
||||
+35
-3
@@ -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"
|
||||
@@ -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;
|
||||
@@ -2504,6 +2515,8 @@ TEST_F(VersionSetTest, ManifestContentValidationOnCloseDisabled) {
|
||||
// Default (option disabled), corrupt manifest after writer close,
|
||||
// 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) {
|
||||
@@ -4431,7 +4463,7 @@ class BestEffortsRecoverIncompleteVersionTest
|
||||
std::move(file), blob_file_path, FileOptions(), options.clock));
|
||||
|
||||
BlobLogWriter blob_log_writer(std::move(file_writer), options.clock,
|
||||
/*statistics*/ nullptr, blob_file_number,
|
||||
/*statistics*/ nullptr,
|
||||
/*use_fsync*/ true,
|
||||
/*do_flush*/ false);
|
||||
|
||||
@@ -4449,8 +4481,8 @@ class BestEffortsRecoverIncompleteVersionTest
|
||||
footer.expiration_range = expiration_range;
|
||||
std::string checksum_method;
|
||||
std::string checksum_value;
|
||||
ASSERT_OK(blob_log_writer.AppendFooter(WriteOptions(), footer,
|
||||
&checksum_method, &checksum_value));
|
||||
ASSERT_OK(blob_log_writer.LegacyAppendFooterAndClose(
|
||||
WriteOptions(), footer, &checksum_method, &checksum_value));
|
||||
}
|
||||
|
||||
void RecoverFromManifestWithMissingFiles(
|
||||
|
||||
@@ -363,6 +363,8 @@ DECLARE_uint64(wp_commit_cache_bits);
|
||||
DECLARE_bool(adaptive_readahead);
|
||||
DECLARE_bool(async_io);
|
||||
DECLARE_string(wal_compression);
|
||||
DECLARE_bool(use_blog_format_for_blobs);
|
||||
DECLARE_string(blog_checksum);
|
||||
DECLARE_bool(verify_sst_unique_id_in_manifest);
|
||||
DECLARE_bool(fast_sst_open);
|
||||
|
||||
|
||||
@@ -1320,6 +1320,11 @@ DEFINE_bool(
|
||||
DEFINE_string(wal_compression, "none",
|
||||
"Algorithm to use for WAL compression. none to disable.");
|
||||
|
||||
DEFINE_bool(use_blog_format_for_blobs, false,
|
||||
"Use blog file format for new blob files.");
|
||||
|
||||
DEFINE_string(blog_checksum, "kXXH3", "Checksum type for blog format files.");
|
||||
|
||||
DEFINE_bool(
|
||||
verify_sst_unique_id_in_manifest, false,
|
||||
"Enable DB options `verify_sst_unique_id_in_manifest`, if true, during "
|
||||
|
||||
@@ -4740,6 +4740,18 @@ void InitializeOptionsFromFlags(
|
||||
|
||||
options.wal_compression =
|
||||
StringToCompressionType(FLAGS_wal_compression.c_str());
|
||||
options.use_blog_format_for_blobs = FLAGS_use_blog_format_for_blobs;
|
||||
// Parse blog_checksum from string. Use the options system for correct
|
||||
// handling of all ChecksumType values.
|
||||
{
|
||||
ConfigOptions config_options;
|
||||
config_options.ignore_unknown_options = true;
|
||||
DBOptions tmp_opts;
|
||||
GetDBOptionsFromString(config_options, tmp_opts,
|
||||
"blog_checksum=" + FLAGS_blog_checksum, &tmp_opts)
|
||||
.PermitUncheckedError();
|
||||
options.blog_checksum = tmp_opts.blog_checksum;
|
||||
}
|
||||
|
||||
options.last_level_temperature =
|
||||
StringToTemperature(FLAGS_last_level_temperature.c_str());
|
||||
|
||||
+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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
// This source code is licensed under both the GPLv2 (found in the
|
||||
// COPYING file in the root directory) and Apache 2.0 License
|
||||
// (found in the LICENSE.Apache file in the root directory).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "rocksdb/rocksdb_namespace.h"
|
||||
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
// Types of checksums to use for checking integrity of logical blocks within
|
||||
// files. All checksums currently use 32 bits of checking power (1 in 4B
|
||||
// chance of failing to detect random corruption). Traditionally, the actual
|
||||
// checking power can be far from ideal if the corruption is due to misplaced
|
||||
// data (e.g. physical blocks out of order in a file, or from another file),
|
||||
// which is fixed in format_version=6 (see below).
|
||||
enum ChecksumType : char {
|
||||
kNoChecksum = 0x0,
|
||||
kCRC32c = 0x1,
|
||||
kxxHash = 0x2,
|
||||
kxxHash64 = 0x3,
|
||||
kXXH3 = 0x4, // Supported since RocksDB 6.27
|
||||
};
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
@@ -28,7 +28,12 @@ enum CompressionType : unsigned char {
|
||||
kZSTD = 0x07,
|
||||
kLastBuiltinCompression = kZSTD,
|
||||
|
||||
// Reserved for future use: up to 0x7F
|
||||
// Values here reserved for future use by RocksDB
|
||||
|
||||
// Sentinel value used in blog file record trailers to indicate the
|
||||
// payload uses streaming compression configured in the file header.
|
||||
// See WriteBatchStreamingCompressionType header property.
|
||||
kStreamingCompressionSentinel = 0x7F,
|
||||
|
||||
// For use by user custom CompressionManagers
|
||||
kCustomCompression80 = 0x80,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "rocksdb/advanced_options.h"
|
||||
#include "rocksdb/checksum_type.h"
|
||||
#include "rocksdb/comparator.h"
|
||||
#include "rocksdb/compression_type.h"
|
||||
#include "rocksdb/customizable.h"
|
||||
@@ -1480,6 +1481,21 @@ struct DBOptions {
|
||||
// the WAL is read.
|
||||
CompressionType wal_compression = kNoCompression;
|
||||
|
||||
// If true, new blob files use the blog file format instead of the legacy
|
||||
// blob log format. The blog format provides context checksums and a
|
||||
// unified file structure shared with WAL. Existing legacy blob files are
|
||||
// still readable regardless of this setting.
|
||||
//
|
||||
// Default: false
|
||||
bool use_blog_format_for_blobs = false;
|
||||
|
||||
// Checksum type used for integrity checks within blog format files
|
||||
// (both WAL and blob). Uses the same ChecksumType enum as
|
||||
// BlockBasedTableOptions::checksum.
|
||||
//
|
||||
// Default: kXXH3
|
||||
ChecksumType blog_checksum = kXXH3;
|
||||
|
||||
// Set to true to re-instate an old behavior of keeping complete, synced WAL
|
||||
// files open for write until they are collected for deletion by a
|
||||
// background thread. This should not be needed unless there is a
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
+3
-13
@@ -46,19 +46,9 @@ struct ConfigOptions;
|
||||
struct EnvOptions;
|
||||
class UserDefinedIndexFactory;
|
||||
|
||||
// Types of checksums to use for checking integrity of logical blocks within
|
||||
// files. All checksums currently use 32 bits of checking power (1 in 4B
|
||||
// chance of failing to detect random corruption). Traditionally, the actual
|
||||
// checking power can be far from ideal if the corruption is due to misplaced
|
||||
// data (e.g. physical blocks out of order in a file, or from another file),
|
||||
// which is fixed in format_version=6 (see below).
|
||||
enum ChecksumType : char {
|
||||
kNoChecksum = 0x0,
|
||||
kCRC32c = 0x1,
|
||||
kxxHash = 0x2,
|
||||
kxxHash64 = 0x3,
|
||||
kXXH3 = 0x4, // Supported since RocksDB 6.27
|
||||
};
|
||||
// ChecksumType is defined in its own header for use by both table options
|
||||
// and DB options (blog file checksums).
|
||||
#include "rocksdb/checksum_type.h"
|
||||
|
||||
// `PinningTier` is used to specify which tier of block-based tables should
|
||||
// be affected by a block cache pinning setting (see
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
namespace ROCKSDB_NAMESPACE {
|
||||
|
||||
class InternalTblPropColl;
|
||||
class CompressionManager;
|
||||
|
||||
// -- Table Properties
|
||||
// Other than basic table properties, each table may also have the user
|
||||
@@ -445,6 +446,28 @@ struct TableProperties {
|
||||
std::string* mismatch) const;
|
||||
};
|
||||
|
||||
// Parse TableProperties::compression_name into human-readable format.
|
||||
// Thread-safe, but not purely local: the single-argument overload may consult
|
||||
// globally registered CompressionManagers via ObjectLibrary using the stored
|
||||
// compatibility name, and the two-argument overload consults the supplied
|
||||
// CompressionManager first before falling back to that lookup.
|
||||
// For format_version >= 7: "<compatibility_name>;<hex_codes>;" -> "ZSTD",
|
||||
// "LZ4", etc. For older versions (no semicolon): returns as-is (e.g., "ZSTD",
|
||||
// "Snappy", "kZSTD" are preserved with their original names). This means the
|
||||
// single-argument overload can now return manager-specific names for custom
|
||||
// compression types when a compatible CompressionManager is registered
|
||||
// globally; otherwise, reserved/custom values fall back to generic names such
|
||||
// as "Reserved7F" or "Custom8A". Returns "NoCompression" for an empty input,
|
||||
// an empty hex field such as "BuiltinV2;;", or when all parsed entries are
|
||||
// filtered out as NoCompression/DisableOption. Returns "Unknown" for malformed
|
||||
// format_version >= 7 metadata, including a missing second semicolon,
|
||||
// odd-length hex payload, or invalid hex characters. If multiple compression
|
||||
// types are present, returns a comma-separated list in input order.
|
||||
std::string ParseCompressionNameForDisplay(const std::string& compression_name);
|
||||
std::string ParseCompressionNameForDisplay(
|
||||
const std::string& compression_name,
|
||||
std::shared_ptr<CompressionManager> compression_manager);
|
||||
|
||||
// Extra properties
|
||||
// Below is a list of non-basic properties that are collected by database
|
||||
// itself. Especially some properties regarding to the internal keys (which
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// NOTE: in 'main' development branch, this should be the *next*
|
||||
// minor or major version number planned for release.
|
||||
#define ROCKSDB_MAJOR 11
|
||||
#define ROCKSDB_MINOR 2
|
||||
#define ROCKSDB_MINOR 3
|
||||
#define ROCKSDB_PATCH 0
|
||||
|
||||
// Make it easy to do conditional compilation based on version checks, i.e.
|
||||
|
||||
@@ -5311,6 +5311,8 @@ class TickerTypeJni {
|
||||
return -0x69;
|
||||
case ROCKSDB_NAMESPACE::Tickers::READ_PATH_RANGE_TOMBSTONES_DISCARDED:
|
||||
return -0x6A;
|
||||
case ROCKSDB_NAMESPACE::Tickers::MANIFEST_VALIDATION_FAILURE_COUNT:
|
||||
return -0x6B;
|
||||
case ROCKSDB_NAMESPACE::Tickers::TICKER_ENUM_MAX:
|
||||
// -0x54 is the max value at this time. Since these values are exposed
|
||||
// directly to Java clients, we'll keep the value the same till the next
|
||||
@@ -5812,6 +5814,8 @@ class TickerTypeJni {
|
||||
return ROCKSDB_NAMESPACE::Tickers::READ_PATH_RANGE_TOMBSTONES_INSERTED;
|
||||
case -0x6A:
|
||||
return ROCKSDB_NAMESPACE::Tickers::READ_PATH_RANGE_TOMBSTONES_DISCARDED;
|
||||
case -0x6B:
|
||||
return ROCKSDB_NAMESPACE::Tickers::MANIFEST_VALIDATION_FAILURE_COUNT;
|
||||
case -0x54:
|
||||
// -0x54 is the max value at this time. Since these values are exposed
|
||||
// directly to Java clients, we'll keep the value the same till the next
|
||||
|
||||
@@ -967,6 +967,11 @@ public enum TickerType {
|
||||
*/
|
||||
READ_PATH_RANGE_TOMBSTONES_DISCARDED((byte) -0x6A),
|
||||
|
||||
/**
|
||||
* # of times MANIFEST content validation detected corruption on DB close
|
||||
*/
|
||||
MANIFEST_VALIDATION_FAILURE_COUNT((byte) -0x6B),
|
||||
|
||||
TICKER_ENUM_MAX((byte) -0x54);
|
||||
|
||||
private final byte value;
|
||||
|
||||
@@ -302,6 +302,8 @@ const std::vector<std::pair<Tickers, std::string>> TickersNameMap = {
|
||||
"rocksdb.read.path.range.tombstones.discarded"},
|
||||
{FILE_OPEN_METADATA_RETRIEVED, "rocksdb.file.open.metadata.retrieved"},
|
||||
{FILE_OPEN_METADATA_PASSED, "rocksdb.file.open.metadata.passed"},
|
||||
{MANIFEST_VALIDATION_FAILURE_COUNT,
|
||||
"rocksdb.manifest.validation.failure.count"},
|
||||
};
|
||||
|
||||
const std::vector<std::pair<Histograms, std::string>> HistogramsNameMap = {
|
||||
|
||||
@@ -415,6 +415,14 @@ static std::unordered_map<std::string, OptionTypeInfo>
|
||||
{offsetof(struct ImmutableDBOptions, wal_compression),
|
||||
OptionType::kCompressionType, OptionVerificationType::kNormal,
|
||||
OptionTypeFlags::kNone}},
|
||||
{"use_blog_format_for_blobs",
|
||||
{offsetof(struct ImmutableDBOptions, use_blog_format_for_blobs),
|
||||
OptionType::kBoolean, OptionVerificationType::kNormal,
|
||||
OptionTypeFlags::kNone}},
|
||||
{"blog_checksum",
|
||||
{offsetof(struct ImmutableDBOptions, blog_checksum),
|
||||
OptionType::kChecksumType, OptionVerificationType::kNormal,
|
||||
OptionTypeFlags::kNone}},
|
||||
{"background_close_inactive_wals",
|
||||
{offsetof(struct ImmutableDBOptions, background_close_inactive_wals),
|
||||
OptionType::kBoolean, OptionVerificationType::kNormal,
|
||||
@@ -800,6 +808,8 @@ ImmutableDBOptions::ImmutableDBOptions(const DBOptions& options)
|
||||
two_write_queues(options.two_write_queues),
|
||||
manual_wal_flush(options.manual_wal_flush),
|
||||
wal_compression(options.wal_compression),
|
||||
use_blog_format_for_blobs(options.use_blog_format_for_blobs),
|
||||
blog_checksum(options.blog_checksum),
|
||||
background_close_inactive_wals(options.background_close_inactive_wals),
|
||||
atomic_flush(options.atomic_flush),
|
||||
avoid_unnecessary_blocking_io(options.avoid_unnecessary_blocking_io),
|
||||
@@ -973,6 +983,9 @@ void ImmutableDBOptions::Dump(Logger* log) const {
|
||||
manual_wal_flush);
|
||||
ROCKS_LOG_HEADER(log, " Options.wal_compression: %d",
|
||||
wal_compression);
|
||||
ROCKS_LOG_HEADER(log, " Options.use_blog_format_for_blobs: %d",
|
||||
use_blog_format_for_blobs);
|
||||
ROCKS_LOG_HEADER(log, " Options.blog_checksum: %d", blog_checksum);
|
||||
ROCKS_LOG_HEADER(log,
|
||||
" Options.background_close_inactive_wals: %d",
|
||||
background_close_inactive_wals);
|
||||
|
||||
@@ -82,6 +82,8 @@ struct ImmutableDBOptions {
|
||||
bool two_write_queues;
|
||||
bool manual_wal_flush;
|
||||
CompressionType wal_compression;
|
||||
bool use_blog_format_for_blobs;
|
||||
ChecksumType blog_checksum;
|
||||
bool background_close_inactive_wals;
|
||||
bool atomic_flush;
|
||||
bool avoid_unnecessary_blocking_io;
|
||||
|
||||
@@ -166,6 +166,9 @@ void BuildDBOptions(const ImmutableDBOptions& immutable_db_options,
|
||||
options.two_write_queues = immutable_db_options.two_write_queues;
|
||||
options.manual_wal_flush = immutable_db_options.manual_wal_flush;
|
||||
options.wal_compression = immutable_db_options.wal_compression;
|
||||
options.use_blog_format_for_blobs =
|
||||
immutable_db_options.use_blog_format_for_blobs;
|
||||
options.blog_checksum = immutable_db_options.blog_checksum;
|
||||
options.background_close_inactive_wals =
|
||||
immutable_db_options.background_close_inactive_wals;
|
||||
options.atomic_flush = immutable_db_options.atomic_flush;
|
||||
|
||||
@@ -467,6 +467,8 @@ TEST_F(OptionsSettableTest, DBOptionsAllFieldsSettable) {
|
||||
"two_write_queues=false;"
|
||||
"manual_wal_flush=false;"
|
||||
"wal_compression=kZSTD;"
|
||||
"use_blog_format_for_blobs=false;"
|
||||
"blog_checksum=kXXH3;"
|
||||
"background_close_inactive_wals=true;"
|
||||
"seq_per_batch=false;"
|
||||
"atomic_flush=false;"
|
||||
|
||||
@@ -161,10 +161,34 @@ void RWMutex::ReadLock() {
|
||||
PthreadCall("read lock", pthread_rwlock_rdlock(&mu_));
|
||||
}
|
||||
|
||||
bool RWMutex::TryReadLock() {
|
||||
int result = pthread_rwlock_tryrdlock(&mu_);
|
||||
if (result == 0) {
|
||||
return true;
|
||||
}
|
||||
if (result == EBUSY) {
|
||||
return false;
|
||||
}
|
||||
PthreadCall("try read lock", result);
|
||||
return false;
|
||||
}
|
||||
|
||||
void RWMutex::WriteLock() {
|
||||
PthreadCall("write lock", pthread_rwlock_wrlock(&mu_));
|
||||
}
|
||||
|
||||
bool RWMutex::TryWriteLock() {
|
||||
int result = pthread_rwlock_trywrlock(&mu_);
|
||||
if (result == 0) {
|
||||
return true;
|
||||
}
|
||||
if (result == EBUSY) {
|
||||
return false;
|
||||
}
|
||||
PthreadCall("try write lock", result);
|
||||
return false;
|
||||
}
|
||||
|
||||
void RWMutex::ReadUnlock() {
|
||||
PthreadCall("read unlock", pthread_rwlock_unlock(&mu_));
|
||||
}
|
||||
|
||||
@@ -136,7 +136,9 @@ class RWMutex {
|
||||
~RWMutex();
|
||||
|
||||
void ReadLock();
|
||||
bool TryReadLock();
|
||||
void WriteLock();
|
||||
bool TryWriteLock();
|
||||
void ReadUnlock();
|
||||
void WriteUnlock();
|
||||
void AssertHeld() const {}
|
||||
|
||||
@@ -152,8 +152,12 @@ class RWMutex {
|
||||
|
||||
void ReadLock() { AcquireSRWLockShared(&srwLock_); }
|
||||
|
||||
bool TryReadLock() { return TryAcquireSRWLockShared(&srwLock_) != 0; }
|
||||
|
||||
void WriteLock() { AcquireSRWLockExclusive(&srwLock_); }
|
||||
|
||||
bool TryWriteLock() { return TryAcquireSRWLockExclusive(&srwLock_) != 0; }
|
||||
|
||||
void ReadUnlock() { ReleaseSRWLockShared(&srwLock_); }
|
||||
|
||||
void WriteUnlock() { ReleaseSRWLockExclusive(&srwLock_); }
|
||||
|
||||
@@ -31,6 +31,9 @@ LIB_SOURCES = \
|
||||
db/blob/blob_source.cc \
|
||||
db/blob/blob_write_batch_transformer.cc \
|
||||
db/blob/prefetch_buffer_collection.cc \
|
||||
db/blog/blog_format.cc \
|
||||
db/blog/blog_reader.cc \
|
||||
db/blog/blog_writer.cc \
|
||||
db/builder.cc \
|
||||
db/c.cc \
|
||||
db/coalescing_iterator.cc \
|
||||
@@ -485,6 +488,8 @@ TEST_MAIN_SOURCES = \
|
||||
db/blob/db_blob_compaction_test.cc \
|
||||
db/blob/db_blob_corruption_test.cc \
|
||||
db/blob/db_blob_index_test.cc \
|
||||
db/blog/blog_format_test.cc \
|
||||
db/blog/blog_writer_test.cc \
|
||||
db/column_family_test.cc \
|
||||
db/compact_files_test.cc \
|
||||
db/compaction/clipping_iterator_test.cc \
|
||||
|
||||
@@ -1094,6 +1094,10 @@ TEST_P(BlockBasedTableReaderMultiScanAsyncIOTest, MultiScanPrepare) {
|
||||
|
||||
options_.statistics = CreateDBStatistics();
|
||||
std::shared_ptr<FileSystem> fs = options_.env->GetFileSystem();
|
||||
int64_t supported_ops = 0;
|
||||
fs->SupportedOps(supported_ops);
|
||||
const bool async_supported =
|
||||
use_async_io && ((supported_ops & (1 << FSSupportedOps::kAsyncIO)) != 0);
|
||||
ReadOptions read_opts;
|
||||
read_opts.fill_cache = fill_cache;
|
||||
size_t ts_sz = options_.comparator->timestamp_size();
|
||||
@@ -1209,17 +1213,12 @@ TEST_P(BlockBasedTableReaderMultiScanAsyncIOTest, MultiScanPrepare) {
|
||||
iter->Prepare(&scan_options);
|
||||
read_count_after =
|
||||
options_.statistics->getTickerCount(NON_LAST_LEVEL_READ_COUNT);
|
||||
if (!use_async_io) {
|
||||
if (!fill_cache) {
|
||||
ASSERT_EQ(read_count_before + 1, read_count_after);
|
||||
} else {
|
||||
ASSERT_EQ(read_count_before + 2, read_count_after);
|
||||
}
|
||||
} else {
|
||||
// stat is recorded in async callback which happens in Poll(), and
|
||||
// Poll() happens during scanning.
|
||||
ASSERT_EQ(read_count_before, read_count_after);
|
||||
}
|
||||
// When async IO is available on this thread, Prepare() only queues the reads
|
||||
// and stats are updated later during Poll(). Otherwise it falls back to the
|
||||
// synchronous path and records the reads immediately.
|
||||
const uint64_t expected_prepare_reads =
|
||||
async_supported ? 0 : (fill_cache ? 2 : 1);
|
||||
ASSERT_EQ(read_count_before + expected_prepare_reads, read_count_after);
|
||||
|
||||
iter->Seek(kv[40 * kEntriesPerBlock].first);
|
||||
for (size_t i = 40 * kEntriesPerBlock; i < 80 * kEntriesPerBlock; ++i) {
|
||||
|
||||
@@ -253,9 +253,7 @@ class ExternalTableReaderAdapter : public TableReader {
|
||||
return s;
|
||||
}
|
||||
|
||||
ParsedInternalKey found_key(parsed_key.user_key, 0, ValueType::kTypeValue);
|
||||
bool matched = false;
|
||||
get_context->SaveValue(found_key, value, &matched, &s,
|
||||
get_context->SaveValue(value, /*seq=*/0,
|
||||
value.IsPinned() ? &value : nullptr);
|
||||
return s;
|
||||
}
|
||||
@@ -419,6 +417,11 @@ class ExternalTableFactoryAdapter : public TableFactory {
|
||||
return Status::NotSupported(
|
||||
"Ingesting file with sequence number larger than 0");
|
||||
}
|
||||
if (topts.ioptions.user_comparator != nullptr &&
|
||||
topts.ioptions.user_comparator->timestamp_size() != 0) {
|
||||
return Status::NotSupported(
|
||||
"External table does not support user-defined timestamps");
|
||||
}
|
||||
std::unique_ptr<ExternalTableReader> reader;
|
||||
FileOptions fopts(topts.env_options);
|
||||
ExternalTableOptions ext_topts(topts.prefix_extractor,
|
||||
@@ -442,7 +445,7 @@ class ExternalTableFactoryAdapter : public TableFactory {
|
||||
ExternalTableBuilderOptions ext_topts(
|
||||
topts.read_options, topts.write_options,
|
||||
topts.moptions.prefix_extractor, topts.ioptions.user_comparator,
|
||||
topts.column_family_name, topts.reason);
|
||||
topts.column_family_name, topts.reason, topts.ioptions.fs);
|
||||
auto file_wrapper =
|
||||
std::make_unique<ExternalTableWritableFileWrapper>(file);
|
||||
builder.reset(inner_->NewTableBuilder(ext_topts, file->file_name(),
|
||||
|
||||
@@ -201,15 +201,22 @@ Status GetContext::SaveWideColumnEntityToColumns(const Slice& user_key,
|
||||
return status;
|
||||
}
|
||||
|
||||
void GetContext::SaveValue(const Slice& value, SequenceNumber /*seq*/) {
|
||||
void GetContext::SaveValue(const Slice& value, SequenceNumber /*seq*/,
|
||||
Cleanable* value_pinner) {
|
||||
assert(state_ == kNotFound);
|
||||
assert(ucmp_->timestamp_size() == 0);
|
||||
|
||||
TEST_SYNC_POINT_CALLBACK("GetContext::SaveValue::Simple", this);
|
||||
|
||||
appendToReplayLog(kTypeValue, value, Slice());
|
||||
|
||||
state_ = kFound;
|
||||
if (LIKELY(pinnable_val_ != nullptr)) {
|
||||
pinnable_val_->PinSelf(value);
|
||||
if (LIKELY(value_pinner != nullptr)) {
|
||||
pinnable_val_->PinSlice(value, value_pinner);
|
||||
} else {
|
||||
pinnable_val_->PinSelf(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-2
@@ -141,8 +141,13 @@ class GetContext {
|
||||
Cleanable* value_pinner = nullptr);
|
||||
|
||||
// Simplified version of the previous function. Should only be used when we
|
||||
// know that the operation is a Put.
|
||||
void SaveValue(const Slice& value, SequenceNumber seq);
|
||||
// know that the operation is a Put and the column family has no
|
||||
// user-defined timestamps.
|
||||
//
|
||||
// value_pinner: if non-null, ownership of the underlying buffer is
|
||||
// transferred via PinSlice (no copy). If null, value is copied via PinSelf.
|
||||
void SaveValue(const Slice& value, SequenceNumber seq,
|
||||
Cleanable* value_pinner = nullptr);
|
||||
|
||||
GetState State() const { return state_; }
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "db/arena_wrapped_db_iter.h"
|
||||
#include "db/db_iter.h"
|
||||
#include "db/dbformat.h"
|
||||
#include "db/lookup_key.h"
|
||||
#include "file/random_access_file_reader.h"
|
||||
#include "options/cf_options.h"
|
||||
#include "rocksdb/env.h"
|
||||
@@ -198,9 +199,10 @@ Status SstFileReader::Get(const ReadOptions& roptions, const Slice& key,
|
||||
nullptr /* value_found */, &merge_context, true,
|
||||
&max_covering_tombstone_seq, r->ioptions.clock);
|
||||
|
||||
status = r->table_reader->Get(
|
||||
roptions, InternalKey(key, kMaxSequenceNumber, kTypeValue).Encode(),
|
||||
&get_ctx, r->moptions.prefix_extractor.get(), false /* skip_filters */);
|
||||
LookupKey lkey(key, kMaxSequenceNumber);
|
||||
status = r->table_reader->Get(roptions, lkey.internal_key(), &get_ctx,
|
||||
r->moptions.prefix_extractor.get(),
|
||||
false /* skip_filters */);
|
||||
|
||||
get_ctx.ReportCounters();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "rocksdb/utilities/options_type.h"
|
||||
#include "table/table_properties_internal.h"
|
||||
#include "table/unique_id_impl.h"
|
||||
#include "util/compression.h"
|
||||
#include "util/random.h"
|
||||
#include "util/string_util.h"
|
||||
|
||||
@@ -37,6 +38,40 @@ void AppendProperty(std::string& props, const std::string& key,
|
||||
const std::string& kv_delim) {
|
||||
AppendProperty(props, key, std::to_string(value), prop_delim, kv_delim);
|
||||
}
|
||||
|
||||
std::shared_ptr<CompressionManager> ResolveCompressionManagerForDisplay(
|
||||
Slice compatibility_name,
|
||||
const std::shared_ptr<CompressionManager>& compression_manager) {
|
||||
std::shared_ptr<CompressionManager> mgr_to_use;
|
||||
if (compression_manager) {
|
||||
mgr_to_use = compression_manager->FindCompatibleCompressionManager(
|
||||
compatibility_name);
|
||||
}
|
||||
if (mgr_to_use == nullptr) {
|
||||
ConfigOptions strict;
|
||||
strict.ignore_unknown_options = false;
|
||||
strict.ignore_unsupported_options = false;
|
||||
Status s = CompressionManager::CreateFromString(
|
||||
strict, compatibility_name.ToString(), &mgr_to_use);
|
||||
if (!s.ok()) {
|
||||
mgr_to_use.reset();
|
||||
}
|
||||
}
|
||||
return mgr_to_use;
|
||||
}
|
||||
|
||||
std::string CompressionTypeDisplayName(
|
||||
CompressionType compression_type,
|
||||
const std::shared_ptr<CompressionManager>& compression_manager) {
|
||||
if (compression_manager) {
|
||||
std::string name =
|
||||
compression_manager->CompressionTypeToString(compression_type);
|
||||
if (!name.empty()) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return CompressionTypeToString(compression_type);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string TableProperties::ToString(const std::string& prop_delim,
|
||||
@@ -615,4 +650,80 @@ void TEST_SetRandomTableProperties(TableProperties* props) {
|
||||
}
|
||||
#endif
|
||||
|
||||
std::string ParseCompressionNameForDisplay(
|
||||
const std::string& compression_name) {
|
||||
// The single-argument overload intentionally consults globally registered
|
||||
// CompressionManagers, keyed by the encoded compatibility name, so custom
|
||||
// managers can contribute display names without an explicit manager handle.
|
||||
return ParseCompressionNameForDisplay(compression_name, nullptr);
|
||||
}
|
||||
|
||||
std::string ParseCompressionNameForDisplay(
|
||||
const std::string& compression_name,
|
||||
std::shared_ptr<CompressionManager> compression_manager) {
|
||||
// Empty = no compression
|
||||
if (compression_name.empty()) {
|
||||
return "NoCompression";
|
||||
}
|
||||
|
||||
// Check for format_version 7 format (contains ';')
|
||||
size_t first_semicolon = compression_name.find(';');
|
||||
if (first_semicolon == std::string::npos) {
|
||||
// Old format - return as-is
|
||||
return compression_name;
|
||||
}
|
||||
|
||||
// New format: "<compatibility_name>;<hex_codes>;"
|
||||
size_t second_semicolon = compression_name.find(';', first_semicolon + 1);
|
||||
if (second_semicolon == std::string::npos) {
|
||||
// Malformed - missing second field
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
Slice compatibility_name(compression_name.data(), first_semicolon);
|
||||
auto mgr_to_use = ResolveCompressionManagerForDisplay(compatibility_name,
|
||||
compression_manager);
|
||||
|
||||
// Extract hex codes
|
||||
std::string hex_codes = compression_name.substr(
|
||||
first_semicolon + 1, second_semicolon - first_semicolon - 1);
|
||||
|
||||
// Validate hex string length (must be even)
|
||||
if (hex_codes.size() % 2 != 0) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// Parse each 2-char hex code to CompressionType.
|
||||
// Note: This intentionally mirrors GetDecompressor()'s decoding shape but
|
||||
// differs in error semantics. GetDecompressor() treats kNoCompression
|
||||
// (0x00) and values >= kDisableCompressionOption (0xFF) as corruption. For
|
||||
// display purposes, we silently filter these out and return "NoCompression"
|
||||
// if no valid types remain.
|
||||
std::vector<std::string> types;
|
||||
for (size_t i = 0; i < hex_codes.size(); i += 2) {
|
||||
const char* ptr = hex_codes.data() + i;
|
||||
uint64_t val = 0;
|
||||
if (!ParseBaseChars<16>(&ptr, 2, &val)) {
|
||||
return "Unknown";
|
||||
}
|
||||
auto ct = static_cast<CompressionType>(val);
|
||||
if (ct != kNoCompression && ct != kDisableCompressionOption) {
|
||||
types.push_back(CompressionTypeDisplayName(ct, mgr_to_use));
|
||||
}
|
||||
}
|
||||
|
||||
if (types.empty()) {
|
||||
return "NoCompression";
|
||||
} else if (types.size() == 1) {
|
||||
return types[0];
|
||||
} else {
|
||||
// Multiple types - join with commas
|
||||
std::string result = types[0];
|
||||
for (size_t i = 1; i < types.size(); ++i) {
|
||||
result += "," + types[i];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ROCKSDB_NAMESPACE
|
||||
+31
-4
@@ -7240,7 +7240,8 @@ TEST_F(ExternalTableTest, BasicTest) {
|
||||
ExternalTableBuilderOptions(ReadOptions(), WriteOptions(),
|
||||
std::shared_ptr<const SliceTransform>(),
|
||||
BytewiseComparator(), "default",
|
||||
TableFileCreationReason::kMisc),
|
||||
TableFileCreationReason::kMisc,
|
||||
/*fs=*/nullptr),
|
||||
file_path, /*file=*/nullptr));
|
||||
builder->Add("foo", "bar");
|
||||
ASSERT_OK(builder->Finish());
|
||||
@@ -7321,10 +7322,18 @@ TEST_F(ExternalTableTest, SstReaderTest) {
|
||||
ASSERT_FALSE(iter->Valid());
|
||||
ASSERT_TRUE(iter->status().ok());
|
||||
|
||||
// Verify external table Get() goes through the simple SaveValue entry point
|
||||
std::atomic<int> simple_save_value_count{0};
|
||||
SyncPoint::GetInstance()->SetCallBack(
|
||||
"GetContext::SaveValue::Simple",
|
||||
[&](void* /*arg*/) { simple_save_value_count.fetch_add(1); });
|
||||
SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
// Test MultiGet
|
||||
std::vector<Slice> keys = {"a", "b", "missing", "c"};
|
||||
std::vector<std::string> values;
|
||||
std::vector<Status> statuses = reader->MultiGet(ReadOptions(), keys, &values);
|
||||
ASSERT_EQ(simple_save_value_count, 3);
|
||||
ASSERT_EQ(values.size(), keys.size());
|
||||
ASSERT_EQ(statuses.size(), keys.size());
|
||||
ASSERT_OK(statuses[0]);
|
||||
@@ -7334,6 +7343,9 @@ TEST_F(ExternalTableTest, SstReaderTest) {
|
||||
ASSERT_TRUE(statuses[2].IsNotFound());
|
||||
ASSERT_OK(statuses[3]);
|
||||
ASSERT_EQ(values[3], "val_c");
|
||||
|
||||
SyncPoint::GetInstance()->DisableProcessing();
|
||||
SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
}
|
||||
|
||||
TEST_F(ExternalTableTest, PinnedGetTest) {
|
||||
@@ -7363,6 +7375,14 @@ TEST_F(ExternalTableTest, PinnedGetTest) {
|
||||
factory->last_reader()->SetPinnedData(
|
||||
{{"key1", "pinned_val1"}, {"key2", "pinned_val2"}});
|
||||
|
||||
// Verify external table Get() goes through the simple SaveValue entry point
|
||||
// (the no-ParsedInternalKey overload) rather than the complex one.
|
||||
std::atomic<int> simple_save_value_count{0};
|
||||
SyncPoint::GetInstance()->SetCallBack(
|
||||
"GetContext::SaveValue::Simple",
|
||||
[&](void* /*arg*/) { simple_save_value_count.fetch_add(1); });
|
||||
SyncPoint::GetInstance()->EnableProcessing();
|
||||
|
||||
PinnableSlice pinnable;
|
||||
ASSERT_OK(
|
||||
db_->Get(ReadOptions(), db_->DefaultColumnFamily(), "key1", &pinnable));
|
||||
@@ -7376,13 +7396,17 @@ TEST_F(ExternalTableTest, PinnedGetTest) {
|
||||
ASSERT_TRUE(pinnable.IsPinned());
|
||||
pinnable.Reset();
|
||||
|
||||
// Two found Gets => simple SaveValue invoked twice.
|
||||
ASSERT_EQ(simple_save_value_count.load(), 2);
|
||||
|
||||
// Verify cleanup ran for both Gets
|
||||
ASSERT_EQ(factory->last_reader()->pin_cleanup_count(), 2);
|
||||
|
||||
// Verify NotFound still works
|
||||
// Verify NotFound still works (does not invoke SaveValue)
|
||||
Status s =
|
||||
db_->Get(ReadOptions(), db_->DefaultColumnFamily(), "missing", &pinnable);
|
||||
ASSERT_TRUE(s.IsNotFound());
|
||||
ASSERT_EQ(simple_save_value_count.load(), 2);
|
||||
|
||||
// Test MultiGet with PinnableSlice to exercise the batched pin path
|
||||
const size_t num_keys = 3;
|
||||
@@ -7408,6 +7432,9 @@ TEST_F(ExternalTableTest, PinnedGetTest) {
|
||||
v.Reset();
|
||||
}
|
||||
ASSERT_EQ(factory->last_reader()->pin_cleanup_count(), 4);
|
||||
|
||||
SyncPoint::GetInstance()->DisableProcessing();
|
||||
SyncPoint::GetInstance()->ClearAllCallBacks();
|
||||
}
|
||||
|
||||
TEST_F(ExternalTableTest, SstReaderPinnableMultiGetTest) {
|
||||
@@ -8608,7 +8635,7 @@ TEST_P(UserDefinedIndexTest, ValueTypeMappingViaDBFlush) {
|
||||
if (is_reverse_comparator_) {
|
||||
// Skip for reverse comparator — the key ordering makes this test
|
||||
// unnecessarily complex and the mapping logic is comparator-independent.
|
||||
ROCKSDB_GTEST_SKIP("Skipped for reverse comparator");
|
||||
ROCKSDB_GTEST_BYPASS("Skipped for reverse comparator");
|
||||
return;
|
||||
}
|
||||
std::string dbname = test::PerThreadDBPath("udi_valuetype_mapping_test");
|
||||
@@ -8676,7 +8703,7 @@ TEST_P(UserDefinedIndexTest, CompactionWithSnapshotsAndUDI) {
|
||||
// Verify that compaction with snapshots (producing multiple versions of the
|
||||
// same user key) works correctly with UDI.
|
||||
if (is_reverse_comparator_) {
|
||||
ROCKSDB_GTEST_SKIP("Skipped for reverse comparator");
|
||||
ROCKSDB_GTEST_BYPASS("Skipped for reverse comparator");
|
||||
return;
|
||||
}
|
||||
std::string dbname = test::PerThreadDBPath("udi_compaction_snapshot_test");
|
||||
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user