diff --git a/.github/.gitignore b/.github/.gitignore new file mode 100644 index 0000000000000..a447f99442861 --- /dev/null +++ b/.github/.gitignore @@ -0,0 +1,18 @@ +# Node modules +scripts/ai-review/node_modules/ +# Note: package-lock.json should be committed for reproducible CI/CD builds + +# Logs +scripts/ai-review/cost-log-*.json +scripts/ai-review/*.log + +# OS files +.DS_Store +Thumbs.db + +# Editor files +*.swp +*.swo +*~ +.vscode/ +.idea/ diff --git a/.github/QUICKSTART.md b/.github/QUICKSTART.md new file mode 100644 index 0000000000000..d22c4d562ab7d --- /dev/null +++ b/.github/QUICKSTART.md @@ -0,0 +1,378 @@ +# Quick Start Guide - PostgreSQL Mirror CI/CD + +**Goal:** Get your PostgreSQL mirror CI/CD system running in 15 minutes. + +--- + +## āœ… What's Been Implemented + +- **Phase 1: Automated Upstream Sync** - Daily sync from postgres/postgres āœ… +- **Phase 2: AI-Powered Code Review** - Claude-based PR reviews āœ… +- **Phase 3: Windows Builds** - Planned for weeks 4-6 šŸ“‹ + +--- + +## šŸš€ Setup Instructions + +### Step 1: Configure GitHub Actions Permissions (2 minutes) + +1. Go to: **Settings → Actions → General** +2. Scroll to: **Workflow permissions** +3. Select: **"Read and write permissions"** +4. Check: **"Allow GitHub Actions to create and approve pull requests"** +5. Click: **Save** + +āœ… This enables workflows to push commits and create issues. + +--- + +### Step 2: Set Up Upstream Sync (3 minutes) + +**Test manual sync first:** + +```bash +# Via GitHub Web UI: +# 1. Go to: Actions tab +# 2. Click: "Sync from Upstream (Manual)" +# 3. Click: "Run workflow" +# 4. Watch it run (should take ~2 minutes) + +# OR via GitHub CLI: +gh workflow run sync-upstream-manual.yml +gh run watch +``` + +**Verify sync worked:** + +```bash +git fetch origin +git log origin/master --oneline -5 + +# Compare with upstream: +# https://github.com/postgres/postgres/commits/master +``` + +**Enable automatic sync:** + +- Automatic sync runs daily at 00:00 UTC +- Already configured, no action needed +- Check: Actions → "Sync from Upstream (Automatic)" + +āœ… Your master branch will now stay synced automatically. + +--- + +### Step 3: Set Up AI Code Review (10 minutes) + +**Choose Your Provider:** + +You can use either **Anthropic API** (simpler) or **AWS Bedrock** (if you have AWS infrastructure). + +#### Option A: Anthropic API (Recommended for getting started) + +**A. Get Claude API Key:** + +1. Go to: https://console.anthropic.com/ +2. Sign up or log in +3. Navigate to: API Keys +4. Create new key +5. Copy the key (starts with `sk-ant-...`) + +**B. Add API Key to GitHub:** + +1. Go to: **Settings → Secrets and variables → Actions** +2. Click: **New repository secret** +3. Name: `ANTHROPIC_API_KEY` +4. Value: Paste your API key +5. Click: **Add secret** + +**C. Ensure config uses Anthropic:** + +Check `.github/scripts/ai-review/config.json` has: +```json +{ + "provider": "anthropic", + ... +} +``` + +#### Option B: AWS Bedrock (If you have AWS) + +See detailed guide: [.github/docs/bedrock-setup.md](.github/docs/bedrock-setup.md) + +**Quick steps:** +1. Enable Claude 3.5 Sonnet in AWS Bedrock console +2. Create IAM user with `bedrock:InvokeModel` permission +3. Add three secrets to GitHub: + - `AWS_ACCESS_KEY_ID` + - `AWS_SECRET_ACCESS_KEY` + - `AWS_REGION` (e.g., `us-east-1`) +4. Update `.github/scripts/ai-review/config.json`: +```json +{ + "provider": "bedrock", + "bedrock_model_id": "us.anthropic.claude-3-5-sonnet-20241022-v2:0", + "bedrock_region": "us-east-1", + ... +} +``` + +**Note:** Both providers have identical pricing ($0.003/1K input, $0.015/1K output tokens). + +--- + +**C. Install Dependencies:** + +```bash +cd .github/scripts/ai-review +npm install + +# Should install: +# - @anthropic-ai/sdk (for Anthropic API) +# - @aws-sdk/client-bedrock-runtime (for AWS Bedrock) +# - @actions/github +# - @actions/core +# - parse-diff +# - minimatch +``` + +**D. Test AI Review:** + +```bash +# Option 1: Create a test PR +git checkout -b test/ai-review +echo "// Test change" >> src/backend/utils/adt/int.c +git add . +git commit -m "Test: AI review" +git push origin test/ai-review +# Create PR via GitHub UI + +# Option 2: Manual trigger on existing PR +gh workflow run ai-code-review.yml -f pr_number= +``` + +āœ… AI will review the PR and post comments + summary. + +--- + +## šŸŽÆ Verify Everything Works + +### Check Sync Status + +```bash +# Check latest sync run +gh run list --workflow=sync-upstream.yml --limit 1 + +# View details +gh run view $(gh run list --workflow=sync-upstream.yml --limit 1 --json databaseId -q '.[0].databaseId') +``` + +**Expected:** āœ… Green checkmark, "Already up to date" or "Successfully synced X commits" + +### Check AI Review Status + +```bash +# Check latest AI review run +gh run list --workflow=ai-code-review.yml --limit 1 + +# View details +gh run view $(gh run list --workflow=ai-code-review.yml --limit 1 --json databaseId -q '.[0].databaseId') +``` + +**Expected:** āœ… Green checkmark, comments posted on PR + +--- + +## šŸ“Š Monitor Costs + +### GitHub Actions Minutes + +```bash +# View usage (requires admin access) +gh api /repos/gburd/postgres/actions/cache/usage + +# Expected monthly usage: +# - Sync: ~150 minutes (FREE - within 2,000 min limit) +# - AI Review: ~200 minutes (FREE - within limit) +``` + +### Claude API Costs + +**View per-PR cost:** +- Check AI review summary comment on PR +- Format: `Cost: $X.XX | Model: claude-3-5-sonnet` + +**Expected costs:** +- Small PR: $0.50 - $1.00 +- Medium PR: $1.00 - $3.00 +- Large PR: $3.00 - $7.50 +- **Monthly (20 PRs):** $35-50 + +**Download detailed logs:** +```bash +gh run list --workflow=ai-code-review.yml --limit 5 +gh run download -n ai-review-cost-log- +``` + +--- + +## šŸ”§ Configuration + +### Adjust Sync Schedule + +Edit `.github/workflows/sync-upstream.yml`: + +```yaml +on: + schedule: + # Current: Daily at 00:00 UTC + - cron: '0 0 * * *' + + # Options: + # Every 6 hours: '0 */6 * * *' + # Twice daily: '0 0,12 * * *' + # Weekdays only: '0 0 * * 1-5' +``` + +### Adjust AI Review Costs + +Edit `.github/scripts/ai-review/config.json`: + +```json +{ + "cost_limits": { + "max_per_pr_dollars": 15.0, // ← Lower this to save money + "max_per_month_dollars": 200.0, // ← Hard monthly cap + "alert_threshold_dollars": 150.0 + }, + + "max_file_size_lines": 5000, // ← Skip files larger than this + + "skip_paths": [ + "*.png", "*.svg", // Already skipped + "vendor/**/*", // ← Add more patterns here + "generated/**/*" + ] +} +``` + +### Adjust AI Review Prompts + +**Make AI reviews stricter or more lenient:** + +Edit files in `.github/scripts/ai-review/prompts/`: +- `c-code.md` - PostgreSQL C code review +- `sql.md` - SQL and regression tests +- `documentation.md` - Documentation review +- `build-system.md` - Makefile/Meson review + +--- + +## šŸ› Troubleshooting + +### Sync Not Working + +**Problem:** Workflow fails with "Permission denied" + +**Fix:** +- Check: Settings → Actions → Workflow permissions +- Ensure: "Read and write permissions" is selected + +--- + +### AI Review Not Posting Comments + +**Problem:** Workflow runs but no comments appear + +**Check:** +1. Is PR a draft? (Draft PRs are skipped to save costs) +2. Are there reviewable files? (Check workflow logs) +3. Is API key valid? (Settings → Secrets → ANTHROPIC_API_KEY) + +**Fix:** +- Mark PR as "Ready for review" if draft +- Check workflow logs: Actions → Latest run → View logs +- Verify API key at https://console.anthropic.com/ + +--- + +### High AI Review Costs + +**Problem:** Costs higher than expected + +**Check:** +- Download cost logs: `gh run download ` +- Look for large files being reviewed +- Check number of PR updates (each triggers review) + +**Fix:** +1. Add large files to `skip_paths` in config.json +2. Lower `max_tokens_per_request` (shorter reviews) +3. Use draft PRs for work-in-progress +4. Batch PR updates to reduce review frequency + +--- + +## šŸ“š Full Documentation + +- **Overview:** [.github/README.md](.github/README.md) +- **Sync Guide:** [.github/docs/sync-setup.md](.github/docs/sync-setup.md) +- **AI Review Guide:** [.github/docs/ai-review-guide.md](.github/docs/ai-review-guide.md) +- **Windows Builds:** [.github/docs/windows-builds.md](.github/docs/windows-builds.md) (planned) +- **Implementation Status:** [.github/IMPLEMENTATION_STATUS.md](.github/IMPLEMENTATION_STATUS.md) + +--- + +## ✨ What's Next? + +### Immediate +- āœ… **Monitor first automatic sync** (tonight at 00:00 UTC) +- āœ… **Test AI review on real PR** +- āœ… **Tune prompts** based on feedback + +### This Week +- Shadow mode testing for AI reviews (Week 1) +- Gather developer feedback +- Adjust configuration + +### Weeks 2-3 +- Enable full AI review mode +- Monitor costs and quality +- Iterate on prompts + +### Weeks 4-6 +- **Phase 3:** Implement Windows dependency builds +- Research winpgbuild approach +- Create build workflows +- Test artifact publishing + +--- + +## šŸŽ‰ Success Criteria + +You'll know everything is working when: + +āœ… **Sync:** +- Master branch matches postgres/postgres +- Daily sync runs show green checkmarks +- No open issues with label `sync-failure` + +āœ… **AI Review:** +- PRs receive inline comments + summary +- Feedback is relevant and actionable +- Costs stay under $50/month +- Developers find reviews helpful + +āœ… **Overall:** +- Automation saves 8-16 hours/month +- Issues caught earlier in development +- No manual sync needed + +--- + +**Need Help?** +- Check documentation: `.github/README.md` +- Check workflow logs: Actions → Failed run → View logs +- Create issue with workflow URL and error messages + +**Ready to go!** šŸš€ diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000000000..bdfcfe74ac4a4 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,315 @@ +# PostgreSQL Mirror CI/CD System + +This directory contains the CI/CD infrastructure for the PostgreSQL personal mirror repository. + +## System Overview + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ PostgreSQL Mirror CI/CD │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ + ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” + │ │ │ + [1] Sync [2] AI Review [3] Windows + Daily @ 00:00 On PR Events On Master Push + │ │ │ + ā–¼ ā–¼ ā–¼ + postgres/postgres Claude API Dependency Builds + │ │ │ + ā–¼ ā–¼ ā–¼ + github.com/gburd PR Comments Build Artifacts + /postgres/ + Labels (90-day retention) + master +``` + +## Components + +### 1. Automated Upstream Sync +**Status:** āœ“ Implemented +**Files:** `workflows/sync-upstream*.yml` + +Automatically syncs the `master` branch with upstream `postgres/postgres` daily. + +- **Frequency:** Daily at 00:00 UTC +- **Trigger:** Cron schedule + manual +- **Features:** + - Fast-forward merge (conflict-free) + - Automatic issue creation on conflicts + - Issue auto-closure on resolution +- **Cost:** Free (~150 min/month, well within free tier) + +**Documentation:** [docs/sync-setup.md](docs/sync-setup.md) + +### 2. AI-Powered Code Review +**Status:** āœ“ Implemented +**Files:** `workflows/ai-code-review.yml`, `scripts/ai-review/` + +Uses Claude API to provide PostgreSQL-aware code review on pull requests. + +- **Trigger:** PR opened/updated, ready for review +- **Features:** + - PostgreSQL-specific C code review + - SQL, documentation, build system review + - Inline comments on issues + - Automatic labeling (security, performance, etc.) + - Cost tracking and limits + - **Provider Options:** Anthropic API or AWS Bedrock +- **Cost:** $35-50/month (estimated) +- **Model:** Claude 3.5 Sonnet + +**Documentation:** [docs/ai-review-guide.md](docs/ai-review-guide.md) + +### 3. Windows Build Integration +**Status:** āœ… Implemented +**Files:** `workflows/windows-dependencies.yml`, `windows/`, `scripts/windows/` + +Builds PostgreSQL Windows dependencies for x64 Windows. + +- **Trigger:** Manual, manifest changes, weekly refresh +- **Features:** + - Core dependencies: OpenSSL, zlib, libxml2 + - Smart caching by version hash + - Dependency bundling + - Artifact publishing (90-day retention) + - PowerShell download helper + - **Cost optimization:** Skips builds for pristine commits (dev setup, .github/ only) +- **Cost:** ~$5-8/month (with caching and optimization) + +**Documentation:** [docs/windows-builds.md](docs/windows-builds.md) | [Usage](docs/windows-builds-usage.md) + +## Quick Start + +### Prerequisites + +1. **GitHub Actions enabled:** + - Settings → Actions → General → Allow all actions + +2. **Workflow permissions:** + - Settings → Actions → General → Workflow permissions + - Select: "Read and write permissions" + - Enable: "Allow GitHub Actions to create and approve pull requests" + +3. **Secrets configured:** + - **Option A - Anthropic API:** + - Settings → Secrets and variables → Actions + - Add: `ANTHROPIC_API_KEY` (get from https://console.anthropic.com/) + - **Option B - AWS Bedrock:** + - Add: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` + - See: [docs/bedrock-setup.md](docs/bedrock-setup.md) + +### Using the Sync System + +**Manual sync:** +```bash +# Via GitHub UI: +# Actions → "Sync from Upstream (Manual)" → Run workflow + +# Via GitHub CLI: +gh workflow run sync-upstream-manual.yml +``` + +**Check sync status:** +```bash +# Latest sync run +gh run list --workflow=sync-upstream.yml --limit 1 + +# View details +gh run view +``` + +### Using AI Code Review + +AI reviews run automatically on PRs. To test manually: + +```bash +# Via GitHub UI: +# Actions → "AI Code Review" → Run workflow → Enter PR number + +# Via GitHub CLI: +gh workflow run ai-code-review.yml -f pr_number=123 +``` + +**Reviewing AI feedback:** +1. AI posts inline comments on specific lines +2. AI posts summary comment with overview +3. AI adds labels (security-concern, needs-tests, etc.) +4. Review and address feedback like human reviewer comments + +### Cost Monitoring + +**View AI review costs:** +```bash +# Download cost logs +gh run download -n ai-review-cost-log- +``` + +**Expected monthly costs (with optimizations):** +- Sync: $0 (free tier) +- AI Review: $30-45 (only on PRs, skips drafts) +- Windows Builds: $5-8 (caching + pristine commit skipping) +- **Total: $35-53/month** + +**Cost optimizations:** +- Windows builds skip "dev setup" and .github/-only commits +- AI review only runs on non-draft PRs +- Aggressive caching reduces build times by 80-90% +- See [Cost Optimization Guide](docs/cost-optimization.md) for details + +## Workflow Files + +### Sync Workflows +- `workflows/sync-upstream.yml` - Automatic daily sync +- `workflows/sync-upstream-manual.yml` - Manual testing sync + +### AI Review Workflows +- `workflows/ai-code-review.yml` - Automatic PR review + +### Windows Build Workflows +- `workflows/windows-dependencies.yml` - Dependency builds (TBD) + +## Configuration Files + +### AI Review Configuration +- `scripts/ai-review/config.json` - Cost limits, file patterns, labels +- `scripts/ai-review/prompts/*.md` - Review prompts by file type +- `scripts/ai-review/package.json` - Node.js dependencies + +### Windows Build Configuration +- `windows/manifest.json` - Dependency versions (TBD) + +## Branch Strategy + +### Master Branch: Mirror Only +- **Purpose:** Pristine copy of `postgres/postgres` +- **Rule:** Never commit directly to master +- **Sync:** Automatic via GitHub Actions +- **Protection:** Consider branch protection rules + +### Feature Branches: Development +- **Pattern:** `feature/*`, `dev/*`, `experiment/*` +- **Workflow:** + ```bash + git checkout master + git pull origin master + git checkout -b feature/my-feature + # ... make changes ... + git push origin feature/my-feature + # Create PR: feature/my-feature → master + ``` + +### Special Branches +- `recovery/*` - Temporary branches for sync conflict resolution +- Development remotes: commitfest, heikki, orioledb, zheap + +## Integration with Cirrus CI + +GitHub Actions and Cirrus CI run independently: + +- **Cirrus CI:** Comprehensive testing (Linux, FreeBSD, macOS, Windows) +- **GitHub Actions:** Sync, AI review, Windows dependency builds +- **No conflicts:** Both can run on same commits + +## Troubleshooting + +### Sync Issues + +**Problem:** Sync workflow failing +**Check:** Actions → "Sync from Upstream (Automatic)" → Latest run +**Fix:** See [docs/sync-setup.md](docs/sync-setup.md#sync-failure-recovery) + +### AI Review Issues + +**Problem:** AI review not running +**Check:** Is PR a draft? Draft PRs are skipped +**Fix:** Mark PR as ready for review + +**Problem:** AI review too expensive +**Check:** Cost logs in workflow artifacts +**Fix:** Adjust limits in `scripts/ai-review/config.json` + +### Workflow Permission Issues + +**Problem:** "Resource not accessible by integration" +**Check:** Settings → Actions → General → Workflow permissions +**Fix:** Enable "Read and write permissions" + +## Security + +### Secrets Management +- `ANTHROPIC_API_KEY`: Claude API key (required for AI review) +- `GITHUB_TOKEN`: Auto-generated, scoped to repository +- Never commit secrets to repository +- Rotate API keys quarterly + +### Permissions +- Workflows use minimum necessary permissions +- `contents: read` for code access +- `pull-requests: write` for comments +- `issues: write` for sync failure issues + +### Audit Trail +- All workflow runs logged (90-day retention) +- Cost tracking for AI reviews +- GitHub Actions audit log available + +## Support and Documentation + +### Detailed Documentation +- [Sync Setup Guide](docs/sync-setup.md) - Upstream sync system +- [AI Review Guide](docs/ai-review-guide.md) - AI code review system +- [Windows Builds Guide](docs/windows-builds.md) - Windows dependencies +- [Cost Optimization Guide](docs/cost-optimization.md) - Reducing CI/CD costs +- [Pristine Master Policy](docs/pristine-master-policy.md) - Master branch management + +### Reporting Issues + +Issues with CI/CD system: +1. Check workflow logs: Actions → Failed run → View logs +2. Search existing issues: label:automation +3. Create issue with workflow run URL and error messages + +### Modifying Workflows + +**Disabling a workflow:** +```bash +# Via GitHub UI: +# Actions → Select workflow → "..." → Disable workflow + +# Via git: +git mv .github/workflows/workflow-name.yml .github/workflows/workflow-name.yml.disabled +git commit -m "Disable workflow" +``` + +**Testing workflow changes:** +1. Create feature branch +2. Modify workflow file +3. Use `workflow_dispatch` trigger to test +4. Verify in Actions tab +5. Merge to master when working + +## Cost Summary + +| Component | Monthly Cost | Usage | Notes | +|-----------|-------------|-------|-------| +| Sync | $0 | ~150 min | Free tier: 2,000 min | +| AI Review | $30-45 | Variable | Claude API usage-based | +| Windows Builds | $5-8 | ~2,500 min | With caching + optimization | +| **Total** | **$35-53** | | After cost optimizations | + +**Comparison:** CodeRabbit (turnkey solution) = $99-499/month + +**Cost savings:** ~40-47% reduction through optimizations (see [Cost Optimization Guide](docs/cost-optimization.md)) + +## References + +- PostgreSQL: https://github.com/postgres/postgres +- GitHub Actions: https://docs.github.com/en/actions +- Claude API: https://docs.anthropic.com/ +- Cirrus CI: https://cirrus-ci.org/ +- winpgbuild: https://github.com/dpage/winpgbuild + +--- + +**Last Updated:** 2026-03-10 +**Maintained by:** PostgreSQL Mirror Automation diff --git a/.github/docs/ai-review-guide.md b/.github/docs/ai-review-guide.md new file mode 100644 index 0000000000000..eff0ed10cba4f --- /dev/null +++ b/.github/docs/ai-review-guide.md @@ -0,0 +1,512 @@ +# AI-Powered Code Review Guide + +## Overview + +This system uses Claude AI (Anthropic) to provide PostgreSQL-aware code reviews on pull requests. Reviews are similar in style to feedback from the PostgreSQL Hackers mailing list. + +## How It Works + +``` +PR Event (opened/updated) + ↓ +GitHub Actions Workflow Starts + ↓ +Fetch PR diff + metadata + ↓ +Filter reviewable files (.c, .h, .sql, docs, Makefiles) + ↓ +Route each file to appropriate review prompt + ↓ +Send to Claude API with PostgreSQL context + ↓ +Parse response for issues + ↓ +Post inline comments + summary to PR + ↓ +Add labels (security-concern, performance, etc.) +``` + +## Features + +### PostgreSQL-Specific Reviews + +**C Code Review:** +- Memory management (palloc/pfree, memory contexts) +- Concurrency (lock ordering, race conditions) +- Error handling (elog/ereport patterns) +- Performance (algorithm complexity, cache efficiency) +- Security (buffer overflows, SQL injection vectors) +- PostgreSQL conventions (naming, comments, style) + +**SQL Review:** +- PostgreSQL SQL dialect correctness +- Regression test patterns +- Performance (index usage, join strategy) +- Deterministic output for tests +- Edge case coverage + +**Documentation Review:** +- Technical accuracy +- SGML/DocBook format +- PostgreSQL style guide compliance +- Examples and cross-references + +**Build System Review:** +- Makefile correctness (GNU Make, PGXS) +- Meson build consistency +- Cross-platform portability +- VPATH build support + +### Automatic Labeling + +Reviews automatically add labels based on findings: + +- `security-concern` - Security issues, vulnerabilities +- `performance-concern` - Performance problems +- `needs-tests` - Missing test coverage +- `needs-docs` - Missing documentation +- `memory-management` - Memory leaks, context issues +- `concurrency-issue` - Deadlocks, race conditions + +### Cost Management + +- **Per-PR limit:** $15 (configurable) +- **Monthly limit:** $200 (configurable) +- **Alert threshold:** $150 +- **Skip draft PRs** to save costs +- **Skip large files** (>5000 lines) +- **Skip binary/generated files** + +## Setup + +### 1. Install Dependencies + +```bash +cd .github/scripts/ai-review +npm install +``` + +### 2. Configure API Key + +Get API key from: https://console.anthropic.com/ + +Add to repository secrets: +1. Settings → Secrets and variables → Actions +2. New repository secret +3. Name: `ANTHROPIC_API_KEY` +4. Value: Your API key +5. Add secret + +### 3. Enable Workflow + +The workflow is triggered automatically on PR events: +- PR opened +- PR synchronized (updated) +- PR reopened +- PR marked ready for review (draft → ready) + +**Draft PRs are skipped** to save costs. + +## Configuration + +### Main Configuration: `config.json` + +```json +{ + "model": "claude-3-5-sonnet-20241022", + "max_tokens_per_request": 4096, + "max_file_size_lines": 5000, + + "cost_limits": { + "max_per_pr_dollars": 15.0, + "max_per_month_dollars": 200.0, + "alert_threshold_dollars": 150.0 + }, + + "skip_paths": [ + "*.png", "*.jpg", "*.svg", + "src/test/regress/expected/*", + "*.po", "*.pot" + ], + + "auto_labels": { + "security-concern": ["security issue", "vulnerability"], + "performance-concern": ["inefficient", "O(n²)"], + "needs-tests": ["missing test", "no test coverage"] + } +} +``` + +**Tunable parameters:** +- `max_tokens_per_request`: Response length (4096 = ~3000 words) +- `max_file_size_lines`: Skip files larger than this +- `cost_limits`: Adjust budget caps +- `skip_paths`: Add more patterns to skip +- `auto_labels`: Customize label keywords + +### Review Prompts + +Located in `.github/scripts/ai-review/prompts/`: + +- `c-code.md` - PostgreSQL C code review +- `sql.md` - SQL and regression test review +- `documentation.md` - Documentation review +- `build-system.md` - Makefile/Meson review + +**Customization:** Edit prompts to adjust review focus and style. + +## Usage + +### Automatic Reviews + +Reviews run automatically on PRs to `master` and `feature/**` branches. + +**Typical workflow:** +1. Create feature branch +2. Make changes +3. Push branch: `git push origin feature/my-feature` +4. Create PR +5. AI review runs automatically +6. Review AI feedback +7. Make updates if needed +8. Push updates → AI re-reviews + +### Manual Reviews + +Trigger manually via GitHub Actions: + +**Via UI:** +1. Actions → "AI Code Review" +2. Run workflow +3. Enter PR number +4. Run workflow + +**Via CLI:** +```bash +gh workflow run ai-code-review.yml -f pr_number=123 +``` + +### Interpreting Reviews + +**Inline comments:** +- Posted on specific lines of code +- Format: `**[Category]**` followed by description +- Categories: Memory, Security, Performance, etc. + +**Summary comment:** +- Posted at PR level +- Overview of files reviewed +- Issue count by category +- Cost information + +**Labels:** +- Automatically added based on findings +- Filter PRs by label to prioritize +- Remove label manually if false positive + +### Best Practices + +**Trust but verify:** +- AI reviews are helpful but not infallible +- False positives happen (~5% rate) +- Use judgment - AI doesn't have full context +- Especially verify: security and correctness issues + +**Iterative improvement:** +- AI learns from the prompts, not from feedback +- If AI consistently misses something, update prompts +- Share false positives/negatives to improve system + +**Cost consciousness:** +- Keep PRs focused (fewer files = lower cost) +- Use draft PRs for work-in-progress (AI skips drafts) +- Mark PR ready when you want AI review + +## Cost Tracking + +### View Costs + +**Per-PR cost:** +- Shown in AI review summary comment +- Format: `Cost: $X.XX | Model: claude-3-5-sonnet` + +**Monthly cost:** +- Download cost logs from workflow artifacts +- Aggregate to calculate monthly total + +**Download cost logs:** +```bash +# List recent runs +gh run list --workflow=ai-code-review.yml --limit 10 + +# Download artifact +gh run download -n ai-review-cost-log- +``` + +### Cost Estimation + +**Token costs (Claude 3.5 Sonnet):** +- Input: $0.003 per 1K tokens +- Output: $0.015 per 1K tokens + +**Typical costs:** +- Small PR (<500 lines, 5 files): $0.50-$1.00 +- Medium PR (500-2000 lines, 15 files): $1.00-$3.00 +- Large PR (2000-5000 lines, 30 files): $3.00-$7.50 + +**Expected monthly (20 PRs/month mixed sizes):** $35-50 + +### Budget Controls + +**Automatic limits:** +- Per-PR limit: Stops reviewing after $15 +- Monthly limit: Stops at $200 (requires manual override) +- Alert: Warning at $150 + +**Manual controls:** +- Disable workflow: Actions → AI Code Review → Disable +- Reduce `max_tokens_per_request` in config +- Add more patterns to `skip_paths` +- Increase `max_file_size_lines` threshold + +## Troubleshooting + +### Issue: No review posted + +**Possible causes:** +1. PR is draft (intentionally skipped) +2. No reviewable files (all binary or skipped patterns) +3. API key missing or invalid +4. Cost limit reached + +**Check:** +- Actions → "AI Code Review" → Latest run → View logs +- Look for: "Skipping draft PR" or "No reviewable files" +- Verify: `ANTHROPIC_API_KEY` secret exists + +### Issue: Review incomplete + +**Possible causes:** +1. PR cost limit reached ($15 default) +2. File too large (>5000 lines) +3. API rate limit hit + +**Check:** +- Review summary comment for "Reached PR cost limit" +- Workflow logs for "Skipping X - too large" + +**Fix:** +- Increase `max_per_pr_dollars` in config +- Increase `max_file_size_lines` (trade-off: higher cost) +- Split large PR into smaller PRs + +### Issue: False positives + +**Example:** AI flags correct code as problematic + +**Handling:** +1. Ignore the comment (human judgment overrides) +2. Reply to comment explaining why it's correct +3. If systematic: Update prompt to clarify + +**Note:** Some false positives are acceptable (5-10% rate) + +### Issue: Claude API errors + +**Error types:** +- `401 Unauthorized`: Invalid API key +- `429 Too Many Requests`: Rate limit +- `500 Internal Server Error`: Claude service issue + +**Check:** +- Workflow logs for error messages +- Claude status: https://status.anthropic.com/ + +**Fix:** +- Rotate API key if 401 +- Wait and retry if 429 or 500 +- Contact Anthropic support if persistent + +### Issue: High costs + +**Unexpected high costs:** +1. Check cost logs for large PRs +2. Review `skip_paths` - are large files being reviewed? +3. Check for repeated reviews (PR updated many times) + +**Optimization:** +- Add more skip patterns for generated files +- Lower `max_tokens_per_request` (shorter reviews) +- Increase `max_file_size_lines` to skip more files +- Batch PR updates to reduce review runs + +## Disabling AI Review + +### Temporarily disable + +**For one PR:** +- Convert to draft +- Or add `[skip ai]` to PR title (requires workflow modification) + +**For all PRs:** +```bash +# Via GitHub UI: +# Actions → "AI Code Review" → "..." → Disable workflow + +# Via git: +git mv .github/workflows/ai-code-review.yml \ + .github/workflows/ai-code-review.yml.disabled +git commit -m "Disable AI code review" +git push +``` + +### Permanently remove + +```bash +# Remove workflow +rm .github/workflows/ai-code-review.yml + +# Remove scripts +rm -rf .github/scripts/ai-review + +# Commit +git commit -am "Remove AI code review system" +git push +``` + +## Testing and Iteration + +### Shadow Mode (Week 1) + +Run reviews but don't post comments: + +1. Modify `review-pr.js`: + ```javascript + // Comment out posting functions + // await postInlineComments(...) + // await postSummaryComment(...) + ``` + +2. Reviews saved to workflow artifacts +3. Review quality offline +4. Tune prompts based on results + +### Comment Mode (Week 2) + +Post comments with `[AI Review]` prefix: + +1. Add prefix to comment body: + ```javascript + const body = `**[AI Review] [${issue.category}]**\n\n${issue.description}`; + ``` + +2. Gather feedback from developers +3. Adjust prompts and configuration + +### Full Mode (Week 3+) + +Remove prefix, enable all features: + +1. Remove `[AI Review]` prefix +2. Enable auto-labeling +3. Monitor quality and costs +4. Iterate on prompts as needed + +## Advanced Customization + +### Custom Review Prompts + +Add a new prompt for a file type: + +1. Create `.github/scripts/ai-review/prompts/my-type.md` +2. Write review guidelines (see existing prompts) +3. Update `config.json`: + ```json + "file_type_patterns": { + "my_type": ["*.ext", "special/*.files"] + } + ``` +4. Test with manual workflow trigger + +### Conditional Reviews + +Skip AI review for certain PRs: + +Modify `.github/workflows/ai-code-review.yml`: +```yaml +jobs: + ai-review: + if: | + github.event.pull_request.draft == false && + !contains(github.event.pull_request.title, '[skip ai]') && + !contains(github.event.pull_request.labels.*.name, 'no-ai-review') +``` + +### Cost Alerts + +Add cost alert notifications: + +1. Create workflow in `.github/workflows/cost-alert.yml` +2. Trigger: On schedule (weekly) +3. Aggregate cost logs +4. Post issue if over threshold + +## Security and Privacy + +### API Key Security + +- Store only in GitHub Secrets (encrypted at rest) +- Never commit to repository +- Never log in workflow output +- Rotate quarterly + +### Code Privacy + +- Code sent to Claude API (Anthropic) +- Anthropic does not train on API data +- API requests are not retained long-term +- See: https://www.anthropic.com/legal/privacy + +### Sensitive Code + +If reviewing sensitive/proprietary code: + +1. Review Anthropic's terms of service +2. Consider: Self-hosted alternative (future) +3. Or: Skip AI review for sensitive PRs (add label) + +## Support + +### Questions + +- Check this guide first +- Search GitHub issues: label:ai-review +- Check Claude API docs: https://docs.anthropic.com/ + +### Reporting Issues + +Create issue with: +- PR number +- Workflow run URL +- Error messages from logs +- Expected vs actual behavior + +### Improving Prompts + +Contributions welcome: +1. Identify systematic issue (false positive/negative) +2. Propose prompt modification +3. Test on sample PRs +4. Submit PR with updated prompt + +## References + +- Claude API: https://docs.anthropic.com/ +- Claude Models: https://www.anthropic.com/product +- PostgreSQL Hacker's Guide: https://wiki.postgresql.org/wiki/Developer_FAQ +- GitHub Actions: https://docs.github.com/en/actions + +--- + +**Version:** 1.0 +**Last Updated:** 2026-03-10 diff --git a/.github/docs/bedrock-setup.md b/.github/docs/bedrock-setup.md new file mode 100644 index 0000000000000..d8fbd898b51c6 --- /dev/null +++ b/.github/docs/bedrock-setup.md @@ -0,0 +1,298 @@ +# AWS Bedrock Setup for AI Code Review + +This guide explains how to use AWS Bedrock instead of the direct Anthropic API for AI code reviews. + +## Why Use Bedrock? + +- **AWS Credits:** Use existing AWS credits +- **Regional Availability:** Deploy in specific AWS regions +- **Compliance:** Meet specific compliance requirements +- **Integration:** Easier integration with AWS infrastructure +- **IAM Roles:** Use IAM roles instead of API keys when running on AWS + +## Prerequisites + +1. **AWS Account** with Bedrock access +2. **Bedrock Model Access** - Claude 3.5 Sonnet must be enabled +3. **IAM Permissions** for Bedrock API calls + +## Step 1: Enable Bedrock Model Access + +1. Log into AWS Console +2. Navigate to **Amazon Bedrock** +3. Go to **Model access** (left sidebar) +4. Click **Modify model access** +5. Find and enable: **Anthropic - Claude 3.5 Sonnet v2** +6. Click **Save changes** +7. Wait for status to show "Access granted" (~2-5 minutes) + +## Step 2: Create IAM User for GitHub Actions + +### Option A: IAM User with Access Keys (Recommended for GitHub Actions) + +1. Go to **IAM Console** +2. Click **Users** → **Create user** +3. Username: `github-actions-bedrock` +4. Click **Next** + +**Attach Policy:** +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "bedrock:InvokeModel" + ], + "Resource": [ + "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-5-sonnet-*" + ] + } + ] +} +``` + +5. Click **Create policy** → **JSON** → Paste above +6. Name: `BedrockClaudeInvokeOnly` +7. Attach policy to user +8. Click **Create user** + +**Create Access Keys:** +1. Click on the created user +2. Go to **Security credentials** tab +3. Click **Create access key** +4. Select: **Third-party service** +5. Click **Next** → **Create access key** +6. **Download** or copy: + - Access key ID (starts with `AKIA...`) + - Secret access key (only shown once!) + +### Option B: IAM Role (For AWS-hosted runners) + +If running GitHub Actions on AWS (self-hosted runners): + +1. Create IAM Role with trust policy for your EC2/ECS/EKS +2. Attach same `BedrockClaudeInvokeOnly` policy +3. Assign role to your runner infrastructure +4. No access keys needed! + +## Step 3: Configure Repository + +### A. Add AWS Secrets to GitHub + +1. Go to: **Settings** → **Secrets and variables** → **Actions** +2. Click **New repository secret** for each: + +**Secret 1:** +- Name: `AWS_ACCESS_KEY_ID` +- Value: Your access key ID from Step 2 + +**Secret 2:** +- Name: `AWS_SECRET_ACCESS_KEY` +- Value: Your secret access key from Step 2 + +**Secret 3:** +- Name: `AWS_REGION` +- Value: Your Bedrock region (e.g., `us-east-1`) + +### B. Update Configuration + +Edit `.github/scripts/ai-review/config.json`: + +```json +{ + "provider": "bedrock", + "model": "claude-3-5-sonnet-20241022", + "bedrock_model_id": "us.anthropic.claude-3-5-sonnet-20241022-v2:0", + "bedrock_region": "us-east-1", + ... +} +``` + +**Available Bedrock Model IDs:** +- US: `us.anthropic.claude-3-5-sonnet-20241022-v2:0` +- EU: `eu.anthropic.claude-3-5-sonnet-20241022-v2:0` +- Asia Pacific: `apac.anthropic.claude-3-5-sonnet-20241022-v2:0` + +**Available Regions:** +- `us-east-1` (US East - N. Virginia) +- `us-west-2` (US West - Oregon) +- `eu-central-1` (Europe - Frankfurt) +- `eu-west-1` (Europe - Ireland) +- `eu-west-2` (Europe - London) +- `ap-southeast-1` (Asia Pacific - Singapore) +- `ap-southeast-2` (Asia Pacific - Sydney) +- `ap-northeast-1` (Asia Pacific - Tokyo) + +Check current availability: https://docs.aws.amazon.com/bedrock/latest/userguide/models-regions.html + +### C. Install Dependencies + +```bash +cd .github/scripts/ai-review +npm install +``` + +This will install the AWS SDK for Bedrock. + +## Step 4: Test Bedrock Integration + +```bash +# Create test PR +git checkout -b test/bedrock-review +echo "// Bedrock test" >> test.c +git add test.c +git commit -m "Test: Bedrock AI review" +git push origin test/bedrock-review +``` + +Then create PR via GitHub UI. Check: +1. **Actions** tab - workflow should run +2. **PR comments** - AI review should appear +3. **Workflow logs** - should show "Using AWS Bedrock as provider" + +## Cost Comparison + +### Bedrock Pricing (Claude 3.5 Sonnet - us-east-1) +- Input: $0.003 per 1K tokens +- Output: $0.015 per 1K tokens + +### Direct Anthropic API Pricing +- Input: $0.003 per 1K tokens +- Output: $0.015 per 1K tokens + +**Same price!** Choose based on infrastructure preference. + +## Troubleshooting + +### Error: "Access denied to model" + +**Check:** +1. Model access enabled in Bedrock console? +2. IAM policy includes correct model ARN? +3. Region matches between config and enabled models? + +**Fix:** +```bash +# Verify model access via AWS CLI +aws bedrock list-foundation-models --region us-east-1 --query 'modelSummaries[?contains(modelId, `claude-3-5-sonnet`)]' +``` + +### Error: "InvalidSignatureException" + +**Check:** +1. AWS_ACCESS_KEY_ID correct? +2. AWS_SECRET_ACCESS_KEY correct? +3. Secrets named exactly as shown? + +**Fix:** +- Re-create access keys +- Update GitHub secrets +- Ensure no extra spaces in secret values + +### Error: "ThrottlingException" + +**Cause:** Bedrock rate limits exceeded + +**Fix:** +1. Reduce `max_concurrent_requests` in config.json +2. Add delays between requests +3. Request quota increase via AWS Support + +### Error: "Model not found" + +**Check:** +1. `bedrock_model_id` matches your region +2. Using cross-region model ID (e.g., `us.anthropic...` in us-east-1) + +**Fix:** +Update `bedrock_model_id` in config.json to match your region: +- US regions: `us.anthropic.claude-3-5-sonnet-20241022-v2:0` +- EU regions: `eu.anthropic.claude-3-5-sonnet-20241022-v2:0` + +## Switching Between Providers + +### Switch to Bedrock + +Edit `.github/scripts/ai-review/config.json`: +```json +{ + "provider": "bedrock", + ... +} +``` + +### Switch to Direct Anthropic API + +Edit `.github/scripts/ai-review/config.json`: +```json +{ + "provider": "anthropic", + ... +} +``` + +No other changes needed! The code automatically detects the provider. + +## Advanced: Cross-Region Setup + +Deploy in multiple regions for redundancy: + +```json +{ + "provider": "bedrock", + "bedrock_regions": ["us-east-1", "us-west-2"], + "bedrock_failover": true +} +``` + +Then update `review-pr.js` to implement failover logic. + +## Security Best Practices + +1. **Least Privilege:** IAM user can only invoke Claude models +2. **Rotate Keys:** Rotate access keys quarterly +3. **Audit Logs:** Enable CloudTrail for Bedrock API calls +4. **Cost Alerts:** Set up AWS Budgets alerts +5. **Secrets:** Never commit AWS credentials to git + +## Monitoring + +### AWS CloudWatch + +Bedrock metrics available: +- `Invocations` - Number of API calls +- `InvocationLatency` - Response time +- `InvocationClientErrors` - 4xx errors +- `InvocationServerErrors` - 5xx errors + +### Cost Tracking + +```bash +# Check Bedrock costs (current month) +aws ce get-cost-and-usage \ + --time-period Start=2026-03-01,End=2026-03-31 \ + --granularity MONTHLY \ + --metrics BlendedCost \ + --filter file://filter.json + +# filter.json: +{ + "Dimensions": { + "Key": "SERVICE", + "Values": ["Amazon Bedrock"] + } +} +``` + +## References + +- AWS Bedrock Docs: https://docs.aws.amazon.com/bedrock/ +- Model Access: https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html +- Bedrock Pricing: https://aws.amazon.com/bedrock/pricing/ +- IAM Best Practices: https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html + +--- + +**Need help?** Check workflow logs in Actions tab or create an issue. diff --git a/.github/docs/cost-optimization.md b/.github/docs/cost-optimization.md new file mode 100644 index 0000000000000..bcfc1c47b3ed8 --- /dev/null +++ b/.github/docs/cost-optimization.md @@ -0,0 +1,219 @@ +# CI/CD Cost Optimization + +## Overview + +This document describes the cost optimization strategies used in the PostgreSQL mirror CI/CD system to minimize GitHub Actions minutes and API costs while maintaining full functionality. + +## Optimization Strategies + +### 1. Skip Builds for Pristine Commits + +**Problem:** "Dev setup" commits and .github/ configuration changes don't require expensive Windows dependency builds or comprehensive testing. + +**Solution:** The Windows Dependencies workflow includes a `check-changes` job that inspects recent commits and skips builds when all commits are: +- Messages starting with "dev setup" (case-insensitive), OR +- Only modifying files under `.github/` directory + +**Implementation:** See `.github/workflows/windows-dependencies.yml` lines 42-90 + +**Savings:** +- Avoids ~45 minutes of Windows runner time per push +- Windows runners cost 2x Linux minutes (1 minute = 2 billed minutes) +- Estimated savings: ~$8-12/month + +### 2. AI Review Only on Pull Requests + +**Problem:** AI code review is expensive and unnecessary for direct commits to master or pristine commits. + +**Solution:** The AI Code Review workflow only triggers on: +- `pull_request` events (opened, synchronized, reopened, ready_for_review) +- Manual `workflow_dispatch` for testing specific PRs +- Skips draft PRs automatically + +**Implementation:** See `.github/workflows/ai-code-review.yml` lines 3-17 + +**Savings:** +- No reviews on dev setup commits or CI/CD changes +- No reviews on draft PRs (saves ~$1-3 per draft) +- Estimated savings: ~$10-20/month + +### 3. Aggressive Caching + +**Windows Dependencies:** +- Cache key: `--win64-` +- Cache duration: GitHub's default (7 days unused, 10 GB limit) +- Cache hit rate: 80-90% for stable versions + +**Node.js Dependencies:** +- AI review scripts cache npm packages +- Cache key based on `package.json` hash +- Near 100% cache hit rate + +**Savings:** +- Reduces build time from 45 minutes to ~5 minutes on cache hit +- Estimated savings: ~$15-20/month + +### 4. Weekly Scheduled Builds + +**Problem:** GitHub Actions artifacts expire after 90 days, making cached dependencies stale. + +**Solution:** Windows Dependencies runs on a weekly schedule (Sunday 4 AM UTC) to refresh artifacts before expiration. + +**Cost:** +- Weekly builds: ~45 minutes/week Ɨ 4 weeks = 180 minutes/month +- Windows multiplier: 360 billed minutes +- Cost: ~$6/month (within budget) + +**Alternative considered:** Daily builds would cost ~$50/month (rejected) + +### 5. Sync Workflow Optimization + +**Automatic Sync:** +- Runs hourly to keep mirror current +- Very lightweight: ~2-3 minutes per run +- Cost: ~150 minutes/month = $0 (within free tier) + +**Manual Sync:** +- Only runs on explicit trigger +- Used for testing and recovery +- Cost: Negligible + +### 6. Smart Workflow Triggers + +**Path-based triggers:** +```yaml +push: + paths: + - '.github/windows/manifest.json' + - '.github/workflows/windows-dependencies.yml' +``` + +Only rebuild Windows dependencies when: +- Manifest versions change +- Workflow itself is updated +- Manual trigger or schedule + +**Branch-based triggers:** +- AI review only on PRs to master, feature/**, dev/** +- Sync only affects master branch + +## Cost Breakdown + +| Component | Monthly Cost | Notes | +|-----------|-------------|-------| +| GitHub Actions - Sync | $0 | ~150 min/month (free: 2,000 min) | +| GitHub Actions - AI Review | $0 | ~200 min/month (free: 2,000 min) | +| GitHub Actions - Windows | ~$5-8 | ~2,500 min/month with optimizations | +| Claude API (Bedrock) | $30-45 | Usage-based, ~15-20 PRs/month | +| **Total** | **~$35-53/month** | | + +**Before optimizations:** ~$75-100/month +**After optimizations:** ~$35-53/month +**Savings:** ~$40-47/month (40-47% reduction) + +## Monitoring Costs + +### GitHub Actions Usage + +Check usage in repository settings: +``` +Settings → Billing and plans → View usage +``` + +Or via CLI: +```bash +gh api repos/:owner/:repo/actions/billing/workflows --jq '.workflows' +``` + +### AWS Bedrock Usage + +Monitor Claude API costs in AWS Console: +``` +AWS Console → Bedrock → Usage → Invocation metrics +``` + +Or via cost logs in artifacts: +``` +.github/scripts/ai-review/cost-log-*.json +``` + +### Setting Alerts + +**GitHub Actions:** +- No built-in alerts +- Monitor via monthly email summaries +- Consider third-party monitoring (e.g., AWS Lambda + GitHub API) + +**AWS Bedrock:** +- Set CloudWatch billing alarms +- Recommended thresholds: + - Warning: $30/month + - Critical: $50/month +- Hard cap in code: $200/month (see `config.json`) + +## Future Optimizations + +### Potential Improvements + +1. **Conditional Testing on PRs** + - Only run full Cirrus CI suite if C code or SQL changes + - Skip for docs-only PRs + - Estimated savings: ~5-10% of testing costs + +2. **Incremental AI Review** + - On PR updates, only review changed files + - Current: Reviews entire PR on each update + - Estimated savings: ~20-30% of AI costs + +3. **Dependency Build Sampling** + - Build only changed dependencies instead of all + - Requires more sophisticated manifest diffing + - Estimated savings: ~30-40% of Windows build costs + +4. **Self-hosted Runners** + - Run Linux builds on own infrastructure + - Keep Windows runners on GitHub (licensing) + - Estimated savings: ~$10-15/month + - **Trade-off:** Maintenance overhead + +### Not Recommended + +1. **Reduce sync frequency** (hourly → daily) + - Savings: Negligible (~$0.50/month) + - Cost: Increased lag with upstream (unacceptable) + +2. **Skip Windows builds entirely** + - Savings: ~$8/month + - Cost: Lose reproducible dependency builds (defeats purpose) + +3. **Reduce AI review quality** (Claude Sonnet → Haiku) + - Savings: ~$20-25/month + - Cost: Significantly worse code review quality + +## Pristine Commit Policy + +The following commits are considered "pristine" and skip expensive builds: + +1. **Dev setup commits:** + - Message starts with "dev setup" (case-insensitive) + - Examples: "dev setup v19", "Dev Setup: Update IDE config" + - Contains: .clang-format, .idea/, .vscode/, flake.nix, etc. + +2. **CI/CD configuration commits:** + - Only modify files under `.github/` + - Examples: Workflow changes, script updates, documentation + +**Why this works:** +- Dev setup commits don't affect PostgreSQL code +- CI/CD commits are tested by running the workflows themselves +- Reduces unnecessary Windows builds by ~60-70% + +**Implementation:** See `pristine-master-policy.md` for details. + +## Questions? + +For more information: +- Pristine master policy: `.github/docs/pristine-master-policy.md` +- Sync setup: `.github/docs/sync-setup.md` +- AI review guide: `.github/docs/ai-review-guide.md` +- Windows builds: `.github/docs/windows-builds.md` diff --git a/.github/docs/pristine-master-policy.md b/.github/docs/pristine-master-policy.md new file mode 100644 index 0000000000000..9c0479d32df6a --- /dev/null +++ b/.github/docs/pristine-master-policy.md @@ -0,0 +1,225 @@ +# Pristine Master Policy + +## Overview + +The `master` branch in this mirror repository follows a "mostly pristine" policy, meaning it should closely mirror the upstream `postgres/postgres` repository with only specific exceptions allowed. + +## Allowed Commits on Master + +Master is considered "pristine" and the sync workflow will successfully merge upstream changes if local commits fall into these categories: + +### 1. āœ… CI/CD Configuration (`.github/` directory only) + +Commits that only modify files within the `.github/` directory are allowed. + +**Examples:** +- Adding GitHub Actions workflows +- Updating AI review configuration +- Modifying sync schedules +- Adding documentation in `.github/docs/` + +**Rationale:** CI/CD configuration is repository-specific and doesn't affect the PostgreSQL codebase itself. + +### 2. āœ… Development Environment Setup (commits named "dev setup ...") + +Commits with messages starting with "dev setup" (case-insensitive) are allowed, even if they modify files outside `.github/`. + +**Examples:** +- `dev setup v19` +- `Dev Setup: Add debugging configuration` +- `DEV SETUP - IDE and tooling` + +**Typical files in dev setup commits:** +- `.clang-format`, `.clangd` - Code formatting and LSP config +- `.envrc` - Directory environment variables (direnv) +- `.gdbinit` - Debugger configuration +- `.idea/`, `.vscode/` - IDE settings +- `flake.nix`, `shell.nix` - Nix development environment +- `pg-aliases.sh` - Personal shell aliases +- Other personal development tools + +**Rationale:** Development environment configuration is personal and doesn't affect the code or CI/CD. It's frequently updated as developers refine their workflow. + +### 3. āŒ Code Changes (NOT allowed) + +Any commits that: +- Modify PostgreSQL source code (`src/`, `contrib/`, etc.) +- Modify tests outside `.github/` +- Modify build system outside `.github/` +- Are not `.github/`-only AND don't start with "dev setup" + +**These will cause sync failures** and require manual resolution. + +## Branch Strategy + +### Master Branch +- **Purpose:** Mirror of upstream `postgres/postgres` + local CI/CD + dev environment +- **Updates:** Automatic hourly sync from upstream +- **Direct commits:** Only `.github/` changes or "dev setup" commits +- **All other work:** Use feature branches + +### Feature Branches +- **Purpose:** All PostgreSQL development work +- **Pattern:** `feature/*`, `dev/*`, `experiment/*` +- **Workflow:** + ```bash + git checkout master + git pull origin master + git checkout -b feature/my-feature + # Make changes... + git push origin feature/my-feature + # Create PR: feature/my-feature → master + ``` + +## Sync Workflow Behavior + +### Scenario 1: No Local Commits +``` +Upstream: A---B---C +Master: A---B---C +``` +**Result:** āœ… Already up to date (no action needed) + +### Scenario 2: Only .github/ Commits +``` +Upstream: A---B---C---D +Master: A---B---C---X (X modifies .github/ only) +``` +**Result:** āœ… Merge commit created +``` +Master: A---B---C---X---M + \ / + D---/ +``` + +### Scenario 3: Only "dev setup" Commits +``` +Upstream: A---B---C---D +Master: A---B---C---Y (Y is "dev setup v19") +``` +**Result:** āœ… Merge commit created +``` +Master: A---B---C---Y---M + \ / + D---/ +``` + +### Scenario 4: Mix of Allowed Commits +``` +Upstream: A---B---C---D +Master: A---B---C---X---Y (X=.github/, Y=dev setup) +``` +**Result:** āœ… Merge commit created + +### Scenario 5: Code Changes (Violation) +``` +Upstream: A---B---C---D +Master: A---B---C---Z (Z modifies src/backend/) +``` +**Result:** āŒ Sync fails, issue created + +**Recovery:** +1. Create feature branch from Z +2. Reset master to match upstream +3. Rebase feature branch +4. Create PR + +## Updating Dev Setup + +When you update your development environment: + +```bash +# Make changes to .clangd, flake.nix, etc. +git add .clangd flake.nix .vscode/ + +# Important: Start message with "dev setup" +git commit -m "dev setup v20: Update clangd config and add new aliases" + +git push origin master +``` + +The sync workflow will recognize this as a dev setup commit and preserve it during merges. + +**Naming convention:** +- āœ… `dev setup v20` +- āœ… `Dev setup: Update IDE config` +- āœ… `DEV SETUP - Add debugging tools` +- āŒ `Update development environment` (doesn't start with "dev setup") +- āŒ `dev environment changes` (doesn't start with "dev setup") + +## Sync Failure Recovery + +If sync fails because of non-allowed commits: + +### Check What's Wrong +```bash +git fetch origin +git fetch upstream https://github.com/postgres/postgres.git master + +# See which commits are problematic +git log upstream/master..origin/master --oneline + +# See which files were changed +git diff --name-only upstream/master...origin/master +``` + +### Option 1: Make Commit Acceptable + +If the commit should have been a "dev setup" commit: + +```bash +# Amend the commit message +git commit --amend -m "dev setup v21: Previous changes" +git push origin master --force-with-lease +``` + +### Option 2: Move to Feature Branch + +If the commit contains code changes: + +```bash +# Create feature branch +git checkout -b feature/recovery origin/master + +# Reset master to upstream +git checkout master +git reset --hard upstream/master +git push origin master --force + +# Your changes are safe in feature/recovery +git checkout feature/recovery +# Create PR when ready +``` + +## FAQ + +**Q: Why allow dev setup commits on master?** +A: Development environment configuration is personal, frequently updated, and doesn't affect the codebase or CI/CD. It's more convenient to keep it on master than manage separate branches. + +**Q: What if I forget to name it "dev setup"?** +A: Sync will fail. You can amend the commit message (see recovery above) or move the commit to a feature branch. + +**Q: Can I have both .github/ and dev setup changes in one commit?** +A: Yes! The sync workflow allows commits that modify .github/, or are named "dev setup", or both. + +**Q: What if upstream modifies the same files as my dev setup commit?** +A: The sync will attempt to merge automatically. If there are conflicts, you'll need to resolve them manually (rare, since upstream shouldn't touch personal dev files). + +**Q: Can I reorder commits on master?** +A: It's not recommended due to complexity. The sync workflow handles commits in any order as long as they follow the policy. + +## Monitoring + +**Check sync status:** +- Actions → "Sync from Upstream (Automatic)" +- Look for green āœ… on recent runs + +**Check for policy violations:** +- Open issues with label `sync-failure` +- These indicate commits that violated the pristine master policy + +## Related Documentation + +- [Sync Setup Guide](sync-setup.md) - Detailed sync workflow documentation +- [QUICKSTART](../QUICKSTART.md) - Quick setup guide +- [README](../README.md) - System overview diff --git a/.github/docs/sync-setup.md b/.github/docs/sync-setup.md new file mode 100644 index 0000000000000..1e12aeea3c5fc --- /dev/null +++ b/.github/docs/sync-setup.md @@ -0,0 +1,326 @@ +# Automated Upstream Sync Documentation + +## Overview + +This repository maintains a mirror of the official PostgreSQL repository at `postgres/postgres`. The sync system automatically keeps the `master` branch synchronized with upstream changes. + +## System Components + +### 1. Automatic Daily Sync +**File:** `.github/workflows/sync-upstream.yml` + +- **Trigger:** Daily at 00:00 UTC (cron schedule) +- **Purpose:** Automatically sync master branch without manual intervention +- **Process:** + 1. Fetches latest commits from `postgres/postgres` + 2. Fast-forward merges to local master (conflict-free) + 3. Pushes to `origin/master` + 4. Creates GitHub issue if conflicts detected + 5. Closes existing sync-failure issues on success + +### 2. Manual Sync Workflow +**File:** `.github/workflows/sync-upstream-manual.yml` + +- **Trigger:** Manual via Actions tab → "Sync from Upstream (Manual)" → Run workflow +- **Purpose:** Testing and on-demand syncs +- **Options:** + - `force_push`: Use `--force-with-lease` when pushing (default: true) + +## Branch Strategy + +### Critical Rule: Master is Pristine + +- **master branch:** Mirror only - pristine copy of `postgres/postgres` +- **All development:** Feature branches (e.g., `feature/hot-updates`, `experiment/zheap`) +- **Never commit directly to master** - this will cause sync failures + +### Feature Branch Workflow + +```bash +# Start new feature from latest master +git checkout master +git pull origin master +git checkout -b feature/my-feature + +# Work on feature +git commit -m "Add feature" + +# Keep feature updated with upstream +git checkout master +git pull origin master +git checkout feature/my-feature +git rebase master + +# Push feature branch +git push origin feature/my-feature + +# Create PR: feature/my-feature → master +``` + +## Sync Failure Recovery + +### Diagnosis + +If sync fails, you'll receive a GitHub issue with label `sync-failure`. Check what commits are on master but not upstream: + +```bash +# Clone or update your local repository +git fetch origin +git fetch upstream https://github.com/postgres/postgres.git master + +# View conflicting commits +git log upstream/master..origin/master --oneline + +# See detailed changes +git diff upstream/master...origin/master +``` + +### Recovery Option 1: Preserve Commits (Recommended) + +If the commits on master should be kept: + +```bash +# Create backup branch from current master +git checkout origin/master +git checkout -b recovery/master-backup-$(date +%Y%m%d) +git push origin recovery/master-backup-$(date +%Y%m%d) + +# Reset master to upstream +git checkout master +git reset --hard upstream/master +git push origin master --force + +# Create feature branch from backup +git checkout -b feature/recovered-work recovery/master-backup-$(date +%Y%m%d) + +# Optional: rebase onto new master +git rebase master + +# Push feature branch +git push origin feature/recovered-work + +# Create PR: feature/recovered-work → master +``` + +### Recovery Option 2: Discard Commits + +If the commits on master were mistakes or already merged upstream: + +```bash +git checkout master +git reset --hard upstream/master +git push origin master --force +``` + +### Verification + +After recovery, verify sync status: + +```bash +# Check that master matches upstream +git log origin/master --oneline -10 +git log upstream/master --oneline -10 + +# These should be identical + +# Or run manual sync workflow +# GitHub → Actions → "Sync from Upstream (Manual)" → Run workflow +``` + +The automatic sync will resume on next scheduled run (00:00 UTC daily). + +## Monitoring + +### Success Indicators + +- āœ“ GitHub Actions badge shows passing +- āœ“ No open issues with label `sync-failure` +- āœ“ `master` branch commit history matches `postgres/postgres` + +### Check Sync Status + +**Via GitHub UI:** +1. Go to: Actions → "Sync from Upstream (Automatic)" +2. Check latest run status + +**Via Git:** +```bash +git fetch origin +git fetch upstream https://github.com/postgres/postgres.git master +git log origin/master..upstream/master --oneline + +# No output = fully synced +# Commits listed = behind upstream (sync pending or failed) +``` + +**Via API:** +```bash +# Check latest workflow run +gh run list --workflow=sync-upstream.yml --limit 1 + +# View run details +gh run view +``` + +### Sync Lag + +Expected lag: <1 hour from upstream commit to mirror + +- Upstream commits at 12:30 UTC → Synced at next daily run (00:00 UTC next day) = ~11.5 hours max +- For faster sync: Manually trigger workflow after major upstream merges + +## Configuration + +### GitHub Actions Permissions + +Required settings (already configured): + +1. **Settings → Actions → General → Workflow permissions:** + - āœ“ "Read and write permissions" + - āœ“ "Allow GitHub Actions to create and approve pull requests" + +2. **Repository Settings → Branches:** + - Consider: Branch protection rule on `master` to prevent direct pushes + - Exception: Allow `github-actions[bot]` to push + +### Adjusting Sync Schedule + +Edit `.github/workflows/sync-upstream.yml`: + +```yaml +on: + schedule: + # Current: Daily at 00:00 UTC + - cron: '0 0 * * *' + + # Examples: + # Every 6 hours: '0 */6 * * *' + # Twice daily: '0 0,12 * * *' + # Weekdays only: '0 0 * * 1-5' +``` + +**Recommendation:** Keep daily schedule to balance freshness with API usage. + +## Troubleshooting + +### Issue: Workflow not running + +**Check:** +1. Actions tab → Check if workflow is disabled +2. Settings → Actions → Ensure workflows are enabled for repository + +**Fix:** +- Enable workflow: Actions → Select workflow → "Enable workflow" + +### Issue: Permission denied on push + +**Check:** +- Settings → Actions → General → Workflow permissions + +**Fix:** +- Set to "Read and write permissions" +- Enable "Allow GitHub Actions to create and approve pull requests" + +### Issue: Merge conflicts every sync + +**Root cause:** Commits being made directly to master + +**Fix:** +1. Review `.git/hooks/` for pre-commit hooks that might auto-commit +2. Check if any automation is committing to master +3. Enforce branch protection rules +4. Educate team members on feature branch workflow + +### Issue: Sync successful but CI fails + +**This is expected** if upstream introduced breaking changes or test failures. + +**Handling:** +- Upstream tests failures are upstream's responsibility +- Focus: Ensure mirror stays in sync +- Separate: Your feature branches should pass CI + +## Cost and Usage + +### GitHub Actions Minutes + +- **Sync workflow:** ~2-3 minutes per run +- **Frequency:** Daily = 60-90 minutes/month +- **Free tier:** 2,000 minutes/month (public repos: unlimited) +- **Cost:** $0 (well within limits) + +### Network Usage + +- Fetches only new commits (incremental) +- Typical: <10 MB per sync +- Total: <300 MB/month + +## Security Considerations + +### Secrets + +- Uses `GITHUB_TOKEN` (automatically provided, scoped to repository) +- No additional secrets required +- Token permissions: Minimum necessary (contents:write, issues:write) + +### Audit Trail + +All syncs are logged: +- GitHub Actions run history (90 days retention) +- Git reflog on server +- Issue creation/closure for failures + +## Integration with Other Workflows + +### Cirrus CI + +Cirrus CI tests trigger on pushes to master: +- Sync pushes → Cirrus CI runs tests on synced commits +- This validates upstream changes against your test matrix + +### AI Code Review + +AI review workflows trigger on PRs, not master pushes: +- Sync to master does NOT trigger AI reviews +- Feature branch PRs → master do trigger AI reviews + +### Windows Builds + +Windows dependency builds trigger on master pushes: +- Sync pushes → Windows builds run +- Ensures dependencies stay compatible with latest upstream + +## Support + +### Reporting Issues + +If sync consistently fails: + +1. Check open issues with label `sync-failure` +2. Review workflow logs: Actions → Failed run → View logs +3. Create issue with: + - Workflow run URL + - Error messages from logs + - Output of `git log upstream/master..origin/master` + +### Disabling Automatic Sync + +If needed (e.g., during major refactoring): + +```bash +# Disable via GitHub UI +# Actions → "Sync from Upstream (Automatic)" → "..." → Disable workflow + +# Or delete/rename the workflow file +git mv .github/workflows/sync-upstream.yml .github/workflows/sync-upstream.yml.disabled +git commit -m "Temporarily disable automatic sync" +git push +``` + +**Remember to re-enable** once work is complete. + +## References + +- Upstream repository: https://github.com/postgres/postgres +- GitHub Actions docs: https://docs.github.com/en/actions +- Git branching strategies: https://git-scm.com/book/en/v2/Git-Branching-Branching-Workflows diff --git a/.github/ocr/context.md b/.github/ocr/context.md new file mode 100644 index 0000000000000..c4a83b85b124e --- /dev/null +++ b/.github/ocr/context.md @@ -0,0 +1,126 @@ +# OCR review context — PostgreSQL contribution standards + +You are reviewing a change to a **PostgreSQL** fork. Every PR here is destined to +become a patch posted to the **pgsql-hackers** mailing list and tracked in a +**commitfest**. Review with the combined rigor, taste, and attention to detail of +the PostgreSQL committers. This context applies to the *whole* change, on top of +the per-file rules. + +## Review discipline +- Be precise and blunt; lead with the most serious problem. No praise, no + validation of the author, no disclaimers — accuracy is the only metric. +- Verify every claim against the actual diff. Confirm names, signatures, line + numbers, and APIs before asserting. Never invent behavior or cite code not in + the change. If unsure, say so, and tag each finding **high / moderate / low** + confidence. +- Judge the change on its merits regardless of how the PR frames it. A draft PR + is WIP: weight design/approach feedback over style nits. + +## Patch hygiene (top rejection reasons on -hackers) +1. **Minimal diff.** The fastest way to get a patch rejected is unrelated + changes: reformatting untouched lines, rewording unrelated comments, touching + code not required by the change. Flag any hunk not needed for the stated + purpose. After the patch, the code should read as if it had always been + written that way. +2. **Atomic, bisectable commits.** Each commit must build and pass tests on its + own — a broken intermediate commit breaks `git bisect`, revert, and + cherry-pick. Flag a commit that only compiles once a later commit lands. + Prefer one focused patch, or a clearly-ordered series of + independently-committable pieces. +3. **Tests + docs are mandatory.** A user-visible change without regression/TAP + tests **and** documentation is WIP, not commit-ready. New behavior needs + tests that cover edge and error paths, not just the happy path. +4. **DRY / reuse.** Prefer existing infrastructure (`List` in `pg_list.h`, + `StringInfo`, `dynahash`/`simplehash`, `palloc`/`MemoryContext`, `foreach`) + over reinventing it. Flag copy-paste and speculative abstraction alike — the + community wants minimal, targeted changes that fit the subsystem's existing + patterns. +5. **Whitespace.** No trailing whitespace; tabs (width 4) for C indentation; + `git diff --check` must be clean. Whitespace-only churn on untouched lines is + a defect. + +## Committer-owned files — do NOT touch in a patch (flag if present) +These are the committer's job at push time; including them causes needless +merge conflicts and is a mistake: +- **`src/include/catalog/catversion.h`** — the `CATALOG_VERSION_NO` bump is done + by the **committer** when pushing. A catversion bump in the PR is **wrong** — + flag it. (This is the single most common author mistake in catalog patches.) +- **Release notes** (`doc/src/sgml/release-*.sgml`) and version strings + (`configure.ac` `AC_INIT` version, `meson.build` `version`, `PG_VERSION`). + +## Generated files — never hand-edit; edit the source +Flag direct edits to generated output; point the author at the source instead: +- Catalog headers `src/include/catalog/*_d.h`, `postgres.bki`, `schemapg.h`, + `system_constraints.sql` → edit the `pg_*.dat` files. +- `src/backend/nodes/{copy,equal,out,read}funcs.c` and other + `gen_node_support.pl` output → annotate the `Node` struct in its header. +- `fmgroids.h`, `fmgrprotos.h`, `fmgrtab.c` → edit `pg_proc.dat`. +- `utils/errcodes.h` → `errcodes.txt`; wait-event headers → + `wait_event_names.txt`; `lwlocknames.h` → `lwlocknames.txt`. +- `configure` → `configure.ac`; `*.po` translations are handled separately; + generated Unicode tables come from their source scripts. + +## Portability is a hard gate +PostgreSQL runs on Linux, Windows (MSVC), macOS, the BSDs and Solaris, across +**x86_64, ARM64, RISC-V, PPC64, s390x**, both endiannesses and 32/64-bit. Any +change must be portable across all of them: +- No unaligned memory access; no dependence on `char` signedness, integer/pointer + width, endianness, or struct padding for on-disk/wire formats. +- Use `int16/int32/int64`, `Size`, and `INT64_FORMAT`/`UINT64_FORMAT` (never + `%ld` for `int64`). +- Atomics/barriers only via `port/atomics` (`pg_atomic_*`, `pg_read/write_barrier`). +- **Windows/MSVC:** any `extern` variable used from another module or an + extension needs `PGDLLIMPORT` in its header; no VLAs or compiler-specific + extensions beyond the tree's C99 baseline. + +## Backward compatibility — the strongest constraint +Do not break SQL behavior, the libpq wire protocol, the logical-replication +protocol, dump/restore, `pg_upgrade`, or exported/`PGDLLIMPORT` APIs without +extraordinary justification. **ABI** matters for back-branches: changing the +size/layout of an exported struct or the signature of an exported function +breaks installed extensions. + +## Mailing-list context & etiquette +Because each PR becomes a pgsql-hackers email read by a busy, expert, opinionated +audience, also flag what reliably wastes reviewer time or draws rejection: +- A patch that **does more than one thing** or bundles unrelated cleanup — split it. +- **Footguns**: easy-to-misuse APIs, silent data-loss/corruption hazards, unsafe + defaults — name them explicitly. +- **Performance claims without a reproducible benchmark.** +- No reference to the **design discussion / prior -hackers thread** (Message-Id) + for a non-trivial change. +- **Do not bikeshed:** keep style nits proportionate and clearly separated from + substantive correctness findings. + +## Minimalism — the "ponytail" discipline +The best code is the code you never wrote (YAGNI). Before accepting new code, +apply the ladder: (1) Does this need to exist at all? (2) Can existing +code/infrastructure already do it? (3) Is this the simplest thing that works? +Flag: speculative scaffolding and config for a path that isn't wired yet; dead +code and unused "flexibility" (fields, params, abstractions, options with no +caller); premature abstraction (a helper used exactly once); knobs/GUCs/flags +nobody asked for. Minimal, targeted changes that fit the existing patterns beat +clever or general-purpose ones. + +## Comment & identity accuracy +- Comments must describe what the code does **now**. Flag aspirational/ + future-tense comments for behavior that already shipped ("will be", "for now", + "not yet", "future", and stale "TODO/FIXME/XXX/HACK"); comments that drifted + from the code they sit above; and incomplete/trailing comments. Comments + explain **why**, not what. No commented-out code. +- **ASCII only** in source and diffs — no smart quotes, em-dashes, or ellipsis + characters. + +## Commit & versioning discipline +- Conventional-commit style, imperative subject, one logical change per commit, + each commit building on its own. +- Do **not** bump version numbers or generated version stamps (including + `catversion.h`) — that is the maintainer's job at commit/release time. + +Understand common list shorthand so your comments are precise and not +miscommunicated: WIP (work in progress), GUC (config variable), WAL, LSN, OID, +TOAST, FSM, TAM (table access method), RLS, DSM, 2PC, PITR, CIC (concurrent index +creation), SAOP, ABI/API, backpatch (apply to supported back-branches), HEAD +(master tip), catversion (catalog version), pgindent, buildfarm, cfbot, +`s/x/y/` (suggested text substitution), footgun, bikeshedding, POLA (principle of +least astonishment). diff --git a/.github/ocr/litellm.yaml b/.github/ocr/litellm.yaml new file mode 100644 index 0000000000000..e23cc4eee6fe2 --- /dev/null +++ b/.github/ocr/litellm.yaml @@ -0,0 +1,41 @@ +# LiteLLM proxy config — bridges Open Code Review (OpenAI protocol) to AWS Bedrock. +# +# This proxy is NOT a hosted service. The ocr-review.yml workflow installs it +# (`pip install 'litellm[proxy]'`) and runs it as a background process bound to +# 127.0.0.1:4000 for the duration of a single GitHub Actions job, then it exits. +# +# Auth to Bedrock: LiteLLM uses boto3's default credential chain, which reads +# the temporary AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN +# minted by the workflow's OIDC "Configure AWS credentials" step; region from +# AWS_REGION. + +model_list: + - model_name: ocr-bedrock + litellm_params: + # Set the repo variable OCR_BEDROCK_MODEL to an Opus inference-profile id + # your account has access to, e.g.: + # bedrock/converse/us.anthropic.claude-opus-4-8 + # The 'converse/' prefix uses Bedrock's Converse API, which is the most + # reliable path for Claude tool-use (what OCR relies on). + model: os.environ/OCR_BEDROCK_MODEL + aws_region_name: os.environ/AWS_REGION + + # "High effort" review. Claude Opus 4.8 on Bedrock uses *adaptive* thinking + # controlled by output_config.effort. Set it DIRECTLY here — NOT via + # reasoning_effort, which LiteLLM still maps to the legacy + # thinking.type.enabled that Opus 4.8 rejects. LiteLLM forwards + # output_config into additionalModelRequestFields for Anthropic models; if + # the build doesn't recognize the effort param it is dropped with a warning + # (no error) and the model reviews at its default effort. + # Valid: low|medium|high|max|xhigh (auto-clamped to the model ceiling). + output_config: + effort: xhigh + max_tokens: 32000 + +litellm_settings: + drop_params: true # silently drop params a model doesn't support + modify_params: true # auto-fix minor request incompatibilities + request_timeout: 600 + +general_settings: + master_key: os.environ/LITELLM_MASTER_KEY diff --git a/.github/ocr/pg-history.py b/.github/ocr/pg-history.py new file mode 100644 index 0000000000000..5794f8a920bd7 --- /dev/null +++ b/.github/ocr/pg-history.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +pg-history: tie a PR's changes to PostgreSQL git + pgsql-hackers email history. + +OCR (the code reviewer) cannot call MCP servers, so this is a separate agent: +it runs a Bedrock (Claude Opus) tool-use loop wired to the Agora MCP server at +https://pg.ddx.io/mcp, lets the model search the mailing-list archives / commit +history / commitfest data, and emits a Markdown summary linking the changes to +the relevant threads (https://pg.ddx.io/m/pgsql-hackers/). + +Env: + PG_HISTORY_MCP_URL MCP endpoint (default https://pg.ddx.io/mcp) + PG_HISTORY_MODEL Bedrock model id (e.g. us.anthropic.claude-opus-4-8) + AWS_REGION region (creds come from the OIDC step's env) + BASE_REF, HEAD_SHA PR base ref and head sha (for the git diff context) + GH_PR_TITLE PR title (optional, adds context) + PG_HISTORY_OUT output markdown path (default /tmp/pg-history.md) +Writes the markdown to PG_HISTORY_OUT; exits 0 even on soft failures (writes a note). +""" +import json, os, subprocess, sys, urllib.request + +MCP_URL = os.environ.get("PG_HISTORY_MCP_URL", "https://pg.ddx.io/mcp") +MODEL = os.environ.get("PG_HISTORY_MODEL", "us.anthropic.claude-opus-4-8").replace("bedrock/converse/", "").replace("bedrock/", "") +REGION = os.environ.get("AWS_REGION", "us-east-1") +BASE_REF = os.environ.get("BASE_REF", "") +HEAD_SHA = os.environ.get("HEAD_SHA", "") +PR_TITLE = os.environ.get("GH_PR_TITLE", "") +OUT = os.environ.get("PG_HISTORY_OUT", "/tmp/pg-history.md") +UA = "pg-history/0.1 (+github-actions)" + +# Curated subset of the 108 Agora tools — the ones useful for connecting a +# change to its discussion/commit history. Intersected with what the server +# actually exposes, so unknown names are harmless. +TOOL_WHITELIST = { + "find_related_discussions", "find_similar_messages", "get_thread", + "discussion_links", "get_author_messages", "browse_by_date", + "blame_symbol", "check_upstream_status", "find_related", + "find_entries_for_thread", "find_entries_for_author", "get_commit", + "search", "hybrid_search", "get_callers", "get_callees", "find_pattern", +} +MAX_ROUNDS = 14 +TOOL_RESULT_CAP = 8000 # chars per tool result fed back to the model + + +def _mcp_post(body, sid=None): + headers = {"Content-Type": "application/json", + "Accept": "application/json, text/event-stream", "User-Agent": UA} + if sid: + headers["Mcp-Session-Id"] = sid + req = urllib.request.Request(MCP_URL, data=json.dumps(body).encode(), headers=headers, method="POST") + resp = urllib.request.urlopen(req, timeout=60) + sid_out = resp.headers.get("Mcp-Session-Id") + result = None + for line in resp.read().decode().splitlines(): + line = line.strip() + if line.startswith("data:"): + line = line[5:].strip() + if not line or line.startswith("event:"): + continue + try: + obj = json.loads(line) + except Exception: + continue + if isinstance(obj, dict) and ("result" in obj or "error" in obj): + result = obj + return result, sid_out + + +class MCP: + def __init__(self): + init, self.sid = _mcp_post({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "pg-history", "version": "0.1"}}}) + if not init or "result" not in init: + raise RuntimeError(f"MCP initialize failed: {init}") + try: + _mcp_post({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, self.sid) + except Exception: + pass + self._id = 1 + + def list_tools(self): + self._id += 1 + res, _ = _mcp_post({"jsonrpc": "2.0", "id": self._id, "method": "tools/list", "params": {}}, self.sid) + return (res or {}).get("result", {}).get("tools", []) + + def call(self, name, args): + self._id += 1 + res, _ = _mcp_post({"jsonrpc": "2.0", "id": self._id, "method": "tools/call", + "params": {"name": name, "arguments": args or {}}}, self.sid) + if not res: + return "(no response)" + if "error" in res: + return f"ERROR: {json.dumps(res['error'])[:500]}" + parts = [] + for c in res.get("result", {}).get("content", []): + if c.get("type") == "text": + parts.append(c["text"]) + return ("\n".join(parts) or "(empty)")[:TOOL_RESULT_CAP] + + +def git(*args): + try: + return subprocess.check_output(["git", *args], text=True, stderr=subprocess.DEVNULL).strip() + except Exception: + return "" + + +def pr_context(): + base = f"origin/{BASE_REF}" if BASE_REF else "" + rng = f"{base}..{HEAD_SHA}" if base and HEAD_SHA else HEAD_SHA + commits = git("log", "--no-merges", "--format=%h %s", f"{rng}") if rng else "" + stat = git("diff", "--stat", rng) if rng else "" + files = git("diff", "--name-only", rng) if rng else "" + return commits[:4000], stat[:3000], files[:2000] + + +SYSTEM = """You are a PostgreSQL community research assistant. Given a pull request's +commits and changed files, use the available tools (backed by the Agora index of +pgsql-hackers mail, commit history, and commitfest data) to connect the change to +its history. Your goal: + +- Find the mailing-list thread(s) and prior discussion behind this change. +- Identify related/superseded prior commits and any commitfest entry. +- Note relevant prior art, rejected approaches, or design rationale. + +Rules (voice & rigor): +- Be precise and blunt. No praise, no filler, no hedging, no disclaimers. Accuracy is + the only success metric — not the author's approval. Lead with the most important finding. +- NEVER hallucinate. Verify every Message-ID, thread subject, commit hash, author name, + and date against an actual tool result before citing it. If a search returns nothing, + say so plainly — do not guess or fabricate a plausible-looking link. +- Assess the change on its merits, independent of how the PR frames it. +- Tag any inferred (not tool-confirmed) linkage with an explicit confidence level: + high / moderate / low. +- Be decisive and efficient: a handful of targeted tool calls, not exhaustive search. +- Cite every mailing-list message as a Markdown link: [subject](https://pg.ddx.io/m/pgsql-hackers/MESSAGE_ID). +- If you find nothing relevant, say so in one line — do not pad. + +When done, output ONLY Markdown (no preamble) with these sections, omitting any that are empty: +## 🧵 Related discussion +## šŸ”— Related commits / prior art +## šŸ“‹ Commitfest +## 🧭 Context for reviewers +Keep it tight (use bullets; link generously).""" + + +def to_toolspec(t): + schema = t.get("inputSchema") or {"type": "object", "properties": {}} + return {"toolSpec": {"name": t["name"], + "description": (t.get("description") or "")[:600], + "inputSchema": {"json": schema}}} + + +def main(): + commits, stat, files = pr_context() + if not commits and not files: + open(OUT, "w").write("") # nothing to do + print("No PR diff context; skipping.") + return + user = (f"PR title: {PR_TITLE}\n\n" if PR_TITLE else "") + \ + f"Commits:\n{commits or '(none)'}\n\nChanged files:\n{files or '(none)'}\n\nDiffstat:\n{stat or '(none)'}\n" + + try: + mcp = MCP() + tools = [to_toolspec(t) for t in mcp.list_tools() if t.get("name") in TOOL_WHITELIST] + except Exception as e: + open(OUT, "w").write(f"_pg-history: could not reach the Agora MCP server ({MCP_URL}): {e}_\n") + print(f"MCP unavailable: {e}") + return + if not tools: + open(OUT, "w").write("_pg-history: no usable MCP tools available._\n") + return + + import boto3 + from botocore.config import Config + + # botocore's default read timeout (60s) is too short for a multi-round + # (MAX_ROUNDS) tool-use loop against a large PR diff on a reasoning model; + # each converse() call alone can take several minutes. Bump it well past + # what a single round needs; connect_timeout stays short since a stuck + # TCP handshake is a different (and much cheaper to detect) failure mode. + brt = boto3.client("bedrock-runtime", region_name=REGION, + config=Config(read_timeout=900, connect_timeout=10)) + messages = [{"role": "user", "content": [{"text": user}]}] + final_text = "" + try: + for _ in range(MAX_ROUNDS): + resp = brt.converse( + modelId=MODEL, + system=[{"text": SYSTEM}], + messages=messages, + toolConfig={"tools": tools}, + inferenceConfig={"maxTokens": 4096}, + ) + out = resp["output"]["message"] + messages.append(out) + if resp.get("stopReason") == "tool_use": + results = [] + for blk in out["content"]: + tu = blk.get("toolUse") + if not tu: + continue + res_text = mcp.call(tu["name"], tu.get("input") or {}) + results.append({"toolResult": {"toolUseId": tu["toolUseId"], + "content": [{"text": res_text}]}}) + messages.append({"role": "user", "content": results}) + continue + final_text = "".join(b.get("text", "") for b in out["content"]).strip() + break + except Exception as e: + open(OUT, "w").write(f"_pg-history: Bedrock call failed: {e}_\n") + print(f"Bedrock error: {e}") + return + + if not final_text: + final_text = "_pg-history: no related history found._" + body = "## šŸ“œ Change history & discussion (Agora / pg.ddx.io)\n\n" + final_text + \ + "\n\nGenerated by pg-history via the Agora MCP server (pg.ddx.io).\n" + open(OUT, "w").write(body) + print(body) + + +if __name__ == "__main__": + main() diff --git a/.github/ocr/rule.json b/.github/ocr/rule.json new file mode 100644 index 0000000000000..60e13e73dcbe0 --- /dev/null +++ b/.github/ocr/rule.json @@ -0,0 +1,65 @@ +{ + "_comment": "OCR per-file review rules for PostgreSQL core + extensions. Cross-cutting contribution standards & mailing-list etiquette live in .github/ocr/context.md, passed via --background-file. OCR uses FIRST-MATCH-WINS in declaration order, so rules are ordered most-specific first. merge_system_rule:true keeps OCR's built-in fine-tuned checks (thread-safety, injection, NPE) alongside these PostgreSQL-specific rules.", + "rules": [ + { + "path": "src/test/**", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL tests. Coverage is mandatory for any behavioral change and must include edge cases (NULL, empty, boundary/overflow) and ERROR paths, not just the happy path. A test that still passes with the feature reverted is worthless — confirm it actually exercises and would catch regressions in the new code. Regression (.sql/expected): deterministic, portable output — ORDER BY where row order matters, no timing/plan-dependent output except intentional EXPLAIN, no absolute paths, locale-independent (C collation or explicit COLLATE), DROP objects the test creates; expected/ output must stay stable across platforms and under the parallel schedule. Concurrency/locking belongs in isolation tests (src/test/isolation, .spec + permutations). End-to-end/crash/replication/CLI behavior belongs in TAP tests (t/*.pl with PostgreSQL::Test::Cluster/Utils) — no hardcoded ports/paths, no sleep as synchronization (use poll_query_until/wait_for), skip cleanly when prerequisites are missing, and clean up nodes." + }, + { + "path": "**/*.{c,h}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL backend/frontend C — review as pgsql-hackers committers do, in priority order.\n\n(1) CORRECTNESS (highest): Memory — every palloc lives in the right MemoryContext; error paths via ereport/elog(ERROR) must not leak memory/buffers/locks/fds (rely on MemoryContext/ResourceOwner reset or PG_TRY/PG_FINALLY); no use-after-free; delete temp contexts. Concurrency — consistent lock ordering (deadlock-free), correct lock levels, balanced LWLockAcquire/Release and START_/END_CRIT_SECTION, no TOCTOU, CHECK_FOR_INTERRUPTS in long loops, async-signal-safe signal handlers (volatile sig_atomic_t). WAL — any change to shared on-disk state must be WAL-logged AND correctly replayed (redo path), crash- and replica-consistent. NULL/edge/overflow handling.\n\n(2) BACKWARD COMPATIBILITY / ABI: don't break behavior, dump/restore, pg_upgrade, libpq wire protocol, logical-replication protocol, or exported/PGDLLIMPORT'd APIs (struct size/layout, function signatures) without extraordinary justification.\n\n(3) CATALOG / GENERATED: new/changed catalog data goes in pg_*.dat, NOT the generated *_d.h/.bki. New Node types: ANNOTATE the struct in its header so gen_node_support.pl regenerates copy/equal/out/read — do NOT hand-edit *funcs.c. New SQL-callable functions: add to pg_proc.dat with an OID from the 8000-9999 developer range (src/include/catalog/unused_oids; check duplicate_oids); committer renumbers at commit. DO NOT bump CATALOG_VERSION_NO in the patch — flag any catversion.h change as a mistake (committer's job).\n\n(4) PERFORMANCE: no regression on hot paths; avoid O(n^2) where better is feasible; minimize work under contended locks; avoid needless palloc churn and large struct copies in hot paths.\n\n(5) SECURITY: bounded string ops (snprintf/strlcpy/strlcat — never strcpy/strcat/sprintf); integer/size-overflow checks before allocation; never user input as a format string; privilege checks via pg_*_aclcheck; beware search_path and SECURITY DEFINER.\n\n(6) PORTABILITY (hard gate): no unaligned access; no dependence on char signedness, int/long/pointer width, endianness, or struct padding for on-disk/wire formats; use int16/int32/int64 + INT64_FORMAT/UINT64_FORMAT (never %ld for int64); align contended shared structs (pg_attribute_aligned/cache-line pad). Atomics/barriers only via port/atomics (pg_atomic_*, pg_read/write_barrier) — never raw intrinsics or volatile-as-barrier. WINDOWS/MSVC: extern vars used cross-module/extension need PGDLLIMPORT; no VLAs or features beyond the C99 baseline the tree targets; use pg_pread/pg_pwrite. Applies across x86_64/ARM64/RISC-V/PPC64/s390x, big/little endian, 32/64-bit.\n\n(7) CONVENTIONS: errmsg starts lowercase, no trailing period, no embedded newlines; errdetail/errhint are complete capitalized sentences; correct ERRCODE_*; wrap user-facing text in _(); errmsg_plural for counts. Assert() only for can't-happen invariants (never user-reachable). Naming: snake_case with subsystem prefix (heap_insert) or CamelCase for major subsystems (ExecInitNode); ALL_CAPS macros. Must pgindent cleanly (tabs, width 4). Comments explain WHY not WHAT; no #ifdef 0 blocks, no commented-out code, no #ifdef fencing your feature. Reuse existing helpers (DRY)." + }, + { + "path": "**/*.dat", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL catalog data (pg_proc.dat, pg_type.dat, etc.) — the SOURCE for generated headers. The generated *_d.h, postgres.bki, fmgroids.h, fmgrtab.c must NOT be hand-edited (they regenerate from these files). OIDs: use a value from the developer range 8000-9999 (src/include/catalog/unused_oids; verify with duplicate_oids); committer renumbers to a final contiguous block, so stay in-range and unique but don't over-optimize the exact number. Keep proc entries complete/consistent (prosrc, provolatile, proparallel, prorettype/proargtypes, matching description). DO NOT bump CATALOG_VERSION_NO / catversion.h — committer's job at push time; flag any such change. New catalog columns/views need documentation in doc/src/sgml/catalogs.sgml." + }, + { + "path": "**/*.{sql,pgsql}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL SQL. Valid PostgreSQL dialect (not MySQL/Oracle); correct types (bigint vs int, text vs varchar); sound transaction/isolation and CTE-materialization assumptions. SECURITY: flag SQL injection in dynamic SQL (require quote_identifier/quote_literal or format() with %I/%L), SECURITY DEFINER without a locked-down search_path, inappropriate RLS bypass. Prefer set-based over row-at-a-time/N+1. BACKWARD COMPATIBILITY (a top rejection reason): changing existing SQL behavior, the output of existing functions, or default GUCs needs extraordinary justification. New SQL-callable objects belong in pg_*.dat with OIDs from the 8000-9999 range, not in generated files. Minimal diff; add regression tests + docs." + }, + { + "path": "**/*.{pl,pm}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Perl (TAP tests and build/catalog tooling). Require 'use strict; use warnings;'. Must be perltidy-clean with the tree's src/tools/pgindent/perltidyrc and pass src/tools/perlcheck/pgperlcritic. Use the framework: PostgreSQL::Test::Cluster, PostgreSQL::Test::Utils, Test::More; no hardcoded ports/paths/PIDs; use safe_psql/poll_query_until, not sleep; skippable without optional prerequisites; clean up nodes. PORTABILITY: run on Windows (no fork-only constructs, use File::Spec, avoid unavailable signals) and the minimum supported Perl. Robustness: avoid two-arg open and string system()/qx with interpolated data (use list forms). Generator scripts (gen_node_support.pl, catalog Perl) must be deterministic and stay in sync with inputs; do not commit their generated output." + }, + { + "path": "**/*.py", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Python (build/test tooling, oauth/pytest tests, src/tools). Follow surrounding style; keep imports to the standard library unless the dependency is already required by the tree (no surprise third-party deps in build/test tooling). PORTABILITY: support the project's minimum Python 3 and run on Windows and the BSDs (use os.path/pathlib, avoid POSIX-only calls and shell=True with interpolated input). Deterministic, self-cleaning tests; no hardcoded ports/paths; skip cleanly without prerequisites. For the Perl->pytest porting effort, confirm behavior parity with the TAP test replaced (same assertions/coverage), not a superficial translation. Minimal diff; match the tree's ruff/black config if present." + }, + { + "path": "**/*.{rs,toml}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. Rust PostgreSQL extension (pgrx) or Rust support crate. Not core C, but it runs inside/alongside the backend, so backend safety applies. SAFETY: in code reachable from an SQL call, a Rust panic aborts the Postgres process — forbid unwrap()/expect()/panic!/unreachable!/todo! and index-panics on reachable paths; use Result and pgrx error reporting (error!/ereport!). Every `unsafe` block needs a comment justifying its invariant; scrutinize raw pointers and FFI across the pg_sys boundary. pgrx: honor #[pg_guard] on extern C fns (correct panic/longjmp handling); never hold Rust references across SPI or anything that can longjmp (skips Rust destructors -> leaks); respect MemoryContext lifetimes for palloc'd data; datum<->Rust conversions must handle NULL. Concurrency uses Postgres shmem/LWLocks (pgrx shmem API), not std::sync alone. Lints: must pass `cargo clippy --all-targets --all-features -- -D warnings` and `cargo fmt --check`; deny unwrap_used/expect_used/panic in libraries; thiserror (libs) / anyhow (bins). Justify every new dependency. Tests: #[pg_test] for in-backend behavior, #[test] for pure logic; cover error and NULL paths. Minimal, idiomatic diff." + }, + { + "path": "**/{configure.ac,*.m4,aclocal.m4}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Autoconf. Edit configure.ac / the m4 macros — do NOT hand-edit generated 'configure' or pg_config.h.in in the same patch (regeneration is the committer's step; a patch that also rewrites generated configure output is suspect). Feature/header/function probes must be portable and not assume a specific OS/compiler. Every configure knob must be mirrored on the Meson side (meson_options.txt/meson.build) and documented. Minimal diff." + }, + { + "path": "**/{meson.build,meson_options.txt}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Meson build. Valid syntax; correct subdir()/dependency()/declare_dependency and install paths; new source files must be added here. CRITICAL: PostgreSQL maintains BOTH Meson and Autoconf/Make — any new file, option, or feature check must be mirrored on the configure.ac/Makefile side so the two never drift (a file built by only one system is a common defect). New options need matching docs and sensible defaults. Minimal diff." + }, + { + "path": "**/{Makefile,GNUmakefile,*.mk}", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL Makefile (GNU Make). $(VAR) refs; correct .PHONY; accurate dependencies (no parallel -j races); $(MAKE) for recursion; VPATH/out-of-tree build support; no hardcoded paths (use standard PostgreSQL makefile vars and $(top_builddir)); clean/distclean/maintainer-clean must remove new artifacts; extensions use PGXS. Must stay in sync with meson.build. Minimal diff." + }, + { + "path": "doc/**/*.sgml", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. PostgreSQL documentation (DocBook SGML). Technically accurate/complete (parameters, limitations, version/compat notes); correct tag usage/nesting (, , , , , /); working cross-references; spell it 'PostgreSQL' in prose; SQL keywords uppercase in examples. Coverage: a new GUC -> config.sgml (and postgresql.conf.sample); new/changed catalogs or views -> catalogs.sgml; new SQL syntax -> the matching ref/*.sgml; new functions -> func.sgml. Do NOT edit release-notes (release-*.sgml) — written by the release team/committers; flag such edits. New user-facing behavior in this PR should ship with matching docs." + }, + { + "path": "**/*.md", + "merge_system_rule": true, + "rule": "REVIEW DISCIPLINE: Precise, blunt, verify against the diff, tag confidence, no praise. Markdown docs. Clear heading hierarchy; fenced code blocks with language hints; accurate instructions/prerequisites; consistent PostgreSQL terminology; no broken relative links or stale claims. Minimal diff." + } + ] +} diff --git a/.github/workflows/ocr-model-check.yml b/.github/workflows/ocr-model-check.yml new file mode 100644 index 0000000000000..10d250528cf7c --- /dev/null +++ b/.github/workflows/ocr-model-check.yml @@ -0,0 +1,89 @@ +# Checks AWS Bedrock weekly for a newer Claude Opus inference profile than the +# one OCR currently uses (vars.OCR_BEDROCK_MODEL) and, if found, opens/updates a +# single GitHub issue telling the maintainer to bump the variable. It does NOT +# change the model automatically: GITHUB_TOKEN cannot write Actions *variables* +# (that needs a PAT with admin), so this is a notify-only mechanism by design. +name: OCR model self-check + +on: + schedule: + - cron: '0 12 * * 1' # Mondays 12:00 UTC + workflow_dispatch: + +permissions: + id-token: write + contents: read + issues: write + +jobs: + check-model: + runs-on: ubuntu-latest + steps: + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + role-session-name: ocr-model-check-${{ github.run_id }} + + - name: Find newest Opus vs configured + id: check + env: + CURRENT: ${{ vars.OCR_BEDROCK_MODEL }} + AWS_REGION: ${{ vars.AWS_REGION }} + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import os, re, subprocess, json + region = os.environ.get("AWS_REGION", "us-east-1") + current = os.environ.get("CURRENT", "") + out = subprocess.run( + ["aws", "bedrock", "list-inference-profiles", "--region", region, + "--query", "inferenceProfileSummaries[].inferenceProfileId", "--output", "json"], + capture_output=True, text=True) + ids = json.loads(out.stdout or "[]") + # Parse claude-opus-- from any profile id (prefix us./global. ok). + def ver(s): + m = re.search(r"claude-opus-(\d+)-(\d+)", s) + return (int(m.group(1)), int(m.group(2))) if m else None + opus = [(ver(i), i) for i in ids if ver(i) and i.startswith(("us.", "global."))] + if not opus: + print("newer=false"); raise SystemExit(0) + best_ver, best_id = max(opus, key=lambda x: x[0]) + cur = ver(current) + newer = (cur is None) or (best_ver > cur) + print(f"newer={'true' if newer else 'false'}") + print(f"best_id={best_id}") + print(f"best_ver={best_ver[0]}.{best_ver[1]}") + print(f"cur_ver={'unknown' if cur is None else f'{cur[0]}.{cur[1]}'}") + PY + + - name: Open/update issue if a newer model exists + if: steps.check.outputs.newer == 'true' + uses: actions/github-script@v9 + with: + script: | + const best = '${{ steps.check.outputs.best_id }}'; + const bestVer = '${{ steps.check.outputs.best_ver }}'; + const curVer = '${{ steps.check.outputs.cur_ver }}'; + const marker = ''; + const title = `OCR: newer Claude Opus available (${bestVer} > ${curVer})`; + const body = `${marker}\n` + + `A newer Claude Opus inference profile is available on Bedrock.\n\n` + + `- **Configured** (\`vars.OCR_BEDROCK_MODEL\`): Opus ${curVer}\n` + + `- **Newest on Bedrock**: \`${best}\` (Opus ${bestVer})\n\n` + + `To upgrade, set the repo variable:\n\n` + + '```\n' + + `gh variable set OCR_BEDROCK_MODEL -R ${context.repo.owner}/${context.repo.repo} \\\n` + + ` -b "bedrock/converse/${best}"\n` + + '```\n\n' + + `Also confirm the \`ocr-bedrock-ci\` IAM inline policy allows invoking the new model ` + + `(the resource is scoped to \`anthropic.claude-opus-*\`), then re-run OCR.\n\n` + + `_Automated by \`.github/workflows/ocr-model-check.yml\`; this issue is upserted._`; + const q = `repo:${context.repo.owner}/${context.repo.repo} in:body "${marker}" state:open`; + const found = await github.rest.search.issuesAndPullRequests({ q, per_page: 1 }); + if (found.data.total_count > 0) { + const n = found.data.items[0].number; + await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: n, title, body }); + } else { + await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title, body }); + } diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml new file mode 100644 index 0000000000000..0828af429b57c --- /dev/null +++ b/.github/workflows/ocr-review.yml @@ -0,0 +1,427 @@ +# Open Code Review (OCR) — AI PR review backed by AWS Bedrock via a LiteLLM proxy. +# +# Flow: +# PR opened/updated (incl. DRAFTS) ─┐ +# /open-code-review PR comment ─┼─► start LiteLLM (127.0.0.1:4000 → Bedrock) +# manual workflow_dispatch ā”€ā”˜ └► ocr review --format json +# └► post inline PR review comments +# +# Required (repo settings — all repo *variables*, no secrets; auth is via GitHub OIDC): +# vars.AWS_ROLE_ARN - IAM role to assume via OIDC (granting bedrock:InvokeModel*) +# vars.AWS_REGION - e.g. us-east-1 +# vars.OCR_BEDROCK_MODEL - LiteLLM model string for the Opus inference profile, e.g. +# bedrock/converse/us.anthropic.claude-opus-4-8 +# +# No static AWS keys are stored. GITHUB_TOKEN (auto) posts the review comments. + +name: OCR AI Review + +on: + pull_request: + # Note: no draft filter — drafts are reviewed too. + types: [opened, synchronize, reopened, ready_for_review] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to review' + required: true + type: number + +# One review per PR; cancel superseded runs to save Bedrock spend. +concurrency: + group: ocr-review-${{ github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number }} + cancel-in-progress: true + +permissions: + id-token: write # required to mint the GitHub OIDC token for AWS role assumption + contents: read + pull-requests: write + +jobs: + ocr-review: + runs-on: ubuntu-latest + # PR events always; comment events only when the comment is on a PR and + # starts with the trigger keyword; manual dispatch always. + if: | + github.event_name == 'pull_request' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && + (startsWith(github.event.comment.body, '/open-code-review') || + startsWith(github.event.comment.body, '@open-code-review'))) + + env: + # LiteLLM listens on localhost only; this key never leaves the runner. + LITELLM_MASTER_KEY: sk-ocr-ci-local + OCR_BEDROCK_MODEL: ${{ vars.OCR_BEDROCK_MODEL }} + # Region is a static var (safe at job level). AWS credentials are NOT set + # here — they're minted by the OIDC "Configure AWS credentials" step below + # and exported to the environment for the LiteLLM/boto3 Bedrock calls. + AWS_REGION: ${{ vars.AWS_REGION }} + + steps: + - name: Resolve PR context + id: pr + uses: actions/github-script@v9 + with: + script: | + let prNumber; + if (context.eventName === 'pull_request') { + prNumber = context.payload.pull_request.number; + } else if (context.eventName === 'issue_comment') { + prNumber = context.issue.number; + } else { + prNumber = parseInt('${{ github.event.inputs.pr_number }}', 10); + } + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + const { data: repo } = await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + core.setOutput('number', String(prNumber)); + core.setOutput('base_ref', pr.base.ref); + core.setOutput('head_ref', pr.head.ref); + core.setOutput('head_sha', pr.head.sha); + core.setOutput('default_branch', repo.default_branch); + core.setOutput('cross_repo', String(pr.head.repo.full_name !== pr.base.repo.full_name)); + + # NOTE: do NOT checkout the PR head. OCR reads the diff and file contents + # straight from git refs (git diff , git show :path, + # git grep ), so the working tree is irrelevant — but our OCR config + # lives on the default branch, not on the PR branch. We check out the repo + # (default ref), fetch the base/head objects, and materialize the config + # from origin/. + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Prepare git refs and OCR config + env: + BASE_REF: ${{ steps.pr.outputs.base_ref }} + HEAD_REF: ${{ steps.pr.outputs.head_ref }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + DEFAULT_BRANCH: ${{ steps.pr.outputs.default_branch }} + run: | + git fetch --no-tags origin "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" || true + git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true + git fetch --no-tags origin "+refs/heads/${HEAD_REF}:refs/remotes/origin/${HEAD_REF}" || true + git fetch --no-tags origin "${HEAD_SHA}" || true + + # OCR config lives on the default branch; materialize it independently + # of whatever ref is checked out. + mkdir -p "$RUNNER_TEMP/ocr" + git show "origin/${DEFAULT_BRANCH}:.github/ocr/litellm.yaml" > "$RUNNER_TEMP/ocr/litellm.yaml" + git show "origin/${DEFAULT_BRANCH}:.github/ocr/rule.json" > "$RUNNER_TEMP/ocr/rule.json" + git show "origin/${DEFAULT_BRANCH}:.github/ocr/context.md" > "$RUNNER_TEMP/ocr/context.md" + echo "Config materialized:"; ls -l "$RUNNER_TEMP/ocr" + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Install LiteLLM proxy + Open Code Review + run: | + python -m pip install --upgrade pip + # Pin LiteLLM to a main commit that supports Claude Opus 4.8 adaptive + # thinking (maps reasoning_effort -> output_config.effort, incl. xhigh). + # Not in any tagged release yet (PyPI latest 1.87.1 lacks the Opus + # normalizer). Bump this SHA once a release ships the feature. + pip install "litellm[proxy] @ git+https://github.com/BerriAI/litellm.git@5be0797d24a2f26eb2123e13788f90055a59d91d" + npm install -g @alibaba-group/open-code-review + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + role-session-name: ocr-review-${{ github.run_id }} + + - name: Start LiteLLM proxy (Bedrock bridge) + run: | + if [ -z "$OCR_BEDROCK_MODEL" ]; then + echo "::error::vars.OCR_BEDROCK_MODEL is not set (e.g. bedrock/converse/us.anthropic.claude-opus-4-1-20250805-v1:0)" + exit 1 + fi + nohup litellm --config "$RUNNER_TEMP/ocr/litellm.yaml" --host 127.0.0.1 --port 4000 \ + > /tmp/litellm.log 2>&1 & + echo "Waiting for LiteLLM to become ready..." + for i in $(seq 1 60); do + if curl -sf http://127.0.0.1:4000/health/readiness >/dev/null; then + echo "LiteLLM ready."; exit 0 + fi + sleep 2 + done + echo "::error::LiteLLM did not become ready in time"; cat /tmp/litellm.log; exit 1 + + - name: Configure OCR + run: | + ocr config set llm.url http://127.0.0.1:4000/v1/chat/completions + ocr config set llm.auth_token "$LITELLM_MASTER_KEY" + ocr config set llm.model ocr-bedrock + ocr config set llm.use_anthropic false + ocr config set language English + + - name: Run OCR review + run: | + ocr review \ + --from "origin/${{ steps.pr.outputs.base_ref }}" \ + --to "${{ steps.pr.outputs.head_sha }}" \ + --rule "$RUNNER_TEMP/ocr/rule.json" \ + --background-file "$RUNNER_TEMP/ocr/context.md" \ + --concurrency 3 \ + --timeout 20 \ + --format json \ + > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true + echo "----- OCR stdout -----"; cat /tmp/ocr-result.json || true + echo "----- OCR stderr -----"; cat /tmp/ocr-stderr.log || true + echo "----- LiteLLM log (tail) -----"; tail -n 50 /tmp/litellm.log || true + + - name: Post review to PR + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + const prNumber = parseInt('${{ steps.pr.outputs.number }}', 10); + const commitSha = '${{ steps.pr.outputs.head_sha }}'; + + // Opus at high effort can emit dozens of findings. Posting them all + // one-by-one trips GitHub's SECONDARY rate limit (403 "content + // creation"), which is what made every run fail after the review + // was already generated. We (a) cap inline comments and overflow + // the rest into the summary, (b) prefer a single bulk createReview, + // and (c) throttle + back off with Retry-After on any fallback. + const MAX_INLINE = 25; + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + + async function withRetry(fn, label) { + for (let attempt = 1; attempt <= 5; attempt++) { + try { return await fn(); } + catch (e) { + const status = e.status || (e.response && e.response.status); + const h = (e.response && e.response.headers) || {}; + const isRate = status === 403 || status === 429; + if (!isRate || attempt === 5) throw e; + let waitMs = 0; + if (h['retry-after']) waitMs = parseInt(h['retry-after'], 10) * 1000; + else if (h['x-ratelimit-reset']) waitMs = parseInt(h['x-ratelimit-reset'], 10) * 1000 - Date.now(); + if (!waitMs || Number.isNaN(waitMs) || waitMs < 0) waitMs = 1000 * Math.pow(2, attempt); + waitMs = Math.min(waitMs, 60000) + 500; + core.warning(`${label}: rate-limited (status ${status}); waiting ${Math.round(waitMs / 1000)}s (attempt ${attempt}/5)`); + await sleep(waitMs); + } + } + } + + let result; + try { + result = JSON.parse(fs.readFileSync('/tmp/ocr-result.json', 'utf8')); + } catch (e) { + const stderr = (() => { try { return fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim(); } catch { return ''; } })(); + await withRetry(() => github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, + body: `āš ļø **OCR** could not produce a review.\n\n\`\`\`\n${(stderr || e.message).slice(0, 8000)}\n\`\`\``, + }), 'error-comment'); + return; + } + + const comments = result.comments || []; + const warnings = result.warnings || []; + + const formatComment = (c) => { + let body = c.content || ''; + if (c.suggestion_code && c.existing_code) { + body += '\n\n```suggestion\n' + c.suggestion_code + (c.suggestion_code.endsWith('\n') ? '' : '\n') + '```'; + } + return body; + }; + const formatMarkdown = (c) => { + let md = `### šŸ“„ \`${c.path}\``; + if (c.start_line && c.end_line) md += ` (L${c.start_line}-L${c.end_line})`; + md += '\n\n' + (c.content || ''); + if (c.suggestion_code && c.existing_code) { + md += '\n\n
šŸ’” Suggested change\n\n'; + md += '**Before:**\n```\n' + c.existing_code + '\n```\n\n**After:**\n```\n' + c.suggestion_code + '\n```\n\n
'; + } + return md; + }; + + if (comments.length === 0) { + await withRetry(() => github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, + body: `āœ… **OCR**: ${result.message || 'No issues found.'}`, + }), 'no-issues-comment'); + return; + } + + const inlineAll = []; + const noLine = []; + for (const c of comments) { + const body = formatComment(c); + const hasLine = (c.start_line >= 1) || (c.end_line >= 1); + if (!hasLine) { noLine.push(c); continue; } + const rc = { path: c.path, body, side: 'RIGHT' }; + if (c.start_line >= 1 && c.end_line >= 1 && c.start_line !== c.end_line) { + rc.start_line = c.start_line; rc.line = c.end_line; rc.start_side = 'RIGHT'; + } else { + rc.line = c.end_line >= 1 ? c.end_line : c.start_line; + } + inlineAll.push({ rc, c }); + } + + const inline = inlineAll.slice(0, MAX_INLINE).map(x => x.rc); + const overflow = inlineAll.slice(MAX_INLINE).map(x => x.c); + + let summary = `šŸ” **OCR** found **${comments.length}** issue(s).`; + summary += `\n- ${inline.length} inline, ${noLine.length + overflow.length} in summary`; + if (overflow.length) summary += ` (inline capped at ${MAX_INLINE})`; + if (warnings.length) summary += `\n- āš ļø ${warnings.length} warning(s) during review`; + for (const c of noLine.concat(overflow)) summary += '\n\n---\n\n' + formatMarkdown(c); + + // Preferred path: ONE createReview carrying every inline comment. + try { + await withRetry(() => github.rest.pulls.createReview({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, + commit_id: commitSha, body: summary, event: 'COMMENT', comments: inline, + }), 'bulk-review'); + return; + } catch (e) { + core.warning(`bulk createReview failed (${e.status || '?'}: ${e.message}); falling back to throttled per-comment posting`); + } + + // Fallback: an invalid inline position (line not in the diff -> 422) + // rejects the whole bulk review. Post the summary, then each comment + // individually with a delay + backoff, skipping ones GitHub rejects. + let ok = 0; const failed = []; + try { + await withRetry(() => github.rest.pulls.createReview({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, + commit_id: commitSha, body: summary, event: 'COMMENT', + }), 'summary-review'); + } catch (err) { failed.push(`summary: ${err.message}`); } + + for (const rc of inline) { + try { + await withRetry(() => github.rest.pulls.createReviewComment({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, + commit_id: commitSha, path: rc.path, body: rc.body, + ...(rc.start_line ? { start_line: rc.start_line, start_side: rc.start_side } : {}), + line: rc.line, side: rc.side, + }), `comment ${rc.path}:${rc.line}`); + ok++; + } catch (inner) { + failed.push(`\`${rc.path}\` L${rc.line}: ${inner.message}`); + } + await sleep(1200); // stay under the secondary content-creation limit + } + + if (failed.length) { + await withRetry(() => github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, + body: `šŸ“Š OCR posted ${ok}/${inline.length} inline comment(s).\n\n
${failed.length} could not be posted\n\n${failed.join('\n')}\n
`, + }), 'summary-failures'); + } + + # Companion job: OCR can't call MCP, so this separate agent ties the PR's + # changes to PostgreSQL git + pgsql-hackers history via the Agora MCP server + # (pg.ddx.io) and posts a single, upserted "history & discussion" comment. + pg-history: + runs-on: ubuntu-latest + if: | + github.event_name == 'pull_request' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && + (startsWith(github.event.comment.body, '/open-code-review') || + startsWith(github.event.comment.body, '@open-code-review') || + startsWith(github.event.comment.body, '/pg-history'))) + steps: + - name: Resolve PR context + id: pr + uses: actions/github-script@v9 + with: + script: | + let prNumber; + if (context.eventName === 'pull_request') prNumber = context.payload.pull_request.number; + else if (context.eventName === 'issue_comment') prNumber = context.issue.number; + else prNumber = parseInt('${{ github.event.inputs.pr_number }}', 10); + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber }); + core.setOutput('number', String(prNumber)); + core.setOutput('base_ref', pr.base.ref); + core.setOutput('head_sha', pr.head.sha); + core.setOutput('title', pr.title || ''); + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Make base/head refs available + env: + BASE_REF: ${{ steps.pr.outputs.base_ref }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: | + git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true + git fetch --no-tags origin "${HEAD_SHA}" || true + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + role-session-name: pg-history-${{ github.run_id }} + + - name: Install deps + run: pip install boto3 + + - name: Run pg-history (Agora MCP) + env: + PG_HISTORY_MODEL: ${{ vars.OCR_BEDROCK_MODEL }} + AWS_REGION: ${{ vars.AWS_REGION }} + BASE_REF: ${{ steps.pr.outputs.base_ref }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + GH_PR_TITLE: ${{ steps.pr.outputs.title }} + PG_HISTORY_OUT: ${{ runner.temp }}/pg-history.md + run: | + python .github/ocr/pg-history.py || true + echo "----- output -----"; cat "${{ runner.temp }}/pg-history.md" 2>/dev/null || echo "(no output)" + + - name: Upsert PR comment + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const path = process.env.RUNNER_TEMP + '/pg-history.md'; + let body = ''; + try { body = fs.readFileSync(path, 'utf8').trim(); } catch (e) {} + if (!body) { console.log('pg-history: empty output, nothing to post'); return; } + const prNumber = parseInt('${{ steps.pr.outputs.number }}', 10); + const marker = ''; + body = marker + '\n' + body; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, per_page: 100 }); + const mine = comments.find(c => c.user.type === 'Bot' && c.body && c.body.includes(marker)); + if (mine) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, repo: context.repo.repo, comment_id: mine.id, body }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body }); + } diff --git a/.github/workflows/sync-upstream-manual.yml b/.github/workflows/sync-upstream-manual.yml new file mode 100644 index 0000000000000..3139e517f409f --- /dev/null +++ b/.github/workflows/sync-upstream-manual.yml @@ -0,0 +1,252 @@ +name: Sync from Upstream (Manual) + +on: + workflow_dispatch: + inputs: + force_push: + description: 'Use --force-with-lease when pushing' + required: false + type: boolean + default: true + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + # PAT with repo + workflow scope. The default GITHUB_TOKEN cannot push + # commits that touch .github/workflows/ (platform block). See SYNC_TOKEN + # repo secret. + token: ${{ secrets.SYNC_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/postgres/postgres.git || true + git remote -v + + - name: Fetch upstream + run: | + echo "Fetching from upstream postgres/postgres..." + git fetch upstream master + echo "Current local master:" + git log origin/master --oneline -5 + echo "Upstream master:" + git log upstream/master --oneline -5 + + - name: Check for local commits + id: check_commits + run: | + git checkout master + LOCAL_COMMITS=$(git rev-list origin/master..upstream/master --count) + DIVERGED=$(git rev-list upstream/master..origin/master --count) + echo "commits_behind=$LOCAL_COMMITS" >> $GITHUB_OUTPUT + echo "commits_ahead=$DIVERGED" >> $GITHUB_OUTPUT + echo "Mirror is $DIVERGED commits ahead and $LOCAL_COMMITS commits behind upstream" + + if [ "$DIVERGED" -gt 0 ]; then + # Check commit messages for "dev setup" or "dev v" pattern + DEV_SETUP_COMMITS=$(git log --format=%s upstream/master...origin/master | grep -iE "^dev (setup|v[0-9])" | wc -l) + echo "dev_setup_commits=$DEV_SETUP_COMMITS" >> $GITHUB_OUTPUT + + # Check if diverged commits only touch .github/ directory + NON_GITHUB_CHANGES=$(git diff --name-only upstream/master...origin/master | grep -v "^\.github/" | wc -l) + echo "non_github_changes=$NON_GITHUB_CHANGES" >> $GITHUB_OUTPUT + + if [ "$NON_GITHUB_CHANGES" -eq 0 ]; then + echo "āœ“ All local commits are CI/CD configuration (.github/ only)" + elif [ "$DEV_SETUP_COMMITS" -gt 0 ]; then + echo "āœ“ Found $DEV_SETUP_COMMITS 'dev setup/version' commit(s)" + else + echo "āš ļø WARNING: Local commits modify files outside .github/ and are not 'dev setup/version' commits!" + git diff --name-only upstream/master...origin/master | grep -v "^\.github/" || true + fi + else + echo "non_github_changes=0" >> $GITHUB_OUTPUT + echo "dev_setup_commits=0" >> $GITHUB_OUTPUT + fi + + - name: Attempt merge + id: merge + run: | + COMMITS_AHEAD=${{ steps.check_commits.outputs.commits_ahead }} + COMMITS_BEHIND=${{ steps.check_commits.outputs.commits_behind }} + NON_GITHUB_CHANGES=${{ steps.check_commits.outputs.non_github_changes }} + DEV_SETUP_COMMITS=${{ steps.check_commits.outputs.dev_setup_commits }} + + # Check if there are problematic local commits + # Allow commits if: + # 1. Only .github/ changes (CI/CD config) + # 2. Has "dev setup/version" commits (personal development environment) + if [ "$COMMITS_AHEAD" -gt 0 ] && [ "$NON_GITHUB_CHANGES" -gt 0 ]; then + if [ "$DEV_SETUP_COMMITS" -eq 0 ]; then + echo "āŒ Local master has commits outside .github/ that are not 'dev setup/version' commits!" + echo "merge_status=conflict" >> $GITHUB_OUTPUT + exit 1 + else + echo "āœ“ Non-.github/ changes are from 'dev setup/version' commits - allowed" + fi + fi + + # Already up to date + if [ "$COMMITS_BEHIND" -eq 0 ]; then + echo "āœ“ Already up to date with upstream" + echo "merge_status=uptodate" >> $GITHUB_OUTPUT + exit 0 + fi + + # Try fast-forward first (clean case) + if [ "$COMMITS_AHEAD" -eq 0 ]; then + echo "Fast-forwarding to upstream (no local commits)..." + git merge --ff-only upstream/master + echo "merge_status=success" >> $GITHUB_OUTPUT + exit 0 + fi + + # Local commits exist (.github/ and/or dev setup/version) - rebase onto upstream + if [ "$DEV_SETUP_COMMITS" -gt 0 ]; then + echo "Rebasing local CI/CD and dev setup/version commits onto upstream..." + else + echo "Rebasing local CI/CD commits (.github/ only) onto upstream..." + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git rebase upstream/master; then + echo "āœ“ Successfully rebased local commits onto upstream" + echo "merge_status=success" >> $GITHUB_OUTPUT + else + echo "āŒ Rebase conflict occurred" + echo "merge_status=conflict" >> $GITHUB_OUTPUT + + # Abort the failed rebase to clean up state + git rebase --abort + exit 1 + fi + continue-on-error: true + + - name: Push to origin + if: steps.merge.outputs.merge_status == 'success' + run: | + if [ "${{ inputs.force_push }}" == "true" ]; then + git push origin master --force-with-lease + else + git push origin master + fi + echo "āœ“ Successfully synced master with upstream" + + - name: Create issue on failure + if: steps.merge.outputs.merge_status == 'conflict' + uses: actions/github-script@v7 + with: + script: | + const title = '🚨 Upstream Sync Failed - Manual Intervention Required'; + const body = `## Sync Failure Report + + The automated sync from \`postgres/postgres\` failed due to conflicting commits. + + **Details:** + - Local master has ${{ steps.check_commits.outputs.commits_ahead }} commit(s) not in upstream + - Upstream has ${{ steps.check_commits.outputs.commits_behind }} new commit(s) + - Non-.github/ changes: ${{ steps.check_commits.outputs.non_github_changes }} files + + **This indicates commits were made directly to master outside .github/**, which violates the pristine mirror policy. + + **Note:** Commits to .github/ (CI/CD configuration) are allowed and will be preserved during sync. + + ### Resolution Steps: + + 1. Identify the conflicting commits: + \`\`\`bash + git fetch origin + git fetch upstream https://github.com/postgres/postgres.git master + git log upstream/master..origin/master + \`\`\` + + 2. If these commits should be preserved: + - Create a feature branch: \`git checkout -b recovery/master-commits origin/master\` + - Reset master: \`git checkout master && git reset --hard upstream/master\` + - Push: \`git push origin master --force\` + - Cherry-pick or rebase the feature branch + + 3. If these commits should be discarded: + - Reset master: \`git checkout master && git reset --hard upstream/master\` + - Push: \`git push origin master --force\` + + 4. Close this issue once resolved + + **Workflow run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + `; + + // Check if issue already exists + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'sync-failure' + }); + + if (issues.data.length === 0) { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['sync-failure', 'automation'] + }); + } + + - name: Close existing sync-failure issues + if: steps.merge.outputs.merge_status == 'success' + uses: actions/github-script@v7 + with: + script: | + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'sync-failure' + }); + + for (const issue of issues.data) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: 'āœ“ Sync successful - closing this issue automatically.' + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed' + }); + } + + - name: Summary + if: always() + run: | + echo "### Sync Summary" >> $GITHUB_STEP_SUMMARY + echo "- **Status:** ${{ steps.merge.outputs.merge_status }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commits behind:** ${{ steps.check_commits.outputs.commits_behind }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commits ahead:** ${{ steps.check_commits.outputs.commits_ahead }}" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.merge.outputs.merge_status }}" == "success" ]; then + echo "- **Result:** āœ“ Successfully synced with upstream" >> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.merge.outputs.merge_status }}" == "uptodate" ]; then + echo "- **Result:** āœ“ Already up to date" >> $GITHUB_STEP_SUMMARY + else + echo "- **Result:** āš ļø Sync failed - manual intervention required" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 0000000000000..eca408a7a11c5 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,260 @@ +name: Sync from Upstream (Automatic) + +on: + schedule: + # Run hourly every day + - cron: '0 * * * *' + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + # PAT with repo + workflow scope. The default GITHUB_TOKEN cannot push + # commits that touch .github/workflows/ (platform block). See SYNC_TOKEN + # repo secret. Falls back to GITHUB_TOKEN if unset (push will fail on + # workflow-file changes, but non-workflow syncs still work). + token: ${{ secrets.SYNC_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream remote + run: | + git remote add upstream https://github.com/postgres/postgres.git || true + git remote -v + + - name: Fetch upstream + run: | + echo "Fetching from upstream postgres/postgres..." + git fetch upstream master + + - name: Check for local commits + id: check_commits + run: | + git checkout master + LOCAL_COMMITS=$(git rev-list origin/master..upstream/master --count) + DIVERGED=$(git rev-list upstream/master..origin/master --count) + echo "commits_behind=$LOCAL_COMMITS" >> $GITHUB_OUTPUT + echo "commits_ahead=$DIVERGED" >> $GITHUB_OUTPUT + + if [ "$LOCAL_COMMITS" -eq 0 ]; then + echo "āœ“ Already up to date with upstream" + else + echo "Mirror is $LOCAL_COMMITS commits behind upstream" + fi + + if [ "$DIVERGED" -gt 0 ]; then + echo "āš ļø Local master has $DIVERGED commits not in upstream" + + # Check commit messages for "dev setup" or "dev v" pattern + DEV_SETUP_COMMITS=$(git log --format=%s upstream/master..origin/master | grep -iE "^dev (setup|v[0-9])" | wc -l) + echo "dev_setup_commits=$DEV_SETUP_COMMITS" >> $GITHUB_OUTPUT + + # Check if diverged commits only touch .github/ directory + NON_GITHUB_CHANGES=$(git diff --name-only upstream/master...origin/master | grep -v "^\.github/" | wc -l) + echo "non_github_changes=$NON_GITHUB_CHANGES" >> $GITHUB_OUTPUT + + if [ "$NON_GITHUB_CHANGES" -eq 0 ]; then + echo "āœ“ All local commits are CI/CD configuration (.github/ only) - will merge" + elif [ "$DEV_SETUP_COMMITS" -gt 0 ]; then + echo "āœ“ Found $DEV_SETUP_COMMITS 'dev setup/version' commit(s)" + else + echo "āš ļø WARNING: Local commits modify files outside .github/ and are not 'dev setup/version' commits!" + git diff --name-only upstream/master...origin/master | grep -v "^\.github/" || true + echo "Non-dev commits:" + git log --format=" %h %s" upstream/master..origin/master | grep -ivE "^ [a-f0-9]* dev (setup|v[0-9])" || true + fi + else + echo "non_github_changes=0" >> $GITHUB_OUTPUT + echo "dev_setup_commits=0" >> $GITHUB_OUTPUT + fi + + - name: Attempt merge + id: merge + run: | + COMMITS_AHEAD=${{ steps.check_commits.outputs.commits_ahead }} + COMMITS_BEHIND=${{ steps.check_commits.outputs.commits_behind }} + NON_GITHUB_CHANGES=${{ steps.check_commits.outputs.non_github_changes }} + DEV_SETUP_COMMITS=${{ steps.check_commits.outputs.dev_setup_commits }} + + # Check if there are problematic local commits + # Allow commits if: + # 1. Only .github/ changes (CI/CD config) + # 2. Has "dev setup/version" commits (personal development environment) + if [ "$COMMITS_AHEAD" -gt 0 ] && [ "$NON_GITHUB_CHANGES" -gt 0 ]; then + if [ "$DEV_SETUP_COMMITS" -eq 0 ]; then + echo "āŒ Local master has commits outside .github/ that are not 'dev setup/version' commits!" + echo "merge_status=conflict" >> $GITHUB_OUTPUT + exit 1 + else + echo "āœ“ Non-.github/ changes are from 'dev setup/version' commits - allowed" + fi + fi + + # Already up to date + if [ "$COMMITS_BEHIND" -eq 0 ]; then + echo "āœ“ Already up to date with upstream" + echo "merge_status=uptodate" >> $GITHUB_OUTPUT + exit 0 + fi + + # Try fast-forward first (clean case) + if [ "$COMMITS_AHEAD" -eq 0 ]; then + echo "Fast-forwarding to upstream (no local commits)..." + git merge --ff-only upstream/master + echo "merge_status=success" >> $GITHUB_OUTPUT + exit 0 + fi + + # Local commits exist (.github/ and/or dev setup/version) - rebase onto upstream + if [ "$DEV_SETUP_COMMITS" -gt 0 ]; then + echo "Rebasing local CI/CD and dev setup/version commits onto upstream..." + else + echo "Rebasing local CI/CD commits (.github/ only) onto upstream..." + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git rebase upstream/master; then + echo "āœ“ Successfully rebased local commits onto upstream" + echo "merge_status=success" >> $GITHUB_OUTPUT + else + echo "āŒ Rebase conflict occurred" + echo "merge_status=conflict" >> $GITHUB_OUTPUT + + # Abort the failed rebase to clean up state + git rebase --abort + exit 1 + fi + continue-on-error: true + + - name: Push to origin + if: steps.merge.outputs.merge_status == 'success' + run: | + git push origin master --force-with-lease + + COMMITS_SYNCED="${{ steps.check_commits.outputs.commits_behind }}" + echo "āœ“ Successfully synced $COMMITS_SYNCED commits from upstream" + + - name: Create issue on failure + if: steps.merge.outputs.merge_status == 'conflict' + uses: actions/github-script@v7 + with: + script: | + const title = '🚨 Automated Upstream Sync Failed'; + const body = `## Automatic Sync Failure + + The daily sync from \`postgres/postgres\` failed. + + **Details:** + - Local master has ${{ steps.check_commits.outputs.commits_ahead }} commit(s) not in upstream + - Upstream has ${{ steps.check_commits.outputs.commits_behind }} new commit(s) + - Non-.github/ changes: ${{ steps.check_commits.outputs.non_github_changes }} files + - **Run date:** ${new Date().toISOString()} + + **Root cause:** Commits were made directly to master outside of .github/, which violates the pristine mirror policy. + + **Note:** Commits to .github/ (CI/CD configuration) are allowed and will be preserved during sync. + + ### Resolution Steps: + + 1. Review the conflicting commits: + \`\`\`bash + git log upstream/master..origin/master --oneline + \`\`\` + + 2. Determine if commits should be: + - **Preserved:** Create feature branch and reset master + - **Discarded:** Hard reset master to upstream + + 3. See [sync documentation](.github/docs/sync-setup.md) for detailed recovery procedures + + 4. Run manual sync workflow after resolution to verify + + **Workflow run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + `; + + // Check if issue already exists + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'sync-failure' + }); + + if (issues.data.length === 0) { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['sync-failure', 'automation', 'urgent'] + }); + } else { + // Update existing issue + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issues.data[0].number, + body: `Sync failed again on ${new Date().toISOString()}\n\nWorkflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}` + }); + } + + - name: Close sync-failure issues + if: steps.merge.outputs.merge_status == 'success' + uses: actions/github-script@v7 + with: + script: | + const issues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'sync-failure' + }); + + for (const issue of issues.data) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `āœ“ Automatic sync successful on ${new Date().toISOString()} - synced ${{ steps.check_commits.outputs.commits_behind }} commits.\n\nClosing issue automatically.` + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed' + }); + } + + - name: Summary + if: always() + run: | + echo "### Daily Sync Summary" >> $GITHUB_STEP_SUMMARY + echo "- **Date:** $(date -u)" >> $GITHUB_STEP_SUMMARY + echo "- **Status:** ${{ steps.merge.outputs.merge_status }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commits synced:** ${{ steps.check_commits.outputs.commits_behind }}" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.merge.outputs.merge_status }}" == "success" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "āœ“ Mirror successfully updated with upstream postgres/postgres" >> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.merge.outputs.merge_status }}" == "uptodate" ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "āœ“ Mirror already up to date" >> $GITHUB_STEP_SUMMARY + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "āš ļø Sync failed - check created issue for details" >> $GITHUB_STEP_SUMMARY + fi diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml index 66ea8b988a18e..d3d5e4e5b4199 100644 --- a/doc/src/sgml/filelist.sgml +++ b/doc/src/sgml/filelist.sgml @@ -49,6 +49,8 @@ + + diff --git a/doc/src/sgml/fileops.sgml b/doc/src/sgml/fileops.sgml new file mode 100644 index 0000000000000..fc7730e2b6ec5 --- /dev/null +++ b/doc/src/sgml/fileops.sgml @@ -0,0 +1,394 @@ + + + + Transactional File Operations + + + transactional file operations + + + + FILEOPS + + + + PostgreSQL includes a transactional file + operations layer (FILEOPS) that makes filesystem operations such as + file creation, deletion, renaming, and truncation atomic with the + enclosing database transaction. These operations are WAL-logged + via the RM_FILEOPS_ID resource manager and + replayed correctly during crash recovery and on standbys. + + + + Overview + + + Without FILEOPS, filesystem operations during CREATE + TABLE or DROP TABLE are not truly + transactional — a crash between the catalog update and the + file operation can leave orphaned files or missing files. The + FILEOPS layer addresses this by: + + + + + + Writing a WAL record before performing the filesystem operation. + + + + + Deferring destructive operations (deletion) until transaction + commit. + + + + + Registering undo actions (delete-on-abort for newly created files) + that execute automatically if the transaction rolls back. + + + + + + + Configuration + + + Transactional file operations are active whenever WAL is being written + for the operation, that is, when XLogIsNeeded() is + true (any wal_level above minimal). + At wal_level = minimal the WAL + records described below are not emitted, matching the rest of the system. + When active, filesystem operations performed within a transaction context + are WAL-logged and integrated with the transaction lifecycle, including + UNDO records for crash-safe rollback. + + + + + Supported Operations + + + + File Creation + + + When a new relation file is created (e.g., during + CREATE TABLE), a + XLOG_FILEOPS_CREATE WAL record is written. + If the transaction aborts, the file is automatically deleted. + + + + + + File Deletion + + + File deletion (e.g., during DROP TABLE) is + deferred until transaction commit. A + XLOG_FILEOPS_DELETE WAL record is written. + If the transaction aborts, the file remains intact. + + + + + + File Move/Rename + + + File renames are WAL-logged via + XLOG_FILEOPS_RENAME. This ensures renames + are replayed during crash recovery. + + + + + + File Truncation + + + File truncations are WAL-logged via + XLOG_FILEOPS_TRUNCATE. The old size is + recorded for potential undo operations. + + + + + + + + Platform-Specific Behavior + + + The FILEOPS implementation includes platform-specific handling for + filesystem differences. On all platforms, parent directory + fsync is performed after file creation or + deletion to ensure directory entry durability. + + + + On systems with copy-on-write filesystems (e.g., ZFS, Btrfs), + the FILEOPS layer respects the existing + data_sync_retry setting for handling + fsync failures. + + + + + Crash Recovery + + + During crash recovery, the FILEOPS resource manager replays + operations from the WAL: + + + + + + CREATE records: re-create the file if it + does not exist. + + + + + DELETE records: perform the deferred deletion. + + + + + RENAME records: re-apply the rename operation. + + + + + TRUNCATE records: re-apply the truncation. + + + + + + On standbys, FILEOPS WAL records are replayed identically, ensuring + that the standby's filesystem state matches the primary's. + + + + + Coverage + + + FILEOPS wraps the filesystem mutations that previously bypassed + crash safety in commands that are otherwise transactional. Routed + through FILEOPS as of this release: + + + + + + CREATE DATABASE with STRATEGY = + FILE_COPY (and the clone path) wraps + the destination tree via copydir; a + transaction abort recursively removes the partial copy. + + + + + CREATE TABLESPACE uses + FileOpsMkdir, + FileOpsSymlink, and + FileOpsChmod on the user-supplied location; + all are reversed on transaction abort. + + + + + DROP TABLESPACE defers the per-database + subdirectory removals, the version-directory removal, and the + symlink unlink to commit time, so a transaction abort restores + the tablespace's filesystem state even after the catalog row has + been processed. This closes a long-standing crash-asymmetry + between CREATE TABLESPACE (which used FILEOPS) + and DROP TABLESPACE (which did not). + + + + + ALTER DATABASE ... SET TABLESPACE wraps the + destination tree through copydir, so a + transaction abort recursively removes the partial copy in the + new tablespace. The source tree is preserved until commit by the + existing movedb_failure_callback. + + + + + File-level mutations (FileOpsCreate, + FileOpsDelete, + FileOpsRename, + FileOpsTruncate, + FileOpsChmod, + FileOpsChown, + FileOpsMkdir, + FileOpsRmdir, + FileOpsRmdirRecursive, + FileOpsSymlink, + FileOpsLink, + FileOpsSetXattr, + FileOpsRemoveXattr) are available to + extension authors needing crash-safe filesystem operations. + + + + + + Operations explicitly NOT routed through FILEOPS, with rationale, + include WAL files (their durability is the WAL itself), replication + slot directories (intentionally non-transactional, recoverable via + slot startup logic), checkpoint state files + (pg_logical/snapshots, replication origin + state, reorderbuffer spills — all reconstructible from WAL), + SLRU segments (clog/multixact/etc, reconstructed from WAL or + advisory), 2PC state files (WAL-driven), the relation map + (WAL-logged via RM_RELMAP_ID), and statistics + files (explicitly non-durable). + + + + + Platform Portability + + + FILEOPS is designed to work on every platform + PostgreSQL supports. Behavior differences by + platform: + + + + + + Linux, FreeBSD, macOS: native POSIX calls + (unlink, rename, + chmod, symlink, + setxattr) with + fsync of file and parent directory at the + appropriate points. The xattr layer + (src/port/pg_xattr.c) wraps the per-platform + differences (Linux setxattr, BSD + extattr_set_file, macOS + setxattr with namespace flag). + + + + + Windows: filesystem operations use the + PostgreSQL Windows compatibility layer + (pgrename, pgunlink, + pgsymlink) which retries on transient + EACCES/ERROR_SHARING_VIOLATION + conditions specific to NTFS. Symbolic links use NTFS junction + points via DeviceIoControl; junctions only + work for directories, which matches FILEOPS's only use case + (tablespace symlinks). chmod on Windows is + advisory (only the read-only bit is meaningful); FILEOPS records + the WAL and UNDO entries unconditionally so behavior is + consistent across platforms even if the on-disk effect is + limited. Extended attributes on Windows are stored in alternate + data streams (NTFS-only), via the + src/port/pg_xattr.c Win32 backend. + Directory fsync is a no-op on NTFS (NTFS + journals directory entries), which + fsync_fname_ext already handles. + + + + + + + Relationship to <literal>pendingDeletes</literal> + + + PostgreSQL has had a separate per-backend + pendingDeletes list in + src/backend/catalog/storage.c long before + FILEOPS existed. pendingDeletes tracks the + RelFileLocator identities of relations that need + to be unlinked at commit (drops) or abort (creates), + coordinates the unlinks with the buffer manager via + DropRelationsAllBuffers, and serializes the + list into xl_xact_commit / + xl_xact_abort records so standby replay can do + the corresponding unlinks. + + + + FILEOPS does NOT replace pendingDeletes in this + release; the two coexist. pendingDeletes continues to + handle relation-file unlinks on commit/abort. A future integration could + let FILEOPS clean up relation-keyed extended attributes when + smgrDoPendingDeletes drops a relation, but no such + hook exists today (no caller stores xattrs on relation files), so nothing + in catalog/storage.c calls into FILEOPS for this yet. + + + + FILEOPS could eventually subsume parts of the + pendingDeletes machinery, providing a single + uniform commit/abort hook for filesystem mutations. This is + deferred for the following reasons: + + + + + + xl_xact_commit carries + XACT_XINFO_HAS_RELFILELOCATORS plus an embedded + RelFileLocator array as the per-commit unlink + list. Replacing that with separate + XLOG_FILEOPS_DELETE records would change the + wire format consumed by logical decoding, + pg_basebackup, + pg_receivewal, and downstream + replication tools that parse commit records. Such a change + requires a deprecation cycle and pg_upgrade coordination. + + + + + smgrunlink is segment-aware: a single + relation drop iterates forks (main, fsm, vm, init) and segment + files (1 GB segments) and coordinates with the buffer manager. + FILEOPS is path-string oriented and would either need to + reimplement segment walking and buffer-pool teardown, or call + back into smgr — at which point the + abstraction is no clearer than the current + pendingDeletes list. + + + + + The wal_level = minimal optimization in + smgrDoPendingSyncs cross-references + pendingDeletes to skip syncing relations + about to be dropped. Replacing + pendingDeletes requires either preserving that + cross-reference or accepting the cost of redundant fsyncs in the + minimal case. + + + + + + A future patch can introduce + FileOpsRelationDrop(rlocator) as an opt-in + alternative to RelationDropStorage that emits + XLOG_FILEOPS_DELETE records per fork and + segment, with a corresponding xl_xact_commit + format flag indicating “this xact has FILEOPS delete records, + do not consult the embedded relfilelocator array.” That + work is out of scope for the initial submission. + + + + diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml index 2101442c90fcb..447e9f6e1771a 100644 --- a/doc/src/sgml/postgres.sgml +++ b/doc/src/sgml/postgres.sgml @@ -164,6 +164,8 @@ break is not needed in a wider output rendering. &high-availability; &monitoring; &wal; + &undo; + &fileops; &logical-replication; &jit; ®ress; diff --git a/doc/src/sgml/undo.sgml b/doc/src/sgml/undo.sgml new file mode 100644 index 0000000000000..bd11d3678599d --- /dev/null +++ b/doc/src/sgml/undo.sgml @@ -0,0 +1,738 @@ + + + + UNDO Logging + + + UNDO logging + + + + PostgreSQL provides an optional UNDO logging + system that records the inverse of data modifications to heap tables. + This enables two capabilities: transaction rollback using stored UNDO + records with full crash recovery and standby replay support, and + point-in-time recovery of pruned tuple data using the + pg_undorecover utility. + + + + The UNDO infrastructure must first be enabled cluster-wide by setting + the enable_undo GUC to on in + postgresql.conf (requires a server restart). + Individual tables then opt in via the enable_undo + storage parameter. When the server-level GUC is off (the default), + there is zero overhead on normal heap operations. + + + + The UNDO system uses a physical approach to + transaction rollback: rather than replaying high-level operations in + reverse, it restores the original page bytes directly. Each rollback + operation generates a WAL record (called a Compensation Log Record, or + CLR) that ensures correct replay on standbys and during crash recovery. + + + + Enabling UNDO Logging + + + First, enable the UNDO infrastructure at the server level in + postgresql.conf (requires restart): + + + +enable_undo = on + + + + Then enable UNDO logging on individual tables using the + enable_undo storage parameter: + + + +-- Enable at table creation +CREATE TABLE important_data ( + id serial PRIMARY KEY, + payload text +) WITH (enable_undo = on); + +-- Enable on an existing table +ALTER TABLE important_data SET (enable_undo = on); + +-- Disable UNDO logging +ALTER TABLE important_data SET (enable_undo = off); + + + + + Enabling or disabling enable_undo requires an + ACCESS EXCLUSIVE lock on the table. Plan for + a maintenance window if the table is under active use. + + + + + System catalogs cannot have UNDO enabled. Attempting to set + enable_undo = on on a system relation will + be silently ignored. + + + + + When to Use UNDO + + + Consider enabling UNDO logging when: + + + + + + You need to recover data that may be lost to aggressive vacuuming + or HOT pruning. UNDO records preserve pruned tuple versions in + a separate log, recoverable via pg_undorecover. + + + + + You want crash-safe rollback with full WAL integration for + critical tables, ensuring that aborted transactions are correctly + rolled back even after a crash or on streaming replication standbys. + + + + + You need an audit trail of old tuple versions for compliance + or forensic purposes. + + + + + + Do not enable UNDO logging on: + + + + + + High-throughput write-heavy tables where the additional I/O + overhead is unacceptable. + + + + + Temporary tables or tables with short-lived data that does not + need recovery protection. + + + + + + + Logged Operations + + + When UNDO is enabled on a table, the following operations generate + UNDO records: + + + + + INSERT + + + Records the block and offset of the newly inserted tuple along + with the ItemId state. On rollback, the inserted tuple is + physically removed from the page and the ItemId is restored to + its prior state. No full tuple payload is stored. + + + + + + DELETE + + + Records the full raw tuple data as it appears on the heap page. + On rollback, the original tuple bytes are restored to the page + via direct memory copy, and the ItemId is restored. + + + + + + UPDATE + + + Records the full raw data of the old tuple version before the + update. On rollback, the old tuple bytes are restored to their + original page location, and the new tuple is removed. + + + + + + Pruning (HOT cleanup and VACUUM) + + + Records full copies of tuples being marked as dead or unused + during page pruning. These records are not rolled back (pruning + is a maintenance operation, not a transactional data change) but + are preserved for point-in-time recovery via + pg_undorecover. + + + + + + + Each rollback operation generates a Compensation Log Record (CLR) in + the WAL stream. CLRs carry full page images, ensuring that the + rollback is correctly replayed on standbys and during crash recovery. + + + + + Crash Recovery and Replication + + + The UNDO system is fully integrated with PostgreSQL's WAL-based + crash recovery and streaming replication. + + + + When a transaction with UNDO records aborts, each UNDO application + generates a CLR (Compensation Log Record) WAL record. These CLRs + contain full page images of the restored heap pages, making them + self-contained and safe to replay. + + + + During crash recovery: + + + + + + The redo phase replays all WAL records forward, including any CLRs + that were generated before the crash. Pages are restored to their + post-rollback state. + + + + + For transactions that were aborting at crash time but had not + completed rollback, the recovery process walks the remaining UNDO + chain and generates new CLRs, using CLR pointers to skip + already-applied records. + + + + + + On streaming replication standbys, CLRs are replayed like any other + WAL record. The standby does not need access to the UNDO log data + itself, since the CLR WAL records are self-contained with full page + images. + + + + + Point-in-Time Recovery with pg_undorecover + + + The pg_undorecover utility reads UNDO log + files directly from the data directory and outputs recovered tuple data. + The server does not need to be running. + + + +# Show all UNDO records +pg_undorecover /path/to/pgdata + +# Filter by relation OID +pg_undorecover -r 16384 /path/to/pgdata + +# Filter by transaction ID and output as CSV +pg_undorecover -x 12345 -f csv /path/to/pgdata + +# Show only pruned records as JSON +pg_undorecover -t prune -f json /path/to/pgdata + +# Show statistics only +pg_undorecover -s -v /path/to/pgdata + + + + pg_undorecover options: + + + + + + + Filter records by relation OID. + + + + + + + Filter records by transaction ID. + + + + + + + + Filter by record type. Valid types: + insert, delete, + update, prune, + inplace. + + + + + + + + + Output format: text (default), + csv, or json. + + + + + + + + Show statistics summary only, without individual records. + + + + + + + Verbose mode with detailed scan progress. + + + + + + + Configuration Parameters + + + + enable_undo (boolean) + + + Master switch that enables the UNDO logging infrastructure. + This is a PGC_POSTMASTER parameter: changing it + requires a server restart. When off (the default), + the UNDO subsystem is completely dormant and tables cannot opt in. + When on, individual tables can enable UNDO via + the enable_undo storage parameter. + + + + + + undo_worker_naptime (integer) + + + Time in milliseconds between UNDO discard worker cycles. + The worker wakes periodically to check for UNDO records that + are no longer needed by any active transaction. + Default: 60000 (1 minute). + + + + + + undo_retention_time (integer) + + + Minimum time in milliseconds to retain UNDO records after + the creating transaction completes. Higher values allow + pg_undorecover to access older data + but consume more disk space. + Default: 3600000 (1 hour). + + + + + + + UNDO data is stored in the standard shared buffer pool alongside + heap and index pages. No dedicated UNDO buffer cache configuration + is needed. The shared buffer pool dynamically adapts to the UNDO + workload through its normal clock-sweep eviction policy. + + + + + UNDO Space Management + + + UNDO records are embedded directly in the WAL stream as + XLOG_UNDO_BATCH records (UNDO-in-WAL architecture). + There are no separate UNDO segment files or directories. This + eliminates a separate storage tier and leverages existing WAL + infrastructure for durability, replication, and archival. + + + + The UNDO discard worker background process advances the + undo_discard_horizon, allowing WAL segments + containing fully-discarded UNDO batches to be recycled. UNDO records + for unresolved (uncommitted/unaborted) transactions are never discarded. + The undo_retention_time controls how long committed + transaction UNDO records are retained beyond the visibility horizon. + + + + To monitor UNDO WAL retention: + + + +SELECT pg_size_pretty( + pg_wal_lsn_diff(pg_current_wal_lsn(), undo_discard_horizon) +) AS undo_wal_retained +FROM pg_stat_undo; + + + + If UNDO space is growing unexpectedly, check for: + + + + + + Long-running transactions that prevent discard. + + + + + A high undo_retention_time value. + + + + + The UNDO worker not running (check + pg_stat_activity for the + undo worker process). + + + + + + + Performance Impact + + + When UNDO is disabled (the default), there is no measurable + performance impact. When enabled on a table, expect: + + + + + + INSERT: Minimal overhead. A small header + record (~40 bytes) is written to the UNDO log recording the + ItemId state. + + + + + DELETE/UPDATE: Moderate overhead. The full + old tuple data is copied to the UNDO log as raw page bytes. + Cost scales with tuple size. + + + + + PRUNE: Overhead proportional to the number + of tuples being pruned. Records are batched for efficiency. + + + + + ABORT: Each UNDO record applied during + rollback generates a CLR WAL record with a full page image + (~8 KB). This increases abort latency by approximately 20-50% + compared to systems without CLR generation, but ensures crash + safety and correct standby replay. + + + + + + UNDO I/O is performed outside critical sections, so it does not + extend the time that buffer locks are held. + + + + + Monitoring + + + Monitor UNDO system health using: + + + + + + pg_stat_undo_logs: Per-log statistics + including size, discard progress, and oldest active transaction. + + + + + pg_waldump: Inspect CLR records in WAL. + CLR records appear as UNDO/APPLY_RECORD entries + and can be filtered with . + + + + + WAL retention due to UNDO batches (check pg_stat_undo). + + + + + pg_stat_activity: Verify the + undo worker background process is running. + + + + + + Key log messages to watch for (at DEBUG1 and above): + + + + + + "applying UNDO chain starting at ..." indicates + a transaction abort is applying its UNDO chain. + + + + + "UNDO rollback: relation %u no longer exists, skipping" + indicates an UNDO record was skipped because the target relation was + dropped before rollback completed. + + + + + + + Architecture Notes + + + The following notes describe the internal architecture for users + interested in the design rationale. + + + + Physical vs Logical UNDO + + + The UNDO system uses physical UNDO operations: + when rolling back a transaction, the original page bytes are restored + directly using memory copy operations. This contrasts with a + logical approach that would replay high-level + operations (like simple_heap_insert or + simple_heap_delete) in reverse. + + + + Advantages of physical UNDO: + + + + + + Crash Safety: Each UNDO application generates a + Compensation Log Record (CLR) in WAL, ensuring that rollback completes + correctly even after a system crash. + + + + + Standby Support: CLRs are replayed on physical + standbys just like forward-progress WAL records. Standbys see + identical heap state as the primary after an abort. + + + + + Determinism: Physical operations cannot fail due + to page-full conditions, TOAST complications, or index conflicts. + The operation is a direct memory copy with no side effects. + + + + + Simplicity: Direct memory copy operations are + simpler and faster than reconstructing logical operations, and have + no side effects (no index updates, no TOAST operations, no + statistics maintenance). + + + + + + Trade-offs: + + + + + + WAL Volume: CLRs with full page images (~8 KB + each) increase WAL generation significantly per abort compared to + PostgreSQL's default rollback mechanism + which generates no WAL. + + + + + Abort Latency: Approximately 20-50% overhead + compared to PostgreSQL's default rollback, + due to reading UNDO records, modifying pages, and writing CLRs. + + + + + + The design prioritizes correctness and crash safety over abort speed. + For workloads where transaction aborts are rare, the overhead is + negligible. + + + + + Compensation Log Records (CLRs) + + + A CLR is a WAL record generated each time an UNDO record is physically + applied to a heap page during rollback. CLRs serve three purposes: + + + + + + Crash recovery: If the server crashes during + rollback, the redo phase replays any CLRs that were already written, + restoring pages to their post-undo state. Rollback then continues + from where it left off, using CLR pointers in the UNDO records to + skip already-applied operations. + + + + + Standby replication: CLRs are streamed to + standbys like any other WAL record. The standby does not need + access to the UNDO log data itself, since CLRs are self-contained + with full page images. + + + + + Audit trail: CLRs provide a permanent record + in WAL of every rollback operation, viewable with + pg_waldump. + + + + + + Each CLR uses REGBUF_FORCE_IMAGE to store a + complete page image, making the CLR self-contained for recovery. + During redo, the page image is restored directly without needing + to re-read the UNDO record or re-apply the operation. + + + + + Buffer Pool Integration + + + UNDO log data is stored in the standard shared buffer pool alongside + heap and index pages. Each UNDO log is mapped to a virtual + RelFileLocator with a dedicated pseudo-database + OID (UNDO_DB_OID = 9), allowing the buffer manager + to handle UNDO data without any changes to the core + BufferTag structure. + + + + This design eliminates the need for a separate UNDO buffer cache, + reducing code complexity and allowing UNDO pages to participate in + the buffer manager's clock-sweep eviction and checkpoint mechanisms + automatically. No dedicated UNDO buffer cache configuration is needed; + the standard shared_buffers setting controls memory + available for all buffer types including UNDO. + + + + + Rollback Flow + + + When a transaction aborts, the rollback proceeds as follows: + + + + + + The transaction manager (xact.c) calls + ApplyUndoChain() with the first UNDO record + pointer for the aborting transaction. + + + + + For each UNDO record in the chain (walked backward): + + + + Read the UNDO record from the log. + + + Check the CLR pointer: if valid, this record was already + applied during a previous rollback attempt; skip it. + + + Open the target relation and read the target page into a + shared buffer with an exclusive lock. + + + Apply the physical modification (memcpy) within a critical + section. + + + Generate a CLR WAL record with a full page image. + + + Store the CLR's LSN back into the UNDO record's + urec_clr_ptr field to mark it as + applied. + + + + + + AtAbort_XactUndo() cleans up record sets and + resets per-transaction state. + + + + + + + diff --git a/examples/01-basic-undo-setup.sql b/examples/01-basic-undo-setup.sql new file mode 100644 index 0000000000000..82042081e4e9b --- /dev/null +++ b/examples/01-basic-undo-setup.sql @@ -0,0 +1,42 @@ +-- ============================================================================ +-- Example 1: Basic UNDO Setup and Monitoring +-- ============================================================================ +-- This example demonstrates: +-- 1. Creating a table that uses UNDO (via the recno access method) +-- 2. Performing modifications +-- 3. Monitoring UNDO activity + +-- STEP 1: Create a table using the recno AM (which supports UNDO) +-- No server-level configuration is needed; UNDO is always-on infrastructure. +CREATE TABLE customer_data ( + id serial PRIMARY KEY, + name text NOT NULL, + email text, + created_at timestamptz DEFAULT now() +) USING recno; + +-- STEP 2: Insert sample data +INSERT INTO customer_data (name, email) VALUES + ('Alice Smith', 'alice@example.com'), + ('Bob Johnson', 'bob@example.com'), + ('Charlie Brown', 'charlie@example.com'); + +-- STEP 3: Perform an update (in-place for recno) +UPDATE customer_data SET email = 'alice.smith@newdomain.com' WHERE name = 'Alice Smith'; + +-- STEP 4: Delete a row +DELETE FROM customer_data WHERE id = 2; + +-- STEP 5: Commit the transaction +COMMIT; + +-- STEP 6: Check UNDO log statistics +SELECT * FROM pg_stat_get_undo_logs(); + +-- STEP 7: Check UNDO buffer statistics +SELECT * FROM pg_stat_get_undo_buffers(); + +-- STEP 8: Verify the UNDO worker is running +SELECT pid, backend_type, state +FROM pg_stat_activity +WHERE backend_type = 'undo worker'; diff --git a/examples/02-undo-rollback.sql b/examples/02-undo-rollback.sql new file mode 100644 index 0000000000000..9af57664747e0 --- /dev/null +++ b/examples/02-undo-rollback.sql @@ -0,0 +1,44 @@ +-- ============================================================================ +-- Example 2: Transaction Rollback with UNDO +-- ============================================================================ +-- Demonstrates how UNDO records enable efficient transaction rollback + +-- Create a table using the recno AM (supports UNDO) +CREATE TABLE order_items ( + order_id int, + item_id int, + quantity int, + price numeric(10,2) +) USING recno; + +-- Begin transaction +BEGIN; + +-- Insert multiple rows +INSERT INTO order_items VALUES + (1001, 1, 5, 29.99), + (1001, 2, 3, 49.99), + (1001, 3, 1, 199.99); + +-- Perform updates +UPDATE order_items SET quantity = 10 WHERE item_id = 1; +UPDATE order_items SET price = 44.99 WHERE item_id = 2; + +-- Delete a row +DELETE FROM order_items WHERE item_id = 3; + +-- Check current state (before rollback) +SELECT * FROM order_items; +-- Should show: 2 rows (items 1 and 2, modified) + +-- Rollback the transaction +-- UNDO records will be applied automatically: +-- - item 3 re-inserted +-- - item 2 price restored to 49.99 +-- - item 1 quantity restored to 5 +-- - all 3 original inserts deleted +ROLLBACK; + +-- Verify all changes were rolled back +SELECT * FROM order_items; +-- Should show: 0 rows (everything rolled back via UNDO) diff --git a/examples/03-undo-subtransactions.sql b/examples/03-undo-subtransactions.sql new file mode 100644 index 0000000000000..22dac58d9d9aa --- /dev/null +++ b/examples/03-undo-subtransactions.sql @@ -0,0 +1,45 @@ +-- ============================================================================ +-- Example 3: Subtransactions (SAVEPOINTs) with UNDO +-- ============================================================================ + +CREATE TABLE account_ledger ( + account_id int, + amount numeric(10,2), + posted_at timestamptz DEFAULT now() +) USING recno; + +BEGIN; + +-- Parent transaction: Initial credit +INSERT INTO account_ledger VALUES (1001, 1000.00); + +SAVEPOINT sp1; + +-- Subtransaction 1: Debit attempt +INSERT INTO account_ledger VALUES (1001, -500.00); + +SAVEPOINT sp2; + +-- Subtransaction 2: Another debit +INSERT INTO account_ledger VALUES (1001, -300.00); + +-- Check balance +SELECT SUM(amount) FROM account_ledger WHERE account_id = 1001; +-- Shows: 200.00 + +-- Rollback to sp2 (undo the -300.00) +ROLLBACK TO sp2; + +-- Check balance after rollback +SELECT SUM(amount) FROM account_ledger WHERE account_id = 1001; +-- Shows: 500.00 + +-- Rollback to sp1 (undo the -500.00) +ROLLBACK TO sp1; + +-- Check balance after full rollback to sp1 +SELECT SUM(amount) FROM account_ledger WHERE account_id = 1001; +-- Shows: 1000.00 (only initial credit remains) + +-- Commit parent transaction +COMMIT; diff --git a/examples/04-transactional-fileops.sql b/examples/04-transactional-fileops.sql new file mode 100644 index 0000000000000..9ceea7a1022c7 --- /dev/null +++ b/examples/04-transactional-fileops.sql @@ -0,0 +1,47 @@ +-- ============================================================================ +-- Example 4: Transactional File Operations (FILEOPS) +-- ============================================================================ +-- Demonstrates WAL-logged, transactional table creation and deletion. +-- FILEOPS is always-on infrastructure; no GUC configuration is needed. + +-- Example 1: Table creation survives crashes +BEGIN; + +CREATE TABLE crash_safe_data ( + id serial PRIMARY KEY, + data text +); + +-- At this point, a XLOG_FILEOPS_CREATE WAL record has been written. +-- If the server crashes before COMMIT, the file will be automatically deleted. + +INSERT INTO crash_safe_data (data) VALUES ('test data'); + +COMMIT; + +-- The file is now durable; CREATE and data are atomic. + +-- Example 2: Table deletion is deferred until commit +BEGIN; + +DROP TABLE crash_safe_data; + +-- The relation file still exists on disk (deletion deferred). +-- A XLOG_FILEOPS_DELETE WAL record has been written. + +COMMIT; + +-- Now the file is deleted atomically with the transaction commit. + +-- Example 3: Rollback properly cleans up created files +BEGIN; + +CREATE TABLE temp_table (id int); +INSERT INTO temp_table VALUES (1), (2), (3); + +-- File exists on disk with data. + +ROLLBACK; + +-- File is automatically deleted (FILEOPS cleanup on abort). +-- No orphaned files left behind. diff --git a/examples/05-undo-monitoring.sql b/examples/05-undo-monitoring.sql new file mode 100644 index 0000000000000..caf027a7eeb10 --- /dev/null +++ b/examples/05-undo-monitoring.sql @@ -0,0 +1,38 @@ +-- ============================================================================ +-- Example 5: Monitoring UNDO Subsystem +-- ============================================================================ + +-- View UNDO log statistics +SELECT * FROM pg_stat_get_undo_logs(); + +-- View UNDO buffer statistics +SELECT * FROM pg_stat_get_undo_buffers(); + +-- Force discard of UNDO records older than the retention horizon +-- (normally handled automatically by the UNDO worker) +SELECT pg_undo_force_discard(); + +-- List tables using an AM that supports UNDO (i.e., recno tables) +SELECT + n.nspname AS schema, + c.relname AS table, + am.amname AS access_method +FROM pg_class c +JOIN pg_namespace n ON c.relnamespace = n.oid +JOIN pg_am am ON c.relam = am.oid +WHERE am.amname = 'recno' +ORDER BY n.nspname, c.relname; + +-- Monitor UNDO worker activity +SELECT + pid, + backend_type, + state, + query_start, + state_change +FROM pg_stat_activity +WHERE backend_type = 'undo worker'; + +-- Check current UNDO retention settings +SHOW undo_retention_time; +SHOW undo_worker_naptime; diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000000..096150dbd188d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,40 @@ +# PostgreSQL UNDO Examples + +This directory contains practical examples demonstrating the UNDO subsystem +and transactional file operations (FILEOPS). + +## Prerequisites + +Tables opt into UNDO by using the `recno` access method: + + CREATE TABLE my_table (...) USING recno; + +UNDO is always-on infrastructure -- there is no GUC to enable or disable it +globally. Table access methods opt in via the `am_supports_undo` callback. + +Optional retention tuning (postgresql.conf): + + undo_retention_time = 3600000 # 1 hour in milliseconds + undo_worker_naptime = 60000 # 1 minute + +## Examples + +- **01-basic-undo-setup.sql**: Creating UNDO-enabled tables and monitoring +- **02-undo-rollback.sql**: Transaction rollback with UNDO records +- **03-undo-subtransactions.sql**: SAVEPOINT and subtransaction rollback +- **04-transactional-fileops.sql**: Crash-safe table creation/deletion +- **05-undo-monitoring.sql**: Monitoring UNDO subsystem usage + +## Running Examples + +```bash +psql -d testdb -f examples/01-basic-undo-setup.sql +psql -d testdb -f examples/02-undo-rollback.sql +... +``` + +## Notes + +- UNDO is always-on; tables opt in via `USING recno` +- FILEOPS (transactional file operations) is always-on for all tables +- System catalogs never use UNDO diff --git a/meson.build b/meson.build index f4cde2492423b..076616b5d2bf9 100644 --- a/meson.build +++ b/meson.build @@ -1585,6 +1585,17 @@ else endif +############################################################### +# Library: FLUX +############################################################### + +# FLUX table access method (UNDO-based heap replacement) is always built: +# guc_parameters.dat and pg_proc.dat reference flux symbols unconditionally, +# so a build with USE_FLUX unset fails to link. USE_FLUX is therefore always +# defined and the sources are always compiled. +flux = declare_dependency() +cdata.set('USE_FLUX', 1) + ############################################################### # Library: selinux diff --git a/src/Makefile.global.in b/src/Makefile.global.in index cef1ad7f87d98..c3443b7afd93e 100644 --- a/src/Makefile.global.in +++ b/src/Makefile.global.in @@ -256,6 +256,10 @@ PG_SYSROOT = @PG_SYSROOT@ override CPPFLAGS += $(ICU_CFLAGS) $(LIBNUMA_CFLAGS) $(LIBURING_CFLAGS) +# FLUX table access method (UNDO-based heap replacement) is always built +USE_FLUX = 1 +override CPPFLAGS += -DUSE_FLUX + ifdef PGXS override CPPFLAGS := -I$(includedir_server) -I$(includedir_internal) $(CPPFLAGS) else # not PGXS diff --git a/src/backend/access/Makefile b/src/backend/access/Makefile index e88d72ea0397d..b28bdc98e01b4 100644 --- a/src/backend/access/Makefile +++ b/src/backend/access/Makefile @@ -22,6 +22,13 @@ SUBDIRS = \ sequence \ table \ tablesample \ - transam + transam \ + undo + +endif + +ifdef USE_FLUX +SUBDIRS += flux +endif include $(top_srcdir)/src/backend/common.mk diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c index 3e832c3797e89..bd7f6d975b4ac 100644 --- a/src/backend/access/common/reloptions.c +++ b/src/backend/access/common/reloptions.c @@ -2180,7 +2180,12 @@ heap_reloptions(char relkind, Datum reloptions, bool validate) return (bytea *) rdopts; case RELKIND_RELATION: case RELKIND_MATVIEW: - return default_reloptions(reloptions, validate, RELOPT_KIND_HEAP); + { + rdopts = (StdRdOptions *) + default_reloptions(reloptions, validate, RELOPT_KIND_HEAP); + + return (bytea *) rdopts; + } default: /* other relkinds are not supported */ return NULL; diff --git a/src/backend/access/flux/Makefile b/src/backend/access/flux/Makefile new file mode 100644 index 0000000000000..0443f539f9e24 --- /dev/null +++ b/src/backend/access/flux/Makefile @@ -0,0 +1,31 @@ +#------------------------------------------------------------------------- +# +# Makefile-- +# Makefile for access/flux +# +# IDENTIFICATION +# src/backend/access/flux/Makefile +# +#------------------------------------------------------------------------- + +subdir = src/backend/access/flux +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global + +OBJS = \ + flux_dirtymap.o \ + flux_handler.o \ + flux_tuple.o \ + flux_mvcc.o \ + flux_stats.o \ + flux_xlog.o \ + flux_fsm.o \ + flux_operations.o \ + flux_lock.o \ + flux_slot.o \ + flux_vm.o \ + flux_undo.o \ + flux_relundo.o \ + flux_pvs.o + +include $(top_srcdir)/src/backend/common.mk \ No newline at end of file diff --git a/src/backend/access/flux/README b/src/backend/access/flux/README new file mode 100644 index 0000000000000..8c710d0831eaf --- /dev/null +++ b/src/backend/access/flux/README @@ -0,0 +1,163 @@ +FLUX +==== + +FLUX is a table access method that realizes what zheap was designed to be: a +HEAP replacement that stores old row versions in UNDO instead of leaving dead +tuples in the main relation, while working entirely on today's index and +executor APIs. FLUX adds no new index-AM contract. It was derived from the +RECNO table AM (the closest in-tree in-place + UNDO code) and then reshaped +into a clean, boring, correct heap replacement. + +This README describes FLUX's design and how it differs from heap. For the +UNDO substrate FLUX relies on, see src/backend/access/undo/README (the +cluster-wide UNDO engine) and src/include/access/relundo.h (the per-relation +UNDO fork). The RECNO/zheap origin design notes are in DESIGN. + + +What FLUX keeps from the zheap model +------------------------------------ + +* In-place UPDATE. When an UPDATE touches only non-indexed columns, the new + tuple version overwrites the old one in the same slot (same TID). The old + version is pushed to the relation's per-relation UNDO fork (RelUndo, + RELUNDO_FORKNUM) via the tuple header's version pointer (t_verptr). A + snapshot that cannot see the updater reconstructs the old version by walking + that UNDO chain (flux_pvs.c, FluxReconstructVisibleVersion). This is the + zheap win: an UPDATE that does not change any indexed column touches no + index at all. + +* UNDO-based rollback. ROLLBACK (and error/abort) replays the RelUndo chain + backward, restoring before-images in place (relundo_apply.c). There are no + dead tuples to vacuum away for a rolled-back change. + +* UNDO-based old-version reconstruction for MVCC. Older snapshots read the + pre-update image from the UNDO fork, not from a second on-page copy. + +* Crash safety. Every page modification and its UNDO record are WAL-logged + (resource manager RM_FLUX_ID, flux_xlog.c); recovery replays them and the + per-relation UNDO recovery restores any before-images for transactions that + were in flight at the crash. + +* sLog for uncommitted-writer tracking. FLUX shares the cluster's sLog + extension (slog.c, registered once as SLogShmemCallbacks) for self-visibility, + command-level (CID) checks, and write-write conflict serialization. The + authoritative commit oracle is CLOG; visibility is heap-shaped xmin/xmax + (see below). + + +How FLUX differs from heap +-------------------------- + +1. Old versions live in UNDO, not in the table. Heap keeps every old row + version in the relation until VACUUM removes it (bloat). FLUX keeps only + the newest version on the page and stores the old one in the UNDO fork, + which is discarded once no snapshot needs it. A non-in-place change's old + version and a DELETE's tombstone are reclaimable as soon as the transaction + commits and the version falls below the removable horizon. + +2. In-place for non-key UPDATEs; out-of-place for key-changing UPDATEs. + This is the central design decision and the reason FLUX needs no index-AM + change. See the next section. + +3. Wide values use standard TOAST, not RECNO's overflow pages. A FLUX + relation gets an ordinary heap TOAST table (relation_needs_toast_table + mirrors heap; relation_toast_am returns the heap AM). On INSERT/UPDATE, + oversized varlena columns are externalized through the AM-agnostic + toast_helper routines (flux_toast_tuple in flux_tuple.c) exactly as heap + does; VACUUM deletes the external datums of dead tuples + (flux_toast_delete). RECNO's overflow machinery, its attribute + compression, and its compression dictionary were removed entirely. + +4. Heap-shaped xmin/xmax visibility. FLUX uses the "boring correct" + visibility model: each tuple carries t_xmin (inserter) and t_xmax + (deleter/updater), resolved against CLOG and the reader's snapshot exactly + like HeapTupleSatisfiesMVCC. FLUX does NOT use the HLC (hybrid logical + clock) model that RECNO retains. + + +Secondary indexes and the key-changing UPDATE (stock table-AM design) +--------------------------------------------------------------------- + +Secondary indexes on a FLUX table are ordinary heap-TID nbtree (or any +standard index-AM) entries: plain 6-byte TIDs, exactly like heap. FLUX uses +only today's stock table-AM and index-AM interfaces -- no per-tuple +generation, no wider index identity, no executor stale-entry recheck +(am_inplace_update_no_dead_tuple is false). The executor treats a FLUX table +exactly like a heap table for indexing. + +The one hazard an in-place-with-stable-TID AM creates for a plain-TID index is +the "A -> B -> A" problem: if the tuple stays at one TID while an indexed +column changes, the index accumulates a stale (oldkey, TID) entry beside the +new (newkey, TID) entry, both pointing at the same live TID; an index scan +would then double-count or return rows under the wrong key. + +FLUX avoids the hazard the heap way: + + * An UPDATE that changes NO indexed column is done IN PLACE (same TID). The + index entries still point at the right key and TID, so no index + maintenance is needed. flux_tuple_update sets *update_indexes = TU_None + and the executor inserts no index entries. + + * An UPDATE that changes ANY indexed column is done OUT OF PLACE: FLUX stores + the new version at a NEW TID (DELETE old + INSERT new -- zheap's + non-in-place update, "essentially DELETE+INSERT"). It sets slot->tts_tid + to the new TID and *update_indexes = TU_All, so the executor inserts fresh + entries in every index at the new TID. The old TID's index entries now + point at a dead tuple and are reclaimed by ordinary VACUUM -- exactly like + heap. The old version remains reachable to older snapshots through the + UNDO before-image the DELETE recorded, and a ROLLBACK undoes both the + DELETE and the INSERT. + +Stock PostgreSQL no longer tells the AM which columns changed, so FLUX +self-computes it (flux_indexed_attr_changed), matching heap's internal +HeapDetermineColumnsInfo: it deforms the old on-page tuple, reads the new +values from the slot, and value-compares every attribute referenced by any +index (RelationGetIndexAttrBitmap HOT_BLOCKING + SUMMARIZED). If any indexed +attribute differs it takes the out-of-place path. FLUX never uses +TU_Summarizing: a key-changing UPDATE always moves the TID, so TU_All already +rebuilds summarizing indexes too. + +Consequence: idxscan, seqscan, and bitmapscan return identical results across +any sequence of key-changing UPDATEs (including A -> B -> A recurrences), +because the index is maintained by the standard heap path and never holds two +live entries for one TID under different keys. Verified by amcheck +(heapallindexed) and by the scan cross-check in the test suite. + + +Registration +------------ + +* pg_am: amname 'flux', amhandler flux_tableam_handler, amtype 't' + (FLUX_TABLE_AM_OID = 9316; pg_proc oid 9402). +* WAL resource manager: RM_FLUX_ID (rmgrlist.h), redo/desc/identify/mask in + flux_xlog.c / fluxdesc.c, logical decoding in decode.c (flux_decode). +* Cluster-wide UNDO resource manager: UNDO_RMID_FLUX (=5), FluxUndoRmgrInit + (undormgrlist.h), used so index inserts on a FLUX table can piggyback their + index-UNDO onto the table's active UNDO context. +* Two-phase commit: TWOPHASE_RM_FLUX_ID (=6), flux_twophase_* callbacks. +* Shared memory: FluxMvccShmemCallbacks and FluxDirtyMapShmemCallbacks + (subsystemlist.h); the sLog is the shared SLogShmemCallbacks. +* GUC: flux_lazy_uncommitted_clear (the RECNO compression GUCs were removed + with the compression subsystem). + +FLUX is always compiled (USE_FLUX), like RECNO, because the catalog and rmgr +lists reference its symbols unconditionally. + + +Source layout +------------- + + flux_handler.c table-AM routine table (flux_methods), scans, TOAST hooks + flux_operations.c INSERT / DELETE / UPDATE (in-place + out-of-place), VACUUM + flux_tuple.c tuple form/deform, flux_toast_tuple / flux_toast_delete + flux_mvcc.c heap-shaped xmin/xmax visibility state, shmem + flux_pvs.c prior-version reconstruction from the UNDO fork + flux_slot.c FLUX minimal tuple slot + flux_xlog.c WAL record types, redo, mask + flux_undo.c UNDO_RMID_FLUX resource manager (index-UNDO piggyback) + flux_relundo.c per-relation UNDO fork glue + flux_lock.c tuple locking (SELECT FOR UPDATE/SHARE) + flux_vm.c visibility map maintenance + flux_fsm.c free-space map integration + flux_dirtymap.c per-block dirty map (scan-path sLog bypass), shmem + flux_stats.c relation size / density estimation helpers diff --git a/src/backend/access/flux/flux_dirtymap.c b/src/backend/access/flux/flux_dirtymap.c new file mode 100644 index 0000000000000..2a0e02a6bfd07 --- /dev/null +++ b/src/backend/access/flux/flux_dirtymap.c @@ -0,0 +1,283 @@ +/*------------------------------------------------------------------------- + * + * flux_dirtymap.c + * Shared-memory dirty block map for the FLUX table access method. + * + * This module tracks which heap pages have ever carried an in-place + * modification whose before-image a scanner might still need from the sLog. + * The scan path uses it as a fast-path filter: if a page's bit is CLEAR, + * every tuple on it is plain-committed with no retained before-image, so the + * per-tuple sLog before-image probe can be skipped for the whole page. + * + * Implementation: + * - A partitioned, open-addressed hash set of 64-bit page keys + * (((uint64) relid << 32) | blkno) in a fixed shared-memory buffer. + * - Partitioned by hash into FLUX_DIRTYMAP_PARTITIONS independent tables, + * each with its own writer spinlock, so concurrent Mark calls on + * different pages do not contend a single global lock. + * - FluxDirtyMapCheck (the per-scanned-tuple hot path) is LOCK-FREE: the + * set is grow-only (a published key is never removed or moved), so a + * reader linear-probing the chain sees either the key or an empty + * terminator, never a torn or reverted slot. Slots are published with a + * single atomic store and read with a single atomic load. + * - FluxDirtyMapMark takes only the target partition's spinlock and does + * an O(1)-amortized open-addressed insert. + * - The map is GROW-ONLY: a key, once set, is never cleared during normal + * operation. See flux_dirtymap.h for the correctness rationale. + * - If a partition ever fills past its load-factor ceiling, a sticky "full" + * flag is latched for that partition and every check on it returns dirty + * (safe degradation to always-probe). + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_dirtymap.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux_dirtymap.h" +#include "port/atomics.h" +#include "storage/shmem.h" +#include "storage/spin.h" + +/* + * Partition count and per-partition capacity. + * + * Partitioning spreads Mark-path writer-lock contention across many locks. + * The Check path is lock-free regardless. Capacity is a power of two so the + * probe index is a cheap mask. 128 partitions x 8192 slots = ~1M slots x 8 + * bytes = 8 MB of shared memory; with the 0.75 load-factor ceiling that holds + * ~786K distinct in-place-modified pages before a partition latches full. + */ +#define FLUX_DIRTYMAP_PARTITIONS 128 +#define FLUX_DIRTYMAP_PART_SLOTS 8192 /* power of two */ +#define FLUX_DIRTYMAP_PART_MASK (FLUX_DIRTYMAP_PART_SLOTS - 1) +#define FLUX_DIRTYMAP_MAX_LOAD ((FLUX_DIRTYMAP_PART_SLOTS * 3) / 4) + +/* + * Empty-slot sentinel. A key of 0 would require relid 0, which is never a + * user relation, so 0 is safe to reserve as "empty". + */ +#define FLUX_DIRTYMAP_EMPTY_KEY UINT64CONST(0) + +/* + * Compose the collision-free 64-bit page key. relid is a 32-bit Oid and + * blkno a 32-bit BlockNumber, so the key is unique per (relid, blkno). + */ +#define FLUX_DIRTYMAP_KEY(relid, blkno) \ + (((uint64) (relid) << 32) | (uint64) (blkno)) + +/* + * One partition: a fixed open-addressed slot array of atomic 64-bit keys, + * a writer spinlock, a live-entry count, and a sticky full flag. + */ +typedef struct FluxDirtyMapPartition +{ + slock_t mutex; /* serializes Mark inserts into this partition */ + int nentries; /* live keys (writer-lock protected) */ + bool full; /* sticky: load ceiling hit -> check == dirty */ + pg_atomic_uint64 slots[FLUX_DIRTYMAP_PART_SLOTS]; +} FluxDirtyMapPartition; + +typedef struct FluxDirtyMapShared +{ + FluxDirtyMapPartition parts[FLUX_DIRTYMAP_PARTITIONS]; +} FluxDirtyMapShared; + +static FluxDirtyMapShared *DirtyMap = NULL; + +/* + * Hash a 64-bit key to a partition index and an initial probe slot. A + * multiplicative (Fibonacci) hash mixes the high and low words so that the + * sequential (relid, blkno) keys of a scan spread across partitions and + * slots rather than clustering. + */ +static inline uint64 +flux_dirtymap_mix(uint64 key) +{ + uint64 h = key * UINT64CONST(0x9E3779B97F4A7C15); + + return h ^ (h >> 32); +} + +static inline uint32 +flux_dirtymap_part(uint64 mixed) +{ + return (uint32) (mixed & (FLUX_DIRTYMAP_PARTITIONS - 1)); +} + +static inline uint32 +flux_dirtymap_slot0(uint64 mixed) +{ + return (uint32) ((mixed >> 7) & FLUX_DIRTYMAP_PART_MASK); +} + +/* ---------------------------------------------------------------- + * Shared memory initialization + * ---------------------------------------------------------------- + */ + +Size +FluxDirtyMapShmemSize(void) +{ + return MAXALIGN(sizeof(FluxDirtyMapShared)); +} + +static void +FluxDirtyMapShmemRequest(void *arg) +{ + ShmemRequestStruct(.name = "FLUX DirtyMap", + .size = FluxDirtyMapShmemSize(), + .ptr = (void **) &DirtyMap, + ); +} + +static void +FluxDirtyMapShmemInit_cb(void *arg) +{ + int p; + int s; + + Assert(DirtyMap != NULL); + + for (p = 0; p < FLUX_DIRTYMAP_PARTITIONS; p++) + { + FluxDirtyMapPartition *part = &DirtyMap->parts[p]; + + SpinLockInit(&part->mutex); + part->nentries = 0; + part->full = false; + for (s = 0; s < FLUX_DIRTYMAP_PART_SLOTS; s++) + pg_atomic_init_u64(&part->slots[s], FLUX_DIRTYMAP_EMPTY_KEY); + } +} + +void +FluxDirtyMapShmemInit(void) +{ + /* Initialization is handled by FluxDirtyMapShmemCallbacks */ +} + +const ShmemCallbacks FluxDirtyMapShmemCallbacks = { + .request_fn = FluxDirtyMapShmemRequest, + .init_fn = FluxDirtyMapShmemInit_cb, +}; + +/* ---------------------------------------------------------------- + * Mark and query + * ---------------------------------------------------------------- + */ + +/* + * FluxDirtyMapMark + * Record that (relid, blkno) carries a retained in-place modification. + * + * Must be called while the page's buffer is exclusively locked, before that + * lock is released, so a concurrent scanner always observes the published + * key. Idempotent. If the target partition is full, its sticky flag is + * latched so every subsequent check on it conservatively returns dirty. + */ +void +FluxDirtyMapMark(Oid relid, BlockNumber blkno) +{ + uint64 key = FLUX_DIRTYMAP_KEY(relid, blkno); + uint64 mixed = flux_dirtymap_mix(key); + FluxDirtyMapPartition *part = &DirtyMap->parts[flux_dirtymap_part(mixed)]; + uint32 slot = flux_dirtymap_slot0(mixed); + int probes; + + SpinLockAcquire(&part->mutex); + + if (part->full) + { + SpinLockRelease(&part->mutex); + return; + } + + for (probes = 0; probes < FLUX_DIRTYMAP_PART_SLOTS; probes++) + { + uint64 cur = pg_atomic_read_u64(&part->slots[slot]); + + if (cur == key) + { + /* already present -- idempotent */ + SpinLockRelease(&part->mutex); + return; + } + if (cur == FLUX_DIRTYMAP_EMPTY_KEY) + { + /* + * Publish the key with a single atomic store. A concurrent + * lock-free reader on the same chain sees either the old empty + * (and keeps probing / terminates) or the full key -- never a + * torn value. Grow-only means this slot never reverts. + */ + if (part->nentries >= FLUX_DIRTYMAP_MAX_LOAD) + { + part->full = true; + SpinLockRelease(&part->mutex); + return; + } + pg_atomic_write_u64(&part->slots[slot], key); + part->nentries++; + SpinLockRelease(&part->mutex); + return; + } + slot = (slot + 1) & FLUX_DIRTYMAP_PART_MASK; + } + + /* probe chain exhausted without an empty slot -> latch full */ + part->full = true; + SpinLockRelease(&part->mutex); +} + +/* + * FluxDirtyMapCheck + * Return true if the page's key is present (or its partition overflowed). + * + * LOCK-FREE. The set is grow-only, so a reader linear-probing the chain sees + * either the key (dirty), an empty terminator (clean), or the partition's + * sticky full flag (dirty). A concurrent Mark only publishes new keys with + * an atomic store; it never moves or clears a slot, so a reader can miss a + * key that is being inserted *concurrently* only if the insert has not yet + * completed -- and the mark-before-buffer-unlock ordering (see + * FluxDirtyMapMark's contract) guarantees any modification a scanner could + * observe was published before the scanner could reach the page. + * + * A false result means the page is provably clean and the scan path may skip + * the per-tuple sLog before-image probe for the whole page. + */ +bool +FluxDirtyMapCheck(Oid relid, BlockNumber blkno) +{ + uint64 key = FLUX_DIRTYMAP_KEY(relid, blkno); + uint64 mixed = flux_dirtymap_mix(key); + FluxDirtyMapPartition *part = &DirtyMap->parts[flux_dirtymap_part(mixed)]; + uint32 slot = flux_dirtymap_slot0(mixed); + int probes; + + /* + * The full flag is read without the spinlock. It is set-once (sticky) + * and only ever transitions false->true, so a stale-false read at worst + * costs one more lock-free probe pass, and a true read is permanent. + */ + if (part->full) + return true; + + for (probes = 0; probes < FLUX_DIRTYMAP_PART_SLOTS; probes++) + { + uint64 cur = pg_atomic_read_u64(&part->slots[slot]); + + if (cur == key) + return true; + if (cur == FLUX_DIRTYMAP_EMPTY_KEY) + return false; /* provably clean */ + slot = (slot + 1) & FLUX_DIRTYMAP_PART_MASK; + } + + /* full chain of non-matching keys: conservatively dirty */ + return true; +} diff --git a/src/backend/access/flux/flux_fsm.c b/src/backend/access/flux/flux_fsm.c new file mode 100644 index 0000000000000..2e4da494da331 --- /dev/null +++ b/src/backend/access/flux/flux_fsm.c @@ -0,0 +1,170 @@ +/*------------------------------------------------------------------------- + * + * flux_fsm.c + * FLUX free space management + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_fsm.c + * + * NOTES + * This is a thin wrapper over PostgreSQL's standard free space map + * (src/backend/storage/freespace). It adds FLUX-specific relation + * extension (page initialization and WAL-logging of the new page) on + * top of the generic GetPageWithFreeSpace/RecordPageWithFreeSpace API. + * + * Page defragmentation is handled entirely by VACUUM via + * FluxPageDefragment() in flux_tuple.c; the FSM only tracks free + * space, it does not schedule or perform compaction. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/flux_xlog.h" +#include "storage/bufmgr.h" +#include "storage/freespace.h" +#include "utils/rel.h" +#include "miscadmin.h" + +/* + * FluxGetPageWithFreeSpace + * + * Find or create a page with at least 'needed' bytes of free space. + * + * First queries PostgreSQL's standard FSM (GetPageWithFreeSpace). If a + * page is returned, that block is used directly. + * + * If no suitable existing page is found, extends the relation by allocating + * a new page with ExtendBufferedRel(), initializes it with FluxInitPage(), + * WAL-logs the initialization, and returns the new block. + * + * Parameters: + * rel - open relation + * needed - minimum number of free bytes required + * + * Returns the block number of a page with sufficient free space. + */ +BlockNumber +FluxGetPageWithFreeSpace(Relation rel, Size needed) +{ + BlockNumber target_block; + Buffer buffer; + Page page; + Size free_space; + + /* + * Ask the FSM for a page with enough free space. Note: we do NOT verify + * the page by locking it here, because callers may already hold buffer + * locks (e.g., the update path holds the old tuple's page lock, and + * vacuum cross-page defrag holds the source page lock). Locking a page + * here would risk self-deadlock if the FSM returns a block that the + * caller already has locked. Callers are responsible for rechecking free + * space after they acquire their own lock on the returned page. + */ + target_block = GetPageWithFreeSpace(rel, needed); + + if (target_block != InvalidBlockNumber) + return target_block; + + /* + * No suitable page found -- extend the relation. + * + * Use the modern ExtendBufferedRel() API which properly handles + * concurrent extension by multiple backends. The old + * ReadBufferExtended(P_NEW) path had a race condition that caused + * BM_IO_IN_PROGRESS assertion failures under concurrency. + */ + buffer = ExtendBufferedRel(BMR_REL(rel), MAIN_FORKNUM, NULL, + EB_LOCK_FIRST); + target_block = BufferGetBlockNumber(buffer); + + page = BufferGetPage(buffer); + FluxInitPage(page, BufferGetPageSize(buffer)); + + START_CRIT_SECTION(); + + MarkBufferDirty(buffer); + + /* Log page initialization */ + if (RelationNeedsWAL(rel)) + { + uint64 init_commit_ts = FluxGetCommitTimestamp(); + FluxPageOpaque phdr; + XLogRecPtr recptr; + + /* + * Set the page's opaque data to match what the REDO handler will + * produce. This is essential for WAL consistency checking: the page + * image stored with the WAL record must match what REDO generates + * when replaying. + */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, init_commit_ts); + + recptr = FluxXLogInitPage(rel, buffer, 0, init_commit_ts); + + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + /* Capture free space before releasing the buffer */ + free_space = PageGetFreeSpace(page); + + UnlockReleaseBuffer(buffer); + + /* Record the new page in FSM */ + FluxRecordFreeSpace(rel, target_block, free_space); + + /* + * Propagate the new FSM leaf value up through the FSM tree so that + * subsequent GetPageWithFreeSpace() calls can find it. Without this, the + * root of the FSM tree remains at zero and all searches fail. + */ + FreeSpaceMapVacuumRange(rel, target_block, target_block + 1); + + return target_block; +} + +/* + * FluxRecordFreeSpace + * + * Update the FSM with the actual free space for a page. + * + * Parameters: + * rel - open relation + * page - block number of the page + * freespace - actual free space in bytes on the page + */ +void +FluxRecordFreeSpace(Relation rel, BlockNumber page, Size freespace) +{ + RecordPageWithFreeSpace(rel, page, freespace); +} + +/* + * FluxVacuumFSM + * + * Update the FSM after a relation truncation. If the new block count is + * smaller than the old count, calls FreeSpaceMapPrepareTruncateRel() to + * remove FSM entries for the truncated pages. + * + * Parameters: + * rel - open relation + * new_nblocks - new number of blocks after truncation + */ +void +FluxVacuumFSM(Relation rel, BlockNumber new_nblocks) +{ + BlockNumber old_nblocks = RelationGetNumberOfBlocks(rel); + + if (new_nblocks < old_nblocks) + { + /* Truncated - update FSM */ + FreeSpaceMapPrepareTruncateRel(rel, new_nblocks); + } +} diff --git a/src/backend/access/flux/flux_handler.c b/src/backend/access/flux/flux_handler.c new file mode 100644 index 0000000000000..ca5a91b023b67 --- /dev/null +++ b/src/backend/access/flux/flux_handler.c @@ -0,0 +1,4764 @@ +/*------------------------------------------------------------------------- + * + * flux_handler.c + * FLUX table access method handler + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_handler.c + * + * NOTES + * This file implements the FLUX table access method, which provides + * time-based MVCC with in-place updates, overflow pages for large + * attributes, compression, and advanced space management. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/genam.h" +#include "access/heapam.h" +#include "access/heaptoast.h" +#include "access/detoast.h" +#include "access/flux.h" +#include "access/flux_dirtymap.h" +#include "access/slog.h" +#include "access/flux_xlog.h" +#include "access/relundo.h" +#include "access/tableam.h" +#include "access/undobuffer.h" +#include "access/tsmapi.h" +#include "access/multixact.h" +#include "access/xact.h" +#include "access/xloginsert.h" +#include "catalog/index.h" +#include "catalog/pg_am.h" +#include "catalog/storage.h" +#include "catalog/storage_xlog.h" +#include "commands/progress.h" +#include "executor/executor.h" +#include "nodes/execnodes.h" +#include "nodes/tidbitmap.h" +#include "utils/backend_progress.h" +#include "utils/builtins.h" +#include "utils/snapmgr.h" +#include "utils/timestamp.h" +#include "utils/tuplesort.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/lmgr.h" +#include "storage/predicate.h" +#include "storage/procarray.h" +#include "storage/read_stream.h" +#include "storage/smgr.h" +#include "utils/datum.h" +#include "utils/memutils.h" +#include "utils/rel.h" + +/* Forward declarations */ +static void flux_prepare_pagescan(FluxScanDesc scan, Buffer buffer); +static BlockNumber flux_scan_stream_read_next(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data); +static BlockNumber flux_bitmap_stream_read_next(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data); +static bool flux_scan_analyze_next_block(TableScanDesc scan, ReadStream *stream); +static bool flux_scan_analyze_next_tuple(TableScanDesc scan, + double *liverows, double *deadrows, + TupleTableSlot *slot); +static void flux_scan_set_tidrange(TableScanDesc sscan, ItemPointer mintid, + ItemPointer maxtid); +static bool flux_scan_getnextslot_tidrange(TableScanDesc sscan, ScanDirection direction, + TupleTableSlot *slot); +static bool flux_scan_bitmap_next_tuple(TableScanDesc scan, + TupleTableSlot *slot, + bool *recheck, + uint64 *lossy_pages, + uint64 *exact_pages); + +/* Include operations from other modules */ +extern void flux_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid, + uint32 options, BulkInsertState bistate); +extern TM_Result flux_tuple_delete(Relation relation, ItemPointer tid, CommandId cid, + uint32 options, Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd); +extern TM_Result flux_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot, + CommandId cid, uint32 options, + Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd, + LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes); +extern void flux_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, + CommandId cid, uint32 options, BulkInsertState bistate); +extern void flux_relation_vacuum(Relation onerel, const VacuumParams *params, + BufferAccessStrategy bstrategy); + +/* + * Read stream callback for sequential scan prefetching. + * + * Returns the next block number to read ahead for the sequential scan. + * The read_stream infrastructure will prefetch these blocks asynchronously, + * reducing I/O wait time for cold data. + * + * Uses rs_prefetch_block (separate from rs_cblock) to track the prefetch + * position independently of the scan's current position. + */ +static BlockNumber +flux_scan_stream_read_next(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data) +{ + FluxScanDesc scan = (FluxScanDesc) callback_private_data; + BlockNumber block; + + block = scan->rs_prefetch_block; + if (block >= scan->rs_nblocks) + return InvalidBlockNumber; + + scan->rs_prefetch_block = block + 1; + return block; +} + +/* + * Read stream callback for bitmap heap scans. + * + * Pulls the next block from the TBM iterator and hands it to the read + * stream so upcoming bitmap pages are prefetched asynchronously, matching + * the HEAP bitmapheap_stream_read_next() behaviour. The TBMIterateResult + * for each block is stashed in per_buffer_data so the consumer can read + * lossy/recheck flags and the exact tuple offsets without re-iterating. + */ +static BlockNumber +flux_bitmap_stream_read_next(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data) +{ + FluxScanDesc scan = (FluxScanDesc) callback_private_data; + TableScanDesc sscan = &scan->rs_base; + TBMIterateResult *tbmres = per_buffer_data; + + for (;;) + { + CHECK_FOR_INTERRUPTS(); + + /* no more entries in the bitmap */ + if (!tbm_iterate(&sscan->st.rs_tbmiterator, tbmres)) + return InvalidBlockNumber; + + /* + * Ignore any claimed entries past what we think is the end of the + * relation. It may have been extended after the start of our scan. + * Skip this optimization under SERIALIZABLE, where all index-reachable + * tuples must be examined for conflict detection. + */ + if (!IsolationIsSerializable() && + tbmres->blockno >= scan->rs_nblocks) + continue; + + return tbmres->blockno; + } + + Assert(false); + return InvalidBlockNumber; +} + +/* + * ------------------------------------------------------------------------ + * Slot related callbacks for FLUX AM + * ------------------------------------------------------------------------ + */ + +/* + * Return slot implementation suitable for storing FLUX tuples + */ +static const TupleTableSlotOps * +flux_slot_callbacks(Relation relation) +{ + (void) relation; + return &TTSOpsFluxTuple; +} + +/* + * flux_begin_bulk_insert - Signal the start of a DML operation. + * + * Activates the Tier-2 UNDO write buffer for this relation. When active, + * per-tuple UNDO records are batched via UndoBufferAddRecord() and flushed + * in larger XLOG_UNDO_BATCH WAL records (overflow path), reducing per-row + * WAL overhead significantly for COPY and multi-insert. + * + * The overflow-flush path emits standalone XLOG_UNDO_BATCH records which + * the revert-worker's UndoReadBatchFromWAL() can walk for any AM. + */ +static void +flux_begin_bulk_insert(Relation rel, uint32 options, int64 nrows) +{ + (void) options; + + UndoBufferBegin(rel, nrows); +} + +/* + * flux_finish_bulk_insert - Complete a DML operation. + * + * Flushes any pending UNDO records and deactivates the write buffer. + */ +static void +flux_finish_bulk_insert(Relation rel, uint32 options) +{ + (void) options; + + UndoBufferEnd(rel); +} + +/* + * ------------------------------------------------------------------------ + * Table scan callbacks for FLUX AM + * ------------------------------------------------------------------------ + */ + +/* + * Start a scan of the FLUX relation + */ +static TableScanDesc +flux_scan_begin(Relation relation, Snapshot snapshot, + int nkeys, ScanKey key, + ParallelTableScanDesc pscan, + uint32 flags) +{ + FluxScanDesc scan; + + + scan = (FluxScanDesc) palloc0(sizeof(FluxScanDescData)); + + scan->rs_base.rs_rd = relation; + scan->rs_base.rs_snapshot = snapshot; + scan->rs_base.rs_nkeys = nkeys; + scan->rs_base.rs_key = key; + scan->rs_base.rs_flags = flags; + scan->rs_base.rs_parallel = pscan; + + scan->rs_cbuf = InvalidBuffer; + scan->rs_cblock = InvalidBlockNumber; + scan->rs_nblocks = RelationGetNumberOfBlocks(relation); + scan->rs_startblock = 0; + scan->rs_coffset = FirstOffsetNumber; + scan->rs_cindex = InvalidOffsetNumber; + scan->rs_inited = false; + scan->rs_ntuples = 0; + scan->rs_vistuples = NULL; + scan->rs_vm_buffer = InvalidBuffer; + scan->rs_vm_blockno = InvalidBlockNumber; + + /* Allocate parallel scan worker data if doing a parallel scan */ + if (pscan != NULL) + scan->rs_parallelworkerdata = palloc_object(ParallelBlockTableScanWorkerData); + else + scan->rs_parallelworkerdata = NULL; + + /* Set up MVCC bookkeeping timestamps (visibility uses xmin/xmax + CLOG) */ + scan->rs_snapshot_ts = GetCurrentTimestamp(); + scan->rs_xact_ts = GetCurrentTimestamp(); + + /* + * Initialize read stream for sequential prefetching (non-parallel only). + * The read stream uses the kernel readahead and our callback to prefetch + * upcoming pages, reducing I/O wait time for cold sequential scans. + * Parallel scans use their own block coordination, so skip the stream. + */ + scan->rs_prefetch_block = 0; + if (flags & SO_TYPE_BITMAPSCAN) + { + /* + * Bitmap scans drive the stream from the TBM iterator. The iterator + * is attached by the executor after beginscan, so the callback only + * runs once read_stream_next_buffer() is first called. Per-buffer + * data carries the TBMIterateResult for each prefetched block. + */ + scan->rs_read_stream = read_stream_begin_relation(READ_STREAM_DEFAULT, + NULL, /* bstrategy */ + relation, + MAIN_FORKNUM, + flux_bitmap_stream_read_next, + scan, + sizeof(TBMIterateResult)); + } + else if (pscan == NULL && scan->rs_nblocks > 0) + { + scan->rs_read_stream = read_stream_begin_relation(READ_STREAM_SEQUENTIAL | + READ_STREAM_USE_BATCHING, + NULL, /* bstrategy */ + relation, + MAIN_FORKNUM, + flux_scan_stream_read_next, + scan, + 0); + } + else + { + scan->rs_read_stream = NULL; + } + + return (TableScanDesc) scan; +} + +/* + * End the scan and release resources + */ +static void +flux_scan_end(TableScanDesc sscan) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + + /* + * FLUX does not compress attributes, so there is no compression + * dictionary to retrain at ANALYZE scan end. + */ + + /* End read stream before releasing buffers */ + if (scan->rs_read_stream != NULL) + { + read_stream_end(scan->rs_read_stream); + scan->rs_read_stream = NULL; + } + + /* Release buffer if held */ + if (BufferIsValid(scan->rs_cbuf)) + { + ReleaseBuffer(scan->rs_cbuf); + scan->rs_cbuf = InvalidBuffer; + } + + /* Release cached visibility map buffer */ + if (BufferIsValid(scan->rs_vm_buffer)) + { + ReleaseBuffer(scan->rs_vm_buffer); + scan->rs_vm_buffer = InvalidBuffer; + } + + if (scan->rs_vistuples) + pfree(scan->rs_vistuples); + + if (scan->rs_parallelworkerdata != NULL) + pfree(scan->rs_parallelworkerdata); + + /* + * Unregister the snapshot if this scan owns it (SO_TEMP_SNAPSHOT). + * Without this, catalog scans and parallel worker scans leak snapshot + * references, causing "resource was not closed" warnings. + */ + if (scan->rs_base.rs_flags & SO_TEMP_SNAPSHOT) + UnregisterSnapshot(scan->rs_base.rs_snapshot); + + pfree(scan); +} + +/* + * Restart a relation scan + */ +static void +flux_scan_rescan(TableScanDesc sscan, ScanKey key, + bool set_params, bool allow_strat, + bool allow_sync, bool allow_pagemode) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + + /* Release current buffer */ + if (BufferIsValid(scan->rs_cbuf)) + { + ReleaseBuffer(scan->rs_cbuf); + scan->rs_cbuf = InvalidBuffer; + } + + /* Release cached VM buffer on rescan (relation may have changed) */ + if (BufferIsValid(scan->rs_vm_buffer)) + { + ReleaseBuffer(scan->rs_vm_buffer); + scan->rs_vm_buffer = InvalidBuffer; + scan->rs_vm_blockno = InvalidBlockNumber; + } + + /* Reset read stream for rescan */ + if (scan->rs_read_stream != NULL) + { + read_stream_reset(scan->rs_read_stream); + scan->rs_prefetch_block = 0; + } + + /* Reset scan position to start of relation */ + scan->rs_cblock = InvalidBlockNumber; + scan->rs_nblocks = RelationGetNumberOfBlocks(sscan->rs_rd); + scan->rs_cindex = 0; + scan->rs_coffset = FirstOffsetNumber; + scan->rs_inited = false; + scan->rs_ntuples = 0; + + /* Update scan key if provided */ + scan->rs_base.rs_nkeys = key ? scan->rs_base.rs_nkeys : 0; + scan->rs_base.rs_key = key; +} + +/* + * Get the next block to scan. + * + * For serial scans, simply advances sequentially. For parallel scans, + * coordinates with other workers via table_block_parallelscan_nextpage() + * so that each block is scanned by exactly one worker. + * + * Returns the next block number, or InvalidBlockNumber when finished. + */ +static BlockNumber +flux_scan_nextblock(FluxScanDesc scan) +{ + BlockNumber nblocks = scan->rs_nblocks; + + if (nblocks == 0) + return InvalidBlockNumber; + + if (scan->rs_base.rs_parallel != NULL) + { + ParallelBlockTableScanDesc pbscan = + (ParallelBlockTableScanDesc) scan->rs_base.rs_parallel; + + /* Initialize parallel worker state on first call */ + if (!scan->rs_inited) + { + table_block_parallelscan_startblock_init(scan->rs_base.rs_rd, + scan->rs_parallelworkerdata, + pbscan, + scan->rs_startblock, + InvalidBlockNumber); + scan->rs_inited = true; + } + + return table_block_parallelscan_nextpage(scan->rs_base.rs_rd, + scan->rs_parallelworkerdata, + pbscan); + } + else + { + /* Serial scan: advance to next block sequentially */ + if (scan->rs_cblock == InvalidBlockNumber) + return 0; + + if (scan->rs_cblock + 1 >= nblocks) + return InvalidBlockNumber; + + return scan->rs_cblock + 1; + } +} + +/* + * flux_prepare_pagescan -- collect visible tuple offsets for page-mode scan + * + * Called once per page. Locks SHARE, checks visibility for all tuples, + * collects visible offsets into scan->rs_vistuples[], then unlocks. + * The buffer remains pinned via scan->rs_cbuf. + * + * This is the FLUX equivalent of heapgetpage(). By doing all visibility + * checks under a single SHARE lock acquisition per page, we avoid the + * overhead of ReadBuffer+LockBuffer+ReleaseBuffer per tuple that the + * original scan path had. + * + * The visibility map optimization (1D) is integrated here: if the page is + * marked all-visible, per-tuple visibility checks are skipped entirely. + */ +static void +flux_prepare_pagescan(FluxScanDesc scan, Buffer buffer) +{ + Page page; + OffsetNumber maxoff; + OffsetNumber offnum; + int ntup = 0; + bool all_visible; + + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + + /* Skip new/empty pages */ + if (PageIsNew(page)) + { + scan->rs_ntuples = 0; + scan->rs_cindex = 0; + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + return; + } + + maxoff = PageGetMaxOffsetNumber(page); + + /* Allocate vistuples array if needed (shared with bitmap scan path) */ + if (scan->rs_vistuples == NULL) + { + scan->rs_vistuples = (OffsetNumber *) + MemoryContextAlloc(TopMemoryContext, + MaxOffsetNumber * sizeof(OffsetNumber)); + } + + /* + * Check visibility: first check the in-page PD_ALL_VISIBLE flag (zero + * cost since the page is already pinned and locked). Only fall through + * to the VM fork if the in-page flag is not set. Use the cached VM + * buffer to avoid per-page ReadBufferExtended overhead. + */ + if (PageIsAllVisible(page)) + all_visible = true; + else + all_visible = FluxVMCheckCached(scan->rs_base.rs_rd, scan->rs_cblock, + FLUX_VM_ALL_VISIBLE, + &scan->rs_vm_buffer, + &scan->rs_vm_blockno); + + for (offnum = FirstOffsetNumber; offnum <= maxoff; + offnum = OffsetNumberNext(offnum)) + { + ItemId itemid; + FluxTupleHeader *tuple_header; + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_header = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records - they are not tuples */ + if (FluxIsOverflowRecordInline(tuple_header, ItemIdGetLength(itemid))) + continue; + + /* Skip speculative tuples not yet confirmed */ + if (tuple_header->t_flags & FLUX_TUPLE_SPECULATIVE) + continue; + + /* + * Check MVCC visibility. If the page is marked all-visible in the + * visibility map, skip the expensive per-tuple check. Otherwise, + * consult the commit timestamp and the sLog for in-progress + * transaction state. + * + * NOTE: Do NOT skip FLUX_TUPLE_DELETED tuples here. The DELETED + * flag is set physically at DELETE time, but the delete may be + * in-progress or aborted. The visibility function correctly consults + * the sLog to determine actual delete status. + */ + if (!all_visible && + scan->rs_base.rs_snapshot && + !FluxTupleVisibleToSnapshotDual(tuple_header, + scan->rs_base.rs_snapshot, + RelationGetRelid(scan->rs_base.rs_rd), + buffer)) + { + /* + * The on-page (newest) version is not visible to our snapshot. + * zheap read path: if this tuple was updated in place and still + * has a version chain in the UNDO fork, an OLDER version may be + * visible -- keep it as a candidate so flux_scan_getnextslot can + * reconstruct and serve the before-image. Only truly-invisible + * tuples (no history, or history exhausted) are dropped here. + */ + if ((tuple_header->t_flags & FLUX_TUPLE_UPDATED) && + IsMVCCSnapshot(scan->rs_base.rs_snapshot) && + RelUndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_header, + ItemIdGetLength(itemid)))) + { + scan->rs_vistuples[ntup++] = offnum; + continue; + } + + /* + * Tuple is not visible. If we're in a serializable transaction, + * check for rw-conflict out: a concurrent writer modified this + * tuple after our snapshot. + */ + if (IsolationIsSerializable()) + { + FluxCheckForSerializableConflictOut(scan->rs_base.rs_rd, + tuple_header, + buffer, + scan->rs_base.rs_snapshot); + } + continue; + } + + /* + * Tuple is visible. Acquire SIREAD predicate lock for SSI so that + * concurrent writers can detect rw-antidependencies on this tuple. + */ + if (IsolationIsSerializable()) + { + ItemPointerData item_tid; + + ItemPointerSet(&item_tid, BufferGetBlockNumber(buffer), offnum); + PredicateLockTID(scan->rs_base.rs_rd, &item_tid, + scan->rs_base.rs_snapshot, + InvalidTransactionId); + } + + scan->rs_vistuples[ntup++] = offnum; + } + + scan->rs_ntuples = ntup; + scan->rs_cindex = 0; + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); +} + +/* + * Get next tuple from scan (page-mode) + * + * Uses page-mode scanning: for each page, flux_prepare_pagescan() collects + * all visible tuple offsets under a single SHARE lock. This function then + * iterates through those offsets from the pinned-but-unlocked buffer. + * + * This eliminates the per-tuple ReadBuffer+LockBuffer+ReleaseBuffer overhead + * of the original implementation (50K rows = 50K buffer ops → 1 per page). + * + * For parallel scans, block assignment is coordinated via + * flux_scan_nextblock() which uses the parallel scan infrastructure. + */ +static bool +flux_scan_getnextslot(TableScanDesc sscan, ScanDirection direction, TupleTableSlot *slot) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + + /* If relation is empty, return false immediately */ + if (scan->rs_nblocks == 0) + { + ExecClearTuple(slot); + return false; + } + + for (;;) + { + Page page; + + /* + * Try to return the next visible tuple from the current page. The + * buffer is pinned but NOT locked -- this matches heap's page-mode + * pattern. FluxSlotStoreTuple acquires its own pin so the slot data + * remains valid after we move to the next page. + */ + while (scan->rs_cindex < scan->rs_ntuples) + { + OffsetNumber offnum; + ItemId itemid; + FluxTupleHeader *tuple_header; + + offnum = scan->rs_vistuples[scan->rs_cindex]; + scan->rs_cindex++; + + page = BufferGetPage(scan->rs_cbuf); + itemid = PageGetItemId(page, offnum); + + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_header = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Before-image substitution for committed in-place UPDATEs + * (zheap read path). + * + * The on-page image is the NEWEST version, stamped with the + * updater's xmin. If that updater is not visible to our MVCC + * snapshot, we must serve the older version reconstructed from the + * per-relation UNDO fork (WS-PVS2): FluxReconstructVisibleVersion + * walks the t_verptr chain and stops at the version whose + * producing xid is visible (XidInMVCCSnapshot). Visibility is + * decided purely by the xid snapshot + CLOG, matching + * FluxTupleSatisfiesMVCC. + * + * Determine on-page visibility once; only reconstruct when the + * newest version is NOT visible to us. When it IS visible, serve + * on-page bytes directly. + */ + if ((tuple_header->t_flags & FLUX_TUPLE_UPDATED) && + scan->rs_base.rs_snapshot != NULL && + IsMVCCSnapshot(scan->rs_base.rs_snapshot) && + RelUndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_header, + ItemIdGetLength(itemid))) && + !FluxTupleVisibleToSnapshotDual(tuple_header, + scan->rs_base.rs_snapshot, + RelationGetRelid(scan->rs_base.rs_rd), + scan->rs_cbuf)) + { + char *bi_data = NULL; + int bi_len = 0; + ItemPointerData item_tid; + + ItemPointerSet(&item_tid, scan->rs_cblock, offnum); + + if (FluxReconstructVisibleVersion( + scan->rs_base.rs_rd, + &item_tid, + (const char *) tuple_header, + ItemIdGetLength(itemid), + scan->rs_base.rs_snapshot, + &bi_data, &bi_len)) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + FluxSlotStoreMaterializedTuple(slot, bi_tuple, bi_len); + ItemPointerSet(&slot->tts_tid, scan->rs_cblock, offnum); + return true; + } + + /* + * No visible older version: the row is genuinely invisible to + * this snapshot (e.g. it was inserted-then-updated all after + * our snapshot). Skip it. + */ + continue; + } + + /* Normal: serve on-page data */ + FluxSlotStoreTuple(slot, tuple_header, + ItemIdGetLength(itemid), scan->rs_cbuf); + ItemPointerSet(&slot->tts_tid, scan->rs_cblock, offnum); + return true; + } + + /* + * Exhausted all visible tuples on the current page. Release the + * buffer pin and advance to the next block. + */ + if (BufferIsValid(scan->rs_cbuf)) + { + ReleaseBuffer(scan->rs_cbuf); + scan->rs_cbuf = InvalidBuffer; + } + + /* + * Get the next page. Use the read stream if available (prefetches + * upcoming pages for I/O efficiency), otherwise fall back to + * ReadBuffer for parallel scans. + */ + if (scan->rs_read_stream != NULL) + { + scan->rs_cbuf = read_stream_next_buffer(scan->rs_read_stream, NULL); + if (!BufferIsValid(scan->rs_cbuf)) + { + ExecClearTuple(slot); + return false; + } + scan->rs_cblock = BufferGetBlockNumber(scan->rs_cbuf); + } + else + { + BlockNumber block = flux_scan_nextblock(scan); + + if (!BlockNumberIsValid(block)) + { + ExecClearTuple(slot); + return false; + } + + scan->rs_cblock = block; + scan->rs_cbuf = ReadBuffer(scan->rs_base.rs_rd, scan->rs_cblock); + } + + /* + * Opportunistic cleanup: try to prune dead tuples before scanning. + * FluxPagePruneOpt() expects only a pin (no lock) and will + * non-blockingly try to get an exclusive lock. + */ + FluxPagePruneOpt(scan->rs_base.rs_rd, scan->rs_cbuf); + + /* + * Prepare the page scan: lock SHARE, collect all visible tuple + * offsets into rs_vistuples[], then unlock. The buffer stays pinned + * for efficient tuple access without re-locking. + */ + flux_prepare_pagescan(scan, scan->rs_cbuf); + } +} + +/* + * Set TID range for TID range scans + * + * This restricts the scan to only return tuples within the given TID range. + * Used by TID range scans (WHERE ctid >= ... AND ctid < ...). + * + * Following the heap AM pattern, we store the effective min/max TIDs in the + * base scan descriptor's st.tidrange fields and configure the scan start + * position accordingly. + */ +static void +flux_scan_set_tidrange(TableScanDesc sscan, ItemPointer mintid, + ItemPointer maxtid) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + BlockNumber nblocks; + ItemPointerData highestItem; + ItemPointerData lowestItem; + + nblocks = RelationGetNumberOfBlocks(sscan->rs_rd); + + /* + * For relations without any pages, we can simply leave the TID range + * unset. There will be no tuples to scan, therefore no tuples outside + * the given TID range. + */ + if (nblocks == 0) + return; + + /* + * Set up ItemPointers which point to the first and last possible tuples + * in the relation. + */ + ItemPointerSet(&highestItem, nblocks - 1, MaxOffsetNumber); + ItemPointerSet(&lowestItem, 0, FirstOffsetNumber); + + /* + * If the given maximum TID is below the highest possible TID in the + * relation, then restrict the range to that, otherwise we scan to the end + * of the relation. + */ + if (ItemPointerCompare(maxtid, &highestItem) < 0) + ItemPointerCopy(maxtid, &highestItem); + + /* + * If the given minimum TID is above the lowest possible TID in the + * relation, then restrict the range to only scan for TIDs above that. + */ + if (ItemPointerCompare(mintid, &lowestItem) > 0) + ItemPointerCopy(mintid, &lowestItem); + + /* + * Check for an empty range. + */ + if (ItemPointerCompare(&highestItem, &lowestItem) < 0) + { + /* Force an empty scan */ + scan->rs_cblock = nblocks; + scan->rs_coffset = FirstOffsetNumber; + ItemPointerSetInvalid(&sscan->st.tidrange.rs_mintid); + ItemPointerSetInvalid(&sscan->st.tidrange.rs_maxtid); + return; + } + + /* Set scan start position to the first block in range */ + scan->rs_cblock = ItemPointerGetBlockNumberNoCheck(&lowestItem); + scan->rs_coffset = FirstOffsetNumber; + + /* Store the effective TID range in the base scan descriptor */ + ItemPointerCopy(&lowestItem, &sscan->st.tidrange.rs_mintid); + ItemPointerCopy(&highestItem, &sscan->st.tidrange.rs_maxtid); +} + +/* + * Get next tuple within the TID range set by flux_scan_set_tidrange. + * + * This delegates to the regular flux_scan_getnextslot for tuple fetching + * and visibility, then filters to only return tuples within the TID range. + * This keeps visibility semantics consistent between regular and TID range + * scans. + */ +static bool +flux_scan_getnextslot_tidrange(TableScanDesc sscan, ScanDirection direction, + TupleTableSlot *slot) +{ + FluxScanDesc scan = (FluxScanDesc) sscan; + ItemPointer mintid = &sscan->st.tidrange.rs_mintid; + ItemPointer maxtid = &sscan->st.tidrange.rs_maxtid; + BlockNumber nblocks; + BlockNumber block; + BlockNumber maxblock; + Buffer buffer; + Page page; + OffsetNumber offnum; + OffsetNumber maxoff; + ItemId itemid; + FluxTupleHeader *tuple_header; + + /* If the range is invalid/empty, we're done */ + if (!ItemPointerIsValid(mintid) || !ItemPointerIsValid(maxtid)) + { + ExecClearTuple(slot); + return false; + } + + maxblock = ItemPointerGetBlockNumber(maxtid); + + /* Clear the slot */ + ExecClearTuple(slot); + + nblocks = RelationGetNumberOfBlocks(sscan->rs_rd); + if (nblocks == 0) + return false; + + /* Scan pages within the TID range */ + for (block = scan->rs_cblock; block <= maxblock && block < nblocks; block++) + { + buffer = ReadBuffer(sscan->rs_rd, block); + + /* + * Opportunistic cleanup on first visit to page, consistent with the + * regular scan path. + */ + if (scan->rs_coffset == FirstOffsetNumber) + FluxPagePruneOpt(sscan->rs_rd, buffer); + + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + + /* Skip new/empty pages */ + if (PageIsNew(page)) + { + UnlockReleaseBuffer(buffer); + scan->rs_coffset = FirstOffsetNumber; + continue; + } + + maxoff = PageGetMaxOffsetNumber(page); + + for (offnum = scan->rs_coffset; offnum <= maxoff; offnum++) + { + ItemPointerData curtid; + + ItemPointerSet(&curtid, block, offnum); + + /* + * Filter by TID range. Skip tuples below mintid. + */ + if (ItemPointerCompare(&curtid, mintid) < 0) + continue; + + /* + * If we've passed maxtid, we're done scanning. + */ + if (ItemPointerCompare(&curtid, maxtid) > 0) + { + UnlockReleaseBuffer(buffer); + return false; + } + + itemid = PageGetItemId(page, offnum); + + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_header = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records - they are not tuples */ + if (FluxIsOverflowRecordInline(tuple_header, + ItemIdGetLength(itemid))) + continue; + + /* Skip speculative tuples not yet confirmed */ + if (tuple_header->t_flags & FLUX_TUPLE_SPECULATIVE) + continue; + + /* Check MVCC visibility (handles DELETED via sLog) */ + if (sscan->rs_snapshot && + !FluxTupleVisibleToSnapshotDual(tuple_header, + sscan->rs_snapshot, + RelationGetRelid(sscan->rs_rd), + buffer)) + continue; + + /* + * Store the tuple into the slot with a buffer pin. Decompression + * happens lazily during deformation. + */ + FluxSlotStoreTuple(slot, tuple_header, + ItemIdGetLength(itemid), buffer); + ItemPointerSet(&slot->tts_tid, block, offnum); + /* Update scan position for next call */ + scan->rs_cblock = block; + scan->rs_coffset = offnum + 1; + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + return true; + } + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + scan->rs_coffset = FirstOffsetNumber; + } + + /* Reached end of assigned range */ + return false; +} + +/* + * Bitmap heap scan: fetch next tuple from bitmap + * + * This is the scan_bitmap_next_tuple callback. It iterates over TIDs from + * the TBM (TID bitmap) built by a BitmapIndexScan, fetches and checks + * visibility for each tuple, and returns visible ones in the slot. + * + * For each block indicated by the bitmap: + * - If the block is "lossy" (the bitmap lost per-tuple precision for this + * block), we check every tuple on the page. + * - If the block is "exact", we only check the specific offsets indicated. + * + * The scan descriptor was set up via table_beginscan_bm() and the TBM + * iterator is stored in scan->st.rs_tbmiterator. + */ +static bool +flux_scan_bitmap_next_tuple(TableScanDesc scan, + TupleTableSlot *slot, + bool *recheck, + uint64 *lossy_pages, + uint64 *exact_pages) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + + Assert(rscan->rs_read_stream); + + for (;;) + { + void *per_buffer_data; + TBMIterateResult *tbmres; + + /* + * If we have tuples remaining from a previously fetched page, try to + * return one. + */ + if (rscan->rs_ntuples > 0 && + rscan->rs_cindex < rscan->rs_ntuples) + { + OffsetNumber offnum; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + + offnum = rscan->rs_vistuples[rscan->rs_cindex]; + rscan->rs_cindex++; + + /* Re-read the page (we released the lock after visibility check) */ + if (!BufferIsValid(rscan->rs_cbuf)) + rscan->rs_cbuf = ReadBuffer(scan->rs_rd, rscan->rs_cblock); + + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_SHARE); + page = BufferGetPage(rscan->rs_cbuf); + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + continue; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * NOTE: Do NOT skip FLUX_TUPLE_DELETED here. This tuple passed + * the visibility check during page scan preparation. The DELETED + * flag may be set but the delete could be in-progress or aborted. + */ + + /* + * FLUX updates in place keeping the same TID, so a changed indexed + * column leaves a stale (oldkey -> tid) secondary entry beside the + * new one. A bitmap scan keeps only TIDs and discards the index + * tuple, so it cannot compare the stored key against the live tuple. + * Force the executor to re-evaluate bitmapqualorig against the live + * tuple whenever we return a committed in-place UPDATE: an exact + * (non-lossy) bitmap would otherwise trust the stale index entry and + * return a row whose live key no longer matches the scan key. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) + *recheck = true; + + /* + * Before-image substitution for in-place UPDATEs (zheap read + * path), mirroring the sequential-scan and index-fetch paths. A + * snapshot that cannot see the updater's xmin must observe the + * before-image, not the on-page (new) data. Serve it from the + * per-relation UNDO fork (WS-PVS2); FluxReconstructVisibleVersion + * walks the t_verptr chain and stops at the version whose producing + * xid is visible. Visibility is ordinary heap-shaped xmin/xmax + + * CLOG + snapshot (FluxTupleSatisfiesMVCC). + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + scan->rs_snapshot != NULL && + IsMVCCSnapshot(scan->rs_snapshot) && + RelUndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr, + ItemIdGetLength(itemid))) && + !FluxTupleVisibleToSnapshotDual(tuple_hdr, scan->rs_snapshot, + RelationGetRelid(scan->rs_rd), + rscan->rs_cbuf)) + { + char *bi_data = NULL; + int bi_len = 0; + ItemPointerData item_tid; + + ItemPointerSet(&item_tid, rscan->rs_cblock, offnum); + + if (FluxReconstructVisibleVersion( + scan->rs_rd, + &item_tid, + (const char *) tuple_hdr, + ItemIdGetLength(itemid), + scan->rs_snapshot, + &bi_data, &bi_len)) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + FluxSlotStoreMaterializedTuple(slot, bi_tuple, bi_len); + slot->tts_tableOid = RelationGetRelid(scan->rs_rd); + ItemPointerSet(&slot->tts_tid, rscan->rs_cblock, offnum); + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + return true; + } + + /* No visible older version: skip this tuple. */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + continue; + } + + /* + * Store the tuple with a buffer pin. The slot gets its own pin + * via FluxSlotStoreTuple so the data stays valid. + */ + FluxSlotStoreTuple(slot, tuple_hdr, + ItemIdGetLength(itemid), rscan->rs_cbuf); + slot->tts_tableOid = RelationGetRelid(scan->rs_rd); + ItemPointerSet(&slot->tts_tid, rscan->rs_cblock, offnum); + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + return true; + } + + /* Release buffer from previous block */ + if (BufferIsValid(rscan->rs_cbuf)) + { + ReleaseBuffer(rscan->rs_cbuf); + rscan->rs_cbuf = InvalidBuffer; + } + + /* + * Advance to the next block in the bitmap. The read stream pulls + * blocks from the TBM iterator (via flux_bitmap_stream_read_next), + * prefetching upcoming bitmap pages, and hands back the matching + * TBMIterateResult in per_buffer_data. Out-of-range blocks were + * already filtered by the callback. + */ + rscan->rs_cbuf = read_stream_next_buffer(rscan->rs_read_stream, + &per_buffer_data); + + if (!BufferIsValid(rscan->rs_cbuf)) + return false; /* bitmap exhausted */ + + tbmres = per_buffer_data; + + Assert(BlockNumberIsValid(tbmres->blockno)); + Assert(BufferGetBlockNumber(rscan->rs_cbuf) == tbmres->blockno); + + *recheck = tbmres->recheck; + + rscan->rs_cblock = tbmres->blockno; + + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_SHARE); + { + Page page = BufferGetPage(rscan->rs_cbuf); + int ntup = 0; + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + + /* Allocate vistuples array if needed */ + if (rscan->rs_vistuples == NULL) + { + rscan->rs_vistuples = (OffsetNumber *) + MemoryContextAlloc(TopMemoryContext, + MaxOffsetNumber * sizeof(OffsetNumber)); + } + + if (!tbmres->lossy) + { + /* + * Exact page: only examine offsets listed in the bitmap. + */ + OffsetNumber offsets[TBM_MAX_TUPLES_PER_PAGE]; + int noffsets; + + noffsets = tbm_extract_page_tuple(tbmres, offsets, + TBM_MAX_TUPLES_PER_PAGE); + + for (int j = 0; j < noffsets; j++) + { + OffsetNumber offnum = offsets[j]; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + + if (offnum < FirstOffsetNumber || offnum > maxoff) + continue; + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + /* Skip speculative tuples */ + if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + continue; + + /* + * Check visibility. Keep an invisible-but-updated tuple that + * still has a version chain as a candidate so the tuple loop + * can reconstruct and serve the before-image (zheap read path). + */ + if (scan->rs_snapshot && + !FluxTupleVisibleToSnapshotDual(tuple_hdr, scan->rs_snapshot, + RelationGetRelid(scan->rs_rd), + rscan->rs_cbuf)) + { + if (!((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + IsMVCCSnapshot(scan->rs_snapshot) && + RelUndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr, + ItemIdGetLength(itemid))))) + continue; + } + + rscan->rs_vistuples[ntup++] = offnum; + } + + (*exact_pages)++; + } + else + { + /* + * Lossy page: examine every tuple on the page. tbmres->lossy + * is true here. + */ + OffsetNumber offnum; + + for (offnum = FirstOffsetNumber; offnum <= maxoff; + offnum = OffsetNumberNext(offnum)) + { + ItemId itemid; + FluxTupleHeader *tuple_hdr; + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + /* Skip speculative tuples */ + if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + continue; + + /* + * Check visibility. Keep an invisible-but-updated tuple that + * still has a version chain as a candidate so the tuple loop + * can reconstruct and serve the before-image (zheap read path). + */ + if (scan->rs_snapshot && + !FluxTupleVisibleToSnapshotDual(tuple_hdr, scan->rs_snapshot, + RelationGetRelid(scan->rs_rd), + rscan->rs_cbuf)) + { + if (!((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + IsMVCCSnapshot(scan->rs_snapshot) && + RelUndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr, + ItemIdGetLength(itemid))))) + continue; + } + + rscan->rs_vistuples[ntup++] = offnum; + } + + (*lossy_pages)++; + } + + rscan->rs_ntuples = ntup; + rscan->rs_cindex = 0; + } + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + /* Loop back to return the first visible tuple from this block */ + } +} + +/* + * ------------------------------------------------------------------------ + * Index scan callbacks for FLUX AM + * ------------------------------------------------------------------------ + */ + +static IndexFetchTableData * +flux_index_fetch_begin(Relation rel, uint32 flags) +{ + IndexFetchFluxData *scan = palloc0_object(IndexFetchFluxData); + + scan->base.rel = rel; + scan->buffer = InvalidBuffer; + + return &scan->base; +} + +static void +flux_index_fetch_reset(IndexFetchTableData *scan) +{ + IndexFetchFluxData *rscan = (IndexFetchFluxData *) scan; + + if (BufferIsValid(rscan->buffer)) + { + ReleaseBuffer(rscan->buffer); + rscan->buffer = InvalidBuffer; + } +} + +static void +flux_index_fetch_end(IndexFetchTableData *scan) +{ + IndexFetchFluxData *rscan = (IndexFetchFluxData *) scan; + + flux_index_fetch_reset(scan); + + pfree(rscan); +} + +/* + * Fetches, as part of an index scan, tuple at `tid` into `slot`, after doing + * a visibility test according to `snapshot`. If a tuple was found and passed + * the visibility test, returns true, false otherwise. Note that *tid may be + * modified when we return true (see later remarks on multiple row versions + * reachable via a single index entry). + * + * *call_again needs to be false on the first call to table_index_fetch_tuple() for + * a tid. If there potentially is another tuple matching the tid, *call_again + * will be set to true, signaling that table_index_fetch_tuple() should be called + * again for the same tid. + * + * *all_dead, if all_dead is not NULL, will be set to true by + * table_index_fetch_tuple() iff it is guaranteed that no backend needs to see + * that tuple. Index AMs can use that to avoid returning that tid in future + * searches. + * + * The difference between this function and table_tuple_fetch_row_version() + * is that this function returns the currently visible version of a row if + * the AM supports storing multiple row versions reachable via a single index + * entry. FLUX does in-place updates so there are no version chains to + * follow; this behaves identically to table_tuple_fetch_row_version(). + */ +static bool +flux_index_fetch_tuple(IndexFetchTableData *iftd, + ItemPointer tid, + Snapshot snapshot, + TupleTableSlot *tts, + bool *call_again, bool *all_dead) +{ + IndexFetchFluxData *scan = (IndexFetchFluxData *) iftd; + Relation rel = scan->base.rel; + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + bool visible = false; + + /* + * Initialize output parameters. all_dead is strictly PER-TID: it must + * reflect only whether *this* TID is dead to every snapshot, never a + * verdict carried over from a previously-fetched TID in the same scan. + * FLUX does in-place updates and never sets call_again, so each TID is + * fetched exactly once; a sticky scan-level all_dead would leak an earlier + * TID's dead verdict onto a later LIVE TID, making indexam.c set + * kill_prior_tuple and _bt_killitems mark the live entry LP_DEAD -- the + * silent-data-loss "kill LIVE entries" failure. So always start false and + * set true only in the truly-dead branch below. + */ + *call_again = false; + if (all_dead) + *all_dead = false; + + /* + * FLUX secondary indexes hold plain 6-byte heap-style TIDs and FLUX does + * OUT-OF-PLACE updates for any key-changing UPDATE (delete + insert at a + * new TID), so a changed indexed column never leaves a stale (oldkey -> + * tid) entry pointing at a live in-place tuple. There is therefore no + * in-place index staleness to recheck: this is a plain heap-style TID + * fetch that just checks visibility. + */ + + /* Clear the slot */ + ExecClearTuple(tts); + + /* + * Release any buffer from a previous index_fetch_tuple call. This + * prevents buffer leaks when scanning multiple tuples. + */ + if (BufferIsValid(scan->buffer)) + { + ReleaseBuffer(scan->buffer); + scan->buffer = InvalidBuffer; + } + + /* Read the page */ + buffer = ReadBuffer(rel, blkno); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + + page = BufferGetPage(buffer); + + /* Validate offset */ + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* Get the item */ + itemid = PageGetItemId(page, offnum); + + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* Get tuple header */ + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Check visibility (heap-shaped xmin/xmax) */ + if (snapshot) + { + if (snapshot->snapshot_type == SNAPSHOT_DIRTY) + { + /* + * For SNAPSHOT_DIRTY (used by _bt_check_unique during ON + * CONFLICT), we must report the inserting/deleting xid through + * the snapshot struct so that SpeculativeInsertionWait can + * function correctly. This mirrors HeapTupleSatisfiesDirty() + * behaviour. + * + * Uses t_xmin for INSERT visibility (fast path) and a single + * batched sLog lookup for DELETE/UPDATE/LOCK state. + */ + SLogTupleOp slog_entries[SLOG_MAX_TUPLE_OPS]; + int slog_nfound = -1; /* lazy: -1 = not yet fetched */ + + snapshot->xmin = InvalidTransactionId; + snapshot->xmax = InvalidTransactionId; + snapshot->speculativeToken = 0; + + /* + * Check if this tuple is uncommitted (inserted by a still-running + * transaction). Use t_xmin for the fast path, falling back + * to sLog for speculative inserts. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + TransactionId hint_xid = InvalidTransactionId; + + if (TransactionIdIsValid(hint_xid) && + !TransactionIdIsCurrentTransactionId(hint_xid)) + { + if (TransactionIdIsInProgress(hint_xid)) + { + /* + * Another transaction is inserting this tuple. Report + * xmin for SpeculativeInsertionWait. + */ + snapshot->xmin = hint_xid; + + if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + { + /* Fetch sLog for speculative token */ + slog_nfound = SLogTupleLookupFiltered( + RelationGetRelid(rel), tid, + InvalidTransactionId, + slog_entries, SLOG_MAX_TUPLE_OPS); + { + int si; + + for (si = 0; si < slog_nfound; si++) + { + if (slog_entries[si].xid == hint_xid && + slog_entries[si].spec_token != 0) + { + snapshot->speculativeToken = + slog_entries[si].spec_token; + break; + } + } + } + } + + visible = true; + goto visibility_done; + } + else if (TransactionIdDidAbort(hint_xid)) + { + /* Inserter aborted -- invisible */ + visible = false; + goto visibility_done; + } + else + { + /* + * Inserter committed. Clear stale flag via + * BufferSetHintBits16 (handles lock upgrade). + */ + BufferSetHintBits16(&tuple_hdr->t_flags, + tuple_hdr->t_flags & ~FLUX_TUPLE_UNCOMMITTED, + buffer); + } + } + else if (TransactionIdIsValid(hint_xid) && + TransactionIdIsCurrentTransactionId(hint_xid)) + { + /* + * Our own insert. Check sLog for our own delete or for + * an ABORTED entry from savepoint rollback. + */ + slog_nfound = SLogTupleLookupFiltered( + RelationGetRelid(rel), tid, + InvalidTransactionId, + slog_entries, SLOG_MAX_TUPLE_OPS); + { + int si; + bool found_invisible = false; + + for (si = 0; si < slog_nfound; si++) + { + if (!TransactionIdEquals(slog_entries[si].xid, hint_xid)) + continue; + if (slog_entries[si].op_type == SLOG_OP_DELETE || + slog_entries[si].op_type == SLOG_OP_ABORTED) + { + found_invisible = true; + break; + } + } + + if (found_invisible) + { + visible = false; + goto visibility_done; + } + } + /* Our insert, not deleted/aborted by us -- fall through */ + } + else + { + /* + * Invalid hint_xid -- fall back to sLog lookup. This + * handles pre-upgrade tuples. + */ + slog_nfound = SLogTupleLookupFiltered( + RelationGetRelid(rel), tid, + InvalidTransactionId, + slog_entries, SLOG_MAX_TUPLE_OPS); + { + int si; + bool found_inserter = false; + + for (si = 0; si < slog_nfound; si++) + { + if (slog_entries[si].op_type == SLOG_OP_INSERT && + TransactionIdIsInProgress(slog_entries[si].xid)) + { + snapshot->xmin = slog_entries[si].xid; + found_inserter = true; + break; + } + if (slog_entries[si].op_type == SLOG_OP_ABORTED) + { + visible = false; + goto visibility_done; + } + } + + if (!found_inserter) + { + /* Stale flag, clear it */ + BufferSetHintBits16(&tuple_hdr->t_flags, + tuple_hdr->t_flags & ~FLUX_TUPLE_UNCOMMITTED, + buffer); + } + else + { + visible = true; + goto visibility_done; + } + } + } + } + + /* Inserting xact is committed (or ours). Check deletion. */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + /* Single batched sLog lookup for delete state */ + if (slog_nfound < 0) + slog_nfound = SLogTupleLookupFiltered( + RelationGetRelid(rel), tid, + InvalidTransactionId, + slog_entries, SLOG_MAX_TUPLE_OPS); + { + int si; + bool delete_aborted = false; + + for (si = 0; si < slog_nfound; si++) + { + if (TransactionIdIsCurrentTransactionId(slog_entries[si].xid) && + (slog_entries[si].op_type == SLOG_OP_DELETE || + slog_entries[si].op_type == SLOG_OP_UPDATE)) + { + /* We deleted it ourselves */ + visible = false; + goto visibility_done; + } + if (TransactionIdIsInProgress(slog_entries[si].xid) && + (slog_entries[si].op_type == SLOG_OP_DELETE || + slog_entries[si].op_type == SLOG_OP_UPDATE)) + { + /* Deleter still running */ + snapshot->xmax = slog_entries[si].xid; + visible = true; + goto visibility_done; + } + if (slog_entries[si].op_type == SLOG_OP_ABORTED) + { + /* + * Delete was rolled back (ROLLBACK TO SAVEPOINT + * or full abort with deferred UNDO). The UNDO + * worker has not yet cleared the DELETED flag. + */ + delete_aborted = true; + } + } + + if (delete_aborted) + { + /* + * All delete operations were aborted. Clear the + * stale DELETED flag via hint bits and treat the + * tuple as live. + */ + BufferSetHintBits16(&tuple_hdr->t_flags, + tuple_hdr->t_flags & ~FLUX_TUPLE_DELETED, + buffer); + /* Fall through — tuple is still live */ + } + else if (slog_nfound == 0) + { + /* + * No sLog entries and DELETED flag is set. The UNDO + * worker always clears the DELETED flag before + * removing the sLog entry, so if we see DELETED with + * no sLog entries, the deletion committed and UNDO + * cleanup removed the entries afterward. + */ + Assert(!(tuple_hdr->t_flags & FLUX_TUPLE_DELETED) || + true); /* invariant: flag + no slog = + * committed */ + visible = false; + goto visibility_done; + } + else + { + /* + * sLog entries exist but none are in-progress, + * current, or aborted — deletion committed. + */ + visible = false; + goto visibility_done; + } + } + } + + /* + * Check if this is the old version of an out-of-place update. The + * FLUX_TUPLE_UPDATED flag means this tuple has been superseded + * by a newer version at t_ctid. However, the updater may have + * aborted — check sLog for ABORTED entries before declaring it + * invisible. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) + { + /* + * FLUX updates in place: an UPDATED tuple whose t_ctid still + * points at itself is the live current version, not a + * superseded old version. Only a genuine out-of-place move + * (cross-page defrag) repoints t_ctid elsewhere. Skip the + * supersession check for in-place updates so a retained + * committed UPDATE marker (kept for before-image serving) + * does not make the live row vanish from a dirty-snapshot + * probe -- e.g. the ON CONFLICT arbiter scan would otherwise + * miss the existing row after any prior UPDATE on it. This + * mirrors the MVCC path's handling in + * FluxTupleVisibleToSnapshotDual. + */ + if (ItemPointerEquals(&tuple_hdr->t_ctid, tid)) + { + /* In-place update: on-page tuple is live. */ + visible = true; + goto visibility_done; + } + + if (slog_nfound < 0) + slog_nfound = SLogTupleLookupFiltered( + RelationGetRelid(rel), tid, + InvalidTransactionId, + slog_entries, SLOG_MAX_TUPLE_OPS); + { + int si; + bool update_aborted = false; + + for (si = 0; si < slog_nfound; si++) + { + if (slog_entries[si].op_type == SLOG_OP_ABORTED) + { + update_aborted = true; + break; + } + if ((slog_entries[si].op_type == SLOG_OP_UPDATE) && + !TransactionIdIsCurrentTransactionId(slog_entries[si].xid) && + TransactionIdDidAbort(slog_entries[si].xid)) + { + update_aborted = true; + break; + } + } + + if (update_aborted) + { + /* + * Updater aborted. Clear stale UPDATED flag via + * hint-bits and treat as still-live tuple. + */ + BufferSetHintBits16(&tuple_hdr->t_flags, + tuple_hdr->t_flags & ~FLUX_TUPLE_UPDATED, + buffer); + /* Fall through to visible */ + } + else if (slog_nfound == 0) + { + /* + * No sLog entries but UPDATED flag is set. This + * means either: (a) the retained UPDATE entry was + * reclaimed by the per-TID oldest-entry eviction in + * flat_hash_apply_insert (hot row), or (b) the + * background worker cleaned up the entry. + * + * In both cases the update committed — the tuple on + * page IS the current version. Clear the stale + * UPDATED flag and treat as visible (live tuple). + */ + BufferSetHintBits16(&tuple_hdr->t_flags, + tuple_hdr->t_flags & ~FLUX_TUPLE_UPDATED, + buffer); + /* Fall through to visible */ + } + else + { + /* + * Check if updater is current, in-progress, or + * committed + */ + bool updater_running = false; + + for (si = 0; si < slog_nfound; si++) + { + if (slog_entries[si].op_type != SLOG_OP_UPDATE) + continue; + + if (TransactionIdIsCurrentTransactionId(slog_entries[si].xid)) + { + /* + * We are the updater — old version is dead. + * This mirrors the DELETED handling above. + */ + visible = false; + goto visibility_done; + } + + if (TransactionIdIsInProgress(slog_entries[si].xid)) + { + snapshot->xmax = slog_entries[si].xid; + updater_running = true; + break; + } + } + + if (!updater_running) + { + /* Updater committed — old version is dead */ + visible = false; + goto visibility_done; + } + /* Updater still running — tuple visible for now */ + } + } + } + + /* LOCKED tuples are still visible (lock != delete) */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_LOCKED) && + !(tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_UPDATED))) + { + visible = true; + goto visibility_done; + } + + visible = true; + } + else + { + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(rel), + buffer); + } + } + else + { + /* + * No snapshot means fetch unconditionally (e.g., system catalog + * scans, VACUUM FULL table rewrite). Only truly deleted tuples are + * invisible. UPDATED tuples are live (they contain the current + * version of the data after an in-place update). + */ + visible = !(tuple_hdr->t_flags & FLUX_TUPLE_DELETED); + } +visibility_done: + + if (!visible) + { + /* + * zheap read path (index fetch): the on-page (newest) version is not + * visible to our snapshot, but if this tuple was updated in place and + * still has a version chain in the UNDO fork, an OLDER version may be + * visible. Reconstruct it and serve the before-image, mirroring the + * sequential-scan path. This is what lets a REPEATABLE READ reader + * whose snapshot predates a committed UPDATE still find the row at its + * old indexed key. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + snapshot != NULL && IsMVCCSnapshot(snapshot) && + RelUndoRecPtrIsValid(FluxTupleGetVersionPtr(tuple_hdr, + ItemIdGetLength(itemid)))) + { + char *bi_data = NULL; + int bi_len = 0; + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + if (FluxReconstructVisibleVersion(rel, tid, + (const char *) tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + &bi_data, &bi_len)) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + FluxSlotStoreMaterializedTuple(tts, bi_tuple, bi_len); + tts->tts_tableOid = RelationGetRelid(rel); + ItemPointerCopy(tid, &tts->tts_tid); + ReleaseBuffer(buffer); + return true; + } + ReleaseBuffer(buffer); + return false; + } + + /* + * Set the all_dead hint only if the tuple is committed-deleted AND its + * deleter (t_xmax) precedes the oldest-xmin horizon, meaning no running + * or future snapshot can still see it. FluxTupleDeadToAll does the + * heap-shaped XID-horizon check (t_xmax committed and < oldest_xmin); + * this replaced the HLC-era FluxCanVacuumTimestamp, which compared the + * on-page word as a wall-clock timestamp -- WRONG post-pivot, since that + * word now packs the XID-based t_xmax. + * + * Setting all_dead prematurely would let the index AM remove the + * entry while concurrent transactions still need it. + */ + if (FluxTupleDeadToAll(tuple_hdr, FluxGetOldestXminHorizon(rel))) + { + if (all_dead) + *all_dead = true; + } + + /* + * Do NOT set call_again for UPDATED tuples. For out-of-place updates + * the executor inserts new index entries (TU_All), so the index scan + * will naturally find the new version through its own index entry. + * Setting call_again=true here with the same tid causes an infinite + * loop because the caller retries with the unchanged tid parameter. + */ + + UnlockReleaseBuffer(buffer); + return false; + } + + /* + * Unlock the buffer before materializing the slot. We keep the pin to + * ensure the page doesn't get evicted. We must unlock here because + * FluxTupleToSlotWithOverflow may need to fetch overflow data, and if + * that overflow is on the same page, it would try to lock an + * already-locked buffer causing an assertion failure. + */ + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + + /* + * Before-image substitution for in-place UPDATEs (zheap read path), + * mirroring the sequential-scan path (flux_getnextslot) and the + * fetch-by-TID path (flux_tuple_fetch_row_version). If the reader can + * see the updater's xmin the on-page value is correct and the callee + * returns false (serve on-page); if not, an older visible version is + * reconstructed from the per-relation UNDO fork (WS-PVS2) so a REPEATABLE + * READ / SERIALIZABLE reader whose snapshot cannot see the update observes + * the before-image through any index -- including one whose key the UPDATE + * never touched (e.g. a primary-key lookup). Visibility is ordinary + * heap-shaped xmin/xmax + CLOG + snapshot (XidInMVCCSnapshot in the callee). + * + * No index-key recheck is needed: FLUX does in-place updates only for + * NON-key columns (key changes go out of place to a new TID), so the + * before-image reached through any index entry has the same key as the + * stored entry that led here. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + !(tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) && + snapshot != NULL && IsMVCCSnapshot(snapshot) && + FluxDirtyMapCheck(RelationGetRelid(rel), + ItemPointerGetBlockNumber(tid))) + { + char *bi_data = NULL; + int bi_len = 0; + + if (FluxReconstructVisibleVersion(rel, tid, + (const char *) tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + &bi_data, &bi_len)) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + FluxSlotStoreMaterializedTuple(tts, bi_tuple, bi_len); + tts->tts_tableOid = RelationGetRelid(rel); + tts->tts_tid = *tid; + + scan->buffer = buffer; + return true; + } + } + + /* + * Tuple is visible. A committed-DELETED tuple can still be visible here: + * an RR/SERIALIZABLE reader whose snapshot predates the delete sees the row + * (FluxTupleVisibleToSnapshotDual already returned true because the deleter + * xid is not visible to this snapshot). FluxTupleToSlotWithOverflow refuses + * a DELETED tuple outright (it can't tell visible-to-this-snapshot from + * physically-dead), so serve the on-page image via FluxSlotStoreTuple -- + * the same virtual-slot store the sequential-scan path uses, which has no + * DELETED reject. This mirrors seqscan and fixes a lost-row/wrong-result + * under REPEATABLE READ via an index path. + */ + if (visible && (tuple_hdr->t_flags & FLUX_TUPLE_DELETED)) + { + FluxSlotStoreTuple(tts, tuple_hdr, ItemIdGetLength(itemid), buffer); + tts->tts_tableOid = RelationGetRelid(rel); + tts->tts_tid = *tid; + /* + * Materialize while the page is still pinned so the returned varlena + * datums are copied into slot-owned memory and never dangle if the + * pinned buffer is released/reused before the caller consumes them. + */ + ExecMaterializeSlot(tts); + ReleaseBuffer(buffer); + return true; + } + + /* Tuple is visible - convert to slot (with overflow fetch) */ + if (FluxTupleToSlotWithOverflow(tuple_hdr, tts, rel)) + { + tts->tts_tableOid = RelationGetRelid(rel); + tts->tts_tid = *tid; + /* Slot is already marked valid by FluxTupleToSlotWithOverflow */ + } + else + { + ReleaseBuffer(buffer); + return false; + } + + /* + * Materialize while the page is still pinned: FluxTupleToSlotWithOverflow + * stored pointers into the buffer page for non-overflow columns. Copying + * them into slot-owned memory now means the returned datums do not depend + * on the buffer staying pinned, so a caller that reads them after the scan + * advances (releasing/reusing the buffer) cannot dangle. This matches the + * lifetime guarantee heap gives once a fetched slot is handed upward. + */ + ExecMaterializeSlot(tts); + ReleaseBuffer(buffer); + + return true; +} + +/* + * ------------------------------------------------------------------------ + * Tuple manipulation callbacks for FLUX AM + * ------------------------------------------------------------------------ + */ + +/* + * Fetch tuple at given TID + */ +static bool +flux_tuple_fetch_row_version(Relation relation, + ItemPointer tid, + Snapshot snapshot, + TupleTableSlot *slot) +{ + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + bool visible = false; + + /* Clear the slot */ + ExecClearTuple(slot); + + /* Read the page */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + + page = BufferGetPage(buffer); + + /* Validate offset */ + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* Get the item */ + itemid = PageGetItemId(page, offnum); + + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* Get tuple header */ + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Check visibility (heap-shaped xmin/xmax). Special case: SnapshotAny + * is used by DELETE RETURNING to fetch the just-deleted tuple, so we must + * allow deleted tuples in that case. + */ + if (snapshot && snapshot != SnapshotAny) + { + /* For normal snapshots, check visibility */ + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(relation), + buffer); + + /* + * If the visibility function says invisible, the tuple is not + * visible. Do NOT additionally check FLUX_TUPLE_DELETED here — + * the visibility function already consulted the sLog to determine if + * the delete is committed/in-progress/aborted. + */ + if (!visible) + { + UnlockReleaseBuffer(buffer); + return false; + } + } + else if (snapshot == SnapshotAny) + { + /* SnapshotAny: fetch any tuple, even if deleted (for RETURNING) */ + visible = true; + } + else + { + /* No snapshot means fetch unconditionally if not deleted */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + UnlockReleaseBuffer(buffer); + return false; + } + visible = true; + } + + if (!visible) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* + * Before-image substitution for committed in-place UPDATEs, mirroring the + * sequential-scan path. An index fetch that lands on a tuple updated in + * place by a transaction not visible to our MVCC snapshot must serve the + * before-image, not the on-page (new) value -- otherwise an UPDATE driven + * by this fetch (e.g. UPDATE ... WHERE pk = const) would recompute on top + * of a concurrent committed update and silently lose it. Serve from the + * per-relation UNDO fork (WS-PVS2). Visibility is ordinary heap-shaped + * xmin/xmax + CLOG + snapshot (XidInMVCCSnapshot in the callee): if the + * reader can see the updater the callee returns false and the on-page + * value is served. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + !(tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) && + snapshot != NULL && IsMVCCSnapshot(snapshot) && + FluxDirtyMapCheck(RelationGetRelid(relation), + ItemPointerGetBlockNumber(tid))) + { + char *bi_data = NULL; + int bi_len = 0; + + if (FluxReconstructVisibleVersion(relation, tid, + (const char *) tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + &bi_data, &bi_len)) + { + FluxTupleHeader *bi_tuple = (FluxTupleHeader *) bi_data; + + FluxSlotStoreMaterializedTuple(slot, bi_tuple, bi_len); + slot->tts_tableOid = RelationGetRelid(relation); + slot->tts_tid = *tid; + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + return true; + } + } + + /* Store tuple into slot with buffer pin for safe access */ + FluxSlotStoreTuple(slot, tuple_hdr, + ItemIdGetLength(itemid), buffer); + slot->tts_tableOid = RelationGetRelid(relation); + slot->tts_tid = *tid; + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + + return true; +} + +/* + * Check if TID is valid for relation scan + */ +static bool +flux_tuple_tid_valid(TableScanDesc scan, ItemPointer tid) +{ + BlockNumber nblocks = RelationGetNumberOfBlocks(scan->rs_rd); + + return ItemPointerIsValid(tid) && + ItemPointerGetBlockNumber(tid) < nblocks; +} + +/* + * Get latest version of tuple (for updates) + */ +static void +flux_tuple_get_latest_tid(TableScanDesc scan, ItemPointer tid) +{ + /* FLUX uses in-place updates, so TID doesn't change */ + /* But we need to follow update chains if they exist */ + + BlockNumber block = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + + buffer = ReadBuffer(scan->rs_rd, block); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + + page = BufferGetPage(buffer); + itemid = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(itemid)) + { + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Follow update chain if needed */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) + { + ItemPointerCopy(&tuple_hdr->t_ctid, tid); + } + } + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); +} + +/* + * Check if tuple satisfies snapshot + */ +static bool +flux_tuple_satisfies_snapshot(Relation rel, TupleTableSlot *slot, + Snapshot snapshot) +{ + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + BlockNumber blkno; + OffsetNumber offnum; + bool visible; + + /* + * Re-fetch the on-disk tuple header by TID so we can check the real + * commit timestamps and transaction status. The previous implementation + * used flux_tuple_from_slot() which fabricated a new tuple with current + * timestamps, making the visibility check meaningless. + */ + if (!ItemPointerIsValid(&slot->tts_tid)) + return false; + + blkno = ItemPointerGetBlockNumber(&slot->tts_tid); + offnum = ItemPointerGetOffsetNumber(&slot->tts_tid); + + buffer = ReadBuffer(rel, blkno); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * For SNAPSHOT_DIRTY, we need to emulate HeapTupleSatisfiesDirty(): + * return the inserting xid and speculative token through the snapshot so + * that callers like _bt_check_unique / SpeculativeInsertionWait can + * properly wait for or detect speculative insertions. + * + * With the sLog migration, t_xmin/t_xmax no longer exist in the tuple + * header. We query the sLog for in-progress transaction state. + */ + if (snapshot->snapshot_type == SNAPSHOT_DIRTY) + { + ItemPointerData item_tid; + + snapshot->xmin = InvalidTransactionId; + snapshot->xmax = InvalidTransactionId; + snapshot->speculativeToken = 0; + + ItemPointerSet(&item_tid, blkno, offnum); + + /* + * Check if this tuple is uncommitted (inserted by a still-running + * transaction). + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + bool is_insert = false; + TransactionId dirty_xid; + + dirty_xid = SLogTupleGetDirtyXid(RelationGetRelid(rel), + &item_tid, &is_insert); + + if (TransactionIdIsValid(dirty_xid) && is_insert) + { + /* + * Another transaction is inserting this tuple. Report xmin + * for SpeculativeInsertionWait. + */ + snapshot->xmin = dirty_xid; + + if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + { + snapshot->speculativeToken = + ItemPointerGetBlockNumber(&tuple_hdr->t_ctid); + } + + UnlockReleaseBuffer(buffer); + return true; /* visible for dirty snapshot purposes */ + } + else if (!TransactionIdIsValid(dirty_xid)) + { + /* + * No in-progress sLog entry found. Check for aborted insert + * with pending UNDO. + */ + if (SLogTupleHasAbortedEntry(RelationGetRelid(rel), + &item_tid)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* + * The tuple's t_xmin is authoritative for the inserter; the sLog + * is consulted here for speculative-insert / command-id state on + * UNCOMMITTED tuples. + */ + { + SLogTupleOp my_entry; + int nfound; + TransactionId myxid = GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(myxid)) + { + nfound = SLogTupleLookupFiltered(RelationGetRelid(rel), + &item_tid, myxid, + &my_entry, 1); + if (nfound > 0 && + my_entry.op_type != SLOG_OP_DELETE) + { + /* Our own insert or in-place update */ + } + else if (nfound > 0) + { + /* Our own delete */ + UnlockReleaseBuffer(buffer); + return false; + } + else + { + /* + * Stale UNCOMMITTED flag. Clear and fall through. + */ + if (BufferIsValid(buffer)) + BufferSetHintBits16(&tuple_hdr->t_flags, + tuple_hdr->t_flags & ~FLUX_TUPLE_UNCOMMITTED, + buffer); + else + tuple_hdr->t_flags &= + ~FLUX_TUPLE_UNCOMMITTED; + } + } + else + { + /* + * No current transaction, stale flag. + */ + if (BufferIsValid(buffer)) + BufferSetHintBits16(&tuple_hdr->t_flags, + tuple_hdr->t_flags & ~FLUX_TUPLE_UNCOMMITTED, + buffer); + else + tuple_hdr->t_flags &= + ~FLUX_TUPLE_UNCOMMITTED; + } + } + } + } + + /* Tuple is committed (or ours). Check if deleted. */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + bool is_insert = false; + TransactionId del_xid; + + del_xid = SLogTupleGetDirtyXid(RelationGetRelid(rel), + &item_tid, &is_insert); + + if (TransactionIdIsValid(del_xid) && !is_insert) + { + /* Deleter still running */ + snapshot->xmax = del_xid; + UnlockReleaseBuffer(buffer); + return true; + } + else if (SLogTupleIsDeletedByMe(RelationGetRelid(rel), + &item_tid)) + { + /* We deleted it ourselves */ + UnlockReleaseBuffer(buffer); + return false; + } + else + { + /* + * Tuple is marked DELETED with no in-progress deleter -- + * deletion is committed. + */ + UnlockReleaseBuffer(buffer); + return false; + } + } + + /* + * Check if tuple has been superseded by an out-of-place update. For + * cross-page updates, t_ctid points to the new version's TID + * (different from this tuple's position). The old version is dead + * for index unique-check purposes. + * + * For in-place updates, t_ctid is self-referencing (points to the + * same TID as the tuple's own position). These tuples are live. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) && + !ItemPointerEquals(&tuple_hdr->t_ctid, &item_tid)) + { + UnlockReleaseBuffer(buffer); + return false; + } + + /* Speculative but our own txn and not yet confirmed -- visible */ + UnlockReleaseBuffer(buffer); + return true; + } + + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(rel), buffer); + + UnlockReleaseBuffer(buffer); + + return visible; +} + +/* + * Speculative tuple insertion for FLUX + * This is used for INSERT ... ON CONFLICT operations + */ +static void +flux_tuple_insert_speculative(Relation relation, TupleTableSlot *slot, + CommandId cid, uint32 options, + BulkInsertState bistate, uint32 specToken) +{ + FluxTuple tuple; + Buffer buf; + Page page; + Size tuple_size; + BlockNumber target_block; + OffsetNumber offnum; + FluxTupleHeader *tuple_hdr; + uint64 commit_ts; + FluxOverflowBuffers overflow_buffers; + int i; + + slot_getallattrs(slot); + + /* Create FLUX tuple from slot with overflow support */ + overflow_buffers.count = 0; + tuple = FluxFormTuple(RelationGetDescr(relation), + slot->tts_values, slot->tts_isnull, + relation, &overflow_buffers); + tuple_size = tuple->t_len; + + /* + * Get timestamp BEFORE entering critical section, as this may allocate + * memory. + */ + commit_ts = FluxGetCommitTimestamp(); + + /* + * Mark the tuple as uncommitted so that SNAPSHOT_DIRTY callers can detect + * in-progress insertions via the sLog. The sLog entry is registered + * after the tuple is placed on the page (below) so that the TID is valid. + */ + tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, so savepoint rollback marks it aborted in CLOG */ + + /* + * Use the FSM to find a page with enough free space, or extend the + * relation with a properly initialized new page. + */ + target_block = FluxGetPageWithFreeSpace(relation, tuple_size); + if (target_block == InvalidBlockNumber) + { + /* Clean up overflow buffers before throwing error */ + for (i = 0; i < overflow_buffers.count; i++) + { + UnlockReleaseBuffer(overflow_buffers.buffers[i].buffer); + pfree(overflow_buffers.buffers[i].record_data); + } + FluxFreeTuple(tuple); + elog(ERROR, "FLUX failed to allocate page for speculative insertion"); + } + + buf = ReadBuffer(relation, target_block); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + /* Verify page has sufficient space */ + if (PageGetFreeSpace(page) < tuple_size) + { + /* FSM was stale, update and retry */ + FluxRecordFreeSpace(relation, target_block, PageGetFreeSpace(page)); + UnlockReleaseBuffer(buf); + + target_block = FluxGetPageWithFreeSpace(relation, tuple_size); + if (target_block == InvalidBlockNumber) + { + /* Clean up overflow buffers before throwing error */ + for (i = 0; i < overflow_buffers.count; i++) + { + UnlockReleaseBuffer(overflow_buffers.buffers[i].buffer); + pfree(overflow_buffers.buffers[i].record_data); + } + FluxFreeTuple(tuple); + elog(ERROR, "FLUX failed to allocate page for speculative insertion after retry"); + } + + buf = ReadBuffer(relation, target_block); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + if (PageGetFreeSpace(page) < tuple_size) + { + /* + * Both FSM attempts returned stale pages. Use P_NEW as final + * fallback — extend the relation to get a guaranteed- empty + * page. Update FSM for accuracy on the bad page. + */ + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buf), + PageGetFreeSpace(page)); + UnlockReleaseBuffer(buf); + + buf = ReadBuffer(relation, P_NEW); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + FluxInitPage(page, BufferGetPageSize(buf)); + } + } + + /* + * Try adding tuple before critical section. If the page is too full (FSM + * was optimistic about alignment/line-pointer overhead), extend the + * relation and use a fresh page. + */ + offnum = PageAddItem(page, tuple->t_data, tuple_size, + InvalidOffsetNumber, false, false); + if (offnum == InvalidOffsetNumber) + { + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buf), + PageGetFreeSpace(page)); + UnlockReleaseBuffer(buf); + + buf = ReadBuffer(relation, P_NEW); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + FluxInitPage(page, BufferGetPageSize(buf)); + + offnum = PageAddItem(page, tuple->t_data, tuple_size, + InvalidOffsetNumber, false, false); + if (offnum == InvalidOffsetNumber) + elog(ERROR, "failed to add FLUX tuple to fresh page during " + "speculative insert (tuple_size=%zu)", tuple_size); + } + + /* NO EREPORT(ERROR) from here till changes are logged */ + START_CRIT_SECTION(); + + /* Mark as speculative insertion */ + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, PageGetItemId(page, offnum)); + tuple_hdr->t_flags |= FLUX_TUPLE_SPECULATIVE; + tuple_hdr->t_commit_ts = 0; /* heap-shaped: t_xmax = Invalid */ + + /* + * Store the speculative insertion token in t_ctid, using the same + * encoding as heap: block number holds the token, offset is set to + * SpecTokenOffsetNumber so callers can distinguish a token from a real + * TID. + */ + ItemPointerSet(&tuple_hdr->t_ctid, specToken, SpecTokenOffsetNumber); + + /* Set TID in slot */ + ItemPointerSet(&slot->tts_tid, BufferGetBlockNumber(buf), offnum); + MarkBufferDirty(buf); + + /* WAL logging */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + xl_flux_insert xlrec; + + xlrec.offnum = offnum; + xlrec.flags = FLUX_TUPLE_SPECULATIVE; + xlrec.tuple_len = (uint32) tuple_size; + xlrec.commit_ts = 0; /* heap-shaped: t_xmax = Invalid */ + + /* + * Force a full-page image and append the tuple body to the main data + * channel (matching FluxXLogInsert's layout, which + * flux_xlog_insert_ redo parses). The redo handler restores the + * tuple from the FPI and never reaches the PageAddItem path for these + * records; the body is carried so logical decoding and any future + * non-FPI replay see a well-formed record. Registering the body as + * block data instead (the historical behavior) left tuple_len + * uninitialized and made redo read past the record on a + * BLK_NEEDS_REDO replay. + */ + XLogBeginInsert(); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_insert)); + XLogRegisterData((char *) tuple->t_data, tuple_size); + + /* Register overflow buffers if any */ + if (overflow_buffers.count > 0) + { + for (i = 0; i < overflow_buffers.count; i++) + { + FluxOverflowBuffer *ovb = &overflow_buffers.buffers[i]; + + /* Register the overflow buffer */ + XLogRegisterBuffer(i + 1, ovb->buffer, REGBUF_STANDARD); + + /* Register the overflow record data */ + XLogRegisterBufData(i + 1, ovb->record_data, ovb->record_len); + } + } + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_INSERT); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + /* + * Register the speculative insertion in the sLog so that SNAPSHOT_DIRTY + * callers can find the inserting xid via SLogTupleGetDirtyXid(). + */ + FluxEnsureSLogCallbacks(); + SLogTupleInsert(RelationGetRelid(relation), &slot->tts_tid, + GetTopTransactionId(), SLOG_OP_INSERT, + GetCurrentSubTransactionId(), cid, commit_ts, + specToken, LockTupleNoKeyExclusive); + + /* Update FSM with remaining free space */ + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buf), + PageGetFreeSpace(page)); + + UnlockReleaseBuffer(buf); + + /* Release overflow buffers, deduplicating shared buffers */ + for (i = 0; i < overflow_buffers.count; i++) + { + Buffer ovf_buf = overflow_buffers.buffers[i].buffer; + bool already_released = (ovf_buf == buf); + int j; + + for (j = 0; j < i && !already_released; j++) + { + if (overflow_buffers.buffers[j].buffer == ovf_buf) + already_released = true; + } + + if (!already_released) + UnlockReleaseBuffer(ovf_buf); + pfree(overflow_buffers.buffers[i].record_data); + } + + FluxFreeTuple(tuple); +} + +/* + * Complete speculative insertion for FLUX + */ +static void +flux_tuple_complete_speculative(Relation relation, TupleTableSlot *slot, + uint32 specToken, bool succeeded) +{ + ItemPointer tid = &slot->tts_tid; + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + + buf = ReadBuffer(relation, ItemPointerGetBlockNumber(tid)); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + itemid = PageGetItemId(page, ItemPointerGetOffsetNumber(tid)); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buf); + return; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + if (succeeded) + { + /* + * Speculative insertion succeeded. Clear the speculative flag and + * restore t_ctid to point to the tuple itself (removing the + * speculative token), mirroring heap_finish_speculative(). + * + * Dirty-read hole fix: this used to ALSO clear FLUX_TUPLE_UNCOMMITTED + * here, i.e. well before the inserting transaction actually commits. + * That made the row immediately visible to every concurrent snapshot + * the instant the speculative insert was confirmed (INSERT ... ON + * CONFLICT DO NOTHING/UPDATE), a straight dirty read -- HEAP never + * exposes an uncommitted speculative tuple this way. The speculative + * INSERT already registered a real shared sLog entry (SLOG_OP_INSERT, + * see flux_tuple_insert_speculative), so leaving + * FLUX_TUPLE_UNCOMMITTED set here is safe and correct: the existing + * commit-time machinery (FluxClearUncommittedFlags / + * flux_stamp_tuple_committed, driven by the tracked sLog key) clears + * it and clears the UNCOMMITTED hint at actual transaction commit, + * exactly like a plain INSERT. + */ + START_CRIT_SECTION(); + + tuple_hdr->t_flags &= ~FLUX_TUPLE_SPECULATIVE; + ItemPointerSet(&tuple_hdr->t_ctid, + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid)); + + MarkBufferDirty(buf); + + /* WAL-log the confirmation */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + xl_flux_insert xlrec; + + xlrec.offnum = ItemPointerGetOffsetNumber(tid); + xlrec.flags = 0; /* cleared SPECULATIVE */ + xlrec.tuple_len = 0; /* no body: confirm only flips flags */ + xlrec.commit_ts = tuple_hdr->t_commit_ts; + + /* + * Confirmation does not add a tuple; it clears the speculative + * flag on a tuple already present on the page. Force a full-page + * image so redo restores the (already-updated) page rather than + * re-adding the tuple via PageAddItem. tuple_len == 0 keeps the + * record body-less, which flux_xlog_insert_redo treats as + * "nothing to add" on the (unreachable, given the forced FPI) + * non-FPI path. + */ + XLogBeginInsert(); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_insert)); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_INSERT); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + } + else + { + TransactionId abort_xid; + + /* + * Speculative abort: mark the tuple deleted by our own XID (heap- + * shaped t_xmax). Since this transaction will abort, the tuple's + * xmin also resolves to aborted, so it is invisible either way; we + * stamp t_xmax for a clean tombstone. + */ + abort_xid = GetTopTransactionId(); + + START_CRIT_SECTION(); + + tuple_hdr->t_flags |= FLUX_TUPLE_DELETED; + FluxTupleSetXmax(tuple_hdr, abort_xid); + + MarkBufferDirty(buf); + + /* WAL log the speculative abort as a delete */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + xl_flux_delete xlrec; + + xlrec.offnum = ItemPointerGetOffsetNumber(tid); + xlrec.flags = 0; + xlrec.tuple_len = ItemIdGetLength(itemid); + xlrec.commit_ts = (uint64) abort_xid; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_delete)); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + XLogRegisterBufData(0, (char *) tuple_hdr, ItemIdGetLength(itemid)); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_DELETE); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + } + + UnlockReleaseBuffer(buf); +} + +/* + * Lock a tuple in FLUX table + */ +static TM_Result +flux_tuple_lock(Relation relation, ItemPointer tid, Snapshot snapshot, + TupleTableSlot *slot, CommandId cid, LockTupleMode mode, + LockWaitPolicy wait_policy, uint8 flags, + TM_FailureData *tmfd) +{ + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + TM_Result result = TM_Ok; + TransactionId current_xid; + bool have_tuple_lock = false; + + /* + * Get transaction XID BEFORE entering critical section, as this may + * allocate memory. + */ + current_xid = GetTopTransactionId(); + +reacquire: + buf = ReadBuffer(relation, ItemPointerGetBlockNumber(tid)); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + itemid = PageGetItemId(page, ItemPointerGetOffsetNumber(tid)); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buf); + return TM_Invisible; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Check if tuple is deleted */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + } + result = TM_Deleted; + goto out_unlock; + } + + /* + * Check visibility using timestamp-based MVCC and handle concurrent + * modifications. Same pattern as UPDATE/DELETE: distinguish truly + * invisible tuples from concurrent modifications. + */ + if (!FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(relation), + buf)) + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* + * Writer-only probe: this branch waits on an in-progress + * INSERT/UPDATE/DELETE writer. Lock-only markers (LOCK_SHARE/ + * LOCK_EXCL) are handled by the dedicated lock-conflict check below + * (SLogTupleHasLockConflict), which serializes lockers via the + * heavyweight LOCKTAG_TUPLE lock. Waiting-as-reader on a pure + * locker's xid here lets two lockers each XactLockTableWait on the + * other and deadlock. + */ + dirty_xid = SLogTupleGetDirtyWriterXid(RelationGetRelid(relation), + tid, &is_insert_entry); + + if (TransactionIdIsValid(dirty_xid) && is_insert_entry) + { + /* Another txn's in-progress INSERT - truly invisible */ + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + } + result = TM_Invisible; + goto out_unlock; + } + + if (TransactionIdIsValid(dirty_xid) && !is_insert_entry) + { + /* Another txn's in-progress UPDATE/DELETE - wait and retry */ + if (wait_policy == LockWaitBlock) + { + TransactionId wait_xid = dirty_xid; + + UnlockReleaseBuffer(buf); + if (!have_tuple_lock) + { + FluxLockTuple(relation, tid, mode, true, + &have_tuple_lock); + } + XactLockTableWait(wait_xid, relation, tid, XLTW_Lock); + goto reacquire; + } + else if (wait_policy == LockWaitError) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + } + result = TM_WouldBlock; + goto out_unlock; + } + else /* LockWaitSkip */ + { + result = TM_WouldBlock; + goto out_unlock; + } + } + + /* + * No sLog entry - committed modification after our snapshot. + * + * If TUPLE_LOCK_FLAG_FIND_LAST_VERSION is set, the caller wants us to + * follow the update chain and lock the latest version. In FLUX with + * in-place updates, the current tuple IS the latest version (same + * TID), so fall through to lock it and set tmfd->traversed to trigger + * EPQ re-evaluation. + * + * Otherwise, report TM_Updated so the executor can handle it. + */ + if (flags & TUPLE_LOCK_FLAG_FIND_LAST_VERSION) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = true; + } + /* Fall through to lock the current (latest) version */ + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + result = TM_Updated; + goto out_unlock; + } + } + + /* + * Check for lock conflicts using the sLog. The sLog tracks all + * in-progress lock/delete/update operations, replacing the old + * t_xmax/MultiXact-based scheme. + */ + { + SLogOpType requested_lock; + TransactionId xwait = InvalidTransactionId; + + requested_lock = (mode == LockTupleKeyShare || + mode == LockTupleShare) + ? SLOG_OP_LOCK_SHARE : SLOG_OP_LOCK_EXCL; + + if (SLogTupleGetLockConflictXid(RelationGetRelid(relation), tid, + current_xid, requested_lock, + &xwait)) + { + /* There's a conflict - check wait policy */ + if (wait_policy == LockWaitError) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + } + result = TM_WouldBlock; + goto out_unlock; + } + else if (wait_policy == LockWaitSkip) + { + result = TM_WouldBlock; + goto out_unlock; + } + else /* LockWaitBlock */ + { + /* + * Wait on the specific conflicting transaction identified by + * SLogTupleGetLockConflictXid -- never on an arbitrary + * in-progress peer, which could be a compatible locker queued + * behind us and would form a spurious mutual-wait cycle. + */ + + /* Release buffer and wait */ + UnlockReleaseBuffer(buf); + + if (TransactionIdIsValid(xwait)) + { + /* Acquire tuple-level lock to wait */ + if (!have_tuple_lock) + { + FluxLockTuple(relation, tid, mode, true, + &have_tuple_lock); + } + + /* Wait for the conflicting transaction */ + XactLockTableWait(xwait, relation, tid, XLTW_Lock); + } + + /* Re-acquire buffer and retry */ + goto reacquire; + } + } + } + + /* + * Lock succeeded. Set traversed for FIND_LAST_VERSION callers. FLUX + * uses in-place updates, so the current tuple IS the latest version — + * the update chain was trivially "followed." + */ + if (tmfd) + { + tmfd->traversed = (flags & TUPLE_LOCK_FLAG_FIND_LAST_VERSION) != 0; + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + } + + tuple_hdr->t_flags |= FLUX_TUPLE_LOCKED; + + START_CRIT_SECTION(); + + MarkBufferDirty(buf); + + /* Log the lock operation */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + xl_flux_lock xlrec; + + xlrec.offnum = ItemPointerGetOffsetNumber(tid); + xlrec.flags = 0; + xlrec.infomask = tuple_hdr->t_infomask; + xlrec.lock_mode = (uint8) mode; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_lock)); + XLogRegisterBuffer(0, buf, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_LOCK); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + /* + * Populate the slot with the locked tuple's data. This must happen after + * END_CRIT_SECTION (since overflow fetch may do I/O and ereport). We must + * unlock the buffer (but keep the pin) before calling + * FluxTupleToSlotWithOverflow because it may fetch overflow data, and if + * that overflow is on the same page, it would try to lock an + * already-locked buffer causing an assertion failure. + * + * FK constraint triggers and other callers of table_tuple_lock() expect + * the slot to contain valid tuple data. + */ + if (slot && result == TM_Ok) + { + /* Unlock buffer but keep pin for slot materialization */ + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + + /* + * Register the lock in the sLog AFTER releasing the buffer lock to + * avoid deadlocks with SLogTupleGetDirtyXid's slow path. + */ + { + SLogOpType lock_op; + + lock_op = (mode == LockTupleKeyShare || mode == LockTupleShare) + ? SLOG_OP_LOCK_SHARE : SLOG_OP_LOCK_EXCL; + + /* + * Record the precise LockTupleMode alongside the coarse lock_op so + * a concurrent updater can apply the real heavyweight conflict + * matrix: FOR KEY SHARE (AccessShareLock) stays compatible with a + * NoKeyExclusive UPDATE, while FOR SHARE/FOR UPDATE correctly block + * it. See SLogTupleGetWriteConflictXid. + */ + FluxEnsureSLogCallbacks(); + SLogTupleInsert(RelationGetRelid(relation), tid, + current_xid, lock_op, + GetCurrentSubTransactionId(), cid, 0, 0, mode); + + /* Mark this block dirty for the scan-path sLog bypass */ + FluxDirtyMapMark(RelationGetRelid(relation), + ItemPointerGetBlockNumber(tid)); + } + + if (!FluxTupleToSlotWithOverflow(tuple_hdr, slot, relation)) + { + /* + * Conversion failed (e.g., tuple was concurrently deleted). + * Return TM_Deleted rather than leaving the slot empty. + */ + ReleaseBuffer(buf); + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, mode); + return TM_Deleted; + } + slot->tts_tid = *tid; + slot->tts_tableOid = RelationGetRelid(relation); + /* + * FluxTupleToSlotWithOverflow leaves the slot virtual with + * tts_values[] pointing into the page buffer (and records no pin in + * the slot). Deep-copy those pass-by-reference datums into slot-owned + * memory BEFORE dropping the pin, so a returned varlena (e.g. numeric + * via UPDATE ... RETURNING / FK / EPQ locking) does not alias a buffer + * that is about to be unpinned and reused. This matches heap's slot + * discipline (ExecStorePinnedBufferHeapTuple keeps the pin in the slot). + */ + ExecMaterializeSlot(slot); + + /* Release the buffer pin now that slot is materialized */ + ReleaseBuffer(buf); + + /* Release tuple-level lock if we acquired it */ + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, mode); + + return result; + } + +out_unlock: + UnlockReleaseBuffer(buf); + + /* Release tuple-level lock if we acquired it */ + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, mode); + + return result; +} + +/* + * Nontransactional truncate for FLUX relation + * + * This is called for TRUNCATE operations. We use RelationTruncate which + * properly handles all forks (main, FSM, VM), WAL logging, shared buffer + * invalidation, and cache coherency. + */ +static void +flux_relation_nontransactional_truncate(Relation rel) +{ + RelationTruncate(rel, 0); +} + +/* + * Set new filelocator for FLUX relation + */ +static void +flux_relation_set_new_filelocator(Relation rel, + const RelFileLocator *newrlocator, + char persistence, + TransactionId *freezeXid, + MultiXactId *minmulti) +{ + SMgrRelation srel; + + /* Set freeze XID to current transaction minimum */ + *freezeXid = RecentXmin; + + /* Set minimum multixact ID */ + *minmulti = GetOldestMultiXactId(); + + /* Create the storage file (empty, no blocks yet) */ + srel = RelationCreateStorage(*newrlocator, persistence, true); + + /* WAL-log the file creation */ + if (persistence == RELPERSISTENCE_PERMANENT) + log_smgrcreate(newrlocator, MAIN_FORKNUM); + + /* + * Note: We do not initialize block 0 here. Block 0 will be created + * on-demand during the first scan or insert operation via + * FluxGetPageWithFreeSpace() or the scan's RBM_ZERO_AND_LOCK logic. + */ + + /* Set up init fork for unlogged tables if needed */ + if (persistence == RELPERSISTENCE_UNLOGGED) + { + Assert(rel->rd_rel->relkind == RELKIND_RELATION || + rel->rd_rel->relkind == RELKIND_TOASTVALUE); + smgrcreate(srel, INIT_FORKNUM, false); + log_smgrcreate(newrlocator, INIT_FORKNUM); + } + + smgrclose(srel); + + /* + * Initialize the per-relation UNDO fork for permanent and unlogged + * relations. This creates the UNDO fork file and writes the initial + * metapage so that subsequent INSERT/UPDATE/DELETE operations can reserve + * UNDO space via RelUndoReserve(). Without this, the smgrexists() guards + * in the DML paths skip all UNDO record emission. + * + * Temp tables are session-private and skip the fork. + * + * RelUndoInitRelation() targets RelationGetSmgr(rel) == rel->rd_locator, + * but during TRUNCATE the caller passes the new locator via newrlocator + * while rel->rd_locator still holds the old value (updated only after we + * return). Temporarily swap rd_locator to the new locator, init, then + * restore; the caller overwrites rd_locator regardless. + */ + if (persistence == RELPERSISTENCE_PERMANENT || + persistence == RELPERSISTENCE_UNLOGGED) + { + RelFileLocator saved_locator = rel->rd_locator; + + rel->rd_locator = *newrlocator; + RelationCloseSmgr(rel); + RelUndoInitRelation(rel); + RelationCloseSmgr(rel); + rel->rd_locator = saved_locator; + } +} + +/* + * Check whether table tuples referenced by index entries are dead. + * + * This is called by index AMs during index tuple deletion (both simple + * deletion during VACUUM and bottom-up deletion during retail inserts). + * The index AM passes a list of TIDs and we check each one's liveness. + * We set knowndeletable=true for entries whose table tuples are dead, + * allowing the index AM to remove its entries. + * + * IMPORTANT: This function must NEVER modify table data. It only reads + * tuple headers to check visibility status. + * + * Modeled on heap_index_delete_tuples() but simplified for FLUX's + * timestamp-based MVCC. + */ +static TransactionId +flux_index_delete_tuples(Relation rel, TM_IndexDeleteOp *delstate) +{ + TransactionId snapshotConflictHorizon = InvalidTransactionId; + BlockNumber blkno = InvalidBlockNumber; + Buffer buf = InvalidBuffer; + Page page = NULL; + OffsetNumber maxoff = InvalidOffsetNumber; + int finalndeltids = 0; + + Assert(delstate->ndeltids > 0); + + /* Iterate over deltids, determine which are deletable */ + for (int i = 0; i < delstate->ndeltids; i++) + { + TM_IndexDelete *ideltid = &delstate->deltids[i]; + TM_IndexStatus *istatus = delstate->status + ideltid->id; + ItemPointer htid = &ideltid->tid; + OffsetNumber offnum; + + /* + * Read buffer for this block if we haven't already. Avoid refetching + * if it's the same block as the previous entry. + */ + if (blkno == InvalidBlockNumber || + ItemPointerGetBlockNumber(htid) != blkno) + { + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + + blkno = ItemPointerGetBlockNumber(htid); + buf = ReadBuffer(rel, blkno); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + maxoff = PageGetMaxOffsetNumber(page); + } + + offnum = ItemPointerGetOffsetNumber(htid); + + /* Sanity check: offset must be valid */ + if (offnum < FirstOffsetNumber || offnum > maxoff) + { + /* + * Index entry points to invalid offset. Mark as deletable to + * clean up the corruption. + */ + istatus->knowndeletable = true; + finalndeltids = i + 1; + continue; + } + + /* Already known to be deletable by the index AM? */ + if (istatus->knowndeletable) + { + Assert(!delstate->bottomup && !istatus->promising); + finalndeltids = i + 1; + continue; + } + + { + ItemId lp = PageGetItemId(page, offnum); + + if (!ItemIdIsNormal(lp)) + { + /* + * LP_DEAD, LP_UNUSED, or LP_REDIRECT: the tuple is gone. The + * index entry can be removed. + */ + istatus->knowndeletable = true; + } + else + { + FluxTupleHeader *tuple_hdr; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, lp); + + /* + * For FLUX, a tuple is vacuumable (and its index entry + * deletable) if it is deleted AND old enough that no snapshot + * can see it. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (FluxTupleDeadToAll(tuple_hdr, FluxGetOldestXminHorizon(rel))) + { + istatus->knowndeletable = true; + } + else + { + /* Recently dead -- cannot delete index entry yet */ + continue; + } + } + else + { + /* Live tuple -- cannot delete index entry */ + continue; + } + } + } + + /* Track progress for bottom-up deletion */ + if (delstate->bottomup && istatus->knowndeletable) + { + int actualfreespace = 0; + + actualfreespace += istatus->freespace; + if (actualfreespace >= delstate->bottomupfreespace) + { + /* Met the space target -- stop early */ + finalndeltids = i + 1; + break; + } + } + + finalndeltids = i + 1; + } + + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + + /* + * Shrink deltids array to exclude non-deletable entries at the end. + */ + Assert(finalndeltids > 0 || delstate->bottomup); + delstate->ndeltids = finalndeltids; + + return snapshotConflictHorizon; +} + +/* + * Copy data for FLUX relation (used by ALTER TABLE SET ACCESS METHOD, etc.) + * + * This performs a block-level copy of all storage forks from the old + * relation files to new ones. Since we copy directly without examining + * shared buffers, we must flush any dirty pages first. The old physical + * files are scheduled for deletion. + */ +static void +flux_relation_copy_data(Relation rel, const RelFileLocator *newrlocator) +{ + SMgrRelation dstrel; + + /* + * Since we copy the file directly without looking at the shared buffers, + * we'd better first flush out any pages of the source relation that are + * in shared buffers. We assume no new changes will be made while we are + * holding exclusive lock on the rel. + */ + FlushRelationBuffers(rel); + + /* + * Create and copy all forks of the relation, and schedule unlinking of + * old physical files. + * + * NOTE: any conflict in relfilenumber value will be caught in + * RelationCreateStorage(). + */ + dstrel = RelationCreateStorage(*newrlocator, rel->rd_rel->relpersistence, + true); + + /* Copy main fork */ + RelationCopyStorage(RelationGetSmgr(rel), dstrel, MAIN_FORKNUM, + rel->rd_rel->relpersistence); + + /* Copy any extra forks that exist (FSM, etc.) */ + for (ForkNumber forkNum = MAIN_FORKNUM + 1; + forkNum <= MAX_FORKNUM; forkNum++) + { + if (smgrexists(RelationGetSmgr(rel), forkNum)) + { + smgrcreate(dstrel, forkNum, false); + + /* + * WAL log creation if the relation is persistent, or this is the + * init fork of an unlogged relation. + */ + if (RelationIsPermanent(rel) || + (rel->rd_rel->relpersistence == RELPERSISTENCE_UNLOGGED && + forkNum == INIT_FORKNUM)) + log_smgrcreate(newrlocator, forkNum); + RelationCopyStorage(RelationGetSmgr(rel), dstrel, forkNum, + rel->rd_rel->relpersistence); + } + } + + /* Drop old relation storage, and close new one */ + RelationDropStorage(rel); + smgrclose(dstrel); +} + +/* + * Copy data for cluster operation + * + * This is called by CLUSTER and VACUUM FULL to copy tuples from the old + * table to the new one, optionally reordering by an index. We scan the + * old table using SnapshotAny and perform our own MVCC visibility checks + * to decide which tuples to keep, which to discard as dead, and which + * are recently dead. + * + * For FLUX, visibility is determined by the tuple's timestamp-based MVCC + * flags (deleted flag, commit timestamp, etc.) rather than heap-style xmin/xmax. + */ +static void +flux_relation_copy_for_cluster(Relation OldTable, Relation NewTable, + Relation OldIndex, bool use_sort, + TransactionId OldestXmin, + Snapshot snapshot, + TransactionId *xid_cutoff, + MultiXactId *multi_cutoff, + double *num_tuples, + double *tups_vacuumed, + double *tups_recently_dead) +{ + TableScanDesc tableScan; + IndexScanDesc indexScan; + TupleTableSlot *slot; + CommandId mycid = GetCurrentCommandId(true); + double live_tuples = 0; + double dead_tuples = 0; + double recent_dead = 0; + + /* Initialize return values */ + *xid_cutoff = InvalidTransactionId; + *multi_cutoff = InvalidMultiXactId; + *num_tuples = 0; + *tups_vacuumed = 0; + *tups_recently_dead = 0; + + /* + * Valid smgr_targblock implies something already wrote to the relation. + * This may be harmless, but this function hasn't planned for it. + */ + Assert(RelationGetTargetBlock(NewTable) == InvalidBlockNumber); + + /* + * Set up the scan. If we have an index and are not doing a sort, use an + * index scan to get tuples in index order. Otherwise do a sequential scan + * (and optionally sort afterward). + */ + if (OldIndex != NULL && !use_sort) + { + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_INDEX_SCAN_HEAP); + + tableScan = NULL; + indexScan = index_beginscan(OldTable, OldIndex, SnapshotAny, NULL, + 0, 0, 0); + index_rescan(indexScan, NULL, 0, NULL, 0); + } + else + { + pgstat_progress_update_param(PROGRESS_REPACK_PHASE, + PROGRESS_REPACK_PHASE_SEQ_SCAN_HEAP); + + tableScan = table_beginscan(OldTable, SnapshotAny, 0, NULL, 0); + indexScan = NULL; + } + + slot = table_slot_create(OldTable, NULL); + + /* + * Scan through the old table. For each tuple, check visibility using + * FLUX's timestamp-based MVCC and either copy it to the new table or + * skip it. + */ + for (;;) + { + bool isdead = false; + bool is_tombstone = false; + uint64 delete_ts = 0; + + CHECK_FOR_INTERRUPTS(); + + if (indexScan != NULL) + { + if (!index_getnext_slot(indexScan, ForwardScanDirection, slot)) + break; + } + else + { + if (!table_scan_getnextslot(tableScan, ForwardScanDirection, slot)) + break; + } + + /* + * For FLUX, check tuple visibility using our page-level access. Read + * the tuple header from the page to check MVCC flags. + */ + { + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + BlockNumber blkno = ItemPointerGetBlockNumber(&slot->tts_tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(&slot->tts_tid); + + buf = ReadBuffer(OldTable, blkno); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + /* Item pointer is dead or unused -- skip */ + UnlockReleaseBuffer(buf); + dead_tuples++; + continue; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + if (tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) + { + /* + * Distinguish cross-page (out-of-place) updates from in-place + * updates. Cross-page updates have t_ctid pointing to a + * different TID (the new version's location). In-place + * updates have t_ctid pointing to self (same TID). + * + * Only cross-page old versions are dead; in-place updated + * tuples contain the current data and are live. + */ + ItemPointerData self_tid; + + ItemPointerSet(&self_tid, blkno, offnum); + if (!ItemPointerEquals(&tuple_hdr->t_ctid, &self_tid)) + isdead = true; /* Cross-page: old version is dead */ + /* else: in-place update, tuple is live — fall through */ + } + + if (!isdead && (tuple_hdr->t_flags & FLUX_TUPLE_DELETED)) + { + /* + * Tuple has been deleted. Check whether it's old enough to be + * truly dead vs recently dead (still needed for MVCC + * snapshots). XID-horizon gate (FluxTupleDeadToAll), not the + * removed HLC timestamp horizon: the on-page word now packs the + * XID-based t_xmax, not a wall-clock commit timestamp. + */ + if (FluxTupleDeadToAll(tuple_hdr, FluxGetOldestXminHorizon(OldTable))) + { + /* Definitely dead -- can discard */ + isdead = true; + } + else + { + /* + * Recently dead -- still needed by some snapshots. Copy + * it to the new relation as a tombstone, preserving its + * original delete timestamp so old-snapshot readers still + * see the pre-delete version (matching heap's RECENTLY_DEAD + * retention during cluster rewrite). + */ + recent_dead++; + isdead = false; + is_tombstone = true; + delete_ts = tuple_hdr->t_commit_ts; + } + } + + UnlockReleaseBuffer(buf); + } + + if (isdead) + { + dead_tuples++; + continue; + } + + /* Live or recently-dead tuple -- copy to new table */ + table_tuple_insert(NewTable, slot, mycid, 0, NULL); + + if (is_tombstone) + { + /* + * The row was copied as a fresh, live INSERT. Rewrite it in the + * new relation as a deleted tombstone carrying its original delete + * marker, and drop the INSERT's local sLog tracking so that + * commit-time bookkeeping does not clobber the tuple with this + * rewrite transaction's commit state (which would resurrect the row + * for readers whose snapshots predate the rewrite commit). + */ + Buffer nbuf; + Page npage; + ItemId nitemid; + FluxTupleHeader *ntuple_hdr; + FluxPageOpaque nphdr; + BlockNumber nblkno = ItemPointerGetBlockNumber(&slot->tts_tid); + OffsetNumber noffnum = ItemPointerGetOffsetNumber(&slot->tts_tid); + + SLogTupleUntrackLocalOnly(RelationGetRelid(NewTable), + &slot->tts_tid); + + nbuf = ReadBuffer(NewTable, nblkno); + LockBuffer(nbuf, BUFFER_LOCK_EXCLUSIVE); + npage = BufferGetPage(nbuf); + nitemid = PageGetItemId(npage, noffnum); + + START_CRIT_SECTION(); + + ntuple_hdr = (FluxTupleHeader *) PageGetItem(npage, nitemid); + ntuple_hdr->t_flags |= FLUX_TUPLE_DELETED; + ntuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + ntuple_hdr->t_commit_ts = delete_ts; + + nphdr = FluxPageGetOpaque(npage); + FluxPageSetCommitTs(nphdr, + Max(FluxPageGetCommitTs(nphdr), delete_ts)); + FluxPageSetFlag(nphdr, FLUX_PAGE_DEFRAG_NEEDED); + + MarkBufferDirty(nbuf); + + if (RelationNeedsWAL(NewTable)) + { + XLogRecPtr recptr; + xl_flux_delete xlrec; + + xlrec.offnum = noffnum; + xlrec.flags = 0; + xlrec.tuple_len = ItemIdGetLength(nitemid); + xlrec.commit_ts = delete_ts; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_delete)); + XLogRegisterBuffer(0, nbuf, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_DELETE); + PageSetLSN(npage, recptr); + } + + END_CRIT_SECTION(); + + UnlockReleaseBuffer(nbuf); + } + + live_tuples++; + + pgstat_progress_update_param(PROGRESS_REPACK_HEAP_TUPLES_SCANNED, + live_tuples + dead_tuples + recent_dead); + } + + /* Clean up scan resources */ + if (indexScan != NULL) + index_endscan(indexScan); + if (tableScan != NULL) + table_endscan(tableScan); + + ExecDropSingleTupleTableSlot(slot); + + /* Return statistics to caller */ + *num_tuples = live_tuples; + *tups_vacuumed = dead_tuples; + *tups_recently_dead = recent_dead; +} + +/* + * Build range scan for index creation + * + * Scans the FLUX table and feeds tuples to the index AM's callback for + * index building. Handles partial indexes, expression indexes, uniqueness + * checking, concurrent index builds, and proper visibility classification. + * + * Modeled on heapam_index_build_range_scan(). + */ +static double +flux_index_build_range_scan(Relation tablerel, Relation indexrel, + IndexInfo *indexInfo, bool allow_sync, + bool anyvisible, bool progress, + BlockNumber start_blockno, BlockNumber numblocks, + IndexBuildCallback callback, void *callback_state, + TableScanDesc scan) +{ + double reltuples = 0; + bool checking_uniqueness; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + ExprState *predicate; + TupleTableSlot *slot; + EState *estate; + ExprContext *econtext; + Snapshot snapshot; + bool need_unregister_snapshot = false; + + /* See whether we're verifying uniqueness/exclusion properties */ + checking_uniqueness = (indexInfo->ii_Unique || + indexInfo->ii_ExclusionOps != NULL); + + /* "Any visible" mode is not compatible with uniqueness checks */ + Assert(!(anyvisible && checking_uniqueness)); + + /* + * Need an EState for evaluation of index expressions and partial-index + * predicates. Also a slot to hold the current tuple. + */ + estate = CreateExecutorState(); + econtext = GetPerTupleExprContext(estate); + slot = table_slot_create(tablerel, NULL); + + /* Arrange for econtext's scan tuple to be the tuple under test */ + econtext->ecxt_scantuple = slot; + + /* Set up execution state for predicate, if any */ + predicate = ExecPrepareQual(indexInfo->ii_Predicate, estate); + + /* + * Prepare for scan. Normal index build uses SnapshotAny (we do our own + * visibility checks). Concurrent/bootstrap uses an MVCC snapshot. + */ + if (!scan) + { + if (IsBootstrapProcessingMode() || indexInfo->ii_Concurrent) + { + snapshot = RegisterSnapshot(GetTransactionSnapshot()); + need_unregister_snapshot = true; + } + else + snapshot = SnapshotAny; + + scan = table_beginscan_strat(tablerel, snapshot, 0, NULL, + true, allow_sync); + } + else + { + snapshot = scan->rs_snapshot; + } + + /* Scan all tuples in the base relation */ + while (table_scan_getnextslot(scan, ForwardScanDirection, slot)) + { + bool tupleIsAlive; + + CHECK_FOR_INTERRUPTS(); + + if (snapshot == SnapshotAny) + { + /* + * Classify the tuple using FLUX's timestamp-based MVCC by + * re-reading the tuple header from the page. + */ + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + BlockNumber blkno = ItemPointerGetBlockNumber(&slot->tts_tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(&slot->tts_tid); + bool indexIt; + + buf = ReadBuffer(tablerel, blkno); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buf); + continue; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (FluxTupleDeadToAll(tuple_hdr, FluxGetOldestXminHorizon(tablerel))) + { + /* Definitely dead -- skip */ + UnlockReleaseBuffer(buf); + continue; + } + else + { + /* Recently dead -- index for MVCC, don't count */ + indexIt = true; + tupleIsAlive = false; + } + } + else if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + { + /* Speculative insertion not yet confirmed -- skip */ + UnlockReleaseBuffer(buf); + continue; + } + else + { + /* Live tuple -- index and count it */ + indexIt = true; + tupleIsAlive = true; + reltuples += 1; + } + + UnlockReleaseBuffer(buf); + + if (!indexIt) + continue; + } + else + { + /* MVCC snapshot already filtered for visibility */ + tupleIsAlive = true; + reltuples += 1; + } + + MemoryContextReset(econtext->ecxt_per_tuple_memory); + + /* In a partial index, discard tuples that don't satisfy predicate */ + if (predicate != NULL) + { + if (!ExecQual(predicate, econtext)) + continue; + } + + /* + * Extract all indexed attributes. This also evaluates any index + * expressions. + */ + FormIndexDatum(indexInfo, slot, estate, values, isnull); + + /* + * Call the AM's callback with the tuple's own TID. FLUX secondary + * indexes hold plain 6-byte heap-style TIDs (no RowID/gen suffix), so + * this matches the stock heap index-build contract exactly. + */ + callback(indexrel, &slot->tts_tid, + values, isnull, tupleIsAlive, callback_state); + } + + table_endscan(scan); + + if (need_unregister_snapshot) + UnregisterSnapshot(snapshot); + + ExecDropSingleTupleTableSlot(slot); + FreeExecutorState(estate); + + /* These may have been pointing to the now-gone estate */ + indexInfo->ii_ExpressionsState = NIL; + indexInfo->ii_PredicateState = NULL; + + return reltuples; +} + +/* + * Validate scan for index + */ +static void +flux_index_validate_scan(Relation tablerel, Relation indexrel, + IndexInfo *indexInfo, Snapshot snapshot, + ValidateIndexState *state) +{ + TableScanDesc scan; + TupleTableSlot *slot; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + ExprState *predicate; + EState *estate; + ExprContext *econtext; + ItemPointer indexcursor = NULL; + ItemPointerData decoded; + bool tuplesort_empty = false; + + /* + * Need an EState for evaluation of index expressions and partial-index + * predicates. + */ + estate = CreateExecutorState(); + econtext = GetPerTupleExprContext(estate); + slot = table_slot_create(tablerel, NULL); + econtext->ecxt_scantuple = slot; + + predicate = ExecPrepareQual(indexInfo->ii_Predicate, estate); + + /* + * Scan the table and the sorted output from tuplesort in parallel. For + * each table tuple, check if there's a matching index entry. Tuples that + * satisfy the predicate but have no index entry need to be inserted into + * the index. + */ + scan = table_beginscan_strat(tablerel, snapshot, 0, NULL, true, false); + + while (table_scan_getnextslot(scan, ForwardScanDirection, slot)) + { + CHECK_FOR_INTERRUPTS(); + + state->htups += 1; + + /* + * Skip tuples that don't satisfy the partial index predicate. + */ + if (predicate != NULL) + { + MemoryContextReset(econtext->ecxt_per_tuple_memory); + if (!ExecQual(predicate, econtext)) + continue; + } + + /* + * Advance the tuplesort cursor past any entries that are for TIDs + * earlier than the current table tuple. + */ + while (!tuplesort_empty && + (!indexcursor || + ItemPointerCompare(indexcursor, &slot->tts_tid) < 0)) + { + Datum ts_val; + bool ts_isnull; + + tuplesort_empty = !tuplesort_getdatum(state->tuplesort, + true, false, + &ts_val, &ts_isnull, + NULL); + Assert(tuplesort_empty || !ts_isnull); + if (!tuplesort_empty) + { + itemptr_decode(&decoded, DatumGetInt64(ts_val)); + indexcursor = &decoded; + } + else + { + indexcursor = NULL; + } + } + + /* + * If the sorted cursor TID matches the current table tuple, the index + * already has this entry. Otherwise, we need to add it. + */ + if (indexcursor != NULL && + ItemPointerCompare(indexcursor, &slot->tts_tid) == 0) + { + /* Already in the index -- skip */ + continue; + } + + MemoryContextReset(econtext->ecxt_per_tuple_memory); + + FormIndexDatum(indexInfo, slot, estate, values, isnull); + + /* + * Insert the missing index entry using the tuple's own TID. + */ + index_insert(indexrel, values, isnull, &slot->tts_tid, + tablerel, indexInfo->ii_Unique ? + UNIQUE_CHECK_YES : UNIQUE_CHECK_NO, + false, indexInfo); + + state->tups_inserted += 1; + } + + table_endscan(scan); + + ExecDropSingleTupleTableSlot(slot); + FreeExecutorState(estate); + + indexInfo->ii_ExpressionsState = NIL; + indexInfo->ii_PredicateState = NULL; +} + +/* + * Get relation size information + * + * Returns the on-disk size in bytes for the specified fork of the relation. + * This is used by pg_relation_size(), VACUUM, CLUSTER, and many other + * operations that need to know the physical storage footprint. + */ +/* + * Use table_block_relation_size() from tableam.c directly. FLUX uses + * standard BLCKSZ-width forks just like heap, so the generic + * implementation is correct and efficient (no smgrexists() overhead). + */ + +/* + * Check if relation needs a TOAST table + */ +static bool +flux_relation_needs_toast_table(Relation rel) +{ + /* + * FLUX uses standard heap TOAST for wide values (it has no on-page + * overflow mechanism). This mirrors heapam_relation_needs_toast_table: + * a TOAST table is needed iff there is a toastable attribute and the + * maximum tuple length could exceed TOAST_TUPLE_THRESHOLD. + */ + int32 data_length = 0; + bool maxlength_unknown = false; + bool has_toastable_attrs = false; + TupleDesc tupdesc = rel->rd_att; + int32 tuple_length; + int i; + + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped) + continue; + if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL) + continue; + data_length = att_align_nominal(data_length, att->attalign); + if (att->attlen > 0) + { + data_length += att->attlen; + } + else + { + int32 maxlen = type_maximum_size(att->atttypid, + att->atttypmod); + + if (maxlen < 0) + maxlength_unknown = true; + else + data_length += maxlen; + if (att->attstorage != TYPSTORAGE_PLAIN) + has_toastable_attrs = true; + } + } + if (!has_toastable_attrs) + return false; /* nothing to toast? */ + if (maxlength_unknown) + return true; /* any unlimited-length attrs? */ + tuple_length = MAXALIGN(FLUX_TUPLE_OVERHEAD + + BITMAPLEN(tupdesc->natts)) + + MAXALIGN(data_length); + return (tuple_length > TOAST_TUPLE_THRESHOLD); +} + +/* + * TOAST tables for FLUX relations are ordinary heap relations (FLUX reuses + * the standard heap TOAST machinery). + */ +static Oid +flux_relation_toast_am(Relation rel) +{ + return HEAP_TABLE_AM_OID; +} + +/* + * Estimate relation size + * + * Provides the planner with estimates of the number of pages, tuples, + * and all-visible fraction for this relation. Uses the actual block count + * from storage and estimates tuple density from the first non-empty page. + */ +static void +flux_relation_estimate_size(Relation rel, int32 *attr_widths, + BlockNumber *pages, double *tuples, + double *allvisfrac) +{ + BlockNumber nblocks; + double tuple_count; + + /* Get actual block count from storage */ + nblocks = smgrnblocks(RelationGetSmgr(rel), MAIN_FORKNUM); + + *pages = Max(nblocks, 1); + + if (nblocks == 0) + { + *tuples = 0; + *allvisfrac = 0.0; + return; + } + + /* + * Estimate tuple count. If we have reltuples from pg_class, use that. + * Otherwise, sample the first block to estimate tuple density. + */ + if (rel->rd_rel->reltuples >= 0) + { + /* + * Scale reltuples by the ratio of current pages to relpages to + * account for growth or shrinkage since last ANALYZE. + */ + if (rel->rd_rel->relpages > 0) + tuple_count = rel->rd_rel->reltuples * + ((double) nblocks / (double) rel->rd_rel->relpages); + else + tuple_count = rel->rd_rel->reltuples; + } + else + { + /* + * No statistics available. Sample the first non-empty page to + * estimate tuple density. If we can't find one, fall back to a + * conservative estimate. + */ + double tuples_per_page = 0; + BlockNumber probe; + + for (probe = 0; probe < Min(nblocks, 10); probe++) + { + Buffer buf; + Page pg; + OffsetNumber maxoff; + OffsetNumber off; + int live = 0; + + buf = ReadBufferExtended(rel, MAIN_FORKNUM, probe, + RBM_NORMAL, NULL); + LockBuffer(buf, BUFFER_LOCK_SHARE); + pg = BufferGetPage(buf); + + if (PageIsNew(pg)) + { + UnlockReleaseBuffer(buf); + continue; + } + + maxoff = PageGetMaxOffsetNumber(pg); + for (off = FirstOffsetNumber; off <= maxoff; off++) + { + ItemId iid = PageGetItemId(pg, off); + + if (!ItemIdIsNormal(iid)) + continue; + + /* + * Skip overflow records -- they are not user tuples and + * should not inflate the density estimate. + */ + if (FluxIsOverflowRecordInline( + (FluxTupleHeader *) PageGetItem(pg, iid), + ItemIdGetLength(iid))) + continue; + + live++; + } + + UnlockReleaseBuffer(buf); + + if (live > 0) + { + tuples_per_page = (double) live; + break; + } + } + + /* Fallback if every sampled page was empty or new */ + if (tuples_per_page <= 0) + tuples_per_page = (BLCKSZ - FLUX_PAGE_OVERHEAD) / 100.0; + + tuple_count = tuples_per_page * nblocks; + } + + *tuples = Max(tuple_count, 0); + + /* + * Compute allvisfrac from pg_class.relallvisible, exactly as heap's + * table_block_relation_estimate_size() does. relallvisible is maintained + * by VACUUM and is an O(1) catalog read -- no per-plan Visibility Map + * scan. The previous implementation swept every VM page (plus a + * smgrexists()/smgrnblocks() probe of the VM fork) on every query plan, + * which showed up as ~9%% of CPU under high-concurrency pgbench. A stale + * catalog value is acceptable here: the planner tolerates an approximate + * allvisfrac, and heap relies on the same value. + */ + if (rel->rd_rel->relpages > 0) + { + double allvisible; + + allvisible = (double) rel->rd_rel->relallvisible / + (double) rel->rd_rel->relpages; + if (allvisible < 0.0) + allvisible = 0.0; + else if (allvisible > 1.0) + allvisible = 1.0; + *allvisfrac = allvisible; + } + else + *allvisfrac = 0.0; +} + +/* + * Sample scan: get next block for sampling (TABLESAMPLE support) + * + * Called by the TABLESAMPLE executor to prepare the next block for tuple + * extraction. The TSM (Table Sample Method) decides which block to visit + * via its NextSampleBlock callback, or, if that callback is NULL, we scan + * sequentially starting from rs_startblock and wrapping around. + * + * We read the selected block into a buffer (pinned, not locked -- locking + * is deferred to flux_scan_sample_next_tuple) and return true. Returns + * false when there are no more blocks to sample. + */ +static bool +flux_scan_sample_next_block(TableScanDesc scan, SampleScanState *scanstate) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + TsmRoutine *tsm = scanstate->tsmroutine; + BlockNumber blockno; + + /* Return false immediately if relation is empty */ + if (rscan->rs_nblocks == 0) + return false; + + /* Release previous buffer, if any */ + if (BufferIsValid(rscan->rs_cbuf)) + { + ReleaseBuffer(rscan->rs_cbuf); + rscan->rs_cbuf = InvalidBuffer; + } + + if (tsm->NextSampleBlock) + { + /* TSM tells us which block to visit next */ + blockno = tsm->NextSampleBlock(scanstate, rscan->rs_nblocks); + } + else + { + /* No NextSampleBlock callback -- scan sequentially */ + if (rscan->rs_cblock == InvalidBlockNumber) + { + Assert(!rscan->rs_inited); + blockno = rscan->rs_startblock; + } + else + { + Assert(rscan->rs_inited); + + blockno = rscan->rs_cblock + 1; + + if (blockno >= rscan->rs_nblocks) + { + /* Wrap to beginning of relation */ + blockno = 0; + } + + if (blockno == rscan->rs_startblock) + { + /* Completed full cycle -- done */ + blockno = InvalidBlockNumber; + } + } + } + + rscan->rs_cblock = blockno; + + if (!BlockNumberIsValid(blockno)) + { + rscan->rs_inited = false; + return false; + } + + Assert(rscan->rs_cblock < rscan->rs_nblocks); + + CHECK_FOR_INTERRUPTS(); + + /* Read the selected block -- comes back pinned but not locked */ + rscan->rs_cbuf = ReadBufferExtended(scan->rs_rd, MAIN_FORKNUM, + blockno, RBM_NORMAL, NULL); + + rscan->rs_inited = true; + return true; +} + +/* + * Sample scan: get next tuple from current block (TABLESAMPLE support) + * + * Called repeatedly for the block prepared by flux_scan_sample_next_block(). + * The TSM's NextSampleTuple callback decides which tuple offsets to examine. + * We lock the buffer, check the tuple at the selected offset for visibility, + * and either return it in the slot (true) or indicate end-of-page (false). + * + * Unlike the ANALYZE path which iterates all items sequentially, here the + * TSM picks specific offsets, and we loop until it returns InvalidOffsetNumber + * to signal that it is done with this block. + */ +static bool +flux_scan_sample_next_tuple(TableScanDesc scan, SampleScanState *scanstate, + TupleTableSlot *slot) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + TsmRoutine *tsm = scanstate->tsmroutine; + BlockNumber blockno = rscan->rs_cblock; + Page page; + OffsetNumber maxoffset; + + /* + * Lock the buffer for visibility checks. We hold the lock for the + * duration of this call and release before returning, matching the heap + * AM's non-pagemode pattern. + */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_SHARE); + + page = BufferGetPage(rscan->rs_cbuf); + maxoffset = PageGetMaxOffsetNumber(page); + + for (;;) + { + OffsetNumber tupoffset; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + bool visible; + + CHECK_FOR_INTERRUPTS(); + + /* Ask the TSM which tuple to examine next on this page */ + tupoffset = tsm->NextSampleTuple(scanstate, blockno, maxoffset); + + if (OffsetNumberIsValid(tupoffset)) + { + /* Skip invalid item pointers */ + itemid = PageGetItemId(page, tupoffset); + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records -- not user-visible tuples */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + /* + * Determine visibility. FLUX uses heap-shaped xmin/xmax MVCC via + * FluxTupleVisibleToSnapshotDual, which handles DELETED/UPDATED + * tuples via sLog consultation. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + visible = false; + else if (scan->rs_snapshot) + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, + scan->rs_snapshot, + RelationGetRelid(scan->rs_rd), + rscan->rs_cbuf); + else + visible = !(tuple_hdr->t_flags & FLUX_TUPLE_DELETED); + + if (!visible) + continue; + + /* + * Found a visible tuple. Store it into the slot with a buffer + * pin so the data stays valid after we unlock. + */ + FluxSlotStoreTuple(slot, tuple_hdr, + ItemIdGetLength(itemid), rscan->rs_cbuf); + slot->tts_tableOid = RelationGetRelid(scan->rs_rd); + ItemPointerSet(&slot->tts_tid, blockno, tupoffset); + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + return true; + } + else + { + /* + * NextSampleTuple returned InvalidOffsetNumber -- done with this + * block. Unlock, clear the slot, and tell the caller to move on. + */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + ExecClearTuple(slot); + return false; + } + } + + /* unreachable */ + Assert(false); +} + +/* + * ------------------------------------------------------------------------ + * Main table AM routine structure for FLUX + * ------------------------------------------------------------------------ + */ +static const TableAmRoutine flux_methods = { + .type = T_TableAmRoutine, + + /* + * FLUX table DML records UNDO into the relation's own UNDO fork + * (RELUNDO_FORKNUM). flux_operations.c reserves space via + * RelUndoReserve() and emits RELUNDO_INSERT/DELETE/UPDATE/DELTA_UPDATE + * records; on abort RelUndoApplyChain() (relundo_apply.c) replays them + * synchronously in the aborting backend. This is the only UNDO path the + * table AM's own row operations use. + * + * The common-WAL UNDO path (UNDO_RMID_FLUX -> flux_undo.c, dispatched by + * undoapply.c) is the AM-agnostic mechanism shared with nbtree, hash, and + * FILEOPS. Indexes on a FLUX relation piggyback their UNDO records onto + * the table's active UNDO context (see NbtreeUndoLogInsert), so an index + * insert rolls back through the same chain as the row change. FLUX's own + * row DML does not emit UNDO_RMID_FLUX records. + * + * Upstream's am_supports_undo contract (see src/include/access/tableam.h) + * is AM-agnostic: each AM owns its page format and rollback path. No + * heap-page-layout constraint applies. + */ + .am_supports_undo = true, + + /* + * FLUX secondary indexes are plain 6-byte heap-TID nbtree entries with + * standard maintenance, like heap. A key-changing UPDATE is heap-like (new + * TID, old version to UNDO, old index entry dies and is VACUUMed); a non-key + * UPDATE is in place. The executor treats FLUX exactly like heap for + * indexing. Leaving this flag false selects the plain-TID index path (see + * indexam.c / execIndexing.c). + */ + .am_inplace_update_no_dead_tuple = false, + + /* Use minimal tuple slot */ + .slot_callbacks = flux_slot_callbacks, + + /* Use minimal scan functions - just return empty results */ + .scan_begin = flux_scan_begin, + .scan_end = flux_scan_end, + .scan_rescan = flux_scan_rescan, + .scan_getnextslot = flux_scan_getnextslot, + + .scan_set_tidrange = flux_scan_set_tidrange, + .scan_getnextslot_tidrange = flux_scan_getnextslot_tidrange, + + .parallelscan_estimate = table_block_parallelscan_estimate, + .parallelscan_initialize = table_block_parallelscan_initialize, + .parallelscan_reinitialize = table_block_parallelscan_reinitialize, + + /* Use minimal index functions */ + .index_fetch_begin = flux_index_fetch_begin, + .index_fetch_reset = flux_index_fetch_reset, + .index_fetch_end = flux_index_fetch_end, + .index_fetch_tuple = flux_index_fetch_tuple, + + /* Use minimal tuple functions */ + .tuple_insert = flux_tuple_insert, + .tuple_insert_speculative = flux_tuple_insert_speculative, + .tuple_complete_speculative = flux_tuple_complete_speculative, + .multi_insert = flux_multi_insert, + .tuple_delete = flux_tuple_delete, + .tuple_update = flux_tuple_update, + .tuple_lock = flux_tuple_lock, + + /* UNDO write-buffer activation / deactivation */ + .begin_bulk_insert = flux_begin_bulk_insert, + .finish_bulk_insert = flux_finish_bulk_insert, + + .tuple_fetch_row_version = flux_tuple_fetch_row_version, + .tuple_get_latest_tid = flux_tuple_get_latest_tid, + .tuple_tid_valid = flux_tuple_tid_valid, + .tuple_satisfies_snapshot = flux_tuple_satisfies_snapshot, + .index_delete_tuples = flux_index_delete_tuples, + + /* Keep only essential relation functions */ + .relation_set_new_filelocator = flux_relation_set_new_filelocator, + .relation_nontransactional_truncate = flux_relation_nontransactional_truncate, + .relation_copy_data = flux_relation_copy_data, + .relation_copy_for_cluster = flux_relation_copy_for_cluster, + .relation_vacuum = flux_relation_vacuum, + .scan_analyze_next_block = flux_scan_analyze_next_block, + .scan_analyze_next_tuple = flux_scan_analyze_next_tuple, + .index_build_range_scan = flux_index_build_range_scan, + .index_validate_scan = flux_index_validate_scan, + + .relation_size = table_block_relation_size, + .relation_needs_toast_table = flux_relation_needs_toast_table, + .relation_toast_am = flux_relation_toast_am, + .relation_fetch_toast_slice = heap_fetch_toast_slice, + + .relation_estimate_size = flux_relation_estimate_size, + + .scan_bitmap_next_tuple = flux_scan_bitmap_next_tuple, + .scan_sample_next_block = flux_scan_sample_next_block, + .scan_sample_next_tuple = flux_scan_sample_next_tuple, + + /* + * FLUX secondary indexes hold plain 6-byte heap-style TID entries and are + * maintained exactly like heap indexes. A key-changing UPDATE stores the + * new version out of place (new TID) so the old (key, TID) index entry + * becomes dead and is reclaimed by VACUUM, never duplicated -- see + * flux_tuple_update(). + */ +}; + +/* + * Return the FLUX table AM routine + */ +const TableAmRoutine * +GetFluxTableAmRoutine(void) +{ + return &flux_methods; +} + +/* + * Handler function for FLUX table access method + */ +PG_FUNCTION_INFO_V1(flux_tableam_handler); + +Datum +flux_tableam_handler(PG_FUNCTION_ARGS) +{ + PG_RETURN_POINTER(&flux_methods); +} + +/* + * flux_analyze_accumulate_sample + * + * During an ANALYZE scan, capture the decompressed bytes of the relation's + * first varlena column from the just-materialized slot. These samples feed + * FluxMaybeRefreshDict() at scan end so it can train a candidate compression + * dictionary from data ANALYZE already sampled, with no extra table reads. + * + * Sample buffers are allocated lazily in the scan's own memory context and + * are bounded by fixed byte/count caps. Once a cap is hit we stop collecting + * but let the ANALYZE scan continue normally. + */ +static void +flux_analyze_accumulate_sample(FluxScanDesc rscan, TupleTableSlot *slot) +{ + /* + * FLUX does not compress attributes and has no compression dictionary, + * so ANALYZE does not accumulate a training corpus. + */ + (void) rscan; + (void) slot; +} + +/* + * ANALYZE support: select next block to sample + * + * Called by ANALYZE to prepare the next sampled block for tuple extraction. + * The ReadStream provides buffers for blocks selected by the BlockSampler + * in analyze.c -- we do not choose blocks ourselves. + * + * We acquire a buffer pin and shared lock here and hold them until + * flux_scan_analyze_next_tuple() has returned false for this block, + * preventing concurrent activity (e.g. pruning) from removing tuples + * out from under us. + */ +static bool +flux_scan_analyze_next_block(TableScanDesc scan, ReadStream *stream) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + + /* + * Get the next buffer from the read stream. The stream was set up by + * analyze.c with a BlockSampler callback, so it yields only the randomly + * selected sample blocks. The buffer comes back already pinned. + */ + rscan->rs_cbuf = read_stream_next_buffer(stream, NULL); + + if (!BufferIsValid(rscan->rs_cbuf)) + return false; + + /* + * Don't lock the buffer here; flux_scan_analyze_next_tuple() manages its + * own lock/unlock cycle so it can release the lock before returning a + * sampled tuple, allowing FluxFetchOverflowColumn() to safely lock the + * same buffer for overflow data on this page. + */ + + rscan->rs_cblock = BufferGetBlockNumber(rscan->rs_cbuf); + rscan->rs_cindex = FirstOffsetNumber; + + return true; +} + +/* + * ANALYZE support: get next tuple from current block + * + * Extracts tuples one at a time from the block prepared by + * flux_scan_analyze_next_block(). For each item pointer on the page we + * classify the tuple as live, dead, or not-a-tuple (overflow record, + * unused pointer) and update the caller's counters. + * + * When a live tuple suitable for sampling is found, it is materialized + * into the slot and we return true. When all items on the page have been + * examined, we release the buffer and return false. + * + * The buffer remains pinned and locked for the entire duration of tuple + * iteration on this block, matching the heap AM contract. + */ +static bool +flux_scan_analyze_next_tuple(TableScanDesc scan, + double *liverows, double *deadrows, + TupleTableSlot *slot) +{ + FluxScanDesc rscan = (FluxScanDesc) scan; + Page targpage; + OffsetNumber maxoffset; + + Assert(BufferIsValid(rscan->rs_cbuf)); + + /* + * Re-acquire the buffer content lock. We release it before returning a + * sampled tuple (see below) so that the caller can safely deform the + * tuple -- FluxFetchOverflowColumn() may need to lock the same buffer to + * read overflow data stored on the same page. + */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_SHARE); + + targpage = BufferGetPage(rscan->rs_cbuf); + maxoffset = PageGetMaxOffsetNumber(targpage); + + /* Inner loop over items on the selected page */ + for (; rscan->rs_cindex <= maxoffset; rscan->rs_cindex++) + { + ItemId itemid; + FluxTupleHeader *tuple_hdr; + bool sample_it = false; + RelUndoRecPtr proxy_verptr = InvalidRelUndoRecPtr; + + itemid = PageGetItemId(targpage, rscan->rs_cindex); + + /* + * Skip unused and dead line pointers. Dead line pointers are counted + * as dead rows because vacuum needs to reclaim them. + */ + if (!ItemIdIsNormal(itemid)) + { + if (ItemIdIsDead(itemid)) + *deadrows += 1; + continue; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(targpage, itemid); + + /* Skip overflow records -- these are not user-visible tuples */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + /* + * Classify the tuple for ANALYZE purposes. FLUX uses + * timestamp-based MVCC rather than xmin/xmax, so we check the tuple + * flags and timestamps directly. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + /* Dead tuple (deleted) -- counted as dead for ANALYZE */ + *deadrows += 1; + } + else if (tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + { + /* + * Speculative insertion not yet confirmed. Don't count it; if + * the inserter commits it will be picked up by a future ANALYZE. + */ + } + else + { + /* + * Tuple is live (or at least not deleted/speculative). Sample it + * for statistics. + */ + sample_it = true; + *liverows += 1; + + /* + * Synthetic dead-tuple proxy for the per-relation UNDO fork. An + * in-place FLUX UPDATE creates no genuine dead tuple, so the + * standard dead-tuple autovacuum trigger would never fire and the + * UNDO fork would grow unbounded. A live tuple that carries a + * version pointer has a prior committed image retained in the fork; + * if that record has not yet been discarded it is reclaimable work, + * so we count it as a dead-tuple proxy. The liveness probe touches + * a fork buffer, so it is deferred until after the content lock on + * the data page is released (below), matching the overflow-fetch + * discipline used for the dictionary sample. + */ + proxy_verptr = FluxTupleGetVersionPtr(tuple_hdr, + ItemIdGetLength(itemid)); + } + + if (sample_it) + { + /* + * Materialize the tuple into palloc'd memory rather than storing + * a buffer-pinned pointer. This is necessary because slot + * deformation may call FluxFetchOverflowColumn(), which acquires + * buffer locks on overflow pages. If the overflow data resides + * on the same page we are scanning, a buffer-pinned slot would + * cause a lock re-entry assertion failure in LockBuffer + * (bufmgr.c). + */ + Size tuple_size = ItemIdGetLength(itemid); + FluxTupleHeader *tuple_copy; + + tuple_copy = (FluxTupleHeader *) palloc(tuple_size); + memcpy(tuple_copy, tuple_hdr, tuple_size); + + FluxSlotStoreMaterializedTuple(slot, tuple_copy, tuple_size); + slot->tts_tableOid = RelationGetRelid(scan->rs_rd); + ItemPointerSet(&slot->tts_tid, rscan->rs_cblock, rscan->rs_cindex); + rscan->rs_cindex++; + + /* + * Release the content lock so the caller can safely deform the + * materialized tuple. The buffer pin is kept so the page stays + * in the buffer pool. We re-acquire the lock at the top of this + * function when called again. + */ + LockBuffer(rscan->rs_cbuf, BUFFER_LOCK_UNLOCK); + + /* + * Opportunistically feed the dictionary-refresh corpus from the + * same sampled tuple. Done after the unlock so any overflow-column + * fetch can lock this page safely. + */ + if (scan->rs_flags & SO_TYPE_ANALYZE) + flux_analyze_accumulate_sample(rscan, slot); + + /* + * Count the UNDO-fork version-chain proxy (see the live-tuple + * branch above). Probing the fork record must happen with no data + * page content lock held. RelUndoReadRecordHeader reads only the + * record header (no payload allocation) and returns false once the + * record has been discarded by VACUUM, so the proxy resets after + * the fork is reclaimed. + */ + if (RelUndoRecPtrIsValid(proxy_verptr)) + { + RelUndoRecordHeader urec_hdr; + + if (RelUndoReadRecordHeader(scan->rs_rd, proxy_verptr, + &urec_hdr)) + *deadrows += 1; + } + + return true; + } + } + + /* + * No more tuples on this page. Release the buffer pin and lock that were + * acquired in flux_scan_analyze_next_block(). + */ + UnlockReleaseBuffer(rscan->rs_cbuf); + rscan->rs_cbuf = InvalidBuffer; + + /* Prevent stale slot contents from holding a pin */ + ExecClearTuple(slot); + + return false; +} diff --git a/src/backend/access/flux/flux_lock.c b/src/backend/access/flux/flux_lock.c new file mode 100644 index 0000000000000..a6292dd58e874 --- /dev/null +++ b/src/backend/access/flux/flux_lock.c @@ -0,0 +1,355 @@ +/*------------------------------------------------------------------------- + * + * flux_lock.c + * FLUX locking mechanisms for concurrent access + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_lock.c + * + * NOTES + * This implements proper locking for FLUX operations to ensure + * data consistency under concurrent access. Uses both buffer locks + * and tuple-level locks with deadlock detection. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "storage/bufmgr.h" +#include "storage/lmgr.h" +#include "storage/lwlock.h" +#include "storage/proc.h" +#include "utils/rel.h" +#include "miscadmin.h" +#include "access/tableam.h" + +/* + * FluxLockTuple + * + * Acquire a tuple-level lock on the specified tuple using PostgreSQL's + * standard LOCKTAG_TUPLE mechanism. The lock mode is converted from + * LockTupleMode to the corresponding LOCKMODE (ShareLock for read modes, + * ExclusiveLock for write modes). + * + * Parameters: + * rel - open relation containing the tuple + * tid - ItemPointer identifying the tuple (block + offset) + * mode - desired lock strength (LockTupleKeyShare through + * LockTupleExclusive) + * wait - if true, block until the lock is available; if false, + * return false immediately if the lock cannot be acquired + * have_tuple_lock - output: set to true if the lock was successfully acquired + * + * Returns true if the lock was acquired, false if 'wait' was false and the + * lock was not available. + * + * The caller is responsible for calling FluxUnlockTuple() to release the + * lock when done. + */ +bool +FluxLockTuple(Relation rel, ItemPointer tid, LockTupleMode mode, + bool wait, bool *have_tuple_lock) +{ + LOCKTAG tag; + LOCKMODE lockmode; + bool result; + + *have_tuple_lock = false; + + /* + * Convert tuple lock mode to standard lock mode using the same mapping as + * heap (tupleLockExtraInfo in heapam.c). The four modes MUST map to four + * distinct LOCKMODEs: collapsing KeyShare/Share or NoKeyExclusive/Exclusive + * makes an FK key-share lock conflict with a concurrent no-key UPDATE, + * manufacturing deadlocks that heap never suffers. + */ + switch (mode) + { + case LockTupleKeyShare: + lockmode = AccessShareLock; + break; + case LockTupleShare: + lockmode = RowShareLock; + break; + case LockTupleNoKeyExclusive: + lockmode = ExclusiveLock; + break; + case LockTupleExclusive: + lockmode = AccessExclusiveLock; + break; + default: + elog(ERROR, "invalid tuple lock mode: %d", mode); + } + + /* Set up lock tag for tuple */ + SET_LOCKTAG_TUPLE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid)); + + /* Acquire the lock */ + if (wait) + { + LockAcquire(&tag, lockmode, false, false); + result = true; + } + else + { + result = (LockAcquireExtended(&tag, lockmode, false, true, true, NULL, false) != LOCKACQUIRE_NOT_AVAIL); + } + + if (result) + *have_tuple_lock = true; + + return result; +} + +/* + * FluxUnlockTuple + * + * Release a tuple-level lock previously acquired by FluxLockTuple(). + * + * Parameters: + * rel - open relation containing the tuple + * tid - ItemPointer identifying the locked tuple + * mode - lock mode that was used when acquiring (must match) + */ +void +FluxUnlockTuple(Relation rel, ItemPointer tid, LockTupleMode mode) +{ + LOCKTAG tag; + LOCKMODE lockmode; + + /* + * Convert tuple lock mode to standard lock mode using the same mapping as + * heap (tupleLockExtraInfo in heapam.c). The four modes MUST map to four + * distinct LOCKMODEs: collapsing KeyShare/Share or NoKeyExclusive/Exclusive + * makes an FK key-share lock conflict with a concurrent no-key UPDATE, + * manufacturing deadlocks that heap never suffers. + */ + switch (mode) + { + case LockTupleKeyShare: + lockmode = AccessShareLock; + break; + case LockTupleShare: + lockmode = RowShareLock; + break; + case LockTupleNoKeyExclusive: + lockmode = ExclusiveLock; + break; + case LockTupleExclusive: + lockmode = AccessExclusiveLock; + break; + default: + elog(ERROR, "invalid tuple lock mode: %d", mode); + } + + /* Set up lock tag for tuple */ + SET_LOCKTAG_TUPLE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid)); + + /* Release the lock */ + LockRelease(&tag, lockmode, false); +} + +/* + * FluxLockPage + * + * Acquire a page-level lock using LOCKTAG_PAGE. This is used for operations + * that need exclusive access to an entire page's structure, such as + * defragmentation or cross-page tuple moves. + * + * Note: This is distinct from buffer-level locking (LockBuffer). Buffer + * locks protect the in-memory page image; page-level locks here protect + * the logical page structure across multiple buffer accesses. + * + * Parameters: + * rel - open relation containing the page + * blkno - block number to lock + * mode - lock mode (typically ShareLock or ExclusiveLock) + */ +void +FluxLockPage(Relation rel, BlockNumber blkno, LOCKMODE mode) +{ + LOCKTAG tag; + + /* Set up lock tag for page */ + SET_LOCKTAG_PAGE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + blkno); + + /* Acquire the lock */ + LockAcquire(&tag, mode, false, false); +} + +/* + * FluxUnlockPage + * + * Release a page-level lock previously acquired by FluxLockPage(). + * + * Parameters: + * rel - open relation containing the page + * blkno - block number to unlock + * mode - lock mode that was used when acquiring (must match) + */ +void +FluxUnlockPage(Relation rel, BlockNumber blkno, LOCKMODE mode) +{ + LOCKTAG tag; + + /* Set up lock tag for page */ + SET_LOCKTAG_PAGE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + blkno); + + /* Release the lock */ + LockRelease(&tag, mode, false); +} + + +/* + * FluxLockMultipleTuples + * + * Acquire tuple-level locks on multiple tuples in a consistent order to + * prevent deadlocks. The TIDs are sorted (using bubble sort, which is + * adequate since N is typically small) before acquiring locks, ensuring + * that all callers acquire locks in the same global order. + * + * If any lock acquisition fails (when wait=false), all previously acquired + * locks are released and the function returns false. + * + * Note: The tids array is sorted in-place, which modifies the caller's array. + * + * Parameters: + * rel - open relation containing the tuples + * tids - array of ItemPointerData identifying tuples to lock (sorted in-place) + * ntids - number of entries in tids array + * mode - desired lock strength for all tuples + * wait - if true, block until all locks are available + * + * Returns true if all locks were acquired, false if any could not be acquired. + */ +bool +FluxLockMultipleTuples(Relation rel, ItemPointerData *tids, int ntids, + LockTupleMode mode, bool wait) +{ + int i, + j; + bool all_locked = true; + bool *locked = palloc0(sizeof(bool) * ntids); + + /* Sort TIDs to ensure consistent lock ordering */ + for (i = 0; i < ntids - 1; i++) + { + for (j = i + 1; j < ntids; j++) + { + if (ItemPointerCompare(&tids[i], &tids[j]) > 0) + { + ItemPointerData temp = tids[i]; + + tids[i] = tids[j]; + tids[j] = temp; + } + } + } + + /* Acquire locks in sorted order */ + for (i = 0; i < ntids; i++) + { + bool have_lock; + + if (!FluxLockTuple(rel, &tids[i], mode, wait, &have_lock)) + { + all_locked = false; + break; + } + locked[i] = have_lock; + } + + /* If we failed to get all locks, release what we got */ + if (!all_locked) + { + for (j = 0; j < i; j++) + { + if (locked[j]) + FluxUnlockTuple(rel, &tids[j], mode); + } + } + + pfree(locked); + return all_locked; +} + +/* + * FluxLockRelationForDDL + * + * Acquire a relation-level lock for DDL operations (e.g., ALTER TABLE, + * DROP TABLE). Delegates to PostgreSQL's standard LockRelationOid(). + * + * Parameters: + * rel - open relation to lock + * lockmode - lock mode (typically AccessExclusiveLock for DDL) + */ +void +FluxLockRelationForDDL(Relation rel, LOCKMODE lockmode) +{ + /* Use standard relation locking */ + LockRelationOid(RelationGetRelid(rel), lockmode); +} + +/* + * FluxHoldsTupleLock + * + * Check whether the current transaction already holds a lock on the + * specified tuple at the given mode. This is useful for avoiding redundant + * lock acquisitions and for assertions in debug builds. + * + * Parameters: + * rel - open relation containing the tuple + * tid - ItemPointer identifying the tuple + * mode - lock mode to check for + * + * Returns true if the current transaction holds the specified lock. + */ +bool +FluxHoldsTupleLock(Relation rel, ItemPointer tid, LockTupleMode mode) +{ + LOCKTAG tag; + LOCKMODE lockmode; + + /* Convert tuple lock mode to standard lock mode */ + switch (mode) + { + case LockTupleKeyShare: + case LockTupleShare: + lockmode = ShareLock; + break; + case LockTupleNoKeyExclusive: + case LockTupleExclusive: + lockmode = ExclusiveLock; + break; + default: + return false; + } + + /* Set up lock tag for tuple */ + SET_LOCKTAG_TUPLE(tag, + rel->rd_locator.dbOid, + rel->rd_locator.relNumber, + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid)); + + /* Check if we hold the lock */ + return LockHeldByMe(&tag, lockmode, false); +} diff --git a/src/backend/access/flux/flux_mvcc.c b/src/backend/access/flux/flux_mvcc.c new file mode 100644 index 0000000000000..e11b9b8a7015f --- /dev/null +++ b/src/backend/access/flux/flux_mvcc.c @@ -0,0 +1,1341 @@ +/*------------------------------------------------------------------------- + * + * flux_mvcc.c + * FLUX heap-compatible xmin/xmax MVCC implementation + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_mvcc.c + * + * NOTES + * FLUX uses ordinary heap-compatible xmin/xmax MVCC visibility, the same + * model as HeapTupleSatisfiesMVCC: a tuple is visible to a snapshot iff its + * inserter (t_xmin) is committed-and-visible-to-the-snapshot AND its + * deleter/updater (t_xmax) is invalid, not committed, or not visible. CLOG + * is the commit-status authority; the snapshot's xmin/xmax/xip decide + * visibility of committed XIDs; subtransaction aborts (ROLLBACK TO + * savepoint) are handled by stamping t_xmin/t_xmax with the current subxid + * so CLOG marks a rolled-back subxact's tuples aborted (plus a transient + * sLog ABORTED marker consulted for the still-in-progress window). + * + * In-place UPDATE keeps the NEWEST version on the page (new t_xmin) and + * pushes the pre-update image to the per-relation UNDO fork via t_verptr + * (zheap style). A snapshot that cannot see the updater's xmin reads the + * old version back from the fork with FluxReconstructVisibleVersion() + * (flux_pvs.c), which walks t_verptr and stops at the version whose + * producing xid is visible (XidInMVCCSnapshot). The single visibility + * function is FluxTupleSatisfiesMVCC(), reached via + * FluxTupleVisibleToSnapshotDual() from every read site. + * + * The pre-commit "dirty read" window closes for free: a reader resolves + * t_xmin against CLOG, which reports committed only at the durability point + * (RecordTransactionCommit flushes the commit record). A not-yet-committed + * (hence not-yet-durable) inserter is simply invisible -- no HLC, no + * timestamp rewind, no flux_cts durable map. + * + * The FLUX_TUPLE_UNCOMMITTED flag (0x0080) is set on insert/delete/update + * and cleared (as a hint bit) at commit. It no longer drives read + * visibility (CLOG does); it is retained for the sLog write-conflict and + * defrag paths. + * + * ISOLATION LEVEL SEMANTICS + * + * FLUX integrates with PostgreSQL's Serializable Snapshot Isolation + * (SSI) infrastructure in predicate.c. The scan path acquires SIREAD + * predicate locks via PredicateLockTID(), and the DML paths (INSERT, + * UPDATE, DELETE) call CheckForSerializableConflictIn() to detect + * rw-antidependencies. The FluxCheckForSerializableConflictOut() + * function handles the reverse direction (reader encounters a tuple + * written by a concurrent transaction) by looking up the writer's + * XID via the sLog and delegating to predicate.c. + * + * BEFORE-IMAGE SERVING: + * + * In-place UPDATEs destroy the pre-image on the page. Under WS-PVS3 the + * visible prior version is reconstructed by walking the durable UNDO fork + * chain (FluxReconstructVisibleVersion), not from any shared sLog DSA + * entry: readers whose snapshot cannot see the committing xid follow the + * on-page head verptr into the UNDO fork. This restores correct snapshot + * semantics for concurrent readers under REPEATABLE READ and SERIALIZABLE. + * + * CONCURRENCY: + * + * 1. Same-tuple write-write conflicts serialize correctly: the + * second writer blocks (via XactLockTableWait on the sLog dirty + * XID) until the first commits or aborts. + * + * 2. Write Skew (A5B) on disjoint tuples IS detected via predicate + * locking (SIREAD locks on tuples read + conflict-in checks on + * writes). + * + * In summary, FLUX's isolation guarantees are: + * - READ COMMITTED: Correct (no dirty reads, each statement gets + * fresh visibility via a per-statement snapshot) + * - REPEATABLE READ: Full Snapshot Isolation; concurrent committed + * UPDATEs are reconstructed from the UNDO fork per reader snapshot + * - SERIALIZABLE: Full SSI via predicate.c integration; write skew + * and phantom anomalies are prevented through predicate locking + * and rw-antidependency cycle detection + * + * References: + * - Berenson et al., "A Critique of ANSI SQL Isolation Levels" (1995) + * - Adya, "Weak Consistency: A Generalized Theory and Optimistic + * Implementations for Distributed Transactions" (2000) + * - Cahill et al., "Serializable Isolation for Snapshot Databases" (2009) + * - Ports & Grittner, "Serializable Snapshot Isolation in PostgreSQL" (2012) + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm.h" +#include "access/flux.h" +#include "access/slog.h" +#include "access/subtrans.h" +#include "access/transam.h" +#include "access/twophase.h" +#include "access/xact.h" +#include "utils/snapmgr.h" +#include "miscadmin.h" +#include "port/atomics.h" +#include "utils/memutils.h" +#include "storage/bufmgr.h" +#include "storage/ipc.h" +#include "storage/lwlock.h" +#include "storage/predicate.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/shmem.h" +#include "utils/guc.h" +#include "utils/timestamp.h" + +/* + * Total number of PGPROC slots, matching the allProcs array size in proc.c. + * This must cover regular backends, auxiliary procs, and prepared transactions + * since GetNumberFromPGProc() can return indices up to TotalProcs - 1. + */ +#define FLUX_TOTAL_PROCS \ + (MaxBackends + NUM_AUXILIARY_PROCS + max_prepared_xacts) + +/* + * Shared memory structures for MVCC + */ +typedef struct FluxMvccShmemData +{ + LWLock mvcc_lock; /* Protects serializable horizon only */ + uint64 oldest_active_ts; /* Cached oldest active transaction ts; + * backs FluxGetOldestActiveTimestamp(), which + * still feeds the defrag WAL record and the VM + * all-visible check. NOT a vacuum horizon. */ + uint64 serializable_horizon; /* Serializable isolation horizon */ + + pg_atomic_uint32 oldest_active_generation; /* Bumped when cache is + * invalidated */ + pg_atomic_uint32 active_xact_count; /* Number of active transactions + * (atomic) */ + + /* + * Per-backend active transaction start timestamps. Each backend slot + * stores the start timestamp of its current FLUX transaction, or 0 if + * idle. This array is indexed by pgprocno (the offset into + * ProcGlobal->allProcs) and is sized to FLUX_TOTAL_PROCS so that + * auxiliary procs and prepared transactions are covered. + * + * Each slot is written only by its owning backend and read by VACUUM, so + * no lock is needed — just a compiler barrier via volatile access. + */ + int num_xact_slots; /* Number of slots (== FLUX_TOTAL_PROCS) */ + uint64 xact_start_ts_slots[FLEXIBLE_ARRAY_MEMBER]; + +} FluxMvccShmemData; + +static FluxMvccShmemData * FluxMvccShmem = NULL; + + +/* + * Per-transaction MVCC state. + * + * SSI (Serializable Snapshot Isolation) conflict detection is now delegated + * entirely to PostgreSQL's predicate.c infrastructure. FLUX integrates + * with it by calling PredicateLockTID() in the scan path and + * CheckForSerializableConflictIn/Out() in the DML paths. The private + * rw-conflict graph that was previously here has been removed. + * + * Commit visibility uses CLOG (heap-shaped xmin/xmax); the timestamp fields + * below are page-level bookkeeping only. + */ +struct FluxTransactionState +{ + uint64 xact_start_ts; /* Transaction start timestamp */ + uint64 xact_commit_ts; /* Transaction commit timestamp */ + + /* + * READ COMMITTED per-command read point. Under RC the visibility read + * point is "now", refreshed each command so a scan sees concurrent + * commits. Captured once per command (keyed by rc_read_point_cid) and + * reused for every tuple. + */ + CommandId rc_read_point_cid; + uint64 rc_read_point_xcc; /* snapXactCompletionCount at capture */ + bool is_serializable; /* Serializable isolation level */ + bool is_read_only; /* Transaction has not performed writes */ + + /* + * EPQ reconcile identity for the in-place write-write conflict path. + * + * When flux_tuple_update detects a committed concurrent UPDATE (head + * verptr's committer is invisible to snapshot) it returns TM_Updated + * and the executor runs EvalPlanQual: table_tuple_lock re-reads the + * latest on-page value (which, because FLUX updates in place, already + * reflects every commit so far) and the quals/SET expression recompute + * on top of it. But EPQ re-evaluates against the same statement + * snapshot (same xid xip / curcid), so a naive probe would re-detect + * the same committed marker and livelock. Heap escapes this because + * EPQ advances tupleid to a new tuple version with a clean xmax; FLUX + * has no new version to advance to. + * + * EPQ dedup: identity of the (verptr, xid) marker we bounced on to + * EvalPlanQual for this exact (relid, tid, cid). If the next probe on + * the same tuple in the same EPQ retry finds an unchanged head verptr + * and xid, we already reconciled it -- skip to prevent livelock. A + * strictly-newer committer stamps a NEW record with a fresh verptr, so + * dedup only suppresses re-firing on the identical marker. No temporal + * ordering is required: this is a physical identity check. + */ + Oid epq_reconcile_relid; + ItemPointerData epq_reconcile_tid; + CommandId epq_reconcile_cid; + RelUndoRecPtr epq_reconcile_verptr; + TransactionId epq_reconcile_xid; +}; + +/* Restart reasons */ +#define FLUX_RESTART_NONE 0 +#define FLUX_RESTART_UNCERTAINTY 1 +#define FLUX_RESTART_SERIALIZABLE 2 + +/* + * Per-backend static transaction state. Using a static struct avoids + * a palloc/pfree cycle per transaction. The struct is reset at the start + * of each transaction by FluxInitTransactionState(). + * + * MyFluxXactState points to &MyFluxXactStateData when a transaction is + * active, and is NULL between transactions. This preserves the existing + * NULL-check pattern throughout the codebase. + */ +static FluxTransactionState MyFluxXactStateData; +static FluxTransactionState *MyFluxXactState = NULL; + +/* + * GUC variables (flux_enable_serializable and flux_max_transactions removed; + * SSI is unconditionally provided via predicate.c) + */ + +/* + * Function prototypes + */ +static void FluxInitTransactionState(void); +static void FluxCleanupTransactionState(void); +static void FluxShmemExit(int code, Datum arg); + +/* FluxCheckUncommittedDelete removed -- logic inlined in visibility checks */ + + +/* + * Shared memory size calculation + * + * Per-transaction state (FluxTransactionState) is allocated in + * backend-local TopTransactionContext, NOT in shared memory, so it + * does not appear here. The only shared-memory array is the + * per-backend xact_start_ts_slots[], which scales naturally with + * FLUX_TOTAL_PROCS (and therefore MaxBackends). During bootstrap + * MaxBackends is ~4, keeping this allocation tiny. + */ +Size +FluxMvccShmemSize(void) +{ + Size size; + + /* + * Base struct (includes the flexible array header but not the array + * elements), plus one uint64 slot per PGPROC (regular backends, auxiliary + * procs, prepared transactions) for tracking active transaction start + * timestamps. + */ + size = offsetof(FluxMvccShmemData, xact_start_ts_slots); + size = add_size(size, mul_size(FLUX_TOTAL_PROCS, sizeof(uint64))); + + return size; +} + +/* + * Initialize shared memory for MVCC + */ +void +FluxMvccShmemInit(void) +{ + bool found; + + FluxMvccShmem = (FluxMvccShmemData *) + ShmemInitStruct("FLUX MVCC Data", + FluxMvccShmemSize(), + &found); + + if (!found) + { + int total_procs = FLUX_TOTAL_PROCS; + + /* Initialize shared memory */ + LWLockInitialize(&FluxMvccShmem->mvcc_lock, LWTRANCHE_BUFFER_MAPPING); + FluxMvccShmem->oldest_active_ts = 1; + pg_atomic_init_u32(&FluxMvccShmem->oldest_active_generation, 0); + FluxMvccShmem->serializable_horizon = 1; + pg_atomic_init_u32(&FluxMvccShmem->active_xact_count, 0); + + /* Initialize per-backend active timestamp slots to 0 (idle) */ + FluxMvccShmem->num_xact_slots = total_procs; + memset(FluxMvccShmem->xact_start_ts_slots, 0, + total_procs * sizeof(uint64)); + } + + /* Register cleanup function */ + on_shmem_exit(FluxShmemExit, 0); +} + +/* + * FluxGetDmlTimestamp -- return the transaction's start timestamp for DML + * page-level bookkeeping. + * + * Within a single transaction, all DML operations (INSERT, UPDATE, DELETE) + * stamp the page-level commit-ts word (FluxPageSetCommitTs) with the + * transaction's start timestamp. + * + * This value is NOT a visibility timestamp. Commit visibility comes from + * CLOG via heap-shaped xmin/xmax: while the transaction is in-flight the + * FLUX_TUPLE_UNCOMMITTED flag is set and self-visibility is determined by + * matching the inserter's XID in the sLog; after commit, t_xmin/t_xmax + CLOG + * decide visibility. Intra-transaction ordering (multiple DMLs in the same + * txn) is handled by CID (command ID) from the sLog entry. + */ +uint64 +FluxGetDmlTimestamp(void) +{ + /* + * Callers must have already called FluxGetTransactionTimestamp() or + * equivalent, which initializes the transaction state. We assert rather + * than lazily initializing, keeping this function as lean as possible on + * the hot path. + */ + Assert(MyFluxXactState != NULL); + + return MyFluxXactState->xact_start_ts; +} + +/* + * FluxGetCommitTimestamp + * + * Return a wall-clock timestamp (microseconds since the PostgreSQL epoch) + * used only for page-level commit-ts bookkeeping and the VM all-visible + * hint. Commit visibility comes from CLOG (heap-shaped xmin/xmax); nothing + * reads this value for a correctness/visibility DECISION, so it does not + * need strict cross-backend monotonicity. + * + * Historically this walked an atomic compare-and-swap loop over a single + * shared global_commit_ts counter to keep timestamps strictly increasing. + * That shared RMW was a hot-path serialization point (one cache line CAS'd + * ~2x per write txn) with no surviving consumer of strict monotonicity, so + * it has been replaced with a plain GetCurrentTimestamp() read: no shared + * counter, no CAS, no spin. Wall-clock microseconds are already + * monotonic-enough for a "min over active start-stamps" horizon and a + * ">= oldest" VM hint (both use non-strict comparisons). + */ +uint64 +FluxGetCommitTimestamp(void) +{ + if (FluxMvccShmem == NULL) + elog(ERROR, "FLUX MVCC not initialized"); + + return (uint64) GetCurrentTimestamp(); +} + +/* + * FluxGetTransactionTimestamp + * + * Return the start timestamp of the current transaction. Initializes + * per-transaction MVCC state on first call within a transaction. + * + * Returns the transaction's start timestamp (uint64). + */ +uint64 +FluxGetTransactionTimestamp(void) +{ + if (MyFluxXactState == NULL) + FluxInitTransactionState(); + + return MyFluxXactState->xact_start_ts; +} + +/* + * Subsystem callback wrappers for PG_SHMEM_SUBSYSTEM infrastructure + */ +static void +FluxMvccShmemRequest(void *arg) +{ + ShmemRequestStruct(.name = "FLUX MVCC Data", + .size = FluxMvccShmemSize(), + .ptr = (void **) &FluxMvccShmem); +} + +static void +FluxMvccShmemInit_cb(void *arg) +{ + int total_procs = FLUX_TOTAL_PROCS; + + /* FluxMvccShmem is already set by the ShmemRequestStruct .ptr mechanism */ + Assert(FluxMvccShmem != NULL); + + /* Initialize shared memory fields */ + LWLockInitialize(&FluxMvccShmem->mvcc_lock, LWTRANCHE_BUFFER_MAPPING); + FluxMvccShmem->oldest_active_ts = 1; + pg_atomic_init_u32(&FluxMvccShmem->oldest_active_generation, 0); + FluxMvccShmem->serializable_horizon = 1; + pg_atomic_init_u32(&FluxMvccShmem->active_xact_count, 0); + + /* Initialize per-backend active timestamp slots to 0 (idle) */ + FluxMvccShmem->num_xact_slots = total_procs; + memset(FluxMvccShmem->xact_start_ts_slots, 0, + total_procs * sizeof(uint64)); + + /* Register cleanup function */ + on_shmem_exit(FluxShmemExit, 0); +} + +const ShmemCallbacks FluxMvccShmemCallbacks = { + .request_fn = FluxMvccShmemRequest, + .init_fn = FluxMvccShmemInit_cb, +}; + +/* + * Initialize per-transaction MVCC state + * + * The start timestamp is a plain wall-clock value from + * FluxGetCommitTimestamp() held in the uint64 xact_start_ts field for + * per-backend slot tracking. + */ +/* + * Transaction callback for FLUX MVCC cleanup. + * + * This is registered once per backend via RegisterXactCallback. + * On transaction commit or abort, it calls FluxCommitTransaction() + * or FluxCleanupTransactionState() to reset MyFluxXactState, + * ensuring the next transaction in this backend gets a fresh start + * timestamp from FluxGetCommitTimestamp(). + */ +static bool flux_xact_callback_registered = false; + +static void +FluxXactCallback(XactEvent event, void *arg) +{ + switch (event) + { + case XACT_EVENT_COMMIT: + case XACT_EVENT_PARALLEL_COMMIT: + FluxCommitTransaction(); + break; + + case XACT_EVENT_ABORT: + case XACT_EVENT_PARALLEL_ABORT: + FluxCleanupTransactionState(); + break; + + case XACT_EVENT_PREPARE: + + /* + * At PREPARE, the transaction is still "in progress" for + * visibility purposes. We must NOT clear the shared-memory + * xact_start_ts slot or decrement active_xact_count -- doing so + * would allow VACUUM to advance the oldest-active horizon past + * this prepared transaction's start timestamp, risking premature + * tuple pruning between PREPARE and COMMIT PREPARED. + * + * AtPrepare_Flux() has already relocated the pin from this + * backend's proc slot onto the prepared xact's dummy-proc slot + * (FluxPrepareReassignSlot), so it survives this backend running + * new transactions or exiting; the backend that runs COMMIT/ + * ROLLBACK PREPARED clears the dummy slot via + * FluxResolvePreparedSlot() in the FLUX 2PC callbacks. Here we + * only drop the backend-local pointer so this backend can start + * fresh transactions. + */ + MyFluxXactState = NULL; + break; + + default: + /* Pre-commit, pre-prepare -- nothing to do */ + break; + } +} + +static void +FluxInitTransactionState(void) +{ + if (MyFluxXactState != NULL) + return; + + /* Register cleanup callback on first use in this backend */ + if (!flux_xact_callback_registered) + { + RegisterXactCallback(FluxXactCallback, NULL); + flux_xact_callback_registered = true; + } + + /* Use the static per-backend struct; zero it to start fresh */ + memset(&MyFluxXactStateData, 0, sizeof(FluxTransactionState)); + MyFluxXactState = &MyFluxXactStateData; + + /* + * Stamp the transaction start timestamp (monotonic wall-clock via + * FluxGetCommitTimestamp). This is a within-transaction bookkeeping + * value only; commit visibility comes from CLOG (heap-shaped xmin/xmax). + */ + MyFluxXactState->xact_start_ts = FluxGetCommitTimestamp(); + + MyFluxXactState->xact_commit_ts = 0; + + /* No RC per-command read point captured yet this transaction. */ + MyFluxXactState->rc_read_point_cid = InvalidCommandId; + MyFluxXactState->rc_read_point_xcc = 0; + MyFluxXactState->is_serializable = (XactIsoLevel == XACT_SERIALIZABLE); + MyFluxXactState->is_read_only = true; /* Until first write */ + + /* Register in shared memory for oldest-active-timestamp tracking */ + if (FluxMvccShmem != NULL) + { + int my_slot = MyProc ? (int) GetNumberFromPGProc(MyProc) : -1; + + /* + * Write our start timestamp into our per-backend slot. This is a + * single-writer/multi-reader pattern (only we write our slot, VACUUM + * reads it), so no lock is needed — just a write barrier. + */ + if (my_slot >= 0 && my_slot < FluxMvccShmem->num_xact_slots) + { + pg_write_barrier(); + FluxMvccShmem->xact_start_ts_slots[my_slot] = + MyFluxXactState->xact_start_ts; + } + + pg_atomic_fetch_add_u32(&FluxMvccShmem->active_xact_count, 1); + + /* + * If our start timestamp is older than the cached oldest, invalidate + * the cache by bumping the generation counter. + */ + if (MyFluxXactState->xact_start_ts < FluxMvccShmem->oldest_active_ts) + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); + } +} + +/* + * Cleanup per-transaction MVCC state + */ +static void +FluxCleanupTransactionState(void) +{ + if (MyFluxXactState == NULL) + return; + + /* + * Clear our slot in shared memory. No lock needed: each backend only + * writes its own slot, and the generation counter invalidates the cached + * oldest_active_ts when needed. + */ + if (FluxMvccShmem != NULL) + { + int my_slot = MyProc ? (int) GetNumberFromPGProc(MyProc) : -1; + uint64 my_ts = MyFluxXactState->xact_start_ts; + + /* Clear our per-backend slot */ + if (my_slot >= 0 && my_slot < FluxMvccShmem->num_xact_slots) + { + FluxMvccShmem->xact_start_ts_slots[my_slot] = 0; + pg_write_barrier(); + } + + pg_atomic_fetch_sub_u32(&FluxMvccShmem->active_xact_count, 1); + + /* + * Invalidate the cached oldest_active_ts if we might have been the + * oldest. Bump the generation counter so that + * FluxGetOldestActiveTimestamp() rescans on the next call. If no + * transactions remain, advance the cached value cheaply. + */ + if (pg_atomic_read_u32(&FluxMvccShmem->active_xact_count) == 0) + { + FluxMvccShmem->oldest_active_ts = (uint64) GetCurrentTimestamp(); + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); + } + else if (my_ts == FluxMvccShmem->oldest_active_ts) + { + /* + * Only invalidate the cache when we were the actual oldest active + * transaction. If my_ts < oldest_active_ts, the cached value was + * already advanced past us by another backend's rescan, so our + * departure cannot change the oldest. Using strict equality + * instead of <= dramatically reduces invalidation frequency under + * high concurrency. + */ + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); + } + } + + MyFluxXactState = NULL; +} + +/* + * FluxPrepareReassignSlot -- move this backend's oldest-active-timestamp slot + * to the prepared transaction's dummy-proc slot. + * + * Called from AtPrepare_Flux() in the preparing backend. A prepared xact is + * still "active" for visibility until COMMIT/ROLLBACK PREPARED, so its start + * timestamp must keep pinning the vacuum horizon -- but the preparing backend + * is about to become free to run new transactions (and may exit entirely). + * Keying the pin by the backend's own proc slot (as the in-progress path does) + * would orphan the pin the moment the backend exits, or let the resolving + * backend -- a different proc slot -- fail to find it. Relocate the pin to + * the dummy PGPROC slot the gxact owns for its whole prepared lifetime; + * FluxResolvePreparedSlot() clears exactly that slot when the xact resolves. + * + * active_xact_count is left unchanged: the transaction is still active, the + * pin simply moved slots. No-op if this backend never opened a FLUX xact. + */ +void +FluxPrepareReassignSlot(int dummy_slot) +{ + int my_slot; + uint64 my_ts; + + if (FluxMvccShmem == NULL || MyFluxXactState == NULL) + return; + + my_slot = MyProc ? (int) GetNumberFromPGProc(MyProc) : -1; + my_ts = MyFluxXactState->xact_start_ts; + + if (dummy_slot < 0 || dummy_slot >= FluxMvccShmem->num_xact_slots) + return; + + /* Publish the pin at the dummy slot, then release the backend slot. */ + FluxMvccShmem->xact_start_ts_slots[dummy_slot] = my_ts; + pg_write_barrier(); + if (my_slot >= 0 && my_slot < FluxMvccShmem->num_xact_slots) + FluxMvccShmem->xact_start_ts_slots[my_slot] = 0; + + /* Force a horizon rescan so both moves are observed. */ + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); +} + +/* + * FluxResolvePreparedSlot -- clear a prepared xact's dummy-proc slot. + * + * Called from the resolving backend (flux_twophase_postcommit / + * flux_twophase_postabort) once COMMIT/ROLLBACK PREPARED finishes. Mirrors + * the tail of FluxCleanupTransactionState() but operates on the gxact's dummy + * slot rather than the resolver's own proc slot. Idempotent: clearing an + * already-zero slot only decrements the active count and bumps the generation + * when the slot actually held a pin, so it is safe to call once per 2PC record. + */ +void +FluxResolvePreparedSlot(int dummy_slot) +{ + uint64 slot_ts; + + if (FluxMvccShmem == NULL) + return; + if (dummy_slot < 0 || dummy_slot >= FluxMvccShmem->num_xact_slots) + return; + + slot_ts = FluxMvccShmem->xact_start_ts_slots[dummy_slot]; + if (slot_ts == 0) + return; /* already cleared -- nothing to do */ + + FluxMvccShmem->xact_start_ts_slots[dummy_slot] = 0; + pg_write_barrier(); + + pg_atomic_fetch_sub_u32(&FluxMvccShmem->active_xact_count, 1); + + if (pg_atomic_read_u32(&FluxMvccShmem->active_xact_count) == 0) + { + FluxMvccShmem->oldest_active_ts = (uint64) GetCurrentTimestamp(); + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); + } + else if (slot_ts <= FluxMvccShmem->oldest_active_ts) + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); +} + +/* + * SSI conflict detection is handled by PostgreSQL's predicate.c infrastructure + * via CheckForSerializableConflictIn/Out calls in the DML and scan paths. + * The FluxCheckSerializableConflict compatibility stub has been removed. + */ + +/* + * FluxCheckForSerializableConflictOut -- detect rw-conflicts where a + * serializable reader encounters a tuple written by a concurrent transaction. + * + * This is the FLUX equivalent of HeapCheckForSerializableConflictOut. + * It determines the XID of the concurrent writer via the sLog and delegates + * to the core CheckForSerializableConflictOut() in predicate.c. + * + * Called when a serializable transaction encounters a tuple that is not + * visible to our snapshot (concurrent insert or concurrent delete/update + * that made the tuple disappear). + */ +void +FluxCheckForSerializableConflictOut(Relation relation, + FluxTupleHeader *tuple, + Buffer buffer, + Snapshot snapshot) +{ + TransactionId xid; + bool is_insert; + + if (!CheckForSerializableConflictOutNeeded(relation, snapshot)) + return; + + /* + * Determine the writer's XID. For FLUX, the tuple header doesn't store + * XIDs — we get them from the sLog. + */ + xid = SLogTupleGetDirtyXid(RelationGetRelid(relation), + &tuple->t_ctid, &is_insert); + + if (!TransactionIdIsValid(xid)) + { + /* + * No in-progress writer found. The writer already committed and its + * sLog entries were cleaned up. In this case, the conflicting + * transaction committed so long ago that it's no longer tracked. No + * conflict to report — analogous to heap's HEAPTUPLE_DEAD case. + */ + return; + } + + /* Skip conflicts with our own transaction */ + if (TransactionIdIsCurrentTransactionId(xid)) + return; + + /* Get top-level XID for subtransaction support */ + xid = SubTransGetTopmostTransaction(xid); + + /* Skip if too old to be a concurrent transaction */ + if (TransactionIdPrecedes(xid, TransactionXmin)) + return; + + CheckForSerializableConflictOut(relation, xid, snapshot); +} + +/* + * Commit the current transaction and assign commit timestamp. + * + * The commit timestamp is a monotonic wall-clock value (FluxGetCommitTimestamp) + * used only for page-level bookkeeping; commit visibility comes from CLOG + * (heap-shaped xmin/xmax MVCC). + */ +void +FluxCommitTransaction(void) +{ + if (MyFluxXactState == NULL) + return; + + MyFluxXactState->xact_commit_ts = FluxGetCommitTimestamp(); + + /* Update serializable horizon (only for serializable transactions) */ + if (FluxMvccShmem != NULL && MyFluxXactState->is_serializable) + { + LWLockAcquire(&FluxMvccShmem->mvcc_lock, LW_EXCLUSIVE); + FluxMvccShmem->serializable_horizon = + Min(FluxMvccShmem->serializable_horizon, + MyFluxXactState->xact_commit_ts); + LWLockRelease(&FluxMvccShmem->mvcc_lock); + } + + FluxCleanupTransactionState(); +} + +/* + * Abort the current transaction + */ +void +FluxAbortTransaction(void) +{ + if (MyFluxXactState == NULL) + return; + + FluxCleanupTransactionState(); +} + +/* + * Get snapshot timestamp for reads + */ +uint64 +FluxGetSnapshotTimestamp(Snapshot snapshot) +{ + if (IsMVCCSnapshot(snapshot)) + { + if (MyFluxXactState == NULL) + FluxInitTransactionState(); + + /* + * REPEATABLE READ / SERIALIZABLE: return transaction-start timestamp + * for a consistent point-in-time snapshot across all statements. + */ + if (IsolationUsesXactSnapshot()) + return MyFluxXactState->xact_start_ts; + + /* + * READ COMMITTED: return current timestamp so each visibility check + * sees the latest committed state. + */ + return (uint64) FluxGetCommitTimestamp(); + } + else + { + /* SnapshotAny or other non-MVCC snapshots */ + return 0; + } +} + +/* + * Check if tuple is visible to the given snapshot + */ +bool +FluxTupleVisibleToSnapshot(FluxTupleHeader *tuple, Snapshot snapshot, + Oid relid, Buffer buffer) +{ + /* Heap-shaped: forward to the single xmin/xmax visibility entry point. */ + return FluxTupleVisibleToSnapshotDual(tuple, snapshot, relid, buffer); +} + +/* + * Invalidate the cached oldest active timestamp, forcing the next call + * to FluxGetOldestActiveTimestamp() to rescan all per-backend slots. + * + * Also callable from VACUUM or any code that needs to force a refresh. + */ +void +FluxUpdateOldestActiveTimestamp(void) +{ + if (FluxMvccShmem == NULL) + return; + + pg_atomic_fetch_add_u32(&FluxMvccShmem->oldest_active_generation, 1); +} + +/* + * Per-backend cache of the oldest-active-timestamp computation. + * Avoids rescanning all per-backend slots on every call; only rescans + * when the global generation counter has been bumped. + */ +static uint32 my_oldest_active_gen = 0; +static uint64 my_oldest_active_cached = 0; + +/* + * FluxGetOldestActiveTimestamp -- return the oldest active transaction's + * start timestamp. + * + * This is the FLUX analog of PostgreSQL's GetOldestNonRemovableTransactionId. + * VACUUM uses this to determine which deleted tuples can be safely removed: + * a deleted tuple whose commit timestamp is older than this value is no + * longer visible to any running transaction and can be reclaimed. + * + * If no transactions are active, returns the current global commit timestamp, + * meaning all committed deletions are eligible for cleanup. + * + * Uses a per-backend cache that is invalidated when the global generation + * counter changes. No LWLock acquisition needed in the common case. + */ +uint64 +FluxGetOldestActiveTimestamp(void) +{ + uint32 current_gen; + + if (FluxMvccShmem == NULL) + elog(ERROR, "FLUX MVCC not initialized"); + + /* Fast path: check if our cached value is still valid */ + current_gen = pg_atomic_read_u32(&FluxMvccShmem->oldest_active_generation); + if (current_gen == my_oldest_active_gen && my_oldest_active_cached != 0) + return my_oldest_active_cached; + + /* Slow path: rescan all per-backend slots (lockless) */ + { + uint64 oldest = 0; + int i; + + pg_read_barrier(); + + for (i = 0; i < FluxMvccShmem->num_xact_slots; i++) + { + uint64 ts = FluxMvccShmem->xact_start_ts_slots[i]; + + if (ts != 0 && (oldest == 0 || ts < oldest)) + oldest = ts; + } + + if (oldest == 0) + oldest = (uint64) GetCurrentTimestamp(); + + /* Update the shared cached value (benign race with other backends) */ + FluxMvccShmem->oldest_active_ts = oldest; + + /* Cache locally */ + my_oldest_active_cached = oldest; + my_oldest_active_gen = current_gen; + + return oldest; + } +} + +/* + * Get MVCC statistics + */ +void +FluxGetMvccStats(uint64 *current_ts, uint64 *oldest_ts, int *active_xacts) +{ + if (FluxMvccShmem == NULL) + { + *current_ts = 0; + *oldest_ts = 0; + *active_xacts = 0; + return; + } + + *current_ts = (uint64) GetCurrentTimestamp(); + *oldest_ts = FluxMvccShmem->oldest_active_ts; + *active_xacts = (int) pg_atomic_read_u32(&FluxMvccShmem->active_xact_count); +} + +/* + * Shared memory exit cleanup + */ +static void +FluxShmemExit(int code, Datum arg) +{ + FluxCleanupTransactionState(); +} + + +/* + * FluxTupleHasCommittedUpdateAfter -- fork-driven lost-update conflict probe. + * + * A FLUX in-place UPDATE stamps a trailing verptr on the new on-page image + * (WS-PVS1) pointing at the UNDO-fork record it just wrote (WS-PVS3 PVS). + * That verptr is the head of the tuple's version chain: the record it + * refers to describes the update that PRODUCED the current on-page image, + * so its urec_xid IS the last committer of this tuple. Older commits have + * already been absorbed into the on-page bytes we are about to overwrite; + * they are not lost-update candidates. A single-step probe is therefore + * sufficient. + * + * Conflict iff: + * 1) the head verptr resolves (RelUndoReadRecordHeader returns true), and + * 2) urec_xid is invisible to snapshot (XidInMVCCSnapshot returns true), + * and + * 3) urec_xid is not our own xid, and + * 4) urec_xid did commit (guarding against in-progress/aborted xids that + * the writer has yet to reach the wait path for). + * + * If RelUndoReadRecordHeader returns false the record was discarded -- + * WS-PVS4's oldest_xmin discard gate guarantees the committer xid then + * precedes every live snapshot's xmin, so it is visible to every reader and + * cannot be a lost-update candidate. Safe terminator. + * + * *out_head_verptr and *out_head_xid receive the observed (verptr, xid) + * identity so the caller can pass them to FluxEpqReconcileMark before + * returning TM_Updated, and to FluxEpqReconcileMatches to skip a + * previously-bounced marker. + */ +bool +FluxTupleHasCommittedUpdateAfter(Relation rel, + const FluxTupleHeader *tuple, + Size tuple_len, + Snapshot snapshot, + TransactionId exclude_xid, + RelUndoRecPtr *out_head_verptr, + TransactionId *out_head_xid, + bool *out_inprogress) +{ + RelUndoRecPtr head; + RelUndoRecordHeader hdr; + + if (out_head_verptr != NULL) + *out_head_verptr = InvalidRelUndoRecPtr; + if (out_head_xid != NULL) + *out_head_xid = InvalidTransactionId; + if (out_inprogress != NULL) + *out_inprogress = false; + + if (snapshot == NULL || !IsMVCCSnapshot(snapshot)) + return false; + + head = FluxTupleGetVersionPtr(tuple, tuple_len); + if (!RelUndoRecPtrIsValid(head)) + return false; /* never updated -- no committed conflict */ + + if (!RelUndoReadRecordHeader(rel, head, &hdr)) + return false; /* discarded -> visible to every snapshot */ + + if (out_head_verptr != NULL) + *out_head_verptr = head; + if (out_head_xid != NULL) + *out_head_xid = hdr.urec_xid; + + if (TransactionIdIsValid(exclude_xid) && + TransactionIdEquals(hdr.urec_xid, exclude_xid)) + return false; /* our own update, not a conflict */ + + if (!TransactionIdIsValid(hdr.urec_xid)) + return false; + + /* + * XidInMVCCSnapshot(xid, snap) returns true iff the xid is NOT visible to + * snap -- i.e. in-progress or later than the snapshot's xmax. A conflict + * requires that the head committer be invisible to the writer's snapshot + * AND actually committed (an in-progress or aborted xid is handled by the + * writer's dirty-xid / abort path, not here). + */ + if (!XidInMVCCSnapshot(hdr.urec_xid, snapshot)) + return false; + + /* + * The head committer is invisible to our snapshot and is not our own xid. + * Normally it must have committed to be a lost-update conflict. But there + * is a commit-visibility window: the committer's PRE_COMMIT callback + * removes its in-progress sLog marker and clears the on-page UNCOMMITTED + * flag BEFORE RecordTransactionCommit() marks CLOG and BEFORE + * ProcArrayEndTransaction() clears it from the running list. In that + * window TransactionIdDidCommit() is still false while + * TransactionIdIsInProgress() is still true, and the sLog marker the + * writer-wait path keys off is already gone. If we returned "no conflict" + * here, a concurrent updater would clobber the just-committed value with + * no 40001 / EPQ -- a lost update. Signal the caller to wait on the + * in-flight head_xid (heap semantics: see live modifier -> wait -> re-check + * CLOG) rather than treating not-yet-committed as no-conflict. + */ + if (!TransactionIdDidCommit(hdr.urec_xid)) + { + if (out_inprogress != NULL && + TransactionIdIsInProgress(hdr.urec_xid)) + *out_inprogress = true; + return false; + } + + return true; +} + +/* + * FluxEpqReconcileMatches -- true iff we already bounced this exact + * (relid, tid, cid, head_verptr, head_xid) marker to EvalPlanQual. + * + * A strictly-newer committer stamps a NEW UNDO record with a fresh + * (blkno, offset, counter) triple, so an unchanged head verptr and xid + * imply the observed marker is the identical one EPQ already reconciled. + * Physical identity, no temporal ordering. + */ +bool +FluxEpqReconcileMatches(Snapshot snapshot, Oid relid, ItemPointer tid, + RelUndoRecPtr head_verptr, TransactionId head_xid) +{ + return MyFluxXactState != NULL && + TransactionIdIsValid(MyFluxXactState->epq_reconcile_xid) && + MyFluxXactState->epq_reconcile_relid == relid && + MyFluxXactState->epq_reconcile_cid == snapshot->curcid && + ItemPointerEquals(&MyFluxXactState->epq_reconcile_tid, tid) && + MyFluxXactState->epq_reconcile_verptr == head_verptr && + TransactionIdEquals(MyFluxXactState->epq_reconcile_xid, head_xid); +} + +/* + * FluxEpqReconcileMark -- record the (verptr, xid) marker identity we are + * bouncing to EvalPlanQual so the retry does not re-report it. + */ +void +FluxEpqReconcileMark(Snapshot snapshot, Oid relid, ItemPointer tid, + RelUndoRecPtr head_verptr, TransactionId head_xid) +{ + if (MyFluxXactState == NULL) + FluxInitTransactionState(); + + MyFluxXactState->epq_reconcile_relid = relid; + ItemPointerCopy(tid, &MyFluxXactState->epq_reconcile_tid); + MyFluxXactState->epq_reconcile_cid = snapshot->curcid; + MyFluxXactState->epq_reconcile_verptr = head_verptr; + MyFluxXactState->epq_reconcile_xid = head_xid; +} + +/* + * FluxTupleSatisfiesMVCC -- heap-compatible xmin/xmax visibility check. + * + * This is the single visibility function for FLUX's heap-shaped MVCC model. + * It mirrors HeapTupleSatisfiesMVCC: a tuple is visible to `snapshot` iff its + * inserter (t_xmin) is committed-and-visible-to-the-snapshot AND its + * deleter/updater (t_xmax) is invalid, not committed, or not visible to the + * snapshot. CLOG is the authoritative commit-status oracle; the snapshot's + * xmin/xmax/xip decide visibility of committed XIDs. The sLog is consulted + * ONLY for the command-id (cid) of the current transaction's own uncommitted + * work, which FLUX stores there instead of in the tuple header. + * + * The pre-commit "dirty read" window closes for free here: a reader resolves + * t_xmin against CLOG, and CLOG marks a transaction committed only at the + * durability point (RecordTransactionCommit flushes the commit record before + * ProcArrayEndTransaction / TransactionIdDidCommit reports true for a + * crash-safe reader path). A not-yet-committed inserter -- which includes a + * not-yet-durable one -- is simply invisible. No HLC, no flux_cts durable + * map, no timestamp rewind. + * + * `curcid` is the reader's command id for MVCC snapshots (InvalidCommandId + * for SELF/ANY, which see all of the current transaction's work). + */ + +/* + * FluxSetHintBits -- cache a CLOG commit result on the tuple as a hint bit. + * + * Mirrors heap's SetHintBits: once TransactionIdDidCommit() has confirmed a + * tuple's xmin (or xmax) committed, record it in t_flags so subsequent + * visibility checks skip the CLOG SLRU lookup entirely. This is the + * optimization that keeps a hot-set scan off the CLOG buffer LWLock (the + * profiled cause of FLUX's post-HLC write-scaling regression: without it, + * every tuple examined by every scan did a TransactionIdDidCommit -> CLOG + * lookup, serializing on the CLOG buffer lock). Non-WAL hint write: the + * flag is reconstructible from CLOG, so a torn/lost hint is harmless. + */ +static inline void +FluxSetHintBits(FluxTupleHeader *tuple, Buffer buffer, uint16 hint) +{ + /* + * Cache the CLOG result as a hint bit, but ONLY when we hold the buffer + * content lock: BufferSetHintBits16 (like MarkBufferDirtyHint) errors on an + * unlocked buffer, and FluxTupleSatisfiesMVCC is reached from some paths + * (post-VACUUM reads, replication reads, detached-tuple checks) where the + * buffer is pinned but not lock-held. If we can't safely set the shared + * hint, just skip it -- the result is still correct, the CLOG lookup simply + * isn't cached on this call; the next scan holding the lock will cache it. + * Setting the in-memory bit without the lock would race a concurrent + * writer's t_flags update, so we do nothing rather than a bare |=. + */ + if (BufferIsValid(buffer) && BufferIsLockedByMe(buffer)) + BufferSetHintBits16(&tuple->t_flags, + tuple->t_flags | hint, buffer); +} + +static bool +FluxTupleSatisfiesMVCC(FluxTupleHeader *tuple, Snapshot snapshot, + Oid relid, CommandId curcid, Buffer buffer) +{ + TransactionId xmin; + TransactionId xmax; + + if (tuple == NULL) + return false; + + xmin = FluxTupleGetXmin(tuple); + xmax = FluxTupleGetXmax(tuple); + + /* + * SnapshotAny / non-MVCC "see everything": show every non-deleted tuple. + * A committed-and-visible xmax still hides it (the row is gone), but for + * SnapshotAny the caller wants raw existence, so only the DELETED flag + * matters. Callers that need SNAPSHOT_DIRTY semantics handle that + * separately in flux_handler.c before reaching here. + */ + if (!IsMVCCSnapshot(snapshot)) + return !(tuple->t_flags & FLUX_TUPLE_DELETED) || + !TransactionIdIsValid(xmax); + + /* ---- xmin side: is the inserter committed-and-visible? ---- */ + if (!TransactionIdIsValid(xmin)) + return false; /* no inserter -- cannot be visible */ + + if (TransactionIdIsCurrentTransactionId(xmin)) + { + /* + * Inserted by our own transaction. Visible unless: (a) it was + * inserted by a later command (curcid ordering), or (b) the + * subtransaction that inserted it was rolled back to a savepoint -- + * FLUX records that as an SLOG_OP_ABORTED entry on the tuple's TID + * (FLUX stamps t_xmin with the TOP xid, so TransactionIdIsCurrent is + * still true for a rolled-back subxact's tuple; the sLog ABORTED marker + * is the authoritative subxact-abort signal). Consult the sLog while + * the insert is still uncommitted; a committed self-insert from an + * earlier command is unconditionally visible. + */ + if (tuple->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + SLogTupleOp e[SLOG_MAX_TUPLE_OPS]; + int n = SLogTupleLookupFiltered(relid, &tuple->t_ctid, + InvalidTransactionId, + e, SLOG_MAX_TUPLE_OPS); + int i; + + for (i = 0; i < n; i++) + { + /* Savepoint rollback marked this op aborted -> invisible. */ + if (e[i].op_type == SLOG_OP_ABORTED) + return false; + /* Our own later-command insert is not yet visible. */ + if (e[i].op_type == SLOG_OP_INSERT && + TransactionIdEquals(e[i].xid, xmin) && + curcid != InvalidCommandId && e[i].cid >= curcid) + return false; + } + } + /* our insert, visible so far -- fall through to xmax check */ + } + else if (XidInMVCCSnapshot(xmin, snapshot)) + return false; /* inserter not yet visible to snapshot */ + else if (tuple->t_flags & FLUX_TUPLE_XMIN_COMMITTED) + { + /* CLOG hint already set: inserter is committed, skip the CLOG lookup */ + } + else if (!TransactionIdDidCommit(xmin)) + return false; /* inserter aborted / in-flight-not-durable */ + else + { + /* + * Inserter committed. Cache it as a hint bit (like heap's + * HEAP_XMIN_COMMITTED) so subsequent visibility checks skip the CLOG + * SLRU lookup -- this is what keeps a hot-set scan off the CLOG buffer + * LWLock. Non-WAL hint write via MarkBufferDirtyHint. + */ + FluxSetHintBits(tuple, buffer, FLUX_TUPLE_XMIN_COMMITTED); + } + + /* Inserter is committed-and-visible. Now the xmax (deleter) side. */ + + if (!TransactionIdIsValid(xmax) || + !(tuple->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_UPDATED))) + { + /* + * No deleter, or the tuple carries no delete/supersede marker: the + * on-page image is live for this snapshot. + * + * NOTE (in-place-UPDATE / zheap read path): when this tuple was + * updated in place, the on-page image is the NEWEST version and its + * xmin is the updater. If that updater is invisible to `snapshot`, + * the xmin check above already returned false, and the caller + * (flux_handler.c) reconstructs the older visible version from the + * UNDO fork via FluxReconstructVisibleVersion(). So an old snapshot + * never sees the wrong (too-new) bytes: it is either hidden here and + * reconstructed, or the updater is visible and the new image is + * correct. + */ + return true; + } + + /* There is a deleter/updater xmax; resolve it heap-style. */ + if (TransactionIdIsCurrentTransactionId(xmax)) + { + /* + * Deleted/superseded by our own transaction. Invisible to us unless + * the delete happened in a later command (then we still see the row). + * Consult the sLog for the delete cid while it is uncommitted. + */ + if (curcid != InvalidCommandId && + (tuple->t_flags & FLUX_TUPLE_UNCOMMITTED)) + { + SLogTupleOp e[SLOG_MAX_TUPLE_OPS]; + int n = SLogTupleLookupFiltered(relid, &tuple->t_ctid, + xmax, e, SLOG_MAX_TUPLE_OPS); + int i; + + for (i = 0; i < n; i++) + { + if ((e[i].op_type == SLOG_OP_DELETE || + e[i].op_type == SLOG_OP_UPDATE) && e[i].cid >= curcid) + return true; /* deleted after our scan started */ + } + } + return false; /* our own delete, visible to this command */ + } + + if (XidInMVCCSnapshot(xmax, snapshot)) + return true; /* deleter not yet visible -- row still here */ + + if (tuple->t_flags & FLUX_TUPLE_XMAX_COMMITTED) + return false; /* CLOG hint: deleter committed -- gone */ + + if (!TransactionIdDidCommit(xmax)) + return true; /* deleter aborted / in-flight -- row still here */ + + /* deleter committed-and-visible -- gone. Cache the CLOG result. */ + FluxSetHintBits(tuple, buffer, FLUX_TUPLE_XMAX_COMMITTED); + return false; +} + +/* + * FluxTupleVisibleToSnapshotDual -- primary visibility entry point. + * + * Formerly routed to HLC vs legacy timestamp visibility; FLUX now uses one + * heap-compatible xmin/xmax model, so this just forwards to + * FluxTupleSatisfiesMVCC. The name and signature are kept because ~35 call + * sites route through here. + */ +bool +FluxTupleVisibleToSnapshotDual(FluxTupleHeader *tuple, Snapshot snapshot, + Oid relid, Buffer buffer) +{ + /* + * Only apply CID filtering for MVCC snapshots. SNAPSHOT_SELF and + * SNAPSHOT_ANY must see all of the current transaction's work. + */ + return FluxTupleSatisfiesMVCC(tuple, snapshot, relid, + (snapshot != NULL && + snapshot->snapshot_type == SNAPSHOT_MVCC) + ? snapshot->curcid : InvalidCommandId, + buffer); +} + +/* + * FluxGetOldestXminHorizon -- XID retention horizon for VACUUM/prune. + * + * Heap-shaped replacement for the former HLC oldest_snapshot_hlc horizon. A + * committed-deleted tuple whose deleter (t_xmax) precedes this horizon is + * invisible to every current and future snapshot and can be physically + * removed. Uses the standard non-removable-transaction horizon so it tracks + * the cluster's oldest snapshot xmin exactly like heap VACUUM. + */ +TransactionId +FluxGetOldestXminHorizon(Relation rel) +{ + return GetOldestNonRemovableTransactionId(rel); +} + +/* + * FluxTupleDeadToAll -- true iff a committed-deleted tuple can be reclaimed. + * + * Heap-shaped: the tuple must carry a committed DELETE marker (DELETED set, + * UNCOMMITTED clear) whose deleter XID (t_xmax) is committed and older than + * the supplied oldest-xmin horizon, so no active or future snapshot can see + * it. Replaces the old `t_commit_ts < oldest_ts` timestamp comparison, which + * is meaningless now that the t_commit_ts word holds an XID, not a timestamp. + */ +bool +FluxTupleDeadToAll(FluxTupleHeader *tuple, TransactionId oldest_xmin) +{ + TransactionId xmax; + + if (!(tuple->t_flags & FLUX_TUPLE_DELETED)) + return false; + if (tuple->t_flags & FLUX_TUPLE_UNCOMMITTED) + return false; + + xmax = FluxTupleGetXmax(tuple); + if (!TransactionIdIsValid(xmax)) + return false; + if (!TransactionIdDidCommit(xmax)) + return false; /* delete aborted / in flight: keep */ + + return TransactionIdPrecedes(xmax, oldest_xmin); +} diff --git a/src/backend/access/flux/flux_operations.c b/src/backend/access/flux/flux_operations.c new file mode 100644 index 0000000000000..c90e78a25eeed --- /dev/null +++ b/src/backend/access/flux/flux_operations.c @@ -0,0 +1,7423 @@ +/*------------------------------------------------------------------------- + * + * flux_operations.c + * FLUX table manipulation operations + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_operations.c + * + * NOTES + * This implements the remaining table manipulation operations for + * FLUX storage manager including insert, update, delete, and + * various DDL operations. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/genam.h" +#include "access/heapam.h" +#include "access/flux.h" +#include "access/flux_dirtymap.h" +#include "access/slog.h" +#include "access/twophase.h" +#include "access/twophase_rmgr.h" +#include "access/flux_undo.h" +#include "access/flux_xlog.h" +#include "access/tableam.h" +#include "access/undobuffer.h" +#include "access/xlog.h" +#include "access/tidstore.h" +#include "access/xact.h" +#include "access/xactundo.h" +#include "access/xloginsert.h" +#include "catalog/catalog.h" +#include "catalog/index.h" +#include "catalog/storage.h" +#include "commands/vacuum.h" +#include "executor/executor.h" +#include "pgstat.h" +#include "storage/bufmgr.h" +#include "storage/freespace.h" +#include "storage/latch.h" +#include "storage/read_stream.h" +#include "storage/lmgr.h" +#include "storage/predicate.h" +#include "storage/procarray.h" +#include "storage/smgr.h" +#include "utils/builtins.h" +#include "utils/datum.h" +#include "utils/injection_point.h" +#include "utils/rel.h" +#include "utils/timestamp.h" +#include "utils/wait_event.h" +#include "storage/bufpage.h" +#include "miscadmin.h" + +/* + * Maximum overflow pointers per tuple for VACUUM overflow cleanup. + * This limits memory usage during VACUUM and is conservative since most + * tuples won't have overflow data. 128 is sufficient for typical workloads. + */ +#define MAX_OVERFLOW_PTRS_PER_TUPLE 128 + +/* Function prototypes for locking */ +extern bool FluxLockTuple(Relation rel, ItemPointer tid, LockTupleMode mode, + bool wait, bool *have_tuple_lock); +extern void FluxUnlockTuple(Relation rel, ItemPointer tid, LockTupleMode mode); +extern void FluxLockPage(Relation rel, BlockNumber blkno, LOCKMODE mode); +extern void FluxUnlockPage(Relation rel, BlockNumber blkno, LOCKMODE mode); + +/* sLog transaction callback prototypes */ +static void FluxSLogXactCallback(XactEvent event, void *arg); +static void FluxSLogSubXactCallback(SubXactEvent event, + SubTransactionId mySubid, + SubTransactionId parentSubid, + void *arg); + + +/* + * In-place update statistics counters. + * + * These track the effectiveness of FLUX's in-place update optimization + * across the lifetime of the backend. They are exposed via + * FluxGetUpdateStats() for monitoring. + */ +static int64 flux_stat_in_place_updates = 0; +static int64 flux_stat_out_of_place_updates = 0; +static int64 flux_stat_defrag_triggered_updates = 0; + +/* Whether sLog transaction callbacks have been registered for this backend */ +static bool flux_slog_callbacks_registered = false; + +/* + * GUC: skip commit-time page re-visits (lazy clear of UNCOMMITTED flags). + * + * Default off. Clearing FLUX_TUPLE_UNCOMMITTED is a pure hint (commit + * visibility comes from CLOG via heap-shaped xmin/xmax); the lazy path clears + * the flag at the next visibility check, while the eager path visits each + * modified page at PRE_COMMIT so later readers can skip the sLog fast-path + * lookup. + */ +bool flux_lazy_uncommitted_clear = false; + +/* + * FluxGetUpdateStats - Return in-place update statistics + * + * Fills in the provided counters with the current backend-local statistics. + */ +void +FluxGetUpdateStats(int64 *in_place, int64 *out_of_place, int64 *defrag_triggered) +{ + if (in_place) + *in_place = flux_stat_in_place_updates; + if (out_of_place) + *out_of_place = flux_stat_out_of_place_updates; + if (defrag_triggered) + *defrag_triggered = flux_stat_defrag_triggered_updates; +} + + +/* + * FluxPagePruneOpt -- opportunistic dead-tuple cleanup on a page. + * + * This is the FLUX equivalent of heap_page_prune_opt(). It is called + * during normal DML operations (insert, update) and sequential scans + * when a page looks like it might benefit from cleanup. The goal is to + * reclaim space from deleted tuples without waiting for VACUUM. + * + * The caller must hold a pin on the buffer but must NOT hold a lock on it. + * We attempt a conditional (non-blocking) exclusive lock; if we cannot + * get it, we return immediately -- this is best-effort cleanup. + * + * Returns the number of tuples pruned. + */ +int +FluxPagePruneOpt(Relation relation, Buffer buffer) +{ + Page page; + FluxPageOpaque opaque; + OffsetNumber offnum; + OffsetNumber maxoff; + uint64 oldest_ts; + TransactionId oldest_xmin_prune; + int ndead = 0; + Size minfree; + + /* Cannot write WAL during recovery, so skip */ + if (RecoveryInProgress()) + return 0; + + page = BufferGetPage(buffer); + + /* Skip if page is not initialized */ + if (PageIsNew(page)) + return 0; + + /* + * Validate page header before accessing special space. During recovery or + * after crashes, pages may have invalid headers. Skip pruning if the page + * header looks corrupt. + */ + { + PageHeader phdr = (PageHeader) page; + + if (phdr->pd_special < SizeOfPageHeaderData || + phdr->pd_special > BLCKSZ) + return 0; + } + + /* + * Quick check without lock: does the page look like it needs pruning? The + * FLUX_PAGE_DEFRAG_NEEDED flag is set by delete and update operations + * when a tuple is marked as deleted. If no deletions have occurred on + * this page, there is nothing to clean up. + */ + opaque = FluxPageGetOpaque(page); + if (!(FluxPageGetFlags(opaque) & FLUX_PAGE_DEFRAG_NEEDED)) + return 0; + + /* + * Heuristic: only prune if the page's free space is below a threshold. + * This avoids spending cycles on pages that already have plenty of room. + * We use 10% of BLCKSZ as the minimum, matching heap's approach. Reading + * pd_lower/pd_upper without a lock is slightly racy but acceptable for a + * heuristic. + */ + minfree = BLCKSZ / 10; + if (PageGetFreeSpace(page) >= minfree && + !(FluxPageGetFlags(opaque) & FLUX_PAGE_FULL)) + return 0; + + /* + * Try to get an exclusive lock without blocking. If the page is busy, + * skip it -- we will get another chance later. + */ + if (!ConditionalLockBufferForCleanup(buffer)) + return 0; + + /* + * Re-check under lock: the page state may have changed while we were + * acquiring the lock (or another backend may have pruned it). + */ + page = BufferGetPage(buffer); + opaque = FluxPageGetOpaque(page); + if (!(FluxPageGetFlags(opaque) & FLUX_PAGE_DEFRAG_NEEDED)) + { + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + return 0; + } + + /* + * oldest_xmin_prune is the reclamation gate (XID horizon, FluxTupleDeadToAll + * below). oldest_ts is NOT a decision input here: it is carried only into + * the DEFRAG WAL record's cosmetic page commit-ts word (FluxXLogDefrag -> + * FluxPageSetCommitTs). + */ + oldest_ts = FluxGetOldestActiveTimestamp(); + oldest_xmin_prune = FluxGetOldestXminHorizon(relation); + maxoff = PageGetMaxOffsetNumber(page); + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(page, offnum); + FluxTupleHeader *tuple_hdr; + + if (!ItemIdIsNormal(itemid)) + continue; + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(PageGetItem(page, itemid), + ItemIdGetLength(itemid))) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * A deleted tuple can be pruned if: - UNCOMMITTED is NOT set (the + * inserting/deleting xact committed) - commit_ts is older than the + * oldest active snapshot + * + * If UNCOMMITTED is still set, the transaction is still in progress + * (or aborted but not yet cleaned up) -- skip it. + */ + if (FluxTupleDeadToAll(tuple_hdr, oldest_xmin_prune)) + { + ndead++; + } + } + + if (ndead == 0) + { + /* + * No reclaimable dead tuples. Clear the defrag flag so we don't + * recheck this page on every access until a new deletion occurs. + */ + FluxPageClearFlag(opaque, FLUX_PAGE_DEFRAG_NEEDED); + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + return 0; + } + + /* + * We have reclaimable dead tuples. Mark them LP_DEAD (reclaiming their + * storage) and defragment the page to compact free space. + * + * CRITICAL: opportunistic pruning must set LP_DEAD, never LP_UNUSED. A + * deleted FLUX tuple may still be referenced by index entries that only + * VACUUM (after index bulk-delete) is allowed to remove. LP_DEAD reclaims + * the tuple's storage while reserving its line pointer so the TID cannot be + * recycled by a later INSERT (PageAddItemExtended only reuses LP_UNUSED + * slots). Recycling a TID whose index entries still exist would let an + * index scan return the unrelated tuple later placed in that slot. Only + * VACUUM Phase III converts LP_DEAD -> LP_UNUSED, after Phase II has removed + * the corresponding index entries. This mirrors heap pruning, which sets + * LP_DEAD and defers LP_UNUSED to post-index-cleanup VACUUM. + */ + START_CRIT_SECTION(); + + maxoff = PageGetMaxOffsetNumber(page); + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(page, offnum); + FluxTupleHeader *tuple_hdr; + + if (!ItemIdIsNormal(itemid)) + continue; + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(PageGetItem(page, itemid), + ItemIdGetLength(itemid))) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + if (FluxTupleDeadToAll(tuple_hdr, oldest_xmin_prune)) + { + ItemIdSetDead(itemid); + } + } + + FluxPageDefragment(page); + + MarkBufferDirty(buffer); + + /* WAL-log the defragmentation using a proper defrag record */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + + /* + * Use FluxXLogDefrag (not FluxXLogInitPage). INIT_PAGE with + * REGBUF_WILL_INIT would zero the page during redo, losing all live + * tuples. The defrag record uses REGBUF_STANDARD which stores a Full + * Page Image, preserving the page contents. + */ + recptr = FluxXLogDefrag(relation, buffer, NULL, 0, oldest_ts); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + /* Update FSM with the reclaimed free space */ + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buffer), + PageGetFreeSpace(page)); + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + + return true; +} + + +/* + * Insert a tuple into a FLUX table + */ +void +flux_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid, + uint32 options, BulkInsertState bistate) +{ + FluxTuple flux_tuple; + Buffer buffer; + Page page; + OffsetNumber offnum; + BlockNumber target_block; + Size tuple_size; + ItemPointer tid = &slot->tts_tid; + uint64 current_ts; + FluxOverflowBuffers overflow_buffers; + FluxLogicalImage insert_logical_img; + Buffer undo_buffer = InvalidBuffer; + RelUndoRecPtr undo_ptr = InvalidRelUndoRecPtr; + Size saveFreeSpace; + int i; + Datum toast_values[MaxTupleAttributeNumber]; + bool toast_isnull[MaxTupleAttributeNumber]; + ToastAttrInfo toast_attr[MaxTupleAttributeNumber]; + ToastTupleContext ttc; + bool toasted = false; + + slot_getallattrs(slot); + + /* + * Get current timestamp for the page-level commit-ts bookkeeping word. + * + * IMPORTANT: Get transaction timestamp here, BEFORE entering critical + * section, because FluxGetTransactionTimestamp() may need to allocate + * memory to initialize transaction state. + * + * CRITICAL FIX: Use FluxGetTransactionTimestamp() for both current_ts + * and xact_ts to ensure consistency. The inserted tuple will be visible + * within the same transaction because the snapshot timestamp will match + * the tuple's commit timestamp. + */ + (void) FluxGetTransactionTimestamp(); + + current_ts = (uint64) FluxGetDmlTimestamp(); + + /* + * Create FLUX tuple from slot. Use the overflow-aware variant which + * will store large attributes (> FLUX_OVERFLOW_THRESHOLD) in overflow + * records on normal data pages, replacing them with compact inline + * overflow pointers. + * + * Overflow buffers are kept pinned for atomic WAL logging inside the + * critical section below. + */ + overflow_buffers.count = 0; + + /* + * FLUX has no on-page overflow: TOAST any wide varlena columns into the + * relation's standard heap TOAST table before forming the tuple, exactly + * like heap. On return toast_values[] holds the (possibly externalized) + * datums that FluxFormTuple stores verbatim. + */ + memcpy(toast_values, slot->tts_values, + RelationGetDescr(relation)->natts * sizeof(Datum)); + memcpy(toast_isnull, slot->tts_isnull, + RelationGetDescr(relation)->natts * sizeof(bool)); + flux_toast_tuple(relation, toast_values, toast_isnull, NULL, NULL, + &ttc, toast_attr, &toasted, 0); + + flux_tuple = FluxFormTuple(RelationGetDescr(relation), + toast_values, + toast_isnull, + relation, + &overflow_buffers); + + /* Set MVCC fields (heap-shaped xmin/xmax) */ + flux_tuple->t_data->t_commit_ts = 0; /* t_xmax = InvalidTransactionId */ + + /* + * Stamp the inserter XID as t_xmin. Visibility resolves t_xmin against + * CLOG + the reader's snapshot (FluxTupleSatisfiesMVCC), so an + * uncommitted insert is automatically invisible to other snapshots. The + * FLUX_TUPLE_UNCOMMITTED flag is kept for the sLog write-conflict and + * defrag paths, but it no longer drives read visibility. + */ + flux_tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + flux_tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, so savepoint rollback marks it aborted in CLOG */ + + /* + * WS-PVS1: the version-chain head lives in the fixed header field + * t_verptr (counted by FLUX_TUPLE_OVERHEAD), so every tuple carries it + * from birth with no on-page growth. Stamp InvalidRelUndoRecPtr here; the + * first UPDATE replaces it with a real chain head. Readers treat an + * invalid head as "no history" (flux_pvs.c), so a never-updated row is + * unaffected. Because the slot width never changes on UPDATE, the first + * UPDATE is always a same-length overwrite (Strategy 1 / CAS fast path). + */ + flux_tuple->t_data->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(flux_tuple->t_data, flux_tuple->t_len, + InvalidRelUndoRecPtr); + tuple_size = flux_tuple->t_len; + + /* Ensure relation storage exists */ + RelationGetSmgr(relation); + + /* + * Find a page with enough free space. + * + * Like heap (RelationGetBufferForTuple), we first try the page we last + * inserted into, cached per-relation in the relcache via + * RelationSetTargetBlock(). Only if we have no cached target do we ask + * the FSM. This avoids an fsm_search() + RecordPageWithFreeSpace() on + * every append: on an append-heavy table (e.g. pgbench_history) every + * backend otherwise pounds the same tail FSM pages, saturating the FSM + * buffer-header spinlock (LockBufHdr) at high concurrency. The cache is + * only a hint -- a stale or too-full target just falls through to the FSM + * below and to the have_page free-space recheck. + * + * The cached-target fast path is used only when the tuple has no overflow + * data: with overflow, FluxFormTupleWithOverflow has already chosen and + * locked pages and the target_block/overflow-buffer interplay below + * assumes the FSM was consulted, so that path stays on the FSM. + * + * Account for fill factor: reserve space for future in-place updates. + */ + saveFreeSpace = RelationGetTargetPageFreeSpace(relation, + FLUX_DEFAULT_FILLFACTOR); + { + target_block = InvalidBlockNumber; + if (overflow_buffers.count == 0) + target_block = RelationGetTargetBlock(relation); + + if (target_block == InvalidBlockNumber) + target_block = FluxGetPageWithFreeSpace(relation, + tuple_size + saveFreeSpace); + } + + if (target_block == InvalidBlockNumber) + { + /* Clean up overflow buffers before throwing error */ + for (i = 0; i < overflow_buffers.count; i++) + { + UnlockReleaseBuffer(overflow_buffers.buffers[i].buffer); + pfree(overflow_buffers.buffers[i].record_data); + } + elog(ERROR, "FLUX failed to allocate page for tuple insertion"); + } + + /* + * Pre-allocate WAL buffer space BEFORE acquiring the data buffer lock. + * XLogEnsureRecordSpace() may allocate memory, so it MUST be called + * outside the critical section. + * + * rdata slots needed: MAX_OVERFLOW_BUFFERS * 2 (header + data per + * overflow record) + 2 (xl_flux_insert header + tuple data) + */ + if (RelationNeedsWAL(relation)) + XLogEnsureRecordSpace(XLR_MAX_BLOCK_ID, 3 + MAX_OVERFLOW_BUFFERS * 2); + + /* + * Check if target_block is already locked in overflow_buffers from + * FluxFormTupleWithOverflow. If FSM returns the same block for both + * overflow storage and main tuple storage, we must reuse that buffer to + * avoid double-locking. + */ + buffer = InvalidBuffer; + for (i = 0; i < overflow_buffers.count; i++) + { + if (BufferGetBlockNumber(overflow_buffers.buffers[i].buffer) == target_block) + { + buffer = overflow_buffers.buffers[i].buffer; + break; + } + } + + /* + * Read and lock the target page only if we don't already have it locked + * from overflow processing. + */ + if (!BufferIsValid(buffer)) + { + buffer = ReadBuffer(relation, target_block); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + } + page = BufferGetPage(buffer); + + /* + * Verify the page has room for the tuple PLUS the fillfactor reservation, + * so in-place UPDATE growth stays possible (heap applies the same + * targetFreeSpace = len + saveFreeSpace bar). A cached target block or a + * stale FSM answer that lacks the slack falls through to FSM retry / + * relation extension below. + */ + if (PageGetFreeSpace(page) < tuple_size + saveFreeSpace) + { + bool buffer_is_from_overflow = false; + + /* + * Check if this buffer is from overflow_buffers. If so, we must NOT + * unlock it for pruning, as overflow_buffers expects all its buffers + * to remain locked until the critical section. + */ + for (i = 0; i < overflow_buffers.count; i++) + { + if (overflow_buffers.buffers[i].buffer == buffer) + { + buffer_is_from_overflow = true; + break; + } + } + + /* + * Page doesn't have enough space. Try opportunistic pruning to + * reclaim space from dead tuples before falling back to the FSM. We + * must release our lock first since FluxPagePruneOpt() takes its own + * conditional lock. + * + * IMPORTANT: Skip pruning if buffer is from overflow_buffers, as we + * must keep those buffers locked. + */ + if (!buffer_is_from_overflow) + { + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + if (FluxPagePruneOpt(relation, buffer)) + { + /* Pruning freed space -- re-lock and check again */ + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + if (PageGetFreeSpace(page) >= tuple_size + saveFreeSpace) + goto have_page; + /* Still not enough after pruning, fall through to FSM retry */ + } + + /* + * FSM information was stale or pruning didn't help. Update and + * retry. + */ + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + FluxRecordFreeSpace(relation, target_block, PageGetFreeSpace(page)); + UnlockReleaseBuffer(buffer); + } + else + { + /* + * Buffer is from overflow_buffers and can't be pruned or + * released. This means FSM returned an overflow page for the main + * tuple, which doesn't have enough space. This should be rare but + * can happen if overflow pages filled up during tuple formation. + * + * Update FSM for this page, then get a DIFFERENT page. We must + * retry until we find a page that's NOT in overflow_buffers. + */ + FluxRecordFreeSpace(relation, target_block, PageGetFreeSpace(page)); + } + + /* + * Retry with updated FSM, excluding blocks in overflow_buffers. Keep + * trying until we find a suitable page that we don't already have + * locked for overflow storage. + */ + for (;;) + { + target_block = FluxGetPageWithFreeSpace(relation, + tuple_size + saveFreeSpace); + if (target_block == InvalidBlockNumber) + { + /* Clean up overflow buffers before throwing error */ + for (i = 0; i < overflow_buffers.count; i++) + { + UnlockReleaseBuffer(overflow_buffers.buffers[i].buffer); + pfree(overflow_buffers.buffers[i].record_data); + } + elog(ERROR, "FLUX failed to allocate page for tuple insertion after retry"); + } + + /* + * Check if target_block is already locked in overflow_buffers. If + * so, skip it and try again - we need a different page. + */ + buffer = InvalidBuffer; + for (i = 0; i < overflow_buffers.count; i++) + { + if (BufferGetBlockNumber(overflow_buffers.buffers[i].buffer) == target_block) + { + /* + * This block is already used for overflow - mark FSM and + * retry + */ + FluxRecordFreeSpace(relation, target_block, 0); + buffer = InvalidBuffer; + break; + } + } + + /* + * If we found a block not in overflow_buffers, check if it has + * space + */ + if (i >= overflow_buffers.count) + { + buffer = ReadBuffer(relation, target_block); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + /* + * Verify the page actually has enough space. If not, update + * FSM and retry. + */ + if (PageGetFreeSpace(page) >= tuple_size + saveFreeSpace) + { + /* Found a suitable page - exit retry loop */ + break; + } + else + { + /* FSM was wrong - update it and retry */ + FluxRecordFreeSpace(relation, target_block, PageGetFreeSpace(page)); + UnlockReleaseBuffer(buffer); + buffer = InvalidBuffer; + /* Continue outer loop to try again */ + } + } + } + } + +have_page: + + /* + * SSI: check for rw-conflict in. An INSERT may conflict with a + * concurrent serializable transaction that holds a relation-level or + * page-level predicate lock (e.g., from a range scan that would have + * included this new tuple). Pass NULL tid since the tuple doesn't exist + * yet — only relation-level and page-level locks are checked. + */ + CheckForSerializableConflictIn(relation, NULL, BufferGetBlockNumber(buffer)); + + /* + * Ensure the current transaction has an XID assigned BEFORE entering the + * critical section. GetCurrentTransactionId() may call + * XactLockTableInsert() which acquires a lock and allocates memory -- + * both forbidden in a critical section. Most inserts already have an XID + * by now (assigned during the unique-index check), but inserting a NULL + * into a UNIQUE column skips that check, so the first assignment can + * otherwise land inside the crit section below. + * + * An assigned XID is also required for correctness: WAL records without + * an attached xid cannot be decoded into a logical replication stream + * (ReorderBuffer groups changes by xid and emits no commit record for + * InvalidTransactionId), and RecordTransactionCommit() would treat the + * transaction as read-only and skip the WAL flush. + */ + (void) GetCurrentTransactionId(); + + /* + * Final free-space check before entering the critical section. + * PageGetFreeSpace may have been optimistic (alignment, line pointer + * overhead). If the page can't actually fit the tuple, release it, + * update FSM, and extend the relation instead. This prevents the PANIC + * that would otherwise fire inside the critical section. + */ + offnum = FluxPageAddTuple(page, flux_tuple, tuple_size); + if (offnum == InvalidOffsetNumber) + { + /* Page too full despite FSM claim — record actual free space */ + FluxRecordFreeSpace(relation, BufferGetBlockNumber(buffer), + PageGetFreeSpace(page)); + UnlockReleaseBuffer(buffer); + + /* Extend the relation to get a guaranteed-empty page */ + buffer = ReadBuffer(relation, P_NEW); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + FluxInitPage(page, BufferGetPageSize(buffer)); + + offnum = FluxPageAddTuple(page, flux_tuple, tuple_size); + if (offnum == InvalidOffsetNumber) + elog(ERROR, "failed to add FLUX tuple to new empty page (tuple_size=%zu)", + (Size) tuple_size); + } + + /* + * Prepare the heap-format logical-decoding image before entering the + * critical section (it calls palloc/heap_form_tuple, which are forbidden + * inside a crit section). No-op unless the relation is logically logged. + */ + FluxXLogPrepareLogicalImage(relation, flux_tuple, &insert_logical_img); + + /* + * Per-relation UNDO: reserve space for an INSERT UNDO record before the + * critical section, because RelUndoReserve() may extend the UNDO fork and + * error out. This is done after acquiring the data buffer lock to keep a + * consistent lock ordering (data buffer -> UNDO buffer) with the UPDATE + * and DELETE paths. + */ + if (smgrexists(RelationGetSmgr(relation), RELUNDO_FORKNUM)) + undo_ptr = RelUndoReserve(relation, + SizeOfRelUndoRecordHeader + + sizeof(RelUndoInsertPayload), + &undo_buffer); + + /* NO EREPORT(ERROR) from here till changes are logged */ + START_CRIT_SECTION(); + + /* Set the tuple's TID */ + ItemPointerSet(tid, BufferGetBlockNumber(buffer), offnum); + flux_tuple->t_self = *tid; + slot->tts_tableOid = RelationGetRelid(relation); + /* + * Set the on-disk tuple's t_ctid to point to itself. This is needed for + * update chains and cross-page defragmentation, which check whether + * t_ctid == self to detect tuples that are not part of an update chain. + */ + { + ItemId inserted_itemid = PageGetItemId(page, offnum); + FluxTupleHeader *inserted_hdr = (FluxTupleHeader *) PageGetItem(page, inserted_itemid); + + ItemPointerSet(&inserted_hdr->t_ctid, BufferGetBlockNumber(buffer), offnum); + } + + + /* + * Update page opaque fields BEFORE WAL logging. When + * XLogRegisterBuffer() takes a Full Page Write (FPW), the page image must + * already contain the same opaque values that REDO will set during + * replay. Otherwise WAL consistency checking will detect a mismatch + * between the FPW and the page produced by REDO, causing a FATAL + * "inconsistent page found" error on the standby. + * + * This matches the fix applied to FluxXLogInitPage in flux_fsm.c. + */ + { + FluxPageOpaque phdr = FluxPageGetOpaque(page); + + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), current_ts)); + } + + MarkBufferDirty(buffer); + + /* Log the insertion with all overflow buffers atomically */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr = FluxXLogInsert(relation, buffer, offnum, + flux_tuple, current_ts, + &overflow_buffers, + &insert_logical_img, + false); + + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + FluxXLogReleaseLogicalImage(&insert_logical_img); + + /* + * Release all overflow buffers and free their cached data. + * + * IMPORTANT: Due to spatial locality optimization, an overflow buffer + * might be the SAME as the main buffer or as another overflow buffer + * (when overflow data is placed on the same page). Skip releasing buffers + * that were already released. The main buffer is NOT released yet — + * only overflow buffers that differ from it. + */ + for (i = 0; i < overflow_buffers.count; i++) + { + Buffer ovf_buf = overflow_buffers.buffers[i].buffer; + bool already_released = (ovf_buf == buffer); + int j; + + /* Check if this buffer was already released by a prior overflow entry */ + for (j = 0; j < i && !already_released; j++) + { + if (overflow_buffers.buffers[j].buffer == ovf_buf) + already_released = true; + } + + if (!already_released) + UnlockReleaseBuffer(ovf_buf); + pfree(overflow_buffers.buffers[i].record_data); + } + + /* + * Finish the per-relation UNDO record now that the insert is complete. + * Write the UNDO record with the inserted TID and register it with the + * transaction system so that rollback can find and apply it. + * + * IMPORTANT: This must happen BEFORE FluxVMUpdateForInsert to maintain + * consistent buffer lock ordering across forks. The UPDATE and DELETE + * paths already follow this ordering. + */ + + /* + * Per-relation UNDO: write the INSERT record now that the insert is + * complete and register it so rollback can find and reverse it. The + * record stores the inserted TID range; rollback marks the line pointer + * unused. + */ + if (RelUndoRecPtrIsValid(undo_ptr)) + { + RelUndoRecordHeader undo_hdr; + RelUndoInsertPayload undo_payload; + + undo_hdr.urec_type = RELUNDO_INSERT; + undo_hdr.urec_len = SizeOfRelUndoRecordHeader + + sizeof(RelUndoInsertPayload); + undo_hdr.urec_xid = GetCurrentTransactionId(); + undo_hdr.urec_prevundorec = GetPerRelUndoPtr(RelationGetRelid(relation)); + undo_hdr.info_flags = 0; + undo_hdr.tuple_len = 0; + + undo_payload.firsttid = *tid; + undo_payload.endtid = *tid; + + RelUndoFinish(relation, undo_buffer, undo_ptr, &undo_hdr, + &undo_payload, sizeof(RelUndoInsertPayload)); + + RegisterPerRelUndo(RelationGetRelid(relation), undo_ptr); + } + + /* + * Clear visibility map bits while buffer is still locked. This is + * usually a fast no-op for newly created tables (no VM fork yet). + */ + FluxVMUpdateForInsert(relation, flux_tuple->t_data, buffer); + + /* + * Save free space while we still have the buffer locked, then release the + * data buffer as soon as possible to reduce contention on hot pages. The + * remaining operations (FSM update, sLog registration) don't need the + * data buffer lock. + */ + { + Size saved_free_space = PageGetFreeSpace(page); + BlockNumber saved_blkno = BufferGetBlockNumber(buffer); + + UnlockReleaseBuffer(buffer); + + /* Update FSM with remaining free space on the page */ + FluxRecordFreeSpace(relation, saved_blkno, saved_free_space); + + /* + * Cache this block as the next insert target (heap's + * RelationSetTargetBlock pattern) so the following append skips the + * FSM search entirely. Only on the no-overflow path -- the overflow + * path deliberately stays on the FSM (see the target-block lookup + * above). If the page is now too full for the next tuple, the + * have_page free-space recheck falls back to the FSM. + */ + if (overflow_buffers.count == 0) + RelationSetTargetBlock(relation, saved_blkno); + } + + /* + * Lightweight subtransaction tracking for savepoint rollback. + * + * We do NOT create a full shared sLog entry here by default (that caused + * "out of shared memory" during bulk inserts with 100K+ rows). Instead, + * we record (tid, xid, subxid) in the per-backend local list only. If a + * savepoint is rolled back, SLogTupleRemoveBySubXid will find the + * matching local entries and create a shared sLog ABORTED entry at that + * time. + * + * Speculative inserts (ON CONFLICT) are handled by the separate + * flux_tuple_insert_speculative() function, which still registers full + * sLog entries for the speculative token. + */ + FluxEnsureSLogCallbacks(); + SLogTupleTrackLocalOnly(RelationGetRelid(relation), tid, + GetTopTransactionId(), + GetCurrentSubTransactionId()); + + FluxFreeTuple(flux_tuple); + + /* Release toasting temporaries now that the tuple is durably stored. */ + if (toasted) + flux_toast_cleanup(&ttc); + + pgstat_count_heap_insert(relation, 1); +} + +/* + * Delete a tuple from a FLUX table with proper tombstone marking + */ +TM_Result +flux_tuple_delete(Relation relation, ItemPointer tid, CommandId cid, + uint32 options, Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd) +{ + BlockNumber blkno; + OffsetNumber offnum; + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + uint64 current_ts; + bool have_tuple_lock; + FluxTuple old_tuple_for_delete_wal; + FluxLogicalImage delete_logical_img; + Buffer del_undo_buffer = InvalidBuffer; + RelUndoRecPtr del_undo_ptr = InvalidRelUndoRecPtr; + TransactionId del_hint_xid; + + /* Extract block and offset from TID */ + blkno = ItemPointerGetBlockNumber(tid); + offnum = ItemPointerGetOffsetNumber(tid); + + /* Validate TID range */ + if (blkno >= RelationGetNumberOfBlocks(relation)) + return TM_Invisible; + + /* Read the page containing the tuple */ + buffer = ReadBuffer(relation, blkno); + + /* + * Lock the buffer exclusively. The exclusive buffer lock is sufficient + * to prevent concurrent modifications — heavyweight tuple locks + * (FluxLockTuple) are only needed for SELECT FOR UPDATE/SHARE, not for + * regular DML. This matches heap's approach for UPDATE/DELETE. + */ + have_tuple_lock = false; + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + /* Validate offset number */ + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + + /* Get the item */ + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Check if tuple is already deleted (tombstone exists). A DELETED flag + * can mean either a committed delete OR an in-progress delete by a + * concurrent transaction (which now also leaves UNCOMMITTED set). + * Distinguish the two: if another in-progress transaction owns the + * delete, wait for it and retry, matching heap's behavior where the + * second DELETE blocks behind the first. Only report TM_Deleted once the + * delete is genuinely committed (or ours). + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + TransactionId del_xid = InvalidTransactionId; + bool del_is_insert = false; + + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + del_xid = SLogTupleGetDirtyXid(RelationGetRelid(relation), + tid, &del_is_insert); + + if (wait && TransactionIdIsValid(del_xid) && + !TransactionIdIsCurrentTransactionId(del_xid) && + !del_is_insert) + { + TransactionId wait_xid = del_xid; + + UnlockReleaseBuffer(buffer); + XactLockTableWait(wait_xid, relation, tid, XLTW_Delete); + + /* Re-read after waking; the delete committed or aborted. */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * If the delete committed, the tombstone remains: report it as + * deleted so the executor can re-evaluate via EPQ. If it + * aborted, the before-image was restored (DELETED cleared) and we + * fall through to perform our own delete. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Deleted; + } + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = GetCurrentTransactionId(); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Deleted; + } + } + + /* + * Handle LOCKED flag: same logic as the UPDATE path — clear our own + * lock before proceeding with the delete. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_LOCKED) + { + SLogTupleOp lock_entry; + int nfound; + + nfound = SLogTupleLookupFiltered(RelationGetRelid(relation), tid, + GetCurrentTransactionId(), &lock_entry, 1); + if (nfound > 0 && + (lock_entry.op_type == SLOG_OP_LOCK_SHARE || + lock_entry.op_type == SLOG_OP_LOCK_EXCL)) + { + tuple_hdr->t_flags &= ~FLUX_TUPLE_LOCKED; + } + } + + /* + * Fast-path: clear stale UNCOMMITTED flag (same optimization as UPDATE). + * We hold the buffer lock exclusively, so this is safe. + */ + if ((tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) && + !(tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_UPDATED))) + { + if (!SLogTupleHasEntry(RelationGetRelid(relation), tid)) + { + tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + } + } + + /* + * Check tuple visibility against snapshot and handle concurrent + * modifications. Same logic as the UPDATE path: distinguish truly + * invisible tuples from concurrent modifications. + */ + if (snapshot) + { + bool visible; + + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(relation), + buffer); + + if (!visible) + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* + * Lock-free: SLogTupleGetDirtyXid reads the seqlock-guarded sLog flat hash with + * EBR. No need to release buffer lock. + */ + dirty_xid = SLogTupleGetDirtyXid(RelationGetRelid(relation), + tid, + &is_insert_entry); + + /* Check if tuple was deleted by another transaction */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = TransactionIdIsValid(dirty_xid) ? + dirty_xid : GetCurrentTransactionId(); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Deleted; + } + + /* + * Buffer lock was never released (wait-free sLog read), so the + * tuple cannot have changed. Proceed with dirty_xid. + */ + { + if (TransactionIdIsValid(dirty_xid) && is_insert_entry) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + + if (TransactionIdIsValid(dirty_xid) && !is_insert_entry) + { + if (wait) + { + TransactionId wait_xid = dirty_xid; + + UnlockReleaseBuffer(buffer); + XactLockTableWait(wait_xid, relation, + tid, XLTW_Delete); + + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + tuple_hdr = (FluxTupleHeader *) + PageGetItem(page, itemid); + + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Deleted; + } + + visible = FluxTupleVisibleToSnapshotDual(tuple_hdr, snapshot, + RelationGetRelid(relation), + buffer); + + if (!visible) + { + /* + * Same EPQ livelock fix as the UPDATE path: check + * for our own LOCK entry before returning + * TM_Updated. + */ + TransactionId myxid_postw = + GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(myxid_postw)) + { + SLogTupleOp my_epw; + int my_nfound_postw; + + my_nfound_postw = SLogTupleLookupFiltered( + RelationGetRelid(relation), + tid, myxid_postw, + &my_epw, 1); + + if (my_nfound_postw > 0) + { + /* Own LOCK entry → proceed */ + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Updated; + } + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Updated; + } + } + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_WouldBlock; + } + } + else + { + /* + * No in-progress sLog entry for another txn. Same + * EPQ-loop fix as the UPDATE path: check if our + * transaction already has a sLog entry (from + * table_tuple_lock during EPQ). If so, fall through; + * otherwise trigger EPQ. + */ + TransactionId myxid_chk = + GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(myxid_chk)) + { + SLogTupleOp my_entry; + int my_nfound; + + my_nfound = SLogTupleLookupFiltered( + RelationGetRelid(relation), + tid, myxid_chk, &my_entry, 1); + if (my_nfound > 0) + { + /* EPQ already done; proceed. */ + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = + InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Updated; + } + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Updated; + } + } + } + /* If now visible, fall through to perform the delete */ + } + } + + /* + * Same as the UPDATE path: even when visibility returned "true", check + * for in-progress modifications by another transaction. Block if found. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* Lock-free: no buffer unlock needed */ + dirty_xid = SLogTupleGetDirtyXid(RelationGetRelid(relation), + tid, &is_insert_entry); + + if (!TransactionIdIsValid(dirty_xid)) + { + /* + * No in-flight transaction is modifying this tuple. The + * UNCOMMITTED flag is stale (left over from a committed + * transaction whose cleanup callback already ran). Clear it + * opportunistically to prevent future visibility re-checks. + */ + tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + MarkBufferDirty(buffer); + } + else if (TransactionIdIsValid(dirty_xid) && + !TransactionIdIsCurrentTransactionId(dirty_xid) && + !is_insert_entry) + { + if (wait) + { + TransactionId wait_xid = dirty_xid; + + UnlockReleaseBuffer(buffer); + XactLockTableWait(wait_xid, relation, tid, XLTW_Delete); + + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + tuple_hdr = (FluxTupleHeader *) + PageGetItem(page, itemid); + + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Deleted; + } + + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_Updated; + } + else + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_WouldBlock; + } + } + } + + /* + * Authoritative write-write / lock-conflict gate (mirrors the UPDATE + * path's final gate). The three checks above use SLogTupleGetDirtyXid, + * which only detects in-progress WRITERS and is blind to lock-only + * markers -- so a SELECT ... FOR UPDATE/FOR SHARE locker (which leaves a + * SLOG_OP_LOCK_EXCL/LOCK_SHARE marker on a committed, non-deleted, + * non-UNCOMMITTED tuple) would sail straight through and DELETE the row + * the locker is protecting. Probe with SLogTupleGetWriteConflictXid, + * which additionally reports a locker whose recorded LockTupleMode + * conflicts with ours under the real heavyweight matrix. + * + * LockTupleMode: LockTupleExclusive (AccessExclusiveLock), matching heap + * DELETE ("we need the strongest one"). A DELETE destroys the key, so it + * must conflict with FOR KEY SHARE FK lockers too; unlike UPDATE (which + * uses NoKeyExclusive precisely to stay compatible with a KeyShare FK + * lock), waiting on a KeyShare locker here is CORRECT, not spurious -- + * the FK machinery guarantees that locker releases before the referenced + * row can go away. + * + * Self-wait / EPQ safety: SLogTupleGetWriteConflictXid skips our own xid + * internally, so XactLockTableWait never receives GetTopTransactionId() + * (no lmgr self-wait assertion). A lock this transaction took during EPQ + * re-evaluation is our own marker and is likewise skipped. Before + * sleeping we acquire the heavyweight LOCKTAG_TUPLE lock so two writers + * racing the same tuple queue FIFO instead of mutually XactLockTableWait + * deadlocking (matches heap_delete's "establish our priority"). + */ + { + TransactionId conflict_xid; + bool conflict_is_insert = false; + + conflict_xid = SLogTupleGetWriteConflictXid(RelationGetRelid(relation), + tid, LockTupleExclusive, + &conflict_is_insert); + + if (TransactionIdIsValid(conflict_xid) && + !TransactionIdIsCurrentTransactionId(conflict_xid) && + !conflict_is_insert) + { + if (wait) + { + TransactionId wait_xid = conflict_xid; + + UnlockReleaseBuffer(buffer); + if (!have_tuple_lock) + FluxLockTuple(relation, tid, LockTupleExclusive, + true, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, tid, XLTW_Delete); + + /* Re-read and re-classify after the conflicter finished. */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, LockTupleExclusive); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, LockTupleExclusive); + return TM_Invisible; + } + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * If the conflicter was a writer that deleted the tuple, + * report TM_Deleted for EPQ. A locker leaves the tuple + * intact -- we hold the tuple lock and now own the FIFO + * slot, so fall through to perform the delete. We do NOT + * re-probe for further lockers: any locker that queued + * after us is behind us on LOCKTAG_TUPLE and will not be + * granted until we release, so re-waiting could livelock. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, LockTupleExclusive); + return TM_Deleted; + } + } + else + { + /* NOWAIT / SKIP LOCKED */ + if (tmfd) + { + tmfd->ctid = *tid; + tmfd->xmax = conflict_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + return TM_WouldBlock; + } + } + } + + /* + * Get transaction timestamp BEFORE critical section. Use xact_ts as + * commit timestamp for within-transaction visibility. + */ + (void) FluxGetTransactionTimestamp(); + current_ts = (uint64) FluxGetDmlTimestamp(); + + /* + * Allocate old_tuple structure and save a copy of the old tuple data + * BEFORE entering the critical section. The tuple header will be + * modified below (DELETED flag, commit_ts, etc.), and the WAL record + * needs the unmodified before-image for UNDO support. + */ + { + uint32 del_old_len = ItemIdGetLength(itemid); + + old_tuple_for_delete_wal = palloc(sizeof(FluxTupleData)); + old_tuple_for_delete_wal->t_len = del_old_len; + old_tuple_for_delete_wal->t_data = (FluxTupleHeader *) palloc(del_old_len); + memcpy(old_tuple_for_delete_wal->t_data, tuple_hdr, del_old_len); + } + + /* + * Prepare the heap-format logical-decoding image of the deleted tuple + * before the critical section (palloc/heap_form_tuple are forbidden + * inside). No-op unless the relation is logically logged. + */ + FluxXLogPrepareLogicalImage(relation, old_tuple_for_delete_wal, + &delete_logical_img); + + /* + * SSI: check for rw-conflict in. If a concurrent serializable + * transaction read this tuple (holds a SIREAD lock on it), our delete + * creates an rw-antidependency that may form a dangerous structure. + */ + CheckForSerializableConflictIn(relation, tid, BufferGetBlockNumber(buffer)); + + /* + * Pre-allocate WAL buffer space BEFORE entering critical section. DELETE + * operations only need the main buffer (no overflow). + * + * CRITICAL: XLogEnsureRecordSpace() may allocate memory, so it MUST be + * called outside the critical section. + */ + if (RelationNeedsWAL(relation)) + XLogEnsureRecordSpace(0, 2); + + /* + * Per-relation UNDO: reserve space for a DELETE UNDO record with full + * tuple data so rollback can restore the deleted tuple. Must happen + * before the critical section since it may extend the UNDO fork. + */ + if (smgrexists(RelationGetSmgr(relation), RELUNDO_FORKNUM)) + del_undo_ptr = RelUndoReserve(relation, + SizeOfRelUndoRecordHeader + + sizeof(RelUndoDeletePayload) + + old_tuple_for_delete_wal->t_len, + &del_undo_buffer); + + /* + * Assign our top-level XID for the t_xmin stamp BEFORE the critical + * section: GetTopTransactionId() allocates a fresh XID on first use, + * which calls XactLockTableInsert -> LockAcquire -> palloc, forbidden + * inside a critical section. Precompute here and write only the bare + * field below, matching the surrounding "compute before crit, write + * inside" pattern (del_undo_ptr, XLogEnsureRecordSpace, logical image). + */ + del_hint_xid = GetCurrentTransactionId(); /* subxid: heap-shaped t_xmax */ + + /* Start critical section for WAL logging */ + START_CRIT_SECTION(); + + /* + * Mark tuple as deleted with tombstone - this is the key FLUX feature. + * Set UNCOMMITTED so concurrent writers/readers consult the sLog while + * the delete is in flight: a second DELETE or UPDATE on this TID must + * detect the in-progress delete (via SLogTupleGetDirtyXid) and block on + * it, rather than racing ahead. The flag is cleared at commit by + * flux_stamp_tuple_committed; VACUUM sees a clean committed delete via + * the xmax XID + CLOG. + * + * Heap-shaped: set t_xmax to the deleter XID (leaving t_xmin, the + * inserter, untouched). Visibility resolves t_xmax against CLOG + the + * reader's snapshot: an old snapshot that predates the delete's commit + * still sees the row; a snapshot after it does not. + */ + tuple_hdr->t_flags |= FLUX_TUPLE_DELETED; + tuple_hdr->t_flags |= FLUX_TUPLE_UNCOMMITTED; + /* Fresh xmax being stamped: drop any stale XMAX_COMMITTED CLOG hint. */ + tuple_hdr->t_flags &= ~FLUX_TUPLE_XMAX_COMMITTED; + FluxTupleSetXmax(tuple_hdr, del_hint_xid); + /* Keep the original t_ctid for potential update chains */ + ItemPointerCopy(tid, &tuple_hdr->t_ctid); + + /* Update page header to match what redo does */ + { + FluxPageOpaque phdr = FluxPageGetOpaque(page); + + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), current_ts)); + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + } + + MarkBufferDirty(buffer); + + /* WAL log the deletion using the pre-saved old tuple copy */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + + recptr = FluxXLogDelete(relation, buffer, offnum, + old_tuple_for_delete_wal, + (uint64) del_hint_xid, + &delete_logical_img); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + + FluxXLogReleaseLogicalImage(&delete_logical_img); + + /* + * Per-relation UNDO: write the DELETE record now that the delete is + * complete. The record stores the deleted TID plus the full old tuple so + * rollback can restore it in place. + */ + if (RelUndoRecPtrIsValid(del_undo_ptr)) + { + RelUndoRecordHeader del_undo_hdr; + RelUndoDeletePayload del_undo_payload; + + del_undo_hdr.urec_type = RELUNDO_DELETE; + del_undo_hdr.urec_len = (uint16) + (SizeOfRelUndoRecordHeader + sizeof(RelUndoDeletePayload) + + old_tuple_for_delete_wal->t_len); + del_undo_hdr.urec_xid = GetCurrentTransactionId(); + del_undo_hdr.urec_prevundorec = + GetPerRelUndoPtr(RelationGetRelid(relation)); + del_undo_hdr.info_flags = RELUNDO_INFO_HAS_TUPLE; + del_undo_hdr.tuple_len = (uint16) old_tuple_for_delete_wal->t_len; + + del_undo_payload.ntids = 1; + del_undo_payload.tids[0] = *tid; + + RelUndoFinishWithTuple(relation, del_undo_buffer, del_undo_ptr, + &del_undo_hdr, + &del_undo_payload, sizeof(RelUndoDeletePayload), + (const char *) old_tuple_for_delete_wal->t_data, + old_tuple_for_delete_wal->t_len); + + RegisterPerRelUndo(RelationGetRelid(relation), del_undo_ptr); + } + + /* + * Clear visibility map bits for this page since we've deleted a tuple. + * The page is no longer all-visible. + */ + FluxVMUpdateForDelete(relation, buffer); + + /* + * Clean up overflow chains if this tuple has overflow attributes. This + * must happen outside the critical section since it performs its own + * buffer I/O. We check the flag and capture the free space before + * releasing the buffer so we can read the tuple header and page state. + */ + { + bool has_overflow = (tuple_hdr->t_flags & FLUX_TUPLE_HAS_OVERFLOW) != 0; + Size free_space = PageGetFreeSpace(page); + + UnlockReleaseBuffer(buffer); + + /* + * Ensure xact/subxact callbacks are registered before any sLog + * operation. This is critical for savepoint rollback: without the + * SubXactCallback, ROLLBACK TO SAVEPOINT won't restore tuples. + */ + FluxEnsureSLogCallbacks(); + + /* + * Register the delete in the sLog AFTER releasing the buffer lock to + * avoid deadlocks with SLogTupleGetDirtyXid's slow path. + */ + SLogTupleInsert(RelationGetRelid(relation), tid, + GetTopTransactionId(), SLOG_OP_DELETE, + GetCurrentSubTransactionId(), cid, current_ts, 0, + LockTupleNoKeyExclusive); + + /* + * Store before-image for savepoint rollback. The tracked key was + * just created by SLogTupleInsert above. We stash the original tuple + * data (saved before the critical section) so that ROLLBACK TO + * SAVEPOINT can physically restore the tuple. + * + * For DELETE, the before-image captures the original flags and + * commit_ts so we can undo the DELETED flag and timestamp. + */ + SLogTupleStoreBeforeImage(RelationGetRelid(relation), tid, + GetTopTransactionId(), + (const char *) old_tuple_for_delete_wal->t_data, + old_tuple_for_delete_wal->t_len, + old_tuple_for_delete_wal->t_data->t_flags, + old_tuple_for_delete_wal->t_data->t_commit_ts, + relation->rd_locator, + relation->rd_rel->relpersistence); + + /* Free old_tuple copy now that before-image has been stored */ + FluxFreeTuple(old_tuple_for_delete_wal); + + /* Mark this block dirty for the scan-path sLog bypass */ + FluxDirtyMapMark(RelationGetRelid(relation), blkno); + + /* + * NOTE: We do NOT immediately clean up overflow chains here. + * Immediate cleanup was: 1. Buggy (collected wrong overflow pointers + * after modification) 2. Expensive on hot paths (extra buffer I/O + + * locking) 3. Complex to WAL-log correctly + * + * Instead, overflow cleanup is deferred to VACUUM. When VACUUM prunes + * deleted tuples, it will also reclaim orphaned overflow pages. + * + * Future enhancement: Log overflow block/offset in WAL DELETE record + * so UNDO log pruning can also clean up overflow chains. + */ + (void) has_overflow; /* Suppress unused variable warning */ + + /* Release tuple lock */ + if (have_tuple_lock) + FluxUnlockTuple(relation, tid, LockTupleExclusive); + + /* Update free space map - deleted tuple creates more free space */ + FluxRecordFreeSpace(relation, blkno, free_space); + } + + /* Return success - tuple was successfully marked as deleted */ + pgstat_count_heap_delete(relation); + return TM_Ok; +} + +/* + * Release a set of overflow buffers collected during a force-shrink update. + * + * FluxStoreOverflowColumn leaves each overflow page locked, dirty, and + * unlogged, handing the buffers back to the caller for atomic WAL logging on + * the success path. On any error path before that logging happens, the caller + * must release them here so no dirty unlogged page is pinned into transaction + * abort. Skips main_buffer (released separately by the caller) and any buffer + * that appears more than once (two overflow columns can land on one page). + */ +static void +flux_release_update_overflow_buffers(FluxOverflowBuffers *bufs, + Buffer main_buffer) +{ + int i; + + for (i = 0; i < bufs->count; i++) + { + Buffer ovf_buf = bufs->buffers[i].buffer; + bool already_released = (ovf_buf == main_buffer); + int j; + + for (j = 0; j < i && !already_released; j++) + { + if (bufs->buffers[j].buffer == ovf_buf) + already_released = true; + } + if (!already_released) + UnlockReleaseBuffer(ovf_buf); + pfree(bufs->buffers[i].record_data); + } + bufs->count = 0; +} + +/* + * Update a tuple in a FLUX table with versioning support + * + * FLUX_RELEASE_TUPLOCK releases the heavyweight tuple lock that competing + * updaters acquire before XactLockTableWait (see the wait sites below). The + * lock is held continuously from acquisition through the recheck/update so it + * serializes racing updaters into a FIFO queue (matching heap_update); it must + * be released on every function-exit path so it never leaks past commit. The + * have_tuple_lock guard makes the macro a no-op on paths that never acquired. + */ +#define FLUX_RELEASE_TUPLOCK() \ + do { \ + if (have_tuple_lock) \ + { \ + FluxUnlockTuple(relation, otid, LockTupleNoKeyExclusive); \ + have_tuple_lock = false; \ + } \ + } while (0) + +/* + * flux_indexed_attr_changed + * + * Self-compute whether any INDEXED attribute changed value between the old + * on-page tuple at 'otid' and the new row in 'slot'. Stock PostgreSQL no + * longer passes the modified-attrs set into tuple_update, so FLUX derives it + * here exactly like heap does internally (HeapDetermineColumnsInfo): deform + * the old on-page tuple, deform the new slot, and value-compare each indexed + * attribute with datum_image_eq. + * + * Returns true if any indexed attribute differs, forcing the OUT-OF-PLACE + * (delete + insert at new TID) update path so secondary indexes are rebuilt. + * Whole-row (attr 0) and non-tableOID system attributes in the indexed set + * are treated as changed (force out-of-place), matching heap's conservative + * direction. If the old tuple can't be read (gone/abnormal), we conservatively + * return true so index maintenance still happens. + */ +static bool +flux_indexed_attr_changed(Relation relation, ItemPointer otid, + TupleTableSlot *slot) +{ + Bitmapset *indexed; + Bitmapset *summarized; + TupleDesc tupdesc = RelationGetDescr(relation); + BlockNumber blkno = ItemPointerGetBlockNumber(otid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(otid); + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleData old_tup; + Datum old_values[MaxTupleAttributeNumber]; + bool old_isnull[MaxTupleAttributeNumber]; + bool changed = false; + int attidx = -1; + + /* + * The full set of attributes referenced by ANY index on the relation: + * HOT_BLOCKING (ordinary/key indexes) plus SUMMARIZED (BRIN and friends). + * Heap's HeapDetermineColumnsInfo uses the same bitmaps; taking their union + * means "any indexed column changed" and lets FLUX force the out-of-place + * TU_All path, which rebuilds every index (summarizing included) at the new + * TID -- so FLUX never needs TU_Summarizing. + */ + indexed = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_HOT_BLOCKING); + summarized = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_SUMMARIZED); + indexed = bms_add_members(indexed, summarized); + bms_free(summarized); + if (indexed == NULL) + return false; /* no indexed columns -> always in place */ + + /* New values live in the slot; make sure they are all deformed. */ + slot_getallattrs(slot); + + /* Read the old on-page tuple. */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + bms_free(indexed); + return true; /* can't read old tuple -> be conservative */ + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + bms_free(indexed); + return true; + } + + old_tup.t_len = ItemIdGetLength(itemid); + old_tup.t_data = (FluxTupleHeader *) PageGetItem(page, itemid); + ItemPointerSet(&old_tup.t_self, blkno, offnum); + FluxDeformTuple(relation, &old_tup, tupdesc, old_values, old_isnull); + + while (!changed && (attidx = bms_next_member(indexed, attidx)) >= 0) + { + AttrNumber attrnum = attidx + FirstLowInvalidHeapAttributeNumber; + Form_pg_attribute att; + Datum old_val, + new_val; + bool old_null, + new_null; + + /* Whole-row reference: treat as changed (force all-index). */ + if (attrnum == 0) + { + changed = true; + break; + } + + /* + * System attributes other than tableOID cannot be compared meaningfully + * for an updated row: treat as changed. tableOID never changes. + */ + if (attrnum < 0) + { + if (attrnum != TableOidAttributeNumber) + changed = true; + continue; + } + + old_val = old_values[attrnum - 1]; + old_null = old_isnull[attrnum - 1]; + new_val = slot->tts_values[attrnum - 1]; + new_null = slot->tts_isnull[attrnum - 1]; + + /* Null-ness differs -> changed. */ + if (old_null != new_null) + { + changed = true; + break; + } + /* Both null -> unchanged. */ + if (new_null) + continue; + + att = TupleDescAttr(tupdesc, attrnum - 1); + if (!datum_image_eq(old_val, new_val, att->attbyval, att->attlen)) + { + changed = true; + break; + } + } + + UnlockReleaseBuffer(buffer); + bms_free(indexed); + return changed; +} + +TM_Result +flux_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot, + CommandId cid, uint32 options, + Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd, + LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes) +{ + BlockNumber blkno; + OffsetNumber offnum; + Buffer buffer; + Page page; + ItemId itemid; + FluxTupleHeader *old_tuple_hdr; + FluxTuple new_tuple; + Size new_tuple_size; + uint64 current_ts; + bool old_has_overflow = false; + bool have_tuple_lock = false; + FluxTuple old_tuple_for_inplace_wal; + FluxLogicalImage update_old_img; + FluxLogicalImage update_new_img; + FluxOverflowBuffers update_overflow_buffers; + int upd_i; + uint64 defrag_oldest_ts = 0; + TransactionId defrag_oldest_xmin = InvalidTransactionId; + bool force_shrink_attempted = false; + Buffer upd_undo_buffer = InvalidBuffer; + RelUndoRecPtr upd_undo_ptr = InvalidRelUndoRecPtr; + Datum upd_toast_values[MaxTupleAttributeNumber]; + bool upd_toast_isnull[MaxTupleAttributeNumber]; + ToastAttrInfo upd_toast_attr[MaxTupleAttributeNumber]; + ToastTupleContext upd_ttc; + bool upd_toasted = false; + + /* Extract block and offset from old TID */ + blkno = ItemPointerGetBlockNumber(otid); + offnum = ItemPointerGetOffsetNumber(otid); + + /* Validate TID range */ + if (blkno >= RelationGetNumberOfBlocks(relation)) + return TM_Invisible; + + /* + * OUT-OF-PLACE (heap-like) UPDATE for index-key changes. + * + * FLUX does not implement the RowID/gen index contract: secondary indexes + * carry plain heap-style TID entries. If we kept the tuple in place while + * an indexed column changed, the executor would insert a new (newkey, TID) + * index entry while the old (oldkey, TID) entry still pointed at the same + * live TID -- the A->B->A duplicate bug, since there is no RowID/gen to + * disambiguate and no stale-entry recheck. + * + * Stock PostgreSQL no longer tells the AM which columns changed, so FLUX + * self-computes it (flux_indexed_attr_changed, matching heap's internal + * HeapDetermineColumnsInfo): when any indexed column changed, store the new + * version at a NEW TID (DELETE old + INSERT new, zheap's non-in-place + * update) and set *update_indexes = TU_All so the executor inserts fresh + * entries in every index at the new TID. The old tuple's index entries + * then die with its TID and are reclaimed by ordinary VACUUM, exactly like + * heap. Non-indexed-column UPDATEs fall through to the in-place path below + * and set TU_None (the zheap win: update without touching indexes). FLUX + * never uses TU_Summarizing: a key-changing update always moves the TID, so + * TU_All already covers summarizing indexes. + */ + if (flux_indexed_attr_changed(relation, otid, slot)) + { + TM_Result del_res; + + del_res = flux_tuple_delete(relation, otid, cid, options, + snapshot, crosscheck, wait, tmfd); + if (del_res != TM_Ok) + return del_res; + + /* + * Insert the new version. flux_tuple_insert sets slot->tts_tid to the + * new location and TOASTs wide columns. The old version remains + * reachable to older snapshots via the UNDO before-image the delete + * recorded; on ROLLBACK both the delete and the insert are undone. + */ + flux_tuple_insert(relation, slot, cid, options, NULL); + + if (update_indexes != NULL) + *update_indexes = TU_All; + if (lockmode != NULL) + *lockmode = LockTupleExclusive; + return TM_Ok; + } + + /* + * In-place UPDATE: no indexed column changed, so the row keeps its TID and + * no index maintenance is needed. Default the out-param to TU_None here so + * every TM_Ok exit of the in-place path below reports "no index update" + * (the caller does not pre-initialize *update_indexes; it reads it only on + * TM_Ok). + */ + if (update_indexes != NULL) + *update_indexes = TU_None; + + /* + * --------------------------------------------------------------- FAST + * PATH: Same-size CAS update under SHARE_EXCLUSIVE buffer lock. + * + * For simple same-size updates (e.g. balance += delta in TPC-B), we can + * avoid the fully exclusive buffer lock by using a share-exclusive lock + * combined with a single-attempt per-tuple CAS trylock (t_writer). This + * still allows concurrent readers while serializing writers on the same + * page. + * + * NOTE on t_writer contention (investigated, no change made): FluxTuple- + * WriterTryLock is a SINGLE compare-and-swap, not a spin loop. On failure + * the backend does not retry -- it drops SHARE_EXCLUSIVE and falls to the + * exclusive slow path below, which is a queued, sleeping lock. Moreover + * BUFFER_LOCK_SHARE_EXCLUSIVE already conflicts with itself (bufmgr.c + * BufferLockAttempt), so at most one CAS writer per buffer runs at a time; + * additional hot-row writers queue+sleep on the buffer content lock, not + * on t_writer. There is therefore no t_writer spin storm to convert to a + * queued lock: the hot-row path ALREADY degrades to fair queued locks + * (BUFFER_LOCK_SHARE_EXCLUSIVE, then LOCKTAG_TUPLE + XactLockTableWait in + * the slow path). The measured hot-row throughput decline (peak ~c8 then + * fall) is dominated by "Lock : tuple" heavyweight waits + per-update + * UNDO/sLog/WAL work under that lock -- the same serialization heap/zheap + * incur on a hot row -- NOT by spinning. Switching the fast path to + * BUFFER_LOCK_EXCLUSIVE would not change this (both modes serialize one + * writer per buffer) and would regress the scattered case by blocking + * concurrent plain-SHARE readers. + * + * FIXME(flux hot-row scaling): the useful lever is reducing per-update + * work under the serialized lock (fold UNDO/sLog/WAL work, shrink the + * critical section) or a group-update / delta-accumulation scheme, not a + * writer-lock mode change. Deferred: any such change is a correctness- + * sensitive redesign (lost-update + self-wait hazards) and must be + * benchmark-driven, not speculative. See TASK B analysis in the handoff. + * + * Eligibility requirements: - New tuple must be the same on-disk size as + * the old tuple - Old tuple must not have overflow data - Old tuple must + * not be deleted, locked, or uncommitted - No speculative insertion - + * Must be a simple UPDATE (not HOT-chain following) - Snapshot visibility + * must be trivially true (committed tuple) - Relation must need WAL (for + * crash safety) + * + * If any condition fails, we fall through to the exclusive-lock path. + * --------------------------------------------------------------- + */ + { + FluxTuple cas_new_tuple; + Size cas_new_size; + + /* Form the new tuple speculatively (no overflow handling) */ + slot_getallattrs(slot); + cas_new_tuple = FluxFormTuple(RelationGetDescr(relation), + slot->tts_values, + slot->tts_isnull, + NULL, /* no overflow */ + NULL); + + cas_new_size = cas_new_tuple->t_len; + + /* Attempt the CAS fast path */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_SHARE_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum >= FirstOffsetNumber && + offnum <= PageGetMaxOffsetNumber(page)) + { + itemid = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(itemid) && + cas_new_size <= ItemIdGetLength(itemid)) + { + Size cas_target_size; + + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * WS-PVS1: the version-chain head lives in the fixed header + * field t_verptr, so both the on-page tuple and the freshly + * formed new tuple carry it inside their header with no + * trailing growth. Same-column-width updates are therefore + * naturally the same length, so this stays on the CAS fast + * path with no first-time growth dispatch. + */ + cas_target_size = cas_new_size; + + /* + * Cheap in-page eligibility gate: committed, not deleted, not + * locked, no overflow, same size for direct memcpy. This is a + * pure buffer-domain check (no sLog probe). + * + * We deliberately do NOT probe the sLog for write-write + * conflicts here. The flag gate is not a reliable conflict + * signal -- a committed in-place UPDATE rewinds t_commit_ts and + * clears FLUX_TUPLE_UNCOMMITTED, and a reader/third writer may + * clear a still-live flag as "stale" -- so any pre-lock sLog + * probe would have to be repeated authoritatively after we own + * t_writer anyway (state can change between the probe and the + * trylock). The authoritative committed-update and + * in-progress-writer probes therefore run once, under the + * t_writer lock below; the pre-lock duplicates were redundant + * seqlock reads on the common no-conflict path. Taking the + * trylock speculatively is harmless: no WAL or page change + * happens until revalidation passes, and a lost race just + * releases t_writer and falls to the exclusive path. + */ + if (!(old_tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | + FLUX_TUPLE_LOCKED | + FLUX_TUPLE_UNCOMMITTED | + FLUX_TUPLE_HAS_OVERFLOW | + FLUX_TUPLE_SPECULATIVE)) && + cas_target_size == ItemIdGetLength(itemid)) + { + uint32 expected = 0; + + if (FluxTupleWriterTryLock(old_tuple_hdr, &expected)) + { + /* + * We now own this tuple exclusively via t_writer, + * which -- not the pre-lock probe -- is the real + * serialization point. Between our pre-lock + * eligibility check and acquiring t_writer, a + * competing CAS writer may have completed its own + * in-place overwrite (it stamps + * FLUX_TUPLE_UNCOMMITTED into the new page image and + * resets on-page t_writer to 0, which lets our CAS + * succeed), or committed and left a conflict marker. + * Acting on the stale pre-lock decision would + * silently clobber that update (lost update). + * Re-validate the flags and the write-write probe + * under the lock; if the tuple is disqualified now, + * release t_writer and fall through to the exclusive + * path, which blocks on the in-progress writer or + * returns TM_Updated for EPQ. + */ + bool cas_revalidated; + + cas_revalidated = + !(old_tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | + FLUX_TUPLE_LOCKED | + FLUX_TUPLE_UNCOMMITTED | + FLUX_TUPLE_HAS_OVERFLOW | + FLUX_TUPLE_SPECULATIVE)) && + cas_target_size == ItemIdGetLength(itemid); + if (cas_revalidated && + snapshot != NULL && IsMVCCSnapshot(snapshot)) + { + RelUndoRecPtr head_verptr; + TransactionId head_xid; + bool head_inprogress = false; + + if (FluxTupleHasCommittedUpdateAfter(relation, + old_tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + GetCurrentTransactionIdIfAny(), + &head_verptr, + &head_xid, + &head_inprogress) && + !FluxEpqReconcileMatches(snapshot, + RelationGetRelid(relation), + otid, + head_verptr, + head_xid)) + cas_revalidated = false; + /* + * Commit-window conflict: the head committer is between PRE_COMMIT + * (marker + UNCOMMITTED cleared) and CLOG commit. Its sLog marker + * is gone, so the dirty-xid probe below cannot see it. Abandon + * the CAS fast path and fall to the slow path, which waits on the + * in-flight writer; otherwise the just-committed update is lost. + */ + else if (head_inprogress) + cas_revalidated = false; + } + + /* + * Re-check for a concurrent in-progress writer (see + * above) + */ + if (cas_revalidated) + { + bool reval_is_insert = false; + TransactionId reval_dirty_xid = + SLogTupleGetDirtyXid(RelationGetRelid(relation), + otid, &reval_is_insert); + + if (TransactionIdIsValid(reval_dirty_xid) && + !reval_is_insert) + cas_revalidated = false; + } + + if (!cas_revalidated) + { + /* + * Lost the race: release and fall to exclusive + * path + */ + FluxTupleWriterUnlock(old_tuple_hdr); + } + else + { + /* + * Fairness gate: funnel the CAS writer through + * LOCKTAG_TUPLE so lockers (flux_tuple_lock) and + * slow-path writers already queued on the same + * tag get FIFO service. Without this, a stream + * of CAS writers can lap a locker sleeping in + * XactLockTableWait under FluxLockTuple, + * starving it past statement_timeout. Must be + * dontWait: t_writer is held here, and blocking + * on a heavyweight sleep under a spinlock would + * deadlock. If not granted, drop t_writer and + * fall through to the exclusive path, which + * takes LOCKTAG_TUPLE with wait=true and queues + * FIFO behind the waiter that beat us here. + */ + bool cas_have_tuple_lock = false; + + if (!FluxLockTuple(relation, otid, + LockTupleNoKeyExclusive, + false, &cas_have_tuple_lock)) + { + /* + * CANDIDATE A (surgical fell-through): a locker + * or updater is queued on LOCKTAG_TUPLE for this + * tid. Drop t_writer and the page lock, then + * BLOCK on LOCKTAG_TUPLE so this writer queues + * FIFO behind the existing waiter. Only after we + * acquire the tag (the locker ahead of us has + * been served) do we re-enter the update path via + * the slow route, which re-reads the page and + * re-classifies. We must release t_writer and + * the buffer content lock BEFORE the blocking + * acquire: holding either across a heavyweight + * sleep would deadlock. Set the OUTER + * have_tuple_lock so the slow path's + * dirty_xid-gated LockTuple sites (guarded by + * !have_tuple_lock) do not double-acquire and + * FLUX_RELEASE_TUPLOCK frees it on every exit. + */ + FluxTupleWriterUnlock(old_tuple_hdr); + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + FluxFreeTuple(cas_new_tuple); + if (!have_tuple_lock) + FluxLockTuple(relation, otid, + LockTupleNoKeyExclusive, + true, &have_tuple_lock); + goto flux_update_slow_path; + } + { + /* + * We own this tuple exclusively under + * share-exclusive page lock. No other writer can + * modify it until we release t_writer. + * + * Compute timestamps outside critical section to + * avoid memory allocation issues. + */ + uint64 cas_current_ts; + uint16 cas_data_offset; + uint16 cas_data_len; + char *cas_old_bytes; + char *cas_new_bytes; + Size cas_tuple_len; + + (void) FluxGetTransactionTimestamp(); + cas_current_ts = (uint64) FluxGetDmlTimestamp(); + + /* Ensure we have an XID for WAL flush */ + (void) GetCurrentTransactionId(); + + /* + * Heap-shaped: the in-place image is a FRESH version. + * Stamp t_xmax = Invalid (this is the newest, live + * version) and t_xmin = the updater below. The + * pre-update image is preserved in the UNDO fork + * (the CAS path writes an undo record), so an older + * snapshot that cannot see the updater's xmin reads + * the before-image back via FluxReconstructVisible- + * Version. + * + * Torn-read note: the CAS path holds only + * BUFFER_LOCK_SHARE_EXCLUSIVE, so a concurrent SHARE + * reader can observe this image mid-overwrite. It is + * marked UNCOMMITTED and its xmin is the still- + * in-progress updater, so FluxTupleSatisfiesMVCC + * hides it (XidInMVCCSnapshot(updater) == true) and + * the reader reconstructs the prior version -- the + * row never blinks out. + */ + cas_new_tuple->t_data->t_commit_ts = 0; + cas_new_tuple->t_data->t_flags |= FLUX_TUPLE_UPDATED; + + /* + * Mark the in-place image UNCOMMITTED, mirroring + * the regular update path. A second writer that + * hits this row sees UNCOMMITTED in the CAS + * eligibility gate, bails to the regular path, + * and blocks on the in-progress writer via + * XactLockTableWait -- without this flag the + * second CAS would silently overwrite an + * uncommitted update (lost update / no + * write-write blocking). PRE_COMMIT clears the + * flag and stamps the real commit timestamp; + * readers self-heal a stale flag via the + * visibility path after a crash. + */ + cas_new_tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + /* + * Clear inherited CLOG hint bits: this image copied the + * old tuple's flags, but we are stamping a NEW t_xmin + * (the updater). A stale XMIN_COMMITTED from the old + * inserter would wrongly assert the new xmin is committed. + */ + cas_new_tuple->t_data->t_flags &= + ~(FLUX_TUPLE_XMIN_COMMITTED | FLUX_TUPLE_XMAX_COMMITTED); + cas_new_tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, so savepoint rollback marks it aborted in CLOG */ + cas_new_tuple->t_data->t_writer = 0; /* clear in new image */ + ItemPointerSet(&cas_new_tuple->t_data->t_ctid, blkno, offnum); + + /* + * Carry the index-identity generation forward from the + * t_gen is reserved/unused in FLUX (FLUX uses plain + * heap-TID index identity, no RowID/gen scheme). Leave it + * at the palloc0 default of 0. + */ + cas_new_tuple->t_data->t_gen = 0; + + /* + * WS-PVS1: build the new-image buffer of + * cas_target_size (== cas_new_size; the + * version pointer is a header field, not a + * trailer). We reserve the per-rel UNDO + * record first so its RelUndoRecPtr can be + * stamped into the new image BEFORE both the + * diff scan (so the diff includes the + * version-pointer change) and the page memcpy. + * + * RelUndoReserve may extend the fork and ereport + * -- neither is safe inside the critical + * section. Lock order: data buffer then UNDO + * buffer. + */ + { + uint32 cas_old_len = ItemIdGetLength(itemid); + char *cas_old_copy = palloc(cas_old_len); + char *cas_full_image; + Buffer cas_undo_buffer = InvalidBuffer; + RelUndoRecPtr cas_undo_ptr = InvalidRelUndoRecPtr; + RelUndoRecPtr cas_prev_undo = InvalidRelUndoRecPtr; + Size cas_undo_reserve; + RelUndoStageResult cas_undo_staged; + bool cas_undo_staged_valid = false; + + memcpy(cas_old_copy, old_tuple_hdr, cas_old_len); + + /* + * Reserve worst-case UNDO size (full-tuple + * record) up front. We commit to either + * delta or full-tuple AFTER stamping the + * urec_ptr into the new image and computing + * the final diff. Over-reservation by a + * few bytes is benign -- the unused tail of + * the reserved space is wasted on that + * page, no worse than a SHRINK update. + */ + cas_undo_reserve = SizeOfRelUndoRecordHeader + + sizeof(RelUndoUpdatePayload) + cas_old_len; + if (smgrexists(RelationGetSmgr(relation), RELUNDO_FORKNUM)) + { + cas_prev_undo = + GetPerRelUndoPtr(RelationGetRelid(relation)); + cas_undo_ptr = RelUndoReserve(relation, + cas_undo_reserve, + &cas_undo_buffer); + } + + /* + * Assemble the on-page image: header+data + * from the freshly formed new tuple plus + * the trailing version-pointer. Every + * committed in-place UPDATE stamps the + * verptr, so the size check above forces + * this same-shape requirement; first-time + * stamping (item length grew from base to + * base+8) happens on the exclusive-lock + * path. The trailing field is part of the + * on-disk slot, so the diff scan and the + * page memcpy both see this final layout. + * Stamp the freshly reserved cas_undo_ptr + * (NEVER preserve the prior pointer): the + * new diff reconstructs this update's + * before-image, and the header's + * urec_prevundorec already chains to the + * prior diff -- the row always points at + * the head of the chain. If no UNDO fork + * exists (unlogged/temp), stamp + * InvalidRelUndoRecPtr to keep slot length + * stable; readers fall back to the on-page + * tuple when the head is invalid. + */ + cas_full_image = palloc(cas_target_size); + memcpy(cas_full_image, cas_new_tuple->t_data, cas_new_size); + { + FluxTupleHeader *cas_full_hdr = + (FluxTupleHeader *) cas_full_image; + + cas_full_hdr->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(cas_full_hdr, + cas_target_size, + cas_undo_ptr); + } + + /* + * Compute the diff region for WAL logging. + * We log only the bytes that actually + * changed within the unified image. Read + * the old side from cas_old_copy (already + * memcpy'd from the page, identical bytes) + * rather than the live locked page: the + * scan is pure CPU work with no I/O, and + * reading a palloc'd copy instead of + * old_tuple_hdr means this loop no longer + * needs the data page to stay locked -- + * cas_target_size == cas_old_len is + * guaranteed by the eligibility gate above + * (same-size CAS only), so cas_old_copy has + * exactly cas_tuple_len bytes available. + */ + cas_tuple_len = cas_target_size; + cas_old_bytes = cas_old_copy; + cas_new_bytes = cas_full_image; + + /* Find first differing byte */ + cas_data_offset = 0; + while (cas_data_offset < cas_tuple_len && + cas_old_bytes[cas_data_offset] == cas_new_bytes[cas_data_offset]) + cas_data_offset++; + + if (cas_data_offset < cas_tuple_len) + { + uint16 cas_end = (uint16) cas_tuple_len; + + /* Find last differing byte */ + while (cas_end > cas_data_offset && + cas_old_bytes[cas_end - 1] == cas_new_bytes[cas_end - 1]) + cas_end--; + + cas_data_len = cas_end - cas_data_offset; + } + else + { + /* + * No actual data change -- release and + * return OK. Cancel the UNDO + * reservation; we have not written + * anything to the page or the UNDO + * fork. + */ + if (RelUndoRecPtrIsValid(cas_undo_ptr)) + RelUndoCancel(relation, cas_undo_buffer, cas_undo_ptr); + pfree(cas_full_image); + pfree(cas_old_copy); + FluxTupleWriterUnlock(old_tuple_hdr); + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + FluxFreeTuple(cas_new_tuple); + + /* Set output TID */ + ItemPointerSet(&slot->tts_tid, blkno, offnum); + slot->tts_tableOid = RelationGetRelid(relation); + if (update_indexes) + *update_indexes = TU_None; + pgstat_count_heap_update(relation, false, false); + FluxUnlockTuple(relation, otid, + LockTupleNoKeyExclusive); + return TM_Ok; + } + + /* SSI conflict check */ + CheckForSerializableConflictIn(relation, otid, + BufferGetBlockNumber(buffer)); + + /* + * FOLD variant: stage the UNDO before-image + * BEFORE the critical section so the combined + * WAL record can carry both the main-fork redo + * byte-diff and the undo before-image in one + * XLogInsert (halving per-UPDATE WAL insert + * count vs the standalone RM_RELUNDO record). + * + * RelUndoReserve() already returned the undo + * buffer exclusively locked+pinned, and (for a + * new page) left the metapage locked; both stay + * held through the crit section below, so one + * XLogInsert may legally register buffers from + * both forks. RelUndoStage() writes+dirties the + * undo page here (no WAL); the crit section emits + * the fold record and PageSetLSNs both pages. + * + * The tid the payloads reference is fully known + * now (blkno/offnum), so set it before staging. + */ + ItemPointerSet(&slot->tts_tid, blkno, offnum); + slot->tts_tableOid = RelationGetRelid(relation); + if (RelUndoRecPtrIsValid(cas_undo_ptr)) + { + RelUndoRecordHeader cas_undo_hdr; + char *cas_combined; + Size cas_payload_total; + RelUndoUpdatePayload cas_undo_payload; + + cas_undo_payload.oldtid = slot->tts_tid; + cas_undo_payload.newtid = slot->tts_tid; + cas_undo_hdr.urec_xid = GetCurrentTransactionId(); + cas_undo_hdr.urec_prevundorec = cas_prev_undo; + cas_undo_hdr.urec_type = RELUNDO_UPDATE; + + /* + * Full old-tuple before-image, always: FLUX matches ZHeap + * (whole old tuple to UNDO, no byte-diff/delta). + * [header][update-payload][old tuple]. + */ + cas_undo_hdr.urec_len = (uint16) + (SizeOfRelUndoRecordHeader + + sizeof(RelUndoUpdatePayload) + cas_old_len); + cas_undo_hdr.info_flags = RELUNDO_INFO_HAS_TUPLE; + cas_undo_hdr.tuple_len = (uint16) cas_old_len; + + cas_payload_total = sizeof(RelUndoUpdatePayload) + + cas_old_len; + cas_combined = palloc(cas_payload_total); + memcpy(cas_combined, &cas_undo_payload, + sizeof(RelUndoUpdatePayload)); + memcpy(cas_combined + sizeof(RelUndoUpdatePayload), + cas_old_copy, cas_old_len); + + RelUndoStage(relation, cas_undo_buffer, cas_undo_ptr, + &cas_undo_hdr, cas_combined, + cas_payload_total, &cas_undo_staged); + cas_undo_staged_valid = true; + pfree(cas_combined); + } + + /* Critical section: modify page + WAL */ + START_CRIT_SECTION(); + + memcpy(old_tuple_hdr, cas_full_image, cas_target_size); + + /* + * Update page-level commit timestamp + * atomically + */ + { + FluxPageOpaque cas_opaque = FluxPageGetOpaque(page); + uint64 cas_old_ts_flags; + uint64 cas_new_ts_flags; + uint64 cur_ts; + + do + { + cas_old_ts_flags = cas_opaque->pd_commit_ts_and_flags; + cur_ts = cas_old_ts_flags & FLUX_PAGE_TS_MASK; + + if (cas_current_ts <= cur_ts) + break; + cas_new_ts_flags = (cas_old_ts_flags & FLUX_PAGE_FLAG_MASK) | + (cas_current_ts & FLUX_PAGE_TS_MASK); + } while (!pg_atomic_compare_exchange_u64( + (pg_atomic_uint64 *) &cas_opaque->pd_commit_ts_and_flags, + &cas_old_ts_flags, cas_new_ts_flags)); + } + + MarkBufferDirtyShared(buffer); + + /* + * WAL log. When we staged an UNDO before-image, + * emit the single combined fold record carrying + * both the main-fork redo diff and the undo + * bytes; it PageSetLSNs both pages (and the + * metapage on a new undo page). Otherwise + * (no UNDO fork) emit the plain redo record. + */ + if (cas_undo_staged_valid) + { + FluxXLogCasUpdateUndo(relation, buffer, offnum, + cas_data_offset, cas_data_len, + cas_new_bytes + cas_data_offset, + 0, /* new version: t_xmax = Invalid */ + &cas_undo_staged); + } + else if (RelationNeedsWAL(relation)) + { + FluxXLogCasUpdate(relation, buffer, offnum, + cas_data_offset, cas_data_len, + cas_new_bytes + cas_data_offset, + 0); /* new version: t_xmax = Invalid */ + } + + END_CRIT_SECTION(); + + /* + * Release the staged UNDO buffers now that the + * fold record has logged them (mirrors the + * release RelUndoFinish would have done), and + * register the record for rollback discovery. + */ + if (cas_undo_staged_valid) + { + RegisterPerRelUndo(RelationGetRelid(relation), + cas_undo_ptr); + pfree(cas_undo_staged.wal_record_data); + UnlockReleaseBuffer(cas_undo_staged.undo_buffer); + if (BufferIsValid(cas_undo_staged.metabuf)) + UnlockReleaseBuffer(cas_undo_staged.metabuf); + } + + /* Release tuple-level CAS lock */ + FluxTupleWriterUnlock(old_tuple_hdr); + + /* + * sLog registration BEFORE buffer release. + * Eliminates the race window where another + * backend reads the modified tuple but finds + * no sLog entry (causing visibility failures + * at high concurrency). Safe: seqlock reads + * are wait-free, no deadlock with buffer + * lock. + */ + FluxEnsureSLogCallbacks(); + SLogTupleInsert(RelationGetRelid(relation), + &slot->tts_tid, + GetTopTransactionId(), + SLOG_OP_UPDATE, + GetCurrentSubTransactionId(), + cid, cas_current_ts, 0, + LockTupleNoKeyExclusive); + + /* Store before-image for rollback */ + SLogTupleStoreBeforeImage( + RelationGetRelid(relation), + &slot->tts_tid, + GetTopTransactionId(), + cas_old_copy, cas_old_len, + ((FluxTupleHeader *) cas_old_copy)->t_flags, + ((FluxTupleHeader *) cas_old_copy)->t_commit_ts, + relation->rd_locator, + relation->rd_rel->relpersistence); + + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + + /* + * No index maintenance for the CAS in-place path. A + * CAS update is same-size and only reaches here when no + * indexed column changed (key changes are routed out of + * place at the top of flux_tuple_update, TU_All). The TID + * is unchanged, so every (key, TID) entry still points at + * the live tuple; *update_indexes is set TU_None below. + */ + + pfree(cas_old_copy); + pfree(cas_full_image); + + /* + * Clear VM all-visible/all-frozen bits. + */ + FluxVMClear(relation, blkno, buffer, FLUX_VM_VALID_BITS); + + ReleaseBuffer(buffer); + + /* Track in-place update */ + flux_stat_in_place_updates++; + + /* Mark this block dirty for the scan-path sLog bypass */ + FluxDirtyMapMark(RelationGetRelid(relation), blkno); + + FluxFreeTuple(cas_new_tuple); + + /* + * Drive retained-marker cleanup now that all + * buffer locks are released. Throttled + * internally; never runs the global sweep under + * a page lock (which would convoy all writers + * to this hot row). + */ + SLogTupleMaybeCleanupRetained(); + + /* + * Same reasoning for the per-relation UNDO fork: + * this in-place AM can correctly report near-zero + * dead tuples, so autovacuum may never launch and + * RelUndoVacuum() (VACUUM-only) may never run. + * RelUndoMaybeVacuum() is the throttled backstop. + */ + RelUndoMaybeVacuum(relation); + pgstat_count_heap_update(relation, false, false); + FluxUnlockTuple(relation, otid, + LockTupleNoKeyExclusive); + return TM_Ok; + } /* end block (tuple-lock granted) */ + } /* end else (revalidated) */ + } + } + /* CAS failed -- another writer has this tuple */ + } + /* Not eligible for CAS fast path */ + } + /* ItemId not normal or new tuple too large */ + } + /* Offset out of range */ + + /* Release shared lock, fall through to exclusive path */ + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + ReleaseBuffer(buffer); + FluxFreeTuple(cas_new_tuple); + } + + /* + * Lock the buffer exclusively. The exclusive buffer lock prevents + * concurrent modification while we hold it, but it is dropped while we + * XactLockTableWait on a conflicting in-progress writer (the wait sites + * below). To stop two backends that each see the other's in-progress + * write from mutually waiting and deadlocking, an updater first takes a + * heavyweight tuple lock (have_tuple_lock) that serializes them into a + * FIFO queue, held continuously through the recheck/update and released + * on every exit via FLUX_RELEASE_TUPLOCK(). This matches heap_update. + * + * CANDIDATE A: a CAS writer that found a locker queued on the tag jumps + * here (flux_update_slow_path) already holding have_tuple_lock, so do + * NOT reset it -- clobbering it would drop the lock we just queued for. + * The buffer was released before the goto, so the re-read below re-pins + * it; the label must precede ReadBuffer. + */ +flux_update_slow_path: + /* Read the page containing the old tuple */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + /* Validate offset number */ + if (offnum < FirstOffsetNumber || offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + + /* Get the old tuple */ + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + return TM_Invisible; + } + + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Check if old tuple has overflow chains to clean up later */ + old_has_overflow = (old_tuple_hdr->t_flags & FLUX_TUPLE_HAS_OVERFLOW) != 0; + + /* + * Check if tuple is already deleted. As in the delete path, a DELETED + * flag may reflect an in-progress delete by a concurrent transaction + * (which also sets UNCOMMITTED). If so, wait for that transaction and + * retry rather than reporting TM_Deleted immediately -- this matches + * heap, where an UPDATE blocks behind a concurrent in-progress DELETE. + */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + TransactionId del_xid = InvalidTransactionId; + bool del_is_insert = false; + + if (old_tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + del_xid = SLogTupleGetWriteConflictXid(RelationGetRelid(relation), + otid, + LockTupleNoKeyExclusive, + &del_is_insert); + + if (wait && TransactionIdIsValid(del_xid) && + !TransactionIdIsCurrentTransactionId(del_xid) && + !del_is_insert) + { + TransactionId wait_xid = del_xid; + + /* + * Acquire a heavyweight tuple lock before sleeping on the + * conflicting xid. This serializes competing updaters of the + * same tuple into a FIFO queue; without it, two backends that + * each see the other's in-progress write would mutually + * XactLockTableWait and deadlock. Matches heap_update. + */ + UnlockReleaseBuffer(buffer); + if (!have_tuple_lock) + FluxLockTuple(relation, otid, LockTupleNoKeyExclusive, + true, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, otid, XLTW_Update); + + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Delete committed -> tombstone persists, report TM_Deleted for + * EPQ. Delete aborted -> before-image restored (DELETED cleared), + * fall through to perform the update. + */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Deleted; + } + } + else + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = GetCurrentTransactionId(); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Deleted; + } + } + + /* + * Handle LOCKED flag: if this tuple is locked by the current transaction + * (FOR SHARE/FOR KEY SHARE/FOR UPDATE), the lock is compatible with + * UPDATE (self-lock). Clear the LOCKED flag since we're about to modify + * the tuple. The sLog LOCK entry will be overwritten by the UPDATE entry + * or cleaned up at commit. + */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_LOCKED) + { + SLogTupleOp lock_entry; + int nfound; + + nfound = SLogTupleLookupFiltered(RelationGetRelid(relation), otid, + GetCurrentTransactionId(), &lock_entry, 1); + if (nfound > 0 && + (lock_entry.op_type == SLOG_OP_LOCK_SHARE || + lock_entry.op_type == SLOG_OP_LOCK_EXCL)) + { + /* Our own lock - clear flag and proceed with update */ + old_tuple_hdr->t_flags &= ~FLUX_TUPLE_LOCKED; + } + + /* + * If it's another transaction's lock, the existing concurrency + * control handles waiting via SLogTupleGetDirtyXid. + */ + } + + /* + * Fast-path: if UNCOMMITTED is set but no sLog entry exists, the previous + * transaction committed and its sLog cleanup already ran. Clear the stale + * flag now while we hold the buffer lock exclusively. This avoids the + * expensive sLog lookup inside the visibility check for the common case + * of UPDATing a recently-committed tuple. + * + * Only do this for tuples that are NOT deleted/updated (those flags + * indicate the tuple is being superseded, which requires the full + * visibility check to determine if the delete/update committed). + */ + if ((old_tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) && + !(old_tuple_hdr->t_flags & (FLUX_TUPLE_DELETED | FLUX_TUPLE_UPDATED))) + { + if (!SLogTupleHasEntry(RelationGetRelid(relation), otid)) + { + old_tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + /* Page will be dirtied by our upcoming update anyway */ + } + } + + /* + * Check tuple visibility against snapshot and handle concurrent + * modifications. Unlike a simple scan visibility check, UPDATE must + * distinguish between: - Truly invisible (another txn's uncommitted + * insert) → TM_Invisible - Concurrent update committed after our + * snapshot → TM_Updated - In-progress modification by another txn → + * wait, retry + */ + if (snapshot) + { + bool visible; + + visible = FluxTupleVisibleToSnapshotDual(old_tuple_hdr, snapshot, + RelationGetRelid(relation), + buffer); + + if (!visible) + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* + * Writer-only probe (seqlock read). We must wait only + * on an in-progress INSERT/UPDATE/DELETE writer, never on a pure + * lock-only marker: a KeyShare locker (AccessShareLock tuplock) is + * compatible with our NoKeyExclusive update (ExclusiveLock tuplock) + * in the standard tuple-lock matrix, so blocking on its xid here -- + * while it is queued behind us reporting a lock conflict -- forms a + * mutual XactLockTableWait cycle that heap avoids via + * HEAP_XMAX_IS_LOCKED_ONLY. Real lock conflicts (Share/Exclusive + * lockers) are still serialized by the heavyweight tuplock below. + */ + dirty_xid = SLogTupleGetWriteConflictXid(RelationGetRelid(relation), + otid, + LockTupleNoKeyExclusive, + &is_insert_entry); + + /* Check if tuple was deleted by another transaction */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = TransactionIdIsValid(dirty_xid) ? + dirty_xid : GetCurrentTransactionId(); + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Deleted; + } + + /* + * Buffer lock was never released (wait-free sLog read), so the + * tuple cannot have changed. Proceed with dirty_xid. + */ + { + if (TransactionIdIsValid(dirty_xid) && is_insert_entry) + { + /* + * Another txn's in-progress INSERT. The tuple truly + * doesn't exist in our snapshot. + */ + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + + if (TransactionIdIsValid(dirty_xid) && !is_insert_entry) + { + /* + * Another txn's in-progress UPDATE/DELETE. Wait for it + * to finish and then retry (the tuple may be gone or + * changed). + */ + if (wait) + { + TransactionId wait_xid = dirty_xid; + + /* + * Serialize competing updaters via a heavyweight + * tuple lock before sleeping on the conflicting xid, + * so two backends racing on the same tuple queue + * instead of deadlocking. Matches heap_update. + */ + UnlockReleaseBuffer(buffer); + if (!have_tuple_lock) + FluxLockTuple(relation, otid, LockTupleNoKeyExclusive, + true, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, + otid, XLTW_Update); + + /* Re-read the page and re-check after waking */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + old_tuple_hdr = (FluxTupleHeader *) + PageGetItem(page, itemid); + + /* If it got deleted while we waited, report that */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Deleted; + } + + /* + * Re-check visibility. The tuple was modified by the + * now-committed txn; its commit_ts is now later than + * our snapshot -> TM_Updated so the executor can EPQ. + */ + visible = FluxTupleVisibleToSnapshotDual(old_tuple_hdr, snapshot, + RelationGetRelid(relation), + buffer); + + if (!visible) + { + /* + * Still not visible after the waited-on txn + * committed. Before returning TM_Updated (which + * triggers another EPQ cycle), check whether we + * already hold a LOCK entry from a previous EPQ + * iteration. If so, we've already re-evaluated + * the quals and should proceed with the update + * instead of looping forever. + * + * Without this check, the following livelock + * occurs with hot-row contention: + * + * 1. We return TM_Updated → executor EPQ 2. + * table_tuple_lock inserts LOCK_EXCL 3. Retry → + * another txn is in-progress → wait 4. Waited + * txn commits → still not visible 5. Return + * TM_Updated → goto 2 (infinite) + * + * Each iteration leaks per-query memory in the + * executor, eventually causing OOM. + */ + TransactionId myxid_postw = + GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(myxid_postw)) + { + SLogTupleOp my_entry_postw; + int my_nfound_postw; + + my_nfound_postw = SLogTupleLookupFiltered( + RelationGetRelid(relation), + otid, myxid_postw, + &my_entry_postw, 1); + + if (my_nfound_postw > 0) + { + /* + * Our LOCK entry from a prior EPQ cycle + * exists. Fall through to perform the + * update. + */ + } + else + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + else + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + /* Now visible — fall through to perform the update */ + } + else + { + /* NOWAIT mode */ + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_WouldBlock; + } + } + else + { + /* + * No in-progress sLog entry for another transaction. The + * modification has already committed. + * + * Check if our own transaction already has a sLog entry + * for this TID (e.g., LOCK_EXCL placed by + * table_tuple_lock during EvalPlanQual). If so, EPQ + * already re-evaluated the WHERE clause and we should + * proceed with the update. + * + * Without this, we return TM_Updated endlessly: FLUX's + * in-place updates mean the tuple's commit_ts permanently + * exceeds the statement snapshot, so the executor's EPQ + * retry loop never terminates. + */ + TransactionId myxid_chk = + GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(myxid_chk)) + { + SLogTupleOp my_entry; + int my_nfound; + + my_nfound = SLogTupleLookupFiltered( + RelationGetRelid(relation), + otid, myxid_chk, &my_entry, 1); + if (my_nfound > 0) + { + /* + * Our own sLog entry exists (LOCK from EPQ path). + * Fall through to perform the update. + */ + } + else + { + /* + * First encounter: trigger EPQ. + */ + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = + InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + else + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + } + /* If now visible, fall through to perform the update */ + } + + /* + * Write-write conflict against an ALREADY-COMMITTED concurrent + * update. + * + * The visibility check above always returns "visible" for a committed + * in-place update, because at commit time t_commit_ts is rewound to + * the original insert timestamp (so mid-life readers still see the + * row). That rewind erases the "updated since you read it" signal + * heap keeps in xmax, so visibility alone can never detect a lost + * update. Instead, the on-page tuple's trailing verptr (WS-PVS1) + * points at the UNDO-fork record produced by the last committed + * update; if that committer is invisible to our snapshot, taking the + * in-place UPDATE now would silently lose it. Return TM_Updated so + * the executor re-evaluates via EvalPlanQual, matching heap. + * + * Converge like heap: bounce to EPQ once per DISTINCT committed + * update, not once per probe. FluxEpqReconcileMatches suppresses + * re-firing on the identical (verptr, xid) marker we already + * bounced on this statement; FluxEpqReconcileMark records the + * marker before we return. A strictly-newer committer stamps a + * fresh verptr, so identity dedup only suppresses the same marker + * and never masks a genuine new conflict. + */ + if (IsMVCCSnapshot(snapshot)) + { + RelUndoRecPtr head_verptr; + TransactionId head_xid; + bool head_inprogress = false; + + if (FluxTupleHasCommittedUpdateAfter(relation, + old_tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + GetCurrentTransactionIdIfAny(), + &head_verptr, + &head_xid, + &head_inprogress) && + !FluxEpqReconcileMatches(snapshot, + RelationGetRelid(relation), + otid, head_verptr, head_xid)) + { + FluxEpqReconcileMark(snapshot, + RelationGetRelid(relation), otid, + head_verptr, head_xid); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + else if (head_inprogress) + { + /* + * Commit-window conflict (see FluxTupleHasCommittedUpdateAfter): + * the head committer cleared its sLog marker + UNCOMMITTED flag + * at PRE_COMMIT but has not yet reached CLOG. Neither this gate + * nor the sLog dirty-xid probe sees it, so we would clobber a + * just-committed update. Wait on the in-flight writer, then retry + * from the top of the slow path where CLOG now resolves it to a + * committed conflict -> TM_Updated -> EPQ. Heap-identical. + */ + if (wait) + { + TransactionId wait_xid = head_xid; + + /* + * Wait for the in-flight committer to reach CLOG or abort, then + * report TM_Updated so the executor re-fetches through + * EvalPlanQual. EPQ's SnapshotAny fetch reconstructs the correct + * visible version (the committed value if head_xid committed, or + * the restored before-image if it aborted) and re-projects the + * update on top. A blind update retry here would instead apply + * over the raw on-page bytes, which on abort are the + * not-yet-reverted value. + */ + UnlockReleaseBuffer(buffer); + if (!have_tuple_lock) + FluxLockTuple(relation, otid, LockTupleNoKeyExclusive, + true, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, otid, XLTW_Update); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + } + } + + /* + * Even when visibility returned "true", the tuple may have an in-progress + * modification by another transaction. This happens when + * FluxTupleVisibleToSnapshotDual returns true for in-progress + * UPDATE/DELETE entries (to preserve tuple existence in scans). We must + * still detect the write-write conflict and block. + * + * The in-progress writer is detected authoritatively via the sLog + * (SLogTupleGetDirtyXid filters by TransactionIdIsInProgress), NOT via + * the on-page FLUX_TUPLE_UNCOMMITTED flag. The page flag and the sLog + * marker live in two domains (buffer locks vs. the sLog seqlock) that are not + * updated atomically, so a live writer's marker can exist while the page + * flag is transiently clear. Gating this wait on the flag (as we once + * did) let such a writer slip past, silently clobbering its in-progress + * update. Consult the sLog unconditionally; only use the flag to + * opportunistically clear stale state when no writer is present. + */ + { + TransactionId dirty_xid; + bool is_insert_entry; + + /* + * Lock-free. Probe for a transaction that conflicts with our + * NoKeyExclusive update under the real heavyweight tuple-lock matrix: + * an in-progress INSERT/UPDATE/DELETE writer always conflicts, and a + * lock-only marker conflicts iff its recorded LockTupleMode does (FOR + * UPDATE/FOR SHARE block; a KeyShare FK locker is compatible and does + * not). A pure writer-only probe would sail past a FOR UPDATE locker + * that left only a LOCK_EXCL marker and clobber the row it protects -- + * a correctness failure. We then acquire the same heavyweight + * LOCKTAG_TUPLE lock and XactLockTableWait on the conflicting xid, + * which serializes us into a FIFO queue rather than deadlocking. + */ + dirty_xid = SLogTupleGetWriteConflictXid(RelationGetRelid(relation), + otid, + LockTupleNoKeyExclusive, + &is_insert_entry); + + if (!TransactionIdIsValid(dirty_xid)) + { + /* + * No active writer. If the page still carries a stale + * UNCOMMITTED flag, clear it opportunistically. + */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + old_tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + MarkBufferDirty(buffer); + } + } + else if (TransactionIdIsValid(dirty_xid) && + !TransactionIdIsCurrentTransactionId(dirty_xid) && + !is_insert_entry) + { + /* + * Another transaction has an in-progress UPDATE/DELETE. Block + * until it finishes, then re-check. + */ + if (wait) + { + TransactionId wait_xid = dirty_xid; + + /* + * Serialize competing updaters via a heavyweight tuple lock + * before sleeping on the conflicting xid, so two backends + * racing on the same tuple queue instead of deadlocking. + * Matches heap_update. + */ + UnlockReleaseBuffer(buffer); + if (!have_tuple_lock) + FluxLockTuple(relation, otid, LockTupleNoKeyExclusive, + true, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, otid, XLTW_Update); + + /* Re-read page after waking */ + buffer = ReadBuffer(relation, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (offnum < FirstOffsetNumber || + offnum > PageGetMaxOffsetNumber(page)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + old_tuple_hdr = (FluxTupleHeader *) + PageGetItem(page, itemid); + + /* If deleted while we waited, report that */ + if (old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Deleted; + } + + /* + * The waited-on txn finished (committed or aborted). Return + * TM_Updated to force the executor to re-evaluate via EPQ + * against the now-stable page. + * + * This is required even on ABORT, and is where FLUX differs + * from heap. FLUX updates in place, so a READ COMMITTED + * scan that ran while the other writer was in progress + * dirty-read that writer's uncommitted value as the base for + * the new tuple's expression (e.g. counter = counter + 10 + * computed off the in-flight value). Heap never sees this + * because its scan reads the prior committed version. By the + * time we get here: - COMMIT: the page holds the other + * writer's committed value; EPQ recomputes on top of it. - + * ABORT: ApplyPerRelUndo() has already restored the + * pre-update image inline (before the conflicting XID left + * the proc array), so the page holds the original committed + * value; EPQ recomputes on top of that. Either way EPQ + * re-reads the correct base and recomputes, matching heap's + * final result. + */ + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = wait_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + else + { + /* NOWAIT mode */ + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = dirty_xid; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_WouldBlock; + } + } + /* If dirty_xid is our own or invalid, proceed with update */ + } + + /* + * Final committed-update gate (authoritative). + * + * STAGE 2 above runs the committed-update detector once, before the + * in-progress-writer wait. That check races: while the head writer W is + * still in progress, the detector declines (W's undo record is not yet + * committed), so control falls through to the writer-wait stage. The + * buffer exclusive lock does NOT block W's commit -- W clears its + * in-progress sLog marker through the sLog seqlock domain, and because FLUX + * updates in place, the on-page value is already W's. If W commits in the + * window between STAGE 2's read and the writer-wait probe, the wait stage + * sees NO in-progress writer and would fall through to apply our update + * over W's now-committed value -- silently losing W's update. + * + * Re-run the detector here, after the wait stage has resolved, as the last + * action before the update proper. Every path that reaches the update + * passes through this point with the buffer exclusively locked, so a + * committer that landed anywhere upstream is caught. Reconcile dedup makes + * this converge exactly like STAGE 2: we bounce to EvalPlanQual once per + * distinct committed marker; a repeat of the identical (verptr, xid) marker + * we already reconciled falls through and applies. + */ + if (snapshot && IsMVCCSnapshot(snapshot)) + { + RelUndoRecPtr head_verptr; + TransactionId head_xid; + bool head_inprogress = false; + + if (FluxTupleHasCommittedUpdateAfter(relation, + old_tuple_hdr, + ItemIdGetLength(itemid), + snapshot, + GetCurrentTransactionIdIfAny(), + &head_verptr, + &head_xid, + &head_inprogress)) + { + if (!FluxEpqReconcileMatches(snapshot, + RelationGetRelid(relation), + otid, head_verptr, head_xid)) + { + FluxEpqReconcileMark(snapshot, + RelationGetRelid(relation), otid, + head_verptr, head_xid); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + UnlockReleaseBuffer(buffer); + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + else if (head_inprogress) + { + /* + * Commit-window conflict (see FluxTupleHasCommittedUpdateAfter): + * the head committer cleared its sLog marker + UNCOMMITTED flag + * at PRE_COMMIT but has not yet reached CLOG. Neither this gate + * nor the sLog dirty-xid probe sees it, so we would clobber a + * just-committed update. Wait on the in-flight writer, then retry + * from the top of the slow path where CLOG now resolves it to a + * committed conflict -> TM_Updated -> EPQ. Heap-identical. + */ + if (wait) + { + TransactionId wait_xid = head_xid; + + /* + * Wait for the in-flight committer to reach CLOG or abort, then + * report TM_Updated so the executor re-fetches through + * EvalPlanQual. EPQ's SnapshotAny fetch reconstructs the correct + * visible version (the committed value if head_xid committed, or + * the restored before-image if it aborted) and re-projects the + * update on top. A blind update retry here would instead apply + * over the raw on-page bytes, which on abort are the + * not-yet-reverted value. + */ + UnlockReleaseBuffer(buffer); + if (!have_tuple_lock) + FluxLockTuple(relation, otid, LockTupleNoKeyExclusive, + true, &have_tuple_lock); + XactLockTableWait(wait_xid, relation, otid, XLTW_Update); + if (tmfd) + { + tmfd->ctid = *otid; + tmfd->xmax = InvalidTransactionId; + tmfd->cmax = InvalidCommandId; + tmfd->traversed = false; + } + FLUX_RELEASE_TUPLOCK(); + return TM_Updated; + } + } + } + + /* + * Get transaction timestamp BEFORE critical section (initializes the + * per-transaction MVCC state FluxGetDmlTimestamp relies on). Commit + * visibility comes from CLOG (heap-shaped xmin/xmax MVCC). + */ + (void) FluxGetTransactionTimestamp(); + current_ts = (uint64) FluxGetDmlTimestamp(); + + /* + * Ensure the current transaction has an XID assigned BEFORE entering the + * critical section. GetCurrentTransactionId() may call + * XactLockTableInsert() which acquires a lock and allocates memory -- + * both forbidden in a critical section. + * + * Without an assigned XID, RecordTransactionCommit() considers the + * transaction read-only and skips the WAL flush, even though we write WAL + * records for the data change. This would cause the update to be lost on + * crash recovery. + */ + (void) GetCurrentTransactionId(); + + /* + * Form the new tuple from the slot. + * + * Fast path: If the old tuple is small (no overflow potential), keep the + * buffer locked and form the tuple without overflow handling. This + * avoids the expensive unlock/relock cycle and the re-validation that + * follows. + * + * Slow path: For large tuples or those with existing overflow data, + * release the buffer lock first. FluxFormTuple may call + * FluxStoreOverflowColumn which acquires buffer locks on overflow pages. + * If overflow data lands on the same page we're updating, that would + * cause a buffer lock re-entry assertion failure. + */ + update_overflow_buffers.count = 0; + { + bool buffer_unlocked = false; + + /* + * This in-place UPDATE path runs only when no indexed column changed + * (a key-changing UPDATE is routed to the out-of-place path), so the + * varlena columns are typically unchanged. FLUX has no on-page + * overflow: TOAST any oversized varlena into the relation's standard + * heap TOAST table before forming, exactly like the insert path, then + * form the tuple from the (possibly externalized) datums. + */ + slot_getallattrs(slot); + { + int upd_natts = RelationGetDescr(relation)->natts; + + memcpy(upd_toast_values, slot->tts_values, upd_natts * sizeof(Datum)); + memcpy(upd_toast_isnull, slot->tts_isnull, upd_natts * sizeof(bool)); + flux_toast_tuple(relation, upd_toast_values, upd_toast_isnull, + NULL, NULL, &upd_ttc, upd_toast_attr, + &upd_toasted, 0); + + new_tuple = FluxFormTuple(RelationGetDescr(relation), + upd_toast_values, + upd_toast_isnull, + NULL, /* FLUX has no on-page overflow */ + NULL); + } + + /* Set MVCC fields for new tuple (heap-shaped: fresh version) */ + new_tuple->t_data->t_commit_ts = 0; /* t_xmax = InvalidTransactionId */ + + /* + * The new on-page image is the NEWEST version; stamp its inserter + * (t_xmin) with our XID. Older snapshots that cannot see this XID + * read the pre-update image back from the UNDO fork via t_verptr. + */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + new_tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, so savepoint rollback marks it aborted in CLOG */ + new_tuple_size = new_tuple->t_len; + + /* + * WS-PVS1: the version-chain head lives in the fixed header field + * t_verptr, so new_tuple already carries it inside its header with no + * trailing growth. The real urec_ptr is stamped in below after + * RelUndoReserve returns; until then t_verptr holds + * InvalidRelUndoRecPtr (consistent with the unlogged/temp path that + * never reserves). + */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(new_tuple->t_data, new_tuple_size, + InvalidRelUndoRecPtr); + + /* + * Pre-compute the oldest active timestamp before (re-)acquiring the + * buffer lock. This avoids an O(MaxBackends) shared-memory scan + * while holding a page-level exclusive lock. The value is used by + * the defrag estimation/execution path below. + */ + defrag_oldest_ts = FluxGetOldestActiveTimestamp(); + defrag_oldest_xmin = FluxGetOldestXminHorizon(relation); + + if (buffer_unlocked) + { + /* + * Re-acquire the buffer lock for the in-place update decision. + * Check if the main buffer is already locked as part of the + * overflow buffers to avoid double-lock assertion failure. + */ + bool buffer_already_locked = false; + + for (upd_i = 0; upd_i < update_overflow_buffers.count; upd_i++) + { + if (update_overflow_buffers.buffers[upd_i].buffer == buffer) + { + buffer_already_locked = true; + break; + } + } + + if (!buffer_already_locked) + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + + page = BufferGetPage(buffer); + + /* + * Re-validate the tuple after re-locking. Another backend may + * have reorganized the page while we didn't hold the lock. + */ + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + } + } + + /* + * Determine if we can do an in-place update. + * + * In-place update is FLUX's primary advantage over heap: it avoids + * creating dead tuple versions and the associated index maintenance. We + * try several strategies in order of increasing cost: + * + * 1. Direct fit: new tuple fits within the old tuple's slot. 2. Page + * space fit: new tuple is larger but the extra bytes fit in the page's + * available free space. 3. Defrag fit: page defragmentation frees enough + * space for the new tuple to fit in-place. + * + */ +retry_fit: + if (new_tuple_size <= ItemIdGetLength(itemid)) + { + /* Strategy 1: new tuple fits within old tuple's slot */ + } + else if (new_tuple_size <= ItemIdGetLength(itemid) + PageGetFreeSpace(page)) + { + /* + * Strategy 2: new tuple is larger but the difference fits in the + * page's free space. We need to relocate the tuple data within the + * page, which PageRepairFragmentation can handle. + */ + } + else + { + /* + * Strategy 3: try page defragmentation to reclaim dead tuple space. + * If the page has the defrag-needed flag and defragmentation would + * free enough space, do it now. + */ + FluxPageOpaque upd_opaque = FluxPageGetOpaque(page); + Size potential_free; + + (void) upd_opaque; /* flag is only an optimization hint; see below */ + + { + /* + * Strategy 3: reclaim space occupied by dead-to-all superseded + * versions via defragmentation. + * + * We DO NOT gate this on FLUX_PAGE_DEFRAG_NEEDED. That flag is an + * optimization hint set by the pruning paths, but in-place UPDATEs + * accumulate dead-to-all versions on a hot page (e.g. TPC-C + * district) without reliably setting it. Trusting the flag as + * authoritative made a 2-byte tuple growth spuriously fail with + * "does not fit" on a page that was actually full of reclaimable + * dead versions (measured: ~34k such aborts/run, contigfree=0, + * maxoff 47-69 on a 500-row table). Always scan; only error if a + * full defrag genuinely cannot free enough space. + */ + /* + * Estimate how much space defragmentation could free by scanning + * for dead tuples. This is a quick scan without actually + * defragmenting yet. + */ + potential_free = PageGetFreeSpace(page); + { + OffsetNumber df_off; + OffsetNumber df_maxoff = PageGetMaxOffsetNumber(page); + + for (df_off = FirstOffsetNumber; df_off <= df_maxoff; df_off++) + { + ItemId df_itemid = PageGetItemId(page, df_off); + FluxTupleHeader *df_hdr; + + if (!ItemIdIsNormal(df_itemid)) + { + if (ItemIdIsDead(df_itemid)) + potential_free += ItemIdGetLength(df_itemid) + sizeof(ItemIdData); + continue; + } + + if (FluxIsOverflowRecordInline(PageGetItem(page, df_itemid), + ItemIdGetLength(df_itemid))) + continue; + + df_hdr = (FluxTupleHeader *) PageGetItem(page, df_itemid); + + if (FluxTupleDeadToAll(df_hdr, defrag_oldest_xmin)) + { + potential_free += ItemIdGetLength(df_itemid) + sizeof(ItemIdData); + } + } + } + + if (new_tuple_size <= ItemIdGetLength(itemid) + potential_free) + { + /* + * Defragmentation should free enough space. Do it now. We + * are already holding an exclusive lock on the buffer. First + * mark dead tuples LP_DEAD, then defragment. + * + * As in FluxPagePruneOpt, opportunistic pruning sets LP_DEAD + * (reclaiming storage) rather than LP_UNUSED. The deleted + * tuples may still have index entries; reserving the line + * pointer until VACUUM removes those entries prevents the TID + * from being recycled and returning wrong index-scan results. + * + * defrag_oldest_ts was pre-computed before acquiring the + * buffer lock to avoid shared-memory scans while holding + * page-level exclusive locks. + */ + START_CRIT_SECTION(); + { + OffsetNumber prune_off; + OffsetNumber prune_maxoff = PageGetMaxOffsetNumber(page); + + for (prune_off = FirstOffsetNumber; prune_off <= prune_maxoff; prune_off++) + { + ItemId prune_itemid = PageGetItemId(page, prune_off); + FluxTupleHeader *prune_hdr; + + if (!ItemIdIsNormal(prune_itemid)) + continue; + + if (FluxIsOverflowRecordInline(PageGetItem(page, prune_itemid), + ItemIdGetLength(prune_itemid))) + continue; + + prune_hdr = (FluxTupleHeader *) PageGetItem(page, prune_itemid); + + if (FluxTupleDeadToAll(prune_hdr, defrag_oldest_xmin)) + { + ItemIdSetDead(prune_itemid); + } + } + } + FluxPageDefragment(page); + MarkBufferDirty(buffer); + + if (RelationNeedsWAL(relation)) + { + XLogRecPtr df_lsn; + + df_lsn = FluxXLogDefrag(relation, buffer, NULL, 0, defrag_oldest_ts); + PageSetLSN(page, df_lsn); + } + END_CRIT_SECTION(); + + FluxRecordFreeSpace(relation, blkno, PageGetFreeSpace(page)); + + /* + * Re-fetch the item after defragmentation since line pointers + * may have been reorganized. The offset number should still + * be valid for surviving tuples. + */ + itemid = PageGetItemId(page, offnum); + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Check again if in-place update now fits */ + if (new_tuple_size <= ItemIdGetLength(itemid) + PageGetFreeSpace(page)) + { + flux_stat_defrag_triggered_updates++; + } + else + { + if (!force_shrink_attempted && + update_overflow_buffers.count == 0) + goto force_shrink_retry; + flux_release_update_overflow_buffers(&update_overflow_buffers, + buffer); + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("updated flux tuple does not fit on page after defragmentation"), + errhint("FLUX tuples have stable TIDs and cannot move to another page. Lower the table's fillfactor to reserve room for in-place growth."))); + } + } + else + { + /* Defrag wouldn't free enough space */ + if (!force_shrink_attempted && + update_overflow_buffers.count == 0) + goto force_shrink_retry; + flux_release_update_overflow_buffers(&update_overflow_buffers, + buffer); + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("updated flux tuple does not fit on page"), + errdetail("The updated row is larger than its current slot, all variable-length columns are already stored off-page, and the page has no free space."), + errhint("FLUX tuples have stable TIDs and cannot move to another page. Lower the table's fillfactor to reserve room for in-place growth."))); + } + } + } + + if (false) + { + /* + * Recovery path for an in-place-grown tuple that no longer fits in its + * slot, even after page defragmentation. FLUX TIDs are stable, so the + * tuple cannot move to another page; the only way to shrink it is to + * push variable-length columns off-page. Re-form the new tuple forcing + * every eligible varlena column into overflow with a zero inline + * prefix, which collapses the main tuple to its minimum footprint, then + * retry the in-place fit (it should now satisfy Strategy 1). + * + * This branch is only entered from the ERROR sites above, and only when + * the first form attempt took the no-overflow fast path (the main + * buffer is still locked and no overflow buffers were collected). + */ +force_shrink_retry: + { + bool relock_already_locked = false; + + FluxFreeTuple(new_tuple); + + /* Release the main buffer lock before re-forming with overflow. */ + LockBuffer(buffer, BUFFER_LOCK_UNLOCK); + + new_tuple = FluxFormTupleForceShrink(RelationGetDescr(relation), + slot->tts_values, + slot->tts_isnull, + relation, + &update_overflow_buffers); + + new_tuple->t_data->t_commit_ts = 0; /* t_xmax = InvalidTransactionId */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + new_tuple->t_data->t_xmin = GetCurrentTransactionId(); /* subxid: heap-shaped, so savepoint rollback marks it aborted in CLOG */ + new_tuple_size = new_tuple->t_len; + + /* + * WS-PVS1: stamp InvalidRelUndoRecPtr into t_verptr, mirroring the + * main exclusive path, regardless of whether we reached this branch + * via fit cascade or force-shrink retry. + */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(new_tuple->t_data, new_tuple_size, + InvalidRelUndoRecPtr); + + /* + * Re-acquire the main buffer lock. An overflow column may have + * landed on the page we are updating, in which case it is already + * locked as part of update_overflow_buffers. + */ + for (upd_i = 0; upd_i < update_overflow_buffers.count; upd_i++) + { + if (update_overflow_buffers.buffers[upd_i].buffer == buffer) + { + relock_already_locked = true; + break; + } + } + if (!relock_already_locked) + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + + page = BufferGetPage(buffer); + + /* Re-validate the line pointer after re-locking. */ + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + { + flux_release_update_overflow_buffers(&update_overflow_buffers, + buffer); + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + FLUX_RELEASE_TUPLOCK(); + return TM_Invisible; + } + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Re-validate visibility after the unlock/relock window. The fit + * cascade ran conflict detection before forming the tuple, but + * re-forming with overflow drops the buffer lock again. A + * concurrent committed delete that landed in that window must be + * reported as TM_Deleted rather than silently overwritten. + */ + if ((old_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) && + !(old_tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED)) + { + flux_release_update_overflow_buffers(&update_overflow_buffers, + buffer); + UnlockReleaseBuffer(buffer); + FluxFreeTuple(new_tuple); + FLUX_RELEASE_TUPLOCK(); + return TM_Deleted; + } + + force_shrink_attempted = true; + goto retry_fit; + } + } + + /* + * Save a copy of the old tuple data BEFORE entering the critical section + * and BEFORE modifying the page. palloc is not allowed inside critical + * sections, and in-place updates overwrite the on-page data, so we must + * preserve the original tuple for WAL logging (the before-image). + * + * For small tuples (common case in pgbench-style OLTP), use stack buffers + * to avoid palloc overhead in the hot path. + */ + { + uint32 old_len = ItemIdGetLength(itemid); + char *old_copy; + + old_tuple_for_inplace_wal = palloc0(sizeof(FluxTupleData)); + old_copy = palloc(old_len); + + memcpy(old_copy, old_tuple_hdr, old_len); + old_tuple_for_inplace_wal->t_len = old_len; + old_tuple_for_inplace_wal->t_data = (FluxTupleHeader *) old_copy; + } + + /* + * NOTE: The early UNDO record (pre-modification) was removed to avoid + * double UNDO records. The deferred UNDO path below (upd_undo_ptr) + * handles UNDO recording for all in-place updates. The deferred approach + * is safe because the WAL record includes both old and new tuple data, + * enabling crash recovery regardless of UNDO write order. + */ + + + /* + * Pre-allocate WAL buffer space BEFORE entering critical section. We may + * need to register the main buffer plus overflow buffers. + * + * rdata slots needed for UPDATE: MAX_OVERFLOW_BUFFERS (data per overflow + * record, no separate header) + 3 (xl_flux_update header + old tuple + * data + new tuple data) + * + * CRITICAL: XLogEnsureRecordSpace() may allocate memory, so it MUST be + * called outside the critical section. + */ + if (RelationNeedsWAL(relation)) + XLogEnsureRecordSpace(XLR_MAX_BLOCK_ID, 4 + MAX_OVERFLOW_BUFFERS); + + /* + * Per-relation UNDO: reserve space for a full-tuple UPDATE record before + * the critical section. The record stores the old tuple so a top-level + * ROLLBACK can restore it in place (RelUndoApplyUpdate handles same-size, + * grow, and shrink). RelUndoReserve may extend the fork and ereport, so + * it must run outside the crit section. Lock order: data buffer then + * UNDO buffer. + * + * If a force-shrink retry pushed columns off-page, update_overflow_buffers + * holds buffers that are pinned, content-locked, and marked dirty but not + * yet WAL-logged (overflow WAL is deferred to the atomic record below). + * RelUndoReserve and CheckForSerializableConflictIn can both ereport here, + * before the critical section makes the change durable. Resource-owner + * cleanup would still release the pins and locks on abort, leaving only + * VACUUM-reclaimable dead overflow space, but release them explicitly so + * the abort path leaves no dirtied-yet-orphaned overflow pages behind. + */ + PG_TRY(); + { + if (smgrexists(RelationGetSmgr(relation), RELUNDO_FORKNUM)) + { + Size upd_undo_reserve; + + /* + * WS-PVS1: reserve a worst-case (full-tuple) record BEFORE + * computing the diff so we can stamp the returned RelUndoRecPtr + * into new_tuple's trailing version slot AHEAD of the diff scan + * and the page memcpy. The diff/full-tuple commit happens after + * stamping; over-reservation by a few bytes is wasted UNDO-fork + * space, not a correctness hazard. Mirrors the CAS fast path + * (flux_operations.c:2018-2037). + */ + upd_undo_reserve = SizeOfRelUndoRecordHeader + + sizeof(RelUndoUpdatePayload) + + old_tuple_for_inplace_wal->t_len; + + upd_undo_ptr = RelUndoReserve(relation, upd_undo_reserve, + &upd_undo_buffer); + + /* + * Stamp the freshly reserved urec_ptr (never preserve a prior + * pointer): the new diff/full-tuple record reconstructs THIS + * update's before-image, and the header's urec_prevundorec + * already chains to the prior diff -- the row's verptr always + * points at the head of the chain. + */ + FluxTupleSetVersionPtr(new_tuple->t_data, new_tuple_size, + upd_undo_ptr); + } + + /* + * WS-PVS1: widen-then-narrow fixup (now a verptr no-op; retained for + * the flag-removal commit). The version pointer lives in the fixed + * header field t_verptr, so FluxTupleGetVersionPtr/SetVersionPtr read + * and write it independently of the slot length -- widening the image + * to the old (larger) slot length no longer moves the pointer. The + * in-place overwrite path below keeps the old line-pointer length when + * the new image is smaller; deform's self-describing t_natts lets the + * trailing slack be ignored by ordinary readers. We still widen + + * zero-pad here so the memcpy below fills the whole slot (no stale + * trailing bytes). + * + * Done here, before the critical section, because repalloc()/pfree() + * are forbidden inside it. + */ + if (new_tuple_size <= ItemIdGetLength(itemid)) + { + Size slot_len = ItemIdGetLength(itemid); + + if (slot_len > new_tuple_size && + (new_tuple->t_data->t_flags & FLUX_TUPLE_HAS_VERSION_PTR)) + { + RelUndoRecPtr verptr = FluxTupleGetVersionPtr(new_tuple->t_data, + new_tuple_size); + + new_tuple->t_data = (FluxTupleHeader *) + repalloc(new_tuple->t_data, slot_len); + MemSet((char *) new_tuple->t_data + new_tuple_size, 0, + slot_len - new_tuple_size); + new_tuple->t_len = (uint32) slot_len; + new_tuple_size = slot_len; + FluxTupleSetVersionPtr(new_tuple->t_data, new_tuple_size, verptr); + } + } + + /* + * transaction read this tuple (holds a SIREAD lock on it), our update + * creates an rw-antidependency that may form a dangerous structure. + */ + CheckForSerializableConflictIn(relation, otid, BufferGetBlockNumber(buffer)); + + /* + * Always set UNCOMMITTED so that visibility checks consult the sLog. + * Even though the tuple position hasn't moved (in-place update), the + * DATA has changed and other transactions must see the old data until + * this update commits. The flag will be lazily cleared on the first + * visibility check after the updating transaction commits (since the + * sLog entry will have been removed at commit time). + * + * Also set FLUX_TUPLE_UPDATED to mark that this tuple has been + * updated in-place. After commit, this flag persists and indicates + * that the tuple's t_commit_ts reflects the original INSERT commit + * time (not the UPDATE commit time). This preserves visibility for + * readers whose snapshots predate the update. + */ + new_tuple->t_data->t_flags |= FLUX_TUPLE_UPDATED; + + /* + * t_gen is reserved/unused in FLUX (FLUX uses plain heap-TID index + * identity, no RowID/gen scheme). Leave it at the palloc0 default. + */ + new_tuple->t_data->t_gen = 0; + + /* + * Build the heap-format logical-decoding images (old and new) BEFORE + * the critical section, since heap_form_tuple()/palloc() are forbidden + * inside it. When the relation is not logically logged these leave + * data == NULL. These calls palloc and can ereport on OOM, so they + * stay inside the PG_TRY: a throw here must also release the + * force-shrink overflow buffers, exactly like the reservation above. + */ + FluxXLogPrepareLogicalImage(relation, old_tuple_for_inplace_wal, + &update_old_img); + FluxXLogPrepareLogicalImage(relation, new_tuple, &update_new_img); + } + PG_CATCH(); + { + flux_release_update_overflow_buffers(&update_overflow_buffers, buffer); + PG_RE_THROW(); + } + PG_END_TRY(); + + /* Start critical section for WAL logging */ + START_CRIT_SECTION(); + + /* + * Set t_ctid on the in-memory new tuple BEFORE copying to the page. This + * ensures the WAL record's new_tuple image includes the correct t_ctid, + * so redo produces a page identical to the primary. (We use blkno/offnum + * which is correct for both the "fits in existing slot" and + * "delete+re-add" strategies since we update offnum below if it changes.) + */ + ItemPointerSet(&new_tuple->t_data->t_ctid, blkno, offnum); + + if (new_tuple_size <= ItemIdGetLength(itemid)) + { + /* + * New tuple fits within the old tuple's allocated space. Overwrite + * directly -- safe because we don't exceed the existing allocation. + * When the kept slot is larger than the new image, new_tuple was + * already widened to the full slot length before the critical section + * (see the WS-PVS1 widen block above), so new_tuple_size == slot_len + * here and the memcpy fills the whole slot, keeping any trailing + * slack zeroed. The version pointer lives in the header field + * t_verptr, so it is copied by the memcpy regardless of slot width. + */ + memcpy(old_tuple_hdr, new_tuple->t_data, new_tuple_size); + } + else + { + /* + * New tuple is larger than the old one but fits on the page (Strategy + * 2 or 3). We cannot memcpy in place because that would overwrite + * adjacent data. Instead, remove the old line pointer entry, compact + * the page, and re-add the new tuple at the same offset. + * + * We use FluxPageIndexTupleDelete instead of the standard + * PageIndexTupleDelete because FLUX pages may contain LP_UNUSED + * items left by opportunistic defragmentation. PageIndexTupleDelete + * asserts all items are LP_NORMAL, which fails when LP_UNUSED items + * are present. FluxPageIndexTupleDelete skips LP_UNUSED items in the + * offset adjustment loop. + */ + FluxPageIndexTupleDelete(page, offnum); + + offnum = PageAddItem(page, new_tuple->t_data, + new_tuple_size, + offnum, false, false); + + if (offnum == InvalidOffsetNumber) + elog(PANIC, "failed to re-add FLUX tuple after delete for growing update"); + + /* Re-fetch itemid and header from the (same) location */ + itemid = PageGetItemId(page, offnum); + old_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + ItemPointerSet(&new_tuple->t_data->t_ctid, blkno, offnum); + } + + /* Set new TID to same location */ + ItemPointerSet(&slot->tts_tid, blkno, offnum); + new_tuple->t_self = slot->tts_tid; + /* + * t_ctid on the on-disk tuple is already correct from the memcpy or + * PageAddItem above, since we set it on new_tuple->t_data before copying. + */ + + /* Track in-place update success */ + flux_stat_in_place_updates++; + + slot->tts_tableOid = RelationGetRelid(relation); + + /* + * Update page opaque header to track the latest commit timestamp and + * current free space. This must happen before MarkBufferDirty and WAL + * logging so that full-page images capture the correct opaque state. The + * redo function performs the same updates so WAL consistency checking + * passes. + */ + { + FluxPageOpaque upd_phdr = FluxPageGetOpaque(page); + + FluxPageSetCommitTs(upd_phdr, Max(FluxPageGetCommitTs(upd_phdr), current_ts)); + } + + MarkBufferDirty(buffer); + + /* WAL log the update with all overflow buffers atomically */ + if (RelationNeedsWAL(relation)) + { + /* + * old_tuple_for_inplace_wal was populated with a copy of the old + * tuple data BEFORE we modified the page. Use its saved + * old_commit_ts for the WAL record so the before-image is correct. + */ + FluxXLogUpdate(relation, buffer, offnum, + old_tuple_for_inplace_wal, new_tuple, + old_tuple_for_inplace_wal->t_data->t_commit_ts, + 0, /* new version: t_xmax = Invalid (heap-shaped) */ + &update_overflow_buffers, + InvalidBuffer, + &update_old_img, &update_new_img); + } + + END_CRIT_SECTION(); + + FluxXLogReleaseLogicalImage(&update_old_img); + FluxXLogReleaseLogicalImage(&update_new_img); + + /* + * Release all overflow buffers first — they were WAL-logged atomically + * above so they're safe to unlock now. + */ + for (upd_i = 0; upd_i < update_overflow_buffers.count; upd_i++) + { + Buffer ovf_buf = update_overflow_buffers.buffers[upd_i].buffer; + bool already_released = (ovf_buf == buffer); + int dup_j; + + for (dup_j = 0; dup_j < upd_i && !already_released; dup_j++) + { + if (update_overflow_buffers.buffers[dup_j].buffer == ovf_buf) + already_released = true; + } + + if (!already_released) + UnlockReleaseBuffer(ovf_buf); + pfree(update_overflow_buffers.buffers[upd_i].record_data); + } + + /* + * Clear VM bits and capture free space while we still hold the main + * buffer lock. Both need page access. + */ + FluxVMUpdateForUpdate(relation, buffer); + { + Size update_free_space = PageGetFreeSpace(page); + + /* + * sLog registration BEFORE buffer release — eliminates the race + * window where another backend reads the modified tuple but finds no + * sLog entry. Safe: seqlock reads take no lock, no deadlock. + */ + FluxEnsureSLogCallbacks(); + SLogTupleInsert(RelationGetRelid(relation), &slot->tts_tid, + GetTopTransactionId(), SLOG_OP_UPDATE, + GetCurrentSubTransactionId(), cid, current_ts, 0, + LockTupleNoKeyExclusive); + SLogTupleStoreBeforeImage(RelationGetRelid(relation), &slot->tts_tid, + GetTopTransactionId(), + (const char *) old_tuple_for_inplace_wal->t_data, + old_tuple_for_inplace_wal->t_len, + old_tuple_for_inplace_wal->t_data->t_flags, + old_tuple_for_inplace_wal->t_data->t_commit_ts, + relation->rd_locator, + relation->rd_rel->relpersistence); + + /* Release the main buffer lock — sLog registered, no race. */ + UnlockReleaseBuffer(buffer); + + FluxRecordFreeSpace(relation, blkno, update_free_space); + } + + /* + * Finish the per-relation UNDO record now that the buffer lock is + * released. This works from old_tuple_for_inplace_wal, a palloc'd copy + * taken before the page modification, so it does NOT need the page. + * + * Moving this out of the buffer-lock-held window significantly reduces + * contention at high concurrency (8+ clients on hot pages). + * + * Write a full-tuple RELUNDO_UPDATE record with old/new TID mapping and + * the old tuple data so that rollback can restore it. + */ + if (RelUndoRecPtrIsValid(upd_undo_ptr)) + { + RelUndoRecordHeader upd_undo_hdr; + RelUndoUpdatePayload upd_undo_payload; + char *upd_combined; + Size upd_payload_total; + + upd_undo_payload.oldtid = *otid; + upd_undo_payload.newtid = slot->tts_tid; + + upd_undo_hdr.urec_xid = GetCurrentTransactionId(); + upd_undo_hdr.urec_prevundorec = + GetPerRelUndoPtr(RelationGetRelid(relation)); + + /* + * Full-tuple UPDATE UNDO record: store the old tuple so rollback + * can restore it (RelUndoApplyUpdate handles same-size, grow, and + * shrink). FLUX matches ZHeap: the whole old tuple to UNDO, no + * byte-diff/delta. Layout written by RelUndoFinish: + * [header][payload][old tuple]. + */ + upd_undo_hdr.urec_type = RELUNDO_UPDATE; + upd_undo_hdr.urec_len = (uint16) + (SizeOfRelUndoRecordHeader + sizeof(RelUndoUpdatePayload) + + old_tuple_for_inplace_wal->t_len); + upd_undo_hdr.info_flags = RELUNDO_INFO_HAS_TUPLE; + upd_undo_hdr.tuple_len = (uint16) old_tuple_for_inplace_wal->t_len; + + upd_payload_total = sizeof(RelUndoUpdatePayload) + + old_tuple_for_inplace_wal->t_len; + upd_combined = palloc(upd_payload_total); + memcpy(upd_combined, &upd_undo_payload, sizeof(RelUndoUpdatePayload)); + memcpy(upd_combined + sizeof(RelUndoUpdatePayload), + old_tuple_for_inplace_wal->t_data, + old_tuple_for_inplace_wal->t_len); + + RelUndoFinish(relation, upd_undo_buffer, upd_undo_ptr, + &upd_undo_hdr, upd_combined, upd_payload_total); + RegisterPerRelUndo(RelationGetRelid(relation), upd_undo_ptr); + pfree(upd_combined); + } + + /* + * No index maintenance for this path. Key-changing UPDATEs are routed out + * of place at the top of flux_tuple_update (delete + insert at a new TID, + * *update_indexes = TU_All); this in-place path runs only for non-indexed + * changes, so the TID is unchanged and every secondary (key, TID) entry + * still points at the live tuple. *update_indexes was defaulted to TU_None + * at the top of the function, so no index re-insert happens. + */ + { + /* + * sLog registration was done above (before buffer release). The + * FluxEnsureSLogCallbacks + SLogTupleInsert + + * SLogTupleStoreBeforeImage calls were moved to eliminate the + * visibility race window at high concurrency. + */ + + /* Free old_tuple copy now that before-image has been stored */ + FluxFreeTuple(old_tuple_for_inplace_wal); + + /* Mark this block dirty for the scan-path sLog bypass */ + FluxDirtyMapMark(RelationGetRelid(relation), blkno); + + /* + * NOTE: We do NOT immediately clean up overflow chains here. + * Immediate cleanup was: 1. Buggy (collected wrong overflow pointers + * after in-place modification) 2. Expensive on hot paths (extra + * buffer I/O + locking during UPDATE) 3. Complex to WAL-log correctly + * + * Instead, overflow cleanup is deferred to VACUUM. When VACUUM prunes + * deleted tuples, it will also reclaim orphaned overflow pages. + * + * Future enhancement: Log overflow block/offset in WAL UPDATE record + * so UNDO log pruning can also clean up overflow chains. + */ + (void) old_has_overflow; /* Suppress unused variable warning */ + } + + FluxFreeTuple(new_tuple); + + FLUX_RELEASE_TUPLOCK(); + + /* Buffer + tuple locks released; safe to drive throttled cleanup. */ + SLogTupleMaybeCleanupRetained(); + RelUndoMaybeVacuum(relation); + pgstat_count_heap_update(relation, false, false); + return TM_Ok; +} + +#undef FLUX_RELEASE_TUPLOCK + + + +/* + * Multi-insert operation for bulk loading (batched page-at-a-time) + * + * Pre-forms all tuples, then inserts them page-at-a-time to minimize + * per-tuple overhead: one FSM lookup, one buffer lock, one WAL record, + * and one UNDO reservation per page batch instead of per tuple. + * + * Tuples that are too large for batch handling (need overflow) are + * inserted individually via the single-insert path. + */ +void +flux_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, + CommandId cid, uint32 options, BulkInsertState bistate) +{ + FluxTuple *formed_tuples; + bool *needs_single_insert; + uint64 current_ts; + TransactionId my_xid; + int i; + int ndone; + + if (ntuples <= 0) + return; + + /* + * Get timestamps and XID outside the loop — these are per-transaction + * cached values, but calling them once avoids function call overhead. + */ + (void) FluxGetTransactionTimestamp(); + current_ts = (uint64) FluxGetDmlTimestamp(); + my_xid = GetCurrentTransactionId(); /* subxid: heap-shaped xmin */ + /* Ensure relation storage exists */ + RelationGetSmgr(relation); + + /* + * Phase 1: Pre-form all tuples without overflow handling. Passing NULL + * for rel and overflow_buffers skips the overflow path, keeping the tuple + * inline. Tuples that exceed the page size will be detected below and + * routed to single-insert. + */ + formed_tuples = (FluxTuple *) palloc(ntuples * sizeof(FluxTuple)); + needs_single_insert = (bool *) palloc0(ntuples * sizeof(bool)); + + for (i = 0; i < ntuples; i++) + { + slot_getallattrs(slots[i]); + formed_tuples[i] = FluxFormTuple(RelationGetDescr(relation), + slots[i]->tts_values, + slots[i]->tts_isnull, + NULL, /* no overflow in batch */ + NULL); + + /* Set MVCC fields (heap-shaped xmin/xmax) */ + formed_tuples[i]->t_data->t_commit_ts = 0; /* t_xmax = Invalid */ + formed_tuples[i]->t_data->t_flags |= FLUX_TUPLE_UNCOMMITTED; + formed_tuples[i]->t_data->t_xmin = my_xid; + + /* + * WS-PVS1: reserve the trailing version-pointer slot at INSERT time so + * the first UPDATE is a same-length overwrite (see flux_tuple_insert + * for the rationale). Done before the size check below so oversize + * routing accounts for the base+8 on-page footprint. + */ + formed_tuples[i]->t_data->t_flags |= FLUX_TUPLE_HAS_VERSION_PTR; + FluxTupleSetVersionPtr(formed_tuples[i]->t_data, + formed_tuples[i]->t_len, + InvalidRelUndoRecPtr); + + /* Mark tuples too large for batch insert */ + if (formed_tuples[i]->t_len > FLUX_MAX_TUPLE_SIZE) + needs_single_insert[i] = true; + } + + /* + * Phase 2: Batch insert page-at-a-time. + * + * For each page: lock once, insert all fitting tuples, WAL-log once, + * unlock. This is much faster than per-tuple buffer operations. + * + * We register each inserted tuple in the backend-local tracked-key list + * (via SLogTupleTrackLocalOnly) so that FluxClearUncommittedFlags() can + * find and stamp them at commit time. Without this, COPY-inserted tuples + * retain FLUX_TUPLE_UNCOMMITTED permanently and are invisible to all + * snapshots. + */ + FluxEnsureSLogCallbacks(); + ndone = 0; + while (ndone < ntuples) + { + Buffer buffer; + Page page; + BlockNumber target_block; + int batch_start; + int batch_count; + int nfit; + Size saveFreeSpace; + Size avail; + OffsetNumber *offnums = NULL; + FluxLogicalImage *logical_imgs = NULL; + FluxLogicalImage combined_image = {NULL, 0}; + bool need_logical = RelationIsLogicallyLogged(relation); + + /* Skip tuples that need single-insert (overflow) */ + if (needs_single_insert[ndone]) + { + flux_tuple_insert(relation, slots[ndone], cid, options, bistate); + FluxFreeTuple(formed_tuples[ndone]); + ndone++; + continue; + } + + /* Find a page with space for at least one tuple (fill-factor aware) */ + saveFreeSpace = RelationGetTargetPageFreeSpace(relation, + FLUX_DEFAULT_FILLFACTOR); + target_block = FluxGetPageWithFreeSpace(relation, + formed_tuples[ndone]->t_len + saveFreeSpace); + if (target_block == InvalidBlockNumber) + { + /* Fall back to single insert */ + flux_tuple_insert(relation, slots[ndone], cid, options, bistate); + FluxFreeTuple(formed_tuples[ndone]); + ndone++; + continue; + } + + buffer = ReadBuffer(relation, target_block); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + /* + * Determine how many tuples will fit on this page BEFORE entering the + * critical section. Logical-decoding images and the offnums array must + * be allocated outside the crit section, so we need the count up front. + * This mirrors heap_multi_insert(): walk the pending tuples, subtracting + * each tuple's (line-pointer + aligned body) cost from the page's free + * space until the next one would not fit, honouring the fill-factor + * reserve and stopping at the first overflow tuple. + */ + avail = PageGetFreeSpace(page); + /* + * Honour the fill-factor reserve while PACKING, not just while choosing + * the target block. FLUX has stable TIDs and updates rows in place, so + * a page packed 100%% full leaves no room for later in-place growth and + * a growing UPDATE (e.g. an accumulating numeric) aborts with "does not + * fit". Reserve saveFreeSpace bytes here so bulk load (COPY / HammerDB) + * leaves the same headroom the single-insert path does. The "always + * take at least one tuple" rule below still guarantees forward progress + * even when a single tuple exceeds the post-reserve budget. + */ + if (avail > saveFreeSpace) + avail -= saveFreeSpace; + nfit = 0; + while (ndone + nfit < ntuples && + !needs_single_insert[ndone + nfit]) + { + Size need = sizeof(ItemIdData) + + MAXALIGN(formed_tuples[ndone + nfit]->t_len); + + /* Always take at least one tuple; stop when the next won't fit */ + if (nfit > 0 && need > avail) + break; + avail -= need; + nfit++; + } + Assert(nfit > 0); + + offnums = (OffsetNumber *) palloc(nfit * sizeof(OffsetNumber)); + if (need_logical) + { + Size total = 0; + char *p; + + logical_imgs = (FluxLogicalImage *) + palloc(nfit * sizeof(FluxLogicalImage)); + for (i = 0; i < nfit; i++) + FluxXLogPrepareLogicalImage(relation, + formed_tuples[ndone + i], + &logical_imgs[i]); + + /* + * Serialize every image into one contiguous blob, framed exactly as + * FluxXLogRegisterLogicalImage emits a single image: each frame is + * "[heap bytes][uint32 len]" in tuple order. The multi-insert + * emitter registers this blob as a single rdata chunk, keeping the + * registered-data slot count constant regardless of batch size (the + * old per-tuple registration overflowed XLR_NORMAL_RDATAS on dense + * pages). We size for all nfit images but register only the prefix + * covering the tuples actually inserted (see below), matching the + * ntuples trailing frames the redo/decode readers expect. + */ + for (i = 0; i < nfit; i++) + if (logical_imgs[i].data != NULL) + total += logical_imgs[i].len + sizeof(uint32); + if (total > 0) + { + combined_image.data = (char *) palloc(total); + p = combined_image.data; + for (i = 0; i < nfit; i++) + { + if (logical_imgs[i].data == NULL) + continue; + memcpy(p, logical_imgs[i].data, logical_imgs[i].len); + p += logical_imgs[i].len; + memcpy(p, &logical_imgs[i].len, sizeof(uint32)); + p += sizeof(uint32); + } + } + } + + /* + * Ensure the current transaction has an XID assigned BEFORE entering + * the critical section (GetCurrentTransactionId may lock and allocate, + * both forbidden in a crit section). An assigned XID is required for + * correctness: a multi-insert WAL record without an attached xid cannot + * be decoded into a logical replication stream (ReorderBuffer groups + * changes by xid and asserts the xid is valid), and a WAL-emitting + * transaction would otherwise be treated as read-only. This mirrors + * flux_tuple_insert() and heap_multi_insert(). + */ + if (RelationNeedsWAL(relation)) + (void) GetCurrentTransactionId(); + + /* Pre-allocate WAL space outside critical section */ + if (RelationNeedsWAL(relation)) + XLogEnsureRecordSpace(XLR_MAX_BLOCK_ID, 4); + + START_CRIT_SECTION(); + + batch_start = ndone; + batch_count = 0; + + /* Insert the tuples we sized for above */ + while (batch_count < nfit) + { + OffsetNumber offnum; + ItemId inserted_itemid; + FluxTupleHeader *inserted_hdr; + + offnum = FluxPageAddTuple(page, formed_tuples[ndone], + formed_tuples[ndone]->t_len); + if (offnum == InvalidOffsetNumber) + break; /* page is full, stop batching */ + + /* Set TID in slot */ + ItemPointerSet(&slots[ndone]->tts_tid, target_block, offnum); + formed_tuples[ndone]->t_self = slots[ndone]->tts_tid; + slots[ndone]->tts_tableOid = RelationGetRelid(relation); + /* Set t_ctid to self */ + inserted_itemid = PageGetItemId(page, offnum); + inserted_hdr = (FluxTupleHeader *) PageGetItem(page, inserted_itemid); + ItemPointerSet(&inserted_hdr->t_ctid, target_block, offnum); + + offnums[batch_count] = offnum; + batch_count++; + ndone++; + } + + if (batch_count > 0) + { + /* Update page opaque fields */ + FluxPageOpaque phdr = FluxPageGetOpaque(page); + + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), current_ts)); + + MarkBufferDirty(buffer); + + /* + * WAL-log the whole batch in one record. Every tuple body is + * logged individually (no forced full-page image), so recovery is + * crash-safe even with full_page_writes=off, and one logical- + * decoding image per tuple is appended for logically-logged + * relations so COPY'd rows replicate. + */ + if (RelationNeedsWAL(relation)) + { + XLogRecPtr recptr; + FluxLogicalImage *emit_image = NULL; + + /* + * Register only the framed-image prefix covering the tuples we + * actually inserted. batch_count can be < nfit if the page + * filled mid-loop, and the redo/decode readers walk exactly + * ntuples (== batch_count) trailing frames, so a longer blob + * would misalign their backward walk. + */ + if (combined_image.data != NULL) + { + Size prefix = 0; + + for (i = 0; i < batch_count; i++) + if (logical_imgs[i].data != NULL) + prefix += logical_imgs[i].len + sizeof(uint32); + combined_image.len = (uint32) prefix; + if (prefix > 0) + emit_image = &combined_image; + } + + recptr = FluxXLogMultiInsert(relation, buffer, + offnums, + &formed_tuples[batch_start], + batch_count, current_ts, + emit_image); + PageSetLSN(page, recptr); + } + } + + END_CRIT_SECTION(); + + if (logical_imgs != NULL) + { + for (i = 0; i < nfit; i++) + FluxXLogReleaseLogicalImage(&logical_imgs[i]); + pfree(logical_imgs); + } + if (combined_image.data != NULL) + pfree(combined_image.data); + pfree(offnums); + + /* + * Register each inserted tuple in the backend-local tracked-key list. + * This is lightweight (no shared hash entry, no LWLock) — just a + * palloc'd linked-list node per tuple. FluxClearUncommittedFlags() + * iterates this list at PRE_COMMIT to clear the UNCOMMITTED flag, + * making the tuples visible. + * + * We skip full sLog registration (shared hash) because COPY doesn't + * use SNAPSHOT_DIRTY or ON CONFLICT, and the local tracking is + * sufficient for commit-time processing. + */ + { + TransactionId xid = GetTopTransactionId(); + TransactionId subxid = GetCurrentSubTransactionId(); + + for (i = batch_start; i < batch_start + batch_count; i++) + { + SLogTupleTrackLocalOnly(RelationGetRelid(relation), + &slots[i]->tts_tid, + xid, subxid); + } + } + + /* Update FSM with remaining free space */ + FluxRecordFreeSpace(relation, target_block, PageGetFreeSpace(page)); + + /* Update visibility map */ + FluxVMUpdateForInsert(relation, formed_tuples[batch_start]->t_data, + buffer); + + UnlockReleaseBuffer(buffer); + + /* + * Count the batched inserts here rather than once for all ntuples at + * the end: tuples routed to the single-insert fallback are already + * counted by their own flux_tuple_insert call, so counting the whole + * ntuples at the tail would double-count them. + */ + pgstat_count_heap_insert(relation, batch_count); + + /* Free formed tuples in this batch */ + for (i = batch_start; i < batch_start + batch_count; i++) + FluxFreeTuple(formed_tuples[i]); + } + + pfree(formed_tuples); + pfree(needs_single_insert); +} + +/* + * FluxVacuumCrossPageDefrag - move live tuples from tail pages to front pages + * + * After single-page defragmentation (Phase III), pages near the end of the + * relation may still contain live tuples, preventing truncation. This + * function moves those tuples to pages near the front that have sufficient + * free space, thereby emptying tail pages so that Phase V can truncate them. + * + * Algorithm: + * 1. Scan backwards from the end of the relation to find source pages + * that have live tuples and could be emptied. + * 2. For each source page, find target pages near the front with enough + * free space (via the FLUX free space map). + * 3. Move each live tuple: copy to target page, insert new index entries + * pointing to the new TID, then mark the old line pointer unused. + * 4. WAL-log all modifications for crash safety. + * + * Locking protocol: + * We acquire an exclusive lock on the source page (higher block number). + * For each tuple move, we lock the target page exclusively while holding + * the source lock. Since we always hold the higher-numbered page first, + * this maintains a consistent lock ordering and avoids deadlocks. VACUUM + * also holds a heavyweight lock on the relation. + * + * We skip: + * - Pages with overflow records (complex linked structure) + * - Pages with tuples that have update chains (t_ctid != self) + * - Pages with deleted-but-not-yet-vacuumed tuples + * + * Returns the number of tuples moved. + */ +static int +FluxVacuumCrossPageDefrag(Relation rel, BlockNumber nblocks, + BlockNumber *empty_end_pages_p, + int nindexes, Relation *indrels, + BufferAccessStrategy bstrategy) +{ + BlockNumber src_blkno; + BlockNumber nonempty_limit; + int tuples_moved = 0; + int pages_emptied = 0; + EState *estate = NULL; + TupleTableSlot *slot = NULL; + IndexInfo **indexInfoArray = NULL; + + /* + * A deferred index-maintenance record: an off-page copy of a moved tuple + * plus its new TID. We must NOT call index_insert() while holding any + * table buffer content lock -- concurrent index bottom-up deletion locks + * the index leaf first and then the table page (flux_index_delete_tuples), + * so inserting into an index under the source/target page lock would form + * an AB-BA deadlock on buffer content locks, which the deadlock detector + * cannot see. Instead we accumulate the moves for a source page and + * replay them after releasing that page's lock. + */ + typedef struct FluxDefragMove + { + char *tupcopy; + Size tuplen; + ItemPointerData new_tid; + } FluxDefragMove; + + /* + * Cross-page defrag relocates a live tuple to a lower-numbered page, + * which changes its TID. FLUX's whole design guarantees stable TIDs + * (UPDATE is performed in place, keeping the same TID); its index integration and the + * UPDATE path (which ereport()s rather than move a tuple that no longer + * fits -- see "FLUX tuples have stable TIDs and cannot move to another + * page") both depend on that invariant. We can insert a new index entry + * for the new TID, but there is no cheap primitive to remove the OLD + * entry for the pre-move TID (btree reclaims dead entries only via + * ambulkdelete, not point-deletes). Leaving the stale entry behind and + * then truncating the vacated tail page orphans it: an index scan hitting + * that entry fails with "could not read blocks N..N". So when the + * relation has indexes we must not move tuples at all -- matching plain + * heap lazy VACUUM, which never relocates tuples precisely because it + * cannot cheaply update index pointers. The tail pages simply are not + * truncated this pass; a later VACUUM (or a REINDEX) can reclaim them. + * + * ponytail: skip-the-move when indexes exist. The optimal fix (relocate + * and keep every index consistent) needs a moved-from-TID bulk-delete + * pass per index; add that if tail reclamation on indexed FLUX tables + * ever measurably matters. + */ + if (nindexes > 0) + return 0; + + /* + * Compute the boundary: we only try to empty pages from position (nblocks + * - empty_end_pages - 1) backwards toward the "used" portion. If there + * are already empty_end_pages trailing, the first candidate source page + * is right before that run. + */ + if (*empty_end_pages_p >= nblocks) + return 0; + + nonempty_limit = nblocks - *empty_end_pages_p; + + /* Need at least a few pages to make defrag worthwhile */ + if (nonempty_limit <= 1) + return 0; + + /* + * Cross-page defragmentation is an internal heuristic whose page-by-page + * progress depends on FSM search order and tuple packing, both of which + * vary across platforms and BLCKSZ. Report it at DEBUG2 rather than INFO + * so that VACUUM VERBOSE output stays stable; the per-relation summary + * below (emitted at INFO) carries the user-meaningful result. + */ + ereport(DEBUG2, + (errmsg_internal("table \"%s\": starting cross-page defragmentation from block %u", + RelationGetRelationName(rel), + nonempty_limit - 1))); + + /* + * Create a single executor state and tuple slot, reused across all index + * insertions to avoid repeated allocation. Also pre-build IndexInfo for + * each index to avoid expensive catalog lookups inside the per-tuple + * loop. + */ + if (nindexes > 0) + { + estate = CreateExecutorState(); + slot = MakeSingleTupleTableSlot(RelationGetDescr(rel), + table_slot_callbacks(rel)); + GetPerTupleExprContext(estate)->ecxt_scantuple = slot; + + indexInfoArray = (IndexInfo **) palloc(nindexes * sizeof(IndexInfo *)); + for (int i = 0; i < nindexes; i++) + indexInfoArray[i] = BuildIndexInfo(indrels[i]); + } + + /* + * Scan backwards from the last candidate source page. Favor moving + * tuples from the highest-numbered pages first, as this maximizes the + * contiguous run of empty tail pages available for truncation. + */ + for (src_blkno = nonempty_limit - 1; + src_blkno != InvalidBlockNumber && src_blkno > 0; + src_blkno--) + { + Buffer src_buf; + Page src_page; + OffsetNumber maxoff; + OffsetNumber offnum; + bool page_emptied = true; + bool skip_page = false; + int ntuples_on_page = 0; + FluxDefragMove *deferred_moves = NULL; + int ndeferred = 0; + + CHECK_FOR_INTERRUPTS(); + + /* Read and exclusively lock the source page */ + src_buf = ReadBufferExtended(rel, MAIN_FORKNUM, src_blkno, + RBM_NORMAL, bstrategy); + LockBuffer(src_buf, BUFFER_LOCK_EXCLUSIVE); + src_page = BufferGetPage(src_buf); + + /* Skip new or empty pages */ + if (PageIsNew(src_page) || PageIsEmpty(src_page)) + { + UnlockReleaseBuffer(src_buf); + continue; + } + + /* + * First pass: check the page for suitability. + * + * We skip pages that have overflow records, deleted tuples that + * haven't been vacuumed yet, or tuples with update chains. + */ + maxoff = PageGetMaxOffsetNumber(src_page); + for (offnum = FirstOffsetNumber; offnum <= maxoff; + offnum = OffsetNumberNext(offnum)) + { + ItemId itemid = PageGetItemId(src_page, offnum); + FluxTupleHeader *tuple_hdr; + ItemPointerData self_tid; + + if (!ItemIdIsNormal(itemid)) + { + if (ItemIdIsDead(itemid)) + { + /* Dead items not yet cleaned -- skip page */ + skip_page = true; + break; + } + continue; /* LP_UNUSED slots are fine */ + } + + tuple_hdr = (FluxTupleHeader *) PageGetItem(src_page, itemid); + + /* Skip pages with overflow records -- too complex to relocate */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + { + skip_page = true; + break; + } + + /* Skip pages with tuples that have overflow pointers */ + if (tuple_hdr->t_flags & FLUX_TUPLE_HAS_OVERFLOW) + { + skip_page = true; + break; + } + + /* Skip pages with deleted-but-not-yet-removed tuples */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + skip_page = true; + break; + } + + /* Skip pages with update chains: ctid must point to self */ + ItemPointerSet(&self_tid, src_blkno, offnum); + if (!ItemPointerIsValid(&tuple_hdr->t_ctid) || + !ItemPointerEquals(&tuple_hdr->t_ctid, &self_tid)) + { + skip_page = true; + break; + } + + ntuples_on_page++; + } + + if (skip_page || ntuples_on_page == 0) + { + UnlockReleaseBuffer(src_buf); + continue; + } + + /* + * Deferred index-maintenance list for this source page. Index inserts + * are replayed only after src_buf is unlocked (see FluxDefragMove). + */ + if (nindexes > 0) + { + deferred_moves = (FluxDefragMove *) + palloc(ntuples_on_page * sizeof(FluxDefragMove)); + ndeferred = 0; + } + + /* + * Second pass: move each live tuple to a target page near the front + * of the relation. + */ + for (offnum = FirstOffsetNumber; offnum <= maxoff; + offnum = OffsetNumberNext(offnum)) + { + ItemId src_itemid; + FluxTupleHeader *src_hdr; + Size tuple_len; + Buffer dst_buf; + Page dst_page; + BlockNumber dst_blkno; + OffsetNumber dst_offnum; + ItemPointerData new_tid; + + src_itemid = PageGetItemId(src_page, offnum); + if (!ItemIdIsNormal(src_itemid)) + continue; + + src_hdr = (FluxTupleHeader *) PageGetItem(src_page, src_itemid); + tuple_len = ItemIdGetLength(src_itemid); + + /* + * Find a target page with enough free space. Use the FSM + * directly (GetPageWithFreeSpace) instead of our wrapper + * FluxGetPageWithFreeSpace, because the wrapper reads and locks + * pages to verify free space, which would deadlock against the + * exclusive lock we already hold on the source page. We verify + * the actual free space below, after locking the target page. The + * target must be before the source block to be useful for + * truncation. + */ + dst_blkno = GetPageWithFreeSpace(rel, + tuple_len + sizeof(ItemIdData)); + if (dst_blkno == InvalidBlockNumber || dst_blkno >= src_blkno) + { + page_emptied = false; + continue; + } + + /* + * Lock the target page. Lock ordering is safe: we hold the + * higher-numbered source page lock already. + */ + dst_buf = ReadBufferExtended(rel, MAIN_FORKNUM, dst_blkno, + RBM_NORMAL, bstrategy); + LockBuffer(dst_buf, BUFFER_LOCK_EXCLUSIVE); + dst_page = BufferGetPage(dst_buf); + + /* Recheck free space -- FSM might be stale */ + if (PageGetFreeSpace(dst_page) < tuple_len + sizeof(ItemIdData)) + { + Size actual_free = PageGetFreeSpace(dst_page); + + UnlockReleaseBuffer(dst_buf); + + /* Update FSM with accurate info */ + FluxRecordFreeSpace(rel, dst_blkno, actual_free); + page_emptied = false; + continue; + } + + /* + * Perform the move in a critical section. Both pages are + * modified atomically from WAL's perspective. + */ + START_CRIT_SECTION(); + + /* Insert the tuple data into the target page */ + dst_offnum = PageAddItem(dst_page, src_hdr, tuple_len, + InvalidOffsetNumber, false, false); + + if (dst_offnum == InvalidOffsetNumber) + { + END_CRIT_SECTION(); + UnlockReleaseBuffer(dst_buf); + page_emptied = false; + continue; + } + + /* Update ctid in the new copy to point to itself */ + { + ItemId dst_itemid; + FluxTupleHeader *dst_hdr; + + dst_itemid = PageGetItemId(dst_page, dst_offnum); + dst_hdr = (FluxTupleHeader *) PageGetItem(dst_page, dst_itemid); + ItemPointerSet(&dst_hdr->t_ctid, dst_blkno, dst_offnum); + } + + ItemPointerSet(&new_tid, dst_blkno, dst_offnum); + + /* Mark the source line pointer as unused */ + ItemIdSetUnused(src_itemid); + + /* Mark both buffers dirty */ + MarkBufferDirty(dst_buf); + MarkBufferDirty(src_buf); + + /* WAL-log the cross-page move */ + if (RelationNeedsWAL(rel)) + { + XLogRecPtr recptr; + + /* + * Use the dedicated cross-page defrag record type. This logs + * both pages (with FPIs when needed) plus the tuple data for + * non-FPI replay. Block 0 = target, block 1 = source. + */ + recptr = FluxXLogCrossPageDefrag(rel, + dst_buf, dst_offnum, + src_buf, offnum, + src_hdr, (uint32) tuple_len); + PageSetLSN(dst_page, recptr); + PageSetLSN(src_page, recptr); + } + + END_CRIT_SECTION(); + + /* Update FSM for the target page */ + FluxRecordFreeSpace(rel, dst_blkno, + PageGetFreeSpace(dst_page)); + + /* + * If the relation has indexes, copy the moved tuple off-page now, + * while we still hold dst_buf, and defer the index insertion until + * after every table page lock for this source page is released. + * Calling index_insert() under a table content lock would invert + * the lock order used by concurrent index bottom-up deletion + * (flux_index_delete_tuples locks the index leaf, then the table + * page) and deadlock on buffer content locks. + */ + if (nindexes > 0) + { + ItemId moved_itemid = PageGetItemId(dst_page, dst_offnum); + FluxTupleHeader *moved_hdr = + (FluxTupleHeader *) PageGetItem(dst_page, moved_itemid); + FluxDefragMove *mv = &deferred_moves[ndeferred++]; + + mv->tuplen = ItemIdGetLength(moved_itemid); + mv->tupcopy = (char *) palloc(mv->tuplen); + memcpy(mv->tupcopy, moved_hdr, mv->tuplen); + mv->new_tid = new_tid; + } + + UnlockReleaseBuffer(dst_buf); + + tuples_moved++; + } + + /* Update FSM for the source page */ + FluxRecordFreeSpace(rel, src_blkno, + PageGetFreeSpace(src_page)); + + UnlockReleaseBuffer(src_buf); + + /* + * Now that no table buffer content lock is held, replay the deferred + * index insertions for the tuples moved off this source page. Working + * from off-page copies is safe: pages carrying overflow pointers were + * skipped in the first pass, so every copied tuple is self-contained. + */ + if (deferred_moves != NULL) + { + for (int m = 0; m < ndeferred; m++) + { + FluxDefragMove *mv = &deferred_moves[m]; + Datum values[INDEX_MAX_KEYS]; + bool isnull[INDEX_MAX_KEYS]; + + ExecClearTuple(slot); + FluxTupleToSlot((FluxTupleHeader *) mv->tupcopy, slot); + slot->tts_tid = mv->new_tid; + for (int i = 0; i < nindexes; i++) + { + FormIndexDatum(indexInfoArray[i], slot, estate, + values, isnull); + + /* + * Skip uniqueness check since we're relocating an existing + * tuple. + */ + index_insert(indrels[i], values, isnull, &mv->new_tid, + rel, UNIQUE_CHECK_NO, false, + indexInfoArray[i]); + + ResetPerTupleExprContext(estate); + } + + pfree(mv->tupcopy); + } + + pfree(deferred_moves); + deferred_moves = NULL; + ndeferred = 0; + } + + if (page_emptied) + { + pages_emptied++; + + /* + * Extend the trailing empty page count if this page is contiguous + * with the existing run. + */ + if (src_blkno == nblocks - *empty_end_pages_p - 1) + (*empty_end_pages_p)++; + } + else + { + /* + * Once we fail to empty a page, stop: pages below this one won't + * contribute to a contiguous run of trailing empties. + */ + break; + } + } + + /* Clean up executor state and pre-built index info */ + if (indexInfoArray != NULL) + { + for (int i = 0; i < nindexes; i++) + pfree(indexInfoArray[i]); + pfree(indexInfoArray); + } + if (slot != NULL) + ExecDropSingleTupleTableSlot(slot); + if (estate != NULL) + FreeExecutorState(estate); + + if (tuples_moved > 0) + ereport(DEBUG2, + (errmsg_internal("table \"%s\": cross-page defrag moved %d tuples, emptied %d pages", + RelationGetRelationName(rel), + tuples_moved, pages_emptied))); + + return tuples_moved; +} + +/* + * Per-stream state for the Phase I VACUUM scan callback. + */ +typedef struct FluxVacScanState +{ + Relation rel; + BlockNumber current_block; /* last block handed out (InvalidBlockNumber + * before the first call) */ + BlockNumber nblocks; + bool skip_all_frozen; /* honor the VM ALL_FROZEN skip? */ +} FluxVacScanState; + +/* + * Read stream callback for VACUUM Phase I. + * + * Hands the read stream the next block that actually needs scanning so the + * upcoming pages are prefetched under AIO, matching heapam's + * heap_vac_scan_next_block(). Pages marked ALL_FROZEN in the visibility map + * are skipped here (unless DISABLE_PAGE_SKIPPING was requested), so the + * stream never issues I/O for pages VACUUM would immediately discard. + */ +static BlockNumber +flux_vac_scan_next_block(ReadStream *stream, + void *callback_private_data, + void *per_buffer_data) +{ + FluxVacScanState *state = callback_private_data; + BlockNumber next_block = state->current_block + 1; + + for (; next_block < state->nblocks; next_block++) + { + CHECK_FOR_INTERRUPTS(); + + if (state->skip_all_frozen && + FluxVMCheck(state->rel, next_block, FLUX_VM_ALL_FROZEN)) + continue; + + state->current_block = next_block; + return next_block; + } + + state->current_block = state->nblocks; + return InvalidBlockNumber; +} + +/* Truncation lock-acquisition tuning, mirroring heap's lazy_truncate_heap. */ +#define FLUX_TRUNCATE_LOCK_WAIT_INTERVAL 50 /* ms */ +#define FLUX_TRUNCATE_LOCK_TIMEOUT 5000 /* ms */ + +/* + * FluxPageIsEmpty + * + * A FLUX page is truncatable-empty when it holds no line pointer in normal + * state. This deliberately treats a page whose line-pointer array is present + * but consists entirely of LP_UNUSED (and/or LP_DEAD) slots as empty, matching + * the emptiness definition used by cross-page defragmentation (page_emptied): + * defrag relocates every NORMAL tuple forward and leaves the source ItemIds + * LP_UNUSED without resetting pd_lower, so PageGetMaxOffsetNumber() still + * reports a nonzero count. Testing max-offset alone would wrongly classify + * such a defragmented tail page as non-empty and decline to truncate it, + * leaking the trailing pages defrag just emptied. + */ +static bool +FluxPageIsEmpty(Page page) +{ + OffsetNumber maxoff; + + if (PageIsNew(page)) + return true; + + maxoff = PageGetMaxOffsetNumber(page); + for (OffsetNumber offnum = FirstOffsetNumber; offnum <= maxoff; + offnum = OffsetNumberNext(offnum)) + { + ItemId itemid = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(itemid)) + return false; + } + + return true; +} + +/* + * FluxCountNondeletablePages + * + * Scan backwards from the end of the relation and return the block number of + * the first (lowest-numbered) trailing page that is NOT empty, i.e. the number + * of blocks the relation should be truncated to. A page counts as empty when + * it has no line pointers in normal state. + * + * The caller MUST hold AccessExclusiveLock on the relation so that no other + * backend can add a tuple to a trailing page between this recount and the + * subsequent RelationTruncate(). This is the FLUX analogue of heap's + * count_nondeletable_pages() and is *necessary*, not optional: the forward + * scan that computed empty_end_pages ran under ShareUpdateExclusiveLock, so a + * concurrent INSERT could have populated a page that was empty at scan time. + */ +static BlockNumber +FluxCountNondeletablePages(Relation onerel) +{ + BlockNumber blkno = RelationGetNumberOfBlocks(onerel); + + while (blkno > 0) + { + Buffer buf; + Page page; + bool empty; + + CHECK_FOR_INTERRUPTS(); + + buf = ReadBuffer(onerel, blkno - 1); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + empty = FluxPageIsEmpty(page); + UnlockReleaseBuffer(buf); + + if (!empty) + break; + + blkno--; + } + + return blkno; +} + +/* + * FluxTruncateRelation + * + * Truncate trailing empty pages off a FLUX relation, mirroring heap's + * lazy_truncate_heap() locking protocol. We acquire AccessExclusiveLock + * conditionally (giving up rather than blocking or deadlocking against the + * lower-grade ShareUpdateExclusiveLock we already hold), re-verify under that + * lock that the trailing pages are still empty, then truncate and release. + * + * Doing the truncation under only ShareUpdateExclusiveLock (as the previous + * code did) races concurrent INSERT/extension: a backend could compute a + * target block, then find that block removed by our smgrtruncate, and + * ReadBuffer() it past EOF ("unexpected data beyond EOF in block N"), or worse + * silently lose a tuple written to a page we truncate away. + */ +static void +FluxTruncateRelation(Relation onerel, BlockNumber orig_nblocks, + BlockNumber desired_nblocks, bool verbose) +{ + BlockNumber new_nblocks; + int lock_retry = 0; + + /* + * Acquire AccessExclusiveLock, retrying with a bounded timeout. If a + * conflicting lock request arrives we give up truncating rather than + * block other backends or deadlock. + */ + while (!ConditionalLockRelation(onerel, AccessExclusiveLock)) + { + CHECK_FOR_INTERRUPTS(); + + if (++lock_retry > (FLUX_TRUNCATE_LOCK_TIMEOUT / + FLUX_TRUNCATE_LOCK_WAIT_INTERVAL)) + { + ereport(verbose ? INFO : DEBUG2, + (errmsg("\"%s\": stopping truncate due to conflicting lock request", + RelationGetRelationName(onerel)))); + return; + } + + (void) WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + FLUX_TRUNCATE_LOCK_WAIT_INTERVAL, + WAIT_EVENT_VACUUM_TRUNCATE); + ResetLatch(MyLatch); + } + + /* + * Under the exclusive lock, re-check that the relation hasn't grown since + * the vacuum scan. If it has, the new pages presumably hold live tuples; + * give up. + */ + if (RelationGetNumberOfBlocks(onerel) != orig_nblocks) + { + UnlockRelation(onerel, AccessExclusiveLock); + return; + } + + /* + * Rescan the tail under the exclusive lock to confirm the pages we intend + * to drop are still empty. A concurrent INSERT under the lower-grade lock + * could have repopulated them after the vacuum scan. + */ + new_nblocks = FluxCountNondeletablePages(onerel); + if (new_nblocks < desired_nblocks) + new_nblocks = desired_nblocks; + + if (new_nblocks >= orig_nblocks) + { + /* Nothing to truncate after re-verification. */ + UnlockRelation(onerel, AccessExclusiveLock); + return; + } + + RelationTruncate(onerel, new_nblocks); + + /* + * Release the exclusive lock as soon as the truncation is done. Other + * backends process the smgr invalidation smgrtruncate sent out when they + * next acquire a lock on the relation. + */ + UnlockRelation(onerel, AccessExclusiveLock); + + if (verbose) + ereport(INFO, (errmsg("truncated \"%s\" from %u to %u pages", + RelationGetRelationName(onerel), + orig_nblocks, new_nblocks))); +} + +/* + * Vacuum a FLUX relation + * + * This performs garbage collection on a FLUX table in multiple phases: + * + * Phase I: Scan all pages, identify dead tuples, collect their TIDs + * Phase II: Remove dead index entries using the collected TIDs + * Phase III: Defragment data pages to reclaim space (must happen AFTER + * index cleanup to avoid dangling index pointers) + * Phase IV: Post-vacuum index cleanup (amvacuumcleanup) + * Phase IV-B: Cross-page defragmentation (move tail tuples to front pages) + * Phase V: Truncate trailing empty pages, update FSM + */ +void +flux_relation_vacuum(Relation onerel, const VacuumParams *params, + BufferAccessStrategy bstrategy) +{ + BlockNumber nblocks; + BlockNumber blkno; + Buffer buf; + Page page; + uint64 oldest_ts; + TransactionId oldest_xmin_vac; + int64 num_tuples = 0; + int64 dead_tuples = 0; + int64 live_tuples = 0; + int64 pages_vacuumed = 0; + BlockNumber empty_end_pages = 0; + bool verbose = (params->options & VACOPT_VERBOSE) != 0; + + /* Index cleanup state */ + Relation *indrels = NULL; + int nindexes = 0; + IndexBulkDeleteResult **indstats = NULL; + TidStore *dead_items = NULL; + VacDeadItemsInfo *dead_items_info = NULL; + bool do_index_cleanup; + + /* + * Initialize FLUX transaction state so that VACUUM's own start timestamp + * is registered in xact_start_ts_slots. Without this, + * FluxGetOldestActiveTimestamp() would see no active transactions and + * fall back to the current wall clock (GetCurrentTimestamp()), which for + * the VM all-visible hint would over-eagerly mark tuples all-visible. + * (The reclamation gate itself is the XID horizon below, not this ts.) + */ + (void) FluxGetTransactionTimestamp(); + + /* + * Get the oldest active transaction's start timestamp. Deleted tuples + * whose commit timestamp is older than this are no longer visible to any + * running transaction and can safely be removed. + * + * Previously this called FluxGetCommitTimestamp() which returns (and + * advances) the current wall-clock time. That was wrong: it made VACUUM + * consider almost all deleted tuples as reclaimable, even those still + * needed by long-running concurrent transactions. + */ + oldest_ts = FluxGetOldestActiveTimestamp(); + oldest_xmin_vac = FluxGetOldestXminHorizon(onerel); + nblocks = RelationGetNumberOfBlocks(onerel); + + if (verbose) + ereport(INFO, (errmsg("vacuuming \"%s\": scanning %u pages", + RelationGetRelationName(onerel), nblocks))); + + /* + * Open all indexes on the relation. We need RowExclusiveLock to prevent + * concurrent index modifications during vacuum. + */ + vac_open_indexes(onerel, RowExclusiveLock, &nindexes, &indrels); + do_index_cleanup = (nindexes > 0); + + /* + * Allocate TidStore for collecting dead tuple TIDs, and per-index stats + * array. We use maintenance_work_mem as the budget for the TidStore. + */ + if (do_index_cleanup) + { + dead_items_info = (VacDeadItemsInfo *) palloc0(sizeof(VacDeadItemsInfo)); + dead_items_info->max_bytes = (size_t) maintenance_work_mem * 1024; + dead_items_info->num_items = 0; + + dead_items = TidStoreCreateLocal(dead_items_info->max_bytes, true); + + indstats = (IndexBulkDeleteResult **) + palloc0(nindexes * sizeof(IndexBulkDeleteResult *)); + } + + /* + * ----------------------------------------------------------------------- + * Phase I: Scan all pages, identify dead tuples, collect TIDs + * + * We scan every page and classify each tuple as live, dead (removable), + * or recently dead (not yet removable). Dead tuple TIDs are recorded in + * the TidStore for later index cleanup. We do NOT defragment pages yet + * -- that must wait until after index entries pointing to dead tuples + * have been removed (Phase II), to avoid dangling index pointers. + * ----------------------------------------------------------------------- + */ + { + ReadStream *scan_stream; + FluxVacScanState scan_state; + + scan_state.rel = onerel; + scan_state.current_block = InvalidBlockNumber; + scan_state.nblocks = nblocks; + scan_state.skip_all_frozen = + !(params->options & VACOPT_DISABLE_PAGE_SKIPPING); + + scan_stream = read_stream_begin_relation(READ_STREAM_MAINTENANCE | + READ_STREAM_USE_BATCHING, + bstrategy, + onerel, + MAIN_FORKNUM, + flux_vac_scan_next_block, + &scan_state, + 0); + + while ((buf = read_stream_next_buffer(scan_stream, NULL)) != InvalidBuffer) + { + OffsetNumber offnum, + maxoffnum; + ItemId itemid; + OffsetNumber dead_offsets[MaxOffsetNumber]; + int ndead_on_page = 0; + + CHECK_FOR_INTERRUPTS(); + + blkno = BufferGetBlockNumber(buf); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + /* Skip if page is new/uninitialized */ + if (PageIsNew(page)) + { + UnlockReleaseBuffer(buf); + continue; + } + + maxoffnum = PageGetMaxOffsetNumber(page); + + /* Scan all tuples on the page */ + for (offnum = FirstOffsetNumber; offnum <= maxoffnum; offnum++) + { + FluxTupleHeader *tuple_hdr; + + itemid = PageGetItemId(page, offnum); + + /* + * LP_DEAD line pointers were produced by opportunistic pruning + * (FluxPagePruneOpt / UPDATE defrag-fit), which reclaims a + * committed-deleted tuple's storage but reserves its TID so it is + * not recycled while index entries still reference it. VACUUM must + * record these TIDs for index cleanup (Phase II) before Phase III + * converts them to LP_UNUSED. Their storage is already gone, so + * there is no tuple header to inspect. + */ + if (ItemIdIsDead(itemid)) + { + if (do_index_cleanup) + { + dead_offsets[ndead_on_page++] = offnum; + dead_tuples++; + } + continue; + } + + /* Skip if not a normal tuple */ + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records - they are managed by tuple lifecycle */ + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + num_tuples++; + + /* + * Check if tuple is deleted and old enough to be removed. + * + * With the sLog-based MVCC model, a deleted tuple can be vacuumed + * if: - FLUX_TUPLE_DELETED is set - FLUX_TUPLE_UNCOMMITTED is + * NOT set (committed delete) - commit_ts is older than the oldest + * active snapshot + * + * If UNCOMMITTED is still set, the deleting transaction is still + * in progress (or aborted but not yet cleaned up by the sLog + * callback). Skip it. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + /* Transaction still in progress -- skip */ + live_tuples++; + } + else if (FluxTupleDeadToAll(tuple_hdr, oldest_xmin_vac)) + { + /* Tuple is dead and can be removed */ + dead_offsets[ndead_on_page++] = offnum; + dead_tuples++; + } + else + { + /* Recently dead -- not yet reclaimable but still dead */ + dead_tuples++; + } + } + else + { + /* Tuple is live */ + live_tuples++; + } + } + + /* + * Record dead tuple TIDs for this page in the TidStore. This is + * needed for index cleanup in Phase II. + */ + if (ndead_on_page > 0 && do_index_cleanup) + { + TidStoreSetBlockOffsets(dead_items, blkno, + dead_offsets, ndead_on_page); + dead_items_info->num_items += ndead_on_page; + } + + UnlockReleaseBuffer(buf); + } + + read_stream_end(scan_stream); + } + + /* + * ----------------------------------------------------------------------- + * Phase II: Index vacuum -- remove dead index entries + * + * For each index on the relation, call the index AM's bulk delete routine + * to remove entries pointing to dead tuples. This MUST happen before we + * defragment data pages (Phase III) to ensure no index entry points to a + * TID that has been recycled. + * ----------------------------------------------------------------------- + */ + if (do_index_cleanup && dead_items_info->num_items > 0) + { + int idx; + + if (verbose) + ereport(INFO, + (errmsg("vacuuming \"%s\": removing %lld dead index entries across %d indexes", + RelationGetRelationName(onerel), + (long long) dead_items_info->num_items, + nindexes))); + + for (idx = 0; idx < nindexes; idx++) + { + IndexVacuumInfo ivinfo; + + ivinfo.index = indrels[idx]; + ivinfo.heaprel = onerel; + ivinfo.analyze_only = false; + ivinfo.report_progress = false; + ivinfo.estimated_count = true; + ivinfo.message_level = verbose ? INFO : DEBUG2; + ivinfo.num_heap_tuples = (double) live_tuples; + ivinfo.strategy = bstrategy; + + indstats[idx] = vac_bulkdel_one_index(&ivinfo, indstats[idx], + dead_items, + dead_items_info); + } + } + + /* + * ----------------------------------------------------------------------- + * Phase III: Defragment data pages -- remove dead tuples from heap + * + * Now that index entries pointing to dead tuples have been removed, we + * can safely defragment data pages. This reclaims the space occupied by + * dead tuples and makes it available for reuse. + * ----------------------------------------------------------------------- + */ + { + ReadStream *stream; + BlockRangeReadStreamPrivate stream_private; + + stream_private.current_blocknum = 0; + stream_private.last_exclusive = nblocks; + + stream = read_stream_begin_relation(READ_STREAM_MAINTENANCE | + READ_STREAM_USE_BATCHING, + bstrategy, + onerel, + MAIN_FORKNUM, + block_range_read_stream_cb, + &stream_private, + 0); + + while ((buf = read_stream_next_buffer(stream, NULL)) != InvalidBuffer) + { + OffsetNumber offnum, + maxoffnum; + ItemId itemid; + bool page_has_dead_tuples = false; + bool page_modified = false; + + CHECK_FOR_INTERRUPTS(); + + blkno = BufferGetBlockNumber(buf); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + + if (PageIsNew(page)) + { + UnlockReleaseBuffer(buf); + continue; + } + + maxoffnum = PageGetMaxOffsetNumber(page); + + /* + * First pass: clean overflow chains for dead tuples. We must do + * this BEFORE defragmenting, because FluxPageDefragment removes + * dead item pointers and after that we can no longer identify + * which tuples had overflow data. We temporarily drop the + * exclusive lock since overflow chain deletion may need to read + * and lock other pages. + */ + for (offnum = FirstOffsetNumber; offnum <= maxoffnum; offnum++) + { + FluxTupleHeader *tuple_hdr; + + itemid = PageGetItemId(page, offnum); + + /* + * LP_DEAD line pointers were already pruned (storage reclaimed) + * by opportunistic pruning and their TIDs recorded in Phase I + * for index cleanup, which has now run. They carry no storage + * and no overflow chain to clean; just flag the page so the + * defragment pass below converts them to LP_UNUSED. + */ + if (ItemIdIsDead(itemid)) + { + page_has_dead_tuples = true; + continue; + } + + if (!ItemIdIsNormal(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + if (FluxIsOverflowRecordInline(tuple_hdr, ItemIdGetLength(itemid))) + continue; + + if (FluxTupleDeadToAll(tuple_hdr, oldest_xmin_vac)) + { + page_has_dead_tuples = true; + + /* + * Delete any external TOAST datums referenced by this dead + * tuple before it is removed by defragmentation. FLUX has no + * on-page overflow chains; wide values live in the standard + * heap TOAST table and are reclaimed here exactly as heap + * VACUUM reclaims them. Only DELETED tuples are dead-to-all. + */ + if (tuple_hdr->t_infomask & FLUX_INFOMASK_HASEXTERNAL) + { + TupleDesc vtupdesc = RelationGetDescr(onerel); + Datum vvalues[MaxTupleAttributeNumber]; + bool visnull[MaxTupleAttributeNumber]; + FluxTupleData vtup; + + vtup.t_len = ItemIdGetLength(itemid); + vtup.t_data = tuple_hdr; + ItemPointerSet(&vtup.t_self, blkno, offnum); + FluxDeformTuple(onerel, &vtup, vtupdesc, vvalues, visnull); + + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + flux_toast_delete(onerel, vvalues, visnull, false); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + } + } + } + + /* + * If page has dead tuples, defragment it to consolidate space. + */ + if (page_has_dead_tuples) + { + START_CRIT_SECTION(); + + /* + * Mark dead tuples as unused before defragmenting. The scan + * above already identified them; now set LP_UNUSED. Index + * cleanup (Phase II) has removed every index entry pointing at + * these TIDs, so it is now safe to free the line pointers for + * recycling -- both the still-materialized DELETED tuples and + * the already-pruned LP_DEAD placeholders. + */ + for (offnum = FirstOffsetNumber; offnum <= maxoffnum; offnum++) + { + FluxTupleHeader *vac_hdr; + + itemid = PageGetItemId(page, offnum); + + if (ItemIdIsDead(itemid)) + { + ItemIdSetUnused(itemid); + continue; + } + + if (!ItemIdIsNormal(itemid)) + continue; + + if (FluxIsOverflowRecordInline(PageGetItem(page, itemid), + ItemIdGetLength(itemid))) + continue; + + vac_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + if (FluxTupleDeadToAll(vac_hdr, oldest_xmin_vac)) + { + ItemIdSetUnused(itemid); + } + } + + FluxPageDefragment(page); + page_modified = true; + pages_vacuumed++; + + MarkBufferDirty(buf); + + if (RelationNeedsWAL(onerel)) + { + XLogRecPtr recptr; + + recptr = FluxXLogDefrag(onerel, buf, NULL, 0, oldest_ts); + PageSetLSN(page, recptr); + } + + END_CRIT_SECTION(); + } + + /* Update FSM with accurate free space information */ + if (page_modified || PageGetFreeSpace(page) > 0) + { + FluxRecordFreeSpace(onerel, blkno, PageGetFreeSpace(page)); + } + + /* + * Update the Visibility Map for this page. + * + * After defragmentation, check whether all remaining tuples on + * the page are visible to all transactions and/or frozen. If so, + * set the appropriate VM bits. This enables index-only scans to + * skip heap fetches and future VACUUMs to skip this page + * entirely. + */ + { + bool all_visible = true; + bool all_frozen = true; + OffsetNumber vm_offnum; + OffsetNumber vm_maxoff; + + vm_maxoff = PageGetMaxOffsetNumber(page); + + for (vm_offnum = FirstOffsetNumber; vm_offnum <= vm_maxoff; vm_offnum++) + { + ItemId vm_itemid; + FluxTupleHeader *vm_tuple_hdr; + + vm_itemid = PageGetItemId(page, vm_offnum); + if (!ItemIdIsNormal(vm_itemid)) + continue; + + /* Skip overflow records */ + if (FluxIsOverflowRecordInline(PageGetItem(page, vm_itemid), + ItemIdGetLength(vm_itemid))) + continue; + + vm_tuple_hdr = (FluxTupleHeader *) PageGetItem(page, vm_itemid); + + /* + * Dead tuples (deleted) that survived defrag are recently + * dead + */ + if (vm_tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + all_visible = false; + all_frozen = false; + break; + } + + /* Speculative tuples are not visible to all */ + if (vm_tuple_hdr->t_flags & FLUX_TUPLE_SPECULATIVE) + { + all_visible = false; + all_frozen = false; + break; + } + + /* + * A live (not deleted/speculative/uncommitted -- checked + * above) tuple is all-visible AND all-frozen to every possible + * snapshot iff its inserting XID (t_xmin) has committed and + * precedes the oldest-xmin horizon, so no snapshot can fail to + * see it. (The former test compared the whole t_commit_ts word + * against a timestamp horizon -- meaningless in the heap-shaped + * model where that word holds an XID, not an HLC timestamp; + * mirrors FluxTupleDeadToAll's XID-horizon gate.) + */ + if ((vm_tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) || + !TransactionIdIsValid(vm_tuple_hdr->t_xmin) || + !TransactionIdDidCommit(vm_tuple_hdr->t_xmin) || + !TransactionIdPrecedes(vm_tuple_hdr->t_xmin, oldest_xmin_vac)) + { + all_visible = false; + all_frozen = false; + break; + } + } + + /* Empty pages are trivially all-visible and all-frozen */ + if (vm_maxoff < FirstOffsetNumber) + { + all_visible = true; + all_frozen = true; + } + + FluxVMVacuumPage(onerel, buf, all_visible, all_frozen); + } + + /* Check if page is completely empty (for truncation) */ + if (PageGetMaxOffsetNumber(page) < FirstOffsetNumber) + { + if (blkno == nblocks - 1 - empty_end_pages) + empty_end_pages++; + } + else + { + empty_end_pages = 0; + } + + UnlockReleaseBuffer(buf); + } + + read_stream_end(stream); + } + + /* + * ----------------------------------------------------------------------- + * Phase IV: Index cleanup (amvacuumcleanup) + * + * Call each index AM's vacuum cleanup routine. This lets the index AM do + * any post-vacuum maintenance such as reclaiming empty pages, updating + * statistics, etc. This is called even if no dead tuples were found, + * since some index AMs use this to update internal metadata. + * ----------------------------------------------------------------------- + */ + if (do_index_cleanup) + { + int idx; + + for (idx = 0; idx < nindexes; idx++) + { + IndexVacuumInfo ivinfo; + + ivinfo.index = indrels[idx]; + ivinfo.heaprel = onerel; + ivinfo.analyze_only = false; + ivinfo.report_progress = false; + ivinfo.estimated_count = (nblocks > pages_vacuumed); + ivinfo.message_level = verbose ? INFO : DEBUG2; + ivinfo.num_heap_tuples = (double) live_tuples; + ivinfo.strategy = bstrategy; + + indstats[idx] = vac_cleanup_one_index(&ivinfo, indstats[idx]); + } + } + + /* + * ----------------------------------------------------------------------- + * Phase IV-B: Cross-page defragmentation + * + * Move live tuples from tail pages to front pages so that more trailing + * pages become empty and can be truncated in Phase V. This must run + * after index cleanup (Phase IV) so that stale index entries for + * previously-dead tuples have already been removed. Indexes are still + * open so we can insert new entries for relocated tuples. + * ----------------------------------------------------------------------- + */ + FluxVacuumCrossPageDefrag(onerel, nblocks, + &empty_end_pages, + nindexes, indrels, + bstrategy); + + /* + * ----------------------------------------------------------------------- + * Phase IV-C: Orphan overflow cleanup + * + * Run the two-pass orphan detection algorithm to find and remove overflow + * records that are not referenced by any live tuple. This catches + * overflow records that were orphaned by crashes, aborted transactions, + * or bugs in the eager cleanup path. + * ----------------------------------------------------------------------- + */ + /* FLUX has no on-page overflow records to vacuum (wide values use TOAST). */ + + /* + * ----------------------------------------------------------------------- + * Phase IV-D: per-relation UNDO fork discard + * + * Reclaim space in the relation's UNDO fork by discarding pages whose + * records are all older than the oldest transaction that could still need + * them for rollback. A fork page is discardable iff its max_xid (the + * largest urec_xid on the page) precedes the cluster-wide removable + * horizon, so no in-progress transaction can roll back into it. Run this + * before truncation so freed pages are returned to the fork's free list. + * ----------------------------------------------------------------------- + */ + RelUndoVacuum(onerel, GetOldestNonRemovableTransactionId(onerel), false); + + /* + * ----------------------------------------------------------------------- + * Phase V: Truncation and final cleanup + * ----------------------------------------------------------------------- + */ + + /* Truncate empty pages at the end of the relation */ + if (empty_end_pages > 0 && (params->options & VACOPT_DISABLE_PAGE_SKIPPING) == 0) + { + BlockNumber new_nblocks = nblocks - empty_end_pages; + + /* + * Truncate under AccessExclusiveLock with a tail re-verification, so + * we never remove a block a concurrent INSERT is targeting. The + * helper may truncate to fewer pages than requested (never more) or + * decline entirely if it can't get the lock or the tail is no longer + * empty. + */ + FluxTruncateRelation(onerel, nblocks, new_nblocks, verbose); + } + + /* Update FSM for the entire relation using the real post-truncate size */ + FluxVacuumFSM(onerel, RelationGetNumberOfBlocks(onerel)); + + /* Clean up index resources */ + if (dead_items != NULL) + TidStoreDestroy(dead_items); + if (dead_items_info != NULL) + pfree(dead_items_info); + if (indstats != NULL) + pfree(indstats); + vac_close_indexes(nindexes, indrels, RowExclusiveLock); + + /* Report statistics */ + if (verbose || params->options & VACOPT_VERBOSE) + { + ereport(INFO, + (errmsg("FLUX vacuum \"%s\": found %lld tuples (%lld live, %lld dead), " + "vacuumed %lld pages, truncated %u pages, " + "cleaned %d indexes", + RelationGetRelationName(onerel), + (long long) num_tuples, + (long long) live_tuples, + (long long) dead_tuples, + (long long) pages_vacuumed, + empty_end_pages, + nindexes))); + } +} + +/* ================================================================ + * sLog transaction callbacks for FLUX + * + * These handle FLUX-specific page operations (clearing UNCOMMITTED flags, + * marking aborted tuples as DELETED) at transaction boundaries. They call + * the generic SLogTuple* functions for shared-hash cleanup. + * ================================================================ + */ + +/* + * Two-phase commit record for FLUX. + * + * One record per tracked tuple is saved at PREPARE time via + * RegisterTwoPhaseRecord(). When COMMIT PREPARED fires, the postcommit + * callback uses these to locate and clear UNCOMMITTED flags. When + * ROLLBACK PREPARED fires, the postabort callback marks tuples as aborted. + */ +typedef struct FluxTwoPhaseRecord +{ + Oid relid; + ItemPointerData tid; + bool local_only; /* INSERT-only: no shared sLog entry */ + SLogOpType op_type; /* INSERT, DELETE, or UPDATE */ +} FluxTwoPhaseRecord; + +/* + * Two-phase info values distinguishing the record kinds saved under + * TWOPHASE_RM_FLUX_ID. info==FLUX_2PC_SLOG carries a FluxTwoPhaseRecord + * (per-tuple sLog/visibility state); info==FLUX_2PC_RELUNDO carries a + * FluxRelUndoTwoPhaseRecord (a per-relation UNDO chain head). + */ +#define FLUX_2PC_SLOG 0 +#define FLUX_2PC_RELUNDO 1 + +/* + * Two-phase commit record for a FLUX per-relation UNDO chain head. + * + * One record per relation touched by the prepared transaction. On ROLLBACK + * PREPARED, flux_twophase_postabort() replays this chain via + * RelUndoApplyChain() to restore in-place before-images -- the physical + * data-restore that the per-tuple sLog records (which only flip visibility + * flags) do not perform. + */ +typedef struct FluxRelUndoTwoPhaseRecord +{ + Oid relid; + RelUndoRecPtr start_urec_ptr; +} FluxRelUndoTwoPhaseRecord; + +/* + * FluxEnsureSLogCallbacks -- register xact/subxact callbacks once per backend. + */ +void +FluxEnsureSLogCallbacks(void) +{ + if (!flux_slog_callbacks_registered) + { + RegisterXactCallback(FluxSLogXactCallback, NULL); + RegisterSubXactCallback(FluxSLogSubXactCallback, NULL); + flux_slog_callbacks_registered = true; + } +} + +/* + * Callback for FluxProcessAbortedEntries: mark aborted INSERT tuples as + * DELETED and remove the ABORTED sLog entry. + * + * After marking DELETED on page, we must remove the shared ABORTED sLog entry. + * Otherwise, post-commit readers would find SLOG_OP_ABORTED and interpret it + * as "a delete was aborted" (tuple still alive), rather than "an INSERT was + * aborted" (tuple is dead). With the sLog entry removed, readers see + * DELETED + slog_nfound==0 → "deletion committed" → invisible. + */ +static bool +flux_process_aborted_cb(const SLogTupleKey *key, + TransactionId xid, TransactionId subxid, + bool local_only, void *arg) +{ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nfound; + int i; + bool has_aborted = false; + + /* Check if this entry has an ABORTED op */ + nfound = SLogTupleLookupFiltered(key->relid, (ItemPointer) &key->tid, + xid, ops, SLOG_MAX_TUPLE_OPS); + for (i = 0; i < nfound; i++) + { + if (ops[i].op_type == SLOG_OP_ABORTED) + { + has_aborted = true; + break; + } + } + + if (has_aborted) + { + Buffer buf; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + OffsetNumber offnum; + Relation rel; + + rel = try_relation_open(key->relid, AccessShareLock); + if (rel == NULL) + return true; /* continue iteration */ + buf = ReadBuffer(rel, ItemPointerGetBlockNumber((ItemPointer) &key->tid)); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + offnum = ItemPointerGetOffsetNumber((ItemPointer) &key->tid); + + if (offnum <= PageGetMaxOffsetNumber(page)) + { + itemid = PageGetItemId(page, offnum); + if (ItemIdIsNormal(itemid)) + { + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + { + tuple_hdr->t_flags |= FLUX_TUPLE_DELETED; + tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + MarkBufferDirty(buf); + } + } + } + + UnlockReleaseBuffer(buf); + relation_close(rel, AccessShareLock); + + /* + * Remove the ABORTED sLog entry now that the page is marked DELETED. + * This ensures post-commit readers see DELETED + no sLog entries, + * which the visibility function correctly interprets as "deletion + * committed" (tuple invisible). + */ + SLogTupleRemove(key->relid, (ItemPointer) &key->tid, xid); + } + + return true; /* continue iteration */ +} + +/* + * FluxProcessAbortedEntries -- at COMMIT, mark tuples from rolled-back + * subtransactions as DELETED on their pages. + */ +static void +FluxProcessAbortedEntries(TransactionId xid) +{ + SLogTupleIterateTrackedKeys(xid, flux_process_aborted_cb, NULL); +} + +/* ---------------------------------------------------------------- + * Batched commit-time UNCOMMITTED-flag clearing for + * FluxClearUncommittedFlags. + * + * Commit visibility comes from CLOG (heap-shaped xmin/xmax MVCC); commit + * does NOT stamp any timestamp. This path only clears the on-page + * FLUX_TUPLE_UNCOMMITTED hint flag so later readers skip the sLog + * fast-path lookup. t_xmin/t_xmax written by the DML stay untouched. + * + * The batched approach collects tracked keys, sorts by (relid, blockno), + * and processes them with minimal buffer I/O: + * - One try_relation_open() per distinct relid + * - One ReadBuffer() per distinct block + * - Local-only INSERTs skip the shared sLog lookup entirely + * ---------------------------------------------------------------- + */ +/* + * flux_cmp_tracked_key_by_block -- qsort comparator for batch commit stamping. + * + * Sorts by (relid, blockno, offnum) to enable sequential I/O: one + * try_relation_open per relation, one ReadBuffer per distinct block. + */ +static int +flux_cmp_tracked_key_by_block(const void *a, const void *b) +{ + const SLogTrackedKeyInfo *ka = (const SLogTrackedKeyInfo *) a; + const SLogTrackedKeyInfo *kb = (const SLogTrackedKeyInfo *) b; + + if (ka->key.relid < kb->key.relid) + return -1; + if (ka->key.relid > kb->key.relid) + return 1; + + { + BlockNumber ba = ItemPointerGetBlockNumber((ItemPointer) &ka->key.tid); + BlockNumber bb = ItemPointerGetBlockNumber((ItemPointer) &kb->key.tid); + + if (ba < bb) + return -1; + if (ba > bb) + return 1; + } + + { + OffsetNumber oa = ItemPointerGetOffsetNumber((ItemPointer) &ka->key.tid); + OffsetNumber ob = ItemPointerGetOffsetNumber((ItemPointer) &kb->key.tid); + + if (oa < ob) + return -1; + if (oa > ob) + return 1; + } + + return 0; +} + +/* + * flux_stamp_tuple_committed -- stamp a single tuple at commit time. + * + * Applies the appropriate timestamp and clears FLUX_TUPLE_UNCOMMITTED + * based on the tracked operation type. Called from the batched commit path + * with the buffer already locked exclusive. + * + * For local-only INSERTs (the common case for single-row INSERT), we skip + * the expensive SLogTupleLookupFiltered() call. A local-only INSERT can + * only be savepoint-aborted if SLogTupleRemoveBySubXid created a shared + * ABORTED entry — in which case local_only would have been cleared on the + * tracked key. So if local_only is still true, this is a live INSERT. + */ +static void +flux_stamp_tuple_committed(Buffer buf, OffsetNumber offnum, + const SLogTrackedKeyInfo * tk, + TransactionId xid) +{ + Page page = BufferGetPage(buf); + ItemId itemid; + FluxTupleHeader *tuple_hdr; + SLogOpType found_op_type; + + if (offnum > PageGetMaxOffsetNumber(page)) + return; + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + return; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Determine the effective operation type for this tuple. + * + * A tracked key whose op_type was flipped to SLOG_OP_ABORTED by + * SLogTupleRemoveBySubXid (savepoint rollback) must be skipped: its tuple + * is logically discarded and a shared ABORTED sLog entry already exists + * to enforce invisibility. Clearing UNCOMMITTED here would resurrect it. + * This check must precede the local_only fast path because savepoint- + * aborted INSERTs keep local_only == true. + * + * For local-only entries (INSERTs with no shared sLog entry), skip the + * shared hash lookup entirely — this is the key optimization for INSERT + * workloads. + */ + if (tk->op_type == SLOG_OP_ABORTED) + { + found_op_type = SLOG_OP_ABORTED; + } + else if (tk->local_only) + { + /* Fast path: live local-only INSERT, no shared sLog lookup needed */ + found_op_type = SLOG_OP_INSERT; + } + else if (tk->op_type == SLOG_OP_INSERT || + tk->op_type == SLOG_OP_UPDATE || + tk->op_type == SLOG_OP_DELETE) + { + /* op_type was tracked correctly at insert time -- skip shared lookup */ + found_op_type = tk->op_type; + } + else + { + /* Fallback: unknown op_type, do the expensive lookup */ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nfound; + int i; + + found_op_type = SLOG_ENTRY_ABORTED_TXN; + nfound = SLogTupleLookupFiltered(tk->key.relid, + (ItemPointer) &tk->key.tid, + xid, ops, SLOG_MAX_TUPLE_OPS); + for (i = 0; i < nfound; i++) + { + if (TransactionIdEquals(ops[i].xid, xid)) + { + found_op_type = ops[i].op_type; + break; + } + } + } + + /* + * Skip entries that were aborted by a savepoint rollback. + */ + if (found_op_type == SLOG_OP_ABORTED) + return; + + /* + * For SLOG_ENTRY_ABORTED_TXN with a non-local entry, this means no shared + * entry was found — shouldn't happen for non-local, but treat as skip. + * For unrecognized op types, also skip. + */ + if (!tk->local_only && found_op_type == SLOG_ENTRY_ABORTED_TXN) + return; + if (found_op_type != SLOG_OP_INSERT && + found_op_type != SLOG_OP_UPDATE && + found_op_type != SLOG_OP_DELETE) + return; + + /* + * Clear the UNCOMMITTED flag (a hint bit) for INSERT/UPDATE/DELETE + * tuples. In the heap-shaped xmin/xmax model, commit does NOT stamp any + * timestamp: t_xmin (inserter) and t_xmax (deleter/updater, in the + * t_commit_ts word) were written by the DML and stay untouched. Overwriting + * t_commit_ts here would clobber t_xmax with a bogus value and corrupt + * visibility (a garbage low-32-bits would be read as an xmax XID). CLOG is + * the authoritative commit oracle; clearing the flag is a pure optimization + * so later readers skip the sLog fast-path lookup. + */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; +} + +/* + * flux_batch_clear_uncommitted -- batch-process tracked keys at commit time. + * + * Processes the pre-sorted array of tracked keys with sequential I/O: + * - One try_relation_open() per distinct relid + * - One ReadBuffer() per distinct block within each relation + * - All tuples on the same page are stamped while holding one buffer lock + * + * This replaces the per-tuple callback pattern which did O(n) ReadBuffer + * calls even when multiple tuples shared the same page. + */ +static void +flux_batch_clear_uncommitted(SLogTrackedKeyInfo * keys, int nkeys, + TransactionId xid) +{ + Oid cur_relid = InvalidOid; + BlockNumber cur_blkno = InvalidBlockNumber; + Relation rel = NULL; + Buffer buf = InvalidBuffer; + int i; + + /* + * Prefetch: issue advisory read-ahead for the first few distinct blocks. + * Since keys are sorted by (relid, blockno), we can scan ahead cheaply. + * This overlaps I/O with the kernel readahead path. + */ + if (nkeys > 0) + { + Oid pf_relid = keys[0].key.relid; + Relation pf_rel; + BlockNumber pf_prev = InvalidBlockNumber; + int pf_count = 0; + + pf_rel = try_relation_open(pf_relid, AccessShareLock); + if (pf_rel != NULL) + { + for (int j = 0; j < nkeys && pf_count < 8; j++) + { + BlockNumber pf_blk; + + /* Stop prefetching if we cross into a different relation */ + if (keys[j].key.relid != pf_relid) + break; + + pf_blk = ItemPointerGetBlockNumber((ItemPointer) &keys[j].key.tid); + if (pf_blk != pf_prev) + { + PrefetchBuffer(pf_rel, MAIN_FORKNUM, pf_blk); + pf_prev = pf_blk; + pf_count++; + } + } + relation_close(pf_rel, AccessShareLock); + } + } + + for (i = 0; i < nkeys; i++) + { + SLogTrackedKeyInfo *tk = &keys[i]; + Oid relid = tk->key.relid; + BlockNumber blkno = ItemPointerGetBlockNumber((ItemPointer) &tk->key.tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber((ItemPointer) &tk->key.tid); + + /* Switch relation if needed (sorted order minimizes switches) */ + if (relid != cur_relid) + { + /* Mark outgoing page dirty (once per page, not per tuple) */ + if (BufferIsValid(buf)) + { + MarkBufferDirtyHint(buf, true); + UnlockReleaseBuffer(buf); + buf = InvalidBuffer; + } + if (rel != NULL) + { + relation_close(rel, AccessShareLock); + rel = NULL; + } + + rel = try_relation_open(relid, AccessShareLock); + if (rel == NULL) + { + cur_relid = relid; + cur_blkno = InvalidBlockNumber; + continue; + } + cur_relid = relid; + cur_blkno = InvalidBlockNumber; + } + + if (rel == NULL) + continue; + + /* + * Switch block if needed — amortizes ReadBuffer across same-page + * tuples + */ + if (blkno != cur_blkno) + { + /* Mark outgoing page dirty (once per page, not per tuple) */ + if (BufferIsValid(buf)) + { + MarkBufferDirtyHint(buf, true); + UnlockReleaseBuffer(buf); + buf = InvalidBuffer; + } + + PG_TRY(); + { + buf = ReadBuffer(rel, blkno); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + } + PG_CATCH(); + { + /* + * If ReadBuffer fails (e.g., relation truncated + * concurrently), skip this block and continue with the next. + */ + buf = InvalidBuffer; + EmitErrorReport(); + FlushErrorState(); + cur_blkno = blkno; + continue; + } + PG_END_TRY(); + + cur_blkno = blkno; + } + + if (!BufferIsValid(buf)) + continue; + + /* Stamp this tuple */ + flux_stamp_tuple_committed(buf, offnum, tk, xid); + } + + /* Mark final page dirty and release */ + if (BufferIsValid(buf)) + { + MarkBufferDirtyHint(buf, true); + UnlockReleaseBuffer(buf); + } + if (rel != NULL) + relation_close(rel, AccessShareLock); +} + +/* + * FluxClearUncommittedFlags -- proactively clear FLUX_TUPLE_UNCOMMITTED on + * all tuples modified by the current transaction at PRE_COMMIT time. + * + * Uses a batched approach: collects all tracked keys into an array, sorts + * by (relid, blockno) for sequential I/O, then processes them with at most + * one ReadBuffer per distinct block and one try_relation_open per relation. + * + * For local-only INSERTs (the common single-row INSERT case), the expensive + * SLogTupleLookupFiltered() call is skipped entirely — a local-only entry + * that hasn't been promoted to a shared ABORTED entry is guaranteed to be + * a live INSERT. + * + * Clearing the flag is a pure hint: commit visibility comes from CLOG + * (heap-shaped xmin/xmax MVCC), not from any stamped timestamp. + */ +static void +FluxClearUncommittedFlags(TransactionId xid) +{ + SLogTrackedKeyInfo *keys; + int nkeys; + + /* + * When lazy clear is enabled, skip the expensive batch page-visit loop. + * The UNCOMMITTED flags will be cleared lazily by readers via the + * visibility functions when they next access these tuples. + */ + if (flux_lazy_uncommitted_clear) + return; + + /* Collect tracked keys into a sortable array */ + nkeys = SLogTupleCollectTrackedKeys(xid, &keys); + if (nkeys == 0) + { + pfree(keys); + return; + } + + /* Sort by (relid, blockno, offnum) for sequential I/O */ + qsort(keys, nkeys, sizeof(SLogTrackedKeyInfo), flux_cmp_tracked_key_by_block); + + /* Batch-process: one ReadBuffer per distinct block */ + flux_batch_clear_uncommitted(keys, nkeys, xid); + + pfree(keys); +} + +/* + * FluxSLogXactCallback -- clean up sLog entries at transaction end. + */ +/* + * flux_register_twophase_cb -- callback for SLogTupleIterateTrackedKeys + * during PREPARE. Saves each tracked tuple as a two-phase record so that + * COMMIT PREPARED / ROLLBACK PREPARED can find them. + * + * For local-only entries (INSERTs), also creates a shared sLog entry so + * that other backends can see the transaction is in-progress and not treat + * the UNCOMMITTED flag as "stale committed." + */ +static bool +flux_register_twophase_cb(const SLogTupleKey *key, + TransactionId xid, TransactionId subxid, + bool local_only, void *arg) +{ + FluxTwoPhaseRecord rec; + + rec.relid = key->relid; + ItemPointerCopy(&key->tid, &rec.tid); + rec.local_only = local_only; + + /* + * Determine op_type: look up in shared sLog if not local-only. For + * local-only entries (INSERTs), we know it's SLOG_OP_INSERT. + */ + if (local_only) + { + rec.op_type = SLOG_OP_INSERT; + + /* + * Promote local-only INSERT to a shared sLog entry. This is critical + * for 2PC correctness: after PREPARE, the originating backend's local + * tracking is gone, but the tuple still has FLUX_TUPLE_UNCOMMITTED + * set. Without a shared sLog entry, other backends would see + * slog_nfound==0 and return invisible (correct), but COMMIT PREPARED + * needs the entry for its postcommit callback to locate and finalize + * the tuple. + * + * With the shared entry, other backends will also find the XID in + * sLog, call TransactionIdIsInProgress() → true (prepared XIDs are + * still "in progress"), and correctly hide the tuple. + */ + SLogTupleInsertRecovery(key->relid, (ItemPointer) &key->tid, + xid, SLOG_OP_INSERT); + } + else + { + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nfound; + int i; + + rec.op_type = SLOG_OP_INSERT; /* fallback */ + nfound = SLogTupleLookupFiltered(key->relid, (ItemPointer) &key->tid, + xid, ops, SLOG_MAX_TUPLE_OPS); + for (i = 0; i < nfound; i++) + { + if (TransactionIdEquals(ops[i].xid, xid)) + { + rec.op_type = ops[i].op_type; + break; + } + } + } + + RegisterTwoPhaseRecord(TWOPHASE_RM_FLUX_ID, FLUX_2PC_SLOG, + &rec, sizeof(FluxTwoPhaseRecord)); + return true; /* continue iteration */ +} + +/* + * flux_register_relundo_cb -- IteratePerRelUndo callback. + * + * Serializes one per-relation UNDO chain head into the 2PC state file so + * ROLLBACK PREPARED can restore in-place before-images. + */ +static void +flux_register_relundo_cb(Oid relid, RelUndoRecPtr start_urec_ptr, void *arg) +{ + FluxRelUndoTwoPhaseRecord rec; + + if (!RelUndoRecPtrIsValid(start_urec_ptr)) + return; + + rec.relid = relid; + rec.start_urec_ptr = start_urec_ptr; + RegisterTwoPhaseRecord(TWOPHASE_RM_FLUX_ID, FLUX_2PC_RELUNDO, + &rec, sizeof(FluxRelUndoTwoPhaseRecord)); +} + +/* + * AtPrepare_Flux -- register two-phase records for FLUX tuples. + * + * Called from PrepareTransaction() between StartPrepare() and EndPrepare(), + * where RegisterTwoPhaseRecord() is valid. Saves each tracked tuple so + * that COMMIT PREPARED / ROLLBACK PREPARED can locate and finalize them. + */ +void +AtPrepare_Flux(void) +{ + TransactionId xid = GetCurrentTransactionIdIfAny(); + + if (!TransactionIdIsValid(xid)) + return; + + SLogTupleIterateTrackedKeys(xid, flux_register_twophase_cb, NULL); + + /* + * Serialize the per-relation UNDO chain heads too. The sLog records + * above carry only per-tuple visibility state; the physical before-image + * of an in-place UPDATE lives in the per-relation UNDO chain and must be + * replayed by RelUndoApplyChain() on ROLLBACK PREPARED. + */ + IteratePerRelUndo(flux_register_relundo_cb, NULL); + + /* + * Relocate this xact's oldest-active-timestamp pin from this backend's + * proc slot onto the prepared xact's dummy-proc slot, so the pin survives + * this backend running new transactions or exiting entirely, and so the + * (possibly different) backend that later runs COMMIT/ROLLBACK PREPARED + * can find and clear it. MarkAsPreparing() has already assigned the dummy + * proc, so its slot number is available here. + */ + { + FullTransactionId prep_fxid = GetTopFullTransactionIdIfAny(); + + if (FullTransactionIdIsValid(prep_fxid)) + FluxPrepareReassignSlot(TwoPhaseGetDummyProcNumber(prep_fxid, false)); + } +} + +static void +FluxSLogXactCallback(XactEvent event, void *arg) +{ + switch (event) + { + case XACT_EVENT_PRE_COMMIT: + { + TransactionId xid = GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(xid)) + { + /* + * Ordering here is load-bearing for write-write + * correctness. Three steps, in this exact order: + * + * 1. FluxProcessAbortedEntries -- mark pages DELETED for + * subxact-aborted ops and remove their ABORTED sLog + * entries. MUST run before SLogTupleCommitByXid, whose + * COMMIT_XID apply removes every non-UPDATE op for the + * xid; running it first would erase the ABORTED marker + * this step needs and resurrect an aborted tuple. + * + * 2. SLogTupleCommitByXid -- finalize the xid's sLog ops. + * + * 3. FluxClearUncommittedFlags -- clear the on-page + * UNCOMMITTED hint flag. Commit visibility itself comes + * from CLOG (heap-shaped xmin/xmax MVCC); clearing the + * flag is a pure hint so later readers skip the sLog + * fast-path lookup. + * + * All three run at PRE_COMMIT, before the point of no + * return: DSA allocation must stay in an abort-legal phase + * to avoid a post-commit OOM->PANIC. + */ + FluxProcessAbortedEntries(xid); + SLogTupleCommitByXid(xid); + FluxClearUncommittedFlags(xid); + } + } + break; + + case XACT_EVENT_PRE_PREPARE: + { + TransactionId xid = GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(xid)) + { + /* + * At PREPARE, we must NOT clear UNCOMMITTED flags. The + * transaction is not yet committed and another backend might + * ROLLBACK PREPARED. + * + * We still need to process any subtransaction-aborted + * entries (mark them DELETED on page) since those are + * definitively aborted regardless of PREPARE outcome. + * + * Two-phase record registration happens in + * AtPrepare_Flux(), called from PrepareTransaction() + * after StartPrepare(). + */ + FluxProcessAbortedEntries(xid); + } + } + break; + + case XACT_EVENT_COMMIT: + case XACT_EVENT_PARALLEL_COMMIT: + { + /* + * The UNCOMMITTED-flag clear and before-image handling already + * happened at XACT_EVENT_PRE_COMMIT (see above). Here we only + * release backend-local tracking state, which is safe in the + * post-commit no-abort region. Commit visibility comes from + * CLOG (heap-shaped xmin/xmax MVCC); nothing further is written + * at COMMIT. + */ + SLogTupleResetTracking(); + } + break; + + case XACT_EVENT_PREPARE: + { + /* + * At PREPARE completion, do NOT remove sLog entries or + * decrement dirty map counters. The sLog entries must + * persist so that visibility checks can see the transaction + * is still in-progress (prepared). Only discard the + * backend-local tracking list since this backend is done. + * + * The two-phase records registered during PRE_PREPARE will be + * used by COMMIT PREPARED / ROLLBACK PREPARED to perform the + * actual cleanup. + */ + SLogTupleResetTracking(); + } + break; + + case XACT_EVENT_ABORT: + case XACT_EVENT_PARALLEL_ABORT: + { + TransactionId xid = GetCurrentTransactionIdIfAny(); + + if (TransactionIdIsValid(xid)) + { + /* + * Mark this transaction's shared sLog ops ABORTED. The + * DSA before-images are freed inside SLogTupleMarkAborted + * under the partition writer lock (single-owner), so we + * must not free them here: an unlocked free races the + * UNDO worker's SLogTupleRemoveByXidGlobal and corrupts + * the DSA heap. + */ + SLogTupleMarkAborted(xid); + } + + /* + * The dirty map is grow-only and carries no per-transaction + * tracking, so there is nothing to undo at ABORT. Leaving the + * page bit set is correct: the sLog entries are marked ABORTED + * (not removed), so a scanner must still probe the sLog to + * detect the aborted state and treat the tuples as still-live. + */ + SLogTupleResetTracking(); + } + break; + + default: + break; + } +} + +/* + * flux_restore_before_image_cb -- callback for FluxRestoreBeforeImages. + * + * For each tracked key with a before-image in the rolled-back subtransaction, + * physically restore the original tuple data on the page. + */ +static bool +flux_restore_before_image_cb(const SLogTupleKey *key, + TransactionId xid, TransactionId subxid, + bool local_only, void *arg) +{ + char *before_data; + int before_len; + uint16 before_flags; + uint64 before_commit_ts; + RelFileLocator before_rlocator; + char before_relpersistence; + + /* Check if this tracked key has a before-image */ + if (!SLogTupleGetBeforeImage(key->relid, (ItemPointer) &key->tid, + xid, subxid, + &before_data, &before_len, + &before_flags, &before_commit_ts, + &before_rlocator, &before_relpersistence)) + return true; /* No before-image (INSERT), continue */ + + /* + * Restore the physical tuple on the page. + * + * This callback fires from SUBXACT_EVENT_ABORT_SUB, by which point the + * subtransaction is already in TRANS_ABORT state (AbortSubTransaction + * sets the state before invoking subxact callbacks). That makes any + * relcache access -- relation_open and friends -- unsafe: it would trip + * the IsTransactionState() assertion in AssertCouldGetRelation. We + * therefore go straight to the buffer manager using the RelFileLocator + * captured at store time, which needs no relcache and works regardless of + * transaction state (including proc_exit teardown). + */ + { + Buffer buf = InvalidBuffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + OffsetNumber offnum; + bool permanent = (before_relpersistence == RELPERSISTENCE_PERMANENT); + + PG_TRY(); + { + buf = ReadBufferWithoutRelcache(before_rlocator, MAIN_FORKNUM, + ItemPointerGetBlockNumber((ItemPointer) &key->tid), + RBM_NORMAL, NULL, permanent); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + offnum = ItemPointerGetOffsetNumber((ItemPointer) &key->tid); + + if (offnum <= PageGetMaxOffsetNumber(page)) + { + itemid = PageGetItemId(page, offnum); + if (ItemIdIsNormal(itemid)) + { + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Restore the tuple to its pre-DML state. + * + * The before-image was the original occupant of this item + * slot. For Strategy 1 in-place updates (new tuple + * smaller than old), the physical page space at this + * offset hasn't been reclaimed or compacted within the + * same subtransaction, so writing before_len bytes is + * safe even when before_len > ItemIdGetLength. + */ + memcpy(tuple_hdr, before_data, before_len); + + /* Update item length if size changed */ + if (before_len != (int) ItemIdGetLength(itemid)) + ItemIdSetNormal(itemid, ItemIdGetOffset(itemid), + before_len); + + MarkBufferDirtyHint(buf, true); + } + } + + UnlockReleaseBuffer(buf); + buf = InvalidBuffer; + } + PG_CATCH(); + { + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + EmitErrorReport(); + FlushErrorState(); + } + PG_END_TRY(); + } + + return true; /* continue iteration */ +} + +/* + * FluxRestoreBeforeImages -- on savepoint rollback, restore physical tuples + * that were modified by the rolled-back subtransaction. + * + * Iterates tracked keys for the given (xid, subxid) and for each one that + * has a stashed before-image, reads the buffer and restores the tuple data. + * This must be called BEFORE SLogTupleRemoveBySubXid (which marks sLog + * entries as ABORTED) so that the tracked key list still has the subxid. + */ +static void +FluxRestoreBeforeImages(TransactionId xid, SubTransactionId subxid) +{ + SLogTupleIterateTrackedKeysForSubXid(xid, subxid, + flux_restore_before_image_cb, + NULL); +} + +/* + * FluxSLogSubXactCallback -- handle subtransaction events. + */ +static void +FluxSLogSubXactCallback(SubXactEvent event, + SubTransactionId mySubid, + SubTransactionId parentSubid, + void *arg) +{ + TransactionId xid; + + switch (event) + { + case SUBXACT_EVENT_ABORT_SUB: + xid = GetTopTransactionIdIfAny(); + if (TransactionIdIsValid(xid)) + { + /* + * Restore physical tuples from before-images FIRST, while the + * tracked key list still has entries for this subxid. Then + * mark sLog entries as ABORTED for visibility. + */ + FluxRestoreBeforeImages(xid, mySubid); + SLogTupleRemoveBySubXid(xid, mySubid); + } + + /* + * The dirty map is grow-only with no per-subtransaction tracking, + * so there is nothing to undo at subtransaction abort. Leaving + * the page bit set is correct: SLogTupleRemoveBySubXid marks + * entries SLOG_OP_ABORTED (does not remove them), so a scanner + * must still probe the sLog to detect the aborted state. + */ + break; + + case SUBXACT_EVENT_COMMIT_SUB: + xid = GetTopTransactionIdIfAny(); + if (TransactionIdIsValid(xid)) + SLogTupleUpdateSubXid(xid, mySubid, parentSubid); + break; + + default: + break; + } +} + +/* ================================================================ + * Two-phase commit callbacks for FLUX + * + * These are invoked by FinishPreparedTransaction() in the backend that + * runs COMMIT PREPARED or ROLLBACK PREPARED. They perform the tuple-level + * cleanup that would normally happen at XACT_EVENT_PRE_COMMIT / COMMIT + * or ABORT in the originating backend. + * ================================================================ + */ + +/* + * flux_twophase_postcommit -- called for each saved record when + * COMMIT PREPARED resolves a prepared transaction. + * + * Clears FLUX_TUPLE_UNCOMMITTED and removes + * shared sLog entries (for DELETE/UPDATE operations). + */ +void +flux_twophase_postcommit(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len) +{ + FluxTwoPhaseRecord *rec = (FluxTwoPhaseRecord *) recdata; + TransactionId xid = XidFromFullTransactionId(fxid); + Buffer buf = InvalidBuffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + OffsetNumber offnum; + Relation rel; + + /* + * Release this prepared xact's oldest-active-timestamp pin (moved onto the + * dummy-proc slot at PREPARE). Idempotent, so running it for every record + * is harmless. Runs under TwoPhaseStateLock held by + * FinishPreparedTransaction(), so look the dummy proc up with lock_held. + */ + FluxResolvePreparedSlot(TwoPhaseGetDummyProcNumber(fxid, true)); + + /* + * Per-relation UNDO chain heads (FLUX_2PC_RELUNDO) are irrelevant on + * COMMIT PREPARED: the committed in-place data is already correct, the + * chain is simply discarded. Only the per-tuple sLog records need work. + */ + if (info == FLUX_2PC_RELUNDO) + return; + + Assert(len == sizeof(FluxTwoPhaseRecord)); + + /* + * Heap-shaped COMMIT PREPARED: nothing to stamp. t_xmin/t_xmax were + * written by the prepared DML and stay untouched; CLOG (updated by the + * 2PC machinery) makes the committed XID visible. We only clear the + * UNCOMMITTED hint-bit flag below. + */ + + rel = try_relation_open(rec->relid, AccessShareLock); + if (rel == NULL) + return; /* relation dropped before COMMIT PREPARED */ + + PG_TRY(); + { + buf = ReadBuffer(rel, ItemPointerGetBlockNumber(&rec->tid)); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + offnum = ItemPointerGetOffsetNumber(&rec->tid); + + if (offnum <= PageGetMaxOffsetNumber(page)) + { + itemid = PageGetItemId(page, offnum); + if (ItemIdIsNormal(itemid)) + { + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Clear UNCOMMITTED flag (hint bit); do NOT touch t_commit_ts. */ + if (tuple_hdr->t_flags & FLUX_TUPLE_UNCOMMITTED) + tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + + MarkBufferDirtyHint(buf, true); + } + } + + UnlockReleaseBuffer(buf); + buf = InvalidBuffer; + relation_close(rel, AccessShareLock); + rel = NULL; + } + PG_CATCH(); + { + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + if (rel != NULL) + relation_close(rel, AccessShareLock); + EmitErrorReport(); + FlushErrorState(); + } + PG_END_TRY(); + + /* + * Remove shared sLog entry for this tuple. At PREPARE time, we promoted + * local-only entries to shared (via SLogTupleInsertRecovery), so ALL + * entries now have a shared sLog entry that needs cleanup. + */ + SLogTupleRemoveByXidSingle(rec->relid, &rec->tid, xid); +} + +/* + * flux_twophase_postabort -- called for each saved record when + * ROLLBACK PREPARED resolves a prepared transaction. + * + * For INSERTs: marks the tuple as DELETED (the insert is rolled back). + * For DELETEs/UPDATEs: marks the sLog entry as ABORTED (the operation + * is undone, tuple remains/reverts to live). + */ +void +flux_twophase_postabort(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len) +{ + FluxTwoPhaseRecord *rec = (FluxTwoPhaseRecord *) recdata; + TransactionId xid = XidFromFullTransactionId(fxid); + Buffer buf = InvalidBuffer; + Page page; + ItemId itemid; + FluxTupleHeader *tuple_hdr; + OffsetNumber offnum; + Relation rel; + + /* + * Release this prepared xact's oldest-active-timestamp pin (moved onto the + * dummy-proc slot at PREPARE). Idempotent and lock-held, as in + * flux_twophase_postcommit(); must precede the early returns below. + */ + FluxResolvePreparedSlot(TwoPhaseGetDummyProcNumber(fxid, true)); + + /* + * Per-relation UNDO chain head (FLUX_2PC_RELUNDO): replay the chain to + * restore in-place before-images. This is the physical data-restore for + * an aborted in-place UPDATE -- the per-tuple sLog records below only flip + * visibility flags. We run in the finishing backend's own live + * transaction (COMMIT/ROLLBACK PREPARED), so relation_open and the chain + * walk are legal here without the TRANS_ABORT juggling ApplyPerRelUndo + * needs. + */ + if (info == FLUX_2PC_RELUNDO) + { + FluxRelUndoTwoPhaseRecord *urec = + (FluxRelUndoTwoPhaseRecord *) recdata; + Relation urel; + + Assert(len == sizeof(FluxRelUndoTwoPhaseRecord)); + + urel = try_relation_open(urec->relid, RowExclusiveLock); + if (urel == NULL) + return; /* relation dropped */ + + PG_TRY(); + { + RelUndoApplyChain(urel, urec->start_urec_ptr); + } + PG_CATCH(); + { + BufferLockReleaseAll(); + EmitErrorReport(); + FlushErrorState(); + } + PG_END_TRY(); + + relation_close(urel, RowExclusiveLock); + return; + } + + Assert(len == sizeof(FluxTwoPhaseRecord)); + + rel = try_relation_open(rec->relid, AccessShareLock); + if (rel == NULL) + return; /* relation dropped */ + + PG_TRY(); + { + buf = ReadBuffer(rel, ItemPointerGetBlockNumber(&rec->tid)); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + offnum = ItemPointerGetOffsetNumber(&rec->tid); + + if (offnum <= PageGetMaxOffsetNumber(page)) + { + itemid = PageGetItemId(page, offnum); + if (ItemIdIsNormal(itemid)) + { + tuple_hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + if (rec->op_type == SLOG_OP_INSERT) + { + /* + * Aborted INSERT: keep UNCOMMITTED set. The shared sLog + * entry is marked ABORTED below, so the visibility code + * path at flux_mvcc.c:1008 will see UNCOMMITTED + + * SLOG_OP_ABORTED and goto not_visible. + * + * We do NOT clear UNCOMMITTED or set DELETED here, + * because that combination (committed-looking tuple with + * DELETED + ABORTED sLog) causes the deletion-check path + * to incorrectly reverse the deletion and make the tuple + * visible. + * + * The UNDO worker / VACUUM will eventually physically + * remove the dead tuple by recognizing the UNCOMMITTED + + * ABORTED pattern. + */ + } + else + { + /* + * Aborted DELETE/UPDATE: the tuple reverts to its pre-DML + * state. Clear any flags set by the aborted operation. + * For DELETE, remove the DELETED flag. For UPDATE, remove + * UPDATED flag and any UNCOMMITTED. + */ + if (rec->op_type == SLOG_OP_DELETE) + { + if (tuple_hdr->t_flags & FLUX_TUPLE_DELETED) + { + tuple_hdr->t_flags &= ~FLUX_TUPLE_DELETED; + MarkBufferDirtyHint(buf, true); + } + } + else if (rec->op_type == SLOG_OP_UPDATE) + { + if (tuple_hdr->t_flags & FLUX_TUPLE_UPDATED) + { + tuple_hdr->t_flags &= ~FLUX_TUPLE_UPDATED; + tuple_hdr->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + MarkBufferDirtyHint(buf, true); + } + } + } + } + } + + UnlockReleaseBuffer(buf); + buf = InvalidBuffer; + relation_close(rel, AccessShareLock); + rel = NULL; + } + PG_CATCH(); + { + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + if (rel != NULL) + relation_close(rel, AccessShareLock); + EmitErrorReport(); + FlushErrorState(); + } + PG_END_TRY(); + + /* + * Mark shared sLog entry as ABORTED. At PREPARE time, we promoted all + * local-only entries to shared, so every entry has a shared sLog entry. + * Marking it ABORTED ensures visibility code correctly hides the tuple + * until UNDO/VACUUM removes it. + */ + SLogTupleMarkAbortedSingle(rec->relid, &rec->tid, xid); +} + +/* + * flux_twophase_recover -- called during startup recovery for each + * saved FLUX record in a prepared transaction's state file. + * + * During recovery, we don't need to do anything special: the tuples + * are already in their prepared-but-uncommitted state on disk (with + * UNCOMMITTED flag set for INSERTs, or sLog entries for DELETE/UPDATE). + * The postcommit/postabort callbacks will handle cleanup when the + * prepared transaction is eventually resolved. + */ +void +flux_twophase_recover(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len) +{ + /* Nothing to do during recovery -- state is already consistent on disk */ +} diff --git a/src/backend/access/flux/flux_pvs.c b/src/backend/access/flux/flux_pvs.c new file mode 100644 index 0000000000000..d2c32c821cfd0 --- /dev/null +++ b/src/backend/access/flux/flux_pvs.c @@ -0,0 +1,208 @@ +/*------------------------------------------------------------------------- + * + * flux_pvs.c + * FLUX per-relation versioned storage (PVS) read path + * + * In-place UPDATEs on a FLUX tuple stamp a trailing RelUndoRecPtr (verptr) + * into the new on-page image (WS-PVS1). Each verptr points to the UNDO-fork + * record that describes the update which produced its host image; reversing + * the diff (or full-tuple) in that record yields the immediately prior + * committed image, whose own trailing verptr -- preserved verbatim through + * the reverse-apply -- continues the chain one further step back. + * + * FluxReconstructVisibleVersion walks that chain on behalf of an MVCC + * reader, stepping back one version at a time until it finds the image + * created by an xid that is VISIBLE to the reader's snapshot (i.e. one + * whose urec_xid is NOT in the in-progress set). The result is the + * before-image the reader should see in place of the on-page (newer) data. + * + * urec_prevundorec is NOT used here: that field threads a per-relation, + * per-transaction rollback LIFO, not a per-tuple version chain. The + * authoritative per-tuple chain is the verptr threaded through reconstructed + * images. + * + * Visibility per step is authoritative on the core MVCC snapshot: + * XidInMVCCSnapshot(urec_xid, snapshot) == true -> updater invisible, + * step back + * XidInMVCCSnapshot(urec_xid, snapshot) == false -> updater visible, + * serve current candidate + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_pvs.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/relundo.h" +#include "utils/snapmgr.h" + +/* + * Safety bound on how deep the version chain we will walk. In a healthy + * system the chain is bounded by the number of UPDATEs visible to the oldest + * snapshot retaining UNDO records; this cap defends against a corrupted + * record that would otherwise loop forever. + */ +#define FLUX_PVS_MAX_CHAIN_DEPTH 10000 + +/* + * FluxReconstructVisibleVersion + * Walk the UNDO-fork version chain and reconstruct the tuple version + * that satisfies the reader's MVCC snapshot. + * + * Inputs: + * rel - target relation (used to read its UNDO fork) + * tid - on-page TID of the row (currently unused but reserved + * for future diagnostics) + * onpage_image - pointer to the on-page tuple bytes (read-only) + * onpage_len - length of onpage_image (ItemIdGetLength of the slot) + * snapshot - MVCC snapshot of the reader + * + * Outputs (only populated when the function returns true): + * out_data - palloc'd buffer holding the reconstructed image + * out_len - length of *out_data + * + * Returns true if a different-from-on-page version should be served and + * *out_data / *out_len have been populated. Returns false when the on-page + * value is what the reader should see (caller must NOT free *out_data in + * that case; it is left untouched). + * + * If the chain is incomplete (record discarded, malformed, or terminates + * before a visible version is found), the function returns the deepest + * reconstructed image it could build, mirroring the "best-effort" semantics + * of the legacy sLog before-image path. If no reconstruction was performed + * (i.e. the on-page image's own verptr is invalid or the very first record + * read failed), the function returns false and the caller serves on-page + * data unchanged. + */ +bool +FluxReconstructVisibleVersion(Relation rel, ItemPointer tid, + const char *onpage_image, Size onpage_len, + Snapshot snapshot, + char **out_data, int *out_len) +{ + const char *candidate = onpage_image; + Size candidate_len = onpage_len; + char *allocated = NULL; + int depth = 0; + + (void) tid; /* reserved for future diagnostics */ + + if (snapshot == NULL || onpage_image == NULL || onpage_len == 0) + return false; + + for (;;) + { + const FluxTupleHeader *hdr = (const FluxTupleHeader *) candidate; + RelUndoRecPtr verptr; + RelUndoRecordHeader urec_hdr; + void *payload = NULL; + Size payload_size = 0; + char *next_image = NULL; + Size next_len = 0; + bool stepped = false; + + if (depth++ > FLUX_PVS_MAX_CHAIN_DEPTH) + { + elog(WARNING, + "FLUX PVS: version chain at (%u,%u) of relation %u exceeds " + "depth cap %d; serving best-effort image", + ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid), + RelationGetRelid(rel), + FLUX_PVS_MAX_CHAIN_DEPTH); + break; + } + + verptr = FluxTupleGetVersionPtr(hdr, candidate_len); + if (!RelUndoRecPtrIsValid(verptr)) + break; /* no further history */ + + if (!RelUndoReadRecord(rel, verptr, &urec_hdr, &payload, &payload_size)) + break; /* discarded or unreadable */ + + /* + * urec_xid is the xid that produced the CURRENT candidate image. If + * it is visible to the reader, the candidate is what we should serve. + */ + if (!XidInMVCCSnapshot(urec_hdr.urec_xid, snapshot)) + { + if (payload != NULL) + pfree(payload); + break; + } + + /* + * Updater invisible to reader: reverse-apply the record to obtain the + * prior committed image and continue. + */ + switch (urec_hdr.urec_type) + { + case RELUNDO_UPDATE: + { + if (!(urec_hdr.info_flags & RELUNDO_INFO_HAS_TUPLE) || + urec_hdr.tuple_len == 0) + { + elog(WARNING, + "FLUX PVS: RELUNDO_UPDATE without tuple at %llu", + (unsigned long long) verptr); + break; + } + if (payload_size < urec_hdr.tuple_len) + { + elog(WARNING, + "FLUX PVS: RELUNDO_UPDATE payload (%zu) smaller " + "than tuple_len (%u) at %llu", + payload_size, urec_hdr.tuple_len, + (unsigned long long) verptr); + break; + } + + /* + * Layout written by RelUndoFinish for a full UPDATE record + * is [RelUndoUpdatePayload][old tuple bytes]; the old + * tuple occupies the trailing tuple_len bytes of the + * payload region returned by RelUndoReadRecord. + */ + next_len = urec_hdr.tuple_len; + next_image = (char *) palloc(next_len); + memcpy(next_image, + (const char *) payload + + (payload_size - next_len), + next_len); + stepped = true; + break; + } + + default: + elog(DEBUG2, + "FLUX PVS: cannot step past urec_type %u at %llu", + urec_hdr.urec_type, (unsigned long long) verptr); + break; + } + + if (payload != NULL) + pfree(payload); + + if (!stepped) + break; /* serve the best-effort candidate we have */ + + /* Replace the candidate with the reconstructed prior image. */ + if (allocated != NULL) + pfree(allocated); + allocated = next_image; + candidate = allocated; + candidate_len = next_len; + } + + if (allocated == NULL) + return false; /* no reconstruction; caller serves on-page */ + + *out_data = allocated; + *out_len = (int) candidate_len; + return true; +} diff --git a/src/backend/access/flux/flux_relundo.c b/src/backend/access/flux/flux_relundo.c new file mode 100644 index 0000000000000..d3bbef53c4d26 --- /dev/null +++ b/src/backend/access/flux/flux_relundo.c @@ -0,0 +1,100 @@ +/*------------------------------------------------------------------------- + * + * flux_relundo.c + * FLUX adapters for the AM-neutral per-relation UNDO hooks + * + * The per-relation UNDO core (src/backend/access/undo/relundo*.c) applies + * UNDO chains and cleans up retained before-images without any compile-time + * knowledge of the FLUX access method. Where it needs FLUX-specific + * behavior -- clearing FLUX transient tuple flags, reversing a FLUX + * byte-diff, or cleaning up + * FLUX's sLog bookkeeping after an abort/discard -- it calls through + * function pointers declared in access/relundo.h. + * + * This file provides the FLUX implementations of those hooks and installs + * them. FluxRelUndoInstallHooks() is invoked from FluxUndoRmgrInit() so + * the pointers are live before crash recovery replays any RELUNDO CLR. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_relundo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/flux_undo.h" +#include "access/relundo.h" +#include "access/slog.h" +#include "access/xact.h" + +/* + * FluxRelUndoClearTransientFlags + * Clear the transient flags on a restored FLUX tuple. + * + * The before-image restored during UNDO is the committed version, so the + * UNCOMMITTED/DELETED/UPDATED markers left by the rolled-back operation must + * be cleared. + */ +static void +FluxRelUndoClearTransientFlags(char *tuple_data) +{ + FluxTupleHeader *hdr = (FluxTupleHeader *) tuple_data; + + hdr->t_flags &= ~(FLUX_TUPLE_UNCOMMITTED | + FLUX_TUPLE_DELETED | + FLUX_TUPLE_UPDATED); +} + +/* + * FluxRelUndoAbortCleanup + * Drop sLog dirty-xid markers for a transaction whose before-images + * have just been physically restored by rollback. + * + * At abort time, sLog entries for this xid were marked ABORTED (not + * removed) so visibility checks could keep treating the tuples as live + * until the physical restore completed. Now that the restore is done, the + * markers can be dropped. Called by the AM-neutral UNDO core (inline from + * xactundo.c, or from the background worker in relundo_worker.c); FLUX is + * the only in-place AM that keeps this kind of transient bookkeeping, so the + * core reaches it through this hook rather than a compile-time dependency. + */ +static void +FluxRelUndoAbortCleanup(TransactionId xid) +{ + SLogTupleRemoveByXidGlobal(xid); +} + +/* + * FluxRelUndoDiscardRetained + * Reclaim sLog before-image entries no longer needed by any active + * snapshot. + * + * Called periodically by the UNDO discard worker (undoworker.c). The + * reclamation horizon is the xid horizon computed inside + * SLogTupleCleanupRetained(). + */ +static void +FluxRelUndoDiscardRetained(void) +{ + SLogTupleCleanupRetained(); +} + +/* + * FluxRelUndoInstallHooks + * Wire the FLUX implementations into the AM-neutral UNDO core. + * + * Called from FluxUndoRmgrInit() at postmaster startup, before crash + * recovery can replay any RELUNDO CLR that would invoke these hooks. + */ +void +FluxRelUndoInstallHooks(void) +{ + RelUndoClearTransientFlags_hook = FluxRelUndoClearTransientFlags; + RelUndoAbortCleanup_hook = FluxRelUndoAbortCleanup; + RelUndoDiscardRetained_hook = FluxRelUndoDiscardRetained; + TableAMPrepare_hook = AtPrepare_Flux; +} diff --git a/src/backend/access/flux/flux_slot.c b/src/backend/access/flux/flux_slot.c new file mode 100644 index 0000000000000..ad79e24e99491 --- /dev/null +++ b/src/backend/access/flux/flux_slot.c @@ -0,0 +1,795 @@ +/*------------------------------------------------------------------------- + * + * flux_slot.c + * FLUX-specific TupleTableSlot implementation + * + * This implements custom TupleTableSlotOps for FLUX table access method. + * FLUX tuples use timestamps for MVCC instead of transaction IDs, and + * have a different on-disk format than heap tuples. This slot type handles + * the FLUX tuple format natively, avoiding unnecessary conversions + * through the heap tuple format. + * + * The slot can hold either: + * - A reference to a FLUX tuple in a buffer page (pinned buffer) + * - A materialized (palloc'd) copy of a FLUX tuple + * - Virtual data in tts_values/tts_isnull (after deforming or direct store) + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_slot.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/slog.h" +#include "access/htup_details.h" +#include "access/tupdesc.h" +#include "access/xact.h" +#include "executor/tuptable.h" +#include "storage/bufmgr.h" +#include "utils/expandeddatum.h" +#include "utils/memutils.h" + +/* + * FluxTupleTableSlot - slot type for FLUX tuples + * + * This extends the base TupleTableSlot with FLUX-specific fields to + * hold a reference to a FLUX tuple either in a buffer or materialized + * in memory. + */ +typedef struct FluxTupleTableSlot +{ + TupleTableSlot base; + + /* Pointer to the FLUX tuple header (in buffer or materialized) */ + FluxTupleHeader *tuple; + + /* Length of the tuple data pointed to by 'tuple' */ + uint32 tuple_len; + + /* + * Values-only ("virtual") payload block. When copyslot deep-copies a + * source slot's datums instead of forming a physical tuple, the + * pass-by-reference values are packed into this single palloc'd block so + * that clear() can free them with one pfree (mirrors VirtualTupleTableSlot's + * ->data). NULL when the slot holds a physical tuple or has no pass-by-ref + * values. + */ + void *values_block; + + /* Deform state: offset into tuple data for lazy attribute extraction */ + uint32 off; + + /* + * If buffer is not InvalidBuffer, the slot holds a pin on this buffer and + * 'tuple' points into the buffer page. When the slot is cleared or + * materialized, the pin is released. + */ + Buffer buffer; +} FluxTupleTableSlot; + +/* Forward declarations */ +const TupleTableSlotOps TTSOpsFluxTuple; +static void tts_flux_deform(TupleTableSlot *slot, int natts); + + +/* + * Initialization - nothing special needed. + */ +static void +tts_flux_init(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + rslot->tuple = NULL; + rslot->tuple_len = 0; + rslot->off = 0; + rslot->buffer = InvalidBuffer; + rslot->values_block = NULL; +} + +/* + * Destruction - release any resources. + */ +static void +tts_flux_release(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + /* If we own a materialized tuple, free it */ + if (TTS_SHOULDFREE(slot) && rslot->tuple) + { + pfree(rslot->tuple); + rslot->tuple = NULL; + } + if (TTS_SHOULDFREE(slot) && rslot->values_block) + { + pfree(rslot->values_block); + rslot->values_block = NULL; + } + + /* Release buffer pin if held */ + if (BufferIsValid(rslot->buffer)) + { + ReleaseBuffer(rslot->buffer); + rslot->buffer = InvalidBuffer; + } +} + +/* + * Clear the slot contents. + * + * Free materialized tuple if owned, release buffer pin, and reset + * the slot to empty state. + */ +static void +tts_flux_clear(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + /* + * Free materialized tuple data if we own it. A tuple residing in a buffer + * cannot be freed directly; only materialized copies can. + */ + if (TTS_SHOULDFREE(slot)) + { + Assert(!BufferIsValid(rslot->buffer)); + + if (rslot->tuple) + pfree(rslot->tuple); + if (rslot->values_block) + pfree(rslot->values_block); + + slot->tts_flags &= ~TTS_FLAG_SHOULDFREE; + } + + /* Release buffer pin if held */ + if (BufferIsValid(rslot->buffer)) + { + ReleaseBuffer(rslot->buffer); + rslot->buffer = InvalidBuffer; + } + + slot->tts_nvalid = 0; + slot->tts_flags |= TTS_FLAG_EMPTY; + ItemPointerSetInvalid(&slot->tts_tid); + rslot->tuple = NULL; + rslot->tuple_len = 0; + rslot->off = 0; + rslot->values_block = NULL; +} + +/* + * Deform FLUX tuple to extract attributes into tts_values/tts_isnull. + * + * This is the FLUX-native equivalent of slot_deform_heap_tuple. It reads + * the FLUX tuple format directly (bitmap + inline attribute data) rather + * than going through the heap tuple deforming path. + */ +static void +tts_flux_deform(TupleTableSlot *slot, int natts) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + TupleDesc tupdesc = slot->tts_tupleDescriptor; + FluxTupleHeader *header = rslot->tuple; + int attnum; + char *data_ptr; + uint8 *nulls_bitmap; + Size bitmap_len; + bool has_nulls; + bool tuple_has_compressed; + + Assert(header != NULL); + Assert(natts <= tupdesc->natts); + + /* Start from where we left off last time */ + attnum = slot->tts_nvalid; + if (attnum >= natts) + return; + + /* + * Use the tuple's actual natts for bitmap_len and data_ptr calculation, + * not the tupdesc's natts. After ALTER TABLE ADD COLUMN, old tuples may + * have fewer attributes than the current schema expects. + */ + { + int tuple_natts = header->t_natts; + + bitmap_len = BITMAPLEN(tuple_natts); + nulls_bitmap = (uint8 *) header->t_attrs_bitmap; + has_nulls = (header->t_infomask & FLUX_INFOMASK_HASNULL) != 0; + tuple_has_compressed = false; /* FLUX does not compress attributes */ + (void) tuple_has_compressed; + + /* + * If this is the first time deforming (attnum == 0), start from the + * beginning of the data area. Otherwise, resume from saved offset. + */ + if (attnum == 0) + data_ptr = (char *) header + FLUX_TUPLE_OVERHEAD + MAXALIGN(bitmap_len); + else + data_ptr = (char *) header + rslot->off; + + /* + * Limit deformation to the attributes physically present in the + * tuple. Attributes beyond tuple_natts were added by ALTER TABLE ADD + * COLUMN and will be filled with their defaults below. + */ + natts = Min(natts, tuple_natts); + } + + for (; attnum < natts; attnum++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, attnum); + + if (att->attisdropped) + { + slot->tts_values[attnum] = (Datum) 0; + slot->tts_isnull[attnum] = true; + continue; + } + + /* Check null bitmap */ + if (has_nulls && att_isnull(attnum, nulls_bitmap)) + { + slot->tts_values[attnum] = (Datum) 0; + slot->tts_isnull[attnum] = true; + continue; + } + + slot->tts_isnull[attnum] = false; + + if (att->attlen > 0) + { + /* Fixed-length attribute - align first */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + slot->tts_values[attnum] = fetchatt(att, data_ptr); + data_ptr += att->attlen; + } + else if (att->attlen == -1) + { + Size attr_len; + + /* Variable-length attribute - align first */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + attr_len = VARSIZE_ANY(data_ptr); + + /* + * FLUX stores varlena values verbatim (wide values are TOASTed + * through the standard heap TOAST path; FLUX has no on-page + * overflow and does not compress attributes). + */ + slot->tts_values[attnum] = PointerGetDatum(data_ptr); + data_ptr += attr_len; + } + else if (att->attlen == -2) + { + /* C string */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + slot->tts_values[attnum] = CStringGetDatum(data_ptr); + data_ptr += strlen(data_ptr) + 1; + } + else + { + elog(ERROR, "unsupported attribute length: %d", att->attlen); + } + } + + /* Save deform state for incremental deforming */ + rslot->off = (uint32) (data_ptr - (char *) header); + slot->tts_nvalid = natts; +} + +/* + * Fill up first natts entries of tts_values and tts_isnull. + * + * If the slot has a FLUX tuple, deform it natively. If values were already + * stored directly (virtual-style), they are already present. + */ +static void +tts_flux_getsomeattrs(TupleTableSlot *slot, int natts) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + Assert(!TTS_EMPTY(slot)); + + if (rslot->tuple != NULL) + { + /* Deform from the FLUX tuple */ + tts_flux_deform(slot, natts); + + /* + * If the tuple had fewer attributes than requested (e.g., after ALTER + * TABLE ADD COLUMN), fill in defaults for the missing ones. + */ + if (slot->tts_nvalid < natts) + { + slot_getmissingattrs(slot, slot->tts_nvalid, natts); + slot->tts_nvalid = natts; + } + } + else + { + /* + * No physical tuple - values were stored directly into tts_values + * (virtual-style). Fill missing attributes. + */ + slot_getmissingattrs(slot, slot->tts_nvalid, natts); + slot->tts_nvalid = natts; + } +} + +/* + * Return system attribute value for FLUX tuples. + * + * FLUX tuples have timestamps instead of XIDs, so most heap system columns + * are not directly applicable. We handle the subset that makes sense. + */ +static Datum +tts_flux_getsysattr(TupleTableSlot *slot, int attnum, bool *isnull) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + Assert(!TTS_EMPTY(slot)); + + /* If no physical tuple, we cannot provide system attributes */ + if (!rslot->tuple) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("cannot retrieve a system column in this context"))); + + /* + * Return the tuple's real MVCC system columns. FLUX uses heap-shaped + * xmin/xmax semantics: t_xmin is the inserter XID (always valid), and the + * xmax (deleter/updater XID) lives in the low 32 bits of t_commit_ts, + * accessed via FluxTupleGetXmax (0 == not deleted/updated). + */ + *isnull = false; + + switch (attnum) + { + case MinTransactionIdAttributeNumber: /* xmin */ + return TransactionIdGetDatum(rslot->tuple->t_xmin); + case MaxTransactionIdAttributeNumber: /* xmax */ + return TransactionIdGetDatum(FluxTupleGetXmax(rslot->tuple)); + case MinCommandIdAttributeNumber: /* cmin */ + case MaxCommandIdAttributeNumber: /* cmax */ + { + /* + * t_cid removed from FluxTupleHeader (saves 4 bytes). Look + * up the command ID from the sLog for in-progress operations; + * return InvalidCommandId if no sLog entry exists (committed + * tuple). + */ + SLogTupleOp slog_entry; + int nfound = SLogTupleLookupFiltered(slot->tts_tableOid, + &slot->tts_tid, + GetTopTransactionIdIfAny(), + &slog_entry, 1); + + return CommandIdGetDatum(nfound > 0 ? slog_entry.cid : InvalidCommandId); + } + default: + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("FLUX does not support system attribute %d", + attnum))); + return 0; /* silence compiler */ + } +} + +/* + * Check if the tuple was created by the current transaction. + * + * For FLUX, we consult the sLog to determine whether the current + * transaction inserted this tuple. This replaces the old t_xact_ts + * comparison that was removed in the sLog migration. + */ +static bool +tts_flux_is_current_xact_tuple(TupleTableSlot *slot) +{ + Assert(!TTS_EMPTY(slot)); + + if (!ItemPointerIsValid(&slot->tts_tid) || + slot->tts_tableOid == InvalidOid) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("don't have a storage tuple in this context"))); + + /* + * Ask the sLog whether the current transaction inserted this tuple. This + * is the FLUX equivalent of checking xmin == current xid. + */ + return SLogTupleIsInsertedByMe(slot->tts_tableOid, &slot->tts_tid); +} + +/* + * Pack a slot's pass-by-reference Datums into a single palloc'd block owned by + * the slot, leaving the slot in a values-only ("virtual") state (tuple == NULL). + * + * This mirrors tts_virtual_materialize: a two-pass scan that first sums the + * aligned size of every non-null pass-by-reference attribute, makes one + * allocation in the slot's memory context, then copies each Datum in and + * repoints tts_values at the copy. byval and null Datums are left untouched. + * + * We deliberately do NOT form a physical FLUX tuple here. Forming one would + * compress every varlena, and the immediate next consumer (flux_multi_insert / + * flux_tuple_insert via slot_getallattrs) would decompress it again before + * re-forming the on-page tuple. Deferring tuple formation to the insert path + * makes bulk COPY compress each value exactly once instead of three times. + * + * The caller must have already populated tts_values/tts_isnull and set + * tts_nvalid to the attribute count; those stay valid because getsomeattrs and + * getsysattr handle tuple == NULL by working from tts_values. + */ +static void +tts_flux_materialize_values(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + TupleDesc desc = slot->tts_tupleDescriptor; + Size sz = 0; + char *data; + + /* First pass: compute the aligned size of the owned block. */ + for (int natt = 0; natt < desc->natts; natt++) + { + CompactAttribute *att = TupleDescCompactAttr(desc, natt); + Datum val; + + if (att->attbyval || slot->tts_isnull[natt]) + continue; + + val = slot->tts_values[natt]; + + if (att->attlen == -1 && + VARATT_IS_EXTERNAL_EXPANDED(DatumGetPointer(val))) + { + sz = att_nominal_alignby(sz, att->attalignby); + sz += EOH_get_flat_size(DatumGetEOHP(val)); + } + else + { + sz = att_nominal_alignby(sz, att->attalignby); + sz = att_addlength_datum(sz, att->attlen, val); + } + } + + rslot->tuple = NULL; + rslot->tuple_len = 0; + rslot->off = 0; + + /* all data is byval / null: nothing to own */ + if (sz == 0) + return; + + rslot->values_block = data = MemoryContextAlloc(slot->tts_mcxt, sz); + slot->tts_flags |= TTS_FLAG_SHOULDFREE; + + /* Second pass: copy each pass-by-reference Datum and repoint tts_values. */ + for (int natt = 0; natt < desc->natts; natt++) + { + CompactAttribute *att = TupleDescCompactAttr(desc, natt); + Datum val; + + if (att->attbyval || slot->tts_isnull[natt]) + continue; + + val = slot->tts_values[natt]; + + if (att->attlen == -1 && + VARATT_IS_EXTERNAL_EXPANDED(DatumGetPointer(val))) + { + ExpandedObjectHeader *eoh = DatumGetEOHP(val); + Size data_length = EOH_get_flat_size(eoh); + + data = (char *) att_nominal_alignby(data, att->attalignby); + EOH_flatten_into(eoh, data, data_length); + + slot->tts_values[natt] = PointerGetDatum(data); + data += data_length; + } + else + { + Size data_length = 0; + + data = (char *) att_nominal_alignby(data, att->attalignby); + data_length = att_addlength_datum(data_length, att->attlen, val); + + memcpy(data, DatumGetPointer(val), data_length); + + slot->tts_values[natt] = PointerGetDatum(data); + data += data_length; + } + } +} + +/* + * Materialize the slot contents. + * + * After materialization, the slot's data is independent of any external + * storage (buffers, other memory contexts). If the slot references a + * tuple in a buffer, the tuple data is copied and the buffer pin released. + */ +static void +tts_flux_materialize(TupleTableSlot *slot) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + MemoryContext oldContext; + + Assert(!TTS_EMPTY(slot)); + + /* Already materialized */ + if (TTS_SHOULDFREE(slot)) + return; + + oldContext = MemoryContextSwitchTo(slot->tts_mcxt); + + if (rslot->tuple != NULL) + { + /* + * We have a physical FLUX tuple (in a buffer or external memory). + * Copy it into the slot's own memory context. + */ + FluxTupleHeader *newtuple; + + newtuple = (FluxTupleHeader *) palloc(rslot->tuple_len); + memcpy(newtuple, rslot->tuple, rslot->tuple_len); + rslot->tuple = newtuple; + + /* + * Reset deform state since tts_values entries may point into the old + * (buffer) tuple data that we're about to release. + */ + rslot->off = 0; + slot->tts_nvalid = 0; + } + else + { + /* + * Virtual tuple (values stored directly). Materialize by copying all + * pass-by-reference Datums into a single block in the slot's memory + * context and leaving the slot in a values-only state (tuple == NULL). + * We deliberately do NOT form a physical FLUX tuple: that would + * compress every varlena, and the insert path (flux_multi_insert via + * slot_getallattrs) would decompress and recompress it. Deferring + * tuple formation to insert compresses each value exactly once. + */ + tts_flux_materialize_values(slot); + + /* + * tts_flux_materialize_values sets SHOULDFREE itself when it owns a + * block; return early so we don't set SHOULDFREE unconditionally for a + * slot that owns nothing (all-byval/null). We still must release any + * buffer pin first. + */ + if (BufferIsValid(rslot->buffer)) + { + ReleaseBuffer(rslot->buffer); + rslot->buffer = InvalidBuffer; + } + MemoryContextSwitchTo(oldContext); + return; + } + + /* + * Release buffer pin if held. Do this after copying but before setting + * TTS_FLAG_SHOULDFREE to avoid a transient state where the slot owns a + * buffer and has SHOULDFREE set. + */ + if (BufferIsValid(rslot->buffer)) + { + ReleaseBuffer(rslot->buffer); + rslot->buffer = InvalidBuffer; + } + + slot->tts_flags |= TTS_FLAG_SHOULDFREE; + + MemoryContextSwitchTo(oldContext); +} + +/* + * Copy the contents of srcslot into dstslot. + * + * The destination must not depend on the source slot's memory after this + * returns. We satisfy that by deep-copying the source's attribute values + * into the destination's own memory context and leaving the destination in + * a values-only ("virtual") state -- rdst->tuple stays NULL. We deliberately + * do NOT form a physical FLUX tuple here: forming one would compress every + * varlena, and the immediate next consumer (flux_multi_insert / + * flux_tuple_insert via slot_getallattrs) would have to decompress it again + * before re-forming the on-page tuple. Deferring tuple formation to the + * insert path makes bulk COPY compress each value exactly once instead of + * three times (compress here, decompress there, recompress there). + * + * The values-only state is fully supported by the other slot ops: + * getsomeattrs and materialize both handle rslot->tuple == NULL by working + * from tts_values, and copy_heap_tuple/copy_minimal_tuple go through + * slot_getallattrs. + */ +static void +tts_flux_copyslot(TupleTableSlot *dstslot, TupleTableSlot *srcslot) +{ + TupleDesc desc = dstslot->tts_tupleDescriptor; + + tts_flux_clear(dstslot); + + slot_getallattrs(srcslot); + + /* Copy the datum pointers first; they still reference source memory. */ + for (int natt = 0; natt < desc->natts; natt++) + { + dstslot->tts_values[natt] = srcslot->tts_values[natt]; + dstslot->tts_isnull[natt] = srcslot->tts_isnull[natt]; + } + dstslot->tts_nvalid = desc->natts; + dstslot->tts_flags &= ~TTS_FLAG_EMPTY; + dstslot->tts_tid = srcslot->tts_tid; + + /* + * Deep-copy the pass-by-reference datums into a single owned block so the + * destination no longer depends on the source's memory (in COPY, the + * source is a per-row scratch slot that gets reset). This leaves the + * destination in a values-only state (tuple == NULL), deferring compression + * to the insert path. + */ + tts_flux_materialize_values(dstslot); +} + +/* + * Return a HeapTuple "owned" by the slot. + * + * Since FLUX tuples are not heap tuples, we must form one from the + * deformed values. The result is a palloc'd HeapTuple that the slot owns. + * + * This is needed by parts of the executor that require heap tuples + * (e.g., for index tuple formation, triggers, etc.). + */ +static HeapTuple +tts_flux_copy_heap_tuple(TupleTableSlot *slot) +{ + HeapTuple htup; + + Assert(!TTS_EMPTY(slot)); + + /* Ensure all attributes are deformed */ + slot_getallattrs(slot); + + htup = heap_form_tuple(slot->tts_tupleDescriptor, + slot->tts_values, + slot->tts_isnull); + + /* + * Propagate TID and table OID from the slot to the HeapTuple. ANALYZE's + * compare_rows() sorts sample tuples by t_self (TID), which + * heap_form_tuple leaves zeroed. Without this, the ItemPointerIsValid + * assertion in ItemPointerGetBlockNumber fires. + */ + htup->t_self = slot->tts_tid; + htup->t_tableOid = slot->tts_tableOid; + + return htup; +} + +/* + * Return a MinimalTuple copy allocated in the caller's memory context. + */ +static MinimalTuple +tts_flux_copy_minimal_tuple(TupleTableSlot *slot, Size extra) +{ + Assert(!TTS_EMPTY(slot)); + + /* Ensure all attributes are deformed */ + slot_getallattrs(slot); + + return heap_form_minimal_tuple(slot->tts_tupleDescriptor, + slot->tts_values, + slot->tts_isnull, + extra); +} + +/* + * The FLUX TupleTableSlotOps structure. + * + * FLUX slots do not "own" heap tuples or minimal tuples natively, so + * get_heap_tuple and get_minimal_tuple are NULL. The copy_ variants are + * provided to satisfy the executor's needs. + */ +const TupleTableSlotOps TTSOpsFluxTuple = { + .base_slot_size = sizeof(FluxTupleTableSlot), + .init = tts_flux_init, + .release = tts_flux_release, + .clear = tts_flux_clear, + .getsomeattrs = tts_flux_getsomeattrs, + .getsysattr = tts_flux_getsysattr, + .is_current_xact_tuple = tts_flux_is_current_xact_tuple, + .materialize = tts_flux_materialize, + .copyslot = tts_flux_copyslot, + + /* FLUX slots do not natively own heap or minimal tuples */ + .get_heap_tuple = NULL, + .get_minimal_tuple = NULL, + .copy_heap_tuple = tts_flux_copy_heap_tuple, + .copy_minimal_tuple = tts_flux_copy_minimal_tuple, +}; + + +/* + * Store a FLUX tuple from a buffer page into the slot. + * + * The tuple data remains in the buffer; a pin is acquired to keep the + * buffer valid for the lifetime of the slot reference. + * + * This is the primary way scan routines populate FLUX slots. + */ +void +FluxSlotStoreTuple(TupleTableSlot *slot, FluxTupleHeader *tuple, + uint32 tuple_len, Buffer buffer) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + Assert(slot->tts_ops == &TTSOpsFluxTuple); + + /* + * Optimize for the common case during sequential scans: if the new tuple + * is on the same buffer as the previous one, skip the expensive + * ReleaseBuffer + IncrBufferRefCount cycle. This mirrors the + * optimization in heap's tts_buffer_heap_store_tuple(). + */ + if (rslot->buffer == buffer) + { + /* Same buffer — just free any materialized data */ + if (unlikely(TTS_SHOULDFREE(slot))) + { + if (rslot->tuple) + pfree(rslot->tuple); + slot->tts_flags &= ~TTS_FLAG_SHOULDFREE; + } + } + else + { + /* Different buffer — full clear (releases old pin) and acquire new */ + tts_flux_clear(slot); + rslot->buffer = buffer; + + if (BufferIsValid(buffer)) + IncrBufferRefCount(buffer); + } + + rslot->tuple = tuple; + rslot->tuple_len = tuple_len; + rslot->off = 0; + + slot->tts_flags &= ~TTS_FLAG_EMPTY; + slot->tts_nvalid = 0; +} + +/* + * Store a materialized (palloc'd) FLUX tuple into the slot. + * + * The slot takes ownership of the tuple data and will pfree it when + * cleared or released. + */ +void +FluxSlotStoreMaterializedTuple(TupleTableSlot *slot, + FluxTupleHeader *tuple, + uint32 tuple_len) +{ + FluxTupleTableSlot *rslot = (FluxTupleTableSlot *) slot; + + Assert(slot->tts_ops == &TTSOpsFluxTuple); + + tts_flux_clear(slot); + + rslot->tuple = tuple; + rslot->tuple_len = tuple_len; + rslot->off = 0; + rslot->buffer = InvalidBuffer; + + slot->tts_flags &= ~TTS_FLAG_EMPTY; + slot->tts_flags |= TTS_FLAG_SHOULDFREE; + slot->tts_nvalid = 0; +} diff --git a/src/backend/access/flux/flux_stats.c b/src/backend/access/flux/flux_stats.c new file mode 100644 index 0000000000000..92b55b3259ab6 --- /dev/null +++ b/src/backend/access/flux/flux_stats.c @@ -0,0 +1,286 @@ +/*------------------------------------------------------------------------- + * + * flux_stats.c + * FLUX-specific statistics collection for ANALYZE + * + * This module collects statistics that are unique to the FLUX storage + * format: compression ratios, overflow usage, space efficiency, and the + * distribution of the per-tuple commit-ts word. These statistics supplement + * the standard + * per-column statistics (MCV, histograms, NULL fractions, etc.) that + * PostgreSQL's ANALYZE framework collects automatically via the + * scan_analyze_next_block / scan_analyze_next_tuple callbacks. + * + * The collected statistics are logged at DEBUG1 level and made available + * through the FluxCollectRelationStats() interface so that the planner + * can incorporate FLUX-specific cost adjustments. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_stats.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/smgr.h" +#include "utils/rel.h" + +/* + * FluxCollectRelationStats + * + * Scan the relation to collect FLUX-specific statistics. This performs + * a full sequential pass over every page, examining each item to measure + * compression ratios, overflow usage, tuple sizes, free space, and the + * distribution of the per-tuple commit-ts word. + * + * This is designed to be called during ANALYZE after the standard sampling + * is complete. It does its own full scan because the standard sampling + * only visits a random subset of blocks, which is fine for per-column + * statistics but insufficient for accurate relation-wide measurements + * like total overflow bytes or bloat factor. + * + * The caller must pass a zeroed FluxRelationStats struct. + */ +void +FluxCollectRelationStats(Relation rel, FluxRelationStats *stats) +{ + BlockNumber nblocks; + BlockNumber blkno; + int64 total_tuple_bytes = 0; + int64 total_compressed_tuples = 0; + int64 total_overflow_tuples = 0; + int64 total_overflow_chains = 0; + int64 total_live = 0; + int64 total_dead = 0; + double total_free_space = 0.0; + int64 total_uncompressed_size = 0; + int64 total_compressed_size = 0; + bool commit_ts_seen = false; + uint64 commit_ts_min = PG_UINT64_MAX; + uint64 commit_ts_max = 0; + + /* Initialize output */ + memset(stats, 0, sizeof(FluxRelationStats)); + + /* Get number of blocks */ + if (!smgrexists(RelationGetSmgr(rel), MAIN_FORKNUM)) + return; + + nblocks = smgrnblocks(RelationGetSmgr(rel), MAIN_FORKNUM); + stats->total_pages = nblocks; + + if (nblocks == 0) + return; + + /* + * Scan every page. We take only a shared lock on each page and release + * it before moving to the next, keeping contention low. + */ + for (blkno = 0; blkno < nblocks; blkno++) + { + Buffer buffer; + Page page; + OffsetNumber maxoff; + OffsetNumber offnum; + Size page_free; + + CHECK_FOR_INTERRUPTS(); + + buffer = ReadBufferExtended(rel, MAIN_FORKNUM, blkno, + RBM_NORMAL, NULL); + LockBuffer(buffer, BUFFER_LOCK_SHARE); + page = BufferGetPage(buffer); + + /* Skip uninitialized pages */ + if (PageIsNew(page)) + { + UnlockReleaseBuffer(buffer); + continue; + } + + maxoff = PageGetMaxOffsetNumber(page); + page_free = PageGetFreeSpace(page); + total_free_space += (double) page_free / (double) BLCKSZ; + + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(page, offnum); + FluxTupleHeader *hdr; + Size item_len; + + if (!ItemIdIsNormal(itemid)) + { + if (ItemIdIsDead(itemid)) + total_dead++; + continue; + } + + item_len = ItemIdGetLength(itemid); + hdr = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Skip overflow records -- counted separately */ + if (FluxIsOverflowRecordInline(hdr, item_len)) + { + total_overflow_chains++; + stats->total_overflow_bytes += item_len; + continue; + } + + /* This is a real tuple */ + total_tuple_bytes += item_len; + + if (hdr->t_flags & FLUX_TUPLE_DELETED) + { + total_dead++; + continue; + } + + /* Live tuple */ + total_live++; + + /* Check compression */ + if (hdr->t_flags & FLUX_TUPLE_COMPRESSED) + { + total_compressed_tuples++; + + /* + * Estimate compression ratio from the compression header that + * follows the tuple header, if present. + */ + if (item_len > FLUX_TUPLE_OVERHEAD + sizeof(FluxCompressionHeader)) + { + FluxCompressionHeader *comp_hdr; + + comp_hdr = (FluxCompressionHeader *) + ((char *) hdr + FLUX_TUPLE_OVERHEAD); + total_uncompressed_size += comp_hdr->orig_size; + total_compressed_size += comp_hdr->comp_size; + } + } + + /* Check overflow */ + if (hdr->t_flags & FLUX_TUPLE_HAS_OVERFLOW) + total_overflow_tuples++; + + /* Track commit-ts word range (diagnostic) */ + if (hdr->t_commit_ts > 0) + { + commit_ts_seen = true; + if (hdr->t_commit_ts < commit_ts_min) + commit_ts_min = hdr->t_commit_ts; + if (hdr->t_commit_ts > commit_ts_max) + commit_ts_max = hdr->t_commit_ts; + } + } + + UnlockReleaseBuffer(buffer); + } + + /* Compute derived statistics */ + stats->total_live_tuples = total_live; + stats->total_dead_tuples = total_dead; + + if (total_live > 0) + { + stats->avg_tuple_size = (double) total_tuple_bytes / (double) total_live; + stats->pct_compressed = (double) total_compressed_tuples / (double) total_live; + stats->pct_overflow = (double) total_overflow_tuples / (double) total_live; + } + + if (total_compressed_size > 0 && total_uncompressed_size > 0) + stats->compression_ratio = (double) total_uncompressed_size / + (double) total_compressed_size; + else + stats->compression_ratio = 1.0; + + if (total_overflow_tuples > 0) + stats->avg_overflow_chain_len = (double) total_overflow_chains / + (double) total_overflow_tuples; + + if (nblocks > 0) + { + stats->avg_live_per_page = (double) total_live / (double) nblocks; + stats->free_space_frac = total_free_space / (double) nblocks; + } + + /* Bloat = total allocated space / actual live data */ + if (total_tuple_bytes > 0) + stats->bloat_factor = ((double) nblocks * BLCKSZ) / + (double) total_tuple_bytes; + else + stats->bloat_factor = 1.0; + + /* commit-ts word range */ + if (commit_ts_seen) + { + stats->commit_ts_stats_valid = true; + stats->commit_ts_min = commit_ts_min; + stats->commit_ts_max = commit_ts_max; + } +} + +/* + * FluxLogRelationStats + * + * Emit the collected FLUX statistics at the given log level (typically + * DEBUG1 during ANALYZE, or LOG for diagnostic purposes). Produces three + * separate ereport messages: + * 1. Page counts and live/dead tuple totals + * 2. Average tuple size, compression percentage/ratio, overflow stats + * 3. Average live tuples per page, free space fraction, bloat factor + * If commit-ts word statistics are valid, a fourth message shows the range. + * + * Parameters: + * rel - the relation whose statistics are being logged + * stats - the collected FluxRelationStats structure + * elevel - ereport log level (e.g., DEBUG1, LOG, WARNING) + */ +void +FluxLogRelationStats(Relation rel, const FluxRelationStats *stats, int elevel) +{ + ereport(elevel, + (errmsg("FLUX stats for \"%s\": " + "%lld pages, %lld live tuples, %lld dead tuples", + RelationGetRelationName(rel), + (long long) stats->total_pages, + (long long) stats->total_live_tuples, + (long long) stats->total_dead_tuples))); + + ereport(elevel, + (errmsg("FLUX stats for \"%s\": " + "avg tuple size %.1f bytes, " + "%.1f%% compressed (ratio %.2f), " + "%.1f%% overflow (avg chain %.1f)", + RelationGetRelationName(rel), + stats->avg_tuple_size, + stats->pct_compressed * 100.0, + stats->compression_ratio, + stats->pct_overflow * 100.0, + stats->avg_overflow_chain_len))); + + ereport(elevel, + (errmsg("FLUX stats for \"%s\": " + "avg %.1f live/page, " + "%.1f%% free space, " + "bloat factor %.2f", + RelationGetRelationName(rel), + stats->avg_live_per_page, + stats->free_space_frac * 100.0, + stats->bloat_factor))); + + if (stats->commit_ts_stats_valid) + { + ereport(elevel, + (errmsg("FLUX stats for \"%s\": " + "commit-ts word range [%llu .. %llu]", + RelationGetRelationName(rel), + (unsigned long long) stats->commit_ts_min, + (unsigned long long) stats->commit_ts_max))); + } +} diff --git a/src/backend/access/flux/flux_tuple.c b/src/backend/access/flux/flux_tuple.c new file mode 100644 index 0000000000000..28724c163a844 --- /dev/null +++ b/src/backend/access/flux/flux_tuple.c @@ -0,0 +1,1106 @@ +/*------------------------------------------------------------------------- + * + * flux_tuple.c + * FLUX tuple handling routines + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_tuple.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/detoast.h" +#include "access/flux.h" +#include "access/flux_xlog.h" +#include "access/heapam.h" +#include "access/heaptoast.h" +#include "access/toast_helper.h" +#include "access/toast_internals.h" +#include "access/tupdesc.h" +#include "access/tupmacs.h" +#include "catalog/pg_type.h" +#include "common/hashfn.h" +#include "executor/tuptable.h" +#include "storage/bufpage.h" +#include "utils/datum.h" +#include "utils/lsyscache.h" +#include "utils/memutils.h" + +/* + * FluxComputeDataSize + * + * Calculate the total on-disk size needed to store a tuple with the given + * attributes. This includes the fixed-size FluxTupleHeader, the null + * bitmap, alignment padding, and all attribute data. + * + * Parameters: + * tupdesc - tuple descriptor defining the attributes + * values - array of Datum values for each attribute + * isnull - array of boolean null indicators + * + * Returns the total size in bytes, including header and alignment. + */ +Size +FluxComputeDataSize(TupleDesc tupdesc, Datum *values, bool *isnull) +{ + Size data_length = 0; + Size bitmap_len; + int i; + + Assert(tupdesc != NULL); + Assert(values != NULL); + Assert(isnull != NULL); + + /* Calculate null bitmap length */ + bitmap_len = BITMAPLEN(tupdesc->natts); + + /* Start with tuple header size */ + data_length = FLUX_TUPLE_OVERHEAD + MAXALIGN(bitmap_len); + + /* Add space for each attribute */ + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped) + continue; + + if (!isnull[i]) + { + Size attr_len; + + /* Align attribute */ + data_length = att_align_nominal(data_length, att->attalign); + + if (att->attlen > 0) + { + /* Fixed-length attribute */ + attr_len = att->attlen; + } + else if (att->attlen == -1) + { + /* Variable-length attribute */ + attr_len = VARSIZE_ANY(DatumGetPointer(values[i])); + } + else if (att->attlen == -2) + { + /* C string */ + attr_len = strlen(DatumGetCString(values[i])) + 1; + } + else + { + elog(ERROR, "unsupported attribute length: %d", att->attlen); + } + + data_length += attr_len; + } + } + + return data_length; +} + +/* + * flux_toast_tuple + * + * TOAST the varlena columns of a to-be-stored FLUX tuple, exactly like heap. + * FLUX has no on-page overflow mechanism; wide values are pushed to the + * relation's standard heap TOAST table via the AM-agnostic toast_helper + * routines (toast_tuple_*). On return, values[]/isnull[] have had any + * externalized/compressed columns replaced so that the tuple formed from + * them fits within a FLUX page. + * + * oldvalues/oldisnull describe the previous tuple version on UPDATE (so + * unchanged external datums are reused and superseded ones scheduled for + * deletion); pass NULL for INSERT. + * + * The returned ToastTupleContext must be released with flux_toast_cleanup() + * after the caller has finished forming and storing the tuple. *changed is + * set true iff any column was actually toasted. + */ +void +flux_toast_tuple(Relation rel, Datum *values, bool *isnull, + Datum *oldvalues, bool *oldisnull, + ToastTupleContext *ttc, ToastAttrInfo *toast_attr, + bool *changed, uint32 options) +{ + TupleDesc tupleDesc = rel->rd_att; + int numAttrs = tupleDesc->natts; + Size hoff; + Size maxDataLen; + + options &= ~HEAP_INSERT_SPECULATIVE; + + ttc->ttc_rel = rel; + ttc->ttc_values = values; + ttc->ttc_isnull = isnull; + ttc->ttc_oldvalues = oldvalues; + ttc->ttc_oldisnull = oldisnull; + ttc->ttc_attr = toast_attr; + toast_tuple_init(ttc); + + /* + * Header overhead for a FLUX tuple: fixed header plus null bitmap when + * nulls are present. Convert to a data-size limit. FLUX targets the + * same TOAST_TUPLE_TARGET as heap so wide rows behave identically. + */ + hoff = FLUX_TUPLE_OVERHEAD; + if ((ttc->ttc_flags & TOAST_HAS_NULLS) != 0) + hoff += BITMAPLEN(numAttrs); + hoff = MAXALIGN(hoff); + maxDataLen = RelationGetToastTupleTarget(rel, TOAST_TUPLE_TARGET) - hoff; + + /* Round 1: compress EXTENDED, externalize very large EXTENDED/EXTERNAL */ + while (heap_compute_data_size(tupleDesc, values, isnull) > maxDataLen) + { + int biggest_attno; + + biggest_attno = toast_tuple_find_biggest_attribute(ttc, true, false); + if (biggest_attno < 0) + break; + if (TupleDescAttr(tupleDesc, biggest_attno)->attstorage == TYPSTORAGE_EXTENDED) + toast_tuple_try_compression(ttc, biggest_attno); + else + toast_attr[biggest_attno].tai_colflags |= TOASTCOL_INCOMPRESSIBLE; + if (toast_attr[biggest_attno].tai_size > maxDataLen && + rel->rd_rel->reltoastrelid != InvalidOid) + toast_tuple_externalize(ttc, biggest_attno, options); + } + + /* Round 2: externalize remaining inline EXTENDED/EXTERNAL */ + while (heap_compute_data_size(tupleDesc, values, isnull) > maxDataLen && + rel->rd_rel->reltoastrelid != InvalidOid) + { + int biggest_attno; + + biggest_attno = toast_tuple_find_biggest_attribute(ttc, false, false); + if (biggest_attno < 0) + break; + toast_tuple_externalize(ttc, biggest_attno, options); + } + + /* Round 3: compress MAIN */ + while (heap_compute_data_size(tupleDesc, values, isnull) > maxDataLen) + { + int biggest_attno; + + biggest_attno = toast_tuple_find_biggest_attribute(ttc, true, true); + if (biggest_attno < 0) + break; + toast_tuple_try_compression(ttc, biggest_attno); + } + + /* Round 4: externalize MAIN, at the larger MAIN target */ + maxDataLen = TOAST_TUPLE_TARGET_MAIN - hoff; + while (heap_compute_data_size(tupleDesc, values, isnull) > maxDataLen && + rel->rd_rel->reltoastrelid != InvalidOid) + { + int biggest_attno; + + biggest_attno = toast_tuple_find_biggest_attribute(ttc, false, true); + if (biggest_attno < 0) + break; + toast_tuple_externalize(ttc, biggest_attno, options); + } + + *changed = (ttc->ttc_flags & TOAST_NEEDS_CHANGE) != 0; +} + +/* + * flux_toast_cleanup + * + * Release toasting temporaries and delete any superseded external datums, + * mirroring toast_tuple_cleanup() as used by heap after the new tuple is + * durably stored. + */ +void +flux_toast_cleanup(ToastTupleContext *ttc) +{ + toast_tuple_cleanup(ttc); +} + +/* + * flux_toast_delete + * + * Delete the external TOAST datums referenced by a FLUX tuple that is being + * removed (DELETE, or the old version of an out-of-place UPDATE). + */ +void +flux_toast_delete(Relation rel, Datum *values, bool *isnull, + bool is_speculative) +{ + toast_delete_external(rel, values, isnull, is_speculative); +} + +/* + * FluxFormTuple + * + * Create a new FLUX tuple from the given attribute values and null indicators. + * Allocates memory for the FluxTupleData wrapper and the on-disk + * FluxTupleHeader + attribute data. + * + * When compression is enabled (flux_enable_compression GUC), variable-length + * attributes exceeding FLUX_MIN_COMPRESS_SIZE (32 bytes) are automatically + * compressed using the algorithm selected by FluxChooseCompressionType(). + * Compressed attributes are stored with a FluxCompressionHeader prefix and + * the tuple's FLUX_INFOMASK_COMPRESSED bit is set. + * + * When a relation is provided, large attributes exceeding FLUX_OVERFLOW_THRESHOLD + * are automatically stored in overflow pages. Overflow pointers are collected in + * overflow_buffers for atomic WAL logging by the caller. + * + * Parameters: + * tupdesc - tuple descriptor defining the schema + * values - array of Datum values for each attribute + * isnull - array of boolean null indicators + * rel - relation for overflow storage (NULL to disable overflow handling) + * overflow_buffers - output for overflow buffers (NULL if rel is NULL) + * + * Returns a palloc'd FluxTuple. The caller is responsible for freeing it + * with FluxFreeTuple() when done. + */ +static FluxTuple flux_form_tuple_internal(TupleDesc tupdesc, Datum *values, + bool *isnull, Relation rel, + FluxOverflowBuffers *overflow_buffers, + bool force_shrink, + const FluxOverflowPtr *old_ovptrs, + const bool *old_ovpresent); + +FluxTuple +FluxFormTuple(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers) +{ + return flux_form_tuple_internal(tupdesc, values, isnull, rel, + overflow_buffers, false, NULL, NULL); +} + +/* + * FluxFormTupleUpdate + * + * Like FluxFormTuple, but for the in-place UPDATE path. old_ovptrs and + * old_ovpresent (indexed by attnum, natts entries) describe the OLD tuple's + * on-page overflow pointers, collected while its buffer was still locked. Any + * over-threshold varlena whose content hash matches the old pointer's stored + * hash (and byte-verifies equal) is COW-referenced against the existing + * overflow chain instead of being re-stored, avoiding needless disk growth and + * WAL. Pass NULL arrays to disable this (identical to FluxFormTuple). + */ +FluxTuple +FluxFormTupleUpdate(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers, + const FluxOverflowPtr *old_ovptrs, + const bool *old_ovpresent) +{ + return flux_form_tuple_internal(tupdesc, values, isnull, rel, + overflow_buffers, false, + old_ovptrs, old_ovpresent); +} + +/* + * FluxFormTupleForceShrink + * + * Like FluxFormTuple, but forces every inline varlena attribute larger than + * an overflow pointer off-page with a zero inline prefix, regardless of the + * normal FLUX_OVERFLOW_THRESHOLD. This shrinks the main tuple to its minimum + * footprint (header + fixed columns + one overflow pointer per large varlena). + * + * Used as a last resort by the in-place UPDATE path: when an updated tuple has + * grown beyond the space available on its page and TID stability forbids + * relocating it, pushing its variable-length data off-page lets the main tuple + * fit back into (or near) its original slot. A relation and overflow_buffers + * are mandatory because every forced column is written to overflow pages. + */ +FluxTuple +FluxFormTupleForceShrink(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers) +{ + Assert(rel != NULL); + Assert(overflow_buffers != NULL); + return flux_form_tuple_internal(tupdesc, values, isnull, rel, + overflow_buffers, true, NULL, NULL); +} + +static FluxTuple +flux_form_tuple_internal(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers, + bool force_shrink, + const FluxOverflowPtr *old_ovptrs, + const bool *old_ovpresent) +{ + FluxTuple tuple; + FluxTupleHeader *header; + Size data_length; + Size tuple_length; + Size bitmap_len; + char *data_ptr; + uint8 *nulls_bitmap; + int i; + bool has_nulls = false; + bool has_varwidth = false; + bool has_external = false; + bool has_compressed = false; + bool has_overflow = false; + + /* + * Working arrays for compressed/overflowed values. We attempt compression + * and overflow first, then compute the final tuple size using the + * (possibly compressed/overflowed) attribute values. + * + * Use stack arrays for small tuples (common OLTP case) to avoid palloc. + */ +#define FLUX_FORM_STACK_ATTRS 16 + Datum *work_values; + bool *is_compressed; /* Track which attrs were compressed */ + bool *is_overflowed; /* Track which attrs were overflowed */ + Datum work_values_stack[FLUX_FORM_STACK_ATTRS]; + bool is_compressed_stack[FLUX_FORM_STACK_ATTRS]; + bool is_overflowed_stack[FLUX_FORM_STACK_ATTRS]; + + Assert(tupdesc != NULL); + Assert(values != NULL); + Assert(isnull != NULL); + + if (tupdesc->natts <= FLUX_FORM_STACK_ATTRS) + { + work_values = work_values_stack; + is_compressed = is_compressed_stack; + is_overflowed = is_overflowed_stack; + memset(is_compressed, 0, tupdesc->natts * sizeof(bool)); + memset(is_overflowed, 0, tupdesc->natts * sizeof(bool)); + } + else + { + work_values = (Datum *) palloc(tupdesc->natts * sizeof(Datum)); + is_compressed = (bool *) palloc0(tupdesc->natts * sizeof(bool)); + is_overflowed = (bool *) palloc0(tupdesc->natts * sizeof(bool)); + } + memcpy(work_values, values, tupdesc->natts * sizeof(Datum)); + + /* FLUX does not use the RECNO overflow mechanism; wide varlena values are + * TOASTed by the caller (standard heap TOAST path) before reaching here. + * Likewise, FLUX does not compress attributes. work_values therefore + * mirrors values exactly. */ + + /* + * Phase 2: Calculate total space needed + */ + data_length = FluxComputeDataSize(tupdesc, work_values, isnull); + tuple_length = data_length; + + /* Allocate tuple */ + tuple = (FluxTuple) palloc0(sizeof(FluxTupleData)); + tuple->t_len = tuple_length; + tuple->t_data = (FluxTupleHeader *) palloc0(tuple_length); + + /* Set up header */ + header = tuple->t_data; + header->t_natts = tupdesc->natts; + header->t_flags = 0; + header->t_commit_ts = 0; /* Will be set during insert */ + ItemPointerSetInvalid(&header->t_ctid); + header->t_infomask = 0; + + if (has_compressed) + { + header->t_flags |= FLUX_TUPLE_COMPRESSED; + header->t_infomask |= FLUX_INFOMASK_COMPRESSED; + } + (void) has_compressed; + + /* Set up null bitmap */ + bitmap_len = BITMAPLEN(tupdesc->natts); + nulls_bitmap = (uint8 *) header->t_attrs_bitmap; + data_ptr = (char *) header + FLUX_TUPLE_OVERHEAD + MAXALIGN(bitmap_len); + + /* + * Initialize null bitmap - PostgreSQL expects all bits set to 1 initially + * (all NOT NULL) + */ + memset(nulls_bitmap, 0xFF, bitmap_len); + + /* Set infomask bits */ + for (i = 0; i < tupdesc->natts; i++) + { + if (isnull[i]) + { + has_nulls = true; + /* Clear the bit for NULL attributes (bit=0 means NULL) */ + nulls_bitmap[i >> 3] &= ~(1 << (i & 0x07)); + } + else + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attlen == -1 || att->attlen == -2) + has_varwidth = true; + + /* Check for external storage */ + if (att->attlen == -1 && VARATT_IS_EXTERNAL(DatumGetPointer(work_values[i]))) + has_external = true; + } + } + + if (has_nulls) + header->t_infomask |= FLUX_INFOMASK_HASNULL; + if (has_varwidth) + header->t_infomask |= FLUX_INFOMASK_HASVARWIDTH; + if (has_external) + header->t_infomask |= FLUX_INFOMASK_HASEXTERNAL; + if (has_overflow) + { + header->t_flags |= FLUX_TUPLE_HAS_OVERFLOW; + header->t_infomask |= FLUX_INFOMASK_HASOVERFLOW; + } + + /* + * Phase 3: Store attribute values (using compressed data where + * applicable) + */ + for (i = 0; i < tupdesc->natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped || isnull[i]) + continue; + + /* Align attribute */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + + if (att->attlen > 0) + { + /* + * Fixed-length attribute - never compressed. Must distinguish + * byval from by-reference fixed-length types (e.g., timetz is 12 + * bytes but passed by reference). + */ + if (att->attbyval) + store_att_byval(data_ptr, work_values[i], att->attlen); + else + memcpy(data_ptr, DatumGetPointer(work_values[i]), att->attlen); + data_ptr += att->attlen; + } + else if (att->attlen == -1) + { + /* Variable-length attribute (possibly compressed) */ + Size attr_len = VARSIZE_ANY(DatumGetPointer(work_values[i])); + + memcpy(data_ptr, DatumGetPointer(work_values[i]), attr_len); + data_ptr += attr_len; + } + else if (att->attlen == -2) + { + /* C string */ + Size attr_len = strlen(DatumGetCString(work_values[i])) + 1; + + memcpy(data_ptr, DatumGetCString(work_values[i]), attr_len); + data_ptr += attr_len; + } + } + + /* + * Free compressed and overflow datums that were allocated by + * FluxCompressAttribute and FluxStoreOverflowColumn + */ + for (i = 0; i < tupdesc->natts; i++) + { + if (is_compressed[i] || is_overflowed[i]) + pfree(DatumGetPointer(work_values[i])); + } + if (tupdesc->natts > FLUX_FORM_STACK_ATTRS) + { + pfree(work_values); + pfree(is_compressed); + pfree(is_overflowed); + } + + return tuple; +} + +/* + * FluxDeformTuple + * + * Extract attribute values and null indicators from a FLUX tuple into the + * provided arrays. This is the inverse of FluxFormTuple(). + * + * When the tuple has the FLUX_INFOMASK_COMPRESSED flag set, variable-length + * attributes may contain a FluxCompressionHeader prefix followed by + * compressed data. This function transparently decompresses such attributes + * so that callers always see the original uncompressed Datum values. + * + * Parameters: + * tuple - the FLUX tuple to deform + * tupdesc - tuple descriptor defining the schema + * values - output array of Datum values (must be pre-allocated) + * isnull - output array of boolean null indicators (must be pre-allocated) + */ +void +FluxDeformTuple(Relation rel, FluxTuple tuple, TupleDesc tupdesc, Datum *values, bool *isnull) +{ + FluxTupleHeader *header; + uint8 *nulls_bitmap; + char *data_ptr; + Size bitmap_len; + int i; + bool tuple_has_compressed; + + Assert(tuple != NULL); + Assert(tupdesc != NULL); + Assert(values != NULL); + Assert(isnull != NULL); + + header = tuple->t_data; + + /* + * Use the tuple's actual natts for bitmap_len and data_ptr calculation. + * After ALTER TABLE ADD COLUMN, old tuples may have fewer attributes. + */ + { + int tuple_natts = header->t_natts; + int loop_natts = Min(tupdesc->natts, tuple_natts); + + bitmap_len = BITMAPLEN(tuple_natts); + nulls_bitmap = (uint8 *) header->t_attrs_bitmap; + data_ptr = (char *) header + FLUX_TUPLE_OVERHEAD + MAXALIGN(bitmap_len); + + tuple_has_compressed = false; /* FLUX does not compress attributes */ + (void) tuple_has_compressed; + + /* Extract each attribute present in the tuple */ + for (i = 0; i < loop_natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + + if (att->attisdropped) + { + values[i] = (Datum) 0; + isnull[i] = true; + continue; + } + + /* + * Check null bitmap: bit=0 means NULL (bit cleared in + * FluxFormTuple) + */ + if (header->t_infomask & FLUX_INFOMASK_HASNULL && + att_isnull(i, nulls_bitmap)) + { + values[i] = (Datum) 0; + isnull[i] = true; + continue; + } + + isnull[i] = false; + + /* Align attribute */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + + if (att->attlen > 0) + { + /* + * Fixed-length attribute - never compressed. Use actual + * attbyval flag (e.g., timetz is 12 bytes but by-ref). + */ + values[i] = fetch_att(data_ptr, att->attbyval, att->attlen); + data_ptr += att->attlen; + } + else if (att->attlen == -1) + { + /* Variable-length attribute (FLUX stores it verbatim) */ + Size attr_len = VARSIZE_ANY(data_ptr); + + values[i] = PointerGetDatum(data_ptr); + data_ptr += attr_len; + } + else if (att->attlen == -2) + { + /* C string - never compressed */ + values[i] = CStringGetDatum(data_ptr); + data_ptr += strlen(data_ptr) + 1; + } + else + { + elog(ERROR, "unsupported attribute length: %d", att->attlen); + } + } + + /* + * Fill missing attributes with defaults for columns added by ALTER + * TABLE ADD COLUMN after this tuple was stored. + */ + for (i = loop_natts; i < tupdesc->natts; i++) + { + values[i] = (Datum) 0; + isnull[i] = true; + } + } /* end of tuple_natts scope block */ +} + +/* + * FluxFreeTuple + * + * Free a FLUX tuple and its associated data. Safe to call with NULL. + * + * Parameters: + * tuple - the FluxTuple to free (may be NULL) + */ +void +FluxFreeTuple(FluxTuple tuple) +{ + if (tuple) + { + if (tuple->t_data) + pfree(tuple->t_data); + pfree(tuple); + } +} + +/* + * FluxInitPage + * + * Initialize a new FLUX page. Calls PostgreSQL's PageInit() with space + * reserved for FluxPageOpaqueData in the special area, then initializes + * the opaque data fields to their default values. + * + * Parameters: + * page - pointer to the page buffer + * pageSize - size of the page (typically BLCKSZ = 8192) + */ +void +FluxInitPage(Page page, Size pageSize) +{ + FluxPageOpaque phdr; + + PageInit(page, pageSize, sizeof(FluxPageOpaqueData)); + + phdr = FluxPageGetOpaque(page); + phdr->pd_commit_ts_and_flags = 0; +} + +/* + * FluxPageAddTuple + * + * Add a FLUX tuple to a page using PageAddItem(). Updates the page's + * opaque data (commit timestamp, free space) after successful insertion. + * + * Parameters: + * page - the page to add the tuple to (must be exclusively locked) + * tuple - the FLUX tuple to add + * tuple_size - size of the tuple data in bytes + * + * Returns the OffsetNumber where the tuple was placed, or + * InvalidOffsetNumber if the page does not have enough space. + */ +OffsetNumber +FluxPageAddTuple(Page page, FluxTuple tuple, Size tuple_size) +{ + FluxPageOpaque phdr; + OffsetNumber offnum; + + /* Try to add the tuple */ + offnum = PageAddItem(page, tuple->t_data, tuple_size, + InvalidOffsetNumber, false, false); + + if (offnum == InvalidOffsetNumber) + return InvalidOffsetNumber; + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + + /* Mark page for defragmentation if fragmented */ + if (PageGetFreeSpace(page) >= tuple_size * 2 && + PageGetMaxOffsetNumber(page) > FirstOffsetNumber + 5) + { + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + } + + return offnum; +} + +/* + * FluxPageUpdateTuple + * + * Attempt to update a tuple in place on a FLUX page. If the new tuple + * fits within the existing allocation (same size or smaller), the data is + * overwritten directly (in-place update). If the new tuple is larger but + * the page has enough total free space, the old tuple is removed and the + * new tuple is added at the same or a new offset. + * + * Parameters: + * page - the page containing the tuple (must be exclusively locked) + * offnum - offset number of the tuple to update + * new_tuple - the new tuple data + * old_commit_ts - commit timestamp of the old version (for WAL logging) + * new_commit_ts - commit timestamp for the new version + * + * Returns true if the update was performed on this page, false if the new + * tuple does not fit (caller must handle cross-page update). + */ +bool +FluxPageUpdateTuple(Page page, OffsetNumber offnum, FluxTuple new_tuple, + uint64 old_commit_ts, uint64 new_commit_ts) +{ + ItemId itemid; + FluxTupleHeader *old_tuple; + Size old_size, + new_size; + FluxPageOpaque phdr; + Size available_space; + OffsetNumber new_offnum; + + itemid = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(itemid)) + return false; + + old_tuple = (FluxTupleHeader *) PageGetItem(page, itemid); + old_size = ItemIdGetLength(itemid); + new_size = new_tuple->t_len; + + /* Check if new tuple fits in same space */ + if (new_size <= old_size) + { + /* In-place update */ + memcpy(old_tuple, new_tuple->t_data, new_size); + if (new_size < old_size) + { + /* Update item length */ + ItemIdSetNormal(itemid, ItemIdGetOffset(itemid), new_size); + } + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), new_commit_ts)); + + return true; + } + + /* Need more space - check if available */ + available_space = PageGetFreeSpace(page) + old_size; + if (new_size <= available_space) + { + /* + * Remove old tuple and re-add the new (larger) one. + * + * We use FluxPageIndexTupleDelete instead of PageIndexTupleDelete + * because the page may contain LP_UNUSED items from defragmentation. + * PageIndexTupleDelete asserts all items are LP_NORMAL; + * FluxPageIndexTupleDelete skips LP_UNUSED items safely. + */ + FluxPageIndexTupleDelete(page, offnum); + + new_offnum = PageAddItem(page, new_tuple->t_data, + new_size, offnum, + false, false); + + if (new_offnum != InvalidOffsetNumber) + { + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), new_commit_ts)); + return true; + } + } + + return false; /* Update failed - need new page */ +} + +/* + * Get number of live tuples on a FLUX page + */ +int +FluxPageGetLiveTuples(Page page, uint64 snapshot_ts) +{ + OffsetNumber maxoff = PageGetMaxOffsetNumber(page); + int live_tuples = 0; + OffsetNumber offnum; + + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(page, offnum); + + if (ItemIdIsNormal(itemid)) + { + FluxTupleHeader *tuple = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* + * Heap-shaped: "live" here means not carrying a committed delete + * marker. This helper is only an estimate (no current callers), so + * it counts non-DELETED tuples rather than doing a full snapshot + * visibility check. snapshot_ts is unused. + */ + (void) snapshot_ts; + if (!(tuple->t_flags & FLUX_TUPLE_DELETED)) + live_tuples++; + } + } + + return live_tuples; +} + +/* + * FluxPageDefragment + * + * Compact a FLUX page by calling PageRepairFragmentation() to consolidate + * free space. Updates the page opaque data with the new free space amount, + * increments the defrag counter, and clears the FLUX_PAGE_DEFRAG_NEEDED flag. + * + * Parameters: + * page - the page to defragment (must be exclusively locked) + */ +void +FluxPageDefragment(Page page) +{ + FluxPageOpaque phdr = FluxPageGetOpaque(page); + + /* Use standard PageRepairFragmentation */ + PageRepairFragmentation(page); + + /* Update page header */ + FluxPageClearFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); +} + +/* + * FluxPageIndexTupleDelete + * + * Like PageIndexTupleDelete, but tolerates LP_UNUSED items on the page. + * + * Standard PageIndexTupleDelete asserts that ALL line pointers have storage + * (ItemIdHasStorage). FLUX pages may contain LP_UNUSED items left behind + * by opportunistic defragmentation. This function skips LP_UNUSED items + * when adjusting offsets, preventing both assertion failures and data + * corruption (LP_UNUSED items have lp_off=0 and must not be adjusted). + */ +void +FluxPageIndexTupleDelete(Page page, OffsetNumber offnum) +{ + PageHeader phdr = (PageHeader) page; + char *addr; + ItemId tup; + Size size; + unsigned offset; + int nbytes; + int offidx; + int nline; + + if (phdr->pd_lower < SizeOfPageHeaderData || + phdr->pd_lower > phdr->pd_upper || + phdr->pd_upper > phdr->pd_special || + phdr->pd_special > BLCKSZ || + phdr->pd_special != MAXALIGN(phdr->pd_special)) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("corrupted page pointers: lower = %u, upper = %u, special = %u", + phdr->pd_lower, phdr->pd_upper, phdr->pd_special))); + + nline = PageGetMaxOffsetNumber(page); + if ((int) offnum <= 0 || (int) offnum > nline) + elog(ERROR, "invalid index offnum: %u", offnum); + + offidx = offnum - 1; + + tup = PageGetItemId(page, offnum); + Assert(ItemIdHasStorage(tup)); + size = ItemIdGetLength(tup); + offset = ItemIdGetOffset(tup); + + if (offset < phdr->pd_upper || (offset + size) > phdr->pd_special || + offset != MAXALIGN(offset)) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("corrupted line pointer: offset = %u, size = %zu", + offset, size))); + + size = MAXALIGN(size); + + /* Remove the line pointer entry by shifting subsequent entries down */ + nbytes = phdr->pd_lower - + ((char *) &phdr->pd_linp[offidx + 1] - (char *) phdr); + + if (nbytes > 0) + memmove(&(phdr->pd_linp[offidx]), + &(phdr->pd_linp[offidx + 1]), + nbytes); + + /* Shift tuple data forward to fill the gap */ + addr = (char *) page + phdr->pd_upper; + + if (offset > phdr->pd_upper) + memmove(addr + size, addr, offset - phdr->pd_upper); + + phdr->pd_upper += size; + phdr->pd_lower -= sizeof(ItemIdData); + + /* Adjust remaining line pointer offsets, skipping LP_UNUSED items */ + if (!PageIsEmpty(page)) + { + int i; + + nline--; + for (i = 1; i <= nline; i++) + { + ItemId ii = PageGetItemId(page, i); + + if (!ItemIdHasStorage(ii)) + continue; + if (ItemIdGetOffset(ii) <= offset) + ii->lp_off += size; + } + } +} + +/* + * Convert a FLUX tuple to a TupleTableSlot + * + * This is the primary retrieval path used during sequential scans. + * When the tuple has compressed attributes (FLUX_INFOMASK_COMPRESSED), + * they are transparently decompressed so the slot always contains + * uncompressed data visible to the executor. + * + * Overflow attributes (FLUX_INFOMASK_HASOVERFLOW) are returned as-is + * by this function since it has no Relation handle. Use + * FluxTupleToSlotWithOverflow() for transparent overflow fetching. + */ +bool +FluxTupleToSlot(FluxTupleHeader *tuple_header, TupleTableSlot *slot) +{ + return FluxTupleToSlotWithOverflow(tuple_header, slot, NULL); +} + +/* + * Convert a FLUX tuple to a TupleTableSlot with overflow support. + * + * When rel is non-NULL and the tuple has overflow attributes, they are + * transparently fetched from overflow records and the slot receives the + * complete original values. When rel is NULL, overflow pointers are + * returned as-is (same as FluxTupleToSlot). + */ +bool +FluxTupleToSlotWithOverflow(FluxTupleHeader *tuple_header, + TupleTableSlot *slot, Relation rel) +{ + TupleDesc tupdesc = slot->tts_tupleDescriptor; + char *data_ptr; + uint8 *nulls_bitmap; + int i; + Size bitmap_len; + bool tuple_has_compressed; + bool tuple_has_overflow; + + if (!tuple_header) + return false; + + /* Check if tuple is deleted */ + if (tuple_header->t_flags & FLUX_TUPLE_DELETED) + return false; + + /* Clear the slot first */ + ExecClearTuple(slot); + + /* + * Use the tuple's actual natts for bitmap_len and data_ptr calculation. + * After ALTER TABLE ADD COLUMN, old tuples may have fewer attributes than + * the current schema expects. + */ + { + int tuple_natts = tuple_header->t_natts; + int loop_natts = Min(tupdesc->natts, tuple_natts); + + bitmap_len = BITMAPLEN(tuple_natts); + + /* Set up pointers to data */ + nulls_bitmap = (uint8 *) tuple_header->t_attrs_bitmap; + data_ptr = (char *) tuple_header + FLUX_TUPLE_OVERHEAD + MAXALIGN(bitmap_len); + + tuple_has_compressed = false; /* FLUX does not compress attributes */ + tuple_has_overflow = false; /* FLUX uses TOAST, not on-page overflow */ + (void) tuple_has_compressed; + (void) tuple_has_overflow; + + /* Decode each attribute present in the tuple */ + for (i = 0; i < loop_natts; i++) + { + Form_pg_attribute att = TupleDescAttr(tupdesc, i); + bool is_null; + + if (att->attisdropped) + { + slot->tts_values[i] = (Datum) 0; + slot->tts_isnull[i] = true; + continue; + } + + /* Check if attribute is null */ + is_null = att_isnull(i, nulls_bitmap); + + if (is_null) + { + slot->tts_values[i] = (Datum) 0; + slot->tts_isnull[i] = true; + } + else + { + /* Extract the actual data */ + if (att->attlen == -1) + { + Size attr_len; + + /* + * Align to the start of this varlena, matching the form + * path (FluxFormTuple aligns data_ptr at the start of each + * attribute before writing). Without this the previous + * attribute's raw += attr_len advance can leave data_ptr + * unaligned, so VARSIZE_ANY reads the length from padding + * bytes -> garbage length -> pglz-corrupt / overrun. + */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + attr_len = VARSIZE_ANY(data_ptr); + + /* + * FLUX stores varlena values verbatim (wide values are + * TOASTed by the standard heap TOAST path; no on-page + * overflow, no attribute compression). + */ + slot->tts_values[i] = PointerGetDatum(data_ptr); + data_ptr = (char *) att_align_nominal(data_ptr + attr_len, att->attalign); + } + else if (att->attlen > 0) + { + /* Fixed-length attribute - never compressed or overflow */ + data_ptr = (char *) att_align_nominal(data_ptr, att->attalign); + slot->tts_values[i] = fetchatt(att, data_ptr); + data_ptr += att->attlen; + } + else + { + /* This shouldn't happen */ + elog(ERROR, "unsupported attribute length: %d", att->attlen); + } + + slot->tts_isnull[i] = false; + } + } + + /* + * Fill missing attributes with defaults for columns added by ALTER + * TABLE ADD COLUMN after this tuple was stored. + */ + if (loop_natts < tupdesc->natts) + { + for (i = loop_natts; i < tupdesc->natts; i++) + { + slot->tts_values[i] = (Datum) 0; + slot->tts_isnull[i] = true; + } + slot->tts_nvalid = loop_natts; + slot_getmissingattrs(slot, loop_natts, tupdesc->natts); + } + } /* end of tuple_natts scope block */ + + /* Mark slot as valid */ + slot->tts_flags &= ~TTS_FLAG_EMPTY; + slot->tts_nvalid = tupdesc->natts; + + return true; +} diff --git a/src/backend/access/flux/flux_undo.c b/src/backend/access/flux/flux_undo.c new file mode 100644 index 0000000000000..3b2ca9a89978e --- /dev/null +++ b/src/backend/access/flux/flux_undo.c @@ -0,0 +1,440 @@ +/*------------------------------------------------------------------------- + * + * flux_undo.c + * FLUX UNDO resource manager + * + * FLUX writes one UNDO record per tuple INSERT, UPDATE and DELETE via + * the shared UNDO-in-WAL infrastructure. Records carry rmid + * UNDO_RMID_FLUX and an info subtype (FLUX_UNDO_INSERT / UPDATE / + * DELETE / DELTA_UPDATE); rollback is driven by undoapply.c which + * dispatches to flux_undo_apply() based on rmid. + * + * Visibility of aborted rows is handled independently of physical + * undo application: FLUX tuples carry a FLUX_TUPLE_UNCOMMITTED flag + * whose MVCC-visibility path consults the sLog, so an aborted + * transaction's tuples are invisible the moment the sLog entry + * transitions to ABORTED (see flux_slog.c's XACT_EVENT_ABORT handler). + * The physical page-mutation done here reclaims on-disk space so + * VACUUM does not have to touch every aborted row. + * + * Crash safety is provided by emitting an xl_undo_apply CLR record + * (XLOG_UNDO_APPLY_RECORD / RM_UNDO_ID) for every page modification. + * The CLR carries the new tuple image (or the LP-state change) and is + * replayed idempotently by the generic undo_xlog.c redo handler; + * FLUX does not need its own redo routine for the undo-apply path. + * + * The callback mirrors heapam_undo.c's control flow: + * + * 1. Defer while in crash recovery or inside a transaction's abort + * path (BumpContext makes relation_close/pfree unsafe); the + * logical-revert worker re-drives the record from a clean top- + * level memory context. + * 2. try_relation_open() the target; if the relation was dropped + * or truncated past the target block, return UNDO_APPLY_SKIPPED. + * 3. Dispatch on info to a page-modification branch, emit a CLR, + * mark the buffer dirty, release locks, close. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/backend/access/flux/flux_undo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/heapam.h" +#include "access/flux.h" +#include "access/flux_undo.h" +#include "access/relation.h" +#include "access/table.h" +#include "access/undo_xlog.h" +#include "access/undormgr.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlogrecovery.h" +#include "access/xlogutils.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "utils/rel.h" +#include "utils/relcache.h" + + +static UndoApplyResult flux_undo_apply(uint8 rmid, uint16 info, + TransactionId xid, Oid reloid, + const char *payload, Size payload_len, + UndoRecPtr urec_ptr); +static void flux_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len); + + +/* The FLUX UNDO RM registration entry */ +static const UndoRmgrData flux_undo_rmgr = { + .rm_name = "flux", + .rm_undo = flux_undo_apply, + .rm_desc = flux_undo_desc, +}; + + +/* + * FluxUndoRmgrInit + * Register the FLUX UNDO resource manager. + * + * Called from InitializeUndoSubsystem() at postmaster startup, alongside + * HeapUndoRmgrInit() and NbtreeUndoRmgrInit(). + */ +void +FluxUndoRmgrInit(void) +{ + RegisterUndoRmgr(UNDO_RMID_FLUX, &flux_undo_rmgr); + + /* + * Install the FLUX implementations of the AM-neutral per-relation UNDO + * hooks now, before crash recovery can replay a RELUNDO CLR that would + * dispatch through them. + */ + FluxRelUndoInstallHooks(); +} + + +/* + * emit_flux_undo_clr + * Emit an XLOG_UNDO_APPLY_RECORD CLR for the page modification + * just performed. Must be called inside the critical section, + * before END_CRIT_SECTION / UnlockReleaseBuffer. + * + * tuple_data is the image to replay into the target slot on redo + * (NULL for LP_UNUSED cases). tuple_len must match the on-page slot + * length that should be installed. + */ +static void +emit_flux_undo_clr(Relation rel, Buffer buffer, UndoRecPtr urec_ptr, + TransactionId xid, BlockNumber blkno, OffsetNumber offnum, + uint16 info, uint16 clr_flags, + const char *tuple_data, uint32 tuple_len) +{ + xl_undo_apply xlrec; + XLogRecPtr lsn; + + if (!RelationNeedsWAL(rel)) + { + /* + * Unlogged / temp relations need no CLR: they do not survive a crash, + * so replay idempotency is irrelevant. + */ + PageSetLSN(BufferGetPage(buffer), GetXLogInsertRecPtr()); + return; + } + + xlrec.urec_ptr = urec_ptr; + xlrec.xid = xid; + xlrec.target_locator = rel->rd_locator; + xlrec.target_block = blkno; + xlrec.target_offset = offnum; + xlrec.operation_type = info; + xlrec.clr_flags = clr_flags; + xlrec.tuple_len = tuple_len; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfUndoApply); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + if ((clr_flags & UNDO_CLR_HAS_TUPLE) && tuple_data != NULL && tuple_len > 0) + XLogRegisterBufData(0, tuple_data, tuple_len); + + lsn = XLogInsert(RM_UNDO_ID, XLOG_UNDO_APPLY_RECORD); + PageSetLSN(BufferGetPage(buffer), lsn); +} + + +/* + * apply_flux_undo_insert + * Undo an INSERT: mark the inserted tuple FLUX_TUPLE_DELETED so + * VACUUM can reclaim its space. The sLog-driven visibility path + * already hides the row from readers once the transaction is + * marked ABORTED; this routine exists purely for physical + * space-reclaim. + * + * We do not use UNDO_CLR_LP_DEAD / UNDO_CLR_LP_UNUSED because those + * drop the item entirely, whereas FLUX needs the tuple header to + * stay intact (the page's commit_ts, overflow pointers, and the + * DELETED bit itself are all read by VACUUM). + */ +static void +apply_flux_undo_insert(Relation rel, Buffer buffer, OffsetNumber offnum, + BlockNumber blkno, UndoRecPtr urec_ptr, + TransactionId xid) +{ + Page page = BufferGetPage(buffer); + ItemId lp; + FluxTupleHeader hdr; + Size len; + char *slot; + + lp = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(lp)) + { + /* + * Already cleaned up (e.g. VACUUM ran between the abort and the + * logical-revert worker's pass). Nothing to do. + */ + return; + } + + len = ItemIdGetLength(lp); + slot = (char *) PageGetItem(page, lp); + + START_CRIT_SECTION(); + + /* + * Read, mutate, write back the tuple header in place. + * + * Set FLUX_TUPLE_DELETED so VACUUM can reclaim the space. Crucially, we + * must NOT clear FLUX_TUPLE_UNCOMMITTED: the inserting transaction never + * committed, so the tuple has to remain on the UNCOMMITTED visibility path + * (flux_mvcc.c), where a SLOG_OP_ABORTED entry resolves the row to + * not-visible. Clearing the flag would route the tuple to the + * post-commit deletion path, whose ABORTED handling resurrects aborted + * DELETEs and would therefore make this never-committed row visible. + */ + memcpy(&hdr, slot, sizeof(hdr)); + hdr.t_flags |= FLUX_TUPLE_DELETED; + memcpy(slot, &hdr, sizeof(hdr)); + + MarkBufferDirty(buffer); + + emit_flux_undo_clr(rel, buffer, urec_ptr, xid, blkno, offnum, + FLUX_UNDO_INSERT, UNDO_CLR_HAS_TUPLE, + slot, (uint32) len); + + END_CRIT_SECTION(); +} + + +/* + * apply_flux_undo_restore_tuple + * Shared helper for UPDATE, DELETE and DELTA_UPDATE undo: overwrite + * the current on-disk tuple with an in-memory before-image. The + * caller is responsible for preparing the before-image (direct + * copy for DELETE/UPDATE, reverse-diff reconstruction for + * DELTA_UPDATE). + * + * For DELETE undo the before-image already carries the pre-delete + * header, so the FLUX_TUPLE_DELETED bit will be cleared as a + * side-effect of the overwrite. + * + * If the before-image is larger than the current on-page slot, the + * undo is skipped (the slot was shrunk by a later in-place update and + * cannot be safely grown from here). The row will remain visible + * per sLog until VACUUM reclaims it. + */ +static bool +apply_flux_undo_restore_tuple(Relation rel, Buffer buffer, OffsetNumber offnum, + BlockNumber blkno, UndoRecPtr urec_ptr, + TransactionId xid, uint16 info, + const char *old_image, uint32 old_len) +{ + Page page = BufferGetPage(buffer); + ItemId lp; + char *slot; + + Assert(old_image != NULL && old_len > 0); + + lp = PageGetItemId(page, offnum); + if (!ItemIdIsNormal(lp)) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: item (%u, %u) no longer normal, skipping", + blkno, offnum))); + return false; + } + + if (ItemIdGetLength(lp) < old_len) + { + ereport(WARNING, + (errmsg_internal("FLUX UNDO: current slot at (%u, %u) is smaller " + "than before-image (%u < %u); rollback skipped, " + "row left under MVCC retention until VACUUM", + blkno, offnum, + (unsigned) ItemIdGetLength(lp), + (unsigned) old_len))); + return false; + } + + slot = (char *) PageGetItem(page, lp); + + START_CRIT_SECTION(); + + memcpy(slot, old_image, old_len); + if (ItemIdGetLength(lp) != old_len) + ItemIdSetNormal(lp, ItemIdGetOffset(lp), old_len); + + MarkBufferDirty(buffer); + + emit_flux_undo_clr(rel, buffer, urec_ptr, xid, blkno, offnum, + info, UNDO_CLR_HAS_TUPLE, + old_image, old_len); + + END_CRIT_SECTION(); + return true; +} + + +/* + * flux_undo_apply + * Apply a single FLUX UNDO record. + * + * Dispatched from undoapply.c for records tagged UNDO_RMID_FLUX. + */ +static UndoApplyResult +flux_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, + const char *payload, Size payload_len, UndoRecPtr urec_ptr) +{ + FluxUndoPayloadHeader hdr; + const char *image_bytes; + Size image_len; + Relation rel; + Buffer buffer; + BlockNumber blkno; + OffsetNumber offnum; + + Assert(rmid == UNDO_RMID_FLUX); + + /* + * Defer during crash recovery (syscache may not be initialised) or during + * an aborting transaction (BumpContext makes relation_close() and pfree() + * unsafe). The logical-revert worker will re-drive the record from a + * clean memory context. + */ + if (InRecovery || IsAbortedTransactionBlockState()) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: deferring xid %u record at %llu " + "(in recovery or abort path)", + xid, + (unsigned long long) urec_ptr))); + return UNDO_APPLY_SKIPPED; + } + + /* Decode the common payload header */ + if (payload_len < SizeOfFluxUndoPayloadHeader) + { + ereport(WARNING, + (errmsg_internal("FLUX UNDO: payload too short (%zu bytes) " + "for record at %llu", + payload_len, + (unsigned long long) urec_ptr))); + return UNDO_APPLY_ERROR; + } + memcpy(&hdr, payload, SizeOfFluxUndoPayloadHeader); + image_bytes = payload + SizeOfFluxUndoPayloadHeader; + image_len = payload_len - SizeOfFluxUndoPayloadHeader; + blkno = ItemPointerGetBlockNumber(&hdr.tid); + offnum = ItemPointerGetOffsetNumber(&hdr.tid); + + /* Open the relation; skip if dropped */ + rel = try_relation_open(reloid, RowExclusiveLock); + if (rel == NULL) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: relation %u no longer exists, " + "skipping record at %llu", + reloid, + (unsigned long long) urec_ptr))); + return UNDO_APPLY_SKIPPED; + } + + /* Skip if the target block was truncated away */ + if (RelationGetNumberOfBlocks(rel) <= blkno) + { + ereport(DEBUG2, + (errmsg_internal("FLUX UNDO: block %u beyond end of " + "relation %u, skipping", + blkno, reloid))); + relation_close(rel, RowExclusiveLock); + return UNDO_APPLY_SKIPPED; + } + + buffer = ReadBuffer(rel, blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + + switch (info) + { + case FLUX_UNDO_INSERT: + apply_flux_undo_insert(rel, buffer, offnum, blkno, + urec_ptr, xid); + break; + + case FLUX_UNDO_UPDATE: + case FLUX_UNDO_DELETE: + if (!(hdr.flags & FLUX_UNDO_FLAG_HAS_TUPLE) || image_len == 0) + { + ereport(WARNING, + (errmsg_internal("FLUX UNDO %s: missing before-image at %llu", + info == FLUX_UNDO_UPDATE ? "UPDATE" : "DELETE", + (unsigned long long) urec_ptr))); + break; + } + apply_flux_undo_restore_tuple(rel, buffer, offnum, blkno, + urec_ptr, xid, info, + image_bytes, (uint32) image_len); + break; + + default: + ereport(WARNING, + (errmsg_internal("FLUX UNDO: unknown subtype 0x%x at %llu", + info, (unsigned long long) urec_ptr))); + break; + } + + UnlockReleaseBuffer(buffer); + relation_close(rel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; +} + + +/* + * flux_undo_desc + * Describe a FLUX UNDO record for pg_waldump / debug logging. + */ +static void +flux_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len) +{ + const char *subtype; + FluxUndoPayloadHeader hdr; + + switch (info) + { + case FLUX_UNDO_INSERT: + subtype = "INSERT"; + break; + case FLUX_UNDO_UPDATE: + subtype = "UPDATE"; + break; + case FLUX_UNDO_DELETE: + subtype = "DELETE"; + break; + default: + subtype = "UNKNOWN"; + break; + } + + if (payload_len >= SizeOfFluxUndoPayloadHeader) + { + memcpy(&hdr, payload, SizeOfFluxUndoPayloadHeader); + appendStringInfo(buf, + "%s tid=(%u,%u) tuple_len=%u flags=0x%x", + subtype, + ItemPointerGetBlockNumber(&hdr.tid), + ItemPointerGetOffsetNumber(&hdr.tid), + hdr.tuple_len, + hdr.flags); + } + else + { + appendStringInfo(buf, "%s (truncated payload, %zu bytes)", + subtype, payload_len); + } +} diff --git a/src/backend/access/flux/flux_vm.c b/src/backend/access/flux/flux_vm.c new file mode 100644 index 0000000000000..471b8941e8497 --- /dev/null +++ b/src/backend/access/flux/flux_vm.c @@ -0,0 +1,643 @@ +/*------------------------------------------------------------------------- + * + * flux_vm.c + * Visibility Map implementation for FLUX + * + * The Visibility Map (VM) tracks the visibility status of pages in a FLUX + * relation. It stores two bits per heap page: + * + * - ALL_VISIBLE: All tuples on the page are visible to all transactions + * - ALL_FROZEN: All tuples on the page are frozen (transaction IDs removed) + * + * The VM enables two critical optimizations: + * 1. Index-only scans can skip heap fetches for all-visible pages + * 2. VACUUM can skip pages that are already all-visible or all-frozen + * + * The VM is stored in a separate fork of the relation (VISIBILITYMAP_FORKNUM) + * and is WAL-logged for crash recovery. + * + * This implementation is based on the heap visibility map + * (src/backend/access/heap/visibilitymap.c) but adapted for FLUX's + * timestamp-based MVCC model. + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/flux/flux_vm.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/flux.h" +#include "access/flux_xlog.h" +#include "access/visibilitymapdefs.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "miscadmin.h" +#include "port/pg_bitutils.h" +#include "storage/bufmgr.h" +#include "storage/buf_internals.h" +#include "storage/lmgr.h" +#include "storage/smgr.h" +#include "utils/inval.h" +#include "utils/rel.h" + +/* + * Size of the bitmap on each visibility map page, in bytes. There's no + * extra headers, so the whole page minus the standard page header is + * used for the bitmap. + */ +#define MAPSIZE (BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) + +/* Number of heap blocks we can represent in one VM page */ +#define HEAPBLOCKS_PER_PAGE (MAPSIZE * 4) + +/* Mapping macros */ +#define HEAPBLK_TO_MAPBLOCK(x) ((x) / HEAPBLOCKS_PER_PAGE) +#define HEAPBLK_TO_MAPBYTE(x) (((x) % HEAPBLOCKS_PER_PAGE) / 4) +#define HEAPBLK_TO_OFFSET(x) (((x) % HEAPBLOCKS_PER_PAGE) % 4) + +/* Bit manipulation - use FLUX-specific values that match PostgreSQL's VM bits */ + +/* Forward declaration */ +static Buffer flux_vm_extend(Relation rel, BlockNumber vm_nblocks); +static Buffer flux_vm_readbuf(Relation rel, BlockNumber blkno, bool extend); + +/* + * FluxVMInit - Initialize visibility map for a FLUX relation + * + * This is called when a FLUX table is created to ensure the VM fork exists. + */ +void +FluxVMInit(Relation rel) +{ + /* + * Create the visibility map fork if it doesn't exist. This happens + * automatically when we first try to extend it via flux_vm_extend(). + */ +} + +/* + * FluxVMSet - Set visibility map bits for a page + * + * Sets the specified bits for the given heap block. The heap buffer must + * be exclusively locked. The VM buffer will be pinned and locked as needed. + */ +void +FluxVMSet(Relation rel, BlockNumber heapBlk, Buffer heapBuf, uint8 flags) +{ + BlockNumber mapBlock; + uint32 mapByte; + uint8 mapOffset; + Page page; + uint8 *map; + Buffer vmBuf; + + Assert(BufferIsValid(heapBuf)); + /* Buffer should be exclusively locked */ + + /* Only set valid bits */ + flags &= FLUX_VM_VALID_BITS; + if (flags == 0) + return; + + /* Calculate the VM page and offset for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + mapByte = HEAPBLK_TO_MAPBYTE(heapBlk); + mapOffset = HEAPBLK_TO_OFFSET(heapBlk); + + /* + * Read or extend the visibility map buffer. flux_vm_readbuf() will + * create the VM fork if it doesn't exist yet. + */ + vmBuf = flux_vm_readbuf(rel, mapBlock, true); + LockBuffer(vmBuf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(vmBuf); + + /* If the page is new, initialize it */ + if (PageIsNew(page)) + PageInit(page, BLCKSZ, 0); + + map = (uint8 *) PageGetContents(page); + + /* Set the bits for this heap block */ + map[mapByte] |= (flags << (mapOffset * 2)); + + MarkBufferDirty(vmBuf); + + /* XLOG stuff */ + if (RelationNeedsWAL(rel)) + { + xl_flux_vm_set xlrec; + XLogRecPtr recptr; + + xlrec.heapBlk = heapBlk; + xlrec.flags = flags; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + + /* + * Register the heap buffer with REGBUF_NO_IMAGE. We reference the + * heap page so that redo can update its LSN, but we do NOT need a + * full-page image of the heap page in this WAL record. The heap + * buffer may not be dirty (e.g., during VACUUM VM updates), so we + * must not let XLogInsert try to take an FPI of it -- that would trip + * the BufferIsDirty assertion. + */ + XLogRegisterBuffer(0, heapBuf, REGBUF_NO_IMAGE | REGBUF_NO_CHANGE); + XLogRegisterBuffer(1, vmBuf, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_VM_SET); + PageSetLSN(page, recptr); + } + + UnlockReleaseBuffer(vmBuf); +} + +/* + * FluxVMClear - Clear visibility map bits for a page + * + * Clears the specified bits for the given heap block. The heap buffer must + * be exclusively locked. + */ +void +FluxVMClear(Relation rel, BlockNumber heapBlk, Buffer heapBuf, uint8 flags) +{ + BlockNumber mapBlock; + uint32 mapByte; + uint8 mapOffset; + Page page; + uint8 *map; + Buffer vmBuf; + + Assert(BufferIsValid(heapBuf)); + /* Buffer should be exclusively locked */ + + /* Only clear valid bits */ + flags &= FLUX_VM_VALID_BITS; + if (flags == 0) + return; + + /* Calculate the VM page and offset for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + mapByte = HEAPBLK_TO_MAPBYTE(heapBlk); + mapOffset = HEAPBLK_TO_OFFSET(heapBlk); + + /* Check if the VM fork/page exists; if not, nothing to clear */ + if (!smgrexists(RelationGetSmgr(rel), VISIBILITYMAP_FORKNUM)) + return; + if (mapBlock >= RelationGetNumberOfBlocksInFork(rel, VISIBILITYMAP_FORKNUM)) + return; + + vmBuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock, + RBM_NORMAL, NULL); + LockBuffer(vmBuf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(vmBuf); + map = (uint8 *) PageGetContents(page); + + /* + * Check if the requested bits are already clear. If so, skip the + * modification and WAL logging entirely. This is the common case after + * the first modification to a page since the last VACUUM, and avoids + * significant WAL amplification on hot pages. + */ + if ((map[mapByte] & (flags << (mapOffset * 2))) == 0) + { + UnlockReleaseBuffer(vmBuf); + return; + } + + /* Clear the bits for this heap block */ + map[mapByte] &= ~(flags << (mapOffset * 2)); + + MarkBufferDirty(vmBuf); + + /* XLOG stuff */ + if (RelationNeedsWAL(rel)) + { + xl_flux_vm_clear xlrec; + XLogRecPtr recptr; + + xlrec.heapBlk = heapBlk; + xlrec.flags = flags; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xlrec)); + + /* + * Register the heap buffer with REGBUF_NO_IMAGE for the same reason + * as in FluxVMSet: the heap buffer may not be dirty. + */ + XLogRegisterBuffer(0, heapBuf, REGBUF_NO_IMAGE | REGBUF_NO_CHANGE); + XLogRegisterBuffer(1, vmBuf, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_VM_CLEAR); + PageSetLSN(page, recptr); + } + + UnlockReleaseBuffer(vmBuf); +} + +/* + * FluxVMCheck - Check visibility map bits for a page + * + * Returns true if ALL the specified bits are set for the given heap block. + * This function does not require any locks and can be called from + * index-only scan paths. + */ +bool +FluxVMCheck(Relation rel, BlockNumber heapBlk, uint8 flags) +{ + BlockNumber mapBlock; + uint32 mapByte; + uint8 mapOffset; + Page page; + uint8 *map; + Buffer vmBuf; + bool result; + + /* Only check valid bits */ + flags &= FLUX_VM_VALID_BITS; + if (flags == 0) + return true; /* No bits to check */ + + /* Calculate the VM page and offset for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + mapByte = HEAPBLK_TO_MAPBYTE(heapBlk); + mapOffset = HEAPBLK_TO_OFFSET(heapBlk); + + /* If the VM fork/page doesn't exist, the bits can't be set */ + if (!smgrexists(RelationGetSmgr(rel), VISIBILITYMAP_FORKNUM)) + return false; + if (mapBlock >= RelationGetNumberOfBlocksInFork(rel, VISIBILITYMAP_FORKNUM)) + return false; + + vmBuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock, + RBM_NORMAL, NULL); + LockBuffer(vmBuf, BUFFER_LOCK_SHARE); + page = BufferGetPage(vmBuf); + map = (uint8 *) PageGetContents(page); + + /* Check if all requested bits are set */ + result = ((map[mapByte] >> (mapOffset * 2)) & flags) == flags; + + UnlockReleaseBuffer(vmBuf); + + return result; +} + +/* + * FluxVMCheckCached - Check visibility map bits with caller-managed buffer cache + * + * Like FluxVMCheck, but the caller provides pointers to a cached VM buffer + * and its block number. The VM buffer is kept pinned across calls; it is + * only released and re-read when the heap block maps to a different VM page. + * This eliminates per-page ReadBufferExtended + UnlockReleaseBuffer overhead + * for sequential scans (one VM page covers HEAPBLOCKS_PER_PAGE heap pages, + * typically ~32K pages with 8KB blocks). + * + * The caller must release the buffer when done (e.g., at scan end). + */ +bool +FluxVMCheckCached(Relation rel, BlockNumber heapBlk, uint8 flags, + Buffer *vmbuf, BlockNumber *vm_blockno) +{ + BlockNumber mapBlock; + uint32 mapByte; + uint8 mapOffset; + Page page; + uint8 *map; + bool result; + + /* Only check valid bits */ + flags &= FLUX_VM_VALID_BITS; + if (flags == 0) + return true; /* No bits to check */ + + /* Calculate the VM page and offset for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + mapByte = HEAPBLK_TO_MAPBYTE(heapBlk); + mapOffset = HEAPBLK_TO_OFFSET(heapBlk); + + /* If the VM fork doesn't exist, the bits can't be set */ + if (!smgrexists(RelationGetSmgr(rel), VISIBILITYMAP_FORKNUM)) + return false; + if (mapBlock >= RelationGetNumberOfBlocksInFork(rel, VISIBILITYMAP_FORKNUM)) + return false; + + /* + * Re-read the VM buffer only when the target VM page changes. Each VM + * page covers HEAPBLOCKS_PER_PAGE heap pages, so for sequential scans + * this avoids ~32K redundant buffer reads per VM page. + */ + if (!BufferIsValid(*vmbuf) || *vm_blockno != mapBlock) + { + if (BufferIsValid(*vmbuf)) + ReleaseBuffer(*vmbuf); + *vmbuf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, mapBlock, + RBM_NORMAL, NULL); + *vm_blockno = mapBlock; + } + + LockBuffer(*vmbuf, BUFFER_LOCK_SHARE); + page = BufferGetPage(*vmbuf); + map = (uint8 *) PageGetContents(page); + + /* Check if all requested bits are set */ + result = ((map[mapByte] >> (mapOffset * 2)) & flags) == flags; + + LockBuffer(*vmbuf, BUFFER_LOCK_UNLOCK); + + return result; +} + +/* + * FluxVMPinBuffer - Pin the visibility map buffer for a heap block + * + * This is used when we need to keep the VM buffer pinned across multiple + * operations. The caller is responsible for unpinning the buffer. + */ +void +FluxVMPinBuffer(Relation rel, BlockNumber heapBlk, Buffer *vmbuf) +{ + BlockNumber mapBlock; + + /* Calculate the VM page for this heap block */ + mapBlock = HEAPBLK_TO_MAPBLOCK(heapBlk); + + /* Pin the buffer if not already pinned */ + if (!BufferIsValid(*vmbuf) || BufferGetBlockNumber(*vmbuf) != mapBlock) + { + if (BufferIsValid(*vmbuf)) + ReleaseBuffer(*vmbuf); + *vmbuf = flux_vm_readbuf(rel, mapBlock, true); + } +} + +/* + * FluxVMExtend - Extend the visibility map to cover more heap blocks + * + * This is called when the heap relation is extended. + */ +void +FluxVMExtend(Relation rel, BlockNumber nheapblocks) +{ + BlockNumber newnblocks; + + /* Calculate how many VM blocks we need */ + newnblocks = (nheapblocks + HEAPBLOCKS_PER_PAGE - 1) / HEAPBLOCKS_PER_PAGE; + + /* Extend the VM fork if necessary, creating it if needed */ + if (newnblocks > 0) + { + Buffer buf; + + buf = flux_vm_extend(rel, newnblocks); + ReleaseBuffer(buf); + } +} + +/* + * FluxVMTruncate - Truncate the visibility map + * + * This is called when the heap relation is truncated. + */ +void +FluxVMTruncate(Relation rel, BlockNumber nheapblocks) +{ + BlockNumber newnblocks; + BlockNumber oldnblocks; + + /* Calculate how many VM blocks we need */ + /* If the VM fork doesn't exist, nothing to truncate */ + if (!smgrexists(RelationGetSmgr(rel), VISIBILITYMAP_FORKNUM)) + return; + + newnblocks = (nheapblocks + HEAPBLOCKS_PER_PAGE - 1) / HEAPBLOCKS_PER_PAGE; + oldnblocks = RelationGetNumberOfBlocksInFork(rel, VISIBILITYMAP_FORKNUM); + + if (newnblocks < oldnblocks) + { + /* + * Truncate the VM fork. We need to flush any dirty VM buffers first. + */ + ForkNumber forknum = VISIBILITYMAP_FORKNUM; + + FlushRelationBuffers(rel); + smgrtruncate(RelationGetSmgr(rel), &forknum, 1, &oldnblocks, &newnblocks); + } +} + +/* + * FluxVMGetPageSize - Get the size of a VM page + */ +Size +FluxVMGetPageSize(void) +{ + return MAPSIZE; +} + +/* + * FluxVMMapHeapToVM - Map a heap block number to VM block number + */ +BlockNumber +FluxVMMapHeapToVM(BlockNumber heapBlk) +{ + return HEAPBLK_TO_MAPBLOCK(heapBlk); +} + +/* + * flux_vm_extend - Extend the VM fork to at least vm_nblocks. + * + * Creates the VM fork if it doesn't exist yet. Returns a buffer for + * the last block of the extended fork (pinned but not locked). + */ +static Buffer +flux_vm_extend(Relation rel, BlockNumber vm_nblocks) +{ + Buffer buf; + + buf = ExtendBufferedRelTo(BMR_REL(rel), VISIBILITYMAP_FORKNUM, NULL, + EB_CREATE_FORK_IF_NEEDED | + EB_CLEAR_SIZE_CACHE, + vm_nblocks, + RBM_ZERO_ON_ERROR); + + /* + * Send a shared-inval message to force other backends to close any smgr + * references they may have for this rel, which we are about to change. + */ + CacheInvalidateSmgr(RelationGetSmgr(rel)->smgr_rlocator); + + return buf; +} + +/* + * flux_vm_readbuf - Read or extend the VM to get the page for blkno. + * + * If extend is true and the block doesn't exist, extends the fork + * (creating it if needed). Returns InvalidBuffer if extend is false + * and the block doesn't exist. Buffer is returned pinned but not locked. + */ +static Buffer +flux_vm_readbuf(Relation rel, BlockNumber blkno, bool extend) +{ + Buffer buf; + SMgrRelation reln = RelationGetSmgr(rel); + + /* + * Ensure we have the cached nblocks value for the VM fork. + */ + if (reln->smgr_cached_nblocks[VISIBILITYMAP_FORKNUM] == InvalidBlockNumber) + { + if (smgrexists(reln, VISIBILITYMAP_FORKNUM)) + smgrnblocks(reln, VISIBILITYMAP_FORKNUM); + else + reln->smgr_cached_nblocks[VISIBILITYMAP_FORKNUM] = 0; + } + + if (blkno >= reln->smgr_cached_nblocks[VISIBILITYMAP_FORKNUM]) + { + if (extend) + buf = flux_vm_extend(rel, blkno + 1); + else + return InvalidBuffer; + } + else + buf = ReadBufferExtended(rel, VISIBILITYMAP_FORKNUM, blkno, + RBM_ZERO_ON_ERROR, NULL); + + /* + * Initializing the page when needed is trickier than it looks, because of + * the possibility of multiple backends doing this concurrently, and our + * desire to not uselessly take the buffer lock in the normal path where + * the page is OK. For a page that's just been extended, this is not + * needed since it was already initialized by ExtendBufferedRelTo. + */ + if (PageIsNew(BufferGetPage(buf))) + { + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + if (PageIsNew(BufferGetPage(buf))) + PageInit(BufferGetPage(buf), BLCKSZ, 0); + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + } + + return buf; +} + +/* + * FluxVMUpdateForInsert - Update VM after inserting a tuple + * + * When we insert a tuple into a page, we may need to clear the all-visible + * and all-frozen bits if the new tuple is not immediately visible to all + * transactions. + */ +void +FluxVMUpdateForInsert(Relation rel, FluxTupleHeader *tuple, Buffer buffer) +{ + BlockNumber blkno = BufferGetBlockNumber(buffer); + + /* + * Check if the new tuple affects the page's visibility status. In FLUX's + * timestamp-based MVCC, a tuple is visible to all if its commit timestamp + * is older than the oldest active transaction. + * + * A future optimization could check if the new tuple is already visible + * to all transactions (e.g., a bulk load with old timestamps). For now, + * conservatively clear the bits on any insert. + */ + (void) tuple; /* reserved for future timestamp checking */ + + /* Clear in-page flag first (zero cost, no I/O) */ + PageClearAllVisible(BufferGetPage(buffer)); + + FluxVMClear(rel, blkno, buffer, FLUX_VM_VALID_BITS); +} + +/* + * FluxVMUpdateForUpdate - Update VM after updating a tuple + * + * Updates always clear the all-visible and all-frozen bits because they + * create a new tuple version that may not be immediately visible. + */ +void +FluxVMUpdateForUpdate(Relation rel, Buffer buffer) +{ + BlockNumber blkno = BufferGetBlockNumber(buffer); + + /* Clear in-page flag first (zero cost, no I/O) */ + PageClearAllVisible(BufferGetPage(buffer)); + + /* Clear both bits - the page now has a new tuple version */ + FluxVMClear(rel, blkno, buffer, FLUX_VM_VALID_BITS); +} + +/* + * FluxVMUpdateForDelete - Update VM after deleting a tuple + * + * Deletes clear the all-visible bit because the deleted tuple may still + * be visible to some transactions. + */ +void +FluxVMUpdateForDelete(Relation rel, Buffer buffer) +{ + BlockNumber blkno = BufferGetBlockNumber(buffer); + + /* Clear in-page flag first (zero cost, no I/O) */ + PageClearAllVisible(BufferGetPage(buffer)); + + /* + * Clear BOTH bits. ALL_FROZEN implies ALL_VISIBLE, so the frozen bit must + * never outlive the visible bit. Leaving ALL_FROZEN set on a page with a + * freshly deleted tuple would cause VACUUM Phase I to skip the page (the + * all-frozen fast path), so the deleted tuple's storage and -- critically + * -- its index entries would never be cleaned, eventually allowing TID + * recycling with stale index entries still present. + */ + FluxVMClear(rel, blkno, buffer, FLUX_VM_VALID_BITS); +} + +/* + * FluxVMVacuumPage - Update VM during VACUUM + * + * This is called by VACUUM after processing a page to set the appropriate + * visibility map bits based on the page's contents. + */ +void +FluxVMVacuumPage(Relation rel, Buffer buffer, bool all_visible, bool all_frozen) +{ + BlockNumber blkno = BufferGetBlockNumber(buffer); + uint8 flags = 0; + + if (all_visible) + flags |= FLUX_VM_ALL_VISIBLE; + if (all_frozen) + flags |= FLUX_VM_ALL_FROZEN; + + if (flags != 0) + FluxVMSet(rel, blkno, buffer, flags); + + /* + * Synchronize the in-page PD_ALL_VISIBLE flag with the VM. Use + * MarkBufferDirtyHint since losing this flag on crash is benign (just + * falls back to VM check on next scan; VACUUM will re-set it). + */ + if (all_visible) + { + if (!PageIsAllVisible(BufferGetPage(buffer))) + { + PageSetAllVisible(BufferGetPage(buffer)); + MarkBufferDirtyHint(buffer, true); + } + } + else + { + if (PageIsAllVisible(BufferGetPage(buffer))) + { + PageClearAllVisible(BufferGetPage(buffer)); + MarkBufferDirtyHint(buffer, true); + } + } +} diff --git a/src/backend/access/flux/flux_xlog.c b/src/backend/access/flux/flux_xlog.c new file mode 100644 index 0000000000000..db655b26d5d2f --- /dev/null +++ b/src/backend/access/flux/flux_xlog.c @@ -0,0 +1,2761 @@ +/*------------------------------------------------------------------------- + * + * flux_xlog.c + * FLUX WAL (Write-Ahead Logging) implementation + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/flux/flux_xlog.c + * + * NOTES + * This implements WAL logging for FLUX operations, providing + * UNDO/REDO functionality for crash recovery. Unlike heap, + * FLUX uses in-place updates with before/after images. + * + * PANIC policy during redo + * ------------------------ + * The per-opcode redo helpers below use elog(PANIC, ...) for any + * invariant violation detected during WAL replay. This is + * deliberate: a mismatch between the WAL stream and the on-disk + * state (truncated overflow payload, failure to add a tuple the + * forward path just wrote, a page full the forward path just + * defragmented, an unknown opcode) is not a recoverable condition. + * Downgrading these sites to ERROR would promote silent divergence + * between the primary and a standby, or between the on-disk + * heap state and the WAL record that described it; PANIC forces + * a postmaster-wide restart and, in the standby case, marks the + * standby inconsistent. Each PANIC site is therefore guarded by + * logic that only fires on actually-corrupt input; fixing a PANIC + * that fires in practice is a correctness bug in the forward + * path, not a reason to soften the guard. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/htup_details.h" +#include "access/flux.h" +#include "access/flux_xlog.h" +#include "access/bufmask.h" +#include "access/relundo.h" +#include "access/relundo_xlog.h" +#include "access/slog.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlogrecord.h" +#include "access/xlogutils.h" +#include "storage/buf_internals.h" +#include "storage/bufmgr.h" +#include "miscadmin.h" +#include "utils/rel.h" +#include "utils/timestamp.h" + +/* + * FluxXLogMaybeAppendLogicalTuple + * Append a heap-format image of `rtup` to the in-progress WAL record + * if `rel` is logically logged. Returns true and sets + * FLUX_WAL_LOGICAL_TUPLE in `*flags` if the image was appended. + * + * The heap image is what logical decoding consumes. Physical REDO + * reads the FLUX-format tuple that precedes this region. By writing + * both, we avoid the need for decode.c to call RelidByRelfilenumber / + * RelationIdGetRelation, which are unsafe before SetupHistoricSnapshot. + * + * The image is appended at the END of the main WAL data channel with + * the length trailing the bytes: + * + * ... [heap bytes] [uint32 heap_len] + * + * So the decoder can read heap_len from (end-4) and back up heap_len + * bytes to find the heap payload, regardless of what precedes it in + * the record (which may vary with compression / cross-page). + * + * For UPDATE we append two back-to-back trailers (old then new); see + * FluxXLogUpdate. + */ +void +FluxXLogPrepareLogicalImage(Relation rel, FluxTuple rtup, + FluxLogicalImage *img) +{ + TupleDesc tupdesc; + Datum *values; + bool *isnull; + HeapTuple heaptup; + + img->data = NULL; + img->len = 0; + + if (rel == NULL || rtup == NULL || !RelationIsLogicallyLogged(rel)) + return; + + tupdesc = RelationGetDescr(rel); + values = (Datum *) palloc(tupdesc->natts * sizeof(Datum)); + isnull = (bool *) palloc(tupdesc->natts * sizeof(bool)); + + FluxDeformTuple(rel, rtup, tupdesc, values, isnull); + heaptup = heap_form_tuple(tupdesc, values, isnull); + + /* + * Copy the heap tuple body into an image buffer that outlives the + * critical section. XLogRegisterData() only records pointers, so the + * bytes must remain valid until XLogInsert() reads them. + */ + img->len = (uint32) heaptup->t_len; + img->data = (char *) palloc(img->len); + memcpy(img->data, heaptup->t_data, img->len); + + heap_freetuple(heaptup); + pfree(values); + pfree(isnull); +} + +void +FluxXLogReleaseLogicalImage(FluxLogicalImage *img) +{ + if (img->data != NULL) + { + pfree(img->data); + img->data = NULL; + } + img->len = 0; +} + +/* + * FluxXLogRegisterLogicalImage + * Register a previously prepared heap-format image onto the in-progress + * WAL record. Allocation-free: safe to call inside a critical section. + * Appends "[heap bytes][uint32 heap_len]" to the main data channel and + * sets FLUX_WAL_LOGICAL_TUPLE in *flags. No-op when img is NULL or + * empty (relation not logically logged). + */ +static void +FluxXLogRegisterLogicalImage(FluxLogicalImage *img, uint16 *flags) +{ + if (img == NULL || img->data == NULL) + return; + + XLogRegisterData(img->data, img->len); + XLogRegisterData((char *) &img->len, sizeof(uint32)); + + *flags |= FLUX_WAL_LOGICAL_TUPLE; +} + +/* ---------------------------------------------------------------- + * WAL Record Logging Functions + * ---------------------------------------------------------------- + */ + +/* + * Log a tuple insert operation. + */ +XLogRecPtr +FluxXLogInsert(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple tuple, uint64 commit_ts, + FluxOverflowBuffers *overflow_buffers, + FluxLogicalImage *logical_img, + bool force_page_image) +{ + xl_flux_insert xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_INSERT; + uint8 main_buf_flags = REGBUF_STANDARD; + int i; + xl_flux_overflow_write ovf_xlrecs[MAX_OVERFLOW_BUFFERS]; + + /* + * The multi-insert (COPY/bulk) path adds many tuples to one page but emits + * a single INSERT record describing only the first tuple. Under + * full_page_writes the page's initial touch captures the whole page, so + * redo restores every tuple. With full_page_writes off there is no such + * image, and the unlogged tuples vanish on crash recovery, leaving later + * CAS_UPDATE redo to PANIC on a missing item. Force a full-page image so + * the batch is crash-safe regardless of full_page_writes. + */ + if (force_page_image) + main_buf_flags |= REGBUF_FORCE_IMAGE; + + /* Fill in the insert record */ + xlrec.offnum = offnum; + xlrec.flags = 0; + xlrec.tuple_len = tuple->t_len; + xlrec.commit_ts = commit_ts; + + /* + * NOTE: XLogEnsureRecordSpace() has already been called by the caller + * (before entering the critical section) to pre-allocate space for the + * main buffer plus all overflow buffers. + */ + XLogBeginInsert(); + + /* Register buffer FIRST, before any data */ + XLogRegisterBuffer(0, buffer, main_buf_flags); + + /* + * Register all overflow buffers (buffers 1..N) for atomic WAL logging. + * This ensures the main tuple and all overflow records are restored + * together during crash recovery, preventing orphaned overflow pages. + * + * IMPORTANT: Due to spatial locality optimization, multiple overflow + * records may reside on the same page. We must register each unique + * buffer only once, but register data for all overflow records. + * + * For each overflow buffer, we need to include: 1. The offset where the + * record should be placed 2. The actual overflow record data + */ + if (overflow_buffers != NULL) + { + int registered_block_id = 1; /* Start after main buffer */ + Buffer registered_buffers[MAX_OVERFLOW_BUFFERS]; + int registered_buffer_ids[MAX_OVERFLOW_BUFFERS]; + int num_registered = 0; + + /* + * NOTE: ovf_xlrecs[] is declared at function scope (not here) so that + * the pointers registered via XLogRegisterBufData() remain valid + * until XLogInsert() is called after this block ends. + */ + + /* Ensure we don't exceed PostgreSQL's hard limit */ + if (overflow_buffers->count > XLR_MAX_BLOCK_ID) + elog(ERROR, "too many overflow records: %d (max %d)", + overflow_buffers->count, XLR_MAX_BLOCK_ID); + + for (i = 0; i < overflow_buffers->count; i++) + { + FluxOverflowBuffer *ovb = &overflow_buffers->buffers[i]; + int block_id = -1; + int j; + + /* + * First check if this overflow buffer is the SAME as the main + * buffer. This can happen when spatial locality places an + * overflow record on the same page as the main tuple. In this + * case, reuse block_id=0. + */ + if (ovb->buffer == buffer) + { + block_id = 0; + xlrec.flags |= FLUX_WAL_HAS_OVERFLOW_BLK0; + } + else + { + /* + * Check if this buffer was already registered among overflow + * buffers (spatial locality: multiple overflow records on + * same page). If so, reuse its block_id instead of + * registering again. + */ + for (j = 0; j < num_registered; j++) + { + if (registered_buffers[j] == ovb->buffer) + { + block_id = registered_buffer_ids[j]; + break; + } + } + + /* If not registered yet, register it now */ + if (block_id < 0) + { + block_id = registered_block_id++; + + /* + * Force a full-page image for overflow buffers. See + * FluxXLogUpdate for the rationale. + */ + XLogRegisterBuffer(block_id, ovb->buffer, + REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + + /* Track this buffer so we don't register it again */ + registered_buffers[num_registered] = ovb->buffer; + registered_buffer_ids[num_registered] = block_id; + num_registered++; + } + } + + /* + * Create a proper xl_flux_overflow_write header with the offset. + * This tells the redo handler where to place the record. Each + * header is stored in a dedicated array slot so the pointer + * passed to XLogRegisterBufData remains valid until XLogInsert. + */ + ovf_xlrecs[i].offnum = ovb->offset; + ovf_xlrecs[i].flags = ovb->flags; + ovf_xlrecs[i].data_len = ovb->record_len; + ovf_xlrecs[i].commit_ts = commit_ts; + + /* Register the header first, then the data */ + XLogRegisterBufData(block_id, (char *) &ovf_xlrecs[i], sizeof(xl_flux_overflow_write)); + XLogRegisterBufData(block_id, ovb->record_data, ovb->record_len); + } + } + + /* Now register the main data */ + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_insert)); + XLogRegisterData((char *) tuple->t_data, tuple->t_len); + + FluxXLogRegisterLogicalImage(logical_img, &xlrec.flags); + + recptr = XLogInsert(RM_FLUX_ID, info); + + /* Set LSN on main page */ + PageSetLSN(page, recptr); + + /* + * Set LSN on all overflow pages. Due to spatial locality, some buffers + * may appear multiple times in overflow_buffers. PageSetLSN is idempotent + * (setting the same LSN multiple times is safe), so we can just iterate + * through all entries without checking for duplicates. + */ + if (overflow_buffers != NULL) + { + for (i = 0; i < overflow_buffers->count; i++) + { + Page ovpage = BufferGetPage(overflow_buffers->buffers[i].buffer); + + PageSetLSN(ovpage, recptr); + } + } + + return recptr; +} + +/* + * Log a batched multi-tuple insert (COPY/bulk load) into one page. + * + * Unlike FluxXLogInsert, this records EVERY tuple body in the batch rather + * than relying on a forced full-page image, so the batch replays correctly + * regardless of full_page_writes. No overflow buffers are involved: tuples + * that need overflow are routed to the single-insert path by the caller. + * + * WAL main-data layout: + * [xl_flux_multi_insert header] + * ntuples * [xl_flux_multi_insert_tuple header][tuple t_data body] + * ntuples * [logical image bytes][uint32 len] (only when logically logged) + */ +XLogRecPtr +FluxXLogMultiInsert(Relation rel, Buffer buffer, + OffsetNumber *offnums, FluxTuple *tuples, + int ntuples, uint64 commit_ts, + FluxLogicalImage *logical_imgs) +{ + xl_flux_multi_insert xlrec; + + /* + * Serialize the whole per-tuple region into one scratch buffer and register + * it as a single rdata chunk. XLogRegisterData() is limited to + * XLR_NORMAL_RDATAS (20) slots, and XLogEnsureRecordSpace() cannot grow that + * inside a critical section, so registering two chunks per tuple would + * overflow the slot array once a page packs more than a handful of rows. + * Mirrors heap_multi_insert()'s single-scratch-buffer pattern; the on-WAL + * bytes are identical to per-tuple registration because redo and logical + * decoding read the region as one contiguous XLogRecGetData() stream. + * + * The region is bounded by BLCKSZ: every tuple consumed sizeof(ItemIdData) + + * MAXALIGN(t_len) on the page, so the sum of SizeOfFluxMultiInsertTuple + + * t_len across the batch is strictly less than one page. + */ + PGAlignedBlock scratch; + char *scratchptr = scratch.data; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_MULTI_INSERT; + int i; + + Assert(ntuples > 0); + Assert(ntuples <= MaxOffsetNumber); + + xlrec.ntuples = (uint16) ntuples; + xlrec.flags = 0; + xlrec.commit_ts = commit_ts; + + for (i = 0; i < ntuples; i++) + { + xl_flux_multi_insert_tuple *tuphdr; + + /* + * SHORTALIGN each per-tuple header so its uint16 fields land on an + * even offset, mirroring heap_multi_insert(). Readers (redo and + * logical decode) advance with the identical SHORTALIGN, so the + * on-WAL stride stays byte-for-byte in lockstep. + */ + scratchptr = (char *) SHORTALIGN(scratchptr); + tuphdr = (xl_flux_multi_insert_tuple *) scratchptr; + + tuphdr->offnum = offnums[i]; + tuphdr->datalen = (uint16) tuples[i]->t_len; + scratchptr += SizeOfFluxMultiInsertTuple; + + memcpy(scratchptr, (char *) tuples[i]->t_data, tuples[i]->t_len); + scratchptr += tuples[i]->t_len; + } + Assert((scratchptr - scratch.data) < BLCKSZ); + + XLogBeginInsert(); + + /* + * Register the page WITHOUT forcing a full-page image. Crash safety comes + * from logging every tuple body above, not from an FPI. + */ + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + XLogRegisterData((char *) &xlrec, SizeOfFluxMultiInsert); + XLogRegisterData(scratch.data, (uint32) (scratchptr - scratch.data)); + + /* + * Append one logical-decoding image per tuple (when logically logged). The + * caller pre-serialized all images into one contiguous blob before entering + * the critical section (logical_imgs[0].data), so a single registration + * covers the whole region and keeps the rdata slot count constant. + */ + if (logical_imgs != NULL && logical_imgs[0].data != NULL) + { + XLogRegisterData(logical_imgs[0].data, logical_imgs[0].len); + xlrec.flags |= FLUX_WAL_LOGICAL_TUPLE; + } + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log a tuple update operation (in-place with before/after images) + */ +XLogRecPtr +FluxXLogUpdate(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple old_tuple, FluxTuple new_tuple, + uint64 old_commit_ts, uint64 new_commit_ts, + FluxOverflowBuffers *overflow_buffers, + Buffer new_buffer, + FluxLogicalImage *old_img, + FluxLogicalImage *new_img) +{ + xl_flux_update xlrec; + xl_flux_prefix_suffix ps; + XLogRecPtr recptr; + uint8 info = XLOG_FLUX_UPDATE_INPLACE; + int i; + bool is_cross_page = (BufferIsValid(new_buffer) && + new_buffer != buffer); + + /* Fill in the update record */ + xlrec.offnum = offnum; + xlrec.flags = 0; + xlrec.old_commit_ts = old_commit_ts; + xlrec.new_commit_ts = new_commit_ts; + xlrec.old_tuple_len = (uint16) old_tuple->t_len; + xlrec.new_tuple_len = (uint16) new_tuple->t_len; + xlrec.dst_block_id = 0; + memset(xlrec.pad, 0, sizeof(xlrec.pad)); + + if (is_cross_page) + xlrec.flags |= FLUX_WAL_CROSS_PAGE; + + /* + * NOTE: XLogEnsureRecordSpace() has already been called by the caller + * (before entering the critical section) to pre-allocate space for the + * main buffer plus all overflow buffers. + */ + XLogBeginInsert(); + + /* + * Register the source buffer (block 0). + * + * A same-page update rewrites the tuple in place at the same offset: redo + * overwrites the slot with the new tuple data (or patches the prefix/suffix + * diff), so no full-page image is required. An image is only needed when + * the new tuple is larger than the old one, because the write path then + * deletes and re-adds the line pointer (shifting page data), which redo + * cannot replay from the new tuple bytes alone. The predicate + * new_tuple->t_len > old_tuple->t_len is a safe superset of that grow case: + * the on-page slot is always at least old_tuple->t_len, so any update that + * outgrows the slot also outgrows old_tuple->t_len and forces the image. + * + * For cross-page updates the redo handler marks the old tuple UPDATED + * directly (see FLUX_WAL_CROSS_PAGE handling in flux_redo) and the new + * tuple is restored from the destination page's image, so the same + * grow-only image rule applies to block 0. + */ + { + uint8 buf_flags = REGBUF_STANDARD; + + if (new_tuple->t_len > old_tuple->t_len) + buf_flags |= REGBUF_FORCE_IMAGE; + + XLogRegisterBuffer(0, buffer, buf_flags); + } + + /* + * Register all overflow buffers (buffers 1..N) for atomic WAL logging. + * This ensures the main tuple UPDATE and all overflow records are + * restored together during crash recovery, preventing orphaned overflow + * pages. + * + * IMPORTANT: Due to spatial locality optimization, multiple overflow + * records may reside on the same page. We must register each unique + * buffer only once, but register data for all overflow records. + */ + { + int next_block_id = 1; /* Start after main buffer (block 0) */ + + if (overflow_buffers != NULL) + { + Buffer registered_buffers[MAX_OVERFLOW_BUFFERS]; + int registered_buffer_ids[MAX_OVERFLOW_BUFFERS]; + int num_registered = 0; + + /* Ensure we don't exceed PostgreSQL's hard limit */ + if (overflow_buffers->count > XLR_MAX_BLOCK_ID) + elog(ERROR, "too many overflow records: %d (max %d)", + overflow_buffers->count, XLR_MAX_BLOCK_ID); + + for (i = 0; i < overflow_buffers->count; i++) + { + FluxOverflowBuffer *ovb = &overflow_buffers->buffers[i]; + int block_id = -1; + int j; + + /* + * First check if this overflow buffer is the SAME as the main + * buffer. This can happen when spatial locality places an + * overflow record on the same page as the main tuple. In this + * case, reuse block_id=0. + */ + if (ovb->buffer == buffer) + { + block_id = 0; + xlrec.flags |= FLUX_WAL_HAS_OVERFLOW_BLK0; + } + else + { + /* + * Check if this buffer was already registered among + * overflow buffers (spatial locality: multiple overflow + * records on same page). If so, reuse its block_id + * instead of registering again. + */ + for (j = 0; j < num_registered; j++) + { + if (registered_buffers[j] == ovb->buffer) + { + block_id = registered_buffer_ids[j]; + break; + } + } + + /* If not registered yet, register it now */ + if (block_id < 0) + { + block_id = next_block_id++; + + /* + * Force a full-page image for overflow buffers. The + * redo handler for overflow pages reconstructs items + * using PageAddItem, but the page layout can differ + * from the primary when the page already contains + * items from prior operations (e.g., free space + * fragmentation, item alignment). Using + * REGBUF_FORCE_IMAGE guarantees the page is restored + * exactly as the primary had it. + */ + XLogRegisterBuffer((uint8) block_id, ovb->buffer, + REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + + /* Track this buffer so we don't register it again */ + registered_buffers[num_registered] = ovb->buffer; + registered_buffer_ids[num_registered] = block_id; + num_registered++; + } + } + + /* Register overflow record data for this buffer */ + XLogRegisterBufData((uint8) block_id, ovb->record_data, + ovb->record_len); + } + } + + /* + * For cross-page out-of-place updates, register the destination + * buffer so both pages are crash-safe. Force a full-page image so + * redo simply restores the page without needing replay logic. + */ + if (is_cross_page) + { + xlrec.dst_block_id = (uint8) next_block_id; + XLogRegisterBuffer((uint8) next_block_id, new_buffer, + REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + next_block_id++; + } + } + + /* Now register the main data */ + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_update)); + + /* + * Log only new tuple for REDO. Old tuple data is stored exclusively in + * the shared UNDO log (UNDO_RMID_FLUX record written via + * UndoBufferAddRecordParts) and is not needed during WAL replay: + * + * - Same-size/shrinking updates: redo overwrites the slot in place using + * only the new tuple data. - Growing updates: REGBUF_FORCE_IMAGE is set + * above, so redo restores the page from a full-page image and never + * enters BLK_NEEDS_REDO. + * + * Prefix/suffix compression: For same-size in-place updates, we compute + * the common prefix and suffix between old and new tuple data. If the + * savings exceed sizeof(xl_flux_prefix_suffix) (4 bytes), we log only + * the changed bytes plus a small header. The redo handler reconstructs + * the full new tuple from the existing page data + diff. + * + * This is only safe for same-size updates without cross-page moves. + * Growing updates use REGBUF_FORCE_IMAGE and never enter BLK_NEEDS_REDO. + */ + if (!is_cross_page && + old_tuple->t_len == new_tuple->t_len && + new_tuple->t_len > 0) + { + char *oldp = (char *) old_tuple->t_data; + char *newp = (char *) new_tuple->t_data; + int len = new_tuple->t_len; + int difflen; + + /* Compute common prefix */ + for (ps.prefixlen = 0; ps.prefixlen < len; ps.prefixlen++) + if (oldp[ps.prefixlen] != newp[ps.prefixlen]) + break; + + /* Compute common suffix (don't overlap with prefix) */ + for (ps.suffixlen = 0; + ps.suffixlen < len - ps.prefixlen; + ps.suffixlen++) + if (oldp[len - 1 - ps.suffixlen] != newp[len - 1 - ps.suffixlen]) + break; + + difflen = len - ps.prefixlen - ps.suffixlen; + + /* + * Use compression only if the savings exceed the header overhead. The + * header is 4 bytes (two uint16s), so we need the prefix + suffix to + * save more than that. + */ + if (ps.prefixlen + ps.suffixlen > (int) sizeof(xl_flux_prefix_suffix) && + difflen >= 0) + { + xlrec.flags |= FLUX_WAL_PREFIX_SUFFIX; + XLogRegisterData((char *) &ps, sizeof(xl_flux_prefix_suffix)); + if (difflen > 0) + XLogRegisterData(newp + ps.prefixlen, difflen); + } + else + { + /* Not worth compressing, log full new tuple */ + XLogRegisterData((char *) new_tuple->t_data, new_tuple->t_len); + } + } + else + { + /* Cross-page or size-changing: log full new tuple */ + XLogRegisterData((char *) new_tuple->t_data, new_tuple->t_len); + } + + /* + * Append heap-format images of old + new tuples for logical decoding. + * Order: old first, then new. Flag is set uniformly on both or neither. + * The images were prepared by the caller before the critical section. + */ + { + uint16 tmpflag = 0; + + FluxXLogRegisterLogicalImage(old_img, &tmpflag); + FluxXLogRegisterLogicalImage(new_img, &tmpflag); + xlrec.flags |= tmpflag; + } + + recptr = XLogInsert(RM_FLUX_ID, info); + + return recptr; +} + +/* + * Log a tuple delete operation + */ +XLogRecPtr +FluxXLogDelete(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple tuple, uint64 commit_ts, + FluxLogicalImage *logical_img) +{ + xl_flux_delete xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_DELETE; + + /* Fill in the delete record */ + xlrec.offnum = offnum; + xlrec.flags = 0; + xlrec.tuple_len = tuple->t_len; + xlrec.commit_ts = commit_ts; + + XLogBeginInsert(); + + /* Register buffer FIRST, before any data */ + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + /* + * Register delete header only -- old tuple data is stored exclusively in + * the UNDO fork. The redo handler only needs the offset and commit_ts to + * set FLUX_TUPLE_DELETED on the existing tuple. + */ + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_delete)); + + /* + * Append heap-format image of the deleted tuple for logical decoding. + * DELETE's REDO path doesn't need the old tuple image on-page (it just + * flips a flag), so this region is strictly for the decode side. The + * image was prepared by the caller before the critical section. + */ + FluxXLogRegisterLogicalImage(logical_img, &xlrec.flags); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log page defragmentation + */ +XLogRecPtr +FluxXLogDefrag(Relation rel, Buffer buffer, FluxOffsetMapping *mappings, + int nmappings, uint64 commit_ts) +{ + xl_flux_defrag xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_DEFRAG; + + /* Fill in the defrag record */ + xlrec.ntuples = nmappings; + xlrec.commit_ts = commit_ts; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_defrag)); + XLogRegisterData((char *) mappings, sizeof(FluxOffsetMapping) * nmappings); + + /* + * Force a full-page image. The caller may have removed dead tuples + * (ItemIdSetUnused) before compaction, and those removals are not encoded + * in the DEFRAG WAL record. Without an FPI the redo handler would call + * PageRepairFragmentation() on a page that still contains the dead + * tuples, producing a page inconsistent with the primary. + */ + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log overflow record write. + * + * The caller must already hold an exclusive lock on the buffer and have + * written the overflow record data to the page. We log either a new + * overflow record (header + data) or a link update (header only). + * + * buffer: already-locked buffer containing the overflow record + * offnum: offset of the overflow record on the page + * record_data: pointer to the record data to log (header, or header+data) + * record_len: length of data to log + * flags: FLUX_OVERFLOW_WAL_NEW_RECORD or FLUX_OVERFLOW_WAL_LINK_UPDATE + * commit_ts: commit timestamp + */ +XLogRecPtr +FluxXLogOverflowWrite(Relation rel, Buffer buffer, OffsetNumber offnum, + char *record_data, uint32 record_len, uint16 flags, + uint64 commit_ts) +{ + xl_flux_overflow_write xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_OVERFLOW_WRITE; + + /* Fill in the overflow write record */ + xlrec.offnum = offnum; + xlrec.flags = flags; + xlrec.data_len = record_len; + xlrec.commit_ts = commit_ts; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_overflow_write)); + XLogRegisterData(record_data, record_len); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log attribute compression + */ +XLogRecPtr +FluxXLogCompress(Relation rel, Buffer buffer, OffsetNumber offnum, + uint16 attr_num, FluxCompressionType comp_type, + uint8 comp_level, char *comp_data, + uint32 orig_size, uint32 comp_size, uint64 commit_ts) +{ + xl_flux_compress xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_COMPRESS; + + /* Fill in the compress record */ + xlrec.offnum = offnum; + xlrec.attr_num = attr_num; + xlrec.comp_type = comp_type; + xlrec.comp_level = comp_level; + xlrec.orig_size = orig_size; + xlrec.comp_size = comp_size; + xlrec.commit_ts = commit_ts; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_compress)); + XLogRegisterData(comp_data, comp_size); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log page initialization + */ +XLogRecPtr +FluxXLogInitPage(Relation rel, Buffer buffer, uint32 flags, uint64 commit_ts) +{ + xl_flux_init_page xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + uint8 info = XLOG_FLUX_INIT_PAGE; + + /* Fill in the init page record */ + xlrec.flags = flags; + xlrec.commit_ts = commit_ts; + + XLogBeginInsert(); + + /* Register buffer FIRST, before any data */ + XLogRegisterBuffer(0, buffer, REGBUF_WILL_INIT | REGBUF_STANDARD); + + /* Now register the data */ + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_init_page)); + + recptr = XLogInsert(RM_FLUX_ID, info); + + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * Log a cross-page defragmentation move. + * + * This logs the move of a single tuple from a source page (block ref 1) + * to a target page (block ref 0). Both pages are registered so that + * full-page images will be taken if needed. The tuple data is included + * in the record so that recovery can replay the move even without FPIs. + */ +XLogRecPtr +FluxXLogCrossPageDefrag(Relation rel, + Buffer dst_buf, OffsetNumber dst_offnum, + Buffer src_buf, OffsetNumber src_offnum, + const void *tuple_data, uint32 tuple_len) +{ + xl_flux_cross_page_defrag xlrec; + XLogRecPtr recptr; + + xlrec.src_offnum = src_offnum; + xlrec.dst_offnum = dst_offnum; + xlrec.tuple_len = tuple_len; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_cross_page_defrag)); + XLogRegisterData((char *) tuple_data, tuple_len); + XLogRegisterBuffer(0, dst_buf, REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + XLogRegisterBuffer(1, src_buf, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_CROSS_PAGE_DEFRAG); + + return recptr; +} + +/* + * FluxXLogCasUpdate -- WAL record for same-size CAS in-place update. + * + * Logs only the changed byte range within the tuple. This is the minimal + * WAL record for the tuple-level CAS fast path where the entire tuple does + * not need to be logged (same size, only data bytes changed). + * + * The caller holds BUFFER_LOCK_SHARE_EXCLUSIVE and the per-tuple t_writer CAS + * lock. We do NOT force a full-page image because: + * (a) the modification is confined to a single tuple's data bytes, and + * (b) the redo handler is idempotent (memcpy of fixed-length data at + * a fixed offset within the tuple). + */ +XLogRecPtr +FluxXLogCasUpdate(Relation rel, Buffer buffer, OffsetNumber offnum, + uint16 data_offset, uint16 data_len, + const char *new_data, uint64 new_commit_ts) +{ + xl_flux_cas_update xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + + xlrec.offnum = offnum; + xlrec.flags = 0; + xlrec.data_offset = data_offset; + xlrec.data_len = data_len; + xlrec.new_commit_ts = new_commit_ts; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_cas_update)); + XLogRegisterData(new_data, data_len); + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_CAS_UPDATE); + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * FluxXLogCasUpdateUndo -- FOLD variant of FluxXLogCasUpdate. + * + * Emits ONE combined WAL record carrying both the main-fork redo byte-diff + * (block 0, identical to FluxXLogCasUpdate) and the relundo-fork UNDO + * before-image (block 1, identical to what RelUndoFinish would have logged in + * a standalone RM_RELUNDO_ID record), plus the relundo metapage (block 2) when + * the UNDO record started a fresh relundo page. + * + * The caller must hold BOTH the main-fork buffer and the staged undo buffer + * (and the metapage buffer, if is_new_page) exclusively locked inside its + * critical section. RelUndoStage() has already written and dirtied the undo + * page; this function only logs the change and stamps all page LSNs. Returns + * the record LSN. + */ +XLogRecPtr +FluxXLogCasUpdateUndo(Relation rel, Buffer buffer, OffsetNumber offnum, + uint16 data_offset, uint16 data_len, + const char *new_data, uint64 new_commit_ts, + const struct RelUndoStageResult *undo) +{ + xl_flux_cas_update_undo xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + + /* redo half (block 0) */ + xlrec.offnum = offnum; + xlrec.flags = 0; + xlrec.data_offset = data_offset; + xlrec.data_len = data_len; + xlrec.new_commit_ts = new_commit_ts; + + /* undo half (block 1) */ + xlrec.urec_type = undo->urec_type; + xlrec.is_new_page = undo->is_new_page ? 1 : 0; + xlrec.urec_len = undo->urec_len; + xlrec.page_offset = undo->page_offset; + xlrec.new_pd_lower = undo->new_pd_lower; + xlrec.max_xid = undo->max_xid; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfFluxCasUpdateUndo); + XLogRegisterData(new_data, data_len); + + /* block 0: main-fork page, redo byte-diff */ + XLogRegisterBuffer(0, buffer, REGBUF_STANDARD); + + /* + * block 1: relundo data page. A freshly allocated page is registered + * WILL_INIT so redo reconstructs it from scratch (the redo handler keys + * off xlrec.is_new_page, not a record-level info bit, because the FLUX + * rmgr consumes the whole info upper-nibble as the opcode); the staged + * block data prepends the RelUndoPageHeaderData in that case (RelUndoStage + * built wal_record_data accordingly). An existing page registers the + * record bytes at page_offset with flag 0 to keep the FPI faithful + * (relundo data pages are non-standard). + */ + if (undo->is_new_page) + XLogRegisterBuffer(1, undo->undo_buffer, REGBUF_WILL_INIT); + else + XLogRegisterBuffer(1, undo->undo_buffer, 0); + + XLogRegisterBufData(1, undo->wal_record_data, undo->wal_record_size); + + /* block 2: relundo metapage, only when a new page was allocated */ + if (undo->is_new_page) + { + Assert(BufferIsValid(undo->metabuf)); + XLogRegisterBuffer(2, undo->metabuf, REGBUF_STANDARD); + } + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_CAS_UPDATE_UNDO); + + /* stamp all touched pages */ + PageSetLSN(page, recptr); + PageSetLSN(BufferGetPage(undo->undo_buffer), recptr); + if (undo->is_new_page) + PageSetLSN(BufferGetPage(undo->metabuf), recptr); + + return recptr; +} + +/* + * FluxXLogWriteDict -- WAL-log a compression-dictionary fork page. + * + * The dictionary fork uses a non-standard page layout that the redo path + * cannot rebuild from a logical delta, so we register the page as a forced + * full-page image and the redo handler restores it verbatim. The caller + * must already hold the buffer locked and have dirtied it inside the same + * critical section. + */ +XLogRecPtr +FluxXLogWriteDict(Relation rel, Buffer buffer) +{ + xl_flux_write_dict xlrec; + XLogRecPtr recptr; + Page page = BufferGetPage(buffer); + + xlrec.blkno = BufferGetBlockNumber(buffer); + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_flux_write_dict)); + XLogRegisterBuffer(0, buffer, REGBUF_FORCE_IMAGE); + + recptr = XLogInsert(RM_FLUX_ID, XLOG_FLUX_WRITE_DICT); + PageSetLSN(page, recptr); + + return recptr; +} + +/* + * REDO function for FLUX WAL records + */ +/* ---------------------------------------------------------------- + * Per-opcode REDO handlers. + * + * flux_redo() is the thin dispatcher; the real work for each + * XLOG_FLUX_* opcode lives in a dedicated static helper below. + * ---------------------------------------------------------------- + */ +static void flux_xlog_insert_redo(XLogReaderState *record); +static void flux_xlog_multi_insert_redo(XLogReaderState *record); +static void flux_xlog_update_inplace_redo(XLogReaderState *record); +static void flux_xlog_delete_redo(XLogReaderState *record); +static void flux_xlog_defrag_redo(XLogReaderState *record); +static void flux_xlog_overflow_write_redo(XLogReaderState *record); +static void flux_xlog_compress_redo(XLogReaderState *record); +static void flux_xlog_init_page_redo(XLogReaderState *record); +static void flux_xlog_cross_page_defrag_redo(XLogReaderState *record); +static void flux_xlog_vm_set_redo(XLogReaderState *record); +static void flux_xlog_vm_clear_redo(XLogReaderState *record); +static void flux_xlog_lock_redo(XLogReaderState *record); + +/* + * flux_xlog_insert_redo + * REDO handler for XLOG_FLUX_INSERT. + */ +static void +flux_xlog_insert_redo(XLogReaderState *record) +{ + RelFileLocator rlocator; + BlockNumber blkno; + Buffer buffer; + Page page; + + XLogRecGetBlockTag(record, 0, &rlocator, NULL, &blkno); + + { + xl_flux_insert *xlrec = (xl_flux_insert *) XLogRecGetData(record); + char *tuple_data = (char *) xlrec + sizeof(xl_flux_insert); + FluxTupleHeader *tuple_hdr = (FluxTupleHeader *) tuple_data; + XLogRedoAction action; + OffsetNumber final_offnum = InvalidOffsetNumber; + bool tuple_uncommitted = false; + + action = XLogReadBufferForRedo(record, 0, &buffer); + + /* + * For BLK_RESTORED (FPI), the page already has the tuple at + * xlrec->offnum + */ + if (action == BLK_RESTORED) + final_offnum = xlrec->offnum; + + if (action == BLK_NEEDS_REDO) + { + FluxPageOpaque phdr; + OffsetNumber inserted_offnum; + char *ovf_data; + Size ovf_len; + + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if + * needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* + * CRITICAL: During normal operation, overflow records are + * inserted BEFORE the main tuple (via FluxStoreOverflowColumn + * called from FluxFormTuple, then FluxPageAddTuple for main). + * This means overflow records get lower offsets (1, 2, 3...) and + * the main tuple gets a higher offset (4, ...). + * + * We MUST replay in the same order. If there are overflow records + * on block_id=0 (same page as main tuple due to spatial + * locality), replay them FIRST before the main tuple. + */ + ovf_data = XLogRecGetBlockData(record, 0, &ovf_len); + if (ovf_data != NULL && ovf_len > 0 && + (xlrec->flags & FLUX_WAL_HAS_OVERFLOW_BLK0)) + { + char *ovf_ptr = ovf_data; + Size ovf_remaining = ovf_len; + + /* + * Block 0 has overflow data. Parse and replay all overflow + * records on this block before the main tuple. Each overflow + * record has format: [xl_flux_overflow_write header][actual + * record data] + */ + while (ovf_remaining > sizeof(xl_flux_overflow_write)) + { + xl_flux_overflow_write *ovf_xlrec = (xl_flux_overflow_write *) ovf_ptr; + char *actual_data = ovf_ptr + sizeof(xl_flux_overflow_write); + Size actual_len = ovf_xlrec->data_len; + OffsetNumber ovf_offnum; + + if (ovf_remaining < sizeof(xl_flux_overflow_write) + actual_len) + elog(PANIC, "FLUX INSERT redo: corrupt overflow data on block 0: " + "ovf_remaining=%zu, sizeof(hdr)=%zu, data_len=%u, " + "total_len=%zu, offnum=%u, flags=%u", + ovf_remaining, sizeof(xl_flux_overflow_write), + (unsigned) actual_len, ovf_len, + (unsigned) ovf_xlrec->offnum, + (unsigned) ovf_xlrec->flags); + + /* + * Use InvalidOffsetNumber to let PageAddItem choose the + * next available offset. This ensures sequential offsets + * matching the original insertion order. + */ + ovf_offnum = PageAddItem(page, actual_data, actual_len, + InvalidOffsetNumber, false, false); + if (ovf_offnum == InvalidOffsetNumber) + { + elog(WARNING, "FLUX INSERT redo: failed to add overflow " + "record on block %u; skipping redo", blkno); + goto insert_skip_tuple; + } + + /* Advance to next overflow record in the block data */ + ovf_ptr += sizeof(xl_flux_overflow_write) + actual_len; + ovf_remaining -= sizeof(xl_flux_overflow_write) + actual_len; + } + } + + /* + * Validate that the record actually carries a tuple body of the + * advertised length before dereferencing it. The speculative + * INSERT writers (INSERT ... ON CONFLICT confirm/abort) historically + * emitted body-less records and relied solely on a full-page image; + * such a record must never reach this non-FPI path, but if it does + * (or tuple_len is otherwise inconsistent with the main data), + * reading PageAddItem(page, tuple_hdr, tuple_len) would walk off the + * end of the WAL record and SIGSEGV. Treat a missing/short body as + * "nothing to replay here" rather than crashing recovery. + */ + if (xlrec->tuple_len == 0 || + XLogRecGetDataLen(record) < + sizeof(xl_flux_insert) + xlrec->tuple_len) + { + elog(WARNING, "FLUX INSERT redo: record on block %u lacks a " + "tuple body (data_len=%u, need=%zu); skipping tuple redo", + blkno, XLogRecGetDataLen(record), + sizeof(xl_flux_insert) + (Size) xlrec->tuple_len); + goto insert_skip_tuple; + } + + /* + * Now replay the main tuple. Use InvalidOffsetNumber to let + * PageAddItem choose the next sequential offset after any + * overflow records we just added. + */ + inserted_offnum = PageAddItem(page, tuple_hdr, xlrec->tuple_len, + InvalidOffsetNumber, false, false); + if (inserted_offnum == InvalidOffsetNumber) + { + /* + * PageAddItem can fail if the page was modified by a later + * operation (CLR from the UNDO subsystem, defrag, or prune) + * whose effects were checkpointed to disk before the crash. + * In that case this INSERT was already superseded and the + * page state is ahead of this WAL record. Advance the page + * LSN so recovery doesn't retry, and skip tuple setup. + * + * PANICing here would make the server permanently + * unrecoverable after certain crash sequences involving the + * logical revert worker. + */ + elog(WARNING, "FLUX INSERT redo: failed to add tuple on " + "block %u (page may have been modified by a later " + "operation); skipping redo", blkno); + goto insert_skip_tuple; + } + final_offnum = inserted_offnum; + + /* + * Fix the tuple's t_ctid to point to itself at the correct + * location. During normal operation, this is set in + * flux_tuple_insert after we know the final TID. During redo, we + * must fix it here. + * + * Defensive: validate the ItemId is LP_NORMAL *and* has storage + * before dereferencing via PageGetItem. PageGetItem asserts + * ItemIdHasStorage (lp_len != 0), which ItemIdIsNormal does not + * imply: PageAddItem can produce an LP_NORMAL line pointer with + * lp_len == 0 for a zero-length payload, and after crash recovery + * involving the UNDO revert worker the slot could be in an + * unexpected state. Skipping the t_ctid fixup for a zero-storage + * item is safe (there is no tuple body to point at). + */ + { + ItemId itemid = PageGetItemId(page, inserted_offnum); + + if (ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxTupleHeader *inserted_hdr = + (FluxTupleHeader *) PageGetItem(page, itemid); + + /* blkno was already fetched at function entry */ + ItemPointerSet(&inserted_hdr->t_ctid, blkno, inserted_offnum); + } + } + + /* + * Update page header. CRITICAL: Must replicate the exact logic + * from FluxPageAddTuple() so the page matches the Full Page + * Write. FluxPageAddTuple sets the FLUX_PAGE_DEFRAG_NEEDED + * flag based on fragmentation heuristics, so we must do the same + * here. + */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + + /* + * Mark page for defragmentation if fragmented. This matches the + * logic in FluxPageAddTuple() at flux_tuple.c:513-517. + */ + if (PageGetFreeSpace(page) >= xlrec->tuple_len * 2 && + PageGetMaxOffsetNumber(page) > FirstOffsetNumber + 5) + { + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + } + + insert_skip_tuple: + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + + /* + * Capture the UNCOMMITTED flag from the replayed page tuple while we + * still hold the buffer. Reading it from the WAL main-data header is + * wrong for the speculative INSERT variants, which restore the tuple + * via a full-page image and carry no tuple body in main data. + */ + if (final_offnum != InvalidOffsetNumber && BufferIsValid(buffer)) + { + Page curpage = BufferGetPage(buffer); + + if (final_offnum <= PageGetMaxOffsetNumber(curpage)) + { + ItemId curiid = PageGetItemId(curpage, final_offnum); + + if (ItemIdIsNormal(curiid) && ItemIdHasStorage(curiid)) + { + FluxTupleHeader *curhdr = + (FluxTupleHeader *) PageGetItem(curpage, curiid); + + tuple_uncommitted = + (curhdr->t_flags & FLUX_TUPLE_UNCOMMITTED) != 0; + } + } + } + + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + /* + * Register UNCOMMITTED tuples in the per-tuple sLog during WAL + * replay. On a hot standby, the sLog is never populated by normal + * INSERT operations (only the primary's transaction machinery does + * that). Without this, the visibility check sees slog_nfound==0 + * and incorrectly assumes the inserter committed, making aborted + * tuples visible until the CLR arrives from the logical revert + * worker. + * + * This entry is cleaned up lazily: for committed transactions, + * SLogTupleEvictCommitted() reclaims the slot when the hash fills. + * For aborted transactions, the CLR sets DELETED, making the sLog + * entry irrelevant for visibility. + */ + if (final_offnum != InvalidOffsetNumber && tuple_uncommitted) + { + TransactionId redo_xid = XLogRecGetXid(record); + + if (TransactionIdIsValid(redo_xid)) + { + ItemPointerData tid; + + ItemPointerSet(&tid, blkno, final_offnum); + SLogTupleInsertRecovery(rlocator.relNumber, &tid, + redo_xid, SLOG_OP_INSERT); + } + } + + /* + * Process overflow buffers on separate pages (buffers 1..N). Each + * overflow buffer contains an overflow record that was registered + * with XLogRegisterBufData during WAL logging. + * + * Note: Overflow records on block_id=0 were already handled above + * before the main tuple to preserve insertion order. + */ + for (int ovf_idx = 1; ovf_idx < XLR_MAX_BLOCK_ID; ovf_idx++) + { + Buffer ovf_buffer; + Page ovf_page; + XLogRedoAction ovf_action; + + if (!XLogRecHasBlockRef(record, ovf_idx)) + break; /* No more overflow buffers */ + + ovf_action = XLogReadBufferForRedo(record, (uint8) ovf_idx, &ovf_buffer); + if (ovf_action == BLK_NEEDS_REDO) + { + char *ovf_data; + Size ovf_len; + + ovf_page = BufferGetPage(ovf_buffer); + + /* Initialize as FLUX page if new */ + if (PageIsNew(ovf_page)) + { + FluxInitPage(ovf_page, BufferGetPageSize(ovf_buffer)); + } + + /* Get the overflow record data from WAL */ + ovf_data = XLogRecGetBlockData(record, (uint8) ovf_idx, &ovf_len); + if (ovf_data != NULL && ovf_len > 0) + { + char *ovf_ptr = ovf_data; + Size ovf_remaining = ovf_len; + + /* + * Parse and replay all overflow records on this block. + * Multiple overflow records may be on the same page due + * to spatial locality optimization. + */ + while (ovf_remaining > sizeof(xl_flux_overflow_write)) + { + xl_flux_overflow_write *ovf_xlrec = (xl_flux_overflow_write *) ovf_ptr; + char *actual_data = ovf_ptr + sizeof(xl_flux_overflow_write); + Size actual_len = ovf_xlrec->data_len; + OffsetNumber ovf_offnum; + + if (ovf_remaining < sizeof(xl_flux_overflow_write) + actual_len) + elog(PANIC, "FLUX INSERT redo: corrupt overflow data on block %u", + BufferGetBlockNumber(ovf_buffer)); + + /* + * Use the specific offset from WAL record. Overflow + * pointers reference these offsets. Use the specific + * offset from WAL record. Overflow pointers reference + * these offsets. + */ + ovf_offnum = PageAddItem(ovf_page, actual_data, actual_len, + ovf_xlrec->offnum, false, false); + if (ovf_offnum == InvalidOffsetNumber) + { + /* + * Overflow page may have been modified by a + * later operation that was checkpointed. Skip + * remaining overflow records on this page. + */ + elog(WARNING, "FLUX INSERT redo: failed to add " + "overflow record on block %u; skipping", + BufferGetBlockNumber(ovf_buffer)); + break; + } + + /* Advance to next overflow record */ + ovf_ptr += sizeof(xl_flux_overflow_write) + actual_len; + ovf_remaining -= sizeof(xl_flux_overflow_write) + actual_len; + } + } + + PageSetLSN(ovf_page, record->EndRecPtr); + MarkBufferDirty(ovf_buffer); + } + if (BufferIsValid(ovf_buffer)) + UnlockReleaseBuffer(ovf_buffer); + } + } +} + +/* + * flux_xlog_multi_insert_redo + * REDO handler for XLOG_FLUX_MULTI_INSERT. + * + * Replays a page-at-a-time batch insert. The WAL main data is: + * [xl_flux_multi_insert header] + * ntuples * { [xl_flux_multi_insert_tuple header][tuple t_data body] } + * ntuples * { [logical image bytes][uint32 len] } (only if logical) + * + * Unlike single INSERT, the batch path never spills to overflow (large + * tuples route to the single-insert path), so there is no overflow replay. + */ +static void +flux_xlog_multi_insert_redo(XLogReaderState *record) +{ + RelFileLocator rlocator; + BlockNumber blkno; + Buffer buffer; + Page page; + + XLogRecGetBlockTag(record, 0, &rlocator, NULL, &blkno); + + { + xl_flux_multi_insert *xlrec = (xl_flux_multi_insert *) XLogRecGetData(record); + int ntuples = xlrec->ntuples; + char *cursor = (char *) xlrec + SizeOfFluxMultiInsert; + XLogRedoAction action; + OffsetNumber *final_offnums; + bool *tuple_uncommitted; + int i; + + final_offnums = (OffsetNumber *) palloc0(ntuples * sizeof(OffsetNumber)); + tuple_uncommitted = (bool *) palloc0(ntuples * sizeof(bool)); + + action = XLogReadBufferForRedo(record, 0, &buffer); + + if (action == BLK_RESTORED) + { + /* + * The full-page image already carries every tuple; recover the + * offsets from the per-tuple WAL headers so we can still register + * uncommitted tuples in the sLog below. + */ + char *c = cursor; + + for (i = 0; i < ntuples; i++) + { + xl_flux_multi_insert_tuple *thdr; + + c = (char *) SHORTALIGN(c); + thdr = (xl_flux_multi_insert_tuple *) c; + + final_offnums[i] = thdr->offnum; + c += SizeOfFluxMultiInsertTuple + thdr->datalen; + } + } + + if (action == BLK_NEEDS_REDO) + { + FluxPageOpaque phdr; + Size max_body = 0; + + page = BufferGetPage(buffer); + + if (PageIsNew(page)) + FluxInitPage(page, BufferGetPageSize(buffer)); + + for (i = 0; i < ntuples; i++) + { + xl_flux_multi_insert_tuple *thdr; + char *body; + Size datalen; + OffsetNumber inserted_offnum; + + cursor = (char *) SHORTALIGN(cursor); + thdr = (xl_flux_multi_insert_tuple *) cursor; + body = cursor + SizeOfFluxMultiInsertTuple; + datalen = thdr->datalen; + + cursor = body + datalen; + + if (datalen == 0) + { + elog(WARNING, "FLUX MULTI_INSERT redo: zero-length tuple " + "%d on block %u; skipping", i, blkno); + continue; + } + + /* + * Use InvalidOffsetNumber so PageAddItem packs sequentially, + * matching the original batch insertion order. Skip on + * failure (the page may have been advanced past this record by + * a later checkpointed operation), exactly as the single + * INSERT redo path does. + */ + inserted_offnum = PageAddItem(page, body, datalen, + InvalidOffsetNumber, false, false); + if (inserted_offnum == InvalidOffsetNumber) + { + elog(WARNING, "FLUX MULTI_INSERT redo: failed to add " + "tuple %d on block %u (page may have been modified by " + "a later operation); skipping", i, blkno); + continue; + } + + final_offnums[i] = inserted_offnum; + if (datalen > max_body) + max_body = datalen; + + /* + * Fix the tuple's t_ctid to point at itself. Guard the + * PageGetItem dereference on ItemIdHasStorage as the single + * INSERT redo path does. + */ + { + ItemId itemid = PageGetItemId(page, inserted_offnum); + + if (ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxTupleHeader *inserted_hdr = + (FluxTupleHeader *) PageGetItem(page, itemid); + + ItemPointerSet(&inserted_hdr->t_ctid, blkno, inserted_offnum); + } + } + } + + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + + /* + * Mark the page for defragmentation if fragmented, matching + * FluxPageAddTuple(). Use the largest tuple body in the batch as + * the heuristic threshold. + */ + if (max_body > 0 && + PageGetFreeSpace(page) >= max_body * 2 && + PageGetMaxOffsetNumber(page) > FirstOffsetNumber + 5) + { + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + } + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + + /* + * Capture the UNCOMMITTED flag from each replayed tuple while we still + * hold the buffer (true for both BLK_NEEDS_REDO and BLK_RESTORED). + */ + if (BufferIsValid(buffer)) + { + Page curpage = BufferGetPage(buffer); + OffsetNumber maxoff = PageGetMaxOffsetNumber(curpage); + + for (i = 0; i < ntuples; i++) + { + OffsetNumber off = final_offnums[i]; + ItemId curiid; + + if (off == InvalidOffsetNumber || off > maxoff) + continue; + + curiid = PageGetItemId(curpage, off); + if (ItemIdIsNormal(curiid) && ItemIdHasStorage(curiid)) + { + FluxTupleHeader *curhdr = + (FluxTupleHeader *) PageGetItem(curpage, curiid); + + tuple_uncommitted[i] = + (curhdr->t_flags & FLUX_TUPLE_UNCOMMITTED) != 0; + } + } + } + + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + /* + * Register UNCOMMITTED tuples in the per-tuple sLog during replay, so + * a hot standby sees aborted batch rows as invisible until the CLR + * arrives. Mirrors the single INSERT redo path. + */ + { + TransactionId redo_xid = XLogRecGetXid(record); + + if (TransactionIdIsValid(redo_xid)) + { + for (i = 0; i < ntuples; i++) + { + ItemPointerData tid; + + if (final_offnums[i] == InvalidOffsetNumber || + !tuple_uncommitted[i]) + continue; + + ItemPointerSet(&tid, blkno, final_offnums[i]); + SLogTupleInsertRecovery(rlocator.relNumber, &tid, + redo_xid, SLOG_OP_INSERT); + } + } + } + + pfree(final_offnums); + pfree(tuple_uncommitted); + } +} + +/* + * flux_xlog_update_inplace_redo + * REDO handler for XLOG_FLUX_UPDATE_INPLACE. + */ +static void +flux_xlog_update_inplace_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_update *xlrec = (xl_flux_update *) XLogRecGetData(record); + XLogRedoAction action; + + /* + * WAL record layout depends on FLUX_WAL_PREFIX_SUFFIX flag: + * + * Without prefix/suffix: [xl_flux_update][full new tuple data] With + * prefix/suffix: [xl_flux_update][xl_flux_prefix_suffix][diff + * bytes] + * + * Old tuple data is in the UNDO fork exclusively. + */ + char *after_header = (char *) xlrec + sizeof(xl_flux_update); + bool use_prefix_suffix = (xlrec->flags & FLUX_WAL_PREFIX_SUFFIX) != 0; + xl_flux_prefix_suffix ps_info = {0, 0}; + char *diff_data = NULL; + char *new_tuple_data = NULL; + FluxTupleHeader *new_tuple_hdr = NULL; + ItemId itemid; + FluxPageOpaque phdr; + + if (use_prefix_suffix) + { + memcpy(&ps_info, after_header, sizeof(xl_flux_prefix_suffix)); + diff_data = after_header + sizeof(xl_flux_prefix_suffix); + } + else + { + new_tuple_data = after_header; + new_tuple_hdr = (FluxTupleHeader *) new_tuple_data; + } + + action = XLogReadBufferForRedo(record, 0, &buffer); + + if (action == BLK_NEEDS_REDO) + { + char *blk0_ovf_data; + Size blk0_ovf_len; + + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if + * needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* + * Process overflow records on block 0 BEFORE the main tuple, + * matching the original insertion order. During normal + * operation, overflow records stored on the same page as the main + * tuple (spatial locality) get lower offsets. We must replay + * them first so the main tuple ends up at the correct offset. + */ + blk0_ovf_data = XLogRecGetBlockData(record, 0, &blk0_ovf_len); + if (blk0_ovf_data != NULL && blk0_ovf_len > 0 && + (xlrec->flags & FLUX_WAL_HAS_OVERFLOW_BLK0)) + { + char *ovf_ptr = blk0_ovf_data; + Size ovf_remaining = blk0_ovf_len; + + while (ovf_remaining > sizeof(xl_flux_overflow_write)) + { + xl_flux_overflow_write *blk0_ovf_xlrec = + (xl_flux_overflow_write *) ovf_ptr; + char *actual_data = ovf_ptr + sizeof(xl_flux_overflow_write); + Size actual_len = blk0_ovf_xlrec->data_len; + OffsetNumber ovf_offnum; + + if (ovf_remaining < sizeof(xl_flux_overflow_write) + actual_len) + elog(PANIC, "FLUX UPDATE redo: corrupt overflow data on block 0"); + + ovf_offnum = PageAddItem(page, actual_data, actual_len, + InvalidOffsetNumber, false, false); + if (ovf_offnum == InvalidOffsetNumber) + elog(PANIC, "FLUX UPDATE redo: failed to add overflow record on block 0"); + + ovf_ptr += sizeof(xl_flux_overflow_write) + actual_len; + ovf_remaining -= sizeof(xl_flux_overflow_write) + actual_len; + } + } + + /* + * Apply the update. BLK_NEEDS_REDO is only returned when the + * page LSN < record LSN (no FPI). + * + * For cross-page out-of-place updates, the new tuple lives on the + * destination page (restored from its FPI). Here we just mark + * the old tuple as UPDATED so visibility checks filter it + * correctly. + * + * Same-page out-of-place updates always force an FPI (see + * FluxXLogUpdate), so they get BLK_RESTORED and never reach this + * code path. + */ + itemid = PageGetItemId(page, xlrec->offnum); + if (ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxTupleHeader *existing_tuple = + (FluxTupleHeader *) PageGetItem(page, itemid); + + if (xlrec->flags & FLUX_WAL_CROSS_PAGE) + { + /* + * Cross-page out-of-place update: mark the old tuple as + * UPDATED. The new version is on the destination page + * restored from its FPI. + */ + existing_tuple->t_flags |= FLUX_TUPLE_UPDATED; + existing_tuple->t_flags &= ~FLUX_TUPLE_UNCOMMITTED; + existing_tuple->t_commit_ts = xlrec->new_commit_ts; + } + else + { + Size existing_len = ItemIdGetLength(itemid); + + if (use_prefix_suffix) + { + /* + * Prefix/suffix compressed update: reconstruct new + * tuple by patching the diff bytes into the existing + * tuple data on the page. + * + * The emitter computes prefix/suffix/difflen against + * new_tuple->t_len (a same-size update only), so redo + * must anchor on xlrec->new_tuple_len -- NOT the on-page + * slot length. A prior non-shrinking shrink update can + * leave the slot larger than the tuple's logical length + * (see flux_operations.c), so using existing_len here + * would over-copy past the diff bytes and corrupt the + * tuple's suffix. + */ + int difflen = (int) xlrec->new_tuple_len - + ps_info.prefixlen - ps_info.suffixlen; + + if (difflen < 0 || + ps_info.prefixlen + ps_info.suffixlen > xlrec->new_tuple_len) + elog(PANIC, "FLUX UPDATE REDO: invalid prefix/suffix " + "(prefix=%u, suffix=%u, tuple_len=%u)", + ps_info.prefixlen, ps_info.suffixlen, + xlrec->new_tuple_len); + + if (difflen > 0) + memcpy((char *) existing_tuple + ps_info.prefixlen, + diff_data, difflen); + } + else if (xlrec->new_tuple_len <= existing_len) + { + /* + * Full new tuple: overwrite in place. Do NOT shrink + * the line pointer; the write path keeps the original + * slot length so the undo before-image can be restored + * into it (see flux_operations.c). Redo must produce + * the identical slot length or WAL consistency checking + * (wal_consistency_checking=flux) will diverge. + */ + memcpy(existing_tuple, new_tuple_hdr, xlrec->new_tuple_len); + } + else + { + /* + * Should not happen: growing updates force FPI via + * REGBUF_FORCE_IMAGE, so BLK_NEEDS_REDO is never + * returned for them. + */ + elog(PANIC, "FLUX UPDATE REDO: new tuple (%u) larger " + "than existing slot (%zu) without FPI", + xlrec->new_tuple_len, existing_len); + } + } + } + else + { + elog(DEBUG1, "FLUX UPDATE REDO: ItemId at offnum=%u is not normal", xlrec->offnum); + } + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->new_commit_ts)); + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + /* + * Process overflow buffers on separate pages (buffers 1..N) for + * UPDATE. Multiple overflow records may share a single block due to + * spatial locality, so we loop through all records within each + * block's data (matching INSERT redo). + */ + for (int ovf_idx = 1; ovf_idx < XLR_MAX_BLOCK_ID; ovf_idx++) + { + Buffer ovf_buffer; + Page ovf_page; + XLogRedoAction ovf_action; + + if (!XLogRecHasBlockRef(record, ovf_idx)) + break; /* No more overflow buffers */ + + ovf_action = XLogReadBufferForRedo(record, (uint8) ovf_idx, &ovf_buffer); + if (ovf_action == BLK_NEEDS_REDO) + { + char *ovf_data; + Size ovf_len; + + ovf_page = BufferGetPage(ovf_buffer); + + /* Initialize as FLUX page if new */ + if (PageIsNew(ovf_page)) + { + FluxInitPage(ovf_page, BufferGetPageSize(ovf_buffer)); + } + + /* Get the overflow record data from WAL */ + ovf_data = XLogRecGetBlockData(record, (uint8) ovf_idx, &ovf_len); + if (ovf_data != NULL && ovf_len > 0) + { + char *ovf_ptr = ovf_data; + Size ovf_remaining = ovf_len; + + /* + * Parse and replay all overflow records on this block. + * Multiple overflow records may share a page due to + * spatial locality. + */ + while (ovf_remaining > sizeof(xl_flux_overflow_write)) + { + xl_flux_overflow_write *ovf_xlrec2 = + (xl_flux_overflow_write *) ovf_ptr; + char *actual_data = ovf_ptr + sizeof(xl_flux_overflow_write); + Size actual_len = ovf_xlrec2->data_len; + OffsetNumber ovf_offnum; + + if (ovf_remaining < sizeof(xl_flux_overflow_write) + actual_len) + elog(PANIC, "FLUX UPDATE redo: corrupt overflow data on block %u", + BufferGetBlockNumber(ovf_buffer)); + + /* + * Use InvalidOffsetNumber to append sequentially, + * matching the original insertion order within this + * page. + */ + ovf_offnum = PageAddItem(ovf_page, actual_data, actual_len, + InvalidOffsetNumber, false, false); + if (ovf_offnum == InvalidOffsetNumber) + elog(PANIC, "FLUX UPDATE redo: failed to add overflow record on block %u", + BufferGetBlockNumber(ovf_buffer)); + + ovf_ptr += sizeof(xl_flux_overflow_write) + actual_len; + ovf_remaining -= sizeof(xl_flux_overflow_write) + actual_len; + } + } + + PageSetLSN(ovf_page, record->EndRecPtr); + MarkBufferDirty(ovf_buffer); + } + if (BufferIsValid(ovf_buffer)) + UnlockReleaseBuffer(ovf_buffer); + } + } +} + +/* + * flux_xlog_delete_redo + * REDO handler for XLOG_FLUX_DELETE. + */ +static void +flux_xlog_delete_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_delete *xlrec = (xl_flux_delete *) XLogRecGetData(record); + XLogRedoAction action; + ItemId itemid; + FluxPageOpaque phdr; + + /* + * WAL record contains only the delete header (offset + commit_ts). + * Old tuple data is stored exclusively in the UNDO fork for + * transaction rollback and is not needed here. + */ + + action = XLogReadBufferForRedo(record, 0, &buffer); + if (action == BLK_NEEDS_REDO) + { + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if + * needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* REDO: Mark tuple as deleted */ + itemid = PageGetItemId(page, xlrec->offnum); + if (ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxTupleHeader *tuple = + (FluxTupleHeader *) PageGetItem(page, itemid); + + tuple->t_flags |= FLUX_TUPLE_DELETED; + tuple->t_commit_ts = xlrec->commit_ts; + } + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + FluxPageSetFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_defrag_redo + * REDO handler for XLOG_FLUX_DEFRAG. + */ +static void +flux_xlog_defrag_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_defrag *xlrec = (xl_flux_defrag *) XLogRecGetData(record); + XLogRedoAction action; + + FluxPageOpaque phdr; + + action = XLogReadBufferForRedo(record, 0, &buffer); + if (action == BLK_NEEDS_REDO) + { + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if + * needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* Defragment the page */ + PageRepairFragmentation(page); + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + FluxPageClearFlag(phdr, FLUX_PAGE_DEFRAG_NEEDED); + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_overflow_write_redo + * REDO handler for XLOG_FLUX_OVERFLOW_WRITE. + */ +static void +flux_xlog_overflow_write_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_overflow_write *xlrec = + (xl_flux_overflow_write *) XLogRecGetData(record); + char *record_data = (char *) xlrec + sizeof(xl_flux_overflow_write); + XLogRedoAction action; + + action = XLogReadBufferForRedo(record, 0, &buffer); + if (action == BLK_NEEDS_REDO) + { + page = BufferGetPage(buffer); + + /* Initialize as normal FLUX page if needed */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + if (xlrec->flags & FLUX_OVERFLOW_WAL_LINK_UPDATE) + { + /* + * Link update: overwrite the existing overflow record header + * at the specified offset with updated chain pointers. + */ + ItemId itemid; + + itemid = PageGetItemId(page, xlrec->offnum); + if (ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxOverflowRecordHeader *existing_hdr = + (FluxOverflowRecordHeader *) PageGetItem(page, itemid); + + memcpy(existing_hdr, record_data, + sizeof(FluxOverflowRecordHeader)); + } + } + else + { + /* + * New overflow record: the logged data is the complete record + * (FluxOverflowRecordHeader + chunk data). Add it to the + * page at the specified offset. + */ + OffsetNumber offnum; + + offnum = PageAddItem(page, record_data, xlrec->data_len, + xlrec->offnum, false, false); + if (offnum == InvalidOffsetNumber) + elog(ERROR, "failed to add overflow record to page during redo"); + } + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_compress_redo + * REDO handler for XLOG_FLUX_COMPRESS. + */ +static void +flux_xlog_compress_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_compress *xlrec = (xl_flux_compress *) XLogRecGetData(record); + XLogRedoAction action; + + ItemId itemid; + FluxPageOpaque phdr; + + action = XLogReadBufferForRedo(record, 0, &buffer); + if (action == BLK_NEEDS_REDO) + { + page = BufferGetPage(buffer); + + /* + * XLogInitBufferForRedo does standard PageInit for new pages, but + * doesn't set up FLUX opaque space. Initialize it here if + * needed. + */ + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + /* Apply compression to the tuple attribute */ + itemid = PageGetItemId(page, xlrec->offnum); + if (ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxTupleHeader *tuple = + (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Mark tuple as compressed */ + tuple->t_flags |= FLUX_TUPLE_COMPRESSED; + tuple->t_infomask |= FLUX_INFOMASK_COMPRESSED; + tuple->t_commit_ts = xlrec->commit_ts; + } + + /* Update page header */ + phdr = FluxPageGetOpaque(page); + FluxPageSetCommitTs(phdr, Max(FluxPageGetCommitTs(phdr), xlrec->commit_ts)); + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_init_page_redo + * REDO handler for XLOG_FLUX_INIT_PAGE. + */ +static void +flux_xlog_init_page_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_init_page *xlrec = (xl_flux_init_page *) XLogRecGetData(record); + XLogRedoAction action; + + action = XLogReadBufferForRedoExtended(record, 0, RBM_ZERO_AND_LOCK, false, &buffer); + if (action == BLK_NEEDS_REDO) + { + FluxPageOpaque phdr; + + page = BufferGetPage(buffer); + + /* Initialize page with FLUX opaque space */ + FluxInitPage(page, BufferGetPageSize(buffer)); + + /* Override commit_ts and flags from WAL record */ + phdr = FluxPageGetOpaque(page); + phdr->pd_commit_ts_and_flags = ((uint64) (xlrec->commit_ts) & FLUX_PAGE_TS_MASK) | (uint64) (xlrec->flags); + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_cross_page_defrag_redo + * REDO handler for XLOG_FLUX_CROSS_PAGE_DEFRAG. + */ +static void +flux_xlog_cross_page_defrag_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_cross_page_defrag *xlrec = + (xl_flux_cross_page_defrag *) XLogRecGetData(record); + char *tuple_data = (char *) xlrec + + sizeof(xl_flux_cross_page_defrag); + XLogRedoAction dst_action; + XLogRedoAction src_action; + + /* + * Redo the target page (block 0): insert the moved tuple. + * XLogReadBufferForRedo will skip replay if FPI is present. + */ + dst_action = XLogReadBufferForRedo(record, 0, &buffer); + if (dst_action == BLK_NEEDS_REDO) + { + page = BufferGetPage(buffer); + + if (PageAddItem(page, tuple_data, xlrec->tuple_len, + xlrec->dst_offnum, false, false) + == InvalidOffsetNumber) + { + /* + * Defensive: with REGBUF_FORCE_IMAGE this path should be + * unreachable, but if it ever fires we must not PANIC — + * skip the move and let the source page processing proceed. + */ + elog(DEBUG1, "flux cross-page defrag: insufficient space on target page during redo"); + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + goto process_source; + } + + /* Update ctid in the new copy to point to itself */ + { + ItemId dst_itemid; + FluxTupleHeader *dst_hdr; + BlockNumber dst_blkno; + + XLogRecGetBlockTag(record, 0, NULL, NULL, &dst_blkno); + dst_itemid = PageGetItemId(page, xlrec->dst_offnum); + if (ItemIdIsNormal(dst_itemid) && ItemIdHasStorage(dst_itemid)) + { + dst_hdr = (FluxTupleHeader *) PageGetItem(page, dst_itemid); + ItemPointerSet(&dst_hdr->t_ctid, dst_blkno, + xlrec->dst_offnum); + } + } + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + /* + * Redo the source page (block 1): mark the old slot unused. + */ +process_source: + src_action = XLogReadBufferForRedo(record, 1, &buffer); + if (src_action == BLK_NEEDS_REDO) + { + ItemId src_itemid; + + page = BufferGetPage(buffer); + src_itemid = PageGetItemId(page, xlrec->src_offnum); + ItemIdSetUnused(src_itemid); + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_vm_set_redo + * REDO handler for XLOG_FLUX_VM_SET. + */ +static void +flux_xlog_vm_set_redo(XLogReaderState *record) +{ + { + xl_flux_vm_set *xlrec = (xl_flux_vm_set *) XLogRecGetData(record); + Buffer vmBuf; + Page vmPage; + uint32 mapByte; + uint8 mapOffset; + uint8 *map; + + /* + * Block 0 is the heap buffer, registered with REGBUF_NO_CHANGE. We + * don't need to redo it since the heap page is not modified by VM + * operations. + */ + + /* Redo VM buffer (block 1) */ + if (XLogReadBufferForRedo(record, 1, &vmBuf) == BLK_NEEDS_REDO) + { + vmPage = BufferGetPage(vmBuf); + + /* Calculate the VM byte and offset for this heap block */ + mapByte = (xlrec->heapBlk % ((BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) * 4)) / 4; + mapOffset = (xlrec->heapBlk % ((BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) * 4)) % 4; + + map = (uint8 *) PageGetContents(vmPage); + map[mapByte] |= (xlrec->flags << (mapOffset * 2)); + + PageSetLSN(vmPage, record->EndRecPtr); + MarkBufferDirty(vmBuf); + } + if (BufferIsValid(vmBuf)) + UnlockReleaseBuffer(vmBuf); + } +} + +/* + * flux_xlog_vm_clear_redo + * REDO handler for XLOG_FLUX_VM_CLEAR. + */ +static void +flux_xlog_vm_clear_redo(XLogReaderState *record) +{ + { + xl_flux_vm_clear *xlrec = (xl_flux_vm_clear *) XLogRecGetData(record); + Buffer vmBuf; + Page vmPage; + uint32 mapByte; + uint8 mapOffset; + uint8 *map; + + /* + * Block 0 is the heap buffer, registered with REGBUF_NO_CHANGE -- + * skip it. + */ + + /* Redo VM buffer (block 1) */ + if (XLogReadBufferForRedo(record, 1, &vmBuf) == BLK_NEEDS_REDO) + { + vmPage = BufferGetPage(vmBuf); + + /* Calculate the VM byte and offset for this heap block */ + mapByte = (xlrec->heapBlk % ((BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) * 4)) / 4; + mapOffset = (xlrec->heapBlk % ((BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) * 4)) % 4; + + map = (uint8 *) PageGetContents(vmPage); + map[mapByte] &= ~(xlrec->flags << (mapOffset * 2)); + + PageSetLSN(vmPage, record->EndRecPtr); + MarkBufferDirty(vmBuf); + } + if (BufferIsValid(vmBuf)) + UnlockReleaseBuffer(vmBuf); + } +} + +/* + * flux_xlog_lock_redo + * REDO handler for XLOG_FLUX_LOCK. + */ +static void +flux_xlog_lock_redo(XLogReaderState *record) +{ + Buffer buffer; + Page page; + + { + xl_flux_lock *xlrec = (xl_flux_lock *) XLogRecGetData(record); + XLogRedoAction action; + + action = XLogReadBufferForRedo(record, 0, &buffer); + if (action == BLK_NEEDS_REDO) + { + ItemId itemid; + + page = BufferGetPage(buffer); + + if (PageIsNew(page)) + { + FluxInitPage(page, BufferGetPageSize(buffer)); + } + + itemid = PageGetItemId(page, xlrec->offnum); + if (ItemIdIsNormal(itemid) && ItemIdHasStorage(itemid)) + { + FluxTupleHeader *tuple = + (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Apply the lock state from the WAL record */ + tuple->t_infomask = xlrec->infomask; + tuple->t_flags |= FLUX_TUPLE_LOCKED; + } + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_cas_update_redo + * REDO handler for XLOG_FLUX_CAS_UPDATE. + * + * Patches a contiguous byte range within a tuple on the page. The record + * carries only the changed bytes (data_offset..data_offset+data_len) and + * the new commit timestamp. Idempotent memcpy; safe for replay. + */ +static void +flux_xlog_cas_update_redo(XLogReaderState *record) +{ + xl_flux_cas_update *xlrec = (xl_flux_cas_update *) XLogRecGetData(record); + char *new_data = ((char *) xlrec) + sizeof(xl_flux_cas_update); + Buffer buffer; + + if (XLogReadBufferForRedo(record, 0, &buffer) == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buffer); + ItemId itemid; + FluxTupleHeader *tuple; + + itemid = PageGetItemId(page, xlrec->offnum); + if (!ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + elog(PANIC, "FLUX CAS_UPDATE redo: invalid item at offset %u", + xlrec->offnum); + + tuple = (FluxTupleHeader *) PageGetItem(page, itemid); + + /* Patch the changed data bytes */ + memcpy(((char *) tuple) + xlrec->data_offset, new_data, xlrec->data_len); + + /* Update commit timestamp */ + tuple->t_commit_ts = xlrec->new_commit_ts; + + /* Ensure t_writer is cleared (crash may have left it non-zero) */ + tuple->t_writer = 0; + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); +} + +/* + * flux_xlog_cas_update_undo_redo + * REDO handler for XLOG_FLUX_CAS_UPDATE_UNDO (FOLD variant). + * + * Replays two page changes from one record: + * block 0 - main-fork page: patch the tuple data bytes, set commit ts, + * clear t_writer (identical to flux_xlog_cas_update_redo). + * block 1 - relundo-fork data page: write the UNDO before-image + * (identical to relundo_redo_insert; new pages honor WILL_INIT + * via xlrec->is_new_page, existing pages memcpy at page_offset). + * block 2 - relundo metapage: FPI restore, present only when is_new_page. + * + * Both page redos are idempotent memcpys; safe for replay. + */ +static void +flux_xlog_cas_update_undo_redo(XLogReaderState *record) +{ + XLogRecPtr lsn = record->EndRecPtr; + xl_flux_cas_update_undo *xlrec = + (xl_flux_cas_update_undo *) XLogRecGetData(record); + char *new_data = ((char *) xlrec) + SizeOfFluxCasUpdateUndo; + Buffer buffer; + XLogRedoAction action; + bool has_metapage = XLogRecHasBlockRef(record, 2); + + /* ---- block 0: main-fork redo byte-diff ---- */ + if (XLogReadBufferForRedo(record, 0, &buffer) == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buffer); + ItemId itemid; + FluxTupleHeader *tuple; + + itemid = PageGetItemId(page, xlrec->offnum); + if (!ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + elog(PANIC, "FLUX CAS_UPDATE_UNDO redo: invalid item at offset %u", + xlrec->offnum); + + tuple = (FluxTupleHeader *) PageGetItem(page, itemid); + + memcpy(((char *) tuple) + xlrec->data_offset, new_data, xlrec->data_len); + tuple->t_commit_ts = xlrec->new_commit_ts; + tuple->t_writer = 0; + + PageSetLSN(page, lsn); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + /* ---- block 1: relundo-fork UNDO before-image ---- */ + if (xlrec->urec_len < SizeOfRelUndoRecordHeader) + elog(PANIC, "CAS_UPDATE_UNDO redo: invalid urec_len %u (min %zu)", + xlrec->urec_len, SizeOfRelUndoRecordHeader); + if (xlrec->page_offset > BLCKSZ - sizeof(RelUndoPageHeaderData)) + elog(PANIC, "CAS_UPDATE_UNDO redo: invalid page offset %u", + xlrec->page_offset); + if (xlrec->new_pd_lower > BLCKSZ) + elog(PANIC, "CAS_UPDATE_UNDO redo: pd_lower %u exceeds page size", + xlrec->new_pd_lower); + if ((uint32) xlrec->page_offset + (uint32) xlrec->urec_len > BLCKSZ) + elog(PANIC, "CAS_UPDATE_UNDO redo: record extends past page end (offset %u + len %u > %u)", + xlrec->page_offset, xlrec->urec_len, (uint32) BLCKSZ); + if (xlrec->new_pd_lower + MAXALIGN(SizeOfPageHeaderData) < xlrec->page_offset) + elog(PANIC, "CAS_UPDATE_UNDO redo: new_pd_lower %u precedes page_offset %u", + xlrec->new_pd_lower, xlrec->page_offset); + if (xlrec->urec_type < RELUNDO_INSERT || xlrec->urec_type > RELUNDO_UPDATE) + elog(PANIC, "CAS_UPDATE_UNDO redo: invalid record type %u", xlrec->urec_type); + + if (xlrec->is_new_page) + { + buffer = XLogInitBufferForRedo(record, 1); + action = BLK_NEEDS_REDO; + } + else + action = XLogReadBufferForRedo(record, 1, &buffer); + + if (action == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buffer); + char *record_data; + Size record_len; + + record_data = XLogRecGetBlockData(record, 1, &record_len); + if (record_data == NULL || record_len == 0) + elog(PANIC, "CAS_UPDATE_UNDO redo: no block data for UNDO record"); + if (record_len > BLCKSZ) + elog(PANIC, "CAS_UPDATE_UNDO redo: block data too large (%zu bytes)", record_len); + + if (xlrec->is_new_page) + { + char *contents; + + if (record_len < SizeOfRelUndoPageHeaderData) + elog(PANIC, "CAS_UPDATE_UNDO redo: INIT_PAGE block data too small (%zu < %zu)", + record_len, SizeOfRelUndoPageHeaderData); + if (record_len > BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) + elog(PANIC, "CAS_UPDATE_UNDO redo: INIT_PAGE block data too large (%zu bytes)", + record_len); + + PageInit(page, BLCKSZ, 0); + contents = PageGetContents(page); + memcpy(contents, record_data, record_len); + } + else + { + RelUndoPageHeader undohdr = (RelUndoPageHeader) PageGetContents(page); + + if (undohdr->pd_lower > BLCKSZ) + elog(PANIC, "CAS_UPDATE_UNDO redo: existing pd_lower %u exceeds page size", + undohdr->pd_lower); + + memcpy((char *) page + xlrec->page_offset, record_data, record_len); + undohdr->pd_lower = xlrec->new_pd_lower; + undohdr->max_xid = xlrec->max_xid; + + if (undohdr->pd_lower + MAXALIGN(SizeOfPageHeaderData) < xlrec->page_offset + record_len) + elog(PANIC, "CAS_UPDATE_UNDO redo: pd_lower %u too small for offset %u + len %zu", + undohdr->pd_lower, xlrec->page_offset, record_len); + } + + PageSetLSN(page, lsn); + MarkBufferDirty(buffer); + } + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + + /* ---- block 2: relundo metapage FPI ---- */ + if (has_metapage) + { + (void) XLogReadBufferForRedo(record, 2, &buffer); + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } +} + +/* + * flux_xlog_write_dict_redo + * REDO handler for XLOG_FLUX_WRITE_DICT. + * + * The record always carries a forced full-page image of the dictionary-fork + * block, so XLogReadBufferForRedo restores the page directly and there is no + * delta to replay. We never reach BLK_NEEDS_REDO without the FPI present; + * the handler simply restores and releases the buffer. + */ +static void +flux_xlog_write_dict_redo(XLogReaderState *record) +{ + Buffer buffer; + + /* + * With REGBUF_FORCE_IMAGE the page is reconstructed from the FPI by + * XLogReadBufferForRedo, which returns BLK_RESTORED. BLK_NEEDS_REDO is + * not expected, but if it ever occurs there is nothing to apply. + */ + (void) XLogReadBufferForRedo(record, 0, &buffer); + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); +} + +/* + * flux_redo + * Thin dispatcher for all XLOG_FLUX_* opcodes. Each case is + * delegated to a dedicated per-opcode static helper above. + */ +void +flux_redo(XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + + switch (info) + { + case XLOG_FLUX_INSERT: + flux_xlog_insert_redo(record); + break; + + case XLOG_FLUX_UPDATE_INPLACE: + flux_xlog_update_inplace_redo(record); + break; + + case XLOG_FLUX_DELETE: + flux_xlog_delete_redo(record); + break; + + case XLOG_FLUX_DEFRAG: + flux_xlog_defrag_redo(record); + break; + + case XLOG_FLUX_OVERFLOW_WRITE: + flux_xlog_overflow_write_redo(record); + break; + + case XLOG_FLUX_COMPRESS: + flux_xlog_compress_redo(record); + break; + + case XLOG_FLUX_INIT_PAGE: + flux_xlog_init_page_redo(record); + break; + + case XLOG_FLUX_CROSS_PAGE_DEFRAG: + flux_xlog_cross_page_defrag_redo(record); + break; + + case XLOG_FLUX_VM_SET: + flux_xlog_vm_set_redo(record); + break; + + case XLOG_FLUX_VM_CLEAR: + flux_xlog_vm_clear_redo(record); + break; + + case XLOG_FLUX_LOCK: + flux_xlog_lock_redo(record); + break; + + case XLOG_FLUX_CAS_UPDATE: + flux_xlog_cas_update_redo(record); + break; + + case XLOG_FLUX_CAS_UPDATE_UNDO: + flux_xlog_cas_update_undo_redo(record); + break; + + case XLOG_FLUX_WRITE_DICT: + flux_xlog_write_dict_redo(record); + break; + + case XLOG_FLUX_MULTI_INSERT: + flux_xlog_multi_insert_redo(record); + break; + + default: + elog(PANIC, "flux_redo: unknown op code %u", info); + } +} + + +/* + * Mask function for FLUX pages (for consistency checking) + */ +void +flux_mask(char *page, BlockNumber blkno) +{ + Page flux_page = (Page) page; + FluxPageOpaque phdr; + bool is_overflow; + OffsetNumber offnum; + OffsetNumber maxoff; + + mask_page_lsn_and_checksum(flux_page); + + mask_page_hint_bits(flux_page); + mask_unused_space(flux_page); + + /* + * Dictionary-fork pages (XLOG_FLUX_WRITE_DICT full-page images) have no + * opaque special area and no line-pointer array; they are initialized with + * PageInit(page, BLCKSZ, 0). FluxPageGetOpaque would read 8 bytes past + * the page end and the line-pointer loop below would mis-mask dict payload, + * producing spurious "inconsistent pages" failures under + * wal_consistency_checking. Detect them by their zero special size and + * leave the (already LSN/checksum/hint-masked) page untouched. + */ + if (PageGetSpecialSize(flux_page) != MAXALIGN(sizeof(FluxPageOpaqueData))) + return; + + phdr = FluxPageGetOpaque(flux_page); + + /* Check page type before masking flags */ + is_overflow = (phdr->pd_commit_ts_and_flags & FLUX_PAGE_OVERFLOW) != 0; + + /* + * Mask the entire packed commit_ts_and_flags field. + * + * The timestamp uses Max(existing, new) during redo which can produce a + * different value if the page was concurrently modified. Heuristic flags + * (e.g., FLUX_PAGE_DEFRAG_NEEDED) may be set by redo but not by the + * original operation, or vice versa. + */ + phdr->pd_commit_ts_and_flags = 0; + + /* + * Overflow pages contain FluxOverflowRecordHeader items, not regular + * tuples. Their contents are fully determined by the WAL data, so no + * per-item masking is needed. + */ + if (is_overflow) + return; + + /* + * Mask tuple-level fields that function as hint bits and are not + * faithfully reproduced by WAL redo. The redo handlers only set the + * minimal fields needed for correctness (t_flags, t_commit_ts); + * transactional fields like infomask bits are set on the primary but not + * replayed. + */ + maxoff = PageGetMaxOffsetNumber(flux_page); + for (offnum = FirstOffsetNumber; offnum <= maxoff; offnum++) + { + ItemId itemid = PageGetItemId(flux_page, offnum); + FluxTupleHeader *tuple_hdr; + + if (!ItemIdIsNormal(itemid) || !ItemIdHasStorage(itemid)) + continue; + + tuple_hdr = (FluxTupleHeader *) PageGetItem(flux_page, itemid); + tuple_hdr->t_infomask = 0; + tuple_hdr->t_flags = 0; + tuple_hdr->t_commit_ts = 0; + tuple_hdr->t_writer = 0; /* transient CAS lock, not replayed */ + ItemPointerSetInvalid(&tuple_hdr->t_ctid); + } +} diff --git a/src/backend/access/flux/meson.build b/src/backend/access/flux/meson.build new file mode 100644 index 0000000000000..7f2cde188c524 --- /dev/null +++ b/src/backend/access/flux/meson.build @@ -0,0 +1,18 @@ +# Copyright (c) 2022-2025, PostgreSQL Global Development Group + +backend_sources += files( + 'flux_dirtymap.c', + 'flux_fsm.c', + 'flux_handler.c', + 'flux_operations.c', + 'flux_tuple.c', + 'flux_mvcc.c', + 'flux_stats.c', + 'flux_xlog.c', + 'flux_lock.c', + 'flux_slot.c', + 'flux_vm.c', + 'flux_undo.c', + 'flux_relundo.c', + 'flux_pvs.c', +) \ No newline at end of file diff --git a/src/backend/access/hash/Makefile b/src/backend/access/hash/Makefile index 75bf36598246b..590c06c0e9976 100644 --- a/src/backend/access/hash/Makefile +++ b/src/backend/access/hash/Makefile @@ -14,6 +14,7 @@ include $(top_builddir)/src/Makefile.global OBJS = \ hash.o \ + hash_undo.o \ hash_xlog.o \ hashfunc.o \ hashinsert.o \ diff --git a/src/backend/access/hash/hash_undo.c b/src/backend/access/hash/hash_undo.c new file mode 100644 index 0000000000000..3b23970f7f9d3 --- /dev/null +++ b/src/backend/access/hash/hash_undo.c @@ -0,0 +1,279 @@ +/*------------------------------------------------------------------------- + * + * hash_undo.c + * Hash index UNDO resource manager + * + * This module implements UNDO apply callbacks for the hash index AM. + * When a transaction aborts, provisionally inserted index entries are + * marked LP_DEAD so that VACUUM is not required to clean up after + * aborted transactions. + * + * Combined with heap UNDO and nbtree UNDO, hash UNDO provides a + * "zero-VACUUM" experience for aborted transactions: heap tuples and + * their index entries are cleaned up immediately during rollback. + * + * UNDO Subtypes: + * INSERT: Undo a hash index tuple insertion (mark entry LP_DEAD) + * + * All hooks are gated by RelationAmSupportsUndo(heapRel) -- hash UNDO + * is controlled by the parent table AM's am_supports_undo declaration. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/hash/hash_undo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/hash.h" +#include "access/relation.h" +#include "access/undobuffer.h" +#include "access/undo_xlog.h" +#include "access/undorecord.h" +#include "access/undormgr.h" +#include "access/xact.h" +#include "access/xloginsert.h" +#include "access/xlogutils.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/itemid.h" +#include "utils/rel.h" +#include "utils/relcache.h" + +/* + * Hash UNDO subtypes (stored in urec_info) + */ +#define HASH_UNDO_INSERT 0x0001 /* bucket/overflow page tuple insertion */ + +/* + * HashUndoInsert - Payload for hash insert undo + */ +typedef struct HashUndoInsert +{ + Oid index_oid; /* OID of the hash index relation */ + BlockNumber blkno; /* Page where tuple was inserted */ + OffsetNumber offset; /* Offset of the inserted tuple */ +} HashUndoInsert; + +#define SizeOfHashUndoInsert \ + (offsetof(HashUndoInsert, offset) + sizeof(OffsetNumber)) + +/* Forward declarations */ +static UndoApplyResult hash_undo_apply(uint8 rmid, uint16 info, + TransactionId xid, Oid reloid, + const char *payload, Size payload_len, + UndoRecPtr urec_ptr); +static void hash_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len); + +/* The hash UNDO RM registration entry */ +static const UndoRmgrData hash_undo_rmgr = { + .rm_name = "hash", + .rm_undo = hash_undo_apply, + .rm_desc = hash_undo_desc, +}; + +/* + * HashUndoRmgrInit - Register the hash UNDO resource manager + */ +void +HashUndoRmgrInit(void) +{ + RegisterUndoRmgr(UNDO_RMID_HASH, &hash_undo_rmgr); +} + +/* + * HashUndoLogInsert - Write UNDO record for a hash index tuple insertion + * + * Called from _hash_doinsert() after the insertion has been WAL-logged. + * This records enough information to mark the inserted entry LP_DEAD on abort. + */ +void +HashUndoLogInsert(Relation rel, Relation heapRel, Buffer buf, + OffsetNumber offset) +{ + TransactionId xid = GetCurrentTransactionId(); + HashUndoInsert hdr; + + hdr.index_oid = RelationGetRelid(rel); + hdr.blkno = BufferGetBlockNumber(buf); + hdr.offset = offset; + + /* + * When the heap has an active UNDO write buffer, piggyback on it to avoid + * a separate UndoLogAllocate + WAL insert + pwrite per index entry. + */ + if (UndoBufferIsActive(heapRel)) + { + UndoBufferAddRecordParts(heapRel, + UNDO_RMID_HASH, + HASH_UNDO_INSERT, + (const char *) &hdr, + SizeOfHashUndoInsert, + NULL, 0); + } + else + { + UndoRecordSet *uset; + + uset = UndoRecordSetCreate(xid, GetCurrentTransactionUndoRecPtr()); + UndoRecordAddPayloadParts(uset, + UNDO_RMID_HASH, + HASH_UNDO_INSERT, + RelationGetRelid(heapRel), + (const char *) &hdr, + SizeOfHashUndoInsert, + NULL, 0); + UndoRecordSetInsert(uset); + UndoRecordSetFree(uset); + } +} + +/* + * hash_undo_apply - Apply a single hash UNDO record + * + * This is the rm_undo callback for the hash RM. On abort, marks the + * inserted index entry as LP_DEAD. + */ +static UndoApplyResult +hash_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, + const char *payload, Size payload_len, UndoRecPtr urec_ptr) +{ + Assert(rmid == UNDO_RMID_HASH); + + /* + * During crash recovery, syscache may not be initialized when + * PerformUndoRecovery() runs. Defer UNDO application until after the + * system is fully initialized (background worker will handle it). + */ + if (InRecovery) + { + ereport(DEBUG2, + (errmsg("hash UNDO: deferring transaction %u to logical revert worker " + "(in crash recovery, syscache not available)", + xid))); + return UNDO_APPLY_SKIPPED; + } + + switch (info) + { + case HASH_UNDO_INSERT: + { + HashUndoInsert hdr; + Relation indexrel; + Buffer buffer; + Page page; + + if (payload_len < SizeOfHashUndoInsert) + return UNDO_APPLY_ERROR; + + memcpy(&hdr, payload, SizeOfHashUndoInsert); + + /* + * Open the index directly using the OID stored in the UNDO + * payload. + */ + indexrel = try_relation_open(hdr.index_oid, RowExclusiveLock); + if (indexrel == NULL) + { + ereport(DEBUG2, + (errmsg("hash UNDO INSERT: index %u no longer exists", + hdr.index_oid))); + return UNDO_APPLY_SKIPPED; + } + + if (RelationGetNumberOfBlocks(indexrel) <= hdr.blkno) + { + ereport(DEBUG2, + (errmsg("hash UNDO INSERT: block %u beyond end of index %u", + hdr.blkno, hdr.index_oid))); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SKIPPED; + } + + buffer = ReadBuffer(indexrel, hdr.blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + if (hdr.offset <= PageGetMaxOffsetNumber(page)) + { + ItemId lp = PageGetItemId(page, hdr.offset); + + START_CRIT_SECTION(); + + if (ItemIdIsNormal(lp)) + ItemIdMarkDead(lp); + + MarkBufferDirty(buffer); + + /* Generate physiological CLR for crash recovery */ + if (RelationNeedsWAL(indexrel)) + { + XLogRecPtr clr_lsn; + xl_undo_apply xlrec; + + xlrec.urec_ptr = urec_ptr; + xlrec.xid = xid; + xlrec.target_locator = indexrel->rd_locator; + xlrec.target_block = hdr.blkno; + xlrec.target_offset = hdr.offset; + xlrec.operation_type = info; + xlrec.clr_flags = UNDO_CLR_LP_DEAD; + xlrec.tuple_len = 0; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, + SizeOfUndoApply); + XLogRegisterBuffer(0, buffer, + REGBUF_STANDARD); + clr_lsn = XLogInsert(RM_UNDO_ID, + XLOG_UNDO_APPLY_RECORD); + PageSetLSN(page, clr_lsn); + } + + END_CRIT_SECTION(); + } + + UnlockReleaseBuffer(buffer); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; + } + + default: + return UNDO_APPLY_SKIPPED; + } +} + +/* + * hash_undo_desc - Describe a hash UNDO record for debugging + */ +static void +hash_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len) +{ + const char *opname; + + switch (info) + { + case HASH_UNDO_INSERT: + opname = "INSERT"; + break; + default: + opname = "UNKNOWN"; + break; + } + + appendStringInfo(buf, "hash %s", opname); + + if (payload_len >= sizeof(Oid) && info == HASH_UNDO_INSERT) + { + Oid index_oid; + + memcpy(&index_oid, payload, sizeof(Oid)); + appendStringInfo(buf, " index %u", index_oid); + } +} diff --git a/src/backend/access/hash/hashinsert.c b/src/backend/access/hash/hashinsert.c index 3395bbc13f825..4704e6a323de6 100644 --- a/src/backend/access/hash/hashinsert.c +++ b/src/backend/access/hash/hashinsert.c @@ -17,6 +17,8 @@ #include "access/hash.h" #include "access/hash_xlog.h" +#include "access/tableam.h" +#include "access/undobuffer.h" #include "access/xloginsert.h" #include "miscadmin.h" #include "storage/predicate.h" @@ -238,6 +240,14 @@ _hash_doinsert(Relation rel, IndexTuple itup, Relation heapRel, bool sorted) END_CRIT_SECTION(); + /* + * Write UNDO record for the insertion if the parent table AM supports + * UNDO. This must happen after WAL logging but while we still hold the + * buffer pin (needed for BufferGetBlockNumber). + */ + if (RelationAmSupportsUndo(heapRel) && UndoBufferIsActive(heapRel)) + HashUndoLogInsert(rel, heapRel, buf, itup_off); + /* drop lock on metapage, but keep pin */ LockBuffer(metabuf, BUFFER_LOCK_UNLOCK); diff --git a/src/backend/access/hash/meson.build b/src/backend/access/hash/meson.build index ad011b8f99ab6..ca2012be87bf7 100644 --- a/src/backend/access/hash/meson.build +++ b/src/backend/access/hash/meson.build @@ -2,6 +2,7 @@ backend_sources += files( 'hash.c', + 'hash_undo.c', 'hash_xlog.c', 'hashfunc.c', 'hashinsert.c', diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c index 8b488cfd8f680..7872f0e4f054d 100644 --- a/src/backend/access/heap/heapam.c +++ b/src/backend/access/heap/heapam.c @@ -39,6 +39,8 @@ #include "access/syncscan.h" #include "access/valid.h" #include "access/visibilitymap.h" +#include "access/xact.h" +#include "access/xlog.h" #include "access/xloginsert.h" #include "catalog/pg_database.h" #include "catalog/pg_database_d.h" @@ -53,10 +55,11 @@ #include "utils/datum.h" #include "utils/injection_point.h" #include "utils/inval.h" +#include "utils/memutils.h" +#include "utils/relcache.h" #include "utils/spccache.h" #include "utils/syscache.h" - static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup, TransactionId xid, CommandId cid, uint32 options); static XLogRecPtr log_heap_update(Relation reln, Buffer oldbuf, @@ -113,7 +116,6 @@ static XLogRecPtr log_heap_new_cid(Relation relation, HeapTuple tup); static HeapTuple ExtractReplicaIdentity(Relation relation, HeapTuple tp, bool key_required, bool *copy); - /* * This table lists the heavyweight lock mode that corresponds to each tuple * lock mode, as well as one or two corresponding MultiXactStatus values: @@ -2149,6 +2151,7 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid, } XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfHeapInsert); xlhdr.t_infomask2 = heaptup->t_data->t_infomask2; @@ -3119,6 +3122,7 @@ heap_delete(Relation relation, const ItemPointerData *tid, xlrec.flags |= XLH_DELETE_NO_LOGICAL; XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfHeapDelete); XLogRegisterBuffer(HEAP_DELETE_BLKREF_HEAP, buffer, REGBUF_STANDARD); diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c index bf87430cf0176..77e7b626f2016 100644 --- a/src/backend/access/heap/heapam_handler.c +++ b/src/backend/access/heap/heapam_handler.c @@ -141,6 +141,34 @@ heapam_tuple_satisfies_snapshot(Relation rel, TupleTableSlot *slot, } +/* ---------------------------------------------------------------------------- + * Bulk DML hint callbacks for heap AM. + * ---------------------------------------------------------------------------- + */ + +/* + * heapam_begin_bulk_insert - Signal the start of a DML operation. + * + * If the relation has UNDO enabled, this activates the UNDO write buffer + * to batch UNDO records and reduce per-row overhead. Always called for + * UNDO-enabled tables regardless of estimated row count. + */ +static void +heapam_begin_bulk_insert(Relation rel, uint32 options, int64 nrows) +{ +} + +/* + * heapam_finish_bulk_insert - Complete a DML operation. + * + * Flushes any pending UNDO records and deactivates the write buffer. + */ +static void +heapam_finish_bulk_insert(Relation rel, uint32 options) +{ +} + + /* ---------------------------------------------------------------------------- * Functions for manipulations of physical tuples for heap AM. * ---------------------------------------------------------------------------- @@ -2651,6 +2679,8 @@ BitmapHeapScanNextBlock(TableScanDesc scan, return true; } +/* heapam does not use UNDO; an in-place-update table AM will set am_supports_undo = true */ + /* ------------------------------------------------------------------------ * Definition of the heap table access method. * ------------------------------------------------------------------------ @@ -2658,6 +2688,7 @@ BitmapHeapScanNextBlock(TableScanDesc scan, static const TableAmRoutine heapam_methods = { .type = T_TableAmRoutine, + .am_supports_undo = false, .slot_callbacks = heapam_slot_callbacks, @@ -2686,6 +2717,9 @@ static const TableAmRoutine heapam_methods = { .tuple_update = heapam_tuple_update, .tuple_lock = heapam_tuple_lock, + .begin_bulk_insert = heapam_begin_bulk_insert, + .finish_bulk_insert = heapam_finish_bulk_insert, + .tuple_fetch_row_version = heapam_fetch_row_version, .tuple_get_latest_tid = heap_get_latest_tid, .tuple_tid_valid = heapam_tuple_tid_valid, diff --git a/src/backend/access/heap/pruneheap.c b/src/backend/access/heap/pruneheap.c index b146119d7aff3..3ab6edfe85f80 100644 --- a/src/backend/access/heap/pruneheap.c +++ b/src/backend/access/heap/pruneheap.c @@ -18,8 +18,11 @@ #include "access/heapam_xlog.h" #include "access/htup_details.h" #include "access/multixact.h" +#include "access/parallel.h" #include "access/transam.h" #include "access/visibilitymap.h" +#include "access/visibilitymapdefs.h" +#include "access/xact.h" #include "access/xlog.h" #include "access/xloginsert.h" #include "commands/vacuum.h" diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c index 39395aed0d592..843cc08949f49 100644 --- a/src/backend/access/heap/vacuumlazy.c +++ b/src/backend/access/heap/vacuumlazy.c @@ -153,6 +153,7 @@ #include "storage/lmgr.h" #include "storage/read_stream.h" #include "utils/injection_point.h" +#include "utils/rel.h" #include "utils/lsyscache.h" #include "utils/pg_rusage.h" #include "utils/timestamp.h" diff --git a/src/backend/access/meson.build b/src/backend/access/meson.build index 5fd18de74f92b..ad3c1084395e3 100644 --- a/src/backend/access/meson.build +++ b/src/backend/access/meson.build @@ -7,6 +7,7 @@ subdir('gist') subdir('hash') subdir('heap') subdir('index') +subdir('flux') subdir('nbtree') subdir('rmgrdesc') subdir('sequence') @@ -14,3 +15,4 @@ subdir('spgist') subdir('table') subdir('tablesample') subdir('transam') +subdir('undo') diff --git a/src/backend/access/nbtree/Makefile b/src/backend/access/nbtree/Makefile index 0daf640af96c7..492bcf578c112 100644 --- a/src/backend/access/nbtree/Makefile +++ b/src/backend/access/nbtree/Makefile @@ -20,6 +20,7 @@ OBJS = \ nbtpreprocesskeys.o \ nbtreadpage.o \ nbtree.o \ + nbtree_undo.o \ nbtsearch.o \ nbtsort.o \ nbtsplitloc.o \ diff --git a/src/backend/access/nbtree/meson.build b/src/backend/access/nbtree/meson.build index 812f067e7101c..f526ef6531729 100644 --- a/src/backend/access/nbtree/meson.build +++ b/src/backend/access/nbtree/meson.build @@ -8,6 +8,7 @@ backend_sources += files( 'nbtpreprocesskeys.c', 'nbtreadpage.c', 'nbtree.c', + 'nbtree_undo.c', 'nbtsearch.c', 'nbtsort.c', 'nbtsplitloc.c', diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c index c8af97dd23dfb..8052ae2cd2aa1 100644 --- a/src/backend/access/nbtree/nbtinsert.c +++ b/src/backend/access/nbtree/nbtinsert.c @@ -15,6 +15,7 @@ #include "postgres.h" +#include "access/heapam.h" #include "access/nbtree.h" #include "access/nbtxlog.h" #include "access/tableam.h" @@ -1420,6 +1421,21 @@ _bt_insertonpg(Relation rel, END_CRIT_SECTION(); + /* + * Write nbtree UNDO record for the insertion. This is done after the + * critical section (UNDO insertion involves I/O) but while we still + * hold the buffer lock. The UNDO record enables cleanup of this + * index entry if the transaction aborts. + * + * Only write UNDO if the parent table AM supports UNDO. The heaprel + * parameter is NULL during index builds and recovery. + */ + if (heaprel != NULL && RelationAmSupportsUndo(heaprel)) + { + NbtreeUndoLogInsert(rel, heaprel, buf, itup, + itemsz, newitemoff, isleaf); + } + /* Release subsidiary buffers */ if (BufferIsValid(metabuf)) _bt_relbuf(rel, metabuf); diff --git a/src/backend/access/nbtree/nbtree_undo.c b/src/backend/access/nbtree/nbtree_undo.c new file mode 100644 index 0000000000000..adc546f52c34a --- /dev/null +++ b/src/backend/access/nbtree/nbtree_undo.c @@ -0,0 +1,591 @@ +/*------------------------------------------------------------------------- + * + * nbtree_undo.c + * nbtree UNDO resource manager + * + * This module implements UNDO apply callbacks for the B-tree index AM. + * When a transaction aborts, provisionally inserted index entries are + * removed (or marked LP_DEAD) so that VACUUM is not required to clean + * up after aborted transactions. + * + * Combined with heap UNDO, nbtree UNDO provides a "zero-VACUUM" + * experience for aborted transactions: both heap tuples and their + * index entries are cleaned up immediately during rollback. + * + * UNDO Subtypes: + * INSERT_LEAF: Undo a leaf-page index tuple insertion + * INSERT_UPPER: Undo an internal-page downlink insertion + * INSERT_POST: Undo a posting list split + * DEDUP: Undo a deduplication pass (restore pre-dedup page) + * DELETE: Undo an ad-hoc deletion (re-insert deleted tuples) + * + * Structural operations (SPLIT, NEWROOT) and VACUUM operations are + * logged for completeness but their undo-apply is handled by falling + * back to per-entry LP_DEAD marking rather than reversing the + * structural change, since concurrent readers may have already + * observed the new structure. + * + * All hooks are guarded by RelationAmSupportsUndo(heaprel) -- nbtree + * UNDO is controlled by the parent table AM's am_supports_undo declaration. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/nbtree/nbtree_undo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/relation.h" +#include "access/undobuffer.h" +#include "access/xact.h" +#include "access/nbtree.h" +#include "access/undo_xlog.h" +#include "access/undorecord.h" +#include "access/undormgr.h" +#include "access/xloginsert.h" +#include "access/xlogutils.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/itemid.h" +#include "utils/rel.h" +#include "utils/relcache.h" + +/* + * nbtree UNDO subtypes (stored in urec_info) + * + * These correspond to the WAL-logged nbtree operations. + */ +#define NBTREE_UNDO_INSERT_LEAF 0x0001 /* leaf tuple insertion */ +#define NBTREE_UNDO_INSERT_UPPER 0x0002 /* internal downlink insertion */ +#define NBTREE_UNDO_INSERT_POST 0x0004 /* posting list split */ +#define NBTREE_UNDO_DELETE 0x0005 /* ad-hoc tuple deletion */ +#define NBTREE_UNDO_SPLIT_L 0x0006 /* page split (new item on left) */ +#define NBTREE_UNDO_SPLIT_R 0x0007 /* page split (new item on right) */ +#define NBTREE_UNDO_NEWROOT 0x0008 /* new root creation */ +#define NBTREE_UNDO_DEDUP 0x0009 /* deduplication pass */ +#define NBTREE_UNDO_VACUUM 0x000A /* vacuum deletion (no-op undo) */ + +/* + * NbtreeUndoInsertLeaf - Payload for leaf insert undo + * + * index_oid allows direct index open during rollback, eliminating + * the O(N_indexes) scan through RelationGetIndexList(). + */ +typedef struct NbtreeUndoInsertLeaf +{ + Oid index_oid; /* OID of the index relation */ + BlockNumber blkno; /* Page where tuple was inserted */ + OffsetNumber offset; /* Offset of the inserted tuple */ + Size itup_sz; /* Size of the index tuple */ + /* Followed by the IndexTupleData */ +} NbtreeUndoInsertLeaf; + +#define SizeOfNbtreeUndoInsertLeaf offsetof(NbtreeUndoInsertLeaf, itup_sz) + sizeof(Size) + +/* + * NbtreeUndoInsertUpper - Payload for internal insert undo + */ +typedef struct NbtreeUndoInsertUpper +{ + Oid index_oid; /* OID of the index relation */ + BlockNumber blkno; /* Internal page */ + OffsetNumber offset; /* Offset of downlink */ + BlockNumber child_blkno; /* Child page whose downlink was added */ + Size itup_sz; /* Size of the downlink tuple */ + /* Followed by the IndexTupleData */ +} NbtreeUndoInsertUpper; + +#define SizeOfNbtreeUndoInsertUpper offsetof(NbtreeUndoInsertUpper, itup_sz) + sizeof(Size) + +/* + * NbtreeUndoDedup - Payload for dedup undo (full pre-dedup page image) + */ +typedef struct NbtreeUndoDedup +{ + Oid index_oid; /* OID of the index relation */ + BlockNumber blkno; /* Page that was deduplicated */ + uint16 page_len; /* Length of saved page image */ + /* Followed by the full page image (pre-dedup) */ +} NbtreeUndoDedup; + +#define SizeOfNbtreeUndoDedup offsetof(NbtreeUndoDedup, page_len) + sizeof(uint16) + +/* + * NbtreeUndoDelete - Payload for ad-hoc delete undo + */ +typedef struct NbtreeUndoDelete +{ + Oid index_oid; /* OID of the index relation */ + BlockNumber blkno; /* Page from which tuples were deleted */ + uint16 ndeleted; /* Number of deleted tuples */ + /* Followed by array of (OffsetNumber, IndexTupleData) pairs */ +} NbtreeUndoDelete; + +#define SizeOfNbtreeUndoDelete offsetof(NbtreeUndoDelete, ndeleted) + sizeof(uint16) + +/* Forward declarations */ +static UndoApplyResult nbtree_undo_apply(uint8 rmid, uint16 info, + TransactionId xid, Oid reloid, + const char *payload, Size payload_len, + UndoRecPtr urec_ptr); +static void nbtree_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len); + +/* The nbtree UNDO RM registration entry */ +static const UndoRmgrData nbtree_undo_rmgr = { + .rm_name = "nbtree", + .rm_undo = nbtree_undo_apply, + .rm_desc = nbtree_undo_desc, +}; + +/* + * NbtreeUndoRmgrInit - Register the nbtree UNDO resource manager + */ +void +NbtreeUndoRmgrInit(void) +{ + RegisterUndoRmgr(UNDO_RMID_NBTREE, &nbtree_undo_rmgr); +} + +/* + * NbtreeUndoLogInsert - Write UNDO record for a leaf index tuple insertion + * + * Called from _bt_insertonpg() after the insertion has been WAL-logged. + * This records enough information to remove the inserted entry on abort. + */ +void +NbtreeUndoLogInsert(Relation rel, Relation heaprel, Buffer buf, + IndexTuple itup, Size itemsz, OffsetNumber offset, + bool isleaf) +{ + TransactionId xid = GetCurrentTransactionId(); + + if (isleaf) + { + NbtreeUndoInsertLeaf hdr; + + hdr.index_oid = RelationGetRelid(rel); + hdr.blkno = BufferGetBlockNumber(buf); + hdr.offset = offset; + hdr.itup_sz = itemsz; + + /* + * When the heap has an active UNDO write buffer, piggyback on it to + * avoid a separate UndoLogAllocate + WAL insert + pwrite per index + * entry. The UndoRecordSet accepts mixed RM IDs. + */ + if (UndoBufferIsActive(heaprel)) + { + UndoBufferAddRecordParts(heaprel, + UNDO_RMID_NBTREE, + NBTREE_UNDO_INSERT_LEAF, + (const char *) &hdr, + SizeOfNbtreeUndoInsertLeaf, + (const char *) itup, + itemsz); + } + else + { + UndoRecordSet *uset; + + uset = UndoRecordSetCreate(xid, GetCurrentTransactionUndoRecPtr()); + UndoRecordAddPayloadParts(uset, + UNDO_RMID_NBTREE, + NBTREE_UNDO_INSERT_LEAF, + RelationGetRelid(heaprel), + (const char *) &hdr, + SizeOfNbtreeUndoInsertLeaf, + (const char *) itup, + itemsz); + UndoRecordSetInsert(uset); + UndoRecordSetFree(uset); + } + } + else + { + NbtreeUndoInsertUpper upper_hdr; + + upper_hdr.index_oid = RelationGetRelid(rel); + upper_hdr.blkno = BufferGetBlockNumber(buf); + upper_hdr.offset = offset; + upper_hdr.child_blkno = BTreeTupleGetDownLink(itup); + upper_hdr.itup_sz = itemsz; + + if (UndoBufferIsActive(heaprel)) + { + UndoBufferAddRecordParts(heaprel, + UNDO_RMID_NBTREE, + NBTREE_UNDO_INSERT_UPPER, + (const char *) &upper_hdr, + SizeOfNbtreeUndoInsertUpper, + (const char *) itup, + itemsz); + } + else + { + UndoRecordSet *uset; + + uset = UndoRecordSetCreate(xid, GetCurrentTransactionUndoRecPtr()); + UndoRecordAddPayloadParts(uset, + UNDO_RMID_NBTREE, + NBTREE_UNDO_INSERT_UPPER, + RelationGetRelid(heaprel), + (const char *) &upper_hdr, + SizeOfNbtreeUndoInsertUpper, + (const char *) itup, + itemsz); + UndoRecordSetInsert(uset); + UndoRecordSetFree(uset); + } + } +} + +/* + * NbtreeUndoLogDedup - Write UNDO record before deduplication + * + * Called from _bt_dedup_pass() before the page is modified. + * Saves a full page image so dedup can be reversed on abort. + */ +void +NbtreeUndoLogDedup(Relation rel, Relation heaprel, Buffer buf) +{ + NbtreeUndoDedup hdr; + Page page = BufferGetPage(buf); + Size page_size = PageGetPageSize(page); + Size payload_size; + char *payload; + UndoRecordSet *uset; + TransactionId xid = GetCurrentTransactionId(); + + payload_size = SizeOfNbtreeUndoDedup + page_size; + payload = (char *) palloc(payload_size); + + hdr.index_oid = RelationGetRelid(rel); + hdr.blkno = BufferGetBlockNumber(buf); + hdr.page_len = (uint16) page_size; + memcpy(payload, &hdr, SizeOfNbtreeUndoDedup); + memcpy(payload + SizeOfNbtreeUndoDedup, page, page_size); + + uset = UndoRecordSetCreate(xid, GetCurrentTransactionUndoRecPtr()); + UndoRecordAddPayload(uset, UNDO_RMID_NBTREE, NBTREE_UNDO_DEDUP, + RelationGetRelid(heaprel), payload, payload_size); + UndoRecordSetInsert(uset); + UndoRecordSetFree(uset); + pfree(payload); +} + +/* + * nbtree_undo_apply - Apply a single nbtree UNDO record + * + * This is the rm_undo callback for the nbtree RM. + */ +static UndoApplyResult +nbtree_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, + const char *payload, Size payload_len, UndoRecPtr urec_ptr) +{ + Assert(rmid == UNDO_RMID_NBTREE); + + /* + * During crash recovery, syscache may not be initialized yet when + * PerformUndoRecovery() runs. try_relation_open() requires syscache to + * check if the relation exists, so we must defer UNDO application until + * after the system is fully initialized. + * + * Check if we're in recovery mode (InRecovery flag is still set). During + * crash recovery, UNDO phase runs before syscache is initialized, so we + * skip UNDO application and rely on the logical revert worker to handle + * it asynchronously after startup completes. + * + * This transaction will be tracked in the ATM (Aborted Transaction Map) + * so the background worker can pick it up later. + * + * Note: InRecovery is only true during startup/recovery; it's false + * during normal operation and during normal transaction abort, so this + * check only affects crash recovery. + */ + if (InRecovery) + { + ereport(DEBUG2, + (errmsg("nbtree UNDO: deferring transaction %u to logical revert worker " + "(in crash recovery, syscache not available)", + xid))); + return UNDO_APPLY_SKIPPED; + } + + switch (info) + { + case NBTREE_UNDO_INSERT_LEAF: + { + NbtreeUndoInsertLeaf hdr; + Relation indexrel; + Buffer buffer; + Page page; + BTPageOpaque opaque; + + if (payload_len < SizeOfNbtreeUndoInsertLeaf) + return UNDO_APPLY_ERROR; + + memcpy(&hdr, payload, SizeOfNbtreeUndoInsertLeaf); + + /* + * Open the index directly using the OID stored in the UNDO + * payload. This avoids the O(N_indexes) scan through + * RelationGetIndexList(). + */ + indexrel = try_relation_open(hdr.index_oid, RowExclusiveLock); + if (indexrel == NULL) + { + ereport(DEBUG2, + (errmsg("nbtree UNDO INSERT_LEAF: index %u no longer exists", + hdr.index_oid))); + return UNDO_APPLY_SKIPPED; + } + + if (RelationGetNumberOfBlocks(indexrel) <= hdr.blkno) + { + ereport(DEBUG2, + (errmsg("nbtree UNDO INSERT_LEAF: block %u beyond end of index %u", + hdr.blkno, hdr.index_oid))); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SKIPPED; + } + + buffer = ReadBuffer(indexrel, hdr.blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + opaque = BTPageGetOpaque(page); + + if (P_ISLEAF(opaque) && + hdr.offset <= PageGetMaxOffsetNumber(page)) + { + ItemId lp = PageGetItemId(page, hdr.offset); + + START_CRIT_SECTION(); + + if (ItemIdIsNormal(lp)) + ItemIdMarkDead(lp); + + MarkBufferDirty(buffer); + + /* Generate physiological CLR */ + if (RelationNeedsWAL(indexrel)) + { + XLogRecPtr clr_lsn; + xl_undo_apply xlrec; + + xlrec.urec_ptr = urec_ptr; + xlrec.xid = xid; + xlrec.target_locator = indexrel->rd_locator; + xlrec.target_block = hdr.blkno; + xlrec.target_offset = hdr.offset; + xlrec.operation_type = info; + xlrec.clr_flags = UNDO_CLR_LP_DEAD; + xlrec.tuple_len = 0; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, + SizeOfUndoApply); + XLogRegisterBuffer(0, buffer, + REGBUF_STANDARD); + clr_lsn = XLogInsert(RM_UNDO_ID, + XLOG_UNDO_APPLY_RECORD); + PageSetLSN(page, clr_lsn); + } + + END_CRIT_SECTION(); + } + + UnlockReleaseBuffer(buffer); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; + } + + case NBTREE_UNDO_INSERT_UPPER: + { + /* + * Undoing internal page insertions is complex and risky. The + * downlink is needed for tree navigation. Instead of removing + * it, we leave it in place. The child page (from a split that + * was part of the aborted transaction) will have its entries + * marked LP_DEAD by the leaf undo, and eventually the page + * will be recycled by VACUUM. + */ + return UNDO_APPLY_SKIPPED; + } + + case NBTREE_UNDO_DEDUP: + { + NbtreeUndoDedup hdr; + Relation indexrel; + Buffer buffer; + Page page; + + if (payload_len < SizeOfNbtreeUndoDedup) + return UNDO_APPLY_ERROR; + + memcpy(&hdr, payload, SizeOfNbtreeUndoDedup); + + /* + * Open the index directly using the OID stored in the UNDO + * payload. + */ + indexrel = try_relation_open(hdr.index_oid, RowExclusiveLock); + if (indexrel == NULL) + { + ereport(DEBUG2, + (errmsg("nbtree UNDO DEDUP: index %u no longer exists", + hdr.index_oid))); + return UNDO_APPLY_SKIPPED; + } + + if (RelationGetNumberOfBlocks(indexrel) <= hdr.blkno) + { + ereport(DEBUG2, + (errmsg("nbtree UNDO DEDUP: block %u beyond end of index %u", + hdr.blkno, hdr.index_oid))); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SKIPPED; + } + + buffer = ReadBuffer(indexrel, hdr.blkno); + LockBuffer(buffer, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buffer); + + START_CRIT_SECTION(); + + /* Restore the full pre-dedup page image */ + memcpy(page, + payload + SizeOfNbtreeUndoDedup, + hdr.page_len); + + MarkBufferDirty(buffer); + + if (RelationNeedsWAL(indexrel)) + { + XLogRecPtr clr_lsn; + xl_undo_apply xlrec; + + xlrec.urec_ptr = urec_ptr; + xlrec.xid = xid; + xlrec.target_locator = indexrel->rd_locator; + xlrec.target_block = hdr.blkno; + xlrec.target_offset = 0; + xlrec.operation_type = info; + xlrec.clr_flags = UNDO_CLR_FULL_PAGE; + xlrec.tuple_len = 0; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, + SizeOfUndoApply); + XLogRegisterBuffer(0, buffer, + REGBUF_FORCE_IMAGE | + REGBUF_STANDARD); + clr_lsn = XLogInsert(RM_UNDO_ID, + XLOG_UNDO_APPLY_RECORD); + PageSetLSN(page, clr_lsn); + } + + END_CRIT_SECTION(); + + UnlockReleaseBuffer(buffer); + relation_close(indexrel, RowExclusiveLock); + return UNDO_APPLY_SUCCESS; + } + + case NBTREE_UNDO_INSERT_POST: + case NBTREE_UNDO_SPLIT_L: + case NBTREE_UNDO_SPLIT_R: + case NBTREE_UNDO_NEWROOT: + + /* + * Structural operations: attempting to reverse a split is too + * dangerous due to concurrent readers. The individual leaf + * entries from the aborted transaction will be cleaned up by + * their own INSERT_LEAF undo records. Structural artifacts + * (empty pages from splits) will be recycled by VACUUM. + */ + return UNDO_APPLY_SKIPPED; + + case NBTREE_UNDO_DELETE: + + /* + * Ad-hoc deletion undo: re-insert the deleted tuples. This is + * complex since we need to find the correct insertion point. For + * now, skip and let the entries be re-created by the reverted + * heap operation. + */ + return UNDO_APPLY_SKIPPED; + + case NBTREE_UNDO_VACUUM: + /* VACUUM runs in its own transaction -- undo is always no-op */ + return UNDO_APPLY_SKIPPED; + + default: + ereport(WARNING, + (errmsg("nbtree UNDO: unknown subtype %u", info))); + return UNDO_APPLY_ERROR; + } +} + +/* + * nbtree_undo_desc - Describe an nbtree UNDO record for debugging + */ +static void +nbtree_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len) +{ + const char *opname; + + switch (info) + { + case NBTREE_UNDO_INSERT_LEAF: + opname = "INSERT_LEAF"; + break; + case NBTREE_UNDO_INSERT_UPPER: + opname = "INSERT_UPPER"; + break; + case NBTREE_UNDO_INSERT_POST: + opname = "INSERT_POST"; + break; + case NBTREE_UNDO_DELETE: + opname = "DELETE"; + break; + case NBTREE_UNDO_SPLIT_L: + opname = "SPLIT_L"; + break; + case NBTREE_UNDO_SPLIT_R: + opname = "SPLIT_R"; + break; + case NBTREE_UNDO_NEWROOT: + opname = "NEWROOT"; + break; + case NBTREE_UNDO_DEDUP: + opname = "DEDUP"; + break; + case NBTREE_UNDO_VACUUM: + opname = "VACUUM"; + break; + default: + opname = "UNKNOWN"; + break; + } + + appendStringInfo(buf, "nbtree %s", opname); + + /* For types that have index_oid at the start of the payload, show it */ + if (payload_len >= sizeof(Oid) && + (info == NBTREE_UNDO_INSERT_LEAF || + info == NBTREE_UNDO_INSERT_UPPER || + info == NBTREE_UNDO_DEDUP || + info == NBTREE_UNDO_DELETE)) + { + Oid index_oid; + + memcpy(&index_oid, payload, sizeof(Oid)); + appendStringInfo(buf, " index %u", index_oid); + } +} diff --git a/src/backend/access/rmgrdesc/Makefile b/src/backend/access/rmgrdesc/Makefile index cd95eec37f148..22d78c74061ab 100644 --- a/src/backend/access/rmgrdesc/Makefile +++ b/src/backend/access/rmgrdesc/Makefile @@ -9,10 +9,12 @@ top_builddir = ../../../.. include $(top_builddir)/src/Makefile.global OBJS = \ + atmdesc.o \ brindesc.o \ clogdesc.o \ committsdesc.o \ dbasedesc.o \ + fileopsdesc.o \ genericdesc.o \ gindesc.o \ gistdesc.o \ @@ -22,6 +24,7 @@ OBJS = \ mxactdesc.o \ nbtdesc.o \ relmapdesc.o \ + relundodesc.o \ replorigindesc.o \ rmgrdesc_utils.o \ seqdesc.o \ @@ -29,7 +32,14 @@ OBJS = \ spgdesc.o \ standbydesc.o \ tblspcdesc.o \ + undodesc.o \ xactdesc.o \ xlogdesc.o +endif + +ifdef USE_FLUX +OBJS += fluxdesc.o +endif + include $(top_srcdir)/src/backend/common.mk diff --git a/src/backend/access/rmgrdesc/atmdesc.c b/src/backend/access/rmgrdesc/atmdesc.c new file mode 100644 index 0000000000000..2864dfb7d2063 --- /dev/null +++ b/src/backend/access/rmgrdesc/atmdesc.c @@ -0,0 +1,64 @@ +/*------------------------------------------------------------------------- + * + * atmdesc.c + * rmgr descriptor routines for access/undo/atm.c + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/rmgrdesc/atmdesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm_xlog.h" + +void +atm_desc(StringInfo buf, XLogReaderState *record) +{ + char *data = XLogRecGetData(record); + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_ATM_ABORT: + { + xl_atm_abort *xlrec = (xl_atm_abort *) data; + + appendStringInfo(buf, + "xid %u, last_batch_lsn %X/%X, dboid %u", + xlrec->xid, + LSN_FORMAT_ARGS(xlrec->last_batch_lsn), + xlrec->dboid); + } + break; + + case XLOG_ATM_FORGET: + { + xl_atm_forget *xlrec = (xl_atm_forget *) data; + + appendStringInfo(buf, "xid %u", xlrec->xid); + } + break; + } +} + +const char * +atm_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & ~XLR_INFO_MASK) + { + case XLOG_ATM_ABORT: + id = "ABORT"; + break; + case XLOG_ATM_FORGET: + id = "FORGET"; + break; + } + + return id; +} diff --git a/src/backend/access/rmgrdesc/fileopsdesc.c b/src/backend/access/rmgrdesc/fileopsdesc.c new file mode 100644 index 0000000000000..d13891729ee1a --- /dev/null +++ b/src/backend/access/rmgrdesc/fileopsdesc.c @@ -0,0 +1,219 @@ +/*------------------------------------------------------------------------- + * + * fileopsdesc.c + * rmgr descriptor routines for storage/file/fileops.c + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/rmgrdesc/fileopsdesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "storage/fileops.h" + +void +fileops_desc(StringInfo buf, XLogReaderState *record) +{ + char *data = XLogRecGetData(record); + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_FILEOPS_CREATE: + { + xl_fileops_create *xlrec = (xl_fileops_create *) data; + const char *path = data + SizeOfFileOpsCreate; + + appendStringInfo(buf, "create \"%s\" flags 0x%x mode 0%o", + path, xlrec->flags, xlrec->mode); + } + break; + + case XLOG_FILEOPS_WRITE: + { + xl_fileops_write *xlrec = (xl_fileops_write *) data; + const char *path = data + SizeOfFileOpsWrite; + + appendStringInfo(buf, "write \"%s\" offset %lld len %u", + path, (long long) xlrec->offset, xlrec->len); + } + break; + + case XLOG_FILEOPS_RENAME: + { + xl_fileops_rename *xlrec = (xl_fileops_rename *) data; + const char *oldpath = data + SizeOfFileOpsRename; + const char *newpath = oldpath + xlrec->oldpath_len; + + appendStringInfo(buf, "rename \"%s\" to \"%s\"", + oldpath, newpath); + } + break; + + case XLOG_FILEOPS_DELETE: + { + xl_fileops_delete *xlrec = (xl_fileops_delete *) data; + const char *path = data + SizeOfFileOpsDelete; + + appendStringInfo(buf, "delete \"%s\" at_%s", + path, + xlrec->at_commit ? "commit" : "abort"); + } + break; + + case XLOG_FILEOPS_SYMLINK: + { + xl_fileops_symlink *xlrec = (xl_fileops_symlink *) data; + const char *target = data + SizeOfFileOpsSymlink; + const char *linkpath = target + xlrec->target_len; + + appendStringInfo(buf, "symlink \"%s\" -> \"%s\"", + linkpath, target); + } + break; + + case XLOG_FILEOPS_LINK: + { + xl_fileops_link *xlrec = (xl_fileops_link *) data; + const char *oldpath = data + SizeOfFileOpsLink; + const char *newpath = oldpath + xlrec->oldpath_len; + + appendStringInfo(buf, "link \"%s\" -> \"%s\"", + newpath, oldpath); + } + break; + + case XLOG_FILEOPS_MKDIR: + { + xl_fileops_mkdir *xlrec = (xl_fileops_mkdir *) data; + const char *path = data + SizeOfFileOpsMkdir; + + appendStringInfo(buf, "mkdir \"%s\" mode 0%o", + path, (unsigned int) xlrec->mode); + } + break; + + case XLOG_FILEOPS_RMDIR: + { + xl_fileops_rmdir *xlrec = (xl_fileops_rmdir *) data; + const char *path = data + SizeOfFileOpsRmdir; + + appendStringInfo(buf, "rmdir \"%s\" at_%s", + path, + xlrec->at_commit ? "commit" : "abort"); + } + break; + + case XLOG_FILEOPS_CHMOD: + { + xl_fileops_chmod *xlrec = (xl_fileops_chmod *) data; + const char *path = data + SizeOfFileOpsChmod; + + appendStringInfo(buf, "chmod \"%s\" mode 0%o", + path, (unsigned int) xlrec->mode); + } + break; + + case XLOG_FILEOPS_CHOWN: + { + xl_fileops_chown *xlrec = (xl_fileops_chown *) data; + const char *path = data + SizeOfFileOpsChown; + + appendStringInfo(buf, "chown \"%s\" uid %d gid %d", + path, (int) xlrec->uid, (int) xlrec->gid); + } + break; + + case XLOG_FILEOPS_TRUNCATE: + { + xl_fileops_truncate *xlrec = (xl_fileops_truncate *) data; + const char *path = data + SizeOfFileOpsTruncate; + + appendStringInfo(buf, "truncate \"%s\" to %lld bytes", + path, (long long) xlrec->length); + } + break; + + case XLOG_FILEOPS_SETXATTR: + { + xl_fileops_setxattr *xlrec = (xl_fileops_setxattr *) data; + const char *path = data + SizeOfFileOpsSetxattr; + const char *name = path + xlrec->path_len; + + appendStringInfo(buf, "setxattr \"%s\" name \"%s\" len %u", + path, name, xlrec->value_len); + } + break; + + case XLOG_FILEOPS_REMOVEXATTR: + { + xl_fileops_removexattr *xlrec = + (xl_fileops_removexattr *) data; + const char *path = data + SizeOfFileOpsRemovexattr; + const char *name = path + xlrec->path_len; + + appendStringInfo(buf, "removexattr \"%s\" name \"%s\"", + path, name); + } + break; + + default: + appendStringInfo(buf, "unknown fileops op code %u", info); + break; + } +} + +const char * +fileops_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & ~XLR_INFO_MASK) + { + case XLOG_FILEOPS_CREATE: + id = "CREATE"; + break; + case XLOG_FILEOPS_DELETE: + id = "DELETE"; + break; + case XLOG_FILEOPS_RENAME: + id = "RENAME"; + break; + case XLOG_FILEOPS_WRITE: + id = "WRITE"; + break; + case XLOG_FILEOPS_TRUNCATE: + id = "TRUNCATE"; + break; + case XLOG_FILEOPS_CHMOD: + id = "CHMOD"; + break; + case XLOG_FILEOPS_CHOWN: + id = "CHOWN"; + break; + case XLOG_FILEOPS_MKDIR: + id = "MKDIR"; + break; + case XLOG_FILEOPS_RMDIR: + id = "RMDIR"; + break; + case XLOG_FILEOPS_SYMLINK: + id = "SYMLINK"; + break; + case XLOG_FILEOPS_LINK: + id = "LINK"; + break; + case XLOG_FILEOPS_SETXATTR: + id = "SETXATTR"; + break; + case XLOG_FILEOPS_REMOVEXATTR: + id = "REMOVEXATTR"; + break; + } + + return id; +} diff --git a/src/backend/access/rmgrdesc/fluxdesc.c b/src/backend/access/rmgrdesc/fluxdesc.c new file mode 100644 index 0000000000000..64e8e1c3a3560 --- /dev/null +++ b/src/backend/access/rmgrdesc/fluxdesc.c @@ -0,0 +1,510 @@ +/*------------------------------------------------------------------------- + * + * fluxdesc.c + * Resource manager descriptor for FLUX - frontend version + * + * This provides minimal desc/identify functions for frontend tools like pg_waldump. + * The full implementations are in flux_xlog.c for backend use. + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/rmgrdesc/fluxdesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/xlog.h" +#include "access/xlog_internal.h" +#include "access/xlogreader.h" + +/* Function prototypes */ +extern void flux_desc(StringInfo buf, XLogReaderState *record); +extern const char *flux_identify(uint8 info); + +/* FLUX WAL record types - keep in sync with flux_xlog.h */ +#define XLOG_FLUX_INSERT 0x00 +#define XLOG_FLUX_UPDATE 0x10 +#define XLOG_FLUX_DELETE 0x20 +#define XLOG_FLUX_VACUUM 0x30 +#define XLOG_FLUX_OVERFLOW_WRITE 0x40 +#define XLOG_FLUX_COMPRESS 0x50 +#define XLOG_FLUX_INIT_PAGE 0x60 +#define XLOG_FLUX_CROSS_PAGE_DEFRAG 0x70 +#define XLOG_FLUX_VM_SET 0x80 +#define XLOG_FLUX_VM_CLEAR 0x90 +#define XLOG_FLUX_LOCK 0xA0 +#define XLOG_FLUX_CAS_UPDATE 0xB0 +#define XLOG_FLUX_WRITE_DICT 0xC0 +#define XLOG_FLUX_MULTI_INSERT 0xD0 +#define XLOG_FLUX_CAS_UPDATE_UNDO 0xE0 +#define XLOG_FLUX_OPMASK 0xF0 + +/* WAL record flags - keep in sync with flux_xlog.h */ +#define FLUX_WAL_CROSS_PAGE 0x0002 + +/* + * Frontend-safe copies of WAL record structures from flux_xlog.h. + * Duplicated here because fluxdesc.c is compiled with FRONTEND defined and + * we need to parse these records in pg_waldump without pulling in backend + * headers. + */ +typedef struct xl_flux_insert_fe +{ + uint16 offnum; + uint16 flags; + uint64 commit_ts; + uint64 xact_ts; +} xl_flux_insert_fe; + +typedef struct xl_flux_delete_fe +{ + uint16 offnum; + uint16 flags; + uint64 commit_ts; + uint64 xact_ts; +} xl_flux_delete_fe; + +typedef struct xl_flux_update_fe +{ + uint16 offnum; + uint16 flags; + uint64 old_commit_ts; + uint64 new_commit_ts; + uint64 xact_ts; +} xl_flux_update_fe; + +/* Mirrors backend xl_flux_cas_update_undo (flux_xlog.h) */ +typedef struct xl_flux_cas_update_undo_fe +{ + uint16 offnum; + uint16 flags; + uint16 data_offset; + uint16 data_len; + uint64 new_commit_ts; + uint8 urec_type; + uint8 is_new_page; + uint16 urec_len; + uint16 page_offset; + uint16 new_pd_lower; + uint32 max_xid; +} xl_flux_cas_update_undo_fe; + +typedef struct xl_flux_vacuum_fe +{ + uint32 ntuples; +} xl_flux_vacuum_fe; + +typedef struct xl_flux_compress_fe +{ + uint16 offnum; + uint16 attr_num; + uint8 comp_type; + uint8 comp_level; + uint32 orig_size; + uint32 comp_size; + uint64 commit_ts; +} xl_flux_compress_fe; + +typedef struct xl_flux_overflow_write_fe +{ + uint16 offnum; + uint16 flags; + uint32 data_len; + uint64 commit_ts; +} xl_flux_overflow_write_fe; + +typedef struct xl_flux_init_page_fe +{ + uint32 flags; + uint64 commit_ts; +} xl_flux_init_page_fe; + +typedef struct xl_flux_cross_page_defrag_fe +{ + uint16 src_offnum; + uint16 dst_offnum; + uint32 tuple_len; +} xl_flux_cross_page_defrag_fe; + +typedef struct xl_flux_vm_set_fe +{ + uint32 heapBlk; + uint8 flags; +} xl_flux_vm_set_fe; + +typedef struct xl_flux_vm_clear_fe +{ + uint32 heapBlk; + uint8 flags; +} xl_flux_vm_clear_fe; + +typedef struct xl_flux_lock_fe +{ + uint16 offnum; + uint16 flags; + uint32 xmax; + uint16 infomask; + uint16 infomask2; + uint8 lock_mode; +} xl_flux_lock_fe; + +typedef struct xl_flux_multi_insert_fe +{ + uint16 ntuples; + uint16 flags; + uint64 commit_ts; +} xl_flux_multi_insert_fe; + +/* + * Human-readable compression type names. + */ +static const char * +flux_comp_type_name(uint8 comp_type) +{ + switch (comp_type) + { + case 0: + return "NONE"; + case 1: + return "LZ4"; + case 2: + return "ZSTD"; + case 3: + return "DELTA"; + case 4: + return "DICTIONARY"; + default: + return "UNKNOWN"; + } +} + +void +flux_desc(StringInfo buf, XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + char *data = XLogRecGetData(record); + Size datalen = XLogRecGetDataLen(record); + uint16 flags = 0; + + switch (info & XLOG_FLUX_OPMASK) + { + case XLOG_FLUX_INSERT: + { + if (datalen >= sizeof(xl_flux_insert_fe)) + { + xl_flux_insert_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_insert_fe)); + flags = xlrec.flags; + appendStringInfo(buf, "off: %u, flags: 0x%04X, " + "commit_ts: " UINT64_FORMAT ", " + "xact_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.flags, + xlrec.commit_ts, xlrec.xact_ts); + } + else + appendStringInfoString(buf, "insert (truncated)"); + } + break; + case XLOG_FLUX_DELETE: + { + if (datalen >= sizeof(xl_flux_delete_fe)) + { + xl_flux_delete_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_delete_fe)); + flags = xlrec.flags; + appendStringInfo(buf, "off: %u, flags: 0x%04X, " + "commit_ts: " UINT64_FORMAT ", " + "xact_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.flags, + xlrec.commit_ts, xlrec.xact_ts); + } + else + appendStringInfoString(buf, "delete (truncated)"); + } + break; + case XLOG_FLUX_UPDATE: + { + if (datalen >= sizeof(xl_flux_update_fe)) + { + xl_flux_update_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_update_fe)); + flags = xlrec.flags; + appendStringInfo(buf, "off: %u, flags: 0x%04X, " + "old_commit_ts: " UINT64_FORMAT ", " + "new_commit_ts: " UINT64_FORMAT ", " + "xact_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.flags, + xlrec.old_commit_ts, + xlrec.new_commit_ts, + xlrec.xact_ts); + if (flags & FLUX_WAL_CROSS_PAGE) + appendStringInfoString(buf, ", cross_page: true"); + } + else + appendStringInfoString(buf, "update (truncated)"); + } + break; + case XLOG_FLUX_VACUUM: + { + if (datalen >= sizeof(xl_flux_vacuum_fe)) + { + xl_flux_vacuum_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_vacuum_fe)); + appendStringInfo(buf, "ntuples: %u", xlrec.ntuples); + } + else + appendStringInfoString(buf, "vacuum (truncated)"); + } + break; + case XLOG_FLUX_COMPRESS: + { + if (datalen >= sizeof(xl_flux_compress_fe)) + { + xl_flux_compress_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_compress_fe)); + appendStringInfo(buf, "off: %u, attr: %u, " + "comp_type: %s, comp_level: %u, " + "orig_size: %u, comp_size: %u, " + "commit_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.attr_num, + flux_comp_type_name(xlrec.comp_type), + xlrec.comp_level, + xlrec.orig_size, xlrec.comp_size, + xlrec.commit_ts); + } + else + appendStringInfoString(buf, "compress (truncated)"); + } + break; + case XLOG_FLUX_OVERFLOW_WRITE: + { + if (datalen >= sizeof(xl_flux_overflow_write_fe)) + { + xl_flux_overflow_write_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_overflow_write_fe)); + appendStringInfo(buf, "off: %u, flags: 0x%04X, " + "data_len: %u, " + "commit_ts: " UINT64_FORMAT, + xlrec.offnum, xlrec.flags, + xlrec.data_len, xlrec.commit_ts); + } + else + appendStringInfoString(buf, "overflow_write (truncated)"); + } + break; + case XLOG_FLUX_INIT_PAGE: + { + if (datalen >= sizeof(xl_flux_init_page_fe)) + { + xl_flux_init_page_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_init_page_fe)); + appendStringInfo(buf, "flags: 0x%08X, " + "commit_ts: " UINT64_FORMAT, + xlrec.flags, xlrec.commit_ts); + } + else + appendStringInfoString(buf, "init_page (truncated)"); + } + break; + case XLOG_FLUX_CROSS_PAGE_DEFRAG: + { + if (datalen >= sizeof(xl_flux_cross_page_defrag_fe)) + { + xl_flux_cross_page_defrag_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_cross_page_defrag_fe)); + appendStringInfo(buf, "src_off: %u, dst_off: %u, " + "tuple_len: %u", + xlrec.src_offnum, xlrec.dst_offnum, + xlrec.tuple_len); + } + else + appendStringInfoString(buf, "cross_page_defrag (truncated)"); + } + break; + case XLOG_FLUX_VM_SET: + { + if (datalen >= sizeof(xl_flux_vm_set_fe)) + { + xl_flux_vm_set_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_vm_set_fe)); + appendStringInfo(buf, "heapBlk: %u, flags: 0x%02X", + xlrec.heapBlk, xlrec.flags); + } + else + appendStringInfoString(buf, "vm_set (truncated)"); + } + break; + case XLOG_FLUX_VM_CLEAR: + { + if (datalen >= sizeof(xl_flux_vm_clear_fe)) + { + xl_flux_vm_clear_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_vm_clear_fe)); + appendStringInfo(buf, "heapBlk: %u, flags: 0x%02X", + xlrec.heapBlk, xlrec.flags); + } + else + appendStringInfoString(buf, "vm_clear (truncated)"); + } + break; + case XLOG_FLUX_LOCK: + { + if (datalen >= sizeof(xl_flux_lock_fe)) + { + xl_flux_lock_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_lock_fe)); + appendStringInfo(buf, "off: %u, xmax: %u, " + "infomask: 0x%04X, infomask2: 0x%04X, " + "lock_mode: %u", + xlrec.offnum, xlrec.xmax, + xlrec.infomask, xlrec.infomask2, + xlrec.lock_mode); + } + else + appendStringInfoString(buf, "lock (truncated)"); + } + break; + case XLOG_FLUX_CAS_UPDATE: + { + if (datalen >= 14) /* minimum: offnum(2)+flags(2)+offset(2)+len(2)+ts(8) - 2 padding */ + { + uint16 offnum; + uint16 d_offset; + uint16 d_len; + + memcpy(&offnum, data, sizeof(uint16)); + memcpy(&d_offset, data + 4, sizeof(uint16)); + memcpy(&d_len, data + 6, sizeof(uint16)); + appendStringInfo(buf, "off: %u, data_offset: %u, data_len: %u", + offnum, d_offset, d_len); + } + else + appendStringInfoString(buf, "cas_update (truncated)"); + } + break; + case XLOG_FLUX_CAS_UPDATE_UNDO: + { + if (datalen >= (int) sizeof(xl_flux_cas_update_undo_fe)) + { + xl_flux_cas_update_undo_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_cas_update_undo_fe)); + appendStringInfo(buf, + "off: %u, data_offset: %u, data_len: %u, " + "urec_type: %u, urec_len: %u, undo_off: %u, new_page: %u", + xlrec.offnum, xlrec.data_offset, xlrec.data_len, + xlrec.urec_type, xlrec.urec_len, xlrec.page_offset, + xlrec.is_new_page); + } + else + appendStringInfoString(buf, "cas_update_undo (truncated)"); + } + break; + case XLOG_FLUX_WRITE_DICT: + { + if (datalen >= sizeof(uint32)) + { + uint32 blkno; + + memcpy(&blkno, data, sizeof(uint32)); + appendStringInfo(buf, "blkno: %u (full-page image)", blkno); + } + else + appendStringInfoString(buf, "write_dict (truncated)"); + } + break; + case XLOG_FLUX_MULTI_INSERT: + { + if (datalen >= sizeof(xl_flux_multi_insert_fe)) + { + xl_flux_multi_insert_fe xlrec; + + memcpy(&xlrec, data, sizeof(xl_flux_multi_insert_fe)); + appendStringInfo(buf, "ntuples: %u, flags: 0x%04X, " + "commit_ts: " UINT64_FORMAT, + xlrec.ntuples, xlrec.flags, + xlrec.commit_ts); + } + else + appendStringInfoString(buf, "multi_insert (truncated)"); + } + break; + default: + appendStringInfoString(buf, "UNKNOWN"); + break; + } +} + +const char * +flux_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & XLOG_FLUX_OPMASK) + { + case XLOG_FLUX_INSERT: + id = "INSERT"; + break; + case XLOG_FLUX_DELETE: + id = "DELETE"; + break; + case XLOG_FLUX_UPDATE: + id = "UPDATE"; + break; + case XLOG_FLUX_VACUUM: + id = "VACUUM"; + break; + case XLOG_FLUX_COMPRESS: + id = "COMPRESS"; + break; + case XLOG_FLUX_OVERFLOW_WRITE: + id = "OVERFLOW_WRITE"; + break; + case XLOG_FLUX_INIT_PAGE: + id = "INIT_PAGE"; + break; + case XLOG_FLUX_CROSS_PAGE_DEFRAG: + id = "CROSS_PAGE_DEFRAG"; + break; + case XLOG_FLUX_VM_SET: + id = "VM_SET"; + break; + case XLOG_FLUX_VM_CLEAR: + id = "VM_CLEAR"; + break; + case XLOG_FLUX_LOCK: + id = "LOCK"; + break; + case XLOG_FLUX_CAS_UPDATE: + id = "CAS_UPDATE"; + break; + case XLOG_FLUX_CAS_UPDATE_UNDO: + id = "CAS_UPDATE_UNDO"; + break; + case XLOG_FLUX_WRITE_DICT: + id = "WRITE_DICT"; + break; + case XLOG_FLUX_MULTI_INSERT: + id = "MULTI_INSERT"; + break; + default: + id = NULL; + break; + } + + return id; +} diff --git a/src/backend/access/rmgrdesc/meson.build b/src/backend/access/rmgrdesc/meson.build index d9000ccd9fd10..ac5bd8a305ca5 100644 --- a/src/backend/access/rmgrdesc/meson.build +++ b/src/backend/access/rmgrdesc/meson.build @@ -2,10 +2,12 @@ # used by frontend programs like pg_waldump rmgr_desc_sources = files( + 'atmdesc.c', 'brindesc.c', 'clogdesc.c', 'committsdesc.c', 'dbasedesc.c', + 'fileopsdesc.c', 'genericdesc.c', 'gindesc.c', 'gistdesc.c', @@ -15,6 +17,7 @@ rmgr_desc_sources = files( 'mxactdesc.c', 'nbtdesc.c', 'relmapdesc.c', + 'relundodesc.c', 'replorigindesc.c', 'rmgrdesc_utils.c', 'seqdesc.c', @@ -22,8 +25,14 @@ rmgr_desc_sources = files( 'spgdesc.c', 'standbydesc.c', 'tblspcdesc.c', + 'undodesc.c', 'xactdesc.c', 'xlogdesc.c', ) + +# FLUX, like RECNO, is always built; rmgrlist.h registers flux_desc/flux_identify +# unconditionally, so fluxdesc.c must always be compiled. +rmgr_desc_sources += files('fluxdesc.c') + backend_sources += rmgr_desc_sources diff --git a/src/backend/access/rmgrdesc/relundodesc.c b/src/backend/access/rmgrdesc/relundodesc.c new file mode 100644 index 0000000000000..448d5765fed1e --- /dev/null +++ b/src/backend/access/rmgrdesc/relundodesc.c @@ -0,0 +1,140 @@ +/*------------------------------------------------------------------------- + * + * relundodesc.c + * rmgr descriptor routines for access/undo/relundo_xlog.c + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/rmgrdesc/relundodesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/relundo_xlog.h" + +/* + * relundo_desc - Describe a per-relation UNDO WAL record for pg_waldump + */ +void +relundo_desc(StringInfo buf, XLogReaderState *record) +{ + char *data = XLogRecGetData(record); + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info & ~XLOG_RELUNDO_INIT_PAGE) + { + case XLOG_RELUNDO_INIT: + { + xl_relundo_init *xlrec = (xl_relundo_init *) data; + + appendStringInfo(buf, "magic 0x%08X, version %u, counter %u", + xlrec->magic, xlrec->version, + xlrec->counter); + } + break; + + case XLOG_RELUNDO_INSERT: + { + xl_relundo_insert *xlrec = (xl_relundo_insert *) data; + const char *type_name; + + switch (xlrec->urec_type) + { + case 1: + type_name = "INSERT"; + break; + case 2: + type_name = "DELETE"; + break; + case 3: + type_name = "UPDATE"; + break; + case 4: + type_name = "TUPLE_LOCK"; + break; + default: + type_name = "UNKNOWN"; + break; + } + + appendStringInfo(buf, + "type %s, len %u, offset %u, new_pd_lower %u, max_xid %u", + type_name, xlrec->urec_len, + xlrec->page_offset, + xlrec->new_pd_lower, + xlrec->max_xid); + + if (info & XLOG_RELUNDO_INIT_PAGE) + appendStringInfoString(buf, " (init page)"); + } + break; + + case XLOG_RELUNDO_DISCARD: + { + xl_relundo_discard *xlrec = (xl_relundo_discard *) data; + + appendStringInfo(buf, + "slot %u, old_tail %u, new_tail %u, discard_xid %u, " + "npages_freed %u", + xlrec->slot, + xlrec->old_tail_blkno, + xlrec->new_tail_blkno, + xlrec->discard_xid, + xlrec->npages_freed); + } + break; + + case XLOG_RELUNDO_APPLY: + { + xl_relundo_apply *xlrec = (xl_relundo_apply *) data; + + appendStringInfo(buf, "urec_ptr %lu", + (unsigned long) xlrec->urec_ptr); + } + break; + + case XLOG_RELUNDO_TRUNCATE: + { + xl_relundo_truncate *xlrec = (xl_relundo_truncate *) data; + + appendStringInfo(buf, "new_nblocks %u", xlrec->new_nblocks); + } + break; + } +} + +/* + * relundo_identify - Identify a per-relation UNDO WAL record type + */ +const char * +relundo_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & ~XLR_INFO_MASK) + { + case XLOG_RELUNDO_INIT: + id = "INIT"; + break; + case XLOG_RELUNDO_INSERT: + id = "INSERT"; + break; + case XLOG_RELUNDO_INSERT | XLOG_RELUNDO_INIT_PAGE: + id = "INSERT+INIT"; + break; + case XLOG_RELUNDO_DISCARD: + id = "DISCARD"; + break; + case XLOG_RELUNDO_TRUNCATE: + id = "TRUNCATE"; + break; + case XLOG_RELUNDO_APPLY: + id = "APPLY"; + break; + } + + return id; +} diff --git a/src/backend/access/rmgrdesc/undodesc.c b/src/backend/access/rmgrdesc/undodesc.c new file mode 100644 index 0000000000000..d4817684ad471 --- /dev/null +++ b/src/backend/access/rmgrdesc/undodesc.c @@ -0,0 +1,209 @@ +/*------------------------------------------------------------------------- + * + * undodesc.c + * rmgr descriptor routines for access/undo + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/rmgrdesc/undodesc.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/undo_xlog.h" +#include "access/xlogreader.h" + +/* + * undo_desc - Describe an UNDO WAL record for pg_waldump + * + * This function generates human-readable output for UNDO WAL records, + * used by pg_waldump and other debugging tools. + */ +void +undo_desc(StringInfo buf, XLogReaderState *record) +{ + char *rec = XLogRecGetData(record); + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_UNDO_ALLOCATE: + { + xl_undo_allocate *xlrec = (xl_undo_allocate *) rec; + + appendStringInfo(buf, "log %u, start %llu, len %u, xid %u", + xlrec->log_number, + (unsigned long long) xlrec->start_ptr, + xlrec->length, + xlrec->xid); + } + break; + + case XLOG_UNDO_DISCARD: + { + xl_undo_discard *xlrec = (xl_undo_discard *) rec; + + appendStringInfo(buf, "log %u, discard_ptr %llu, oldest_xid %u", + xlrec->log_number, + (unsigned long long) xlrec->discard_ptr, + xlrec->oldest_xid); + } + break; + + case XLOG_UNDO_EXTEND: + { + xl_undo_extend *xlrec = (xl_undo_extend *) rec; + + appendStringInfo(buf, "log %u, new_size %llu", + xlrec->log_number, + (unsigned long long) xlrec->new_size); + } + break; + + case XLOG_UNDO_APPLY_RECORD: + { + xl_undo_apply *xlrec = (xl_undo_apply *) rec; + const char *op_name; + + switch (xlrec->operation_type) + { + case 0x0001: + op_name = "INSERT"; + break; + case 0x0002: + op_name = "DELETE"; + break; + case 0x0003: + op_name = "UPDATE"; + break; + case 0x0004: + op_name = "PRUNE"; + break; + case 0x0005: + op_name = "INPLACE"; + break; + case 0x0006: + op_name = "HOT_UPDATE"; + break; + default: + op_name = "UNKNOWN"; + break; + } + + appendStringInfo(buf, + "undo apply %s: urec_ptr %llu, xid %u, " + "block %u, offset %u, clr_flags 0x%04x, " + "tuple_len %u", + op_name, + (unsigned long long) xlrec->urec_ptr, + xlrec->xid, + xlrec->target_block, + xlrec->target_offset, + xlrec->clr_flags, + xlrec->tuple_len); + } + break; + + case XLOG_UNDO_PAGE_WRITE: + { + xl_undo_page_write *xlrec = (xl_undo_page_write *) rec; + + appendStringInfo(buf, "page_offset %u, data_len %u", + xlrec->page_offset, + xlrec->data_len); + } + break; + + case XLOG_UNDO_BATCH: + { + xl_undo_batch *xlrec = (xl_undo_batch *) rec; + + appendStringInfo(buf, + "undo batch: xid %u, nrecords %u, " + "total_len %u, chain_prev %X/%X, " + "primary_reloid %u, persistence %d", + xlrec->xid, + xlrec->nrecords, + xlrec->total_len, + LSN_FORMAT_ARGS(xlrec->chain_prev), + xlrec->primary_reloid, + xlrec->persistence); + } + break; + + case XLOG_UNDO_ROTATE: + { + xl_undo_rotate *xlrec = (xl_undo_rotate *) rec; + const char *trigger_name; + + switch (xlrec->trigger) + { + case UNDO_ROTATE_CAPACITY: + trigger_name = "capacity"; + break; + case UNDO_ROTATE_CHECKPOINT: + trigger_name = "checkpoint"; + break; + case UNDO_ROTATE_PRESSURE: + trigger_name = "pressure"; + break; + case UNDO_ROTATE_MANUAL: + trigger_name = "manual"; + break; + default: + trigger_name = "unknown"; + break; + } + + appendStringInfo(buf, + "old_log %u, seal_ptr %llu, new_log %u, " + "trigger %s", + xlrec->old_log_number, + (unsigned long long) xlrec->old_seal_ptr, + xlrec->new_log_number, + trigger_name); + } + break; + } +} + +/* + * undo_identify - Identify an UNDO WAL record type + * + * Returns a string identifying the operation type for debugging output. + */ +const char * +undo_identify(uint8 info) +{ + const char *id = NULL; + + switch (info & ~XLR_INFO_MASK) + { + case XLOG_UNDO_ALLOCATE: + id = "ALLOCATE"; + break; + case XLOG_UNDO_DISCARD: + id = "DISCARD"; + break; + case XLOG_UNDO_EXTEND: + id = "EXTEND"; + break; + case XLOG_UNDO_APPLY_RECORD: + id = "APPLY_RECORD"; + break; + case XLOG_UNDO_ROTATE: + id = "ROTATE"; + break; + case XLOG_UNDO_PAGE_WRITE: + id = "PAGE_WRITE"; + break; + case XLOG_UNDO_BATCH: + id = "BATCH"; + break; + } + + return id; +} diff --git a/src/backend/access/table/tableam.c b/src/backend/access/table/tableam.c index 68ff0966f1c57..37d0cfe45c283 100644 --- a/src/backend/access/table/tableam.c +++ b/src/backend/access/table/tableam.c @@ -823,3 +823,16 @@ table_block_relation_estimate_size(Relation rel, int32 *attr_widths, else *allvisfrac = (double) relallvisible / curpages; } + +/* + * RelationAmSupportsUndo + * Returns true if the relation's table AM declared UNDO support. + * Used by index AMs to gate UNDO record generation on the parent table. + */ +bool +RelationAmSupportsUndo(Relation rel) +{ + if (!rel->rd_tableam) + return false; + return rel->rd_tableam->am_supports_undo; +} diff --git a/src/backend/access/transam/rmgr.c b/src/backend/access/transam/rmgr.c index 4fda03a3cfcc6..c1fcd230cc1e9 100644 --- a/src/backend/access/transam/rmgr.c +++ b/src/backend/access/transam/rmgr.c @@ -40,6 +40,13 @@ #include "replication/origin.h" #include "storage/standby.h" #include "utils/relmapper.h" +#include "access/undo_xlog.h" +#include "access/atm.h" +#include "access/relundo_xlog.h" +#include "storage/fileops.h" +#ifdef USE_FLUX +#include "access/flux_xlog.h" +#endif /* IWYU pragma: end_keep */ diff --git a/src/backend/access/transam/twophase.c b/src/backend/access/transam/twophase.c index fa3bc50ec483a..58ed3158a274a 100644 --- a/src/backend/access/transam/twophase.c +++ b/src/backend/access/transam/twophase.c @@ -77,6 +77,8 @@ #include #include "access/commit_ts.h" +#include "access/atm.h" +#include "access/xactundo.h" #include "access/htup_details.h" #include "access/subtrans.h" #include "access/transam.h" @@ -162,6 +164,18 @@ typedef struct GlobalTransactionData */ XLogRecPtr prepare_start_lsn; /* XLOG offset of prepare record start */ XLogRecPtr prepare_end_lsn; /* XLOG offset of prepare record end */ + + /* + * Permanent-level UNDO chain-head LSN for this prepared xact, or + * InvalidXLogRecPtr if it generated no cluster-wide UNDO. Set at PREPARE + * (MarkAsPreparingGuts), at redo (PrepareRedoAdd), and when a stale 2PC + * file is recovered from disk. UndoGetOldestBatchLSN() scans this so the + * UNDO-batch WAL a still-prepared xact needs for ROLLBACK PREPARED is not + * recycled -- neither the per-backend retention slot (cleared when the + * preparing backend exits) nor the ATM (prepared xacts aren't in it) + * covers this case. + */ + XLogRecPtr undo_batch_lsn; FullTransactionId fxid; /* The GXACT full xid */ Oid owner; /* ID of user that executed the xact */ @@ -492,6 +506,7 @@ MarkAsPreparingGuts(GlobalTransaction gxact, FullTransactionId fxid, gxact->locking_backend = MyProcNumber; gxact->valid = false; gxact->inredo = false; + gxact->undo_batch_lsn = GetCurrentXactLastBatchLSN(UNDOPERSISTENCE_PERMANENT); strlcpy(gxact->gid, gid, GIDSIZE); /* @@ -978,8 +993,14 @@ TwoPhaseFilePath(char *path, FullTransactionId fxid) /* * Header for a 2PC state file + * + * TWOPHASE_MAGIC must be bumped whenever xl_xact_prepare changes layout. + * The struct gained last_batch_lsn[NUndoPersistenceLevels] (24 bytes) for + * UNDO chain tracking across 2PC boundaries, requiring this bump from + * 0x57F94534 to 0x57F94535 to prevent old servers from silently misreading + * the variable-length arrays that follow the fixed header at the wrong offsets. */ -#define TWOPHASE_MAGIC 0x57F94534 /* format identifier */ +#define TWOPHASE_MAGIC 0x57F94535 /* format identifier */ typedef xl_xact_prepare TwoPhaseFileHeader; @@ -1101,6 +1122,10 @@ StartPrepare(GlobalTransaction gxact) hdr.origin_lsn = InvalidXLogRecPtr; hdr.origin_timestamp = 0; + /* Save UNDO chain head LSNs so recovery can find UNDO records */ + for (int j = 0; j < NUndoPersistenceLevels; j++) + hdr.last_batch_lsn[j] = GetCurrentXactLastBatchLSN(j); + save_state_data(&hdr, sizeof(TwoPhaseFileHeader)); save_state_data(gxact->gid, hdr.gidlen); @@ -1498,6 +1523,47 @@ StandbyTransactionIdIsPrepared(TransactionId xid) return result; } +/* + * RecoveryTransactionIdIsPrepared + * Check if a transaction ID is in the in-memory prepared transaction list. + * + * This is used during crash recovery UNDO phase, before prepared transaction + * files exist on disk. It checks the in-memory TwoPhaseState that was + * reconstructed from WAL replay. + */ +bool +RecoveryTransactionIdIsPrepared(TransactionId xid) +{ + int i; + FullTransactionId fxid; + + Assert(TransactionIdIsValid(xid)); + + if (max_prepared_xacts <= 0) + return false; /* 2PC not enabled */ + + if (TwoPhaseState == NULL) + return false; /* 2PC not initialized yet */ + + fxid = AdjustToFullTransactionId(xid); + + LWLockAcquire(TwoPhaseStateLock, LW_SHARED); + + for (i = 0; i < TwoPhaseState->numPrepXacts; i++) + { + GlobalTransaction gxact = TwoPhaseState->prepXacts[i]; + + if (FullTransactionIdEquals(gxact->fxid, fxid)) + { + LWLockRelease(TwoPhaseStateLock); + return true; + } + } + + LWLockRelease(TwoPhaseStateLock); + return false; +} + /* * FinishPreparedTransaction: execute COMMIT PREPARED or ROLLBACK PREPARED */ @@ -1585,6 +1651,7 @@ FinishPreparedTransaction(const char *gid, bool isCommit) hdr->ninvalmsgs, invalmsgs, hdr->initfileinval, gid); else + { RecordTransactionAbortPrepared(xid, hdr->nsubxacts, children, hdr->nabortrels, abortrels, @@ -1592,6 +1659,32 @@ FinishPreparedTransaction(const char *gid, bool isCommit) abortstats, gid); + /* + * ROLLBACK PREPARED: hand any cluster-wide UNDO chain to the same + * async revert machinery an ordinary large-transaction abort uses. + * The permanent-level chain-head LSN + * was durably saved in the 2PC header at PREPARE; ATMAddAborted() + * records (xid, dboid, last_batch_lsn) in the sLog Aborted + * Transaction Map, and the logical revert worker walks the UNDO + * chain backwards from that LSN, restoring before-images. No apply + * code lives here. + * + * Only the permanent level is applied: TEMP undo is gone (the + * originating backend exited) and UNLOGGED forks are reset on crash; + * this matches the crash-recovery UNDO phase in undo_xlog.c. + */ + { + XLogRecPtr perm_lsn = + hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; + + if (XLogRecPtrIsValid(perm_lsn) && + !ATMAddAborted(xid, hdr->database, perm_lsn)) + elog(WARNING, + "ATM full: could not record rolled-back prepared " + "transaction %u for UNDO", xid); + } + } + ProcArrayRemove(proc, latestXid); /* @@ -2145,6 +2238,13 @@ RecoverPreparedTransactions(void) hdr->prepared_at, hdr->owner, hdr->database); + /* + * MarkAsPreparingGuts read the live backend's last_batch_lsn (wrong + * xact during recovery); restore this xact's real value from the 2PC + * header so WAL retention pins its UNDO batch until ROLLBACK PREPARED. + */ + gxact->undo_batch_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; + /* recovered, so reset the flag for entries generated by redo */ gxact->inredo = false; @@ -2597,6 +2697,7 @@ PrepareRedoAdd(FullTransactionId fxid, char *buf, gxact->valid = false; gxact->ondisk = !XLogRecPtrIsValid(start_lsn); gxact->inredo = true; /* yes, added in redo */ + gxact->undo_batch_lsn = hdr->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; strlcpy(gxact->gid, gid, GIDSIZE); /* And insert it into the active array */ @@ -2878,3 +2979,36 @@ TwoPhaseGetOldestXidInCommit(void) return oldestRunningXid; } + +/* + * TwoPhaseGetOldestUndoBatchLSN + * Return the oldest permanent-level UNDO chain-head LSN across all + * prepared transactions, or InvalidXLogRecPtr if none generated UNDO. + * + * UndoGetOldestBatchLSN() folds this into the WAL-retention horizon so the + * UNDO-batch WAL a still-prepared xact needs for ROLLBACK PREPARED survives + * checkpoints and backend exit (see undo_batch_lsn in GlobalTransactionData). + */ +XLogRecPtr +TwoPhaseGetOldestUndoBatchLSN(void) +{ + XLogRecPtr oldest = InvalidXLogRecPtr; + + if (max_prepared_xacts <= 0) + return InvalidXLogRecPtr; + + LWLockAcquire(TwoPhaseStateLock, LW_SHARED); + + for (int i = 0; i < TwoPhaseState->numPrepXacts; i++) + { + XLogRecPtr lsn = TwoPhaseState->prepXacts[i]->undo_batch_lsn; + + if (XLogRecPtrIsValid(lsn) && + (!XLogRecPtrIsValid(oldest) || lsn < oldest)) + oldest = lsn; + } + + LWLockRelease(TwoPhaseStateLock); + + return oldest; +} diff --git a/src/backend/access/transam/twophase_rmgr.c b/src/backend/access/transam/twophase_rmgr.c index fae254c6e2364..f7ef856019c9f 100644 --- a/src/backend/access/transam/twophase_rmgr.c +++ b/src/backend/access/transam/twophase_rmgr.c @@ -15,6 +15,7 @@ #include "postgres.h" #include "access/multixact.h" +#include "access/flux.h" #include "access/twophase_rmgr.h" #include "pgstat.h" #include "storage/lock.h" @@ -27,7 +28,8 @@ const TwoPhaseCallback twophase_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] = lock_twophase_recover, /* Lock */ NULL, /* pgstat */ multixact_twophase_recover, /* MultiXact */ - predicatelock_twophase_recover /* PredicateLock */ + predicatelock_twophase_recover, /* PredicateLock */ + flux_twophase_recover /* FLUX */ }; const TwoPhaseCallback twophase_postcommit_callbacks[TWOPHASE_RM_MAX_ID + 1] = @@ -36,7 +38,8 @@ const TwoPhaseCallback twophase_postcommit_callbacks[TWOPHASE_RM_MAX_ID + 1] = lock_twophase_postcommit, /* Lock */ pgstat_twophase_postcommit, /* pgstat */ multixact_twophase_postcommit, /* MultiXact */ - NULL /* PredicateLock */ + NULL, /* PredicateLock */ + flux_twophase_postcommit /* FLUX */ }; const TwoPhaseCallback twophase_postabort_callbacks[TWOPHASE_RM_MAX_ID + 1] = @@ -45,7 +48,8 @@ const TwoPhaseCallback twophase_postabort_callbacks[TWOPHASE_RM_MAX_ID + 1] = lock_twophase_postabort, /* Lock */ pgstat_twophase_postabort, /* pgstat */ multixact_twophase_postabort, /* MultiXact */ - NULL /* PredicateLock */ + NULL, /* PredicateLock */ + flux_twophase_postabort /* FLUX */ }; const TwoPhaseCallback twophase_standby_recover_callbacks[TWOPHASE_RM_MAX_ID + 1] = @@ -54,5 +58,6 @@ const TwoPhaseCallback twophase_standby_recover_callbacks[TWOPHASE_RM_MAX_ID + 1 lock_twophase_standby_recover, /* Lock */ NULL, /* pgstat */ NULL, /* MultiXact */ - NULL /* PredicateLock */ + NULL, /* PredicateLock */ + flux_twophase_recover /* FLUX */ }; diff --git a/src/backend/access/transam/xact.c b/src/backend/access/transam/xact.c index 3a89149016fe6..07ca48cf50118 100644 --- a/src/backend/access/transam/xact.c +++ b/src/backend/access/transam/xact.c @@ -26,6 +26,12 @@ #include "access/subtrans.h" #include "access/transam.h" #include "access/twophase.h" +#include "access/relundo.h" +#include "access/relundo_worker.h" +#include "access/undo_xlog.h" +#include "access/undolog.h" +#include "access/undorecord.h" +#include "access/xactundo.h" #include "access/xact.h" #include "access/xlog.h" #include "access/xloginsert.h" @@ -217,6 +223,7 @@ typedef struct TransactionStateData bool parallelChildXact; /* is any parent transaction parallel? */ bool chain; /* start a new block after this one */ bool topXidLogged; /* for a subxact: is top-level XID logged? */ + uint64 undoRecPtr; /* most recent UNDO record in chain */ struct TransactionStateData *parent; /* back link to parent */ } TransactionStateData; @@ -316,6 +323,23 @@ typedef struct XactCallbackItem static XactCallbackItem *Xact_callbacks = NULL; +/* + * Table-AM hook for AtPrepare work that must run between StartPrepare() and + * EndPrepare() (see access/xact.h for why this can't be an ordinary + * XactCallback). NULL when no such AM is compiled in. + */ +void (*TableAMPrepare_hook) (void) = NULL; + +/* + * Pending-structural-file-operation hooks, mirroring smgr's + * own pending-deletes housekeeping ordering (see access/xact.h). NULL when + * no such subsystem is compiled in. + */ +void (*PendingPhysOpsDo_hook) (bool isCommit) = NULL; +void (*PendingPhysOpsPostPrepare_hook) (void) = NULL; +void (*PendingPhysOpsAtSubCommit_hook) (void) = NULL; +void (*PendingPhysOpsAtSubAbort_hook) (void) = NULL; + /* * List of add-on start- and end-of-subxact callbacks */ @@ -418,6 +442,37 @@ IsAbortedTransactionBlockState(void) } +/* + * EnterInlineUndoApplyState / LeaveInlineUndoApplyState + * + * AtAbort_XactUndo() may apply UNDO records synchronously in this backend + * while AbortTransaction() has already set s->state = TRANS_ABORT but has + * not yet torn down the relcache, locks, or resource owner. UNDO appliers + * (e.g. a table AM's undo-apply callback) need to open relations, which trips + * Assert(IsTransactionState()) because TRANS_ABORT is not TRANS_INPROGRESS. + * + * These helpers temporarily present TRANS_INPROGRESS for the duration of the + * inline apply. This is safe because all backing resources are still live at + * this point in the abort sequence; only the state enum has advanced. The + * caller MUST pair Enter/Leave (use PG_TRY/PG_FINALLY) so the real abort state + * is always restored before the rest of AbortTransaction() proceeds. + */ +int +EnterInlineUndoApplyState(void) +{ + TransactionState s = CurrentTransactionState; + int saved = (int) s->state; + + s->state = TRANS_INPROGRESS; + return saved; +} + +void +LeaveInlineUndoApplyState(int saved) +{ + CurrentTransactionState->state = (TransState) saved; +} + /* * GetTopTransactionId * @@ -1123,6 +1178,36 @@ IsInParallelMode(void) return s->parallelModeLevel != 0 || s->parallelChildXact; } +/* + * SetCurrentTransactionUndoRecPtr + * Set the most recent UNDO record pointer for the current transaction. + * + * Called from heap_insert/delete/update when they generate UNDO records. + * The pointer is used during abort to walk the UNDO chain and apply + * compensation operations. + */ +void +SetCurrentTransactionUndoRecPtr(uint64 undo_ptr) +{ + TransactionState s = CurrentTransactionState; + + s->undoRecPtr = undo_ptr; +} + +/* + * GetCurrentTransactionUndoRecPtr + * Get the most recent UNDO record pointer for the current transaction. + * + * Returns InvalidUndoRecPtr (0) if no UNDO records have been generated. + */ +uint64 +GetCurrentTransactionUndoRecPtr(void) +{ + TransactionState s = CurrentTransactionState; + + return s->undoRecPtr; +} + /* * CommandCounterIncrement */ @@ -2143,6 +2228,7 @@ StartTransaction(void) s->childXids = NULL; s->nChildXids = 0; s->maxChildXids = 0; + s->undoRecPtr = 0; /* no UNDO records yet */ /* * Once the current user ID and the security context flags are fetched, @@ -2449,6 +2535,9 @@ CommitTransaction(void) CallXactCallbacks(is_parallel_worker ? XACT_EVENT_PARALLEL_COMMIT : XACT_EVENT_COMMIT); + /* Clean up transaction undo state (free per-persistence record sets) */ + AtCommit_XactUndo(); + CurrentResourceOwner = NULL; ResourceOwnerRelease(TopTransactionResourceOwner, RESOURCE_RELEASE_BEFORE_LOCKS, @@ -2493,6 +2582,8 @@ CommitTransaction(void) * attempt to access affected files. */ smgrDoPendingDeletes(true); + if (PendingPhysOpsDo_hook) + PendingPhysOpsDo_hook(true); /* * Send out notification signals to other backends (and do other @@ -2669,6 +2760,29 @@ PrepareTransaction(void) (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("cannot PREPARE a transaction that has exported snapshots"))); + /* + * Don't allow PREPARE TRANSACTION if this transaction generated any + * UNDO -- cluster-wide (via InsertUndoRecord) or per-relation (via the + * per-relation UNDO fork). Neither has a working ROLLBACK PREPARED apply + * path yet: per-relation UNDO's before-images live in backend-private, + * CurTransactionContext-allocated state (XactUndo.relundo_list) that is + * gone the moment this function returns; cluster-wide UNDO's chain-head + * LSN is durably saved in the 2PC state file + * (xl_xact_prepare.last_batch_lsn) but nothing on the COMMIT PREPARED / + * ROLLBACK PREPARED path or crash-recovery path ever reads it back or + * calls into the UNDO apply machinery (confirmed: twophase.c's + * RecordTransactionCommitPrepared/RecordTransactionAbortPrepared never + * call AtAbort_XactUndo() or ATMAddAborted(), the only two entry points + * that trigger UNDO application). Silently proceeding would make ROLLBACK + * PREPARED a no-op for either kind of UNDO, corrupting data. Reject + * early, before StartPrepare() writes any 2PC state. + */ + if (XactUndoHasUnrecoverableUndo()) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("cannot PREPARE a transaction that generated UNDO"), + errhint("Commit or roll back the transaction without PREPARE TRANSACTION."))); + /* Prevent cancel/die interrupt while cleaning up */ HOLD_INTERRUPTS(); @@ -2781,6 +2895,8 @@ PrepareTransaction(void) PostPrepare_Inval(); PostPrepare_smgr(); + if (PendingPhysOpsPostPrepare_hook) + PendingPhysOpsPostPrepare_hook(); PostPrepare_MultiXact(fxid); @@ -2928,6 +3044,25 @@ AbortTransaction(void) TransStateAsString(s->state)); Assert(s->parent == NULL); + /* + * Discard the UNDO record pointer for this transaction. + * + * Physical UNDO application is NOT needed during standard transaction + * abort because PostgreSQL's MVCC-based heap already handles rollback + * through CLOG: the aborting transaction's xid is marked as aborted in + * CLOG, and subsequent visibility checks will ignore changes made by this + * transaction. INSERT tuples become invisible (eventually pruned), + * DELETE/UPDATE changes are ignored (old tuple versions remain visible). + * + * Physical UNDO application is intended for cases where the page has been + * modified in-place and the old state cannot be recovered through CLOG + * alone (e.g., in ZHeap-style in-place updates, or after pruning has + * removed old tuple versions). The UNDO records written during this + * transaction are preserved in the UNDO log for use by the undo worker, + * crash recovery, or future in-place update mechanisms. + */ + s->undoRecPtr = 0; + /* * set the current transaction state information appropriately during the * abort processing @@ -2963,6 +3098,9 @@ AbortTransaction(void) s->parallelModeLevel = 0; s->parallelChildXact = false; /* should be false already */ + /* Clean up transaction undo state (free per-persistence record sets) */ + AtAbort_XactUndo(); + /* * do abort processing */ @@ -3030,7 +3168,18 @@ AbortTransaction(void) ResourceOwnerRelease(TopTransactionResourceOwner, RESOURCE_RELEASE_AFTER_LOCKS, false, true); + + /* + * Wait for any pending synchronous per-relation UNDO worker to finish. + * Done after lock release so the worker can acquire its own lock on the + * target relation, making per-relation rollback synchronous from the + * client's point of view. + */ + WaitForPendingRelUndo(); + smgrDoPendingDeletes(false); + if (PendingPhysOpsDo_hook) + PendingPhysOpsDo_hook(false); AtEOXact_GUC(false, 1); AtEOXact_SPI(false); @@ -5217,6 +5366,8 @@ CommitSubTransaction(void) AtEOSubXact_TypeCache(); AtEOSubXact_Inval(true); AtSubCommit_smgr(); + if (PendingPhysOpsAtSubCommit_hook) + PendingPhysOpsAtSubCommit_hook(); /* * The only lock we actually release here is the subtransaction XID lock. @@ -5408,6 +5559,8 @@ AbortSubTransaction(void) RESOURCE_RELEASE_AFTER_LOCKS, false, false); AtSubAbort_smgr(); + if (PendingPhysOpsAtSubAbort_hook) + PendingPhysOpsAtSubAbort_hook(); AtEOXact_GUC(false, s->gucNestLevel); AtEOSubXact_SPI(false, s->subTransactionId); @@ -6434,6 +6587,12 @@ xact_redo(XLogReaderState *record) ParseCommitRecord(XLogRecGetInfo(record), xlrec, &parsed); xact_redo_commit(&parsed, XLogRecGetXid(record), record->EndRecPtr, XLogRecGetOrigin(record)); + + /* + * Remove from UNDO recovery tracking — committed, no rollback + * needed + */ + UndoRecoveryRemoveXid(XLogRecGetXid(record)); } else if (info == XLOG_XACT_COMMIT_PREPARED) { @@ -6448,6 +6607,9 @@ xact_redo(XLogReaderState *record) LWLockAcquire(TwoPhaseStateLock, LW_EXCLUSIVE); PrepareRedoRemove(parsed.twophase_xid, false); LWLockRelease(TwoPhaseStateLock); + + /* Remove from UNDO recovery tracking */ + UndoRecoveryRemoveXid(parsed.twophase_xid); } else if (info == XLOG_XACT_ABORT) { @@ -6457,6 +6619,13 @@ xact_redo(XLogReaderState *record) ParseAbortRecord(XLogRecGetInfo(record), xlrec, &parsed); xact_redo_abort(&parsed, XLogRecGetXid(record), record->EndRecPtr, XLogRecGetOrigin(record)); + + /* + * Remove from UNDO recovery tracking — abort record present means + * the UNDO rollback was already completed (or will be handled by the + * abort record's own redo logic). + */ + UndoRecoveryRemoveXid(XLogRecGetXid(record)); } else if (info == XLOG_XACT_ABORT_PREPARED) { @@ -6471,12 +6640,22 @@ xact_redo(XLogReaderState *record) LWLockAcquire(TwoPhaseStateLock, LW_EXCLUSIVE); PrepareRedoRemove(parsed.twophase_xid, false); LWLockRelease(TwoPhaseStateLock); + + /* Remove from UNDO recovery tracking */ + UndoRecoveryRemoveXid(parsed.twophase_xid); } else if (info == XLOG_XACT_PREPARE) { + xl_xact_prepare *xlrec = (xl_xact_prepare *) XLogRecGetData(record); + /* * Store xid and start/end pointers of the WAL record in TwoPhaseState * gxact entry. + * + * NB: xl_xact_prepare includes last_batch_lsn[NUndoPersistenceLevels] + * for UNDO chain tracking across 2PC boundaries. This extended the + * on-disk struct by 24 bytes and required a XLOG_PAGE_MAGIC bump + * (0xD120 -> 0xD121) to prevent misinterpretation by older replicas. */ LWLockAcquire(TwoPhaseStateLock, LW_EXCLUSIVE); PrepareRedoAdd(InvalidFullTransactionId, @@ -6485,6 +6664,21 @@ xact_redo(XLogReaderState *record) record->EndRecPtr, XLogRecGetOrigin(record)); LWLockRelease(TwoPhaseStateLock); + + /* + * Restore UNDO recovery tracking for the prepared transaction. The + * UNDO chain LSNs were saved in the prepare record so that if the + * server crashes after PREPARE but before COMMIT/ROLLBACK PREPARED, + * recovery can still find and roll back UNDO records. + */ + for (int j = 0; j < NUndoPersistenceLevels; j++) + { + if (!XLogRecPtrIsInvalid(xlrec->last_batch_lsn[j])) + UndoRecoveryTrackBatch(xlrec->xid, + xlrec->last_batch_lsn[j], + InvalidXLogRecPtr, + (UndoPersistenceLevel) j); + } } else if (info == XLOG_XACT_ASSIGNMENT) { diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index b23d8bbbdad60..1bae88257416d 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -46,6 +46,7 @@ #include #include +#include "access/atm.h" #include "access/clog.h" #include "access/commit_ts.h" #include "access/heaptoast.h" @@ -55,6 +56,8 @@ #include "access/timeline.h" #include "access/transam.h" #include "access/twophase.h" +#include "access/undolog.h" +#include "access/undo_xlog.h" #include "access/xact.h" #include "access/xlog_internal.h" #include "access/xlogarchive.h" @@ -6096,6 +6099,18 @@ StartupXLOG(void) */ restoreTwoPhaseData(); + /* + * Reload the Aborted Transaction Map from its checkpoint state file before + * the redo pass. The ATM is otherwise reconstructed only by replaying + * XLOG_ATM_ABORT / XLOG_ATM_FORGET, which redo cannot do for aborts whose + * records precede the checkpoint redo point. Reloading here -- before + * redo, exactly like restoreTwoPhaseData() above -- lets atm_redo's + * XLOG_ATM_FORGET replays correctly remove entries forgotten after the + * checkpoint, and XLOG_ATM_ABORT replays re-add (idempotently) entries + * aborted after it. + */ + ATMReloadFromCheckpoint(); + /* * When starting with crash recovery, reset pgstat data - it might not be * valid. Otherwise restore pgstat data. It's safe to do this here, @@ -6560,6 +6575,24 @@ StartupXLOG(void) if (performedWalRecovery) promoted = PerformRecoveryXLogAction(); + /* + * Finalize ATM state after recovery. WAL replay has reconstructed the + * Aborted Transaction Map via XLOG_ATM_ABORT and XLOG_ATM_FORGET redo + * handlers. Log a summary of entries that still need Logical Revert. + */ + if (performedWalRecovery) + ATMRecoveryFinalize(); + + /* + * Flush any deferred UNDO transactions to the ATM. During the UNDO + * phase, if syscache wasn't available, we deferred transaction + * processing. Now that recovery is complete and WAL writes are allowed + * (checkpoint/ end-of-recovery record was written above), we can add them + * to the ATM for asynchronous processing by the logical revert worker. + */ + if (performedWalRecovery) + FlushDeferredUndoXacts(); + /* * If any of the critical GUCs have changed, log them before we allow * backends to write WAL. @@ -7410,6 +7443,16 @@ CreateCheckPoint(int flags) VirtualTransactionId *vxids; int nvxids; int oldXLogAllowed = 0; + instr_time phase_start, + phase_end; + double syncpre_ms = 0, + delay_start_ms = 0, + delay_complete_ms = 0, + xlogflush_ms = 0, + ctlfile_ms = 0, + syncpost_ms = 0, + removewal_ms = 0, + truncsub_ms = 0; /* * An end-of-recovery checkpoint is really a shutdown checkpoint, just @@ -7440,7 +7483,11 @@ CreateCheckPoint(int flags) * smgr must not do anything that'd have to be undone if we decide no * checkpoint is needed. */ + INSTR_TIME_SET_CURRENT(phase_start); SyncPreCheckpoint(); + INSTR_TIME_SET_CURRENT(phase_end); + INSTR_TIME_SUBTRACT(phase_end, phase_start); + syncpre_ms = INSTR_TIME_GET_MILLISEC(phase_end); /* Run these points outside the critical section. */ INJECTION_POINT("create-checkpoint-initial", NULL); @@ -7692,6 +7739,7 @@ CreateCheckPoint(int flags) * clog and we will correctly flush the update below. So we cannot miss * any xacts we need to wait for. */ + INSTR_TIME_SET_CURRENT(phase_start); vxids = GetVirtualXIDsDelayingChkpt(&nvxids, DELAY_CHKPT_START); if (nvxids > 0) { @@ -7711,9 +7759,13 @@ CreateCheckPoint(int flags) DELAY_CHKPT_START)); } pfree(vxids); + INSTR_TIME_SET_CURRENT(phase_end); + INSTR_TIME_SUBTRACT(phase_end, phase_start); + delay_start_ms = INSTR_TIME_GET_MILLISEC(phase_end); CheckPointGuts(checkPoint.redo, flags); + INSTR_TIME_SET_CURRENT(phase_start); vxids = GetVirtualXIDsDelayingChkpt(&nvxids, DELAY_CHKPT_COMPLETE); if (nvxids > 0) { @@ -7728,6 +7780,9 @@ CreateCheckPoint(int flags) DELAY_CHKPT_COMPLETE)); } pfree(vxids); + INSTR_TIME_SET_CURRENT(phase_end); + INSTR_TIME_SUBTRACT(phase_end, phase_start); + delay_complete_ms = INSTR_TIME_GET_MILLISEC(phase_end); /* * Take a snapshot of running transactions and write this to WAL. This @@ -7751,7 +7806,11 @@ CreateCheckPoint(int flags) shutdown ? XLOG_CHECKPOINT_SHUTDOWN : XLOG_CHECKPOINT_ONLINE); + INSTR_TIME_SET_CURRENT(phase_start); XLogFlush(recptr); + INSTR_TIME_SET_CURRENT(phase_end); + INSTR_TIME_SUBTRACT(phase_end, phase_start); + xlogflush_ms = INSTR_TIME_GET_MILLISEC(phase_end); /* * We mustn't write any new WAL after a shutdown checkpoint, or it will be @@ -7785,6 +7844,7 @@ CreateCheckPoint(int flags) /* * Update the control file. */ + INSTR_TIME_SET_CURRENT(phase_start); LWLockAcquire(ControlFileLock, LW_EXCLUSIVE); if (shutdown) ControlFile->state = DB_SHUTDOWNED; @@ -7803,6 +7863,9 @@ CreateCheckPoint(int flags) UpdateControlFile(); LWLockRelease(ControlFileLock); + INSTR_TIME_SET_CURRENT(phase_end); + INSTR_TIME_SUBTRACT(phase_end, phase_start); + ctlfile_ms = INSTR_TIME_GET_MILLISEC(phase_end); /* * We are now done with critical updates; no need for system panic if we @@ -7832,7 +7895,11 @@ CreateCheckPoint(int flags) /* * Let smgr do post-checkpoint cleanup (eg, deleting old files). */ + INSTR_TIME_SET_CURRENT(phase_start); SyncPostCheckpoint(); + INSTR_TIME_SET_CURRENT(phase_end); + INSTR_TIME_SUBTRACT(phase_end, phase_start); + syncpost_ms = INSTR_TIME_GET_MILLISEC(phase_end); /* * Update the average distance between checkpoints if the prior checkpoint @@ -7861,8 +7928,12 @@ CreateCheckPoint(int flags) KeepLogSeg(recptr, &_logSegNo); } _logSegNo--; + INSTR_TIME_SET_CURRENT(phase_start); RemoveOldXlogFiles(_logSegNo, RedoRecPtr, recptr, checkPoint.ThisTimeLineID); + INSTR_TIME_SET_CURRENT(phase_end); + INSTR_TIME_SUBTRACT(phase_end, phase_start); + removewal_ms = INSTR_TIME_GET_MILLISEC(phase_end); /* * Make more log segments if needed. (Do this after recycling old log @@ -7878,8 +7949,24 @@ CreateCheckPoint(int flags) * in subtrans.c). During recovery, though, we mustn't do this because * StartupSUBTRANS hasn't been called yet. */ + INSTR_TIME_SET_CURRENT(phase_start); if (!RecoveryInProgress()) TruncateSUBTRANS(GetOldestTransactionIdConsideredRunning()); + INSTR_TIME_SET_CURRENT(phase_end); + INSTR_TIME_SUBTRACT(phase_end, phase_start); + truncsub_ms = INSTR_TIME_GET_MILLISEC(phase_end); + + /* Log phase breakdown for diagnosing slow checkpoints. */ + if (log_checkpoints) + ereport(LOG, + (errmsg("checkpoint phase breakdown: " + "SyncPre=%.3f s, DelayStart=%.3f s, DelayComplete=%.3f s, " + "XLogFlush=%.3f s, ControlFile=%.3f s, SyncPost=%.3f s, " + "RemoveWAL=%.3f s, TruncSub=%.3f s", + syncpre_ms / 1000.0, delay_start_ms / 1000.0, + delay_complete_ms / 1000.0, xlogflush_ms / 1000.0, + ctlfile_ms / 1000.0, syncpost_ms / 1000.0, + removewal_ms / 1000.0, truncsub_ms / 1000.0))); /* Real work is done; log and update stats. */ LogCheckpointEnd(false, flags); @@ -8051,6 +8138,9 @@ CheckPointGuts(XLogRecPtr checkPointRedo, int flags) CheckPointRelationMap(); CheckPointReplicationOrigin(); + /* Persist UNDO log discard pointers and log statistics */ + CheckPointUndoLog(); + /* Write out all dirty data in SLRUs and the main buffer pool */ TRACE_POSTGRESQL_BUFFER_CHECKPOINT_START(flags); CheckpointStats.ckpt_write_t = GetCurrentTimestamp(); @@ -8080,6 +8170,13 @@ CheckPointGuts(XLogRecPtr checkPointRedo, int flags) CheckPointSnapBuild(); CheckPointLogicalRewriteHeap(); CheckPointTwoPhase(checkPointRedo); + + /* + * Persist the Aborted Transaction Map so it survives a crash even when + * this checkpoint advances the redo pointer past an un-forgotten + * XLOG_ATM_ABORT record (see CheckPointATM / ATMReloadFromCheckpoint). + */ + CheckPointATM(); } /* @@ -8549,6 +8646,26 @@ KeepLogSeg(XLogRecPtr recptr, XLogSegNo *logSegNo) segno = unsummarized_segno; } + /* + * If UNDO-in-WAL is active, retain WAL segments that contain UNDO records + * still needed for rollback of in-progress transactions. + * + * Scan live per-backend UNDO batch LSN slots at every checkpoint rather + * than using the worker-updated cached horizon, to ensure WAL retention + * is accurate even when the UNDO worker lags. + */ + { + keep = UndoGetOldestBatchLSN(); + if (XLogRecPtrIsValid(keep)) + { + XLogSegNo undo_segno; + + XLByteToSeg(keep, undo_segno, wal_segment_size); + if (undo_segno < segno) + segno = undo_segno; + } + } + /* but, keep at least wal_keep_size if that's set */ if (wal_keep_size_mb > 0) { diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index 5f3b065b8942e..980c4e00974fb 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -30,8 +30,11 @@ #include #include +#include "access/relundo.h" #include "access/timeline.h" #include "access/transam.h" +#include "access/undo_xlog.h" +#include "access/undolog.h" #include "access/xact.h" #include "access/xlog_internal.h" #include "access/xlogarchive.h" @@ -1860,6 +1863,50 @@ PerformWalRecovery(void) (errmsg("last completed transaction was at log time %s", timestamptz_to_str(xtime)))); + /* + * ARIES-style undo phase: roll back incomplete transactions that + * wrote UNDO records (XLOG_UNDO_BATCH) but did not commit. + * + * During the redo phase above, UndoRecoveryTrackBatch() was called + * from the XLOG_UNDO_BATCH redo handler to record which transactions + * have UNDO data. UndoRecoveryRemoveXid() was called from the + * XLOG_XACT_COMMIT and XLOG_XACT_ABORT redo handlers to remove + * completed transactions. Any remaining entries represent incomplete + * transactions that need their UNDO chains walked for rollback. + * + * We check UndoRecoveryNeeded() to avoid overhead when no UNDO + * records were present in the WAL stream. + */ + if (UndoRecoveryNeeded()) + { + ereport(LOG, + (errmsg("starting undo phase for incomplete transactions"))); + PerformUndoRecovery(); + ereport(LOG, + (errmsg("undo phase complete"))); + } + + /* + * Reverse-apply per-relation UNDO (in-place before-images) for + * loser transactions. Like PerformUndoRecovery() above, this runs + * before WAL insertion is enabled, so it writes no WAL; durability is + * provided by the end-of-recovery checkpoint. + * + * Unlike the cluster-wide UNDO phase, this is driven by an end-of-redo + * scan of the UNDO forks on disk rather than redo-time tracking: a + * CHECKPOINT taken after an uncommitted in-place UPDATE advances the + * redo start past that UPDATE's WAL, so nothing is replayed for it even + * though its uncommitted value is durable. + * + * This block is reached only once redo has finished -- at crash + * recovery end or at standby promotion. A streaming hot standby never + * arrives here. At promotion StandbyMode is still set (it is cleared + * later in FinishWalRecovery), so gating on !StandbyMode would skip the + * scan for exactly the promoted-standby case that needs loser rollback. + * Run it unconditionally, matching PerformUndoRecovery() above. + */ + PerformRelUndoRecovery(); + InRedo = false; } else diff --git a/src/backend/access/undo/Makefile b/src/backend/access/undo/Makefile new file mode 100644 index 0000000000000..3599eeed4cd5c --- /dev/null +++ b/src/backend/access/undo/Makefile @@ -0,0 +1,41 @@ +#------------------------------------------------------------------------- +# +# Makefile-- +# Makefile for access/undo +# +# IDENTIFICATION +# src/backend/access/undo/Makefile +# +#------------------------------------------------------------------------- + +subdir = src/backend/access/undo +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global + +OBJS = \ + atm.o \ + logical_revert_worker.o \ + relundo.o \ + relundo_apply.o \ + relundo_discard.o \ + relundo_page.o \ + relundo_recovery.o \ + relundo_worker.o \ + relundo_xlog.o \ + slog.o \ + slog_flathash.o \ + slog_tuple.o \ + undo.o \ + undo_bufmgr.o \ + undo_xlog.o \ + undoapply.o \ + undobuffer.o \ + undoinsert.o \ + undolog.o \ + undorecord.o \ + undormgr.o \ + undostats.o \ + undoworker.o \ + xactundo.o + +include $(top_srcdir)/src/backend/common.mk diff --git a/src/backend/access/undo/README b/src/backend/access/undo/README new file mode 100644 index 0000000000000..9990747bd8639 --- /dev/null +++ b/src/backend/access/undo/README @@ -0,0 +1,1175 @@ +UNDO Log Management for PostgreSQL +=================================== + +This directory contains the implementation of the generic UNDO log system +for PostgreSQL, providing transactional UNDO logging for heap tuple +operations, transaction rollback, and point-in-time data recovery. + +## 1. Architecture Overview + +The UNDO system adds a separate, append-only log that records the inverse +of each data modification. Every INSERT, DELETE, UPDATE, and PRUNE +operation on an UNDO-enabled table writes a record to the UNDO log +before (or just after, for INSERT) the actual modification. This +enables two key capabilities: + + 1. **Transaction rollback**: On ABORT, the UNDO chain is walked backward + and each operation is reversed (delete the inserted row, re-insert + the deleted row, etc.). + + 2. **Point-in-time recovery**: Pruned tuples (removed by HOT pruning + or VACUUM) are preserved in the UNDO log and can be recovered via + UNDO chain traversal during rollback or by the logical revert + worker, even after the original data pages have been reclaimed. + +### UNDO Chain Model + +Each transaction that modifies an UNDO-enabled table builds a backward +chain of UNDO records: + + newest record --> ... --> oldest record + (currentUndoPtr) (firstUndoPtr) + +The chain is linked through the `urec_prev` field in each record header. +During rollback, the chain is traversed from `firstUndoPtr` forward +through the contiguous buffer written by UndoRecordSetInsert, then +follows `urec_prev` links to earlier batches. + +Subtransaction commit merges the child's chain into the parent. +Subtransaction abort applies the child's chain immediately. + +### Opt-In Model + +UNDO is always-on infrastructure. Table access methods opt in by +implementing the am_supports_undo callback. System catalogs never use UNDO. + +### Two UNDO Facilities: Cluster-Wide UndoLog vs. Per-Relation RelUndo + +The subsystem provides two distinct UNDO facilities, and a consumer chooses +the one whose locality and discard model fits its workload. They are not +redundant; each exists because the other is a poor fit for one class of +consumer. + + 1. **Cluster-wide UndoLog** (undolog.c, undorecord.c, undoapply.c): + one logical, append-only UNDO stream shared by all consumers, embedded + in the WAL (XLOG_UNDO_BATCH). Discard is governed globally by a single + cluster-wide horizon (undo_discard_horizon): a batch's WAL is retained + until every transaction that could still need it has resolved, then the + WAL segment is recycled normally. This suits **sparse, cross-object** + UNDO -- a transaction that touches a handful of unrelated objects (for + example filesystem operations, or an index AM logging a few structural + changes) writes one interleaved chain, and the single global horizon is + the right granularity because such operations are not concentrated on + any one relation. + + 2. **Per-relation RelUndo fork** (relundo.c, relundo_page.c, + relundo_apply.c, RELUNDO_FORKNUM): a dedicated UNDO fork living + alongside each relation's data, discarded per-relation on that + relation's own schedule (RelUndoVacuum / RelUndoMaybeVacuum). This + suits a **high-churn in-place-update table AM**, where UNDO volume is + dominated by one relation being updated repeatedly. Two properties + make the per-relation fork the right choice there: + + - **Locality.** Before-images for a relation's tuples live in that + relation's fork, next to the data they reverse, rather than + interleaved with every other consumer's records in one global + stream. Reverse-applying a chain, or reconstructing an older + version for a snapshot, reads one relation's fork, not the whole + cluster-wide log. + + - **Per-relation discard.** A hot table can reclaim its own UNDO the + instant its own oldest interesting snapshot advances, without being + pinned by an unrelated long-running transaction elsewhere in the + cluster. Under the single cluster-wide horizon, one long reader + anywhere would pin every consumer's UNDO; the per-relation fork + confines that back-pressure to the relation that actually has the + old snapshot. + + A cluster-wide consumer never touches the RelUndo fork, and an in-place + table AM never has to share the global horizon with sparse cross-object + UNDO. The two facilities share only the UNDO record header format and the + rollback-dispatch machinery (undormgr.c); their storage and discard paths + are independent. + + +## Recovery Model: Constant-Time Recovery (CTR), not ARIES + +This UNDO system implements Constant-Time Recovery (CTR), the model from +Antonopoulos et al., "Constant Time Recovery in Azure SQL Database" +(VLDB 2019) -- see "References" below. This is a deliberate design +choice, not an incomplete or informal implementation of ARIES's classic +analysis/redo/undo three-phase model with a backward log scan. The two +models differ in a way that matters operationally: under ARIES, +database-open after a crash waits for the undo phase (rolling back every +loser transaction) to finish; under CTR, rollback of already-committed +work is instantaneous from the client's perspective and physical +undo/rollback is deferred to background work that runs after (and +overlapping with) new transactions, not before them. + +The two phases are: + +**Redo (physical, synchronous, ordinary WAL replay):** Standard +PostgreSQL WAL replay applies all logged changes forward from the +checkpoint redo point, including XLOG_UNDO_BATCH records (which contain +UNDO payload data, not a rollback action) and any XLOG_UNDO_APPLY_RECORD +/ XLOG_RELUNDO_APPLY CLRs already generated before the crash. This is +exactly PostgreSQL's normal, single forward pass over WAL -- there is no +separate "analysis" pass building an in-memory dirty-page table the way +ARIES does; CLOG reconstruction during ordinary redo is what lets a +later step distinguish committed from aborted work. + +**Undo / rollback (logical, deferred, asynchronous):** Aborting a +transaction does NOT synchronously replay its UNDO chain the way ARIES's +undo phase does. Instead: + + - At ABORT time, xactundo.c's AtAbort_XactUndo() classifies the + transaction by UNDO volume (undo_instant_abort_threshold, default + 64KB). Small transactions apply their UNDO chain inline in the + aborting backend before ROLLBACK returns (still O(1) from the + client's perspective for typical row counts). Large transactions + take the "instant abort" path: ATMAddAborted() records the xid as + aborted in the shared-memory Aborted Transaction Map (ATM, atm.c) and + returns immediately -- ROLLBACK latency is O(1) regardless of how + much UNDO the transaction wrote, indistinguishable from a CLOG-only + abort. + - The ATM is sLog-backed (see access/slog.h): it is consulted by + an in-place-update AM's own visibility checks so that an aborted + transaction's writes are invisible to every reader immediately, before + any physical undo has run. + - A background Logical Revert worker (logical_revert_worker.c) scans + the ATM for entries not yet marked reverted and calls + ApplyUndoChainFromWAL() to physically walk and apply each one's UNDO + chain, generating a CLR (XLOG_UNDO_APPLY_RECORD) per page it + restores for crash-safety. This work is fully decoupled from + ROLLBACK's return to the client. + +This is the CTR guarantee stated precisely: physical redo is ordinary, +synchronous WAL replay (same cost and semantics as if UNDO did not +exist); logical undo of aborted work is asynchronous and does not block +transaction throughput, database open after crash, or any other +transaction's forward progress. It is not a defect that rollback +"doesn't happen immediately" for large transactions -- that deferral is +the entire point of the design. + +**Crash recovery's role:** PerformUndoRecovery() (undo_xlog.c), called +from the redo pass, does the CTR analog of ARIES's "redo incomplete +undo": each XLOG_UNDO_BATCH record is registered in an in-memory table +keyed by XID as it is replayed, and removed when a commit or abort +record for that XID is later replayed. After the forward redo pass +completes, whatever entries remain represent transactions that wrote +UNDO data but reached neither COMMIT nor ABORT before the crash -- +treated the same as any other in-flight-at-crash transaction, their UNDO +chains are walked and applied during this recovery pass (this is the one +place synchronous UNDO application happens on the recovery path itself, +because there is no live backend left to defer it to). Ordinary +already-aborted-before-the-crash transactions are handled by the ATM / +Logical Revert worker exactly as in normal runtime operation, not by a +special recovery-time undo phase. + +**Inter-transaction UNDO ordering:** Records are applied per-transaction in +newest-batch-first order within each transaction. No global LSN ordering across +concurrent aborted transactions is enforced. This is safe because PostgreSQL's +locking model prevents two concurrent transactions from holding conflicting physical +locks on the same tuple -- there can be no conflicting physical UNDO operations +between concurrent transactions. + +**Idempotency (crash-during-rollback safety):** urec_clr_ptr, the field +named below in the UndoRecordHeader layout, is NOT how idempotency is +achieved for either mechanism in this system -- see "CLR idempotency" +below for the corrected mechanism. + +**TEMP and UNLOGGED skip:** During crash recovery, UNDOPERSISTENCE_TEMP records +are skipped (temporary tables do not survive server restart) and +UNDOPERSISTENCE_UNLOGGED records are skipped (unlogged table data forks are reset +to their empty init fork on crash recovery). This mirrors the behavior of the +standard heap AM for these persistence levels. + +## 2. UndoRecPtr Format + +UndoRecPtr is a 64-bit pointer encoding both log identity and position: + + Bits 63-40: Log number (24 bits = up to 16M logs) + Bits 39-0: Byte offset (40 bits = up to 1TB per log) + + #define MakeUndoRecPtr(logno, offset) (((uint64)(logno) << 40) | (uint64)(offset)) + #define UndoRecPtrGetLogNo(ptr) ((uint32)(((uint64)(ptr)) >> 40)) + #define UndoRecPtrGetOffset(ptr) (((uint64)(ptr)) & 0xFFFFFFFFFFULL) + +InvalidUndoRecPtr is defined as 0. Log number 0 is never allocated +(next_log_number starts at 1), so offset 0 in log 0 is always invalid. + +## 3. UNDO Record Format + +Every UNDO record starts with a fixed UndoRecordHeader (see undorecord.h). +The serialized size is given by SizeOfUndoRecordHeader: + + Offset Size Field Description + ------ ---- ----- ----------- + 0 1 urec_rmid UNDO resource manager ID (dispatches apply) + 1 1 urec_flags Generic flags (UNDO_INFO_HAS_PAYLOAD, etc.) + 2 2 urec_info RM-specific subtype and flags + 4 4 urec_len Total record length including header + payload + 8 4 urec_xid Transaction ID + 12 4 (padding) Alignment for 8-byte urec_prev + 16 8 urec_prev Previous UNDO record in chain (UndoRecPtr) + 24 4 urec_reloid Relation OID (InvalidOid if N/A) + 28 4 urec_payload_len Length of RM-specific payload that follows + 32 8 urec_clr_ptr Always InvalidXLogRecPtr; see below + +The header is AM-agnostic. The urec_rmid field identifies the resource manager +that owns the record. Block number, offset, and tuple data are part of the +RM-specific opaque payload, not the generic header. + +The urec_clr_ptr field is NOT the idempotency mechanism its name suggests. +UNDO records are immutable once written into the WAL stream (they live +inside XLOG_UNDO_BATCH records, and WAL is append-only/immutable), so +there is no way to write a CLR's LSN back into a urec_clr_ptr field after +the fact -- every write site sets it to InvalidXLogRecPtr +(undorecord.c, undobuffer.c) and it is never updated afterward. +undoapply.c's own idempotency-design comment on ApplyOneUndoRecord() +states this explicitly. The real idempotency mechanism is full-page-image ++ page-LSN comparison during ordinary WAL redo; see "CLR idempotency, +corrected" below. + +### Record Types (Heap RM, urec_rmid = UNDO_RMID_HEAP) + +The urec_info field carries the RM-specific record type: + + UNDO_INSERT (0x0001) Marks an INSERT; no tuple payload needed. + Rollback: ItemId marked dead (indexed) or unused. + + UNDO_DELETE (0x0002) Stores the full old tuple. + Rollback: memcpy old tuple bytes back to page. + + UNDO_UPDATE (0x0003) Stores the old tuple version. + Rollback: memcpy old tuple bytes to original location. + + UNDO_PRUNE (0x0004) Stores a pruned tuple (LP_DEAD or LP_UNUSED). + Not rolled back; retained for diagnostics. + + UNDO_INPLACE (0x0005) Stores old data from in-place update. + Rollback: memcpy old tuple bytes in place. + +Other resource managers (e.g., nbtree with UNDO_RMID_BTREE) define their own +record types in their own urec_info space. + +### Payload + +The payload is an opaque byte sequence whose interpretation is entirely +RM-specific. For the heap RM, DELETE/UPDATE/PRUNE/INPLACE payloads contain +a small RM-specific header (block number, offset, tuple length) followed by +the raw HeapTupleHeader data (t_data). INSERT records have no payload +(urec_payload_len = 0). + +## 4. Storage Architecture (UNDO-in-WAL) + +UNDO records are embedded directly in the standard WAL stream as +XLOG_UNDO_BATCH records. There are NO separate UNDO segment files or +directories. The previous design using $PGDATA/base/undo/ flat files +was removed in favor of UNDO-in-WAL, which eliminates a separate storage +tier and leverages existing WAL infrastructure for durability, replication, +and archival. + +WAL retention of UNDO batches is governed by undo_discard_horizon, which +is the oldest XLogRecPtr still needed by either: + (a) an in-flight transaction that may abort (always retained), or + (b) the Logical Revert Worker's pending queue (ATM entries). + +UNDO records for unresolved (uncommitted/unaborted) transactions are +NEVER discarded regardless of any retention timer. + +## 5. Module Organization + +The undo subsystem is split into several modules with clean separation +of concerns, following the architecture of the EDB undo-record-set branch: + + undo.c - Central coordination: UndoShmemSize/UndoShmemInit + aggregates all subsystem shared memory needs. + UndoContext memory context management. + + undolog.c - UNDO log control structures and WAL batch coordination. + UndoLogControl/UndoLogSharedData structures. + + undorecord.c - UndoRecordSet and UndoRecordHeader: record format, + serialization, deserialization, and batch buffering. + + xactundo.c - Per-transaction undo management. Maintains up to 3 + UndoRecordSets per transaction (one per persistence + level: permanent, unlogged, temporary). Hooks into + xact.c via AtCommit/AtAbort_XactUndo. + + undoapply.c - Physical undo application during rollback. Walks the + undo chain backward and applies page-level restores + via memcpy. Generates CLRs for crash safety. + + undoinsert.c - Batch insertion of accumulated records into undo log. + + undo_xlog.c - WAL redo routines for the RM_UNDO_ID resource manager. + Handles CLR replay (XLOG_UNDO_APPLY_RECORD) using + full page images via XLogReadBufferForRedo. + + undo_bufmgr.c - Buffer management mapping undo logs into shared_buffers. + Virtual RelFileLocator: spcOid=1663, dbOid=9, + relNumber=log_number. + + undostats.c - Statistics and monitoring functions. + + undoworker.c - Background worker for undo record discard. + + undormgr.c - UNDO resource manager registry. RegisterUndoRmgr() + allows any AM to register an rm_undo callback keyed + by urec_rmid. undoapply.c dispatches to these callbacks. + + undobuffer.c - AM-agnostic Tier 2 UNDO write buffer. Accumulates + serialized UndoRecordHeaders in a per-backend buffer, + embedded into DML WAL records or flushed as standalone + XLOG_UNDO_BATCH records. Used by heapam and nbtree. + +### Key Types (from undodefs.h) + + UndoRecPtr - 64-bit pointer to an undo record + UndoPersistenceLevel - Enum: PERMANENT, UNLOGGED, TEMP + NUndoPersistenceLevels - 3 (array index bound) + UndoRecordSet - Opaque batch container for undo records + UndoRecordSetType - URST_TRANSACTION, URST_MULTI, URST_EPHEMERAL + UndoRecordSetChunkHeader - On-disk chunk header for multi-chunk sets + +### Initialization Flow + + ipci.c calls UndoShmemSize() and UndoShmemInit() from undo.c which + in turn calls each subsystem: + + UndoShmemSize() = UndoLogShmemSize() + + XactUndoShmemSize() + + UndoWorkerShmemSize() + + UndoShmemInit() -> UndoLogShmemInit() + -> XactUndoShmemInit() + -> UndoWorkerShmemInit() + + Per-backend initialization is done by InitializeUndo() which calls + InitializeXactUndo() and registers the exit callback. + +## 6. Shared Memory Structures (detail) + +### UndoLogSharedData + +Global control structure in shared memory: + + - logs[MAX_UNDO_LOGS] Array of UndoLogControl (one per active log) + - next_log_number Counter for allocating new log numbers + - allocation_lock LWLock protecting log allocation + +### UndoLogControl + +Per-log metadata (one per active log slot): + + - log_number Log file identity + - insert_ptr UndoRecPtr of next insertion position + - discard_ptr UndoRecPtr; data before this has been discarded + - oldest_xid Oldest transaction still referencing this log + - lock LWLock protecting concurrent access + - in_use Whether this slot is active + +### UNDO Buffer Manager (undo_bufmgr.c) + +UNDO log blocks are managed through PostgreSQL's standard shared_buffers +pool via undo_bufmgr.c. Each undo log is mapped to a virtual +RelFileLocator (spcOid=1663, dbOid=UNDO_DB_OID=9, relNumber=log_number) +and accessed via ReadBufferWithoutRelcache(). This provides: + + - Unified buffer management (no separate cache to tune) + - Automatic clock-sweep eviction via shared_buffers + - Built-in dirty buffer tracking and checkpoint support + - Standard buffer locking and pin semantics + +## 7. Physical UNDO Application (undoapply.c) + +The core design decision is **physical** UNDO application: during rollback, +stored tuple data is copied directly back to heap pages via memcpy, rather +than using logical operations (simple_heap_delete, simple_heap_insert). + +### Why Physical Over Logical + +The previous implementation used logical operations which went through the +full executor path, triggered index updates, generated WAL, and could fail +visibility checks. The physical rewrite follows ZHeap's approach: + + Physical (current): + - Stores: Complete tuple data (HeapTupleHeaderData + payload) + - Apply: Direct memcpy to restore exact page state + - Safety: Cannot fail (no page-full, no toast, no index conflicts) + - WAL: CLR with full page image (~8 KB per record) + + Logical (previous / future for table AMs): + - Stores: Operation metadata (INSERT/DELETE/UPDATE type + TID) + - Apply: Reconstruct operation using table AM logic + - Safety: Can fail on page-full, toast complications, visibility checks + - WAL: Standard heap WAL records (~50-100 bytes per record) + +### Critical Section Pattern + +Each UNDO application follows this pattern (from ApplyOneUndoRecord): + + 1. Open relation with RowExclusiveLock + 2. ReadBuffer to get the target page + 3. LockBuffer(BUFFER_LOCK_EXCLUSIVE) + 4. START_CRIT_SECTION + 5. Physical modification (memcpy / ItemId manipulation) + 6. MarkBufferDirty + 7. Generate CLR via XLogInsert(RM_UNDO_ID, XLOG_UNDO_APPLY_RECORD) + with REGBUF_FORCE_IMAGE for full page image + 8. PageSetLSN(page, lsn) + 9. END_CRIT_SECTION + 10. UnlockReleaseBuffer + +There is no "write CLR pointer back to urec_clr_ptr" step: as established +above, that field is never updated after the record is written. Step 8 +(PageSetLSN) is what makes step 9 onward idempotent -- see "CLR +idempotency, corrected" below. + +Key principle: **UNDO record I/O (reading) occurs BEFORE the critical +section. Only the page modification and WAL write occur inside the +critical section.** + +### CLR idempotency, corrected + +The UNDO record header's urec_clr_ptr field is always InvalidXLogRecPtr +(see "3. UNDO Record Format" above); it plays no role in idempotency. +The actual mechanism is full-page-image (FPI) + page-LSN comparison, +the same mechanism ordinary WAL redo uses everywhere else in PostgreSQL: + + 1. Every CLR (XLOG_UNDO_APPLY_RECORD) is written with + REGBUF_FORCE_IMAGE, so it always carries a full-page image of the + page it restores. + 2. After the physical page modification, PageSetLSN(page, lsn) sets + the page's LSN to the CLR's own LSN. + 3. If a crash happens and recovery replays this CLR again (because + redo starts from a checkpoint before the CLR), the standard + XLogReadBufferForRedo() call used by the CLR's own redo handler + compares the on-disk page's LSN to the record being replayed: if + the page's LSN is already >= the record's LSN, it returns + BLK_DONE (page already reflects this change or a later one) or + BLK_RESTORED (an FPI was applied) rather than BLK_NEEDS_REDO, and + no further modification happens. + 4. If instead a NEW rollback attempt (not a WAL replay) re-walks the + same UNDO chain -- e.g. ApplyUndoChainFromWAL() called again after + an interrupted first attempt -- there is no per-record "already + applied" flag to check at all; the record's rm_undo callback is + simply re-invoked. This is safe because the callbacks are + themselves idempotent: restoring the same before-image bytes twice + produces the same final page state (memcpy is idempotent), and any + second CLR this produces is itself subject to the same page-LSN + redo-skip logic in step 3. + +This prevents double-application and enables idempotent crash recovery +without ever needing to mutate an already-written UNDO record. + +## 8. WAL Integration + +### Resource Managers and the RM-ID Namespaces + +The UNDO subsystem introduces new WAL resource-manager IDs (in +access/rmgrlist.h) and, separately, a set of UNDO-record dispatch IDs (in +access/undormgr.h). These are two different namespaces and both are shared, +finite resources, so each new ID must be justified and, once shipped, kept +stable. + +**WAL resource-manager IDs (rmgrlist.h).** A WAL RM ID is stamped into +every WAL record and is limited to a one-byte field (RM_MAX_ID = 255, with +128..255 reserved for custom/extension RMs). A facility needs its own WAL +RM only when it emits WAL records with a redo/desc/identify behavior distinct +from every existing RM; sharing another RM's opcode space would force that +RM's redo routine to understand records it does not own. The UNDO stack adds +these, each because it replays a distinct on-disk change: + + RM_UNDO_ID "Undo" Cluster-wide UndoLog: batch allocation, discard, + log extension, and physical CLRs + (XLOG_UNDO_APPLY_RECORD). Its own RM because + none of these map onto any table/index RM's redo. + RM_ATM_ID "ATM" Aborted Transaction Map: XLOG_ATM_ABORT / + XLOG_ATM_FORGET, which reconstruct the + constant-time-recovery abort map during redo. + Distinct lifecycle from UndoLog batches, so a + distinct RM. + RM_RELUNDO_ID "RelUndo" Per-relation UNDO fork: metapage/page changes + and per-relation CLRs. Needs startup/cleanup and + mask callbacks the UndoLog RM does not, hence its + own RM. + + A consumer that writes UNDO through one of these facilities does NOT get + its own WAL RM for that purpose; it reuses the facility's RM. A consumer + adds a WAL RM only for WAL it emits itself that no existing RM can redo + (for example a table AM's own heap-change records). Those consumer WAL RMs + are added in the consumer's own commit, at the end of rmgrlist.h, so + existing IDs never shift (IDs are position-defined and WAL-durable). + +**UNDO-record dispatch IDs (undormgr.h, UNDO_RMID_*).** Independently of WAL +RM IDs, every UNDO record header carries a urec_rmid that selects which +registered rm_undo callback reverses it during rollback (see +access/undormgr.h and access/undormgrlist.h). This namespace is also finite +(MAX_UNDO_RMGRS = 256) and stamped into WAL-durable UNDO records, so IDs must +be unique and stable. The core header defines only UNDO_RMID_INVALID and the +built-in index-AM IDs; every other consumer defines its own UNDO_RMID_* +constant in its own header, in the same commit that registers it via +access/undormgrlist.h, so the core names no specific consumer. A new consumer +takes the next free value and never reuses a retired one. + +A resource manager is registered for UNDO-related WAL: + + RM_UNDO_ID (23) - UNDO log management operations + +### UNDO WAL Record Types + + XLOG_UNDO_ALLOCATE (0x00) Space allocated in UNDO log. + Fields: start_ptr, length, xid, log_number + + XLOG_UNDO_DISCARD (0x10) Discard pointer advanced. + Fields: discard_ptr, oldest_xid, log_number + + XLOG_UNDO_EXTEND (0x20) Log file extended. + Fields: log_number, new_size + + XLOG_UNDO_APPLY_RECORD (0x30) CLR: Physical UNDO applied to page. + Fields: urec_ptr, xid, target_locator, target_block, + target_offset, operation_type + Always includes REGBUF_FORCE_IMAGE (full page image). + +### WAL Replay + +During crash recovery: + + undo_redo() replays UNDO WAL records: + - ALLOCATE: Creates/updates log control structures, advances insert_ptr + - DISCARD: Updates discard_ptr and oldest_xid + - EXTEND: Extends the physical log file + - APPLY_RECORD: CLR -- restores full page image via XLogReadBufferForRedo. + Since CLRs use REGBUF_FORCE_IMAGE, the page is restored + directly from the WAL record without re-reading UNDO data. + +## 9. Recovery Process + +The UNDO system follows the Constant-Time Recovery (CTR) model described +above, not ARIES's analysis/redo/undo three-phase model: + + Redo: Ordinary, synchronous WAL replay forward (includes UNDO + allocations and any CLRs generated before the crash) -- + this is PostgreSQL's normal single forward pass, not a + distinct ARIES "redo phase" preceded by a separate analysis + pass. + Undo: Logical/deferred. Transactions that were in-flight (neither + committed nor aborted) at crash time are rolled back as part + of PerformUndoRecovery() during this recovery run (see + "Recovery Model" above). Transactions that had already + aborted before the crash are handled exactly as they are + during normal runtime operation -- via the ATM and the + background Logical Revert worker, not a recovery-time undo + phase. + +During normal operation, UNDO rollback for a small transaction is handled +in-process by ApplyUndoChainFromWAL() called from AtAbort_XactUndo() +(xactundo.c) on abort; for a large transaction it is deferred to the ATM +/ Logical Revert worker (see "Recovery Model" above) rather than run +synchronously in the aborting backend. + +During crash recovery, the UNDO log state is reconstructed by +redo (including replaying any CLRs generated before the crash), +and any transactions that were in progress at crash time will be +rolled back as part of normal recovery. + +### ApplyUndoChainFromWAL() -- Physical Application + +Walks the UNDO chain backward from the most recent WAL batch, +applying each record using +physical page modifications (memcpy, ItemId manipulation): + + INSERT -> ItemIdSetDead (if indexed) or ItemIdSetUnused + DELETE -> memcpy(page_htup, tuple_data, tuple_len) to restore old tuple + UPDATE -> memcpy(page_htup, tuple_data, tuple_len) to restore old version + PRUNE -> skipped (informational only) + INPLACE -> memcpy(page_htup, tuple_data, tuple_len) to restore old data + +For each applied record, a CLR is generated via XLogInsert with +REGBUF_FORCE_IMAGE. There is no "write CLR pointer back to urec_clr_ptr" +step -- see "CLR idempotency, corrected" above for the actual mechanism +(full-page image + page-LSN comparison during redo). + +This replaced the previous logical approach (simple_heap_delete, +simple_heap_insert) which went through the full executor path, triggered +index updates, generated WAL, and could fail visibility checks. The +physical approach follows ZHeap's zheap_undo_actions() pattern. + +Error handling is defensive: if a relation has been dropped or a record +cannot be applied, a WARNING is emitted and processing continues. + +### Crash During Rollback + +If a crash occurs during rollback: + + 1. Recovery replays WAL forward, including any CLRs already generated. + 2. Pages modified by already-applied UNDO records are restored via + the full page images in the CLRs, via the ordinary redo path's + page-LSN check (XLogReadBufferForRedo returning BLK_DONE / + BLK_RESTORED for a page already at or past the CLR's LSN). + 3. Any UNDO record whose page-level change was already durable is + therefore a no-op if its rm_undo callback runs again; there is no + urec_clr_ptr field being checked to decide this. + 4. Remaining UNDO records are applied normally, generating new CLRs. + +Result: Rollback always completes, even after repeated crashes. + +## 10. UNDO Discard Worker + +The undoworker background process (undoworker.c) periodically scans +active transactions and advances discard pointers: + + 1. Queries ProcArray for the oldest active transaction + 2. Identifies UNDO records older than oldest_xid + 3. Advances discard_ptr (WAL-logged via XLOG_UNDO_DISCARD) + 4. Future: physically truncates/deletes reclaimed log files + +### GUC Parameters + + undo_worker_naptime Sleep interval between discard cycles (ms) + Default: 60000 (1 minute) + + undo_retention_time Minimum retention time for UNDO records (ms) + Default: 3600000 (1 hour) + +## 11. Performance Characteristics + +### Zero Overhead When Disabled + +For AMs that do not support UNDO, the only overhead is the +am_supports_undo check -- a single pointer dereference and comparison. +No UNDO allocations, writes, or locks are taken. + +### Overhead When Active + + INSERT: One UNDO record (header only, no payload). ~40 bytes + (SizeOfUndoRecordHeader). + DELETE: One UNDO record + full tuple copy. 40-byte header + t_len bytes. + UPDATE: One UNDO record + old tuple copy. 40-byte header + t_len bytes. + PRUNE: One UNDO record per pruned tuple. Batched via UndoRecordSet. + +UNDO I/O occurs outside critical sections to avoid holding buffer locks +during writes. For INSERT, UNDO is generated after END_CRIT_SECTION. +For DELETE/UPDATE/PRUNE, UNDO is generated before START_CRIT_SECTION. + +### Abort Overhead + + ABORT: Each UNDO record applied during rollback generates a CLR + WAL record with a full page image (~8 KB per record). + Abort latency increases approximately 20-50% compared to + PostgreSQL's default rollback, which generates no WAL. + WAL volume per abort increases significantly due to CLRs. + + RECOVERY: Checkpoint time increases 7-15% due to more dirty buffers. + Recovery time increases 10-20% due to CLR replay. + +Trade-off: Higher abort overhead in exchange for crash safety and +standby support. For workloads where aborts are rare, the overhead +is negligible. + +### Buffer Cache + +UNDO blocks share the standard shared_buffers pool with heap and index +data. No separate cache tuning is needed; the standard shared_buffers +setting controls memory available for all buffer types including UNDO. + +## 13. Monitoring and Troubleshooting + +### Monitoring Functions + + pg_stat_get_undo_logs() Per-log statistics (size, discard progress) + pg_stat_get_undo_buffers() Buffer hit/miss/eviction statistics + pg_undo_force_discard() Force discard of old UNDO records + +### Key Log Messages + + DEBUG1 "created UNDO log file: ..." + DEBUG1 "applying UNDO chain starting at ..." + DEBUG2 "transaction %u committed with UNDO chain starting at %llu" + DEBUG2 "UNDO log %u: discard pointer updated to offset %llu" + WARNING "UNDO rollback: relation %u no longer exists, skipping" + +### Common Issues + + "too many UNDO logs active" + The compile-time limit MAX_UNDO_LOGS (100) was reached. Each + concurrent writer to an UNDO-enabled table needs an active log. + + "UNDO log %u would exceed segment size" + The segment capacity threshold was reached. The UNDO log will + seal the current segment and rotate to a fresh one via + UndoLogSealAndRotate(). If rotation fails due to backpressure, + the transaction may block until space is reclaimed. + + Growing WAL retention from UNDO + Check that the UNDO worker is running (pg_stat_activity). + Verify undo_retention_time is not set too high. + Long-running transactions prevent discard. + +## 14. File Structure + +### Backend Implementation (src/backend/access/undo/) + + undo.c Central coordination, shared memory aggregation + undolog.c Core log file management, allocation, I/O, segment rotation + undorecord.c Record format, serialization, UndoRecordSet + undoinsert.c Batch insertion of accumulated records + undoapply.c Physical rollback: ApplyUndoChainFromWAL(), memcpy-based restore, CLRs + xactundo.c Per-transaction undo management, per-persistence-level sets + undo_xlog.c WAL redo routines, CLR replay, segment rotation WAL + undo_bufmgr.c shared_buffers integration, virtual RelFileLocator mapping + undoworker.c Background discard worker, rotation checks + undostats.c Statistics collection, segment state tracking + undormgr.c UNDO resource manager dispatch (RegisterUndoRmgr, per-AM callbacks) + undobuffer.c AM-agnostic Tier 2 UNDO write buffer (UndoBufferBegin/End/Flush) + +### Header Files (src/include/access/) + + undodefs.h Core type definitions (UndoRecPtr, UndoPersistenceLevel) + undo.h Central coordination API + undolog.h UndoLogControl, UndoLogSharedData, log management API + undorecord.h UndoRecordHeader, record types, UndoRecordSet + undo_xlog.h WAL record structures (xl_undo_allocate, xl_undo_apply, etc.) + xactundo.h Per-transaction undo API (PrepareXactUndoData, etc.) + undoworker.h Worker shared memory and GUC declarations + undo_bufmgr.h shared_buffers wrapper API for UNDO log blocks + undostats.h Statistics structures and functions + undormgr.h UNDO resource manager registration API (per-AM dispatch) + undobuffer.h AM-agnostic Tier 2 write buffer API + +### Modified Core Files + + src/backend/access/heap/heapam.c INSERT/DELETE/UPDATE UNDO logging, + RelationHasUndo() helper + src/backend/access/heap/heapam_handler.c begin/finish_bulk_insert -> UndoBuffer + src/backend/access/nbtree/nbtree_undo.c B-tree index UNDO RM (INSERT_LEAF etc.) + src/backend/access/heap/pruneheap.c PRUNE UNDO logging + src/backend/access/transam/xact.c Transaction UNDO chain tracking + src/backend/access/transam/rmgr.c Resource manager registration + src/backend/storage/ipc/ipci.c Shared memory initialization + src/include/access/rmgrlist.h RM_UNDO_ID + src/include/access/heapam.h RelationHasUndo() declaration + src/include/access/xact.h UNDO chain accessors + +## 15. Limitations and Future Work + +### Current Limitations + + - No TOAST-aware UNDO (large tuples stored inline) + - No delta compression for UPDATE records (full old tuple stored) + - ProcArray integration for oldest XID is simplified + - Reads use xmin/xmax + CLOG (heap-compatible MVCC); an in-place-update + AM's old versions come from the per-relation UNDO fork, not UNDO-based + MVCC + - Point-in-time recovery tool (pg_undorecover) is planned but not + yet implemented; UNDO data can be inspected via pg_waldump + +### Implemented + + - Log rotation and segment lifecycle management + (see UndoLogSealAndRotate() in undolog.c) + - AM-agnostic UNDO write buffer for reduced per-row overhead in DML + (see UndoBufferBegin() in undobuffer.c) + +### Planned Future Work + + - Delta compression for UPDATE records + - TOAST-aware UNDO storage + - Time-travel query support using UNDO data + - Parallel UNDO application for faster rollback + - Online UNDO log compaction + +## Known Performance Gaps and Future Work + +**Large-transaction rollback complexity:** + +The UNDO system uses two rollback strategies depending on estimated UNDO +record size (controlled by ``undo_instant_abort_threshold``, default 64 KB): + +**Small transactions (UNDO < 64 KB, roughly < 600 rows for a 2-column table):** +Synchronous rollback -- the backend walks the UNDO chain and restores heap +tuples before ROLLBACK returns. Complexity is O(N) in modified rows. Each +row requires: + + - 1 WAL batch record read (amortized: N/``undo_batch_record_limit`` reads, + default 1000 records per batch) + - 1 heap buffer write (cache hit if table fits in ``shared_buffers``; + otherwise a cold I/O read + write) + - 1 CLR WAL record written per batch (~8 KB each) + +**Large transactions (UNDO >= 64 KB, roughly >= 600 rows):** +ATM instant abort -- the backend records the XID as aborted in the +shared-memory Aborted Transaction Map (ATM) and returns immediately. +User-visible ROLLBACK latency is O(1), indistinguishable from CLOG-only +rollback. The UNDO background worker (``undo worker``) applies the UNDO +chain asynchronously, restoring heap tuples without holding the committing +backend. + +This makes large-transaction rollback invisible to end users regardless of +transaction size. The O(N) heap restoration work happens in the background. + +**b8 benchmark results -- nuc** (FreeBSD 15, amd64, 8-core, 32 GB RAM, +shared_buffers=128MB, synchronous_commit=on, 2-iteration median, +PostgreSQL 19devel, 2026-05-05, with HEAP_UNDO_DELETE_VISIBILITY_ONLY):: + + DML execution time (baseline -> UNDO OFF -> UNDO ON): + + Rows INSERT (base->off->on) UPDATE (base->off->on) DELETE (base->off->on) + ------ ----------------------- ----------------------- ----------------------- + 10K 25ms -> 28ms -> 28ms 14ms -> 13ms -> 15ms 6ms -> 6ms -> 7ms + 100K 157ms-> 154ms -> 157ms 75ms -> 81ms -> 80ms 36ms -> 36ms -> 61ms (*) + + DML overhead at 10K rows (ON vs baseline): INSERT +10%, UPDATE +7%, DELETE +12%. + The DELETE overhead at 10K dropped from the pre-optimization baseline of +19% + to +12% with HEAP_UNDO_DELETE_VISIBILITY_ONLY, which writes 8 bytes per deleted + tuple (xmax + infomask + infomask2) instead of the full 160-560 byte + before-image, reducing DELETE UNDO WAL volume by ~93-98%. + + (*) The 100K DELETE measurement has CV=40% with 2 iterations -- too noisy to + interpret. The 10K result (CV=5%) is the reliable reference for DELETE overhead. + + Rollback latency (user-visible): + + Rows Baseline UNDO OFF UNDO ON Mechanism + -------- --------- --------- -------- --------------------------------- + 10,000 <1ms <1ms <1ms All: O(1) via CLOG / ATM + 100,000 <1ms <1ms <1ms ATM instant abort (>64KB threshold) + + ROLLBACK latency is O(1) for all transaction sizes. UNDO-based rollback + matches CLOG-only rollback latency due to the ATM instant-abort path. + Background restoration completes asynchronously with no client-visible + delay. Zero dead tuples remain after rollback completes (no VACUUM debt). + + pgbench TPS (standard OLTP, 30s runs, 2-iteration median): + + Scale Clients Baseline UNDO OFF UNDO ON OFF/Base ON/Base + ----- ------- --------- --------- --------- --------- --------- + 10 1 551 TPS 281 TPS 249 TPS 0.51x 0.45x (**) + 10 4 513 TPS 506 TPS 494 TPS 0.99x 0.96x + 10 8 805 TPS 796 TPS 770 TPS 0.99x 0.96x + 50 1 243 TPS 326 TPS 256 TPS 1.34x 1.05x (**) + 50 4 562 TPS 554 TPS 559 TPS 0.99x 0.99x + 50 8 1036 TPS 1042 TPS 1017 TPS 1.01x 0.98x + + (**) The c=1 results show high variance at both scales on FreeBSD; they are + dominated by measurement artifacts rather than PostgreSQL throughput. + The c=4 and c=8 results are representative: code-presence overhead (OFF vs + baseline) is 0-1%; full UNDO overhead (ON vs baseline) is 1-4%. + + Cold-WAL rollback (crash recovery): not measured in b8; would require + WAL read from disk. Expected O(N) at ~50 MB/s WAL read throughput for + large transactions. Crash recovery times scale with unrecovered UNDO + volume at server restart. + +Compared to baseline CLOG-only rollback: + + - Baseline rollback: O(1), <1ms regardless of transaction size + - UNDO ON rollback: O(1) user-visible via ATM, O(N) background work + - UNDO advantage: zero dead tuples after rollback; no VACUUM debt + - Net VACUUM savings: eliminates dead-tuple cleanup for rolled-back rows + +Tuning: increase ``undo_batch_size_kb`` (default 256 KB) and +``undo_batch_record_limit`` (default 1000 records) to reduce the number of +WAL reads during background UNDO application. Increase +``undo_instant_abort_threshold`` to force synchronous rollback for larger +transactions (trading user-visible rollback latency for faster background +cleanup); set to 0 to always use ATM (instant abort for all sizes). + +**TOAST:** UNDO protects TOAST tables. When a table AM uses UNDO, its +associated ``pg_toast_NNN`` relation is automatically enrolled in the same +UNDO log. Rolling back an UPDATE that modified a TOASTed column correctly +restores the old TOAST chunks: UNDO is applied newest-first, so the heap +tuple UPDATE is undone after the new TOAST chunk inserts are undone and +before the old TOAST chunk deletes are undone. This restores full +referential integrity across the heap and TOAST table. + +**Autovacuum interaction:** UNDO-enabled tables have no dead tuples after +a rolled-back transaction -- the tuples are physically reversed, not merely +marked dead. As a result, ``pg_stat_user_tables.n_dead_tup`` stays near +zero for UNDO-enabled tables with active DML, and the standard autovacuum +dead-tuple trigger (``autovacuum_vacuum_threshold`` + +``autovacuum_vacuum_scale_factor``) will not fire. + +This does **not** mean autovacuum is unnecessary. UNDO-enabled tables still +accumulate update-chain bloat, need FSM updates, and benefit from hint-bit +setting and index bloat cleanup. To ensure timely vacuuming on UNDO-enabled +tables, either: + + * Set ``autovacuum_vacuum_threshold = 0`` at the table level so that + even zero dead tuples triggers autovacuum based on insert/update count: + + .. code-block:: sql + + CREATE TABLE my_table (...) USING + WITH (autovacuum_vacuum_threshold = 0, + autovacuum_vacuum_scale_factor = 0.05); + + -- or for an existing table: + ALTER TABLE my_table + SET (autovacuum_vacuum_threshold = 0, + autovacuum_vacuum_scale_factor = 0.05); + + * Or rely on the per-table ``autovacuum_vacuum_scale_factor`` (default + 0.2) which triggers on total modifications, including rolled-back ones + (``n_mod_since_analyze`` counts all modifications regardless of outcome). + +Operators deploying UNDO-enabled AMs on high-write tables should verify +autovacuum configuration and monitor ``pg_stat_user_tables`` for bloat. + +**Version reconstruction for an in-place-update AM:** For the heap AM, UNDO is +used only for physical rollback (restoring the pre-DML tuple state); heap +snapshot visibility uses the dead-tuple + CLOG mechanism. An in-place-update +AM can resolve visibility with xmin/xmax + CLOG exactly like heap -- that is +NOT UNDO-based MVCC and NOT an sLog before-image MVCC scheme. What differs is +physical: an in-place UPDATE overwrites the page, so the prior tuple version +is not on the page. A snapshot older than an in-place UPDATE reconstructs the +prior version by walking the relation's per-relation UNDO fork (RelUndo, +RELUNDO_FORKNUM), not the sLog. The sLog serves only the abort/self-visibility +window (constant-time abort and hiding an aborting xid's writes); the commit +oracle is CLOG. + +## 16. References + +Design inspired by: + + ZHeap (EnterpriseDB, 2017-2019) + Transaction slots, sequential logs, TPD pages. + ZHeap used UNDO for both MVCC visibility (providing old tuple + versions to concurrent readers) and rollback. An in-place-update AM + can borrow the in-place-update + per-relation UNDO fork shape for + old-version reconstruction, but resolve commit visibility through + xmin/xmax + CLOG (like heap), not through UNDO. For heap, UNDO is + used only for physical rollback since heap retains dead tuples. + + BerkeleyDB + LSN-based chaining, pre-log-then-operate, deferred deletion + + Aether DB + Per-process WAL streams, physiological logging, CLRs + + Oracle Database + UNDO tablespace model, automatic UNDO management + + Antonopoulos et al., "Constant Time Recovery in Azure SQL Database" + Proceedings of the VLDB Endowment, Vol. 12, No. 12, August 2019. + Our ATM/sLog directly implements the CTR architecture: the secondary + log (sLog) provides constant-time abort, the logical_revert_worker + implements background Logical Revert, and UNDO-in-WAL serves as our + Persistent Version Store equivalent. Key difference: CTR uses a + separate PVS; we embed UNDO records in the WAL stream, sharing WAL + retention constraints but eliminating a separate storage tier. + +## 17. Development Status + +**Status**: Functional; hardening in progress + +The architecture is implemented and the UNDO subsystem is functional with +test coverage and measured performance data, but there are open items under +review (WAL-layer AM-agnosticism, rollback batch-truncation robustness, +cluster-wide-consumer post-commit flush ordering, and removal of vestigial +timestamp-era apparatus). See section 18 for known limitations. + +- Core UNDO log management: Complete +- Heap UNDO logging: Complete +- TOAST UNDO integration: Complete +- Table AM capability API: Complete +- Optimization and hardening: Complete +- Documentation: Complete (b8 benchmark measured and recorded) + +Test suites passing: +- Regression tests: src/test/regress/sql/undo.sql, undo_toast.sql +- Crash recovery: src/test/recovery/t/053-060 (large-txn, concurrent DML, + prepared transactions, checkpoint, TOAST crash recovery) + +## 18. Known Limitations + +The current implementation has the following known limitations: + +### WAL Retention for UNDO +- UNDO batches in WAL are retained until undo_discard_horizon advances +- Horizon is gated by oldest in-flight transaction (never discards unresolved) +- Logical Revert Worker's pending ATM entries also pin WAL retention +- WAL segment recycling respects undo_discard_horizon automatically + +### WAL Level +UNDO-enabled tables require ``wal_level = replica`` or higher for +streaming standbys to receive CLR records written during UNDO application. +At ``wal_level = minimal``, single-row DML UNDO records are still written +and crash recovery works correctly, but standbys will not receive them. + +### Logical Decoding +``XLOG_UNDO_BATCH`` records use a custom resource manager. The UNDO RM's +``rm_decode`` callback is a no-op: logical decoding filters these records and +they do not appear as change events. Logical replication of UNDO-enabled +tables works correctly -- the logical decoder sees heap INSERT/UPDATE/DELETE +changes normally. + +### AM Compatibility +UNDO is always-on infrastructure. Table AMs opt in via the +am_supports_undo callback. AMs that do not implement this callback +(e.g., the default heap AM) operate without UNDO overhead. + +### TOAST Support +UNDO now fully protects TOAST tables (see Section 15). TOASTed column +rollbacks are handled correctly via automatic TOAST table enrollment. + +### Delta Compression +- UPDATE records store full old tuple, not delta +- Could be optimized similar to xl_heap_update PREFIX_FROM_OLD +- Impact: Higher UNDO write amplification on partial updates +- Mitigation: Use HOT updates when possible + +### Standby-Side Before-Image Regeneration (designed, rejected) +A proposed optimization would have standbys regenerate each in-place-update +before-image locally rather than receiving it in the per-relation UNDO +fork's WAL payload, trimming WAL volume on write-heavy workloads. It is +not implemented, and was rejected for two reasons: + +- It is not viable on the current write path. An in-place + UPDATE/DELETE overwrites the tuple and emits its data-change WAL record + BEFORE the relundo before-image record (RelUndoFinish). + Standby redo is strictly LSN-ordered, so by the time it replays the + before-image record the old tuple is already gone from the page -- there + is nothing to regenerate from. Enabling regeneration would require + reordering the two records across the write path, reintroducing the + cross-record ordering coupling the per-relation fork was built to avoid. +- The risk/reward is upside-down. The saving is one old-tuple image per + in-place UPDATE/DELETE (already delta-compressed on the CAS path), while + a regeneration defect would serve incorrect reads on a standby or break + rollback after promotion. Correctness on standbys is not worth trading + for a bounded WAL-volume saving. + +Revisit only if WAL volume from before-images becomes a measured +bottleneck AND the write path is reordered to log the before-image first, +behind a default-off switch that retains the payload path as fallback. + +### ProcArray Integration +- GetOldestActiveTransactionId() simplified for initial implementation +- Proper ProcArray scan for oldest XID needed for production +- Impact: Less aggressive UNDO discard than optimal + +### UNDO-Based MVCC (In Progress for in-place-update AMs) +- Heap AM: UNDO for rollback and recovery only (heap retains dead tuples) +- In-place-update AM: may require UNDO-based read visibility (in-place + updates destroy the prior version). The sLog already captures + before-images at DML time; making these available in shared memory for + concurrent readers is the remaining work. +- Future work: Time-travel queries (SELECT AS OF TIMESTAMP) for both AMs + +### Platform Support +- Tested on: Linux (primary), FreeBSD, Windows, macOS +- Full platform matrix testing pending +- Extended file attributes (xattr) support varies by platform + +### Parallel UNDO Apply +- Transaction rollback runs sequentially in a single backend process +- Large aborts can be slow +- Future work: Parallel UNDO application for faster rollback + +## 19. Upgrade Guide + +### Prerequisites +- PostgreSQL 17+ (uses current rmgrlist.h structure) +- Sufficient WAL disk space (UNDO batches share WAL retention) + +### Enabling UNDO + +UNDO is **disabled by default** and must be enabled per-relation: + + -- Create a table using an AM that supports UNDO + CREATE TABLE important_data (id int, data text) USING ; + +### Monitoring UNDO + +UNDO batches share the WAL stream. Monitor WAL retention: + + SELECT slot_name, restart_lsn, confirmed_flush_lsn + FROM pg_replication_slots; + + -- Check UNDO log state: + SELECT * FROM pg_stat_get_undo_logs(); + +### Rollback Plan + +If issues arise: + +1. UNDO is integral to AMs that use it -- it cannot be + disabled per-table. To stop UNDO activity, convert the table to + a different AM (e.g., heap). + +2. Existing UNDO batches in WAL are retained until retention expires. + +3. Stop UNDO worker if needed: + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE backend_type = 'undo worker'; + +### Performance Tuning + +Recommended initial settings: + + # UNDO worker wakes every second + undo_worker_naptime = 1000 + + # Retain UNDO for 1 minute (adjust based on workload) + undo_retention_time = 60000 + + # Up to MAX_UNDO_LOGS (100) concurrent UNDO logs supported + + # WAL retention handles UNDO storage automatically + # Ensure max_wal_size accommodates UNDO batch retention + max_wal_size = 4GB + +Monitor and adjust based on: +- Long-running transaction frequency +- Update-heavy workload patterns +- Disk space availability + +### Future Enhancements Planned +- Delta compression for UPDATE records +- Time-travel query support (SELECT AS OF TIMESTAMP) +- Parallel UNDO application + +### Bulk UNDO Hints (Implemented) + +The `begin_bulk_insert` table AM callback enables batched UNDO recording +for large DML operations (INSERT, UPDATE, DELETE with >1000 estimated rows). +Instead of per-row UndoLogAllocate + WAL insert + UndoLogWrite, records are +accumulated in a persistent UndoRecordSet and flushed in batches: + +- Flush threshold: 256KB or 1000 records (whichever comes first) +- Activated via: table_begin_bulk_insert() from ExecInitModifyTable +- Deactivated via: table_finish_bulk_insert() from ExecEndModifyTable +- Heap AM callbacks: heapam_begin_bulk_insert / heapam_finish_bulk_insert + +## Lock Ordering + +All UNDO LWLocks must be acquired in the order listed below to +prevent deadlocks. An agent holding lock N must never acquire lock M +where M < N. Standard PostgreSQL buffer-content locks and heavyweight +locks sit outside this hierarchy — they must be acquired before any +UNDO LWLock. A consumer AM's own locks (for example an optional +per-tuple tracking structure, or an AM-private dirty-page map) extend this +hierarchy at higher-numbered levels and are documented with that consumer, +not here. + + Level Lock Holder(s) + ----- ---- --------- + 1 Buffer content locks All backends (standard PG) + 2 LWTRANCHE_UNDO_LOG Per-log lock (undolog.c:97) + (per-log instance) Protects log metadata and append + 3 LWTRANCHE_UNDO_LOG Allocation lock (undolog.c:105) + (allocation instance) Serializes log space allocation + 4 LWTRANCHE_UNDO_LOG Flush lock (undo_flush.c:61) + (flush instance) Protects batch flush state + 5 LWTRANCHE_UNDO_WORKER Revert worker state (logical_revert_worker.c:109) + Protects revert queue and worker state + +Rules: +- Never acquire a buffer content lock while holding any UNDO LWLock. +- The revert worker acquires locks 2→5 in sequence during UNDO apply; + backends acquiring lock 5 (to enqueue work) must not already hold 2-4. diff --git a/src/backend/access/undo/atm.c b/src/backend/access/undo/atm.c new file mode 100644 index 0000000000000..ee655bf8aa651 --- /dev/null +++ b/src/backend/access/undo/atm.c @@ -0,0 +1,532 @@ +/*------------------------------------------------------------------------- + * + * atm.c + * Aborted Transaction Map for CTR (Constant-Time Recovery) + * + * The ATM is a shared-memory data structure mapping TransactionId to UNDO + * chain metadata for aborted transactions. It enables: + * + * 1. Background Logical Revert: the Logical Revert worker scans the + * sLog for entries where revert_complete == false and applies their + * UNDO chains asynchronously. + * + * 2. Instant abort: at transaction abort time, the backend writes an + * ATM entry (sLog + WAL) instead of performing synchronous + * rollback, making ROLLBACK O(1). + * + * Implementation: All ATM functions are thin wrappers around the sLog + * (Secondary Log) hash tables defined in access/slog.h. The sLog + * provides O(1) lookups, replacing the old fixed-size linear array. + * + * WAL: ATMAddAborted() emits XLOG_ATM_ABORT; ATMForget() emits + * XLOG_ATM_FORGET. During recovery, atm_redo() replays these without + * re-emitting WAL. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/atm.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "access/atm.h" +#include "access/slog.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlogreader.h" +#include "access/xlogutils.h" +#include "common/file_utils.h" +#include "pgstat.h" +#include "port/pg_crc32c.h" +#include "storage/fd.h" +#include "storage/lwlock.h" +#include "storage/shmem.h" +#include "utils/wait_event.h" + +/* + * On-disk ATM state file. + * + * The Aborted Transaction Map is a shared-memory-only structure otherwise + * reconstructed only by replaying XLOG_ATM_ABORT / XLOG_ATM_FORGET during the + * redo pass. Because a checkpoint may advance the redo pointer PAST an + * un-forgotten XLOG_ATM_ABORT, redo alone can miss aborts and silently lose a + * guaranteed rollback. To close that window we persist the map at each + * checkpoint (CheckPointATM) and reload it at startup before redo + * (ATMReloadFromCheckpoint), exactly mirroring how CheckPointTwoPhase / + * restoreTwoPhaseData keep prepared-xact state that predates the redo point. + * + * The file is a single flat file in the data directory: a header, then a + * packed array of records, then a CRC over header+records. It is written to + * a temporary name and durably renamed into place, so a torn write never + * corrupts a previously good file. + */ +#define ATM_STATE_FILE "pg_undo_atm" +#define ATM_STATE_TMP_FILE "pg_undo_atm.tmp" +#define ATM_STATE_MAGIC 0x41544D31 /* "ATM1" */ + +typedef struct AtmStateHeader +{ + uint32 magic; + uint32 count; /* number of AtmStateRecord that follow */ +} AtmStateHeader; + +typedef struct AtmStateRecord +{ + TransactionId xid; + Oid reloid; + Oid dboid; + XLogRecPtr last_batch_lsn; + bool revert_complete; +} AtmStateRecord; + +/* Internal helpers that skip WAL emission (used during redo) */ +static bool ATMAddAbortedInternal(TransactionId xid, Oid dboid, Oid reloid, + XLogRecPtr last_batch_lsn); +static void ATMForgetInternal(TransactionId xid); + +/* + * ATMShmemSize + * Calculate shared memory space needed for the ATM. + * + * The ATM is now backed by sLog, which manages its own shared memory. + * ATM itself needs no additional shared memory. + */ +Size +ATMShmemSize(void) +{ + return 0; +} + +/* + * ATMShmemInit + * Initialize ATM shared memory (no-op, sLog handles it). + */ +void +ATMShmemInit(void) +{ + /* sLog initialization is done separately via SLogShmemInit() */ +} + +/* + * ATMGetLastBatchLSN + * Retrieve the WAL LSN of the last UNDO batch for an aborted transaction. + * + * Returns true if found, storing the LSN in *lsn_out. + */ +bool +ATMGetLastBatchLSN(TransactionId xid, XLogRecPtr *lsn_out) +{ + return SLogTxnLookupByXid(xid, lsn_out); +} + +/* + * ATMAddAbortedInternal + * Add an entry to the ATM without emitting WAL. + * + * Used during both normal operation (after WAL has been written by the + * caller) and during redo replay. + * + * Returns false if the sLog is full. + */ +static bool +ATMAddAbortedInternal(TransactionId xid, Oid dboid, Oid reloid, + XLogRecPtr last_batch_lsn) +{ + return SLogTxnInsert(xid, reloid, dboid, last_batch_lsn); +} + +/* + * ATMAddAborted + * Record an aborted transaction in the ATM with WAL logging. + * + * Called from the abort path. Returns false if the sLog is full, + * signaling the caller to fall back to synchronous rollback. + */ +bool +ATMAddAborted(TransactionId xid, Oid dboid, XLogRecPtr last_batch_lsn) +{ + xl_atm_abort xlrec; + + /* Write WAL first */ + xlrec.xid = xid; + xlrec.last_batch_lsn = last_batch_lsn; + xlrec.dboid = dboid; + xlrec.reloid = InvalidOid; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfXlAtmAbort); + XLogInsert(RM_ATM_ID, XLOG_ATM_ABORT); + + /* Now update shared memory */ + return ATMAddAbortedInternal(xid, dboid, InvalidOid, last_batch_lsn); +} + +/* + * ATMForgetInternal + * Remove ATM entries for a transaction without emitting WAL. + */ +static void +ATMForgetInternal(TransactionId xid) +{ + SLogTxnRemoveByXid(xid); +} + +/* + * ATMForget + * Remove ATM entries after Logical Revert has completed. + * + * Emits a WAL record so that the removal survives recovery. + */ +void +ATMForget(TransactionId xid) +{ + xl_atm_forget xlrec; + + /* Write WAL first */ + xlrec.xid = xid; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfXlAtmForget); + XLogInsert(RM_ATM_ID, XLOG_ATM_FORGET); + + /* Now update shared memory */ + ATMForgetInternal(xid); +} + +/* + * ATMMarkReverted + * Mark an ATM entry's revert as complete. + * + * The entry is kept in the ATM (for visibility checks) until ATMForget() + * is called after the Logical Revert worker confirms all effects are gone. + */ +void +ATMMarkReverted(TransactionId xid) +{ + SLogTxnMarkReverted(xid); +} + +/* + * ATMGetNextUnreverted + * Find the next ATM entry that hasn't been reverted yet. + * + * Used by the Logical Revert background worker to find work. + * + * Returns true if an unreverted entry was found, filling in the output + * parameters. + */ +bool +ATMGetNextUnreverted(TransactionId *xid_out, Oid *dboid_out, + XLogRecPtr *lsn_out) +{ + return SLogTxnGetNextUnreverted(xid_out, dboid_out, lsn_out); +} + +/* + * ATMGetOldestUnrevertedLSN + * Return the oldest last_batch_lsn across all unreverted ATM entries. + * + * Used by the WAL retention logic to prevent recycling WAL segments that + * still contain UNDO batches needed by the logical revert worker. + * Returns InvalidXLogRecPtr if no unreverted entries exist. + */ +XLogRecPtr +ATMGetOldestUnrevertedLSN(void) +{ + return SLogTxnGetOldestUnrevertedLSN(); +} + +/* + * ATMRecoveryFinalize + * Called at the end of recovery to log the ATM state. + * + * After WAL redo has reconstructed the ATM via sLog, this logs the number + * of unreverted entries so the DBA can see how much Logical Revert work + * remains, and the oldest unreverted last_batch_lsn -- the LSN that pins + * UNDO WAL against recycling (ATMGetOldestUnrevertedLSN -> + * UndoGetOldestBatchLSN -> KeepLogSeg) until the logical revert worker + * forgets the entry. Emitting the LSN here, before any worker runs, gives + * an observable proof that the retention floor survived the crash. + */ +void +ATMRecoveryFinalize(void) +{ + int total = 0; + int unreverted = 0; + + SLogRecoveryFinalize(&total, &unreverted); + + if (total > 0) + elog(LOG, "ATM recovery complete: %d entries, %d unreverted, " + "oldest unreverted LSN %X/%X", + total, unreverted, + LSN_FORMAT_ARGS(ATMGetOldestUnrevertedLSN())); +} + +/* + * atm_redo + * WAL redo handler for ATM resource manager. + */ +void +atm_redo(XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_ATM_ABORT: + { + xl_atm_abort *xlrec = + (xl_atm_abort *) XLogRecGetData(record); + + ATMAddAbortedInternal(xlrec->xid, xlrec->dboid, + xlrec->reloid, xlrec->last_batch_lsn); + } + break; + + case XLOG_ATM_FORGET: + { + xl_atm_forget *xlrec = + (xl_atm_forget *) XLogRecGetData(record); + + ATMForgetInternal(xlrec->xid); + } + break; + + default: + elog(PANIC, "atm_redo: unknown op code %u", info); + break; + } +} + +/* + * CheckPointATM + * Durably persist the Aborted Transaction Map at a checkpoint. + * + * Writes every current ATM entry (xid, reloid, dboid, last_batch_lsn, + * revert_complete) to a single flat file, via a temporary file that is + * durably renamed into place, so the map survives a crash even when the + * checkpoint's redo pointer advances past the entries' XLOG_ATM_ABORT + * records. Called from CheckPointGuts(), outside any critical section + * (palloc and file I/O are therefore safe), alongside CheckPointTwoPhase. + * + * We snapshot ALL entries, not only those with an XLOG_ATM_ABORT preceding + * the redo point: an entry whose abort record follows the redo point will be + * re-added by redo, and ATMAddAbortedInternal is idempotent on (xid, reloid) + * (SLogTxnInsert leaves an existing entry untouched), so double-add across + * the checkpoint boundary converges to a single entry. Persisting the whole + * map is simpler than partitioning it by redo point and is cheap because the + * ATM only holds un-forgotten aborts. + */ +void +CheckPointATM(void) +{ + SLogTxnEntry *entries; + int count; + AtmStateHeader hdr; + pg_crc32c crc; + int fd; + int i; + + count = SLogTxnSnapshotForCheckpoint(&entries); + + /* + * Always (re)write the file, even when empty, so a stale file from a + * checkpoint that had entries is replaced by an authoritative empty one. + * An empty file (count == 0) records "no un-forgotten aborts as of this + * redo point". + */ + hdr.magic = ATM_STATE_MAGIC; + hdr.count = (uint32) count; + + INIT_CRC32C(crc); + COMP_CRC32C(crc, &hdr, sizeof(hdr)); + + fd = OpenTransientFile(ATM_STATE_TMP_FILE, + O_CREAT | O_TRUNC | O_WRONLY | PG_BINARY); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", + ATM_STATE_TMP_FILE))); + + pgstat_report_wait_start(WAIT_EVENT_TWOPHASE_FILE_WRITE); + errno = 0; + if (write(fd, &hdr, sizeof(hdr)) != sizeof(hdr)) + { + if (errno == 0) + errno = ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write file \"%s\": %m", + ATM_STATE_TMP_FILE))); + } + + for (i = 0; i < count; i++) + { + AtmStateRecord rec; + + rec.xid = entries[i].xid; + rec.reloid = entries[i].reloid; + rec.dboid = entries[i].dboid; + rec.last_batch_lsn = entries[i].last_batch_lsn; + rec.revert_complete = entries[i].revert_complete; + + COMP_CRC32C(crc, &rec, sizeof(rec)); + + errno = 0; + if (write(fd, &rec, sizeof(rec)) != sizeof(rec)) + { + if (errno == 0) + errno = ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write file \"%s\": %m", + ATM_STATE_TMP_FILE))); + } + } + + FIN_CRC32C(crc); + errno = 0; + if (write(fd, &crc, sizeof(crc)) != sizeof(crc)) + { + if (errno == 0) + errno = ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write file \"%s\": %m", + ATM_STATE_TMP_FILE))); + } + pgstat_report_wait_end(); + + pgstat_report_wait_start(WAIT_EVENT_TWOPHASE_FILE_SYNC); + if (pg_fsync(fd) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", + ATM_STATE_TMP_FILE))); + pgstat_report_wait_end(); + + if (CloseTransientFile(fd) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not close file \"%s\": %m", + ATM_STATE_TMP_FILE))); + + /* Atomically replace the live file, fsyncing both file and directory. */ + durable_rename(ATM_STATE_TMP_FILE, ATM_STATE_FILE, ERROR); + + if (entries != NULL) + pfree(entries); +} + +/* + * ATMReloadFromCheckpoint + * Reconstruct the ATM from the checkpoint state file, before redo. + * + * Called from StartupXLOG at the same point as restoreTwoPhaseData(), i.e. + * BEFORE the redo pass. Each persisted entry is re-inserted into the map + * without emitting WAL (ATMAddAbortedInternal), preserving its + * revert_complete flag. Reloading before redo is essential: an + * XLOG_ATM_FORGET replayed after the checkpoint must be able to remove an + * entry that WAS persisted, and an XLOG_ATM_ABORT replayed after the + * checkpoint re-adds idempotently on top of what we loaded. + * + * A missing file (fresh initdb, or a cluster that never checkpointed the + * ATM) is not an error: it means "no persisted entries". A file that fails + * its CRC or magic check is treated as absent with a warning; every entry it + * could have held is still WAL-durable via XLOG_ATM_ABORT, and any whose + * abort record precedes the redo point would then be lost -- but a corrupt + * ATM state file implies a torn write that durable_rename is designed to + * prevent, so this is a belt-and-suspenders path, not an expected one. + */ +void +ATMReloadFromCheckpoint(void) +{ + int fd; + AtmStateHeader hdr; + pg_crc32c crc, + file_crc; + AtmStateRecord *records = NULL; + uint32 i; + ssize_t nread; + + fd = OpenTransientFile(ATM_STATE_FILE, O_RDONLY | PG_BINARY); + if (fd < 0) + { + if (errno == ENOENT) + return; /* no persisted state -- nothing to reload */ + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open file \"%s\": %m", ATM_STATE_FILE))); + } + + pgstat_report_wait_start(WAIT_EVENT_TWOPHASE_FILE_READ); + nread = read(fd, &hdr, sizeof(hdr)); + if (nread != sizeof(hdr) || hdr.magic != ATM_STATE_MAGIC) + { + pgstat_report_wait_end(); + CloseTransientFile(fd); + ereport(WARNING, + (errmsg("ignoring ATM state file \"%s\" with invalid header", + ATM_STATE_FILE))); + return; + } + + INIT_CRC32C(crc); + COMP_CRC32C(crc, &hdr, sizeof(hdr)); + + if (hdr.count > 0) + { + Size bytes = (Size) hdr.count * sizeof(AtmStateRecord); + + records = (AtmStateRecord *) palloc(bytes); + nread = read(fd, records, bytes); + if (nread != (ssize_t) bytes) + { + pgstat_report_wait_end(); + CloseTransientFile(fd); + pfree(records); + ereport(WARNING, + (errmsg("ignoring truncated ATM state file \"%s\"", + ATM_STATE_FILE))); + return; + } + COMP_CRC32C(crc, records, bytes); + } + + nread = read(fd, &file_crc, sizeof(file_crc)); + pgstat_report_wait_end(); + CloseTransientFile(fd); + + FIN_CRC32C(crc); + if (nread != sizeof(file_crc) || !EQ_CRC32C(crc, file_crc)) + { + if (records != NULL) + pfree(records); + ereport(WARNING, + (errmsg("ignoring ATM state file \"%s\" with bad checksum", + ATM_STATE_FILE))); + return; + } + + for (i = 0; i < hdr.count; i++) + { + ATMAddAbortedInternal(records[i].xid, records[i].dboid, + records[i].reloid, records[i].last_batch_lsn); + if (records[i].revert_complete) + ATMMarkReverted(records[i].xid); + } + + if (records != NULL) + pfree(records); + + if (hdr.count > 0) + elog(LOG, "ATM reloaded %u entries from checkpoint state file", + hdr.count); +} diff --git a/src/backend/access/undo/logical_revert_worker.c b/src/backend/access/undo/logical_revert_worker.c new file mode 100644 index 0000000000000..fee9b31909406 --- /dev/null +++ b/src/backend/access/undo/logical_revert_worker.c @@ -0,0 +1,593 @@ +/*------------------------------------------------------------------------- + * + * logical_revert_worker.c + * Background worker for timer-driven Logical Revert via ATM scan + * + * This worker periodically scans the ATM (Aborted Transaction Map) for + * entries whose WAL-based UNDO chains have not yet been confirmed as applied. + * For each unreverted entry whose database matches the worker's connected + * database, the worker: + * + * 1. Applies the WAL-based UNDO chain via ApplyUndoChainFromWAL() + * (idempotent: CLR records prevent double-application) + * 2. Marks the ATM entry as reverted via ATMMarkReverted() + * 3. Emits XLOG_ATM_FORGET and removes the entry via ATMForget() + * + * Unlike event-driven UNDO worker variants (which process a shared memory work + * queue), this worker is timer-driven: it sleeps for logical_revert_naptime + * milliseconds between scan cycles. + * + * Shared memory: a single LogicalRevertState struct holds the LWLock + * protecting the running flag and a counter for assigning worker IDs. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/logical_revert_worker.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "access/atm.h" +#include "access/heapam.h" +#include "access/logical_revert_worker.h" +#include "access/table.h" +#include "access/undo_xlog.h" +#include "access/undorecord.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xlogdefs.h" +#include "catalog/pg_database.h" +#include "miscadmin.h" +#include "nodes/pg_list.h" +#include "pgstat.h" +#include "postmaster/bgworker.h" +#include "storage/ipc.h" +#include "storage/latch.h" +#include "storage/lwlock.h" +#include "storage/shmem.h" +#include "tcop/tcopprot.h" +#include "utils/guc.h" +#include "utils/injection_point.h" +#include "utils/memutils.h" + +/* GUC parameter: sleep time between ATM scans in milliseconds */ +int logical_revert_naptime = 1000; + +/* GUC parameter: max number of logical revert workers (0 = disabled) */ +int max_logical_revert_workers = 2; + +/* + * Shared memory state for the Logical Revert worker. + * + * Minimal: just a lock and a worker-id counter. The ATM itself is the + * "work queue" -- the worker reads it directly via ATMGetNextUnreverted(). + */ +typedef struct LogicalRevertState +{ + LWLock lock; + int next_worker_id; +} LogicalRevertState; + +static LogicalRevertState * RevertState = NULL; + +/* Flags set by signal handlers */ +static volatile sig_atomic_t got_SIGHUP = false; +static volatile sig_atomic_t got_SIGTERM = false; + +/* Signal handlers */ +static void logical_revert_sighup(SIGNAL_ARGS); +static void logical_revert_sigterm(SIGNAL_ARGS); +static void process_revert_entry(TransactionId xid, XLogRecPtr last_batch_lsn); +static List *get_revertable_database_list(MemoryContext resultcxt); + +/* + * LogicalRevertShmemSize + * Calculate shared memory space needed. + */ +Size +LogicalRevertShmemSize(void) +{ + return sizeof(LogicalRevertState); +} + +/* + * LogicalRevertShmemInit + * Allocate and initialize shared memory. + */ +void +LogicalRevertShmemInit(void) +{ + bool found; + + RevertState = (LogicalRevertState *) + ShmemInitStruct("Logical Revert Worker State", + sizeof(LogicalRevertState), + &found); + + if (!found) + { + LWLockInitialize(&RevertState->lock, LWTRANCHE_UNDO_WORKER); + RevertState->next_worker_id = 1; + } +} + +/* + * logical_revert_sighup + * SIGHUP signal handler -- reload configuration. + */ +static void +logical_revert_sighup(SIGNAL_ARGS) +{ + int save_errno = errno; + + got_SIGHUP = true; + SetLatch(MyLatch); + + errno = save_errno; +} + +/* + * logical_revert_sigterm + * SIGTERM signal handler -- request shutdown. + */ +static void +logical_revert_sigterm(SIGNAL_ARGS) +{ + int save_errno = errno; + + got_SIGTERM = true; + SetLatch(MyLatch); + + errno = save_errno; +} + +/* + * process_revert_entry + * Apply the WAL-based UNDO chain for a single ATM entry. + * + * Walks the UNDO chain from last_batch_lsn backward, applying each record. + * CLR records are written during application so that crash recovery is + * idempotent. Returns silently if last_batch_lsn is invalid (nothing to do). + */ +static void +process_revert_entry(TransactionId xid, XLogRecPtr last_batch_lsn) +{ + if (!XLogRecPtrIsValid(last_batch_lsn)) + return; /* nothing to apply */ + + ereport(DEBUG1, + (errmsg("logical revert: applying UNDO chain for xid %u " + "from LSN %X/%X", + xid, LSN_FORMAT_ARGS(last_batch_lsn)))); + + ApplyUndoChainFromWAL(last_batch_lsn); +} + +/* + * LogicalRevertWorkerMain + * Main entry point for the Logical Revert background worker. + * + * The worker connects to a specific database, then loops: scan the ATM + * for unreverted entries matching this database, apply them, mark done, + * forget. Sleep when idle. + */ +void +LogicalRevertWorkerMain(Datum main_arg) +{ + Oid dboid = DatumGetObjectId(main_arg); + int worker_id; + + /* Establish signal handlers */ + pqsignal(SIGHUP, logical_revert_sighup); + pqsignal(SIGTERM, logical_revert_sigterm); + + BackgroundWorkerUnblockSignals(); + + /* Connect to the target database */ + BackgroundWorkerInitializeConnectionByOid(dboid, InvalidOid, 0); + + /* Assign a worker ID */ + LWLockAcquire(&RevertState->lock, LW_EXCLUSIVE); + worker_id = RevertState->next_worker_id++; + LWLockRelease(&RevertState->lock); + + elog(LOG, "logical revert worker %d started for database %u", + worker_id, dboid); + + while (!got_SIGTERM) + { + TransactionId xid; + Oid entry_dboid; + XLogRecPtr last_batch_lsn; + int rc; + + /* + * Service interrupts and ProcSignalBarriers at the top of every + * iteration. Without this, a worker that is busy reverting back- + * to-back ATM entries via the `continue` path below never reaches the + * WaitLatch sleep, and ALTER DATABASE ... SET TABLESPACE / CREATE + * DATABASE ... STRATEGY=FILE_COPY will hang waiting for this backend + * to accept PROCSIGNAL_BARRIER_SMGRRELEASE. + */ + CHECK_FOR_INTERRUPTS(); + + /* Reload configuration on SIGHUP */ + if (got_SIGHUP) + { + got_SIGHUP = false; + ProcessConfigFile(PGC_SIGHUP); + } + + /* Scan ATM for the next unreverted entry */ + if (ATMGetNextUnreverted(&xid, &entry_dboid, &last_batch_lsn)) + { + /* + * ATMGetNextUnreverted returns entries for any database. Skip + * entries that belong to a different database. + */ + if (entry_dboid != MyDatabaseId) + goto sleep; + + StartTransactionCommand(); + + /* + * Test hook: let a recovery test freeze the worker here, after the + * ATM entry has been (re)discovered but before it is reverted and + * forgotten, so the test can observe the reconstructed ATM state. + */ + INJECTION_POINT("logical-revert-before-process", NULL); + + PG_TRY(); + { + process_revert_entry(xid, last_batch_lsn); + + /* + * Mark the ATM entry as reverted, then emit XLOG_ATM_FORGET + * and remove it from the ATM entirely. + */ + ATMMarkReverted(xid); + ATMForget(xid); + } + PG_CATCH(); + { + EmitErrorReport(); + FlushErrorState(); + + /* + * Reset the cached WAL reader -- it may hold stale segment + * state (invalid FD, partial read buffer) after the error. + * Without this, the next UndoReadBatchFromWAL call would + * reuse the corrupted reader and SIGSEGV. + */ + UndoResetBatchReader(); + + /* + * If the WAL segment holding this abort's UNDO batch is + * behind the redo pointer, it looks recycled -- which must + * never happen for an un-reverted abort. The retention + * chain pins that WAL against recycling for exactly as long + * as the ATM entry survives: + * + * ATMGetOldestUnrevertedLSN (atm.c) + * -> UndoGetOldestBatchLSN (undolog.c) + * -> KeepLogSeg (xlog.c) + * + * so KeepLogSeg cannot advance past last_batch_lsn until + * ATMForget removes the entry. Reaching this branch means + * that invariant was violated and UNDO WAL for an + * un-reverted abort is genuinely gone: the cluster's + * guaranteed-rollback claim is already broken. Silently + * marking the entry reverted would fake a rollback that + * never happened and leave stale tuple state behind, so + * fail loudly instead -- the integrity guarantee is + * cluster-wide, hence PANIC rather than continuing. + * + * The other-error branch is a transient failure (the WAL is + * still retained); log it and let the next iteration retry. + */ + { + XLogRecPtr redo = GetRedoRecPtr(); + + if (XLogRecPtrIsValid(last_batch_lsn) && + last_batch_lsn < redo) + { + elog(PANIC, "logical revert worker: UNDO WAL for " + "un-reverted xid %u at %X/%X was recycled " + "(redo at %X/%X); retention invariant violated " + "(ATMGetOldestUnrevertedLSN -> " + "UndoGetOldestBatchLSN -> KeepLogSeg must pin " + "this WAL until ATMForget) -- guaranteed " + "rollback can no longer be honored", + xid, LSN_FORMAT_ARGS(last_batch_lsn), + LSN_FORMAT_ARGS(redo)); + } + else + { + elog(LOG, "logical revert worker: failed to revert " + "xid %u, will retry", xid); + } + } + } + PG_END_TRY(); + + CommitTransactionCommand(); + + /* Immediately look for more work instead of sleeping */ + continue; + } + +sleep: + /* No work available, wait */ + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + logical_revert_naptime, + PG_WAIT_EXTENSION); + + ResetLatch(MyLatch); + + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + } + + elog(LOG, "logical revert worker %d shutting down", worker_id); + proc_exit(0); +} + +/* + * StartLogicalRevertWorker + * Launch a logical revert worker for the specified database. + * + * The worker uses a modest `bgw_restart_time` so that if it exits due + * to a transient error the postmaster auto-restarts it. That removes + * the need for the launcher to re-spawn workers on every scan. + */ +void +StartLogicalRevertWorker(Oid dboid) +{ + BackgroundWorker worker; + BackgroundWorkerHandle *handle; + + memset(&worker, 0, sizeof(BackgroundWorker)); + worker.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION | + BGWORKER_INTERRUPTIBLE; + worker.bgw_start_time = BgWorkerStart_RecoveryFinished; + worker.bgw_restart_time = 60; + sprintf(worker.bgw_library_name, "postgres"); + sprintf(worker.bgw_function_name, "LogicalRevertWorkerMain"); + snprintf(worker.bgw_name, BGW_MAXLEN, + "logical revert worker for database %u", dboid); + snprintf(worker.bgw_type, BGW_MAXLEN, "logical revert worker"); + worker.bgw_main_arg = ObjectIdGetDatum(dboid); + worker.bgw_notify_pid = MyProcPid; + + if (!RegisterDynamicBackgroundWorker(&worker, &handle)) + { + ereport(WARNING, + (errmsg("could not register logical revert worker for database %u", + dboid))); + } + else + { + elog(DEBUG1, "started logical revert worker for database %u", dboid); + } +} + +/* + * get_revertable_database_list + * Return a palloc'd List of OIDs of databases that should get a worker. + * + * Scans pg_database and collects every connectable, non-template database. + * The returned list is allocated in resultcxt (a long-lived context) so it + * survives the transaction the scan runs in. Mirrors autovacuum's + * get_database_list(): the scan must run inside a transaction, but the + * result outlives it. + */ +static List * +get_revertable_database_list(MemoryContext resultcxt) +{ + List *dblist = NIL; + Relation pg_database; + TableScanDesc scan; + HeapTuple tup; + + StartTransactionCommand(); + + pg_database = table_open(DatabaseRelationId, AccessShareLock); + scan = table_beginscan_catalog(pg_database, 0, NULL); + while ((tup = heap_getnext(scan, ForwardScanDirection)) != NULL) + { + Form_pg_database db = (Form_pg_database) GETSTRUCT(tup); + MemoryContext oldcxt; + + if (!db->datallowconn) + continue; + + /* + * Skip template databases. template1 / template0 are not expected to + * accumulate ATM entries, and holding a revert-worker connection to + * template1 would block subsequent CREATE DATABASE commands that use + * it as the source template. + */ + if (strncmp(NameStr(db->datname), "template", 8) == 0) + continue; + + /* Append the OID in the caller's long-lived context. */ + oldcxt = MemoryContextSwitchTo(resultcxt); + dblist = lappend_oid(dblist, db->oid); + MemoryContextSwitchTo(oldcxt); + } + table_endscan(scan); + table_close(pg_database, AccessShareLock); + + CommitTransactionCommand(); + + return dblist; +} + +/* + * LogicalRevertLauncherMain + * Launcher background worker. + * + * The launcher re-scans pg_database on every wake-up and spawns a + * LogicalRevertWorker for any connectable, non-template database that does + * not already have one. This way databases created after server start get + * a worker without requiring a restart. Databases that disappear are + * dropped from the tracking set so their OIDs can be re-tracked if reused. + * + * We track the set of databases we have already spawned a worker for so we + * do not re-register on every wake-up: the per-db workers use + * `bgw_restart_time = 60` and the postmaster auto-restarts them if they + * exit, and the bgworker slot pool is small enough that eager re-spawning + * would exhaust it. + */ +void +LogicalRevertLauncherMain(Datum main_arg) +{ + MemoryContext launcher_cxt; + List *known_dbs = NIL; + + pqsignal(SIGHUP, logical_revert_sighup); + pqsignal(SIGTERM, logical_revert_sigterm); + BackgroundWorkerUnblockSignals(); + + /* + * The launcher does not connect to any database itself. It spawns per-db + * workers which do their own connection (via + * BackgroundWorkerInitializeConnectionByOid). For the pg_database scans + * we connect to postgres (not template1, because holding a connection to + * template1 would block CREATE DATABASE). + */ + BackgroundWorkerInitializeConnection("postgres", NULL, 0); + + /* + * Long-lived context for the set of databases we have already started a + * worker for. It must outlive the per-scan transactions, so it cannot + * live in a transaction-scoped context. + */ + launcher_cxt = AllocSetContextCreate(TopMemoryContext, + "Logical Revert Launcher", + ALLOCSET_SMALL_SIZES); + + elog(LOG, "logical revert launcher started"); + + while (!got_SIGTERM) + { + int rc; + List *current_dbs; + List *next_known = NIL; + MemoryContext oldcxt; + ListCell *lc; + + /* Service interrupts and ProcSignalBarriers at every iteration. */ + CHECK_FOR_INTERRUPTS(); + + if (got_SIGHUP) + { + got_SIGHUP = false; + ProcessConfigFile(PGC_SIGHUP); + } + + /* Re-scan pg_database to discover databases created or dropped. */ + current_dbs = get_revertable_database_list(launcher_cxt); + + oldcxt = MemoryContextSwitchTo(launcher_cxt); + foreach(lc, current_dbs) + { + Oid dboid = lfirst_oid(lc); + + /* + * Carry forward databases that already have a live worker. + */ + if (list_member_oid(known_dbs, dboid)) + { + next_known = lappend_oid(next_known, dboid); + continue; + } + + /* + * Start a worker for a newly seen database only while we stay at + * or below max_logical_revert_workers concurrently tracked + * per-database workers. The GUC is a hard ceiling on the number + * of live revert workers so the launcher never exhausts the + * shared background-worker slot pool. On a cluster with more + * connectable databases than workers, the databases past the + * ceiling are serviced once a tracked database is dropped (its + * slot frees up on the next scan); raise the GUC to cover all + * databases concurrently. + */ + if (list_length(next_known) >= max_logical_revert_workers) + continue; + + StartLogicalRevertWorker(dboid); + next_known = lappend_oid(next_known, dboid); + } + MemoryContextSwitchTo(oldcxt); + + /* + * Replace the tracked set with the databases that still exist. Any + * dropped database falls out here; its worker (if any) exits on its + * own when its database disappears, and forgetting the OID lets a + * reused OID be re-tracked on a later scan. + */ + list_free(known_dbs); + known_dbs = next_known; + list_free(current_dbs); + + /* + * Sleep until the next scan. A shorter interval than the original + * one-shot design so newly created databases pick up a revert worker + * promptly; the no-op fast path (every db already tracked) is cheap. + */ + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_POSTMASTER_DEATH, + 60000L, /* 1 minute */ + PG_WAIT_EXTENSION); + + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + + ResetLatch(MyLatch); + } + + elog(LOG, "logical revert launcher shutting down"); + proc_exit(0); +} + +/* + * LogicalRevertLauncherRegister + * Register the logical revert launcher as a static background worker. + * + * Called from postmaster.c at startup, alongside ApplyLauncherRegister(). + * UNDO is always-on infrastructure; table AMs opt in via am_supports_undo. + */ +void +LogicalRevertLauncherRegister(void) +{ + BackgroundWorker bgw; + + /* Disabled during binary upgrade or when explicitly turned off. */ + if (IsBinaryUpgrade) + return; + if (max_logical_revert_workers <= 0) + return; + + memset(&bgw, 0, sizeof(bgw)); + bgw.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION; + bgw.bgw_start_time = BgWorkerStart_RecoveryFinished; + bgw.bgw_restart_time = 5; + snprintf(bgw.bgw_library_name, MAXPGPATH, "postgres"); + snprintf(bgw.bgw_function_name, BGW_MAXLEN, "LogicalRevertLauncherMain"); + snprintf(bgw.bgw_name, BGW_MAXLEN, "logical revert launcher"); + snprintf(bgw.bgw_type, BGW_MAXLEN, "logical revert launcher"); + bgw.bgw_notify_pid = 0; + bgw.bgw_main_arg = (Datum) 0; + + RegisterBackgroundWorker(&bgw); +} diff --git a/src/backend/access/undo/meson.build b/src/backend/access/undo/meson.build new file mode 100644 index 0000000000000..8bdab6ef2da64 --- /dev/null +++ b/src/backend/access/undo/meson.build @@ -0,0 +1,28 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +backend_sources += files( + 'atm.c', + 'logical_revert_worker.c', + 'relundo.c', + 'relundo_apply.c', + 'relundo_discard.c', + 'relundo_page.c', + 'relundo_recovery.c', + 'relundo_worker.c', + 'relundo_xlog.c', + 'slog.c', + 'slog_flathash.c', + 'slog_tuple.c', + 'undo.c', + 'undo_bufmgr.c', + 'undo_xlog.c', + 'undoapply.c', + 'undobuffer.c', + 'undoinsert.c', + 'undolog.c', + 'undorecord.c', + 'undormgr.c', + 'undostats.c', + 'undoworker.c', + 'xactundo.c', +) diff --git a/src/backend/access/undo/relundo.c b/src/backend/access/undo/relundo.c new file mode 100644 index 0000000000000..ceec933ca3c12 --- /dev/null +++ b/src/backend/access/undo/relundo.c @@ -0,0 +1,1443 @@ +/*------------------------------------------------------------------------- + * + * relundo.c + * Per-relation UNDO core implementation + * + * This file implements the main API for per-relation UNDO logging used by + * table access methods that need MVCC visibility via UNDO chain walking. + * + * The two-phase insert protocol works as follows: + * + * 1. RelUndoReserve() - Finds (or allocates) a page with enough space, + * pins and exclusively locks the buffer, advances pd_lower to reserve + * space, and returns an RelUndoRecPtr encoding the position. + * + * 2. Caller performs the DML operation. + * + * 3a. RelUndoFinish() - Writes the actual UNDO record into the reserved + * space, marks the buffer dirty, and releases it. + * 3b. RelUndoCancel() - Releases the buffer without writing; the reserved + * space becomes a hole (zero-filled). + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/relundo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/relundo.h" +#include "access/relundo_xlog.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "access/xlogutils.h" +#include "catalog/storage.h" +#include "catalog/storage_xlog.h" +#include "common/relpath.h" +#include "miscadmin.h" +#include "port/atomics.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/procarray.h" +#include "storage/procnumber.h" +#include "storage/smgr.h" +#include "utils/timestamp.h" + +/* + * AM-neutral hook function pointers (declared in access/relundo.h). + * + * The owning in-place table AM installs these at subsystem init so the UNDO + * core can clear AM transient tuple flags and reclaim retained before-images + * without compile-time knowledge of any AM's tuple format. They remain NULL + * when no in-place AM is registered. + */ +void (*RelUndoClearTransientFlags_hook) (char *tuple_data) = NULL; +void (*RelUndoAbortCleanup_hook) (TransactionId xid) = NULL; +void (*RelUndoDiscardRetained_hook) (void) = NULL; + +/* + * Per-backend UNDO head page cache. + * + * Every RelUndoReserve() call currently acquires an EXCLUSIVE lock on the + * metapage to find the current head page. For insert-heavy workloads this + * is a severe contention point (100K inserts = 100K exclusive metapage locks). + * + * This hash-table cache remembers the current head page and its free space + * for recently-used relations. When the cached page has enough space, we + * skip the metapage entirely and go directly to the data page with an + * EXCLUSIVE lock. Cache misses and full pages fall back to the metapage path. + * + * The cache is implemented as an open-addressing hash table with linear + * probing and a clock-hand eviction policy. This gives O(1) average-case + * lookup and update, replacing the O(N) linear scan of the previous LRU list. + * + * Cache entries are invalidated when: + * - The cached page turns out to be full (optimistic approach) + * - A new page is allocated (the head page changes) + * - RelUndoInitRelation is called (the fork is recreated) + * + * Size: must be a power of two for the hash masking to work correctly. + * 64 slots covers workloads with up to ~48 concurrently-active relations + * before eviction starts (load factor ~0.75). Increasing to 128 or 256 + * is straightforward if profiling shows benefit. + * + * Probe chain correctness: we use a tombstone OID (RELUNDO_CACHE_TOMBSTONE) + * rather than InvalidOid for invalidated slots. Setting a slot to InvalidOid + * would create a gap that breaks the linear probe chain for entries that were + * inserted past that slot. A tombstone is skipped during lookup but does + * not terminate the probe, so all entries remain reachable. During insert, + * tombstone slots are reused (treated as free), which amortizes tombstone + * accumulation. The table is re-initialized on first use (all slots + * InvalidOid), so no "previous tombstone" confusion can arise at startup. + */ +#define RELUNDO_HEAD_CACHE_SIZE 64 /* must be a power of two */ +#define RELUNDO_HEAD_CACHE_MASK (RELUNDO_HEAD_CACHE_SIZE - 1) + +/* + * Sentinel OID used to mark a cache slot as "tombstone" (deleted but + * still part of a probe chain). Must not be a valid relation OID and + * must differ from InvalidOid (0). OID 1 is reserved by the system + * (pg_type), so it is safe to repurpose as a tombstone here; it will + * never appear as a user-relation OID passed to these cache functions. + */ +#define RELUNDO_CACHE_TOMBSTONE ((Oid) 1) + +typedef struct RelUndoHeadCacheEntry +{ + Oid relid; /* Relation OID; InvalidOid=empty, + * RELUNDO_CACHE_TOMBSTONE=deleted */ + BlockNumber head_blkno; /* Cached head page block number */ + Size free_space; /* Last-known free space on head page */ +} RelUndoHeadCacheEntry; + +static RelUndoHeadCacheEntry relundo_head_cache[RELUNDO_HEAD_CACHE_SIZE]; +static bool relundo_head_cache_init = false; + +/* Clock hand for round-robin eviction (avoids pure linear probing pile-up) */ +static int relundo_cache_evict_hand = 0; + +/* + * Per-backend pending metapage buffer. + * + * When RelUndoReserve() allocates a new UNDO page, the metapage is modified + * (new head_blkno) and must be included in the WAL record written by + * RelUndoFinish(). Previously, RelUndoReserve() released the metapage lock + * and RelUndoFinish() re-acquired it, creating an ABBA deadlock: + * + * Backend A: holds metapage → wants UNDO data page + * Backend B: holds UNDO data page → wants metapage + * + * Fix: keep the metapage locked through the Reserve→Finish cycle. + * RelUndoReserve() stores the locked metapage buffer here, and + * RelUndoFinish()/RelUndoFinishWithTuple() retrieves it. + * RelUndoCancel() releases it if the operation is aborted. + */ +static Buffer relundo_pending_metabuf = InvalidBuffer; + +/* + * relundo_oid_hash -- fast hash of a relation OID for cache slot selection. + * + * Uses Knuth's multiplicative hash (2654435761 is close to 2^32/phi). + * The result is masked to RELUNDO_HEAD_CACHE_SIZE slots. + */ +static inline int +relundo_oid_hash(Oid relid) +{ + return (int) (((uint32) relid * UINT32_C(2654435761)) >> (32 - 6)); +} + +/* + * relundo_my_slot -- pick this backend's UNDO head slot. + * + * Every committed append hashes to one of RELUNDO_NUM_HEADS independent head + * pages so concurrent writers spread across distinct tail-page content locks + * instead of serializing on one. A backend always resolves to the same slot + * (its ProcNumber modulo the slot count), which keeps the process-local head + * cache coherent: a given backend only ever touches its own slot's head page, + * so the single-entry-per-relation cache never aliases across slots. + * + * MyProcNumber is INVALID_PROC_NUMBER (-1) outside a normal backend (e.g. in + * the startup process during recovery, which never calls RelUndoReserve on the + * write path). Fold that to slot 0 defensively so the modulo is well defined. + */ +static inline int +relundo_my_slot(void) +{ + int pn = (int) MyProcNumber; + + if (pn < 0) + return 0; + return pn % RELUNDO_NUM_HEADS; +} + +/* + * relundo_head_cache_lookup -- find a cache entry for the given relation. + * + * Uses open-addressing with linear probing. Returns the cache entry pointer + * if found, NULL otherwise. Average cost: O(1) under low load. + * + * Tombstone slots (RELUNDO_CACHE_TOMBSTONE) are skipped but do not terminate + * the probe, preserving the correctness of chains established at insert time. + * Only a truly empty slot (InvalidOid) terminates the probe early. + */ +static RelUndoHeadCacheEntry * +relundo_head_cache_lookup(Oid relid) +{ + int start; + int i; + int probes; + + if (!relundo_head_cache_init) + { + int j; + + for (j = 0; j < RELUNDO_HEAD_CACHE_SIZE; j++) + relundo_head_cache[j].relid = InvalidOid; + relundo_head_cache_init = true; + } + + start = relundo_oid_hash(relid); + + /* + * Linear probe up to RELUNDO_HEAD_CACHE_SIZE slots. + * + * - Match found: return the entry. + * - Tombstone (RELUNDO_CACHE_TOMBSTONE): continue probing; the target + * entry may sit beyond this deleted slot. + * - Empty slot (InvalidOid): the entry is definitely not present + * (it would have been placed before the first empty slot at insert + * time), so terminate early. + */ + for (probes = 0; probes < RELUNDO_HEAD_CACHE_SIZE; probes++) + { + i = (start + probes) & RELUNDO_HEAD_CACHE_MASK; + + if (relundo_head_cache[i].relid == relid) + return &relundo_head_cache[i]; + + if (relundo_head_cache[i].relid == InvalidOid) + return NULL; /* definitely not present */ + + /* RELUNDO_CACHE_TOMBSTONE: keep probing */ + } + + return NULL; +} + +/* + * relundo_head_cache_update -- update or insert a cache entry. + * + * Finds the existing slot for relid (if any) or inserts into the first + * free slot in the probe sequence. If the probe sequence is full, evicts + * via the clock hand to avoid unbounded probing. + */ +static void +relundo_head_cache_update(Oid relid, BlockNumber head_blkno, Size free_space) +{ + int start; + int i; + int probes; + int free_slot = -1; + + if (!relundo_head_cache_init) + { + int j; + + for (j = 0; j < RELUNDO_HEAD_CACHE_SIZE; j++) + relundo_head_cache[j].relid = InvalidOid; + relundo_head_cache_init = true; + } + + start = relundo_oid_hash(relid); + + /* Probe for existing entry or first free/tombstone slot */ + for (probes = 0; probes < RELUNDO_HEAD_CACHE_SIZE; probes++) + { + i = (start + probes) & RELUNDO_HEAD_CACHE_MASK; + + if (relundo_head_cache[i].relid == relid) + { + /* Update in-place */ + relundo_head_cache[i].head_blkno = head_blkno; + relundo_head_cache[i].free_space = free_space; + return; + } + + /* + * A truly empty slot (InvalidOid) terminates the existing probe + * chain: the relid definitely doesn't exist beyond this point. + * Use the earliest tombstone found so far (if any) to compact the + * table, or this slot if no tombstone was seen. + */ + if (relundo_head_cache[i].relid == InvalidOid) + { + if (free_slot < 0) + free_slot = i; + break; + } + + /* + * Tombstone: record as candidate insertion point (reuses the slot + * to amortize tombstone accumulation) but keep probing in case + * relid exists beyond this slot. + */ + if (relundo_head_cache[i].relid == RELUNDO_CACHE_TOMBSTONE && + free_slot < 0) + free_slot = i; /* remember but keep probing for existing */ + } + + if (free_slot >= 0) + { + /* Insert into the free slot found during probing */ + relundo_head_cache[free_slot].relid = relid; + relundo_head_cache[free_slot].head_blkno = head_blkno; + relundo_head_cache[free_slot].free_space = free_space; + return; + } + + /* + * No free slot found in the probe sequence (table heavily loaded). + * Evict using the clock hand: advance to the next "real" entry (not + * empty, not tombstone) and overwrite it. This bounds the cost to + * O(1) amortized and avoids evicting a tombstone (which would leave + * a gap that looks like "nothing past here" during lookup). + */ + for (probes = 0; probes < RELUNDO_HEAD_CACHE_SIZE; probes++) + { + i = relundo_cache_evict_hand & RELUNDO_HEAD_CACHE_MASK; + relundo_cache_evict_hand = (i + 1) & RELUNDO_HEAD_CACHE_MASK; + + if (relundo_head_cache[i].relid != InvalidOid && + relundo_head_cache[i].relid != RELUNDO_CACHE_TOMBSTONE) + { + relundo_head_cache[i].relid = relid; + relundo_head_cache[i].head_blkno = head_blkno; + relundo_head_cache[i].free_space = free_space; + return; + } + } + + /* Should not reach here if init is correct, but handle gracefully */ + relundo_head_cache[start].relid = relid; + relundo_head_cache[start].head_blkno = head_blkno; + relundo_head_cache[start].free_space = free_space; +} + +/* + * relundo_head_cache_invalidate -- remove a cache entry for the given relation. + * + * Marks the slot with a tombstone (RELUNDO_CACHE_TOMBSTONE) rather than + * InvalidOid. This preserves the correctness of probe chains: other entries + * that were inserted past this slot via linear probing remain reachable. + * Tombstone slots are reused by relundo_head_cache_update, so they do not + * accumulate indefinitely. + */ +static void +relundo_head_cache_invalidate(Oid relid) +{ + int start; + int i; + int probes; + + if (!relundo_head_cache_init) + return; + + start = relundo_oid_hash(relid); + + for (probes = 0; probes < RELUNDO_HEAD_CACHE_SIZE; probes++) + { + i = (start + probes) & RELUNDO_HEAD_CACHE_MASK; + + if (relundo_head_cache[i].relid == relid) + { + /* Replace with tombstone, not InvalidOid, to preserve chains */ + relundo_head_cache[i].relid = RELUNDO_CACHE_TOMBSTONE; + return; + } + + if (relundo_head_cache[i].relid == InvalidOid) + return; /* not present; tombstones don't terminate probes */ + } +} + +/* + * RelUndoHeadCacheInvalidate -- public wrapper around the per-backend head + * page cache invalidation. + * + * RelUndoDiscard() (in relundo_discard.c) reclaims pages and may physically + * truncate the fork back to the metapage. The discarding backend's head page + * cache can still name a block that no longer exists on disk; the next reserve + * in that backend would then ReadBufferExtended() a truncated block and fault + * with "could not read blocks N..N: read only 0 of 8192 bytes". Discard must + * therefore drop the cache entry for the relation so the next reserve re-reads + * the metapage instead of trusting the stale block number. + */ +void +RelUndoHeadCacheInvalidate(Oid relid) +{ + relundo_head_cache_invalidate(relid); +} + +/* + * RelUndoReserve + * Reserve space for an UNDO record (Phase 1 of 2-phase insert) + * + * Finds a page with enough free space for record_size bytes (which must + * include the RelUndoRecordHeader). If the current head page doesn't have + * enough room, a new page is allocated and linked at the head. + * + * Returns an RelUndoRecPtr encoding (counter, blockno, offset). + * The buffer is returned pinned and exclusively locked via *undo_buffer. + */ +RelUndoRecPtr +RelUndoReserve(Relation rel, Size record_size, Buffer *undo_buffer) +{ + Buffer metabuf; + Page metapage; + RelUndoMetaPage meta; + Buffer databuf; + Page datapage; + RelUndoPageHeader datahdr; + BlockNumber blkno; + uint16 offset; + RelUndoRecPtr ptr; + RelUndoHeadCacheEntry *cache_entry; + int slot = relundo_my_slot(); + + /* + * Sanity check: record must fit on an empty data page. The usable space + * is the contents area minus our RelUndoPageHeaderData. + */ + { + Size max_record = BLCKSZ - MAXALIGN(SizeOfPageHeaderData) + - SizeOfRelUndoPageHeaderData; + + if (record_size > max_record) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("UNDO record size %zu exceeds maximum %zu", + record_size, max_record))); + } + + /* + * Tier 1: per-backend head page cache. If we have a cached head page for + * this relation with enough free space, skip the metapage lock entirely + * and go directly to the data page. + * + * pd_lower is advanced (at the reserve: label below) with a plain + * non-atomic read-add-store, so it MUST be done under the buffer's + * EXCLUSIVE content lock. An earlier "lock-free" tier advanced pd_lower + * via an atomic CAS while holding no content lock; that was unsound + * because a buffer content lock and a lock-free atomic do not mutually + * exclude, so the non-atomic store on this path could straddle and clobber + * a concurrent CAS on the same hot head page, handing two reservers + * overlapping offsets and corrupting the resulting UNDO records. Every + * remaining reserve path advances pd_lower only under the buffer lock, so + * they are mutually exclusive. + */ + cache_entry = relundo_head_cache_lookup(RelationGetRelid(rel)); + if (cache_entry != NULL && + BlockNumberIsValid(cache_entry->head_blkno) && + cache_entry->free_space >= record_size && + cache_entry->head_blkno < + smgrnblocks(RelationGetSmgr(rel), RELUNDO_FORKNUM)) + { + /* + * The head cache is process-local, but a concurrent autovacuum worker + * (holding only ShareUpdateExclusiveLock, which does not exclude our + * RowExclusiveLock) can whole-chain-discard and physically truncate + * this fork. smgrtruncate broadcasts a smgr invalidation but does NOT + * touch any other backend's relundo_head_cache, so our cached + * head_blkno may now point past the shrunken EOF. Reading it would + * either fault on a nonexistent block or return a recycled/garbage + * page. The smgrnblocks bound in the guard above rejects that stale + * entry, and we fall through to the metapage path, which re-reads the + * authoritative head_blkno under lock. + */ + databuf = ReadBufferExtended(rel, RELUNDO_FORKNUM, + cache_entry->head_blkno, + RBM_NORMAL, NULL); + LockBuffer(databuf, BUFFER_LOCK_EXCLUSIVE); + datapage = BufferGetPage(databuf); + + /* Verify the page still has space (another backend may have used it) */ + if (relundo_get_free_space(datapage) >= record_size) + { + blkno = cache_entry->head_blkno; + + /* Update cached free space */ + cache_entry->free_space = relundo_get_free_space(datapage) - record_size; + + goto reserve; + } + + /* Cache was stale -- fall through to metapage path */ + UnlockReleaseBuffer(databuf); + relundo_head_cache_invalidate(RelationGetRelid(rel)); + } + + /* + * Tier 2: Shared-lock fast path. Read the metapage with SHARED lock to + * get the head page, then check the data page directly. Only if the + * data page is full do we re-acquire the metapage with EXCLUSIVE lock for + * new page allocation. This reduces contention when multiple backends + * are doing concurrent DML on the same relation. + */ + metabuf = relundo_get_metapage(rel, BUFFER_LOCK_SHARE); + metapage = BufferGetPage(metabuf); + meta = (RelUndoMetaPage) PageGetContents(metapage); + + elog(DEBUG1, "RelUndoReserve: record_size=%zu, slot=%d, head_blkno=%u", + record_size, slot, meta->head_blkno[slot]); + + if (BlockNumberIsValid(meta->head_blkno[slot])) + { + BlockNumber cached_head = meta->head_blkno[slot]; + + /* Release the shared lock before touching the data page */ + UnlockReleaseBuffer(metabuf); + + elog(DEBUG1, "RelUndoReserve: reading existing head page %u (shared-lock path)", + cached_head); + + databuf = ReadBufferExtended(rel, RELUNDO_FORKNUM, cached_head, + RBM_NORMAL, NULL); + LockBuffer(databuf, BUFFER_LOCK_EXCLUSIVE); + + datapage = BufferGetPage(databuf); + + elog(DEBUG1, "RelUndoReserve: free_space=%zu", + relundo_get_free_space(datapage)); + + if (relundo_get_free_space(datapage) >= record_size) + { + /* Enough space on current head page */ + blkno = cached_head; + + elog(DEBUG1, "RelUndoReserve: enough space, using block %u", blkno); + + /* Update the head page cache */ + relundo_head_cache_update(RelationGetRelid(rel), blkno, + relundo_get_free_space(datapage) - record_size); + + goto reserve; + } + + /* Not enough space; release this page, fall through to exclusive path */ + elog(DEBUG1, "RelUndoReserve: not enough space, need new page allocation"); + UnlockReleaseBuffer(databuf); + } + else + { + /* No head page yet -- release shared lock */ + UnlockReleaseBuffer(metabuf); + } + + /* + * Need EXCLUSIVE metapage lock for new page allocation. + * Re-read the metapage since another backend may have allocated + * a new page between our shared-lock release and now. + */ + metabuf = relundo_get_metapage(rel, BUFFER_LOCK_EXCLUSIVE); + metapage = BufferGetPage(metabuf); + meta = (RelUndoMetaPage) PageGetContents(metapage); + + /* Re-check: another backend may have added space while we waited */ + if (BlockNumberIsValid(meta->head_blkno[slot])) + { + databuf = ReadBufferExtended(rel, RELUNDO_FORKNUM, meta->head_blkno[slot], + RBM_NORMAL, NULL); + LockBuffer(databuf, BUFFER_LOCK_EXCLUSIVE); + datapage = BufferGetPage(databuf); + + if (relundo_get_free_space(datapage) >= record_size) + { + blkno = meta->head_blkno[slot]; + + relundo_head_cache_update(RelationGetRelid(rel), blkno, + relundo_get_free_space(datapage) - record_size); + + UnlockReleaseBuffer(metabuf); + goto reserve; + } + + UnlockReleaseBuffer(databuf); + } + + /* + * Need a new page. relundo_allocate_page handles free list / extend, + * links the new page as head, and marks both buffers dirty. + */ + blkno = relundo_allocate_page(rel, metabuf, slot, &databuf); + datapage = BufferGetPage(databuf); + + /* Update cache with the new head page */ + relundo_head_cache_update(RelationGetRelid(rel), blkno, + relundo_get_free_space(datapage) - record_size); + + /* + * Keep the metapage locked: RelUndoFinish() needs it for the WAL record. + * Store in per-backend variable to avoid changing the API. + */ + relundo_pending_metabuf = metabuf; + +reserve: + /* Reserve space by advancing pd_lower */ + elog(DEBUG1, "RelUndoReserve: at reserve label, block=%u", blkno); + + datahdr = (RelUndoPageHeader) PageGetContents(datapage); + + elog(DEBUG1, "RelUndoReserve: datahdr=%p, pd_lower=%u, pd_upper=%u, counter=%u", + datahdr, datahdr->pd_lower, datahdr->pd_upper, datahdr->counter); + + offset = datahdr->pd_lower; + datahdr->pd_lower += record_size; + + elog(DEBUG1, "RelUndoReserve: reserved offset=%u, new pd_lower=%u", + offset, datahdr->pd_lower); + + /* Build the UNDO pointer */ + ptr = MakeRelUndoRecPtr(datahdr->counter, blkno, offset); + + *undo_buffer = databuf; + return ptr; +} + +/* + * RelUndoStage + * Write an UNDO record onto its reserved page WITHOUT WAL logging. + * + * Performs every page mutation RelUndoFinish() does (header+payload memcpy, + * max_xid bump, MarkBufferDirty on the data page and, for a new page, the + * metapage) and builds the block-0 WAL data buffer, but does NOT open a + * critical section, XLogInsert, PageSetLSN, or release any buffer. The undo + * buffer (and metapage, if a new page was allocated) stay locked+pinned. + * + * The staged facts are returned in *result so the caller can either emit the + * standalone RM_RELUNDO_ID record (RelUndoFinish wrapper) or fold the same + * bytes into a combined record under a different resource manager (the + * caller's WAL-fold path). + */ +void +RelUndoStage(Relation rel, Buffer undo_buffer, RelUndoRecPtr ptr, + const RelUndoRecordHeader *header, const void *payload, + Size payload_size, RelUndoStageResult *result) +{ + Page page; + char *contents; + uint16 offset; + Size total_record_size; + char *record_data; + RelUndoPageHeader datahdr; + bool is_new_page; + Buffer metabuf = InvalidBuffer; + + page = BufferGetPage(undo_buffer); + contents = PageGetContents(page); + offset = RelUndoGetOffset(ptr); + datahdr = (RelUndoPageHeader) contents; + + /* + * Check if this is the first record on a newly allocated page. If the + * offset equals the header size, this is a new page. + */ + is_new_page = (offset == SizeOfRelUndoPageHeaderData); + + /* Calculate total UNDO record size */ + total_record_size = SizeOfRelUndoRecordHeader + payload_size; + + /* Write the header */ + memcpy(contents + offset, header, SizeOfRelUndoRecordHeader); + + /* Write the payload immediately after the header */ + if (payload_size > 0 && payload != NULL) + memcpy(contents + offset + SizeOfRelUndoRecordHeader, + payload, payload_size); + + /* + * Advance the page's max_xid watermark to cover this record. Done before + * WAL-logging so the new-page header copy and the xlrec both observe the + * updated value; redo restores max_xid from the xlrec. + */ + if (!TransactionIdIsValid(datahdr->max_xid) || + TransactionIdFollows(header->urec_xid, datahdr->max_xid)) + datahdr->max_xid = header->urec_xid; + + /* + * Mark the buffer dirty now, before any critical section. + * XLogRegisterBuffer requires the buffer to be dirty when called. + */ + MarkBufferDirty(undo_buffer); + + /* + * If this is a new page, adopt the metapage lock that RelUndoReserve left + * pending. It was modified during page allocation and must be included in + * whichever WAL record the caller emits. + */ + if (is_new_page) + { + Assert(BufferIsValid(relundo_pending_metabuf)); + metabuf = relundo_pending_metabuf; + relundo_pending_metabuf = InvalidBuffer; + + /* Mark metabuf dirty before WAL-logging (assertion requires it) */ + MarkBufferDirty(metabuf); + } + + /* + * Build the block-0 WAL data buffer. For a new page we prepend the + * RelUndoPageHeaderData so redo can reconstruct prev_blkno/counter. + */ + if (is_new_page) + { + Size wal_data_size = SizeOfRelUndoPageHeaderData + total_record_size; + + record_data = (char *) palloc(wal_data_size); + + /* Copy page header */ + memcpy(record_data, datahdr, SizeOfRelUndoPageHeaderData); + + /* Copy UNDO record after the page header */ + memcpy(record_data + SizeOfRelUndoPageHeaderData, + header, SizeOfRelUndoRecordHeader); + if (payload_size > 0 && payload != NULL) + memcpy(record_data + SizeOfRelUndoPageHeaderData + SizeOfRelUndoRecordHeader, + payload, payload_size); + + result->wal_record_size = wal_data_size; + } + else + { + /* Normal case: just the UNDO record */ + record_data = (char *) palloc(total_record_size); + memcpy(record_data, header, SizeOfRelUndoRecordHeader); + if (payload_size > 0 && payload != NULL) + memcpy(record_data + SizeOfRelUndoRecordHeader, payload, payload_size); + + result->wal_record_size = total_record_size; + } + + result->undo_buffer = undo_buffer; + result->metabuf = metabuf; + result->is_new_page = is_new_page; + result->urec_type = header->urec_type; + result->urec_len = header->urec_len; + result->page_offset = MAXALIGN(SizeOfPageHeaderData) + offset; + result->new_pd_lower = datahdr->pd_lower; + result->max_xid = datahdr->max_xid; + result->wal_record_data = record_data; +} + +/* + * RelUndoFinish + * Complete UNDO record insertion (Phase 2 of 2-phase insert) + * + * Writes the header and payload into the space reserved by RelUndoReserve(), + * WAL-logs the insertion as a standalone RM_RELUNDO_ID record, and releases the + * buffer(s). Built on RelUndoStage(): stage the page mutation, then emit the + * record and stamp the LSN. + */ +void +RelUndoFinish(Relation rel, Buffer undo_buffer, RelUndoRecPtr ptr, + const RelUndoRecordHeader *header, const void *payload, + Size payload_size) +{ + RelUndoStageResult staged; + Page page; + uint8 info; + + RelUndoStage(rel, undo_buffer, ptr, header, payload, payload_size, &staged); + + page = BufferGetPage(undo_buffer); + + /* WAL-log the insertion */ + START_CRIT_SECTION(); + + { + xl_relundo_insert xlrec; + + xlrec.urec_type = staged.urec_type; + xlrec.urec_len = staged.urec_len; + xlrec.page_offset = staged.page_offset; + xlrec.new_pd_lower = staged.new_pd_lower; + xlrec.max_xid = staged.max_xid; + + info = XLOG_RELUNDO_INSERT; + if (staged.is_new_page) + info |= XLOG_RELUNDO_INIT_PAGE; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfRelundoInsert); + + /* + * Register the data page. We register the entire UNDO record + * (header + payload) as block data. + * + * For a new page, the block data also carries the RelUndoPageHeaderData + * so redo can reconstruct prev_blkno/counter; REGBUF_WILL_INIT tells + * redo it will initialize the page. + * + * For an existing page, do NOT pass REGBUF_STANDARD. RelUndo data + * pages keep the standard PageHeader.pd_lower pinned at the empty value + * and track their real used extent in the shadow RelUndoPageHeader + * inside the page contents area. REGBUF_STANDARD would treat + * [pd_lower, pd_upper) as a free "hole" and elide the entire contents + * from any full-page image, so a BLK_RESTORED redo would bring the page + * back zeroed -- losing the record bytes and the prev_blkno chain link. + * Logging the whole page (flag 0) keeps the FPI faithful. + */ + if (staged.is_new_page) + XLogRegisterBuffer(0, undo_buffer, REGBUF_WILL_INIT); + else + XLogRegisterBuffer(0, undo_buffer, 0); + + XLogRegisterBufData(0, staged.wal_record_data, staged.wal_record_size); + + /* + * When allocating a new page, the metapage was also updated + * (head_blkno). Register it as block 1 so the metapage state is + * preserved in WAL. Use REGBUF_STANDARD to get a full page image. + */ + if (staged.is_new_page) + XLogRegisterBuffer(1, staged.metabuf, REGBUF_STANDARD); + + { + XLogRecPtr recptr = XLogInsert(RM_RELUNDO_ID, info); + + /* + * Stamp the record LSN onto every page we dirtied and registered. + * Without this the buffer manager's WAL-before-data rule is broken: + * the checkpointer flushes WAL only up to a dirty buffer's page LSN + * before writing it, so a stale/zero LSN lets an UNDO page + * (including its prev_blkno chain links) reach disk ahead of the + * WAL that describes it, corrupting the chain on crash recovery. + */ + PageSetLSN(page, recptr); + if (staged.is_new_page) + PageSetLSN(BufferGetPage(staged.metabuf), recptr); + } + } + + END_CRIT_SECTION(); + + pfree(staged.wal_record_data); + + UnlockReleaseBuffer(undo_buffer); + + /* Release metapage if we locked it */ + if (BufferIsValid(staged.metabuf)) + UnlockReleaseBuffer(staged.metabuf); +} + +/* + * RelUndoFinishWithTuple + * Complete UNDO record insertion with tuple data (Phase 2 of 2-phase insert) + * + * Like RelUndoFinish(), but also writes tuple data after the payload. + * The total record layout on the UNDO page is: + * [RelUndoRecordHeader][payload][tuple_data] + * + * The header must have RELUNDO_INFO_HAS_TUPLE set and tuple_len filled in + * by the caller. + */ +void +RelUndoFinishWithTuple(Relation rel, Buffer undo_buffer, RelUndoRecPtr ptr, + const RelUndoRecordHeader *header, const void *payload, + Size payload_size, const char *tuple_data, + uint32 tuple_len) +{ + Page page; + char *contents; + uint16 offset; + Size total_record_size; + xl_relundo_insert xlrec; + char *record_data; + RelUndoPageHeader datahdr; + bool is_new_page; + uint8 info; + Buffer metabuf = InvalidBuffer; + + elog(DEBUG1, "RelUndoFinishWithTuple: starting, ptr=%lu, payload_size=%zu, tuple_len=%u", + (unsigned long) ptr, payload_size, tuple_len); + + page = BufferGetPage(undo_buffer); + contents = PageGetContents(page); + offset = RelUndoGetOffset(ptr); + datahdr = (RelUndoPageHeader) contents; + + is_new_page = (offset == SizeOfRelUndoPageHeaderData); + + /* Total UNDO record size includes header + payload + tuple data */ + total_record_size = SizeOfRelUndoRecordHeader + payload_size + tuple_len; + + /* Write the header */ + memcpy(contents + offset, header, SizeOfRelUndoRecordHeader); + + /* Write the payload immediately after the header */ + if (payload_size > 0 && payload != NULL) + memcpy(contents + offset + SizeOfRelUndoRecordHeader, + payload, payload_size); + + /* Write the tuple data after the payload */ + if (tuple_len > 0 && tuple_data != NULL) + memcpy(contents + offset + SizeOfRelUndoRecordHeader + payload_size, + tuple_data, tuple_len); + + /* Advance the page's max_xid watermark to cover this record. */ + if (!TransactionIdIsValid(datahdr->max_xid) || + TransactionIdFollows(header->urec_xid, datahdr->max_xid)) + datahdr->max_xid = header->urec_xid; + + MarkBufferDirty(undo_buffer); + + if (is_new_page) + { + Assert(BufferIsValid(relundo_pending_metabuf)); + metabuf = relundo_pending_metabuf; + relundo_pending_metabuf = InvalidBuffer; + + /* Mark metabuf dirty before WAL-logging (assertion requires it) */ + MarkBufferDirty(metabuf); + } + + /* + * Allocate WAL record data buffer before entering critical section. + */ + if (is_new_page) + { + Size wal_data_size = SizeOfRelUndoPageHeaderData + total_record_size; + + record_data = (char *) palloc(wal_data_size); + memcpy(record_data, datahdr, SizeOfRelUndoPageHeaderData); + memcpy(record_data + SizeOfRelUndoPageHeaderData, + header, SizeOfRelUndoRecordHeader); + if (payload_size > 0 && payload != NULL) + memcpy(record_data + SizeOfRelUndoPageHeaderData + SizeOfRelUndoRecordHeader, + payload, payload_size); + if (tuple_len > 0 && tuple_data != NULL) + memcpy(record_data + SizeOfRelUndoPageHeaderData + SizeOfRelUndoRecordHeader + payload_size, + tuple_data, tuple_len); + } + else + { + record_data = (char *) palloc(total_record_size); + memcpy(record_data, header, SizeOfRelUndoRecordHeader); + if (payload_size > 0 && payload != NULL) + memcpy(record_data + SizeOfRelUndoRecordHeader, payload, payload_size); + if (tuple_len > 0 && tuple_data != NULL) + memcpy(record_data + SizeOfRelUndoRecordHeader + payload_size, + tuple_data, tuple_len); + } + + /* WAL-log the insertion */ + START_CRIT_SECTION(); + + xlrec.urec_type = header->urec_type; + xlrec.urec_len = header->urec_len; + xlrec.page_offset = MAXALIGN(SizeOfPageHeaderData) + offset; + xlrec.new_pd_lower = datahdr->pd_lower; + xlrec.max_xid = datahdr->max_xid; + + info = XLOG_RELUNDO_INSERT; + if (is_new_page) + info |= XLOG_RELUNDO_INIT_PAGE; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfRelundoInsert); + + if (is_new_page) + { + Size wal_data_size = SizeOfRelUndoPageHeaderData + total_record_size; + + XLogRegisterBuffer(0, undo_buffer, REGBUF_WILL_INIT); + XLogRegisterBufData(0, record_data, wal_data_size); + XLogRegisterBuffer(1, metabuf, REGBUF_STANDARD); + } + else + { + /* Full page image, no hole: see the REGBUF note in RelUndoFinish. */ + XLogRegisterBuffer(0, undo_buffer, 0); + XLogRegisterBufData(0, record_data, total_record_size); + } + + { + XLogRecPtr recptr = XLogInsert(RM_RELUNDO_ID, info); + + /* Stamp the record LSN onto every dirtied+registered page; see the + * WAL-before-data note in RelUndoFinish. */ + PageSetLSN(page, recptr); + if (is_new_page) + PageSetLSN(BufferGetPage(metabuf), recptr); + } + + END_CRIT_SECTION(); + + pfree(record_data); + + UnlockReleaseBuffer(undo_buffer); + + if (BufferIsValid(metabuf)) + UnlockReleaseBuffer(metabuf); +} + +/* + * RelUndoCancel + * Cancel UNDO record reservation + * + * The reserved space is left as a zero-filled hole. Readers will see + * urec_type == 0 and skip it. The buffer is released. + */ +void +RelUndoCancel(Relation rel, Buffer undo_buffer, RelUndoRecPtr ptr) +{ + /* + * The space was already zeroed by relundo_init_page(). pd_lower has been + * advanced past it, so it's just a hole. Nothing to write. + */ + UnlockReleaseBuffer(undo_buffer); + + /* Release pending metapage buffer if RelUndoReserve allocated a new page */ + if (BufferIsValid(relundo_pending_metabuf)) + { + UnlockReleaseBuffer(relundo_pending_metabuf); + relundo_pending_metabuf = InvalidBuffer; + } +} + +/* + * relundo_fork_nblocks_fast + * + * Return the number of blocks in the UNDO fork with no per-call filesystem + * syscalls on the hot path. + * + * The old guard was smgrexists() + smgrnblocks() per call. smgrexists() -> + * mdexists() unconditionally mdclose()s the fork fd (to notice an unlink) and + * the next access reopens it; on the multiversion read path (per scanned + * tuple carrying a retained before-image) that FD open/close storm dominated + * CPU at high core count (dentry lockref contention). smgrnblocks_cached() + * does NOT help outside recovery -- it only returns a cached value when + * InRecovery -- so the previous version still hit smgrexists() every call. + * + * smgrnblocks() itself is cheap after the first call: it opens the fork once + * (mdopenfork) and leaves the fd cached in the SMgrRelation; it never closes + * it. So the fix is to drop smgrexists() from the hot path entirely and rely + * on smgrnblocks(). A valid RelUndoRecPtr can only have been produced by a + * writer that extended the fork, so the fork provably exists whenever a + * caller has a valid pointer into it; smgrnblocks() therefore never faults + * here. For extra safety against a fake/partial relcache entry whose fork is + * genuinely absent, we probe smgrexists() exactly ONCE per SMgrRelation and + * latch the positive result (a stat that then keeps the fd via the + * subsequent smgrnblocks open); if the fork is absent we return 0 and never + * cache, so a later-created fork is still picked up. + */ +static inline BlockNumber +relundo_fork_nblocks_fast(Relation rel) +{ + SMgrRelation smgr = RelationGetSmgr(rel); + + /* + * If the fork's fd is already open in this SMgrRelation, it exists and is + * open -- go straight to smgrnblocks (no stat, no close). md_num_open_segs + * is >0 once mdopenfork has run for this fork. + */ + if (smgr->md_num_open_segs[RELUNDO_FORKNUM] > 0) + return smgrnblocks(smgr, RELUNDO_FORKNUM); + + /* Cold: confirm the fork exists once, then open+size it (fd stays open). */ + if (!smgrexists(smgr, RELUNDO_FORKNUM)) + return 0; + return smgrnblocks(smgr, RELUNDO_FORKNUM); +} + +/* + * RelUndoReadRecord + * Read an UNDO record from the log + * + * Reads the header and payload from the location encoded in ptr. + * Returns false if the pointer is invalid or the record has been discarded. + * On success, *payload is palloc'd and must be pfree'd by the caller. + */ +bool +RelUndoReadRecord(Relation rel, RelUndoRecPtr ptr, RelUndoRecordHeader *header, + void **payload, Size *payload_size) +{ + BlockNumber blkno; + uint16 offset; + Buffer buf; + Page page; + char *contents; + Size psize; + + if (!RelUndoRecPtrIsValid(ptr)) + return false; + + blkno = RelUndoGetBlockNum(ptr); + offset = RelUndoGetOffset(ptr); + + /* + * Bounds-check the block against the UNDO fork size using the cached + * nblocks (no per-call filesystem syscalls; see + * relundo_fork_nblocks_fast). A zero result means the fork does not + * exist -- treat as out of range. The UNDO fork is always a standard + * BLCKSZ-paged smgr fork. + */ + if (blkno >= relundo_fork_nblocks_fast(rel)) + return false; + + buf = ReadBufferExtended(rel, RELUNDO_FORKNUM, blkno, RBM_NORMAL, NULL); + LockBuffer(buf, BUFFER_LOCK_SHARE); + + page = BufferGetPage(buf); + contents = PageGetContents(page); + + /* Validate that offset is within the written portion of the page */ + { + RelUndoPageHeader hdr = (RelUndoPageHeader) contents; + + if (offset < SizeOfRelUndoPageHeaderData || offset >= hdr->pd_lower) + { + UnlockReleaseBuffer(buf); + return false; + } + + /* + * ABA defense: reject a verptr whose embedded generation counter does + * not match the page's current counter. A free-list recycle bumps + * meta->counter (relundo_page.c) before re-initialising the page, so + * a stale verptr to a recycled blkno will see hdr->counter != its own + * counter and fail here. Returning false is the chain-end signal: the + * caller's version-reconstruction walk treats it as a best-effort + * terminator, which is the correct, safe answer (never silently + * reverse-apply a structurally valid but unrelated record). + */ + if (RelUndoGetCounter(ptr) != hdr->counter) + { + UnlockReleaseBuffer(buf); + return false; + } + } + + /* Copy the header */ + memcpy(header, contents + offset, SizeOfRelUndoRecordHeader); + + /* A zero urec_type means the slot was cancelled (hole) */ + if (header->urec_type == 0) + { + UnlockReleaseBuffer(buf); + return false; + } + + /* Calculate payload size and copy it */ + if (header->urec_len > SizeOfRelUndoRecordHeader) + { + psize = header->urec_len - SizeOfRelUndoRecordHeader; + *payload = palloc(psize); + memcpy(*payload, contents + offset + SizeOfRelUndoRecordHeader, psize); + *payload_size = psize; + } + else + { + *payload = NULL; + *payload_size = 0; + } + + UnlockReleaseBuffer(buf); + return true; +} + +/* + * RelUndoReadRecordHeader + * Read only the header of an UNDO record. + * + * Same discard/ABA/hole semantics as RelUndoReadRecord but skips the + * payload palloc+memcpy. Used by hot probes (e.g. the lost-update + * conflict probe) that need only urec_xid. + */ +bool +RelUndoReadRecordHeader(Relation rel, RelUndoRecPtr ptr, + RelUndoRecordHeader *header) +{ + BlockNumber blkno; + uint16 offset; + Buffer buf; + Page page; + char *contents; + + if (!RelUndoRecPtrIsValid(ptr)) + return false; + + blkno = RelUndoGetBlockNum(ptr); + offset = RelUndoGetOffset(ptr); + + /* Cached bounds check; see relundo_fork_nblocks_fast. */ + if (blkno >= relundo_fork_nblocks_fast(rel)) + return false; + + buf = ReadBufferExtended(rel, RELUNDO_FORKNUM, blkno, RBM_NORMAL, NULL); + LockBuffer(buf, BUFFER_LOCK_SHARE); + + page = BufferGetPage(buf); + contents = PageGetContents(page); + + { + RelUndoPageHeader hdr = (RelUndoPageHeader) contents; + + if (offset < SizeOfRelUndoPageHeaderData || offset >= hdr->pd_lower) + { + UnlockReleaseBuffer(buf); + return false; + } + + if (RelUndoGetCounter(ptr) != hdr->counter) + { + UnlockReleaseBuffer(buf); + return false; + } + } + + memcpy(header, contents + offset, SizeOfRelUndoRecordHeader); + + if (header->urec_type == 0) + { + UnlockReleaseBuffer(buf); + return false; + } + + UnlockReleaseBuffer(buf); + return true; +} + +/* + * RelUndoInitRelation + * Initialize per-relation UNDO for a new relation + * + * Creates the UNDO fork and writes the initial metapage (block 0). + * The chain starts empty (head_blkno = tail_blkno = InvalidBlockNumber). + * + * This function is idempotent: if the UNDO fork already exists (e.g., + * during TRUNCATE where the new relfilenumber may already have a fork + * from a prior operation, or during recovery replay), we truncate it + * back to zero blocks and reinitialize. + */ +void +RelUndoInitRelation(Relation rel) +{ + Buffer metabuf; + Page metapage; + RelUndoMetaPage meta; + SMgrRelation srel; + + /* Invalidate cached head page for this relation */ + relundo_head_cache_invalidate(RelationGetRelid(rel)); + + srel = RelationGetSmgr(rel); + + /* + * Create the physical fork file. Pass isRedo=true so that smgrcreate + * is idempotent -- if the file already exists (e.g., during TRUNCATE + * or recovery replay), it simply opens it rather than raising an error. + */ + smgrcreate(srel, RELUNDO_FORKNUM, true); + + /* + * WAL-log the fork creation for crash safety. + */ + if (!InRecovery) + log_smgrcreate(&rel->rd_locator, RELUNDO_FORKNUM); + + /* + * If the fork already has blocks (e.g., re-initialization during + * TRUNCATE), truncate it back to zero so we can reinitialize cleanly. + * This discards any stale UNDO data from the previous relfilenumber + * incarnation. + */ + if (smgrnblocks(srel, RELUNDO_FORKNUM) > 0) + { + ForkNumber forknum = RELUNDO_FORKNUM; + BlockNumber old_nblocks = smgrnblocks(srel, RELUNDO_FORKNUM); + BlockNumber new_nblocks = 0; + + smgrtruncate(srel, &forknum, 1, &old_nblocks, &new_nblocks); + } + + /* Allocate the metapage (block 0) */ + metabuf = ExtendBufferedRel(BMR_REL(rel), RELUNDO_FORKNUM, NULL, + EB_LOCK_FIRST); + + Assert(BufferGetBlockNumber(metabuf) == 0); + + metapage = BufferGetPage(metabuf); + + /* Initialize standard page header */ + PageInit(metapage, BLCKSZ, 0); + + /* Initialize the UNDO metapage fields */ + meta = (RelUndoMetaPage) PageGetContents(metapage); + meta->magic = RELUNDO_METAPAGE_MAGIC; + meta->version = RELUNDO_METAPAGE_VERSION; + meta->counter = 1; /* Start at 1 so 0 is clearly "no counter" */ + for (int s = 0; s < RELUNDO_NUM_HEADS; s++) + { + meta->head_blkno[s] = InvalidBlockNumber; + meta->tail_blkno[s] = InvalidBlockNumber; + } + meta->free_blkno = InvalidBlockNumber; + meta->total_records = 0; + meta->discarded_records = 0; + meta->system_alloc_watermark = InvalidBlockNumber; + + /* Include the meta struct in the recorded region of any FPI. */ + RelUndoMetaPageSetPdLower(metapage); + + MarkBufferDirty(metabuf); + + /* + * WAL-log the metapage initialization. This is critical for crash safety. + * If we crash after table creation but before the first INSERT, the + * metapage must be recoverable. + */ + if (!InRecovery) + { + xl_relundo_init xlrec; + XLogRecPtr recptr; + + xlrec.magic = RELUNDO_METAPAGE_MAGIC; + xlrec.version = RELUNDO_METAPAGE_VERSION; + xlrec.counter = 1; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfRelundoInit); + XLogRegisterBuffer(0, metabuf, REGBUF_WILL_INIT | REGBUF_STANDARD); + + recptr = XLogInsert(RM_RELUNDO_ID, XLOG_RELUNDO_INIT); + + PageSetLSN(metapage, recptr); + } + + UnlockReleaseBuffer(metabuf); +} + +/* + * RelUndoDropRelation + * Drop per-relation UNDO when relation is dropped + * + * The UNDO fork is removed along with the relation's other forks by the + * storage manager. We just need to make sure we don't leave stale state. + */ +void +RelUndoDropRelation(Relation rel) +{ + SMgrRelation srel; + + srel = RelationGetSmgr(rel); + + /* + * If the UNDO fork doesn't exist, nothing to do. This handles the case + * where the relation never had per-relation UNDO enabled. + */ + if (!smgrexists(srel, RELUNDO_FORKNUM)) + return; + + /* + * The actual file removal happens as part of the relation's overall drop + * via smgrdounlinkall(). We don't need to explicitly drop the fork here + * because the storage manager handles all forks together. + * + * If in the future we need explicit fork removal, we could truncate and + * unlink here. + */ +} + +/* + * RelUndoVacuum + * Vacuum per-relation UNDO log + * + * Discards UNDO records whose owning transaction precedes oldest_xmin, the + * oldest XID for which any active transaction could still require a rollback + * before-image. Pages whose max_xid precedes oldest_xmin are reclaimed. + * + * RelUndoDiscard splices the reclaimed run directly onto the metapage's free + * list as a single bounded, fully WAL-logged operation, so the pages are + * immediately available for reuse by relundo_allocate_page and the fork stops + * growing across repeated VACUUM cycles. + */ +void +RelUndoVacuum(Relation rel, TransactionId oldest_xmin, bool nowait) +{ + /* If no UNDO fork exists, nothing to vacuum */ + if (!smgrexists(RelationGetSmgr(rel), RELUNDO_FORKNUM)) + return; + + /* A meaningless horizon would discard nothing; bail early. */ + if (!TransactionIdIsValid(oldest_xmin)) + return; + + RelUndoDiscard(rel, oldest_xmin, nowait); +} + +/* + * RELUNDO_MAYBE_VACUUM_MIN_BLOCKS + * Skip the throttled discard sweep below this fork size (in blocks). + * Chosen to make the common case (a quiescent or lightly-written + * table) resolve to a single relundo_fork_nblocks_fast() call with no + * buffer I/O, while still catching sustained-churn growth well before + * it reaches problematic size. + */ +#define RELUNDO_MAYBE_VACUUM_MIN_BLOCKS 64 + +/* + * RelUndoMaybeVacuum + * Throttled, self-clocking per-relation UNDO fork discard. + * + * RelUndoVacuum() (the function above) is normally invoked once per VACUUM, + * via the owning table AM's relation_vacuum callback. But an AM whose + * updates are in-place can correctly report near-zero dead tuples, + * so autovacuum's dead-tuple/insert-count thresholds may never fire even + * under sustained write churn -- and RelUndoVacuum is the ONLY code that + * discards the on-disk UNDO fork, so an AM that never gets vacuumed would + * grow its UNDO fork without bound. + * + * This is a cheap, throttled backstop an AM's DML path can call so fork + * discard runs on its own schedule, decoupled from VACUUM ever being + * triggered. Intended to be called from the AM's DML hot path after + * releasing all buffer/tuple locks -- RelUndoDiscard() takes the UNDO fork's + * own metapage lock, which must never be acquired while holding a data page + * lock (that would convoy every writer to a hot page behind the discard + * sweep). + * + * The cadence uses a per-backend static timestamp: each backend independently + * throttles to once every 5 seconds, so concurrent backends do redundant but + * bounded work rather than needing shared coordination. + */ +void +RelUndoMaybeVacuum(Relation rel) +{ + static TimestampTz relundo_last_vacuum = 0; + TimestampTz now_ts; + BlockNumber nblocks; + + now_ts = GetCurrentTimestamp(); + if (now_ts - relundo_last_vacuum < 5000000) /* 5 seconds */ + return; + + nblocks = relundo_fork_nblocks_fast(rel); + if (nblocks < RELUNDO_MAYBE_VACUUM_MIN_BLOCKS) + return; + + relundo_last_vacuum = now_ts; + + /* + * Hot-path discard MUST NOT block: this runs inline from the AM's update + * path for every qualifying update. Blocking on the per-relation UNDO + * metapage EXCLUSIVE lock here serializes ALL concurrent updates to the + * relation behind one lock (measured: the dominant cause of a TPROC-C + * throughput collapse and hours-long lock-wait pileups on hot tables like + * TPC-C district). Pass nowait=true so a contended metapage is simply + * skipped -- some other backend (or a later call) reclaims the space; UNDO + * discard is space reclamation, never per-update correctness. + */ + RelUndoVacuum(rel, GetOldestNonRemovableTransactionId(rel), true); +} diff --git a/src/backend/access/undo/relundo_apply.c b/src/backend/access/undo/relundo_apply.c new file mode 100644 index 0000000000000..362fcc5936dc7 --- /dev/null +++ b/src/backend/access/undo/relundo_apply.c @@ -0,0 +1,1142 @@ +/*------------------------------------------------------------------------- + * + * relundo_apply.c + * Apply per-relation UNDO records for transaction rollback + * + * This module implements transaction rollback for per-relation UNDO. + * It walks the UNDO chain backwards and applies each operation to restore + * the database to its pre-transaction state. + * + * The rollback operations are: + * - INSERT: Mark inserted tuples as dead/unused + * - DELETE: Restore deleted tuple from UNDO record + * - UPDATE: Restore old tuple version from UNDO record + * - TUPLE_LOCK: Remove lock marker + * + * For crash safety, we write Compensation Log Records (CLRs) for each + * UNDO application. If we crash during rollback, the CLRs prevent + * double-application when recovery replays the UNDO chain. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/relundo_apply.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/htup_details.h" +#include "access/relation.h" +#include "access/relundo.h" +#include "access/relundo_xlog.h" +#include "access/xloginsert.h" +#include "miscadmin.h" +#include "storage/buf.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "utils/rel.h" + +/* + * Maximum number of distinct data pages a single UNDO-record apply may touch. + * A per-relation UNDO consumer emits INSERT (1 page), in-place UPDATE (1), + * out-of-place UPDATE/DELTA (2), and DELETE with ntids==1 (1). The cap leaves + * ample room below XLR_MAX_BLOCK_ID (32) for the fork page registered + * alongside. + */ +#define RELUNDO_APPLY_MAX_DATA_BUFS 8 + +/* + * Maximum number of consecutive UNDO records folded into one compensation log + * record (CLR). A bulk DELETE/UPDATE rollback emits one UNDO record per tuple, + * and long runs of those records target the same data page and live on the same + * UNDO-fork page (the chain is walked in reverse-insertion order). Folding such + * a run into a single CLR replaces N forced full-page images plus N XLogFlush + * calls with one of each. The cap bounds how long the batch holds the data + * page's exclusive buffer lock; each per-record apply is an in-memory + * memcpy/flag-set, so 128 is a small, bounded hold. + */ +#define RELUNDO_APPLY_MAX_BATCH 128 + +/* + * Maximum number of distinct UNDO-fork pages whose records a single CLR may + * cover. Each contributes one forced full-page image and one exclusive buffer + * lock held across the XLogInsert. Together with the single batched data page, + * the total registered blocks (1 + this) stays well under XLR_MAX_BLOCK_ID (32). + */ +#define RELUNDO_APPLY_MAX_FORK_BUFS 8 + +/* Forward declarations for internal functions */ +static Page RelUndoTrackPage(Relation rel, Buffer *touched, int *ntouched, + BlockNumber blkno); +static bool RelUndoRecordSingleDataPage(const RelUndoRecordHeader *header, + const void *payload, BlockNumber *blk); +static bool RelUndoForkTrack(BlockNumber *fork_blks, int *nfork, + BlockNumber blkno); +static void RelUndoApplyOneRecord(Relation rel, const RelUndoRecordHeader *header, + void *payload, RelUndoRecPtr current_ptr, + Buffer *touched, int *ntouched); +static void RelUndoApplyInsert(Relation rel, Page page, OffsetNumber offset); +static void RelUndoApplyUpdate(Relation rel, Page page, OffsetNumber offset, + char *tuple_data, uint32 tuple_len); +static void RelUndoApplyDelete(Relation rel, Page page, OffsetNumber offset, + char *tuple_data, uint32 tuple_len); +static void RelUndoApplyTupleLock(Relation rel, Page page, OffsetNumber offset); +static void RelUndoLogApplyCLR(Relation rel, const RelUndoRecPtr *urec_ptrs, + int nptrs, Buffer *touched, int ntouched); + +/* + * RelUndoApplyChain - Walk and apply per-relation UNDO chain for rollback + * + * This is the main entry point for transaction abort. We walk backwards + * through the UNDO chain starting from start_ptr, applying each operation + * until we reach an invalid pointer or the beginning of the chain. + * + * Each record type is handled self-contained: each case manages its own + * buffer acquisition, apply, dirty marking, and buffer release. + */ +void +RelUndoApplyChain(Relation rel, RelUndoRecPtr start_ptr) +{ + RelUndoRecPtr current_ptr = start_ptr; + RelUndoRecordHeader header; + void *payload = NULL; + Size payload_size; + + if (!RelUndoRecPtrIsValid(current_ptr)) + { + elog(DEBUG1, "RelUndoApplyChain: no valid UNDO pointer"); + return; + } + + elog(DEBUG1, "RelUndoApplyChain: starting rollback at %lu", + (unsigned long) current_ptr); + + /* + * Walk backwards through the chain, applying each record. + * + * Each record's physical restoration is applied to its data page(s) while + * holding their buffers exclusively locked, then a single redoable + * compensation log record (XLOG_RELUNDO_APPLY) logs full-page images of + * every restored page plus the UNDO-fork page(s) (carrying the CLR_APPLIED + * flag). The apply helpers may ereport(ERROR) on corruption, so they run + * BEFORE the critical section in RelUndoLogApplyCLR; an error there aborts + * the worker transaction and releases the buffer locks. + * + * Bulk DELETE/UPDATE rollback emits one UNDO record per tuple, and long + * runs of those records target the same data page (reverse-insertion + * order). We fold such a run into one CLR: we keep that one data page's + * buffer exclusively locked and apply each record's in-memory restoration + * onto it, accumulating the records' urec_ptrs, then emit a single CLR + * (one forced data-page image, one flush) covering the whole batch. A + * record that does not target exactly that single data page (out-of-place + * update, multi-TID delete, a different page) terminates the batch and is + * handled on its own with the unchanged singleton path. Folding never + * widens the simultaneous buffer-lock footprint beyond one data page. + */ + while (RelUndoRecPtrIsValid(current_ptr)) + { + Buffer touched[RELUNDO_APPLY_MAX_DATA_BUFS]; + int ntouched = 0; + RelUndoRecPtr batch_ptrs[RELUNDO_APPLY_MAX_BATCH]; + int nbatch = 0; + BlockNumber batch_blkno = InvalidBlockNumber; + BlockNumber fork_blks[RELUNDO_APPLY_MAX_FORK_BUFS]; + int nfork = 0; + int i; + + if (!RelUndoReadRecord(rel, current_ptr, &header, &payload, &payload_size)) + { + elog(WARNING, "RelUndoApplyChain: could not read UNDO record at %lu", + (unsigned long) current_ptr); + break; + } + + /* Skip already-applied records (CLR check for crash safety) */ + if (header.info_flags & RELUNDO_INFO_CLR_APPLIED) + { + elog(DEBUG1, "RelUndoApplyChain: skipping already-applied record at %lu", + (unsigned long) current_ptr); + current_ptr = header.urec_prevundorec; + if (payload) + { + pfree(payload); + payload = NULL; + } + continue; + } + + elog(DEBUG1, "RelUndoApplyChain: processing record type %d at %lu", + header.urec_type, (unsigned long) current_ptr); + + /* Apply this record; it pins+locks its data page(s) into touched[]. */ + RelUndoApplyOneRecord(rel, &header, payload, current_ptr, + touched, &ntouched); + batch_ptrs[nbatch++] = current_ptr; + (void) RelUndoRecordSingleDataPage(&header, payload, &batch_blkno); + (void) RelUndoForkTrack(fork_blks, &nfork, RelUndoGetBlockNum(current_ptr)); + + current_ptr = header.urec_prevundorec; + if (payload) + { + pfree(payload); + payload = NULL; + } + + /* + * Extend the batch with following records that restore the very same + * single data page. We only peek ahead while the current record was + * itself a single-page record (batch_blkno valid); a multi-page record + * leaves ntouched > 1 and ends the batch immediately. + */ + while (ntouched == 1 && + BlockNumberIsValid(batch_blkno) && + nbatch < RELUNDO_APPLY_MAX_BATCH && + RelUndoRecPtrIsValid(current_ptr)) + { + RelUndoRecordHeader peek_hdr; + void *peek_payload = NULL; + Size peek_size; + BlockNumber peek_blkno = InvalidBlockNumber; + + if (!RelUndoReadRecord(rel, current_ptr, &peek_hdr, + &peek_payload, &peek_size)) + break; + + if (peek_hdr.info_flags & RELUNDO_INFO_CLR_APPLIED) + { + /* Stop the batch; the skip is handled by the outer loop. */ + if (peek_payload) + pfree(peek_payload); + break; + } + + if (!RelUndoRecordSingleDataPage(&peek_hdr, peek_payload, &peek_blkno) || + peek_blkno != batch_blkno) + { + /* Not foldable: leave current_ptr for the outer loop. */ + if (peek_payload) + pfree(peek_payload); + break; + } + + /* + * The CLR will register one forced image per distinct fork page in + * the batch. If folding this record would exceed the fork-page cap + * (and its fork page is not already in the batch), stop here. + */ + if (nfork >= RELUNDO_APPLY_MAX_FORK_BUFS) + { + BlockNumber peek_fork = RelUndoGetBlockNum(current_ptr); + bool seen = false; + + for (i = 0; i < nfork; i++) + { + if (fork_blks[i] == peek_fork) + { + seen = true; + break; + } + } + if (!seen) + { + if (peek_payload) + pfree(peek_payload); + break; + } + } + + /* Foldable: apply onto the already-locked data page. */ + RelUndoApplyOneRecord(rel, &peek_hdr, peek_payload, current_ptr, + touched, &ntouched); + batch_ptrs[nbatch++] = current_ptr; + (void) RelUndoForkTrack(fork_blks, &nfork, + RelUndoGetBlockNum(current_ptr)); + + current_ptr = peek_hdr.urec_prevundorec; + if (peek_payload) + pfree(peek_payload); + } + + /* + * Log one redoable CLR carrying full-page images of every restored + * data page plus the fork page(s) for the batched records, then + * release the data buffers. For non-WAL relations there is nothing to + * log; just mark dirty and release. + */ + if (RelationNeedsWAL(rel)) + RelUndoLogApplyCLR(rel, batch_ptrs, nbatch, touched, ntouched); + else + { + for (i = 0; i < ntouched; i++) + { + MarkBufferDirty(touched[i]); + UnlockReleaseBuffer(touched[i]); + } + } + } + + elog(DEBUG1, "RelUndoApplyChain: rollback complete"); +} + +/* + * RelUndoApplyRecordForRecovery - Reverse-apply one UNDO record during crash + * recovery, without writing a compensation log record (CLR). + * + * Crash recovery rolls back loser transactions after the redo pass. At that + * point WAL insertion is not yet permitted (LocalSetXLogInsertAllowed() runs + * later in StartupXLOG), so this path mirrors the non-WAL branch of + * RelUndoApplyChain: it restores the before-image into the data page(s) in + * memory and marks them dirty, relying on the end-of-recovery checkpoint for + * durability. Re-application is harmless because redo always re-establishes + * the post-modification page state before this reverse-apply runs, so a crash + * mid-recovery simply replays redo and re-applies the before-image. + * + * Records already carrying RELUNDO_INFO_CLR_APPLIED (rolled back online before + * the crash) are skipped. The caller is responsible for driving this in + * newest-first order for each loser transaction's tracked record pointers. + * + * Idempotency across a second crash that brackets the end-of-recovery + * checkpoint: absolute records (full-tuple UPDATE/DELETE, INSERT) restore a + * fixed before-image, so re-applying them is a no-op. A DELTA record + * reconstructs the before-image relative to the live page tuple, which is NOT + * idempotent in isolation; the write path guarantees safety by emitting DELTA + * only when overwriting a COMMITTED version, so at most one DELTA exists per + * tid and every newer record for that tid is absolute. Newest-first replay + * therefore re-derives the DELTA's page anchor before it is reverse-applied, + * and a lone DELTA reverse-apply (overwrite-with-old-bytes) is idempotent. + */ +void +RelUndoApplyRecordForRecovery(Relation rel, RelUndoRecPtr ptr) +{ + RelUndoRecordHeader header; + void *payload = NULL; + Size payload_size; + Buffer touched[RELUNDO_APPLY_MAX_DATA_BUFS]; + int ntouched = 0; + int i; + + if (!RelUndoRecPtrIsValid(ptr)) + return; + + if (!RelUndoReadRecord(rel, ptr, &header, &payload, &payload_size)) + { + elog(WARNING, "RelUndoApplyRecordForRecovery: could not read UNDO record at %lu", + (unsigned long) ptr); + return; + } + + /* Already rolled back online before the crash: nothing to do. */ + if (header.info_flags & RELUNDO_INFO_CLR_APPLIED) + { + if (payload) + pfree(payload); + return; + } + + RelUndoApplyOneRecord(rel, &header, payload, ptr, touched, &ntouched); + + for (i = 0; i < ntouched; i++) + { + MarkBufferDirty(touched[i]); + UnlockReleaseBuffer(touched[i]); + } + + if (payload) + pfree(payload); +} + +/* + * RelUndoRecordSingleDataPage - Classify whether an UNDO record restores + * exactly one data page, and if so report that block number. + * + * Returns true and sets *blk when the record's restoration touches a single + * data page (in-place UPDATE, single-TID DELETE, INSERT, TUPLE_LOCK). Returns + * false for records that may touch two pages (out-of-place UPDATE where oldtid + * != newtid) or more than one TID (multi-TID DELETE); those are not eligible to + * be folded into a same-page batch. No buffers are touched. + */ +static bool +RelUndoRecordSingleDataPage(const RelUndoRecordHeader *header, + const void *payload, BlockNumber *blk) +{ + *blk = InvalidBlockNumber; + + switch (header->urec_type) + { + case RELUNDO_INSERT: + { + const RelUndoInsertPayload *p = payload; + + *blk = ItemPointerGetBlockNumber(&p->firsttid); + return true; + } + + case RELUNDO_DELETE: + { + const RelUndoDeletePayload *p = payload; + + if (p->ntids != 1) + return false; + *blk = ItemPointerGetBlockNumber(&p->tids[0]); + return true; + } + + case RELUNDO_UPDATE: + { + const RelUndoUpdatePayload *p = payload; + + if (!ItemPointerEquals(&p->oldtid, &p->newtid)) + return false; + *blk = ItemPointerGetBlockNumber(&p->oldtid); + return true; + } + + case RELUNDO_TUPLE_LOCK: + { + const RelUndoTupleLockPayload *p = payload; + + *blk = ItemPointerGetBlockNumber(&p->tid); + return true; + } + + default: + return false; + } +} + +/* + * RelUndoForkTrack - Add an UNDO-fork block to the batch's distinct-fork set. + * + * Returns true if blkno was newly added, false if it was already present or the + * set is full (caller has already guaranteed room via the cap check). + */ +static bool +RelUndoForkTrack(BlockNumber *fork_blks, int *nfork, BlockNumber blkno) +{ + int i; + + for (i = 0; i < *nfork; i++) + { + if (fork_blks[i] == blkno) + return false; + } + + Assert(*nfork < RELUNDO_APPLY_MAX_FORK_BUFS); + fork_blks[(*nfork)++] = blkno; + return true; +} + +/* + * RelUndoApplyOneRecord - Apply one UNDO record's physical restoration. + * + * Pins+locks the record's data page(s) into touched[] (via RelUndoTrackPage, + * which deduplicates against pages already locked for this batch) and mutates + * them in memory. Does NOT log or release buffers; the caller batches one or + * more applied records and emits a single CLR. May ereport(ERROR) on + * corruption, before any WAL is written. + */ +static void +RelUndoApplyOneRecord(Relation rel, const RelUndoRecordHeader *header, + void *payload, RelUndoRecPtr current_ptr, + Buffer *touched, int *ntouched) +{ + Page page; + BlockNumber target_blkno; + OffsetNumber target_offset; + int i; + + switch (header->urec_type) + { + case RELUNDO_INSERT: + { + RelUndoInsertPayload *ins_payload = (RelUndoInsertPayload *) payload; + + target_blkno = ItemPointerGetBlockNumber(&ins_payload->firsttid); + target_offset = ItemPointerGetOffsetNumber(&ins_payload->firsttid); + + page = RelUndoTrackPage(rel, touched, ntouched, target_blkno); + RelUndoApplyInsert(rel, page, target_offset); + break; + } + + case RELUNDO_DELETE: + { + RelUndoDeletePayload *del_payload = (RelUndoDeletePayload *) payload; + char *tuple_data_buf = NULL; + uint32 tlen = 0; + + RelUndoReadRecordWithTuple(rel, current_ptr, + &tuple_data_buf, &tlen); + + for (i = 0; i < del_payload->ntids; i++) + { + target_blkno = ItemPointerGetBlockNumber(&del_payload->tids[i]); + target_offset = ItemPointerGetOffsetNumber(&del_payload->tids[i]); + + page = RelUndoTrackPage(rel, touched, ntouched, target_blkno); + + if (tuple_data_buf && tlen > 0) + RelUndoApplyDelete(rel, page, target_offset, + tuple_data_buf, tlen); + } + + if (tuple_data_buf) + pfree(tuple_data_buf); + break; + } + + case RELUNDO_UPDATE: + { + RelUndoUpdatePayload *upd_payload = (RelUndoUpdatePayload *) payload; + char *tuple_data_buf = NULL; + uint32 tlen = 0; + + RelUndoReadRecordWithTuple(rel, current_ptr, + &tuple_data_buf, &tlen); + + /* Restore old tuple at the old location */ + target_blkno = ItemPointerGetBlockNumber(&upd_payload->oldtid); + target_offset = ItemPointerGetOffsetNumber(&upd_payload->oldtid); + + page = RelUndoTrackPage(rel, touched, ntouched, target_blkno); + + /* + * Partial-tuple (byte-diff) record: the trailing bytes are a + * RelUndoDiffRecord, not a full before-image. Reconstruct the + * old tuple from the current on-page bytes plus the diff, then + * restore it exactly like a full-image update. Reverse-apply is + * a self-describing byte splice, so no table-AM callback is + * needed. Only reachable for an in-place update (oldtid == + * newtid); the producing AM emits a delta only when the on-page + * slot length equals the length the diff was computed against. + */ + if ((header->info_flags & RELUNDO_INFO_PARTIAL_TUPLE) && + tuple_data_buf && tlen >= SizeOfRelUndoDiffRecord) + { + const RelUndoDiffRecord *diff = + (const RelUndoDiffRecord *) tuple_data_buf; + ItemId dlp = PageGetItemId(page, target_offset); + + if (ItemIdIsNormal(dlp)) + { + const char *cur = (const char *) PageGetItem(page, dlp); + Size cur_len = ItemIdGetLength(dlp); + Size recon_len = 0; + char *recon = palloc(diff->old_total_len); + + if (RelUndoApplyDiffReverse(cur, cur_len, diff, + recon, &recon_len)) + RelUndoApplyUpdate(rel, page, target_offset, + recon, (uint32) recon_len); + else + elog(ERROR, "RelUndoApplyChain: byte-diff reverse-apply " + "failed at (%u,%u)", target_blkno, target_offset); + pfree(recon); + } + else + elog(DEBUG2, "RelUndoApplyChain: delta target (%u,%u) not " + "normal, skipping", target_blkno, target_offset); + } + else if (tuple_data_buf && tlen > 0) + RelUndoApplyUpdate(rel, page, target_offset, + tuple_data_buf, tlen); + + if (tuple_data_buf) + pfree(tuple_data_buf); + + /* + * Mark the new tuple version as unused, but only for + * out-of-place updates where oldtid != newtid. For in-place + * updates the old and new tuple share the same slot, so marking + * it unused would destroy the just-restored old tuple. + */ + if (!ItemPointerEquals(&upd_payload->oldtid, + &upd_payload->newtid)) + { + BlockNumber new_blkno; + OffsetNumber new_offset; + Page new_page; + + new_blkno = ItemPointerGetBlockNumber(&upd_payload->newtid); + new_offset = ItemPointerGetOffsetNumber(&upd_payload->newtid); + + new_page = RelUndoTrackPage(rel, touched, ntouched, new_blkno); + RelUndoApplyInsert(rel, new_page, new_offset); + } + break; + } + + case RELUNDO_TUPLE_LOCK: + { + RelUndoTupleLockPayload *lock_payload = (RelUndoTupleLockPayload *) payload; + + target_blkno = ItemPointerGetBlockNumber(&lock_payload->tid); + target_offset = ItemPointerGetOffsetNumber(&lock_payload->tid); + + page = RelUndoTrackPage(rel, touched, ntouched, target_blkno); + RelUndoApplyTupleLock(rel, page, target_offset); + break; + } + + default: + /* Release any tracked buffers before erroring out. */ + for (i = 0; i < *ntouched; i++) + UnlockReleaseBuffer(touched[i]); + *ntouched = 0; + elog(ERROR, "RelUndoApplyChain: unknown UNDO record type %d", + header->urec_type); + } +} + +/* + * RelUndoTrackPage - Read+pin+exclusive-lock a data page for the current + * apply, deduplicating against pages already locked for this record. + * + * Returns the page so the caller can mutate it. The buffer is added to the + * touched[] array (kept locked) the first time a block is requested; a repeat + * request for the same block returns the already-locked page. All tracked + * buffers are released by RelUndoLogApplyCLR after the CLR is logged. + */ +static Page +RelUndoTrackPage(Relation rel, Buffer *touched, int *ntouched, + BlockNumber blkno) +{ + Buffer buf; + int i; + + for (i = 0; i < *ntouched; i++) + { + if (BufferGetBlockNumber(touched[i]) == blkno) + return BufferGetPage(touched[i]); + } + + if (*ntouched >= RELUNDO_APPLY_MAX_DATA_BUFS) + { + /* Release locks before erroring so the worker can clean up. */ + for (i = 0; i < *ntouched; i++) + UnlockReleaseBuffer(touched[i]); + *ntouched = 0; + elog(ERROR, "RelUndoApplyChain: too many data pages (%d) for one UNDO record", + RELUNDO_APPLY_MAX_DATA_BUFS + 1); + } + + buf = ReadBuffer(rel, blkno); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + touched[*ntouched] = buf; + (*ntouched)++; + return BufferGetPage(buf); +} + +/* + * RelUndoApplyInsert - Undo an INSERT operation + * + * Mark the inserted tuple as dead/unused. For INSERT, we don't need the + * original tuple data - we just mark the slot as available. + */ +static void +RelUndoApplyInsert(Relation rel, Page page, OffsetNumber offset) +{ + ItemId lp; + + elog(DEBUG1, "RelUndoApplyInsert: page=%p, offset=%u", page, offset); + + /* Validate offset */ + if (offset == InvalidOffsetNumber || offset > PageGetMaxOffsetNumber(page)) + elog(ERROR, "RelUndoApplyInsert: invalid offset %u (max=%u)", + offset, PageGetMaxOffsetNumber(page)); + + elog(DEBUG1, "RelUndoApplyInsert: calling PageGetItemId"); + lp = PageGetItemId(page, offset); + + elog(DEBUG1, "RelUndoApplyInsert: got ItemId %p", lp); + + if (!ItemIdIsNormal(lp)) + elog(WARNING, "RelUndoApplyInsert: tuple at offset %u is not normal", offset); + + /* Mark the line pointer as unused (LP_UNUSED) */ + elog(DEBUG1, "RelUndoApplyInsert: calling ItemIdSetUnused"); + ItemIdSetUnused(lp); + + elog(DEBUG1, "RelUndoApplyInsert: marked tuple at offset %u as unused", offset); +} + +/* + * RelUndoApplyDelete - Undo a DELETE operation + * + * Restore the deleted tuple from the UNDO record. The tuple data is stored + * in the UNDO record and includes the full tuple (header + data). + */ +static void +RelUndoApplyDelete(Relation rel, Page page, OffsetNumber offset, + char *tuple_data, uint32 tuple_len) +{ + ItemId lp; + Size aligned_len; + + /* Validate inputs */ + if (tuple_data == NULL || tuple_len == 0) + elog(ERROR, "RelUndoApplyDelete: invalid tuple data"); + + if (offset == InvalidOffsetNumber || offset > PageGetMaxOffsetNumber(page)) + elog(ERROR, "RelUndoApplyDelete: invalid offset %u", offset); + + lp = PageGetItemId(page, offset); + + aligned_len = MAXALIGN(tuple_len); + + /* + * Restore the tuple data. We use memcpy to copy the complete tuple + * including the header. + */ + if (ItemIdIsUsed(lp)) + { + /* + * Tuple slot is still occupied -- the common case for an in-place AM + * whose DELETE is in place (it only flags the tuple deleted and leaves + * the full-length body on the page). Restoring is an in-place + * overwrite of the same-length slot, so it needs no free space; do + * not consult PageGetFreeSpace here or a full page would spuriously + * fail the rollback. + */ + if (ItemIdGetLength(lp) != tuple_len) + elog(ERROR, "RelUndoApplyDelete: tuple length mismatch"); + + memcpy(PageGetItem(page, lp), tuple_data, tuple_len); + } + else + { + /* Need to allocate a new slot -- this path consumes free space. */ + OffsetNumber new_offset; + + if (PageGetFreeSpace(page) < aligned_len) + elog(ERROR, "RelUndoApplyDelete: insufficient space on page to restore tuple"); + + new_offset = PageAddItem(page, tuple_data, tuple_len, + offset, false, false); + if (new_offset != offset) + elog(ERROR, "RelUndoApplyDelete: could not restore tuple at expected offset"); + } + + /* + * Clear transient flags. The restored tuple is the committed + * before-image of the DELETE, so it should not be marked deleted or + * uncommitted. + */ + if (RelUndoClearTransientFlags_hook) + { + lp = PageGetItemId(page, offset); + RelUndoClearTransientFlags_hook((char *) PageGetItem(page, lp)); + } + + elog(DEBUG2, "RelUndoApplyDelete: restored tuple at offset %u (%u bytes)", + offset, tuple_len); +} + +/* + * RelUndoApplyUpdate - Undo an UPDATE operation + * + * Restore the old tuple version from the UNDO record. The tuple data was + * stored in the UNDO record and includes the full tuple (header + data). + * + * For an in-place update, the old tuple was physically overwritten at + * the same offset. We restore it by copying the saved data back. + */ +static void +RelUndoApplyUpdate(Relation rel, Page page, OffsetNumber offset, + char *tuple_data, uint32 tuple_len) +{ + ItemId lp; + + /* Validate inputs */ + if (tuple_data == NULL || tuple_len == 0) + elog(ERROR, "RelUndoApplyUpdate: invalid tuple data"); + + if (offset == InvalidOffsetNumber || offset > PageGetMaxOffsetNumber(page)) + elog(ERROR, "RelUndoApplyUpdate: invalid offset %u", offset); + + lp = PageGetItemId(page, offset); + + if (!ItemIdIsNormal(lp)) + elog(ERROR, "RelUndoApplyUpdate: tuple at offset %u is not normal", offset); + + /* + * Restore the old tuple. Handle size differences between the new tuple + * (currently on page) and the old tuple (from UNDO). + */ + if (tuple_len <= ItemIdGetLength(lp)) + { + /* + * Old tuple is same size or smaller than the new one. Simply + * overwrite in place and adjust the length. + */ + memcpy(PageGetItem(page, lp), tuple_data, tuple_len); + if (tuple_len != ItemIdGetLength(lp)) + ItemIdSetNormal(lp, ItemIdGetOffset(lp), tuple_len); + } + else + { + /* + * Old tuple is larger than the new one. Delete the current item + * and re-add the old tuple at the same offset. + */ + OffsetNumber restored_offset; + + PageIndexTupleDelete(page, offset); + restored_offset = PageAddItem(page, tuple_data, + tuple_len, offset, false, false); + if (restored_offset == InvalidOffsetNumber) + { + /* + * Try without specifying a target offset. The page should have + * enough free space since we just removed the (smaller) new tuple. + */ + restored_offset = PageAddItem(page, tuple_data, + tuple_len, InvalidOffsetNumber, + false, false); + } + + if (restored_offset == InvalidOffsetNumber) + elog(ERROR, "RelUndoApplyUpdate: could not restore old tuple at offset %u (%u bytes)", + offset, tuple_len); + } + + /* + * Clear transient flags on the restored tuple. The UNDO record stores + * the before-image which was committed, so UNCOMMITTED should not be set. + * Clear it defensively in case lazy clearing hadn't run before the + * snapshot was taken, and also clear DELETED/UPDATED since the operation + * that set them is being rolled back. + */ + if (RelUndoClearTransientFlags_hook) + { + lp = PageGetItemId(page, offset); + RelUndoClearTransientFlags_hook((char *) PageGetItem(page, lp)); + } + + elog(DEBUG2, "RelUndoApplyUpdate: restored old tuple at offset %u (%u bytes)", + offset, tuple_len); +} + +/* + * RelUndoApplyDiffReverse - reconstruct old tuple bytes from new bytes + diff. + * + * Generic (AM-neutral) reverse-apply of a byte-diff (RelUndoDiffRecord). The + * diff is a single splice against the new (current on-page) tuple: copy the + * unchanged prefix of new_data, substitute the carried old bytes, then copy + * the unchanged suffix, producing an old_total_len-byte old tuple. Bounds are + * validated against new_len and old_total_len so a corrupt record fails + * cleanly rather than scribbling. Returns true on success. + * + * FLUX's PVS read path mirrors this exact algorithm (FluxApplyDiffReverse) so + * a single delta record serves both rollback and old-version reconstruction. + */ +bool +RelUndoApplyDiffReverse(const char *new_data, Size new_len, + const RelUndoDiffRecord *diff, + char *out_old_data, Size *out_old_len) +{ + Size old_total_len; + Size tail; + + if (diff == NULL || new_data == NULL || out_old_data == NULL) + return false; + + old_total_len = diff->old_total_len; + + /* The spliced region must lie within the new tuple. */ + if ((Size) diff->offset > new_len || + (Size) diff->offset + diff->del_len > new_len) + return false; + + tail = new_len - diff->offset - diff->del_len; + + /* Reconstructed length must be prefix + ins + suffix. */ + if ((Size) diff->offset + diff->ins_len + tail != old_total_len) + return false; + + /* prefix (unchanged new bytes) */ + memcpy(out_old_data, new_data, diff->offset); + /* changed region: substitute the carried old bytes */ + memcpy(out_old_data + diff->offset, + (const char *) diff + SizeOfRelUndoDiffRecord, + diff->ins_len); + /* suffix (unchanged new bytes) */ + memcpy(out_old_data + diff->offset + diff->ins_len, + new_data + diff->offset + diff->del_len, + tail); + + *out_old_len = old_total_len; + return true; +} + +/* + * RelUndoApplyTupleLock - Undo a tuple lock operation + * + * Remove the lock marker from the tuple by clearing the lock-related + * infomask bits and resetting xmax to InvalidTransactionId. + */ +static void +RelUndoApplyTupleLock(Relation rel, Page page, OffsetNumber offset) +{ + ItemId lp; + HeapTupleHeader htup; + + /* Validate offset */ + if (offset == InvalidOffsetNumber || offset > PageGetMaxOffsetNumber(page)) + elog(ERROR, "RelUndoApplyTupleLock: invalid offset %u", offset); + + lp = PageGetItemId(page, offset); + + if (!ItemIdIsNormal(lp)) + elog(ERROR, "RelUndoApplyTupleLock: tuple at offset %u is not normal", offset); + + htup = (HeapTupleHeader) PageGetItem(page, lp); + + /* Clear lock-related infomask bits */ + htup->t_infomask &= ~(HEAP_XMAX_LOCK_ONLY | + HEAP_XMAX_KEYSHR_LOCK | + HEAP_XMAX_SHR_LOCK | + HEAP_XMAX_EXCL_LOCK); + htup->t_infomask2 &= ~HEAP_KEYS_UPDATED; + + /* Reset xmax to invalid */ + HeapTupleHeaderSetXmax(htup, InvalidTransactionId); + + elog(DEBUG2, "RelUndoApplyTupleLock: cleared lock from tuple at offset %u", offset); +} + +/* + * RelUndoLogApplyCLR - Write a redoable Compensation Log Record for a batch + * + * Logs a single XLOG_RELUNDO_APPLY record covering one or more consecutive + * UNDO records (urec_ptrs[0..nptrs-1]) that were all applied to the same set of + * data pages (passed in touched[], already mutated and held exclusively + * locked). Each record's RELUNDO_INFO_CLR_APPLIED flag is set in place on its + * UNDO-fork page; the distinct fork pages are logged alongside the data pages. + * + * Because an in-place MVCC AM has no durable xmin, the rollback's + * physical page changes are NOT reconstructable from any forward WAL record; + * the forward record holds the *new* (aborted) value. We therefore force a + * full-page image of each restored page so crash redo reinstates the + * before-image. The CLR_APPLIED flag on each fork page makes a re-driven + * RelUndoApplyChain idempotent (it skips already-applied records), preventing + * double-application after a crash during rollback. + * + * relundo_redo_apply restores every registered block image and ignores the + * record body, so a batched CLR replays identically to a sequence of + * single-record CLRs -- only the WAL volume (one forced fork-page and data-page + * image instead of N) and the flush count (one instead of N) shrink. + * + * All data buffers in touched[] are released here after logging. + */ +static void +RelUndoLogApplyCLR(Relation rel, const RelUndoRecPtr *urec_ptrs, int nptrs, + Buffer *touched, int ntouched) +{ + xl_relundo_apply xlrec; + BlockNumber fork_blks[RELUNDO_APPLY_MAX_FORK_BUFS]; + Buffer fork_bufs[RELUNDO_APPLY_MAX_FORK_BUFS]; + int nfork = 0; + XLogRecPtr recptr; + uint8 block_id; + int i; + int j; + + Assert(nptrs >= 1); + Assert(ntouched >= 1); + + /* + * Collect the distinct fork pages this batch touches, in ascending block + * order so concurrent rollback appliers acquire fork-page locks in a + * consistent order (deadlock-free). Data pages were already locked by the + * apply path before any fork page, so the global order is data-then-fork. + */ + for (i = 0; i < nptrs; i++) + { + BlockNumber blk = RelUndoGetBlockNum(urec_ptrs[i]); + int pos; + + for (pos = 0; pos < nfork; pos++) + { + if (fork_blks[pos] == blk) + break; + } + if (pos < nfork) + continue; /* already collected */ + + Assert(nfork < RELUNDO_APPLY_MAX_FORK_BUFS); + /* insertion sort into ascending order */ + for (pos = nfork; pos > 0 && fork_blks[pos - 1] > blk; pos--) + fork_blks[pos] = fork_blks[pos - 1]; + fork_blks[pos] = blk; + nfork++; + } + + xlrec.urec_ptr = urec_ptrs[0]; + xlrec.target_reloc = rel->rd_locator; + + /* + * Read and exclusive-lock each distinct fork page (ascending), before the + * critical section since ReadBuffer may perform I/O. + */ + for (i = 0; i < nfork; i++) + { + fork_bufs[i] = ReadBufferExtended(rel, RELUNDO_FORKNUM, fork_blks[i], + RBM_NORMAL, NULL); + LockBuffer(fork_bufs[i], BUFFER_LOCK_EXCLUSIVE); + } + + START_CRIT_SECTION(); + + /* + * Set the CLR flags in place on every batched record's fork-page header. + */ + for (i = 0; i < nptrs; i++) + { + BlockNumber blk = RelUndoGetBlockNum(urec_ptrs[i]); + uint16 off = RelUndoGetOffset(urec_ptrs[i]); + char *contents; + RelUndoRecordHeader *rec_hdr; + + for (j = 0; j < nfork; j++) + { + if (fork_blks[j] == blk) + break; + } + Assert(j < nfork); + + contents = PageGetContents(BufferGetPage(fork_bufs[j])); + rec_hdr = (RelUndoRecordHeader *) (contents + off); + rec_hdr->info_flags |= (RELUNDO_INFO_HAS_CLR | RELUNDO_INFO_CLR_APPLIED); + } + + for (i = 0; i < ntouched; i++) + MarkBufferDirty(touched[i]); + for (i = 0; i < nfork; i++) + MarkBufferDirty(fork_bufs[i]); + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, sizeof(xl_relundo_apply)); + + /* + * Force a full-page image of every restored data page then every fork + * page. The restoration is an arbitrary in-place rewrite, so only an FPI + * can reproduce it during redo. + */ + block_id = 0; + for (i = 0; i < ntouched; i++) + XLogRegisterBuffer(block_id++, touched[i], + REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + for (i = 0; i < nfork; i++) + XLogRegisterBuffer(block_id++, fork_bufs[i], + REGBUF_STANDARD | REGBUF_FORCE_IMAGE); + + recptr = XLogInsert(RM_RELUNDO_ID, XLOG_RELUNDO_APPLY); + + for (i = 0; i < ntouched; i++) + PageSetLSN(BufferGetPage(touched[i]), recptr); + for (i = 0; i < nfork; i++) + PageSetLSN(BufferGetPage(fork_bufs[i]), recptr); + + END_CRIT_SECTION(); + + /* + * Force the CLR to durable storage before releasing the buffers. Unlike + * heap, an in-place MVCC AM has no durable xmin/clog, so recovery cannot + * re-drive the per-relation fork undo for a loser transaction: the + * before-image we just restored exists only in these (still dirty) buffers and in this CLR. If + * we crash before the CLR is flushed, redo replays only the forward + * (aborted) page change and the rollback is silently lost. Flushing here + * makes the compensation durable so crash redo reinstates the before-image + * from the forced full-page images. Batching amortizes this flush across + * every record folded into the CLR. + */ + XLogFlush(recptr); + + elog(DEBUG3, "RelUndoLogApplyCLR: CLR for %d UNDO record(s), %d data page(s), %d fork page(s)", + nptrs, ntouched, nfork); + + for (i = 0; i < nfork; i++) + UnlockReleaseBuffer(fork_bufs[i]); + for (i = 0; i < ntouched; i++) + UnlockReleaseBuffer(touched[i]); +} + +/* + * RelUndoReadRecordWithTuple - Read UNDO record including tuple data + * + * This is like RelUndoReadRecord but also reads the tuple data that follows + * the payload if RELUNDO_INFO_HAS_TUPLE is set. + */ +RelUndoRecordHeader * +RelUndoReadRecordWithTuple(Relation rel, RelUndoRecPtr ptr, + char **tuple_data_out, uint32 *tuple_len_out) +{ + RelUndoRecordHeader header_local; + RelUndoRecordHeader *header; + void *payload; + Size payload_size; + bool success; + + /* Initialize outputs */ + *tuple_data_out = NULL; + *tuple_len_out = 0; + + /* Read the basic record (header + payload, no tuple data) */ + success = RelUndoReadRecord(rel, ptr, &header_local, &payload, &payload_size); + if (!success) + return NULL; + + /* + * Allocate combined buffer for header + payload. Tuple data will be + * allocated separately if present. + */ + header = (RelUndoRecordHeader *) palloc(SizeOfRelUndoRecordHeader + payload_size); + memcpy(header, &header_local, SizeOfRelUndoRecordHeader); + memcpy((char *) header + SizeOfRelUndoRecordHeader, payload, payload_size); + + /* Free the payload allocated by RelUndoReadRecord */ + pfree(payload); + + /* + * If tuple data is present, extract it from the combined payload. + * + * RelUndoReadRecord reads (urec_len - SizeOfRelUndoRecordHeader) bytes as + * "payload", which includes both the actual payload and the tuple data. + * The tuple data occupies the last tuple_len bytes of that region. + */ + if ((header->info_flags & RELUNDO_INFO_HAS_TUPLE) && header->tuple_len > 0) + { + uint32 tlen = header->tuple_len; + Size actual_payload_size; + + /* + * payload_size from RelUndoReadRecord includes both the real payload + * and the tuple data. The actual payload is the first part. + */ + if (payload_size < tlen) + { + elog(WARNING, "RelUndoReadRecordWithTuple: tuple_len %u exceeds payload_size %zu", + tlen, payload_size); + return header; + } + + actual_payload_size = payload_size - tlen; + + /* + * Allocate and copy the tuple data from the tail of the combined + * buffer + */ + *tuple_data_out = (char *) palloc(tlen); + memcpy(*tuple_data_out, + (char *) header + SizeOfRelUndoRecordHeader + actual_payload_size, + tlen); + *tuple_len_out = tlen; + + elog(DEBUG2, "RelUndoReadRecordWithTuple: read %u bytes of tuple data", tlen); + } + + return header; +} diff --git a/src/backend/access/undo/relundo_discard.c b/src/backend/access/undo/relundo_discard.c new file mode 100644 index 0000000000000..a4f030175fa5c --- /dev/null +++ b/src/backend/access/undo/relundo_discard.c @@ -0,0 +1,534 @@ +/*------------------------------------------------------------------------- + * + * relundo_discard.c + * Per-relation UNDO discard and space reclamation + * + * This file implements the counter-based discard logic for per-relation UNDO. + * During VACUUM, old UNDO records are discarded and their pages reclaimed + * to the free list for reuse. + * + * Discard walks the page chain from the tail (oldest) toward the head + * (newest). Each page's generation counter is compared against the + * oldest-visible cutoff using modular 16-bit arithmetic. If a page's + * counter precedes the cutoff, all records on that page are safe to + * discard and the page is moved to the free list. + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/relundo_discard.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/relundo.h" +#include "access/relundo_xlog.h" +#include "access/transam.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "common/relpath.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/lmgr.h" +#include "storage/proc.h" +#include "storage/smgr.h" +#include "utils/rel.h" + +static void RelUndoTruncateEmptyChain(Relation rel, Buffer metabuf); +static void RelUndoDiscardSlot(Relation rel, Buffer metabuf, int slot, + TransactionId oldest_xmin); + +/* + * relundo_page_is_discardable + * Check if every record on a page is older than the discard horizon. + * + * A page is discardable iff its max_xid (the largest urec_xid of any record + * on the page) precedes oldest_xmin. In that case no active transaction can + * still need any record on the page for rollback, so the page can be freed. + * + * An empty page (max_xid == InvalidTransactionId) carries no live records and + * is trivially discardable. + */ +static bool +relundo_page_is_discardable(Page page, TransactionId oldest_xmin) +{ + RelUndoPageHeader hdr; + + hdr = (RelUndoPageHeader) PageGetContents(page); + + if (!TransactionIdIsValid(hdr->max_xid)) + return true; + + return TransactionIdPrecedes(hdr->max_xid, oldest_xmin); +} + +/* + * RelUndoDiscard + * Discard old UNDO records and reclaim space across all head slots. + * + * Each of the RELUNDO_NUM_HEADS append chains is independently append-only, + * so discardability is monotonic tail->head within each slot. We walk and + * splice each slot's chain separately under the single metapage exclusive + * lock, then physically truncate the fork only if every slot ended empty. + */ +void +RelUndoDiscard(Relation rel, TransactionId oldest_xmin, bool nowait) +{ + Buffer metabuf; + Page metapage; + RelUndoMetaPage meta; + bool all_empty; + + /* + * Lock the metapage exclusively for the duration of discard. When nowait + * is set (the inline hot-path caller, RelUndoMaybeVacuum), acquire the lock + * CONDITIONALLY: if another backend holds it, skip this discard entirely + * rather than block the update. This keeps concurrent updates to the same + * relation from serializing behind the single per-relation UNDO metapage + * lock. Space reclamation is best-effort; a skipped discard is retried on + * a later update. + */ + if (nowait) + { + /* + * Pin the metapage (block 0) and take its content lock CONDITIONALLY. + * The UNDO fork is known to exist (RelUndoVacuum checked smgrexists), + * so block 0 is present and initialized. If another backend holds the + * lock, skip this discard rather than block the update. + */ + metabuf = ReadBufferExtended(rel, RELUNDO_FORKNUM, + RELUNDO_METAPAGE_BLKNO, RBM_NORMAL, NULL); + if (!ConditionalLockBuffer(metabuf)) + { + ReleaseBuffer(metabuf); + return; + } + } + else + metabuf = relundo_get_metapage(rel, BUFFER_LOCK_EXCLUSIVE); + metapage = BufferGetPage(metabuf); + meta = (RelUndoMetaPage) PageGetContents(metapage); + + /* + * A conditionally-locked metapage might (in a rare crash-recovery window) + * be uninitialized; the blocking relundo_get_metapage path reinitializes + * it, but the nowait path must not do WAL work, so just skip if invalid. + */ + if (nowait && meta->magic != RELUNDO_METAPAGE_MAGIC) + { + UnlockReleaseBuffer(metabuf); + return; + } + + for (int slot = 0; slot < RELUNDO_NUM_HEADS; slot++) + RelUndoDiscardSlot(rel, metabuf, slot, oldest_xmin); + + /* + * If every slot's chain is now empty, the free list holds every allocated + * data block -- the contiguous physical suffix [1 .. system_alloc_watermark] + * -- so the fork can be physically truncated back to the metapage. That is + * the only provably-safe truncate case, and we still hold the metapage + * exclusive lock (blocking any concurrent allocator). + */ + all_empty = true; + for (int slot = 0; slot < RELUNDO_NUM_HEADS; slot++) + { + if (BlockNumberIsValid(meta->tail_blkno[slot])) + { + all_empty = false; + break; + } + } + + if (all_empty) + RelUndoTruncateEmptyChain(rel, metabuf); + + /* + * Discard moved (and possibly truncated away) the pages this backend's + * head page cache may still name. Drop the cache entry so the next + * reserve re-reads the metapage instead of faulting on a stale block + * number that no longer exists on disk. + */ + RelUndoHeadCacheInvalidate(RelationGetRelid(rel)); + + UnlockReleaseBuffer(metabuf); +} + +/* + * RelUndoDiscardSlot + * Discard the tail run of one head slot's chain. + * + * Walks the slot's page chain from the head toward the tail. Any page whose + * max_xid precedes oldest_xmin holds only records that no active transaction + * can still need for rollback; such pages are unlinked from the data chain and + * spliced onto the shared free list in one WAL-logged operation. + * + * The chain is chronologically ordered (head newest, tail oldest), so the + * discardable pages form a contiguous run at the tail end. The caller holds + * metabuf pinned and exclusively locked. + */ +static void +RelUndoDiscardSlot(Relation rel, Buffer metabuf, int slot, + TransactionId oldest_xmin) +{ + Page metapage = BufferGetPage(metabuf); + RelUndoMetaPage meta = (RelUndoMetaPage) PageGetContents(metapage); + BlockNumber old_tail_blkno; + BlockNumber new_tail_blkno = InvalidBlockNumber; + BlockNumber run_head_blkno = InvalidBlockNumber; + BlockNumber current_blkno; + BlockNumber old_free_head; + uint32 npages_freed = 0; + Buffer runtail_buf; + Buffer newtail_buf = InvalidBuffer; + + old_tail_blkno = meta->tail_blkno[slot]; + old_free_head = meta->free_blkno; + + if (!BlockNumberIsValid(old_tail_blkno)) + { + /* Empty chain, nothing to discard */ + return; + } + + /* + * Pass 1 (read-only): walk from head toward tail following prev_blkno. + * A page is discardable iff its max_xid precedes oldest_xmin. The last + * (closest-to-tail) page that is NOT discardable becomes the new tail; + * every page below it forms a contiguous discardable run. run_head is + * the newest page in that run (the page just below the new tail, or the + * chain head if the whole chain is discardable). + * + * This relies on a precondition of the append-only fork: discardability is + * monotonic from tail (oldest) to head (newest). Records are appended in + * commit order, so a page's max_xid never decreases as the chain advances + * head-ward; once a page is non-discardable (its max_xid reaches + * oldest_xmin) every newer page above it is non-discardable too. Thus the + * discardable pages always form a single contiguous run at the tail, and + * keeping the closest-to-tail non-discardable page as the new tail never + * splices a still-live page onto the free list. + */ + current_blkno = meta->head_blkno[slot]; + while (BlockNumberIsValid(current_blkno) && current_blkno != RELUNDO_METAPAGE_BLKNO) + { + Buffer buf; + Page page; + RelUndoPageHeader hdr; + BlockNumber prev; + + buf = ReadBufferExtended(rel, RELUNDO_FORKNUM, current_blkno, + RBM_NORMAL, NULL); + LockBuffer(buf, BUFFER_LOCK_SHARE); + + page = BufferGetPage(buf); + hdr = (RelUndoPageHeader) PageGetContents(page); + prev = hdr->prev_blkno; + + if (!relundo_page_is_discardable(page, oldest_xmin)) + { + new_tail_blkno = current_blkno; + run_head_blkno = prev; + } + + UnlockReleaseBuffer(buf); + + /* + * Data pages are block >= 1; the metapage is block 0 and the caller + * already holds it EXCLUSIVE. A well-formed chain terminates with + * InvalidBlockNumber, never 0. Guard against a 0 (or meta-block) link + * so a malformed/uninitialized chain head cannot self-deadlock by + * re-locking the metapage. + */ + if (prev == RELUNDO_METAPAGE_BLKNO) + break; + current_blkno = prev; + } + + if (!BlockNumberIsValid(new_tail_blkno)) + { + /* Whole chain is discardable: the run starts at the chain head. */ + run_head_blkno = meta->head_blkno[slot]; + } + + if (!BlockNumberIsValid(run_head_blkno)) + { + /* Nothing below the new tail is discardable. */ + return; + } + + /* + * Pass 2 (read-only): count the pages in the discardable run, walking + * from run_head down to (and including) the old tail. + */ + current_blkno = run_head_blkno; + while (BlockNumberIsValid(current_blkno) && current_blkno != RELUNDO_METAPAGE_BLKNO) + { + Buffer buf; + Page page; + RelUndoPageHeader hdr; + BlockNumber prev; + + buf = ReadBufferExtended(rel, RELUNDO_FORKNUM, current_blkno, + RBM_NORMAL, NULL); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + hdr = (RelUndoPageHeader) PageGetContents(page); + prev = hdr->prev_blkno; + UnlockReleaseBuffer(buf); + npages_freed++; + if (prev == RELUNDO_METAPAGE_BLKNO) + break; + current_blkno = prev; + } + + Assert(npages_freed > 0); + + /* + * Splice the whole run directly onto the free list with a bounded, fully + * WAL-logged set of mutations. Both the free list and the discardable run + * are threaded through the same durable prev_blkno fields (logged at insert + * time), so the run's internal links are left untouched and only its + * boundaries change: + * + * - the run's tail (old chain tail) gets prev_blkno = old_free_head, + * appending the prior free list after the run; + * - the new live tail (if any) gets prev_blkno = InvalidBlockNumber, + * detaching the live chain from the run; + * - the metapage's tail and free-list head are updated. + * + * Folding reclamation into this single WAL-logged operation makes discard + * crash-safe end to end: there is no separate, unlogged deallocation step + * whose replay could leave the free list inconsistent with the metapage. + * + * Pin and exclusively lock the boundary data pages BEFORE the critical + * section so XLogRegisterBuffer sees them dirty and locked. + */ + runtail_buf = ReadBufferExtended(rel, RELUNDO_FORKNUM, old_tail_blkno, + RBM_NORMAL, NULL); + LockBuffer(runtail_buf, BUFFER_LOCK_EXCLUSIVE); + + if (BlockNumberIsValid(new_tail_blkno)) + { + newtail_buf = ReadBufferExtended(rel, RELUNDO_FORKNUM, new_tail_blkno, + RBM_NORMAL, NULL); + LockBuffer(newtail_buf, BUFFER_LOCK_EXCLUSIVE); + } + + /* Apply the in-memory mutations. */ + { + RelUndoPageHeader runtail_hdr; + + runtail_hdr = (RelUndoPageHeader) PageGetContents(BufferGetPage(runtail_buf)); + runtail_hdr->prev_blkno = old_free_head; + MarkBufferDirty(runtail_buf); + + if (BufferIsValid(newtail_buf)) + { + RelUndoPageHeader newtail_hdr; + + newtail_hdr = (RelUndoPageHeader) PageGetContents(BufferGetPage(newtail_buf)); + newtail_hdr->prev_blkno = InvalidBlockNumber; + MarkBufferDirty(newtail_buf); + + meta->tail_blkno[slot] = new_tail_blkno; + } + else + { + /* Whole chain discarded: this slot's data chain is now empty. */ + meta->head_blkno[slot] = InvalidBlockNumber; + meta->tail_blkno[slot] = InvalidBlockNumber; + } + + meta->free_blkno = run_head_blkno; + meta->discarded_records += npages_freed; /* approximate */ + MarkBufferDirty(metabuf); + } + + /* WAL-log the discard operation. */ + START_CRIT_SECTION(); + { + xl_relundo_discard xlrec; + XLogRecPtr lsn; + + xlrec.old_tail_blkno = old_tail_blkno; + xlrec.new_tail_blkno = meta->tail_blkno[slot]; + xlrec.free_head_blkno = run_head_blkno; + xlrec.old_free_head = old_free_head; + xlrec.discard_xid = oldest_xmin; + xlrec.npages_freed = npages_freed; + xlrec.slot = (uint16) slot; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfRelundoDiscard); + + /* Block 0: metapage (tail + free-list head). */ + XLogRegisterBuffer(0, metabuf, REGBUF_STANDARD); + + /* + * Blocks 1 and 2 are data pages, which pin the standard PageHeader + * pd_lower at the empty value and track their real extent in the shadow + * RelUndoPageHeader. REGBUF_STANDARD would elide the whole contents as + * a free "hole", so a restored FPI would return a zeroed page and lose + * the prev_blkno chain links. Log the full page image (flag 0). + */ + + /* Block 1: run tail, whose prev_blkno now links to old_free_head. */ + XLogRegisterBuffer(1, runtail_buf, 0); + + /* Block 2: new live tail, whose prev_blkno is cleared (if present). */ + if (BufferIsValid(newtail_buf)) + XLogRegisterBuffer(2, newtail_buf, 0); + + lsn = XLogInsert(RM_RELUNDO_ID, XLOG_RELUNDO_DISCARD); + + /* + * Stamp the record LSN onto every page we dirtied and registered so + * the buffer manager cannot flush any of them to disk ahead of this + * WAL record (WAL-before-data). Otherwise a crash could leave a page's + * spliced prev_blkno link on disk without the matching WAL, corrupting + * the free-list / data-chain threading. + */ + PageSetLSN(metapage, lsn); + PageSetLSN(BufferGetPage(runtail_buf), lsn); + if (BufferIsValid(newtail_buf)) + PageSetLSN(BufferGetPage(newtail_buf), lsn); + } + END_CRIT_SECTION(); + + if (BufferIsValid(newtail_buf)) + UnlockReleaseBuffer(newtail_buf); + UnlockReleaseBuffer(runtail_buf); +} + +/* + * RelUndoTruncateEmptyChain + * Physically truncate an emptied UNDO fork back to the metapage. + * + * Precondition: the caller holds metabuf pinned and exclusively locked, and + * the data chain is empty (head_blkno == tail_blkno == InvalidBlockNumber) + * after a whole-chain discard. In that state the free list contains every + * data block ever allocated, i.e. the contiguous suffix [1 .. watermark], so + * the fork can be truncated to a single block (the metapage). + * + * Physical truncation drops buffers beyond the new EOF, so it must not run + * while a concurrent lock-free reserver holds a pin on (and is about to write + * to) one of those data pages. VACUUM holds only ShareUpdateExclusiveLock, + * which does not exclude the RowExclusiveLock that DML reservers hold, so we + * gate the truncate behind a *conditional* AccessExclusiveLock (mirroring + * lazy_truncate_heap()). If the lock is not immediately available, we skip + * the physical reclaim entirely: the pages remain on the free list and are + * safely recyclable on the next allocation, so nothing is lost but disk + * space until a later discard succeeds in acquiring the lock. + * + * When the lock is held, this mirrors the crash-safe WAL-logged truncate + * pattern in RelationTruncate(): set delayChkptFlags, enter the critical + * section, mutate the metapage and WAL-log it, XLogFlush, then smgrtruncate + * (which drops the now-defunct buffers), leave the critical section, and + * finally clear delayChkptFlags. Dirtying the metapage and setting the + * checkpoint-delay flags both happen inside the critical section, so a + * concurrent checkpoint can never observe a half-applied truncate. + */ +static void +RelUndoTruncateEmptyChain(Relation rel, Buffer metabuf) +{ + Page metapage = BufferGetPage(metabuf); + RelUndoMetaPage meta = (RelUndoMetaPage) PageGetContents(metapage); + SMgrRelation srel = RelationGetSmgr(rel); + ForkNumber forknum = RELUNDO_FORKNUM; + BlockNumber old_nblocks; + BlockNumber new_nblocks = 1; /* keep only the metapage (block 0) */ + + /* Caller guarantees every slot's chain was discarded. */ +#ifdef USE_ASSERT_CHECKING + for (int slot = 0; slot < RELUNDO_NUM_HEADS; slot++) + { + Assert(!BlockNumberIsValid(meta->head_blkno[slot])); + Assert(!BlockNumberIsValid(meta->tail_blkno[slot])); + } +#endif + + old_nblocks = smgrnblocks(srel, RELUNDO_FORKNUM); + + /* Nothing to reclaim if the fork is already just the metapage. */ + if (old_nblocks <= new_nblocks) + return; + + /* + * With the whole chain discarded, the free list holds every data block the + * metapage durably knows about -- the contiguous suffix [1 .. watermark]. + * The watermark is advanced in the same WAL-logged metapage mutation that + * threads a freshly extended block onto the chain, but ExtendBufferedRel + * grows the file before that record is flushed. A crash in that window + * leaves orphaned tail blocks (watermark < nblocks - 1) that belong to no + * chain and no free list. Truncating to the metapage drops them too, which + * is safe under the AccessExclusiveLock gate below since no reserver can + * hold a pin. The watermark can never exceed the physical EOF, so assert + * only that bound. It may also be invalid if the only blocks on disk are + * orphans from a torn extend whose metapage update never reached WAL. + */ + Assert(!BlockNumberIsValid(meta->system_alloc_watermark) || + meta->system_alloc_watermark <= old_nblocks - 1); + + /* + * Gate the physical truncate behind a conditional AccessExclusiveLock so + * we never drop a buffer that a concurrent lock-free reserver has pinned. + * If the lock is unavailable, leave the pages on the free list (still + * recyclable) and reclaim the disk on a future discard. + */ + if (!ConditionalLockRelation(rel, AccessExclusiveLock)) + return; + + Assert((MyProc->delayChkptFlags & + (DELAY_CHKPT_START | DELAY_CHKPT_COMPLETE)) == 0); + + START_CRIT_SECTION(); + { + xl_relundo_truncate xlrec; + XLogRecPtr lsn; + + MyProc->delayChkptFlags |= DELAY_CHKPT_START | DELAY_CHKPT_COMPLETE; + + /* + * Reset metapage to the empty-fork state: the data chain is already + * empty (head/tail cleared by the discard above) and the freed pages + * cease to exist. Reset all four pointers here too so the truncate + * WAL record is self-describing and its redo does not depend on the + * preceding discard record having been flushed. + */ + for (int slot = 0; slot < RELUNDO_NUM_HEADS; slot++) + { + meta->head_blkno[slot] = InvalidBlockNumber; + meta->tail_blkno[slot] = InvalidBlockNumber; + } + meta->free_blkno = InvalidBlockNumber; + meta->system_alloc_watermark = InvalidBlockNumber; + MarkBufferDirty(metabuf); + + xlrec.new_nblocks = new_nblocks; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfRelundoTruncate); + + /* Block 0: metapage (free-list head + watermark reset). */ + XLogRegisterBuffer(0, metabuf, REGBUF_STANDARD); + + lsn = XLogInsert(RM_RELUNDO_ID, XLOG_RELUNDO_TRUNCATE); + PageSetLSN(metapage, lsn); + + /* + * Flush so the truncation cannot reach disk before its WAL record; + * smgrtruncate drops the to-be-removed buffers and shrinks the file. + */ + XLogFlush(lsn); + + smgrtruncate(srel, &forknum, 1, &old_nblocks, &new_nblocks); + } + END_CRIT_SECTION(); + + MyProc->delayChkptFlags &= ~(DELAY_CHKPT_START | DELAY_CHKPT_COMPLETE); + + UnlockRelation(rel, AccessExclusiveLock); +} diff --git a/src/backend/access/undo/relundo_page.c b/src/backend/access/undo/relundo_page.c new file mode 100644 index 0000000000000..cf1a2197da682 --- /dev/null +++ b/src/backend/access/undo/relundo_page.c @@ -0,0 +1,360 @@ +/*------------------------------------------------------------------------- + * + * relundo_page.c + * Per-relation UNDO page management + * + * This file handles UNDO page allocation, metapage management, and chain + * traversal for per-relation UNDO logs. + * + * The UNDO fork layout is: + * Block 0: Metapage (standard PageHeaderData + RelUndoMetaPageData) + * Block 1+: Data pages (standard PageHeaderData + RelUndoPageHeaderData + records) + * + * Data pages grow from the bottom up: pd_lower advances as records are + * appended. All offsets in RelUndoPageHeaderData are relative to the + * start of the page contents area (after standard PageHeaderData). + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/relundo_page.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/relundo.h" +#include "common/relpath.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/smgr.h" + +/* + * relundo_get_metapage + * Read and pin the metapage for a relation's UNDO fork. + * + * The caller specifies the lock mode (BUFFER_LOCK_SHARE or + * BUFFER_LOCK_EXCLUSIVE). Returns a pinned and locked buffer. + * The caller must release the buffer when done. + */ +Buffer +relundo_get_metapage(Relation rel, int mode) +{ + Buffer buf; + Page page; + RelUndoMetaPage meta; + + /* + * If the RELUNDO fork has no blocks (e.g., after crash recovery where + * the fork was created but the metapage wasn't written), create and + * initialize the metapage now. + */ + if (smgrnblocks(RelationGetSmgr(rel), RELUNDO_FORKNUM) == 0) + { + if (mode == BUFFER_LOCK_EXCLUSIVE) + { + elog(LOG, "UNDO fork for relation \"%s\" has no blocks, initializing metapage", + RelationGetRelationName(rel)); + + buf = ExtendBufferedRel(BMR_REL(rel), RELUNDO_FORKNUM, NULL, + EB_LOCK_FIRST); + Assert(BufferGetBlockNumber(buf) == 0); + + page = BufferGetPage(buf); + PageInit(page, BLCKSZ, 0); + meta = (RelUndoMetaPage) PageGetContents(page); + meta->magic = RELUNDO_METAPAGE_MAGIC; + meta->version = RELUNDO_METAPAGE_VERSION; + meta->counter = 1; + for (int s = 0; s < RELUNDO_NUM_HEADS; s++) + { + meta->head_blkno[s] = InvalidBlockNumber; + meta->tail_blkno[s] = InvalidBlockNumber; + } + meta->free_blkno = InvalidBlockNumber; + meta->total_records = 0; + meta->discarded_records = 0; + meta->system_alloc_watermark = InvalidBlockNumber; + + /* Include the meta struct in the recorded region of any FPI. */ + RelUndoMetaPageSetPdLower(page); + + MarkBufferDirty(buf); + + /* Downgrade lock if caller only wants SHARE */ + if (mode == BUFFER_LOCK_SHARE) + { + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + LockBuffer(buf, BUFFER_LOCK_SHARE); + } + + return buf; + } + else + { + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("UNDO fork for relation \"%s\" has no blocks", + RelationGetRelationName(rel)))); + } + } + + buf = ReadBufferExtended(rel, RELUNDO_FORKNUM, 0, RBM_NORMAL, NULL); + LockBuffer(buf, mode); + + page = BufferGetPage(buf); + meta = (RelUndoMetaPage) PageGetContents(page); + + if (meta->magic != RELUNDO_METAPAGE_MAGIC) + { + /* + * The metapage magic is invalid. This can happen after crash + * recovery if the RELUNDO fork was created but the metapage + * initialization WAL record wasn't replayed (e.g., the crash + * occurred between smgrcreate and the metapage write). + * + * Reinitialize the metapage so subsequent UNDO operations can + * proceed. This is safe because an uninitialized metapage means + * no UNDO records were ever written, so there's nothing to lose. + */ + /* + * Upgrade to exclusive lock if needed for reinitialization. + */ + if (mode != BUFFER_LOCK_EXCLUSIVE) + { + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE); + page = BufferGetPage(buf); + meta = (RelUndoMetaPage) PageGetContents(page); + + /* Re-check after acquiring exclusive lock */ + if (meta->magic == RELUNDO_METAPAGE_MAGIC) + { + /* Another backend fixed it while we re-locked */ + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + LockBuffer(buf, mode); + return buf; + } + } + + elog(LOG, "reinitializing corrupted UNDO metapage for relation \"%s\" " + "(found magic 0x%08X, expected 0x%08X)", + RelationGetRelationName(rel), meta->magic, + RELUNDO_METAPAGE_MAGIC); + + PageInit(page, BLCKSZ, 0); + meta = (RelUndoMetaPage) PageGetContents(page); + meta->magic = RELUNDO_METAPAGE_MAGIC; + meta->version = RELUNDO_METAPAGE_VERSION; + meta->counter = 1; + for (int s = 0; s < RELUNDO_NUM_HEADS; s++) + { + meta->head_blkno[s] = InvalidBlockNumber; + meta->tail_blkno[s] = InvalidBlockNumber; + } + meta->free_blkno = InvalidBlockNumber; + meta->total_records = 0; + meta->discarded_records = 0; + meta->system_alloc_watermark = InvalidBlockNumber; + + /* Include the meta struct in the recorded region of any FPI. */ + RelUndoMetaPageSetPdLower(page); + + MarkBufferDirty(buf); + + /* Downgrade back to requested lock mode */ + if (mode != BUFFER_LOCK_EXCLUSIVE) + { + LockBuffer(buf, BUFFER_LOCK_UNLOCK); + LockBuffer(buf, mode); + } + } + + if (meta->version != RELUNDO_METAPAGE_VERSION) + ereport(ERROR, + (errcode(ERRCODE_INDEX_CORRUPTED), + errmsg("unsupported UNDO metapage version %u in relation \"%s\" (expected %u)", + meta->version, RelationGetRelationName(rel), + RELUNDO_METAPAGE_VERSION))); + + return buf; +} + +/* + * relundo_allocate_page + * Allocate a new UNDO page and add it to the head of the slot's chain. + * + * The metapage buffer must be pinned and exclusively locked by the caller. + * Returns the new block number and the pinned/exclusively-locked buffer + * via *newbuf. The metapage is updated (head_blkno[slot]) and marked dirty. + * + * slot selects which of the RELUNDO_NUM_HEADS independent append chains the + * new page joins; the free list and the generation counter are shared across + * all slots (both are protected by the same metapage exclusive lock held here). + */ +BlockNumber +relundo_allocate_page(Relation rel, Buffer metabuf, int slot, Buffer *newbuf) +{ + Page metapage; + RelUndoMetaPage meta; + BlockNumber newblkno; + BlockNumber old_head; + Buffer buf; + Page page; + + Assert(slot >= 0 && slot < RELUNDO_NUM_HEADS); + + metapage = BufferGetPage(metabuf); + meta = (RelUndoMetaPage) PageGetContents(metapage); + + old_head = meta->head_blkno[slot]; + + /* Try the free list first */ + if (BlockNumberIsValid(meta->free_blkno)) + { + Buffer freebuf; + Page freepage; + RelUndoPageHeader freehdr; + + newblkno = meta->free_blkno; + + freebuf = ReadBufferExtended(rel, RELUNDO_FORKNUM, newblkno, + RBM_NORMAL, NULL); + LockBuffer(freebuf, BUFFER_LOCK_EXCLUSIVE); + + freepage = BufferGetPage(freebuf); + freehdr = (RelUndoPageHeader) PageGetContents(freepage); + + /* + * The free list is threaded through prev_blkno. Pop the head of the + * free list. + */ + meta->free_blkno = freehdr->prev_blkno; + + /* + * ABA defense for the WS-PVS2 reader: a recycled block reused at the + * same (blkno, offset) as a discarded prior record would otherwise be + * indistinguishable from that prior record to a stale verptr. Bump + * the generation counter so the recycled page's hdr->counter differs + * from any previous one at this blkno; RelUndoReadRecord validates + * (RelUndoGetCounter(ptr) == hdr->counter) to reject stale verptrs. + * + * Modular 16-bit increment, skipping 0 (reserved for "uninitialized" + * so a zeroed page never aliases a live counter). 16-bit wraparound + * (every 65535 recycles of the same fork) can produce a counter that + * matches a long-ago page header — RelUndoReadRecord then returns + * false (chain-end / best-effort fallback), never returning wrong + * data. No further handling needed. + * + * Retention invariant: the oldest_xmin discard gate already protects + * every record the reader REVERSE-APPLIES (urec_xid >= oldest_xmin, + * thus non-discardable). This counter+validation only protects the + * single visibility-PROBE record the reader STOPS on, whose urec_xid + * may legitimately be < oldest_xmin and thus reside on a discardable + * (and reusable) page. + * + * Only the recycle branch needs the bump: a freshly extended block + * has never been the target of any verptr, so it has no ABA hazard. + * The counter need not be globally unique per page; uniqueness for + * the reader comes from a GIVEN blkno getting a different counter + * each time IT is recycled. + */ + meta->counter++; + if (meta->counter == 0) + meta->counter = 1; + + /* Re-initialize the page for use as a data page */ + relundo_init_page(freepage, old_head, meta->counter); + + MarkBufferDirty(freebuf); + buf = freebuf; + } + else + { + /* Extend the relation to get a new block */ + buf = ExtendBufferedRel(BMR_REL(rel), RELUNDO_FORKNUM, NULL, + EB_LOCK_FIRST); + newblkno = BufferGetBlockNumber(buf); + + page = BufferGetPage(buf); + relundo_init_page(page, old_head, meta->counter); + + MarkBufferDirty(buf); + } + + /* Update metapage: new head of this slot's chain */ + meta->head_blkno[slot] = newblkno; + + /* If this is the first data page in the slot, it's also the tail */ + if (!BlockNumberIsValid(old_head)) + meta->tail_blkno[slot] = newblkno; + + /* + * Track system allocation watermark. This records the highest block + * number allocated, enabling efficient reclamation of pages that were + * allocated by a system transaction but never used (because the user + * transaction aborted). + * + * Verified: The metapage is WAL-logged via REGBUF_STANDARD as block 1 + * in the caller's XLOG_RELUNDO_INSERT record (see RelUndoFinish). + * On crash recovery the FPI restores all metapage fields including + * system_alloc_watermark. + */ + if (!BlockNumberIsValid(meta->system_alloc_watermark) || + newblkno > meta->system_alloc_watermark) + meta->system_alloc_watermark = newblkno; + + MarkBufferDirty(metabuf); + + *newbuf = buf; + return newblkno; +} + +/* + * relundo_init_page + * Initialize a new UNDO data page. + * + * Uses standard PageInit for compatibility with the buffer manager's + * page verification, then sets up the RelUndoPageHeaderData in the + * contents area. + * + * pd_lower starts just after the UNDO page header; pd_upper is set to + * the full extent of the contents area. + */ +void +relundo_init_page(Page page, BlockNumber prev_blkno, uint16 counter) +{ + RelUndoPageHeader hdr; + + /* Initialize with standard page header (no special area) */ + PageInit(page, BLCKSZ, 0); + + /* Set up our UNDO-specific header in the page contents area */ + hdr = (RelUndoPageHeader) PageGetContents(page); + hdr->prev_blkno = prev_blkno; + hdr->max_xid = InvalidTransactionId; + hdr->counter = counter; + hdr->pd_lower = SizeOfRelUndoPageHeaderData; + hdr->pd_upper = BLCKSZ - MAXALIGN(SizeOfPageHeaderData); +} + +/* + * relundo_get_free_space + * Get amount of free space on an UNDO page. + * + * Returns the number of bytes available for new UNDO records. + * The offsets in the page header are relative to the contents area. + */ +Size +relundo_get_free_space(Page page) +{ + RelUndoPageHeader hdr; + + hdr = (RelUndoPageHeader) PageGetContents(page); + + if (hdr->pd_upper <= hdr->pd_lower) + return 0; + + return (Size) (hdr->pd_upper - hdr->pd_lower); +} diff --git a/src/backend/access/undo/relundo_recovery.c b/src/backend/access/undo/relundo_recovery.c new file mode 100644 index 0000000000000..0928d3e7ae268 --- /dev/null +++ b/src/backend/access/undo/relundo_recovery.c @@ -0,0 +1,408 @@ +/*------------------------------------------------------------------------- + * + * relundo_recovery.c + * Crash-recovery driver for per-relation UNDO (loser-transaction rollback) + * + * An in-place MVCC table access method overwrites the + * committed tuple bytes on the data page on UPDATE, and the only durable copy + * of the prior committed version is the before-image stored in the relation's + * UNDO fork. WAL redo faithfully re-establishes the page state as it was at + * crash time, including modifications made by transactions that never + * committed. + * Nothing in the redo pass reverses those uncommitted in-place changes, so an + * uncommitted new value would remain visible after restart -- a wrong-results + * bug. + * + * This module closes that gap with an end-of-recovery scan of the UNDO forks + * on disk. A track-during-redo approach cannot work: a CHECKPOINT taken after + * an uncommitted in-place UPDATE advances the redo start LSN past that UPDATE's + * WAL, so redo never replays it and a tracker would stay empty even though the + * uncommitted value is durable on the flushed data page. The before-image, + * however, is always durable in the UNDO fork. After redo finishes, CLOG has + * been fully reconstructed, so TransactionIdDidCommit() authoritatively + * separates committed (winner) from incomplete (loser) transactions. + * + * PerformRelUndoRecovery() enumerates every relundo fork on disk without + * catalog access (the ResetUnloggedRelations filesystem-walk pattern), opens + * each relation with a fake relcache entry, walks its UNDO page chain + * newest-first (metapage head_blkno then prev_blkno toward the tail; within a + * page, records are replayed high offset to low), and for every record whose + * creating transaction did not commit and is not a prepared transaction, calls + * RelUndoApplyRecordForRecovery() to restore the before-image in place. + * + * Newest-first order matters: a later in-place update's before-image is the + * earlier update's after-image, so the most recent record for a tuple must be + * reverse-applied first. Walking head->tail across pages and high->low within + * a page yields newest-first within every transaction; cross-transaction order + * is immaterial because each record restores an independent before-image. + * + * No compensation log record (CLR) is written here. PerformRelUndoRecovery() + * runs from PerformWalRecovery(), before StartupXLOG() enables WAL insertion + * for this backend, so WAL cannot be emitted. This mirrors the cluster-wide + * PerformUndoRecovery() path: durability is provided by the end-of-recovery + * checkpoint, and re-application after a mid-recovery crash is harmless because + * redo first re-establishes the post-modification page before the before-image + * is restored again. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/relundo_recovery.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/relundo.h" +#include "access/transam.h" +#include "access/twophase.h" +#include "access/xlogutils.h" +#include "catalog/pg_tablespace_d.h" +#include "common/relpath.h" +#include "miscadmin.h" +#include "postgres_ext.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/fd.h" +#include "storage/reinit.h" +#include "storage/smgr.h" +#include "utils/memutils.h" + +static void RelUndoRecoveryScanTablespaceDir(const char *tsdirname, Oid spcoid); +static void RelUndoRecoveryScanDbspaceDir(const char *dbspacedirname, + Oid spcoid, Oid dboid); +static void RelUndoRecoveryScanOneFork(RelFileLocator rloc); +static int RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno); + +/* Running count of before-images restored across all forks. */ +static int relundo_recovery_applied = 0; + +/* + * PerformRelUndoRecovery - Reverse-apply loser transactions' before-images. + * + * Entry point called once at the end of WAL redo (crash recovery / PITR). + * Walks the data directory for per-relation UNDO forks and rolls back every + * incomplete transaction's in-place modifications. + */ +void +PerformRelUndoRecovery(void) +{ + char tblspc_path[MAXPGPATH + sizeof(PG_TBLSPC_DIR) + sizeof(TABLESPACE_VERSION_DIRECTORY)]; + DIR *spc_dir; + struct dirent *spc_de; + MemoryContext tmpctx, + oldctx; + + relundo_recovery_applied = 0; + + /* + * Use a private memory context so the directory-walk allocations are + * reclaimed in one shot regardless of how the scan exits. + */ + tmpctx = AllocSetContextCreate(CurrentMemoryContext, + "PerformRelUndoRecovery", + ALLOCSET_DEFAULT_SIZES); + oldctx = MemoryContextSwitchTo(tmpctx); + + /* Default tablespace lives under $PGDATA/base. */ + RelUndoRecoveryScanTablespaceDir("base", DEFAULTTABLESPACE_OID); + + /* Non-default tablespaces are symlinked under pg_tblspc. */ + spc_dir = AllocateDir(PG_TBLSPC_DIR); + while ((spc_de = ReadDir(spc_dir, PG_TBLSPC_DIR)) != NULL) + { + Oid spcoid; + + if (strcmp(spc_de->d_name, ".") == 0 || + strcmp(spc_de->d_name, "..") == 0) + continue; + + spcoid = atooid(spc_de->d_name); + if (!OidIsValid(spcoid)) + continue; + + snprintf(tblspc_path, sizeof(tblspc_path), "%s/%s/%s", + PG_TBLSPC_DIR, spc_de->d_name, TABLESPACE_VERSION_DIRECTORY); + RelUndoRecoveryScanTablespaceDir(tblspc_path, spcoid); + } + FreeDir(spc_dir); + + MemoryContextSwitchTo(oldctx); + MemoryContextDelete(tmpctx); + + if (relundo_recovery_applied > 0) + ereport(LOG, + (errmsg("per-relation UNDO recovery complete: %d before-image(s) restored", + relundo_recovery_applied))); +} + +/* + * RelUndoRecoveryScanTablespaceDir - Scan one tablespace's per-database dirs. + */ +static void +RelUndoRecoveryScanTablespaceDir(const char *tsdirname, Oid spcoid) +{ + DIR *ts_dir; + struct dirent *de; + char dbspace_path[MAXPGPATH * 2]; + + ts_dir = AllocateDir(tsdirname); + + /* + * A missing tablespace directory is not fatal: a crashed DROP TABLESPACE + * can leave a dangling pg_tblspc symlink. Mirror ResetUnloggedRelations + * and let it pass. + */ + if (ts_dir == NULL && errno == ENOENT) + return; + + while ((de = ReadDir(ts_dir, tsdirname)) != NULL) + { + Oid dboid; + + /* Per-database directories have purely numeric names. */ + if (strspn(de->d_name, "0123456789") != strlen(de->d_name)) + continue; + + dboid = atooid(de->d_name); + + snprintf(dbspace_path, sizeof(dbspace_path), "%s/%s", + tsdirname, de->d_name); + RelUndoRecoveryScanDbspaceDir(dbspace_path, spcoid, dboid); + } + + FreeDir(ts_dir); +} + +/* + * RelUndoRecoveryScanDbspaceDir - Scan one database dir for relundo forks. + * + * Only the first segment (segno 0) of each relundo fork is processed; the + * buffer manager transparently spans higher segments when reading the fork. + */ +static void +RelUndoRecoveryScanDbspaceDir(const char *dbspacedirname, Oid spcoid, Oid dboid) +{ + DIR *dbspace_dir; + struct dirent *de; + + dbspace_dir = AllocateDir(dbspacedirname); + if (dbspace_dir == NULL && errno == ENOENT) + return; + + while ((de = ReadDir(dbspace_dir, dbspacedirname)) != NULL) + { + RelFileNumber relnumber; + ForkNumber forknum; + unsigned segno; + RelFileLocator rloc; + + if (!parse_filename_for_nontemp_relation(de->d_name, &relnumber, + &forknum, &segno)) + continue; + + if (forknum != RELUNDO_FORKNUM || segno != 0) + continue; + + rloc.spcOid = spcoid; + rloc.dbOid = dboid; + rloc.relNumber = relnumber; + + RelUndoRecoveryScanOneFork(rloc); + } + + FreeDir(dbspace_dir); +} + +/* + * RelUndoRecoveryScanOneFork - Roll back losers recorded in one UNDO fork. + * + * Opens the relation with a fake relcache entry (no catalog access), reads the + * metapage to find the head of the page chain, and walks the chain newest-page + * first applying loser before-images. + */ +static void +RelUndoRecoveryScanOneFork(RelFileLocator rloc) +{ + Relation rel; + Buffer metabuf; + Page metapage; + RelUndoMetaPage meta; + BlockNumber head_blkno[RELUNDO_NUM_HEADS]; + BlockNumber nblocks; + + rel = CreateFakeRelcacheEntry(rloc); + + /* + * An empty or absent fork has nothing to roll back. The relation is a + * fake relcache entry whose rd_rel->relkind is zero, so the relkind + * dispatch in RelationGetNumberOfBlocksInFork() would trip an assertion; + * probe the underlying smgr directly instead, as XLOG replay does. + */ + if (!smgrexists(RelationGetSmgr(rel), RELUNDO_FORKNUM)) + { + FreeFakeRelcacheEntry(rel); + return; + } + + nblocks = smgrnblocks(RelationGetSmgr(rel), RELUNDO_FORKNUM); + if (nblocks == 0) + { + FreeFakeRelcacheEntry(rel); + return; + } + + /* Read the metapage (block 0) directly; do not auto-initialize it. */ + metabuf = ReadBufferExtended(rel, RELUNDO_FORKNUM, 0, RBM_NORMAL, NULL); + LockBuffer(metabuf, BUFFER_LOCK_SHARE); + metapage = BufferGetPage(metabuf); + meta = (RelUndoMetaPage) PageGetContents(metapage); + + if (meta->magic != RELUNDO_METAPAGE_MAGIC) + { + UnlockReleaseBuffer(metabuf); + FreeFakeRelcacheEntry(rel); + return; + } + + for (int slot = 0; slot < RELUNDO_NUM_HEADS; slot++) + head_blkno[slot] = meta->head_blkno[slot]; + UnlockReleaseBuffer(metabuf); + + /* + * Walk each of the RELUNDO_NUM_HEADS independent append chains from its + * head (newest) toward its tail (oldest). Only real data blocks (block >= + * 1, below the fork's block count) are valid chain links. Block 0 is the + * metapage: a zeroed data page carries a prev_blkno of 0 -- not + * InvalidBlockNumber -- and BlockNumberIsValid(0) is true, so an unguarded + * walk would read the metapage as a data page, interpret its magic as the + * next block number, and fault. Treat block 0 and any out-of-range block + * as the chain terminus. + * + * A corrupt prev_blkno could form a cycle. Each chain has at most nblocks + * links, so cap the walk at nblocks iterations and fail loudly on overrun + * rather than spinning forever during recovery. + */ + for (int slot = 0; slot < RELUNDO_NUM_HEADS; slot++) + { + BlockNumber blkno = head_blkno[slot]; + + for (BlockNumber steps = 0; + blkno != InvalidBlockNumber && blkno >= 1 && blkno < nblocks; + steps++) + { + if (steps >= nblocks) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("per-relation UNDO page chain for relation %u/%u/%u exceeds %u pages; possible cycle from corrupt prev_blkno", + rloc.spcOid, rloc.dbOid, rloc.relNumber, nblocks))); + + blkno = RelUndoRecoveryApplyPage(rel, blkno); + } + } + + FreeFakeRelcacheEntry(rel); +} + +/* + * RelUndoRecoveryApplyPage - Reverse-apply loser records on one UNDO page. + * + * Reads the page's records (oldest-to-newest by ascending offset) under a + * share lock, then releases the lock and reverse-applies the loser records + * newest-first. The undo-page lock must be dropped before + * RelUndoApplyRecordForRecovery() runs, because that routine re-reads the same + * undo page and would otherwise self-deadlock on the buffer lock. + * + * Returns the previous page in the chain (toward the tail), or + * InvalidBlockNumber when this is the oldest page. + */ +static int +RelUndoRecoveryApplyPage(Relation rel, BlockNumber blkno) +{ + Buffer buf; + Page page; + char *contents; + RelUndoPageHeader hdr; + BlockNumber prev; + uint16 page_counter; + uint16 pd_lower; + uint16 offset; + uint16 *offsets; + TransactionId *xids; + int nrecs = 0; + int maxrecs; + int i; + + buf = ReadBufferExtended(rel, RELUNDO_FORKNUM, blkno, RBM_NORMAL, NULL); + LockBuffer(buf, BUFFER_LOCK_SHARE); + page = BufferGetPage(buf); + contents = PageGetContents(page); + hdr = (RelUndoPageHeader) contents; + + prev = hdr->prev_blkno; + page_counter = hdr->counter; + pd_lower = hdr->pd_lower; + + /* Upper bound on records: each record is at least a header in size. */ + maxrecs = (pd_lower > SizeOfRelUndoPageHeaderData) + ? (pd_lower - SizeOfRelUndoPageHeaderData) / SizeOfRelUndoRecordHeader + 1 + : 0; + + if (maxrecs == 0) + { + UnlockReleaseBuffer(buf); + return prev; + } + + offsets = (uint16 *) palloc(sizeof(uint16) * maxrecs); + xids = (TransactionId *) palloc(sizeof(TransactionId) * maxrecs); + + /* Collect record offsets and xids in insertion order. */ + offset = SizeOfRelUndoPageHeaderData; + while (offset < pd_lower && nrecs < maxrecs) + { + RelUndoRecordHeader rhdr; + + memcpy(&rhdr, contents + offset, SizeOfRelUndoRecordHeader); + + /* A zero-length or malformed record terminates the scan defensively. */ + if (rhdr.urec_len < SizeOfRelUndoRecordHeader) + break; + + /* urec_type 0 marks a cancelled hole; skip but keep striding. */ + if (rhdr.urec_type != 0) + { + offsets[nrecs] = offset; + xids[nrecs] = rhdr.urec_xid; + nrecs++; + } + + offset += rhdr.urec_len; + } + + UnlockReleaseBuffer(buf); + + /* Reverse-apply newest-first; skip winners and prepared transactions. */ + for (i = nrecs - 1; i >= 0; i--) + { + TransactionId xid = xids[i]; + + if (!TransactionIdIsNormal(xid)) + continue; + if (TransactionIdDidCommit(xid)) + continue; + if (RecoveryTransactionIdIsPrepared(xid)) + continue; + + RelUndoApplyRecordForRecovery(rel, + MakeRelUndoRecPtr(page_counter, blkno, + offsets[i])); + relundo_recovery_applied++; + } + + pfree(offsets); + pfree(xids); + + return prev; +} diff --git a/src/backend/access/undo/relundo_worker.c b/src/backend/access/undo/relundo_worker.c new file mode 100644 index 0000000000000..1f867868079f3 --- /dev/null +++ b/src/backend/access/undo/relundo_worker.c @@ -0,0 +1,731 @@ +/*------------------------------------------------------------------------- + * + * relundo_worker.c + * Background worker for applying per-relation UNDO records asynchronously + * + * This module implements the async per-relation UNDO worker system that + * applies UNDO records for aborted transactions. Workers run in background + * processes to avoid blocking ROLLBACK commands with synchronous UNDO + * application. + * + * The system consists of: + * 1. A launcher process that manages the worker pool + * 2. Individual worker processes that apply UNDO chains + * 3. A shared memory work queue for coordinating pending work + * + * Architecture matches autovacuum: launcher spawns workers as needed, + * workers process work items, communicate via shared memory. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/relundo_worker.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#include + +#include "access/heapam.h" +#include "access/htup_details.h" +#include "access/relundo_worker.h" +#include "access/xact.h" +#include "access/relundo.h" +#include "access/table.h" +#include "access/tableam.h" +#include "catalog/pg_class.h" +#include "common/relpath.h" +#include "miscadmin.h" +#include "pgstat.h" +#include "postmaster/bgworker.h" +#include "storage/bufmgr.h" +#include "storage/ipc.h" +#include "storage/latch.h" +#include "storage/lwlock.h" +#include "storage/shmem.h" +#include "storage/smgr.h" +#include "tcop/tcopprot.h" +#include "utils/fmgroids.h" +#include "utils/guc.h" +#include "utils/rel.h" +#include "utils/timestamp.h" + +/* GUC parameters */ +int max_relundo_workers = 3; +int relundo_worker_naptime = 5000; /* milliseconds */ + +/* Shared memory state */ +static RelUndoWorkQueue *WorkQueue = NULL; + +/* Flags set by signal handlers */ +static volatile sig_atomic_t got_SIGHUP = false; +static volatile sig_atomic_t got_SIGTERM = false; + +/* Forward declarations */ +static void relundo_worker_sighup(SIGNAL_ARGS); +static void relundo_worker_sigterm(SIGNAL_ARGS); +static void process_relundo_work_item(RelUndoWorkItem *item); + +/* + * RelUndoWorkerShmemSize + * Calculate shared memory space needed for per-relation UNDO workers + */ +Size +RelUndoWorkerShmemSize(void) +{ + Size size = 0; + + size = add_size(size, sizeof(RelUndoWorkQueue)); + return size; +} + +/* + * RelUndoWorkerShmemInit + * Allocate and initialize shared memory for per-relation UNDO workers + */ +void +RelUndoWorkerShmemInit(void) +{ + bool found; + + WorkQueue = (RelUndoWorkQueue *) + ShmemInitStruct("Per-Relation UNDO Work Queue", + sizeof(RelUndoWorkQueue), + &found); + + if (!found) + { + /* First time through, initialize the work queue */ + LWLockInitialize(&WorkQueue->lock, LWTRANCHE_UNDO_WORKER); + WorkQueue->num_items = 0; + WorkQueue->next_worker_id = 1; + memset(WorkQueue->items, 0, sizeof(WorkQueue->items)); + } +} + +/* + * RelUndoQueueAdd + * Add a new per-relation UNDO work item to the queue + * + * Called during transaction abort to queue UNDO application work for + * background workers. + */ +void +RelUndoQueueAdd(Oid dboid, Oid reloid, RelUndoRecPtr start_urec_ptr, + TransactionId xid) +{ + int i; + bool found_slot = false; + + LWLockAcquire(&WorkQueue->lock, LW_EXCLUSIVE); + + /* Check if we already have work for this relation */ + for (i = 0; i < WorkQueue->num_items; i++) + { + RelUndoWorkItem *item = &WorkQueue->items[i]; + + if (item->dboid == dboid && item->reloid == reloid) + { + /* Update existing entry with latest UNDO pointer */ + item->start_urec_ptr = start_urec_ptr; + item->xid = xid; + item->queued_at = GetCurrentTimestamp(); + found_slot = true; + break; + } + } + + if (!found_slot) + { + RelUndoWorkItem *item; + + /* Add new work item */ + if (WorkQueue->num_items >= MAX_UNDO_WORK_ITEMS) + { + LWLockRelease(&WorkQueue->lock); + ereport(WARNING, + (errmsg("Per-relation UNDO work queue is full, cannot queue work for relation %u", + reloid))); + return; + } + + item = &WorkQueue->items[WorkQueue->num_items]; + item->dboid = dboid; + item->reloid = reloid; + item->start_urec_ptr = start_urec_ptr; + item->xid = xid; + item->queued_at = GetCurrentTimestamp(); + item->in_progress = false; + item->worker_id = 0; + WorkQueue->num_items++; + } + + LWLockRelease(&WorkQueue->lock); + + elog(DEBUG1, "Queued per-relation UNDO work for database %u, relation %u (ptr=%lu)", + dboid, reloid, (unsigned long) start_urec_ptr); +} + +/* + * RelUndoQueueGetNext + * Get the next work item for a worker to process + * + * Returns true if work was found, false if queue is empty. + * Marks the item as in_progress to prevent other workers from taking it. + */ +bool +RelUndoQueueGetNext(RelUndoWorkItem *item_out, int worker_id) +{ + int i; + bool found = false; + + LWLockAcquire(&WorkQueue->lock, LW_EXCLUSIVE); + + for (i = 0; i < WorkQueue->num_items; i++) + { + RelUndoWorkItem *item = &WorkQueue->items[i]; + + if (!item->in_progress && item->dboid == MyDatabaseId) + { + /* Found work for this database */ + memcpy(item_out, item, sizeof(RelUndoWorkItem)); + item->in_progress = true; + item->worker_id = worker_id; + found = true; + break; + } + } + + LWLockRelease(&WorkQueue->lock); + + return found; +} + +/* + * RelUndoQueueMarkComplete + * Mark a work item as complete and remove it from the queue + */ +void +RelUndoQueueMarkComplete(Oid dboid, Oid reloid, int worker_id) +{ + int i, + j; + + LWLockAcquire(&WorkQueue->lock, LW_EXCLUSIVE); + + for (i = 0; i < WorkQueue->num_items; i++) + { + RelUndoWorkItem *item = &WorkQueue->items[i]; + + if (item->dboid == dboid && item->reloid == reloid && + item->worker_id == worker_id) + { + /* Found the item, remove it by shifting remaining items */ + for (j = i; j < WorkQueue->num_items - 1; j++) + { + memcpy(&WorkQueue->items[j], &WorkQueue->items[j + 1], + sizeof(RelUndoWorkItem)); + } + WorkQueue->num_items--; + break; + } + } + + LWLockRelease(&WorkQueue->lock); + + elog(DEBUG1, "Completed per-relation UNDO work for database %u, relation %u", + dboid, reloid); +} + +/* + * relundo_worker_sighup + * SIGHUP signal handler for per-relation UNDO worker + */ +static void +relundo_worker_sighup(SIGNAL_ARGS) +{ + int save_errno = errno; + + got_SIGHUP = true; + SetLatch(MyLatch); + + errno = save_errno; +} + +/* + * relundo_worker_sigterm + * SIGTERM signal handler for per-relation UNDO worker + */ +static void +relundo_worker_sigterm(SIGNAL_ARGS) +{ + int save_errno = errno; + + got_SIGTERM = true; + SetLatch(MyLatch); + + errno = save_errno; +} + +/* + * process_relundo_work_item + * Apply per-relation UNDO records for a single work item + */ +static void +process_relundo_work_item(RelUndoWorkItem *item) +{ + Relation rel; + + elog(LOG, "Per-relation UNDO worker processing: database %u, relation %u, UNDO ptr %lu", + item->dboid, item->reloid, (unsigned long) item->start_urec_ptr); + + /* + * Open the relation with RowExclusiveLock, the same lock level used by + * normal DML. The UNDO apply modifies individual tuples and does not + * need to block concurrent readers or writers at the relation level. + * + * Previously this used AccessExclusiveLock, which created lock convoys + * under high concurrency: the UNDO worker's exclusive lock request + * would queue behind active transactions, and all new transactions + * would queue behind the UNDO worker, causing a complete stall. + */ + PG_TRY(); + { + rel = table_open(item->reloid, RowExclusiveLock); + + /* Apply the UNDO chain */ + RelUndoApplyChain(rel, item->start_urec_ptr); + + /* + * Clean up any ABORTED sLog entries for this transaction. At abort + * time, sLog entries were marked ABORTED (not removed) so visibility + * checks could detect aborted-but-not-yet-undone inserts. Now that + * the tuples are physically restored, remove those entries. + */ + if (RelUndoAbortCleanup_hook) + RelUndoAbortCleanup_hook(item->xid); + + table_close(rel, RowExclusiveLock); + } + PG_CATCH(); + { + /* + * If relation was dropped or doesn't exist, that's OK - nothing to + * do. Just log it and move on. + */ + EmitErrorReport(); + FlushErrorState(); + + elog(LOG, "Per-relation UNDO worker: failed to process relation %u, skipping", + item->reloid); + } + PG_END_TRY(); +} + +/* + * RelUndoWorkerMain + * Main entry point for per-relation UNDO worker process + */ +void +RelUndoWorkerMain(Datum main_arg) +{ + Oid dboid = DatumGetObjectId(main_arg); + int worker_id; + + /* Establish signal handlers */ + pqsignal(SIGHUP, relundo_worker_sighup); + pqsignal(SIGTERM, relundo_worker_sigterm); + + /* We're now ready to receive signals */ + BackgroundWorkerUnblockSignals(); + + /* Connect to the specified database */ + BackgroundWorkerInitializeConnectionByOid(dboid, InvalidOid, 0); + + /* Get a worker ID */ + LWLockAcquire(&WorkQueue->lock, LW_EXCLUSIVE); + worker_id = WorkQueue->next_worker_id++; + LWLockRelease(&WorkQueue->lock); + + elog(LOG, "Per-relation UNDO worker %d started for database %u", worker_id, dboid); + + /* Main work loop */ + while (!got_SIGTERM) + { + RelUndoWorkItem item; + + /* Handle SIGHUP - reload configuration */ + if (got_SIGHUP) + { + got_SIGHUP = false; + ProcessConfigFile(PGC_SIGHUP); + } + + /* Check for UNDO chain application work */ + if (RelUndoQueueGetNext(&item, worker_id)) + { + /* Start a transaction for applying UNDO */ + StartTransactionCommand(); + + /* Process the work item */ + process_relundo_work_item(&item); + + /* Mark as complete */ + RelUndoQueueMarkComplete(item.dboid, item.reloid, worker_id); + + /* Commit the transaction */ + CommitTransactionCommand(); + } + else + { + /* + * No UNDO chain work available, so exit. The worker is registered + * with BGW_NEVER_RESTART so once the queue is drained it should + * not linger -- the aborting backend may be waiting for us via + * WaitForBackgroundWorkerShutdown(). + */ + break; /* exit the main loop */ + } + } + + elog(LOG, "Per-relation UNDO worker %d shutting down", worker_id); + proc_exit(0); +} + +/* + * Per-database worker tracking in the launcher. + * + * The launcher maintains a local array of per-database worker slots. + * Each slot records the database OID and the background worker handle + * returned by RegisterDynamicBackgroundWorker(). When a worker exits, + * the slot is freed for reuse. + */ +#define MAX_LAUNCHER_DB_SLOTS MAX_UNDO_WORK_ITEMS + +typedef struct LauncherDbSlot +{ + Oid dboid; /* Database OID, or InvalidOid if free */ + BackgroundWorkerHandle *handle; /* Worker handle (NULL if slot is free) */ + TimestampTz last_spawn_attempt; /* 0 = never attempted */ +} LauncherDbSlot; + +/* + * launcher_spawn_worker + * Spawn a per-relation UNDO worker for the given database. + * + * Returns true on success, false if RegisterDynamicBackgroundWorker fails + * (e.g., because the max_worker_processes limit was reached). + */ +static bool +launcher_spawn_worker(Oid dboid, BackgroundWorkerHandle **handle_out) +{ + BackgroundWorker worker; + + memset(&worker, 0, sizeof(BackgroundWorker)); + worker.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION; + worker.bgw_start_time = BgWorkerStart_RecoveryFinished; + worker.bgw_restart_time = BGW_NEVER_RESTART; + sprintf(worker.bgw_library_name, "postgres"); + sprintf(worker.bgw_function_name, "RelUndoWorkerMain"); + snprintf(worker.bgw_name, BGW_MAXLEN, + "per-relation undo worker for database %u", dboid); + snprintf(worker.bgw_type, BGW_MAXLEN, "per-relation undo worker"); + worker.bgw_main_arg = ObjectIdGetDatum(dboid); + worker.bgw_notify_pid = 0; /* launcher does not need SIGUSR1 */ + + if (!RegisterDynamicBackgroundWorker(&worker, handle_out)) + { + ereport(DEBUG1, + (errmsg("per-relation UNDO launcher: could not register worker for database %u", + dboid))); + return false; + } + + elog(DEBUG1, "per-relation UNDO launcher: spawned worker for database %u", + dboid); + return true; +} + +/* + * RelUndoLauncherMain + * Main entry point for per-relation UNDO launcher process + * + * The launcher periodically scans the shared work queue for databases + * that have pending UNDO work and spawns per-database worker processes + * as needed, up to max_relundo_workers total. + */ +void +RelUndoLauncherMain(Datum main_arg) +{ + LauncherDbSlot db_slots[MAX_LAUNCHER_DB_SLOTS]; + int nslots = 0; + + /* Establish signal handlers */ + pqsignal(SIGHUP, relundo_worker_sighup); + pqsignal(SIGTERM, relundo_worker_sigterm); + + /* We're now ready to receive signals */ + BackgroundWorkerUnblockSignals(); + + elog(LOG, "Per-relation UNDO launcher started"); + + memset(db_slots, 0, sizeof(db_slots)); + + /* Main monitoring loop */ + while (!got_SIGTERM) + { + int rc; + int active_count; + int i, + j; + Oid pending_dbs[MAX_UNDO_WORK_ITEMS]; + int npending = 0; + + /* Handle SIGHUP - reload configuration */ + if (got_SIGHUP) + { + got_SIGHUP = false; + ProcessConfigFile(PGC_SIGHUP); + } + + /* + * Step 1: Check existing worker handles to see which are still alive. + * Workers that have exited (BGWH_STOPPED) are freed. + */ + active_count = 0; + for (i = 0; i < nslots; i++) + { + if (db_slots[i].dboid == InvalidOid) + continue; + + if (db_slots[i].handle != NULL) + { + pid_t pid; + BgwHandleStatus status; + + status = GetBackgroundWorkerPid(db_slots[i].handle, &pid); + if (status == BGWH_STOPPED || status == BGWH_POSTMASTER_DIED) + { + /* + * Worker has exited. Reset any items that were marked + * in_progress for this database so they can be retried. + */ + LWLockAcquire(&WorkQueue->lock, LW_EXCLUSIVE); + for (j = 0; j < WorkQueue->num_items; j++) + { + if (WorkQueue->items[j].dboid == db_slots[i].dboid && + WorkQueue->items[j].in_progress) + WorkQueue->items[j].in_progress = false; + } + LWLockRelease(&WorkQueue->lock); + + { + Oid exited_dboid = db_slots[i].dboid; + + pfree(db_slots[i].handle); + db_slots[i].handle = NULL; + db_slots[i].dboid = InvalidOid; + + elog(DEBUG1, + "per-relation UNDO launcher: worker for database %u has exited", + exited_dboid); + } + continue; + } + + active_count++; + } + } + + /* Compact the slots array to remove freed entries */ + nslots = 0; + for (i = 0; i < MAX_LAUNCHER_DB_SLOTS; i++) + { + if (db_slots[i].dboid != InvalidOid) + nslots = i + 1; + } + + /* + * Step 2: Scan the work queue for databases with pending (not yet + * in_progress) work items that do not already have an active worker. + */ + LWLockAcquire(&WorkQueue->lock, LW_SHARED); + for (i = 0; i < WorkQueue->num_items; i++) + { + RelUndoWorkItem *item = &WorkQueue->items[i]; + bool has_worker; + bool already_listed; + + if (item->in_progress) + continue; /* someone is working on this */ + + /* Check if we already noted this database needs a worker */ + already_listed = false; + for (j = 0; j < npending; j++) + { + if (pending_dbs[j] == item->dboid) + { + already_listed = true; + break; + } + } + if (already_listed) + continue; + + /* Check if there is already an active worker for this database */ + has_worker = false; + for (j = 0; j < MAX_LAUNCHER_DB_SLOTS; j++) + { + if (db_slots[j].dboid == item->dboid && + db_slots[j].handle != NULL) + { + has_worker = true; + break; + } + } + + if (!has_worker) + { + if (npending < MAX_UNDO_WORK_ITEMS) + pending_dbs[npending++] = item->dboid; + } + } + LWLockRelease(&WorkQueue->lock); + + /* + * Step 3: Spawn workers for databases that need them, up to the + * max_relundo_workers limit. + */ + for (i = 0; i < npending; i++) + { + Oid dboid = pending_dbs[i]; + int free_slot = -1; + BackgroundWorkerHandle *handle; + + if (active_count >= max_relundo_workers) + break; /* at the worker limit */ + + /* Find a free slot in db_slots */ + for (j = 0; j < MAX_LAUNCHER_DB_SLOTS; j++) + { + if (db_slots[j].dboid == InvalidOid) + { + free_slot = j; + break; + } + } + if (free_slot < 0) + break; /* no free slots (shouldn't happen) */ + + /* + * Back off if we recently failed to spawn a worker for this + * slot. This prevents hammering RegisterDynamicBackgroundWorker() + * when worker slots are exhausted. + */ + if (db_slots[free_slot].last_spawn_attempt != 0) + { + TimestampTz now = GetCurrentTimestamp(); + + if (now - db_slots[free_slot].last_spawn_attempt < + (TimestampTz) relundo_worker_naptime * 4 * 1000) + continue; /* too soon, skip this database for now */ + } + + if (launcher_spawn_worker(dboid, &handle)) + { + db_slots[free_slot].dboid = dboid; + db_slots[free_slot].handle = handle; + db_slots[free_slot].last_spawn_attempt = 0; + if (free_slot >= nslots) + nslots = free_slot + 1; + active_count++; + } + else + { + db_slots[free_slot].last_spawn_attempt = GetCurrentTimestamp(); + } + } + + /* Sleep until next check or until woken */ + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + relundo_worker_naptime * 2, + PG_WAIT_EXTENSION); + + ResetLatch(MyLatch); + + /* Emergency bailout if postmaster has died */ + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + } + + elog(LOG, "Per-relation UNDO launcher shutting down"); + proc_exit(0); +} + +/* + * Saved background worker handle for the most recent synchronous UNDO + * worker. WaitForPendingRelUndo() uses this to block until the worker + * exits, making the "sync rollback" path truly synchronous. + */ +static BackgroundWorkerHandle *pending_undo_handle = NULL; + +/* + * StartRelUndoWorker + * Request a background worker for applying per-relation UNDO in a database + */ +void +StartRelUndoWorker(Oid dboid) +{ + BackgroundWorker worker; + BackgroundWorkerHandle *handle; + + memset(&worker, 0, sizeof(BackgroundWorker)); + worker.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION; + worker.bgw_start_time = BgWorkerStart_RecoveryFinished; + worker.bgw_restart_time = BGW_NEVER_RESTART; + sprintf(worker.bgw_library_name, "postgres"); + sprintf(worker.bgw_function_name, "RelUndoWorkerMain"); + snprintf(worker.bgw_name, BGW_MAXLEN, "per-relation undo worker for database %u", dboid); + snprintf(worker.bgw_type, BGW_MAXLEN, "per-relation undo worker"); + worker.bgw_main_arg = ObjectIdGetDatum(dboid); + worker.bgw_notify_pid = MyProcPid; + + if (!RegisterDynamicBackgroundWorker(&worker, &handle)) + { + ereport(WARNING, + (errmsg("could not register per-relation UNDO worker for database %u", dboid))); + } + else + { + elog(DEBUG1, "Started per-relation UNDO worker for database %u", dboid); + pending_undo_handle = handle; + } +} + +/* + * WaitForPendingRelUndo + * Block until the most recent synchronous UNDO worker exits. + * + * Called from AbortTransaction() AFTER locks have been released so the + * UNDO worker can acquire AccessExclusiveLock on the target relation. + * This makes the "sync rollback" path truly synchronous: the aborting + * backend does not return to the client until the UNDO is applied and + * the original tuple data is restored. + */ +void +WaitForPendingRelUndo(void) +{ + if (pending_undo_handle == NULL) + return; + + (void) WaitForBackgroundWorkerShutdown(pending_undo_handle); + + pfree(pending_undo_handle); + pending_undo_handle = NULL; +} diff --git a/src/backend/access/undo/relundo_xlog.c b/src/backend/access/undo/relundo_xlog.c new file mode 100644 index 0000000000000..694354b0ddf55 --- /dev/null +++ b/src/backend/access/undo/relundo_xlog.c @@ -0,0 +1,760 @@ +/*------------------------------------------------------------------------- + * + * relundo_xlog.c + * Per-relation UNDO resource manager WAL redo routines + * + * This module implements the WAL redo callback for the RM_RELUNDO_ID + * resource manager. It handles replay of: + * + * XLOG_RELUNDO_INIT - Replay metapage initialization + * XLOG_RELUNDO_INSERT - Replay UNDO record insertion into a data page + * XLOG_RELUNDO_DISCARD - Replay discard of old UNDO pages + * + * Redo Strategy + * ------------- + * INIT and DISCARD use full page images (FPI) via XLogInitBufferForRedo() + * or REGBUF_FORCE_IMAGE, so redo simply restores the page image. + * + * INSERT records may include FPIs on the first modification after a + * checkpoint. When no FPI is present (BLK_NEEDS_REDO), the redo + * function reconstructs the insertion by copying the UNDO record data + * into the page at the recorded offset and updating pd_lower. + * + * Async I/O Strategy + * ------------------ + * INSERT records may reference two blocks: block 0 (data page) and + * block 1 (metapage, when the head pointer was updated). To overlap + * the I/O for both blocks, we issue a PrefetchSharedBuffer() for + * block 1 before processing block 0. This allows the kernel or the + * AIO worker to start reading the metapage in parallel with the data + * page read, reducing overall latency during crash recovery. + * + * When io_method is WORKER or IO_URING, we also enter batch mode + * (pgaio_enter_batchmode) so that multiple I/O submissions can be + * coalesced into fewer system calls. The batch is exited after all + * blocks in the record have been processed. + * + * Parallel Redo Support + * --------------------- + * This resource manager supports parallel WAL replay for multi-core crash + * recovery via the startup, cleanup, and mask callbacks registered in + * rmgrlist.h. + * + * Page dependency rules for parallel redo: + * + * - Records that touch different pages can be replayed in parallel with + * no ordering constraints. + * + * - Within the same page, XLOG_RELUNDO_INIT (or INSERT with the + * XLOG_RELUNDO_INIT_PAGE flag) must be replayed before any subsequent + * XLOG_RELUNDO_INSERT on that page. The recovery manager enforces + * this automatically via the page LSN check in XLogReadBufferForRedo. + * + * - XLOG_RELUNDO_DISCARD only modifies the metapage (block 0). It is + * ordered relative to other metapage modifications by the page LSN. + * + * - The metapage (block 0) is a serialization point: INSERT records that + * update the head pointer and DISCARD records both touch the metapage, + * so they are serialized on that page by the buffer lock. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/relundo_xlog.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/bufmask.h" +#include "access/relundo.h" +#include "access/relundo_xlog.h" +#include "access/xlogutils.h" +#include "miscadmin.h" +#include "storage/aio.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/smgr.h" + +/* + * relundo_redo_init - Replay metapage initialization + * + * The metapage is always logged with a full page image via + * XLogInitBufferForRedo, so we just need to initialize and restore it. + */ +static void +relundo_redo_init(XLogReaderState *record) +{ + XLogRecPtr lsn = record->EndRecPtr; + xl_relundo_init *xlrec = (xl_relundo_init *) XLogRecGetData(record); + Buffer buf; + Page page; + RelUndoMetaPageData *meta; + + /* Consistency checks on WAL record data */ + if (xlrec->magic != RELUNDO_METAPAGE_MAGIC) + elog(PANIC, "relundo_redo_init: invalid magic 0x%X (expected 0x%X)", + xlrec->magic, RELUNDO_METAPAGE_MAGIC); + + if (xlrec->version != RELUNDO_METAPAGE_VERSION) + elog(PANIC, "relundo_redo_init: invalid version %u (expected %u)", + xlrec->version, RELUNDO_METAPAGE_VERSION); + + /* + * Initial counter should be 1 for a freshly initialized metapage. + * RelUndoInitRelation sets counter to 1 so that 0 is clearly + * "no counter / uninitialized". Accept any small value as valid + * since the counter only increments from 1 and a freshly initialized + * metapage will have counter == 1. + */ + if (xlrec->counter > 1) + elog(PANIC, "relundo_redo_init: initial counter %u too large for init record", + xlrec->counter); + + buf = XLogInitBufferForRedo(record, 0); + page = BufferGetPage(buf); + + /* Initialize the metapage from scratch */ + PageInit(page, BLCKSZ, 0); + + meta = (RelUndoMetaPageData *) PageGetContents(page); + meta->magic = xlrec->magic; + meta->version = xlrec->version; + meta->counter = xlrec->counter; + for (int slot = 0; slot < RELUNDO_NUM_HEADS; slot++) + { + meta->head_blkno[slot] = InvalidBlockNumber; + meta->tail_blkno[slot] = InvalidBlockNumber; + } + meta->free_blkno = InvalidBlockNumber; + meta->total_records = 0; + meta->discarded_records = 0; + + /* Match the do-time metapage: cover the meta struct with pd_lower. */ + RelUndoMetaPageSetPdLower(page); + + PageSetLSN(page, lsn); + MarkBufferDirty(buf); + UnlockReleaseBuffer(buf); +} + +/* + * relundo_prefetch_block - Issue async prefetch for a WAL-referenced block + * + * If the WAL record references the given block_id and it has not already + * been prefetched by the XLogPrefetcher, initiate an async read via + * PrefetchSharedBuffer(). This is a no-op when USE_PREFETCH is not + * available or when the block is already in the buffer pool. + * + * Returns true if I/O was initiated, false otherwise (cache hit or no-op). + */ +static bool +relundo_prefetch_block(XLogReaderState *record, uint8 block_id) +{ +#ifdef USE_PREFETCH + RelFileLocator rlocator; + ForkNumber forknum; + BlockNumber blkno; + Buffer prefetch_buffer; + SMgrRelation smgr; + + if (!XLogRecGetBlockTagExtended(record, block_id, + &rlocator, &forknum, &blkno, + &prefetch_buffer)) + return false; + + /* If the XLogPrefetcher already cached a buffer hint, skip prefetch. */ + if (BufferIsValid(prefetch_buffer)) + return false; + + smgr = smgropen(rlocator, INVALID_PROC_NUMBER); + + /* + * Only prefetch if the relation fork exists and the block is within + * the current size. During recovery, relations may not yet have been + * extended to the referenced block. + */ + if (smgrexists(smgr, forknum)) + { + BlockNumber nblocks = smgrnblocks(smgr, forknum); + + if (blkno < nblocks) + { + PrefetchSharedBuffer(smgr, forknum, blkno); + return true; + } + } +#endif /* USE_PREFETCH */ + + return false; +} + +/* + * relundo_redo_insert - Replay UNDO record insertion + * + * When a full page image is present, it is restored automatically by + * XLogReadBufferForRedo (BLK_RESTORED). Otherwise (BLK_NEEDS_REDO), + * we copy the UNDO record data into the page at the recorded offset + * and update pd_lower. + * + * If the XLOG_RELUNDO_INIT_PAGE flag is set, the page is a newly + * allocated data page and must be initialized from scratch before + * inserting the record. + * + * Async I/O: When this record references both block 0 (data page) and + * block 1 (metapage), we prefetch block 1 before reading block 0. + * This allows the I/O for the metapage to proceed in parallel with + * the data page read and redo processing, reducing stall time. + */ +static void +relundo_redo_insert(XLogReaderState *record) +{ + XLogRecPtr lsn = record->EndRecPtr; + xl_relundo_insert *xlrec = (xl_relundo_insert *) XLogRecGetData(record); + Buffer buf; + XLogRedoAction action; + bool has_metapage = XLogRecHasBlockRef(record, 1); + bool use_batchmode; + + /* Consistency checks on WAL record data */ + if (xlrec->urec_len < SizeOfRelUndoRecordHeader) + elog(PANIC, "relundo_redo_insert: invalid record length %u (min %zu)", + xlrec->urec_len, SizeOfRelUndoRecordHeader); + + if (xlrec->page_offset > BLCKSZ - sizeof(RelUndoPageHeaderData)) + elog(PANIC, "relundo_redo_insert: invalid page offset %u", + xlrec->page_offset); + + if (xlrec->new_pd_lower > BLCKSZ) + elog(PANIC, "relundo_redo_insert: pd_lower %u exceeds page size", + xlrec->new_pd_lower); + + /* Cross-field check: record must fit within page */ + if ((uint32) xlrec->page_offset + (uint32) xlrec->urec_len > BLCKSZ) + elog(PANIC, "relundo_redo_insert: record extends past page end (offset %u + len %u > %u)", + xlrec->page_offset, xlrec->urec_len, (uint32) BLCKSZ); + + /* + * new_pd_lower must be at least as far as the start of the record we are + * inserting. page_offset is page-absolute (MAXALIGN(SizeOfPageHeaderData) + * + contents offset) while new_pd_lower is relative to the page contents + * area, so compare them in the same coordinate system by adding the + * standard page-header size to the contents-relative pd_lower. + */ + if (xlrec->new_pd_lower + MAXALIGN(SizeOfPageHeaderData) < xlrec->page_offset) + elog(PANIC, "relundo_redo_insert: new_pd_lower %u precedes page_offset %u", + xlrec->new_pd_lower, xlrec->page_offset); + + /* Validate record type is in valid range */ + if (xlrec->urec_type < RELUNDO_INSERT || xlrec->urec_type > RELUNDO_TUPLE_LOCK) + elog(PANIC, "relundo_redo_insert: invalid record type %u", xlrec->urec_type); + + /* + * Async I/O optimization: when the record touches both the data page + * (block 0) and the metapage (block 1), issue a prefetch for the + * metapage before we read block 0. This allows both I/Os to be in + * flight simultaneously. + * + * Enter batch mode so that the buffer manager can coalesce the I/O + * submissions when using io_method = worker or io_uring. Batch mode + * is only useful when we have multiple blocks to process; for single- + * block records the overhead is not worthwhile. + */ + use_batchmode = has_metapage && (io_method != IOMETHOD_SYNC); + + if (use_batchmode) + pgaio_enter_batchmode(); + + if (has_metapage) + relundo_prefetch_block(record, 1); + + if (XLogRecGetInfo(record) & XLOG_RELUNDO_INIT_PAGE) + { + /* New page: initialize from scratch, then apply insert */ + buf = XLogInitBufferForRedo(record, 0); + action = BLK_NEEDS_REDO; + } + else + { + action = XLogReadBufferForRedo(record, 0, &buf); + } + + if (action == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buf); + char *record_data; + Size record_len; + + record_data = XLogRecGetBlockData(record, 0, &record_len); + + if (record_data == NULL || record_len == 0) + elog(PANIC, "relundo_redo_insert: no block data for UNDO record"); + + /* Consistency check: verify data length is reasonable */ + if (record_len > BLCKSZ) + elog(PANIC, "relundo_redo_insert: block data too large (%zu bytes)", record_len); + + /* + * If the page was just initialized (INIT_PAGE flag), the block data + * contains both the RelUndoPageHeaderData and the UNDO record. + * Initialize the page structure first, then copy both. + */ + if (XLogRecGetInfo(record) & XLOG_RELUNDO_INIT_PAGE) + { + char *contents; + + /* INIT_PAGE data must include at least the page header */ + if (record_len < SizeOfRelUndoPageHeaderData) + elog(PANIC, "relundo_redo_insert: INIT_PAGE block data too small (%zu < %zu)", + record_len, SizeOfRelUndoPageHeaderData); + + /* Block data plus page header must fit in a page */ + if (record_len > BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) + elog(PANIC, "relundo_redo_insert: INIT_PAGE block data too large (%zu bytes)", + record_len); + + PageInit(page, BLCKSZ, 0); + + /* + * The record_data contains: 1. RelUndoPageHeaderData + * (SizeOfRelUndoPageHeaderData bytes) 2. UNDO record (remaining + * bytes) + * + * Copy both to the page contents area. + */ + contents = PageGetContents(page); + memcpy(contents, record_data, record_len); + } + else + { + RelUndoPageHeader undohdr = (RelUndoPageHeader) PageGetContents(page); + + /* Consistency check: verify pd_lower is reasonable before update */ + if (undohdr->pd_lower > BLCKSZ) + elog(PANIC, "relundo_redo_insert: existing pd_lower %u exceeds page size", + undohdr->pd_lower); + + /* + * Normal case: page already exists, just copy the UNDO record to + * the specified offset. + */ + memcpy((char *) page + xlrec->page_offset, record_data, record_len); + + /* Update the page's free space pointer */ + undohdr->pd_lower = xlrec->new_pd_lower; + + /* Restore the page's max_xid discard watermark. */ + undohdr->max_xid = xlrec->max_xid; + + /* + * Post-condition check: verify pd_lower is reasonable after + * update. pd_lower is contents-relative while page_offset is + * page-absolute, so add the standard page-header size to pd_lower + * before comparing against the page-absolute end of the record. + */ + if (undohdr->pd_lower + MAXALIGN(SizeOfPageHeaderData) < xlrec->page_offset + record_len) + elog(PANIC, "relundo_redo_insert: pd_lower %u too small for offset %u + len %zu", + undohdr->pd_lower, xlrec->page_offset, record_len); + } + + PageSetLSN(page, lsn); + MarkBufferDirty(buf); + } + + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + + /* + * Block 1 (metapage) may also be present if the head pointer was updated. + * If so, restore its FPI. The prefetch issued above should have brought + * the page into cache (or at least started the I/O), so this read should + * complete quickly. + */ + if (has_metapage) + { + action = XLogReadBufferForRedo(record, 1, &buf); + /* Metapage is always logged with FPI, so BLK_RESTORED or BLK_DONE */ + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + } + + if (use_batchmode) + pgaio_exit_batchmode(); +} + +/* + * relundo_redo_discard - Replay UNDO page discard + * + * Discard splices a contiguous run of discardable pages off the tail of the + * data chain directly onto the metapage's free list. The run is already + * internally linked by durable prev_blkno fields, so only the run boundaries + * change. The record carries a bounded set of buffers: + * + * Block 0: the metapage (tail + free-list head) + * Block 1: the run's old-tail page (prev_blkno -> old free head) + * Block 2: the new live tail page (prev_blkno -> Invalid), if any + * + * The metapage is registered REGBUF_STANDARD (not a forced FPI), so on a + * BLK_NEEDS_REDO replay we re-apply the metapage mutations explicitly rather + * than relying on a restored image. + */ +static void +relundo_redo_discard(XLogReaderState *record) +{ + XLogRecPtr lsn = record->EndRecPtr; + Buffer buf; + XLogRedoAction action; + xl_relundo_discard *xlrec = (xl_relundo_discard *) XLogRecGetData(record); + bool whole_chain = !BlockNumberIsValid(xlrec->new_tail_blkno); + + /* + * Consistency check on WAL record data. A run is always at least one + * page; there is no upper bound, since a whole-chain discard of a large + * fork legitimately frees arbitrarily many pages. Do not impose an + * arbitrary ceiling here: do-time has no matching guard, so any cap we + * reject below an achievable run length would turn a logged record into + * one its own crash recovery refuses to replay (a PANIC loop). + */ + if (xlrec->npages_freed == 0) + elog(PANIC, "relundo_redo_discard: npages_freed is zero"); + + /* + * Block 0 is the metapage; the run's old tail is a real data page, so its + * block number must never be the metapage (block 0). + */ + if (xlrec->old_tail_blkno == 0) + elog(PANIC, "relundo_redo_discard: old_tail_blkno is metapage block 0"); + + /* + * new_tail_blkno is either a real data page (>= 1) or InvalidBlockNumber + * when the whole chain was discarded; it must never be the metapage. + */ + if (xlrec->new_tail_blkno == 0) + elog(PANIC, "relundo_redo_discard: new_tail_blkno is metapage block 0"); + + /* Block 0: metapage (tail + free-list head). */ + action = XLogReadBufferForRedo(record, 0, &buf); + + if (action == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buf); + RelUndoMetaPageData *meta; + + meta = (RelUndoMetaPageData *) PageGetContents(page); + + /* Post-condition checks on metapage */ + if (meta->magic != RELUNDO_METAPAGE_MAGIC) + elog(PANIC, "relundo_redo_discard: metapage has invalid magic 0x%X", + meta->magic); + + if (meta->counter > 65535) + elog(PANIC, "relundo_redo_discard: counter %u exceeds maximum", + meta->counter); + + /* Advance the live data chain tail (and head if it is now empty). */ + meta->tail_blkno[xlrec->slot] = xlrec->new_tail_blkno; + if (whole_chain) + meta->head_blkno[xlrec->slot] = InvalidBlockNumber; + + /* Splice the run directly onto the free list. */ + meta->free_blkno = xlrec->free_head_blkno; + meta->discarded_records += xlrec->npages_freed; + + PageSetLSN(page, lsn); + MarkBufferDirty(buf); + } + + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + + /* + * Block 1: the run's old-tail page, whose prev_blkno now links to the old + * free-list head (appending the prior free list after the run). + */ + action = XLogReadBufferForRedo(record, 1, &buf); + if (action == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buf); + RelUndoPageHeader hdr = (RelUndoPageHeader) PageGetContents(page); + + hdr->prev_blkno = xlrec->old_free_head; + PageSetLSN(page, lsn); + MarkBufferDirty(buf); + } + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + + /* + * Block 2: the new live tail page (present only when the chain is not + * fully discarded), whose prev_blkno is cleared to detach it from the run. + */ + if (!whole_chain && XLogRecHasBlockRef(record, 2)) + { + action = XLogReadBufferForRedo(record, 2, &buf); + if (action == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buf); + RelUndoPageHeader hdr = (RelUndoPageHeader) PageGetContents(page); + + hdr->prev_blkno = InvalidBlockNumber; + PageSetLSN(page, lsn); + MarkBufferDirty(buf); + } + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + } +} + +/* + * relundo_redo_truncate - Replay physical truncation of an emptied UNDO fork + * + * The do-time path (RelUndoTruncateEmptyChain) logs this only when a discard + * empties the whole data chain, at which point the free list -- and thus every + * allocated data block -- is the contiguous physical suffix [1 .. watermark]. + * Redo restores the metapage (block 0) with its free-list head and allocation + * watermark reset, then drops the truncated buffers and shrinks the fork file. + * + * The metapage is registered REGBUF_STANDARD, so on BLK_NEEDS_REDO we re-apply + * the field resets explicitly rather than relying on a restored image. + */ +static void +relundo_redo_truncate(XLogReaderState *record) +{ + XLogRecPtr lsn = record->EndRecPtr; + xl_relundo_truncate *xlrec = (xl_relundo_truncate *) XLogRecGetData(record); + Buffer buf; + XLogRedoAction action; + RelFileLocator rlocator; + ForkNumber forknum; + BlockNumber blkno; + SMgrRelation reln; + BlockNumber old_nblocks; + BlockNumber new_nblocks = xlrec->new_nblocks; + + /* The fork is always truncated back to just the metapage (block 0). */ + if (new_nblocks != 1) + elog(PANIC, "relundo_redo_truncate: unexpected new_nblocks %u", + new_nblocks); + + /* Block 0: metapage (free-list head + watermark reset). */ + action = XLogReadBufferForRedo(record, 0, &buf); + + if (action == BLK_NEEDS_REDO) + { + Page page = BufferGetPage(buf); + RelUndoMetaPageData *meta; + + meta = (RelUndoMetaPageData *) PageGetContents(page); + + if (meta->magic != RELUNDO_METAPAGE_MAGIC) + elog(PANIC, "relundo_redo_truncate: metapage has invalid magic 0x%X", + meta->magic); + + /* + * The discard that triggered this truncation already emptied the data + * chain; the freed pages no longer exist, so clear the free-list head + * and the allocation watermark too. + */ + for (int slot = 0; slot < RELUNDO_NUM_HEADS; slot++) + { + meta->head_blkno[slot] = InvalidBlockNumber; + meta->tail_blkno[slot] = InvalidBlockNumber; + } + meta->free_blkno = InvalidBlockNumber; + meta->system_alloc_watermark = InvalidBlockNumber; + + PageSetLSN(page, lsn); + MarkBufferDirty(buf); + } + + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + + /* + * Now physically truncate the fork. Resolve the relation from block 0's + * tag, force-create the fork if it is missing (it may have been dropped + * later in the WAL stream), and mirror smgr_redo's XLOG_SMGR_TRUNCATE arm: + * advance the minimum recovery point, then smgrtruncate (which drops the + * to-be-removed buffers via DropRelationBuffers). + */ + XLogRecGetBlockTag(record, 0, &rlocator, &forknum, &blkno); + Assert(forknum == RELUNDO_FORKNUM); + reln = smgropen(rlocator, INVALID_PROC_NUMBER); + smgrcreate(reln, forknum, true); + + XLogFlush(lsn); + + old_nblocks = smgrnblocks(reln, forknum); + if (old_nblocks > new_nblocks) + { + /* Tell xlogutils.c so cached relation sizes stay consistent. */ + XLogTruncateRelation(rlocator, forknum, new_nblocks); + + START_CRIT_SECTION(); + smgrtruncate(reln, &forknum, 1, &old_nblocks, &new_nblocks); + END_CRIT_SECTION(); + } +} + +/* + * relundo_redo_apply - Replay a rollback compensation log record (CLR) + * + * The CLR carries full-page images of every data page (main fork) restored + * during online abort plus the UNDO-fork page (carrying RELUNDO_INFO_CLR_APPLIED). + * Each referenced block was registered with REGBUF_FORCE_IMAGE, so redo simply + * restores the recorded images, reinstating the before-image rollback that + * would otherwise be lost when dirty buffers vanish on an immediate crash. + * + * Because every block is logged with a forced FPI, XLogReadBufferForRedo + * returns BLK_RESTORED for blocks needing redo and BLK_DONE for those already + * at or past the record LSN; in neither case is there extra work to do. + */ +static void +relundo_redo_apply(XLogReaderState *record) +{ + int block_id; + int max_block_id = XLogRecMaxBlockId(record); + + for (block_id = 0; block_id <= max_block_id; block_id++) + { + Buffer buf; + + if (!XLogRecHasBlockRef(record, block_id)) + continue; + + (void) XLogReadBufferForRedo(record, (uint8) block_id, &buf); + if (BufferIsValid(buf)) + UnlockReleaseBuffer(buf); + } +} + +/* + * relundo_redo - Main redo dispatch for RM_RELUNDO_ID + */ +void +relundo_redo(XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + /* + * Strip XLOG_RELUNDO_INIT_PAGE flag for the switch; it only affects + * INSERT processing. + */ + switch (info & ~XLOG_RELUNDO_INIT_PAGE) + { + case XLOG_RELUNDO_INIT: + relundo_redo_init(record); + break; + + case XLOG_RELUNDO_INSERT: + relundo_redo_insert(record); + break; + + case XLOG_RELUNDO_DISCARD: + relundo_redo_discard(record); + break; + + case XLOG_RELUNDO_TRUNCATE: + relundo_redo_truncate(record); + break; + + case XLOG_RELUNDO_APPLY: + relundo_redo_apply(record); + break; + + default: + elog(PANIC, "relundo_redo: unknown op code %u", info); + } +} + +/* + * relundo_startup - Initialize per-backend state for parallel redo + * + * Called once per backend at the start of parallel WAL replay. + * We don't currently need any special per-backend state for per-relation UNDO, + * but this hook is required for parallel redo support. + */ +void +relundo_startup(void) +{ + /* + * No per-backend initialization needed currently. + * If we add backend-local caches or state in the future, + * initialize them here. + */ +} + +/* + * relundo_cleanup - Clean up per-backend state after parallel redo + * + * Called once per backend at the end of parallel WAL replay. + * Counterpart to relundo_startup(). + */ +void +relundo_cleanup(void) +{ + /* + * No per-backend cleanup needed currently. + * If relundo_startup() initializes any resources, + * release them here. + */ +} + +/* + * relundo_mask - Mask non-critical page fields for consistency checking + * + * During parallel redo, pages may be replayed in different order across + * backends. This function masks out fields that may differ but do not + * indicate corruption, so that page comparisons (e.g. by pg_waldump + * --check) avoid false positives. + * + * We use the standard mask_page_lsn_and_checksum() helper from bufmask.h, + * matching the convention used by heap, btree, and other resource managers. + * + * RelUndo pages do not use the standard line-pointer layout, so we cannot + * call mask_unused_space() (which operates on the standard PageHeader's + * pd_lower/pd_upper). Instead, for data pages we mask the free space + * tracked by the RelUndoPageHeader's own pd_lower and pd_upper fields + * within the contents area. + */ +void +relundo_mask(char *pagedata, BlockNumber blkno) +{ + Page page = (Page) pagedata; + + /* + * Mask LSN and checksum -- these may differ across parallel redo + * workers due to replay ordering. + */ + mask_page_lsn_and_checksum(page); + + if (blkno == 0) + { + /* + * Metapage: do not mask magic, version, counter, or block pointers. + * Those must match exactly for consistency. LSN and checksum are + * already masked above. + */ + } + else + { + /* + * Data page: mask unused space between the UNDO page header's + * pd_lower (next insertion point) and pd_upper (end of usable + * space). This region may contain stale data from prior page + * reuse and is not meaningful for consistency. + * + * The RelUndoPageHeader sits at the start of the page contents + * area (after the standard PageHeaderData). Its pd_lower and + * pd_upper are offsets relative to the contents area. + */ + RelUndoPageHeader undohdr = (RelUndoPageHeader) PageGetContents(page); + char *contents = (char *) PageGetContents(page); + int lower = undohdr->pd_lower; + int upper = undohdr->pd_upper; + + if (lower < upper) + memset(contents + lower, MASK_MARKER, upper - lower); + } +} diff --git a/src/backend/access/undo/slog.c b/src/backend/access/undo/slog.c new file mode 100644 index 0000000000000..8c2b219cf3263 --- /dev/null +++ b/src/backend/access/undo/slog.c @@ -0,0 +1,749 @@ +/*------------------------------------------------------------------------- + * + * slog.c + * Secondary Log (sLog) -- transaction Aborted Transaction Map (ATM) + * + * The sLog tracks aborted transactions in shared memory for the UNDO + * subsystem's Constant-Time Recovery. + * + * Transaction sLog (adaptive radix tree): + * - A shared-memory adaptive radix tree (radixtree.h, RT_SHMEM) keyed by + * (xid, reloid) packed into a uint64 stores full abort metadata. With + * xid in the high bits, ordered iteration groups all entries for a + * given xid contiguously, enabling efficient per-xid range operations. + * - The tree lives in the sLog's DSA area and grows on demand. + * - The radix tree is protected by a single LWLock (sLog modifications + * only occur on transaction abort, an uncommon path). + * + * An optional per-tuple flat-hash extension (see the tuple sLog, added by + * the consumer that needs bounded-recovery uncommitted-writer tracking) + * shares this file's shared-memory segment and initialization but is not + * required by the UNDO core. + * + * Locking: Transaction sLog uses LWTRANCHE_SLOG. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/slog.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#ifdef WIN32 +#include +#endif + +#include "access/relundo.h" +#include "access/slog.h" +#include "access/slog_internal.h" +#include "access/transam.h" +#include "access/xact.h" +#include "common/hashfn.h" +#include "nodes/lockoptions.h" +#include "miscadmin.h" +#include "storage/lock.h" +#include "storage/lwlock.h" +#include "storage/off.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/shmem.h" +#include "storage/spin.h" +#include "storage/subsystems.h" +#include "utils/dsa.h" +#include "utils/memutils.h" +#include "utils/snapmgr.h" +#include "utils/timestamp.h" + +/* ---------------------------------------------------------------- + * Adaptive radix tree instantiation for the transaction sLog (ATM) + * + * The Aborted Transaction Map is keyed by a single uint64 formed from + * (xid, reloid): key = (xid << 32) | reloid. Both are uint32 and the + * two halves pack exactly. Because xid occupies the high 32 bits, the + * radix tree's ordered iteration groups all entries for one xid + * contiguously (xid-major, reloid-minor ascending) -- the same ordering + * property the old skip-list provided, which the per-xid range + * operations below rely on. + * + * The tree lives in the sLog's DSA area so it grows on demand rather + * than reserving a fixed shared-memory pool. All access is serialized externally by the + * existing txn_lock (LWLock): SET/DELETE under LW_EXCLUSIVE, FIND and + * iteration under LW_SHARED. We do not use the radix tree's own + * internal lock (RT_LOCK_*), since txn_lock already provides the needed + * serialization. + * ---------------------------------------------------------------- + */ + +/* + * slog_atm_value_t - value stored in the ATM radix tree. + * + * The key (xid, reloid) is implicit in the tree path, so only the data + * fields are stored here. + */ +typedef struct slog_atm_value_t +{ + XLogRecPtr last_batch_lsn; /* LSN of last UNDO batch for this xid */ + Oid dboid; + TimestampTz abort_time; + bool revert_complete; +} slog_atm_value_t; + +#define RT_PREFIX slog_atm +#define RT_SCOPE static pg_attribute_unused() +#define RT_DECLARE +#define RT_DEFINE +#define RT_VALUE_TYPE slog_atm_value_t +#define RT_SHMEM +#define RT_USE_DELETE +#include "lib/radixtree.h" + +/* ATM key encoding: xid in the high 32 bits, reloid in the low 32 bits. */ +static inline uint64 +slog_atm_key(TransactionId xid, Oid reloid) +{ + return ((uint64) xid << 32) | (uint64) reloid; +} + +static inline TransactionId +slog_atm_key_xid(uint64 key) +{ + return (TransactionId) (key >> 32); +} + +static inline Oid +slog_atm_key_reloid(uint64 key) +{ + return (Oid) (key & 0xFFFFFFFFu); +} + +/* + * Initial size for the sLog DSA area (backs the aborted-txn radix tree). + * Grows dynamically as needed up to slog_dsa_max_size_mb. SLOG_DSA_INIT_SIZE + * and the shared-state struct are defined in access/slog_internal.h, shared + * with the optional tuple sLog (slog_tuple.c). + */ +#define SLOG_DSA_MAX_SIZE_MB 256 /* default max: 256 MB */ + +/* GUC: maximum sLog DSA area size (in MB) */ +int slog_dsa_max_size_mb = SLOG_DSA_MAX_SIZE_MB; + +/* ---------------------------------------------------------------- + * Static variables + * ---------------------------------------------------------------- + */ +SLogSharedState *SLogState = NULL; + +/* Per-backend DSA attachment (lazy, via SLogEnsureDsaAttached) */ +static dsa_area *slog_dsa_handle = NULL; + +/* Per-backend attached ATM radix tree (lazy, via slog_atm_tree) */ +static slog_atm_radix_tree *slog_atm_tree_local = NULL; + + +/* + * slog_atm_tree + * Return this backend's attached ATM radix tree, attaching lazily. + * + * Ensures the sLog DSA area is attached, then attaches to the shared + * radix tree via its handle. The attached wrapper is cached for the + * life of the backend (the control block lives in the pinned DSA area). + * The caller must hold txn_lock across any use of the returned tree. + */ +static slog_atm_radix_tree * +slog_atm_tree(void) +{ + MemoryContext oldcxt; + + if (slog_atm_tree_local != NULL) + return slog_atm_tree_local; + + SLogEnsureDsaAttached(); + if (slog_dsa_handle == NULL) + elog(PANIC, "sLog: DSA not attached for ATM radix tree"); + + /* + * Attach in TopMemoryContext so the wrapper persists across + * transactions, mirroring the DSA attach above. + */ + oldcxt = MemoryContextSwitchTo(TopMemoryContext); + slog_atm_tree_local = slog_atm_attach(slog_dsa_handle, + SLogState->atm_handle); + MemoryContextSwitchTo(oldcxt); + + return slog_atm_tree_local; +} + +/* ---------------------------------------------------------------- + * Shared memory sizing and initialization + * ---------------------------------------------------------------- + */ + +/* + * SLogShmemSize + * Calculate shared memory needed for the sLog. + * + * Note: The DSA initial region is embedded in SLogSharedState (dsa_space[]), + * so sizeof(SLogSharedState) already includes SLOG_DSA_INIT_SIZE. The ATM + * radix tree is allocated from that DSA area on demand, so it needs no fixed + * reservation here. + */ +Size +SLogShmemSize(void) +{ + return add_size(MAXALIGN(sizeof(SLogSharedState)), SLogTupleShmemSize()); +} + +/* + * SLogShmemRequest + * Register shared memory needs for the sLog. + * + * Registers the shared state struct (which embeds the DSA initial region + * backing the aborted-transaction radix tree). + */ +void +SLogShmemRequest(void) +{ + /* Register the shared state structure */ + ShmemRequestStruct(.name = "Secondary Log State", + .size = sizeof(SLogSharedState), + .ptr = (void **) &SLogState, + ); + + /* Optional tuple sLog: register its flat-hash partition block. */ + SLogTupleShmemRequest(); +} + +/* + * SLogShmemInit + * Initialize sLog shared memory contents. + * + * Called during the init_fn phase. The framework has already allocated + * SLogState. We initialize the transaction ATM radix tree and its DSA area. + */ +void +SLogShmemInit(void) +{ + /* ATM radix tree is created below, after the DSA area exists. */ + SLogState->atm_handle = InvalidDsaPointer; + + /* ---- Initialize locks ---- */ + LWLockInitialize(&SLogState->txn_lock.lock, LWTRANCHE_SLOG); + + /* ---- Optional tuple sLog: allocate + init the flat-hash partitions ---- */ + SLogTupleShmemInit(); + + /* ---- Initialize DSA area (backs the aborted-txn radix tree) ---- */ + SLogState->dsa_area = dsa_create_in_place(SLogState->dsa_space, + SLOG_DSA_INIT_SIZE, + LWTRANCHE_SLOG, + 0); + dsa_pin(SLogState->dsa_area); + dsa_set_size_limit(SLogState->dsa_area, + (Size) slog_dsa_max_size_mb * 1024 * 1024); + + /* + * Create the ATM radix tree in the DSA area and record its handle. The + * transient tree wrapper returned here is discarded when init's memory + * context is reset; the control block lives in the pinned DSA area, and + * each backend re-attaches lazily via slog_atm_tree(). We reuse + * LWTRANCHE_SLOG for the tree's internal lock (unused -- txn_lock + * serializes all access -- but a valid tranche is required). + */ + { + slog_atm_radix_tree *tree; + + tree = slog_atm_create(SLogState->dsa_area, LWTRANCHE_SLOG); + SLogState->atm_handle = slog_atm_get_handle(tree); + } + + dsa_detach(SLogState->dsa_area); + SLogState->dsa_area = NULL; /* backends re-attach lazily */ +} + +/* + * SLogShmemRequest_cb / SLogShmemInit_cb + * ShmemCallbacks adapters for SLogShmemRequest()/SLogShmemInit(). + * + * The sLog is registered as its own PG_SHMEM_SUBSYSTEM entry (see + * storage/subsystemlist.h) rather than being sized/initialized from within + * the generic UNDO subsystem's callback, so the UNDO core has no + * compile-time or link-time dependency on any consumer. + * + * No .attach_fn is provided: SLogShmemInit() has no found-guard, so the + * framework re-attaches EXEC_BACKEND children to the already-initialized + * shared struct via the ShmemRequestStruct(.ptr=&SLogState) registration + * in SLogShmemRequest(). + */ +static void +SLogShmemRequest_cb(void *arg) +{ + SLogShmemRequest(); +} + +static void +SLogShmemInit_cb(void *arg) +{ + SLogShmemInit(); +} + +const ShmemCallbacks SLogShmemCallbacks = { + .request_fn = SLogShmemRequest_cb, + .init_fn = SLogShmemInit_cb, +}; + +/* ---------------------------------------------------------------- + * DSA lifecycle for the shared sLog DSA area + * ---------------------------------------------------------------- + */ + +/* + * SLogEnsureDsaAttached + * Lazy per-backend DSA attachment. + * + * Must be called before any DSA alloc/free/get_address operations. + * Safe to call multiple times (no-op after first attach). + */ +void +SLogEnsureDsaAttached(void) +{ + MemoryContext oldcxt; + + if (slog_dsa_handle != NULL) + return; /* already attached */ + + if (SLogState == NULL) + return; /* sLog not yet initialized */ + + /* + * Allocate in TopMemoryContext so the dsa_area struct persists across + * transactions. dsa_attach_in_place internally palloc's, so if we're + * in a transaction context, the handle would become a dangling pointer + * after transaction end. + */ + oldcxt = MemoryContextSwitchTo(TopMemoryContext); + slog_dsa_handle = dsa_attach_in_place(SLogState->dsa_space, NULL); + dsa_pin_mapping(slog_dsa_handle); + MemoryContextSwitchTo(oldcxt); +} + +/* ================================================================ + * Transaction sLog functions + * ================================================================ + */ + +/* + * SLogTxnInsert + * Insert an aborted transaction entry into the sLog. + * + * Creates an entry in the ATM radix tree. The tree grows on demand from + * the DSA area, so unlike the old fixed pool this never fails for lack of + * space; the return type is retained for API compatibility and is always + * true. + */ +bool +SLogTxnInsert(TransactionId xid, Oid reloid, Oid dboid, + XLogRecPtr last_batch_lsn) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + uint64 key = slog_atm_key(xid, reloid); + slog_atm_value_t value; + + value.last_batch_lsn = last_batch_lsn; + value.dboid = dboid; + value.abort_time = GetCurrentTimestamp(); + value.revert_complete = false; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); + + /* + * RT_SET returns whether the key already existed. A duplicate insert is + * a no-op on the (xid, reloid) identity; the original entry's data fields + * would be overwritten, so preserve the old behavior of leaving an + * existing entry untouched by finding first. + */ + if (slog_atm_find(tree, key) == NULL) + (void) slog_atm_set(tree, key, &value); + + LWLockRelease(&SLogState->txn_lock.lock); + return true; +} + +/* + * SLogTxnLookup + * Look up a specific (xid, reloid) entry. + * + * Returns true if found, copying the entry into *entry_out. + */ +bool +SLogTxnLookup(TransactionId xid, Oid reloid, SLogTxnEntry *entry_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + uint64 key = slog_atm_key(xid, reloid); + slog_atm_value_t *found; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + found = slog_atm_find(tree, key); + + if (found != NULL && entry_out != NULL) + { + entry_out->xid = xid; + entry_out->reloid = reloid; + entry_out->last_batch_lsn = found->last_batch_lsn; + entry_out->dboid = found->dboid; + entry_out->abort_time = found->abort_time; + entry_out->revert_complete = found->revert_complete; + } + + LWLockRelease(&SLogState->txn_lock.lock); + + return (found != NULL); +} + +/* + * SLogTxnLookupByXid + * Find the UNDO chain for a given xid (any reloid). + * + * The radix tree has no seek-to-key API, so we iterate its entries in + * ascending key order (xid-major) and return the first one whose xid + * matches. Because keys are xid-major we can stop as soon as we pass + * the target xid. The ATM only holds un-reverted aborted transactions, + * so it is small and a full scan is acceptable on this cold path. + */ +bool +SLogTxnLookupByXid(TransactionId xid, XLogRecPtr *lsn_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + bool result = false; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + TransactionId k_xid = slog_atm_key_xid(k); + + if (k_xid < xid) + continue; + if (k_xid > xid) + break; /* passed the target range */ + + if (lsn_out) + *lsn_out = val->last_batch_lsn; + result = true; + break; + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + return result; +} + +/* + * SLogTxnRemove + * Remove a specific (xid, reloid) entry. + */ +void +SLogTxnRemove(TransactionId xid, Oid reloid) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + uint64 key = slog_atm_key(xid, reloid); + + LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); + + (void) slog_atm_delete(tree, key); + + LWLockRelease(&SLogState->txn_lock.lock); +} + +/* + * SLogTxnRemoveByXid + * Remove all sLog entries for a given transaction ID. + * + * Collects all keys for this xid into a local array during an ascending + * iteration (keys are contiguous thanks to the xid-major ordering), then + * deletes them after ending the iteration -- we do not delete while + * iterating. The ATM is small, so the full scan is acceptable. + */ +void +SLogTxnRemoveByXid(TransactionId xid) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + uint64 *to_remove; + int nremove = 0; + int max_remove = 64; + int i; + + to_remove = (uint64 *) palloc(max_remove * sizeof(uint64)); + + LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); + + /* Collect all keys with matching xid */ + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + TransactionId k_xid = slog_atm_key_xid(k); + + (void) val; + if (k_xid < xid) + continue; + if (k_xid > xid) + break; + + if (nremove >= max_remove) + { + max_remove *= 2; + to_remove = (uint64 *) repalloc(to_remove, + max_remove * sizeof(uint64)); + } + to_remove[nremove++] = k; + } + slog_atm_end_iterate(iter); + + /* Remove collected keys */ + for (i = 0; i < nremove; i++) + (void) slog_atm_delete(tree, to_remove[i]); + + LWLockRelease(&SLogState->txn_lock.lock); + + pfree(to_remove); +} + +/* + * SLogTxnMarkReverted + * Mark all entries for a given xid as revert_complete. + * + * Iterates the tree in ascending key order and rewrites the value of each + * entry for this xid with revert_complete = true. Keys for one xid are + * contiguous, so we stop once we pass the target xid. We collect the + * matching (key, value) pairs first and RT_SET them after ending the + * iteration, so the tree is never mutated while an iterator is live. + */ +void +SLogTxnMarkReverted(TransactionId xid) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + uint64 *keys; + slog_atm_value_t *vals; + int n = 0; + int max_n = 64; + int i; + + keys = (uint64 *) palloc(max_n * sizeof(uint64)); + vals = (slog_atm_value_t *) palloc(max_n * sizeof(slog_atm_value_t)); + + LWLockAcquire(&SLogState->txn_lock.lock, LW_EXCLUSIVE); + + /* + * Collect matching entries first, then RT_SET them after ending the + * iteration, to avoid mutating the tree while an iterator is live. + */ + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + TransactionId k_xid = slog_atm_key_xid(k); + + if (k_xid < xid) + continue; + if (k_xid > xid) + break; + + if (n >= max_n) + { + max_n *= 2; + keys = (uint64 *) repalloc(keys, max_n * sizeof(uint64)); + vals = (slog_atm_value_t *) repalloc(vals, + max_n * sizeof(slog_atm_value_t)); + } + keys[n] = k; + vals[n] = *val; + vals[n].revert_complete = true; + n++; + } + slog_atm_end_iterate(iter); + + for (i = 0; i < n; i++) + (void) slog_atm_set(tree, keys[i], &vals[i]); + + LWLockRelease(&SLogState->txn_lock.lock); + + pfree(keys); + pfree(vals); +} + +/* + * SLogTxnGetNextUnreverted + * Find an entry that hasn't been reverted yet. + * + * Iterates the tree in ascending key order (xid-major), returning the + * first entry with revert_complete == false. + */ +bool +SLogTxnGetNextUnreverted(TransactionId *xid_out, Oid *dboid_out, + XLogRecPtr *lsn_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + bool result = false; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + if (!val->revert_complete) + { + *xid_out = slog_atm_key_xid(k); + *dboid_out = val->dboid; + *lsn_out = val->last_batch_lsn; + result = true; + break; + } + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + return result; +} + +/* + * SLogRecoveryFinalize + * Count entries after recovery for logging. + */ +void +SLogRecoveryFinalize(int *total_out, int *unreverted_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + int total = 0; + int unreverted = 0; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + total++; + if (!val->revert_complete) + unreverted++; + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + + if (total_out) + *total_out = total; + if (unreverted_out) + *unreverted_out = unreverted; +} + +/* + * SLogTxnGetOldestUnrevertedLSN + * Return the minimum last_batch_lsn across all unreverted entries. + * + * Used by the WAL retention logic to prevent recycling WAL segments that + * still contain UNDO batches needed by the logical revert worker. + * Returns InvalidXLogRecPtr if no unreverted entries exist. + */ +XLogRecPtr +SLogTxnGetOldestUnrevertedLSN(void) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + XLogRecPtr oldest = InvalidXLogRecPtr; + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + if (!val->revert_complete && + XLogRecPtrIsValid(val->last_batch_lsn)) + { + if (!XLogRecPtrIsValid(oldest) || + val->last_batch_lsn < oldest) + oldest = val->last_batch_lsn; + } + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + return oldest; +} + +/* + * SLogTxnSnapshotForCheckpoint + * Copy every ATM entry into a palloc'd array for durable checkpointing. + * + * Returns the number of entries and stores a palloc'd array of SLogTxnEntry + * in *entries_out (NULL if there are none). The caller owns the array and + * must pfree it. Runs outside any critical section (CheckPointATM), so + * palloc is safe. + */ +int +SLogTxnSnapshotForCheckpoint(SLogTxnEntry **entries_out) +{ + slog_atm_radix_tree *tree = slog_atm_tree(); + slog_atm_iter *iter; + slog_atm_value_t *val; + uint64 k; + SLogTxnEntry *arr = NULL; + int count = 0; + int capacity = 64; + + *entries_out = NULL; + + arr = (SLogTxnEntry *) palloc(sizeof(SLogTxnEntry) * capacity); + + LWLockAcquire(&SLogState->txn_lock.lock, LW_SHARED); + + iter = slog_atm_begin_iterate(tree); + while ((val = slog_atm_iterate_next(iter, &k)) != NULL) + { + if (count >= capacity) + { + capacity *= 2; + arr = (SLogTxnEntry *) repalloc(arr, + sizeof(SLogTxnEntry) * capacity); + } + + arr[count].xid = slog_atm_key_xid(k); + arr[count].reloid = slog_atm_key_reloid(k); + arr[count].last_batch_lsn = val->last_batch_lsn; + arr[count].dboid = val->dboid; + arr[count].abort_time = val->abort_time; + arr[count].revert_complete = val->revert_complete; + count++; + } + slog_atm_end_iterate(iter); + + LWLockRelease(&SLogState->txn_lock.lock); + + if (count == 0) + { + pfree(arr); + return 0; + } + + *entries_out = arr; + return count; +} diff --git a/src/backend/access/undo/slog_flathash.c b/src/backend/access/undo/slog_flathash.c new file mode 100644 index 0000000000000..29a540236066e --- /dev/null +++ b/src/backend/access/undo/slog_flathash.c @@ -0,0 +1,796 @@ +/*------------------------------------------------------------------------- + * + * slog_flathash.c + * Seqlock-protected flat open-addressing hash for sLog tuple tracking. + * + * Implements the flat hash table operations (probe, insert, remove) and + * the apply handler that mutates the single hash copy. The hash uses + * linear probing with a power-of-2 capacity and tombstone markers. A + * per-partition seqlock (in SLogFlatPartition) provides retry-based + * consistent reads; the writer lock serializes mutations. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/slog_flathash.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/slog_flathash.h" +#include "access/transam.h" +#include "common/hashfn.h" +#include "utils/dsa.h" + +/* + * Maximum probe distance before giving up. With load factor < 0.7 and + * power-of-2 sizing, typical probe chains are very short (< 5). + * We cap at 128 to bound worst-case scan time. + */ +#define SLOG_FLAT_MAX_PROBE 128 + +/* ---------------------------------------------------------------- + * Size computation + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashDataSize + * Size of one copy of the flat hash data structure. + */ +Size +SLogFlatHashDataSize(int capacity) +{ + return offsetof(SLogFlatHash, buckets) + + (Size) capacity * sizeof(SLogFlatBucket); +} + +/* + * SLogFlatHashShmemSize + * Shared memory needed for one flat-hash copy (the seqlock guards a + * single copy in place; the sequence counter and writer lock live in + * the SLogFlatPartition struct, not here). + */ +Size +SLogFlatHashShmemSize(int capacity, int max_backends) +{ + (void) max_backends; /* retained for call-site compatibility */ + return MAXALIGN(SLogFlatHashDataSize(capacity)); +} + +/* + * SLogFlatHashPartitionedShmemSize + * Total shared memory needed for all partitions' flat-hash copies. + * + * Each partition gets capacity/N buckets in one copy. The per-partition + * seqlock counter and writer lock are embedded in SLogFlatPartition in the + * SLogSharedState, so only the hash copies are sized here. + */ +Size +SLogFlatHashPartitionedShmemSize(int total_capacity, int max_backends) +{ + int per_partition_capacity; + Size per_partition_size; + Size total; + + per_partition_capacity = total_capacity / SLogNumPartitions; + if (per_partition_capacity < 64) + per_partition_capacity = 64; + + per_partition_size = SLogFlatHashShmemSize(per_partition_capacity, + max_backends); + total = (Size) SLogNumPartitions * MAXALIGN(per_partition_size); + + return total; +} + +/* ---------------------------------------------------------------- + * Initialization + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashInit + * Initialize a flat hash data block (one copy). + * + * Sets all buckets to EMPTY state. + */ +void +SLogFlatHashInit(void *data, int capacity) +{ + SLogFlatHash *ht = (SLogFlatHash *) data; + int i; + + ht->capacity = capacity; + ht->num_entries = 0; + ht->num_tombstones = 0; + ht->padding = 0; + + for (i = 0; i < capacity; i++) + { + ht->buckets[i].hash_val = SLOG_FLAT_EMPTY; + memset(&ht->buckets[i].key, 0, sizeof(SLogTupleKey)); + ht->buckets[i].padding = 0; + ht->buckets[i].entry.nops = 0; + memset(ht->buckets[i].entry.ops, 0, + sizeof(SLogTupleOp) * SLOG_MAX_TUPLE_OPS); + } +} + +/* ---------------------------------------------------------------- + * Hash function + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashComputeHash + * Compute a 32-bit hash for an SLogTupleKey. + * + * The key must have been zeroed before population (for padding bytes). + * Returns a non-zero, non-TOMBSTONE value (adjusts if hash_bytes returns + * 0 or UINT32_MAX). + */ +uint32 +SLogFlatHashComputeHash(const SLogTupleKey *key) +{ + uint32 h; + + h = hash_bytes((const unsigned char *) key, sizeof(SLogTupleKey)); + + /* Ensure we never produce EMPTY or TOMBSTONE values */ + if (h == SLOG_FLAT_EMPTY) + h = 1; + else if (h == SLOG_FLAT_TOMBSTONE) + h = SLOG_FLAT_TOMBSTONE - 1; + + return h; +} + +/* ---------------------------------------------------------------- + * Probe operations + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashProbe + * Look up a key in the flat hash. Returns bucket pointer if found, + * NULL if not present. + * + * Linear probing: start at hash_val % capacity, walk forward skipping + * tombstones, stop at EMPTY (not found) or matching key (found). + */ +SLogFlatBucket * +SLogFlatHashProbe(const SLogFlatHash * ht, const SLogTupleKey *key) +{ + uint32 h; + uint32 idx; + int probe; + + /* + * Fast path: an empty hash cannot contain the key, so skip the key hash + * computation and bucket walk entirely. This lookup sits on the calling + * AM's per-tuple visibility/conflict hot path and the sLog + * (uncommitted-writer tracking) is empty for the vast majority of lookups + * in a committed-data OLTP workload (measured ~4%% of AM CPU under a + * TPROC-C-style workload before this guard). Callers read the hash under + * the partition seqlock, so a concurrent writer's insert (which bumps + * num_entries) is caught by the seqlock retry. + */ + if (ht->num_entries == 0) + return NULL; + + h = SLogFlatHashComputeHash(key); + idx = h & (uint32) (ht->capacity - 1); + + for (probe = 0; probe < SLOG_FLAT_MAX_PROBE; probe++) + { + const SLogFlatBucket *bucket = &ht->buckets[idx]; + + if (bucket->hash_val == SLOG_FLAT_EMPTY) + return NULL; /* definitive miss */ + + if (bucket->hash_val != SLOG_FLAT_TOMBSTONE && + bucket->hash_val == h && + memcmp(&bucket->key, key, sizeof(SLogTupleKey)) == 0) + { + return (SLogFlatBucket *) bucket; /* found */ + } + + idx = (idx + 1) & (uint32) (ht->capacity - 1); + } + + return NULL; /* probe limit exceeded */ +} + +/* + * SLogFlatHashHasOpForXid + * Return true iff the entry for key holds an in-use op for xid. + * + * Used by SLogTupleInsert to confirm an op was actually stored, rather than + * silently dropped because the per-TID ops array was full. Probing the + * bucket alone is insufficient on a hot row, where the bucket pre-exists with + * other markers. + */ +bool +SLogFlatHashHasOpForXid(const SLogFlatHash * ht, const SLogTupleKey *key, + TransactionId xid) +{ + const SLogFlatBucket *bucket = SLogFlatHashProbe(ht, key); + const SLogTupleEntry *entry; + int i; + + if (bucket == NULL) + return false; + + entry = &bucket->entry; + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + TransactionIdEquals(entry->ops[i].xid, xid)) + return true; + } + return false; +} + +/* + * SLogFlatHashProbeForInsert + * Find a slot for inserting a key. Returns the bucket to use. + * + * If the key already exists, returns that bucket (for update-in-place). + * Otherwise returns the first EMPTY or TOMBSTONE slot encountered. + * Returns NULL if probe limit exceeded without finding a slot. + */ +SLogFlatBucket * +SLogFlatHashProbeForInsert(SLogFlatHash * ht, const SLogTupleKey *key, + uint32 hash_val) +{ + uint32 idx; + int probe; + SLogFlatBucket *first_free = NULL; + + idx = hash_val & (uint32) (ht->capacity - 1); + + for (probe = 0; probe < SLOG_FLAT_MAX_PROBE; probe++) + { + SLogFlatBucket *bucket = &ht->buckets[idx]; + + if (bucket->hash_val == SLOG_FLAT_EMPTY) + { + /* Definitive miss — use first_free if we found one, else this */ + return first_free ? first_free : bucket; + } + + if (bucket->hash_val == SLOG_FLAT_TOMBSTONE) + { + /* Remember first tombstone for potential reuse */ + if (first_free == NULL) + first_free = bucket; + } + else if (bucket->hash_val == hash_val && + memcmp(&bucket->key, key, sizeof(SLogTupleKey)) == 0) + { + /* Key already exists */ + return bucket; + } + + idx = (idx + 1) & (uint32) (ht->capacity - 1); + } + + /* Probe limit exceeded; return first_free if available */ + return first_free; +} + +/* ---------------------------------------------------------------- + * Apply handler (single-copy, under the seqlock writer) + * ---------------------------------------------------------------- + */ + +/* + * flat_hash_apply_insert + * Apply an INSERT operation: find/create entry, add op to slot. + */ +static void +flat_hash_apply_insert(SLogFlatHash * ht, const SLogFlatOp * op) +{ + uint32 h; + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + bool existing; + + h = SLogFlatHashComputeHash(&op->key); + bucket = SLogFlatHashProbeForInsert(ht, &op->key, h); + + if (bucket == NULL) + { + elog(WARNING, "SLOG_LOST_OP table_full relid=%u xid=%u op=%d", + op->key.relid, op->tuple_op.xid, (int) op->tuple_op.op_type); + return; /* table full, operation lost */ + } + + /* Determine if this is an existing entry */ + existing = (bucket->hash_val != SLOG_FLAT_EMPTY && + bucket->hash_val != SLOG_FLAT_TOMBSTONE); + + if (!existing) + { + /* New entry */ + if (bucket->hash_val == SLOG_FLAT_TOMBSTONE) + ht->num_tombstones--; + + bucket->hash_val = h; + memcpy(&bucket->key, &op->key, sizeof(SLogTupleKey)); + bucket->entry.nops = 0; + memset(&bucket->entry.key, 0, sizeof(SLogTupleKey)); + memcpy(&bucket->entry.key, &op->key, sizeof(SLogTupleKey)); + memset(bucket->entry.ops, 0, sizeof(bucket->entry.ops)); + ht->num_entries++; + } + + entry = &bucket->entry; + + /* Check if this xid already has an op (overwrite) */ + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + entry->ops[i].xid == op->tuple_op.xid) + { + /* Overwrite existing op for same xid */ + memcpy(&entry->ops[i], &op->tuple_op, sizeof(SLogTupleOp)); + return; + } + } + + /* Find a free slot */ + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + { + memcpy(&entry->ops[i], &op->tuple_op, sizeof(SLogTupleOp)); + entry->nops++; + return; + } + } + + /* + * No free slot — reclaim the oldest horizon-eligible retained UPDATE + * marker. + * + * Hot rows (e.g. a TPC-C district) accumulate one retained UPDATE marker + * per committed transaction. Under WS-PVS3 committed UPDATE markers are + * removed at commit (flat_hash_apply_commit_xid), so in steady state the + * array only fills with in-progress writers or WS-PVS3 stragglers; this + * path drains the latter. + */ + { + int oldest_idx = -1; + TransactionId reclaim_xid_horizon = op->reclaim_xid_horizon; + + /* + * Take the first horizon-eligible UPDATE marker. A marker is + * reclaimable once its xid precedes the oldest active snapshot's xmin + * (reclaim_xid_horizon): at that point the xid's outcome is settled and + * visible to (or irrelevant to) every live snapshot, so no residual + * consumer can still need it (WS-PVS3 moved the write-write conflict + * probe and MVCC read to the durable UNDO fork chain). An in-progress + * xid can never precede this horizon, so this test alone never frees a + * marker a concurrent reader or writer still needs. Every marker that + * passes the horizon predicate is equally reclaimable, so we take the + * first one found. + * + * An INVALID reclaim_xid_horizon disables reclamation entirely: every + * marker is treated as non-reclaimable and we fall through to + * oldest_idx == -1. This is the fail-safe direction and is exactly what + * the caller relies on -- SLogTupleInsert passes InvalidTransactionId on + * the fast path (skipping the ProcArrayLock horizon scan) and only + * supplies a real, freshly-computed horizon on the full-array retry. A + * missing horizon must never make markers look reclaimable, or a live + * reader's / in-progress writer's op could be freed (lost update / MVCC + * corruption). + * + * The xid horizon is the authoritative, self-healing reclaim gate: a + * below-horizon marker is reclaimable whether or not it was ever + * committed in CLOG. + */ + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + if (entry->ops[i].op_type != SLOG_OP_UPDATE) + continue; + if (!TransactionIdIsValid(entry->ops[i].xid)) + continue; + if (!TransactionIdIsValid(reclaim_xid_horizon) || + !TransactionIdPrecedes(entry->ops[i].xid, reclaim_xid_horizon)) + continue; /* invalid horizon disables reclaim (fail-safe); + * else still visible-relevant to an active + * snapshot, or in-progress */ + oldest_idx = i; + break; + } + + if (oldest_idx >= 0) + { + /* Safe to reclaim — no active snapshot needs this entry */ + entry->ops[oldest_idx].in_use = false; + entry->nops--; + + /* Now insert the new op in the freed slot */ + memcpy(&entry->ops[oldest_idx], &op->tuple_op, sizeof(SLogTupleOp)); + entry->nops++; + return; + } + } + + /* + * No reclaimable slot: every one of the SLOG_MAX_TUPLE_OPS slots holds an + * UPDATE whose xid is still at/above the reclaim horizon (in-progress or + * very recently committed) or a non-UPDATE marker. With the xid-horizon + * reclaim above this requires SLOG_MAX_TUPLE_OPS concurrent unsettled + * writers on a single TID, which is not reachable under normal load. The + * caller (SLogTupleInsert) detects the drop via SLogFlatHashHasOpForXid and + * falls back to local-only tracking + UNDO replay, so this is safe but + * worth surfacing. + */ + elog(WARNING, "SLOG_LOST_OP ops_full relid=%u xid=%u op=%d nops=%d horizon=%u", + op->key.relid, op->tuple_op.xid, (int) op->tuple_op.op_type, + entry->nops, op->reclaim_xid_horizon); +} + +/* + * flat_hash_apply_remove_xid + * Remove all ops for a given xid from an entry. + * Remove the entry entirely if nops reaches 0. + */ +static void +flat_hash_apply_remove_xid(SLogFlatHash * ht, const SLogFlatOp * op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + entry->ops[i].xid == op->xid) + { + entry->ops[i].in_use = false; + entry->nops--; + } + } + + if (entry->nops == 0) + { + bucket->hash_val = SLOG_FLAT_TOMBSTONE; + ht->num_entries--; + ht->num_tombstones++; + } +} + +/* + * flat_hash_apply_remove_entry + * Remove an entire entry (tombstone it). + */ +static void +flat_hash_apply_remove_entry(SLogFlatHash * ht, const SLogFlatOp * op) +{ + SLogFlatBucket *bucket; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + bucket->hash_val = SLOG_FLAT_TOMBSTONE; + ht->num_entries--; + ht->num_tombstones++; +} + +/* + * flat_hash_apply_mark_aborted + * Mark all ops for a given xid as SLOG_OP_ABORTED. + */ +static void +flat_hash_apply_mark_aborted(SLogFlatHash * ht, const SLogFlatOp * op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + entry->ops[i].xid == op->xid) + { + entry->ops[i].op_type = SLOG_OP_ABORTED; + } + } +} + +/* + * flat_hash_apply_update_op + * Update a specific op slot in place (e.g. re-parent a subxid). + */ +static void +flat_hash_apply_update_op(SLogFlatHash * ht, const SLogFlatOp * op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + entry->ops[i].xid == op->xid) + { + /* + * Apply selective updates from the op. We use the tuple_op + * fields as the source of truth for what to update. + */ + if (op->subxid != InvalidTransactionId) + { + /* Re-parent subxid */ + if (entry->ops[i].subxid == op->tuple_op.subxid) + entry->ops[i].subxid = op->subxid; + } + break; + } + } +} + +/* + * flat_hash_apply_commit_xid + * Remove every op belonging to xid. + * + * WS-PVS3 Phase 2: committed UPDATE markers are no longer retained here. + * The write-write conflict probe now reads the head verptr on the tuple + * and resolves it in the durable UNDO fork (via the AM's version-reconstruction + * walk), and the shared before-image was dropped in Phase 1. With no consumer + * for a retained marker, an UPDATE at commit is treated like INSERT/DELETE/LOCK: + * removed. This drains bucket table_full pressure. + */ +static void +flat_hash_apply_commit_xid(SLogFlatHash * ht, const SLogFlatOp * op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + if (entry->ops[i].xid != op->xid) + continue; + + entry->ops[i].in_use = false; + entry->nops--; + } + + if (entry->nops == 0) + { + bucket->hash_val = SLOG_FLAT_TOMBSTONE; + ht->num_entries--; + ht->num_tombstones++; + } +} + +/* + * flat_hash_apply_cleanup_retained + * Remove retained committed UPDATE markers whose committing xid + * precedes the reclaim xid horizon (op->reclaim_xid_horizon). + * + * Gates on the xid horizon -- NOT an HLC threshold -- so a marker is + * reclaimed only when its committing xid precedes every live snapshot. + * The write-write conflict probe and MVCC read now use the durable UNDO + * fork chain, not this hash, so retained UPDATE markers here are dead + * bookkeeping under WS-PVS3; this sweep still applies for any leftover + * entries not removed at commit. + */ +static void +flat_hash_apply_cleanup_retained(SLogFlatHash * ht, const SLogFlatOp * op) +{ + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + + bucket = SLogFlatHashProbe(ht, &op->key); + if (bucket == NULL) + return; + + entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + if (entry->ops[i].op_type != SLOG_OP_UPDATE) + continue; + if (!TransactionIdIsValid(entry->ops[i].xid)) + continue; + if (TransactionIdIsValid(op->reclaim_xid_horizon) && + !TransactionIdPrecedes(entry->ops[i].xid, + op->reclaim_xid_horizon)) + continue; + + /* + * Expired retained entry. Gated on the xid horizon alone -- a marker + * committed in CLOG or an aborted/in-flight one is drained the moment + * its xid precedes the horizon, so no slot is pinned forever. Must + * match the read-side eligibility scan in SLogTupleCleanupRetained. + */ + entry->ops[i].in_use = false; + entry->nops--; + } + + if (entry->nops == 0) + { + bucket->hash_val = SLOG_FLAT_TOMBSTONE; + ht->num_entries--; + ht->num_tombstones++; + } +} + +/* + * flat_hash_apply_create_aborted + * Create a new entry with an ABORTED op (for local-only INSERT abort). + */ +static void +flat_hash_apply_create_aborted(SLogFlatHash * ht, const SLogFlatOp * op) +{ + uint32 h; + SLogFlatBucket *bucket; + SLogTupleEntry *entry; + int i; + bool existing; + + h = SLogFlatHashComputeHash(&op->key); + bucket = SLogFlatHashProbeForInsert(ht, &op->key, h); + + if (bucket == NULL) + return; /* table full */ + + existing = (bucket->hash_val != SLOG_FLAT_EMPTY && + bucket->hash_val != SLOG_FLAT_TOMBSTONE); + + if (!existing) + { + if (bucket->hash_val == SLOG_FLAT_TOMBSTONE) + ht->num_tombstones--; + + bucket->hash_val = h; + memcpy(&bucket->key, &op->key, sizeof(SLogTupleKey)); + bucket->entry.nops = 0; + memset(&bucket->entry.key, 0, sizeof(SLogTupleKey)); + memcpy(&bucket->entry.key, &op->key, sizeof(SLogTupleKey)); + memset(bucket->entry.ops, 0, sizeof(bucket->entry.ops)); + ht->num_entries++; + } + + entry = &bucket->entry; + + /* Find a free slot for the ABORTED entry */ + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + { + entry->ops[i].xid = op->xid; + entry->ops[i].subxid = op->subxid; + entry->ops[i].op_type = SLOG_OP_ABORTED; + entry->ops[i].in_use = true; + entry->ops[i].commit_ts = 0; + entry->ops[i].spec_token = 0; + entry->ops[i].cid = InvalidCommandId; + entry->nops++; + return; + } + } + /* No free slot — operation lost */ +} + +/* + * SLogFlatHashApply + * Apply one op to the flat hash. Dispatches to operation-specific handlers. + */ +void +SLogFlatHashApply(void *data, const void *operation, Size op_size) +{ + SLogFlatHash *ht = (SLogFlatHash *) data; + const SLogFlatOp *op = (const SLogFlatOp *) operation; + + Assert(op_size == sizeof(SLogFlatOp)); + + switch (op->kind) + { + case SLOG_FLAT_OP_INSERT: + flat_hash_apply_insert(ht, op); + break; + case SLOG_FLAT_OP_REMOVE_XID: + flat_hash_apply_remove_xid(ht, op); + break; + case SLOG_FLAT_OP_REMOVE_ENTRY: + flat_hash_apply_remove_entry(ht, op); + break; + case SLOG_FLAT_OP_MARK_ABORTED: + flat_hash_apply_mark_aborted(ht, op); + break; + case SLOG_FLAT_OP_UPDATE_OP: + flat_hash_apply_update_op(ht, op); + break; + case SLOG_FLAT_OP_COMMIT_XID: + flat_hash_apply_commit_xid(ht, op); + break; + case SLOG_FLAT_OP_CLEANUP_RETAINED: + flat_hash_apply_cleanup_retained(ht, op); + break; + case SLOG_FLAT_OP_CREATE_ABORTED: + flat_hash_apply_create_aborted(ht, op); + break; + } +} + +/* ---------------------------------------------------------------- + * Scan API + * ---------------------------------------------------------------- + */ + +/* + * SLogFlatHashScanInit + * Initialize a sequential scan over the flat hash. + */ +void +SLogFlatHashScanInit(SLogFlatHashScanState * state) +{ + state->current_index = 0; +} + +/* + * SLogFlatHashScanNext + * Return the next occupied bucket, or NULL when the scan is complete. + * + * Skips EMPTY and TOMBSTONE slots. The returned pointer is valid only + * within the current seqlock read section (or under the writer lock). + */ +const SLogFlatBucket * +SLogFlatHashScanNext(const SLogFlatHash * ht, SLogFlatHashScanState * state) +{ + while (state->current_index < ht->capacity) + { + const SLogFlatBucket *bucket = &ht->buckets[state->current_index]; + + state->current_index++; + + if (bucket->hash_val != SLOG_FLAT_EMPTY && + bucket->hash_val != SLOG_FLAT_TOMBSTONE) + { + return bucket; + } + } + + return NULL; +} diff --git a/src/backend/access/undo/slog_tuple.c b/src/backend/access/undo/slog_tuple.c new file mode 100644 index 0000000000000..6b9b640d26ca0 --- /dev/null +++ b/src/backend/access/undo/slog_tuple.c @@ -0,0 +1,2966 @@ +/*------------------------------------------------------------------------- + * + * slog_tuple.c + * Tuple sLog -- optional per-tuple UNDO tracking extension + * + * The tuple sLog is an OPTIONAL extension of the sLog subsystem (slog.c). + * It provides bounded-recovery-time uncommitted-writer tracking for in-place + * MVCC table access methods: a flat, partitioned, seqlock-guarded hash of + * in-flight per-tuple operations, keyed by (relid, tid). Because the + * uncommitted writers of every hot tuple are recorded in shared memory, + * recovery and cross-backend visibility need not scan an unbounded UNDO + * chain to decide whether a given physical tuple version is visible; the + * hash answers "is there an in-flight/aborted writer of this tuple?" in + * O(1), which is what bounds recovery and read cost. + * + * It is OPTIONAL: an access method opts in by registering an + * SLogAmDescriptor (SLogRegisterAmDescriptor), which supplies the AM's + * policy (e.g. the maximum before-image size it will stash). The UNDO core + * and the transaction sLog (the Aborted Transaction Map in slog.c) do not + * require this file; a build with no in-place-MVCC AM never calls into it. + * + * The flat hash and its shared-memory partitions are carved from the same + * shared segment as the transaction sLog (see slog.c's SLogShmemInit); the + * two facilities share only that segment and its initialization, never each + * other's data structures. + * + * WAL-free: entries are transient, removed at commit/abort. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/slog_tuple.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#ifdef WIN32 +#include +#endif + +#include "access/relundo.h" +#include "access/slog.h" +#include "access/slog_flathash.h" +#include "access/slog_internal.h" +#include "access/transam.h" +#include "access/xact.h" +#include "common/hashfn.h" +#include "nodes/lockoptions.h" +#include "miscadmin.h" +#include "storage/lock.h" +#include "storage/lwlock.h" +#include "storage/off.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/shmem.h" +#include "storage/spin.h" +#include "utils/dsa.h" +#include "utils/memutils.h" +#include "utils/snapmgr.h" +#include "utils/timestamp.h" + + +/* + * slog_num_cpus - portable replacement for sysconf(_SC_NPROCESSORS_ONLN). + * + * Returns the number of online CPUs (or a sane positive fallback if the + * platform refuses to answer). Used to auto-size the sLog flat-hash + * partition count when the GUC is left at zero. + */ +static int +slog_num_cpus(void) +{ +#ifdef WIN32 + SYSTEM_INFO si; + + GetSystemInfo(&si); + if (si.dwNumberOfProcessors > 0) + return (int) si.dwNumberOfProcessors; + return 4; +#else + long n = sysconf(_SC_NPROCESSORS_ONLN); + + if (n <= 0) + return 4; + return (int) n; +#endif +} + + +/* + * Maximum number of abort ops applied to a partition between seqlock + * begin/end cycles in SLogTupleMarkAborted / SLogTupleCleanupRetained. + * + * The seqlock keeps its counter odd for the whole write cycle, so any + * concurrent wait-free reader spins until the counter turns even. A very + * large rollback (hundreds of thousands of tuples applied in one cycle) + * would pin readers spinning for the entire batch. Ending and re-beginning + * the seqlock every SLOG_ABORT_PUBLISH_BATCH ops bounds that odd-hold + * window; the partition writer_lock is held across the whole function, so + * dropping to even at a batch boundary leaves the partition consistent for + * any reader that observes it. + */ +#define SLOG_ABORT_PUBLISH_BATCH 24 + + +/* + * Wall-clock sampling period for the self-clocking throttles in + * SLogTupleInsert() and SLogTupleMaybeCleanupRetained(). GetCurrentTimestamp() + * is a clock_gettime() syscall that dominates the per-tuple hot path, so we + * read it once every SLOG_INSERT_CLOCK_PERIOD calls and reuse the cached + * horizon in between. Must be a power of two (callers mask with PERIOD - 1). + */ +#define SLOG_INSERT_CLOCK_PERIOD 256 + + +/* + * SLogTupleNumEntries + * Calculate the number of hash entries for the tuple sLog. + * + * Auto-sizing formula: MaxBackends * 1024, clamped to [4096, 4194304]. + */ +int +SLogTupleNumEntries(void) +{ + int n = MaxBackends * SLOG_TUPLE_PER_BACKEND_SLOTS; + + n = Max(n, SLOG_TUPLE_MIN_ENTRIES); + n = Min(n, SLOG_TUPLE_MAX_ENTRIES); + return n; +} + + + +/* + * GUC: slog_num_partitions — number of flat hash partitions. + * 0 = auto (heuristic: 4 Ɨ NumCPUs, clamped [16..256], power of 2). + * Set at postmaster startup, immutable thereafter. + */ +int slog_num_partitions = 0; + +/* Runtime partition count (set once during SLogShmemInit, read everywhere) */ +int SLogNumPartitions = SLOG_FLAT_DEFAULT_PARTITIONS; + +/* + * Compute the effective partition count from the GUC value. + * Called once during SLogShmemInit. + */ +static int +SLogComputeNumPartitions(void) +{ + int n; + + if (slog_num_partitions > 0) + { + /* Explicit GUC value — clamp and round to power of 2 */ + n = slog_num_partitions; + } + else + { + /* Auto-size: 4 x number of CPUs */ + int ncpus = slog_num_cpus(); + + n = ncpus * 4; + } + + /* Clamp */ + n = Max(n, SLOG_FLAT_MIN_PARTITIONS); + n = Min(n, SLOG_FLAT_MAX_PARTITIONS); + + /* Round up to next power of 2 (for fast modulo via bitmask) */ + { + int p = 1; + + while (p < n) + p <<= 1; + n = p; + } + + return n; +} + +/* + * Flat hash capacity: round up SLogTupleNumEntries to next power of 2, + * then divide by 0.7 (max load factor) to ensure probe chains stay short. + */ +static inline int +SLogFlatHashCapacity(void) +{ + int n = SLogTupleNumEntries(); + int cap; + + /* Target: num_entries / capacity <= 0.7, so capacity >= n / 0.7 */ + cap = (int) ((double) n / 0.7) + 1; + + /* Round up to next power of 2 */ + cap--; + cap |= cap >> 1; + cap |= cap >> 2; + cap |= cap >> 4; + cap |= cap >> 8; + cap |= cap >> 16; + cap++; + + /* Clamp to reasonable bounds */ + if (cap < 2048) + cap = 2048; + if (cap > 2 * 1048576) + cap = 2 * 1048576; + + return cap; +} + +/* ---------------------------------------------------------------- + * Static variables (tuple sLog) + * ---------------------------------------------------------------- + */ + +/* Pointer to ShmemAlloc'd flat-hash region, set by ShmemRequestStruct framework */ +static char *SLogFlatHashBlock = NULL; /* single allocation for all partition flat-hash copies */ + +/* Rate-limiting for sLog overflow warnings (per-backend) */ +static int slog_overflow_warning_count = 0; +static TimestampTz slog_overflow_last_warning = 0; + + +/* ---------------------------------------------------------------- + * Backend-private tracking for tuple sLog cleanup + * ---------------------------------------------------------------- + */ +typedef struct SLogTrackedKey +{ + SLogTupleKey key; + TransactionId xid; + TransactionId subxid; + bool local_only; /* no shared hash entry (INSERT-only) */ + SLogOpType op_type; /* DML type (for commit retention decisions) */ + + /* Before-image for savepoint rollback (NULL if not applicable) */ + char *before_image; /* palloc'd copy of tuple data, or NULL */ + int before_image_len; /* length of before_image data */ + uint16 before_flags; /* original t_flags before DML */ + uint64 before_commit_ts; /* original t_commit_ts before DML */ + + /* + * Physical relation locator captured at store time. Savepoint-abort + * restore runs in TRANS_ABORT state where relation_open's relcache lookup + * is unsafe, so we read the buffer via ReadBufferWithoutRelcache instead. + */ + RelFileLocator before_rlocator; + char before_relpersistence; + + struct SLogTrackedKey *next; +} SLogTrackedKey; + +static SLogTrackedKey *slog_tracked_keys = NULL; +static bool slog_has_shared_entries = false; /* any non-local_only entries? */ + +/* ---------------------------------------------------------------- + * TID encoding for backend-local INSERT tracking + * + * Each (blkno, offnum) pair maps to a dense 64-bit key used by the + * backend-local INSERT-tracking hash (see below): + * + * blkno * MaxOffsetNumber + (offnum - 1) + * + * Losslessness invariant: this key is in-memory and per-backend only -- + * never written to WAL or disk -- so the divisor need only be strictly + * greater than any real offnum a tracked page can hold. MaxOffsetNumber + * is the maximum number of line pointers any page can hold and is an + * AM-agnostic upper bound: offnum <= MaxOffsetNumber always holds, so the + * divisor never aliases two pages' TIDs onto the same key. A check at each + * encode site enforces this; an out-of-range offset is a corruption bug, + * not a recoverable condition, so it raises ERROR even in production builds. + * ---------------------------------------------------------------- + */ +#define SLOG_ENCODE_TID(blkno, offnum) \ + ((uint64) (blkno) * MaxOffsetNumber + (uint64) ((offnum) - 1)) +#define SLOG_DECODE_BLKNO(encoded) \ + ((BlockNumber) ((encoded) / MaxOffsetNumber)) +#define SLOG_DECODE_OFFNUM(encoded) \ + ((OffsetNumber) ((encoded) % MaxOffsetNumber + 1)) + +/* ---------------------------------------------------------------- + * Backend-local INSERT tracking (simplehash) + * + * Top-level local-only INSERTs are recorded in a single backend-local + * open-addressing hash keyed by (relid, encoded_tid). This replaces the + * former per-relid linked-list-of-sparsemaps, which forced an O(n) chunk + * scan on every per-tuple visibility probe. The hash lives in + * TopTransactionContext and is destroyed per transaction. No locks: it is + * strictly backend-private. + * ---------------------------------------------------------------- + */ +typedef struct SLogInsertTidKey +{ + Oid relid; + uint64 encoded_tid; /* SLOG_ENCODE_TID(blkno, offnum) */ +} SLogInsertTidKey; + +typedef struct SLogInsertTidEntry +{ + SLogInsertTidKey key; /* (relid, encoded_tid) */ + char status; /* required by simplehash */ +} SLogInsertTidEntry; + +static inline uint32 +slog_insert_tid_hash(Oid relid, uint64 encoded_tid) +{ + uint64 h = hash_combine64((uint64) hash_uint32((uint32) relid), + murmurhash64(encoded_tid)); + + return (uint32) (h ^ (h >> 32)); +} + +#define SH_PREFIX sloginsert +#define SH_ELEMENT_TYPE SLogInsertTidEntry +#define SH_KEY_TYPE SLogInsertTidKey +#define SH_KEY key +#define SH_HASH_KEY(tb, key) slog_insert_tid_hash((key).relid, (key).encoded_tid) +#define SH_EQUAL(tb, a, b) ((a).relid == (b).relid && (a).encoded_tid == (b).encoded_tid) +#define SH_SCOPE static inline +#define SH_DEFINE +#define SH_DECLARE +#include "lib/simplehash.h" + +static sloginsert_hash *slog_insert_tids = NULL; + +/* + * Lazily create the backend-local INSERT hash in TopTransactionContext. + * Returns the (now non-NULL) table. + */ +static inline sloginsert_hash * +slog_insert_tids_ensure(void) +{ + if (slog_insert_tids == NULL) + slog_insert_tids = sloginsert_create(TopTransactionContext, 256, NULL); + return slog_insert_tids; +} + +/* ---------------------------------------------------------------- + * Internal helpers + * ---------------------------------------------------------------- + */ + +/* + * Partition accessor helpers for the tuple sLog. + * + * These inline functions encapsulate the key→partition routing so that + * callers don't need to repeat the pattern. + */ +static inline SLogFlatPartition * +SLogGetPartition(const SLogTupleKey *key) +{ + int part = SLogFlatHashPartitionIndex(key); + + return &SLogState->tuple_partitions[part]; +} + +static inline SLogFlatPartition * +SLogGetPartitionByIndex(int part) +{ + Assert(part >= 0 && part < SLogNumPartitions); + return &SLogState->tuple_partitions[part]; +} + +/* + * Convenience macros for partition-routed locking. + * + * Most functions have a local `key` variable of type SLogTupleKey and need + * to route to the correct partition. These macros minimize the diff from + * the old single-lock code. The `fp__` variable is defined in-scope by + * SLOG_PART_READ_BEGIN / SLOG_PART_WRITE_BEGIN. + */ +#define SLOG_PART_WRITER_LOCK(key_ptr) \ + (&SLogGetPartition(key_ptr)->writer_lock.lock) + +/* + * SLogPartApplyOne + * Single-op write helper: acquire writer_lock, one seqlock cycle, one + * apply, release. Covers the many write sites that apply exactly one + * SLogFlatOp to fp->hash. + */ +static inline void +SLogPartApplyOne(SLogFlatPartition * fp, const SLogFlatOp *op) +{ + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogSeqWriteBegin(fp); + SLogFlatHashApply(fp->hash, op, sizeof(*op)); + SLogSeqWriteEnd(fp); + LWLockRelease(&fp->writer_lock.lock); +} + +/* ---------------------------------------------------------------- + * Per-AM descriptor (opt-in policy) + * + * An access method that wants tuple-sLog tracking registers a descriptor + * once, at startup (before shared memory is used). The descriptor carries + * only DATA -- policy resolved once, never a per-op callback on the hot + * read/write path -- so it costs nothing on the tuple probe/insert fast + * path. Its sole live knob today is before_image_max, the cap on the + * backend-local before-image the AM stashes for savepoint rollback. + * + * ponytail: data-descriptor only, resolved once at registration; NO per-op + * callback on the hot path. An opaque per-op payload and a pluggable key + * type are deferred until a second in-place-MVCC AM exists (YAGNI). + * ---------------------------------------------------------------- + */ +static SLogAmDescriptor slog_am_desc = { + .before_image_max = 0, /* 0 = no AM registered / before-images disabled */ +}; + +/* + * SLogRegisterAmDescriptor + * Record the opting-in AM's tuple-sLog policy. + * + * Called once at startup. Copies the caller's descriptor by value. + */ +void +SLogRegisterAmDescriptor(const SLogAmDescriptor *desc) +{ + Assert(desc != NULL); + slog_am_desc = *desc; +} + +/* ---------------------------------------------------------------- + * Tuple sLog shared-memory sizing and initialization + * + * Called from slog.c's SLogShmemSize/Request/Init so the tuple flat-hash + * partitions share the sLog's shared segment. Keeping the tuple-specific + * shmem code here (rather than in slog.c) keeps slog.c free of any + * flat-hash/partition knowledge. + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleShmemSize + * Additional shared memory needed for the tuple flat-hash partitions. + */ +Size +SLogTupleShmemSize(void) +{ + return SLogFlatHashPartitionedShmemSize(SLogFlatHashCapacity(), MaxBackends); +} + +/* + * SLogTupleShmemRequest + * Register the tuple flat-hash partition block. + */ +void +SLogTupleShmemRequest(void) +{ + /* Compute partition count early so shmem sizing is correct */ + SLogNumPartitions = SLogComputeNumPartitions(); + + ShmemRequestStruct(.name = "sLog Flat Hash Partitions", + .size = SLogFlatHashPartitionedShmemSize( + SLogFlatHashCapacity(), MaxBackends), + .ptr = (void **) &SLogFlatHashBlock, + ); +} + +/* + * SLogTupleShmemInit + * Allocate and initialize the tuple flat-hash partitions. + * + * Invoked from SLogShmemInit() after the shared state struct exists. Runs + * unconditionally (whether or not an AM has opted in) so the two sLog files + * share one segment with no init-ordering dependency; an idle flat hash + * costs only its shared-memory footprint. + */ +void +SLogTupleShmemInit(void) +{ + int total_capacity; + int per_part_cap; + Size per_part_shmem_size; + char *block_ptr; + int part; + + /* ---- Compute and set partition count (once, globally) ---- */ + SLogNumPartitions = SLogComputeNumPartitions(); + SLogState->num_partitions = SLogNumPartitions; + + /* Allocate partition array in shared memory (after SLogState) */ + SLogState->tuple_partitions = (SLogFlatPartition *) + ShmemAlloc(sizeof(SLogFlatPartition) * SLogNumPartitions); + memset(SLogState->tuple_partitions, 0, + sizeof(SLogFlatPartition) * SLogNumPartitions); + + ereport(LOG, + (errmsg("sLog: %d flat hash partitions (slog_num_partitions=%d, CPUs=%d)", + SLogNumPartitions, slog_num_partitions, + slog_num_cpus()))); + + /* ---- Initialize partitioned seqlock flat hashes ---- */ + total_capacity = SLogFlatHashCapacity(); + per_part_cap = total_capacity / SLogNumPartitions; + if (per_part_cap < 64) + per_part_cap = 64; + + per_part_shmem_size = MAXALIGN(SLogFlatHashShmemSize(per_part_cap, + MaxBackends)); + block_ptr = SLogFlatHashBlock; + + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = &SLogState->tuple_partitions[part]; + + /* Initialize per-partition writer lock */ + LWLockInitialize(&fp->writer_lock.lock, LWTRANCHE_SLOG); + + /* Seqlock counter starts even (stable, no writer in progress) */ + SeqLockInit(&fp->seqlock); + + /* Single flat-hash copy carved from the shared block */ + fp->hash = (SLogFlatHash *) block_ptr; + SLogFlatHashInit(fp->hash, per_part_cap); + + block_ptr += per_part_shmem_size; + } +} + +/* ================================================================ + * Tuple sLog functions + * ================================================================ + */ + +/* ---------------------------------------------------------------- + * Emergency eviction + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleEvictCommitted -- evict entries for committed transactions. + * + * Called when the sLog flat hash is full. Scans each partition under its + * writer lock to collect evictable keys, then applies REMOVE_ENTRY ops. + * (This is a cold path, run only when a partition is full; holding the + * writer lock across the scan is simpler than a seqlock retry loop and the + * per-op TransactionIdIsInProgress/DidCommit probes must not run repeatedly.) + * + * Returns the number of entries evicted. + */ +static int +SLogTupleEvictCommitted(void) +{ + SLogTupleKey *keys_to_evict; + int nkeys = 0; + int max_evict = 1024; + int part; + + keys_to_evict = (SLogTupleKey *) + palloc(sizeof(SLogTupleKey) * max_evict); + + /* Phase 1: scan each partition under its writer lock to collect keys */ + for (part = 0; part < SLogNumPartitions && nkeys < max_evict; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + SLogFlatHashScanState scan; + const SLogFlatBucket *bucket; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + SLogFlatHashScanInit(&scan); + while ((bucket = SLogFlatHashScanNext(fp->hash, &scan)) != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + bool all_committed = true; + bool has_any_op = false; + int i; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + has_any_op = true; + + if (TransactionIdIsInProgress(entry->ops[i].xid) || + !TransactionIdDidCommit(entry->ops[i].xid)) + { + all_committed = false; + break; + } + } + + if (has_any_op && all_committed) + { + keys_to_evict[nkeys++] = bucket->key; + if (nkeys >= max_evict) + break; + } + } + + LWLockRelease(&fp->writer_lock.lock); + } + + /* Phase 2: apply removals grouped by partition */ + if (nkeys > 0) + { + int i; + + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + bool has_keys_for_part = false; + + /* Check if any keys belong to this partition */ + for (i = 0; i < nkeys; i++) + { + if (SLogFlatHashPartitionIndex(&keys_to_evict[i]) == part) + { + has_keys_for_part = true; + break; + } + } + if (!has_keys_for_part) + continue; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogSeqWriteBegin(fp); + + for (i = 0; i < nkeys; i++) + { + SLogFlatOp flat_op; + + if (SLogFlatHashPartitionIndex(&keys_to_evict[i]) != part) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_REMOVE_ENTRY; + flat_op.key = keys_to_evict[i]; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + } + + SLogSeqWriteEnd(fp); + LWLockRelease(&fp->writer_lock.lock); + } + } + + pfree(keys_to_evict); + + return nkeys; +} + +/* ---------------------------------------------------------------- + * Core Tuple sLog API + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleInsert + * Record a tuple operation in the sLog. + * + * Inserts into the seqlock-protected flat hash (wait-free reads). + * Performs overflow handling before failing. + * + * Also adds the key to the backend-private tracking list for cleanup. + */ +bool +SLogTupleInsert(Oid relid, ItemPointer tid, TransactionId xid, + SLogOpType op_type, TransactionId subxid, + CommandId cid, TimestampTz commit_ts, + uint32 spec_token, LockTupleMode lock_mode) +{ + SLogTupleKey key; + SLogFlatOp flat_op; + SLogFlatPartition *fp; + int entries_before; + int entries_after; + + Assert(SLogState != NULL); + Assert(TransactionIdIsValid(xid)); + Assert(ItemPointerIsValid(tid)); + + /* Zero for deterministic hashing (ItemPointerData is 6 bytes) */ + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + /* + * Retained UPDATE markers are never evicted by the on-overflow path -- + * SLogTupleEvictCommitted() leaves any op whose xid is in-progress or not + * yet committed in CLOG. They are cleaned up by SLogTupleCleanupRetained(), + * driven by the UNDO background worker or, when it is disabled, by the + * access method calling SLogTupleMaybeCleanupRetained() outside any + * buffer-locked section. This function never triggers that sweep itself + * (see note below). + * + * Per-TID ops array overflow (SLOG_MAX_TUPLE_OPS=32 slots all full of + * retained entries on hot rows) is handled by flat_hash_apply_insert() + * which reclaims the oldest retained entry when no free slot exists. + */ + + { + /* Build the flat hash op */ + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_INSERT; + flat_op.key = key; + flat_op.xid = xid; + flat_op.subxid = subxid; + + /* + * The xid reclaim horizon is computed LAZILY, not on every insert. It + * is only consumed by flat_hash_apply_insert to reclaim a slot when a + * hot row's per-TID ops array is completely full; the common path (free + * slot, same-xid overwrite, or a slot freed by coalescing) never reads + * it. Computing it here would run a ProcArrayLock-shared xid-horizon + * scan on every CAS-update -- a measured hot-path cost -- for a value + * used only in the rare full-array case. + * + * So the fast path passes InvalidTransactionId, which now fail-safe + * DISABLES reclamation (see flat_hash_apply_insert): an invalid horizon + * frees NOTHING. This preserves the original safety invariant -- a + * stale or absent horizon must never cause a lost update -- in the most + * conservative direction possible. If the op is then dropped because + * the array was genuinely full, the caller (below) recomputes the real, + * authoritative horizon and retries; reclamation is thus attempted with + * a fresh horizon exactly when, and only when, it is actually needed. + */ + flat_op.reclaim_xid_horizon = InvalidTransactionId; + flat_op.tuple_op.xid = xid; + flat_op.tuple_op.subxid = subxid; + flat_op.tuple_op.op_type = op_type; + flat_op.tuple_op.cid = cid; + flat_op.tuple_op.commit_ts = commit_ts; + flat_op.tuple_op.spec_token = spec_token; + flat_op.tuple_op.lock_mode = lock_mode; + flat_op.tuple_op.in_use = true; + } + + /* Apply to flat hash via seqlock writer path (partition-local) */ + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + /* Check capacity before insert (writer lock held: fp->hash is stable) */ + entries_before = fp->hash->num_entries; + + SLogSeqWriteBegin(fp); + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + SLogSeqWriteEnd(fp); + + /* Check if insert succeeded */ + entries_after = fp->hash->num_entries; + + LWLockRelease(&fp->writer_lock.lock); + + /* + * Verify the op was actually stored. We must probe for THIS xid's op, not + * merely the bucket: on a hot row the bucket pre-exists (it holds other + * TIDs' / xids' markers), so num_entries is unchanged and the bucket is + * present even when flat_hash_apply_insert silently dropped our op because + * the per-TID ops array was full with nothing reclaimable. Testing only + * bucket presence (the old SLogFlatHashProbe != NULL) reports success for a + * dropped op, so no marker exists to stamp at PRE_COMMIT and the next + * concurrent writer clobbers this update -- a lost update. Probe the ops + * array for our xid instead. + */ + if (entries_after == entries_before) + { + bool op_stored; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + op_stored = SLogFlatHashHasOpForXid(fp->hash, &key, xid); + LWLockRelease(&fp->writer_lock.lock); + + if (!op_stored) + { + /* + * The op was dropped: the per-TID ops array was full and the fast + * path passed an invalid horizon, which disables reclamation. On a + * hot row the array is full of THIS relation's own below-horizon + * UPDATE markers, which the reclaim path can free WITHOUT the + * cross-partition SLogTupleEvictCommitted() sweep. So recompute the + * real, authoritative horizon now (the ProcArrayLock scan we skipped + * on the fast path) and retry: flat_hash_apply_insert can now + * reclaim a settled marker and store our op in place. + */ + TransactionId horizon = GetOldestNonRemovableTransactionId(NULL); + + flat_op.reclaim_xid_horizon = horizon; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogSeqWriteBegin(fp); + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + SLogSeqWriteEnd(fp); + op_stored = SLogFlatHashHasOpForXid(fp->hash, &key, xid); + LWLockRelease(&fp->writer_lock.lock); + } + + if (!op_stored) + { + /* Table or per-TID array was full — try eviction */ + int evicted = SLogTupleEvictCommitted(); + + if (evicted > 0) + { + /* Retry */ + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogSeqWriteBegin(fp); + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + SLogSeqWriteEnd(fp); + op_stored = SLogFlatHashHasOpForXid(fp->hash, &key, xid); + LWLockRelease(&fp->writer_lock.lock); + } + + if (!op_stored) + { + slog_overflow_warning_count++; + { + TimestampTz now = GetCurrentTimestamp(); + + if (slog_overflow_warning_count == 1 || + TimestampDifferenceExceeds(slog_overflow_last_warning, + now, 1000)) + { + elog(WARNING, "sLog tuple hash partition full " + "(%d entries); %d overflow(s) this transaction " + "on rel %u (visibility relies on UNCOMMITTED " + "flag + UNDO replay)", + SLogTupleNumEntries() / SLogNumPartitions, + slog_overflow_warning_count, relid); + slog_overflow_last_warning = now; + } + } + + SLogTupleTrackLocalOnly(relid, tid, xid, subxid); + return false; + } + } + } + + SLogTupleTrackKey(key, xid, subxid, op_type); + return true; +} + +/* + * SLogTupleInsertRecovery + * Record a tuple operation during WAL replay (recovery-safe). + * + * This is a simplified variant of SLogTupleInsert() designed for use during + * WAL redo on hot standbys. Key differences: + * - Does NOT call SLogTupleTrackKey() (no backend-local tracking needed) + * - Returns false silently if the hash is full (instead of ERROR/PANIC) + * - No retries with pg_usleep (would delay WAL replay) + * + * Used by an AM's WAL redo path to register UNCOMMITTED tuples in the + * per-tuple sLog so that the AM's visibility check can correctly determine + * visibility on standbys (where the sLog is otherwise never populated). + */ +bool +SLogTupleInsertRecovery(Oid relid, ItemPointer tid, TransactionId xid, + SLogOpType op_type) +{ + SLogTupleKey key; + SLogFlatOp clear_op; + SLogFlatOp flat_op; + + if (SLogState == NULL) + return false; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + /* + * Clear any pre-existing entry at this exact TID before registering the + * new op. A physical INSERT always targets a free line pointer, so any + * sLog entry already present at this TID belongs to a dead prior occupant + * (deleted and VACUUM-recycled). On the primary the prior occupant's sLog + * entry is removed by its commit/abort xact callback, but the standby redo + * path only ever inserts -- it never removes -- so without this the stale + * entry survives. flat_hash_apply_insert() only overwrites a same-xid op, + * so a stale op from the prior occupant's (different) xid would persist and + * make the AM's visibility check trip on TransactionIdDidAbort()/IsInProgress() + * for that dead xid, wrongly hiding the freshly inserted live tuple. + * + * Tombstoning the entry is safe and self-healing: flat_hash_apply_insert() + * reuses tombstoned buckets, so the immediately following INSERT recreates + * a clean entry holding only this insert's op. + */ + memset(&clear_op, 0, sizeof(clear_op)); + clear_op.kind = SLOG_FLAT_OP_REMOVE_ENTRY; + clear_op.key = key; + + /* Apply to flat hash */ + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_INSERT; + flat_op.key = key; + flat_op.xid = xid; + flat_op.tuple_op.xid = xid; + flat_op.tuple_op.subxid = InvalidTransactionId; + flat_op.tuple_op.op_type = op_type; + flat_op.tuple_op.cid = InvalidCommandId; + flat_op.tuple_op.commit_ts = 0; + flat_op.tuple_op.spec_token = 0; + flat_op.tuple_op.in_use = true; + + LWLockAcquire(SLOG_PART_WRITER_LOCK(&key), LW_EXCLUSIVE); + SLogSeqWriteBegin(SLogGetPartition(&key)); + SLogFlatHashApply(SLogGetPartition(&key)->hash, &clear_op, sizeof(clear_op)); + SLogFlatHashApply(SLogGetPartition(&key)->hash, &flat_op, sizeof(flat_op)); + SLogSeqWriteEnd(SLogGetPartition(&key)); + LWLockRelease(SLOG_PART_WRITER_LOCK(&key)); + + return true; +} + +/* + * SLogTupleLookup + * Look up a tuple's sLog entry (copy semantics). + * + * Returns true if found, copying the full entry into *entry_out. + * WAIT-FREE: uses the seqlock read-side retry loop. + */ +bool +SLogTupleLookup(Oid relid, ItemPointer tid, SLogTupleEntry *entry_out) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + bool found; + uint32 slog_seq_; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp, slog_seq_) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + found = (bucket != NULL); + if (found && entry_out) + memcpy(entry_out, &bucket->entry, sizeof(SLogTupleEntry)); + } + SLOG_SEQ_READ_END(fp, slog_seq_); + + return found; +} + +/* + * SLogTupleLookupFiltered + * Find sLog entries for a TID, optionally filtered by xid. + * + * WAIT-FREE: uses the seqlock read-side. If xid_filter is valid, returns + * only ops for that xid. If InvalidTransactionId, returns all active + * ops for this TID. + * + * Returns the number of ops written to ops_out. + */ +int +SLogTupleLookupFiltered(Oid relid, ItemPointer tid, + TransactionId xid_filter, + SLogTupleOp *ops_out, int max_ops) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + int nfound = 0; + uint32 slog_seq_; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp, slog_seq_) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + nfound = 0; /* reset: body may re-run on retry */ + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + int i; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS && nfound < max_ops; i++) + { + if (!entry->ops[i].in_use) + continue; + + if (TransactionIdIsValid(xid_filter) && + !TransactionIdEquals(entry->ops[i].xid, xid_filter)) + continue; + + memcpy(&ops_out[nfound], &entry->ops[i], sizeof(SLogTupleOp)); + nfound++; + } + } + } + SLOG_SEQ_READ_END(fp, slog_seq_); + + return nfound; +} + +/* + * SLogTupleRemove + * Remove operations for a specific xid from a tuple entry. + * + * Uses the seqlock writer path with external (writer_lock) serialization. + */ +void +SLogTupleRemove(Oid relid, ItemPointer tid, TransactionId xid) +{ + SLogTupleKey key; + SLogFlatOp op; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + memset(&op, 0, sizeof(op)); + op.kind = SLOG_FLAT_OP_REMOVE_XID; + op.key = key; + op.xid = xid; + + SLogPartApplyOne(SLogGetPartition(&key), &op); +} + +/* + * Width of the touched-partition bitmap, in uint64 words. Sized to cover + * SLOG_FLAT_MAX_PARTITIONS so the bitmap is a fixed on-stack array regardless + * of the runtime partition count. + */ +#define SLOG_PART_BITMAP_WORDS ((SLOG_FLAT_MAX_PARTITIONS + 63) / 64) + +/* + * SLogCollectTrackedPartitions + * Build a bitmap of the partitions touched by xid's shared tracked keys. + * + * Walks slog_tracked_keys once, setting one bit per distinct partition that + * holds a non-local_only entry for xid. Callers then iterate only the set + * partitions instead of sweeping all SLogNumPartitions, turning the batch + * apply from O(num_partitions * tracked_keys) into + * O(touched_partitions * tracked_keys) -- optimal for the common single-row, + * single-partition transaction. + * + * Returns the number of distinct partitions marked. + */ +static int +SLogCollectTrackedPartitions(TransactionId xid, + uint64 *bitmap) +{ + SLogTrackedKey *tk; + int nparts = 0; + + memset(bitmap, 0, sizeof(uint64) * SLOG_PART_BITMAP_WORDS); + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + int part; + uint64 mask; + + if (!TransactionIdEquals(tk->xid, xid) || tk->local_only) + continue; + + part = SLogFlatHashPartitionIndex(&tk->key); + mask = UINT64CONST(1) << (part & 63); + if (!(bitmap[part >> 6] & mask)) + { + bitmap[part >> 6] |= mask; + nparts++; + } + } + + return nparts; +} + +/* + * SLogTupleRemoveByXid + * Remove all tuple sLog entries for a transaction. + * + * Uses the backend-local tracking list. Applies REMOVE_XID ops to the + * flat hash in a single writer batch. + */ +void +SLogTupleRemoveByXid(TransactionId xid) +{ + SLogTrackedKey *tk; + uint64 touched[SLOG_PART_BITMAP_WORDS] = {0}; + int part; + + if (SLogState == NULL) + return; + + /* + * Collect the partitions xid actually touches in one pass. Walking only + * those avoids an O(num_partitions * tracked_keys) sweep over every + * partition for what is usually a single-row transaction. + */ + if (SLogCollectTrackedPartitions(xid, touched) == 0) + return; + + /* Batch apply REMOVE_XID ops grouped by partition */ + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp; + + if (!(touched[part >> 6] & (UINT64CONST(1) << (part & 63)))) + continue; + + fp = SLogGetPartitionByIndex(part); + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogSeqWriteBegin(fp); + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + SLogFlatOp flat_op; + + if (!TransactionIdEquals(tk->xid, xid) || tk->local_only) + continue; + if (SLogFlatHashPartitionIndex(&tk->key) != part) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_REMOVE_XID; + flat_op.key = tk->key; + flat_op.xid = xid; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + } + + SLogSeqWriteEnd(fp); + LWLockRelease(&fp->writer_lock.lock); + } +} + +/* + * SLogTupleCommitByXid + * Handle commit for tuple sLog: remove ALL of the committing xid's ops + * (INSERT/DELETE/LOCK and UPDATE alike). + * + * WS-PVS3: committed-UPDATE markers are NO LONGER retained on the flat hash. + * Snapshot-isolation readers reconstruct the visible version by walking the + * durable UNDO fork chain (via the AM's version-reconstruction walk), so at + * commit every op of the xid is removed -- identical to INSERT/DELETE/LOCK -- + * which also drains bucket table_full pressure. (The backend-local + * before-image kept by SLogTupleStoreBeforeImage remains for + * intra-transaction savepoint rollback.) + * + * Uses the backend-local tracking list. Applies COMMIT_XID ops to the + * flat hash in batch. + */ +void +SLogTupleCommitByXid(TransactionId xid) +{ + SLogTrackedKey *tk; + uint64 touched[SLOG_PART_BITMAP_WORDS] = {0}; + + if (SLogState == NULL) + return; + + /* Fast path: INSERT-only transactions never touch the shared hash */ + if (!slog_has_shared_entries) + return; + + /* + * Collect the partitions xid touches in one pass; the COMMIT_XID batch + * below iterates only those rather than sweeping all SLogNumPartitions. + */ + if (SLogCollectTrackedPartitions(xid, touched) == 0) + return; + + /* + * WS-PVS3: committed-UPDATE cross-backend before-images are not published + * to shared DSA (Phase 1) and committed-UPDATE markers are no longer + * retained on the flat hash (Phase 2). Snapshot-isolation readers + * reconstruct the visible version by walking the durable UNDO fork + * chain (via the AM's version-reconstruction walk); the write-write + * conflict probe reads the head verptr on the on-page tuple and resolves + * it in the same fork. + * + * flat_hash_apply_commit_xid therefore removes every op of xid at commit + * -- identical to INSERT/DELETE/LOCK -- which drains bucket table_full. + * + * The local tk->before_image (palloc'd in TopTransactionContext by + * SLogTupleStoreBeforeImage) is still used for intra-transaction + * savepoint rollback via the AM's before-image restore and is unaffected. + */ + + /* Batch apply COMMIT_XID ops grouped by partition */ + { + int part; + + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp; + + if (!(touched[part >> 6] & (UINT64CONST(1) << (part & 63)))) + continue; + + fp = SLogGetPartitionByIndex(part); + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + SLogSeqWriteBegin(fp); + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + SLogFlatOp flat_op; + + if (!TransactionIdEquals(tk->xid, xid) || tk->local_only) + continue; + if (SLogFlatHashPartitionIndex(&tk->key) != part) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_COMMIT_XID; + flat_op.key = tk->key; + flat_op.xid = xid; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + } + + SLogSeqWriteEnd(fp); + LWLockRelease(&fp->writer_lock.lock); + } + } +} + +/* + * SLogTupleRemoveByXidSingle + * Remove the sLog entry for a single tuple identified by (relid, tid, xid). + * + * Used by an AM's two-phase postcommit callback to clean up sLog entries + * one at a time (since the local tracking list is unavailable in the + * resolving backend). + */ +void +SLogTupleRemoveByXidSingle(Oid relid, ItemPointer tid, TransactionId xid) +{ + SLogTupleKey key; + SLogFlatOp flat_op; + + if (SLogState == NULL) + return; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + /* Apply to flat hash */ + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_REMOVE_XID; + flat_op.key = key; + flat_op.xid = xid; + + SLogPartApplyOne(SLogGetPartition(&key), &flat_op); +} + +/* + * SLogTupleMarkAbortedSingle + * Mark the sLog entry for a single tuple as ABORTED. + * + * Used by an AM's two-phase postabort callback to mark sLog entries + * one at a time (since the local tracking list is unavailable in the + * resolving backend). Only operates on tuples that already have a shared + * sLog entry (DELETE/UPDATE operations). + */ +void +SLogTupleMarkAbortedSingle(Oid relid, ItemPointer tid, TransactionId xid) +{ + SLogTupleKey key; + SLogFlatOp flat_op; + + if (SLogState == NULL) + return; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + /* Apply to flat hash */ + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_MARK_ABORTED; + flat_op.key = key; + flat_op.xid = xid; + + SLogPartApplyOne(SLogGetPartition(&key), &flat_op); +} + +/* + * SLogTupleRemoveBySubXid + * Handle subtransaction abort for tuple sLog. + * + * For entries that have a shared sLog entry, marks matching ops as ABORTED. + * For entries that only have local tracking (from SLogTupleTrackLocalOnly, + * used by INSERT), creates a shared ABORTED entry so visibility code can + * find it. + */ +void +SLogTupleRemoveBySubXid(TransactionId xid, TransactionId subxid) +{ + SLogTrackedKey *tk; + + if (SLogState == NULL) + return; + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + SLogFlatOp flat_op; + + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (tk->subxid != subxid) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + + if (tk->local_only) + { + /* + * INSERT elision: no shared entry exists yet. Create one with + * SLOG_OP_ABORTED so visibility code can find it. + */ + flat_op.kind = SLOG_FLAT_OP_CREATE_ABORTED; + flat_op.key = tk->key; + flat_op.xid = xid; + flat_op.subxid = subxid; + } + else + { + /* Shared entry exists -- mark matching ops ABORTED */ + flat_op.kind = SLOG_FLAT_OP_MARK_ABORTED; + flat_op.key = tk->key; + flat_op.xid = xid; + } + + LWLockAcquire(SLOG_PART_WRITER_LOCK(&tk->key), LW_EXCLUSIVE); + SLogSeqWriteBegin(SLogGetPartition(&tk->key)); + SLogFlatHashApply(SLogGetPartition(&tk->key)->hash, &flat_op, + sizeof(flat_op)); + SLogSeqWriteEnd(SLogGetPartition(&tk->key)); + LWLockRelease(SLOG_PART_WRITER_LOCK(&tk->key)); + + /* + * Mark the backend-local tracked key as aborted so the AM's commit-time + * flag clearing skips it. Without this, a local-only INSERT tracked key + * still reads as a live INSERT at top-level commit, and the AM's + * uncommitted-flag clearing would clear the tuple's UNCOMMITTED flag and + * stamp a commit marker -- resurrecting a tuple that the savepoint + * rollback was supposed to discard. + */ + tk->op_type = SLOG_OP_ABORTED; + } +} + +/* + * SLogTupleUpdateSubXid + * Re-parent ops on subtransaction commit. + * + * When a subtransaction commits, its entries' subxid is updated to the + * parent's subxid so they survive subtransaction commit but are cleaned + * up at top-level commit. + */ +void +SLogTupleUpdateSubXid(TransactionId xid, + TransactionId old_subxid, + TransactionId new_subxid) +{ + SLogTrackedKey *tk; + + if (SLogState == NULL) + return; + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (tk->subxid != old_subxid) + continue; + + /* Re-parent the local entry */ + tk->subxid = new_subxid; + + /* Also re-parent in the shared sLog if an entry exists */ + if (!tk->local_only) + { + SLogFlatOp flat_op; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_UPDATE_OP; + flat_op.key = tk->key; + flat_op.xid = xid; + flat_op.subxid = new_subxid; + flat_op.tuple_op.subxid = old_subxid; + + LWLockAcquire(SLOG_PART_WRITER_LOCK(&tk->key), LW_EXCLUSIVE); + SLogSeqWriteBegin(SLogGetPartition(&tk->key)); + SLogFlatHashApply(SLogGetPartition(&tk->key)->hash, &flat_op, + sizeof(flat_op)); + SLogSeqWriteEnd(SLogGetPartition(&tk->key)); + LWLockRelease(SLOG_PART_WRITER_LOCK(&tk->key)); + } + } +} + +/* + * SLogTupleMarkAborted + * Mark all ops for a transaction as SLOG_OP_ABORTED. + * + * Called at transaction abort. Entries remain so visibility checks can + * distinguish "committed (no entry)" from "aborted (UNDO pending)". + * + * For local-only entries (INSERT elision), we CREATE a shared ABORTED + * entry at abort time. This is safe because: + * (a) Abort is uncommon (vast majority of transactions commit) + * (b) The UNDO worker removes both the sLog entry and the page tuple, + * bounding the lifetime of these entries + * (c) If the hash is full, we log a warning; the UNDO worker will + * eventually remove the tuple physically, resolving the anomaly + */ +void +SLogTupleMarkAborted(TransactionId xid) +{ + SLogTrackedKey *tk; + int part; + int ops_since_publish; + + if (SLogState == NULL) + return; + + /* + * Process all entries grouped by partition. For each partition, acquire + * its writer lock once, apply all relevant ops, then release. + */ + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + bool has_entries = false; + + /* Quick check: any local-only INSERT entries at all? */ + if (slog_insert_tids != NULL && slog_insert_tids->members > 0) + has_entries = true; /* conservative; filtered per-entry below */ + + /* Check linked-list entries */ + if (!has_entries) + { + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (SLogFlatHashPartitionIndex(&tk->key) == part) + { + has_entries = true; + break; + } + } + } + + if (!has_entries && + (slog_insert_tids == NULL || slog_insert_tids->members == 0)) + continue; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + SLogSeqWriteBegin(fp); + + /* + * Bound the seqlock odd-hold window on a very large rollback + * (hundreds of thousands of tuples). While seq is odd every + * wait-free reader of this partition spins; ending and re-beginning + * the seqlock every SLOG_ABORT_PUBLISH_BATCH ops lets those readers + * make progress. Correctness across the boundary: each tuple's + * abort visibility is resolved independently and each seq cycle + * leaves fp->hash consistent, so a reader that observes the even + * counter at a boundary sees a valid partial state. + */ + ops_since_publish = 0; + + /* Process backend-local INSERT-hash entries for this partition */ + if (slog_insert_tids != NULL) + { + sloginsert_iterator it; + SLogInsertTidEntry *ie; + + sloginsert_start_iterate(slog_insert_tids, &it); + while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) + { + SLogFlatOp flat_op; + SLogTupleKey smkey; + + memset(&smkey, 0, sizeof(smkey)); + smkey.relid = ie->key.relid; + ItemPointerSet(&smkey.tid, + SLOG_DECODE_BLKNO(ie->key.encoded_tid), + SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); + + /* Only process if this key belongs to current partition */ + if (SLogFlatHashPartitionIndex(&smkey) == part) + { + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_CREATE_ABORTED; + flat_op.key = smkey; + flat_op.xid = xid; + flat_op.subxid = InvalidTransactionId; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + + if (++ops_since_publish >= SLOG_ABORT_PUBLISH_BATCH) + { + SLogSeqWriteEnd(fp); + SLogSeqWriteBegin(fp); + ops_since_publish = 0; + } + } + } + } + + /* Process linked-list entries for this partition */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + SLogFlatOp flat_op; + + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (SLogFlatHashPartitionIndex(&tk->key) != part) + continue; + + memset(&flat_op, 0, sizeof(flat_op)); + if (tk->local_only) + { + flat_op.kind = SLOG_FLAT_OP_CREATE_ABORTED; + flat_op.key = tk->key; + flat_op.xid = xid; + flat_op.subxid = tk->subxid; + } + else + { + flat_op.kind = SLOG_FLAT_OP_MARK_ABORTED; + flat_op.key = tk->key; + flat_op.xid = xid; + } + + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + + if (++ops_since_publish >= SLOG_ABORT_PUBLISH_BATCH) + { + SLogSeqWriteEnd(fp); + SLogSeqWriteBegin(fp); + ops_since_publish = 0; + } + } + + SLogSeqWriteEnd(fp); + + LWLockRelease(&fp->writer_lock.lock); + } +} + +/* + * SLogTupleRemoveByXidGlobal + * Remove ALL ops for a transaction by scanning the shared flat hash. + * + * Unlike SLogTupleRemoveByXid, this does not use the backend-local tracking + * list (which doesn't exist in the UNDO worker process). Used by the UNDO + * worker to clean up ABORTED entries after UNDO has been applied. + */ +void +SLogTupleRemoveByXidGlobal(TransactionId xid) +{ + SLogTupleKey *collected_keys; + int max_keys; + int part; + + if (SLogState == NULL) + return; + + max_keys = SLogTupleNumEntries(); + if (max_keys <= 0) + return; + + collected_keys = (SLogTupleKey *) + palloc(sizeof(SLogTupleKey) * max_keys); + + /* + * Process each partition while holding its exclusive writer lock. The + * writer lock serializes all writers for the partition, so the single + * copy is stable while we scan it. We apply REMOVE_XID inside one seqlock + * cycle. + */ + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + SLogFlatHashScanState scan; + const SLogFlatBucket *bucket; + int nkeys = 0; + int i; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + /* Scan the single copy; stable because we hold the writer lock. */ + SLogFlatHashScanInit(&scan); + while ((bucket = SLogFlatHashScanNext(fp->hash, &scan)) != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (entry->ops[i].in_use && + TransactionIdEquals(entry->ops[i].xid, xid)) + { + if (nkeys < max_keys) + collected_keys[nkeys++] = bucket->key; + break; + } + } + } + + /* Drop the slots in one seq cycle. */ + if (nkeys > 0) + { + SLogSeqWriteBegin(fp); + for (i = 0; i < nkeys; i++) + { + SLogFlatOp flat_op; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_REMOVE_XID; + flat_op.key = collected_keys[i]; + flat_op.xid = xid; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + } + SLogSeqWriteEnd(fp); + } + + LWLockRelease(&fp->writer_lock.lock); + } + + pfree(collected_keys); +} + +/* + * SLogTupleIterateByTid + * Call a callback for each active operation on a tuple. + * + * WAIT-FREE: uses the seqlock read-side. The in-use ops are copied into a + * local array under the seqlock retry loop; the callback then runs after a + * consistent read, so it may have side effects and receives pointers into + * the local copy (valid for the duration of this call). + */ +void +SLogTupleIterateByTid(Oid relid, ItemPointer tid, + SLogTupleIterCallback callback, void *arg) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nops = 0; + uint32 slog_seq_; + int i; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp, slog_seq_) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + nops = 0; /* reset: body may re-run on retry */ + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (entry->ops[i].in_use) + ops[nops++] = entry->ops[i]; + } + } + SLOG_SEQ_READ_END(fp, slog_seq_); + + /* Consistent read complete; run the (side-effecting) callback. */ + for (i = 0; i < nops; i++) + if (!callback(&ops[i], arg)) + break; +} + +/* ---------------------------------------------------------------- + * Convenience wrappers + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleHasEntry -- quick probe: does ANY active entry exist for this TID? + * WAIT-FREE: uses the seqlock read-side. + */ +bool +SLogTupleHasEntry(Oid relid, ItemPointer tid) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + bool has_entry = false; + uint32 slog_seq_; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp, slog_seq_) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + has_entry = (bucket != NULL && bucket->entry.nops > 0); + } + SLOG_SEQ_READ_END(fp, slog_seq_); + + return has_entry; +} + +/* + * SLogTupleIsInsertedByMe -- check if current transaction inserted this tuple. + * + * Checks both the shared hash (normal case) and the backend-local tracking + * list (for local-only INSERTs that have no shared entry). + * + * Uses the top-level XID because SLogTupleTrackLocalOnly() always stores + * GetTopTransactionId(). This ensures correct results even when called + * from within a subtransaction (savepoint). + */ +bool +SLogTupleIsInsertedByMe(Oid relid, ItemPointer tid) +{ + SLogTupleOp op; + int nfound; + SLogTrackedKey *tk; + TransactionId myxid = GetTopTransactionIdIfAny(); + + if (!TransactionIdIsValid(myxid)) + return false; + + /* + * Special-marker TIDs (SpecTokenOffsetNumber 0xFFFE for speculative + * insertions, MovedPartitionsOffsetNumber 0xFFFD for cross-partition + * moves) can appear in a tuple's on-page t_ctid. All sLog entries are + * keyed by an on-page (block, offnum) with offnum <= MaxOffsetNumber, + * so this backend cannot have "inserted" the tuple under such a key. + * Bail out early to avoid tripping the encode-guard elog(ERROR) below. + */ + if (ItemPointerGetOffsetNumber(tid) > MaxOffsetNumber) + return false; + + /* Check shared hash first (normal case) */ + nfound = SLogTupleLookupFiltered(relid, tid, myxid, &op, 1); + if (nfound > 0 && op.op_type == SLOG_OP_INSERT) + return true; + + /* + * Check the backend-local INSERT hash (top-level local-only INSERTs). + */ + { + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + SLogInsertTidKey ikey; + + if (offnum > MaxOffsetNumber) + elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", + offnum, MaxOffsetNumber); + ikey.relid = relid; + ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); + if (slog_insert_tids != NULL && + sloginsert_lookup(slog_insert_tids, ikey) != NULL) + return true; + } + + /* + * Fall back to backend-local tracking list. This handles the overflow + * case where SLogTupleInsert returned false (hash full) but the INSERT + * was tracked locally via SLogTupleTrackLocalOnly or SLogTupleTrackKey. + * Also handles subtransaction local-only entries (which still use the + * linked list). + */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, myxid)) + continue; + if (tk->key.relid != relid) + continue; + if (ItemPointerEquals(&tk->key.tid, tid)) + return true; + } + + return false; +} + +/* + * SLogTupleIsDeletedByMe -- check if current transaction deleted this tuple. + */ +bool +SLogTupleIsDeletedByMe(Oid relid, ItemPointer tid) +{ + SLogTupleOp op; + int nfound; + TransactionId myxid = GetCurrentTransactionIdIfAny(); + + if (!TransactionIdIsValid(myxid)) + return false; + + nfound = SLogTupleLookupFiltered(relid, tid, myxid, &op, 1); + return (nfound > 0 && + (op.op_type == SLOG_OP_DELETE || + op.op_type == SLOG_OP_UPDATE)); +} + +/* + * SLogTupleGetDirtyXid -- for SNAPSHOT_DIRTY, get the xid of the in-progress + * transaction operating on this tuple. + * + * Returns the xid of the first in-progress INSERT or DELETE/UPDATE entry + * found (excluding our own), or InvalidTransactionId if none. + * + * WAIT-FREE: uses the seqlock read-side. The ops are snapshotted into a + * local array inside the retry loop; the (LWLock-taking) transaction-status + * checks then run after a consistent read, never inside the retry loop. + */ +TransactionId +SLogTupleGetDirtyXid(Oid relid, ItemPointer tid, bool *is_insert) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nops = 0; + TransactionId result = InvalidTransactionId; + uint32 slog_seq_; + int i; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp, slog_seq_) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + nops = 0; /* reset: body may re-run on retry */ + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (entry->ops[i].in_use) + ops[nops++] = entry->ops[i]; + } + } + SLOG_SEQ_READ_END(fp, slog_seq_); + + /* Consistent snapshot taken; resolve status outside the retry loop. */ + for (i = 0; i < nops; i++) + { + TransactionId xid = ops[i].xid; + SLogOpType op = ops[i].op_type; + + if (TransactionIdIsCurrentTransactionId(xid)) + continue; + if (!TransactionIdIsInProgress(xid)) + continue; + + if (is_insert) + *is_insert = (op == SLOG_OP_INSERT); + result = xid; + break; + } + + return result; +} + +/* + * SLogTupleGetDirtyWriterXid -- like SLogTupleGetDirtyXid, but returns only + * the xid of an in-progress *writer* (INSERT/UPDATE/DELETE), ignoring + * lock-only markers (LOCK_SHARE/LOCK_EXCL) and aborted markers. + * + * Used at the write-wait decision in the UPDATE/DELETE paths. An updater + * already holds the heavyweight LOCKTAG_TUPLE lock (LockTupleNoKeyExclusive -> + * ExclusiveLock), which serializes against conflicting lockers via the + * standard lock matrix: a key-share locker (AccessShareLock) is compatible and + * does not block, while a share/exclusive locker conflicts and blocks the + * updater on the lock manager. XactLockTableWait must therefore fire only for + * an actual in-progress writer -- never for a pure locker. Waiting on a + * locker's xid here while that locker is queued behind us for the same tuple + * ExclusiveLock manufactures a deadlock cycle that heap avoids via + * HEAP_XMAX_IS_LOCKED_ONLY. + * + * WAIT-FREE: uses the seqlock read-side, identical to SLogTupleGetDirtyXid. + */ +TransactionId +SLogTupleGetDirtyWriterXid(Oid relid, ItemPointer tid, bool *is_insert) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nops = 0; + TransactionId result = InvalidTransactionId; + uint32 slog_seq_; + int i; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp, slog_seq_) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + nops = 0; /* reset: body may re-run on retry */ + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (entry->ops[i].in_use) + ops[nops++] = entry->ops[i]; + } + } + SLOG_SEQ_READ_END(fp, slog_seq_); + + /* Consistent snapshot taken; resolve status outside the retry loop. */ + for (i = 0; i < nops; i++) + { + TransactionId xid; + SLogOpType op = ops[i].op_type; + + /* Only real writers block another writer. */ + if (op != SLOG_OP_INSERT && + op != SLOG_OP_UPDATE && + op != SLOG_OP_DELETE) + continue; + + xid = ops[i].xid; + + if (TransactionIdIsCurrentTransactionId(xid)) + continue; + if (!TransactionIdIsInProgress(xid)) + continue; + + if (is_insert) + *is_insert = (op == SLOG_OP_INSERT); + result = xid; + break; + } + + return result; +} + +/* + * slog_tuplock_to_lockmode -- map a LockTupleMode to its heavyweight LOCKMODE. + * + * The four tuple-lock strengths MUST map to four distinct LOCKMODEs so the + * real conflict matrix (DoLockModesConflict) can distinguish a compatible + * KeyShare FK locker from a conflicting Share/Exclusive locker. + */ +static LOCKMODE +slog_tuplock_to_lockmode(LockTupleMode mode) +{ + switch (mode) + { + case LockTupleKeyShare: + return AccessShareLock; + case LockTupleShare: + return RowShareLock; + case LockTupleNoKeyExclusive: + return ExclusiveLock; + case LockTupleExclusive: + return AccessExclusiveLock; + } + elog(ERROR, "invalid tuple lock mode: %d", (int) mode); + return NoLock; /* keep compiler quiet */ +} + +/* + * SLogTupleGetWriteConflictXid -- find an in-progress transaction whose marker + * conflicts with a writer (UPDATE/DELETE) acquiring tuple lock my_mode. + * + * Unlike SLogTupleGetDirtyWriterXid (which only ever reports writers and + * silently ignores lock-only markers), this also reports a *locker* whose + * recorded LockTupleMode conflicts with my_mode under the standard heavyweight + * matrix. That is required for correctness: a SELECT ... FOR UPDATE locker + * leaves only a LOCK_EXCL marker (no on-page writer state), and an updater that + * consults a writer-only probe sails past it and clobbers the row the locker is + * protecting. The four-way mapping keeps a KeyShare FK locker (AccessShareLock) + * compatible with a NoKeyExclusive UPDATE (ExclusiveLock) while making FOR SHARE + * (RowShareLock) and FOR UPDATE (AccessExclusiveLock) correctly block it. + * + * Writers take priority over lockers in the returned xid so the caller's + * is_insert handling (TM_Invisible for an in-progress INSERT) is preserved; a + * conflicting locker is returned only when no in-progress writer exists. + * + * Returns the conflicting xid, or InvalidTransactionId if none. *is_insert is + * set true only when the returned xid is an in-progress INSERT writer. + * + * WAIT-FREE: uses the seqlock read-side, identical to SLogTupleGetDirtyWriterXid. + */ +TransactionId +SLogTupleGetWriteConflictXid(Oid relid, ItemPointer tid, + LockTupleMode my_mode, bool *is_insert) +{ + SLogTupleKey key; + SLogFlatPartition *fp; + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nops = 0; + TransactionId writer_xid = InvalidTransactionId; + bool writer_is_insert = false; + TransactionId locker_xid = InvalidTransactionId; + LOCKMODE my_lockmode = slog_tuplock_to_lockmode(my_mode); + uint32 slog_seq_; + int i; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + fp = SLogGetPartition(&key); + + SLOG_SEQ_READ_BEGIN(fp, slog_seq_) + { + const SLogFlatBucket *bucket = SLogFlatHashProbe(fp->hash, &key); + + nops = 0; /* reset: body may re-run on retry */ + if (bucket != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + if (entry->ops[i].in_use) + ops[nops++] = entry->ops[i]; + } + } + SLOG_SEQ_READ_END(fp, slog_seq_); + + /* Consistent snapshot taken; resolve status outside the retry loop. */ + for (i = 0; i < nops; i++) + { + TransactionId xid; + SLogOpType op = ops[i].op_type; + + xid = ops[i].xid; + + if (TransactionIdIsCurrentTransactionId(xid)) + continue; + if (!TransactionIdIsInProgress(xid)) + continue; + + if (op == SLOG_OP_INSERT || + op == SLOG_OP_UPDATE || + op == SLOG_OP_DELETE) + { + /* A real writer: highest priority, stop scanning. */ + writer_xid = xid; + writer_is_insert = (op == SLOG_OP_INSERT); + break; + } + + if (op == SLOG_OP_LOCK_SHARE || op == SLOG_OP_LOCK_EXCL) + { + LOCKMODE locker_lockmode = + slog_tuplock_to_lockmode(ops[i].lock_mode); + + if (DoLockModesConflict(my_lockmode, locker_lockmode)) + locker_xid = xid; /* candidate; keep seeking a writer */ + } + } + + if (TransactionIdIsValid(writer_xid)) + { + if (is_insert) + *is_insert = writer_is_insert; + return writer_xid; + } + + if (is_insert) + *is_insert = false; + return locker_xid; +} + +/* + * SLogTupleHasLockConflict -- check if any active lock entries on this TID + * conflict with the requested lock mode. + */ +bool +SLogTupleHasLockConflict(Oid relid, ItemPointer tid, + TransactionId my_xid, + SLogOpType requested_lock) +{ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nfound; + int i; + + nfound = SLogTupleLookupFiltered(relid, tid, InvalidTransactionId, + ops, SLOG_MAX_TUPLE_OPS); + + for (i = 0; i < nfound; i++) + { + if (TransactionIdEquals(ops[i].xid, my_xid)) + continue; + if (!TransactionIdIsInProgress(ops[i].xid)) + continue; + + /* Only lock/mutating entries can conflict */ + if (ops[i].op_type != SLOG_OP_LOCK_SHARE && + ops[i].op_type != SLOG_OP_LOCK_EXCL && + ops[i].op_type != SLOG_OP_DELETE && + ops[i].op_type != SLOG_OP_UPDATE) + continue; + + /* + * Lock compatibility matrix: + * SHARE vs SHARE: compatible + * SHARE vs EXCL/DELETE/UPDATE: conflict + * EXCL vs anything: conflict + */ + if (requested_lock == SLOG_OP_LOCK_SHARE) + { + if (ops[i].op_type == SLOG_OP_LOCK_EXCL || + ops[i].op_type == SLOG_OP_DELETE || + ops[i].op_type == SLOG_OP_UPDATE) + return true; + } + else if (requested_lock == SLOG_OP_LOCK_EXCL) + { + return true; + } + } + + return false; +} + +/* + * SLogTupleGetLockConflictXid -- like SLogTupleHasLockConflict, but also + * returns the xid of the *conflicting* transaction (the one whose marker + * actually conflicts with requested_lock under the matrix above). + * + * The waiter must XactLockTableWait on the conflicting xid specifically. + * Waiting on the first in-progress xid found on the TID (as a broad + * SLogTupleGetDirtyXid probe would return) can pick a compatible peer -- e.g. + * another KeyShare locker -- which is not blocking us and may itself be queued + * behind us, manufacturing a spurious mutual-wait deadlock cycle. + * + * Returns true and sets *xid_out if a conflicting in-progress transaction + * exists; returns false otherwise (xid_out is set to InvalidTransactionId). + */ +bool +SLogTupleGetLockConflictXid(Oid relid, ItemPointer tid, + TransactionId my_xid, + SLogOpType requested_lock, + TransactionId *xid_out) +{ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nfound; + int i; + + *xid_out = InvalidTransactionId; + + nfound = SLogTupleLookupFiltered(relid, tid, InvalidTransactionId, + ops, SLOG_MAX_TUPLE_OPS); + + for (i = 0; i < nfound; i++) + { + if (TransactionIdEquals(ops[i].xid, my_xid)) + continue; + if (!TransactionIdIsInProgress(ops[i].xid)) + continue; + + /* Only lock/mutating entries can conflict */ + if (ops[i].op_type != SLOG_OP_LOCK_SHARE && + ops[i].op_type != SLOG_OP_LOCK_EXCL && + ops[i].op_type != SLOG_OP_DELETE && + ops[i].op_type != SLOG_OP_UPDATE) + continue; + + /* Same matrix as SLogTupleHasLockConflict. */ + if (requested_lock == SLOG_OP_LOCK_SHARE) + { + if (ops[i].op_type == SLOG_OP_LOCK_EXCL || + ops[i].op_type == SLOG_OP_DELETE || + ops[i].op_type == SLOG_OP_UPDATE) + { + *xid_out = ops[i].xid; + return true; + } + } + else if (requested_lock == SLOG_OP_LOCK_EXCL) + { + *xid_out = ops[i].xid; + return true; + } + } + + return false; +} + +/* + * SLogTupleHasAbortedEntry -- check if any aborted sLog op exists for a TID. + */ +bool +SLogTupleHasAbortedEntry(Oid relid, ItemPointer tid) +{ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; + int nfound; + int i; + + nfound = SLogTupleLookupFiltered(relid, tid, InvalidTransactionId, + ops, SLOG_MAX_TUPLE_OPS); + + for (i = 0; i < nfound; i++) + { + /* Explicitly marked ABORTED */ + if (ops[i].op_type == SLOG_OP_ABORTED) + return true; + + /* Skip our own transaction's entries for CLOG fallback */ + if (TransactionIdIsCurrentTransactionId(ops[i].xid)) + continue; + + /* CLOG fallback: completed but did not commit => aborted */ + if (!TransactionIdIsInProgress(ops[i].xid) && + TransactionIdDidAbort(ops[i].xid)) + return true; + } + + return false; +} + +/* ---------------------------------------------------------------- + * Backend-private tracking for tuple sLog cleanup + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleTrackKey + * Remember a tuple key for cleanup at commit/abort. + * + * Allocated in TopTransactionContext so it's automatically freed + * when the transaction ends. + */ +void +SLogTupleTrackKey(SLogTupleKey key, TransactionId xid, TransactionId subxid, + SLogOpType op_type) +{ + MemoryContext oldcxt; + SLogTrackedKey *tk; + + oldcxt = MemoryContextSwitchTo(TopTransactionContext); + + tk = (SLogTrackedKey *) palloc(sizeof(SLogTrackedKey)); + memcpy(&tk->key, &key, sizeof(SLogTupleKey)); + tk->xid = xid; + tk->subxid = subxid; + tk->local_only = false; + slog_has_shared_entries = true; + tk->op_type = op_type; + tk->before_image = NULL; + tk->before_image_len = 0; + tk->before_flags = 0; + tk->before_commit_ts = 0; + tk->next = slog_tracked_keys; + slog_tracked_keys = tk; + + MemoryContextSwitchTo(oldcxt); +} + +/* + * SLogTupleTrackLocalOnly + * Lightweight local-only tracking (INSERTs only). + * + * Records (relid, tid, xid, subxid) in the per-backend local list WITHOUT + * creating a shared sLog entry. On subtransaction abort, + * SLogTupleRemoveBySubXid will create a shared ABORTED entry for visibility. + * + * OOM optimization: When not inside a subtransaction, records the TID in a + * backend-local open-addressing hash (simplehash) keyed by (relid, + * encoded_tid) instead of a 136-byte linked-list node, giving O(1) insert + * and probe and bounded per-backend memory under bulk INSERT. Subtransaction + * entries still use the linked list because subtxn abort needs per-entry + * subxid filtering. + */ +void +SLogTupleTrackLocalOnly(Oid relid, ItemPointer tid, + TransactionId xid, TransactionId subxid) +{ + MemoryContext oldcxt; + + /* + * Fast path: top-level transaction with no savepoint → use the hash. + * The subxid is InvalidTransactionId in this case (top-level INSERTs + * always pass the top xid as both xid and subxid=Invalid). + */ + if (!IsSubTransaction()) + { + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + SLogInsertTidKey ikey; + bool found; + + if (offnum > MaxOffsetNumber) + elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", + offnum, MaxOffsetNumber); + ikey.relid = relid; + ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); + + /* + * Record the TID in the backend-local hash. Insert is amortized + * O(1); the table and its entries live in TopTransactionContext and + * are destroyed at transaction end. + */ + (void) sloginsert_insert(slog_insert_tids_ensure(), ikey, &found); + return; + } + + { + SLogTrackedKey *tk; + SLogTupleKey key; + + memset(&key, 0, sizeof(key)); + key.relid = relid; + ItemPointerCopy(tid, &key.tid); + + oldcxt = MemoryContextSwitchTo(TopTransactionContext); + + tk = (SLogTrackedKey *) palloc(sizeof(SLogTrackedKey)); + memcpy(&tk->key, &key, sizeof(SLogTupleKey)); + tk->xid = xid; + tk->subxid = subxid; + tk->local_only = true; + tk->op_type = SLOG_OP_INSERT; + tk->before_image = NULL; + tk->before_image_len = 0; + tk->before_flags = 0; + tk->before_commit_ts = 0; + tk->next = slog_tracked_keys; + slog_tracked_keys = tk; + + MemoryContextSwitchTo(oldcxt); + } +} + +/* + * SLogTupleUntrackLocalOnly + * Remove local-only INSERT tracking for (relid, tid). + * + * Counterpart to SLogTupleTrackLocalOnly. Used when a freshly inserted tuple + * must NOT be stamped with the inserting transaction's commit marker at commit + * time -- e.g. VACUUM FULL / CLUSTER copies a recently-dead tombstone into the + * new relation and rewrites it with its ORIGINAL delete timestamp. Leaving the + * INSERT tracked would let the AM's commit-time stamping clobber the tuple's + * commit metadata with the rewrite transaction's commit marker, resurrecting + * the deleted row for any reader whose snapshot predates that commit. + * + * Clears the backend-local INSERT-hash entry on the top-level fast path and + * also drops any matching linked-list node (the savepoint/fallback path). + */ +void +SLogTupleUntrackLocalOnly(Oid relid, ItemPointer tid) +{ + BlockNumber blkno = ItemPointerGetBlockNumber(tid); + OffsetNumber offnum = ItemPointerGetOffsetNumber(tid); + SLogTrackedKey **link; + + if (offnum > MaxOffsetNumber) + elog(ERROR, "offset %u exceeds page item limit %d in sLog TID encoding", + offnum, MaxOffsetNumber); + + /* Fast path: drop the backend-local INSERT-hash entry if present. */ + if (slog_insert_tids != NULL) + { + SLogInsertTidKey ikey; + + ikey.relid = relid; + ikey.encoded_tid = SLOG_ENCODE_TID(blkno, offnum); + (void) sloginsert_delete(slog_insert_tids, ikey); + } + + /* Fallback/subtxn path: unlink any matching local-only INSERT node. */ + link = &slog_tracked_keys; + while (*link != NULL) + { + SLogTrackedKey *tk = *link; + + if (tk->local_only && + tk->key.relid == relid && + ItemPointerGetBlockNumber(&tk->key.tid) == blkno && + ItemPointerGetOffsetNumber(&tk->key.tid) == offnum) + { + *link = tk->next; + pfree(tk); + continue; + } + link = &tk->next; + } +} + +/* + * SLogTupleStoreBeforeImage + * Attach a before-image to the most recent tracked key for the given + * (relid, tid, xid) combination. + * + * This is called during DELETE and UPDATE operations to stash the original + * tuple data before in-place modification. On subtransaction abort, the AM's + * before-image restore path uses this data to physically restore the tuple. + * + * The before-image is allocated in TopTransactionContext so it survives + * subtransaction rollback. Memory is freed when the tracked key list is + * reset at top-level transaction end. + * + * Size cap: if the tuple is larger than the registered AM's before_image_max + * (0 if no AM opted in), we skip storing the before-image. On savepoint + * rollback for such tuples, the tuple cannot be restored and the operation + * will raise an error. + */ +void +SLogTupleStoreBeforeImage(Oid relid, ItemPointer tid, TransactionId xid, + const char *data, int len, + uint16 flags, uint64 commit_ts, + RelFileLocator rlocator, char relpersistence) +{ + SLogTrackedKey *tk; + MemoryContext oldcxt; + + /* Enforce the opting-in AM's size cap */ + if (slog_am_desc.before_image_max == 0 || + len > (int) slog_am_desc.before_image_max) + return; + + /* Find the matching tracked key (most recently added = list head) */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (tk->key.relid != relid) + continue; + if (!ItemPointerEquals(&tk->key.tid, tid)) + continue; + + /* Found it — store local copy for savepoint rollback */ + oldcxt = MemoryContextSwitchTo(TopTransactionContext); + + tk->before_image = palloc(len); + memcpy(tk->before_image, data, len); + tk->before_image_len = len; + tk->before_flags = flags; + tk->before_commit_ts = commit_ts; + tk->before_rlocator = rlocator; + tk->before_relpersistence = relpersistence; + + MemoryContextSwitchTo(oldcxt); + + /* + * Only the local per-backend before-image is stored -- consumed by the + * AM's intra-transaction savepoint rollback. Cross-backend MVCC reads + * no longer need a shared before-image: WS-PVS3 walks the durable UNDO + * fork chain via the AM's version-reconstruction walk, and the + * write-write conflict probe resolves the head verptr in the same fork. + */ + + return; + } + + /* Tracked key not found — this shouldn't happen, but is non-fatal */ + elog(WARNING, "SLogTupleStoreBeforeImage: no tracked key for rel %u tid (%u,%u) xid %u", + relid, ItemPointerGetBlockNumber(tid), + ItemPointerGetOffsetNumber(tid), xid); +} + +/* + * SLogTupleIterateTrackedKeysForSubXid + * Iterate over tracked keys matching a given xid AND subxid. + * + * This is used by the AM's before-image restore path to find entries that + * need physical restoration during savepoint rollback. + */ +void +SLogTupleIterateTrackedKeysForSubXid(TransactionId xid, + TransactionId subxid, + SLogTrackedKeyCallback callback, + void *arg) +{ + SLogTrackedKey *tk; + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (tk->subxid != subxid) + continue; + + if (!callback(&tk->key, tk->xid, tk->subxid, tk->local_only, arg)) + break; + } +} + +/* + * SLogTupleGetBeforeImage + * Retrieve the before-image for a specific tracked key. + * + * Returns true if a before-image is available, filling in the output params. + * Returns false if no before-image was stored (e.g., INSERT, or tuple was + * too large). + */ +bool +SLogTupleGetBeforeImage(Oid relid, ItemPointer tid, TransactionId xid, + TransactionId subxid, + char **data_out, int *len_out, + uint16 *flags_out, uint64 *commit_ts_out, + RelFileLocator *rlocator_out, char *relpersistence_out) +{ + SLogTrackedKey *tk; + + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + if (tk->subxid != subxid) + continue; + if (tk->key.relid != relid) + continue; + if (!ItemPointerEquals(&tk->key.tid, tid)) + continue; + + if (tk->before_image == NULL) + return false; + + *data_out = tk->before_image; + *len_out = tk->before_image_len; + *flags_out = tk->before_flags; + *commit_ts_out = tk->before_commit_ts; + *rlocator_out = tk->before_rlocator; + *relpersistence_out = tk->before_relpersistence; + return true; + } + + return false; +} + +/* + * SLogTupleCleanupRetained + * Free retained sLog entries that are no longer visible to any + * active snapshot. + * + * WS-PVS3: committed UPDATE markers are no longer retained (commit_xid + * apply removes them), so under steady-state this routine primarily + * cleans up other retained state. Reclamation gates on the xid horizon + * (GetOldestNonRemovableTransactionId) so a marker is reclaimable only + * once its committing xid precedes the oldest active snapshot's xmin. + * + * Scans the flat hash under read-side, then applies CLEANUP_RETAINED ops. + * Walks every partition taking each writer lock LW_EXCLUSIVE, so callers MUST + * NOT hold a buffer content lock or other page-level critical section across + * this call. Driven by the UNDO background worker, or by + * SLogTupleMaybeCleanupRetained() when the worker is disabled. The + * reclamation decision is the xid horizon (GetOldestNonRemovableTransactionId) + * computed below. + */ +void +SLogTupleCleanupRetained(void) +{ + SLogTupleKey *collected_keys; + int max_keys = 256; + int part; + int i; + TransactionId reclaim_xid_horizon; + + if (SLogState == NULL) + return; + + /* + * Compute the xid horizon once for this pass. The read-side eligibility + * scan and the CLEANUP_RETAINED apply gate on the same horizon so the set + * of slots reclaimed here matches the set the apply drops. + */ + reclaim_xid_horizon = GetOldestNonRemovableTransactionId(NULL); + if (!TransactionIdIsValid(reclaim_xid_horizon)) + return; + + collected_keys = (SLogTupleKey *) + palloc(max_keys * sizeof(SLogTupleKey)); + + /* + * Process each partition independently while holding its exclusive + * writer lock. The writer lock serializes all writers (other cleanup + * runs and forward-path inserts) for the partition, so the single copy is + * stable to scan. We apply the CLEANUP_RETAINED ops inside seqlock + * cycles. + */ + for (part = 0; part < SLogNumPartitions; part++) + { + SLogFlatPartition *fp = SLogGetPartitionByIndex(part); + SLogFlatHashScanState scan; + const SLogFlatBucket *bucket; + int nkeys = 0; + + LWLockAcquire(&fp->writer_lock.lock, LW_EXCLUSIVE); + + /* + * Scan the single copy to collect expired keys. Stable because we + * hold the writer lock. + */ + SLogFlatHashScanInit(&scan); + while ((bucket = SLogFlatHashScanNext(fp->hash, &scan)) != NULL) + { + const SLogTupleEntry *entry = &bucket->entry; + bool has_expired = false; + + for (i = 0; i < SLOG_MAX_TUPLE_OPS; i++) + { + if (!entry->ops[i].in_use) + continue; + if (entry->ops[i].op_type != SLOG_OP_UPDATE) + continue; + if (!TransactionIdIsValid(entry->ops[i].xid)) + continue; + /* + * Reclaim once the xid precedes the oldest active snapshot's + * xmin (xid authority -- see the function header). This is the + * authoritative, self-healing gate: a below-horizon marker is + * reclaimable whether or not it was ever committed in CLOG, and + * an in-progress xid can never precede the horizon. + */ + if (!TransactionIdPrecedes(entry->ops[i].xid, + reclaim_xid_horizon)) + continue; + + if (!has_expired) + { + if (nkeys >= max_keys) + { + max_keys *= 2; + collected_keys = (SLogTupleKey *) + repalloc(collected_keys, + max_keys * sizeof(SLogTupleKey)); + } + collected_keys[nkeys++] = bucket->key; + has_expired = true; + } + } + } + + /* Null the now-dangling pointers and drop the slots. */ + if (nkeys > 0) + { + int ops_since_publish = 0; + + SLogSeqWriteBegin(fp); + for (i = 0; i < nkeys; i++) + { + SLogFlatOp flat_op; + + memset(&flat_op, 0, sizeof(flat_op)); + flat_op.kind = SLOG_FLAT_OP_CLEANUP_RETAINED; + flat_op.key = collected_keys[i]; + flat_op.reclaim_xid_horizon = reclaim_xid_horizon; + SLogFlatHashApply(fp->hash, &flat_op, sizeof(flat_op)); + + /* + * Bound the seqlock odd-hold window so wait-free readers of + * this partition make progress. On a hot row many retained + * UPDATE markers expire at once, so nkeys can be large; a + * single uninterrupted odd window would spin every reader + * for its whole duration. Each key's reclamation is + * independent and each seq cycle leaves fp->hash consistent, + * so ending and re-beginning at a batch boundary is safe -- + * the partition LWLock still serializes us against other + * before-image freers. + */ + if (++ops_since_publish >= SLOG_ABORT_PUBLISH_BATCH) + { + SLogSeqWriteEnd(fp); + SLogSeqWriteBegin(fp); + ops_since_publish = 0; + } + } + SLogSeqWriteEnd(fp); + } + + LWLockRelease(&fp->writer_lock.lock); + } + + pfree(collected_keys); +} + +/* + * SLogTupleMaybeCleanupRetained + * Throttled, self-clocking entry point for retained-entry cleanup. + * + * Intended for access methods to call from their DML paths when the UNDO + * background worker is disabled (max_logical_revert_workers = 0), so the DSA + * before-image area does not fill up under sustained high-TPS load. Unlike + * SLogTupleCleanupRetained(), the heavy global sweep here runs at most once + * every 5 seconds per backend; the common call is a counter increment that + * returns immediately. + * + * CRITICAL: the caller MUST NOT hold any buffer content lock or other + * page-level critical section across this call. When the throttle fires this + * walks every sLog partition taking each writer lock LW_EXCLUSIVE; running it + * under a buffer lock would serialize all writers to a hot page behind the + * sweep (the c>=2 hot-row UPDATE convoy this throttle was introduced to avoid). + */ +void +SLogTupleMaybeCleanupRetained(void) +{ + static TimestampTz slog_last_cleanup = 0; + static uint32 slog_cleanup_clock = 0; + TimestampTz now_ts; + + if (SLogState == NULL) + return; + + /* + * Sample the wall clock only once every SLOG_INSERT_CLOCK_PERIOD calls. + * The throttle below already bounds the heavy sweep to once per 5s; this + * just keeps the common-case cost down to a counter increment so the + * post-unlock call on every UPDATE does not add a clock_gettime() syscall + * to the hot path (matters for the >=HEAP perf target). + */ + if ((slog_cleanup_clock++ & (SLOG_INSERT_CLOCK_PERIOD - 1)) != 0) + return; + + now_ts = GetCurrentTimestamp(); + + if (now_ts - slog_last_cleanup > 5000000) /* 5 seconds */ + { + slog_last_cleanup = now_ts; + SLogTupleCleanupRetained(); + } +} + +/* + * SLogTupleAnyTracked + * True iff the current backend has tracked any tuple key (INSERT, + * UPDATE, or DELETE) for the current transaction -- i.e. this backend + * touched at least one tracked tuple since the last + * SLogTupleResetTracking(). + * + * Used by the AM's xact callback to decide whether a transaction that + * touched no tracked tuples should skip writing a durable commit-map entry. + */ +bool +SLogTupleAnyTracked(void) +{ + return slog_tracked_keys != NULL || + (slog_insert_tids != NULL && slog_insert_tids->members > 0); +} + +/* + * SLogTupleResetTracking + * Clear the backend-private tracking list and reset overflow state. + * + * Also frees the backend-local INSERT hash. The table lives in + * TopTransactionContext, so it would be freed at transaction end anyway. + * Explicit cleanup here allows earlier memory reclaim and makes the state + * consistent for any subsequent operations within the same backend lifetime. + */ +void +SLogTupleResetTracking(void) +{ + /* Destroy the backend-local INSERT hash */ + if (slog_insert_tids != NULL) + { + sloginsert_destroy(slog_insert_tids); + slog_insert_tids = NULL; + } + + slog_tracked_keys = NULL; + slog_has_shared_entries = false; + slog_overflow_warning_count = 0; + slog_overflow_last_warning = 0; +} + +/* + * SLogTupleIterateTrackedKeys + * Iterate over tracked keys for a given xid. + * + * Calls the callback for each tracked key matching the given xid. + * If the callback returns false, iteration stops early. + * Used by AM-specific pre-commit callbacks that need to touch pages. + * + * Iterates both hash-based INSERT entries (top-level) and + * linked-list entries. + */ +void +SLogTupleIterateTrackedKeys(TransactionId xid, + SLogTrackedKeyCallback callback, + void *arg) +{ + SLogTrackedKey *tk; + + /* Iterate backend-local INSERT-hash entries (top-level local-only) */ + if (slog_insert_tids != NULL) + { + sloginsert_iterator it; + SLogInsertTidEntry *ie; + + sloginsert_start_iterate(slog_insert_tids, &it); + while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) + { + SLogTupleKey key; + + memset(&key, 0, sizeof(key)); + key.relid = ie->key.relid; + ItemPointerSet(&key.tid, + SLOG_DECODE_BLKNO(ie->key.encoded_tid), + SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); + + if (!callback(&key, xid, InvalidTransactionId, true, arg)) + return; + } + } + + /* Iterate linked-list entries */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + + if (!callback(&tk->key, tk->xid, tk->subxid, tk->local_only, arg)) + break; + } +} + +/* + * SLogTupleIterateTrackedKeysExt + * Extended iteration that also passes before-image metadata. + * + * Used by commit-time callbacks that need the original t_commit_ts + * to restore it on in-place-updated tuples (preserving visibility + * for readers with older snapshots). + * + * Sparsemap entries are local-only INSERTs with no before-image, so + * before_commit_ts=0 and has_before_image=false for those entries. + */ +void +SLogTupleIterateTrackedKeysExt(TransactionId xid, + SLogTrackedKeyExtCallback callback, + void *arg) +{ + SLogTrackedKey *tk; + + /* Iterate backend-local INSERT-hash entries (top-level local-only) */ + if (slog_insert_tids != NULL) + { + sloginsert_iterator it; + SLogInsertTidEntry *ie; + + sloginsert_start_iterate(slog_insert_tids, &it); + while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) + { + SLogTupleKey key; + + memset(&key, 0, sizeof(key)); + key.relid = ie->key.relid; + ItemPointerSet(&key.tid, + SLOG_DECODE_BLKNO(ie->key.encoded_tid), + SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); + + if (!callback(&key, xid, InvalidTransactionId, true, + 0, false, arg)) + return; + } + } + + /* Iterate linked-list entries */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + + if (!callback(&tk->key, tk->xid, tk->subxid, tk->local_only, + tk->before_commit_ts, + (tk->before_image != NULL), + arg)) + break; + } +} + +/* + * SLogTupleCollectTrackedKeys + * Collect all tracked keys for a given xid into a palloc'd array. + * + * Returns the number of collected keys. The caller can then sort the + * array for batch processing (e.g. by relid/blockno to amortize buffer + * I/O at commit time). + * + * The returned array is allocated in the current memory context; the caller + * is responsible for pfree(). + */ +int +SLogTupleCollectTrackedKeys(TransactionId xid, SLogTrackedKeyInfo **out_keys) +{ + SLogTrackedKey *tk; + int count = 0; + int capacity = 64; + SLogTrackedKeyInfo *arr; + + /* + * A single very large transaction can touch tens of millions of tracked + * keys; at 48 bytes each the array crosses MaxAllocSize (1 GB) past ~22M + * entries. Use the huge-allocation API so bulk loads do not fail at + * commit. This array is transient and pfree'd by the caller. + */ + arr = (SLogTrackedKeyInfo *) MemoryContextAllocHuge(CurrentMemoryContext, + sizeof(SLogTrackedKeyInfo) * (Size) capacity); + + /* Collect backend-local INSERT-hash entries (top-level local-only) */ + if (slog_insert_tids != NULL) + { + sloginsert_iterator it; + SLogInsertTidEntry *ie; + + sloginsert_start_iterate(slog_insert_tids, &it); + while ((ie = sloginsert_iterate(slog_insert_tids, &it)) != NULL) + { + if (count >= capacity) + { + capacity *= 2; + arr = (SLogTrackedKeyInfo *) + repalloc_huge(arr, sizeof(SLogTrackedKeyInfo) * (Size) capacity); + } + + memset(&arr[count].key, 0, sizeof(SLogTupleKey)); + arr[count].key.relid = ie->key.relid; + ItemPointerSet(&arr[count].key.tid, + SLOG_DECODE_BLKNO(ie->key.encoded_tid), + SLOG_DECODE_OFFNUM(ie->key.encoded_tid)); + arr[count].xid = xid; + arr[count].subxid = InvalidTransactionId; + arr[count].local_only = true; + arr[count].op_type = SLOG_OP_INSERT; + arr[count].before_commit_ts = 0; + arr[count].has_before_image = false; + count++; + } + } + + /* Collect linked-list entries */ + for (tk = slog_tracked_keys; tk != NULL; tk = tk->next) + { + if (!TransactionIdEquals(tk->xid, xid)) + continue; + + if (count >= capacity) + { + capacity *= 2; + arr = (SLogTrackedKeyInfo *) + repalloc_huge(arr, sizeof(SLogTrackedKeyInfo) * (Size) capacity); + } + + arr[count].key = tk->key; + arr[count].xid = tk->xid; + arr[count].subxid = tk->subxid; + arr[count].local_only = tk->local_only; + arr[count].op_type = tk->op_type; + arr[count].before_commit_ts = tk->before_commit_ts; + arr[count].has_before_image = (tk->before_image != NULL); + count++; + } + + *out_keys = arr; + return count; +} diff --git a/src/backend/access/undo/undo.c b/src/backend/access/undo/undo.c new file mode 100644 index 0000000000000..441eddd29b0ff --- /dev/null +++ b/src/backend/access/undo/undo.c @@ -0,0 +1,270 @@ +/*------------------------------------------------------------------------- + * + * undo.c + * Common undo layer coordination + * + * The undo subsystem consists of several logically separate subsystems + * that work together to achieve a common goal. The code in this file + * provides a limited amount of common infrastructure that can be used + * by all of those various subsystems, and helps coordinate activities + * such as shared memory initialization and startup/shutdown. + * + * This file has no compile-time or link-time dependency on any specific + * table or index access method. Shared memory for AM-specific tracking + * structures (such as an optional per-tuple tracking hash an AM may register) + * is registered as its own sibling entry in storage/subsystemlist.h, not + * sized or initialized here. Likewise, the + * set of UNDO resource managers to register comes from access/undormgrlist.h + * (mirroring the subsystemlist.h idiom): a new consumer adds itself to that + * list, and never edits this file. + * + * Shared memory initialization uses the PG_SHMEM_SUBSYSTEM pattern: + * UndoShmemCallbacks is registered in subsystemlist.h, and the framework + * calls UndoShmemRequest() and UndoShmemInit() at the appropriate times + * during postmaster startup. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/undo/undo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm.h" +#include "access/logical_revert_worker.h" +#include "access/relundo_worker.h" +#include "access/undo.h" +#include "access/undolog.h" +#include "access/undormgr.h" +#include "access/undormgrs.h" +#include "access/undoworker.h" +#include "access/xactundo.h" +#include "storage/ipc.h" +#include "storage/shmem.h" +#include "storage/subsystems.h" +#include "utils/memutils.h" + +/* + * UndoContext is a child of TopMemoryContext which is never reset. The only + * reason for having a separate context is to make it easier to spot leaks or + * excessive memory utilization related to undo operations. + */ +MemoryContext UndoContext = NULL; + +static void AtProcExit_Undo(int code, Datum arg); +static void UndoShmemRequest_internal(void *arg); +static void UndoShmemInit_internal(void *arg); +static void UndoShmemAttach_internal(void *arg); +static void RegisterUndoRmgrs(void); + +/* + * ShmemCallbacks for the UNDO subsystem. + * + * Registered via PG_SHMEM_SUBSYSTEM(UndoShmemCallbacks) in subsystemlist.h. + * + * init_fn initializes the contents of all UNDO shared memory structures. + */ +const ShmemCallbacks UndoShmemCallbacks = { + .request_fn = UndoShmemRequest_internal, + .init_fn = UndoShmemInit_internal, + .attach_fn = UndoShmemAttach_internal, +}; + +/* + * UndoShmemSize + * Figure out how much shared memory will be needed for undo. + * + * Each subsystem separately computes the space it requires, and we + * carefully add up those values here. AM-specific tracking structures + * (such as an optional per-tuple tracking hash) are sized by their own + * PG_SHMEM_SUBSYSTEM entry and are not included here. + */ +Size +UndoShmemSize(void) +{ + Size size; + + size = UndoLogShmemSize(); + size = add_size(size, XactUndoShmemSize()); + size = add_size(size, UndoWorkerShmemSize()); + size = add_size(size, RelUndoWorkerShmemSize()); + size = add_size(size, LogicalRevertShmemSize()); + size = add_size(size, ATMShmemSize()); + + return size; +} + +/* + * UndoShmemRequest_internal + * Register shared memory needs for UNDO subsystems. + * + * Called during the request_fn phase of postmaster startup, before shared + * memory is allocated. + */ +static void +UndoShmemRequest_internal(void *arg) +{ + /* + * Register the UNDO background worker. This must happen during the + * request_fn phase (before BackgroundWorkerShmemInit runs in the init_fn + * phase), because RegisterBackgroundWorker() cannot be called after + * BackgroundWorkerShmemInit(). + * + * Use a static flag to ensure we only register once. The request_fn + * callback is called again during postmaster reinitialize (after a child + * crash), and RegisterBackgroundWorker() would fail if called after the + * first shmem init. + */ + { + static bool undo_worker_registered = false; + + if (!undo_worker_registered) + { + UndoWorkerRegister(); + undo_worker_registered = true; + } + } +} + +/* + * UndoShmemInit / UndoShmemInit_internal + * Initialize undo-related shared memory. + * + * Also, perform other initialization steps that need to be done very early. + * This is called once during postmaster startup via the ShmemCallbacks + * framework. + */ +static void +UndoShmemInit_internal(void *arg) +{ + UndoShmemInit(); +} + +void +UndoShmemInit(void) +{ + /* + * Initialize the undo memory context. If it already exists (crash restart + * via reset_shared()), reset it instead. + */ + if (UndoContext) + MemoryContextReset(UndoContext); + else + UndoContext = AllocSetContextCreate(TopMemoryContext, "Undo", + ALLOCSET_DEFAULT_SIZES); + + /* Now give various undo subsystems a chance to initialize. */ + UndoLogShmemInit(); + XactUndoShmemInit(); + UndoWorkerShmemInit(); + RelUndoWorkerShmemInit(); + LogicalRevertShmemInit(); + ATMShmemInit(); + + /* + * Initialize the UNDO resource manager dispatch table and register the + * built-in resource managers listed in access/undormgrlist.h. + */ + RegisterUndoRmgrs(); +} + +/* + * UndoShmemAttach_internal + * Re-establish per-process UNDO state in EXEC_BACKEND children. + * + * Under EXEC_BACKEND (Windows, or --enable-exec-backend builds) a child does + * not inherit the postmaster's address space, so module-scope state populated + * by UndoShmemInit() is not present and must be rebuilt here. Two distinct + * kinds of state need re-establishing: + * + * 1. The pointers into shared memory held by the legacy ShmemInitStruct-based + * sub-modules (UndoLogShared, UndoWorkerShmem, WorkQueue, RevertState). + * Re-calling each *ShmemInit() in a child is safe: + * ShmemInitStruct() self-attaches via AttachShmemIndexEntry() when + * IsUnderPostmaster, re-assigns the module global, and reports found=true so + * the one-time "if (!found)" initialization block is correctly skipped. + * + * 2. The UndoRmgrs[] dispatch table and each registered AM's relundo hook + * function pointers, both rebuilt by RegisterUndoRmgrs() (idempotent: + * InitUndoRmgrs() zeroes the table first). + * + * XactUndoShmemInit() and ATMShmemInit() are no-ops and so omitted here. Any + * AM-specific shared structure with the same "no found-guard" hazard + * handles its own EXEC_BACKEND re-attach via its own PG_SHMEM_SUBSYSTEM entry + * (see storage/subsystemlist.h); this function is only responsible for the + * generic UNDO sub-modules listed above. + */ +static void +UndoShmemAttach_internal(void *arg) +{ + UndoLogShmemInit(); + UndoWorkerShmemInit(); + RelUndoWorkerShmemInit(); + LogicalRevertShmemInit(); + + RegisterUndoRmgrs(); +} + +/* + * RegisterUndoRmgrs + * Initialize the UNDO resource manager dispatch table and register the + * built-in resource managers listed in access/undormgrlist.h. + * + * Called from both UndoShmemInit() (postmaster/standalone) and + * UndoShmemAttach_internal() (EXEC_BACKEND children). Idempotent: + * InitUndoRmgrs() zeroes the dispatch table before the *UndoRmgrInit() calls + * re-register, so RegisterUndoRmgr()'s double-registration guard is not + * tripped on a second invocation in the same process. + * + * This function has no compile-time knowledge of which resource managers + * exist: it just expands access/undormgrlist.h through the UNDO_RMGR_INIT + * macro. A new UNDO-writing consumer (a new index AM, table AM, or other + * subsystem) adds one line to that list; this function and the rest of + * undo.c are never modified. + */ +static void +RegisterUndoRmgrs(void) +{ + /* + * Initialize the UNDO resource manager dispatch table. + */ + InitUndoRmgrs(); + + /* + * Register every built-in resource manager listed in + * access/undormgrlist.h. Some *UndoRmgrInit() implementations also + * install their AM's relundo hooks (see access/relundo.h) as part of + * registration. + */ +#define UNDO_RMGR_INIT(initfunc) \ + initfunc(); +#include "access/undormgrlist.h" +#undef UNDO_RMGR_INIT +} + +/* + * InitializeUndo + * Per-backend initialization for the undo subsystem. + * + * Called once per backend from InitPostgres(). + */ +void +InitializeUndo(void) +{ + InitializeXactUndo(); + on_shmem_exit(AtProcExit_Undo, 0); +} + +/* + * AtProcExit_Undo + * Shut down undo subsystems in the correct order. + * + * Higher-level stuff should be shut down first. + */ +static void +AtProcExit_Undo(int code, Datum arg) +{ + AtProcExit_XactUndo(); +} diff --git a/src/backend/access/undo/undo_bufmgr.c b/src/backend/access/undo/undo_bufmgr.c new file mode 100644 index 0000000000000..1d35cde5596f1 --- /dev/null +++ b/src/backend/access/undo/undo_bufmgr.c @@ -0,0 +1,250 @@ +/*------------------------------------------------------------------------- + * + * undo_bufmgr.c + * UNDO log buffer manager integration with PostgreSQL's shared_buffers + * + * This module routes undo log I/O through PostgreSQL's standard + * shared buffer pool. The approach follows ZHeap's design where undo + * data is "accessed through the buffer pool ... similar to regular + * relation data" (ZHeap README, lines 30-40). + * + * Each undo log is mapped to a virtual RelFileLocator: + * + * spcOid = UNDO_DEFAULT_TABLESPACE_OID (pg_default, 1663) + * dbOid = UNDO_DB_OID (pseudo-database 9) + * relNumber = undo log number + * + * This virtual locator is used with ReadBufferWithoutRelcache() to + * read/write undo blocks through the shared buffer pool. The fork + * number MAIN_FORKNUM is used (following ZHeap's UndoLogForkNum + * convention), and undo buffers are distinguished from regular data + * by the UNDO_DB_OID in the BufferTag's dbOid field. + * + * Benefits: + * - Unified buffer management (no separate cache to tune) + * - Automatic clock-sweep eviction via shared_buffers + * - Built-in dirty buffer tracking and checkpoint support + * - WAL integration for crash safety + * - Standard buffer locking and pin semantics + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undo_bufmgr.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "storage/buf_internals.h" + +#include "access/undo_bufmgr.h" + + +/* ---------------------------------------------------------------- + * Buffer tag construction + * ---------------------------------------------------------------- + */ + +/* + * UndoMakeBufferTag + * Initialize a BufferTag for an undo log block. + * + * This constructs the BufferTag that the shared buffer manager uses + * to identify this undo block in its hash table. The tag encodes the + * virtual RelFileLocator (mapping log_number to a pseudo-relation) + * and UndoLogForkNum (MAIN_FORKNUM) as the fork number. + */ +void +UndoMakeBufferTag(BufferTag *tag, uint32 log_number, + BlockNumber block_number) +{ + RelFileLocator rlocator; + + UndoLogGetRelFileLocator(log_number, &rlocator); + InitBufferTag(tag, &rlocator, UndoLogForkNum, block_number); +} + + +/* ---------------------------------------------------------------- + * Buffer read/release API + * ---------------------------------------------------------------- + */ + +/* + * ReadUndoBuffer + * Read an undo log block into the shared buffer pool. + * + * Translates the undo log number and block number into a virtual + * RelFileLocator and calls ReadBufferWithoutRelcache() to obtain + * a shared buffer. + * + * The returned Buffer handle is pinned. The caller must release it + * via ReleaseUndoBuffer() (or UnlockReleaseUndoBuffer() if locked). + * + * For normal reads (RBM_NORMAL), the caller should lock the buffer + * after this call: + * + * buf = ReadUndoBuffer(logno, blkno, RBM_NORMAL); + * LockBuffer(buf, BUFFER_LOCK_SHARE); + * ... read data from BufferGetPage(buf) ... + * UnlockReleaseUndoBuffer(buf); + * + * For new page allocation (RBM_ZERO_AND_LOCK), the buffer is returned + * zero-filled and exclusively locked: + * + * buf = ReadUndoBuffer(logno, blkno, RBM_ZERO_AND_LOCK); + * ... initialize page contents ... + * MarkUndoBufferDirty(buf); + * UnlockReleaseUndoBuffer(buf); + */ +Buffer +ReadUndoBuffer(uint32 log_number, BlockNumber block_number, + ReadBufferMode mode) +{ + return ReadUndoBufferExtended(log_number, block_number, mode, NULL); +} + +/* + * ReadUndoBufferExtended + * Like ReadUndoBuffer but with explicit buffer access strategy. + * + * The strategy parameter can be used to control buffer pool usage when + * performing bulk undo log operations (e.g., sequential scan during + * discard, or recovery). Pass NULL for the default strategy. + * + * Undo logs are always permanent (they must survive crashes for + * recovery purposes), so we pass permanent=true to + * ReadBufferWithoutRelcache(). + */ +Buffer +ReadUndoBufferExtended(uint32 log_number, BlockNumber block_number, + ReadBufferMode mode, BufferAccessStrategy strategy) +{ + RelFileLocator rlocator; + + UndoLogGetRelFileLocator(log_number, &rlocator); + + return ReadBufferWithoutRelcache(rlocator, + UndoLogForkNum, + block_number, + mode, + strategy, + true); /* permanent */ +} + +/* + * ReleaseUndoBuffer + * Release a pinned undo buffer. + * + * The buffer must not be locked when this is called. + * This is a thin wrapper for API consistency; callers that hold + * a lock should use UnlockReleaseUndoBuffer() instead. + */ +void +ReleaseUndoBuffer(Buffer buffer) +{ + ReleaseBuffer(buffer); +} + +/* + * UnlockReleaseUndoBuffer + * Unlock and release an undo buffer in one call. + * + * Convenience function that combines UnlockReleaseBuffer() semantics + * for undo buffers. + */ +void +UnlockReleaseUndoBuffer(Buffer buffer) +{ + UnlockReleaseBuffer(buffer); +} + +/* + * MarkUndoBufferDirty + * Mark an undo buffer as needing write-back. + * + * The buffer must be exclusively locked when this is called. + * The dirty buffer will be written back during the next checkpoint + * or when evicted from the buffer pool. + */ +void +MarkUndoBufferDirty(Buffer buffer) +{ + MarkBufferDirty(buffer); +} + + +/* ---------------------------------------------------------------- + * Buffer invalidation + * ---------------------------------------------------------------- + */ + +/* + * InvalidateUndoBuffers + * Drop all shared buffers belonging to a given undo log. + * + * This is called when an undo log is fully discarded and no longer + * needed. All pages for the specified undo log number are removed + * from the shared buffer pool without being written back to disk, + * since the underlying undo log files are being removed. + * + * Uses DropRelationBuffers() which is the standard public API for + * dropping buffers belonging to a relation. We open an SMgrRelation + * for the virtual undo log locator and drop all buffers for the + * UndoLogForkNum fork starting from block 0. + * + * The caller must ensure that no other backend is concurrently + * accessing buffers for this undo log. + */ +void +InvalidateUndoBuffers(uint32 log_number) +{ + RelFileLocator rlocator; + SMgrRelation srel; + ForkNumber forknum = UndoLogForkNum; + BlockNumber firstDelBlock = 0; + + UndoLogGetRelFileLocator(log_number, &rlocator); + srel = smgropen(rlocator, INVALID_PROC_NUMBER); + + DropRelationBuffers(srel, &forknum, 1, &firstDelBlock); + + smgrclose(srel); +} + +/* + * InvalidateUndoBufferRange + * Drop shared buffers for a range of blocks in an undo log. + * + * This is called during undo log truncation when only a portion of + * the undo log is being discarded. Blocks starting from first_block + * onward are invalidated. + * + * Note: DropRelationBuffers drops all blocks >= firstDelBlock for the + * given fork, so we pass first_block as the starting block. The + * last_block parameter documents the intended range boundary but the + * buffer manager will drop any matching buffer with blockNum >= + * first_block. + * + * The caller must ensure that no other backend is concurrently + * accessing the buffers being invalidated. + */ +void +InvalidateUndoBufferRange(uint32 log_number, BlockNumber first_block, + BlockNumber last_block) +{ + RelFileLocator rlocator; + SMgrRelation srel; + ForkNumber forknum = UndoLogForkNum; + + Assert(first_block <= last_block); + + UndoLogGetRelFileLocator(log_number, &rlocator); + srel = smgropen(rlocator, INVALID_PROC_NUMBER); + + DropRelationBuffers(srel, &forknum, 1, &first_block); + + smgrclose(srel); +} diff --git a/src/backend/access/undo/undo_xlog.c b/src/backend/access/undo/undo_xlog.c new file mode 100644 index 0000000000000..a63cc22b20fd5 --- /dev/null +++ b/src/backend/access/undo/undo_xlog.c @@ -0,0 +1,1459 @@ +/*------------------------------------------------------------------------- + * + * undo_xlog.c + * UNDO resource manager WAL redo routines + * + * This module implements the WAL redo callback for the RM_UNDO_ID resource + * manager. It handles replay of: + * + * XLOG_UNDO_ALLOCATE - Replay UNDO log space allocation + * XLOG_UNDO_DISCARD - Replay UNDO record discard + * XLOG_UNDO_EXTEND - Replay UNDO log file extension + * XLOG_UNDO_APPLY_RECORD - Replay CLR (Compensation Log Record) + * + * CLR Redo Strategy + * ----------------- + * CLRs for UNDO application use REGBUF_FORCE_IMAGE to store a full page + * image. During redo, XLogReadBufferForRedo() will restore the full page + * image automatically (returning BLK_RESTORED). No additional replay + * logic is needed because the page image already contains the result of + * the UNDO application. + * + * This is the same strategy used by ZHeap (log_zheap_undo_actions with + * REGBUF_FORCE_IMAGE) and is the simplest correct approach for crash + * recovery of UNDO operations. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undo_xlog.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm.h" +/* + * FIXME(reviewer-item-2): agnosticism breach. The core UNDO WAL layer + * should not know heap WAL record formats. This #include and the + * RM_HEAP_ID branches in UndoValidateBatchLSN() and + * UndoReadBatchFromWAL() below embed per-RM WAL-format knowledge (opcode -> + * payload offset, XLH_*_HAS_UNDO flag tests) directly here. The clean fix + * is an optional rmgr callback (e.g. rm_undo_batch_locate) that the owning + * rmgr implements and core calls if present. Deferred: adding an rmgr method + * changes the RmgrData struct (a PGDLLIMPORT, ABI for custom-rmgr + * extensions), the PG_RMGR macro arity, and every PG_RMGR line in + * rmgrlist.h (~25 RMs) plus the parallel pg_waldump table -- a tree-wide + * WAL-record-ABI change out of scope for this fix pass. + */ +#include "access/heapam_xlog.h" +#include "access/htup_details.h" +#include "access/twophase.h" +#include "access/undo_xlog.h" +#include "access/undolog.h" +#include "access/undorecord.h" +#include "access/undormgr.h" +#include "access/xlog.h" +#include "access/xlog_internal.h" +#include "access/xlogreader.h" +#include "access/xlogutils.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/bufpage.h" +#include "storage/itemid.h" +#include "utils/memutils.h" + +/* + * undo_redo - Replay an UNDO WAL record during crash recovery + * + * This function handles all UNDO resource manager WAL record types. + * For CLRs (XLOG_UNDO_APPLY_RECORD), the full page image is restored + * automatically by XLogReadBufferForRedo(), so no additional replay + * logic is needed. + */ +void +undo_redo(XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + + switch (info) + { + case XLOG_UNDO_ALLOCATE: + { + xl_undo_allocate *xlrec = (xl_undo_allocate *) XLogRecGetData(record); + + /* + * During recovery, update the UNDO log's insert pointer to + * reflect this allocation. This ensures that after crash + * recovery the UNDO log metadata is consistent. + * + * Note: UndoLogShared may not be initialized yet during early + * recovery. We guard against that. + */ + if (UndoLogShared != NULL) + { + UndoLogControl *log = NULL; + int i; + + /* + * Find the log control structure. O(MAX_UNDO_LOGS) scan: + * with MAX_UNDO_LOGS=64 this is acceptable at recovery + * time (called once per record). + */ + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + if (UndoLogShared->logs[i].in_use && + UndoLogShared->logs[i].log_number == xlrec->log_number) + { + log = &UndoLogShared->logs[i]; + break; + } + } + + if (log == NULL) + { + /* Log doesn't exist yet, create it */ + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + if (!UndoLogShared->logs[i].in_use) + { + log = &UndoLogShared->logs[i]; + log->log_number = xlrec->log_number; + pg_atomic_write_u64(&log->insert_ptr, xlrec->start_ptr); + log->discard_ptr = MakeUndoRecPtr(xlrec->log_number, 0); + log->oldest_xid = InvalidTransactionId; + log->in_use = true; + break; + } + } + } + + if (log != NULL) + { + /* + * Advance insert pointer past this allocation. Only + * move forward, never regress -- with coalesced WAL + * records from concurrent backends, a later record + * may cover a range already subsumed by an earlier + * one. + */ + UndoRecPtr new_end = xlrec->start_ptr + xlrec->length; + UndoRecPtr cur_ptr = pg_atomic_read_u64(&log->insert_ptr); + + if (new_end > cur_ptr) + pg_atomic_write_u64(&log->insert_ptr, new_end); + } + } + } + break; + + case XLOG_UNDO_DISCARD: + { + xl_undo_discard *xlrec = (xl_undo_discard *) XLogRecGetData(record); + + if (UndoLogShared != NULL) + { + int i; + + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + if (UndoLogShared->logs[i].in_use && + UndoLogShared->logs[i].log_number == xlrec->log_number) + { + UndoLogShared->logs[i].discard_ptr = xlrec->discard_ptr; + UndoLogShared->logs[i].oldest_xid = xlrec->oldest_xid; + break; + } + } + } + } + break; + + case XLOG_UNDO_EXTEND: + { + xl_undo_extend *xlrec = (xl_undo_extend *) XLogRecGetData(record); + + /* + * Extend the UNDO log file to the specified size. The file + * will be created if it doesn't exist. + * + * With append-only I/O, the smgr-managed file is no longer + * used -- UNDO data is written directly to the segment file. + */ + ExtendUndoLogFile(xlrec->log_number, xlrec->new_size); + } + break; + + case XLOG_UNDO_APPLY_RECORD: + { + /* + * Physiological CLR redo: re-apply the exact page + * modification that was performed during UNDO application. + * + * If a full page image is present (BLK_RESTORED or + * UNDO_CLR_FULL_PAGE), the page is already correct. Otherwise + * (BLK_NEEDS_REDO), we replay the operation using the + * metadata and optional tuple data in the record. + */ + xl_undo_apply *xlrec; + Buffer buffer; + XLogRedoAction action; + + xlrec = (xl_undo_apply *) XLogRecGetData(record); + action = XLogReadBufferForRedo(record, 0, &buffer); + + switch (action) + { + case BLK_RESTORED: + /* Full page image applied -- nothing more to do */ + break; + + case BLK_DONE: + /* Page already up-to-date (LSN check) */ + break; + + case BLK_NEEDS_REDO: + { + Page page = BufferGetPage(buffer); + + if (xlrec->clr_flags & UNDO_CLR_LP_DEAD) + { + /* + * Mark the line pointer LP_DEAD, keeping its + * storage. Used only by nbtree/hash index + * INSERT undo, where btree binary search still + * reads the dead tuple's key until VACUUM + * physically removes it. Must match the + * forward-apply path (ItemIdMarkDead), which + * does not zero lp_off/lp_len. + */ + ItemId lp = PageGetItemId(page, + xlrec->target_offset); + + if (ItemIdIsNormal(lp)) + ItemIdMarkDead(lp); + } + else if (xlrec->clr_flags & UNDO_CLR_LP_UNUSED) + { + /* + * Mark the line pointer LP_UNUSED. Used for + * INSERT undo (no indexes). + */ + ItemId lp = PageGetItemId(page, + xlrec->target_offset); + + ItemIdSetUnused(lp); + PageSetHasFreeLinePointers(page); + } + else if (xlrec->clr_flags & UNDO_CLR_HAS_TUPLE) + { + /* + * Restore tuple data. Used for DELETE undo, + * full-tuple UPDATE undo, and INPLACE undo. + * The tuple data is in the buffer-specific + * data registered with block reference 0. + */ + ItemId lp = PageGetItemId(page, + xlrec->target_offset); + + if (ItemIdIsUsed(lp) && ItemIdHasStorage(lp) && + xlrec->tuple_len > 0) + { + HeapTupleHeader htup; + char *data; + Size datalen; + + data = XLogRecGetBlockData(record, 0, + &datalen); + Assert(data != NULL); + Assert(datalen >= xlrec->tuple_len); + + ItemIdSetNormal(lp, ItemIdGetOffset(lp), + xlrec->tuple_len); + htup = (HeapTupleHeader) PageGetItem(page, lp); + memcpy(htup, data, xlrec->tuple_len); + } + } + else if (xlrec->clr_flags & UNDO_CLR_HAS_DELTA) + { + /* + * Delta-encoded UPDATE redo. Reconstruct old + * tuple from current page contents + delta. + * The delta data (HeapUndoDeltaHeader + + * changed bytes) is in block data. + */ + ItemId lp = PageGetItemId(page, + xlrec->target_offset); + + if (ItemIdIsUsed(lp) && ItemIdHasStorage(lp)) + { + char *data; + Size datalen; + HeapTupleHeader cur_htup; + const char *cur_data; + Size cur_len; + uint16 prefix_len; + uint16 suffix_len; + uint32 changed_len; + uint32 old_tuple_len; + const char *changed_data; + char *restored; + Size hdr_size; + + data = XLogRecGetBlockData(record, 0, + &datalen); + Assert(data != NULL); + + /* + * The block data contains: - + * old_tuple_len (uint32) - prefix_len + * (uint16) - suffix_len (uint16) - + * changed_len (uint32) - changed bytes + * (changed_len) + */ + hdr_size = sizeof(uint32) + + 2 * sizeof(uint16) + sizeof(uint32); + + if (datalen < hdr_size) + ereport(ERROR, + (errmsg("invalid delta CLR at %X/%X: " + "block data too short (%zu bytes)", + LSN_FORMAT_ARGS(record->ReadRecPtr), + datalen))); + + memcpy(&old_tuple_len, data, sizeof(uint32)); + memcpy(&prefix_len, data + sizeof(uint32), + sizeof(uint16)); + memcpy(&suffix_len, + data + sizeof(uint32) + sizeof(uint16), + sizeof(uint16)); + memcpy(&changed_len, + data + sizeof(uint32) + 2 * sizeof(uint16), + sizeof(uint32)); + changed_data = data + hdr_size; + + cur_htup = (HeapTupleHeader) + PageGetItem(page, lp); + cur_data = (const char *) cur_htup; + cur_len = ItemIdGetLength(lp); + + /* + * Validate lengths before any pointer + * arithmetic: a corrupt CLR could + * otherwise cause a buffer underrun or + * overflow. + */ + if (prefix_len > cur_len || + suffix_len > cur_len || + prefix_len + suffix_len > cur_len || + (Size) (prefix_len + changed_len + suffix_len) != (Size) old_tuple_len || + datalen < hdr_size + changed_len) + ereport(ERROR, + (errmsg("invalid delta CLR at %X/%X: " + "prefix=%u suffix=%u changed=%u " + "old_len=%u cur_len=%zu", + LSN_FORMAT_ARGS(record->ReadRecPtr), + prefix_len, suffix_len, + changed_len, old_tuple_len, + cur_len))); + + restored = palloc(old_tuple_len); + + /* prefix from current tuple */ + if (prefix_len > 0) + memcpy(restored, cur_data, prefix_len); + + /* changed middle from CLR data */ + if (changed_len > 0) + memcpy(restored + prefix_len, + changed_data, changed_len); + + /* suffix from current tuple */ + if (suffix_len > 0) + memcpy(restored + prefix_len + changed_len, + cur_data + cur_len - suffix_len, + suffix_len); + + ItemIdSetNormal(lp, ItemIdGetOffset(lp), + old_tuple_len); + memcpy(cur_htup, restored, old_tuple_len); + pfree(restored); + } + } + else if (xlrec->clr_flags & UNDO_CLR_HAS_VISIBILITY) + { + /* + * Visibility-delta redo: restore only the + * three tuple-header fields changed by + * heap_delete(). The column data is unchanged + * on the page. + */ + char *vis_data; + Size vis_datalen; + xl_undo_apply_visibility vis_rec; + ItemId vlp; + HeapTupleHeader vhtup; + + vis_data = XLogRecGetBlockData(record, 0, + &vis_datalen); + Assert(vis_data != NULL); + Assert(vis_datalen >= SizeOfUndoApplyVisibility); + + memcpy(&vis_rec, vis_data, + SizeOfUndoApplyVisibility); + + vlp = PageGetItemId(page, + xlrec->target_offset); + if (ItemIdIsUsed(vlp) && ItemIdHasStorage(vlp)) + { + vhtup = (HeapTupleHeader) + PageGetItem(page, vlp); + HeapTupleHeaderSetXmax(vhtup, + vis_rec.old_xmax); + vhtup->t_infomask = + vis_rec.old_infomask; + vhtup->t_infomask2 = + vis_rec.old_infomask2; + } + } + else if (xlrec->clr_flags & UNDO_CLR_HOT_RESTORE) + { + /* + * HOT update rollback: restore old tuple's + * infomask and kill new tuple version. + */ + char *data; + Size datalen; + xl_undo_apply_hot hot_data; + ItemId old_lp; + HeapTupleHeader old_htup; + ItemId new_lp; + + data = XLogRecGetBlockData(record, 0, + &datalen); + Assert(data != NULL); + Assert(datalen >= SizeOfUndoApplyHot); + + memcpy(&hot_data, data, SizeOfUndoApplyHot); + + old_lp = PageGetItemId(page, + xlrec->target_offset); + if (ItemIdIsNormal(old_lp)) + { + old_htup = (HeapTupleHeader) + PageGetItem(page, old_lp); + old_htup->t_infomask = hot_data.old_infomask; + old_htup->t_infomask2 = hot_data.old_infomask2; + ItemPointerSet(&old_htup->t_ctid, + xlrec->target_block, + xlrec->target_offset); + } + + /* Kill the new tuple version */ + new_lp = PageGetItemId(page, + hot_data.new_offset); + if (ItemIdIsNormal(new_lp)) + ItemIdSetDead(new_lp); + } + + PageSetLSN(page, record->EndRecPtr); + MarkBufferDirty(buffer); + } + break; + + case BLK_NOTFOUND: + /* Block doesn't exist (truncated?) -- skip */ + break; + } + + if (BufferIsValid(buffer)) + UnlockReleaseBuffer(buffer); + } + break; + + case XLOG_UNDO_PAGE_WRITE: + + /* + * XLOG_UNDO_PAGE_WRITE is no longer emitted (append-only I/O + * architecture writes directly via pwrite, not through + * shared_buffers). We keep this case for backward compatibility + * with WAL from before the transition. Old records are simply + * ignored -- the UNDO data was already written to the segment + * file by the originating backend. + */ + break; + + case XLOG_UNDO_BATCH: + { + xl_undo_batch *xlrec = (xl_undo_batch *) XLogRecGetData(record); + + /* + * During recovery, track this batch for incomplete + * transaction detection. After redo completes, any + * transaction that wrote UNDO batches but did not commit will + * need its UNDO chain walked for rollback. + * + * The batch payload (serialized UNDO records) is part of the + * WAL record and can be re-read later via XLogReadRecord() + * during the undo phase. + */ + UndoRecoveryTrackBatch(xlrec->xid, record->ReadRecPtr, + xlrec->chain_prev, + xlrec->persistence); + + ereport(DEBUG2, + (errmsg("undo_redo: BATCH xid %u, nrecords %u, " + "total_len %u, chain_prev %X/%X", + xlrec->xid, xlrec->nrecords, + xlrec->total_len, + LSN_FORMAT_ARGS(xlrec->chain_prev)))); + } + break; + + case XLOG_UNDO_ROTATE: + { + xl_undo_rotate *xlrec = (xl_undo_rotate *) XLogRecGetData(record); + + /* + * Replay segment rotation: mark the old log SEALED and the + * new log ACTIVE. This reconstructs the lifecycle state so + * that after recovery the discard worker can clean up sealed + * logs properly. + */ + if (UndoLogShared != NULL) + { + int j; + + /* Seal the old log */ + if (xlrec->old_log_number != 0) + { + for (j = 0; j < MAX_UNDO_LOGS; j++) + { + UndoLogControl *old_log = &UndoLogShared->logs[j]; + + if (old_log->in_use && + old_log->log_number == xlrec->old_log_number) + { + old_log->state = UNDO_LOG_SEALED; + pg_atomic_write_u64(&old_log->seal_ptr, + xlrec->old_seal_ptr); + break; + } + } + } + + /* Activate the new log (find or create slot) */ + { + UndoLogControl *new_log = NULL; + int new_slot = -1; + + /* Check if it already exists (idempotent replay) */ + for (j = 0; j < MAX_UNDO_LOGS; j++) + { + if (UndoLogShared->logs[j].in_use && + UndoLogShared->logs[j].log_number == xlrec->new_log_number) + { + new_log = &UndoLogShared->logs[j]; + new_slot = j; + break; + } + } + + /* If not found, allocate a free slot */ + if (new_log == NULL) + { + for (j = 0; j < MAX_UNDO_LOGS; j++) + { + if (!UndoLogShared->logs[j].in_use) + { + new_log = &UndoLogShared->logs[j]; + new_slot = j; + new_log->log_number = xlrec->new_log_number; + pg_atomic_write_u64(&new_log->insert_ptr, + MakeUndoRecPtr(xlrec->new_log_number, 0)); + new_log->discard_ptr = MakeUndoRecPtr(xlrec->new_log_number, 0); + new_log->oldest_xid = InvalidTransactionId; + new_log->in_use = true; + break; + } + } + } + + if (new_log != NULL) + { + new_log->state = UNDO_LOG_ACTIVE; + pg_atomic_write_u64(&new_log->seal_ptr, InvalidUndoRecPtr); + pg_atomic_write_u32(&UndoLogShared->active_log_idx, + (uint32) new_slot); + } + } + } + } + break; + + default: + elog(PANIC, "undo_redo: unknown op code %u", info); + } +} + +/* ---------------------------------------------------------------- + * UNDO recovery tracking + * + * During WAL redo, we track which transactions wrote UNDO batches. + * When a commit/abort record is redone, the XID is removed. + * After redo completes, remaining entries represent incomplete + * transactions that need their UNDO chains walked. + * ---------------------------------------------------------------- + */ + +/* Hash table entry for tracking incomplete UNDO transactions */ +typedef struct UndoRecoveryEntry +{ + TransactionId xid; /* hash key */ + XLogRecPtr last_batch_lsn[NUndoPersistenceLevels]; /* chain heads per + * persistence level */ + char status; /* in use */ +} UndoRecoveryEntry; + +/* Simple dynamic array for recovery tracking (used during startup only) */ +static UndoRecoveryEntry * undo_recovery_entries = NULL; +static int undo_recovery_nentries = 0; +static int undo_recovery_capacity = 0; + +/* + * Safety cap to prevent OOM during recovery. If more than this many + * distinct in-flight XIDs are found in WAL at crash time, we stop + * tracking new ones and log a warning. The untracked transactions will + * need manual resolution (e.g. via pg_resetwal or targeted UNDO apply). + * + * 1 million entries Ɨ ~40 bytes each ā‰ˆ 40 MB, which is reasonable for + * a recovery-only allocation. + */ +#define UNDO_RECOVERY_MAX_ENTRIES 1048576 + +/* + * UndoRecoveryTrackBatch - Record an UNDO batch during WAL redo + * + * Called from the XLOG_UNDO_BATCH redo handler to track which + * transactions have UNDO data that may need rollback. + */ +void +UndoRecoveryTrackBatch(TransactionId xid, XLogRecPtr batch_lsn, + XLogRecPtr chain_prev, + UndoPersistenceLevel persistence) +{ + int i; + UndoRecoveryEntry *entry = NULL; + + if (!TransactionIdIsValid(xid)) + return; + + /* Find existing entry for this XID */ + for (i = 0; i < undo_recovery_nentries; i++) + { + if (undo_recovery_entries[i].xid == xid) + { + entry = &undo_recovery_entries[i]; + break; + } + } + + /* Create new entry if needed */ + if (entry == NULL) + { + /* Safety cap: refuse to track more XIDs to prevent OOM */ + if (undo_recovery_nentries >= UNDO_RECOVERY_MAX_ENTRIES) + { + static bool warned = false; + + if (!warned) + { + ereport(WARNING, + (errmsg("UNDO recovery: reached maximum tracked transaction limit (%d)", + UNDO_RECOVERY_MAX_ENTRIES), + errhint("Transactions beyond this limit will not be automatically rolled back. " + "Manual intervention may be required after recovery completes."))); + warned = true; + } + return; + } + + if (undo_recovery_nentries >= undo_recovery_capacity) + { + int new_capacity = (undo_recovery_capacity == 0) ? 64 : + undo_recovery_capacity * 2; + UndoRecoveryEntry *new_entries; + + /* Clamp doubling to not exceed the safety cap */ + if (new_capacity > UNDO_RECOVERY_MAX_ENTRIES) + new_capacity = UNDO_RECOVERY_MAX_ENTRIES; + + if (undo_recovery_entries == NULL) + { + new_entries = (UndoRecoveryEntry *) + palloc0(sizeof(UndoRecoveryEntry) * new_capacity); + } + else + { + new_entries = (UndoRecoveryEntry *) + repalloc(undo_recovery_entries, + sizeof(UndoRecoveryEntry) * new_capacity); + memset(&new_entries[undo_recovery_capacity], 0, + sizeof(UndoRecoveryEntry) * (new_capacity - undo_recovery_capacity)); + } + undo_recovery_entries = new_entries; + undo_recovery_capacity = new_capacity; + } + + entry = &undo_recovery_entries[undo_recovery_nentries++]; + entry->xid = xid; + for (i = 0; i < NUndoPersistenceLevels; i++) + entry->last_batch_lsn[i] = InvalidXLogRecPtr; + } + + /* Update the chain head for this persistence level */ + if (persistence < NUndoPersistenceLevels) + entry->last_batch_lsn[persistence] = batch_lsn; +} + +/* + * UndoRecoveryRemoveXid - Remove an XID from recovery tracking + * + * Called when a commit or abort record is redone during recovery. + * Committed transactions don't need UNDO rollback. Aborted transactions + * that were already fully rolled back (abort record present) also don't + * need further work. + */ +void +UndoRecoveryRemoveXid(TransactionId xid) +{ + int i; + + if (!TransactionIdIsValid(xid)) + return; + + for (i = 0; i < undo_recovery_nentries; i++) + { + if (undo_recovery_entries[i].xid == xid) + { + /* Mark as removed by zeroing XID */ + undo_recovery_entries[i].xid = InvalidTransactionId; + break; + } + } +} + +/* + * UndoRecoveryNeeded - Check if there are incomplete transactions needing UNDO + * + * Returns true if any tracked transactions remain after redo is complete. + */ +bool +UndoRecoveryNeeded(void) +{ + int i; + + for (i = 0; i < undo_recovery_nentries; i++) + { + if (TransactionIdIsValid(undo_recovery_entries[i].xid)) + return true; + } + + return false; +} + +/* + * DeferredUndoXact - Transaction deferred for async UNDO processing + * + * During crash recovery, if syscache isn't available, we skip UNDO application + * and defer the transaction for later processing by the logical revert worker. + */ +typedef struct DeferredUndoXact +{ + TransactionId xid; + Oid dboid; + XLogRecPtr last_batch_lsn; + struct DeferredUndoXact *next; +} DeferredUndoXact; + +static DeferredUndoXact * deferred_undo_xacts = NULL; + +/* + * PerformUndoRecovery - Walk and apply UNDO chains for incomplete transactions + * + * This is the ARIES-style undo phase, called after the redo loop completes. + * For each incomplete transaction that wrote UNDO batches, we walk the + * UNDO chain backward and apply each record via the RM dispatch table. + * + * CLRs are generated during this phase to ensure idempotency in case of + * a crash during the undo phase itself. + * + * If UNDO application is skipped (e.g., due to syscache not being available), + * the transaction is tracked for deferred processing after recovery completes. + */ +void +PerformUndoRecovery(void) +{ + int i, + j; + int total_xacts = 0; + int total_records = 0; + int pending_xacts = 0; + int deferred_xacts = 0; + + /* Count pending transactions for the opening log message. */ + for (i = 0; i < undo_recovery_nentries; i++) + { + if (TransactionIdIsValid(undo_recovery_entries[i].xid)) + pending_xacts++; + } + + if (pending_xacts > 0) + ereport(LOG, + (errmsg("UNDO recovery: %d incomplete transaction(s) to roll back", + pending_xacts))); + + for (i = 0; i < undo_recovery_nentries; i++) + { + UndoRecoveryEntry *entry = &undo_recovery_entries[i]; + bool any_skipped = false; + + if (!TransactionIdIsValid(entry->xid)) + continue; + + /* + * Skip prepared transactions. Prepared (2PC) transactions must remain + * in the prepared state after crash recovery, not be automatically + * rolled back. They will be explicitly committed or rolled back later + * via COMMIT PREPARED or ROLLBACK PREPARED. + * + * During recovery, RecoveryTransactionIdIsPrepared() checks the + * in-memory prepared transaction state reconstructed from WAL replay. + */ + if (RecoveryTransactionIdIsPrepared(entry->xid)) + { + ereport(LOG, + (errmsg("UNDO recovery: skipping prepared transaction %u " + "(will remain in prepared state)", + entry->xid))); + continue; + } + + total_xacts++; + + ereport(LOG, + (errmsg("UNDO recovery: rolling back transaction %u", + entry->xid))); + + /* + * Walk each persistence level's UNDO chain independently. This + * mirrors the normal abort path in AtAbort_XactUndo(). + * + * TEMP and UNLOGGED levels are skipped during crash recovery: - TEMP: + * temporary tables are destroyed on server restart, so there is + * nothing to roll back and the pages no longer exist. - UNLOGGED: + * unlogged table files are reset to empty on crash recovery + * (initfork), making any prior UNDO application wrong. + */ + for (j = 0; j < NUndoPersistenceLevels; j++) + { + XLogRecPtr batch_lsn = entry->last_batch_lsn[j]; + + if (j == UNDOPERSISTENCE_TEMP || j == UNDOPERSISTENCE_UNLOGGED) + continue; + + while (XLogRecPtrIsValid(batch_lsn)) + { + UndoBatchData *batch; + char *pos; + char *end; + + batch = UndoReadBatchFromWAL(batch_lsn); + if (batch == NULL) + { + /* + * A missing or unreadable UNDO batch during crash + * recovery. This can happen with fsync=off when WAL + * was not persisted before the crash, or when WAL + * segments were recycled before the UNDO chain was + * fully applied. + * + * Rather than PANIC (which makes the database + * permanently unrecoverable), skip this transaction's + * rollback. The affected tuples will retain their + * UNCOMMITTED flag and be invisible until VACUUM + * removes them. This is a bounded anomaly similar to + * the old hash-overflow degraded mode. + */ + ereport(WARNING, + (errmsg("UNDO recovery: could not read batch at %X/%X " + "for transaction %u; skipping rollback " + "(affected tuples will be cleaned by VACUUM)", + LSN_FORMAT_ARGS(batch_lsn), + entry->xid))); + break; /* skip remaining chain for this persistence level */ + } + + /* Walk records within this batch */ + pos = batch->payload; + end = pos + batch->payload_len; + + while (pos < end) + { + UndoRecordHeader header; + char *payload = NULL; + + if ((Size) (end - pos) < SizeOfUndoRecordHeader) + break; + + memcpy(&header, pos, SizeOfUndoRecordHeader); + + if (header.urec_len < SizeOfUndoRecordHeader || + (Size) (end - pos) < header.urec_len) + break; + + if (header.urec_payload_len > 0) + payload = pos + SizeOfUndoRecordHeader; + + /* + * Apply this UNDO record via the RM dispatch table. + * + * Idempotency note: urec_clr_ptr in the UNDO record + * header is always InvalidXLogRecPtr (UNDO records are + * immutable in WAL; the CLR is a separate WAL record that + * cannot update them). Double-application is prevented by + * page LSN: each CLR bumps the heap page LSN to the CLR's + * EndRecPtr. When rm_undo reads the buffer, + * XLogReadBufferForRedo returns BLK_DONE or BLK_RESTORED + * for pages that were already restored by a CLR in Phase + * 1 redo, preventing re-application. + */ + { + const UndoRmgrData *rmgr = GetUndoRmgr(header.urec_rmid); + + if (rmgr != NULL) + { + UndoApplyResult result; + + result = rmgr->rm_undo(header.urec_rmid, + header.urec_info, + header.urec_xid, + header.urec_reloid, + payload, + header.urec_payload_len, + InvalidUndoRecPtr); + total_records++; + + /* + * If any UNDO record was skipped (e.g., due to + * syscache not being initialized), mark this + * transaction for deferred processing by the + * logical revert worker. + */ + if (result == UNDO_APPLY_SKIPPED) + any_skipped = true; + } + } + + pos += header.urec_len; + } + + /* Follow chain to previous batch */ + { + XLogRecPtr next_lsn = batch->header.chain_prev; + + /* + * Guard against circular or forward-pointing chains: + * chain_prev must be strictly older (smaller LSN) than + * the current batch or invalid (end of chain). A + * forward- pointing chain_prev would cause an infinite + * loop. + */ + if (XLogRecPtrIsValid(next_lsn) && next_lsn >= batch_lsn) + ereport(PANIC, + (errmsg("UNDO recovery: chain_prev %X/%X >= batch_lsn %X/%X " + "for transaction %u; corrupt UNDO chain", + LSN_FORMAT_ARGS(next_lsn), + LSN_FORMAT_ARGS(batch_lsn), + entry->xid))); + UndoFreeBatchData(batch); + batch_lsn = next_lsn; + } + } + } + + /* + * If any UNDO records were skipped (e.g., due to syscache not being + * initialized during early recovery), track this transaction for + * deferred processing. We cannot add it to the ATM yet because + * ATMAddAborted() writes WAL, which isn't allowed during recovery. + * + * Instead, we add it to an in-memory list that will be flushed to the + * ATM after recovery completes (when InRedo is set to false). + * + * Use the permanent persistence level's last_batch_lsn for tracking. + * TEMP and UNLOGGED are skipped during crash recovery anyway. + */ + if (any_skipped) + { + XLogRecPtr perm_lsn = entry->last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; + + if (XLogRecPtrIsValid(perm_lsn)) + { + DeferredUndoXact *deferred = (DeferredUndoXact *) + palloc(sizeof(DeferredUndoXact)); + + deferred->xid = entry->xid; + deferred->dboid = MyDatabaseId; + deferred->last_batch_lsn = perm_lsn; + deferred->next = deferred_undo_xacts; + deferred_undo_xacts = deferred; + + deferred_xacts++; + ereport(LOG, + (errmsg("UNDO recovery: transaction %u deferred to " + "logical revert worker (syscache not ready)", + entry->xid))); + } + } + } + + if (total_xacts > 0) + { + if (deferred_xacts > 0) + ereport(LOG, + (errmsg("UNDO recovery complete: %d transactions processed, " + "%d records applied, %d transactions deferred to " + "logical revert worker", + total_xacts, total_records, deferred_xacts))); + else + ereport(LOG, + (errmsg("UNDO recovery complete: %d transactions rolled back, " + "%d records applied", + total_xacts, total_records))); + } + + /* Free tracking data */ + if (undo_recovery_entries != NULL) + { + pfree(undo_recovery_entries); + undo_recovery_entries = NULL; + } + undo_recovery_nentries = 0; + undo_recovery_capacity = 0; +} + +/* + * FlushDeferredUndoXacts - Add deferred transactions to the ATM + * + * Called after recovery completes (when InRedo is false) to add any + * transactions that were deferred during UNDO recovery to the Aborted + * Transaction Map (ATM). These transactions will be processed + * asynchronously by the logical revert worker. + * + * This must be called after InRedo is set to false because ATMAddAborted() + * writes WAL, which is not allowed during recovery. + */ +void +FlushDeferredUndoXacts(void) +{ + DeferredUndoXact *deferred; + int count = 0; + + if (deferred_undo_xacts == NULL) + return; + + ereport(LOG, + (errmsg("flushing deferred UNDO transactions to ATM"))); + + /* Walk the list and add each transaction to the ATM */ + while (deferred_undo_xacts != NULL) + { + deferred = deferred_undo_xacts; + deferred_undo_xacts = deferred->next; + + ATMAddAborted(deferred->xid, deferred->dboid, deferred->last_batch_lsn); + count++; + + pfree(deferred); + } + + if (count > 0) + ereport(LOG, + (errmsg("added %d deferred transaction(s) to ATM for async UNDO processing", + count))); +} + +/* ---------------------------------------------------------------- + * UNDO batch reading from WAL + * ---------------------------------------------------------------- + */ + +/* + * UndoReadBatchFromWAL - Read a single XLOG_UNDO_BATCH record from WAL + * + * Uses XLogReader to read the WAL record at the given LSN. + * Returns a palloc'd UndoBatchData containing the header and a copy + * of the payload. The caller must pfree via UndoFreeBatchData(). + * + * Returns NULL if the record cannot be read or is not an UNDO batch. + */ +/* Module-level cached XLogReader for UndoReadBatchFromWAL. + * Allocated once and reused across calls to avoid per-batch + * open/close overhead on WAL segment files during rollback. + */ +static XLogReaderState *undo_batch_reader = NULL; +static XLogReaderRoutine undo_batch_reader_routine = { + .page_read = read_local_xlog_page, + .segment_open = wal_segment_open, + .segment_close = wal_segment_close, +}; + +/* + * UndoValidateBatchLSN + * Quick check that the WAL record at batch_lsn is a valid UNDO source. + * + * Returns true if the record is RM_UNDO_ID (standalone batch) or RM_HEAP_ID + * with a HAS_UNDO flag. Returns false for any other unrecognized + * record type. Used by the inline UNDO path to avoid calling + * ApplyUndoChainFromWAL on a batch_lsn that points to the wrong record type. + * + * FIXME(reviewer-item-2): the RM_HEAP_ID branch below tests XLH_*_HAS_UNDO + * heap WAL flags -- per-RM WAL-format knowledge that belongs behind an rmgr + * callback, not in the AM-agnostic UNDO core. See the note at the + * heapam_xlog.h #include for why the extraction is deferred. + */ +bool +UndoValidateBatchLSN(XLogRecPtr batch_lsn) +{ + XLogRecord *record_hdr; + char *errormsg = NULL; + uint8 rmid; + MemoryContext old_ctx; + bool result = false; + + if (!XLogRecPtrIsValid(batch_lsn)) + return false; + + /* + * Perform the entire read under TopMemoryContext. The cached reader and, + * critically, its lazily-allocated decode_buffer (xlogreader.c) are palloc'd + * in CurrentMemoryContext. Callers such as the inline-abort path run with a + * transient context that is deleted right after; allocating the reader state + * there would leave the static undo_batch_reader (and its decode buffer) + * dangling, crashing the next abort. TopMemoryContext makes the cache truly + * persistent. + */ + old_ctx = MemoryContextSwitchTo(TopMemoryContext); + + if (undo_batch_reader == NULL) + { + undo_batch_reader = XLogReaderAllocate(wal_segment_size, NULL, + &undo_batch_reader_routine, NULL); + if (undo_batch_reader == NULL) + { + MemoryContextSwitchTo(old_ctx); + return false; + } + } + + XLogBeginRead(undo_batch_reader, batch_lsn); + record_hdr = XLogReadRecord(undo_batch_reader, &errormsg); + if (record_hdr == NULL) + { + MemoryContextSwitchTo(old_ctx); + return false; + } + + rmid = XLogRecGetRmid(undo_batch_reader); + + /* Standalone UNDO batch */ + if (rmid == RM_UNDO_ID) + result = true; + /* Heap record with embedded UNDO */ + else if (rmid == RM_HEAP_ID) + { + uint8 info = XLogRecGetInfo(undo_batch_reader) & XLOG_HEAP_OPMASK; + char *data = XLogRecGetData(undo_batch_reader); + + if (info == XLOG_HEAP_INSERT) + result = (((xl_heap_insert *) data)->flags & XLH_INSERT_HAS_UNDO) != 0; + else if (info == XLOG_HEAP_DELETE) + result = (((xl_heap_delete *) data)->flags & XLH_DELETE_HAS_UNDO) != 0; + else if (info == XLOG_HEAP_UPDATE || info == XLOG_HEAP_HOT_UPDATE) + result = (((xl_heap_update *) data)->flags & XLH_UPDATE_HAS_UNDO) != 0; + } + + /* Any other rmid is not a valid inline UNDO source */ + MemoryContextSwitchTo(old_ctx); + return result; +} + +UndoBatchData * +UndoReadBatchFromWAL(XLogRecPtr batch_lsn) +{ + XLogRecord *record_hdr; + char *errormsg = NULL; + UndoBatchData *result; + xl_undo_batch *xlrec; + char *record_data; + Size record_len; + Size payload_offset; + + if (!XLogRecPtrIsValid(batch_lsn)) + return NULL; + + /* + * Safety check: verify the WAL segment containing this LSN has not been + * recycled by a checkpoint. If the LSN is behind the current redo + * pointer and the segment file doesn't exist, reading would cause SIGBUS + * (signal 10: Bus error) or SIGSEGV. Return NULL gracefully instead. + * + * Compare against GetRedoRecPtr() — if our target is well behind the + * redo pointer AND behind the last checkpoint's redo location, the + * segment may have been recycled. + */ + { + XLogRecPtr redo_ptr = GetRedoRecPtr(); + XLogSegNo target_segno; + char path[MAXPGPATH]; + + if (batch_lsn < redo_ptr) + { + XLByteToSeg(batch_lsn, target_segno, wal_segment_size); + XLogFilePath(path, GetWALInsertionTimeLine(), target_segno, + wal_segment_size); + + if (access(path, F_OK) != 0) + { + ereport(WARNING, + (errmsg("UNDO batch at %X/%X: WAL segment \"%s\" no longer " + "exists (recycled by checkpoint); skipping rollback", + LSN_FORMAT_ARGS(batch_lsn), path))); + return NULL; + } + } + } + + /* + * Allocate the reader and perform the read under TopMemoryContext. Both + * the cached reader and its lazily-allocated decode_buffer (xlogreader.c) + * are palloc'd in CurrentMemoryContext; if that is a transient caller + * context (e.g. the inline-abort context) it gets deleted, leaving the + * static undo_batch_reader and its decode buffer dangling and crashing the + * next abort. We switch back to the caller's context before allocating the + * returned UndoBatchData, which the caller owns. See the matching comment + * in UndoValidateBatchLSN. + */ + { + MemoryContext read_ctx = MemoryContextSwitchTo(TopMemoryContext); + + if (undo_batch_reader == NULL) + { + undo_batch_reader = XLogReaderAllocate(wal_segment_size, NULL, + &undo_batch_reader_routine, NULL); + if (undo_batch_reader == NULL) + { + MemoryContextSwitchTo(read_ctx); + ereport(WARNING, + (errmsg("could not allocate XLogReader for UNDO batch read"))); + return NULL; + } + } + + /* Position the reader at the target LSN, then read */ + XLogBeginRead(undo_batch_reader, batch_lsn); + record_hdr = XLogReadRecord(undo_batch_reader, &errormsg); + MemoryContextSwitchTo(read_ctx); + } + if (record_hdr == NULL) + { + if (errormsg) + ereport(WARNING, + (errmsg("could not read WAL record at %X/%X: %s", + LSN_FORMAT_ARGS(batch_lsn), errormsg))); + return NULL; + } + + /* + * Determine record format: either a standalone XLOG_UNDO_BATCH record + * (overflow path or legacy) or a heap WAL record with embedded UNDO + * (XLOG_HEAP_INSERT/DELETE/UPDATE with HAS_UNDO flag set). + * + * FIXME(reviewer-item-2): the RM_HEAP_ID branch derives the embedded + * xl_undo_batch payload offset from heap opcodes (SizeOfHeapInsert etc.) + * and tests XLH_*_HAS_UNDO flags -- heap WAL-format knowledge that should + * live behind an rmgr callback. Deferred; see the heapam_xlog.h #include. + */ + record_data = XLogRecGetData(undo_batch_reader); + record_len = XLogRecGetDataLen(undo_batch_reader); + + if (XLogRecGetRmid(undo_batch_reader) == RM_UNDO_ID && + (XLogRecGetInfo(undo_batch_reader) & ~XLR_INFO_MASK) == XLOG_UNDO_BATCH) + { + /* Standalone XLOG_UNDO_BATCH record (overflow / legacy path) */ + if (record_len < SizeOfUndoBatch) + { + ereport(WARNING, + (errmsg("UNDO batch record at %X/%X too short: %zu bytes", + LSN_FORMAT_ARGS(batch_lsn), record_len))); + return NULL; + } + + xlrec = (xl_undo_batch *) record_data; + payload_offset = SizeOfUndoBatch; + } + else if (XLogRecGetRmid(undo_batch_reader) == RM_HEAP_ID) + { + /* + * Heap WAL record with embedded UNDO payload. Determine the offset of + * the xl_undo_batch header from the opcode. + */ + uint8 info = XLogRecGetInfo(undo_batch_reader) & XLOG_HEAP_OPMASK; + + if (info == XLOG_HEAP_DELETE) + { + xl_heap_delete *del = (xl_heap_delete *) record_data; + + if (!(del->flags & XLH_DELETE_HAS_UNDO)) + { + ereport(WARNING, + (errmsg("heap DELETE record at %X/%X has no embedded UNDO", + LSN_FORMAT_ARGS(batch_lsn)))); + return NULL; + } + payload_offset = SizeOfHeapDelete; + } + else if (info == XLOG_HEAP_INSERT) + { + xl_heap_insert *ins = (xl_heap_insert *) record_data; + + if (!(ins->flags & XLH_INSERT_HAS_UNDO)) + { + ereport(WARNING, + (errmsg("heap INSERT record at %X/%X has no embedded UNDO", + LSN_FORMAT_ARGS(batch_lsn)))); + return NULL; + } + payload_offset = SizeOfHeapInsert; + } + else if (info == XLOG_HEAP_UPDATE || info == XLOG_HEAP_HOT_UPDATE) + { + xl_heap_update *upd = (xl_heap_update *) record_data; + + if (!(upd->flags & XLH_UPDATE_HAS_UNDO)) + { + ereport(WARNING, + (errmsg("heap UPDATE record at %X/%X has no embedded UNDO", + LSN_FORMAT_ARGS(batch_lsn)))); + return NULL; + } + payload_offset = SizeOfHeapUpdate; + } + else + { + ereport(WARNING, + (errmsg("unsupported heap opcode 0x%02x at %X/%X for UNDO read", + info, LSN_FORMAT_ARGS(batch_lsn)))); + return NULL; + } + + if (record_len < payload_offset + SizeOfUndoBatch) + { + ereport(WARNING, + (errmsg("heap record at %X/%X too short for embedded UNDO: %zu bytes", + LSN_FORMAT_ARGS(batch_lsn), record_len))); + return NULL; + } + + xlrec = (xl_undo_batch *) (record_data + payload_offset); + payload_offset += SizeOfUndoBatch; + } + else + { + ereport(WARNING, + (errmsg("WAL record at %X/%X is not an UNDO batch (rmid=%u, info=0x%02x)", + LSN_FORMAT_ARGS(batch_lsn), + XLogRecGetRmid(undo_batch_reader), + XLogRecGetInfo(undo_batch_reader) & ~XLR_INFO_MASK))); + return NULL; + } + + /* + * Validate that the claimed payload length fits within the WAL record. + * A mismatch here means the WAL segment was recycled and overwritten + * (the LSN now points to a different record), or the record is corrupt. + * Without this check, the memcpy below would read past the XLogReader's + * internal buffer, potentially accessing unmapped memory (SIGBUS/SIGSEGV). + */ + if (payload_offset + (Size) xlrec->total_len > record_len) + { + ereport(WARNING, + (errmsg("UNDO batch at %X/%X: payload length %u exceeds " + "record data (offset %zu, record_len %zu)", + LSN_FORMAT_ARGS(batch_lsn), + xlrec->total_len, payload_offset, record_len))); + return NULL; + } + + /* + * Allocate UndoBatchData. We use palloc (CurrentMemoryContext) because + * this structure is only needed until ApplyUndoChainFromWAL processes the + * batch. We intentionally do NOT pfree in UndoFreeBatchData() because + * calling pfree on BumpContext memory would ERROR. The memory will be + * reclaimed when the current memory context is reset. + */ + result = (UndoBatchData *) palloc(sizeof(UndoBatchData)); + memcpy(&result->header, xlrec, SizeOfUndoBatch); + result->payload_len = (Size) xlrec->total_len; + if (result->payload_len > 0) + { + result->payload = (char *) palloc(result->payload_len); + memcpy(result->payload, record_data + payload_offset, + result->payload_len); + } + else + { + result->payload = NULL; + } + + /* Do not free reader -- it is cached for reuse. */ + return result; +} + +/* + * UndoFreeBatchData - Release a UndoBatchData structure + * + * This is a no-op function. We don't actually pfree the batch or payload + * because they were allocated with palloc() from CurrentMemoryContext, which + * may be a BumpContext. Calling pfree on BumpContext memory would ERROR. + * The memory will be automatically reclaimed when the current memory context + * is reset (e.g., at end of query, transaction, or subtransaction). + * + * This function exists to maintain API compatibility and to serve as a + * clear marker in the code where batch data is no longer needed. + */ +void +UndoFreeBatchData(UndoBatchData * batch) +{ + /* Intentionally empty - memory reclaimed by context reset */ + (void) batch; +} + +/* + * UndoResetBatchReader - Free and NULL the cached WAL reader. + * + * Must be called after a PG_CATCH that could leave the static reader in + * an inconsistent state (stale segment FD, partial read buffer, etc.). + * The next call to UndoReadBatchFromWAL will reallocate a fresh reader. + */ +void +UndoResetBatchReader(void) +{ + if (undo_batch_reader != NULL) + { + XLogReaderFree(undo_batch_reader); + undo_batch_reader = NULL; + } +} diff --git a/src/backend/access/undo/undoapply.c b/src/backend/access/undo/undoapply.c new file mode 100644 index 0000000000000..dc5b185d07ca7 --- /dev/null +++ b/src/backend/access/undo/undoapply.c @@ -0,0 +1,324 @@ +/*------------------------------------------------------------------------- + * + * undoapply.c + * Generic UNDO record application during transaction rollback + * + * When a transaction aborts, this module walks the UNDO chain backward + * from the most recent record to the first. For each record, it + * dispatches to the appropriate resource manager's rm_undo callback + * based on the urec_rmid field in the record header. + * + * This module is AM-agnostic: it contains no AM- or subsystem-specific + * code. All such UNDO application logic lives in the respective resource + * managers, each registered via RegisterUndoRmgr() (see access/undormgr.h). + * + * The dispatch pattern is analogous to WAL resource managers: each RM + * registers its callbacks via RegisterUndoRmgr(), and this module + * routes UNDO records to the correct handler. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undoapply.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/undolog.h" +#include "access/undorecord.h" +#include "access/undormgr.h" +#include "access/undo_xlog.h" +#include "miscadmin.h" +#include "utils/injection_point.h" +#include "utils/memutils.h" + +/* + * ApplyOneUndoRecord - Apply a single UNDO record via RM dispatch + * + * Checks the CLR pointer to avoid double-application, then dispatches + * to the appropriate resource manager's rm_undo callback. + * + * Returns true if successfully applied, false if skipped. + */ +static bool +ApplyOneUndoRecord(UndoRecordHeader *header, char *payload, + UndoRecPtr urec_ptr) +{ + const UndoRmgrData *rmgr; + UndoApplyResult result; + + /* + * Idempotency design note: + * + * UNDO records are immutable once written to WAL; urec_clr_ptr in the + * header is always InvalidXLogRecPtr and cannot be updated after the + * fact. Double-application is prevented by page LSN instead: each CLR + * (XLOG_UNDO_APPLY_RECORD) written by rm_undo bumps the heap page LSN to + * the CLR's EndRecPtr. During crash recovery Phase 2, rm_undo reads the + * buffer via XLogReadBufferForRedo; if the page LSN >= CLR LSN (meaning + * the CLR was already replayed in Phase 1), the buffer read returns + * BLK_DONE or BLK_RESTORED and no modification is made. + * + * This function is therefore unconditionally correct to call for every + * UNDO record encountered during chain walking. + */ + + /* + * Look up the resource manager for this record. + */ + rmgr = GetUndoRmgr(header->urec_rmid); + if (rmgr == NULL) + { + ereport(WARNING, + (errmsg("UNDO rollback: unknown RM ID %u for record at %llu, skipping", + header->urec_rmid, + (unsigned long long) urec_ptr))); + return false; + } + + /* + * Dispatch to the RM's undo-apply callback. The callback is responsible + * for all AM-specific work: opening relations, locking buffers, modifying + * pages, generating CLRs, and releasing resources. + */ + result = rmgr->rm_undo(header->urec_rmid, + header->urec_info, + header->urec_xid, + header->urec_reloid, + payload, + header->urec_payload_len, + urec_ptr); + + if (result == UNDO_APPLY_SUCCESS) + { + ereport(DEBUG2, + (errmsg("UNDO rollback: applied %s record at %llu", + rmgr->rm_name, + (unsigned long long) urec_ptr))); + return true; + } + else if (result == UNDO_APPLY_SKIPPED) + { + ereport(DEBUG2, + (errmsg("UNDO rollback: skipped %s record at %llu", + rmgr->rm_name, + (unsigned long long) urec_ptr))); + return false; + } + else + { + ereport(WARNING, + (errmsg("UNDO rollback: error applying %s record at %llu", + rmgr->rm_name, + (unsigned long long) urec_ptr))); + return false; + } +} + +/* + * ApplyUndoChainFromWAL - Walk and apply an UNDO chain from WAL + * + * Reads XLOG_UNDO_BATCH WAL records via UndoReadBatchFromWAL() and + * iterates through the serialized records within each batch. + * + * The chain is walked backward via the xl_undo_batch.chain_prev LSN + * from the most recent batch to the first. Within each batch, records + * are applied in reverse order (newest to oldest) as required by + * ARIES-style rollback semantics. This is achieved by first scanning + * forward through the serialized records to collect their start offsets, + * then iterating the collected offsets in reverse to apply each record. + */ +bool +ApplyUndoChainFromWAL(XLogRecPtr last_batch_lsn) +{ + return ApplyUndoChainFromWALBounded(last_batch_lsn, InvalidXLogRecPtr); +} + +/* + * ApplyUndoChainFromWALBounded - Apply an UNDO chain, stopping at a boundary + * + * Identical to ApplyUndoChainFromWAL() except that the chain walk halts before + * processing any batch whose LSN is at or below stop_at_lsn. This applies only + * the batches strictly newer than stop_at_lsn, which is what subtransaction + * abort needs: stop_at_lsn is the parent's saved chain head, so the parent's + * (and earlier subtransactions') batches are left intact while the aborting + * subtransaction's batches are reverted. + * + * Pass InvalidXLogRecPtr as stop_at_lsn to walk the entire chain (top-level + * rollback / recovery semantics). + */ +bool +ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn, XLogRecPtr stop_at_lsn) +{ + XLogRecPtr batch_lsn; + int records_applied = 0; + int records_skipped = 0; + int batches_processed = 0; + + if (!XLogRecPtrIsValid(last_batch_lsn)) + return false; + + ereport(DEBUG1, + (errmsg("applying UNDO chain from WAL starting at %X/%X " + "(stop boundary %X/%X)", + LSN_FORMAT_ARGS(last_batch_lsn), + LSN_FORMAT_ARGS(stop_at_lsn)))); + + batch_lsn = last_batch_lsn; + + while (XLogRecPtrIsValid(batch_lsn)) + { + UndoBatchData *batch; + char *pos; + char *end; + + /* + * Bounded walk: stop once we reach a batch that belongs to the parent + * (or an earlier subtransaction). chain_prev LSNs decrease strictly + * as we walk backward, so the first batch at or below the boundary + * marks the start of the region we must preserve. + */ + if (XLogRecPtrIsValid(stop_at_lsn) && batch_lsn <= stop_at_lsn) + break; + + INJECTION_POINT("undo-apply-before-batch", NULL); + + batch = UndoReadBatchFromWAL(batch_lsn); + if (batch == NULL) + { + ereport(WARNING, + (errmsg("UNDO rollback: could not read batch at %X/%X, " + "stopping chain walk", + LSN_FORMAT_ARGS(batch_lsn)))); + break; + } + + batches_processed++; + + /* + * Walk through records within this batch in reverse order. + * + * ARIES requires that UNDO records within a batch be applied + * newest-first (reverse of serialization order). We first scan + * forward to collect pointers to each record start, then iterate the + * collected pointers in reverse to apply them. + */ + pos = batch->payload; + end = pos + batch->payload_len; + + { + char **record_starts; + int nrecords_in_batch = 0; + int max_records = 1024; + int i; + MemoryContext batchctx; + MemoryContext oldctx; + + /* + * record_starts must grow to hold EVERY record in the batch -- + * silently truncating rollback is a data-integrity bug (the + * dropped records are never applied, so a partially-applied + * in-place UPDATE/DELETE is left on the page). We therefore + * repalloc() to double the array whenever it fills. + * + * The caller's CurrentMemoryContext may be a BumpContext (executor + * abort path), which supports neither repalloc() nor pfree(). So + * we do all growth in a private AllocSet child context and delete + * it whole at the end of the batch -- context-agnostic, leak-free, + * and unbounded. + */ + batchctx = AllocSetContextCreate(CurrentMemoryContext, + "UNDO batch record_starts", + ALLOCSET_DEFAULT_SIZES); + oldctx = MemoryContextSwitchTo(batchctx); + + record_starts = (char **) palloc(max_records * sizeof(char *)); + + /* First pass: collect record start pointers by scanning forward */ + while (pos < end) + { + UndoRecordHeader hdr; + + if ((Size) (end - pos) < SizeOfUndoRecordHeader) + { + ereport(WARNING, + (errmsg("UNDO rollback: truncated record in batch at %X/%X", + LSN_FORMAT_ARGS(batch_lsn)))); + break; + } + + memcpy(&hdr, pos, SizeOfUndoRecordHeader); + + if (hdr.urec_len < SizeOfUndoRecordHeader || + (Size) (end - pos) < hdr.urec_len) + { + ereport(WARNING, + (errmsg("UNDO rollback: invalid record size %u in batch at %X/%X", + hdr.urec_len, LSN_FORMAT_ARGS(batch_lsn)))); + break; + } + + /* Grow the array (double it) rather than truncate the rollback */ + if (nrecords_in_batch >= max_records) + { + max_records *= 2; + record_starts = (char **) repalloc(record_starts, + max_records * sizeof(char *)); + } + + record_starts[nrecords_in_batch++] = pos; + pos += hdr.urec_len; + } + + /* + * Second pass: apply records in reverse order (newest first). + * Even if there was a scan error, apply whatever records we + * successfully collected. + */ + for (i = nrecords_in_batch - 1; i >= 0; i--) + { + UndoRecordHeader header; + char *payload = NULL; + + memcpy(&header, record_starts[i], SizeOfUndoRecordHeader); + + if (header.urec_payload_len > 0) + payload = record_starts[i] + SizeOfUndoRecordHeader; + + if (ApplyOneUndoRecord(&header, payload, InvalidUndoRecPtr)) + records_applied++; + else + records_skipped++; + } + + MemoryContextSwitchTo(oldctx); + MemoryContextDelete(batchctx); + } + + INJECTION_POINT("undo-apply-after-batch", NULL); + + /* Follow chain to previous batch */ + batch_lsn = batch->header.chain_prev; + UndoFreeBatchData(batch); + } + + /* Report results */ + if (records_skipped > 0) + { + ereport(WARNING, + (errmsg("UNDO rollback from WAL: %d batches, %d records applied, " + "%d skipped", + batches_processed, records_applied, records_skipped))); + } + else + { + ereport(DEBUG1, + (errmsg("UNDO rollback from WAL complete: %d batches, " + "%d records applied", + batches_processed, records_applied))); + } + + return (batches_processed > 0); +} diff --git a/src/backend/access/undo/undobuffer.c b/src/backend/access/undo/undobuffer.c new file mode 100644 index 0000000000000..eb3e73c057039 --- /dev/null +++ b/src/backend/access/undo/undobuffer.c @@ -0,0 +1,353 @@ +/*------------------------------------------------------------------------- + * + * undobuffer.c + * AM-agnostic Tier 2 UNDO write buffer + * + * This module implements a per-backend byte buffer that accumulates + * serialized UNDO records for the current DML operation. At WAL-write time, + * the buffer contents are embedded directly inside the AM's WAL record, + * eliminating a separate XLOG_UNDO_BATCH record for single-tuple operations. + * + * The buffer logic is entirely AM-agnostic: it serializes UndoRecordHeaders + * with opaque payloads, identified by urec_rmid for dispatch during rollback. + * Any access method (heap, nbtree, custom AMs) can use this buffer. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undobuffer.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/undobuffer.h" +#include "access/undolog.h" +#include "access/undorecord.h" +#include "access/undo_xlog.h" +#include "access/xactundo.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "utils/memutils.h" +#include "utils/rel.h" + +/* + * Flush thresholds. Tunable via the undo_batch_size_kb and + * undo_batch_record_limit GUCs (src/backend/access/undo/undolog.c). + */ +#define UNDO_BUFFER_FLUSH_THRESHOLD (undo_batch_size_kb * 1024) +#define UNDO_BUFFER_FLUSH_RECORDS undo_batch_record_limit + +/* + * Per-backend Tier 2 UNDO buffer. Only one relation can be active at a time. + */ +typedef struct UndoTier2Buffer +{ + char *data; /* palloc'd: serialized + * UndoRecordHeader+payload */ + Size len; /* bytes currently used */ + Size capacity; /* allocated capacity */ + int nrecords; /* records in buffer */ + TransactionId xid; /* owning transaction */ + XLogRecPtr chain_prev; /* LSN of previous UNDO batch for chain + * linkage */ + Oid relid; /* OID of the relation with active buffer */ + bool active; +} UndoTier2Buffer; + +static UndoTier2Buffer undo_t2buf = +{ + .data = NULL, + .len = 0, + .capacity = 0, + .nrecords = 0, + .xid = InvalidTransactionId, + .chain_prev = InvalidXLogRecPtr, + .relid = InvalidOid, + .active = false, +}; + +/* + * UndoTier2EnsureCapacity - grow undo_t2buf to hold additional bytes + */ +static void +UndoTier2EnsureCapacity(Size additional) +{ + if (undo_t2buf.len + additional <= undo_t2buf.capacity) + return; /* already enough room */ + + if (undo_t2buf.capacity == 0) + { + undo_t2buf.capacity = Max(512, additional); + undo_t2buf.data = MemoryContextAlloc(TopMemoryContext, + undo_t2buf.capacity); + } + else + { + Size new_cap = undo_t2buf.capacity; + + while (new_cap < undo_t2buf.len + additional) + new_cap *= 2; + undo_t2buf.data = repalloc(undo_t2buf.data, new_cap); + undo_t2buf.capacity = new_cap; + } +} + +/* + * UndoTier2AddRecord - serialize one UNDO record into undo_t2buf + */ +static void +UndoTier2AddRecord(uint8 rmid, uint16 info, Oid reloid, + const char *payload, Size payload_len) +{ + Size record_size = SizeOfUndoRecordHeader + payload_len; + UndoRecordHeader *header; + char *dest; + + UndoTier2EnsureCapacity(record_size); + + dest = undo_t2buf.data + undo_t2buf.len; + header = (UndoRecordHeader *) dest; + memset(header, 0, SizeOfUndoRecordHeader); + header->urec_rmid = rmid; + header->urec_flags = UNDO_INFO_XID_VALID; + if (payload_len > 0) + header->urec_flags |= UNDO_INFO_HAS_PAYLOAD; + header->urec_info = info; + header->urec_len = (uint32) record_size; + header->urec_xid = undo_t2buf.xid; + header->urec_prev = (UndoRecPtr) undo_t2buf.chain_prev; + header->urec_reloid = reloid; + header->urec_payload_len = (uint32) payload_len; + header->urec_clr_ptr = InvalidXLogRecPtr; + + if (payload_len > 0 && payload != NULL) + memcpy(dest + SizeOfUndoRecordHeader, payload, payload_len); + + undo_t2buf.len += record_size; + undo_t2buf.nrecords++; +} + +/* + * UndoTier2AddRecordParts - like UndoTier2AddRecord but scatter-gather + */ +static void +UndoTier2AddRecordParts(uint8 rmid, uint16 info, Oid reloid, + const char *part1, Size part1_len, + const char *part2, Size part2_len) +{ + Size payload_len = part1_len + part2_len; + Size record_size = SizeOfUndoRecordHeader + payload_len; + UndoRecordHeader *header; + char *dest; + + UndoTier2EnsureCapacity(record_size); + + dest = undo_t2buf.data + undo_t2buf.len; + header = (UndoRecordHeader *) dest; + memset(header, 0, SizeOfUndoRecordHeader); + header->urec_rmid = rmid; + header->urec_flags = UNDO_INFO_XID_VALID; + if (payload_len > 0) + header->urec_flags |= UNDO_INFO_HAS_PAYLOAD; + header->urec_info = info; + header->urec_len = (uint32) record_size; + header->urec_xid = undo_t2buf.xid; + header->urec_prev = (UndoRecPtr) undo_t2buf.chain_prev; + header->urec_reloid = reloid; + header->urec_payload_len = (uint32) payload_len; + header->urec_clr_ptr = InvalidXLogRecPtr; + + dest += SizeOfUndoRecordHeader; + if (part1_len > 0 && part1 != NULL) + memcpy(dest, part1, part1_len); + if (part2_len > 0 && part2 != NULL) + memcpy(dest + part1_len, part2, part2_len); + + undo_t2buf.len += record_size; + undo_t2buf.nrecords++; +} + + +/* ----------------------------------------------------------------------- + * Public API + * ----------------------------------------------------------------------- + */ + +void +UndoBufferBegin(Relation rel, int64 nrows) +{ + /* Only one relation at a time can have an active buffer */ + if (undo_t2buf.active) + { + if (undo_t2buf.relid == RelationGetRelid(rel)) + return; /* already active for this relation */ + + /* Different relation -- flush and end the previous one */ + UndoBufferEnd(rel); + } + + undo_t2buf.xid = GetCurrentTransactionId(); + undo_t2buf.relid = RelationGetRelid(rel); + undo_t2buf.chain_prev = (XLogRecPtr) GetCurrentTransactionUndoRecPtr(); + undo_t2buf.len = 0; + undo_t2buf.nrecords = 0; + undo_t2buf.active = true; + /* undo_t2buf.data and capacity are preserved across activations */ + + ereport(DEBUG2, + (errmsg("UNDO tier2 buffer activated for relation %u, estimated %lld rows", + RelationGetRelid(rel), (long long) nrows))); +} + +void +UndoBufferEnd(Relation rel) +{ + if (!undo_t2buf.active) + return; + + /* Flush any remaining records via the overflow path */ + if (undo_t2buf.nrecords > 0) + UndoBufferFlush(); + + ereport(DEBUG2, + (errmsg("UNDO tier2 buffer deactivated for relation %u", + undo_t2buf.relid))); + + undo_t2buf.relid = InvalidOid; + undo_t2buf.len = 0; + undo_t2buf.nrecords = 0; + undo_t2buf.xid = InvalidTransactionId; + undo_t2buf.chain_prev = InvalidXLogRecPtr; + undo_t2buf.active = false; +} + +bool +UndoBufferIsActive(Relation rel) +{ + return undo_t2buf.active && + undo_t2buf.relid == RelationGetRelid(rel); +} + +void +UndoBufferAddRecord(Relation rel, uint8 rmid, uint16 info, + const char *payload, Size payload_len) +{ + Assert(undo_t2buf.active); + + UndoTier2AddRecord(rmid, info, RelationGetRelid(rel), + payload, payload_len); + + /* Overflow flush when thresholds are exceeded */ + if (undo_t2buf.len >= UNDO_BUFFER_FLUSH_THRESHOLD || + undo_t2buf.nrecords >= UNDO_BUFFER_FLUSH_RECORDS) + UndoBufferFlush(); +} + +void +UndoBufferAddRecordParts(Relation rel, uint8 rmid, uint16 info, + const char *part1, Size part1_len, + const char *part2, Size part2_len) +{ + Assert(undo_t2buf.active); + + UndoTier2AddRecordParts(rmid, info, RelationGetRelid(rel), + part1, part1_len, part2, part2_len); + + /* Overflow flush when thresholds are exceeded */ + if (undo_t2buf.len >= UNDO_BUFFER_FLUSH_THRESHOLD || + undo_t2buf.nrecords >= UNDO_BUFFER_FLUSH_RECORDS) + UndoBufferFlush(); +} + +bool +UndoBufferHasPendingData(void) +{ + return undo_t2buf.active && undo_t2buf.nrecords > 0; +} + +void +UndoBufferTakePayload(char **data_out, Size *len_out, int *nrecords_out, + XLogRecPtr *chain_prev_out) +{ + Assert(undo_t2buf.active); + Assert(undo_t2buf.nrecords > 0); + + *data_out = undo_t2buf.data; + *len_out = undo_t2buf.len; + *nrecords_out = undo_t2buf.nrecords; + *chain_prev_out = undo_t2buf.chain_prev; +} + +void +UndoBufferReset(XLogRecPtr embedded_lsn) +{ + /* Update chain head so the next batch links to this one */ + undo_t2buf.chain_prev = embedded_lsn; + undo_t2buf.len = 0; + undo_t2buf.nrecords = 0; + + /* + * Update the per-transaction undo pointer so that subsequent + * UndoBufferBegin calls (for different relations in the same transaction) + * pick up the correct chain_prev. Without this, multi-table transactions + * would break the UNDO chain. + */ + SetCurrentTransactionUndoRecPtr((UndoRecPtr) embedded_lsn); +} + +void +UndoBufferFlush(void) +{ + xl_undo_batch xlrec; + XLogRecPtr batch_lsn; + Oid primary_reloid = InvalidOid; + + if (!undo_t2buf.active || undo_t2buf.nrecords == 0) + return; + + /* Extract primary reloid from first record as an optimization hint */ + if (undo_t2buf.len >= SizeOfUndoRecordHeader) + { + UndoRecordHeader *first_hdr = (UndoRecordHeader *) undo_t2buf.data; + + primary_reloid = first_hdr->urec_reloid; + } + + /* Build the batch header */ + xlrec.xid = undo_t2buf.xid; + xlrec.chain_prev = undo_t2buf.chain_prev; + xlrec.nrecords = (uint32) undo_t2buf.nrecords; + xlrec.total_len = (uint32) undo_t2buf.len; + xlrec.primary_reloid = primary_reloid; + xlrec.persistence = UNDOPERSISTENCE_PERMANENT; + + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfUndoBatch); + XLogRegisterData(undo_t2buf.data, undo_t2buf.len); + (void) XLogInsert(RM_UNDO_ID, XLOG_UNDO_BATCH); + + /* + * XLogInsert() returns the end+1 position; the rollback path re-reads the + * batch by its START LSN. ProcLastRecPtr is the start of the record we + * just inserted. See the matching comment in UndoRecordSetInsert(). + */ + batch_lsn = ProcLastRecPtr; + + /* Update chain tracking */ + undo_t2buf.chain_prev = batch_lsn; + UndoRegisterBatchLSN(batch_lsn); + XActUndoUpdateLastBatchLSN(batch_lsn, UNDOPERSISTENCE_PERMANENT); + SetCurrentTransactionUndoRecPtr((UndoRecPtr) batch_lsn); + + ereport(DEBUG2, + (errmsg("UNDO tier2 overflow flush: %d records, %zu bytes, lsn %X/%X", + undo_t2buf.nrecords, undo_t2buf.len, + LSN_FORMAT_ARGS(batch_lsn)))); + + /* Reset buffer for next batch */ + undo_t2buf.len = 0; + undo_t2buf.nrecords = 0; +} diff --git a/src/backend/access/undo/undoinsert.c b/src/backend/access/undo/undoinsert.c new file mode 100644 index 0000000000000..47ceb92877aea --- /dev/null +++ b/src/backend/access/undo/undoinsert.c @@ -0,0 +1,166 @@ +/*------------------------------------------------------------------------- + * + * undoinsert.c + * UNDO record batch insertion operations + * + * This file implements batch insertion of UNDO records into the WAL + * stream. Records are accumulated in an UndoRecordSet and then + * written as a single XLOG_UNDO_BATCH WAL record. + * + * UNDO-IN-WAL ARCHITECTURE + * ------------------------ + * All UNDO record data flows through the standard WAL pipeline: + * UndoRecordSetInsert() -> XLogBeginInsert() + * -> XLogRegisterData(batch_header) + * -> XLogRegisterData(uset->buffer, uset->buffer_size) + * -> XLogInsert(RM_UNDO_ID, XLOG_UNDO_BATCH) + * + * This eliminates the separate UNDO segment file I/O path (pwrite + + * fdatasync) and provides: + * - Replicas receive and can apply UNDO records + * - One durability path, one sync at commit + * - Unified crash recovery with explicit UNDO phase + * + * Coalescing: The existing UndoRecordSet mechanism batches records. + * This batch becomes one WAL record. A 1000-row INSERT produces ~1 + * WAL record containing 1000 UNDO records. + * + * Legacy support: UndoWalBatchFlush/Reset are kept as no-ops for + * callers that haven't been updated yet. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undoinsert.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/undolog.h" +#include "access/undorecord.h" +#include "access/undo_xlog.h" +#include "access/xloginsert.h" +#include "access/xlog.h" +#include "utils/injection_point.h" + +/* + * UndoWalBatchFlush - Legacy no-op + * + * With UNDO-in-WAL, there is no separate deferred WAL batch to flush. + * UNDO data is written directly to WAL in UndoRecordSetInsert(). + * This function is kept for callers that haven't been updated yet. + */ +void +UndoWalBatchFlush(void) +{ + /* No-op: UNDO data is now written directly to WAL */ +} + +/* + * UndoWalBatchReset - Legacy no-op + * + * With UNDO-in-WAL, there is no separate deferred WAL batch to reset. + */ +void +UndoWalBatchReset(void) +{ + /* No-op: UNDO data is now written directly to WAL */ +} + +/* + * UndoRecordSetInsert - Insert accumulated UNDO records into WAL + * + * This function writes all UNDO records in the set as a single + * XLOG_UNDO_BATCH WAL record. The batch payload is the serialized + * content of uset->buffer (concatenated UndoRecordHeader+payload). + * + * Returns the (legacy) UndoRecPtr for backward compatibility. + * The actual record location is the XLogRecPtr stored in + * uset->last_batch_lsn after this call. + */ +UndoRecPtr +UndoRecordSetInsert(UndoRecordSet *uset) +{ + xl_undo_batch xlrec; + XLogRecPtr batch_lsn; + Oid primary_reloid = InvalidOid; + + if (uset == NULL || uset->nrecords == 0) + return InvalidUndoRecPtr; + + /* + * Extract the primary relation OID from the first record in the batch as + * an optimization hint. Most batches contain records for a single + * relation. + */ + if (uset->buffer_size >= SizeOfUndoRecordHeader) + { + UndoRecordHeader *first_hdr = (UndoRecordHeader *) uset->buffer; + + primary_reloid = first_hdr->urec_reloid; + } + + /* Build the batch header */ + xlrec.xid = uset->xid; + xlrec.chain_prev = uset->last_batch_lsn; + xlrec.nrecords = (uint32) uset->nrecords; + xlrec.total_len = (uint32) uset->buffer_size; + xlrec.primary_reloid = primary_reloid; + xlrec.persistence = uset->persistence; + + /* + * Write the UNDO batch as a single WAL record. + * + * XLogRegisterData has no size limit on main data (tracked as uint64 in + * xloginsert.c), so even a 256KB batch is fine. The WAL insertion lock + * will be held for the duration of the record write, which is acceptable + * for batch sizes up to a few hundred KB. + */ + XLogBeginInsert(); + XLogRegisterData((char *) &xlrec, SizeOfUndoBatch); + XLogRegisterData(uset->buffer, uset->buffer_size); + + /* + * Use the CACHED variant: UndoRecordSetInsert() always runs inside the + * caller's critical section (it emits a WAL record), and + * InjectionPointRun() may palloc (dlopen the callback library, or a + * wait-mode callback allocating to suspend the backend), which is + * forbidden in a critical section. The matching INJECTION_POINT_LOAD is + * issued pre-crit in PrepareXactUndoData(); INJECTION_POINT_CACHED is + * palloc-free. + */ + INJECTION_POINT_CACHED("undo-batch-before-wal-insert", NULL); + + (void) XLogInsert(RM_UNDO_ID, XLOG_UNDO_BATCH); + + /* + * XLogInsert() returns the end+1 position of the record, but the rollback + * path must re-read the batch by its START LSN. ProcLastRecPtr holds the + * start of the record we just inserted; use it so UndoReadBatchFromWAL + * lands on this XLOG_UNDO_BATCH instead of the following record. + */ + batch_lsn = ProcLastRecPtr; + + INJECTION_POINT_CACHED("undo-batch-after-wal-insert", NULL); + + /* Update the record set's chain pointer for subsequent batches */ + uset->last_batch_lsn = batch_lsn; + + /* + * Register the batch LSN for WAL retention tracking. Only the first call + * per transaction takes effect (UndoRegisterBatchLSN is a no-op if the + * slot is already occupied), so this records the oldest batch for this + * transaction without additional bookkeeping. + */ + UndoRegisterBatchLSN(batch_lsn); + + /* + * For legacy compatibility, return a non-zero UndoRecPtr. The actual + * location is in uset->last_batch_lsn (XLogRecPtr). + */ + uset->prev_undo_ptr = (UndoRecPtr) batch_lsn; + + return (UndoRecPtr) batch_lsn; +} diff --git a/src/backend/access/undo/undolog.c b/src/backend/access/undo/undolog.c new file mode 100644 index 0000000000000..7af37e03c3c4b --- /dev/null +++ b/src/backend/access/undo/undolog.c @@ -0,0 +1,534 @@ +/*------------------------------------------------------------------------- + * + * undolog.c + * PostgreSQL UNDO log manager -- WAL-integrated version + * + * With UNDO-in-WAL, UNDO records are stored in the standard WAL stream + * as XLOG_UNDO_BATCH records. The separate base/undo/ segment files, + * direct pwrite()/pread() I/O path, and per-backend fd cache have been + * removed. This file retains: + * + * - GUC parameters (undo_retention_time, etc.) + * - Shared memory structures for UNDO state tracking + * - Discard pointer management (repurposed for WAL-based UNDO) + * - Checkpoint support (statistics logging) + * + * The previous functions (UndoLogAllocate, UndoLogWrite, UndoLogRead, + * UndoLogSync, UndoLogSealAndRotate, etc.) are removed. Callers now + * use UndoRecordSetInsert() which writes directly to WAL via XLogInsert. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undolog.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm.h" +#include "access/transam.h" +#include "access/twophase.h" +#include "access/undolog.h" +#include "access/xlog.h" +#include "miscadmin.h" +#include "storage/lwlock.h" +#include "storage/procnumber.h" +#include "storage/shmem.h" +#include "utils/guc.h" +#include "utils/timestamp.h" + +/* GUC parameters */ +int undo_retention_time = 60000; /* 60 seconds */ +int undo_worker_naptime = 10000; /* 10 seconds */ +int undo_buffer_size = 1024; /* 1MB in KB */ +int undo_max_wal_retention_size = 0; /* 0 = unlimited, in MB */ +int undo_batch_size_kb = 256; /* UNDO batch flush threshold in KB */ +int undo_batch_record_limit = 1000; /* UNDO batch flush threshold in + * records */ + +/* Shared memory pointer */ +UndoLogSharedData *UndoLogShared = NULL; + +/* + * UndoLogShmemSize + * Calculate shared memory size for UNDO log management + * + * The size includes the fixed UndoLogSharedData fields plus a per-backend + * array of pg_atomic_uint64 for first UNDO batch LSN tracking. + */ +Size +UndoLogShmemSize(void) +{ + Size size; + + /* Fixed struct size up to (but not including) the flexible array */ + size = offsetof(UndoLogSharedData, backend_undo_lsns); + + /* Per-backend first-batch LSN slots */ + size = add_size(size, mul_size(MaxBackends, sizeof(pg_atomic_uint64))); + + return size; +} + +/* + * UndoLogShmemInit + * Initialize shared memory for UNDO log management + */ +void +UndoLogShmemInit(void) +{ + bool found; + + UndoLogShared = (UndoLogSharedData *) + ShmemInitStruct("UNDO Log Control", UndoLogShmemSize(), &found); + + if (!found) + { + int i; + + /* Initialize all log control structures */ + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + log->log_number = 0; + pg_atomic_init_u64(&log->insert_ptr, InvalidUndoRecPtr); + log->discard_ptr = InvalidUndoRecPtr; + log->oldest_xid = InvalidTransactionId; + LWLockInitialize(&log->lock, LWTRANCHE_UNDO_LOG); + log->in_use = false; + log->state = UNDO_LOG_FREE; + pg_atomic_init_u64(&log->seal_ptr, InvalidUndoRecPtr); + log->sealed_time = 0; + } + + UndoLogShared->next_log_number = 1; + LWLockInitialize(&UndoLogShared->allocation_lock, LWTRANCHE_UNDO_LOG); + pg_atomic_init_u32(&UndoLogShared->active_log_idx, MAX_UNDO_LOGS); + pg_atomic_init_u64(&UndoLogShared->total_allocated, 0); + pg_atomic_init_u64(&UndoLogShared->total_discarded, 0); + pg_atomic_init_u64(&UndoLogShared->undo_discard_horizon, + InvalidXLogRecPtr); + + /* Initialize per-backend first UNDO batch LSN slots */ + for (i = 0; i < MaxBackends; i++) + pg_atomic_init_u64(&UndoLogShared->backend_undo_lsns[i], + InvalidXLogRecPtr); + } +} + +/* + * UndoLogDiscard + * Advance the UNDO discard horizon. + * + * With UNDO-in-WAL, discard means advancing the WAL retention horizon + * past which UNDO records are no longer needed for rollback. The + * background UNDO worker calls this after confirming all transactions + * older than oldest_needed have committed or had their UNDO applied. + */ +void +UndoLogDiscard(UndoRecPtr oldest_needed) +{ + int i; + + if (!UndoRecPtrIsValid(oldest_needed)) + return; + + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (!log->in_use) + continue; + + LWLockAcquire(&log->lock, LW_EXCLUSIVE); + + if (UndoRecPtrGetLogNo(oldest_needed) == log->log_number) + { + if (UndoRecPtrGetOffset(oldest_needed) > UndoRecPtrGetOffset(log->discard_ptr)) + { + log->discard_ptr = oldest_needed; + ereport(DEBUG2, + (errmsg("UNDO discard: log %u advanced to offset %llu", + log->log_number, + (unsigned long long) UndoRecPtrGetOffset(oldest_needed)))); + } + } + + LWLockRelease(&log->lock); + } +} + +/* + * UndoLogGetOldestDiscardPtr + * Get the oldest UNDO discard pointer across all active logs. + * + * Used to determine WAL retention requirements for UNDO. + */ +UndoRecPtr +UndoLogGetOldestDiscardPtr(void) +{ + UndoRecPtr oldest = InvalidUndoRecPtr; + int i; + + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (log->in_use) + { + if (!UndoRecPtrIsValid(oldest) || + log->discard_ptr < oldest) + oldest = log->discard_ptr; + } + } + + return oldest; +} + +/* + * CheckPointUndoLog + * Perform checkpoint processing for the UNDO log subsystem. + * + * With UNDO-in-WAL, there are no UNDO segment files to sync. + * This function logs statistics when log_checkpoints is enabled. + */ +void +CheckPointUndoLog(void) +{ + int active_logs = 0; + uint64 total_allocated = 0; + uint64 total_discarded = 0; + int i; + + if (UndoLogShared == NULL) + return; + + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (!log->in_use) + continue; + + active_logs++; + total_allocated += UndoRecPtrGetOffset(pg_atomic_read_u64(&log->insert_ptr)); + + LWLockAcquire(&log->lock, LW_SHARED); + total_discarded += UndoRecPtrGetOffset(log->discard_ptr); + LWLockRelease(&log->lock); + } + + if (log_checkpoints && active_logs > 0) + { + ereport(LOG, + (errmsg("UNDO checkpoint: %d active log(s), " + "%llu bytes allocated, %llu bytes discarded, " + "%llu bytes retained", + active_logs, + (unsigned long long) total_allocated, + (unsigned long long) total_discarded, + (unsigned long long) (total_allocated - total_discarded)))); + } +} + +/* + * UndoGetDiscardHorizon + * Return the current UNDO discard horizon LSN. + * + * WAL segments containing data at or after this LSN must be retained + * because they contain UNDO records that may still be needed for + * rollback of in-progress transactions. + * + * Returns InvalidXLogRecPtr if no UNDO data exists (UNDO not in use + * or all transactions have committed). + */ +XLogRecPtr +UndoGetDiscardHorizon(void) +{ + if (UndoLogShared == NULL) + return InvalidXLogRecPtr; + + return (XLogRecPtr) pg_atomic_read_u64(&UndoLogShared->undo_discard_horizon); +} + +/* + * UndoSetDiscardHorizon + * Advance the UNDO discard horizon to a new LSN. + * + * Called by the UNDO discard worker after confirming that all UNDO + * records before 'horizon' have been processed (transactions committed + * or rolled back, index pruning completed). + * + * The horizon only moves forward -- if the new value is older than + * the current horizon, the call is a no-op. + */ +void +UndoSetDiscardHorizon(XLogRecPtr horizon) +{ + uint64 old_horizon; + + if (UndoLogShared == NULL || !XLogRecPtrIsValid(horizon)) + return; + + /* Advance forward only */ + while (true) + { + old_horizon = pg_atomic_read_u64(&UndoLogShared->undo_discard_horizon); + + if (XLogRecPtrIsValid((XLogRecPtr) old_horizon) && + horizon <= (XLogRecPtr) old_horizon) + break; /* already at or past this point */ + + if (pg_atomic_compare_exchange_u64(&UndoLogShared->undo_discard_horizon, + &old_horizon, (uint64) horizon)) + break; + } +} + +/* + * UndoRegisterBatchLSN + * Register the first UNDO batch LSN for the current backend. + * + * Called from UndoRecordSetInsert() the first time a transaction writes + * UNDO data. Stores the LSN in the per-backend slot so that the UNDO + * discard worker can find the oldest in-flight UNDO batch and avoid + * recycling WAL segments that still contain needed UNDO data. + * + * Only the FIRST call per transaction takes effect (we want the oldest, + * i.e., smallest, LSN). Subsequent calls for the same transaction are + * no-ops because the slot is already occupied. + */ +void +UndoRegisterBatchLSN(XLogRecPtr batch_lsn) +{ + pg_atomic_uint64 *slot; + uint64 expected; + + if (UndoLogShared == NULL || !XLogRecPtrIsValid(batch_lsn)) + return; + if (MyProcNumber < 0 || MyProcNumber >= MaxBackends) + return; + + slot = &UndoLogShared->backend_undo_lsns[MyProcNumber]; + expected = InvalidXLogRecPtr; + + /* + * Only set if the slot is currently empty. This records the first + * (oldest) batch for this transaction; later batches have larger LSNs and + * should not overwrite the stored value. + */ + (void) pg_atomic_compare_exchange_u64(slot, &expected, (uint64) batch_lsn); +} + +/* + * UndoClearBatchLSN + * Clear the per-backend UNDO batch LSN registration. + * + * Called at transaction commit or abort to release the WAL retention + * hold that was established by UndoRegisterBatchLSN(). + */ +void +UndoClearBatchLSN(void) +{ + if (UndoLogShared == NULL) + return; + if (MyProcNumber < 0 || MyProcNumber >= MaxBackends) + return; + + pg_atomic_write_u64(&UndoLogShared->backend_undo_lsns[MyProcNumber], + (uint64) InvalidXLogRecPtr); +} + +/* + * UndoGetOldestBatchLSN + * Return the oldest UNDO batch LSN that must be retained in WAL. + * + * Considers both: + * 1. Per-backend slots (in-flight transactions with UNDO data) + * 2. ATM entries (aborted transactions awaiting Logical Revert) + * + * The ATM check is critical: once a transaction aborts, its per-backend + * slot is cleared by UndoClearBatchLSN(), but the logical revert worker + * still needs to read the UNDO batches from WAL. Without this check, + * checkpoints could recycle WAL segments containing needed UNDO data, + * causing the revert worker to crash (SIGBUS/SIGSEGV) or read garbage. + * + * Returns InvalidXLogRecPtr if no WAL retention is needed for UNDO. + */ +XLogRecPtr +UndoGetOldestBatchLSN(void) +{ + XLogRecPtr oldest = InvalidXLogRecPtr; + XLogRecPtr atm_oldest; + int i; + + if (UndoLogShared == NULL) + return InvalidXLogRecPtr; + + /* Check per-backend slots for in-flight transactions */ + for (i = 0; i < MaxBackends; i++) + { + XLogRecPtr lsn = (XLogRecPtr) + pg_atomic_read_u64(&UndoLogShared->backend_undo_lsns[i]); + + if (XLogRecPtrIsValid(lsn)) + { + if (!XLogRecPtrIsValid(oldest) || lsn < oldest) + oldest = lsn; + } + } + + /* + * Check ATM for aborted transactions whose UNDO chains haven't been + * applied yet. Their WAL segments must not be recycled. + */ + atm_oldest = ATMGetOldestUnrevertedLSN(); + if (XLogRecPtrIsValid(atm_oldest)) + { + if (!XLogRecPtrIsValid(oldest) || atm_oldest < oldest) + oldest = atm_oldest; + } + + /* + * Check prepared (2PC) transactions. A xact can sit PREPARED + * indefinitely; its UNDO-batch WAL must survive until ROLLBACK PREPARED + * reads it. Neither the per-backend slot (cleared when the preparing + * backend exits) nor the ATM (prepared xacts aren't in it) covers this. + */ + { + XLogRecPtr prep_oldest = TwoPhaseGetOldestUndoBatchLSN(); + + if (XLogRecPtrIsValid(prep_oldest) && + (!XLogRecPtrIsValid(oldest) || prep_oldest < oldest)) + oldest = prep_oldest; + } + + return oldest; +} + +/* + * Legacy no-op stubs + * + * UNDO-in-WAL has no per-log segment files, no fd cache, and no + * per-backend write-pointer tracking: UNDO data lives in the WAL stream + * and durability/recycling are handled by WAL flush and the discard + * worker. The operations below are therefore inherently nothing-to-do in + * this mode, but they still have live callers in the shared transaction + * and discard paths (e.g. UndoLogCloseFiles / UndoFlushResetMaxWritePtr + * from xactundo.c, UndoLogDeleteSegmentFile from the discard worker, + * ExtendUndoLogFile from the undo_xlog.c redo path). We keep them as + * no-ops so those callers stay uniform across both UNDO modes rather than + * sprinkling mode checks at every call site; the no-op is the correct + * behaviour here, not a placeholder awaiting future work. + */ + +void +UndoLogSync(void) +{ + /* No-op: WAL sync handles durability */ +} + +void +UndoLogCloseFiles(void) +{ + /* No-op: no fd cache with UNDO-in-WAL */ +} + +void +UndoFlushResetMaxWritePtr(void) +{ + /* No-op: no per-backend write pointer tracking with UNDO-in-WAL */ +} + +UndoRecPtr +UndoFlushGetMaxWritePtr(void) +{ + /* No-op: no per-backend write pointer tracking with UNDO-in-WAL */ + return InvalidUndoRecPtr; +} + +void +UndoLogSealAndRotate(uint8 trigger pg_attribute_unused()) +{ + /* No-op: no segment rotation with UNDO-in-WAL */ +} + +void +UndoLogDeleteSegmentFile(uint32 log_number pg_attribute_unused()) +{ + /* No-op: no segment files with UNDO-in-WAL */ +} + +bool +UndoLogTryPressureDiscard(void) +{ + /* No-op: no segment pressure with UNDO-in-WAL */ + return false; +} + +char * +UndoLogPath(uint32 log_number, char *path) +{ + /* Legacy: construct the path even though files no longer exist */ + snprintf(path, MAXPGPATH, "base/undo/%012u", log_number); + return path; +} + +void +ExtendUndoLogFile(uint32 log_number pg_attribute_unused(), + uint64 logical_end pg_attribute_unused()) +{ + /* No-op: no segment files with UNDO-in-WAL */ +} + +void +ExtendUndoLogSmgrFile(uint32 log_number pg_attribute_unused(), + uint64 logical_end pg_attribute_unused()) +{ + /* No-op: no smgr-managed UNDO files with UNDO-in-WAL */ +} + +UndoRecPtr +UndoLogGetInsertPtr(uint32 log_number) +{ + int i; + UndoRecPtr ptr = InvalidUndoRecPtr; + + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (log->in_use && log->log_number == log_number) + { + ptr = pg_atomic_read_u64(&log->insert_ptr); + break; + } + } + + return ptr; +} + +UndoRecPtr +UndoLogGetDiscardPtr(uint32 log_number) +{ + int i; + UndoRecPtr ptr = InvalidUndoRecPtr; + + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (log->in_use && log->log_number == log_number) + { + LWLockAcquire(&log->lock, LW_SHARED); + ptr = log->discard_ptr; + LWLockRelease(&log->lock); + break; + } + } + + return ptr; +} diff --git a/src/backend/access/undo/undorecord.c b/src/backend/access/undo/undorecord.c new file mode 100644 index 0000000000000..bb76aa5b3e8a4 --- /dev/null +++ b/src/backend/access/undo/undorecord.c @@ -0,0 +1,401 @@ +/*------------------------------------------------------------------------- + * + * undorecord.c + * UNDO record assembly and serialization + * + * This file implements the AM-agnostic UNDO record format and provides + * functions for creating, serializing, and deserializing UNDO records. + * All AM-specific knowledge is kept out of this module; records carry + * opaque payloads whose interpretation is delegated to the owning RM. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undorecord.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/undorecord.h" +#include "utils/memutils.h" + +/* + * Per-backend recycled memory context for UndoRecordSet. + * + * Instead of creating and destroying a MemoryContext for every + * UndoRecordSet, we recycle one context across operations within a + * transaction. This avoids the overhead of repeated + * AllocSetContextCreate/MemoryContextDelete for high-frequency + * operations (e.g., 1000-row INSERTs). The cached context is cleaned + * up at transaction end by UndoRecordSetResetCache(). + */ +static MemoryContext UndoRecordReusableContext = NULL; + +/* + * UndoRecordGetPayloadSize - Calculate size needed for an UNDO record + * + * This includes the fixed header plus the RM-specific payload. + */ +Size +UndoRecordGetPayloadSize(Size payload_len) +{ + return SizeOfUndoRecordHeader + payload_len; +} + +/* + * UndoRecordSerialize - Serialize an UNDO record into a buffer + * + * The destination buffer must be large enough to hold the entire record. + * Use UndoRecordGetPayloadSize() to determine the required size. + */ +void +UndoRecordSerialize(char *dest, UndoRecordHeader *header, + const char *payload, Size payload_len) +{ + /* Copy header */ + memcpy(dest, header, SizeOfUndoRecordHeader); + + /* Copy payload if present */ + if (payload_len > 0 && payload != NULL) + { + memcpy(dest + SizeOfUndoRecordHeader, payload, payload_len); + } +} + +/* + * UndoRecordDeserialize - Deserialize an UNDO record from a buffer + * + * Reads the header and sets the payload pointer into the source buffer + * (zero-copy). Returns true on success, false on failure. + */ +bool +UndoRecordDeserialize(const char *src, UndoRecordHeader *header, + char **payload) +{ + if (src == NULL || header == NULL) + return false; + + /* Copy header */ + memcpy(header, src, SizeOfUndoRecordHeader); + + /* Set payload pointer if there is payload data */ + if (header->urec_payload_len > 0) + { + if (payload != NULL) + *payload = (char *) (src + SizeOfUndoRecordHeader); + } + else + { + if (payload != NULL) + *payload = NULL; + } + + return true; +} + +/* + * UndoRecordSetCreate - Create a new UNDO record set + * + * A record set accumulates multiple UNDO records before writing them + * to the UNDO log in a batch. This improves performance by reducing + * I/O operations. + */ +UndoRecordSet * +UndoRecordSetCreate(TransactionId xid, UndoRecPtr prev_undo_ptr) +{ + UndoRecordSet *uset; + MemoryContext mctx; + MemoryContext parent; + + /* + * Use TopTransactionContext as the parent so the record set survives + * across SPI statement boundaries. When called from PL/pgSQL DO blocks, + * CurrentMemoryContext is the executor's per-query context + * (es_query_cxt), which is destroyed in FreeExecutorState() after each + * SPI_execute call. Since xactundo.c stores the uset pointer in the + * static XactUndo.record_set[] and reuses it across multiple statements + * within a transaction, the context must outlive any single query. + * TopTransactionContext is ideal: it survives until transaction + * commit/abort, and AtAbort cleanup will free the uset via + * UndoRecordSetFree(). + */ + parent = TopTransactionContext; + + /* + * Reuse a previously recycled memory context if available. This avoids + * the overhead of AllocSetContextCreate/MemoryContextDelete for every + * UndoRecordSet within a transaction. MemoryContextReset clears all + * allocations but keeps the context's memory blocks for reuse. + */ + if (UndoRecordReusableContext != NULL) + { + mctx = UndoRecordReusableContext; + UndoRecordReusableContext = NULL; /* take ownership */ + MemoryContextReset(mctx); + MemoryContextSetParent(mctx, parent); + } + else + { + mctx = AllocSetContextCreate(parent, + "UNDO record set", + ALLOCSET_DEFAULT_SIZES); + } + + /* + * Allocate everything in the uset's memory context using direct + * MemoryContextAlloc to avoid MemoryContextSwitchTo overhead. + */ + uset = (UndoRecordSet *) MemoryContextAllocZero(mctx, sizeof(UndoRecordSet)); + uset->xid = xid; + uset->prev_undo_ptr = prev_undo_ptr; + uset->persistence = UNDOPERSISTENCE_PERMANENT; + uset->type = URST_TRANSACTION; + + /* + * Allocate initial buffer. 512 bytes is enough for a single UNDO record + * (48-byte header + typical heap payload). For bulk mode the buffer + * grows dynamically via UndoRecordEnsureCapacity. + */ + uset->buffer_capacity = 512; + uset->buffer = (char *) MemoryContextAlloc(mctx, uset->buffer_capacity); + uset->buffer_size = 0; + + uset->last_batch_lsn = InvalidXLogRecPtr; + uset->mctx = mctx; + + return uset; +} + +/* + * UndoRecordSetFree - Free an UNDO record set + * + * Recycles the memory context for later reuse if possible, otherwise + * destroys it. We keep at most one recycled context to bound memory. + */ +void +UndoRecordSetFree(UndoRecordSet *uset) +{ + MemoryContext mctx; + + if (uset == NULL || uset->mctx == NULL) + return; + + mctx = uset->mctx; + + if (UndoRecordReusableContext == NULL) + { + /* + * Recycle this context for the next UndoRecordSetCreate call. + * + * Re-parent to TopMemoryContext so the cached context is not + * destroyed if its original parent is cleaned up before + * UndoRecordSetResetCache() runs. This can happen when the UNDO + * record set was created inside an SPI execution context (e.g., DO $$ + * ... $$ blocks): SPI_finish() deletes its procCxt/execCxt, which + * would recursively destroy this child context, leaving + * UndoRecordReusableContext as a dangling pointer. + * UndoRecordSetCreate() will re-parent it to the caller's + * CurrentMemoryContext on reuse. + */ + MemoryContextSetParent(mctx, TopMemoryContext); + UndoRecordReusableContext = mctx; + } + else + { + /* Already have one recycled context; destroy this one */ + MemoryContextDelete(mctx); + } +} + +/* + * UndoRecordEnsureCapacity - Ensure the uset buffer can hold additional bytes + * + * Grows the buffer (using the uset's memory context) if needed. + * Avoids MemoryContextSwitchTo overhead by using MemoryContextAlloc directly. + */ +static void +UndoRecordEnsureCapacity(UndoRecordSet *uset, Size additional) +{ + if (uset->buffer_size + additional > uset->buffer_capacity) + { + Size new_capacity = uset->buffer_capacity * 2; + char *newbuf; + + while (new_capacity < uset->buffer_size + additional) + new_capacity *= 2; + + newbuf = (char *) MemoryContextAlloc(uset->mctx, new_capacity); + if (uset->buffer_size > 0) + memcpy(newbuf, uset->buffer, uset->buffer_size); + pfree(uset->buffer); + uset->buffer = newbuf; + uset->buffer_capacity = new_capacity; + } +} + +/* + * UndoRecordSetReset - Reset a record set for reuse + * + * Resets the buffer position and record count without freeing the memory + * context or reallocating the buffer. This is much cheaper than + * UndoRecordSetCreate/Free (~5 cycles vs ~300 cycles) because it avoids + * MemoryContextReset/AllocSetContextCreate overhead entirely. + * + * The prev_undo_ptr and other metadata are preserved so the record set + * can continue chaining records correctly across multiple insertions + * within the same transaction. + */ +void +UndoRecordSetReset(UndoRecordSet *uset) +{ + if (uset == NULL) + return; + + uset->buffer_size = 0; + uset->nrecords = 0; +} + +/* + * UndoRecordSetResetCache - Release the recycled memory context. + * + * Called at transaction end (commit or abort) to ensure the cached + * context does not outlive the transaction. + */ +void +UndoRecordSetResetCache(void) +{ + if (UndoRecordReusableContext != NULL) + { + MemoryContextDelete(UndoRecordReusableContext); + UndoRecordReusableContext = NULL; + } +} + +/* + * UndoRecordAddPayload - Add an UNDO record with opaque payload to the set + * + * This is the main API for adding UNDO records. The caller provides an + * RM ID, RM-specific info flags, a relation OID, and an opaque payload. + * The payload's interpretation is entirely RM-specific. + */ +void +UndoRecordAddPayload(UndoRecordSet *uset, + uint8 rmid, + uint16 info, + Oid reloid, + const char *payload, + Size payload_len) +{ + UndoRecordHeader *header; + Size record_size; + char *dest; + + if (uset == NULL) + elog(ERROR, "cannot add UNDO record to NULL set"); + + record_size = UndoRecordGetPayloadSize(payload_len); + + /* Expand buffer if needed (allocate in the uset's memory context) */ + UndoRecordEnsureCapacity(uset, record_size); + + /* + * Build the header directly in the buffer, avoiding a separate stack + * variable, memset, and memcpy. We zero the header in-place to avoid + * uninitialized padding bytes in the on-disk format. + */ + dest = uset->buffer + uset->buffer_size; + header = (UndoRecordHeader *) dest; + memset(header, 0, SizeOfUndoRecordHeader); + header->urec_rmid = rmid; + header->urec_flags = UNDO_INFO_XID_VALID; + if (payload_len > 0) + header->urec_flags |= UNDO_INFO_HAS_PAYLOAD; + header->urec_info = info; + header->urec_len = (uint32) record_size; + header->urec_xid = uset->xid; + header->urec_prev = uset->prev_undo_ptr; + header->urec_reloid = reloid; + header->urec_payload_len = (uint32) payload_len; + header->urec_clr_ptr = InvalidXLogRecPtr; + + /* Copy payload directly after header */ + if (payload_len > 0 && payload != NULL) + memcpy(dest + SizeOfUndoRecordHeader, payload, payload_len); + + uset->buffer_size += record_size; + uset->nrecords++; +} + +/* + * UndoRecordAddPayloadParts - Add an UNDO record with scatter-gather payload + * + * Like UndoRecordAddPayload, but takes the payload as two parts that are + * concatenated directly into the uset buffer. This avoids allocating an + * intermediate payload buffer when the caller has the data in separate + * pieces (e.g., a fixed header struct + variable-length tuple data). + */ +void +UndoRecordAddPayloadParts(UndoRecordSet *uset, + uint8 rmid, + uint16 info, + Oid reloid, + const char *part1, + Size part1_len, + const char *part2, + Size part2_len) +{ + UndoRecordHeader *header; + Size payload_len = part1_len + part2_len; + Size record_size; + char *dest; + + if (uset == NULL) + elog(ERROR, "cannot add UNDO record to NULL set"); + + record_size = UndoRecordGetPayloadSize(payload_len); + + UndoRecordEnsureCapacity(uset, record_size); + + /* Build header directly in the buffer */ + dest = uset->buffer + uset->buffer_size; + header = (UndoRecordHeader *) dest; + memset(header, 0, SizeOfUndoRecordHeader); + header->urec_rmid = rmid; + header->urec_flags = UNDO_INFO_XID_VALID; + if (payload_len > 0) + header->urec_flags |= UNDO_INFO_HAS_PAYLOAD; + header->urec_info = info; + header->urec_len = (uint32) record_size; + header->urec_xid = uset->xid; + header->urec_prev = uset->prev_undo_ptr; + header->urec_reloid = reloid; + header->urec_payload_len = (uint32) payload_len; + header->urec_clr_ptr = InvalidXLogRecPtr; + + /* Copy payload parts directly after header */ + dest += SizeOfUndoRecordHeader; + if (part1_len > 0 && part1 != NULL) + { + memcpy(dest, part1, part1_len); + dest += part1_len; + } + if (part2_len > 0 && part2 != NULL) + memcpy(dest, part2, part2_len); + + uset->buffer_size += record_size; + uset->nrecords++; +} + +/* + * UndoRecordSetGetSize - Get total size of all records in set + */ +Size +UndoRecordSetGetSize(UndoRecordSet *uset) +{ + if (uset == NULL) + return 0; + + return uset->buffer_size; +} diff --git a/src/backend/access/undo/undormgr.c b/src/backend/access/undo/undormgr.c new file mode 100644 index 0000000000000..851ef61ae8d4f --- /dev/null +++ b/src/backend/access/undo/undormgr.c @@ -0,0 +1,70 @@ +/*------------------------------------------------------------------------- + * + * undormgr.c + * UNDO resource manager registration and dispatch + * + * This module manages the registration table for UNDO resource managers. + * Each access method or subsystem that writes UNDO records registers + * its callbacks here. The generic UNDO infrastructure dispatches to + * the appropriate callback based on the urec_rmid in the record header. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undormgr.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/undormgr.h" + +/* Global registration table, indexed by RM ID */ +const UndoRmgrData *UndoRmgrs[MAX_UNDO_RMGRS]; + +/* + * RegisterUndoRmgr - Register an UNDO resource manager + * + * Called by each AM/subsystem during initialization to register its + * UNDO apply and describe callbacks. + */ +void +RegisterUndoRmgr(uint8 rmid, const UndoRmgrData *rmgr) +{ + if (rmid == UNDO_RMID_INVALID) + elog(ERROR, "cannot register UNDO RM with invalid ID 0"); + + if (UndoRmgrs[rmid] != NULL) + elog(ERROR, "UNDO RM ID %u already registered as \"%s\"", + rmid, UndoRmgrs[rmid]->rm_name); + + if (rmgr->rm_undo == NULL) + elog(ERROR, "UNDO RM \"%s\" must provide an rm_undo callback", + rmgr->rm_name ? rmgr->rm_name : "(null)"); + + UndoRmgrs[rmid] = rmgr; +} + +/* + * GetUndoRmgr - Look up an UNDO resource manager by ID + * + * Returns the registration entry, or NULL if not registered. + */ +const UndoRmgrData * +GetUndoRmgr(uint8 rmid) +{ + return UndoRmgrs[rmid]; +} + +/* + * InitUndoRmgrs - Initialize the UNDO resource manager table + * + * Called during postmaster startup. Individual RMs register themselves + * via RegisterUndoRmgr() during their initialization. + */ +void +InitUndoRmgrs(void) +{ + MemSet(UndoRmgrs, 0, sizeof(UndoRmgrs)); +} diff --git a/src/backend/access/undo/undostats.c b/src/backend/access/undo/undostats.c new file mode 100644 index 0000000000000..554252cbc57cc --- /dev/null +++ b/src/backend/access/undo/undostats.c @@ -0,0 +1,375 @@ +/*------------------------------------------------------------------------- + * + * undostats.c + * UNDO log statistics collection and reporting + * + * This module provides monitoring and observability for the UNDO + * subsystem, including: + * - Per-log statistics (insert/discard pointers, size, oldest xid, state) + * - Buffer cache statistics (hits, misses, evictions) + * - Aggregate counters (total records, bytes generated) + * - Force discard and rotation SQL function + * + * Statistics can be queried via SQL functions pg_stat_get_undo_logs() + * and pg_stat_get_undo_buffers(), registered in pg_proc.dat. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undostats.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/htup_details.h" +#include "access/undolog.h" +#include "access/undostats.h" +#include "access/undoworker.h" +#include "access/undo_xlog.h" +#include "catalog/pg_authid.h" +#include "fmgr.h" +#include "funcapi.h" +#include "miscadmin.h" +#include "storage/lwlock.h" +#include "utils/acl.h" +#include "utils/builtins.h" + +PG_FUNCTION_INFO_V1(pg_stat_get_undo_logs); +PG_FUNCTION_INFO_V1(pg_stat_get_undo_buffers); +PG_FUNCTION_INFO_V1(pg_undo_force_discard); + +/* + * UndoLogStateToString - Convert lifecycle state to display string + */ +static const char * +UndoLogStateToString(UndoLogState state) +{ + switch (state) + { + case UNDO_LOG_FREE: + return "free"; + case UNDO_LOG_ACTIVE: + return "active"; + case UNDO_LOG_SEALED: + return "sealed"; + case UNDO_LOG_DISCARDABLE: + return "discardable"; + } + return "unknown"; +} + +/* + * GetUndoLogStats - Get statistics for all active UNDO logs + * + * Fills the provided array with stats for each active log. + * Returns the number of active logs found. + */ +int +GetUndoLogStats(UndoLogStat *stats, int max_stats) +{ + int count = 0; + int i; + + if (UndoLogShared == NULL) + return 0; + + for (i = 0; i < MAX_UNDO_LOGS && count < max_stats; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (!log->in_use) + continue; + + LWLockAcquire(&log->lock, LW_SHARED); + + stats[count].log_number = log->log_number; + stats[count].insert_ptr = pg_atomic_read_u64(&log->insert_ptr); + stats[count].discard_ptr = log->discard_ptr; + stats[count].oldest_xid = log->oldest_xid; + stats[count].state = log->state; + + /* Calculate size as difference between insert and discard offsets */ + stats[count].size_bytes = + UndoRecPtrGetOffset(stats[count].insert_ptr) - + UndoRecPtrGetOffset(log->discard_ptr); + + LWLockRelease(&log->lock); + + count++; + } + + return count; +} + +/* + * GetUndoBufferStats - Get UNDO buffer statistics + * + * With the shared_buffers integration, UNDO pages are managed by the + * standard buffer pool. Dedicated UNDO buffer statistics are no longer + * tracked separately. This function returns zeros for all counters. + * Use pg_buffercache to inspect UNDO pages in shared_buffers if needed. + */ +void +GetUndoBufferStats(UndoBufferStat *stats) +{ + stats->num_buffers = 0; + stats->cache_hits = 0; + stats->cache_misses = 0; + stats->cache_evictions = 0; + stats->cache_writes = 0; +} + +/* + * pg_stat_get_undo_logs - SQL-callable function returning UNDO log stats + * + * Returns a set of rows, one per active UNDO log, with columns: + * log_number, insert_offset, discard_offset, size_bytes, oldest_xid, state + */ +Datum +pg_stat_get_undo_logs(PG_FUNCTION_ARGS) +{ + FuncCallContext *funcctx; + UndoLogStat *stats; + + if (SRF_IS_FIRSTCALL()) + { + MemoryContext oldcxt; + TupleDesc tupdesc; + int nstats; + + funcctx = SRF_FIRSTCALL_INIT(); + oldcxt = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx); + + /* Build tuple descriptor with 6 columns (added state) */ + tupdesc = CreateTemplateTupleDesc(6); + TupleDescInitEntry(tupdesc, (AttrNumber) 1, "log_number", + INT4OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 2, "insert_offset", + INT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 3, "discard_offset", + INT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 4, "size_bytes", + INT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 5, "oldest_xid", + XIDOID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 6, "state", + TEXTOID, -1, 0); + + funcctx->tuple_desc = BlessTupleDesc(tupdesc); + + /* Collect stats snapshot */ + stats = (UndoLogStat *) palloc(sizeof(UndoLogStat) * MAX_UNDO_LOGS); + nstats = GetUndoLogStats(stats, MAX_UNDO_LOGS); + + funcctx->user_fctx = stats; + funcctx->max_calls = nstats; + + MemoryContextSwitchTo(oldcxt); + } + + funcctx = SRF_PERCALL_SETUP(); + stats = (UndoLogStat *) funcctx->user_fctx; + + if (funcctx->call_cntr < funcctx->max_calls) + { + UndoLogStat *stat = &stats[funcctx->call_cntr]; + Datum values[6]; + bool nulls[6]; + HeapTuple tuple; + + MemSet(nulls, 0, sizeof(nulls)); + + values[0] = Int32GetDatum(stat->log_number); + values[1] = Int64GetDatum(UndoRecPtrGetOffset(stat->insert_ptr)); + values[2] = Int64GetDatum(UndoRecPtrGetOffset(stat->discard_ptr)); + values[3] = Int64GetDatum(stat->size_bytes); + values[4] = TransactionIdGetDatum(stat->oldest_xid); + values[5] = CStringGetTextDatum(UndoLogStateToString(stat->state)); + + tuple = heap_form_tuple(funcctx->tuple_desc, values, nulls); + + SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple)); + } + + SRF_RETURN_DONE(funcctx); +} + +/* + * pg_stat_get_undo_buffers - SQL-callable function returning buffer stats + * + * Returns a single row with UNDO buffer cache statistics: + * num_buffers, cache_hits, cache_misses, cache_evictions, cache_writes, + * hit_ratio + */ +Datum +pg_stat_get_undo_buffers(PG_FUNCTION_ARGS) +{ + TupleDesc tupdesc; + Datum values[6]; + bool nulls[6]; + HeapTuple tuple; + UndoBufferStat stats; + + /* Build tuple descriptor */ + tupdesc = CreateTemplateTupleDesc(6); + TupleDescInitEntry(tupdesc, (AttrNumber) 1, "num_buffers", + INT4OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 2, "cache_hits", + INT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 3, "cache_misses", + INT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 4, "cache_evictions", + INT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 5, "cache_writes", + INT8OID, -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 6, "hit_ratio", + FLOAT4OID, -1, 0); + + tupdesc = BlessTupleDesc(tupdesc); + + /* Get statistics */ + GetUndoBufferStats(&stats); + + MemSet(nulls, 0, sizeof(nulls)); + + values[0] = Int32GetDatum(stats.num_buffers); + values[1] = Int64GetDatum(stats.cache_hits); + values[2] = Int64GetDatum(stats.cache_misses); + values[3] = Int64GetDatum(stats.cache_evictions); + values[4] = Int64GetDatum(stats.cache_writes); + + /* Calculate hit ratio */ + { + uint64 total = stats.cache_hits + stats.cache_misses; + + if (total > 0) + values[5] = Float4GetDatum((float4) stats.cache_hits / total); + else + values[5] = Float4GetDatum(0.0); + } + + tuple = heap_form_tuple(tupdesc, values, nulls); + + PG_RETURN_DATUM(HeapTupleGetDatum(tuple)); +} + +/* + * pg_undo_force_discard - Force UNDO log discard and optional rotation + * + * SQL-callable function that performs immediate discard of reclaimable + * UNDO records and optionally rotates the active log segment. + * + * Arguments: + * force_rotate (bool) - If true, seal and rotate the active log first + * + * Returns the number of log segments freed (int4). + * + * Requires the pg_maintain role for access. + */ +Datum +pg_undo_force_discard(PG_FUNCTION_ARGS) +{ + bool force_rotate = PG_GETARG_BOOL(0); + int freed_count = 0; + TransactionId oldest_xid; + int i; + + /* Permission check: require pg_maintain role */ + if (!has_privs_of_role(GetUserId(), ROLE_PG_MAINTAIN)) + ereport(ERROR, + (errcode(ERRCODE_INSUFFICIENT_PRIVILEGE), + errmsg("must be a member of pg_maintain to force UNDO discard"))); + + if (UndoLogShared == NULL) + ereport(ERROR, + (errmsg("UNDO subsystem is not initialized"))); + + /* Optional rotation */ + if (force_rotate) + UndoLogSealAndRotate(UNDO_ROTATE_MANUAL); + + /* Perform inline discard (same as discard worker Phase 1 + Phase 2) */ + oldest_xid = UndoWorkerGetOldestXid(); + if (!TransactionIdIsValid(oldest_xid)) + oldest_xid = ReadNextTransactionId(); + + /* Phase 1: advance discard pointers */ + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (!log->in_use) + continue; + + LWLockAcquire(&log->lock, LW_EXCLUSIVE); + + if (TransactionIdIsValid(log->oldest_xid) && + TransactionIdPrecedes(log->oldest_xid, oldest_xid)) + { + UndoRecPtr insert_ptr = pg_atomic_read_u64(&log->insert_ptr); + + if (UndoRecPtrGetOffset(insert_ptr) > + UndoRecPtrGetOffset(log->discard_ptr)) + { + log->discard_ptr = insert_ptr; + log->oldest_xid = oldest_xid; + } + } + + LWLockRelease(&log->lock); + } + + /* Phase 2: lifecycle transitions */ + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (!log->in_use) + continue; + + LWLockAcquire(&log->lock, LW_EXCLUSIVE); + + /* SEALED -> DISCARDABLE if fully discarded */ + if (log->state == UNDO_LOG_SEALED) + { + UndoRecPtr seal = pg_atomic_read_u64(&log->seal_ptr); + UndoRecPtr discard = log->discard_ptr; + + if (UndoRecPtrIsValid(seal) && + UndoRecPtrGetOffset(discard) >= UndoRecPtrGetOffset(seal)) + { + log->state = UNDO_LOG_DISCARDABLE; + } + } + + /* DISCARDABLE -> FREE: clean up */ + if (log->state == UNDO_LOG_DISCARDABLE) + { + uint32 log_number = log->log_number; + + log->in_use = false; + log->state = UNDO_LOG_FREE; + log->log_number = 0; + pg_atomic_write_u64(&log->insert_ptr, InvalidUndoRecPtr); + log->discard_ptr = InvalidUndoRecPtr; + log->oldest_xid = InvalidTransactionId; + pg_atomic_write_u64(&log->seal_ptr, InvalidUndoRecPtr); + log->sealed_time = 0; + + LWLockRelease(&log->lock); + + UndoLogDeleteSegmentFile(log_number); + freed_count++; + continue; + } + + LWLockRelease(&log->lock); + } + + /* Wake the background worker for any remaining work */ + WakeUndoDiscardWorker(); + + PG_RETURN_INT32(freed_count); +} diff --git a/src/backend/access/undo/undoworker.c b/src/backend/access/undo/undoworker.c new file mode 100644 index 0000000000000..a4922957ad75f --- /dev/null +++ b/src/backend/access/undo/undoworker.c @@ -0,0 +1,635 @@ +/*------------------------------------------------------------------------- + * + * undoworker.c + * UNDO worker background process implementation + * + * The UNDO worker periodically discards old UNDO records that are no + * longer needed by any active transaction. This is essential for + * preventing unbounded growth of UNDO logs. + * + * The worker also advances the undo_discard_horizon, allowing WAL + * segments containing fully-discarded UNDO batches to be recycled. + * + * Design based on ZHeap's UNDO worker and PostgreSQL's autovacuum + * launcher patterns. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/access/undo/undoworker.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#include + +#include "access/relundo.h" +#include "access/undolog.h" +#include "access/undorecord.h" +#include "access/undormgr.h" +#include "access/undoworker.h" +#include "access/transam.h" +#include "access/xact.h" +#include "access/xlog.h" +#include "libpq/pqsignal.h" +#include "miscadmin.h" +#include "pgstat.h" +#include "postmaster/bgworker.h" +#include "postmaster/interrupt.h" +#include "storage/aio_subsys.h" +#include "storage/bufmgr.h" +#include "storage/fd.h" +#include "storage/ipc.h" +#include "storage/latch.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "storage/procsignal.h" +#include "storage/smgr.h" +#include "tcop/tcopprot.h" +#include "utils/guc.h" +#include "utils/hsearch.h" +#include "utils/injection_point.h" +#include "utils/memutils.h" +#include "utils/resowner.h" +#include "utils/timeout.h" +#include "utils/timestamp.h" +#include "utils/wait_event.h" + +/* Shared memory state */ +static UndoWorkerShmemData *UndoWorkerShmem = NULL; + +/* Adaptive sleep: use shorter interval when sealed logs are pending */ +#define UNDO_WORKER_FAST_NAPTIME_MS 200 + +/* Forward declarations */ +static void undo_worker_sighup(SIGNAL_ARGS); +static void undo_worker_sigterm(SIGNAL_ARGS); +static void perform_undo_discard(void); + +/* + * UndoWorkerShmemSize - Calculate shared memory needed + */ +Size +UndoWorkerShmemSize(void) +{ + return sizeof(UndoWorkerShmemData); +} + +/* + * UndoWorkerShmemInit - Initialize shared memory + */ +void +UndoWorkerShmemInit(void) +{ + bool found; + + UndoWorkerShmem = (UndoWorkerShmemData *) + ShmemInitStruct("UNDO Worker Data", + UndoWorkerShmemSize(), + &found); + + if (!found) + { + LWLockInitialize(&UndoWorkerShmem->lock, + LWTRANCHE_UNDO_LOG); + + pg_atomic_init_u64(&UndoWorkerShmem->last_discard_time, 0); + UndoWorkerShmem->oldest_xid_checked = InvalidTransactionId; + UndoWorkerShmem->last_discard_ptr = InvalidUndoRecPtr; + UndoWorkerShmem->naptime_ms = undo_worker_naptime; + UndoWorkerShmem->shutdown_requested = false; + + /* Rotation coordination fields */ + UndoWorkerShmem->worker_proc = INVALID_PROC_NUMBER; + pg_atomic_init_u32(&UndoWorkerShmem->sealed_log_count, 0); + } +} + +/* + * undo_worker_sighup - SIGHUP handler + */ +static void +undo_worker_sighup(SIGNAL_ARGS) +{ + (void) postgres_signal_arg; /* unused */ + ConfigReloadPending = true; + SetLatch(MyLatch); +} + +/* + * undo_worker_sigterm - SIGTERM handler + */ +static void +undo_worker_sigterm(SIGNAL_ARGS) +{ + (void) postgres_signal_arg; /* unused */ + UndoWorkerShmem->shutdown_requested = true; + SetLatch(MyLatch); +} + +/* + * WakeUndoDiscardWorker + * Wake the UNDO discard worker via its latch. + * + * Follows the WAL writer wakeup pattern: read the worker's ProcNumber + * and set its latch to interrupt the WaitLatch sleep. Safe to call + * from any backend, including during allocation pressure. + */ +void +WakeUndoDiscardWorker(void) +{ + ProcNumber proc; + + if (UndoWorkerShmem == NULL) + return; + + proc = UndoWorkerShmem->worker_proc; + if (proc != INVALID_PROC_NUMBER) + SetLatch(&GetPGProcByNumber(proc)->procLatch); +} + +/* + * UndoWorkerGetOldestXid - Get oldest transaction still needing UNDO + * + * Returns the oldest transaction ID that is still active across all + * databases. Any UNDO records created by transactions older than this + * can be safely discarded, because those transactions have already + * committed or aborted and their UNDO is no longer needed. + * + * We use GetOldestActiveTransactionId() from procarray.c which properly + * acquires ProcArrayLock and scans all backends. We pass allDbs=true + * because UNDO logs are not per-database -- a single UNDO log may + * contain records for multiple databases. + * + * Returns InvalidTransactionId if there are no active transactions, + * meaning all UNDO records can potentially be discarded (subject to + * retention policy). + */ +TransactionId +UndoWorkerGetOldestXid(void) +{ + TransactionId oldest_xid; + + /* + * Don't attempt the scan during recovery -- the UNDO worker should not be + * running in that case, but guard defensively. + */ + if (RecoveryInProgress()) + return InvalidTransactionId; + + /* + * GetOldestActiveTransactionId scans ProcArray under ProcArrayLock + * (LW_SHARED) and returns the smallest XID among all active backends. We + * pass inCommitOnly=false (we want all active XIDs, not just those in + * commit critical section) and allDbs=true (UNDO spans all databases). + */ + oldest_xid = GetOldestActiveTransactionId(false, true); + + return oldest_xid; +} + +/* + * perform_undo_discard - Main discard logic + * + * Two-phase approach: + * Phase 1: Update discard pointers for all in-use logs based on + * the oldest active transaction ID. + * Phase 2: Scan SEALED/DISCARDABLE logs and manage lifecycle + * transitions: SEALED -> DISCARDABLE -> FREE. + */ +static void +perform_undo_discard(void) +{ + TransactionId oldest_xid; + UndoRecPtr oldest_undo_ptr; + TimestampTz current_time; + int i; + int freed_count = 0; + + /* Get oldest active transaction */ + oldest_xid = UndoWorkerGetOldestXid(); + + if (!TransactionIdIsValid(oldest_xid)) + { + /* No active transactions, can discard all UNDO */ + oldest_xid = ReadNextTransactionId(); + } + + current_time = GetCurrentTimestamp(); + + /* + * Scan per-backend UNDO batch LSN slots and clear any that belong to dead + * backends. A backend that was SIGKILLed (or otherwise exited without + * calling AtProcExit) will leave its slot occupied, which pins the WAL + * discard horizon indefinitely. We detect dead backends by checking + * ProcGlobal->allProcs[i].pid == 0, which indicates the slot is not in + * use by a live process (pid 0 also indicates prepared-xact dummy + * PGPROCs, but those do not write UNDO data). + */ + for (i = 0; i < MaxBackends; i++) + { + XLogRecPtr slot_lsn; + + slot_lsn = (XLogRecPtr) + pg_atomic_read_u64(&UndoLogShared->backend_undo_lsns[i]); + + if (!XLogRecPtrIsValid(slot_lsn)) + continue; + + if (GetPGProcByNumber(i)->pid == 0) + { + pg_atomic_write_u64(&UndoLogShared->backend_undo_lsns[i], + (uint64) InvalidXLogRecPtr); + ereport(DEBUG2, + (errmsg("UNDO worker: cleared stale batch LSN for dead backend slot %d", i))); + } + } + + /* + * Phase 1: For each UNDO log, determine what can be discarded. We need + * to respect the retention_time setting to allow point-in-time recovery. + */ + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (!log->in_use) + continue; + + /* + * Calculate the oldest UNDO pointer that must be retained. This is + * based on: 1. The oldest active transaction 2. The retention time + * setting + */ + LWLockAcquire(&log->lock, LW_SHARED); + + if (TransactionIdIsValid(log->oldest_xid) && + TransactionIdPrecedes(log->oldest_xid, oldest_xid)) + { + /* This log has UNDO that can be discarded */ + oldest_undo_ptr = pg_atomic_read_u64(&log->insert_ptr); + + LWLockRelease(&log->lock); + + /* Update discard pointer */ + UndoLogDiscard(oldest_undo_ptr); + + /* Update cumulative discard counter */ + pg_atomic_fetch_add_u64(&UndoLogShared->total_discarded, + UndoRecPtrGetOffset(oldest_undo_ptr)); + + ereport(DEBUG2, + (errmsg("UNDO worker: discarded log %u up to %llu", + log->log_number, + (unsigned long long) oldest_undo_ptr))); + } + else + { + LWLockRelease(&log->lock); + } + } + + /* + * Phase 2: Manage lifecycle transitions for SEALED and DISCARDABLE logs. + * + * SEALED logs whose discard_ptr >= seal_ptr have had all their records + * discarded and can transition to DISCARDABLE. DISCARDABLE logs can have + * their slot freed and segment file deleted. + */ + for (i = 0; i < MAX_UNDO_LOGS; i++) + { + UndoLogControl *log = &UndoLogShared->logs[i]; + + if (!log->in_use) + continue; + + LWLockAcquire(&log->lock, LW_EXCLUSIVE); + + if (log->state == UNDO_LOG_SEALED) + { + UndoRecPtr seal = pg_atomic_read_u64(&log->seal_ptr); + UndoRecPtr discard = log->discard_ptr; + + if (UndoRecPtrIsValid(seal) && + UndoRecPtrGetOffset(discard) >= UndoRecPtrGetOffset(seal)) + { + /* All records discarded -- transition to DISCARDABLE */ + log->state = UNDO_LOG_DISCARDABLE; + ereport(DEBUG1, + (errmsg("UNDO worker: log %u transitioned to DISCARDABLE", + log->log_number))); + } + } + + if (log->state == UNDO_LOG_DISCARDABLE) + { + uint32 log_number = log->log_number; + + /* Free the slot */ + log->in_use = false; + log->state = UNDO_LOG_FREE; + log->log_number = 0; + pg_atomic_write_u64(&log->insert_ptr, InvalidUndoRecPtr); + log->discard_ptr = InvalidUndoRecPtr; + log->oldest_xid = InvalidTransactionId; + pg_atomic_write_u64(&log->seal_ptr, InvalidUndoRecPtr); + log->sealed_time = 0; + + LWLockRelease(&log->lock); + + /* Delete the segment file outside the lock */ + UndoLogDeleteSegmentFile(log_number); + + /* Decrement sealed log count */ + pg_atomic_fetch_sub_u32(&UndoWorkerShmem->sealed_log_count, 1); + + freed_count++; + continue; + } + + LWLockRelease(&log->lock); + } + + if (freed_count > 0) + ereport(LOG, + (errmsg("UNDO worker: freed %d discardable log segment(s)", + freed_count))); + + /* + * Advance the WAL discard horizon so KeepLogSeg() can allow recycling of + * WAL segments no longer needed for UNDO rollback. + * + * UndoGetOldestBatchLSN() scans per-backend slots and returns the minimum + * first-batch LSN across all active transactions that have written UNDO + * data. WAL before this LSN cannot be recycled. + * + * If no backend has in-flight UNDO data the function returns + * InvalidXLogRecPtr, meaning there is no UNDO-imposed WAL retention + * requirement. We do not call UndoSetDiscardHorizon in that case because + * an invalid horizon is already the "no constraint" sentinel. + */ + { + XLogRecPtr new_horizon = UndoGetOldestBatchLSN(); + + if (XLogRecPtrIsValid(new_horizon)) + UndoSetDiscardHorizon(new_horizon); + + /* + * If undo_max_wal_retention_size is set, warn when the retained WAL + * distance between the current write position and the UNDO discard + * horizon exceeds the configured limit. This helps operators detect + * long-running transactions that prevent WAL recycling. + */ + if (undo_max_wal_retention_size > 0 && XLogRecPtrIsValid(new_horizon)) + { + XLogRecPtr write_ptr = GetXLogWriteRecPtr(); + + if (write_ptr > new_horizon) + { + uint64 retained_mb = (write_ptr - new_horizon) >> 20; + + if (retained_mb > (uint64) undo_max_wal_retention_size) + ereport(WARNING, + (errmsg("UNDO WAL retention (%lu MB) exceeds undo_max_wal_retention_size (%d MB)", + (unsigned long) retained_mb, undo_max_wal_retention_size), + errhint("Investigate long-running transactions or increase undo_max_wal_retention_size."))); + } + } + } + + /* Record this discard operation */ + LWLockAcquire(&UndoWorkerShmem->lock, LW_EXCLUSIVE); + pg_atomic_write_u64(&UndoWorkerShmem->last_discard_time, + (uint64) current_time); + UndoWorkerShmem->oldest_xid_checked = oldest_xid; + LWLockRelease(&UndoWorkerShmem->lock); +} + +/* + * UndoWorkerMain - Main loop for UNDO worker + * + * This is the entry point for the UNDO worker background process. + * It runs continuously, waking periodically to discard old UNDO. + * + * Uses adaptive sleep: when sealed logs are pending cleanup, the worker + * wakes more frequently (200ms) to process them promptly. Otherwise + * it uses the configured undo_worker_naptime. + */ +void +UndoWorkerMain(Datum main_arg) +{ + sigjmp_buf local_sigjmp_buf; + MemoryContext undo_worker_context; + + (void) main_arg; /* unused */ + + /* Establish signal handlers */ + pqsignal(SIGHUP, undo_worker_sighup); + pqsignal(SIGTERM, undo_worker_sigterm); + + /* We're now ready to receive signals */ + BackgroundWorkerUnblockSignals(); + + /* + * Connect with no specific database so the worker is a stats-reporting + * backend (visible in pg_stat_activity as 'undo worker') while not pinning + * any database against DROP. Discard operates on cluster-wide UNDO logs, + * so it needs no per-database catalog access. + */ + BackgroundWorkerInitializeConnection(NULL, NULL, 0); + + /* Register our ProcNumber for latch-based wakeup by other backends */ + UndoWorkerShmem->worker_proc = MyProcNumber; + + /* Initialize worker state */ + ereport(LOG, + (errmsg("UNDO worker started"))); + + /* + * Create a memory context for the worker. This will be reset after each + * iteration and during error recovery. + */ + undo_worker_context = AllocSetContextCreate(TopMemoryContext, + "UNDO Worker", + ALLOCSET_DEFAULT_SIZES); + MemoryContextSwitchTo(undo_worker_context); + + /* + * If an exception is encountered, processing resumes here. + * + * Unlike the autovacuum worker, this is a long-lived background process + * that must survive transient errors (e.g. an ERROR raised while + * discarding UNDO or cleaning up retained sLog before-images). Letting + * the error propagate would terminate the worker; the postmaster would + * restart it, but any in-flight discard progress would be abandoned and + * discard could stall. Instead we recover in place, mirroring the + * autovacuum launcher. + * + * We use sigsetjmp(..., 1) so the prevailing signal mask is restored on + * longjmp; signals other than SIGQUIT stay blocked until we exit. The + * HOLD_INTERRUPTS() call is still required because InterruptPending might + * already be set. + */ + if (sigsetjmp(local_sigjmp_buf, 1) != 0) + { + /* since not using PG_TRY, must reset error stack by hand */ + error_context_stack = NULL; + + /* Prevents interrupts while cleaning up */ + HOLD_INTERRUPTS(); + + /* Report the error to the server log */ + EmitErrorReport(); + + /* + * Abort the current transaction in order to recover, but only if one + * is actually in progress. perform_undo_discard() and the sLog + * cleanup operate on shared memory and do not normally open a + * transaction; guarding avoids a spurious "AbortCurrentTransaction + * when not in transaction" path. + */ + if (IsTransactionState()) + AbortCurrentTransaction(); + + /* Release any other resources we might still be holding. */ + LWLockReleaseAll(); + pgstat_report_wait_end(); + pgaio_error_cleanup(); + UnlockBuffers(); + if (AuxProcessResourceOwner) + ReleaseAuxProcessResources(false); + AtEOXact_Buffers(false); + AtEOXact_SMgr(); + AtEOXact_Files(false); + AtEOXact_HashTables(false); + + /* Return to the worker context and clear ErrorContext. */ + MemoryContextSwitchTo(undo_worker_context); + FlushErrorState(); + + /* Flush any leaked data in the worker context. */ + MemoryContextReset(undo_worker_context); + + /* Now we can allow interrupts again */ + RESUME_INTERRUPTS(); + + /* + * Sleep at least 1 second after any error. We don't want to be + * filling the error logs as fast as we can. + */ + pg_usleep(1000000L); + } + + /* We can now handle ereport(ERROR) */ + PG_exception_stack = &local_sigjmp_buf; + + /* + * Main loop: wake up periodically and discard old UNDO + */ + while (!UndoWorkerShmem->shutdown_requested) + { + int rc; + long naptime; + uint32 sealed_count; + + /* Process any pending configuration changes */ + if (ConfigReloadPending) + { + ConfigReloadPending = false; + ProcessConfigFile(PGC_SIGHUP); + + /* Update naptime from GUC */ + UndoWorkerShmem->naptime_ms = undo_worker_naptime; + } + + CHECK_FOR_INTERRUPTS(); + + INJECTION_POINT("undo-worker-before-discard", NULL); + + /* Perform UNDO discard */ + perform_undo_discard(); + + /* + * Clean up retained before-image entries that are no longer needed by + * any active snapshot. A registered AM hook performs the reclamation; + * the reclamation horizon is the xid horizon. + */ + if (RelUndoDiscardRetained_hook) + RelUndoDiscardRetained_hook(); + + INJECTION_POINT("undo-worker-after-discard", NULL); + + /* + * Adaptive sleep: use a shorter interval when sealed logs are pending + * cleanup, similar to the WAL writer's adaptive sleep. + */ + sealed_count = pg_atomic_read_u32(&UndoWorkerShmem->sealed_log_count); + if (sealed_count > 0) + naptime = UNDO_WORKER_FAST_NAPTIME_MS; + else + naptime = UndoWorkerShmem->naptime_ms; + + /* Sleep until next iteration, latch set, or signal */ + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + naptime, + WAIT_EVENT_UNDO_WORKER_MAIN); + + ResetLatch(MyLatch); + + /* Emergency bailout if postmaster died */ + if (rc & WL_POSTMASTER_DEATH) + proc_exit(1); + } + + /* Clear our ProcNumber before exiting */ + UndoWorkerShmem->worker_proc = INVALID_PROC_NUMBER; + + /* Normal shutdown */ + ereport(LOG, + (errmsg("UNDO worker shutting down"))); + + proc_exit(0); +} + +/* + * UndoWorkerRegister - Register the UNDO worker at server start + * + * This is called from postmaster during server initialization. + */ +void +UndoWorkerRegister(void) +{ + BackgroundWorker worker; + + memset(&worker, 0, sizeof(BackgroundWorker)); + + worker.bgw_flags = BGWORKER_SHMEM_ACCESS | + BGWORKER_BACKEND_DATABASE_CONNECTION; + worker.bgw_start_time = BgWorkerStart_RecoveryFinished; + worker.bgw_restart_time = 10; /* Restart after 10 seconds if crashed */ + + sprintf(worker.bgw_library_name, "postgres"); + sprintf(worker.bgw_function_name, "UndoWorkerMain"); + snprintf(worker.bgw_name, BGW_MAXLEN, "undo worker"); + snprintf(worker.bgw_type, BGW_MAXLEN, "undo worker"); + + RegisterBackgroundWorker(&worker); +} + +/* + * UndoWorkerRequestShutdown - Request worker to shut down + */ +void +UndoWorkerRequestShutdown(void) +{ + if (UndoWorkerShmem != NULL) + { + LWLockAcquire(&UndoWorkerShmem->lock, LW_EXCLUSIVE); + UndoWorkerShmem->shutdown_requested = true; + LWLockRelease(&UndoWorkerShmem->lock); + } +} diff --git a/src/backend/access/undo/xactundo.c b/src/backend/access/undo/xactundo.c new file mode 100644 index 0000000000000..1c00ec167d47f --- /dev/null +++ b/src/backend/access/undo/xactundo.c @@ -0,0 +1,1458 @@ +/*------------------------------------------------------------------------- + * + * xactundo.c + * Management of undo record sets for transactions + * + * Undo records that need to be applied after a transaction or + * subtransaction abort should be inserted using the functions defined + * in this file; thus, every table or index access method that wants to + * use undo for post-abort cleanup should invoke these interfaces. + * + * The reason for this design is that we want to pack all of the undo + * records for a single transaction into one place, regardless of the + * AM which generated them. That way, we can apply the undo actions + * which pertain to that transaction in the correct order; namely, + * backwards as compared with the order in which the records were + * generated. + * + * We may use up to three undo record sets per transaction, one per + * persistence level (permanent, unlogged, temporary). We assume that + * it's OK to apply the undo records for each persistence level + * independently of the others. This is safe since the modifications + * must necessarily touch disjoint sets of pages. + * + * CROSS-RELATION ORDERING INVARIANT (important for TOAST correctness): + * + * All UNDO records for all relations touched by a single transaction are + * packed into the same UndoRecordSet, in strict WAL-LSN emission order. + * The newest-first application order during rollback guarantees correct + * restoration ordering for multi-object operations (e.g. a cluster-wide + * consumer that touches several relations in one transaction). + * + * PARALLEL RECOVERY: XLOG_UNDO_BATCH records are handled by the startup + * process via ApplyUndoChainFromWAL(); they are not dispatched to parallel + * workers because UNDO application requires coordinated per-transaction + * state. + * + * SUBTRANSACTION TRACKING: + * + * Subtransaction state is tracked using a dynamically-grown array allocated + * in TopMemoryContext. The array starts at INITIAL_SUBXACT_CAPACITY (64) + * slots and doubles when needed via repalloc(). The array persists across + * transactions within the same backend to avoid repeated allocation for + * steady-state workloads. + * + * Growth via repalloc() in TopMemoryContext during SubXactCallbacks is safe + * because it does not interact with pgstat's per-subtransaction tracking + * (the original corruption bug was caused by palloc of new per-subtransaction + * nodes in CurTransactionContext, not by growing an existing TopMemoryContext + * allocation). + * + * This design follows the EDB undo-record-set branch architecture + * (xactundo.c) adapted for the physical undo approach used here. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/access/undo/xactundo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/atm.h" +#include "access/relundo.h" +#include "access/relundo_worker.h" +#include "access/undo.h" +#include "access/undo_xlog.h" +#include "access/xlog.h" +#include "access/undolog.h" +#include "access/undorecord.h" +#include "access/xact.h" +#include "access/xactundo.h" +#include "access/xlogdefs.h" +#include "access/table.h" +#include "catalog/pg_class.h" +#include "miscadmin.h" +#include "storage/bufmgr.h" +#include "storage/ipc.h" +#include "utils/injection_point.h" +#include "storage/lmgr.h" +#include "utils/memutils.h" +#include "utils/rel.h" + +/* GUC: UNDO bytes threshold for instant abort via ATM */ +int undo_instant_abort_threshold = 65536; + +/* + * Initial capacity for the dynamically-grown subtransaction stack. + * Covers 99.9% of workloads without needing reallocation. The stack + * doubles when needed, so there is no artificial upper limit. + */ +#define INITIAL_SUBXACT_CAPACITY 64 + +/* Per-subtransaction backend-private undo state (array element). */ +typedef struct XactUndoSubTransactionState +{ + SubTransactionId nestingLevel; + UndoRecPtr start_location[NUndoPersistenceLevels]; + + /* + * Snapshot of the parent's last_batch_lsn at the time this subtransaction + * started. On subtransaction abort, after applying this subtransaction's + * UNDO chain, we restore XactUndo.last_batch_lsn to these saved values so + * the parent's subsequent abort (or further subtransactions) only covers + * the parent's own records and does not double-apply already-reversed + * batches. + */ + XLogRecPtr last_batch_lsn[NUndoPersistenceLevels]; +} XactUndoSubTransactionState; + +/* + * Per-relation UNDO registration. Any AM using the per-relation UNDO fork + * registers the head of its UNDO chain for each relation it modified. On + * abort, ApplyPerRelUndo() walks this list and either queues synchronous + * worker rollback or records an ATM instant-abort entry for asynchronous + * rollback. + */ +typedef struct PerRelUndoEntry +{ + Oid relid; + RelUndoRecPtr start_urec_ptr; + struct PerRelUndoEntry *next; +} PerRelUndoEntry; + +/* Backend-private undo state. */ +typedef struct XactUndoData +{ + bool has_undo; /* has this xact generated any undo? */ + int subxact_depth; /* 0 = top-level, 1+ = savepoints */ + int subxact_capacity; /* allocated slots in subxact_stack */ + + /* Dynamically-grown subtransaction stack (TopMemoryContext). */ + XactUndoSubTransactionState *subxact_stack; + + /* + * Per-persistence-level record sets. These are created lazily on first + * use and destroyed at transaction end. + */ + UndoRecordSet *record_set[NUndoPersistenceLevels]; + + /* Tracking for the most recent undo insertion per persistence level. */ + UndoRecPtr last_location[NUndoPersistenceLevels]; + + /* + * WAL-based UNDO chain heads. When UNDO records are routed through WAL + * via XLOG_UNDO_BATCH, this tracks the LSN of the most recent batch per + * persistence level. Used for rollback chain walking. + */ + XLogRecPtr last_batch_lsn[NUndoPersistenceLevels]; + + /* + * Per-relation UNDO chain heads, one entry per relation modified via the + * per-relation UNDO fork. Allocated in CurTransactionContext, so + * the list pointer is reset to NULL at every transaction end. + */ + PerRelUndoEntry *relundo_list; +} XactUndoData; + +static XactUndoData XactUndo; +static bool subxact_callback_registered = false; + +/* + * Compile-time guard: xl_xact_prepare.last_batch_lsn[3] must match + * NUndoPersistenceLevels. Both headers are available here; xact.h avoids + * including undodefs.h to keep its include footprint minimal. + */ +StaticAssertDecl(NUndoPersistenceLevels == 3, + "xl_xact_prepare.last_batch_lsn array size (3) must match NUndoPersistenceLevels"); + +static void ResetXactUndo(void); +static void ApplyPerRelUndo(void); +static void CollapseXactUndoSubTransactions(void); +static UndoPersistenceLevel GetUndoPersistenceLevel(char relpersistence); +static void EnsureSubxactStackCapacity(void); +static void XactUndo_SubXactCallback(SubXactEvent event, SubTransactionId mySubid, + SubTransactionId parentSubid, void *arg); + +/* Convenience macro: pointer to current subtransaction state. */ +#define CURRENT_SUBXACT() (&XactUndo.subxact_stack[XactUndo.subxact_depth]) + +/* + * XactUndoShmemSize + * How much shared memory do we need for transaction undo state? + * + * Currently no shared memory is needed -- all state is backend-private. + * This function exists for forward compatibility with the architecture + * where an UndoRequestManager will be added later. + */ +Size +XactUndoShmemSize(void) +{ + return 0; +} + +/* + * XactUndoShmemInit + * Initialize shared memory for transaction undo state. + * + * Currently a no-op; provided for the unified UndoShmemInit() pattern. + */ +void +XactUndoShmemInit(void) +{ + /* Nothing to do yet. */ +} + +/* + * InitializeXactUndo + * Per-backend initialization for transaction undo. + */ +void +InitializeXactUndo(void) +{ + /* Ensure the dynamic subxact stack is allocated */ + EnsureSubxactStackCapacity(); + + ResetXactUndo(); + + /* + * Register callback to track subtransaction lifecycle. Do this lazily on + * first transaction to ensure it's registered for the backend that will + * actually use UNDO. + */ + if (!subxact_callback_registered) + { + RegisterSubXactCallback(XactUndo_SubXactCallback, NULL); + subxact_callback_registered = true; + } +} + +/* + * GetUndoPersistenceLevel + * Map relation persistence character to UndoPersistenceLevel. + */ +static UndoPersistenceLevel +GetUndoPersistenceLevel(char relpersistence) +{ + switch (relpersistence) + { + case RELPERSISTENCE_PERMANENT: + return UNDOPERSISTENCE_PERMANENT; + case RELPERSISTENCE_UNLOGGED: + return UNDOPERSISTENCE_UNLOGGED; + case RELPERSISTENCE_TEMP: + return UNDOPERSISTENCE_TEMP; + default: + elog(ERROR, "unrecognized relpersistence: %c", relpersistence); + return UNDOPERSISTENCE_PERMANENT; /* keep compiler quiet */ + } +} + +/* + * PrepareXactUndoData + * Prepare to insert a transactional undo record. + * + * Finds or creates the appropriate per-persistence-level UndoRecordSet + * for the current transaction and adds the record to it. + * + * The API is AM-agnostic: callers pass an RM ID, RM-specific info, + * a relation OID, and an opaque payload. + * + * Returns the UndoRecPtr where the record will be inserted (or + * InvalidUndoRecPtr if undo is disabled). + */ +UndoRecPtr +PrepareXactUndoData(XactUndoContext *ctx, char persistence, + uint8 rmid, uint16 info, Oid reloid, + const char *payload, Size payload_len) +{ + int nestingLevel = GetCurrentTransactionNestLevel(); + UndoPersistenceLevel plevel = GetUndoPersistenceLevel(persistence); + TransactionId xid = GetCurrentTransactionId(); + UndoRecordSet *uset; + XactUndoSubTransactionState *cur; + UndoRecPtr *sub_start_location; + + /* Remember that we've done something undo-related. */ + XactUndo.has_undo = true; + + /* + * Load the UndoRecordSetInsert() injection points into this backend's + * local cache now, while we are guaranteed not to be inside a critical + * section (PrepareXactUndoData may itself palloc). UndoRecordSetInsert() + * runs inside the caller's crit section and can only fire these via the + * palloc-free INJECTION_POINT_CACHED variant. + */ + INJECTION_POINT_LOAD("undo-batch-before-wal-insert"); + INJECTION_POINT_LOAD("undo-batch-after-wal-insert"); + + /* + * If we've entered a subtransaction deeper than what's currently tracked, + * push a new entry onto the subxact_stack. This handles the case where + * PrepareXactUndoData is called for the first time in a subtransaction + * that was started before the SubXactCallback fired (e.g., if the + * callback hadn't been registered yet when the subtransaction began). + */ + cur = CURRENT_SUBXACT(); + if (nestingLevel > (int) cur->nestingLevel) + { + int i; + + XactUndo.subxact_depth++; + EnsureSubxactStackCapacity(); + + cur = CURRENT_SUBXACT(); + cur->nestingLevel = nestingLevel; + for (i = 0; i < NUndoPersistenceLevels; ++i) + { + cur->start_location[i] = InvalidUndoRecPtr; + cur->last_batch_lsn[i] = XactUndo.last_batch_lsn[i]; + } + } + + /* + * Make sure we have an UndoRecordSet of the appropriate type open for + * this persistence level. These record sets are always associated with + * the toplevel transaction, not a subtransaction, to avoid fragmentation. + */ + uset = XactUndo.record_set[plevel]; + if (uset == NULL) + { + uset = UndoRecordSetCreate(xid, GetCurrentTransactionUndoRecPtr()); + XactUndo.record_set[plevel] = uset; + } + + /* Remember persistence level for InsertXactUndoData. */ + ctx->plevel = plevel; + ctx->uset = uset; + + /* Add the record to the record set using generic payload API. */ + UndoRecordAddPayload(uset, rmid, info, reloid, payload, payload_len); + + /* + * If this is the first undo for this persistence level in this + * subtransaction, record the start location. The actual UndoRecPtr is not + * known until insertion, so we use a sentinel for now and the caller will + * update it after InsertXactUndoData. + */ + sub_start_location = &cur->start_location[plevel]; + if (!UndoRecPtrIsValid(*sub_start_location)) + *sub_start_location = (UndoRecPtr) 1; /* will be set properly */ + + return InvalidUndoRecPtr; /* actual ptr assigned during insert */ +} + +/* + * PrepareXactUndoDataParts + * Like PrepareXactUndoData, but with scatter-gather payload. + * + * Used when the payload is in two non-contiguous pieces (e.g., a fixed + * header struct followed by variable-length tuple data). Avoids the + * need to assemble an intermediate contiguous buffer. + */ +UndoRecPtr +PrepareXactUndoDataParts(XactUndoContext *ctx, char persistence, + uint8 rmid, uint16 info, Oid reloid, + const char *part1, Size part1_len, + const char *part2, Size part2_len) +{ + int nestingLevel = GetCurrentTransactionNestLevel(); + UndoPersistenceLevel plevel = GetUndoPersistenceLevel(persistence); + TransactionId xid = GetCurrentTransactionId(); + UndoRecordSet *uset; + XactUndoSubTransactionState *cur; + UndoRecPtr *sub_start_location; + + /* Remember that we've done something undo-related. */ + XactUndo.has_undo = true; + + /* + * If we've entered a subtransaction deeper than what's currently tracked, + * push a new entry onto the subxact_stack. + */ + cur = CURRENT_SUBXACT(); + if (nestingLevel > (int) cur->nestingLevel) + { + int i; + + XactUndo.subxact_depth++; + EnsureSubxactStackCapacity(); + + cur = CURRENT_SUBXACT(); + cur->nestingLevel = nestingLevel; + for (i = 0; i < NUndoPersistenceLevels; ++i) + { + cur->start_location[i] = InvalidUndoRecPtr; + cur->last_batch_lsn[i] = XactUndo.last_batch_lsn[i]; + } + } + + /* + * Make sure we have an UndoRecordSet of the appropriate type open for + * this persistence level. + */ + uset = XactUndo.record_set[plevel]; + if (uset == NULL) + { + uset = UndoRecordSetCreate(xid, GetCurrentTransactionUndoRecPtr()); + XactUndo.record_set[plevel] = uset; + } + + /* Remember persistence level for InsertXactUndoData. */ + ctx->plevel = plevel; + ctx->uset = uset; + + /* Add the record using scatter-gather payload API. */ + UndoRecordAddPayloadParts(uset, rmid, info, reloid, + part1, part1_len, part2, part2_len); + + /* + * If this is the first undo for this persistence level in this + * subtransaction, record the start location. + */ + sub_start_location = &cur->start_location[plevel]; + if (!UndoRecPtrIsValid(*sub_start_location)) + *sub_start_location = (UndoRecPtr) 1; /* will be set properly */ + + return InvalidUndoRecPtr; /* actual ptr assigned during insert */ +} + +/* + * InsertXactUndoData + * Insert the prepared undo data into the undo log. + * + * This performs the actual write of the accumulated records. + * Also updates the transaction-level undo record pointer (undoRecPtr + * in TransactionState) so that subsequent UNDO records chain correctly. + */ +void +InsertXactUndoData(XactUndoContext *ctx) +{ + UndoRecordSet *uset = ctx->uset; + UndoRecPtr ptr; + + Assert(uset != NULL); + + ptr = UndoRecordSetInsert(uset); + if (UndoRecPtrIsValid(ptr)) + { + XactUndoSubTransactionState *cur = CURRENT_SUBXACT(); + + XactUndo.last_location[ctx->plevel] = ptr; + + /* + * Track the WAL LSN of the most recent UNDO batch for this + * persistence level. This is used during rollback to walk the UNDO + * chain backward through WAL. + * + * The pointer must be a valid LSN; the caller must have produced an + * RM_UNDO_ID XLOG_UNDO_BATCH record via UndoRecordSetInsert. We do + * not validate the rmid here because UndoValidateBatchLSN reads from + * the on-disk WAL via read_local_xlog_page, which waits on flush and + * cannot be safely called from the WAL-insertion hot path. The + * rollback path at AtAbort_XactUndo validates before applying. + */ + Assert(XLogRecPtrIsValid(uset->last_batch_lsn)); + XactUndo.last_batch_lsn[ctx->plevel] = uset->last_batch_lsn; + + /* Fix up subtransaction start location if needed */ + if (cur->start_location[ctx->plevel] == (UndoRecPtr) 1) + cur->start_location[ctx->plevel] = ptr; + + /* + * Update the per-transaction undo pointer in TransactionState so that + * the next UndoRecordSetCreate (if called directly by heap AM or + * other subsystems) picks up the correct chain pointer. + */ + SetCurrentTransactionUndoRecPtr(ptr); + } + else + { + XactUndoSubTransactionState *cur = CURRENT_SUBXACT(); + + /* + * The batch was empty or elided, so no record pointer exists to fix + * up the sentinel with. Leaving the (UndoRecPtr) 1 sentinel in place + * would make UndoRecPtrIsValid() treat it as a real start location + * and leak it into subxact->parent merges and rollback chain walks; + * reset it so this level records no start location. + */ + if (cur->start_location[ctx->plevel] == (UndoRecPtr) 1) + cur->start_location[ctx->plevel] = InvalidUndoRecPtr; + } +} + +/* + * CleanupXactUndoInsertion + * Clean up after an undo insertion cycle. + * + * Resets the record set's buffer position and record count so it can + * accumulate more records. Does NOT free the record set -- that + * happens at transaction end (AtCommit_XactUndo / AtAbort_XactUndo). + * + * The record set's prev_undo_ptr is preserved across resets (it was + * updated by UndoRecordSetInsert), so subsequent records chain + * correctly through the undo log. + */ +void +CleanupXactUndoInsertion(XactUndoContext *ctx) +{ + if (ctx->uset != NULL) + UndoRecordSetReset(ctx->uset); +} + +/* + * GetCurrentXactUndoRecPtr + * Get the most recent undo record pointer for a persistence level. + */ +UndoRecPtr +GetCurrentXactUndoRecPtr(UndoPersistenceLevel plevel) +{ + return XactUndo.last_location[plevel]; +} + +/* + * GetCurrentXactLastBatchLSN + * Get the WAL LSN of the most recent UNDO batch for a persistence level. + * + * Used during transaction abort to start the WAL-based UNDO chain walk. + */ +XLogRecPtr +GetCurrentXactLastBatchLSN(UndoPersistenceLevel plevel) +{ + return XactUndo.last_batch_lsn[plevel]; +} + +/* + * XActUndoUpdateLastBatchLSN + * Record the LSN of an UNDO batch for the current transaction. + * + * Called from the heap DML code after writing an UNDO batch -- either + * embedded inside a heap WAL record (HAS_UNDO path) or as a standalone + * XLOG_UNDO_BATCH overflow record. Updates last_batch_lsn so that + * AtAbort_XactUndo() can find the head of the UNDO chain, and registers + * the batch LSN for WAL retention tracking on first call per transaction. + */ +void +XActUndoUpdateLastBatchLSN(XLogRecPtr lsn, UndoPersistenceLevel plevel) +{ + if (!XLogRecPtrIsValid(lsn) || plevel >= NUndoPersistenceLevels) + return; + + /* + * The pointer must be a valid LSN. Rmid validation is deferred to the + * rollback path; see comment in InsertXactUndoData for why we cannot call + * UndoValidateBatchLSN here. + */ + Assert(XLogRecPtrIsValid(lsn)); + + XactUndo.has_undo = true; + XactUndo.last_batch_lsn[plevel] = lsn; +} + +/* + * RegisterPerRelUndo + * Register (or refresh) the head of a relation's per-relation UNDO chain. + * + * Called by table AMs that use the per-relation UNDO fork after writing an + * UNDO record. The most recent pointer per relation is retained; rollback + * walks the chain backwards from it. Entries live in CurTransactionContext + * and are dropped automatically at transaction end. + */ +void +RegisterPerRelUndo(Oid relid, RelUndoRecPtr start_urec_ptr) +{ + PerRelUndoEntry *entry; + + /* Mark that we have UNDO so commit/abort cleanup runs. */ + XactUndo.has_undo = true; + + /* If already registered, advance the pointer to the latest record. */ + for (entry = XactUndo.relundo_list; entry != NULL; entry = entry->next) + { + if (entry->relid == relid) + { + entry->start_urec_ptr = start_urec_ptr; + return; + } + } + + entry = (PerRelUndoEntry *) MemoryContextAlloc(TopTransactionContext, + sizeof(PerRelUndoEntry)); + entry->relid = relid; + entry->start_urec_ptr = start_urec_ptr; + entry->next = XactUndo.relundo_list; + XactUndo.relundo_list = entry; +} + +/* + * GetPerRelUndoPtr + * Return the latest UNDO record pointer registered for a relation, or + * InvalidRelUndoRecPtr if none. Used to chain a new record's + * urec_prevundorec to the previous one. + */ +RelUndoRecPtr +GetPerRelUndoPtr(Oid relid) +{ + PerRelUndoEntry *entry; + + for (entry = XactUndo.relundo_list; entry != NULL; entry = entry->next) + { + if (entry->relid == relid) + return entry->start_urec_ptr; + } + + return InvalidRelUndoRecPtr; +} + +/* + * IteratePerRelUndo + * Invoke callback(relid, start_urec_ptr, arg) for each registered + * per-relation UNDO chain head. + * + * Used by an AM's PREPARE-time hook to serialize the per-relation UNDO chain + * heads into the 2PC state file so ROLLBACK PREPARED can restore + * before-images. + */ +void +IteratePerRelUndo(PerRelUndoIterCB callback, void *arg) +{ + PerRelUndoEntry *entry; + + for (entry = XactUndo.relundo_list; entry != NULL; entry = entry->next) + callback(entry->relid, entry->start_urec_ptr, arg); +} + +/* + * XactUndoHasUnrecoverableUndo + * Does the current transaction hold UNDO that has no working ROLLBACK + * PREPARED apply path yet? + * + * Both UNDO mechanisms are now recoverable across 2PC: + * + * - Cluster-wide UNDO (nbtree/hash and other cluster-wide consumers; + * last_batch_lsn): the permanent chain-head LSN is durably saved in + * xl_xact_prepare, its WAL is pinned while the xact stays prepared + * (undo_batch_lsn in twophase.c / UndoGetOldestBatchLSN), and + * FinishPreparedTransaction() feeds it to ATMAddAborted() on ROLLBACK + * PREPARED. + * + * - Per-relation UNDO (relundo_list): the owning AM's PREPARE-time hook + * serializes its per-relation UNDO chain heads via + * RegisterTwoPhaseRecord(). Its two-phase postabort handler replays the + * chain via RelUndoApplyChain() to restore in-place before-images; its + * postcommit handler discards it. + * + * Nothing remains that PREPARE must reject on UNDO grounds, so this returns + * false unconditionally. Kept as a single choke point (rather than deleting + * the call in PrepareTransaction) so any future UNDO mechanism that is not + * 2PC-safe has one obvious place to re-assert a guard. + */ +bool +XactUndoHasUnrecoverableUndo(void) +{ + return false; +} + +/* + * ApplyPerRelUndo + * Roll back all registered per-relation UNDO chains on abort. + * + * Each modified relation is queued for the background per-relation UNDO + * worker, which opens the relation and walks its UNDO chain backwards, + * restoring before-images in place. The aborting backend blocks on + * WaitForPendingRelUndo() (called from AbortTransaction after lock release) + * so rollback is synchronous from the client's point of view. + * + * Per-relation UNDO cannot be applied inline here: the backend is in + * TRANS_ABORT, where catalog access (relation_open) is unsafe. + */ +static void +ApplyPerRelUndo(void) +{ + PerRelUndoEntry *entry; + TransactionId xid = GetCurrentTransactionIdIfAny(); + bool all_applied = true; + + if (XactUndo.relundo_list == NULL) + return; + + /* + * Apply each relation's UNDO chain INLINE, in this backend, BEFORE the + * caller (AbortTransaction) reaches ProcArrayEndTransaction and releases + * the transaction lock that conflicting writers wait on. + * + * This ordering is mandatory for in-place MVCC: a second writer + * blocked in XactLockTableWait wakes the instant our XID leaves the proc + * array. If the before-image were restored asynchronously by the + * background worker (which runs only after lock release), the waiter + * would read our not-yet-reverted in-place value, write on top of it, + * commit, and then the worker would clobber the waiter's committed value + * with our stale before-image -- a lost update. Restoring synchronously + * here closes that window: the page already holds the pre-abort image + * when the waiter wakes. + * + * We are in TRANS_ABORT but all backing resources (relcache, locks, + * resource owner) are still live, so present TRANS_INPROGRESS for the + * duration of the apply (table_open asserts IsTransactionState()). The + * RowExclusiveLock we already hold from our own DML makes the re-open a + * no-op at the lock manager. + */ + for (entry = XactUndo.relundo_list; entry != NULL; entry = entry->next) + { + int saved_trans_state; + bool applied = false; + + saved_trans_state = EnterInlineUndoApplyState(); + PG_TRY(); + { + Relation rel = table_open(entry->relid, RowExclusiveLock); + + RelUndoApplyChain(rel, entry->start_urec_ptr); + table_close(rel, RowExclusiveLock); + applied = true; + } + PG_CATCH(); + { + /* + * A chain that errored mid-apply may still hold EXCLUSIVE content + * locks on the pages it had pinned into touched[] (RelUndoApplyChain + * releases them only on its normal CLR path). Release them here so + * a caught error cannot leak a held lock into the next iteration -- + * a subsequent entry that re-locks the same buffer would trip the + * BufferLockAcquire lockmode==UNLOCK assert. + */ + BufferLockReleaseAll(); + EmitErrorReport(); + FlushErrorState(); + applied = false; + } + PG_END_TRY(); + LeaveInlineUndoApplyState(saved_trans_state); + + if (!applied) + all_applied = false; + } + + if (all_applied) + { + /* + * Every page is physically restored. Remove the ABORTED sLog entries + * (kept until now so visibility checks treated the tuples as live). + */ + if (TransactionIdIsValid(xid) && RelUndoAbortCleanup_hook) + RelUndoAbortCleanup_hook(xid); + } + else + { + /* + * At least one relation could not be restored inline (dropped, + * error). Fall back to the background worker for those; queue every + * entry and let the worker's idempotent already-applied check skip + * the ones we already reverted. + */ + for (entry = XactUndo.relundo_list; entry != NULL; entry = entry->next) + RelUndoQueueAdd(MyDatabaseId, entry->relid, + entry->start_urec_ptr, xid); + + StartRelUndoWorker(MyDatabaseId); + } +} + +/* + * AtCommit_XactUndo + * Post-commit cleanup of the undo state. + * + * On commit, undo records are no longer needed for rollback. + * Free all record sets and reset state. + * + * UNDO pages are managed by shared_buffers and flushed by the + * checkpointer -- no per-commit fdatasync is needed. We only + * flush the deferred WAL allocation records so recovery can + * reconstruct the UNDO log insert pointer. + * + * NB: This code MUST NOT FAIL, since it is run as a post-commit step. + */ +void +AtCommit_XactUndo(void) +{ + int i; + + if (!XactUndo.has_undo) + { + /* Flush any deferred WAL even if has_undo is false */ + UndoWalBatchFlush(); + return; + } + + /* + * With UNDO-in-WAL, all UNDO data was already written to WAL via + * XLOG_UNDO_BATCH records during the transaction. The single XLogFlush() + * at commit time (in RecordTransactionCommit) ensures both the UNDO data + * and the commit record are durable. No separate fdatasync is needed. + * + * Legacy WAL batch flush is now a no-op but kept for safety. + */ + UndoWalBatchFlush(); + + /* + * Free all per-persistence-level record sets. + * + * We can safely call UndoRecordSetFree() during commit because we're in + * CurTransactionContext, not BumpContext (which is only used during + * abort). The record sets are allocated in CurTransactionContext and will + * be freed when that context is destroyed at transaction end. + */ + for (i = 0; i < NUndoPersistenceLevels; i++) + { + if (XactUndo.record_set[i] != NULL) + { + UndoRecordSetFree(XactUndo.record_set[i]); + XactUndo.record_set[i] = NULL; + } + } + + /* Release WAL retention hold acquired in UndoRecordSetInsert(). */ + UndoClearBatchLSN(); + + ResetXactUndo(); +} + +/* + * AtAbort_XactUndo + * Post-abort cleanup of the undo state. + * + * On abort, we need to apply the undo chain to roll back changes. + * The actual undo application is triggered by xact.c before calling + * this function. Here we apply per-relation UNDO and clean up the record sets. + * + * With append-only I/O, we sync UNDO files before UNDO replay so that + * if we crash during rollback, recovery can re-read the UNDO records + * from the segment file and continue the rollback. + */ +void +AtAbort_XactUndo(void) +{ + int i; + bool lsn_safely_held = false; /* true if inline UNDO or ATM + * holds LSN */ + + /* Always clean up the recycled context; see AtCommit_XactUndo. */ + UndoRecordSetResetCache(); + + if (!XactUndo.has_undo) + { + /* No UNDO data was written; nothing to do */ + UndoWalBatchReset(); + return; + } + + /* + * With UNDO-in-WAL, all UNDO data is already in the WAL stream. No + * separate sync is needed. The UNDO data can be read back from WAL for + * rollback via UndoReadBatchFromWAL(). + * + * For crash safety during abort: if we crash mid-rollback, the recovery + * undo phase will find this transaction's UNDO batches in WAL and + * complete the rollback. + */ + UndoWalBatchFlush(); /* no-op, kept for safety */ + + INJECTION_POINT("undo-xact-abort-before-apply", NULL); + + /* Collapse all subtransaction state. */ + CollapseXactUndoSubTransactions(); + + /* + * UNDO application strategy: inline for small transactions, deferred for + * large ones. Controlled by undo_instant_abort_threshold GUC. + * + * For small transactions (< threshold bytes of UNDO): apply UNDO + * synchronously in this backend. This avoids ATM pool accumulation and + * eliminates the dependency on the background logical revert worker. + * + * For large transactions (>= threshold): register in the ATM for deferred + * asynchronous rollback by the logical revert worker. + * + * The BumpContext issue (pfree crashes during abort) is avoided by: - + * Creating a temporary AllocSetContext for inline UNDO application - + * ApplyUndoChainFromWAL already avoids pfree on its allocations - + * Switching back to the abort context afterward + */ + { + XLogRecPtr perm_lsn = + XactUndo.last_batch_lsn[UNDOPERSISTENCE_PERMANENT]; + + if (XLogRecPtrIsValid(perm_lsn)) + { + Size total_undo_bytes = 0; + + /* Calculate total UNDO data size for threshold comparison */ + for (i = 0; i < NUndoPersistenceLevels; i++) + { + if (XactUndo.record_set[i] != NULL) + total_undo_bytes += UndoRecordSetGetSize( + XactUndo.record_set[i]); + } + + /* + * The UNDO batch records were inserted into the WAL buffers + * during this transaction but may not yet be flushed to disk. The + * inline apply path reads them back via the local XLog reader, + * which would otherwise busy-wait (pg_usleep) for the walwriter + * to flush past the batch LSN -- adding multi-second latency to + * every rollback. Flush WAL up to the end of the last record this + * backend wrote so the batch is immediately readable. We must not + * flush to GetXLogInsertRecPtr(): under concurrency that global + * insert position can point into the middle of a record another + * backend has reserved but not finished copying, so XLogFlush + * would fail with "xlog flush request is not satisfied". + * XactLastRecEnd is always a valid record boundary and, since + * this backend wrote the UNDO batch, is guaranteed to be at or + * past the batch end. This mirrors the commit-path flush in + * RecordTransactionCommit. + */ + XLogFlush(XactLastRecEnd); + + if (undo_instant_abort_threshold > 0 && + total_undo_bytes < (Size) undo_instant_abort_threshold) + { + /* + * Small transaction: apply UNDO inline. Use a dedicated + * AllocSetContext to avoid BumpContext pfree issues. + */ + MemoryContext undo_ctx; + MemoryContext old_ctx; + int saved_trans_state; + + undo_ctx = AllocSetContextCreate(TopMemoryContext, + "Inline UNDO Apply", + ALLOCSET_DEFAULT_SIZES); + old_ctx = MemoryContextSwitchTo(undo_ctx); + + { + bool undo_applied = false; + + /* + * Validate the batch LSN points to an actual UNDO record + * before attempting inline application. Stale LSNs from + * chain_prev tracking anomalies can point to non-UNDO WAL + * records, which would cause "not an UNDO batch" + * warnings. + */ + if (!UndoValidateBatchLSN(perm_lsn)) + { + elog(DEBUG1, "inline UNDO: last_batch_lsn %X/%X is not " + "a valid UNDO batch, deferring to ATM", + LSN_FORMAT_ARGS(perm_lsn)); + undo_applied = false; + goto inline_undo_done; + } + + /* + * AbortTransaction() has already advanced the transaction + * state to TRANS_ABORT, but the relcache, locks, and + * resource owner are all still live. UNDO appliers open + * relations, which asserts IsTransactionState(); + * temporarily present TRANS_INPROGRESS for the duration + * of the inline apply and always restore the real state + * afterward. + */ + saved_trans_state = EnterInlineUndoApplyState(); + PG_TRY(); + { + undo_applied = ApplyUndoChainFromWAL(perm_lsn); + } + PG_CATCH(); + { + /* + * If inline UNDO throws an error, fall back to ATM. + * Release any content locks the failed chain left held + * before unwinding. + */ + BufferLockReleaseAll(); + LeaveInlineUndoApplyState(saved_trans_state); + FlushErrorState(); + undo_applied = false; + } + PG_END_TRY(); + LeaveInlineUndoApplyState(saved_trans_state); + + MemoryContextSwitchTo(old_ctx); + MemoryContextDelete(undo_ctx); + + inline_undo_done: + if (!undo_applied) + { + /* + * Inline UNDO failed (WAL recycled, wrong record + * type, or chain walk aborted). Register in ATM for + * deferred processing by the revert worker. + */ + elog(DEBUG1, "inline UNDO failed for xid %u, " + "deferring to ATM", + GetCurrentTransactionId()); + + if (ATMAddAborted(GetCurrentTransactionId(), + MyDatabaseId, perm_lsn)) + lsn_safely_held = true; /* ATM holds the LSN */ + else + elog(WARNING, "ATM full: could not record aborted transaction %u", GetCurrentTransactionId()); + + + + + } + else + { + lsn_safely_held = true; /* UNDO fully applied, WAL can + * be recycled */ + ereport(DEBUG2, + (errmsg("inline UNDO applied for xid %u " + "(%zu bytes)", + GetCurrentTransactionId(), + total_undo_bytes))); + } + } + } + else + { + /* + * Large transaction or threshold=0: register in ATM for + * deferred rollback by the logical revert worker. + */ + if (ATMAddAborted(GetCurrentTransactionId(), + MyDatabaseId, perm_lsn)) + lsn_safely_held = true; + else + elog(WARNING, + "ATM full: could not record aborted transaction %u", + GetCurrentTransactionId()); + } + } + } + + INJECTION_POINT("undo-xact-abort-after-atm", NULL); + + /* + * Roll back per-relation UNDO chains. Queues each modified + * relation for the background per-relation UNDO worker; the aborting + * backend waits for completion in WaitForPendingRelUndo() after lock + * release. + */ + ApplyPerRelUndo(); + + /* Free all per-persistence-level record sets. */ + for (i = 0; i < NUndoPersistenceLevels; i++) + { + if (XactUndo.record_set[i] != NULL) + { + UndoRecordSetFree(XactUndo.record_set[i]); + XactUndo.record_set[i] = NULL; + } + } + + /* Close cached UNDO log fds. */ + UndoLogCloseFiles(); + + /* Reset per-backend write pointer tracking. */ + UndoFlushResetMaxWritePtr(); + + /* + * Release WAL retention hold ONLY if the LSN is safely held elsewhere: + * either inline UNDO completed (no WAL needed) or ATM registered the + * entry (revert worker will use ATM's copy of the LSN). + * + * If BOTH failed (inline UNDO failed AND ATM pool full), retain the + * per-backend slot to prevent checkpoint from recycling the WAL segment + * containing our UNDO data. The slot will be cleared at backend exit via + * AtCleanup_XactUndo. + */ + if (lsn_safely_held) + UndoClearBatchLSN(); + else + elog(DEBUG1, "retaining per-backend UNDO LSN slot (ATM and inline both failed)"); + + ResetXactUndo(); +} + +/* + * AtSubCommit_XactUndo + * Subtransaction commit: merge sub undo state into parent. + */ +void +AtSubCommit_XactUndo(int level) +{ + XactUndoSubTransactionState *cur; + XactUndoSubTransactionState *parent; + int i; + + if (XactUndo.subxact_depth <= 0) + return; + + cur = CURRENT_SUBXACT(); + if ((int) cur->nestingLevel != level) + return; + + parent = &XactUndo.subxact_stack[XactUndo.subxact_depth - 1]; + + /* + * Merge start locations into parent. + * + * Invariant: all UNDO records for this transaction, regardless of nesting + * level, are stored in a single chain per persistence level (one + * UndoRecordSet). start_location tracks the earliest record the + * subtransaction generated. Since records are strictly append-only, the + * parent's start location is always earlier than the subtransaction's if + * it exists. We only update the parent's start when it is not yet set + * (the parent wrote no UNDO before this subtransaction). + */ + for (i = 0; i < NUndoPersistenceLevels; i++) + { + if (UndoRecPtrIsValid(cur->start_location[i]) && + !UndoRecPtrIsValid(parent->start_location[i])) + { + parent->start_location[i] = cur->start_location[i]; + } + } + + XactUndo.subxact_depth--; +} + +/* + * AtSubAbort_XactUndo + * Subtransaction abort: apply undo for this sub-level, clean up. + * + * For per-relation UNDO, we apply the subtransaction's records synchronously + * by queuing work for the background UNDO worker. This ensures that tuples + * inserted/modified by the aborting subtransaction are physically restored + * before control returns to the caller. + * + * Any AM-private per-subtransaction tracking state is cleaned up by the + * owning AM's own SubXactCallback. + */ +void +AtSubAbort_XactUndo(int level) +{ + XactUndoSubTransactionState *cur; + XactUndoSubTransactionState *parent; + int i; + + if (XactUndo.subxact_depth <= 0) + return; + + cur = CURRENT_SUBXACT(); + if ((int) cur->nestingLevel != level) + return; + + parent = &XactUndo.subxact_stack[XactUndo.subxact_depth - 1]; + + /* + * Apply per-relation UNDO for records generated during this + * subtransaction. We iterate the record sets and apply records whose + * UndoRecPtr is at or after this subtransaction's start_location. + * + * For each persistence level where this subtransaction generated UNDO + * records, queue the work for the per-relation UNDO worker to apply them + * synchronously. The parent transaction's records (before the + * subtransaction's start_location) are preserved. + */ + for (i = 0; i < NUndoPersistenceLevels; i++) + { + UndoRecPtr sub_start = cur->start_location[i]; + + if (!UndoRecPtrIsValid(sub_start)) + continue; + + /* + * Eagerly roll back the cluster-wide UNDO this subtransaction wrote. + * + * If the subtransaction advanced XactUndo.last_batch_lsn[i] beyond + * the value saved at subtransaction start, it wrote UNDO batches that + * must now be reversed. We must apply them here rather than defer to + * the top-level ATM entry: a subtransaction can abort while its + * parent goes on to COMMIT, in which case no top-level ATM entry is + * ever created and the deferred UNDO would never run, leaking the + * aborted subtransaction's writes as if committed. + * + * Apply only the batches strictly newer than the parent's saved head + * (cur->last_batch_lsn[i]) via the bounded chain walk, so the + * parent's and earlier subtransactions' batches are preserved. + * Afterward restore last_batch_lsn[i] to the parent's value so a + * later parent abort walks only the parent's batches and does not + * double-apply ours. + * + * BumpContext safety: subtransaction abort may run under a + * BumpContext that does not support pfree(), so apply inside a + * dedicated AllocSetContext and present an in-progress transaction + * state for the relation opens, mirroring the inline path in + * AtAbort_XactUndo(). + */ + if (XLogRecPtrIsValid(XactUndo.last_batch_lsn[i]) && + XactUndo.last_batch_lsn[i] != cur->last_batch_lsn[i]) + { + XLogRecPtr sub_head = XactUndo.last_batch_lsn[i]; + XLogRecPtr parent_head = cur->last_batch_lsn[i]; + + /* + * Invariant: the current head must be strictly newer (larger) + * than the parent's saved head. If this fires, a sibling + * subtransaction improperly modified last_batch_lsn after its own + * abort, which would cause us to skip or double-apply batches. + */ + Assert(!XLogRecPtrIsValid(parent_head) || + sub_head > parent_head); + + /* + * Flush WAL so the subtransaction's batches are readable by the + * local XLog reader without busy-waiting on the walwriter. See + * the matching flush in AtAbort_XactUndo() for why XactLastRecEnd + * is the correct, always-valid record boundary to flush to. + */ + XLogFlush(XactLastRecEnd); + + if (UndoValidateBatchLSN(sub_head)) + { + MemoryContext undo_ctx; + MemoryContext old_ctx; + int saved_trans_state; + + undo_ctx = AllocSetContextCreate(TopMemoryContext, + "Subxact UNDO Apply", + ALLOCSET_DEFAULT_SIZES); + old_ctx = MemoryContextSwitchTo(undo_ctx); + + saved_trans_state = EnterInlineUndoApplyState(); + PG_TRY(); + { + ApplyUndoChainFromWALBounded(sub_head, parent_head); + } + PG_CATCH(); + { + /* + * Release any content locks the failed subxact UNDO + * chain left held before unwinding. + */ + BufferLockReleaseAll(); + LeaveInlineUndoApplyState(saved_trans_state); + FlushErrorState(); + } + PG_END_TRY(); + LeaveInlineUndoApplyState(saved_trans_state); + + MemoryContextSwitchTo(old_ctx); + MemoryContextDelete(undo_ctx); + } + else + elog(DEBUG1, "subxact UNDO: head %X/%X is not a valid UNDO " + "batch, skipping eager rollback", + LSN_FORMAT_ARGS(sub_head)); + + XactUndo.last_batch_lsn[i] = parent_head; + } + + /* + * Reset the last_location to what it was before this subtransaction, + * so that if the parent transaction continues and then aborts, only + * the parent's records are applied (the subtransaction's records have + * already been applied). + */ + if (UndoRecPtrIsValid(parent->start_location[i])) + XactUndo.last_location[i] = parent->start_location[i]; + } + + XactUndo.subxact_depth--; +} + +/* + * AtProcExit_XactUndo + * Process exit cleanup for transaction undo. + */ +void +AtProcExit_XactUndo(void) +{ + int i; + + /* Free any lingering record sets. */ + for (i = 0; i < NUndoPersistenceLevels; i++) + { + if (XactUndo.record_set[i] != NULL) + { + UndoRecordSetFree(XactUndo.record_set[i]); + XactUndo.record_set[i] = NULL; + } + } + + /* Close any cached UNDO log fds before process exit. */ + UndoLogCloseFiles(); + + /* Reset per-backend write pointer tracking. */ + UndoFlushResetMaxWritePtr(); + + /* Release any WAL retention hold (in case process exits mid-transaction). */ + UndoClearBatchLSN(); + + ResetXactUndo(); +} + +/* + * XactUndo_SubXactCallback + * Subtransaction callback to manage UNDO subtransaction state. + * + * This ensures the UNDO subsystem properly tracks all subtransactions, + * including those created by ROLLBACK TO SAVEPOINT. + * + * The subtransaction state is stored in a dynamically-grown array in + * TopMemoryContext. In the common case (depth < capacity), no allocation + * occurs. Growth via repalloc is safe here because it operates on a + * TopMemoryContext allocation, not a per-subtransaction context. + */ +static void +XactUndo_SubXactCallback(SubXactEvent event, SubTransactionId mySubid, + SubTransactionId parentSubid, void *arg) +{ + int i; + + /* These parameters are mandated by the callback signature. */ + (void) parentSubid; + (void) arg; + + switch (event) + { + case SUBXACT_EVENT_START_SUB: + + /* + * A new subtransaction is starting. Push an entry onto the + * dynamically-grown stack, extending it if needed. + */ + XactUndo.subxact_depth++; + EnsureSubxactStackCapacity(); + { + XactUndoSubTransactionState *s = CURRENT_SUBXACT(); + + s->nestingLevel = mySubid; + for (i = 0; i < NUndoPersistenceLevels; ++i) + { + s->start_location[i] = InvalidUndoRecPtr; + /* Save parent's last_batch_lsn for restore on abort */ + s->last_batch_lsn[i] = XactUndo.last_batch_lsn[i]; + } + } + break; + + case SUBXACT_EVENT_COMMIT_SUB: + + /* + * Subtransaction is committing. Merge its UNDO state into parent. + */ + AtSubCommit_XactUndo(mySubid); + break; + + case SUBXACT_EVENT_ABORT_SUB: + + /* + * Subtransaction is aborting. Apply UNDO and clean up. + */ + AtSubAbort_XactUndo(mySubid); + break; + + case SUBXACT_EVENT_PRE_COMMIT_SUB: + /* Nothing to do at pre-commit */ + break; + } +} + +/* + * EnsureSubxactStackCapacity + * Ensure the subxact_stack has room for the current depth. + * + * If the stack hasn't been allocated yet, allocate it with the initial + * capacity. If we've exceeded the current capacity, double it. + * The stack lives in TopMemoryContext so it persists across transactions + * within the same backend. + */ +static void +EnsureSubxactStackCapacity(void) +{ + if (XactUndo.subxact_stack == NULL) + { + /* First-time allocation */ + XactUndo.subxact_capacity = INITIAL_SUBXACT_CAPACITY; + XactUndo.subxact_stack = (XactUndoSubTransactionState *) + MemoryContextAllocZero(TopMemoryContext, + XactUndo.subxact_capacity * + sizeof(XactUndoSubTransactionState)); + } + else if (XactUndo.subxact_depth >= XactUndo.subxact_capacity) + { + int new_capacity = XactUndo.subxact_capacity * 2; + + XactUndo.subxact_stack = (XactUndoSubTransactionState *) + repalloc(XactUndo.subxact_stack, + new_capacity * sizeof(XactUndoSubTransactionState)); + /* Zero the newly-allocated portion */ + memset(&XactUndo.subxact_stack[XactUndo.subxact_capacity], 0, + (new_capacity - XactUndo.subxact_capacity) * + sizeof(XactUndoSubTransactionState)); + XactUndo.subxact_capacity = new_capacity; + } +} + +/* + * ResetXactUndo + * Reset all backend-private undo state for the next transaction. + */ +static void +ResetXactUndo(void) +{ + int i; + + XactUndo.has_undo = false; + XactUndo.subxact_depth = 0; + + /* + * The relundo_list entries live in CurTransactionContext and are freed + * automatically at transaction end; just drop the dangling head pointer. + */ + XactUndo.relundo_list = NULL; + + /* + * The subxact_stack allocation persists across transactions (it's in + * TopMemoryContext). We just reset the depth and initialize slot 0. + */ + if (XactUndo.subxact_stack != NULL) + { + XactUndo.subxact_stack[0].nestingLevel = 1; + for (i = 0; i < NUndoPersistenceLevels; i++) + { + XactUndo.subxact_stack[0].start_location[i] = InvalidUndoRecPtr; + XactUndo.subxact_stack[0].last_batch_lsn[i] = InvalidXLogRecPtr; + } + } + + for (i = 0; i < NUndoPersistenceLevels; i++) + { + XactUndo.record_set[i] = NULL; + XactUndo.last_location[i] = InvalidUndoRecPtr; + XactUndo.last_batch_lsn[i] = InvalidXLogRecPtr; + } +} + +/* + * CollapseXactUndoSubTransactions + * Collapse all subtransaction state into the top level. + */ +static void +CollapseXactUndoSubTransactions(void) +{ + while (XactUndo.subxact_depth > 0) + { + /* + * Merge current level into parent by calling AtSubCommit_XactUndo + * with the current level's nestingLevel. + */ + AtSubCommit_XactUndo( + XactUndo.subxact_stack[XactUndo.subxact_depth].nestingLevel); + } +} diff --git a/src/backend/commands/dbcommands.c b/src/backend/commands/dbcommands.c index a32af9894bf39..47697d9bea2c4 100644 --- a/src/backend/commands/dbcommands.c +++ b/src/backend/commands/dbcommands.c @@ -56,6 +56,7 @@ #include "replication/slot.h" #include "storage/copydir.h" #include "storage/fd.h" +#include "storage/fileops.h" #include "storage/ipc.h" #include "storage/lmgr.h" #include "storage/md.h" @@ -473,55 +474,91 @@ CreateDirAndVersionFile(char *dbpath, Oid dbid, Oid tsid, bool isRedo) nbytes = strlen(PG_MAJORVERSION) + 1; /* Create database directory. */ - if (MakePGDirectory(dbpath) < 0) + if (!isRedo) { - /* Failure other than already exists or not in WAL replay? */ - if (errno != EEXIST || !isRedo) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not create directory \"%s\": %m", dbpath))); + FileOpsMkdir(dbpath, pg_dir_create_mode); + } + else + { + if (MakePGDirectory(dbpath) < 0) + { + /* Failure other than already exists or not in WAL replay? */ + if (errno != EEXIST) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", dbpath))); + } } /* - * Create PG_VERSION file in the database path. If the file already - * exists and we are in WAL replay then try again to open it in write - * mode. + * Create PG_VERSION file in the database path. */ snprintf(versionfile, sizeof(versionfile), "%s/%s", dbpath, "PG_VERSION"); - fd = OpenTransientFile(versionfile, O_WRONLY | O_CREAT | O_EXCL | PG_BINARY); - if (fd < 0 && errno == EEXIST && isRedo) - fd = OpenTransientFile(versionfile, O_WRONLY | O_TRUNC | PG_BINARY); + if (!isRedo) + { + /* + * Use FileOpsCreate + FileOpsWrite for transactional version file + * creation with crash-safe rollback support. + */ + fd = FileOpsCreate(versionfile, O_WRONLY | PG_BINARY, + pg_file_create_mode, true); + pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_WRITE); + errno = 0; + if ((int) write(fd, buf, nbytes) != nbytes) + { + if (errno == 0) + errno = ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\": %m", versionfile))); + } + pgstat_report_wait_end(); - if (fd < 0) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not create file \"%s\": %m", versionfile))); + pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_SYNC); + if (pg_fsync(fd) != 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", versionfile))); + fsync_fname(dbpath, true); + pgstat_report_wait_end(); - /* Write PG_MAJORVERSION in the PG_VERSION file. */ - pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_WRITE); - errno = 0; - if (write(fd, buf, nbytes) != nbytes) - { - /* If write didn't set errno, assume problem is no disk space. */ - if (errno == 0) - errno = ENOSPC; - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not write to file \"%s\": %m", versionfile))); + CloseTransientFile(fd); } - pgstat_report_wait_end(); + else + { + /* WAL replay: use raw file operations */ + fd = OpenTransientFile(versionfile, O_WRONLY | O_CREAT | O_EXCL | PG_BINARY); + if (fd < 0 && errno == EEXIST) + fd = OpenTransientFile(versionfile, O_WRONLY | O_TRUNC | PG_BINARY); - pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_SYNC); - if (pg_fsync(fd) != 0) - ereport(data_sync_elevel(ERROR), - (errcode_for_file_access(), - errmsg("could not fsync file \"%s\": %m", versionfile))); - fsync_fname(dbpath, true); - pgstat_report_wait_end(); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", versionfile))); - /* Close the version file. */ - CloseTransientFile(fd); + pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_WRITE); + errno = 0; + if ((int) write(fd, buf, nbytes) != nbytes) + { + if (errno == 0) + errno = ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\": %m", versionfile))); + } + pgstat_report_wait_end(); + + pgstat_report_wait_start(WAIT_EVENT_VERSION_FILE_SYNC); + if (pg_fsync(fd) != 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", versionfile))); + fsync_fname(dbpath, true); + pgstat_report_wait_end(); + + CloseTransientFile(fd); + } /* If we are not in WAL replay then write the WAL. */ if (!isRedo) @@ -633,9 +670,11 @@ CreateDatabaseUsingFileCopy(Oid src_dboid, Oid dst_dboid, Oid src_tsid, /* * Copy this subdirectory to the new location * - * We don't need to copy subdirectories + * We don't need to copy subdirectories. Register the destination + * for delete-on-abort so a transaction abort recursively removes + * the partial copy. */ - copydir(srcpath, dstpath, false); + copydir(srcpath, dstpath, false, true); /* Record the filesystem change in XLOG */ { @@ -2221,8 +2260,18 @@ movedb(const char *dbname, const char *tblspcname) FreeDir(dstdir); /* - * The directory exists but is empty. We must remove it before using + * The directory exists but is empty. We must remove it before using * the copydir function. + * + * NB: this rmdir is deliberately NOT routed through FILEOPS. The + * destination tablespace path is not yet referenced by any catalog + * row (the pg_database.dattablespace update happens later, and is + * what binds dst_dbpath to the database on commit), so an abort that + * leaves dst_dbpath missing does not orphan any catalog state -- the + * database remains in src_tblspcoid. copydir() below will recreate + * dst_dbpath via MakePGDirectory in its first action and registers + * the destination tree for delete-on-abort, so the partial-copy + * window is covered. */ if (rmdir(dst_dbpath) != 0) elog(ERROR, "could not remove directory \"%s\": %m", @@ -2245,9 +2294,13 @@ movedb(const char *dbname, const char *tblspcname) bool new_record_repl[Natts_pg_database] = {0}; /* - * Copy files from the old tablespace to the new one + * Copy files from the old tablespace to the new one. Register the + * destination for delete-on-abort so a transaction abort recursively + * removes the partial copy (closing the long-standing crash-asymmetry + * with the source tree, which is preserved until commit by movedb's + * post-commit cleanup). */ - copydir(src_dbpath, dst_dbpath, false); + copydir(src_dbpath, dst_dbpath, false, true); /* * Record the filesystem change in XLOG @@ -3407,9 +3460,12 @@ dbase_redo(XLogReaderState *record) /* * Copy this subdirectory to the new location * - * We don't need to copy subdirectories + * We don't need to copy subdirectories. Pass false for + * register_for_abort_cleanup: this is a WAL replay path with no + * surrounding transaction; the redo handler is itself the recovery + * action. */ - copydir(src_path, dst_path, false); + copydir(src_path, dst_path, false, false); pfree(src_path); pfree(dst_path); diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c index 2fa534413eaca..ad687e56d1796 100644 --- a/src/backend/commands/tablecmds.c +++ b/src/backend/commands/tablecmds.c @@ -997,6 +997,7 @@ DefineRelation(CreateStmt *stmt, char relkind, Oid ownerId, break; default: (void) heap_reloptions(relkind, reloptions, true); + break; } if (stmt->ofTypename) diff --git a/src/backend/commands/tablespace.c b/src/backend/commands/tablespace.c index d91fcf0facf8b..ed52266ed911d 100644 --- a/src/backend/commands/tablespace.c +++ b/src/backend/commands/tablespace.c @@ -70,6 +70,7 @@ #include "miscadmin.h" #include "postmaster/bgwriter.h" #include "storage/fd.h" +#include "storage/fileops.h" #include "storage/lwlock.h" #include "storage/procsignal.h" #include "storage/standby.h" @@ -149,31 +150,36 @@ TablespaceCreateDbspace(Oid spcOid, Oid dbOid, bool isRedo) } else { - /* Directory creation failed? */ - if (MakePGDirectory(dir) < 0) + if (!isRedo) { - /* Failure other than not exists or not in WAL replay? */ - if (errno != ENOENT || !isRedo) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not create directory \"%s\": %m", - dir))); - - /* - * During WAL replay, it's conceivable that several levels - * of directories are missing if tablespaces are dropped - * further ahead of the WAL stream than we're currently - * replaying. An easy way forward is to create them as - * plain directories and hope they are removed by further - * WAL replay if necessary. If this also fails, there is - * trouble we cannot get out of, so just report that and - * bail out. - */ - if (pg_mkdir_p(dir, pg_dir_create_mode) < 0) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not create directory \"%s\": %m", - dir))); + /* Normal operation: use FileOpsMkdir for crash safety */ + FileOpsMkdir(dir, pg_dir_create_mode); + } + else + { + /* WAL replay: use raw mkdir with fallback */ + if (MakePGDirectory(dir) < 0) + { + if (errno != ENOENT) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", + dir))); + + /* + * During WAL replay, it's conceivable that several + * levels of directories are missing if tablespaces + * are dropped further ahead of the WAL stream than + * we're currently replaying. Create them as plain + * directories and hope they are removed by further + * WAL replay if necessary. + */ + if (pg_mkdir_p(dir, pg_dir_create_mode) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", + dir))); + } } } @@ -595,11 +601,18 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid) if (in_place) { - if (MakePGDirectory(linkloc) < 0 && errno != EEXIST) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not create directory \"%s\": %m", - linkloc))); + if (!InRecovery) + { + FileOpsMkdir(linkloc, pg_dir_create_mode); + } + else + { + if (MakePGDirectory(linkloc) < 0 && errno != EEXIST) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", + linkloc))); + } } location_with_version_dir = psprintf("%s/%s", in_place ? linkloc : location, @@ -610,20 +623,36 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid) * it doesn't exist or has the wrong owner. Not needed for in-place mode, * because in that case we created the directory with the desired * permissions. + * + * Outside recovery, route through FileOpsChmod so a transaction abort + * restores the prior mode via the FILEOPS UNDO record. In recovery + * (where there is no surrounding transaction) issue chmod directly. + * On Windows, chmod is partial (only the read-only bit is meaningful); + * FileOpsChmod handles platform differences. */ - if (!in_place && chmod(location, pg_dir_create_mode) != 0) + if (!in_place) { - if (errno == ENOENT) - ereport(ERROR, - (errcode(ERRCODE_UNDEFINED_FILE), - errmsg("directory \"%s\" does not exist", location), - InRecovery ? errhint("Create this directory for the tablespace before " - "restarting the server.") : 0)); + int rc; + + if (InRecovery) + rc = chmod(location, pg_dir_create_mode); else - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not set permissions on directory \"%s\": %m", - location))); + rc = FileOpsChmod(location, pg_dir_create_mode); + + if (rc != 0) + { + if (errno == ENOENT) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_FILE), + errmsg("directory \"%s\" does not exist", location), + InRecovery ? errhint("Create this directory for the tablespace before " + "restarting the server.") : 0)); + else + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not set permissions on directory \"%s\": %m", + location))); + } } /* @@ -640,6 +669,8 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid) (errcode_for_file_access(), errmsg("could not stat directory \"%s\": %m", location_with_version_dir))); + else if (!InRecovery) + FileOpsMkdir(location_with_version_dir, pg_dir_create_mode); else if (MakePGDirectory(location_with_version_dir) < 0) ereport(ERROR, (errcode_for_file_access(), @@ -666,11 +697,16 @@ create_tablespace_directories(const char *location, const Oid tablespaceoid) /* * Create the symlink under PGDATA */ - if (!in_place && symlink(location, linkloc) < 0) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not create symbolic link \"%s\": %m", - linkloc))); + if (!in_place) + { + if (!InRecovery) + FileOpsSymlink(location, linkloc); + else if (symlink(location, linkloc) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create symbolic link \"%s\": %m", + linkloc))); + } pfree(linkloc); pfree(location_with_version_dir); @@ -768,27 +804,50 @@ destroy_tablespace_directories(Oid tablespaceoid, bool redo) return false; } - /* remove empty directory */ - if (rmdir(subfile) < 0) - ereport(redo ? LOG : ERROR, - (errcode_for_file_access(), - errmsg("could not remove directory \"%s\": %m", - subfile))); + /* + * Remove empty directory. Outside recovery, defer to commit time so + * a transaction abort leaves the per-DB tablespace subdirectory + * intact. In recovery, issue rmdir directly because the redo + * handler is itself the recovery action. + */ + if (redo) + { + if (rmdir(subfile) < 0) + ereport(LOG, + (errcode_for_file_access(), + errmsg("could not remove directory \"%s\": %m", + subfile))); + } + else + { + FileOpsRmdir(subfile, true /* at_commit */); + } pfree(subfile); } FreeDir(dirdesc); - /* remove version directory */ - if (rmdir(linkloc_with_version_dir) < 0) + /* + * Remove version directory. Same forward / redo split as above: + * defer to commit outside recovery so abort restores the version dir; + * issue rmdir directly during redo. + */ + if (redo) { - ereport(redo ? LOG : ERROR, - (errcode_for_file_access(), - errmsg("could not remove directory \"%s\": %m", - linkloc_with_version_dir))); - pfree(linkloc_with_version_dir); - return false; + if (rmdir(linkloc_with_version_dir) < 0) + { + ereport(LOG, + (errcode_for_file_access(), + errmsg("could not remove directory \"%s\": %m", + linkloc_with_version_dir))); + pfree(linkloc_with_version_dir); + return false; + } + } + else + { + FileOpsRmdir(linkloc_with_version_dir, true /* at_commit */); } /* @@ -814,26 +873,53 @@ destroy_tablespace_directories(Oid tablespaceoid, bool redo) } else if (S_ISDIR(st.st_mode)) { - if (rmdir(linkloc) < 0) + /* + * Junction-style entry (Windows) or stale directory (any platform). + * In redo, remove now; outside redo, defer to commit so abort + * restores the entry. + */ + if (redo) { - int saved_errno = errno; + if (rmdir(linkloc) < 0) + { + int saved_errno = errno; - ereport(redo ? LOG : (saved_errno == ENOENT ? WARNING : ERROR), - (errcode_for_file_access(), - errmsg("could not remove directory \"%s\": %m", - linkloc))); + ereport(saved_errno == ENOENT ? LOG : LOG, + (errcode_for_file_access(), + errmsg("could not remove directory \"%s\": %m", + linkloc))); + } + } + else + { + FileOpsRmdir(linkloc, true /* at_commit */); } } else if (S_ISLNK(st.st_mode)) { - if (unlink(linkloc) < 0) + /* + * Tablespace symlink under PGDATA/pg_tblspc/. Symmetric with + * FileOpsSymlink on the create path: defer the unlink to commit so + * abort restores the symlink, closing the long-standing + * crash-asymmetry between CREATE TABLESPACE and DROP TABLESPACE. + * In redo, issue unlink directly because the redo handler is itself + * the recovery action. + */ + if (redo) { - int saved_errno = errno; + if (unlink(linkloc) < 0) + { + int saved_errno = errno; - ereport(redo ? LOG : (saved_errno == ENOENT ? WARNING : ERROR), - (errcode_for_file_access(), - errmsg("could not remove symbolic link \"%s\": %m", - linkloc))); + ereport(saved_errno == ENOENT ? LOG : LOG, + (errcode_for_file_access(), + errmsg("could not remove symbolic link \"%s\": %m", + linkloc))); + } + } + else + { + FileOpsDelete(linkloc, true /* at_commit */); } } else diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c index 9a1c0992bfea8..89eb64b98e318 100644 --- a/src/backend/executor/nodeModifyTable.c +++ b/src/backend/executor/nodeModifyTable.c @@ -5817,6 +5817,34 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags) resultRelInfo->ri_BatchSize = 1; } + /* + * Signal the table AM about DML operations. + * + * Tell the AM that a DML operation is starting so it can enable + * optimizations like the UNDO write buffer. This is always done for + * INSERT, UPDATE, and DELETE operations regardless of estimated row count + * -- the UNDO write buffer overhead is negligible (one palloc of ~512 + * bytes, reused for the entire transaction) and the benefit of batched + * UNDO recording applies to operations of any size. + * + * We pass the subplan's row estimate (input rows to be modified) as a + * hint; the AM may use it for buffer pre-sizing. + */ + { + Cardinality estimated_rows = subplan->plan_rows; + + if (operation == CMD_INSERT || operation == CMD_UPDATE || + operation == CMD_DELETE) + { + for (i = 0; i < mtstate->mt_nrels; i++) + { + resultRelInfo = mtstate->resultRelInfo + i; + rel = resultRelInfo->ri_RelationDesc; + table_begin_bulk_insert(rel, 0, (int64) estimated_rows); + } + } + } + /* * Lastly, if this is not the primary (canSetTag) ModifyTable node, add it * to estate->es_auxmodifytables so that it will be run to completion by @@ -5847,13 +5875,16 @@ ExecEndModifyTable(ModifyTableState *node) int i; /* - * Allow any FDWs to shut down + * Allow any FDWs to shut down, and finalize bulk insert mode. */ for (i = 0; i < node->mt_nrels; i++) { int j; ResultRelInfo *resultRelInfo = node->resultRelInfo + i; + /* End bulk insert mode (flushes pending UNDO records) */ + table_finish_bulk_insert(resultRelInfo->ri_RelationDesc, 0); + if (!resultRelInfo->ri_usesFdwDirectModify && resultRelInfo->ri_FdwRoutine != NULL && resultRelInfo->ri_FdwRoutine->EndForeignModify != NULL) diff --git a/src/backend/postmaster/bgworker.c b/src/backend/postmaster/bgworker.c index 3da2a90417fc6..4b345e6fbf23b 100644 --- a/src/backend/postmaster/bgworker.c +++ b/src/backend/postmaster/bgworker.c @@ -13,6 +13,9 @@ #include "postgres.h" #include "access/parallel.h" +#include "access/logical_revert_worker.h" +#include "access/relundo_worker.h" +#include "access/undoworker.h" #include "commands/repack.h" #include "libpq/pqsignal.h" #include "miscadmin.h" @@ -166,6 +169,22 @@ static const struct { .fn_name = "DataChecksumsWorkerMain", .fn_addr = DataChecksumsWorkerMain + }, + { + .fn_name = "LogicalRevertWorkerMain", + .fn_addr = LogicalRevertWorkerMain + }, + { + .fn_name = "LogicalRevertLauncherMain", + .fn_addr = LogicalRevertLauncherMain + }, + { + .fn_name = "UndoWorkerMain", + .fn_addr = UndoWorkerMain + }, + { + .fn_name = "RelUndoWorkerMain", + .fn_addr = RelUndoWorkerMain } }; diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c index 90c7c4528e872..a76bc2c3c031b 100644 --- a/src/backend/postmaster/postmaster.c +++ b/src/backend/postmaster/postmaster.c @@ -89,6 +89,8 @@ #include #endif +#include "access/logical_revert_worker.h" +#include "access/undolog.h" #include "access/xlog.h" #include "access/xlog_internal.h" #include "access/xlogrecovery.h" @@ -925,6 +927,13 @@ PostmasterMain(int argc, char *argv[]) */ ApplyLauncherRegister(); + /* + * The Logical Revert Launcher wires up the async physical-undo-apply + * path. The launcher scans pg_database once at startup and spawns a + * per-database LogicalRevertWorker which drains the ATM. + */ + LogicalRevertLauncherRegister(); + /* * Register the shared memory needs of all core subsystems. */ diff --git a/src/backend/replication/logical/decode.c b/src/backend/replication/logical/decode.c index c944be4ac83c8..789a16b942a15 100644 --- a/src/backend/replication/logical/decode.c +++ b/src/backend/replication/logical/decode.c @@ -27,6 +27,7 @@ #include "postgres.h" #include "access/heapam_xlog.h" +#include "access/flux_xlog.h" #include "access/transam.h" #include "access/xact.h" #include "access/xlog_internal.h" @@ -68,6 +69,8 @@ static inline bool FilterPrepare(LogicalDecodingContext *ctx, static bool DecodeTXNNeedSkip(LogicalDecodingContext *ctx, XLogRecordBuffer *buf, Oid txn_dbid, ReplOriginId origin_id); +static inline bool FilterByOrigin(LogicalDecodingContext *ctx, + ReplOriginId origin_id); /* * Take every XLogReadRecord()ed record and perform the actions required to @@ -564,6 +567,465 @@ heap_decode(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) } } +/* + * Parse XLOG_FLUX_INSERT from wal into proper tuplebufs. + * + * Inserts contain the new tuple in FLUX format. + */ +static void +DecodeFluxInsert(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + Size datalen; + char *tupledata; + XLogReaderState *r = buf->record; + xl_flux_insert *xlrec; + ReorderBufferChange *change; + RelFileLocator target_locator; + + xlrec = (xl_flux_insert *) XLogRecGetData(r); + + /* only interested in our database */ + XLogRecGetBlockTag(r, 0, &target_locator, NULL, NULL); + if (target_locator.dbOid != ctx->slot->data.database) + return; + + /* output plugin doesn't look for this origin, no need to queue */ + if (FilterByOrigin(ctx, XLogRecGetOrigin(r))) + return; + + change = ReorderBufferAllocChange(ctx->reorder); + change->action = REORDER_BUFFER_CHANGE_INSERT; + change->origin_id = XLogRecGetOrigin(r); + + memcpy(&change->data.tp.rlocator, &target_locator, sizeof(RelFileLocator)); + + /* + * Get FLUX tuple data from WAL record. The tuple follows the + * xl_flux_insert structure. + */ + tupledata = (char *) xlrec + sizeof(xl_flux_insert); + datalen = xlrec->tuple_len; + + /* + * When FLUX_WAL_LOGICAL_TUPLE is set, the write path appended a + * heap-format image at the END of the main WAL data: ... [heap bytes] + * [uint32 heap_len] Read heap_len from the last 4 bytes, then back up + * heap_len more bytes to find the heap tuple payload. This is immune to + * whatever compression data precedes it. + */ + if (xlrec->flags & FLUX_WAL_LOGICAL_TUPLE) + { + Size full_len = XLogRecGetDataLen(r); + char *end = (char *) xlrec + full_len; + uint32 heap_len; + char *heap_data; + + memcpy(&heap_len, end - sizeof(uint32), sizeof(uint32)); + elog(DEBUG1, "FLUX DecodeInsert: full_len=%zu heap_len=%u action=INSERT lsn=%X/%X", + full_len, heap_len, + (uint32) (buf->origptr >> 32), (uint32) buf->origptr); + heap_data = end - sizeof(uint32) - heap_len; + + change->data.tp.newtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + heap_len - SizeofHeapTupleHeader); + change->data.tp.newtuple->t_len = heap_len; + ItemPointerSetInvalid(&change->data.tp.newtuple->t_self); + change->data.tp.newtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.newtuple->t_data, heap_data, heap_len); + } + else + { + /* + * Legacy path: no heap image in WAL (publisher compiled without or + * table not logically logged when the record was written). pgoutput + * will most likely reject this; initial-sync via COPY still works, so + * this is only a streaming-decoding concern. + */ + change->data.tp.newtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, datalen); + memcpy(change->data.tp.newtuple->t_data, tupledata, datalen); + change->data.tp.newtuple->t_len = datalen; + ItemPointerSetInvalid(&change->data.tp.newtuple->t_self); + change->data.tp.newtuple->t_tableOid = InvalidOid; + } + + change->data.tp.clear_toast_afterwards = true; + + ReorderBufferQueueChange(ctx->reorder, XLogRecGetXid(r), buf->origptr, + change, false); +} + +/* + * Parse XLOG_FLUX_MULTI_INSERT from wal into proper tuplebufs. + * + * A batched insert carries N tuples on one page. The record layout is: + * + * xl_flux_multi_insert -- header + * N * { xl_flux_multi_insert_tuple | FLUX body }-- per-tuple region + * N * { heap body | uint32 len } -- when FLUX_WAL_LOGICAL_TUPLE + * + * We queue one REORDER_BUFFER_CHANGE_INSERT per tuple, preferring the + * trailing heap-format image when present (mirrors DecodeFluxInsert). The + * heap images are appended in tuple order, so the i-th image from the front + * of the trailing region corresponds to the i-th per-tuple entry. + */ +static void +DecodeFluxMultiInsert(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + XLogReaderState *r = buf->record; + xl_flux_multi_insert *xlrec; + RelFileLocator target_locator; + char *data; + char *cursor; + Size total_len; + int ntuples; + int i; + bool has_logical; + char **image_data = NULL; + uint32 *image_len = NULL; + + xlrec = (xl_flux_multi_insert *) XLogRecGetData(r); + ntuples = xlrec->ntuples; + + /* only interested in our database */ + XLogRecGetBlockTag(r, 0, &target_locator, NULL, NULL); + if (target_locator.dbOid != ctx->slot->data.database) + return; + + /* output plugin doesn't look for this origin, no need to queue */ + if (FilterByOrigin(ctx, XLogRecGetOrigin(r))) + return; + + data = XLogRecGetData(r); + total_len = XLogRecGetDataLen(r); + has_logical = (xlrec->flags & FLUX_WAL_LOGICAL_TUPLE) != 0; + + /* + * When heap-format images are present, locate each one up front by + * walking the trailing image region from the record tail backward (each + * region is "[heap bytes][uint32 len]"). The images were appended in + * tuple order, so walking back collects them in reverse; we store them + * indexed by tuple so the queue loop can read them front-to-back. + */ + if (has_logical) + { + const char *end = data + total_len; + + image_data = (char **) palloc(ntuples * sizeof(char *)); + image_len = (uint32 *) palloc(ntuples * sizeof(uint32)); + + for (i = ntuples - 1; i >= 0; i--) + { + uint32 heap_len; + + memcpy(&heap_len, end - sizeof(uint32), sizeof(uint32)); + end -= sizeof(uint32); + end -= heap_len; + image_data[i] = (char *) end; + image_len[i] = heap_len; + } + } + + /* Walk the per-tuple region forward, queuing one INSERT per tuple */ + cursor = data + SizeOfFluxMultiInsert; + for (i = 0; i < ntuples; i++) + { + xl_flux_multi_insert_tuple *tuphdr; + char *tupledata; + ReorderBufferChange *change; + + cursor = (char *) SHORTALIGN(cursor); + tuphdr = (xl_flux_multi_insert_tuple *) cursor; + cursor += SizeOfFluxMultiInsertTuple; + tupledata = cursor; + cursor += tuphdr->datalen; + + change = ReorderBufferAllocChange(ctx->reorder); + change->action = REORDER_BUFFER_CHANGE_INSERT; + change->origin_id = XLogRecGetOrigin(r); + memcpy(&change->data.tp.rlocator, &target_locator, + sizeof(RelFileLocator)); + + if (has_logical) + { + uint32 heap_len = image_len[i]; + + change->data.tp.newtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + heap_len - SizeofHeapTupleHeader); + change->data.tp.newtuple->t_len = heap_len; + ItemPointerSetInvalid(&change->data.tp.newtuple->t_self); + change->data.tp.newtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.newtuple->t_data, image_data[i], heap_len); + } + else + { + /* + * Legacy path: no heap image (table not logically logged when the + * record was written). Queue the raw FLUX body; pgoutput will + * most likely reject it, but initial COPY sync still works. + */ + change->data.tp.newtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, tuphdr->datalen); + memcpy(change->data.tp.newtuple->t_data, tupledata, + tuphdr->datalen); + change->data.tp.newtuple->t_len = tuphdr->datalen; + ItemPointerSetInvalid(&change->data.tp.newtuple->t_self); + change->data.tp.newtuple->t_tableOid = InvalidOid; + } + + change->data.tp.clear_toast_afterwards = (i == ntuples - 1); + + ReorderBufferQueueChange(ctx->reorder, XLogRecGetXid(r), buf->origptr, + change, false); + } + + if (has_logical) + { + pfree(image_data); + pfree(image_len); + } +} + +/* + * Parse XLOG_FLUX_UPDATE_INPLACE from wal into proper tuplebufs. + * + * Updates contain both the old and new tuple in FLUX format. + */ +static void +DecodeFluxUpdate(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + XLogReaderState *r = buf->record; + xl_flux_update *xlrec; + ReorderBufferChange *change; + char *old_tuple_data; + char *new_tuple_data; + RelFileLocator target_locator; + + xlrec = (xl_flux_update *) XLogRecGetData(r); + + /* only interested in our database */ + XLogRecGetBlockTag(r, 0, &target_locator, NULL, NULL); + if (target_locator.dbOid != ctx->slot->data.database) + return; + + /* output plugin doesn't look for this origin, no need to queue */ + if (FilterByOrigin(ctx, XLogRecGetOrigin(r))) + return; + + change = ReorderBufferAllocChange(ctx->reorder); + change->action = REORDER_BUFFER_CHANGE_UPDATE; + change->origin_id = XLogRecGetOrigin(r); + memcpy(&change->data.tp.rlocator, &target_locator, sizeof(RelFileLocator)); + + /* + * Get old and new tuple data from WAL record. The structure is: + * xl_flux_update | old_tuple | new_tuple [| logical images] + */ + old_tuple_data = (char *) xlrec + sizeof(xl_flux_update); + new_tuple_data = old_tuple_data + xlrec->old_tuple_len; + + if (xlrec->flags & FLUX_WAL_LOGICAL_TUPLE) + { + /* + * Two heap-format images appended at the END of the record, each with + * trailing length: + * + * ... [old_heap bytes] [uint32 old_heap_len] [new_heap bytes] [uint32 + * new_heap_len] + * + * Walk backwards from end: read new_heap_len last, then old_heap_len + * before the new bytes + its length. + */ + Size full_len = XLogRecGetDataLen(r); + char *end = (char *) xlrec + full_len; + uint32 old_heap_len; + uint32 new_heap_len; + char *old_heap_data; + char *new_heap_data; + + memcpy(&new_heap_len, end - sizeof(uint32), sizeof(uint32)); + new_heap_data = end - sizeof(uint32) - new_heap_len; + memcpy(&old_heap_len, + new_heap_data - sizeof(uint32), sizeof(uint32)); + old_heap_data = new_heap_data - sizeof(uint32) - old_heap_len; + + change->data.tp.oldtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + old_heap_len - SizeofHeapTupleHeader); + change->data.tp.oldtuple->t_len = old_heap_len; + ItemPointerSetInvalid(&change->data.tp.oldtuple->t_self); + change->data.tp.oldtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.oldtuple->t_data, old_heap_data, old_heap_len); + + change->data.tp.newtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + new_heap_len - SizeofHeapTupleHeader); + change->data.tp.newtuple->t_len = new_heap_len; + ItemPointerSetInvalid(&change->data.tp.newtuple->t_self); + change->data.tp.newtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.newtuple->t_data, new_heap_data, new_heap_len); + } + else + { + /* Legacy: FLUX-format bytes (will confuse pgoutput) */ + change->data.tp.oldtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, xlrec->old_tuple_len); + memcpy(change->data.tp.oldtuple->t_data, old_tuple_data, + xlrec->old_tuple_len); + change->data.tp.oldtuple->t_len = xlrec->old_tuple_len; + ItemPointerSetInvalid(&change->data.tp.oldtuple->t_self); + change->data.tp.oldtuple->t_tableOid = InvalidOid; + + change->data.tp.newtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, xlrec->new_tuple_len); + memcpy(change->data.tp.newtuple->t_data, new_tuple_data, + xlrec->new_tuple_len); + change->data.tp.newtuple->t_len = xlrec->new_tuple_len; + ItemPointerSetInvalid(&change->data.tp.newtuple->t_self); + change->data.tp.newtuple->t_tableOid = InvalidOid; + } + + change->data.tp.clear_toast_afterwards = true; + + ReorderBufferQueueChange(ctx->reorder, XLogRecGetXid(r), buf->origptr, + change, false); +} + +/* + * Parse XLOG_FLUX_DELETE from wal into proper tuplebufs. + * + * Deletes contain the old tuple for UNDO purposes. + */ +static void +DecodeFluxDelete(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + XLogReaderState *r = buf->record; + xl_flux_delete *xlrec; + ReorderBufferChange *change; + char *tupledata; + RelFileLocator target_locator; + + xlrec = (xl_flux_delete *) XLogRecGetData(r); + + /* only interested in our database */ + XLogRecGetBlockTag(r, 0, &target_locator, NULL, NULL); + if (target_locator.dbOid != ctx->slot->data.database) + return; + + /* output plugin doesn't look for this origin, no need to queue */ + if (FilterByOrigin(ctx, XLogRecGetOrigin(r))) + return; + + change = ReorderBufferAllocChange(ctx->reorder); + change->action = REORDER_BUFFER_CHANGE_DELETE; + change->origin_id = XLogRecGetOrigin(r); + memcpy(&change->data.tp.rlocator, &target_locator, sizeof(RelFileLocator)); + + /* + * Get old tuple data from WAL record. The tuple follows the + * xl_flux_delete structure. + */ + tupledata = (char *) xlrec + sizeof(xl_flux_delete); + + if (xlrec->flags & FLUX_WAL_LOGICAL_TUPLE) + { + /* + * Heap image is the last region of the WAL record: ... [heap bytes] + * [uint32 heap_len] + */ + Size full_len = XLogRecGetDataLen(r); + char *end = (char *) xlrec + full_len; + uint32 heap_len; + char *heap_data; + + memcpy(&heap_len, end - sizeof(uint32), sizeof(uint32)); + heap_data = end - sizeof(uint32) - heap_len; + + change->data.tp.oldtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, + heap_len - SizeofHeapTupleHeader); + change->data.tp.oldtuple->t_len = heap_len; + ItemPointerSetInvalid(&change->data.tp.oldtuple->t_self); + change->data.tp.oldtuple->t_tableOid = InvalidOid; + memcpy(change->data.tp.oldtuple->t_data, heap_data, heap_len); + } + else + { + /* Legacy path */ + change->data.tp.oldtuple = + ReorderBufferAllocTupleBuf(ctx->reorder, xlrec->tuple_len); + memcpy(change->data.tp.oldtuple->t_data, tupledata, + xlrec->tuple_len); + change->data.tp.oldtuple->t_len = xlrec->tuple_len; + ItemPointerSetInvalid(&change->data.tp.oldtuple->t_self); + change->data.tp.oldtuple->t_tableOid = InvalidOid; + } + + change->data.tp.clear_toast_afterwards = true; + + ReorderBufferQueueChange(ctx->reorder, XLogRecGetXid(r), buf->origptr, + change, false); +} + + +/* + * Handle rmgr FLUX records for LogicalDecodingProcessRecord(). + * + * FLUX's WAL record layout and the XLOG_FLUX_* opcodes are defined in + * flux_xlog.h; the FLUX decode helpers above interpret them directly. + */ +void +flux_decode(LogicalDecodingContext *ctx, XLogRecordBuffer *buf) +{ + uint8 info = XLogRecGetInfo(buf->record) & XLOG_FLUX_OPMASK; + TransactionId xid = XLogRecGetXid(buf->record); + SnapBuild *builder = ctx->snapshot_builder; + + ReorderBufferProcessXid(ctx->reorder, xid, buf->origptr); + + if (SnapBuildCurrentState(builder) < SNAPBUILD_FULL_SNAPSHOT) + return; + + switch (info) + { + case XLOG_FLUX_INSERT: + if (SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward) + DecodeFluxInsert(ctx, buf); + break; + + case XLOG_FLUX_MULTI_INSERT: + if (SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward) + DecodeFluxMultiInsert(ctx, buf); + break; + + case XLOG_FLUX_UPDATE_INPLACE: + if (SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward) + DecodeFluxUpdate(ctx, buf); + break; + + case XLOG_FLUX_DELETE: + if (SnapBuildProcessChange(builder, xid, buf->origptr) && + !ctx->fast_forward) + DecodeFluxDelete(ctx, buf); + break; + + case XLOG_FLUX_DEFRAG: + case XLOG_FLUX_COMPRESS: + case XLOG_FLUX_OVERFLOW_WRITE: + case XLOG_FLUX_INIT_PAGE: + break; + + default: + elog(ERROR, "unexpected RM_FLUX_ID record type: %u", info); + break; + } +} + /* * Ask output plugin whether we want to skip this PREPARE and send * this transaction as a regular commit later. diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c index 169829eb02070..722f75c4690b5 100644 --- a/src/backend/storage/buffer/bufmgr.c +++ b/src/backend/storage/buffer/bufmgr.c @@ -3218,6 +3218,68 @@ MarkBufferDirty(Buffer buffer) } } +/* + * MarkBufferDirtyShared + * + * Like MarkBufferDirty, but callable while holding only a SHARED content + * lock on the buffer. The BM_DIRTY bit is set atomically via CAS on the + * buffer header state word, which is safe regardless of lock mode. + * + * The caller MUST ensure that either: + * (a) a WAL record covering the modification has already been inserted + * (so the WAL flush in the checkpointer will find the record), or + * (b) full_page_writes will capture a consistent image (the modification + * is protected by a per-tuple lock that prevents torn pages). + * + * This is used by an in-place-update table AM's tuple-level CAS update path + * where per-tuple atomics protect individual tuple data under a shared page + * lock. + */ +void +MarkBufferDirtyShared(Buffer buffer) +{ + BufferDesc *bufHdr; + uint64 buf_state; + uint64 old_buf_state; + + if (!BufferIsValid(buffer)) + elog(ERROR, "bad buffer ID: %d", buffer); + + if (BufferIsLocal(buffer)) + { + MarkLocalBufferDirty(buffer); + return; + } + + bufHdr = GetBufferDescriptor(buffer - 1); + + Assert(BufferIsPinned(buffer)); + /* Caller holds at least BUFFER_LOCK_SHARE -- no exclusive assertion */ + + old_buf_state = pg_atomic_read_u64(&bufHdr->state); + for (;;) + { + if (old_buf_state & BM_LOCKED) + old_buf_state = WaitBufHdrUnlocked(bufHdr); + + buf_state = old_buf_state; + + Assert(BUF_STATE_GET_REFCOUNT(buf_state) > 0); + buf_state |= BM_DIRTY; + + if (pg_atomic_compare_exchange_u64(&bufHdr->state, &old_buf_state, + buf_state)) + break; + } + + if (!(old_buf_state & BM_DIRTY)) + { + pgBufferUsage.shared_blks_dirtied++; + if (VacuumCostActive) + VacuumCostBalance += VacuumCostPageDirty; + } +} + /* * ReleaseAndReadBuffer -- combine ReleaseBuffer() and ReadBuffer() * @@ -5899,6 +5961,78 @@ UnlockBuffers(void) } } +/* + * BufferLockReleaseAll -- release any buffer content locks still held by this + * backend. + * + * Normally, buffer content locks are released as part of resource-owner + * cleanup (see ResOwnerReleaseBuffer, which calls BufferLockUnlock on any + * entry whose data.lockmode != BUFFER_LOCK_UNLOCK). However, that cleanup + * runs in ResourceOwnerRelease(RESOURCE_RELEASE_LOCKS), which is *after* + * AtAbort_XactUndo() in AbortTransaction(). Inline UNDO application may + * itself take buffer content locks, and BufferLockAcquire asserts that no + * lock is already held. If a statement errors out while holding a share + * or exclusive content lock, that assert would trip on any re-lock during + * UNDO application. + * + * This helper releases such stragglers early, before inline UNDO runs. + * It mirrors the still-locked branch of ResOwnerReleaseBuffer (matching + * the HOLD_INTERRUPTS/BufferLockUnlock discipline). Callers must be in + * an error-recovery path -- releasing content locks under a live statement + * would violate correctness. + * + * The subsequent ResourceOwnerRelease pass at RESOURCE_RELEASE_LOCKS still + * runs, but ResOwnerReleaseBuffer only calls BufferLockUnlock when + * data.lockmode != BUFFER_LOCK_UNLOCK, so already-released buffers are + * skipped -- no double-unlock. + */ +void +BufferLockReleaseAll(void) +{ + PrivateRefCountEntry *res; + int i; + + /* Walk the small array first (fast path, covers the usual case). */ + for (i = 0; i < REFCOUNT_ARRAY_ENTRIES; i++) + { + Buffer buffer = PrivateRefCountArrayKeys[i]; + + if (buffer == InvalidBuffer) + continue; + + res = &PrivateRefCountArray[i]; + if (res->data.lockmode == BUFFER_LOCK_UNLOCK) + continue; + + /* Local buffers do not have content locks. */ + if (BufferIsLocal(buffer)) + continue; + + HOLD_INTERRUPTS(); /* matched by RESUME_INTERRUPTS in BufferLockUnlock */ + BufferLockUnlock(buffer, GetBufferDescriptor(buffer - 1)); + } + + /* Then the overflow hash, if any. */ + if (PrivateRefCountOverflowed) + { + refcount_iterator iter; + + refcount_start_iterate(PrivateRefCountHash, &iter); + while ((res = refcount_iterate(PrivateRefCountHash, &iter)) != NULL) + { + Buffer buffer = res->buffer; + + if (res->data.lockmode == BUFFER_LOCK_UNLOCK) + continue; + if (BufferIsLocal(buffer)) + continue; + + HOLD_INTERRUPTS(); + BufferLockUnlock(buffer, GetBufferDescriptor(buffer - 1)); + } + } +} + /* * Acquire the buffer content lock in the specified mode * diff --git a/src/backend/storage/file/Makefile b/src/backend/storage/file/Makefile index 660ac51807e79..8bcfafec7cf7c 100644 --- a/src/backend/storage/file/Makefile +++ b/src/backend/storage/file/Makefile @@ -16,6 +16,8 @@ OBJS = \ buffile.o \ copydir.o \ fd.o \ + fileops.o \ + fileops_undo.o \ fileset.o \ reinit.o \ sharedfileset.o diff --git a/src/backend/storage/file/README b/src/backend/storage/file/README new file mode 100644 index 0000000000000..4c5b45f9b8893 --- /dev/null +++ b/src/backend/storage/file/README @@ -0,0 +1,180 @@ +src/backend/storage/file/README + +Transactional File Operations (FILEOPS) +======================================= + +This directory's fileops.c and fileops_undo.c implement FILEOPS: a set of +filesystem operations that participate in PostgreSQL transactions. An +ordinary unlink() or rename() issued in the middle of a transaction is not +covered by WAL and is not reversed if the transaction aborts or the server +crashes. FILEOPS closes that gap for the filesystem mutations the server +performs outside the buffer manager -- creating and removing database and +tablespace directories, copying directory trees, writing version files, +managing symlinks, and setting extended attributes. + + +Why FILEOPS exists +------------------ + +PostgreSQL already makes heap and index changes crash-safe by WAL-logging +them and replaying the log during recovery. Directory- and file-level +operations historically had no equivalent. Code paths such as CREATE +DATABASE, CREATE TABLESPACE, and ALTER DATABASE ... SET TABLESPACE perform +raw mkdir(), symlink(), rename(), and rmtree() calls and then rely on +ad-hoc cleanup callbacks to undo their effects on failure. That cleanup is +best-effort: it does not survive a crash, and it is easy to get the abort +ordering wrong. + +FILEOPS gives these operations the same two guarantees the rest of the +system enjoys: + + Atomicity with the transaction. A FILEOPS request does not touch the + filesystem when it is issued. It is recorded as a deferred PendingFileOp + and executed only when the transaction commits (or, for delete-on-abort + registrations, when it aborts). If the transaction never commits, the + on-disk state is never changed. + + Crash safety. Each executed operation is WAL-logged under the + RM_FILEOPS_ID resource manager, so redo during crash recovery reproduces + it on the primary, and a physical standby reproduces it by WAL replay. + This holds for the immediate operations (CREATE, WRITE, LINK, MKDIR, + CHMOD, CHOWN, TRUNCATE, SETXATTR, REMOVEXATTR) and, as of the B3 fix, for + the deferred DESTRUCTIVE operations (RENAME, DELETE, RMDIR) as well: their + WAL record is emitted at commit-EXECUTION time inside FileOpsDoPendingOps() + -- when the op actually happens on the primary -- and their redo handlers + are now real and idempotent (a delete of an already-absent file, a rename + whose source is already gone, or an rmdir of an already-absent directory + all count as success), so the standby converges regardless of a primary + crash mid-commit. Only the commit path is logged; a delete-on-abort / + rmdir-on-abort registration executed during ABORT emits no WAL, because a + standby must never apply an aborted transaction's filesystem effect. See + src/test/recovery/t/070_fileops_standby_divergence.pl for the + physical-standby diff test that gates this. + + Two deliberate exceptions: + + RMTREE (FileOpsRmtree) writes no WAL and has no redo. Its only core + caller (copydir.c) registers it at_commit=false, i.e. it fires only on + ABORT to clean up a partially-copied destination tree; aborts are never + replayed as committed, so it cannot diverge a standby. A commit-time + RMTREE would NOT be standby-safe and is currently unused. + + SYMLINK redo is an intentional no-op. FileOpsSymlink is used ONLY for + tablespace symlinks (create_tablespace_directories), and standby / + crash-recovery replay of those is owned by RM_TBLSPC, which applies the + recovery-time layout remap. Replaying the FILEOPS record would be + redundant and wrong on a remapped standby (see sec2 in fileops.c). + + Operations that must be reversible on rollback + also emit UNDO records (RM_UNDO_ID, subtypes FILEOPS_UNDO_*) carrying the + before-image needed to reverse them -- the original mode for a chmod, the + original length for a truncate, the prior xattr value for a setxattr, and + so on. + +The operation set deliberately follows the Berkeley DB fileops.src model: +every filesystem mutation is a composable unit with its own WAL record +type, redo handler, and rmgrdesc descriptor, so each can be replayed +independently. + + +How to use the API +------------------ + +The public API is declared in src/include/storage/fileops.h. Call these in +place of the raw libc/syscall equivalents whenever the operation must be +transactional. Each returns the same way its libc counterpart does (the +create/rename/chmod/chown/mkdir/symlink/link calls return an int errno-style +result; the void calls register a pending op): + + FileOpsCreate(path, flags, mode, register_delete) + FileOpsDelete(path, at_commit) + FileOpsRename(oldpath, newpath) + FileOpsWrite(path, offset, data, len) + FileOpsTruncate(path, length) + FileOpsChmod(path, mode) + FileOpsChown(path, uid, gid) + FileOpsMkdir(path, mode) + FileOpsRmdir(path, at_commit) + FileOpsRmdirRecursive(path, at_commit) + FileOpsRmtree(path, at_commit) + FileOpsSymlink(target, linkpath) + FileOpsLink(oldpath, newpath) + FileOpsSetXattr(path, name, value, len) + FileOpsRemoveXattr(path, name) + +Two flags recur in this API and are worth understanding: + + register_delete (FileOpsCreate). When true, the newly created file is + registered for deletion if the transaction aborts. This is the common + case for files a transaction brings into existence: keep them on commit, + remove them on abort. + + at_commit (FileOpsDelete, FileOpsRmdir, FileOpsRmdirRecursive, + FileOpsRmtree). Selects when the removal happens. at_commit = true means + "remove this when the transaction commits" -- the right choice for tearing + down state the transaction is logically deleting (for example, dropping a + database directory). at_commit = false means "remove this only if the + transaction aborts" -- used to clean up something the transaction just + created, such as the destination tree of an in-progress directory copy. + +FileOpsRmtree differs from FileOpsRmdirRecursive in when the tree is walked. +FileOpsRmdirRecursive enumerates the directory's contents at registration +time, so it removes what is present now. FileOpsRmtree defers the walk to +execute time, so it removes whatever ended up on disk by commit/abort -- +the correct choice when registering the destination of a copydir() that is +still being populated. + +The API hides platform differences: PG_O_DIRECT for unbuffered I/O, +pg_fsync() for the correct per-platform flush, fsync_parent_path() for +directory durability on Unix, and durable_rename()/durable_unlink() for +crash-safe rename and unlink ordering. + + +Transaction lifecycle integration +---------------------------------- + +The deferred operations are driven by hooks that the transaction machinery +(src/backend/access/transam/xact.c) calls at the right points. Callers of +the FileOps* API do not invoke these directly; they are documented here so +the lifecycle is clear: + + FileOpsDoPendingOps(isCommit). Executes the pending operation list at + end of transaction. Called with true from the commit path and with false + from the abort path; each PendingFileOp's at_commit flag decides whether + it fires in this phase. + + AtSubCommit_FileOps(). On subtransaction commit, reparents the + subtransaction's pending operations to the parent so they are decided by + the parent's eventual commit or abort. + + AtSubAbort_FileOps(). On subtransaction abort, executes and discards the + pending operations belonging to the aborting subtransaction. + + PostPrepare_FileOps(). At PREPARE TRANSACTION, hands the pending + operations off so they are resolved when the prepared transaction is later + committed or rolled back. + + FileOpsCancelPendingDelete(path, at_commit). Removes a previously + registered delete-on-commit or delete-on-abort entry, for the case where + a caller decides the file should no longer be removed. + + FileOpsSync(path). Forces durability of a path outside the deferred + queue when an operation needs to be flushed immediately. + + +WAL replay and UNDO +------------------- + +fileops.c provides the redo side: fileops_redo() dispatches each +XLOG_FILEOPS_* record to the matching action during recovery, and +fileops_desc()/fileops_identify() render records for pg_waldump. Because +the redo handler is itself the recovery action, code paths that run under +WAL replay (the "redo" arms in dbcommands.c, tablespace.c, and copydir.c) +call the raw syscalls directly rather than re-entering the FileOps* API. + +fileops_undo.c provides the rollback side. For operations whose effect must +be reversed when a transaction aborts, it records a FILEOPS_UNDO_* payload +holding the before-image and, during UNDO application, performs the inverse +action: unlink a created file, rename back, restore the original truncation +length, mode, owner, or extended-attribute value. FileopsUndoRmgrInit() +registers this handler with the UNDO machinery at startup. diff --git a/src/backend/storage/file/copydir.c b/src/backend/storage/file/copydir.c index ee42c796f773a..8b331665598f5 100644 --- a/src/backend/storage/file/copydir.c +++ b/src/backend/storage/file/copydir.c @@ -24,11 +24,13 @@ #include #include +#include "access/xlog.h" #include "common/file_utils.h" #include "miscadmin.h" #include "pgstat.h" #include "storage/copydir.h" #include "storage/fd.h" +#include "storage/fileops.h" #include "utils/wait_event.h" /* GUCs */ @@ -44,9 +46,17 @@ static void clone_file(const char *fromfile, const char *tofile); * * This function uses the file_copy_method GUC. New uses of this function must * be documented in doc/src/sgml/config.sgml. + * + * If register_for_abort_cleanup is true and we are not in WAL replay, the + * destination tree is registered with FILEOPS so that a transaction abort + * recursively removes the partial copy. This closes the long-standing + * crash-asymmetry where CREATE DATABASE FILE_COPY and ALTER DATABASE SET + * TABLESPACE leave orphan trees behind on rollback. Recovery callers pass + * false because they are themselves the recovery action. */ void -copydir(const char *fromdir, const char *todir, bool recurse) +copydir(const char *fromdir, const char *todir, bool recurse, + bool register_for_abort_cleanup) { DIR *xldir; struct dirent *xlde; @@ -58,6 +68,16 @@ copydir(const char *fromdir, const char *todir, bool recurse) (errcode_for_file_access(), errmsg("could not create directory \"%s\": %m", todir))); + /* + * Register the destination tree for delete-on-abort BEFORE we start + * copying. If the surrounding transaction aborts (or this function + * itself errors out), FILEOPS will recursively unlink the partial copy. + * Skip this in WAL replay: the redo handler IS the recovery action and + * has no surrounding transaction context. + */ + if (register_for_abort_cleanup && !RecoveryInProgress()) + FileOpsRmtree(todir, false /* at_commit -> at abort */); + xldir = AllocateDir(fromdir); while ((xlde = ReadDir(xldir, fromdir)) != NULL) @@ -78,9 +98,15 @@ copydir(const char *fromdir, const char *todir, bool recurse) if (xlde_type == PGFILETYPE_DIR) { - /* recurse to handle subdirectories */ + /* + * Recurse to handle subdirectories. Pass false for + * register_for_abort_cleanup: the parent call (if any) already + * registered the top-level destination tree, and FileOpsRmdirRecursive + * walks the tree at abort time, so registering subdirectories + * separately would only duplicate work. + */ if (recurse) - copydir(fromfile, tofile, true); + copydir(fromfile, tofile, true, false); } else if (xlde_type == PGFILETYPE_REG) { diff --git a/src/backend/storage/file/fileops.c b/src/backend/storage/file/fileops.c new file mode 100644 index 0000000000000..ced44ef57269c --- /dev/null +++ b/src/backend/storage/file/fileops.c @@ -0,0 +1,2299 @@ +/*------------------------------------------------------------------------- + * + * fileops.c + * Transactional file operations with WAL logging + * + * This module provides transactional filesystem operations that integrate + * with PostgreSQL's WAL and transaction management. File operations are + * logged to WAL and deferred until transaction commit/abort, following + * the same pattern used for relation creation/deletion in catalog/storage.c. + * + * The deferred operations pattern works as follows: + * 1. The API function logs the operation to WAL + * 2. A PendingFileOp entry is added to a linked list + * 3. At commit/abort time, FileOpsDoPendingOps() executes or discards + * the pending operations based on transaction outcome + * + * Subtransaction support: + * - At subtransaction commit, entries are reassigned to the parent level + * - At subtransaction abort, abort-time actions execute immediately + * + * Platform-specific handling: + * - O_DIRECT: Uses PG_O_DIRECT abstraction (Linux native O_DIRECT, + * macOS F_NOCACHE via fcntl, Windows FILE_FLAG_NO_BUFFERING) + * - fsync: Uses pg_fsync() which selects the appropriate mechanism + * (Linux fdatasync, macOS F_FULLFSYNC, Windows FlushFileBuffers, + * BSD fsync) + * - Directory sync: Uses fsync_fname()/fsync_parent_path() which + * handle directory fsync on Unix platforms (not needed on Windows) + * - Durable operations: Uses durable_rename()/durable_unlink() which + * ensure operations persist across crashes via proper fsync ordering + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/storage/file/fileops.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#include +#include +#ifdef HAVE_SYS_FCNTL_H +#include +#endif + +/* + * ENODATA is Linux-specific; FreeBSD and other BSDs don't define it. + * When removing extended attributes, ENODATA means "attribute does not + * exist" -- equivalent to ENOATTR on BSDs. + */ +#ifndef ENODATA +#ifdef ENOATTR +#define ENODATA ENOATTR +#else +#define ENODATA ENOENT +#endif +#endif + +#include "access/fileops_xlog.h" +#include "access/rmgr.h" +#include "access/undolog.h" +#include "access/undorecord.h" +#include "access/undormgr.h" +#include "access/xact.h" +#include "access/xactundo.h" +#include "access/xlog.h" +#include "access/xloginsert.h" +#include "catalog/pg_class.h" +#include "miscadmin.h" +#include "port/pg_xattr.h" +#include "storage/fd.h" +#include "storage/fileops.h" +#include "utils/injection_point.h" +#include "utils/memutils.h" + + +/* Head of the pending file operations linked list */ +static PendingFileOp *pendingFileOps = NULL; + +/* + * fileops_fsync_parent -- fsync the parent directory of a file path + * + * This ensures that directory entry changes (create, delete, rename) + * are durable. On Windows, directory fsync is not needed because NTFS + * journals directory entries; fsync_fname_ext() handles this by being + * a no-op for directories on Windows. + */ +static void +fileops_fsync_parent(const char *fname, int elevel) +{ + char parentpath[MAXPGPATH]; + char *sep; + + strlcpy(parentpath, fname, MAXPGPATH); + + sep = strrchr(parentpath, '/'); + if (sep != NULL) + { + /* Got a path component, fsync the directory portion */ + if (sep == parentpath) + parentpath[1] = '\0'; /* root directory */ + else + *sep = '\0'; + + fsync_fname_ext(parentpath, true, true, elevel); + } +} + +/* + * AddPendingFileOp - Add a new pending file operation to the list + * + * All fields are deep-copied into TopMemoryContext to survive + * until transaction end, following the PendingRelDelete pattern. + */ +static void +AddPendingFileOp(PendingFileOpType type, const char *path, + const char *newpath, off_t length, bool at_commit) +{ + PendingFileOp *pending; + MemoryContext oldcxt; + + oldcxt = MemoryContextSwitchTo(TopMemoryContext); + + pending = (PendingFileOp *) palloc(sizeof(PendingFileOp)); + pending->type = type; + pending->path = pstrdup(path); + pending->newpath = newpath ? pstrdup(newpath) : NULL; + pending->length = length; + pending->data = NULL; + pending->data_len = 0; + pending->at_commit = at_commit; + pending->nestLevel = GetCurrentTransactionNestLevel(); + pending->next = pendingFileOps; + pendingFileOps = pending; + + MemoryContextSwitchTo(oldcxt); +} + +/* + * AddPendingFileOpWithData - Add a pending file operation with extra data + * + * Like AddPendingFileOp but also stores arbitrary data (e.g., original + * xattr value for restore on abort). + */ +static void +AddPendingFileOpWithData(PendingFileOpType type, const char *path, + const char *newpath, off_t length, + const void *data, size_t data_len, + bool at_commit) +{ + PendingFileOp *pending; + MemoryContext oldcxt; + + oldcxt = MemoryContextSwitchTo(TopMemoryContext); + + pending = (PendingFileOp *) palloc(sizeof(PendingFileOp)); + pending->type = type; + pending->path = pstrdup(path); + pending->newpath = newpath ? pstrdup(newpath) : NULL; + pending->length = length; + if (data && data_len > 0) + { + pending->data = palloc(data_len); + memcpy(pending->data, data, data_len); + pending->data_len = data_len; + } + else + { + pending->data = NULL; + pending->data_len = 0; + } + pending->at_commit = at_commit; + pending->nestLevel = GetCurrentTransactionNestLevel(); + pending->next = pendingFileOps; + pendingFileOps = pending; + + MemoryContextSwitchTo(oldcxt); +} + +/* + * FreePendingFileOp - Free a pending file operation entry + */ +static void +FreePendingFileOp(PendingFileOp *pending) +{ + if (pending->path) + pfree(pending->path); + if (pending->newpath) + pfree(pending->newpath); + if (pending->data) + pfree(pending->data); + pfree(pending); +} + +/* + * FileOpsCancelPendingDelete - Cancel a pending file deletion + * + * This removes matching DELETE entries from the pendingFileOps list. + * It is called by RelationPreserveStorage() to ensure that when a + * relation's storage is preserved (e.g., during index reuse in ALTER TABLE), + * the corresponding FileOps DELETE entry is also cancelled, preventing + * FileOpsDoPendingOps from deleting the file at commit time. + */ +void +FileOpsCancelPendingDelete(const char *path, bool at_commit) +{ + PendingFileOp *pending; + PendingFileOp *prev; + PendingFileOp *next; + + prev = NULL; + for (pending = pendingFileOps; pending != NULL; pending = next) + { + next = pending->next; + if (pending->type == PENDING_FILEOP_DELETE && + pending->at_commit == at_commit && + strcmp(pending->path, path) == 0) + { + /* unlink and free list entry */ + if (prev) + prev->next = next; + else + pendingFileOps = next; + FreePendingFileOp(pending); + /* prev does not change */ + } + else + { + prev = pending; + } + } +} + +/* + * FileOpsSync - Ensure a file's data is durably written to disk + * + * This is a convenience wrapper around fsync_fname() that uses the + * platform-appropriate sync mechanism: + * - Linux: fdatasync() (only flushes data, not metadata unless needed) + * - macOS: fcntl(F_FULLFSYNC) (flushes disk write cache) + * - FreeBSD: fsync() + * - Windows: FlushFileBuffers() + * + * An ERROR is raised if the sync fails. + */ +/* + * FileOpsCreate - Create a file within a transaction + * + * Creates the file immediately (so it can be used within the transaction) + * and logs the creation to WAL. If register_delete is true, the file will + * be deleted if the transaction aborts. + * + * Platform handling: + * - Linux/FreeBSD: O_DIRECT passed directly to open() + * - macOS: F_NOCACHE fcntl applied after open() + * - Windows: FILE_FLAG_NO_BUFFERING (handled by port layer) + * + * Returns the file descriptor on success, or -1 on failure. + */ +int +FileOpsCreate(const char *path, int flags, mode_t mode, bool register_delete) +{ + int fd; + bool need_wal = XLogIsNeeded(); + xl_fileops_create xlrec; + int pathlen = 0; + char undo_payload[sizeof(FileopsUndoCreate) + MAXPGPATH]; + XactUndoContext undo_ctx; + + Assert(!IsInParallelMode()); + + /* + * Write-ahead ordering: the WAL record (and UNDO record) must be durably + * on disk BEFORE the file is physically created. Once that is true, the + * create+fsync below is contractually obligated to happen -- a failure + * at that point would leave a durable record of a creation that never + * occurred, so it runs inside a critical section and any failure is + * promoted to PANIC by the elog machinery (see errfinish() in elog.c). + * This mirrors RelationTruncate() in catalog/storage.c and the (already + * correct) FileOpsTruncate() below. + */ + if (need_wal) + { + FileopsUndoCreate *hdr = (FileopsUndoCreate *) undo_payload; + + xlrec.flags = flags; + xlrec.mode = mode; + xlrec.register_delete = register_delete; + + pathlen = (int) strlen(path) + 1; + + /* + * The path length is stored in a uint16 (hdr->path_len) and the + * payload buffer is sized for MAXPGPATH. Paths are MAXPGPATH-bounded + * everywhere upstream, so this is defensive, but a filesystem-mutation + * WAL record must never silently truncate an over-long path into a + * uint16 -- fail loudly instead. + */ + if (pathlen > MAXPGPATH) + ereport(ERROR, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("file operation path length %d exceeds maximum %d", + pathlen, MAXPGPATH))); + + hdr->path_len = (uint16) pathlen; + memcpy(undo_payload + sizeof(FileopsUndoCreate), path, pathlen); + + /* + * PrepareXactUndoData() may palloc (first UNDO record of this + * persistence level in the transaction allocates an UndoRecordSet), + * so it must run before the critical section; palloc is forbidden + * inside one. + */ + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_CREATE, + InvalidOid, undo_payload, + sizeof(FileopsUndoCreate) + pathlen); + + /* Load the injection point cache entry before the critical section; + * looking it up can palloc, which is forbidden once we START_CRIT_SECTION. */ + INJECTION_POINT_LOAD("fileops-create-after-wal-flush"); + } + + if (need_wal) + START_CRIT_SECTION(); + + if (need_wal) + { + XLogRecPtr lsn; + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsCreate); + XLogRegisterData(path, pathlen); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_CREATE); + + InsertXactUndoData(&undo_ctx); + CleanupXactUndoInsertion(&undo_ctx); + + XLogFlush(lsn); + + INJECTION_POINT_CACHED("fileops-create-after-wal-flush", NULL); + } + + fd = OpenTransientFilePerm(path, flags | O_CREAT, mode); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", path))); + + if (enableFsync) + { + if (pg_fsync(fd) != 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", path))); + fileops_fsync_parent(path, data_sync_elevel(ERROR)); + } + + if (need_wal) + END_CRIT_SECTION(); + + if (register_delete) + AddPendingFileOp(PENDING_FILEOP_DELETE, path, NULL, 0, false); + + return fd; +} + +/* + * FileOpsDelete - Schedule a file deletion within a transaction + * + * The file is not deleted immediately. Instead, the deletion is deferred + * to transaction commit (if at_commit is true) or abort (if false). + * On Unix: unlink() with parent dir fsync. + * On Windows: pgunlink() with retry on EACCES. + */ +void +FileOpsDelete(const char *path, bool at_commit) +{ + Assert(!IsInParallelMode()); + + /* + * B3: no WAL is emitted at registration time. The deferred delete is + * WAL-logged in FileOpsDoPendingOps() at commit-execution time, when it + * actually happens on the primary, so a physical standby reproduces it + * via redo. Registration-time logging would be a no-op on redo (the + * standby never runs FileOpsDoPendingOps()) and, for at_commit=false + * delete-on-abort registrations, would wrongly apply an aborted + * transaction's effect on the standby. + */ + AddPendingFileOp(PENDING_FILEOP_DELETE, path, NULL, 0, at_commit); +} + +/* + * FileOpsRename - Rename a file within a transaction + * + * The rename is deferred to commit time. Uses durable_rename() internally + * which handles all platform differences: + * - Unix: rename() with fsync of both old and new parent dirs + * - Windows: MoveFileEx(MOVEFILE_REPLACE_EXISTING) with retry + */ +int +FileOpsRename(const char *oldpath, const char *newpath) +{ + Assert(!IsInParallelMode()); + + /* + * B3: WAL is emitted at commit-execution time in FileOpsDoPendingOps(), + * not at registration, so the standby reproduces the rename via redo. + */ + AddPendingFileOp(PENDING_FILEOP_RENAME, oldpath, newpath, 0, true); + + return 0; +} + +/* + * FileOpsWrite - Write data to a file at a specific offset + * + * Immediate execution, WAL-logged for crash recovery replay. + * Uses pwrite() on POSIX. On Windows: SetFilePointerEx + WriteFile. + * + * Returns 0 on success, -1 on failure. + */ +int +FileOpsWrite(const char *path, off_t offset, const void *data, uint32 len) +{ + int fd; + ssize_t nbytes; + bool need_wal = XLogIsNeeded(); + + Assert(!IsInParallelMode()); + + fd = OpenTransientFile(path, O_RDWR | PG_BINARY); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open file \"%s\" for writing: %m", path))); + + /* + * Write-ahead ordering: WAL must be durable before the physical write + * that it describes. There is no UNDO record here (unlike the other + * Category-A operations) because FileOpsWrite has no "original value" + * concept to restore on abort -- callers that need rollback protection + * pair it with FileOpsCreate's delete-on-abort or FileOpsTruncate. Once + * the WAL record is flushed, the write+fsync below is contractually + * obligated to happen; a failure at that point is escalated to PANIC by + * the surrounding critical section (see errfinish() in elog.c), matching + * FileOpsTruncate() below and RelationTruncate() in catalog/storage.c. + */ + if (need_wal) + { + xl_fileops_write xlrec; + int pathlen; + XLogRecPtr lsn; + + pathlen = (int) strlen(path) + 1; + + xlrec.offset = offset; + xlrec.len = len; + xlrec.path_len = (uint16) pathlen; + + START_CRIT_SECTION(); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsWrite); + XLogRegisterData(path, pathlen); + XLogRegisterData(data, len); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_WRITE); + + XLogFlush(lsn); + } + + nbytes = pg_pwrite(fd, data, len, offset); + if (nbytes != (ssize_t) len) + { + int save_errno = errno; + + CloseTransientFile(fd); + errno = save_errno; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write %u bytes to file \"%s\" at offset %lld: %m", + len, path, (long long) offset))); + } + + if (enableFsync && pg_fsync(fd) != 0) + { + int save_errno = errno; + + CloseTransientFile(fd); + errno = save_errno; + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\" after write: %m", path))); + } + + if (need_wal) + END_CRIT_SECTION(); + + if (CloseTransientFile(fd) != 0) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not close file \"%s\": %m", path))); + + return 0; +} + +/* + * FileOpsTruncate - Truncate a file within a transaction + * + * Executed immediately and WAL-logged. Uses ftruncate() on POSIX, + * SetEndOfFile() on Windows. File is fsynced after truncation. + */ +void +FileOpsTruncate(const char *path, off_t length) +{ + int fd; + struct stat st; + off_t orig_length = 0; + bool need_wal = XLogIsNeeded(); + + Assert(!IsInParallelMode()); + + /* Capture original length for UNDO before truncation */ + if (stat(path, &st) == 0) + orig_length = st.st_size; + + if (need_wal) + { + xl_fileops_truncate xlrec; + int pathlen; + XactUndoContext undo_ctx; + char payload[sizeof(FileopsUndoTruncate) + MAXPGPATH]; + FileopsUndoTruncate *hdr = (FileopsUndoTruncate *) payload; + XLogRecPtr lsn; + + xlrec.length = length; + pathlen = (int) strlen(path) + 1; + + hdr->orig_length = orig_length; + hdr->path_len = (uint16) pathlen; + memcpy(payload + sizeof(FileopsUndoTruncate), path, pathlen); + + /* + * PrepareXactUndoData() may palloc, so it must happen before the + * critical section below (palloc is forbidden inside one). + */ + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_TRUNCATE, + InvalidOid, payload, + sizeof(FileopsUndoTruncate) + pathlen); + + /* + * Once the WAL/UNDO record is durably flushed below, the physical + * ftruncate()+fsync() is contractually obligated to happen: a + * durably-recorded truncation that never occurs on disk would leave + * recovery/rollback with nothing to reconcile against. Wrap the + * whole WAL-insert-through-syscall sequence in a critical section so + * that any failure past this point is promoted to PANIC by the elog + * machinery (errfinish() in elog.c), instead of silently diverging + * via an ordinary transaction abort. This retrofits the same + * protection RelationTruncate() already has in catalog/storage.c, + * which this function was previously missing. + */ + START_CRIT_SECTION(); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsTruncate); + XLogRegisterData(path, pathlen); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_TRUNCATE); + + InsertXactUndoData(&undo_ctx); + CleanupXactUndoInsertion(&undo_ctx); + + /* + * Flush the redo and UNDO records to durable storage before mutating + * the file below. ftruncate()+pg_fsync() makes the physical change + * durable immediately; if we crashed after that but before the WAL + * reached disk, recovery would have no record of the operation and no + * UNDO batch to reverse it on rollback. This mirrors the write-ahead + * ordering in RelationTruncate() (see catalog/storage.c). Flush the + * LSN XLogInsert() itself returned, not a separately-fetched "current + * insert position" -- under concurrency the latter can point into the + * middle of a record another backend has reserved but not finished + * copying, which makes XLogFlush() fail with "xlog flush request is + * not satisfied". + */ + XLogFlush(lsn); + } + + fd = OpenTransientFile(path, O_RDWR | PG_BINARY); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open file \"%s\" for truncation: %m", path))); + + if (ftruncate(fd, length) < 0) + { + int save_errno = errno; + + CloseTransientFile(fd); + errno = save_errno; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not truncate file \"%s\" to %lld bytes: %m", + path, (long long) length))); + } + + if (enableFsync && pg_fsync(fd) != 0) + { + int save_errno = errno; + + CloseTransientFile(fd); + errno = save_errno; + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\" after truncation: %m", + path))); + } + + if (need_wal) + END_CRIT_SECTION(); + + if (CloseTransientFile(fd) != 0) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not close file \"%s\": %m", path))); + + /* + * Register abort-time pending op to restore original size on rollback. + * This provides immediate rollback without waiting for the background + * UNDO worker (which is deferred due to BumpContext limitations). + */ + AddPendingFileOp(PENDING_FILEOP_TRUNCATE, path, NULL, orig_length, false); +} + +/* + * FileOpsChmod - Change file permissions within a transaction + * + * Immediate execution, WAL-logged. + * On POSIX: chmod(). On Windows: _chmod() with limited mode bits + * (only _S_IREAD/_S_IWRITE; no group/other). Logs WARNING for + * unsupported mode bits on Windows. + * + * Returns 0 on success, -1 on failure with errno left set so the caller + * can distinguish ENOENT from other errors. No WAL, UNDO record, or + * pending op is registered on the failure path because the on-disk mode + * was not changed. + * + * Design note (write-ordering vs. tolerable-failure contract): WAL/UNDO + * must be durable before chmod() runs, but tablespace.c's caller relies on + * getting a plain -1/ENOENT back (not a crash) when the target directory + * does not exist -- a real caller-observed case, not a hypothetical one. + * We resolve the conflict by reusing the stat() this function already + * needs (to capture the original mode for UNDO) as a preflight: if the + * path cannot be stat()ed, chmod() would fail the same way, so we return + * -1 with stat()'s errno before writing anything to WAL. This is not a + * "trial run" of the mutating syscall itself (no double-mutation hazard), + * and existence does not flip in the narrow window between this stat() + * and the chmod() below for paths FileOps callers own/manage. Once that + * preflight passes, the WAL/UNDO record becomes durable and chmod() is + * contractually obligated to happen: any failure past that point (a + * genuine race, read-only remount, immutable-flag, etc.) is escalated to + * PANIC by the surrounding critical section, per errfinish() in elog.c. + */ +int +FileOpsChmod(const char *path, mode_t mode) +{ + struct stat st; + mode_t orig_mode; + bool need_wal; + + Assert(!IsInParallelMode()); + + /* Capture original mode for UNDO before chmod, and preflight ENOENT */ + if (stat(path, &st) != 0) + return -1; /* leave errno set by stat() for the caller */ + orig_mode = st.st_mode & 07777; + + need_wal = XLogIsNeeded(); + + if (need_wal) + { + xl_fileops_chmod xlrec; + int pathlen; + XactUndoContext undo_ctx; + char payload[sizeof(FileopsUndoChmod) + MAXPGPATH]; + FileopsUndoChmod *hdr = (FileopsUndoChmod *) payload; + XLogRecPtr lsn; + + xlrec.mode = mode; + pathlen = (int) strlen(path) + 1; + + hdr->orig_mode = orig_mode; + hdr->path_len = (uint16) pathlen; + memcpy(payload + sizeof(FileopsUndoChmod), path, pathlen); + + /* PrepareXactUndoData() may palloc; must run before the crit section */ + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_CHMOD, + InvalidOid, payload, + sizeof(FileopsUndoChmod) + pathlen); + + /* Load the injection point cache entry before the critical section; + * looking it up can palloc, which is forbidden once we START_CRIT_SECTION. */ + INJECTION_POINT_LOAD("fileops-chmod-after-wal-flush"); + + START_CRIT_SECTION(); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsChmod); + XLogRegisterData(path, pathlen); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_CHMOD); + + InsertXactUndoData(&undo_ctx); + CleanupXactUndoInsertion(&undo_ctx); + + XLogFlush(lsn); + + INJECTION_POINT_CACHED("fileops-chmod-after-wal-flush", NULL); + } + + if (chmod(path, mode) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not chmod file \"%s\": %m", path))); + + if (need_wal) + END_CRIT_SECTION(); + + /* + * Register abort-time pending op to restore original mode on rollback. + * Store mode in the length field (mode_t fits in off_t). + */ + AddPendingFileOp(PENDING_FILEOP_CHMOD, path, NULL, (off_t) orig_mode, false); + + return 0; +} + +/* + * FileOpsChown - Change file ownership within a transaction + * + * Immediate execution, WAL-logged. + * On POSIX: chown(). On Windows: no-op with WARNING (Windows uses + * ACLs for ownership, not uid/gid). + * + * Returns 0 on success. + */ +int +FileOpsChown(const char *path, uid_t uid, gid_t gid) +{ + struct stat st; + uid_t orig_uid = (uid_t) -1; + gid_t orig_gid = (gid_t) -1; + bool need_wal = XLogIsNeeded(); + + Assert(!IsInParallelMode()); + + /* Capture original ownership for UNDO before chown */ + if (stat(path, &st) == 0) + { + orig_uid = st.st_uid; + orig_gid = st.st_gid; + } + + /* + * Write-ahead ordering: WAL/UNDO must be durable before the physical + * chown(). Once durable, chown() is contractually obligated to happen; + * a failure past that point is escalated to PANIC by the surrounding + * critical section (see errfinish() in elog.c), matching FileOpsTruncate() + * above and RelationTruncate() in catalog/storage.c. + */ + if (need_wal) + { + xl_fileops_chown xlrec; + int pathlen; + XactUndoContext undo_ctx; + char payload[sizeof(FileopsUndoChown) + MAXPGPATH]; + FileopsUndoChown *hdr = (FileopsUndoChown *) payload; + XLogRecPtr lsn; + + xlrec.uid = uid; + xlrec.gid = gid; + pathlen = (int) strlen(path) + 1; + + hdr->orig_uid = orig_uid; + hdr->orig_gid = orig_gid; + hdr->path_len = (uint16) pathlen; + memcpy(payload + sizeof(FileopsUndoChown), path, pathlen); + + /* PrepareXactUndoData() may palloc; must run before the crit section */ + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_CHOWN, + InvalidOid, payload, + sizeof(FileopsUndoChown) + pathlen); + + START_CRIT_SECTION(); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsChown); + XLogRegisterData(path, pathlen); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_CHOWN); + + InsertXactUndoData(&undo_ctx); + CleanupXactUndoInsertion(&undo_ctx); + + XLogFlush(lsn); + } + +#ifndef WIN32 + if (chown(path, uid, gid) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not chown file \"%s\" to %d:%d: %m", + path, (int) uid, (int) gid))); +#else + ereport(WARNING, + (errmsg("chown is not supported on Windows, skipping for \"%s\"", + path))); +#endif + + if (need_wal) + END_CRIT_SECTION(); + + return 0; +} + +void +FileOpsSync(const char *path) +{ + fsync_fname(path, false); +} + +/* + * FileOpsMkdir - Create a directory within a transaction + * + * Immediate execution. Optionally registers rmdir-on-abort. + * Uses MakePGDirectory() pattern (mkdir with pg_dir_create_mode). + * On Windows: _mkdir() (no mode parameter, permissions from parent). + * + * Returns 0 on success. + */ +int +FileOpsMkdir(const char *path, mode_t mode) +{ + bool need_wal = XLogIsNeeded(); + + Assert(!IsInParallelMode()); + + /* + * Write-ahead ordering: WAL/UNDO must be durable before the physical + * mkdir()+parent-dir-fsync. Once durable, the mkdir is contractually + * obligated to happen; a failure past that point is escalated to PANIC + * by the surrounding critical section (see errfinish() in elog.c), + * matching FileOpsTruncate() above and RelationTruncate() in + * catalog/storage.c. + */ + if (need_wal) + { + xl_fileops_mkdir xlrec; + int pathlen; + XactUndoContext undo_ctx; + char payload[sizeof(FileopsUndoMkdir) + MAXPGPATH]; + FileopsUndoMkdir *hdr = (FileopsUndoMkdir *) payload; + XLogRecPtr lsn; + + xlrec.mode = mode; + pathlen = (int) strlen(path) + 1; + + hdr->path_len = (uint16) pathlen; + memcpy(payload + sizeof(FileopsUndoMkdir), path, pathlen); + + /* PrepareXactUndoData() may palloc; must run before the crit section */ + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_MKDIR, + InvalidOid, payload, + sizeof(FileopsUndoMkdir) + pathlen); + + START_CRIT_SECTION(); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsMkdir); + XLogRegisterData(path, pathlen); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_MKDIR); + + InsertXactUndoData(&undo_ctx); + CleanupXactUndoInsertion(&undo_ctx); + + XLogFlush(lsn); + } + + if (MakePGDirectory(path) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", path))); + + if (enableFsync) + fileops_fsync_parent(path, data_sync_elevel(ERROR)); + + if (need_wal) + END_CRIT_SECTION(); + + /* Register rmdir-on-abort so directory is cleaned up on rollback */ + AddPendingFileOp(PENDING_FILEOP_RMDIR, path, NULL, 0, false); + + return 0; +} + +/* + * FileOpsRmdir - Remove a directory within a transaction + * + * Deferred to commit time (like DELETE). Uses rmdir() on all platforms. + * On Windows: _rmdir(). + */ +void +FileOpsRmdir(const char *path, bool at_commit) +{ + Assert(!IsInParallelMode()); + + /* + * B3: WAL is emitted at commit-execution time in FileOpsDoPendingOps(), + * not at registration, so the standby reproduces the rmdir via redo. + */ + AddPendingFileOp(PENDING_FILEOP_RMDIR, path, NULL, 0, at_commit); +} + +/* + * FileOpsRmtree - Schedule a recursive directory removal at commit/abort time. + * + * Unlike FileOpsRmdirRecursive (which enumerates the tree at registration + * time and queues per-entry FILEOPS_DELETE/RMDIR ops), this enqueues a + * single PENDING_FILEOP_RMTREE that defers the tree walk to execute time. + * That is the right semantic for callers that register the destination of + * an in-progress copy: the directory is empty (or nonexistent) when the + * registration happens, but contains files by the time of abort. + * + * No WAL is written because rmtree is not a directly recoverable + * operation; the abort case relies on the surrounding DDL having already + * recorded enough state to make the partial tree disposable. + */ +void +FileOpsRmtree(const char *path, bool at_commit) +{ + Assert(!IsInParallelMode()); + AddPendingFileOp(PENDING_FILEOP_RMTREE, path, NULL, 0, at_commit); +} + +/* + * FileOpsRmdirRecursive - Schedule recursive directory removal. + * + * Enumerates directory tree and schedules: + * - FileOpsDelete() for each file (at_commit) + * - FileOpsRmdir() for each subdirectory (at_commit, leaf-first order) + * + * Permissive: ENOENT is logged but not an error. + */ +void +FileOpsRmdirRecursive(const char *path, bool at_commit) +{ + DIR *dir; + struct dirent *de; + + dir = AllocateDir(path); + if (dir == NULL) + { + if (errno == ENOENT) + { + elog(LOG, "FileOpsRmdirRecursive: directory \"%s\" does not exist", + path); + return; + } + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open directory \"%s\": %m", path))); + } + + while ((de = ReadDirExtended(dir, path, LOG)) != NULL) + { + char subpath[MAXPGPATH]; + struct stat st; + + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) + continue; + + snprintf(subpath, MAXPGPATH, "%s/%s", path, de->d_name); + if (lstat(subpath, &st) == 0) + { + if (S_ISDIR(st.st_mode)) + FileOpsRmdirRecursive(subpath, at_commit); + else + FileOpsDelete(subpath, at_commit); + } + } + FreeDir(dir); + FileOpsRmdir(path, at_commit); +} + +/* + * FileOpsSymlink - Create a symbolic link within a transaction + * + * Immediate execution. Registers delete-on-abort for cleanup. + * On POSIX: symlink(). On Windows: pgsymlink() which creates NTFS + * junction points via DeviceIoControl(). Note: junction points only + * work for directories on Windows. + * + * Returns 0 on success. + */ +int +FileOpsSymlink(const char *target, const char *linkpath) +{ + bool need_wal = XLogIsNeeded(); + + Assert(!IsInParallelMode()); + + /* + * Write-ahead ordering: WAL/UNDO must be durable before the physical + * symlink()+parent-dir-fsync. Once durable, the symlink is + * contractually obligated to happen; a failure past that point is + * escalated to PANIC by the surrounding critical section (see + * errfinish() in elog.c), matching FileOpsTruncate() above and + * RelationTruncate() in catalog/storage.c. + */ + if (need_wal) + { + xl_fileops_symlink xlrec; + int targetlen; + int linkpathlen; + XactUndoContext undo_ctx; + char payload[sizeof(FileopsUndoSymlink) + MAXPGPATH]; + FileopsUndoSymlink *hdr = (FileopsUndoSymlink *) payload; + XLogRecPtr lsn; + + targetlen = (int) strlen(target) + 1; + linkpathlen = (int) strlen(linkpath) + 1; + xlrec.target_len = (uint16) targetlen; + + hdr->linkpath_len = (uint16) linkpathlen; + memcpy(payload + sizeof(FileopsUndoSymlink), linkpath, linkpathlen); + + /* PrepareXactUndoData() may palloc; must run before the crit section */ + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_SYMLINK, + InvalidOid, payload, + sizeof(FileopsUndoSymlink) + linkpathlen); + + START_CRIT_SECTION(); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsSymlink); + XLogRegisterData(target, targetlen); + XLogRegisterData(linkpath, linkpathlen); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_SYMLINK); + + InsertXactUndoData(&undo_ctx); + CleanupXactUndoInsertion(&undo_ctx); + + XLogFlush(lsn); + } + + if (symlink(target, linkpath) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create symbolic link \"%s\" -> \"%s\": %m", + linkpath, target))); + + if (enableFsync) + fileops_fsync_parent(linkpath, data_sync_elevel(ERROR)); + + if (need_wal) + END_CRIT_SECTION(); + + /* Register delete-on-abort to clean up the symlink on rollback */ + AddPendingFileOp(PENDING_FILEOP_DELETE, linkpath, NULL, 0, false); + + return 0; +} + +/* + * FileOpsLink - Create a hard link within a transaction + * + * Immediate execution. Registers delete-on-abort for cleanup. + * On POSIX: link(). On Windows: CreateHardLinkA() (NTFS only). + * + * Returns 0 on success. + */ +int +FileOpsLink(const char *oldpath, const char *newpath) +{ + bool need_wal = XLogIsNeeded(); + + Assert(!IsInParallelMode()); + + /* + * Write-ahead ordering: WAL/UNDO must be durable before the physical + * link()+parent-dir-fsync. Once durable, the link is contractually + * obligated to happen; a failure past that point is escalated to PANIC + * by the surrounding critical section (see errfinish() in elog.c), + * matching FileOpsTruncate() above and RelationTruncate() in + * catalog/storage.c. + */ + if (need_wal) + { + xl_fileops_link xlrec; + int oldpathlen; + int newpathlen; + XactUndoContext undo_ctx; + char payload[sizeof(FileopsUndoLink) + MAXPGPATH]; + FileopsUndoLink *hdr = (FileopsUndoLink *) payload; + XLogRecPtr lsn; + + oldpathlen = (int) strlen(oldpath) + 1; + newpathlen = (int) strlen(newpath) + 1; + xlrec.oldpath_len = (uint16) oldpathlen; + + hdr->newpath_len = (uint16) newpathlen; + memcpy(payload + sizeof(FileopsUndoLink), newpath, newpathlen); + + /* PrepareXactUndoData() may palloc; must run before the crit section */ + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_LINK, + InvalidOid, payload, + sizeof(FileopsUndoLink) + newpathlen); + + START_CRIT_SECTION(); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsLink); + XLogRegisterData(oldpath, oldpathlen); + XLogRegisterData(newpath, newpathlen); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_LINK); + + InsertXactUndoData(&undo_ctx); + CleanupXactUndoInsertion(&undo_ctx); + + XLogFlush(lsn); + } + + if (link(oldpath, newpath) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create hard link \"%s\" -> \"%s\": %m", + newpath, oldpath))); + + if (enableFsync) + fileops_fsync_parent(newpath, data_sync_elevel(ERROR)); + + if (need_wal) + END_CRIT_SECTION(); + + /* Register delete-on-abort to clean up the link on rollback */ + AddPendingFileOp(PENDING_FILEOP_DELETE, newpath, NULL, 0, false); + + return 0; +} + +/* + * FileOpsSetXattr - Set an extended attribute on a file + * + * Immediate execution, WAL-logged. + * Uses pg_setxattr() portability layer which handles: + * - Linux: setxattr() + * - macOS: setxattr() with extra options parameter + * - FreeBSD: extattr_set_file() + * - Windows: NTFS Alternate Data Streams + * - Other: returns ENOTSUP + * + * Returns 0 on success, -1 if extended attributes are not supported on + * this filesystem/platform (errno left as ENOTSUP/EOPNOTSUPP). + * + * Design note (write-ordering vs. tolerable-failure conflict): the analysis + * that preceded this fix flagged that WAL-before-syscall conflicts with the + * old "return -1 on ENOTSUP/EOPNOTSUPP/EPERM/EACCES" contract, since a + * durable WAL/UNDO record asserting a xattr change that never happened is + * exactly the bug being fixed. We resolve it by splitting the old tolerable + * set into two buckets: + * + * - ENOTSUP/EOPNOTSUPP ("this filesystem doesn't implement xattrs at + * all") is a whole-filesystem capability question, not a per-call race: + * it cannot flip between a preflight check and the real syscall a few + * instructions later. The pg_getxattr() probe below (already needed to + * capture the original value for UNDO) surfaces this same errno for the + * same reason before any WAL is written, so we check it there and + * return -1 pre-WAL -- no new syscall, no trial run of the mutating + * operation itself. + * + * - EPERM/EACCES ("security policy denies this specific write") is a + * genuine permission decision that CAN legitimately differ between a + * read probe and a write attempt (e.g. SELinux, immutable flag), so it + * cannot be preflighted without attempting the mutating syscall itself + * (which the prior analysis correctly flagged as fragile/racy and not + * attempted here). grep of the tree found zero production callers of + * FileOpsSetXattr; the only consumer (test_fileops) exists to probe + * whole-filesystem xattr support, which the ENOTSUP/EOPNOTSUPP preflight + * above already covers. We therefore treat a post-WAL EPERM/EACCES the + * same as any other post-WAL syscall failure: it escalates to PANIC via + * the surrounding critical section (errfinish() in elog.c), because a + * durable WAL/UNDO record with no corresponding physical change is the + * load-bearing invariant this whole fix protects. If a real caller ever + * needs graceful EPERM/EACCES handling, that is a signal this decision + * needs revisiting (see FILEOPS-ANALYSIS-UNIMPLEMENTED.md point 3). + */ +int +FileOpsSetXattr(const char *path, const char *name, + const void *value, size_t len) +{ + bool had_value = false; + char *orig_value = NULL; + ssize_t orig_value_len = 0; + bool need_wal; + struct stat st; + + Assert(!IsInParallelMode()); + + /* + * Preflight existence check: FileOpsSetXattr on a nonexistent path is a + * caller error (ENOENT was never in the tolerable set below), and must be + * caught here, before any WAL is written -- otherwise it would be caught + * by the mutating syscall itself AFTER WAL is durable, escalating an + * ordinary caller mistake to PANIC. + */ + if (stat(path, &st) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not stat file \"%s\": %m", path))); + + /* + * Capture existing xattr value before overwriting. This is needed by the + * abort-time pending op (clean rollback) regardless of wal_level, so it + * must run unconditionally -- not gated on XLogIsNeeded() like the WAL/UNDO + * emission below. If the attribute doesn't exist, had_value stays false. + * + * This call doubles as the ENOTSUP/EOPNOTSUPP capability preflight + * described above: on every supported platform, a filesystem/inode that + * doesn't implement extended attributes fails an attribute-value probe + * the same way it would fail a set, regardless of whether the named + * attribute happens to exist. + */ + { + ssize_t vlen; + + vlen = pg_getxattr(path, name, NULL, 0); + if (vlen > 0) + { + orig_value = (char *) palloc(vlen); + orig_value_len = pg_getxattr(path, name, orig_value, vlen); + if (orig_value_len >= 0) + had_value = true; + else + { + pfree(orig_value); + orig_value = NULL; + orig_value_len = 0; + } + } + else if (vlen == 0) + { + /* Attribute exists but has zero-length value */ + had_value = true; + } + else if (errno == ENOTSUP || errno == EOPNOTSUPP) + { + /* Whole-filesystem capability failure; safe to preflight (see above) */ + return -1; + } + /* else: ENODATA/PG_ENOATTR (attribute absent) or other; had_value=false */ + } + + need_wal = XLogIsNeeded(); + + /* + * Write-ahead ordering: WAL/UNDO must be durable before the physical + * setxattr(). Once durable, setxattr() is contractually obligated to + * succeed; any failure past this point (including EPERM/EACCES, per the + * design note above) is escalated to PANIC by the surrounding critical + * section. + */ + if (need_wal) + { + xl_fileops_setxattr xlrec; + int pathlen; + int namelen; + XactUndoContext undo_ctx; + Size payload_len; + char *payload; + FileopsUndoSetxattr *hdr; + XLogRecPtr lsn; + + pathlen = (int) strlen(path) + 1; + namelen = (int) strlen(name) + 1; + + xlrec.path_len = (uint16) pathlen; + xlrec.name_len = (uint16) namelen; + xlrec.value_len = (uint32) len; + + payload_len = sizeof(FileopsUndoSetxattr) + pathlen + namelen + + (had_value ? orig_value_len : 0); + /* PrepareXactUndoData() may palloc; this palloc must run before the + * crit section too. */ + payload = (char *) palloc(payload_len); + hdr = (FileopsUndoSetxattr *) payload; + + hdr->path_len = (uint16) pathlen; + hdr->name_len = (uint16) namelen; + hdr->orig_value_len = (uint32) (had_value ? orig_value_len : 0); + hdr->had_value = had_value; + + memcpy(payload + sizeof(FileopsUndoSetxattr), path, pathlen); + memcpy(payload + sizeof(FileopsUndoSetxattr) + pathlen, name, namelen); + if (had_value && orig_value_len > 0) + memcpy(payload + sizeof(FileopsUndoSetxattr) + pathlen + namelen, + orig_value, orig_value_len); + + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_SETXATTR, + InvalidOid, payload, payload_len); + + START_CRIT_SECTION(); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsSetxattr); + XLogRegisterData(path, pathlen); + XLogRegisterData(name, namelen); + XLogRegisterData(value, (uint32) len); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_SETXATTR); + + InsertXactUndoData(&undo_ctx); + CleanupXactUndoInsertion(&undo_ctx); + pfree(payload); + + XLogFlush(lsn); + } + + if (pg_setxattr(path, name, value, len) < 0) + { + if (orig_value) + pfree(orig_value); + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not set extended attribute \"%s\" on \"%s\": %m", + name, path))); + } + + if (need_wal) + END_CRIT_SECTION(); + + /* + * Register abort-time pending op to restore/remove xattr on rollback. If + * had_value: restore original value. If !had_value: remove the xattr. + */ + if (had_value) + AddPendingFileOpWithData(PENDING_FILEOP_SETXATTR, path, name, + 0, orig_value, (size_t) orig_value_len, false); + else + AddPendingFileOp(PENDING_FILEOP_REMOVEXATTR, path, name, 0, false); + + if (orig_value) + pfree(orig_value); + + return 0; +} + +/* + * FileOpsRemoveXattr - Remove an extended attribute from a file + * + * Immediate execution, WAL-logged. + * Uses pg_removexattr() portability layer. + * + * Returns 0 on success, -1 if extended attributes are not supported on + * this filesystem/platform (errno left as ENOTSUP/EOPNOTSUPP). + * + * Design note: see FileOpsSetXattr's design note above for the general + * reasoning (write-ahead ordering vs. the old tolerable-failure contract). + * REMOVEXATTR's tolerable set is narrower than SETXATTR's: only + * ENOTSUP/EOPNOTSUPP (whole-filesystem incapability, preflighted the same + * way, via the pg_getxattr() probe already needed to save the original + * value for UNDO) and "attribute does not exist" (PG_ENOATTR/ENODATA). + * The latter is not a permission decision like SETXATTR's EPERM/EACCES -- + * it is symmetric between a read probe and the write attempt (if + * pg_getxattr() just reported the attribute absent, pg_removexattr() will + * fail the same way for the same reason microseconds later), and it is + * information we already captured for the UNDO decision, not a new trial + * run of the mutating syscall. We therefore tolerate exactly that one + * errno post-WAL, inside the critical section, and PANIC on anything else, + * matching fileops_redo()'s own existing tolerance for this same errno. + */ +int +FileOpsRemoveXattr(const char *path, const char *name) +{ + char *saved_value = NULL; + ssize_t saved_value_len = 0; + bool need_wal; + + Assert(!IsInParallelMode()); + + /* + * Capture existing xattr value before removal. This is needed by the + * abort-time pending op (clean rollback) regardless of wal_level, so it + * must run unconditionally -- not gated on XLogIsNeeded() like the WAL/UNDO + * emission below. We need it to restore the attribute on rollback. It + * also doubles as the ENOTSUP/EOPNOTSUPP capability preflight (see design + * note above): a filesystem/inode without xattr support fails this probe + * the same way it would fail a removal. + */ + { + ssize_t vlen; + + vlen = pg_getxattr(path, name, NULL, 0); + if (vlen > 0) + { + saved_value = (char *) palloc(vlen); + saved_value_len = pg_getxattr(path, name, saved_value, vlen); + if (saved_value_len < 0) + { + pfree(saved_value); + saved_value = NULL; + saved_value_len = 0; + } + } + else if (vlen == 0) + { + /* Attribute exists but has zero-length value */ + saved_value_len = 0; + saved_value = (char *) palloc(1); /* non-NULL marker */ + } + else if (errno == ENOTSUP || errno == EOPNOTSUPP) + { + /* Whole-filesystem capability failure; safe to preflight (see above) */ + return -1; + } + /* else: ENODATA/PG_ENOATTR (attribute absent) or other; saved_value=NULL */ + } + + need_wal = XLogIsNeeded(); + + /* + * Write-ahead ordering: WAL (and UNDO, if there was a value to restore) + * must be durable before the physical removal. Once durable, + * pg_removexattr() is contractually obligated to either succeed or fail + * with the already-anticipated PG_ENOATTR (see design note); any other + * failure escalates to PANIC via the surrounding critical section. + */ + if (need_wal) + { + xl_fileops_removexattr xlrec; + int pathlen; + int namelen; + XactUndoContext undo_ctx; + Size payload_len = 0; + char *payload = NULL; + XLogRecPtr lsn; + + pathlen = (int) strlen(path) + 1; + namelen = (int) strlen(name) + 1; + + xlrec.path_len = (uint16) pathlen; + xlrec.name_len = (uint16) namelen; + + if (saved_value != NULL) + { + FileopsUndoRemovexattr *hdr; + + payload_len = sizeof(FileopsUndoRemovexattr) + pathlen + namelen + + saved_value_len; + /* PrepareXactUndoData() may palloc; must run before the crit + * section, so this palloc does too. */ + payload = (char *) palloc(payload_len); + hdr = (FileopsUndoRemovexattr *) payload; + + hdr->path_len = (uint16) pathlen; + hdr->name_len = (uint16) namelen; + hdr->value_len = (uint32) saved_value_len; + + memcpy(payload + sizeof(FileopsUndoRemovexattr), path, pathlen); + memcpy(payload + sizeof(FileopsUndoRemovexattr) + pathlen, + name, namelen); + if (saved_value_len > 0) + memcpy(payload + sizeof(FileopsUndoRemovexattr) + pathlen + namelen, + saved_value, saved_value_len); + + PrepareXactUndoData(&undo_ctx, RELPERSISTENCE_PERMANENT, + UNDO_RMID_FILEOPS, FILEOPS_UNDO_REMOVEXATTR, + InvalidOid, payload, payload_len); + } + + START_CRIT_SECTION(); + + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsRemovexattr); + XLogRegisterData(path, pathlen); + XLogRegisterData(name, namelen); + lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_REMOVEXATTR); + + if (saved_value != NULL) + { + InsertXactUndoData(&undo_ctx); + CleanupXactUndoInsertion(&undo_ctx); + pfree(payload); + } + + XLogFlush(lsn); + } + + if (pg_removexattr(path, name) < 0 && errno != PG_ENOATTR) + { + if (saved_value) + pfree(saved_value); + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not remove extended attribute \"%s\" from \"%s\": %m", + name, path))); + } + + if (need_wal) + END_CRIT_SECTION(); + + /* + * Register abort-time pending op to restore the removed xattr on + * rollback. Use SETXATTR type: on abort, set the xattr back to its saved + * value. + */ + if (saved_value != NULL) + AddPendingFileOpWithData(PENDING_FILEOP_SETXATTR, path, name, + 0, saved_value, (size_t) saved_value_len, false); + + if (saved_value) + pfree(saved_value); + + return 0; +} + +/* + * FileOpsDoPendingOps - Execute pending file operations at transaction end + * + * At commit, operations with at_commit=true are executed. + * At abort, operations with at_commit=false are executed. + * + * This is called from xact.c at transaction commit/abort, analogous + * to smgrDoPendingDeletes(). + */ +void +FileOpsDoPendingOps(bool isCommit) +{ + int nestLevel = GetCurrentTransactionNestLevel(); + PendingFileOp *pending; + PendingFileOp *prev; + PendingFileOp *next; + PendingFileOp *exec_head = NULL; + PendingFileOp *exec_tail = NULL; + XLogRecPtr max_lsn = InvalidXLogRecPtr; + + /* + * Pass 1: detach the entries belonging to this (or an inner) nesting + * level from the pending list. Those matching the transaction outcome + * are queued for execution; the rest are discarded. Outer-level entries + * stay on the list for a later call. The queue is built in pending-list + * order, which is newest-first because AddPendingFileOp prepends. + */ + prev = NULL; + for (pending = pendingFileOps; pending != NULL; pending = next) + { + next = pending->next; + + if (pending->nestLevel < nestLevel) + { + /* outer-level entries should not be processed yet */ + prev = pending; + continue; + } + + /* unlink from list first, so we don't retry on failure */ + if (prev) + prev->next = next; + else + pendingFileOps = next; + + if (pending->at_commit == isCommit) + { + /* queue for execution, preserving pending-list (newest-first) order */ + pending->next = NULL; + if (exec_tail != NULL) + exec_tail->next = pending; + else + exec_head = pending; + exec_tail = pending; + } + else + FreePendingFileOp(pending); + } + + /* + * Commit-time ops must run in registration order (oldest-first) so that + * nested directory removals proceed deepest-first. The pending list is + * LIFO, so the queue collected above is newest-first; reverse it for + * commit. Abort-time ops must keep LIFO order, which the collected queue + * already preserves, so they are left as-is. + */ + if (isCommit) + { + PendingFileOp *rev = NULL; + + while (exec_head != NULL) + { + next = exec_head->next; + exec_head->next = rev; + rev = exec_head; + exec_head = next; + } + exec_head = rev; + } + + /* Pass 2: execute the queued ops in order, freeing each when done. */ + for (pending = exec_head; pending != NULL; pending = next) + { + next = pending->next; + + { + switch (pending->type) + { + case PENDING_FILEOP_CREATE: + /* Creates are executed immediately, nothing to do */ + break; + + case PENDING_FILEOP_RENAME: + /* + * B3: log-at-commit-execution. Emit the WAL record now, + * when the rename actually happens on the primary, so a + * physical standby reproduces it via redo (which is now a + * real, idempotent handler). WAL-before-op preserves + * write-ahead ordering; the commit record that follows + * orders this after the transaction's other WAL. Only + * commit-time execution is logged: an abort never reaches + * a PENDING_FILEOP_RENAME (rename is always at_commit). + */ + if (isCommit && XLogIsNeeded()) + { + xl_fileops_rename xlrec; + int oldpathlen = (int) strlen(pending->path) + 1; + int newpathlen = (int) strlen(pending->newpath) + 1; + + xlrec.oldpath_len = (uint16) oldpathlen; + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsRename); + XLogRegisterData(pending->path, oldpathlen); + XLogRegisterData(pending->newpath, newpathlen); + max_lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_RENAME); + + /* + * WAL-before-mutation durability: flush this record + * BEFORE touching the filesystem. A crash after the + * rename but before a batched flush would mutate the + * primary's FS without the record ever reaching the + * standby, diverging the two. Mirrors the create/write + * paths that flush inside their crit section before + * mutating. + */ + XLogFlush(max_lsn); + } + (void) durable_rename(pending->path, pending->newpath, + WARNING); + break; + + case PENDING_FILEOP_DELETE: + /* + * B3: log-at-commit-execution, but ONLY for the commit + * path. A delete-on-abort registration (at_commit=false) + * executes here during abort; that must NOT be WAL-logged, + * because a standby must never apply an aborted + * transaction's filesystem effect. + */ + if (isCommit && XLogIsNeeded()) + { + xl_fileops_delete xlrec; + int pathlen = (int) strlen(pending->path) + 1; + + xlrec.at_commit = true; + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsDelete); + XLogRegisterData(pending->path, pathlen); + max_lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_DELETE); + + /* WAL-before-mutation durability (see RENAME above). */ + XLogFlush(max_lsn); + } + if (unlink(pending->path) < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not remove file \"%s\": %m", + pending->path))); + } + else + { + if (enableFsync) + fileops_fsync_parent(pending->path, WARNING); + } + break; + + case PENDING_FILEOP_RMDIR: + /* + * B3: log-at-commit-execution, commit path only (see the + * DELETE case above for the abort-path rationale). + */ + if (isCommit && XLogIsNeeded()) + { + xl_fileops_rmdir xlrec; + int pathlen = (int) strlen(pending->path) + 1; + + xlrec.at_commit = true; + XLogBeginInsert(); + XLogRegisterData(&xlrec, SizeOfFileOpsRmdir); + XLogRegisterData(pending->path, pathlen); + max_lsn = XLogInsert(RM_FILEOPS_ID, XLOG_FILEOPS_RMDIR); + + /* WAL-before-mutation durability (see RENAME above). */ + XLogFlush(max_lsn); + } + if (rmdir(pending->path) < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not remove directory \"%s\": %m", + pending->path))); + } + else + { + if (enableFsync) + fileops_fsync_parent(pending->path, WARNING); + } + break; + + case PENDING_FILEOP_RMTREE: + /* + * Recursive removal. We defer the actual tree walk + * to execute time so that a copy operation that + * registered the destination tree at copy-start can + * have the abort path remove whatever ended up on + * disk by the time of abort. Enumerating at + * registration time would only see the empty + * destination directory created moments earlier. + */ + if (!rmtree(pending->path, true)) + ereport(WARNING, + (errmsg("could not remove directory tree \"%s\"", + pending->path))); + else if (enableFsync) + fileops_fsync_parent(pending->path, WARNING); + break; + + case PENDING_FILEOP_TRUNCATE: + { + /* + * Restore original file size on abort. The original + * length is stored in pending->length. + */ + int trunc_fd; + + trunc_fd = OpenTransientFile(pending->path, + O_RDWR | PG_BINARY); + if (trunc_fd >= 0) + { + if (ftruncate(trunc_fd, pending->length) < 0) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not restore file \"%s\" to original size %lld: %m", + pending->path, + (long long) pending->length))); + else if (enableFsync && pg_fsync(trunc_fd) != 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\" after restoring size: %m", + pending->path))); + CloseTransientFile(trunc_fd); + } + else + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not open file \"%s\" to restore size: %m", + pending->path))); + } + } + break; + + case PENDING_FILEOP_CHMOD: + { + /* + * Restore original file mode on abort. The original + * mode is stored in pending->length (cast from + * mode_t). + */ + mode_t restore_mode = (mode_t) pending->length; + + if (chmod(pending->path, restore_mode) < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not restore file \"%s\" to original mode 0%o: %m", + pending->path, + (unsigned int) restore_mode))); + } + } + break; + + case PENDING_FILEOP_SETXATTR: + { + /* + * Restore an xattr to its original value on abort. + * path = file, newpath = attr name, data = orig + * value. + */ + if (pending->data && pending->data_len > 0) + { + if (pg_setxattr(pending->path, pending->newpath, + pending->data, pending->data_len) < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not restore extended attribute \"%s\" on \"%s\": %m", + pending->newpath, + pending->path))); + } + } + else + { + /* Zero-length original value */ + if (pg_setxattr(pending->path, pending->newpath, + "", 0) < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not restore extended attribute \"%s\" on \"%s\": %m", + pending->newpath, + pending->path))); + } + } + } + break; + + case PENDING_FILEOP_REMOVEXATTR: + { + /* + * Remove an xattr on abort (attribute was newly + * created in the aborted transaction). + */ + if (pg_removexattr(pending->path, pending->newpath) < 0) + { + /* + * "Attribute already absent" is success for a + * remove-on-abort (idempotent, like our unlink/rmdir + * redo). Use PG_ENOATTR, the portable absent-attribute + * errno: on Linux that is ENODATA, on macOS/BSD ENOATTR + * -- the raw ENODATA/ENOENT check missed the macOS case + * and warned spuriously (sec3 portability bug). + */ + if (errno != ENOENT && errno != PG_ENOATTR) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not remove extended attribute \"%s\" from \"%s\": %m", + pending->newpath, + pending->path))); + } + } + break; + + default: + break; + } + } + + FreePendingFileOp(pending); + } + + /* + * B3: each destructive commit op above now XLogFlush()es its own record + * BEFORE performing the filesystem mutation (WAL-before-mutation), so no + * primary FS change can outrun its WAL record. This trailing flush is + * therefore redundant, kept only as a no-op safety backstop (max_lsn is + * already durable). Only the commit path ever sets max_lsn. + */ + if (!XLogRecPtrIsInvalid(max_lsn)) + XLogFlush(max_lsn); +} + +/* + * AtSubCommit_FileOps - Handle subtransaction commit + * + * Reassign all pending ops from the current nesting level to the parent. + */ +void +AtSubCommit_FileOps(void) +{ + int nestLevel = GetCurrentTransactionNestLevel(); + PendingFileOp *pending; + + for (pending = pendingFileOps; pending != NULL; pending = pending->next) + { + if (pending->nestLevel >= nestLevel) + pending->nestLevel = nestLevel - 1; + } +} + +/* + * AtSubAbort_FileOps - Handle subtransaction abort + * + * Execute abort-time actions for the current nesting level immediately. + */ +void +AtSubAbort_FileOps(void) +{ + FileOpsDoPendingOps(false); +} + +/* + * PostPrepare_FileOps - Clean up after PREPARE TRANSACTION + * + * Discard all pending file operations since they've been recorded + * in the two-phase state file. + */ +void +PostPrepare_FileOps(void) +{ + PendingFileOp *pending; + PendingFileOp *next; + + for (pending = pendingFileOps; pending != NULL; pending = next) + { + next = pending->next; + pendingFileOps = next; + FreePendingFileOp(pending); + } +} + +/* + * fileops_redo - WAL redo function for FILEOPS records + * + * Replay file operations during crash recovery or standby apply. + * Each operation type has its own redo handler added in separate commits. + */ +void +fileops_redo(XLogReaderState *record) +{ + uint8 info = XLogRecGetInfo(record) & ~XLR_INFO_MASK; + char *data = XLogRecGetData(record); + + switch (info) + { + case XLOG_FILEOPS_CREATE: + { + xl_fileops_create *xlrec = (xl_fileops_create *) data; + const char *path = data + SizeOfFileOpsCreate; + int fd; + + fd = BasicOpenFilePerm(path, + (xlrec->flags & ~PG_O_DIRECT) | O_CREAT, + xlrec->mode); + if (fd < 0) + { + if (errno == ENOENT) + { + char parentpath[MAXPGPATH]; + char *sep; + + strlcpy(parentpath, path, MAXPGPATH); + sep = strrchr(parentpath, '/'); + if (sep != NULL) + { + *sep = '\0'; + if (MakePGDirectory(parentpath) < 0 && + errno != EEXIST) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\" during WAL replay: %m", + parentpath))); + } + + fd = BasicOpenFilePerm(path, + (xlrec->flags & ~PG_O_DIRECT) | O_CREAT, + xlrec->mode); + } + + if (fd < 0 && errno != EEXIST) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not create file \"%s\" during WAL replay: %m", + path))); + } + + if (fd >= 0) + { + if (enableFsync && pg_fsync(fd) != 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\" during WAL replay: %m", + path))); + close(fd); + if (enableFsync) + fileops_fsync_parent(path, data_sync_elevel(ERROR)); + } + } + break; + + case XLOG_FILEOPS_WRITE: + { + xl_fileops_write *xlrec = (xl_fileops_write *) data; + const char *path = data + SizeOfFileOpsWrite; + const char *wdata = path + xlrec->path_len; + int fd; + + fd = BasicOpenFile(path, O_RDWR | PG_BINARY); + if (fd < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not open file \"%s\" for write during WAL replay: %m", + path))); + } + else + { + if (pg_pwrite(fd, wdata, xlrec->len, xlrec->offset) != + (ssize_t) xlrec->len) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\" during WAL replay: %m", + path))); + else if (enableFsync && pg_fsync(fd) != 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\" during WAL replay: %m", + path))); + close(fd); + } + } + break; + + case XLOG_FILEOPS_RENAME: + + /* + * B3: idempotent redo of a deferred rename executed at commit on + * the primary. The record carries oldpath then newpath. A + * standby (or a re-replay after a primary crash mid-commit) may + * find the rename already applied: source gone. In that case + * treat it as success rather than failing on a missing source -- + * this is what makes redo converge whether the crash happened + * before or after the physical rename. + */ + { + xl_fileops_rename *xlrec = (xl_fileops_rename *) data; + const char *oldpath = data + SizeOfFileOpsRename; + const char *newpath = oldpath + xlrec->oldpath_len; + + if (rename(oldpath, newpath) < 0) + { + /* + * ENOENT on the source means the rename already took + * effect (or the source never existed on this standby); + * either way the desired end state -- newpath present, + * oldpath absent -- is what we want, so it is not an + * error. + */ + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not rename file \"%s\" to \"%s\" during WAL replay: %m", + oldpath, newpath))); + } + else if (enableFsync) + { + fileops_fsync_parent(newpath, data_sync_elevel(ERROR)); + fileops_fsync_parent(oldpath, data_sync_elevel(ERROR)); + } + } + break; + + case XLOG_FILEOPS_DELETE: + + /* + * B3: idempotent redo of a deferred delete executed at commit on + * the primary. An already-absent file (ENOENT) means the delete + * already took effect -- treat as success. Only commit-path + * deletes are ever logged (see FileOpsDoPendingOps), so redo of an + * aborted transaction's delete-on-abort can never occur here. + */ + { + xl_fileops_delete *xlrec pg_attribute_unused() = + (xl_fileops_delete *) data; + const char *path = data + SizeOfFileOpsDelete; + + if (unlink(path) < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not remove file \"%s\" during WAL replay: %m", + path))); + } + else if (enableFsync) + fileops_fsync_parent(path, data_sync_elevel(ERROR)); + } + break; + + case XLOG_FILEOPS_SYMLINK: + + /* + * sec2: intentional no-op during redo. + * + * FileOpsSymlink() has exactly one caller, + * create_tablespace_directories() in tablespace.c, and it is used + * ONLY for tablespace symlinks under $PGDATA/pg_tblspc. Standby + * (and primary crash-recovery) replay of those symlinks is OWNED + * by RM_TBLSPC: tblspc_redo() -> create_tablespace_directories() + * runs with InRecovery set, first removing any stale symlink + * (remove_tablespace_symlink) and then re-creating it with the + * recovery-time layout logic that honors the standby's own + * tablespace layout / allow_in_place_tablespaces / tablespace_map + * remap. + * + * Replaying the FILEOPS record here would be redundant (RM_TBLSPC + * already creates the link) and, worse, actively wrong on a + * remapped standby: it would unlink the correctly-remapped link + * and re-point it at the primary's LITERAL absolute target, which + * may not exist or may belong to a different location on the + * standby. So FILEOPS defers tablespace-symlink replay to + * RM_TBLSPC and does nothing here. + * + * If FileOpsSymlink ever gains a non-tablespace caller, this must + * be revisited to replay those (and only those) symlinks; that + * would require distinguishing the two cases in the record. + */ + break; + + case XLOG_FILEOPS_LINK: + { + xl_fileops_link *xlrec = (xl_fileops_link *) data; + const char *oldpath = data + SizeOfFileOpsLink; + const char *newpath = oldpath + xlrec->oldpath_len; + + /* Remove existing link first for idempotent redo */ + unlink(newpath); + if (link(oldpath, newpath) < 0 && errno != EEXIST) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not create hard link \"%s\" during WAL replay: %m", + newpath))); + else if (enableFsync) + fileops_fsync_parent(newpath, data_sync_elevel(ERROR)); + } + break; + + case XLOG_FILEOPS_MKDIR: + { + xl_fileops_mkdir *xlrec pg_attribute_unused() = + (xl_fileops_mkdir *) data; + const char *path = data + SizeOfFileOpsMkdir; + + if (MakePGDirectory(path) < 0 && errno != EEXIST) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\" during WAL replay: %m", + path))); + else if (enableFsync) + fileops_fsync_parent(path, data_sync_elevel(ERROR)); + } + break; + + case XLOG_FILEOPS_RMDIR: + + /* + * B3: idempotent redo of a deferred rmdir executed at commit on + * the primary. An already-absent directory (ENOENT) means the + * rmdir already took effect -- treat as success. + */ + { + xl_fileops_rmdir *xlrec pg_attribute_unused() = + (xl_fileops_rmdir *) data; + const char *path = data + SizeOfFileOpsRmdir; + + if (rmdir(path) < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not remove directory \"%s\" during WAL replay: %m", + path))); + } + else if (enableFsync) + fileops_fsync_parent(path, data_sync_elevel(ERROR)); + } + break; + + case XLOG_FILEOPS_CHMOD: + { + xl_fileops_chmod *xlrec = (xl_fileops_chmod *) data; + const char *path = data + SizeOfFileOpsChmod; + + if (chmod(path, xlrec->mode) < 0 && errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not chmod file \"%s\" during WAL replay: %m", + path))); + } + break; + + case XLOG_FILEOPS_CHOWN: + { + xl_fileops_chown *xlrec = (xl_fileops_chown *) data; + const char *path = data + SizeOfFileOpsChown; + +#ifndef WIN32 + if (chown(path, xlrec->uid, xlrec->gid) < 0 && + errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not chown file \"%s\" during WAL replay: %m", + path))); +#endif + } + break; + + case XLOG_FILEOPS_TRUNCATE: + { + xl_fileops_truncate *xlrec = (xl_fileops_truncate *) data; + const char *path = data + SizeOfFileOpsTruncate; + int fd; + + XLogFlush(record->EndRecPtr); + + fd = BasicOpenFile(path, O_RDWR | PG_BINARY); + if (fd < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not open file \"%s\" for truncation during WAL replay: %m", + path))); + } + else + { + if (ftruncate(fd, xlrec->length) < 0) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not truncate file \"%s\" to %lld bytes during WAL replay: %m", + path, (long long) xlrec->length))); + else if (enableFsync && pg_fsync(fd) != 0) + ereport(data_sync_elevel(ERROR), + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\" during WAL replay: %m", + path))); + close(fd); + } + } + break; + + case XLOG_FILEOPS_SETXATTR: + { + xl_fileops_setxattr *xlrec = (xl_fileops_setxattr *) data; + const char *path = data + SizeOfFileOpsSetxattr; + const char *name = path + xlrec->path_len; + const void *value = name + xlrec->name_len; + + if (pg_setxattr(path, name, value, xlrec->value_len) < 0 && + errno != ENOTSUP && errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not set extended attribute \"%s\" on \"%s\" during WAL replay: %m", + name, path))); + } + break; + + case XLOG_FILEOPS_REMOVEXATTR: + { + xl_fileops_removexattr *xlrec = + (xl_fileops_removexattr *) data; + const char *path = data + SizeOfFileOpsRemovexattr; + const char *name = path + xlrec->path_len; + + if (pg_removexattr(path, name) < 0 && + errno != ENOTSUP && errno != ENOENT && + errno != PG_ENOATTR) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("could not remove extended attribute \"%s\" from \"%s\" during WAL replay: %m", + name, path))); + } + break; + + default: + elog(PANIC, "fileops_redo: unknown op code %u", info); + break; + } +} diff --git a/src/backend/storage/file/fileops_undo.c b/src/backend/storage/file/fileops_undo.c new file mode 100644 index 0000000000000..c037e6e4e85ae --- /dev/null +++ b/src/backend/storage/file/fileops_undo.c @@ -0,0 +1,377 @@ +/*------------------------------------------------------------------------- + * + * fileops_undo.c + * FILEOPS UNDO resource manager + * + * This module implements the UNDO apply callbacks for transactional file + * operations. UNDO covers structural filesystem operations (file existence, + * permissions, directory structure). File content recovery uses traditional + * WAL (via FileOpsWrite), not FILEOPS UNDO. + * + * On transaction abort, each FILEOPS UNDO record reverses the structural + * change made by the corresponding FileOps* function: + * - CREATE: unlink the created file + * - RENAME: rename back to the original path + * - TRUNCATE: ftruncate to original length + * - CHMOD: chmod to original mode + * - CHOWN: chown to original uid/gid + * - MKDIR: rmdir the created directory + * - SYMLINK: unlink the symlink + * - LINK: unlink the hard link + * - SETXATTR: restore/remove the original xattr + * - REMOVEXATTR: restore the original xattr value + * + * DELETE and RMDIR are handled by PendingFileOps (deferred execution) + * and do not need UNDO records since they execute only at commit time. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/storage/file/fileops_undo.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#include + +#include "access/undormgr.h" +#include "access/xact.h" +#include "port/pg_xattr.h" +#include "storage/fd.h" +#include "storage/fileops.h" +#include "utils/injection_point.h" + +/* + * UNDO subtype constants and payload structures are defined in fileops.h, + * shared between this file (apply side) and fileops.c (insert side). + */ + +/* Forward declarations */ +static UndoApplyResult fileops_undo_apply(uint8 rmid, uint16 info, + TransactionId xid, Oid reloid, + const char *payload, Size payload_len, + UndoRecPtr urec_ptr); +static void fileops_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len); + +/* The FILEOPS UNDO RM registration entry */ +static const UndoRmgrData fileops_undo_rmgr = { + .rm_name = "fileops", + .rm_undo = fileops_undo_apply, + .rm_desc = fileops_undo_desc, +}; + +/* + * FileopsUndoRmgrInit - Register the FILEOPS UNDO resource manager + */ +void +FileopsUndoRmgrInit(void) +{ + RegisterUndoRmgr(UNDO_RMID_FILEOPS, &fileops_undo_rmgr); + + /* + * Wire FILEOPS's pending-structural-file-operation callbacks into the + * AM-neutral hooks in xact.c (see access/xact.h for why xact.c cannot + * call these by name directly). + */ + PendingPhysOpsDo_hook = FileOpsDoPendingOps; + PendingPhysOpsPostPrepare_hook = PostPrepare_FileOps; + PendingPhysOpsAtSubCommit_hook = AtSubCommit_FileOps; + PendingPhysOpsAtSubAbort_hook = AtSubAbort_FileOps; +} + +/* + * fileops_undo_apply - Apply a single FILEOPS UNDO record + * + * Reverses the structural filesystem operation. + */ +static UndoApplyResult +fileops_undo_apply(uint8 rmid, uint16 info, TransactionId xid, Oid reloid, + const char *payload, Size payload_len, UndoRecPtr urec_ptr) +{ + Assert(rmid == UNDO_RMID_FILEOPS); + + INJECTION_POINT("fileops-undo-apply-begin", NULL); + + switch (info) + { + case FILEOPS_UNDO_CREATE: + { + FileopsUndoCreate hdr; + const char *path; + + if (payload_len < sizeof(FileopsUndoCreate)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoCreate)); + path = payload + sizeof(FileopsUndoCreate); + + if (unlink(path) < 0 && errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO CREATE: could not unlink \"%s\": %m", + path))); + else + ereport(DEBUG2, + (errmsg("FILEOPS UNDO CREATE: unlinked \"%s\"", + path))); + } + break; + + case FILEOPS_UNDO_RENAME: + { + FileopsUndoRename hdr; + const char *oldpath; + const char *newpath; + + if (payload_len < sizeof(FileopsUndoRename)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoRename)); + oldpath = payload + sizeof(FileopsUndoRename); + newpath = oldpath + hdr.oldpath_len; + + /* Reverse the rename: newpath -> oldpath */ + if (rename(newpath, oldpath) < 0) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO RENAME: could not rename \"%s\" back to \"%s\": %m", + newpath, oldpath))); + else + ereport(DEBUG2, + (errmsg("FILEOPS UNDO RENAME: renamed \"%s\" back to \"%s\"", + newpath, oldpath))); + } + break; + + case FILEOPS_UNDO_TRUNCATE: + { + FileopsUndoTruncate hdr; + const char *path; + int fd; + + if (payload_len < sizeof(FileopsUndoTruncate)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoTruncate)); + path = payload + sizeof(FileopsUndoTruncate); + + fd = BasicOpenFile(path, O_RDWR | PG_BINARY); + if (fd < 0) + { + if (errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO TRUNCATE: could not open \"%s\": %m", + path))); + } + else + { + if (ftruncate(fd, hdr.orig_length) < 0) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO TRUNCATE: could not restore length of \"%s\": %m", + path))); + close(fd); + } + } + break; + + case FILEOPS_UNDO_CHMOD: + { + FileopsUndoChmod hdr; + const char *path; + + if (payload_len < sizeof(FileopsUndoChmod)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoChmod)); + path = payload + sizeof(FileopsUndoChmod); + + if (chmod(path, hdr.orig_mode) < 0 && errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO CHMOD: could not restore mode of \"%s\": %m", + path))); + } + break; + + case FILEOPS_UNDO_CHOWN: + { +#ifndef WIN32 + FileopsUndoChown hdr; + const char *path; + + if (payload_len < sizeof(FileopsUndoChown)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoChown)); + path = payload + sizeof(FileopsUndoChown); + + if (chown(path, hdr.orig_uid, hdr.orig_gid) < 0 && + errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO CHOWN: could not restore ownership of \"%s\": %m", + path))); +#endif + } + break; + + case FILEOPS_UNDO_MKDIR: + { + FileopsUndoMkdir hdr; + const char *path; + + if (payload_len < sizeof(FileopsUndoMkdir)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoMkdir)); + path = payload + sizeof(FileopsUndoMkdir); + + if (rmdir(path) < 0 && errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO MKDIR: could not rmdir \"%s\": %m", + path))); + } + break; + + case FILEOPS_UNDO_SYMLINK: + { + FileopsUndoSymlink hdr; + const char *linkpath; + + if (payload_len < sizeof(FileopsUndoSymlink)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoSymlink)); + linkpath = payload + sizeof(FileopsUndoSymlink); + + if (unlink(linkpath) < 0 && errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO SYMLINK: could not unlink \"%s\": %m", + linkpath))); + } + break; + + case FILEOPS_UNDO_LINK: + { + FileopsUndoLink hdr; + const char *newpath; + + if (payload_len < sizeof(FileopsUndoLink)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoLink)); + newpath = payload + sizeof(FileopsUndoLink); + + if (unlink(newpath) < 0 && errno != ENOENT) + ereport(WARNING, + (errcode_for_file_access(), + errmsg("FILEOPS UNDO LINK: could not unlink \"%s\": %m", + newpath))); + } + break; + + case FILEOPS_UNDO_SETXATTR: + { + FileopsUndoSetxattr hdr; + const char *path; + const char *name; + + if (payload_len < sizeof(FileopsUndoSetxattr)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoSetxattr)); + path = payload + sizeof(FileopsUndoSetxattr); + name = path + hdr.path_len; + + if (hdr.had_value) + { + /* Restore original value */ + const void *orig_value = name + hdr.name_len; + + pg_setxattr(path, name, orig_value, hdr.orig_value_len); + } + else + { + /* xattr didn't exist before, remove it */ + pg_removexattr(path, name); + } + } + break; + + case FILEOPS_UNDO_REMOVEXATTR: + { + FileopsUndoRemovexattr hdr; + const char *path; + const char *name; + const void *value; + + if (payload_len < sizeof(FileopsUndoRemovexattr)) + return UNDO_APPLY_ERROR; + memcpy(&hdr, payload, sizeof(FileopsUndoRemovexattr)); + path = payload + sizeof(FileopsUndoRemovexattr); + name = path + hdr.path_len; + value = name + hdr.name_len; + + /* Restore the removed xattr */ + pg_setxattr(path, name, value, hdr.value_len); + } + break; + + default: + ereport(WARNING, + (errmsg("FILEOPS UNDO: unknown subtype %u", info))); + return UNDO_APPLY_ERROR; + } + + INJECTION_POINT("fileops-undo-apply-end", NULL); + + return UNDO_APPLY_SUCCESS; +} + +/* + * fileops_undo_desc - Describe a FILEOPS UNDO record for debugging + */ +static void +fileops_undo_desc(StringInfo buf, uint8 rmid, uint16 info, + const char *payload, Size payload_len) +{ + const char *opname; + + switch (info) + { + case FILEOPS_UNDO_CREATE: + opname = "CREATE"; + break; + case FILEOPS_UNDO_RENAME: + opname = "RENAME"; + break; + case FILEOPS_UNDO_TRUNCATE: + opname = "TRUNCATE"; + break; + case FILEOPS_UNDO_CHMOD: + opname = "CHMOD"; + break; + case FILEOPS_UNDO_CHOWN: + opname = "CHOWN"; + break; + case FILEOPS_UNDO_MKDIR: + opname = "MKDIR"; + break; + case FILEOPS_UNDO_SYMLINK: + opname = "SYMLINK"; + break; + case FILEOPS_UNDO_LINK: + opname = "LINK"; + break; + case FILEOPS_UNDO_SETXATTR: + opname = "SETXATTR"; + break; + case FILEOPS_UNDO_REMOVEXATTR: + opname = "REMOVEXATTR"; + break; + default: + opname = "UNKNOWN"; + break; + } + + appendStringInfo(buf, "fileops %s", opname); +} diff --git a/src/backend/storage/file/meson.build b/src/backend/storage/file/meson.build index 795402589b0b9..7e487324c5f3e 100644 --- a/src/backend/storage/file/meson.build +++ b/src/backend/storage/file/meson.build @@ -4,6 +4,8 @@ backend_sources += files( 'buffile.c', 'copydir.c', 'fd.c', + 'fileops.c', + 'fileops_undo.c', 'fileset.c', 'reinit.c', 'sharedfileset.c', diff --git a/src/backend/storage/lmgr/README b/src/backend/storage/lmgr/README index 45de0fd2bd6f0..637643c0cf51b 100644 --- a/src/backend/storage/lmgr/README +++ b/src/backend/storage/lmgr/README @@ -36,6 +36,23 @@ Regular locks should be used for all user-driven lock requests. * SIReadLock predicate locks. See separate README-SSI file for details. +In addition to those four interprocess lock types, there is a specialized +reader/writer primitive: + +* Sequence locks (seqlocks; see src/include/storage/seqlock.h). A seqlock +protects read-mostly, rarely-written shared data with a single sequence +counter (even = stable, odd = write in progress). Readers take no lock and +pay no atomic read-modify-write and no StoreLoad fence on the common path: +they read the counter, copy the data into local variables, re-read the +counter, and retry if it changed. A writer bumps the counter odd, mutates +the single copy in place, and bumps it even; writer-vs-writer exclusion is +the caller's responsibility (typically an LWLock already held for other +reasons). Readers may retry if a writer intervenes, so a reader is not +wait-free and can be starved by continuous writers -- seqlocks suit +read-mostly data with short, infrequent writes. Because the data has a +single copy that the writer mutates in place, a reader must not retain +pointers into it across the re-check. + Acquisition of either a spinlock or a lightweight lock causes query cancel and die() interrupts to be held off until all such locks are released. No such restriction exists for regular locks, however. Also diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c index 73a56f1df1dc3..b093940cb0c34 100644 --- a/src/backend/tcop/utility.c +++ b/src/backend/tcop/utility.c @@ -1190,6 +1190,7 @@ ProcessUtilitySlow(ParseState *pstate, validnsps, true, false); + (void) heap_reloptions(RELKIND_TOASTVALUE, toast_options, true); diff --git a/src/backend/utils/activity/pgstat_relation.c b/src/backend/utils/activity/pgstat_relation.c index 04f2eb21d0bb5..abc27f4bcc289 100644 --- a/src/backend/utils/activity/pgstat_relation.c +++ b/src/backend/utils/activity/pgstat_relation.c @@ -17,6 +17,7 @@ #include "postgres.h" +#include "access/tableam.h" #include "access/twophase_rmgr.h" #include "access/xact.h" #include "catalog/catalog.h" @@ -200,6 +201,7 @@ pgstat_drop_relation(Relation rel) save_truncdrop_counters(pgstat_info->trans, true); pgstat_info->trans->tuples_inserted = 0; pgstat_info->trans->tuples_updated = 0; + pgstat_info->trans->tuples_updated_no_dead = 0; pgstat_info->trans->tuples_deleted = 0; } } @@ -397,6 +399,16 @@ pgstat_count_heap_update(Relation rel, bool hot, bool newpage) ensure_tabstat_xact_level(pgstat_info); pgstat_info->trans->tuples_updated++; + /* + * An in-place-update AM (e.g. RECNO) overwrites the committed tuple + * bytes and leaves no old version behind, so this update created no + * dead tuple. AtEOXact_PgStat_Relations() subtracts this count back + * out of delta_dead_tuples, which otherwise assumes every UPDATE + * creates one dead tuple (true for heap, not for an in-place AM). + */ + if (rel->rd_tableam && rel->rd_tableam->am_inplace_update_no_dead_tuple) + pgstat_info->trans->tuples_updated_no_dead++; + /* * tuples_hot_updated and tuples_newpage_updated counters are * nontransactional, so just advance them @@ -437,6 +449,7 @@ pgstat_count_truncate(Relation rel) save_truncdrop_counters(pgstat_info->trans, false); pgstat_info->trans->tuples_inserted = 0; pgstat_info->trans->tuples_updated = 0; + pgstat_info->trans->tuples_updated_no_dead = 0; pgstat_info->trans->tuples_deleted = 0; } } @@ -580,9 +593,18 @@ AtEOXact_PgStat_Relations(PgStat_SubXactStatus *xact_state, bool isCommit) /* insert adds a live tuple, delete removes one */ tabstat->counts.delta_live_tuples += trans->tuples_inserted - trans->tuples_deleted; - /* update and delete each create a dead tuple */ + /* + * update and delete each create a dead tuple, EXCEPT an update on + * an in-place-update AM (am_inplace_update_no_dead_tuple), which + * overwrites the committed bytes and leaves nothing to reclaim. + * trans->tuples_updated_no_dead counts exactly those updates (set + * in pgstat_count_heap_update()); subtract them back out so a + * table that never leaves a dead tuple behind correctly reports + * ~0 dead tuples instead of one per update. + */ tabstat->counts.delta_dead_tuples += - trans->tuples_updated + trans->tuples_deleted; + (trans->tuples_updated - trans->tuples_updated_no_dead) + + trans->tuples_deleted; /* insert, update, delete each count as one change event */ tabstat->counts.changed_tuples += trans->tuples_inserted + trans->tuples_updated + @@ -592,7 +614,8 @@ AtEOXact_PgStat_Relations(PgStat_SubXactStatus *xact_state, bool isCommit) { /* inserted tuples are dead, deleted tuples are unaffected */ tabstat->counts.delta_dead_tuples += - trans->tuples_inserted + trans->tuples_updated; + trans->tuples_inserted + + (trans->tuples_updated - trans->tuples_updated_no_dead); /* an aborted xact generates no changed_tuple events */ } tabstat->trans = NULL; @@ -993,6 +1016,7 @@ save_truncdrop_counters(PgStat_TableXactStatus *trans, bool is_drop) { trans->inserted_pre_truncdrop = trans->tuples_inserted; trans->updated_pre_truncdrop = trans->tuples_updated; + trans->updated_no_dead_pre_truncdrop = trans->tuples_updated_no_dead; trans->deleted_pre_truncdrop = trans->tuples_deleted; trans->truncdropped = true; } @@ -1008,6 +1032,7 @@ restore_truncdrop_counters(PgStat_TableXactStatus *trans) { trans->tuples_inserted = trans->inserted_pre_truncdrop; trans->tuples_updated = trans->updated_pre_truncdrop; + trans->tuples_updated_no_dead = trans->updated_no_dead_pre_truncdrop; trans->tuples_deleted = trans->deleted_pre_truncdrop; } } diff --git a/src/backend/utils/activity/wait_event_names.txt b/src/backend/utils/activity/wait_event_names.txt index 256b3a3c02e01..80013fa70bb1b 100644 --- a/src/backend/utils/activity/wait_event_names.txt +++ b/src/backend/utils/activity/wait_event_names.txt @@ -69,6 +69,8 @@ WAL_RECEIVER_MAIN "Waiting in main loop of WAL receiver process." WAL_SENDER_MAIN "Waiting in main loop of WAL sender process." WAL_SUMMARIZER_WAL "Waiting in WAL summarizer for more WAL to be generated." WAL_WRITER_MAIN "Waiting in main loop of WAL writer process." +UNDO_FLUSH_MAIN "Waiting in main loop of UNDO flush writer process." +UNDO_WORKER_MAIN "Waiting in main loop of UNDO discard worker process." ABI_compatibility: @@ -167,6 +169,7 @@ WAL_RECEIVER_UPSTREAM_CATCHUP "Waiting for upstream server WAL flush position to WAL_RECEIVER_WAIT_START "Waiting for startup process to send initial data for streaming replication." WAL_SUMMARY_READY "Waiting for a new WAL summary to be generated." XACT_GROUP_UPDATE "Waiting for the group leader to update transaction status at transaction end." +UNDO_FLUSH_SYNC "Waiting for UNDO flush writer to sync UNDO data to disk." ABI_compatibility: @@ -418,6 +421,10 @@ XactSLRU "Waiting to access the transaction status SLRU cache." ParallelVacuumDSA "Waiting for parallel vacuum dynamic shared memory allocation." AioUringCompletion "Waiting for another process to complete IO via io_uring." ShmemIndex "Waiting to find or allocate space in shared memory." +UndoLog "Waiting to access or modify UNDO log metadata." +UndoWorker "Waiting to access or modify UNDO worker shared memory queue." +AbortedTxnMap "Waiting to access the Aborted Transaction Map." +SecondaryLog "Waiting to access the Secondary Log (sLog)." # No "ABI_compatibility" region here as WaitEventLWLock has its own C code. diff --git a/src/backend/utils/init/postinit.c b/src/backend/utils/init/postinit.c index 3d8c9bdebd559..993de1aa39752 100644 --- a/src/backend/utils/init/postinit.c +++ b/src/backend/utils/init/postinit.c @@ -21,6 +21,7 @@ #include "access/genam.h" #include "access/heapam.h" +#include "access/undo.h" #include "access/htup_details.h" #include "access/session.h" #include "access/tableam.h" @@ -856,6 +857,9 @@ InitPostgres(const char *in_dbname, Oid dboid, InitCatalogCache(); InitPlanCache(); + /* Initialize per-backend undo subsystem state */ + InitializeUndo(); + /* Initialize portal manager */ EnablePortalManager(); diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index adb72361ce0bb..be072364d9fe3 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -1105,6 +1105,12 @@ options => 'file_extend_method_options', }, +{ name => 'flux_lazy_uncommitted_clear', type => 'bool', context => 'PGC_USERSET', group => 'CLIENT_CONN_STATEMENT', + short_desc => 'Skip commit-time page re-visits for FLUX UNCOMMITTED flag clearing.', + variable => 'flux_lazy_uncommitted_clear', + boot_val => 'false', +}, + { name => 'from_collapse_limit', type => 'int', context => 'PGC_USERSET', group => 'QUERY_TUNING_OTHER', short_desc => 'Sets the FROM-list size beyond which subqueries are not collapsed.', long_desc => 'The planner will merge subqueries into upper queries if the resulting FROM list would have no more than this many items.', @@ -1951,6 +1957,16 @@ max => 'MAX_KILOBYTES', }, +{ name => 'logical_revert_naptime', type => 'int', context => 'PGC_SIGHUP', group => 'VACUUM_AUTOVACUUM', + short_desc => 'Time between ATM scan cycles in the logical revert worker.', + long_desc => 'The logical revert worker sleeps for this many milliseconds between scans of the ATM for unreverted aborted transactions.', + flags => 'GUC_UNIT_MS', + variable => 'logical_revert_naptime', + boot_val => '1000', + min => '100', + max => 'INT_MAX', +}, + { name => 'maintenance_io_concurrency', type => 'int', context => 'PGC_USERSET', group => 'RESOURCES_IO', short_desc => 'A variant of "effective_io_concurrency" that is used for maintenance work.', long_desc => '0 disables simultaneous requests.', @@ -2044,6 +2060,15 @@ max => 'MAX_BACKENDS', }, +{ name => 'max_logical_revert_workers', type => 'int', context => 'PGC_POSTMASTER', group => 'VACUUM_AUTOVACUUM', + short_desc => 'Maximum number of logical revert background workers.', + long_desc => 'Sets the maximum number of logical revert workers that apply UNDO chains for aborted transactions. Acts as a hard ceiling on the number of concurrently running per-database revert workers. Set to 0 to disable the logical revert launcher entirely.', + variable => 'max_logical_revert_workers', + boot_val => '2', + min => '0', + max => '64', +}, + { name => 'max_notify_queue_pages', type => 'int', context => 'PGC_POSTMASTER', group => 'RESOURCES_DISK', short_desc => 'Sets the maximum number of allocated pages for NOTIFY / LISTEN queue.', variable => 'max_notify_queue_pages', @@ -2755,6 +2780,24 @@ boot_val => '""', }, +{ name => 'slog_dsa_max_size_mb', type => 'int', context => 'PGC_POSTMASTER', group => 'WAL_SETTINGS', + short_desc => 'Maximum shared memory for the sLog DSA area.', + long_desc => 'Sets the maximum size (in MB) of the DSA area used by the sLog to back its aborted-transaction radix tree. When this limit is reached, new entries cannot be stored and a WARNING is emitted.', + variable => 'slog_dsa_max_size_mb', + boot_val => '256', + min => '1', + max => '16384', +}, + +{ name => 'slog_num_partitions', type => 'int', context => 'PGC_POSTMASTER', group => 'WAL_SETTINGS', + short_desc => 'Number of sLog flat hash partitions (0 = auto from CPU count).', + long_desc => 'Controls the number of partitions for the optional sLog tuple tracking hash. More partitions reduce writer lock contention. Set 0 for auto-sizing (4x CPUs, clamped 16-256, power of 2). Requires restart.', + variable => 'slog_num_partitions', + boot_val => '0', + min => '0', + max => '256', +}, + { name => 'ssl', type => 'bool', context => 'PGC_SIGHUP', group => 'CONN_AUTH_SSL', short_desc => 'Enables SSL connections.', variable => 'EnableSSL', @@ -3292,6 +3335,74 @@ boot_val => 'false', }, +{ name => 'undo_batch_record_limit', type => 'int', context => 'PGC_SIGHUP', group => 'RESOURCES_MEM', + short_desc => 'Sets the UNDO write buffer flush threshold in number of records.', + long_desc => 'When the UNDO write buffer accumulates this many records, it inserts a new XLOG_UNDO_BATCH WAL record. Larger values reduce the number of WAL reads during rollback of large transactions but hold the WAL insertion lock longer per batch.', + variable => 'undo_batch_record_limit', + boot_val => '1000', + min => '100', + max => '100000', +}, + +{ name => 'undo_batch_size_kb', type => 'int', context => 'PGC_SIGHUP', group => 'RESOURCES_MEM', + short_desc => 'Sets the UNDO write buffer flush threshold in kilobytes.', + long_desc => 'When the UNDO write buffer accumulates this many kilobytes, it inserts a new XLOG_UNDO_BATCH WAL record. (This does not flush WAL to disk; that is controlled by synchronous_commit.) Larger values reduce the number of WAL reads during rollback of large transactions but hold the WAL insertion lock longer per batch.', + flags => 'GUC_UNIT_KB', + variable => 'undo_batch_size_kb', + boot_val => '256', + min => '64', + max => '4096', +}, + +{ name => 'undo_buffer_size', type => 'int', context => 'PGC_POSTMASTER', group => 'RESOURCES_MEM', + short_desc => 'Sets the size of the UNDO buffer cache.', + long_desc => 'Size of the dedicated buffer cache for UNDO log pages, in kilobytes.', + flags => 'GUC_UNIT_KB', + variable => 'undo_buffer_size', + boot_val => '1024', + min => '128', + max => 'INT_MAX / 1024', +}, + +{ name => 'undo_instant_abort_threshold', type => 'int', context => 'PGC_USERSET', group => 'WAL_SETTINGS', + short_desc => 'Per-relation UNDO size threshold for ATM instant abort.', + long_desc => 'When estimated per-relation UNDO data for a transaction exceeds this many bytes, abort uses ATM instant abort (O(1) with asynchronous cleanup) instead of synchronous rollback. Set to 0 to always use ATM instant abort.', + variable => 'undo_instant_abort_threshold', + boot_val => '65536', + min => '0', + max => 'INT_MAX', +}, + +{ name => 'undo_max_wal_retention_size', type => 'int', context => 'PGC_SIGHUP', group => 'WAL_SETTINGS', + short_desc => 'Maximum WAL retained for UNDO rollback before warning.', + long_desc => 'If the WAL retained because of in-flight UNDO exceeds this size (in MB), a WARNING is logged. 0 disables the check.', + flags => 'GUC_UNIT_MB', + variable => 'undo_max_wal_retention_size', + boot_val => '0', + min => '0', + max => 'INT_MAX / 2', +}, + +{ name => 'undo_retention_time', type => 'int', context => 'PGC_SIGHUP', group => 'WAL_SETTINGS', + short_desc => 'Minimum time to retain UNDO records.', + long_desc => 'UNDO records will not be discarded until they are at least this old, in milliseconds.', + flags => 'GUC_UNIT_MS', + variable => 'undo_retention_time', + boot_val => '60000', + min => '0', + max => 'INT_MAX', +}, + +{ name => 'undo_worker_naptime', type => 'int', context => 'PGC_SIGHUP', group => 'VACUUM_AUTOVACUUM', + short_desc => 'Time to sleep between runs of the UNDO discard worker.', + long_desc => 'The UNDO discard worker wakes up periodically to discard old UNDO records.', + flags => 'GUC_UNIT_MS', + variable => 'undo_worker_naptime', + boot_val => '10000', + min => '1', + max => 'INT_MAX', +}, + { name => 'unix_socket_directories', type => 'string', context => 'PGC_POSTMASTER', group => 'CONN_AUTH_SETTINGS', short_desc => 'Sets the directories where Unix-domain sockets will be created.', flags => 'GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY', diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c index 1ec460b6a8236..026f00b643b69 100644 --- a/src/backend/utils/misc/guc_tables.c +++ b/src/backend/utils/misc/guc_tables.c @@ -31,9 +31,14 @@ #include "access/commit_ts.h" #include "access/gin.h" +#include "access/flux.h" +#include "access/logical_revert_worker.h" +#include "access/slog.h" #include "access/slru.h" #include "access/toast_compression.h" #include "access/twophase.h" +#include "access/undolog.h" +#include "access/xactundo.h" #include "access/xlog_internal.h" #include "access/xlogprefetcher.h" #include "access/xlogrecovery.h" diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample index 7958653077b16..c29c1dc4a3949 100644 --- a/src/backend/utils/misc/postgresql.conf.sample +++ b/src/backend/utils/misc/postgresql.conf.sample @@ -911,6 +911,21 @@ # (change requires restart) #recovery_init_sync_method = fsync # fsync, syncfs (Linux 5.8+) +# - Cluster-wide UNDO (development and testing) - + +#undo_buffer_size = 1MB # memory buffer for UNDO log records + # (change requires restart) +#undo_instant_abort_threshold = 65536 # bytes; 0 = always use ATM instant abort +#undo_max_wal_retention_size = 0 # MB; 0 = unlimited UNDO WAL retention +#undo_retention_time = 300s # time to retain UNDO records +#undo_worker_naptime = 60s # time between UNDO discard worker runs +#undo_batch_size_kb = 256 # KB per UNDO batch flush (64-4096); reload +#undo_batch_record_limit = 1000 # records per UNDO batch flush (100-100000); reload +#slog_dsa_max_size_mb = 256 # MB; max sLog DSA area size (aborted-txn tree) +#slog_num_partitions = 0 # 0 = auto (4x CPUs); requires restart +#max_logical_revert_workers = 2 # 0 disables the logical revert launcher +#logical_revert_naptime = 1s # time between ATM scan cycles + #------------------------------------------------------------------------------ # CONFIG FILE INCLUDES diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c index 4948e6d80c7ee..7380615a657f5 100644 --- a/src/bin/pg_dump/pg_dump.c +++ b/src/bin/pg_dump/pg_dump.c @@ -17475,6 +17475,7 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo) if (nonemptyReloptions(tbinfo->reloptions)) { addcomma = true; + /* Emits all heap reloptions */ appendReloptionsArrayAH(q, tbinfo->reloptions, "", fout); } if (nonemptyReloptions(tbinfo->toast_reloptions)) diff --git a/src/bin/pg_upgrade/t/002_pg_upgrade.pl b/src/bin/pg_upgrade/t/002_pg_upgrade.pl index 0a4121fdc4d9f..7bcc994702819 100644 --- a/src/bin/pg_upgrade/t/002_pg_upgrade.pl +++ b/src/bin/pg_upgrade/t/002_pg_upgrade.pl @@ -288,7 +288,7 @@ sub get_dump_for_comparison '--bindir=', '--host=' . $oldnode->host, '--port=' . $oldnode->port, - "--schedule=$srcdir/src/test/regress/parallel_schedule", + "--schedule=$srcdir/src/test/regress/integration_schedule", '--max-concurrent-tests=20', "--inputdir=$inputdir", "--outputdir=$outputdir" diff --git a/src/bin/pg_waldump/fileopsdesc.c b/src/bin/pg_waldump/fileopsdesc.c new file mode 120000 index 0000000000000..318ef5c750898 --- /dev/null +++ b/src/bin/pg_waldump/fileopsdesc.c @@ -0,0 +1 @@ +../../../src/backend/access/rmgrdesc/fileopsdesc.c \ No newline at end of file diff --git a/src/bin/pg_waldump/relundodesc.c b/src/bin/pg_waldump/relundodesc.c new file mode 120000 index 0000000000000..90437665e3733 --- /dev/null +++ b/src/bin/pg_waldump/relundodesc.c @@ -0,0 +1 @@ +../../../src/backend/access/rmgrdesc/relundodesc.c \ No newline at end of file diff --git a/src/bin/pg_waldump/rmgrdesc.c b/src/bin/pg_waldump/rmgrdesc.c index 931ab8b979e23..64da5a2b37dcd 100644 --- a/src/bin/pg_waldump/rmgrdesc.c +++ b/src/bin/pg_waldump/rmgrdesc.c @@ -17,9 +17,14 @@ #include "access/hash_xlog.h" #include "access/heapam_xlog.h" #include "access/multixact.h" +#include "access/flux_xlog.h" #include "access/nbtxlog.h" #include "access/rmgr.h" #include "access/spgxlog.h" +#include "access/atm_xlog.h" +#include "access/fileops_xlog.h" +#include "access/relundo_xlog.h" +#include "access/undo_xlog.h" #include "access/xact.h" #include "access/xlog_internal.h" #include "catalog/storage_xlog.h" diff --git a/src/bin/pg_waldump/t/001_basic.pl b/src/bin/pg_waldump/t/001_basic.pl index 53b2f016b8035..2cd04e38f439c 100644 --- a/src/bin/pg_waldump/t/001_basic.pl +++ b/src/bin/pg_waldump/t/001_basic.pl @@ -80,7 +80,11 @@ ReplicationOrigin Generic LogicalMessage -XLOG2$/, +XLOG2 +Undo +ATM +FileOps +RelUndo$/, 'rmgr list'); diff --git a/src/bin/pg_waldump/undodesc.c b/src/bin/pg_waldump/undodesc.c new file mode 120000 index 0000000000000..6bb50cf1d40f7 --- /dev/null +++ b/src/bin/pg_waldump/undodesc.c @@ -0,0 +1 @@ +../../../src/backend/access/rmgrdesc/undodesc.c \ No newline at end of file diff --git a/src/common/relpath.c b/src/common/relpath.c index 8fb3bed7873ab..32f12c5cdd8a2 100644 --- a/src/common/relpath.c +++ b/src/common/relpath.c @@ -35,6 +35,7 @@ const char *const forkNames[] = { [FSM_FORKNUM] = "fsm", [VISIBILITYMAP_FORKNUM] = "vm", [INIT_FORKNUM] = "init", + [RELUNDO_FORKNUM] = "relundo", }; StaticAssertDecl(lengthof(forkNames) == (MAX_FORKNUM + 1), diff --git a/src/include/access/atm.h b/src/include/access/atm.h new file mode 100644 index 0000000000000..d5529a235f0a7 --- /dev/null +++ b/src/include/access/atm.h @@ -0,0 +1,63 @@ +/*------------------------------------------------------------------------- + * + * atm.h + * Aborted Transaction Map for CTR (Constant-Time Recovery) + * + * The ATM is a shared-memory structure that tracks aborted transactions + * whose per-relation UNDO chains have not yet been applied (Logical + * Revert). It drives the background Logical Revert worker. + * + * The ATM is now backed by the sLog (Secondary Log) shared-memory hash + * tables defined in access/slog.h. All ATM functions are thin wrappers + * around sLog operations, preserving the existing API and WAL format. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/atm.h + * + *------------------------------------------------------------------------- + */ +#ifndef ATM_H +#define ATM_H + +#include "access/atm_xlog.h" +#include "access/transam.h" +#include "datatype/timestamp.h" +#include "storage/lwlock.h" + +/* Shared memory sizing and initialization */ +extern Size ATMShmemSize(void); +extern void ATMShmemInit(void); + +/* Core API */ +extern bool ATMGetLastBatchLSN(TransactionId xid, XLogRecPtr *lsn_out); +extern bool ATMAddAborted(TransactionId xid, Oid dboid, + XLogRecPtr last_batch_lsn); +extern void ATMForget(TransactionId xid); +extern void ATMMarkReverted(TransactionId xid); + +/* Iteration for Logical Revert worker */ +extern bool ATMGetNextUnreverted(TransactionId *xid_out, Oid *dboid_out, + XLogRecPtr *lsn_out); + +/* WAL retention: oldest batch LSN across unreverted entries */ +extern XLogRecPtr ATMGetOldestUnrevertedLSN(void); + +/* Recovery support */ +extern void ATMRecoveryFinalize(void); + +/* + * Crash-safety: persist the ATM at each checkpoint and reload it at startup + * before the redo pass. The ATM lives only in shared memory and is otherwise + * reconstructed solely by replaying XLOG_ATM_ABORT / XLOG_ATM_FORGET during + * redo; a checkpoint that advances the redo pointer past an un-forgotten + * XLOG_ATM_ABORT would make crash recovery miss that abort, silently losing a + * guaranteed rollback. CheckPointATM() durably snapshots the map; startup + * calls ATMReloadFromCheckpoint() before redo so atm_redo's XLOG_ATM_FORGET + * replays can correctly remove entries forgotten after the checkpoint. + */ +extern void CheckPointATM(void); +extern void ATMReloadFromCheckpoint(void); + +#endif /* ATM_H */ diff --git a/src/include/access/atm_xlog.h b/src/include/access/atm_xlog.h new file mode 100644 index 0000000000000..947512f5559c8 --- /dev/null +++ b/src/include/access/atm_xlog.h @@ -0,0 +1,49 @@ +/*------------------------------------------------------------------------- + * + * atm_xlog.h + * Aborted Transaction Map XLOG resource manager definitions + * + * This header is safe for inclusion from frontend code (e.g., pg_waldump). + * For the full ATM API, include "access/atm.h" instead. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/atm_xlog.h + * + *------------------------------------------------------------------------- + */ +#ifndef ATM_XLOG_H +#define ATM_XLOG_H + +#include "access/xlogreader.h" +#include "lib/stringinfo.h" + +/* WAL record types for RM_ATM_ID */ +#define XLOG_ATM_ABORT 0x00 +#define XLOG_ATM_FORGET 0x10 + +/* WAL record structures */ +typedef struct xl_atm_abort +{ + TransactionId xid; + XLogRecPtr last_batch_lsn; /* LSN of last UNDO batch for this xid */ + Oid dboid; + Oid reloid; /* InvalidOid (kept for struct layout) */ +} xl_atm_abort; + +#define SizeOfXlAtmAbort (offsetof(xl_atm_abort, reloid) + sizeof(Oid)) + +typedef struct xl_atm_forget +{ + TransactionId xid; +} xl_atm_forget; + +#define SizeOfXlAtmForget sizeof(xl_atm_forget) + +/* Resource manager functions */ +extern void atm_redo(XLogReaderState *record); +extern void atm_desc(StringInfo buf, XLogReaderState *record); +extern const char *atm_identify(uint8 info); + +#endif /* ATM_XLOG_H */ diff --git a/src/include/access/fileops_xlog.h b/src/include/access/fileops_xlog.h new file mode 100644 index 0000000000000..f00055962e2b3 --- /dev/null +++ b/src/include/access/fileops_xlog.h @@ -0,0 +1,34 @@ +/* + * fileops_xlog.h + * Transactional file operations XLOG resource manager definitions + * + * IDENTIFICATION + * src/include/access/fileops_xlog.h + */ +#ifndef FILEOPS_XLOG_H +#define FILEOPS_XLOG_H + +#include "access/xlogreader.h" +#include "lib/stringinfo.h" + +/* XLOG stuff - all record types defined upfront for WAL compatibility */ +#define XLOG_FILEOPS_CREATE 0x00 +#define XLOG_FILEOPS_DELETE 0x10 +#define XLOG_FILEOPS_RENAME 0x20 +#define XLOG_FILEOPS_WRITE 0x30 +#define XLOG_FILEOPS_TRUNCATE 0x40 +#define XLOG_FILEOPS_CHMOD 0x50 +#define XLOG_FILEOPS_CHOWN 0x60 +#define XLOG_FILEOPS_MKDIR 0x70 +#define XLOG_FILEOPS_RMDIR 0x80 +#define XLOG_FILEOPS_SYMLINK 0x90 +#define XLOG_FILEOPS_LINK 0xA0 +#define XLOG_FILEOPS_SETXATTR 0xB0 +#define XLOG_FILEOPS_REMOVEXATTR 0xC0 + +/* Resource manager functions */ +extern void fileops_redo(XLogReaderState *record); +extern void fileops_desc(StringInfo buf, XLogReaderState *record); +extern const char *fileops_identify(uint8 info); + +#endif /* FILEOPS_XLOG_H */ diff --git a/src/include/access/flux.h b/src/include/access/flux.h new file mode 100644 index 0000000000000..e4638e4217b4b --- /dev/null +++ b/src/include/access/flux.h @@ -0,0 +1,919 @@ +/*------------------------------------------------------------------------- + * + * flux.h + * FLUX table access method definitions + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/flux.h + * + *------------------------------------------------------------------------- + */ +#ifndef FLUX_H +#define FLUX_H + +#include "postgres.h" + +#include "access/heapam.h" +#include "access/relundo.h" +#include "access/toast_helper.h" +#include "storage/shmem.h" +#include "access/relscan.h" +#include "access/sdir.h" +#include "access/tableam.h" +#include "access/xact.h" +#include "executor/tuptable.h" +#include "port/atomics.h" +#include "storage/buf.h" +#include "storage/bufpage.h" +#include "storage/procnumber.h" +#include "utils/rel.h" +#include "utils/snapshot.h" + +/* + * FLUX special space structure - stored in page special space (8 bytes) + * + * Packs the page-level commit timestamp and 3 flag bits into a single + * uint64. The top 3 bits (63-61) store page flags; the lower 61 bits + * store the commit timestamp (sufficient for 73,000+ years of microseconds). + * + * pd_free_space was removed -- use PageGetFreeSpace() directly (same as + * heap). pd_flags was removed -- flags are packed into the timestamp word. + */ +typedef struct FluxPageOpaqueData +{ + uint64 pd_commit_ts_and_flags; /* bits 63-61: flags, bits 60-0: ts */ +} FluxPageOpaqueData; + +typedef FluxPageOpaqueData *FluxPageOpaque; + +/* Page flags (stored in top 3 bits of pd_commit_ts_and_flags) */ +#define FLUX_PAGE_FLAG_SHIFT 61 +#define FLUX_PAGE_FLAG_MASK (UINT64CONST(0x7) << FLUX_PAGE_FLAG_SHIFT) +#define FLUX_PAGE_TS_MASK (~FLUX_PAGE_FLAG_MASK) + +#define FLUX_PAGE_OVERFLOW (UINT64CONST(1) << 61) +#define FLUX_PAGE_DEFRAG_NEEDED (UINT64CONST(1) << 62) +#define FLUX_PAGE_FULL (UINT64CONST(1) << 63) + +/* Accessor macros for page opaque */ +#define FluxPageGetOpaque(page) \ + ((FluxPageOpaque) PageGetSpecialPointer(page)) + +#define FluxPageGetCommitTs(opaque) \ + ((opaque)->pd_commit_ts_and_flags & FLUX_PAGE_TS_MASK) + +#define FluxPageSetCommitTs(opaque, ts) \ + ((opaque)->pd_commit_ts_and_flags = \ + ((opaque)->pd_commit_ts_and_flags & FLUX_PAGE_FLAG_MASK) | \ + ((uint64)(ts) & FLUX_PAGE_TS_MASK)) + +#define FluxPageGetFlags(opaque) \ + ((opaque)->pd_commit_ts_and_flags & FLUX_PAGE_FLAG_MASK) + +#define FluxPageSetFlag(opaque, flag) \ + ((opaque)->pd_commit_ts_and_flags |= (flag)) + +#define FluxPageClearFlag(opaque, flag) \ + ((opaque)->pd_commit_ts_and_flags &= ~(flag)) + +/* + * FLUX tuple header structure (v3 -- heap-compatible xmin/xmax MVCC) + * + * Visibility is ordinary heap-shaped xmin/xmax + CLOG + snapshot (the same + * model HeapTupleSatisfiesMVCC uses), NOT the former HLC/sLog timestamp + * scheme. A tuple is visible to a snapshot iff its inserter (t_xmin) is + * committed-and-visible-to-the-snapshot AND its deleter/updater (t_xmax) is + * either invalid, not committed, or not visible to the snapshot. + * + * - t_xmin: the inserting transaction's XID. Always valid once the tuple + * exists on the page (it rides through WAL redo verbatim in the logged + * tuple body, so it needs no separate redo reconstruction). + * - t_xmax: the deleting/updating transaction's XID, or InvalidTransactionId + * for a live never-superseded tuple. Physically stored in the low 32 + * bits of the t_commit_ts word (see FluxTupleGetXmax below) so the on-disk + * and WAL byte layout is unchanged from the HLC era; the DML paths and + * WAL now carry an XID in that slot instead of a timestamp. + * + * In-place UPDATE keeps the newest version on the page (new t_xmin) and pushes + * the pre-update image to the per-relation UNDO fork via t_verptr (zheap + * style). A snapshot that predates the update reads the old version back from + * the fork with FluxReconstructVisibleVersion(), which walks t_verptr and + * stops at the version whose producing xid is visible (XidInMVCCSnapshot). + * + * Transient operation state (who is inserting/deleting/locking concurrently) + * is still tracked in the sLog for write-write conflict serialization and SSI, + * but it is no longer consulted for read visibility -- CLOG is authoritative. + * + * t_writer: Per-tuple CAS writer lock for same-size updates under + * BUFFER_LOCK_SHARE. 0 = unlocked; non-zero = (MyProcNumber + 1) of + * the writer. Operated on via atomic CAS through FluxTupleWriter* + * macros below. It sits in the fixed header (see the field list and the + * 40-byte total documented on the struct below); it is never read on the + * visibility path, only CAS'd during an in-flight same-size update. + */ +typedef struct FluxTupleHeader +{ + uint64 t_commit_ts; /* 8B low 32 bits = t_xmax (deleter/updater XID); + * high 32 bits reserved. Accessed via + * FluxTupleGetXmax/SetXmax. Kept as a uint64 + * field so the on-disk/WAL byte layout is + * unchanged from the HLC era. */ + RelUndoRecPtr t_verptr; /* 8B UNDO-fork version-chain head (WS-PVS1) */ + uint32 t_writer; /* 4B Per-tuple CAS writer lock (0=free) */ + + /* + * t_gen: RESERVED, unused in FLUX. FLUX uses plain heap-TID index + * identity (no RowID/gen scheme): a key-changing UPDATE goes out-of-place + * (new TID) and maintains indexes the standard heap way, so no per-tuple + * generation is needed to disambiguate index entries. The field is + * retained (kept at 0) as reserved header space rather than changing the + * tuple geometry; a future slimming pass may remove it. + */ + uint32 t_gen; /* 4B reserved (unused in FLUX) */ + + /* + * t_xmin: the inserting transaction's XID (heap xmin semantics). Always + * valid once the tuple exists. Visibility resolves it against CLOG and + * the reader's snapshot exactly like HeapTupleSatisfiesMVCC, so no HLC + * timestamp or sLog lookup is needed on the common read path. The tuple + * body (including this field) is WAL-logged verbatim, so t_xmin survives + * redo without separate reconstruction. + * + * (Formerly t_xid_hint, which was "valid only while UNCOMMITTED"; the + * heap-shaped model makes it permanently authoritative as xmin.) + */ + TransactionId t_xmin; /* 4B Inserter XID (heap xmin) */ + + /* + * t_verptr is the head of this tuple's persistent version chain in the + * UNDO fork (WS-PVS1/2). It was formerly an unaligned 8-byte trailer at + * the end of the on-page item, located by item_len - 8 arithmetic and + * gated by FLUX_TUPLE_HAS_VERSION_PTR. Since every tuple now reserves it + * from birth (born-with-flag), the trailer was always present, so it is a + * plain aligned header field: no growth on first UPDATE, no packed-page + * failure, no trailing-byte arithmetic. InvalidRelUndoRecPtr means "never + * updated -- no history"; readers treat that as "on-page image is current". + * + * t_cid removed: command ID is now obtained from the sLog entry + * (FluxSLogEntry.cid) when FLUX_TUPLE_UNCOMMITTED is set. This saves 4 + * bytes per tuple. The sLog lookup is mandatory for uncommitted + * DELETE/UPDATE visibility anyway, so fetching the cid from there adds + * zero extra overhead. (INSERT visibility no longer needs the sLog at + * all in the common case -- see t_xmin above.) + */ + ItemPointerData t_ctid; /* 6B Current TID / update chain */ + uint16 t_natts; /* 2B Number of attributes */ + uint16 t_flags; /* 2B Tuple flags */ + uint8 t_infomask; /* 1B HASNULL, HASVARWIDTH, etc. */ + uint8 t_pad; /* 1B padding so t_attrs_bitmap begins at a + * MAXALIGN boundary equal to FLUX_TUPLE_OVERHEAD. + * Without this the null bitmap (anchored at + * t_attrs_bitmap) and the column data (anchored at + * FLUX_TUPLE_OVERHEAD) disagree, corrupting reads. + * The whole header is still MAXALIGN'd to 40 bytes + * (8+8+4+4+4+6+2+2+1+1 = 40), so no footprint change. */ + uint8 t_attrs_bitmap[FLEXIBLE_ARRAY_MEMBER]; +} FluxTupleHeader; + +/* Fixed size: 40 bytes raw (MAXALIGN'd to 40 bytes): 8 t_commit_ts + 8 t_verptr + * + 4 t_writer + 4 t_gen + 4 t_xmin + 6 t_ctid + 2 t_natts + 2 t_flags + * + 1 t_infomask + 1 t_pad = 40, then the FLEXIBLE_ARRAY_MEMBER null bitmap. */ + +/* + * Per-tuple CAS writer lock accessor macros. + * + * t_writer is a plain uint32 on disk (initialized to 0 by palloc0/memset). + * At runtime we operate on it via pg_atomic_compare_exchange_u32 by casting + * its address to (pg_atomic_uint32 *). This is safe on all PostgreSQL + * platforms because pg_atomic_uint32 is { volatile uint32 value; } with + * identical size and alignment. + * + * FluxTupleWriterTryLock: CAS 0 -> (MyProcNumber+1). Returns true on success. + * FluxTupleWriterUnlock: Atomic write 0 (release). + * FluxTupleWriterIsLocked: Non-zero check (relaxed read). + */ +#define FluxTupleWriterTryLock(hdr, expected_ptr) \ + pg_atomic_compare_exchange_u32((pg_atomic_uint32 *) &(hdr)->t_writer, \ + (expected_ptr), (uint32)(MyProcNumber + 1)) + +#define FluxTupleWriterUnlock(hdr) \ + pg_atomic_write_u32((pg_atomic_uint32 *) &(hdr)->t_writer, 0) + +#define FluxTupleWriterIsLocked(hdr) \ + (pg_atomic_read_u32((pg_atomic_uint32 *) &(hdr)->t_writer) != 0) + +/* Tuple flags (uint16) */ +#define FLUX_TUPLE_COMPRESSED 0x0001 +#define FLUX_TUPLE_HAS_OVERFLOW 0x0002 +#define FLUX_TUPLE_DELETED 0x0004 +#define FLUX_TUPLE_UPDATED 0x0008 +#define FLUX_TUPLE_LOCKED 0x0010 +#define FLUX_TUPLE_SPECULATIVE 0x0020 +#define FLUX_TUPLE_HAS_INLINE_DIFF 0x0040 /* reserved (unused) */ +#define FLUX_TUPLE_UNCOMMITTED 0x0080 /* Inserted but not yet committed */ +#define FLUX_TUPLE_HAS_VERSION_PTR 0x0100 /* trailing RelUndoRecPtr (version-chain head) follows column data */ +#define FLUX_TUPLE_XMIN_COMMITTED 0x0200 /* t_xmin known-committed (CLOG hint, like heap HEAP_XMIN_COMMITTED) */ +#define FLUX_TUPLE_XMAX_COMMITTED 0x0400 /* t_xmax known-committed (CLOG hint, like heap HEAP_XMAX_COMMITTED) */ + +/* Tuple infomask bits (uint8 -- reduced from uint16) */ +#define FLUX_INFOMASK_HASNULL 0x01 +#define FLUX_INFOMASK_HASVARWIDTH 0x02 +#define FLUX_INFOMASK_HASEXTERNAL 0x04 +#define FLUX_INFOMASK_COMPRESSED 0x08 +#define FLUX_INFOMASK_HASOVERFLOW 0x10 + +/* + * FLUX tuple structure + */ +typedef struct FluxTupleData +{ + uint32 t_len; /* Length of tuple */ + ItemPointerData t_self; /* TID of this tuple */ + Oid t_tableOid; /* Table OID */ + FluxTupleHeader *t_data; /* Tuple header and data */ +} FluxTupleData; + +typedef FluxTupleData *FluxTuple; + +/* + * Column-level overflow + * + * When an individual column value is too large to store inline in the main + * tuple, it is stored as one or more "overflow records" on normal FLUX data + * pages. The main tuple stores a compact overflow pointer (FluxOverflowPtr) + * wrapped in a varlena, optionally preceded by an inline prefix of the + * original data for efficient prefix matching. + * + * Overflow records use a lightweight header (FluxOverflowRecordHeader) + * without MVCC fields -- they share the visibility of the parent tuple. + * Each overflow record holds a chunk of the column data and a continuation + * pointer to the next chunk (or InvalidBlockNumber if this is the last). + * + * This approach stores overflow data on regular pages that can also hold + * normal tuples, within the same relation -- no separate out-of-line + * relation is created. + */ + +/* + * Overflow pointer stored inline in the main tuple (wrapped as varlena). + * + * On-disk layout of an overflowed column in the main tuple: + * [varlena header][FluxOverflowPtr][inline_prefix_bytes...] + * + * The FLUX_OVERFLOW_PTR_MAGIC sentinel distinguishes this from a normal + * varlena value during deform. + */ +#define FLUX_OVERFLOW_PTR_MAGIC 0x52564F50 /* "RVOP" */ + +typedef struct FluxOverflowPtr +{ + uint32 ov_magic; /* FLUX_OVERFLOW_PTR_MAGIC */ + BlockNumber ov_first_block; /* First overflow record's page */ + OffsetNumber ov_first_offset; /* First overflow record's offset on page */ + uint16 ov_inline_prefix; /* Bytes of inline prefix stored after ptr */ + uint32 ov_total_length; /* Total uncompressed column data length */ + uint32 ov_content_hash; /* 32-bit content-hash prefilter for COW */ +} FluxOverflowPtr; + +/* + * ov_content_hash reclaims the two formerly-reserved uint16 fields + * (ov_padding, ov_flags), so the struct stays 20 bytes and + * FLUX_OVERFLOW_PTR_SIZE is unchanged. A wider (64-bit) hash would force + * struct growth that inflates FLUX_OVERFLOW_PTR_SIZE and defeats the + * force-shrink UPDATE recovery path, breaking in-place updates on full pages. + * + * The hash is only a cheap prefilter: every COW-reference candidate is + * byte-verified against the fetched old chain before it is accepted, so a + * 32-bit collision merely costs a wasted fetch and falls through to a normal + * re-store. It never produces an incorrect result. + */ + +/* Minimum varlena size for an overflow pointer (no inline prefix) */ +#define FLUX_OVERFLOW_PTR_SIZE (VARHDRSZ + sizeof(FluxOverflowPtr)) + +/* Default inline prefix size (configurable via GUC) */ +#define FLUX_OVERFLOW_DEFAULT_PREFIX 128 + +/* + * Check if a varlena datum is an overflow pointer. + * + * The check requires: correct size range, and magic value match. + */ +static inline bool +FluxIsOverflowPtr(const void *ptr) +{ + Size vsize; + const FluxOverflowPtr *ovp; + + if (ptr == NULL) + return false; + + vsize = VARSIZE_ANY_EXHDR(ptr); + if (vsize < sizeof(FluxOverflowPtr)) + return false; + + ovp = (const FluxOverflowPtr *) VARDATA_ANY(ptr); + return ovp->ov_magic == FLUX_OVERFLOW_PTR_MAGIC; +} + +/* + * Extract overflow pointer from a varlena datum. + */ +static inline const FluxOverflowPtr * +FluxGetOverflowPtr(const void *ptr) +{ + return (const FluxOverflowPtr *) VARDATA_ANY(ptr); +} + +/* + * Lightweight header for overflow records stored on normal data pages. + * + * Overflow records are stored via PageAddItem just like normal tuples, but + * they carry this minimal header instead of a full FluxTupleHeader. The + * ov_magic field lets us distinguish overflow records from normal tuples + * during page scans (e.g., sequential scan must skip these). + */ +#define FLUX_OVERFLOW_RECORD_MAGIC 0x524F5643 /* "ROVC" */ + +typedef struct FluxOverflowRecordHeader +{ + uint32 or_magic; /* FLUX_OVERFLOW_RECORD_MAGIC */ + uint32 or_data_len; /* Bytes of column data in this record */ + BlockNumber or_next_block; /* Next overflow record's page, or Invalid */ + OffsetNumber or_next_offset; /* Next overflow record's offset */ + uint16 or_flags; /* Flags (reserved) */ + /* Column data follows immediately after this header */ +} FluxOverflowRecordHeader; + +/* Maximum column data per overflow record */ +#define FLUX_OVERFLOW_RECORD_OVERHEAD MAXALIGN(sizeof(FluxOverflowRecordHeader)) +#define FLUX_OVERFLOW_MAX_CHUNK_SIZE \ + (FLUX_MAX_TUPLE_SIZE - FLUX_OVERFLOW_RECORD_OVERHEAD) + +/* + * Structure to track overflow buffers for atomic WAL logging. + * + * When creating overflow chains, we keep buffers pinned and collect them + * here so the caller can register them all in a single WAL record with + * the main tuple modification. This ensures atomicity during crash recovery. + */ +#define MAX_OVERFLOW_BUFFERS 32 + +typedef struct FluxOverflowBuffer +{ + Buffer buffer; /* Pinned buffer containing overflow record */ + OffsetNumber offset; /* Offset of overflow record on page */ + char *record_data; /* FluxOverflowRecordHeader + data */ + uint32 record_len; /* Total record length */ + uint16 flags; /* FLUX_OVERFLOW_WAL_NEW_RECORD or + * _LINK_UPDATE */ +} FluxOverflowBuffer; + +typedef struct FluxOverflowBuffers +{ + int count; /* Number of overflow buffers */ + FluxOverflowBuffer buffers[MAX_OVERFLOW_BUFFERS]; +} FluxOverflowBuffers; + +/* + * Compression types + */ +typedef enum FluxCompressionType +{ + FLUX_COMP_NONE, + FLUX_COMP_LZ4, + FLUX_COMP_ZSTD, + FLUX_COMP_DELTA, /* For numeric columns */ + FLUX_COMP_DICTIONARY /* For text columns */ +} FluxCompressionType; + +/* + * Values for the flux_compression_algorithm GUC. AUTO lets + * FluxChooseCompressionType() pick per attribute type; LZ4/ZSTD force that + * codec for compressible varlena attributes; NONE disables compression. + */ +typedef enum FluxCompressionAlgoGuc +{ + FLUX_COMP_ALGO_AUTO, + FLUX_COMP_ALGO_LZ4, + FLUX_COMP_ALGO_ZSTD, + FLUX_COMP_ALGO_OFF +} FluxCompressionAlgoGuc; + +typedef struct FluxCompressionHeader +{ + uint8 comp_type; + uint8 comp_level; + uint16 dict_id; /* trained-dict id, 0 = FLUX_DICT_INVALID_ID */ + uint32 orig_size; + uint32 comp_size; +} FluxCompressionHeader; + +/* + * FLUX timestamp word. + * + * A uint64 wall-clock timestamp (microseconds since the PG epoch) used for + * per-page commit-ts bookkeeping (FluxPageSetCommitTs). It is NOT a + * visibility timestamp: commit visibility comes from CLOG via heap-shaped + * xmin/xmax MVCC. + */ + +/* + * Tuple MVCC field accessors (heap-shaped xmin/xmax). + * + * t_xmax is physically the low 32 bits of the t_commit_ts word; the high 32 + * bits are reserved. This preserves the on-disk/WAL byte layout while giving + * the tuple a heap-compatible deleter/updater XID. InvalidTransactionId (0) + * means "live, never superseded". + */ +#define FluxTupleGetXmax(tup) ((TransactionId) ((tup)->t_commit_ts & 0xFFFFFFFFULL)) +#define FluxTupleSetXmax(tup, xid) \ + ((tup)->t_commit_ts = ((tup)->t_commit_ts & 0xFFFFFFFF00000000ULL) | \ + ((uint64) (TransactionId) (xid))) +#define FluxTupleGetXmin(tup) ((tup)->t_xmin) +#define FluxTupleSetXmin(tup, xid) ((tup)->t_xmin = (TransactionId) (xid)) + +/* + * Transaction state for uncertainty tracking. + * + * The full struct definition lives in flux_mvcc.c (private to that module). + * External code should use the opaque forward declaration below. + */ +typedef struct FluxTransactionState FluxTransactionState; + +/* + * Free space management + */ +typedef struct FluxFreeSpaceMap +{ + uint32 total_pages; + uint32 pages_with_space; + uint8 *fsm_data; /* Bitmap of page utilization */ + uint32 *defrag_queue; /* Pages needing defragmentation */ + uint32 defrag_queue_size; +} FluxFreeSpaceMap; + +/* Free space map levels */ +#define FLUX_FSM_FULL 0 +#define FLUX_FSM_75_PERCENT 1 +#define FLUX_FSM_50_PERCENT 2 +#define FLUX_FSM_25_PERCENT 3 +#define FLUX_FSM_EMPTY 4 + +/* + * Visibility Map support for FLUX + * + * The visibility map tracks two bits per page: + * - ALL_VISIBLE: all tuples on page are visible to all transactions + * - ALL_FROZEN: all tuples on page are frozen (no further VACUUM needed) + * + * This enables: + * - Index-only scans (can skip heap fetch if page is all-visible) + * - VACUUM optimization (can skip pages marked all-visible/frozen) + */ + +/* Visibility map bits */ +#define FLUX_VM_ALL_VISIBLE 0x01 /* All tuples visible to all xacts */ +#define FLUX_VM_ALL_FROZEN 0x02 /* All tuples frozen */ + +/* Combined flags for convenience */ +#define FLUX_VM_VALID_BITS (FLUX_VM_ALL_VISIBLE | FLUX_VM_ALL_FROZEN) + +/* Visibility map fork number (uses PostgreSQL's fork infrastructure) */ +#define FLUX_VM_FORKNUM VISIBILITYMAP_FORKNUM + +/* + * Scan descriptor for FLUX scans + */ +typedef struct FluxScanDescData +{ + TableScanDescData rs_base; /* Base scan descriptor */ + Buffer rs_cbuf; /* Current buffer */ + BlockNumber rs_cblock; /* Current block */ + BlockNumber rs_nblocks; /* Total blocks in relation (cached) */ + BlockNumber rs_startblock; /* Starting block for sample scans */ + OffsetNumber rs_cindex; /* Current offset in page */ + OffsetNumber rs_coffset; /* Current offset number */ + bool rs_inited; /* True after first block is fetched */ + int rs_ntuples; /* Number of tuples on current page */ + OffsetNumber *rs_vistuples; /* Offset numbers of visible tuples */ + uint64 rs_snapshot_ts; /* Snapshot timestamp */ + uint64 rs_xact_ts; /* Transaction timestamp */ + ParallelBlockTableScanWorkerData *rs_parallelworkerdata; /* Parallel scan worker + * state */ + struct ReadStream *rs_read_stream; /* Read stream for sequential + * prefetching */ + BlockNumber rs_prefetch_block; /* Next block for read stream callback */ + + /* Cached visibility map buffer to avoid per-page VM I/O */ + Buffer rs_vm_buffer; /* Pinned VM buffer (or InvalidBuffer) */ + BlockNumber rs_vm_blockno; /* VM block number for rs_vm_buffer */ + + /* + * ANALYZE dictionary-refresh sample accumulation. During an ANALYZE scan + * the decompressed bytes of the first varlena column are gathered here so + * FluxMaybeRefreshDict() can train a candidate dictionary at scan end. + * All fields stay zero/NULL on non-ANALYZE scans. + */ + char *rs_dict_samplebuf; /* Concatenated sample bytes */ + size_t *rs_dict_sizes; /* Per-sample lengths, in order */ + int rs_dict_nsamples; /* Number of accumulated samples */ + Size rs_dict_total; /* Total bytes in rs_dict_samplebuf */ + Size rs_dict_cap; /* Capacity of rs_dict_samplebuf */ + int rs_dict_maxsamples; /* Capacity of rs_dict_sizes */ + int16 rs_dict_attnum; /* 1-based varlena attr sampled, 0 = none */ +} FluxScanDescData; + +typedef FluxScanDescData *FluxScanDesc; + +/* + * Index fetch table data for FLUX + */ +typedef struct IndexFetchFluxData +{ + IndexFetchTableData base; /* AM independent part of the descriptor */ + + Buffer buffer; +} IndexFetchFluxData; + +/* + * Constants + */ +#define FLUX_PAGE_OVERHEAD (MAXALIGN(SizeOfPageHeaderData) + MAXALIGN(sizeof(FluxPageOpaqueData))) +#define FLUX_TUPLE_OVERHEAD (MAXALIGN(sizeof(FluxTupleHeader))) + +/* + * The null bitmap is anchored at t_attrs_bitmap and the column data is + * anchored at FLUX_TUPLE_OVERHEAD; the two MUST coincide or reads corrupt. + * t_pad keeps t_attrs_bitmap on the MAXALIGN boundary that equals the + * overhead. Enforce it so a future header change cannot silently break it. + */ +StaticAssertDecl(offsetof(FluxTupleHeader, t_attrs_bitmap) == FLUX_TUPLE_OVERHEAD, + "FLUX tuple null bitmap must begin at FLUX_TUPLE_OVERHEAD"); +#define FLUX_MAX_TUPLE_SIZE MAXALIGN_DOWN(BLCKSZ - FLUX_PAGE_OVERHEAD - sizeof(ItemIdData)) +#define FLUX_OVERFLOW_THRESHOLD (FLUX_MAX_TUPLE_SIZE / 4) + +/* + * Hard ceiling on the number of line pointers a FLUX page can hold. Unlike + * heap, FLUX pages mix full tuples with small overflow-continuation records, + * and FluxPageAddTuple deliberately omits PAI_IS_HEAP so PageAddItemExtended + * does not clamp offsets to MaxHeapTuplesPerPage. The smallest storable item + * is an overflow-record header, so the densest possible packing is bounded by + * that item size plus its line pointer. This is the FLUX-true analogue of + * MaxHeapTuplesPerPage and must be used for dense TID encoding so that every + * valid offset maps to a distinct index without aliasing into the next block. + */ +#define MaxFluxItemsPerPage \ + ((int) ((BLCKSZ - FLUX_PAGE_OVERHEAD) / \ + (FLUX_OVERFLOW_RECORD_OVERHEAD + sizeof(ItemIdData)))) + +/* + * Fill factor support. Unlike heap, FLUX has STABLE TIDs and updates rows + * IN PLACE -- a row that grows (e.g. an accumulating numeric like TPC-C + * w_ytd/d_ytd gaining a digit) cannot be relocated to another page the way + * heap moves a grown tuple. It must fit on its home page or the UPDATE fails + * with "does not fit". So FLUX must reserve per-page headroom by default; + * packing pages 100%% full (heap's default) guarantees that any in-place + * growth on a full page aborts. The default reserves ~10%% of each page, + * which comfortably covers digit-growth of numeric columns plus transient + * line-pointer bloat from concurrent updates to a hot page. Users can raise + * it with WITH (fillfactor=N) for append-mostly tables that never grow rows. + */ +#define FLUX_MIN_FILLFACTOR 10 +#define FLUX_DEFAULT_FILLFACTOR 90 + +/* Macros for tuple access */ +#define FluxTupleGetHeader(tuple) ((tuple)->t_data) +#define FluxTupleGetData(tuple) \ + ((char *) (tuple)->t_data + FLUX_TUPLE_OVERHEAD) + +/* + * Version-pointer accessors (WS-PVS1). + * + * The version-chain head now lives in the fixed header field t_verptr (it + * was formerly an unaligned 8-byte trailer located by item_len - 8). The + * item_len parameter is retained for source compatibility with existing + * call sites but is no longer used. + */ +static inline RelUndoRecPtr +FluxTupleGetVersionPtr(const FluxTupleHeader *hdr, Size item_len) +{ + (void) item_len; + return hdr->t_verptr; +} + +static inline void +FluxTupleSetVersionPtr(FluxTupleHeader *hdr, Size item_len, RelUndoRecPtr ptr) +{ + (void) item_len; + hdr->t_verptr = ptr; +} + +/* + * FluxReconstructVisibleVersion (WS-PVS2) + * + * Walk the per-tuple version chain in the UNDO fork to find the image + * the reader's MVCC snapshot should see in place of the on-page (newer) + * data. Returns true and populates *out_data / *out_len with a palloc'd + * reconstructed image when a step back was taken; returns false when the + * on-page image is what the reader should see. + * + * See src/backend/access/flux/flux_pvs.c for the algorithm. + */ +extern bool FluxReconstructVisibleVersion(Relation rel, ItemPointer tid, + const char *onpage_image, + Size onpage_len, + Snapshot snapshot, + char **out_data, int *out_len); + +/* Slot operations for FLUX tuples */ +extern PGDLLIMPORT const TupleTableSlotOps TTSOpsFluxTuple; +extern void FluxSlotStoreTuple(TupleTableSlot *slot, FluxTupleHeader *tuple, + uint32 tuple_len, Buffer buffer); +extern void FluxSlotStoreMaterializedTuple(TupleTableSlot *slot, + FluxTupleHeader *tuple, + uint32 tuple_len); + +#define TTS_IS_FLUXTUPLE(slot) ((slot)->tts_ops == &TTSOpsFluxTuple) + +/* Function prototypes */ +extern Size FluxComputeDataSize(TupleDesc tupdesc, Datum *values, bool *isnull); +extern void flux_toast_tuple(Relation rel, Datum *values, bool *isnull, + Datum *oldvalues, bool *oldisnull, + ToastTupleContext *ttc, ToastAttrInfo *toast_attr, + bool *changed, uint32 options); +extern void flux_toast_cleanup(ToastTupleContext *ttc); +extern void flux_toast_delete(Relation rel, Datum *values, bool *isnull, + bool is_speculative); +extern FluxTuple FluxFormTuple(TupleDesc tupdesc, Datum *values, bool *isnull, + Relation rel, FluxOverflowBuffers *overflow_buffers); +extern FluxTuple FluxFormTupleForceShrink(TupleDesc tupdesc, Datum *values, + bool *isnull, Relation rel, + FluxOverflowBuffers *overflow_buffers); +extern FluxTuple FluxFormTupleUpdate(TupleDesc tupdesc, Datum *values, + bool *isnull, Relation rel, + FluxOverflowBuffers *overflow_buffers, + const FluxOverflowPtr *old_ovptrs, + const bool *old_ovpresent); +extern void FluxDeformTuple(Relation rel, FluxTuple tuple, TupleDesc tupdesc, Datum *values, bool *isnull); +extern void FluxFreeTuple(FluxTuple tuple); +extern bool FluxTupleToSlot(FluxTupleHeader *tuple_header, TupleTableSlot *slot); +extern bool FluxTupleToSlotWithOverflow(FluxTupleHeader *tuple_header, + TupleTableSlot *slot, Relation rel); + +/* Page management */ +extern void FluxInitPage(Page page, Size pageSize); +extern OffsetNumber FluxPageAddTuple(Page page, FluxTuple tuple, Size tuple_size); +extern bool FluxPageUpdateTuple(Page page, OffsetNumber offnum, FluxTuple new_tuple, + uint64 old_commit_ts, uint64 new_commit_ts); +extern int FluxPageGetLiveTuples(Page page, uint64 snapshot_ts); +extern void FluxPageDefragment(Page page); +extern void FluxPageIndexTupleDelete(Page page, OffsetNumber offnum); +extern int FluxPagePruneOpt(Relation rel, Buffer buffer); + +/* Overflow handling - column-level overflow */ +extern Datum FluxStoreOverflowColumn(Relation rel, Datum value, int attnum, + Size inline_prefix_size, + FluxOverflowBuffers *overflow_buffers); +extern Datum FluxFetchOverflowColumn(Relation rel, const void *overflow_varlena); +extern void FluxDeleteOverflowChain(Relation rel, BlockNumber first_block, + OffsetNumber first_offset); +extern int FluxCollectOverflowPtrs(FluxTupleHeader *tuple_hdr, + TupleDesc tupdesc, + BlockNumber *blocks, OffsetNumber *offsets, + int max_ptrs); +extern void FluxCollectOverflowPtrsByAttr(FluxTupleHeader *tuple_hdr, + TupleDesc tupdesc, + FluxOverflowPtr *out_ptrs, + bool *out_present, int natts); +extern void FluxDeleteTupleOverflows(Relation rel, FluxTupleHeader *tuple_hdr, + TupleDesc tupdesc); +extern bool FluxIsOverflowRecord(const void *item, Size item_len); + +/* + * Inline version of FluxIsOverflowRecord for hot scan paths. + * Checks whether an item is an overflow continuation record by testing + * the magic number in the header. + */ +static inline bool +FluxIsOverflowRecordInline(const void *item, Size item_len) +{ + if (item_len < sizeof(FluxOverflowRecordHeader)) + return false; + return ((const FluxOverflowRecordHeader *) item)->or_magic == + FLUX_OVERFLOW_RECORD_MAGIC; +} +extern void FluxGetOverflowStats(Relation rel, int64 *total_overflow_records, + int64 *total_overflow_bytes, int64 *avg_chain_length); +extern void FluxVacuumOverflowRecords(Relation rel); +extern BlockNumber FluxFindOverflowPageForReuse(Relation rel, Page head_page, + Size needed); + +/* Compression */ +extern Datum FluxCompressAttribute(Relation rel, Datum value, Oid typid, FluxCompressionType comp_type); +extern Datum FluxDecompressAttribute(Oid relid, Datum value, Oid typid, FluxCompressionHeader *header); +extern void FluxMaybeRefreshDict(Relation rel, const char *sample_buf, + const size_t *sample_sizes, int nsamples, + Size total); + +/* Free space management */ +extern BlockNumber FluxGetPageWithFreeSpace(Relation rel, Size needed); +extern void FluxRecordFreeSpace(Relation rel, BlockNumber page, Size freespace); +extern void FluxVacuumFSM(Relation rel, BlockNumber new_nblocks); + +/* Visibility Map management */ +extern void FluxVMInit(Relation rel); +extern void FluxVMSet(Relation rel, BlockNumber heapBlk, Buffer heapBuf, uint8 flags); +extern void FluxVMClear(Relation rel, BlockNumber heapBlk, Buffer heapBuf, uint8 flags); +extern bool FluxVMCheck(Relation rel, BlockNumber heapBlk, uint8 flags); +extern bool FluxVMCheckCached(Relation rel, BlockNumber heapBlk, uint8 flags, + Buffer *vmbuf, BlockNumber *vm_blockno); +extern void FluxVMPinBuffer(Relation rel, BlockNumber heapBlk, Buffer *vmbuf); +extern void FluxVMExtend(Relation rel, BlockNumber nheapblocks); +extern void FluxVMTruncate(Relation rel, BlockNumber nheapblocks); +extern Size FluxVMGetPageSize(void); +extern BlockNumber FluxVMMapHeapToVM(BlockNumber heapBlk); +extern void FluxVMUpdateForInsert(Relation rel, FluxTupleHeader *tuple, Buffer buffer); +extern void FluxVMUpdateForUpdate(Relation rel, Buffer buffer); +extern void FluxVMUpdateForDelete(Relation rel, Buffer buffer); +extern void FluxVMVacuumPage(Relation rel, Buffer buffer, bool all_visible, bool all_frozen); + +/* MVCC functions */ +extern uint64 FluxGetCommitTimestamp(void); +extern uint64 FluxGetTransactionTimestamp(void); +extern uint64 FluxGetOldestActiveTimestamp(void); +extern Size FluxMvccShmemSize(void); +extern void FluxMvccShmemInit(void); +extern const ShmemCallbacks FluxMvccShmemCallbacks; +extern void FluxCommitTransaction(void); +extern void FluxAbortTransaction(void); +extern uint64 FluxGetSnapshotTimestamp(Snapshot snapshot); +extern bool FluxTupleVisibleToSnapshot(FluxTupleHeader *tuple, Snapshot snapshot, + Oid relid, Buffer buffer); +extern void FluxUpdateOldestActiveTimestamp(void); +extern void FluxPrepareReassignSlot(int dummy_slot); +extern void FluxResolvePreparedSlot(int dummy_slot); +extern void FluxGetMvccStats(uint64 *current_ts, uint64 *oldest_ts, int *active_xacts); + +/* SSI (Serializable Snapshot Isolation) via predicate.c integration */ +extern void FluxCheckForSerializableConflictOut(Relation relation, + FluxTupleHeader *tuple, + Buffer buffer, + Snapshot snapshot); + +/* MVCC timestamp helpers (page-level bookkeeping; visibility uses xmin/xmax) */ +extern uint64 FluxGetDmlTimestamp(void); + +/* + * WS-PVS3 lost-update conflict probe (fork-driven). Reads the head verptr + * from the on-page tuple, resolves it in the UNDO fork, and reports whether + * that head record's committer is concurrent-or-later to snapshot (and not + * exclude_xid). If it is, the caller returns TM_Updated to drive EPQ. EPQ + * dedup lives in FluxTransactionState: FluxEpqReconcileMatches skips a + * probe we already bounced on for this exact (relid, tid, cid, head verptr, + * head xid); FluxEpqReconcileMark stamps the identity we just bounced on. + */ +extern bool FluxTupleHasCommittedUpdateAfter(Relation rel, + const FluxTupleHeader *tuple, + Size tuple_len, + Snapshot snapshot, + TransactionId exclude_xid, + RelUndoRecPtr *out_head_verptr, + TransactionId *out_head_xid, + bool *out_inprogress); +extern bool FluxEpqReconcileMatches(Snapshot snapshot, Oid relid, + ItemPointer tid, + RelUndoRecPtr head_verptr, + TransactionId head_xid); +extern void FluxEpqReconcileMark(Snapshot snapshot, Oid relid, + ItemPointer tid, + RelUndoRecPtr head_verptr, + TransactionId head_xid); +extern bool FluxTupleVisibleToSnapshotDual(FluxTupleHeader *tuple, + Snapshot snapshot, + Oid relid, Buffer buffer); +extern TransactionId FluxGetOldestXminHorizon(Relation rel); +extern bool FluxTupleDeadToAll(FluxTupleHeader *tuple, + TransactionId oldest_xmin); + +/* + * MultiXact support has been removed. Concurrent tuple locking is now + * tracked via the sLog (flux_slog.c). + */ + +/* Dirty block map (lock-free sLog bypass) */ +extern Size FluxDirtyMapShmemSize(void); +extern void FluxDirtyMapShmemInit(void); +extern const ShmemCallbacks FluxDirtyMapShmemCallbacks; + +extern bool flux_lazy_uncommitted_clear; + + +/* Lock operations */ +extern bool FluxLockTuple(Relation rel, ItemPointer tid, LockTupleMode mode, + bool wait, bool *have_tuple_lock); +extern void FluxUnlockTuple(Relation rel, ItemPointer tid, LockTupleMode mode); +extern void FluxLockPage(Relation rel, BlockNumber blkno, LOCKMODE mode); +extern void FluxUnlockPage(Relation rel, BlockNumber blkno, LOCKMODE mode); +extern bool FluxLockMultipleTuples(Relation rel, ItemPointerData *tids, int ntids, + LockTupleMode mode, bool wait); +extern void FluxLockRelationForDDL(Relation rel, LOCKMODE lockmode); +extern bool FluxHoldsTupleLock(Relation rel, ItemPointer tid, LockTupleMode mode); + +/* Table operations */ +extern void flux_tuple_insert(Relation relation, TupleTableSlot *slot, CommandId cid, + uint32 options, BulkInsertState bistate); +extern TM_Result flux_tuple_delete(Relation relation, ItemPointer tid, CommandId cid, + uint32 options, Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd); +extern TM_Result flux_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot, + CommandId cid, uint32 options, + Snapshot snapshot, Snapshot crosscheck, + bool wait, TM_FailureData *tmfd, LockTupleMode *lockmode, + TU_UpdateIndexes *update_indexes); +extern void flux_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples, + CommandId cid, uint32 options, BulkInsertState bistate); +extern void flux_relation_vacuum(Relation onerel, const VacuumParams *params, + BufferAccessStrategy bstrategy); +extern const TableAmRoutine *GetFluxTableAmRoutine(void); + +/* + * flux_tableam_handler is declared via PG_FUNCTION_INFO_V1 in + * flux_handler.c. Do not redeclare it here: on Windows that emits a + * __declspec(dllimport) prototype that conflicts with the implicit + * dllexport from the V1 info macro. Catalog references go through + * pg_proc by name. + */ + +/* Compression statistics and management */ +extern void FluxResetCompressionDict(void); + +/* In-place update statistics */ +extern void FluxGetUpdateStats(int64 *in_place, int64 *out_of_place, + int64 *defrag_triggered); + +/* + * FLUX-specific ANALYZE statistics + * + * These statistics capture properties unique to the FLUX storage format + * and are collected during ANALYZE. They are stored in the relation's + * pg_class.reloptions and consumed by the planner to improve cost estimates. + */ +typedef struct FluxRelationStats +{ + /* Compression effectiveness */ + double compression_ratio; /* avg uncompressed/compressed size */ + double pct_compressed; /* fraction of tuples that are compressed */ + + /* Overflow usage */ + double pct_overflow; /* fraction of tuples with overflow attrs */ + double avg_overflow_chain_len; /* avg overflow records per overflow + * tuple */ + int64 total_overflow_bytes; /* total bytes in overflow records */ + + /* Space efficiency */ + double avg_tuple_size; /* average on-disk tuple size (bytes) */ + double avg_live_per_page; /* average live tuples per page */ + double free_space_frac; /* average fraction of free space per page */ + double bloat_factor; /* allocated space / live data ratio */ + + /* Page-level summary */ + int64 total_pages; /* total pages in relation */ + int64 total_live_tuples; /* total live tuples counted */ + int64 total_dead_tuples; /* total dead tuples counted */ + + /* Per-tuple commit-ts word distribution */ + bool commit_ts_stats_valid; /* true if commit-ts word fields are populated */ + uint64 commit_ts_min; /* min commit-ts word seen */ + uint64 commit_ts_max; /* max commit-ts word seen */ +} FluxRelationStats; + +/* ANALYZE statistics collection (flux_stats.c) */ +extern void FluxCollectRelationStats(Relation rel, FluxRelationStats *stats); +extern void FluxLogRelationStats(Relation rel, const FluxRelationStats *stats, + int elevel); + +/* GUC variables */ +extern int flux_compression_level; +extern int flux_compression_algorithm; +extern bool flux_enable_compression; +extern bool flux_analyze_refresh_dict; +extern double flux_compression_min_ratio; +extern int flux_overflow_inline_prefix; + +/* sLog transaction callbacks (flux_operations.c) */ +extern void FluxEnsureSLogCallbacks(void); + +/* Two-phase commit support (flux_operations.c) */ +extern void AtPrepare_Flux(void); +extern void flux_twophase_postcommit(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void flux_twophase_postabort(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void flux_twophase_recover(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); + + +#endif /* FLUX_H */ diff --git a/src/include/access/flux_dirtymap.h b/src/include/access/flux_dirtymap.h new file mode 100644 index 0000000000000..1f33b297f79a9 --- /dev/null +++ b/src/include/access/flux_dirtymap.h @@ -0,0 +1,77 @@ +/*------------------------------------------------------------------------- + * + * flux_dirtymap.h + * Shared-memory dirty block map for the FLUX table access method. + * + * The dirty map tracks which heap pages have ever carried an in-place + * modification (in-place UPDATE or in-place DELETE) whose before-image a + * scanner might still need from the sLog. If a page's bit is CLEAR, every + * tuple on it is plain-committed with no retained before-image, and the scan + * path can skip the per-tuple sLog before-image probe for the whole page + * (the fast path). + * + * Implementation: a partitioned, open-addressed hash set of 64-bit page keys + * (((uint64) relid << 32) | blkno) in a fixed shared-memory buffer. The set + * is sharded into independent partitions by hash, each with its own writer + * spinlock; FluxDirtyMapCheck (the per-scanned-tuple hot path) is lock-free + * because the set is grow-only (a published key is never moved or cleared). + * Each (relid, blkno) page owns a distinct 64-bit key. A relation with no + * in-place modifications contributes no keys and costs no probes. + * + * Correctness invariant: + * - SET is MANDATORY. Every in-place modification must set the page's bit + * before the buffer lock that covers the modification is released, so a + * concurrent scanner on another backend always observes it. A scanner + * that pins-and-locks the page after the writer released the lock is + * guaranteed to see the set bit. + * - The map is GROW-ONLY during normal operation. A bit, once set, is not + * cleared by commit or abort. This is the safe direction: a stale set + * bit only costs an unnecessary sLog probe (which then returns the + * on-page value), whereas a wrongly-cleared bit while an old snapshot + * still needs the before-image would be a false negative -> WRONG + * RESULTS. Reclaiming bits safely requires rebuilding the clear set from + * the sLog's retained-entry set under the same xid horizon that gates + * before-image reclamation; that is a future optimization, not required + * for correctness. + * + * Overflow: the hash is fixed-size. If a partition ever fills past its + * load-factor ceiling, a sticky "full" flag is latched for that partition and + * every subsequent check on it returns dirty, degrading safely to the + * pre-fast-path behavior (always probe the sLog). + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/flux_dirtymap.h + * + *------------------------------------------------------------------------- + */ +#ifndef FLUX_DIRTYMAP_H +#define FLUX_DIRTYMAP_H + +#include "storage/block.h" +#include "storage/shmem.h" + +/* Shared memory sizing and initialization */ +extern Size FluxDirtyMapShmemSize(void); +extern void FluxDirtyMapShmemInit(void); +extern const ShmemCallbacks FluxDirtyMapShmemCallbacks; + +/* + * Mark a page's bit dirty (called from the INSERT/UPDATE/DELETE in-place + * paths while the page's buffer is exclusively locked). Idempotent; safe to + * call repeatedly for the same page. + */ +extern void FluxDirtyMapMark(Oid relid, BlockNumber blkno); + +/* + * Query: might the page carry a retained in-place modification? + * + * Returns true if the page's bit is set (or the map has overflowed), in which + * case the scan path must run the per-tuple sLog probe. Returns false only + * when the bit is provably clear, in which case the scan path may skip the + * probe for the whole page. + */ +extern bool FluxDirtyMapCheck(Oid relid, BlockNumber blkno); + +#endif /* FLUX_DIRTYMAP_H */ diff --git a/src/include/access/flux_undo.h b/src/include/access/flux_undo.h new file mode 100644 index 0000000000000..d3a3faac8cda2 --- /dev/null +++ b/src/include/access/flux_undo.h @@ -0,0 +1,83 @@ +/*------------------------------------------------------------------------- + * + * flux_undo.h + * Public interface for the FLUX UNDO resource manager + * + * FLUX participates in UNDO-in-WAL via its own UNDO resource manager + * (UNDO_RMID_FLUX). Records are written through the shared + * UndoBuffer* (access/undobuffer.h) / Xact-level UNDO APIs (access/xactundo.h); rollback is + * dispatched via undoapply.c to flux_undo_apply() based on the rmid + * stamped into each UNDO record. + * + * Visibility correctness for aborted transactions is handled by + * FLUX's sLog + FLUX_TUPLE_UNCOMMITTED flag, independently of + * physical UNDO application. The UNDO records written here drive + * the logical-revert worker's physical cleanup of aborted rows so + * VACUUM does not have to. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * src/include/access/flux_undo.h + * + *------------------------------------------------------------------------- + */ +#ifndef FLUX_UNDO_H +#define FLUX_UNDO_H + +#include "access/undodefs.h" +#include "access/undormgr.h" +#include "storage/itemptr.h" + +/* + * FLUX's UNDO resource-manager ID. Defined in FLUX's own header, not in the + * generic access/undormgr.h, so the UNDO core names no specific consumer. + * See access/undormgr.h for the shared, WAL-durable ID number space. + */ +#define UNDO_RMID_FLUX 5 + +/* + * FLUX UNDO subtypes. Values occupy the 16-bit urec_info field of + * the UNDO record header and are orthogonal to the FLUX WAL opcodes + * in flux_xlog.h. + */ +#define FLUX_UNDO_INSERT 0x0001 +#define FLUX_UNDO_UPDATE 0x0002 /* full-tuple before-image */ +#define FLUX_UNDO_DELETE 0x0003 /* restore deleted tuple */ + +/* + * Common fixed-length header for every FLUX UNDO payload. The + * variable-length tuple / diff image (if any) follows immediately + * after the header. + * + * The header is deliberately small and self-describing so the same + * struct can be passed as part1 in UndoBufferAddRecordParts() + * avoiding an intermediate palloc. + */ +typedef struct FluxUndoPayloadHeader +{ + ItemPointerData tid; /* target tuple id */ + uint32 tuple_len; /* length of trailing tuple/diff image */ + uint16 flags; /* future use: partial-tuple, index-flags */ + uint16 pad; +} FluxUndoPayloadHeader; + +#define SizeOfFluxUndoPayloadHeader (sizeof(FluxUndoPayloadHeader)) + +/* flags bits */ +#define FLUX_UNDO_FLAG_HAS_TUPLE 0x0001 +#define FLUX_UNDO_FLAG_PARTIAL_TUPLE 0x0002 + +/* + * Registration entry point, called once at postmaster startup from + * InitializeUndoSubsystem() alongside HeapUndoRmgrInit and friends. + */ +extern void FluxUndoRmgrInit(void); + +/* + * Install the FLUX implementations of the AM-neutral per-relation UNDO + * hooks (see access/relundo.h). Called from FluxUndoRmgrInit() so the + * pointers are live before crash recovery replays any RELUNDO CLR. + */ +extern void FluxRelUndoInstallHooks(void); + +#endif /* FLUX_UNDO_H */ diff --git a/src/include/access/flux_xlog.h b/src/include/access/flux_xlog.h new file mode 100644 index 0000000000000..5cf26a980f6d5 --- /dev/null +++ b/src/include/access/flux_xlog.h @@ -0,0 +1,521 @@ +/*------------------------------------------------------------------------- + * + * flux_xlog.h + * FLUX table access method WAL definitions + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/flux_xlog.h + * + *------------------------------------------------------------------------- + */ +#ifndef FLUX_XLOG_H +#define FLUX_XLOG_H + +#include "postgres.h" + +#include "access/xlogreader.h" +#include "lib/stringinfo.h" +#include "storage/buf.h" +#include "storage/off.h" + +/* Forward declarations */ +typedef struct FluxTupleData *FluxTuple; +typedef enum FluxCompressionType FluxCompressionType; +typedef struct RelationData *Relation; +typedef struct FluxOverflowBuffers FluxOverflowBuffers; +struct RelUndoStageResult; + +/* + * Heap-format tuple image for logical decoding. + * + * Logical decoding consumes a heap-format image of the FLUX tuple that the + * write path appends to the end of the main WAL data channel as + * "[heap bytes][uint32 heap_len]" (see decode.c). Forming that image calls + * heap_form_tuple()/palloc(), which are forbidden inside a WAL critical + * section. Callers therefore prepare the image with + * FluxXLogPrepareLogicalImage() BEFORE entering the critical section; the + * WAL functions only register the prebuilt bytes (an allocation-free + * operation) inside the section. The image must stay alive until after + * XLogInsert() returns; release it with FluxXLogReleaseLogicalImage() after + * END_CRIT_SECTION(). + * + * When the relation is not logically logged, prepare leaves data == NULL and + * the WAL functions skip the append. + */ +typedef struct FluxLogicalImage +{ + char *data; /* palloc'd heap tuple body, or NULL */ + uint32 len; /* heap tuple t_len (also written trailing) */ +} FluxLogicalImage; + +/* + * WAL record types for FLUX + */ +/* + * WAL record types for FLUX. + * + * Each opcode must be unique. The info byte uses bits 0-7 with + * XLR_INFO_MASK occupying the upper bits, so we have the lower + * nibble(s) available for opcodes. + */ +#define XLOG_FLUX_INSERT 0x00 +#define XLOG_FLUX_UPDATE_INPLACE 0x10 +#define XLOG_FLUX_DELETE 0x20 +#define XLOG_FLUX_DEFRAG 0x30 /* single-page defrag */ +#define XLOG_FLUX_OVERFLOW_WRITE 0x40 +#define XLOG_FLUX_COMPRESS 0x50 +#define XLOG_FLUX_INIT_PAGE 0x60 +#define XLOG_FLUX_CROSS_PAGE_DEFRAG 0x70 /* cross-page tuple move */ +#define XLOG_FLUX_VM_SET 0x80 /* Set visibility map bits */ +#define XLOG_FLUX_VM_CLEAR 0x90 /* Clear visibility map bits */ +#define XLOG_FLUX_LOCK 0xA0 /* Tuple lock */ +#define XLOG_FLUX_CAS_UPDATE 0xB0 /* Same-size CAS in-place update */ +#define XLOG_FLUX_WRITE_DICT 0xC0 /* Compression-dictionary fork write */ +#define XLOG_FLUX_MULTI_INSERT 0xD0 /* Batched multi-tuple insert */ +#define XLOG_FLUX_CAS_UPDATE_UNDO 0xE0 /* CAS update folded with UNDO + * before-image */ +#define XLOG_FLUX_OPMASK 0xF0 + +/* Aliases for backward compatibility / clarity */ +#define XLOG_FLUX_VACUUM XLOG_FLUX_DEFRAG +#define XLOG_FLUX_UPDATE XLOG_FLUX_UPDATE_INPLACE + +/* Flags for xl_flux_overflow_write */ +#define FLUX_OVERFLOW_WAL_NEW_RECORD 0x0000 /* New overflow record */ +#define FLUX_OVERFLOW_WAL_LINK_UPDATE 0x0001 /* Link update only */ + +/* + * Common WAL record flags. + * + * These appear in the 'flags' field of the DML WAL record structures + * (xl_flux_insert, xl_flux_update, xl_flux_delete). + * + * Note: bit 0x0001 is reserved (formerly FLUX_WAL_HAS_HLC) and unused. + */ +#define FLUX_WAL_CROSS_PAGE 0x0002 /* Cross-page out-of-place update */ +#define FLUX_WAL_HAS_OVERFLOW_BLK0 0x0004 /* Block 0 buf data has overflow + * records */ +#define FLUX_WAL_PREFIX_SUFFIX 0x0008 /* Update uses prefix/suffix + * compression */ +/* + * Heap-format tuple image is appended to the WAL record for the benefit of + * logical decoding. Set when RelationIsLogicallyLogged(rel) at WAL-emit + * time. The layout of the appended region is: + * + * uint32 logical_len -- bytes of the heap-tuple payload + * bytes[logical_len] HeapTuple t_data bytes + * + * For INSERT / DELETE the record contains exactly one heap-tuple payload. + * For UPDATE it contains two back-to-back payloads (old, then new). + */ +#define FLUX_WAL_LOGICAL_TUPLE 0x0010 + +#ifndef FRONTEND +/* + * WAL record data structures + */ +typedef struct xl_flux_insert +{ + OffsetNumber offnum; /* Offset number */ + uint16 flags; /* Flags (FLUX_WAL_* bits) */ + uint32 tuple_len; /* Length of tuple data that follows */ + uint64 commit_ts; /* Commit timestamp */ + /* Tuple data follows */ +} xl_flux_insert; + +typedef struct xl_flux_update +{ + OffsetNumber offnum; /* Offset number on source page (block 0) */ + uint16 flags; /* Flags (FLUX_WAL_* bits) */ + uint64 old_commit_ts; /* Old commit timestamp */ + uint64 new_commit_ts; /* New commit timestamp */ + uint16 old_tuple_len; /* Length of old tuple */ + uint16 new_tuple_len; /* Length of new tuple data that follows */ + uint8 dst_block_id; /* Block ID of destination page for cross-page + * updates (only valid when + * FLUX_WAL_CROSS_PAGE is set in flags) */ + uint8 pad[3]; /* Padding for alignment */ + /* New tuple data follows (old tuple data is in UNDO fork only) */ +} xl_flux_update; + +/* + * Prefix/suffix compression header for in-place updates. + * + * When FLUX_WAL_PREFIX_SUFFIX is set in xl_flux_update.flags, this header + * immediately follows the xl_flux_update struct and precedes the diff data. + * Only the changed bytes (between prefixlen and len-suffixlen) are logged. + * + * The redo handler reconstructs the full new tuple by: + * 1. Reading the existing tuple from the page (old data) + * 2. Keeping old[0..prefixlen-1] as-is + * 3. Copying the diff data into old[prefixlen..len-suffixlen-1] + * 4. Keeping old[len-suffixlen..len-1] as-is + */ +typedef struct xl_flux_prefix_suffix +{ + uint16 prefixlen; /* Bytes of common prefix */ + uint16 suffixlen; /* Bytes of common suffix */ +} xl_flux_prefix_suffix; + +typedef struct xl_flux_delete +{ + OffsetNumber offnum; /* Offset number */ + uint16 flags; /* Flags (FLUX_WAL_* bits) */ + uint32 tuple_len; /* Length of old tuple (for logical decoding) */ + uint64 commit_ts; /* Commit timestamp */ + /* Old tuple data is in UNDO fork only */ +} xl_flux_delete; + +/* + * WAL record for a batched multi-tuple insert (XLOG_FLUX_MULTI_INSERT). + * + * flux_multi_insert packs many tuples onto a single page in one critical + * section. Rather than emit one xl_flux_insert per tuple (and force a + * full-page image to stay crash-safe), we log every tuple body once in a + * single record modelled on heap's xl_heap_multi_insert. + * + * Layout of the record's main data: + * + * xl_flux_multi_insert -- header (ntuples, flags, ts) + * repeated ntuples times: + * xl_flux_multi_insert_tuple -- per-tuple header + * char body[datalen] -- tuple t_data bytes + * + * If flags & FLUX_WAL_LOGICAL_TUPLE, ntuples heap-format logical images + * follow (one per tuple) after the per-tuple region, each as [body][uint32 len]. + */ +typedef struct xl_flux_multi_insert +{ + uint16 ntuples; /* Number of tuples in this batch */ + uint16 flags; /* Flags (FLUX_WAL_* bits) */ + uint64 commit_ts; /* Shared commit timestamp */ + /* xl_flux_multi_insert_tuple entries follow */ +} xl_flux_multi_insert; + +#define SizeOfFluxMultiInsert sizeof(xl_flux_multi_insert) + +typedef struct xl_flux_multi_insert_tuple +{ + OffsetNumber offnum; /* Target offset on the page */ + uint16 datalen; /* Length of tuple t_data bytes that follow */ + /* char body[datalen] follows */ +} xl_flux_multi_insert_tuple; + +#define SizeOfFluxMultiInsertTuple sizeof(xl_flux_multi_insert_tuple) + +typedef struct xl_flux_lock +{ + OffsetNumber offnum; /* Offset number */ + uint16 flags; /* Flags */ + uint8 infomask; /* Infomask bits (uint8) */ + uint8 lock_mode; /* LockTupleMode */ +} xl_flux_lock; + +/* + * WAL record for same-size CAS in-place update (XLOG_FLUX_CAS_UPDATE). + * + * This is a lightweight record logged by the tuple-level CAS update fast + * path. Only the changed portion of the tuple data is logged (the region + * between data_offset and data_offset+data_len within the on-page tuple). + * The redo handler patches these bytes directly into the tuple on the page. + */ +typedef struct xl_flux_cas_update +{ + OffsetNumber offnum; /* Tuple offset on page */ + uint16 flags; /* FLUX_WAL_* bits */ + uint16 data_offset; /* Byte offset within tuple for patch start */ + uint16 data_len; /* Length of replacement data */ + uint64 new_commit_ts; /* New commit timestamp for the tuple */ + /* char data[data_len] follows */ +} xl_flux_cas_update; + +/* + * WAL record for a CAS in-place update folded with its UNDO before-image + * (XLOG_FLUX_CAS_UPDATE_UNDO). + * + * This is the FOLD variant of the CAS fast path: instead of emitting a + * separate RM_FLUX_ID CAS-update record and a separate RM_RELUNDO_ID + * before-image record, one record carries both. Block 0 is the main-fork + * page (same redo byte-diff as xl_flux_cas_update). Block 1 is the + * relundo-fork data page (the UNDO record bytes, replayed exactly like + * relundo_redo_insert). Block 2 is the relundo metapage, present only when + * the UNDO record started a fresh relundo page (is_new_page). + * + * The redo handler replays block 0 like flux_xlog_cas_update_redo, then + * block 1 like relundo_redo_insert (honoring the new-page INIT and the + * metapage FPI). + */ +typedef struct xl_flux_cas_update_undo +{ + /* --- redo half (block 0): mirrors xl_flux_cas_update --- */ + OffsetNumber offnum; /* Tuple offset on main-fork page */ + uint16 flags; /* FLUX_WAL_* bits */ + uint16 data_offset; /* Byte offset within tuple for patch start */ + uint16 data_len; /* Length of replacement data */ + uint64 new_commit_ts; /* New commit timestamp for the tuple */ + + /* --- undo half (block 1): mirrors xl_relundo_insert --- */ + uint8 urec_type; /* UNDO record type */ + uint8 is_new_page; /* first record on a freshly allocated page */ + uint16 urec_len; /* UNDO record length */ + uint16 page_offset; /* page-absolute offset of the UNDO record */ + uint16 new_pd_lower; /* shadow pd_lower after the UNDO write */ + TransactionId max_xid; /* undo-page max_xid watermark after the bump */ + /* char redo_data[data_len] follows (block 0 byte-diff) */ +} xl_flux_cas_update_undo; + +#define SizeOfFluxCasUpdateUndo sizeof(xl_flux_cas_update_undo) + +typedef struct xl_flux_defrag +{ + uint16 ntuples; /* Number of tuples moved */ + uint64 commit_ts; /* Commit timestamp */ + /* Array of offset mappings follows */ +} xl_flux_defrag; + +typedef struct xl_flux_overflow_write +{ + OffsetNumber offnum; /* Offset of overflow record on page */ + uint16 flags; /* Flags (0 = new record, 1 = link update) */ + uint32 data_len; /* Length of overflow data chunk */ + uint64 commit_ts; /* Commit timestamp */ + /* FluxOverflowRecordHeader + data follows for new records */ + /* FluxOverflowRecordHeader follows for link updates */ +} xl_flux_overflow_write; + +typedef struct xl_flux_compress +{ + OffsetNumber offnum; /* Offset number */ + uint16 attr_num; /* Attribute number */ + uint8 comp_type; /* Compression type */ + uint8 comp_level; /* Compression level */ + uint32 orig_size; /* Original size */ + uint32 comp_size; /* Compressed size */ + uint64 commit_ts; /* Commit timestamp */ + /* Compressed data follows */ +} xl_flux_compress; + +typedef struct xl_flux_vacuum +{ + uint32 ntuples; /* Number of removed tuples */ +} xl_flux_vacuum; + +/* + * Cross-page defragmentation: records moving a tuple from a source page + * (block ref 1) to a target page (block ref 0). The source line pointer + * is set LP_UNUSED and the tuple data is added to the target page. + * + * If full-page images are present, recovery simply restores both pages. + * Otherwise, recovery replays the move: adds the tuple to the target + * and marks the source slot unused. + */ +typedef struct xl_flux_cross_page_defrag +{ + OffsetNumber src_offnum; /* Source line pointer offset (on block 1) */ + OffsetNumber dst_offnum; /* Target line pointer offset (on block 0) */ + uint32 tuple_len; /* Length of moved tuple data */ + /* Tuple data follows */ +} xl_flux_cross_page_defrag; + +typedef struct xl_flux_init_page +{ + uint32 flags; /* Page flags */ + uint64 commit_ts; /* Initial commit timestamp */ +} xl_flux_init_page; + +/* + * Compression-dictionary fork write (XLOG_FLUX_WRITE_DICT). + * + * The dictionary fork (FLUX_DICT_FORKNUM) stores trained ZSTD/LZ4 dictionary + * blobs append-only. Its pages use a non-standard layout (the directory + * metapage keeps FluxDictMeta above pd_lower; data pages keep payload above + * pd_lower), so the redo path cannot reconstruct them from logical deltas. + * Instead each dirtied dict-fork page is logged as a full-page image with + * REGBUF_FORCE_IMAGE and the redo handler simply restores the registered + * block. No record-specific payload is required beyond the block image. + */ +typedef struct xl_flux_write_dict +{ + BlockNumber blkno; /* Dictionary-fork block being written */ +} xl_flux_write_dict; + +/* + * Visibility Map WAL records + */ +typedef struct xl_flux_vm_set +{ + BlockNumber heapBlk; /* Heap block number */ + uint8 flags; /* VM flags being set */ +} xl_flux_vm_set; + +typedef struct xl_flux_vm_clear +{ + BlockNumber heapBlk; /* Heap block number */ + uint8 flags; /* VM flags being cleared */ +} xl_flux_vm_clear; + +/* + * Offset mapping for defragmentation + */ +typedef struct FluxOffsetMapping +{ + OffsetNumber old_offnum; + OffsetNumber new_offnum; +} FluxOffsetMapping; +#else /* FRONTEND */ + +/* Frontend-safe versions of WAL record structures */ +typedef struct xl_flux_insert +{ + uint16 offnum; /* Offset number */ + uint16 flags; /* Flags */ + uint32 tuple_len; /* Length of tuple data */ + uint64 commit_ts; /* Commit timestamp */ +} xl_flux_insert; + +typedef struct xl_flux_delete +{ + uint16 offnum; /* Offset number */ + uint16 flags; /* Flags */ + uint32 tuple_len; /* Length of old tuple */ + uint64 commit_ts; /* Commit timestamp */ +} xl_flux_delete; + +typedef struct xl_flux_multi_insert +{ + uint16 ntuples; /* Number of tuples in this batch */ + uint16 flags; /* Flags */ + uint64 commit_ts; /* Shared commit timestamp */ +} xl_flux_multi_insert; + +typedef struct xl_flux_multi_insert_tuple +{ + uint16 offnum; /* Target offset on the page */ + uint16 datalen; /* Length of tuple data that follows */ +} xl_flux_multi_insert_tuple; + +typedef struct xl_flux_update +{ + uint16 offnum; /* Offset number */ + uint16 flags; /* Flags */ + uint64 old_commit_ts; /* Old commit timestamp */ + uint64 new_commit_ts; /* New commit timestamp */ + uint16 old_tuple_len; /* Length of old tuple */ + uint16 new_tuple_len; /* Length of new tuple */ +} xl_flux_update; + +typedef struct xl_flux_vacuum +{ + uint32 ntuples; /* Number of removed tuples */ +} xl_flux_vacuum; + +typedef struct xl_flux_compress +{ + uint16 offnum; /* Offset number */ + uint16 attr_num; /* Attribute number */ + uint8 comp_type; /* Compression type */ + uint8 comp_level; /* Compression level */ + uint32 orig_size; /* Original size */ + uint32 comp_size; /* Compressed size */ + uint64 commit_ts; /* Commit timestamp */ +} xl_flux_compress; + +#endif /* !FRONTEND */ + +/* + * Function prototypes + */ + +/* Frontend-safe function prototypes (pg_waldump, etc.) */ +extern void flux_desc(StringInfo buf, XLogReaderState *record); +extern const char *flux_identify(uint8 info); + +#ifndef FRONTEND +/* WAL replay and logging functions - backend only */ +extern void flux_redo(XLogReaderState *record); +extern void flux_mask(char *page, BlockNumber blkno); +/* + * Prepare/release the heap-format logical-decoding image. Call prepare + * BEFORE START_CRIT_SECTION() and release AFTER END_CRIT_SECTION(). When rel + * is not logically logged, prepare sets img->data = NULL and the WAL + * functions emit no logical image. + */ +extern void FluxXLogPrepareLogicalImage(Relation rel, FluxTuple rtup, + FluxLogicalImage *img); +extern void FluxXLogReleaseLogicalImage(FluxLogicalImage *img); + +extern XLogRecPtr FluxXLogInsert(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple tuple, uint64 commit_ts, + FluxOverflowBuffers *overflow_buffers, + FluxLogicalImage *logical_img, + bool force_page_image); +extern XLogRecPtr FluxXLogUpdate(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple old_tuple, FluxTuple new_tuple, + uint64 old_commit_ts, uint64 new_commit_ts, + FluxOverflowBuffers *overflow_buffers, + Buffer new_buffer, + FluxLogicalImage *old_img, + FluxLogicalImage *new_img); +extern XLogRecPtr FluxXLogDelete(Relation rel, Buffer buffer, OffsetNumber offnum, + FluxTuple tuple, uint64 commit_ts, + FluxLogicalImage *logical_img); + +/* + * Log a batch of tuples inserted onto a single page by flux_multi_insert. + * Every tuple body is logged once (no forced full-page image), modelled on + * heap's xl_heap_multi_insert. offnums[i] is the on-page offset assigned to + * tuples[i]; logical_imgs may be NULL (no logical decoding) or an array of + * ntuples prepared images. + */ +extern XLogRecPtr FluxXLogMultiInsert(Relation rel, Buffer buffer, + OffsetNumber *offnums, FluxTuple *tuples, + int ntuples, uint64 commit_ts, + FluxLogicalImage *logical_imgs); + +extern XLogRecPtr FluxXLogDefrag(Relation rel, Buffer buffer, + FluxOffsetMapping *mappings, int nmappings, uint64 commit_ts); +extern XLogRecPtr FluxXLogOverflowWrite(Relation rel, Buffer buffer, + OffsetNumber offnum, char *record_data, + uint32 record_len, uint16 flags, + uint64 commit_ts); +extern XLogRecPtr FluxXLogCompress(Relation rel, Buffer buffer, OffsetNumber offnum, + uint16 attr_num, FluxCompressionType comp_type, + uint8 comp_level, char *comp_data, uint32 orig_size, uint32 comp_size, + uint64 commit_ts); +extern XLogRecPtr FluxXLogInitPage(Relation rel, Buffer buffer, uint32 flags, uint64 commit_ts); +extern XLogRecPtr FluxXLogCrossPageDefrag(Relation rel, + Buffer dst_buf, OffsetNumber dst_offnum, + Buffer src_buf, OffsetNumber src_offnum, + const void *tuple_data, uint32 tuple_len); +extern XLogRecPtr FluxXLogCasUpdate(Relation rel, Buffer buffer, + OffsetNumber offnum, + uint16 data_offset, uint16 data_len, + const char *new_data, + uint64 new_commit_ts); +extern XLogRecPtr FluxXLogCasUpdateUndo(Relation rel, Buffer buffer, + OffsetNumber offnum, + uint16 data_offset, uint16 data_len, + const char *new_data, + uint64 new_commit_ts, + const struct RelUndoStageResult *undo); + +/* + * Log a compression-dictionary fork page as a full-page image so crash + * recovery and physical replicas reproduce the append-only dict fork. The + * caller must hold the buffer locked and have already modified it inside a + * critical section; this registers the page with REGBUF_FORCE_IMAGE and sets + * the page LSN. + */ +extern XLogRecPtr FluxXLogWriteDict(Relation rel, Buffer buffer); + +/* + * Logical replication decode entry point for FLUX WAL records. + */ +struct LogicalDecodingContext; +struct XLogRecordBuffer; +extern void flux_decode(struct LogicalDecodingContext *ctx, + struct XLogRecordBuffer *buf); +#endif /* !FRONTEND */ +#endif /* FLUX_XLOG_H */ diff --git a/src/include/access/hash.h b/src/include/access/hash.h index a8702f0e5ea13..ce8de4208ab7b 100644 --- a/src/include/access/hash.h +++ b/src/include/access/hash.h @@ -487,4 +487,9 @@ extern void hashbucketcleanup(Relation rel, Bucket cur_bucket, bool split_cleanup, IndexBulkDeleteCallback callback, void *callback_state); +/* hash_undo.c -- UNDO support */ +extern void HashUndoRmgrInit(void); +extern void HashUndoLogInsert(Relation rel, Relation heapRel, Buffer buf, + OffsetNumber offset); + #endif /* HASH_H */ diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h index 5176478c29583..3cf4050cddee9 100644 --- a/src/include/access/heapam.h +++ b/src/include/access/heapam.h @@ -544,4 +544,5 @@ heap_execute_freeze_tuple(HeapTupleHeader tuple, HeapTupleFreeze *frz) tuple->t_infomask2 = frz->t_infomask2; } + #endif /* HEAPAM_H */ diff --git a/src/include/access/heapam_xlog.h b/src/include/access/heapam_xlog.h index 3f79c389a90a7..4af279ea975ec 100644 --- a/src/include/access/heapam_xlog.h +++ b/src/include/access/heapam_xlog.h @@ -77,6 +77,8 @@ /* all_frozen_set always implies all_visible_set */ #define XLH_INSERT_ALL_FROZEN_SET (1<<5) +/* UNDO payload is embedded in this WAL record (bit 6 unused, confirmed by audit) */ +#define XLH_INSERT_HAS_UNDO (1<<6) /* * xl_heap_update flag values, 8 bits are available. @@ -90,6 +92,8 @@ #define XLH_UPDATE_CONTAINS_NEW_TUPLE (1<<4) #define XLH_UPDATE_PREFIX_FROM_OLD (1<<5) #define XLH_UPDATE_SUFFIX_FROM_OLD (1<<6) +/* UNDO payload is embedded in this WAL record (bit 7 unused, confirmed by audit) */ +#define XLH_UPDATE_HAS_UNDO (1<<7) /* convenience macro for checking whether any form of old tuple was logged */ #define XLH_UPDATE_CONTAINS_OLD \ @@ -107,6 +111,9 @@ /* See heap_delete() */ #define XLH_DELETE_NO_LOGICAL (1<<5) +/* UNDO payload is embedded in this WAL record (bit 6 unused, confirmed by audit) */ +#define XLH_DELETE_HAS_UNDO (1<<6) + /* convenience macro for checking whether any form of old tuple was logged */ #define XLH_DELETE_CONTAINS_OLD \ (XLH_DELETE_CONTAINS_OLD_TUPLE | XLH_DELETE_CONTAINS_OLD_KEY) diff --git a/src/include/access/logical_revert_worker.h b/src/include/access/logical_revert_worker.h new file mode 100644 index 0000000000000..43dc92c052911 --- /dev/null +++ b/src/include/access/logical_revert_worker.h @@ -0,0 +1,43 @@ +/*------------------------------------------------------------------------- + * + * logical_revert_worker.h + * Background worker for timer-driven Logical Revert via ATM scan + * + * The Logical Revert worker periodically scans the ATM (Aborted Transaction + * Map) for entries whose UNDO chains have not yet been applied, opens the + * target relation, applies the UNDO chain via the per-AM apply callback, + * marks the ATM entry as reverted, emits an XLOG_ATM_FORGET WAL record, and + * removes the entry from the ATM. + * + * This worker is timer-driven (periodic scan) rather than event-driven + * (queue-based). + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/logical_revert_worker.h + * + *------------------------------------------------------------------------- + */ +#ifndef LOGICAL_REVERT_WORKER_H +#define LOGICAL_REVERT_WORKER_H + +#include "postgres.h" + +/* Shared memory sizing and initialization */ +extern Size LogicalRevertShmemSize(void); +extern void LogicalRevertShmemInit(void); + +/* Worker entry points */ +extern void LogicalRevertWorkerMain(Datum main_arg); + +/* Launch a logical revert worker for a specific database */ +extern void StartLogicalRevertWorker(Oid dboid); +extern void LogicalRevertLauncherMain(Datum main_arg); +extern void LogicalRevertLauncherRegister(void); + +/* GUC parameters */ +extern int logical_revert_naptime; +extern int max_logical_revert_workers; + +#endif /* LOGICAL_REVERT_WORKER_H */ diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h index 3097e9bb1af9b..5ae836e96bc20 100644 --- a/src/include/access/nbtree.h +++ b/src/include/access/nbtree.h @@ -1331,4 +1331,43 @@ extern IndexBuildResult *btbuild(Relation heap, Relation index, struct IndexInfo *indexInfo); extern void _bt_parallel_build_main(dsm_segment *seg, shm_toc *toc); +/* + * nbtree UNDO support (nbtree_undo.c) + */ + +/* nbtree UNDO subtypes (stored in urec_info) */ +#define NBTREE_UNDO_INSERT_LEAF 0x0001 +#define NBTREE_UNDO_INSERT_UPPER 0x0002 +#define NBTREE_UNDO_INSERT_POST 0x0004 +#define NBTREE_UNDO_DELETE 0x0005 +#define NBTREE_UNDO_SPLIT_L 0x0006 +#define NBTREE_UNDO_SPLIT_R 0x0007 +#define NBTREE_UNDO_NEWROOT 0x0008 +#define NBTREE_UNDO_DEDUP 0x0009 +#define NBTREE_UNDO_VACUUM 0x000A + +/* + * NbtreeUndoInsertLeafHeader - Minimal payload header for INSERT_LEAF records + * + * This must match the first fields of the full NbtreeUndoInsertLeaf struct + * defined in nbtree_undo.c. Exposed here so the UNDO discard worker can + * extract (index_oid, blkno, offset) for targeted index pruning without + * depending on the full struct. + */ +typedef struct NbtreeUndoInsertLeafHeader +{ + Oid index_oid; /* OID of the index relation */ + BlockNumber blkno; /* Page where tuple was inserted */ + OffsetNumber offset; /* Offset of the inserted tuple */ +} NbtreeUndoInsertLeafHeader; + +#define SizeOfNbtreeUndoInsertLeafHeader \ + (offsetof(NbtreeUndoInsertLeafHeader, offset) + sizeof(OffsetNumber)) + +extern void NbtreeUndoRmgrInit(void); +extern void NbtreeUndoLogInsert(Relation rel, Relation heaprel, Buffer buf, + IndexTuple itup, Size itemsz, + OffsetNumber offset, bool isleaf); +extern void NbtreeUndoLogDedup(Relation rel, Relation heaprel, Buffer buf); + #endif /* NBTREE_H */ diff --git a/src/include/access/relundo.h b/src/include/access/relundo.h new file mode 100644 index 0000000000000..4b0f454575066 --- /dev/null +++ b/src/include/access/relundo.h @@ -0,0 +1,759 @@ +/*------------------------------------------------------------------------- + * + * relundo.h + * Per-relation UNDO for MVCC visibility determination + * + * This subsystem provides per-relation UNDO logging for table access methods + * that need to determine tuple visibility by walking UNDO chains. + * This is complementary to the existing cluster-wide UNDO system which is used + * for transaction rollback. + * + * ARCHITECTURE: + * ------------- + * Per-relation UNDO stores operation metadata (INSERT/DELETE/UPDATE/LOCK) within + * each relation's UNDO fork, enabling MVCC visibility checks via UNDO chain walking. + * Each UNDO record contains minimal metadata needed for visibility determination. + * + * This differs from cluster-wide UNDO which stores complete tuple data in shared + * log files for physical transaction rollback. The two systems coexist independently: + * + * Cluster-Wide UNDO (existing): Transaction rollback, crash recovery + * Per-Relation UNDO (this file): MVCC visibility determination + * + * UNDO POINTER FORMAT: + * ------------------- + * RelUndoRecPtr is a 64-bit pointer with three fields: + * Bits 0-15: Offset within page (16 bits, max 64KB pages) + * Bits 16-47: Block number (32 bits, max 4 billion blocks) + * Bits 48-63: Counter (16 bits, wraps every 65536 generations) + * + * The counter enables fast age comparison without reading UNDO pages. + * + * USAGE PATTERN: + * ------------- + * Table AMs that need per-relation UNDO follow this pattern: + * + * 1. RelUndoReserve() - Reserve space, pin buffer + * 2. Perform DML operation (may fail) + * 3. RelUndoFinish() - Write UNDO record, release buffer + * OR RelUndoCancel() - Release reservation on error + * + * Example: + * Buffer undo_buf; + * RelUndoRecPtr ptr = RelUndoReserve(rel, record_size, &undo_buf); + * + * // Perform DML (may error out safely) + * InsertTuple(rel, tid); + * + * // Commit UNDO record + * RelUndoFinish(rel, undo_buf, ptr, &header, payload, payload_size); + * + * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/relundo.h + * + *------------------------------------------------------------------------- + */ +#ifndef RELUNDO_H +#define RELUNDO_H + +#include "access/transam.h" +#include "access/xlogdefs.h" +#include "common/relpath.h" +#include "storage/block.h" +#include "storage/buf.h" +#include "storage/bufpage.h" +#include "storage/itemptr.h" +#include "storage/relfilelocator.h" +#include "utils/rel.h" +#include "utils/snapshot.h" + +/* + * RelUndoRecPtr: 64-bit pointer for per-relation UNDO records + * + * Layout: + * [63:48] Counter (16 bits) - Generation counter for age comparison + * [47:16] BlockNum (32 bits) - Block number in relation UNDO fork + * [15:0] Offset (16 bits) - Byte offset within page + */ +typedef uint64 RelUndoRecPtr; + +/* Invalid UNDO pointer constant */ +#define InvalidRelUndoRecPtr ((RelUndoRecPtr) 0) + +/* Check if pointer is valid */ +#define RelUndoRecPtrIsValid(ptr) \ + ((ptr) != InvalidRelUndoRecPtr) + +/* Extract counter field (bits 63:48) */ +#define RelUndoGetCounter(ptr) \ + ((uint16)(((ptr) >> 48) & 0xFFFF)) + +/* Extract block number field (bits 47:16) */ +#define RelUndoGetBlockNum(ptr) \ + ((BlockNumber)(((ptr) >> 16) & 0xFFFFFFFF)) + +/* Extract offset field (bits 15:0) */ +#define RelUndoGetOffset(ptr) \ + ((uint16)((ptr) & 0xFFFF)) + +/* Construct UNDO pointer from components */ +#define MakeRelUndoRecPtr(counter, blkno, offset) \ + ((((uint64)(counter)) << 48) | (((uint64)(blkno)) << 16) | ((uint64)(offset))) + +/* + * Per-relation UNDO record types + * + * These record the operations needed for MVCC visibility determination. + * Unlike cluster-wide UNDO (which stores complete tuples for rollback), + * per-relation UNDO stores only operation metadata. + */ +typedef enum RelUndoRecordType +{ + RELUNDO_INSERT = 1, /* Insertion record with TID range */ + RELUNDO_DELETE = 2, /* Deletion (batched up to 50 TIDs) */ + RELUNDO_UPDATE = 3, /* Update with old/new TID link */ + RELUNDO_TUPLE_LOCK = 4 /* SELECT FOR UPDATE/SHARE */ +} RelUndoRecordType; + +/* + * Common header for all per-relation UNDO records + * + * Every UNDO record starts with this fixed-size header, followed by + * type-specific payload data. + */ +typedef struct RelUndoRecordHeader +{ + uint16 urec_type; /* RelUndoRecordType */ + uint16 urec_len; /* Total length including header */ + TransactionId urec_xid; /* Creating transaction ID */ + RelUndoRecPtr urec_prevundorec; /* Previous record in chain */ + + /* Rollback support fields */ + uint16 info_flags; /* Information flags (see below) */ + uint16 tuple_len; /* Length of tuple data (0 if none) */ + /* Followed by type-specific payload + optional tuple data */ +} RelUndoRecordHeader; + +/* Size of the common UNDO record header */ +#define SizeOfRelUndoRecordHeader \ + sizeof(RelUndoRecordHeader) + +/* + * RelUndoRecordHeader info_flags values + * + * These flags indicate what additional data is stored with the UNDO record + * to support transaction rollback. + */ +#define RELUNDO_INFO_HAS_TUPLE 0x0001 /* Record contains complete tuple */ +#define RELUNDO_INFO_HAS_CLR 0x0002 /* CLR pointer is valid */ +#define RELUNDO_INFO_CLR_APPLIED 0x0004 /* CLR has been applied */ +#define RELUNDO_INFO_PARTIAL_TUPLE 0x0008 /* Delta/partial tuple only: the + * trailing tuple_len bytes are a + * RelUndoDiffRecord byte-diff, not a + * full before-image. Reverse-applied + * against the current on-page tuple to + * reconstruct the old bytes (see + * RelUndoApplyDiffReverse). A + * self-describing byte splice, so the + * engine reverse-applies it with no AM + * knowledge; the producing AM decides + * when the change is compact enough to + * be worth a delta. */ +#define RELUNDO_INFO_ESCROW 0x0010 /* commutative delta record: the + * consumer AM's escrow apply hook + * reverses it by ADDing the carried + * negated delta, not by restoring a + * full before-image */ + +/* + * RelUndoDiffRecord: on-disk byte-diff carried by a RELUNDO_UPDATE record + * flagged RELUNDO_INFO_PARTIAL_TUPLE. + * + * A byte-diff is a single splice against the NEW (current on-page) tuple: the + * longest common prefix and suffix are stripped, and only the differing middle + * of the OLD bytes is carried. Layout: this fixed header, then ins_len bytes + * of the old tuple's differing region. Reconstruction copies the new tuple's + * unchanged prefix, substitutes the carried old bytes, then copies the new + * tuple's unchanged suffix, producing an old_total_len-byte old tuple (which + * may differ from the new length when the update grew or shrank the row). + * + * The format is self-describing and AM-neutral, so RelUndoApplyDiffReverse + * reverses it during rollback/recovery without any table-AM callback. The + * producing AM (FLUX) builds it via FluxComputeTupleDiff and also reverses it + * in its own version-reconstruction read path via FluxApplyDiffReverse; both + * share this one wire struct. + */ +typedef struct RelUndoDiffRecord +{ + uint16 old_total_len; /* length of the reconstructed old tuple */ + uint16 offset; /* byte offset of the splice in the new tuple */ + uint16 del_len; /* new bytes removed at offset */ + uint16 ins_len; /* old bytes inserted at offset (== count of + * trailing old_bytes) */ + /* ins_len bytes of the old tuple's differing region follow */ +} RelUndoDiffRecord; + +#define SizeOfRelUndoDiffRecord (offsetof(RelUndoDiffRecord, ins_len) + sizeof(uint16)) + +/* + * RelUndoApplyDiffReverse - reconstruct old tuple bytes from new bytes + diff. + * + * Generic (AM-neutral) reverse-apply of a RelUndoDiffRecord: substitutes the + * carried old bytes over the changed region of new_data. out_old_data must + * hold diff->old_total_len bytes. Returns true on success, false on a + * malformed / out-of-bounds diff. Used by the UNDO engine's RELUNDO_UPDATE + * rollback path; the identical algorithm is mirrored in FLUX's PVS read path + * (FluxApplyDiffReverse) so a delta round-trips for both rollback and + * old-version reconstruction. + */ +extern bool RelUndoApplyDiffReverse(const char *new_data, Size new_len, + const RelUndoDiffRecord *diff, + char *out_old_data, Size *out_old_len); + +/* + * RELUNDO_INSERT payload + * + * Records insertion of a range of consecutive TIDs. + */ +typedef struct RelUndoInsertPayload +{ + ItemPointerData firsttid; /* First inserted TID */ + ItemPointerData endtid; /* Last inserted TID (inclusive) */ +} RelUndoInsertPayload; + +/* + * RELUNDO_DELETE payload + * + * Records deletion of up to 50 TIDs (batched for efficiency). + */ +#define RELUNDO_DELETE_MAX_TIDS 50 + +typedef struct RelUndoDeletePayload +{ + uint16 ntids; /* Number of TIDs in this record */ + ItemPointerData tids[RELUNDO_DELETE_MAX_TIDS]; +} RelUndoDeletePayload; + +/* + * RELUNDO_UPDATE payload + * + * Records update operation linking old and new tuple versions. + */ +typedef struct RelUndoUpdatePayload +{ + ItemPointerData oldtid; /* Old tuple TID */ + ItemPointerData newtid; /* New tuple TID */ + /* Optional: column bitmap for partial updates could be added here */ +} RelUndoUpdatePayload; + +/* + * RELUNDO_TUPLE_LOCK payload + * + * Records tuple lock (SELECT FOR UPDATE/SHARE). + */ +typedef struct RelUndoTupleLockPayload +{ + ItemPointerData tid; /* Locked tuple TID */ + uint16 lock_mode; /* LockTupleMode */ +} RelUndoTupleLockPayload; + +/* + * Per-relation UNDO metapage structure + * + * Stored at block 0 of the relation's UNDO fork. Tracks the head/tail + * of the UNDO page chain and the current generation counter. + * + * The metapage is the root of all per-relation UNDO state. It is read + * and updated during Reserve (to find the head page), Discard (to advance + * the tail), and Init (to set up an empty chain). All metapage modifications + * must be WAL-logged for crash safety. + * + * Memory layout is designed for 8-byte alignment of the 64-bit fields. + */ +/* + * Number of independent append points ("head slots") in a relation's UNDO + * fork. Every committed CAS UPDATE appends its before-image to the head page + * of one slot; a backend hashes to slot (MyProcNumber % RELUNDO_NUM_HEADS) so + * that concurrent writers contend on RELUNDO_NUM_HEADS distinct tail pages + * instead of one. + * + * Originally sized to match the common WAL's NUM_XLOGINSERT_LOCKS (8). + * Raised to 16 after Plan B EC2 benchmarking showed cached-tpcb throughput + * negative-scaling past ~64-96 concurrent writers on a 96-core host: at + * c=192, MyProcNumber % 8 puts ~24 backends on each slot's exclusive + * RelUndoReserve() buffer lock, cluster-wide (this is shared by every + * in-place-update table's writers, not scoped per relation). Doubling to 16 halves that + * per-slot contention (~12 backends/slot at c=192) at a bounded cost: + * RelUndoDiscard()/RelUndoDiscardSlot() walk all RELUNDO_NUM_HEADS slots + * under the metapage's exclusive lock on every VACUUM and on every + * RelUndoMaybeVacuum() throttled backstop call (relundo.c), so raising this + * further multiplies that discard-side cost without EC2-scale concurrency + * data to justify it; 16 was chosen as the largest change defensible from + * local (20-thread) validation alone. Revisit with real high-core-count A/B + * data before raising further. + * + * The per-txn rollback chain threads through urec_prevundorec and the reader + * keys off the physical (counter, blkno, offset) triple, so neither cares + * which slot a record landed on; striping is transparent to both. + * + * Changing this value changes the on-disk RelUndoMetaPageData layout + * (head_blkno[]/tail_blkno[] array size) -- see RELUNDO_METAPAGE_VERSION. + */ +#define RELUNDO_NUM_HEADS 16 + +typedef struct RelUndoMetaPageData +{ + uint32 magic; /* RELUNDO_METAPAGE_MAGIC: validates that + * block 0 is actually a metapage */ + uint16 version; /* Format version (currently 3); allows future + * on-disk format changes */ + uint16 counter; /* Current generation counter; incremented + * when starting a new batch of records. + * Embedded in RelUndoRecPtr for O(1) age + * comparison. Wraps at 65536. */ + BlockNumber head_blkno[RELUNDO_NUM_HEADS]; /* Newest UNDO page per slot + * (where new records are + * appended). InvalidBlockNumber + * if that slot's chain is + * empty. */ + BlockNumber tail_blkno[RELUNDO_NUM_HEADS]; /* Oldest UNDO page per slot + * (first to be discarded). + * InvalidBlockNumber if that + * slot's chain is empty. */ + BlockNumber free_blkno; /* Head of the free page list. Pages discarded + * by VACUUM are spliced here for reuse, + * avoiding fork extension. Shared across all + * slots. InvalidBlockNumber if no free pages. */ + uint64 total_records; /* Cumulative count of all UNDO records ever + * created (monotonically increasing) */ + uint64 discarded_records; /* Cumulative count of discarded records. + * (total - discarded) = live records. */ + BlockNumber system_alloc_watermark; /* High-water mark of system-allocated + * pages. Tracks the highest block + * number allocated via system + * transaction, enabling efficient + * reclamation of unused pages. */ +} RelUndoMetaPageData; + +typedef RelUndoMetaPageData *RelUndoMetaPage; + +/* Magic number for metapage validation */ +#define RELUNDO_METAPAGE_MAGIC 0x4F56554D /* "OVUM" */ + +/* + * Block number of the metapage within the UNDO fork. Data pages are block + * >= 1; a chain link is InvalidBlockNumber at the tail and must NEVER be this + * block. Chain walkers use it to avoid re-locking the metapage (which the + * caller typically already holds). + */ +#define RELUNDO_METAPAGE_BLKNO ((BlockNumber) 0) + +/* Current metapage format version */ +#define RELUNDO_METAPAGE_VERSION 4 + +/* + * Advance a freshly PageInit'd metapage's pd_lower to cover the + * RelUndoMetaPageData struct that lives in the page contents area. + * + * The metapage keeps all of its state in PageGetContents(page) rather than in + * line pointers, so a bare PageInit leaves pd_lower at the empty-page value and + * the entire meta struct sits inside the "hole" [pd_lower, pd_upper). A + * REGBUF_STANDARD full-page image elides that hole, so the FPI would carry a + * valid header and zeroed contents; a standby (or crash recovery) restoring it + * reconstructs a metapage with magic 0x0. Growing pd_lower past the struct + * makes the meta fields part of the FPI's recorded region. Mirrors nbtree's + * _bt_initmetapage. Call after PageInit and after populating the fields. + */ +static inline void +RelUndoMetaPageSetPdLower(Page page) +{ + ((PageHeader) page)->pd_lower = + ((char *) PageGetContents(page) + sizeof(RelUndoMetaPageData)) + - (char *) page; +} + +/* + * Per-relation UNDO data page header + * + * Each UNDO data page (block >= 1) starts with this header. + * Pages are linked in a singly-linked chain from head to tail via prev_blkno. + * + * Records are appended starting at pd_lower and grow toward pd_upper. + * Free space is [pd_lower, pd_upper). When pd_lower >= pd_upper, the page + * is full and a new page must be allocated. + * + * The max_xid field tracks the largest urec_xid of any record on the page. + * This drives page-granularity discard: a page is reclaimable once max_xid + * precedes the relation's oldest non-removable xid (oldest_xmin), since no + * active transaction can then need any record on the page for rollback. + * + * The counter field stamps the page with its generation at creation time. + * It is retained for record-pointer addressing (RelUndoRecPtr) but is no + * longer used for discard eligibility. + */ +typedef struct RelUndoPageHeaderData +{ + BlockNumber prev_blkno; /* Previous page in chain (toward tail). + * InvalidBlockNumber for the oldest page in + * the chain (the tail). */ + TransactionId max_xid; /* Largest urec_xid of any record on this page. + * InvalidTransactionId on an empty page. Used + * for discard eligibility checks. */ + uint16 counter; /* Generation counter at page creation. Used + * for record-pointer addressing. */ + uint16 pd_lower; /* Byte offset of next record insertion point + * (grows upward from header). */ + uint16 pd_upper; /* Byte offset of end of usable space + * (typically BLCKSZ). */ +} RelUndoPageHeaderData; + +typedef RelUndoPageHeaderData *RelUndoPageHeader; + +/* Size of UNDO page header */ +#define SizeOfRelUndoPageHeaderData (sizeof(RelUndoPageHeaderData)) + +/* Maximum free space in an UNDO data page */ +#define RelUndoPageMaxFreeSpace \ + (BLCKSZ - SizeOfRelUndoPageHeaderData) + +/* + * Internal page management functions (used by relundo.c and relundo_discard.c) + * ============================================================================= + */ + +/* Read and pin the metapage (block 0) of the UNDO fork */ +extern Buffer relundo_get_metapage(Relation rel, int mode); + +/* Allocate a new data page at the head of the given slot's chain */ +extern BlockNumber relundo_allocate_page(Relation rel, Buffer metabuf, + int slot, Buffer *newbuf); + +/* Initialize an UNDO data page */ +extern void relundo_init_page(Page page, BlockNumber prev_blkno, + uint16 counter); + +/* Get free space on an UNDO data page */ +extern Size relundo_get_free_space(Page page); + +/* + * Public API for table access methods + * ==================================== + */ + +/* + * RelUndoReserve - Reserve space for an UNDO record (Phase 1 of 2-phase insert) + * + * Reserves space in the relation's UNDO log and pins the buffer. The caller + * should then perform the DML operation, and finally call RelUndoFinish() to + * commit the UNDO record or RelUndoCancel() to release the reservation. + * + * Parameters: + * rel - Relation to insert UNDO record into + * record_size - Total size of UNDO record (header + payload) + * undo_buffer - (output) Buffer containing the reserved space + * + * Returns: + * RelUndoRecPtr pointing to the reserved space + * + * The returned buffer is pinned and locked (exclusive). Caller must eventually + * call RelUndoFinish() or RelUndoCancel(). + */ +extern RelUndoRecPtr RelUndoReserve(Relation rel, Size record_size, + Buffer *undo_buffer); + +/* + * RelUndoStageResult - facts produced by RelUndoStage() for a deferred WAL emit + * + * RelUndoStage() writes the UNDO record onto the reserved page and dirties the + * buffers but performs NO WAL insert. The caller then either lets RelUndoFinish + * emit the standalone RM_RELUNDO_ID record, or folds these staged bytes into a + * different resource manager's combined record (the caller's WAL-fold path). The undo and + * metapage buffers remain locked+pinned; the caller is responsible for + * PageSetLSN on them under its critical section and for releasing them. + */ +typedef struct RelUndoStageResult +{ + Buffer undo_buffer; /* reserved data-page buffer (still locked) */ + Buffer metabuf; /* metapage buffer if is_new_page, else Invalid */ + bool is_new_page; /* first record on a freshly allocated page */ + uint8 urec_type; /* header->urec_type (for the xlrec) */ + uint16 urec_len; /* header->urec_len (for the xlrec) */ + uint16 page_offset; /* page-absolute offset of the record */ + uint16 new_pd_lower; /* shadow pd_lower after the write */ + TransactionId max_xid; /* page max_xid watermark after the bump */ + char *wal_record_data; /* palloc'd block-0 data (caller pfrees) */ + Size wal_record_size; /* size of wal_record_data */ +} RelUndoStageResult; + +/* + * RelUndoStage - write an UNDO record onto its reserved page WITHOUT WAL. + * + * Performs every page mutation RelUndoFinish() does (header+payload memcpy, + * max_xid bump, MarkBufferDirty on the data page and, for a new page, the + * metapage) and builds the block-0 WAL data buffer, but does NOT open a + * critical section, XLogInsert, PageSetLSN, or release any buffer. The staged + * facts are returned in *result so the caller can emit the WAL record itself. + */ +extern void RelUndoStage(Relation rel, Buffer undo_buffer, RelUndoRecPtr ptr, + const RelUndoRecordHeader *header, + const void *payload, Size payload_size, + RelUndoStageResult *result); + +/* + * RelUndoFinish - Complete UNDO record insertion (Phase 2 of 2-phase insert) + * + * Writes the UNDO record to the previously reserved space and releases the buffer. + * This must be called after successful DML operation completion. + * + * Parameters: + * rel - Relation containing the UNDO log + * undo_buffer - Buffer from RelUndoReserve() (will be unlocked/unpinned) + * ptr - RelUndoRecPtr from RelUndoReserve() + * header - UNDO record header to write + * payload - UNDO record payload data + * payload_size - Size of payload data + * + * The buffer is marked dirty, WAL-logged, and released. + */ +extern void RelUndoFinish(Relation rel, Buffer undo_buffer, + RelUndoRecPtr ptr, + const RelUndoRecordHeader *header, + const void *payload, Size payload_size); + +/* + * RelUndoFinishWithTuple - Complete UNDO record insertion with tuple data + * + * Like RelUndoFinish(), but also writes tuple data after the payload for + * operations that need to store the complete tuple (DELETE, UPDATE). + * + * Parameters: + * rel - Relation containing the UNDO log + * undo_buffer - Buffer from RelUndoReserve() (will be unlocked/unpinned) + * ptr - RelUndoRecPtr from RelUndoReserve() + * header - UNDO record header (must have RELUNDO_INFO_HAS_TUPLE set) + * payload - UNDO record payload data + * payload_size - Size of payload data + * tuple_data - Complete tuple data to store + * tuple_len - Length of tuple data + * + * The record layout on the UNDO page is: + * [RelUndoRecordHeader][payload][tuple_data] + */ +extern void RelUndoFinishWithTuple(Relation rel, Buffer undo_buffer, + RelUndoRecPtr ptr, + const RelUndoRecordHeader *header, + const void *payload, Size payload_size, + const char *tuple_data, uint32 tuple_len); + +/* + * RelUndoCancel - Cancel UNDO record reservation + * + * Releases a reservation made by RelUndoReserve() without writing an UNDO record. + * Use this when the DML operation fails and needs to be rolled back. + * + * Parameters: + * rel - Relation containing the UNDO log + * undo_buffer - Buffer from RelUndoReserve() (will be unlocked/unpinned) + * ptr - RelUndoRecPtr from RelUndoReserve() + * + * The reserved space is left as a "hole" that can be skipped during chain walking. + */ +extern void RelUndoCancel(Relation rel, Buffer undo_buffer, RelUndoRecPtr ptr); + +/* + * RelUndoReadRecord - Read an UNDO record + * + * Reads an UNDO record at the specified pointer and returns the header and payload. + * + * Parameters: + * rel - Relation containing the UNDO log + * ptr - RelUndoRecPtr to read from + * header - (output) UNDO record header + * payload - (output) Allocated payload buffer (caller must pfree) + * payload_size - (output) Size of payload + * + * Returns: + * true if record was successfully read, false if pointer is invalid or + * record has been discarded + * + * If successful, *payload is allocated in CurrentMemoryContext and must be + * freed by the caller. + */ +extern bool RelUndoReadRecord(Relation rel, RelUndoRecPtr ptr, + RelUndoRecordHeader *header, + void **payload, Size *payload_size); + +/* + * RelUndoReadRecordHeader - Read only the header of an UNDO record. + * + * Header-only variant that skips the payload palloc. Used by hot probes + * that need only urec_xid (e.g. the lost-update conflict probe). Returns + * false with the same semantics as RelUndoReadRecord. + */ +extern bool RelUndoReadRecordHeader(Relation rel, RelUndoRecPtr ptr, + RelUndoRecordHeader *header); + +/* + * RelUndoDiscard - Discard old UNDO records + * + * Frees space occupied by UNDO records that no active transaction can still + * need for rollback. Called during VACUUM to reclaim space. + * + * Parameters: + * rel - Relation to discard UNDO from + * oldest_xmin - Oldest non-removable XID for this relation + * + * A page is discardable iff its max_xid precedes oldest_xmin, meaning every + * record on the page belongs to a transaction that has already committed or + * aborted and is older than any active snapshot. + */ +extern void RelUndoDiscard(Relation rel, TransactionId oldest_xmin, bool nowait); + +/* + * RelUndoHeadCacheInvalidate - drop the per-backend head page cache entry. + * + * Must be called by RelUndoDiscard() after it reclaims pages, since discard + * can physically truncate the fork and leave the cached head block number + * pointing past the new end of file. + */ +extern void RelUndoHeadCacheInvalidate(Oid relid); + +/* + * RelUndoInitRelation - Initialize per-relation UNDO for a new relation + * + * Creates the UNDO fork and initializes the metapage. Called during CREATE TABLE + * for table AMs that use per-relation UNDO. + * + * Parameters: + * rel - Relation to initialize + */ +extern void RelUndoInitRelation(Relation rel); + +/* + * RelUndoDropRelation - Drop per-relation UNDO when relation is dropped + * + * Removes the UNDO fork. Called during DROP TABLE for table AMs that use + * per-relation UNDO. + * + * Parameters: + * rel - Relation being dropped + */ +extern void RelUndoDropRelation(Relation rel); + +/* + * RelUndoVacuum - Vacuum per-relation UNDO log + * + * Performs maintenance on the UNDO log: discards old records, reclaims space, + * and updates statistics. Called during VACUUM. + * + * Parameters: + * rel - Relation to vacuum + * oldest_xmin - Oldest XID still visible to any transaction + */ +extern void RelUndoVacuum(Relation rel, TransactionId oldest_xmin, bool nowait); + +/* + * RelUndoMaybeVacuum - Throttled, self-clocking per-relation UNDO fork + * discard, decoupled from VACUUM. + * + * An in-place-update AM can correctly report near-zero dead tuples, + * so autovacuum's dead-tuple/insert-count thresholds may never fire even + * under sustained write churn, and RelUndoVacuum() above -- the only code + * that discards the on-disk UNDO fork -- is normally reached exclusively via + * VACUUM. Call this from the owning AM's DML hot path (after releasing all + * buffer/tuple locks -- see relundo.c for why) as a backstop so the fork + * gets discarded on its own schedule. A no-op most calls (throttled to once + * every 5 seconds per backend, and skipped entirely below a minimum fork + * size), so it is safe to call unconditionally on every DML operation. + * + * Parameters: + * rel - Relation whose UNDO fork may need discarding + */ +extern void RelUndoMaybeVacuum(Relation rel); + +/* + * ============================================================================= + * ROLLBACK API - Support for transaction abort via UNDO application + * ============================================================================= + */ + +/* + * RelUndoApplyChain - Walk and apply per-relation UNDO chain for rollback + * + * Walks backwards through the UNDO chain applying each operation to restore + * the database state. Called during transaction abort. + */ +extern void RelUndoApplyChain(Relation rel, RelUndoRecPtr start_ptr); + +/* + * RelUndoApplyRecordForRecovery - Reverse-apply one UNDO record during crash + * recovery, restoring the before-image in place without writing a CLR. + */ +extern void RelUndoApplyRecordForRecovery(Relation rel, RelUndoRecPtr ptr); + +/* Read UNDO record including tuple data for rollback */ +extern RelUndoRecordHeader *RelUndoReadRecordWithTuple(Relation rel, + RelUndoRecPtr ptr, + char **tuple_data_out, + uint32 *tuple_len_out); + +/* + * ============================================================================= + * CRASH-RECOVERY API - Reverse-apply per-relation UNDO for loser transactions + * ============================================================================= + * + * An in-place MVCC table AM overwrites the committed tuple + * bytes on UPDATE, and the only durable copy of the prior committed version + * is the before-image in the relation's UNDO fork. If a transaction does an + * in-place modification and the server crashes before it commits, redo + * re-establishes the uncommitted page state, so a recovery-time driver must + * reverse-apply the before-images of every incomplete (loser) transaction. + * + * After the redo pass finishes, CLOG is fully reconstructed, so + * PerformRelUndoRecovery() scans every per-relation UNDO fork on disk and, + * for each record whose creating transaction did not commit and is not a + * prepared transaction, reverse-applies its before-image. See + * relundo_recovery.c for why an end-of-recovery fork scan is required rather + * than tracking insertions during redo. + */ + +/* Scan UNDO forks and reverse-apply all loser transactions' before-images. */ +extern void PerformRelUndoRecovery(void); + +/* + * ============================================================================= + * AM-NEUTRAL HOOKS - decouple UNDO core from any specific table AM + * ============================================================================= + * + * The per-relation UNDO core (slog.c, relundo_apply.c, undoworker.c) is + * format-agnostic: it stores and restores opaque tuple bytes without knowing + * the in-place table AM's tuple layout. The operations that do require + * AM-specific knowledge are reached through these function pointers, which the + * owning table AM installs at subsystem init. When no in-place AM is + * registered the pointers stay NULL and the core degrades gracefully (no + * transient-flag clearing). + * + * RelUndoClearTransientFlags_hook: clear the AM's per-tuple transient state + * bits (uncommitted/deleted/updated) on a freshly restored before-image, so + * the restored committed version is not mistaken for an in-flight change. + * + * RelUndoAbortCleanup_hook: called after a rolled-back transaction's + * before-images have been physically restored (inline in xactundo.c, or by + * the background worker in relundo_worker.c), so the AM can drop any + * transient bookkeeping entries it kept ONLY for as long as the abort was + * pending (e.g. dirty-xid write-write-conflict markers). xid is the + * transaction that was rolled back. NULL is a valid no-op for an AM with + * no such transient state. + * + * RelUndoDiscardRetained_hook: called periodically by the UNDO discard worker + * (undoworker.c) so the AM can reclaim any retained-version bookkeeping + * (before-images, dirty markers) that has aged out. The reclamation + * horizon is the AM's own xid horizon. NULL is a valid no-op. + */ +extern void (*RelUndoClearTransientFlags_hook) (char *tuple_data); +extern void (*RelUndoAbortCleanup_hook) (TransactionId xid); +extern void (*RelUndoDiscardRetained_hook) (void); + +#endif /* RELUNDO_H */ diff --git a/src/include/access/relundo_worker.h b/src/include/access/relundo_worker.h new file mode 100644 index 0000000000000..7ea28350ed78f --- /dev/null +++ b/src/include/access/relundo_worker.h @@ -0,0 +1,89 @@ +/*------------------------------------------------------------------------- + * + * relundo_worker.h + * Background worker for applying per-relation UNDO records asynchronously + * + * This module implements background workers that apply per-relation UNDO + * records for aborted transactions. The workers run asynchronously, similar + * to autovacuum, to avoid blocking ROLLBACK commands. + * + * Architecture: + * - Main launcher process manages worker pool + * - Individual workers process UNDO chains for specific databases + * - Shared memory queue tracks pending UNDO work + * - Workers coordinate to avoid duplicate work + * + * This follows the ZHeap architecture where UNDO application is deferred + * to background processes rather than being synchronous during ROLLBACK. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/relundo_worker.h + * + *------------------------------------------------------------------------- + */ +#ifndef RELUNDO_WORKER_H +#define RELUNDO_WORKER_H + +#include "postgres.h" +#include "access/relundo.h" +#include "datatype/timestamp.h" +#include "storage/lwlock.h" + +/* + * Shared memory structure for UNDO work queue + */ +/* + * MAX_UNDO_WORK_ITEMS limits the in-flight UNDO work queue. + * Keep moderate (64) so shmem stays small during bootstrap. + * Production workloads rarely exceed this with synchronous abort. + */ +#define MAX_UNDO_WORK_ITEMS 64 + +typedef struct RelUndoWorkItem +{ + Oid dboid; /* Database OID */ + Oid reloid; /* Relation OID */ + RelUndoRecPtr start_urec_ptr; /* First UNDO record to apply */ + TransactionId xid; /* Transaction that created the UNDO */ + TimestampTz queued_at; /* When this was queued */ + bool in_progress; /* Worker currently processing this */ + int worker_id; /* ID of worker processing (if in_progress) */ +} RelUndoWorkItem; + +typedef struct RelUndoWorkQueue +{ + LWLock lock; /* Protects the queue */ + int num_items; /* Number of pending items */ + int next_worker_id; /* For assigning worker IDs */ + RelUndoWorkItem items[MAX_UNDO_WORK_ITEMS]; +} RelUndoWorkQueue; + +/* + * Worker registration and lifecycle + */ +extern Size RelUndoWorkerShmemSize(void); +extern void RelUndoWorkerShmemInit(void); +extern void RelUndoLauncherMain(Datum main_arg); +extern void RelUndoWorkerMain(Datum main_arg); + +/* + * Work queue operations + */ +extern void RelUndoQueueAdd(Oid dboid, Oid reloid, RelUndoRecPtr start_urec_ptr, + TransactionId xid); +extern bool RelUndoQueueGetNext(RelUndoWorkItem *item_out, int worker_id); +extern void RelUndoQueueMarkComplete(Oid dboid, Oid reloid, int worker_id); + +/* + * Worker management + */ +extern void StartRelUndoWorker(Oid dboid); +extern void WaitForPendingRelUndo(void); + +/* GUC parameters */ +extern int max_relundo_workers; +extern int relundo_worker_naptime; + +#endif /* RELUNDO_WORKER_H */ diff --git a/src/include/access/relundo_xlog.h b/src/include/access/relundo_xlog.h new file mode 100644 index 0000000000000..6945166b53834 --- /dev/null +++ b/src/include/access/relundo_xlog.h @@ -0,0 +1,164 @@ +/*------------------------------------------------------------------------- + * + * relundo_xlog.h + * Per-relation UNDO WAL record definitions + * + * This file contains the WAL record format definitions for per-relation + * UNDO operations. These records are logged by the RM_RELUNDO_ID resource + * manager. + * + * Record types: + * XLOG_RELUNDO_INIT - Metapage initialization + * XLOG_RELUNDO_INSERT - UNDO record insertion into a data page + * XLOG_RELUNDO_DISCARD - Discard old UNDO pages during VACUUM + * + * Per-relation UNDO stores operation metadata for MVCC visibility in + * each relation's UNDO fork. This is distinct from the cluster-wide + * UNDO system (RM_UNDO_ID) which handles transaction rollback. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/relundo_xlog.h + * + *------------------------------------------------------------------------- + */ +#ifndef RELUNDO_XLOG_H +#define RELUNDO_XLOG_H + +#include "postgres.h" + +#include "access/xlogreader.h" +#include "lib/stringinfo.h" +#include "storage/block.h" +#include "storage/relfilelocator.h" + +/* Forward declaration - full definition in relundo.h */ +typedef uint64 RelUndoRecPtr; + +/* + * WAL record types for per-relation UNDO operations + * + * The high 4 bits of the info byte encode the operation type, + * following PostgreSQL convention. + */ +#define XLOG_RELUNDO_INIT 0x00 /* Metapage initialization */ +#define XLOG_RELUNDO_INSERT 0x10 /* UNDO record insertion */ +#define XLOG_RELUNDO_DISCARD 0x20 /* Discard old UNDO pages */ +#define XLOG_RELUNDO_TRUNCATE 0x30 /* Physically truncate the fork */ +#define XLOG_RELUNDO_APPLY 0x40 /* Apply UNDO for rollback (CLR) */ + +/* + * Flag: set when the data page being inserted into is newly initialized + * (first tuple on the page). When set, redo will re-initialize the + * page from scratch before applying the insert. + */ +#define XLOG_RELUNDO_INIT_PAGE 0x80 + +/* + * xl_relundo_init - WAL record for metapage initialization + * + * Logged when RelUndoInitRelation() creates the UNDO fork and writes + * the initial metapage (block 0). + * + * Backup block 0: the metapage + */ +typedef struct xl_relundo_init +{ + uint32 magic; /* RELUNDO_METAPAGE_MAGIC */ + uint16 version; /* Format version */ + uint16 counter; /* Initial generation counter */ +} xl_relundo_init; + +#define SizeOfRelundoInit (offsetof(xl_relundo_init, counter) + sizeof(uint16)) + +/* + * xl_relundo_insert - WAL record for UNDO record insertion + * + * Logged when RelUndoFinish() writes an UNDO record to a data page. + * + * Backup block 0: the data page receiving the UNDO record + * Backup block 1: the metapage (if head_blkno was updated) + * + * The actual UNDO record data is stored as block data associated with + * backup block 0 (via XLogRegisterBufData). + */ +typedef struct xl_relundo_insert +{ + uint16 urec_type; /* RelUndoRecordType of the UNDO record */ + uint16 urec_len; /* Total length of UNDO record */ + uint16 page_offset; /* Byte offset within page where record starts */ + uint16 new_pd_lower; /* Updated pd_lower after insertion */ + TransactionId max_xid; /* Updated page max_xid watermark after insert */ +} xl_relundo_insert; + +#define SizeOfRelundoInsert (offsetof(xl_relundo_insert, max_xid) + sizeof(TransactionId)) + +/* + * xl_relundo_discard - WAL record for UNDO page discard + * + * Logged when RelUndoDiscard() reclaims a contiguous run of discardable + * pages from the tail of the data chain by splicing the whole run directly + * onto the metapage's free list. Only the run boundaries change, so the + * record covers a fixed set of buffers regardless of run length: + * + * Backup block 0: the metapage (tail + free-list head) + * Backup block 1: the run's old-tail page (prev_blkno -> old free head) + * Backup block 2: the new live tail page (prev_blkno -> Invalid) + */ +typedef struct xl_relundo_discard +{ + BlockNumber old_tail_blkno; /* Old chain tail (run's tail), block 1 */ + BlockNumber new_tail_blkno; /* New chain tail after discard */ + BlockNumber free_head_blkno; /* New free-list head (run's head) */ + BlockNumber old_free_head; /* Prior free-list head, written to block 1 */ + TransactionId discard_xid; /* oldest_xmin cutoff used for discard */ + uint32 npages_freed; /* Number of pages spliced onto free list */ + uint16 slot; /* Head slot whose chain was discarded */ +} xl_relundo_discard; + +#define SizeOfRelundoDiscard (offsetof(xl_relundo_discard, slot) + sizeof(uint16)) + +/* + * xl_relundo_truncate - WAL record for physical fork truncation + * + * Logged when RelUndoDiscard() empties the entire data chain. At that + * point the free list holds every allocated data block, i.e. the + * contiguous physical suffix [1 .. system_alloc_watermark], so the fork + * can be physically truncated back to just the metapage (block 0). Redo + * resets the metapage free-list/watermark fields and truncates the fork. + * + * Backup block 0: the metapage (free_blkno + watermark reset) + */ +typedef struct xl_relundo_truncate +{ + BlockNumber new_nblocks; /* New fork length in blocks (always 1) */ +} xl_relundo_truncate; + +#define SizeOfRelundoTruncate (offsetof(xl_relundo_truncate, new_nblocks) + sizeof(BlockNumber)) + +/* Resource manager functions */ +extern void relundo_redo(XLogReaderState *record); +extern void relundo_desc(StringInfo buf, XLogReaderState *record); +extern const char *relundo_identify(uint8 info); + +/* Parallel redo support */ +extern void relundo_startup(void); +extern void relundo_cleanup(void); +extern void relundo_mask(char *pagedata, BlockNumber blkno); + +/* + * XLOG_RELUNDO_APPLY - Compensation Log Record for UNDO application + * + * Records that we've applied an UNDO operation during transaction rollback. + * Prevents double-application if we crash during rollback. + */ +typedef struct xl_relundo_apply +{ + RelUndoRecPtr urec_ptr; /* UNDO record that was applied */ + RelFileLocator target_reloc; /* Target relation */ +} xl_relundo_apply; + +#define SizeOfRelUndoApply (offsetof(xl_relundo_apply, target_reloc) + sizeof(RelFileLocator)) + +#endif /* RELUNDO_XLOG_H */ diff --git a/src/include/access/rmgrlist.h b/src/include/access/rmgrlist.h index ae32ef16d67b6..8268c83327ecf 100644 --- a/src/include/access/rmgrlist.h +++ b/src/include/access/rmgrlist.h @@ -48,3 +48,10 @@ PG_RMGR(RM_REPLORIGIN_ID, "ReplicationOrigin", replorigin_redo, replorigin_desc, PG_RMGR(RM_GENERIC_ID, "Generic", generic_redo, generic_desc, generic_identify, NULL, NULL, generic_mask, NULL) PG_RMGR(RM_LOGICALMSG_ID, "LogicalMessage", logicalmsg_redo, logicalmsg_desc, logicalmsg_identify, NULL, NULL, NULL, logicalmsg_decode) PG_RMGR(RM_XLOG2_ID, "XLOG2", xlog2_redo, xlog2_desc, xlog2_identify, NULL, NULL, NULL, xlog2_decode) +PG_RMGR(RM_UNDO_ID, "Undo", undo_redo, undo_desc, undo_identify, NULL, NULL, NULL, NULL) +PG_RMGR(RM_ATM_ID, "ATM", atm_redo, atm_desc, atm_identify, NULL, NULL, NULL, NULL) +PG_RMGR(RM_FILEOPS_ID, "FileOps", fileops_redo, fileops_desc, fileops_identify, NULL, NULL, NULL, NULL) +PG_RMGR(RM_RELUNDO_ID, "RelUndo", relundo_redo, relundo_desc, relundo_identify, relundo_startup, relundo_cleanup, relundo_mask, NULL) +#ifdef USE_FLUX +PG_RMGR(RM_FLUX_ID, "FLUX", flux_redo, flux_desc, flux_identify, NULL, NULL, flux_mask, flux_decode) +#endif diff --git a/src/include/access/slog.h b/src/include/access/slog.h new file mode 100644 index 0000000000000..e3e1c793aac81 --- /dev/null +++ b/src/include/access/slog.h @@ -0,0 +1,391 @@ +/*------------------------------------------------------------------------- + * + * slog.h + * Secondary Log (sLog) for shared-memory tracking + * + * The sLog provides a shared-memory Aborted Transaction Map (ATM) for the + * UNDO subsystem's Constant-Time Recovery: + * + * Transaction radix tree - Aborted transaction entries keyed by + * (xid, reloid) packed into a uint64, ordered for efficient xid-based + * range operations. Protected by a single LWLock (modifications are + * infrequent). + * + * An optional per-tuple flat-hash extension (the tuple sLog) adds + * bounded-recovery uncommitted-writer tracking for in-place-update table + * AMs. It shares this subsystem's shared-memory segment and initialization + * but is not required by the UNDO core; its API is declared where it is + * defined. + * + * WAL: Transaction sLog reuses existing RM_ATM_ID records. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/slog.h + * + *------------------------------------------------------------------------- + */ +#ifndef SLOG_H +#define SLOG_H + +#include "access/transam.h" +#include "access/xlogdefs.h" +#include "datatype/timestamp.h" +#include "nodes/lockoptions.h" +#include "storage/itemptr.h" +#include "storage/lwlock.h" +#include "storage/relfilelocator.h" +#include "utils/dsa.h" + +/* + * Maximum concurrent operations on a single TID. + * + * Under EPQ retry patterns with high concurrency, each backend that sees + * TM_Updated calls table_tuple_lock which adds a LOCK_EXCL sLog entry. + * These entries persist until the owning transaction commits, so with N + * concurrent backends hitting the same hot row, up to N-1 LOCK entries + * can coexist. Additionally, committed in-place UPDATE markers are retained + * (one per committing xid) until the oldest-snapshot horizon advances past + * them, so a hot row updated repeatedly while a long reader holds a snapshot + * accumulates markers on top of the live LOCK entries. 128 slots handles + * realistic OLTP concurrency plus retained-marker headroom; per-TID + * reclamation of the oldest retained marker kicks in before the array fills. + */ +#define SLOG_MAX_TUPLE_OPS 128 + +/* + * Tuple hash auto-sizing constants. + * + * All DML operations (INSERT/DELETE/UPDATE/LOCK) attempt to create shared + * hash entries. On overflow (hash full), the operation proceeds gracefully + * with local-only tracking rather than crashing. Auto-sizing formula: + * MaxBackends * SLOG_TUPLE_PER_BACKEND_SLOTS, clamped. + * + * The per-backend slot count must be large enough to accommodate OLTP + * workloads where UPDATE before-images are retained until eviction. + * With transactions touching ~25 rows, and retained entries from committed + * transactions accumulating between eviction passes, 1024 slots per backend + * provides adequate headroom. + */ +#define SLOG_TUPLE_PER_BACKEND_SLOTS 1024 +#define SLOG_TUPLE_MIN_ENTRIES 4096 +#define SLOG_TUPLE_MAX_ENTRIES 4194304 + +/* + * sLog entry types for tuple operations. + */ +typedef enum SLogOpType +{ + SLOG_ENTRY_ABORTED_TXN = 0, /* Transaction-level abort entry */ + SLOG_OP_INSERT = 1, + SLOG_OP_DELETE = 2, + SLOG_OP_UPDATE = 3, + SLOG_OP_LOCK_SHARE = 4, + SLOG_OP_LOCK_EXCL = 5, + SLOG_OP_ABORTED = 6, /* Tuple-level: op was aborted, UNDO pending */ +} SLogOpType; + +/* ---------------------------------------------------------------- + * Transaction sLog structures + * + * SLogTxnEntry is used as the public output type for lookups. + * Internally, the ATM radix tree stores only the data fields; the key + * (xid, reloid) is implicit in the tree path. + * ---------------------------------------------------------------- + */ + +/* + * SLogTxnEntry - Public output structure for transaction lookups. + * Callers receive copies of this via SLogTxnLookup(). + */ +typedef struct SLogTxnEntry +{ + TransactionId xid; + Oid reloid; + XLogRecPtr last_batch_lsn; /* LSN of last UNDO batch for this xid */ + Oid dboid; /* database OID */ + TimestampTz abort_time; /* when transaction aborted */ + bool revert_complete; /* has Logical Revert finished? */ +} SLogTxnEntry; + +/* ---------------------------------------------------------------- + * Tuple sLog structures + * ---------------------------------------------------------------- + */ + +/* + * SLogTupleKey - Hash key for the tuple flat hash. + * + * Note: ItemPointerData is 6 bytes. Always memset(&key, 0, sizeof(key)) + * before populating to ensure deterministic byte hashing. + */ +typedef struct SLogTupleKey +{ + Oid relid; + ItemPointerData tid; +} SLogTupleKey; + +/* + * SLogTupleOp - Single operation on a tuple. + * + * Uses an in_use slot-based model for O(1) removal without array compaction + * under the exclusive lock. + */ +typedef struct SLogTupleOp +{ + TransactionId xid; + TransactionId subxid; /* subtransaction ID, or InvalidTransactionId */ + SLogOpType op_type; + CommandId cid; + TimestampTz commit_ts; /* 0 if not yet committed */ + uint32 spec_token; /* speculative insertion token, or 0 */ + bool in_use; /* slot occupied? */ + + /* + * Precise tuple-lock strength for LOCK_SHARE/LOCK_EXCL markers. The + * SLOG_OP_LOCK_* op_type only distinguishes shared vs. exclusive intent; + * this field preserves the full four-way LockTupleMode so a concurrent + * updater can apply the real heavyweight conflict matrix (a KeyShare FK + * locker is compatible with a NoKeyExclusive UPDATE, but a Share/Exclusive + * locker conflicts and must block it). Ignored for non-lock ops. + */ + LockTupleMode lock_mode; +} SLogTupleOp; + +/* + * SLogTupleEntry - Value in the tuple flat hash. + */ +typedef struct SLogTupleEntry +{ + SLogTupleKey key; /* hash key */ + int nops; /* number of active operations */ + SLogTupleOp ops[SLOG_MAX_TUPLE_OPS]; +} SLogTupleEntry; + +/* + * SLogAmDescriptor - per-AM opt-in policy for the tuple sLog. + * + * An in-place-MVCC table AM that wants tuple tracking registers one of these + * once at startup via SLogRegisterAmDescriptor(). It carries only DATA + * resolved once at registration -- never a per-op callback -- so it adds no + * indirection to the tuple probe/insert hot path. The AM, not the generic + * tuple sLog, owns the policy values here. + */ +typedef struct SLogAmDescriptor +{ + /* + * Maximum size (bytes) of a before-image the AM will stash in the + * backend-local savepoint-rollback scratch. A larger tuple is not + * stored; the AM falls back to its durable UNDO fork for that version. + * 0 means the AM stores no before-images through the tuple sLog. + */ + uint32 before_image_max; +} SLogAmDescriptor; + +/* Callback type for tuple iteration */ +typedef bool (*SLogTupleIterCallback) (const SLogTupleOp *op, void *arg); + +/* ---------------------------------------------------------------- + * Shared state + * + * The transaction radix tree is allocated in shared memory; its internal + * structures are opaque to callers. The SLogSharedState is defined in + * slog.c. + * ---------------------------------------------------------------- + */ + +/* ---------------------------------------------------------------- + * API: Shared memory + * ---------------------------------------------------------------- + */ +extern Size SLogShmemSize(void); +extern void SLogShmemRequest(void); +extern void SLogShmemInit(void); + +/* ---------------------------------------------------------------- + * API: Transaction sLog + * ---------------------------------------------------------------- + */ +extern bool SLogTxnInsert(TransactionId xid, Oid reloid, Oid dboid, + XLogRecPtr last_batch_lsn); +extern bool SLogTxnLookup(TransactionId xid, Oid reloid, + SLogTxnEntry *entry_out); +extern bool SLogTxnLookupByXid(TransactionId xid, XLogRecPtr *lsn_out); +extern void SLogTxnRemove(TransactionId xid, Oid reloid); +extern void SLogTxnRemoveByXid(TransactionId xid); +extern void SLogTxnMarkReverted(TransactionId xid); +extern bool SLogTxnGetNextUnreverted(TransactionId *xid_out, Oid *dboid_out, + XLogRecPtr *lsn_out); +extern XLogRecPtr SLogTxnGetOldestUnrevertedLSN(void); +extern int SLogTxnSnapshotForCheckpoint(SLogTxnEntry **entries_out); +extern void SLogRecoveryFinalize(int *total_out, int *unreverted_out); + +/* DSA lifecycle (shared-memory area backing the aborted-txn radix tree) */ +extern void SLogEnsureDsaAttached(void); + +/* GUC: maximum DSA size (in MB) */ +extern int slog_dsa_max_size_mb; + +/* ---------------------------------------------------------------- + * API: Tuple sLog (optional per-tuple tracking extension, slog_tuple.c) + * ---------------------------------------------------------------- + */ + +/* Per-AM opt-in registration (call once at startup) */ +extern void SLogRegisterAmDescriptor(const SLogAmDescriptor *desc); + +/* Dynamic sizing */ +extern int SLogTupleNumEntries(void); + +/* Core operations */ +extern bool SLogTupleInsert(Oid relid, ItemPointer tid, TransactionId xid, + SLogOpType op_type, TransactionId subxid, + CommandId cid, TimestampTz commit_ts, + uint32 spec_token, LockTupleMode lock_mode); +extern bool SLogTupleInsertRecovery(Oid relid, ItemPointer tid, + TransactionId xid, SLogOpType op_type); +extern bool SLogTupleLookup(Oid relid, ItemPointer tid, + SLogTupleEntry *entry_out); +extern void SLogTupleRemove(Oid relid, ItemPointer tid, TransactionId xid); +extern void SLogTupleRemoveByXid(TransactionId xid); +extern void SLogTupleRemoveBySubXid(TransactionId xid, TransactionId subxid); +extern void SLogTupleIterateByTid(Oid relid, ItemPointer tid, + SLogTupleIterCallback callback, void *arg); + +/* Filtered lookup (xid_filter=InvalidTransactionId means all) */ +extern int SLogTupleLookupFiltered(Oid relid, ItemPointer tid, + TransactionId xid_filter, + SLogTupleOp *ops_out, int max_ops); + +/* Subtransaction re-parenting on subxact commit */ +extern void SLogTupleUpdateSubXid(TransactionId xid, + TransactionId old_subxid, + TransactionId new_subxid); + +/* Mark all ops for xid as SLOG_OP_ABORTED */ +extern void SLogTupleMarkAborted(TransactionId xid); + +/* Global removal for UNDO worker (no backend-local list) */ +extern void SLogTupleRemoveByXidGlobal(TransactionId xid); + +/* Lightweight local-only tracking (INSERTs only) */ +extern void SLogTupleTrackLocalOnly(Oid relid, ItemPointer tid, + TransactionId xid, TransactionId subxid); +extern void SLogTupleUntrackLocalOnly(Oid relid, ItemPointer tid); + +/* Convenience wrappers */ +extern bool SLogTupleHasEntry(Oid relid, ItemPointer tid); +extern bool SLogTupleIsInsertedByMe(Oid relid, ItemPointer tid); +extern bool SLogTupleIsDeletedByMe(Oid relid, ItemPointer tid); +extern TransactionId SLogTupleGetDirtyXid(Oid relid, ItemPointer tid, + bool *is_insert); +extern TransactionId SLogTupleGetDirtyWriterXid(Oid relid, ItemPointer tid, + bool *is_insert); +extern TransactionId SLogTupleGetWriteConflictXid(Oid relid, ItemPointer tid, + LockTupleMode my_mode, + bool *is_insert); +extern bool SLogTupleHasLockConflict(Oid relid, ItemPointer tid, + TransactionId my_xid, + SLogOpType requested_lock); +extern bool SLogTupleGetLockConflictXid(Oid relid, ItemPointer tid, + TransactionId my_xid, + SLogOpType requested_lock, + TransactionId *xid_out); +extern bool SLogTupleHasAbortedEntry(Oid relid, ItemPointer tid); + + +/* Backend-private tracking for cleanup at commit/abort */ +extern void SLogTupleTrackKey(SLogTupleKey key, TransactionId xid, + TransactionId subxid, SLogOpType op_type); +extern void SLogTupleResetTracking(void); +extern bool SLogTupleAnyTracked(void); + +/* + * SLogTrackedKeyInfo -- public snapshot of a tracked key for batch processing. + * + * Returned by SLogTupleCollectTrackedKeys() so that callers (e.g. an AM's + * commit-time stamping) can sort and batch-process tuples without knowledge + * of the internal tracked-key linked-list structure. + */ +typedef struct SLogTrackedKeyInfo +{ + SLogTupleKey key; + TransactionId xid; + TransactionId subxid; + bool local_only; + SLogOpType op_type; + uint64 before_commit_ts; + bool has_before_image; +} SLogTrackedKeyInfo; + +/* Collect tracked keys into a sortable array (for batch commit processing) */ +extern int SLogTupleCollectTrackedKeys(TransactionId xid, + SLogTrackedKeyInfo * *out_keys); + +/* Iterate tracked keys (for AM-specific pre-commit callbacks) */ +typedef bool (*SLogTrackedKeyCallback) (const SLogTupleKey *key, + TransactionId xid, + TransactionId subxid, + bool local_only, + void *arg); +extern void SLogTupleIterateTrackedKeys(TransactionId xid, + SLogTrackedKeyCallback callback, + void *arg); + +/* Extended callback with before-image metadata (for commit-time processing) */ +typedef bool (*SLogTrackedKeyExtCallback) (const SLogTupleKey *key, + TransactionId xid, + TransactionId subxid, + bool local_only, + uint64 before_commit_ts, + bool has_before_image, + void *arg); +extern void SLogTupleIterateTrackedKeysExt(TransactionId xid, + SLogTrackedKeyExtCallback callback, + void *arg); + +/* Iterate tracked keys for a specific subtransaction (savepoint rollback) */ +extern void SLogTupleIterateTrackedKeysForSubXid(TransactionId xid, + TransactionId subxid, + SLogTrackedKeyCallback callback, + void *arg); + +/* Before-image storage for savepoint rollback */ +extern void SLogTupleStoreBeforeImage(Oid relid, ItemPointer tid, + TransactionId xid, + const char *data, int len, + uint16 flags, uint64 commit_ts, + RelFileLocator rlocator, + char relpersistence); +extern bool SLogTupleGetBeforeImage(Oid relid, ItemPointer tid, + TransactionId xid, TransactionId subxid, + char **data_out, int *len_out, + uint16 *flags_out, uint64 *commit_ts_out, + RelFileLocator *rlocator_out, + char *relpersistence_out); + +/* Commit retention: retain committed UPDATE entries with before-images */ +extern void SLogTupleCommitByXid(TransactionId xid); + +/* Per-tuple operations for two-phase commit resolution */ +extern void SLogTupleRemoveByXidSingle(Oid relid, ItemPointer tid, + TransactionId xid); +extern void SLogTupleMarkAbortedSingle(Oid relid, ItemPointer tid, + TransactionId xid); + +/* Cleanup retained entries when no longer needed by any snapshot */ +extern void SLogTupleCleanupRetained(void); + +/* + * Throttled cleanup trigger for access methods to call from DML paths OUTSIDE + * any buffer-locked critical section (drives cleanup when the UNDO worker is + * disabled). Cheap to call repeatedly; fires the global sweep at most once + * every few seconds per backend. + */ +extern void SLogTupleMaybeCleanupRetained(void); + +/* GUC: number of sLog flat hash partitions (0 = auto based on CPU count) */ +extern int slog_num_partitions; + +#endif /* SLOG_H */ diff --git a/src/include/access/slog_flathash.h b/src/include/access/slog_flathash.h new file mode 100644 index 0000000000000..7f23a61c79f4d --- /dev/null +++ b/src/include/access/slog_flathash.h @@ -0,0 +1,307 @@ +/*------------------------------------------------------------------------- + * + * slog_flathash.h + * Seqlock-protected flat open-addressing hash for sLog tuple tracking. + * + * This provides wait-free read access to sLog tuple entries via a seqlock + * (sequence lock). The flat hash uses open addressing with linear probing + * and tombstone markers for deletions. + * + * Architecture: A SINGLE copy of the hash lives in shared memory, guarded + * by a per-partition sequence counter (SLogFlatPartition.seq). Readers + * acquire-load the counter, read data into local variables, then re-load + * the counter and retry if it changed -- no announce-store, no StoreLoad + * fence (unlike the left-right lock this replaces). Writers are already + * mutually excluded by the per-partition writer_lock LWLock, so a writer + * simply makes the counter odd (write in progress), mutates the single + * copy in place, then makes it even again (+2 total). A reader that + * observes an odd counter spins until it turns even. + * + * Scan semantics: SLogFlatHashScanInit/ScanNext iterate all occupied + * buckets linearly. Read-side scans must run inside a seqlock retry loop + * (SLOG_SEQ_READ_BEGIN/END) and may only copy data into locals; a writer + * holds writer_lock and mutates fp->hash directly. For write operations + * that need global scans (eviction, xid removal), the pattern is: scan + * fp->hash under the writer lock (stable, no reader can tear it), collect + * keys, then apply write ops. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/slog_flathash.h + * + *------------------------------------------------------------------------- + */ +#ifndef SLOG_FLATHASH_H +#define SLOG_FLATHASH_H + +#include "access/slog.h" +#include "port/atomics.h" +#include "storage/lwlock.h" +#include "storage/s_lock.h" +#include "storage/seqlock.h" + +/* + * Bucket states encoded via hash_val: + * 0 = empty (never used) + * UINT32_MAX = tombstone (deleted) + * anything else = occupied with that hash value + */ +#define SLOG_FLAT_EMPTY 0 +#define SLOG_FLAT_TOMBSTONE UINT32_MAX + +/* + * SLogFlatBucket - One bucket in the flat open-addressing hash table. + * + * Layout: hash_val marks the state, key is the lookup key, entry contains + * the full SLogTupleEntry data (ops array etc). + */ +typedef struct SLogFlatBucket +{ + uint32 hash_val; /* 0=empty, TOMBSTONE=deleted, else hash */ + SLogTupleKey key; /* (relid, tid) */ + uint16 padding; /* alignment padding */ + SLogTupleEntry entry; /* nops + ops[SLOG_MAX_TUPLE_OPS] */ +} SLogFlatBucket; + +/* + * SLogFlatHash - The flat hash table header, followed by buckets[]. + * + * A single copy lives in shared memory, guarded by the partition seqlock. + */ +typedef struct SLogFlatHash +{ + int32 capacity; /* number of buckets (power of 2) */ + int32 num_entries; /* current live entries */ + int32 num_tombstones; /* tombstone count (for load factor) */ + int32 padding; + SLogFlatBucket buckets[FLEXIBLE_ARRAY_MEMBER]; +} SLogFlatHash; + +/* + * Operation kinds for the flat-hash apply dispatcher (SLogFlatHashApply). + */ +typedef enum SLogFlatOpKind +{ + SLOG_FLAT_OP_INSERT, /* Insert/update a single op slot */ + SLOG_FLAT_OP_REMOVE_XID, /* Remove all ops for xid from entry */ + SLOG_FLAT_OP_REMOVE_ENTRY, /* Remove entire entry (tombstone it) */ + SLOG_FLAT_OP_MARK_ABORTED, /* Mark all ops for xid as ABORTED */ + SLOG_FLAT_OP_UPDATE_OP, /* Update a specific op slot in-place */ + SLOG_FLAT_OP_COMMIT_XID, /* Handle commit retention for an entry */ + SLOG_FLAT_OP_CLEANUP_RETAINED, /* Remove old retained entries */ + SLOG_FLAT_OP_CREATE_ABORTED, /* Create a new ABORTED entry (for + * local-only) */ +} SLogFlatOpKind; + +/* + * SLogFlatOp - A single operation to be applied to the flat hash. + * + * Passed to SLogFlatHashApply(), which mutates the single flat-hash copy. + */ +typedef struct SLogFlatOp +{ + SLogFlatOpKind kind; + SLogTupleKey key; /* which entry */ + TransactionId xid; /* target xid */ + TransactionId subxid; /* for subxid operations */ + TransactionId reclaim_xid_horizon; /* INSERT: oldest active snapshot + * xmin; a committed UPDATE marker may + * be reclaimed only if its xid + * precedes this (visible to all + * snapshots) */ + SLogTupleOp tuple_op; /* the op to insert/update (for INSERT) */ +} SLogFlatOp; + +/* ---------------------------------------------------------------- + * Partitioned flat hash: 32-way sharding to reduce writer lock contention. + * + * Each partition has its own single flat-hash copy guarded by a seqlock + * (wait-free reads) and its own writer lock. Key routing: + * hash(key) % NUM_PARTITIONS. This reduces writer lock contention + * proportionally to the number of partitions. + * + * The partition count is determined at startup by the slog_num_partitions + * GUC (default: 0 = auto-size based on CPU count). The heuristic targets + * 4Ɨ the number of CPUs, clamped to [16, 256], rounded to next power of 2. + * This ensures that at peak concurrency each CPU core has ~4 partitions to + * spread writes across, minimizing writer lock wait time. + * ---------------------------------------------------------------- + */ + +/* Default partition count used only before SLogShmemInit sets the real value */ +#define SLOG_FLAT_DEFAULT_PARTITIONS 32 +#define SLOG_FLAT_MIN_PARTITIONS 16 +#define SLOG_FLAT_MAX_PARTITIONS 256 + +/* + * SLogFlatPartition - Per-partition state. + * + * Each partition owns a slice of the total flat hash capacity. Reads are + * wait-free via the seqlock (seq); writes are serialized by writer_lock and + * mutate the single copy (*hash) in place. + */ +typedef struct SLogFlatPartition +{ + SLogFlatHash *hash; /* single flat-hash copy (in shmem) */ + SeqLock seqlock; /* retry-based consistent reads */ + LWLockPadded writer_lock; /* per-partition writer serialization */ +} SLogFlatPartition; + +/* + * Seqlock write-side helpers (thin wrappers over the generic SeqLock). + * + * The caller MUST already hold the partition writer_lock LW_EXCLUSIVE, which + * is the sole writer mutual-exclusion mechanism (the seqlock provides no + * writer exclusion of its own). Between _begin and _end the counter is odd, + * so any concurrent reader retries; the writer mutates fp->hash directly. + */ +static inline void +SLogSeqWriteBegin(SLogFlatPartition * fp) +{ + SeqLockWriteBegin(&fp->seqlock); +} + +static inline void +SLogSeqWriteEnd(SLogFlatPartition * fp) +{ + SeqLockWriteEnd(&fp->seqlock); +} + +/* + * Seqlock read-side helpers -- "copy into locals, then act after". + * + * Usage: + * uint32 slog_seq_; + * SLOG_SEQ_READ_BEGIN(fp, slog_seq_) + * { + * ... probe fp->hash and memcpy/read into LOCAL variables ONLY ... + * } + * SLOG_SEQ_READ_END(fp, slog_seq_); + * ... now act on the locals ... + * + * The body may run more than once, so it must have no side effects beyond + * writing caller-provided output locals (reset any accumulator at the top of + * the body). It must NOT retain pointers into fp->hash past the loop nor run + * callbacks with side effects; those run after SLOG_SEQ_READ_END confirms a + * consistent read. These macros wrap the generic SeqLock reader API. + */ +#define SLOG_SEQ_READ_BEGIN(fp, seqvar) \ + for (;;) \ + { \ + (seqvar) = SeqLockReadBegin(&(fp)->seqlock); + +#define SLOG_SEQ_READ_END(fp, seqvar) \ + if (SeqLockReadRetry(&(fp)->seqlock, (seqvar))) \ + break; \ + } + +/* + * Compute the shared memory size needed for the flat hash data + * (the single copy guarded by the seqlock). + */ +extern Size SLogFlatHashDataSize(int capacity); + +/* + * Compute the total shared memory needed for one partition's flat hash + * (the single copy; the seqlock and writer_lock are embedded in + * SLogFlatPartition). + */ +extern Size SLogFlatHashShmemSize(int capacity, int max_backends); + +/* + * Compute the total shared memory needed for all partitions. + */ +extern Size SLogFlatHashPartitionedShmemSize(int total_capacity, + int max_backends); + +/* + * Initialize the flat hash in a pre-allocated data block. + * Called during SLogShmemInit to set up the single copy. + */ +extern void SLogFlatHashInit(void *data, int capacity); + +/* + * Apply one SLogFlatOp to the (single) flat-hash copy. Called by writers + * holding the partition writer_lock, between SLogSeqWriteBegin/End. + */ +extern void SLogFlatHashApply(void *data, const void *operation, Size op_size); + +/* + * Hash computation for SLogTupleKey. + */ +extern uint32 SLogFlatHashComputeHash(const SLogTupleKey *key); + +/* + * Runtime partition count — set during SLogShmemInit() based on + * the slog_num_partitions GUC. Declared in slog.c. + */ +extern int SLogNumPartitions; + +/* + * Compute which partition a key belongs to. + */ +static inline int +SLogFlatHashPartitionIndex(const SLogTupleKey *key) +{ + return (int) (SLogFlatHashComputeHash(key) % (uint32) SLogNumPartitions); +} + +/* + * Probe the flat hash for a key. Returns pointer to the bucket if found, + * NULL if not found. Only valid during a read-side or write-side critical + * section. + */ +extern SLogFlatBucket * SLogFlatHashProbe(const SLogFlatHash * ht, + const SLogTupleKey *key); + +/* + * Return true iff the entry for key holds an in-use op for xid. Detects a + * silently-dropped op (per-TID array full) where the bucket itself exists. + * Only valid during a read-side or write-side critical section. + */ +extern bool SLogFlatHashHasOpForXid(const SLogFlatHash * ht, + const SLogTupleKey *key, + TransactionId xid); + +/* + * Find a bucket for insertion (first empty or tombstone slot on probe chain). + * Returns NULL if the table is full (all slots on probe chain occupied). + * Only valid during write-side critical section. + */ +extern SLogFlatBucket * SLogFlatHashProbeForInsert(SLogFlatHash * ht, + const SLogTupleKey *key, + uint32 hash_val); + +/* + * Scan API for iterating all occupied buckets. + * + * Used by global-scan operations (eviction, xid removal, cleanup) that + * need to visit every entry. The scan iterates linearly over the bucket + * array, skipping EMPTY and TOMBSTONE slots. + * + * Usage pattern: + * SLogFlatHashScanState state; + * const SLogFlatBucket *bucket; + * + * SLogFlatHashScanInit(&state); + * while ((bucket = SLogFlatHashScanNext(ht, &state)) != NULL) + * { + * // process bucket->entry + * } + * + * The scan must be performed inside a seqlock read-side retry loop + * (SLOG_SEQ_READ_BEGIN/END, read into locals only) or by a writer holding + * writer_lock. For write operations, scan fp->hash under the writer lock + * (stable) to collect keys, then apply write ops. + */ +typedef struct SLogFlatHashScanState +{ + int32 current_index; +} SLogFlatHashScanState; + +extern void SLogFlatHashScanInit(SLogFlatHashScanState * state); +extern const SLogFlatBucket *SLogFlatHashScanNext(const SLogFlatHash * ht, + SLogFlatHashScanState * state); + +#endif /* SLOG_FLATHASH_H */ diff --git a/src/include/access/slog_internal.h b/src/include/access/slog_internal.h new file mode 100644 index 0000000000000..aa4d123defd84 --- /dev/null +++ b/src/include/access/slog_internal.h @@ -0,0 +1,66 @@ +/*------------------------------------------------------------------------- + * + * slog_internal.h + * Shared-state definitions private to the sLog implementation + * + * The sLog is split across two translation units that share one + * shared-memory segment: slog.c (the always-present transaction Aborted + * Transaction Map) and slog_tuple.c (the optional per-tuple flat-hash + * tracking extension). This header exposes the shared state struct and the + * few globals both files touch, so it is deliberately NOT part of the public + * sLog API in access/slog.h. Only the two sLog .c files include it. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/slog_internal.h + * + *------------------------------------------------------------------------- + */ +#ifndef SLOG_INTERNAL_H +#define SLOG_INTERNAL_H + +#include "access/slog_flathash.h" +#include "storage/lwlock.h" +#include "utils/dsa.h" + +/* + * Initial size for the sLog DSA area (backs the aborted-txn radix tree). + * Grows dynamically as needed up to slog_dsa_max_size_mb. + */ +#define SLOG_DSA_INIT_SIZE (512 * 1024) /* 512 KB */ + +/* + * Shared state for the whole sLog subsystem. + * + * The transaction ATM fields are always used. The tuple flat-hash fields + * (tuple_partitions, num_partitions) are used only when the tuple sLog + * extension is compiled in and an access method has opted in; they are + * initialized unconditionally in SLogShmemInit() so the two files can share + * one segment without an init-ordering dependency. + */ +typedef struct SLogSharedState +{ + /* Transaction ATM (adaptive radix tree in the DSA area below) */ + dsa_pointer atm_handle; /* RT handle; InvalidDsaPointer until init */ + LWLockPadded txn_lock; /* single LWLock serializing ATM access */ + + /* Tuple flat hash: N-way partitioned for reduced writer contention. + * Partition count determined at startup by slog_num_partitions GUC. */ + SLogFlatPartition *tuple_partitions; /* palloc'd array in shmem */ + int num_partitions; /* actual partition count */ + + /* DSA area backing the aborted-txn radix tree */ + dsa_area *dsa_area; /* set during SLogShmemInit, NULL until then */ + char dsa_space[SLOG_DSA_INIT_SIZE]; +} SLogSharedState; + +/* The single shared-state instance (defined in slog.c). */ +extern SLogSharedState *SLogState; + +/* Tuple-hash shmem sizing/init helpers (defined in slog_tuple.c). */ +extern Size SLogTupleShmemSize(void); +extern void SLogTupleShmemRequest(void); +extern void SLogTupleShmemInit(void); + +#endif /* SLOG_INTERNAL_H */ diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h index f2c36696bcad0..8bfada80ab5aa 100644 --- a/src/include/access/tableam.h +++ b/src/include/access/tableam.h @@ -323,6 +323,83 @@ typedef struct TableAmRoutine /* this must be set to T_TableAmRoutine */ NodeTag type; + /* + * am_inplace_update_no_dead_tuple: true if an UPDATE on this AM creates + * zero dead tuples (the in-place image is overwritten; there is no old + * version left behind for VACUUM to reclaim). + * + * pgstat's per-table dead-tuple accounting + * (pgstat_relation.c: delta_dead_tuples += tuples_updated + tuples_deleted) + * hardcodes heap's UPDATE-creates-a-dead-tuple semantics for every AM. + * That assumption is wrong for an in-place AM: pgstat_count_heap_update() + * is still called (for the tuples_updated/tuples_hot_updated counters, + * which other stats consume), but the resulting dead-tuple delta must be + * suppressed, or autovacuum's dead-tuple threshold is driven by phantom + * dead tuples that were never created, and n_dead_tup reports a number + * unrelated to any real reclaimable state. + * + * When true, pgstat_count_heap_update() skips the delta_dead_tuples + * increment for non-transactional (committed) updates. Deletes are + * unaffected by this flag: even an in-place-update AM's DELETE removes a + * tuple that VACUUM must eventually reclaim space for, so tuples_deleted + * still contributes. + * + * The heap AM leaves this false. + */ + bool am_inplace_update_no_dead_tuple; + + /* + * am_supports_undo: true if this AM supports cluster-wide UNDO. + * + * An AM that sets this to true must: 1. Register an UNDO resource manager + * via RegisterUndoRmgr() (see src/include/access/undormgr.h) with an + * rm_undo callback that handles its own page format during rollback. 2. + * Write UNDO records tagged with its own urec_rmid so that undoapply.c + * dispatches to the correct apply handler. 3. Generate CLR (Compensation + * Log Records) in its rm_undo callback for crash-recovery idempotency. + * + * The UNDO infrastructure is AM-agnostic: UndoRecordHeader carries an + * opaque payload interpreted exclusively by the owning RM's callbacks. + * Each AM handles its own page format in its own rm_undo implementation. + * There is no requirement to use heap page layout. + * + * For UNDO record generation, AMs can either: (a) Use the shared Tier 2 + * buffer (UndoBufferAddRecord() / UndoBufferAddRecordParts() from + * undobuffer.h) to embed UNDO data into DML WAL records, or (b) Create a + * standalone UndoRecordSet for batched/deferred writes. + * + * How an AM decides whether UNDO is active for a given relation is + * AM-specific. The heap AM does not use UNDO (am_supports_undo = false). + * A future RECNO AM will set am_supports_undo = true and register its own + * UNDO RM. + * + * See src/include/access/undormgr.h for the RM registration API and + * src/backend/access/undo/undoapply.c for the dispatch mechanism. + */ + bool am_supports_undo; + + /* + * am_inplace_update_keeps_tid: true if this AM performs UPDATE in place, + * keeping the same TID for the updated row instead of inserting a new + * version at a new TID. + * + * The heap AM creates a new tuple version at a new TID on UPDATE, so when + * an indexed column changes the executor re-inserts entries into every + * index (including indexes on unchanged columns) pointing at the new TID; + * the old entries become dead and are reclaimed by VACUUM. + * + * An in-place AM that keeps the TID must NOT re-insert index entries whose + * key is unchanged by the UPDATE: the existing (key, TID) entry is still + * valid, and inserting an identical (key, TID) pair would create a + * duplicate that violates index-AM invariants (e.g. nbtree posting-list + * dedup requires strictly increasing heap TIDs). ExecInsertIndexTuples() + * consults this flag together with the per-index "indexUnchanged" signal + * to skip those redundant re-inserts. + * + * The heap AM leaves this false. + */ + bool am_inplace_update_keeps_tid; + /* ------------------------------------------------------------------------ * Slot related callbacks. @@ -599,6 +676,19 @@ typedef struct TableAmRoutine uint8 flags, TM_FailureData *tmfd); + /* + * Notify the AM that a bulk DML operation is about to begin. + * + * The AM can use this hint to pre-allocate resources, enable batched UNDO + * recording, or otherwise optimize for the expected workload. 'nrows' is + * the planner's estimate of the number of rows to be modified (0 means + * unknown). + * + * Optional callback. + */ + void (*begin_bulk_insert) (Relation rel, uint32 options, + int64 nrows); + /* * Perform operations necessary to complete insertions made via * tuple_insert and multi_insert with a BulkInsertState specified. In-tree @@ -1653,6 +1743,21 @@ table_tuple_lock(Relation rel, ItemPointer tid, Snapshot snapshot, flags, tmfd); } +/* + * Notify the AM that a bulk DML operation is about to begin. + * + * 'nrows' is the planner's row count estimate (0 = unknown). + * The AM may use this to pre-allocate UNDO buffers, enable batched + * recording, or other bulk-mode optimizations. + */ +static inline void +table_begin_bulk_insert(Relation rel, uint32 options, int64 nrows) +{ + /* optional callback */ + if (rel->rd_tableam && rel->rd_tableam->begin_bulk_insert) + rel->rd_tableam->begin_bulk_insert(rel, options, nrows); +} + /* * Perform operations necessary to complete insertions made via * tuple_insert and multi_insert with a BulkInsertState specified. @@ -2140,4 +2245,11 @@ extern const TableAmRoutine *GetTableAmRoutine(Oid amhandler); extern const TableAmRoutine *GetHeapamTableAmRoutine(void); +/* ---------------------------------------------------------------------------- + * Functions in tableam.c + * ---------------------------------------------------------------------------- + */ + +extern bool RelationAmSupportsUndo(Relation rel); + #endif /* TABLEAM_H */ diff --git a/src/include/access/twophase.h b/src/include/access/twophase.h index 1d2ff42c9b72f..3714ea62b3385 100644 --- a/src/include/access/twophase.h +++ b/src/include/access/twophase.h @@ -48,6 +48,7 @@ extern GlobalTransaction MarkAsPreparing(FullTransactionId fxid, const char *gid extern void StartPrepare(GlobalTransaction gxact); extern void EndPrepare(GlobalTransaction gxact); extern bool StandbyTransactionIdIsPrepared(TransactionId xid); +extern bool RecoveryTransactionIdIsPrepared(TransactionId xid); extern TransactionId PrescanPreparedTransactions(TransactionId **xids_p, int *nxids_p); @@ -72,4 +73,6 @@ extern bool LookupGXactBySubid(Oid subid); extern TransactionId TwoPhaseGetOldestXidInCommit(void); +extern XLogRecPtr TwoPhaseGetOldestUndoBatchLSN(void); + #endif /* TWOPHASE_H */ diff --git a/src/include/access/twophase_rmgr.h b/src/include/access/twophase_rmgr.h index 8927f369c39b5..9560eca97bee5 100644 --- a/src/include/access/twophase_rmgr.h +++ b/src/include/access/twophase_rmgr.h @@ -28,7 +28,8 @@ typedef uint8 TwoPhaseRmgrId; #define TWOPHASE_RM_PGSTAT_ID 2 #define TWOPHASE_RM_MULTIXACT_ID 3 #define TWOPHASE_RM_PREDICATELOCK_ID 4 -#define TWOPHASE_RM_MAX_ID TWOPHASE_RM_PREDICATELOCK_ID +#define TWOPHASE_RM_FLUX_ID 5 +#define TWOPHASE_RM_MAX_ID TWOPHASE_RM_FLUX_ID extern PGDLLIMPORT const TwoPhaseCallback twophase_recover_callbacks[]; extern PGDLLIMPORT const TwoPhaseCallback twophase_postcommit_callbacks[]; diff --git a/src/include/access/undo.h b/src/include/access/undo.h new file mode 100644 index 0000000000000..d258c804e0151 --- /dev/null +++ b/src/include/access/undo.h @@ -0,0 +1,52 @@ +/*------------------------------------------------------------------------- + * + * undo.h + * Common undo layer interface + * + * The undo subsystem consists of several logically separate subsystems + * that work together: + * + * undolog.c - Undo log file management and space allocation + * undorecord.c - Record format, serialization, and UndoRecordSet + * xactundo.c - Per-transaction record set management + * undoapply.c - Physical undo application during rollback + * undoworker.c - Background discard worker + * undo_bufmgr.c - Buffer management via shared_buffers + * undo_xlog.c - WAL redo routines + * + * This header provides the unified entry points for shared memory + * initialization and startup/shutdown coordination across all undo + * subsystems. The design follows the EDB undo-record-set branch + * pattern where UndoShmemSize()/UndoShmemInit() aggregate the + * requirements of all subsystems. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undo.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDO_H +#define UNDO_H + +#include "access/undodefs.h" +#include "utils/palloc.h" + +/* + * Unified shared memory initialization. + * + * UndoShmemSize() computes the total shared memory needed by all undo + * subsystems. UndoShmemInit() initializes all undo shared memory + * structures. These are called from ipci.c during postmaster startup. + */ +extern Size UndoShmemSize(void); +extern void UndoShmemInit(void); + +/* Per-backend initialization */ +extern void InitializeUndo(void); + +/* Memory context for undo-related allocations */ +extern MemoryContext UndoContext; + +#endif /* UNDO_H */ diff --git a/src/include/access/undo_bufmgr.h b/src/include/access/undo_bufmgr.h new file mode 100644 index 0000000000000..b0c3736b73122 --- /dev/null +++ b/src/include/access/undo_bufmgr.h @@ -0,0 +1,297 @@ +/*------------------------------------------------------------------------- + * + * undo_bufmgr.h + * UNDO log buffer management and file layout definitions + * + * UNDO-in-WAL architecture: + * + * - UNDO records are embedded in the WAL stream as XLOG_UNDO_BATCH + * records. There are no separate UNDO segment files. + * - Reads: UndoReadBatchFromWAL() reads UNDO batches from WAL via + * XLogReader (for rollback chain traversal). + * - Sync: WAL flush handles durability (standard XLogFlush path). + * - Retention: undo_discard_horizon prevents WAL recycling past + * oldest needed UNDO batch. + * + * This module retains virtual RelFileLocator mapping for: + * - Buffer invalidation during segment discard (InvalidateUndoBuffers) + * - Legacy backward compatibility + * + * Each undo log is mapped to a virtual relation: + * RelFileLocator = { + * spcOid = UNDO_DEFAULT_TABLESPACE_OID (pg_default, 1663) + * dbOid = UNDO_DB_OID (pseudo-database 9) + * relNumber = log_number (undo log number as RelFileNumber) + * } + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undo_bufmgr.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDO_BUFMGR_H +#define UNDO_BUFMGR_H + +#include "storage/block.h" +#include "storage/buf.h" +#include "storage/bufmgr.h" +#include "storage/relfilelocator.h" + +/* + * Pseudo-database OID used for undo log relations in the buffer pool. + * This matches ZHeap's UndoLogDatabaseOid convention. This OID must not + * collide with any real database OID; value 9 is reserved for this purpose. + */ +#define UNDO_DB_OID 9 + +/* + * Default tablespace OID for undo log buffers. This matches the + * pg_default tablespace (OID 1663 from pg_tablespace.dat). + * Eventually per-tablespace undo logs may be supported, but for now + * all undo data uses the default tablespace. + */ +#define UNDO_DEFAULT_TABLESPACE_OID 1663 + +/* + * Fork number used for undo log buffers in the shared buffer pool. + * + * Following ZHeap's convention (UndoLogForkNum = MAIN_FORKNUM), we use + * MAIN_FORKNUM for undo log buffer operations. Undo buffers are + * distinguished from regular relation data by the UNDO_DB_OID in the + * dbOid field of the BufferTag, not by a special fork number. + * + * Using MAIN_FORKNUM is necessary because the smgr layer sizes internal + * arrays to MAX_FORKNUM+1 entries. A fork number beyond that range + * would cause out-of-bounds accesses in smgr_cached_nblocks[] and + * similar arrays. + */ +#define UndoLogForkNum MAIN_FORKNUM + +/* + * UNDO_FORKNUM is reserved for future use when the smgr layer is + * extended to support undo-specific file management (Task #5). + * It is defined in buf_internals.h as a constant but not currently + * used in buffer operations. + */ + + +/* ---------------------------------------------------------------- + * Undo log to RelFileLocator mapping + * ---------------------------------------------------------------- + */ + +/* + * UndoLogGetRelFileLocator + * Build a virtual RelFileLocator for an undo log number. + * + * This mapping allows the standard buffer manager to identify undo log + * blocks using its existing BufferTag infrastructure. The resulting + * RelFileLocator does not correspond to any entry in pg_class; it is + * purely a buffer-pool-internal identifier. + * + * Parameters: + * log_number - the undo log number (0..16M) + * rlocator - output RelFileLocator to populate + */ +static inline void +UndoLogGetRelFileLocator(uint32 log_number, RelFileLocator *rlocator) +{ + rlocator->spcOid = UNDO_DEFAULT_TABLESPACE_OID; + rlocator->dbOid = UNDO_DB_OID; + rlocator->relNumber = (RelFileNumber) log_number; +} + +/* + * IsUndoRelFileLocator + * Check whether a RelFileLocator refers to an undo log. + * + * This is useful for code that needs to distinguish undo log locators + * from regular relation locators (e.g., in smgr dispatch, checkpoint + * logic, or buffer tag inspection). + */ +static inline bool +IsUndoRelFileLocator(const RelFileLocator *rlocator) +{ + return (rlocator->dbOid == UNDO_DB_OID); +} + +/* + * UNDO file layout: append-only + * + * UNDO log files use an append-only layout with NO PageHeaderData overhead. + * The logical byte offset in UndoRecPtr maps directly to the physical file + * offset. This eliminates the overhead of page headers, pd_lower tracking, + * LSN management, and full-page images for UNDO data. + * + * UNDO data is written via pwrite() and read via pread(), bypassing + * shared_buffers entirely for the write path. For reads, hot data is + * served from the kernel page cache (no I/O), while cold data requires + * sequential I/O on the pre-allocated file. + * + * The buffer pool integration (ReadUndoBuffer etc.) is retained only for + * the buffer invalidation API used during segment discard. + */ + +/* + * UndoRecPtrGetFileOffset + * Compute the physical file offset for an undo log logical byte offset. + * + * With the append-only layout, the logical offset IS the file offset. + */ +#define UndoRecPtrGetFileOffset(offset) ((uint64) (offset)) + +/* + * Legacy page-layout macros (retained for undo_bufmgr.c invalidation API). + * + * These are used only by buffer invalidation during discard, not by the + * write/read paths. The "block number" is conceptual, mapping the + * contiguous byte stream to BLCKSZ-aligned regions. + */ +#define UNDO_USABLE_BYTES_PER_PAGE BLCKSZ + +#define UndoRecPtrGetBlockNum(offset) \ + ((BlockNumber) ((offset) / BLCKSZ)) + +#define UndoRecPtrGetPageOffset(offset) \ + ((uint32) ((offset) % BLCKSZ)) + +/* + * UndoLogicalToFileSize + * Compute the physical file size needed for a given logical byte count. + * + * With append-only layout, physical size equals logical size (no headers). + * We round up to BLCKSZ alignment for pre-allocation. + */ +#define UndoLogicalToFileSize(logical_size) \ + ((uint64) (((logical_size) + BLCKSZ - 1) / BLCKSZ) * BLCKSZ) + + +/* ---------------------------------------------------------------- + * Buffer read/release API + * ---------------------------------------------------------------- + */ + +/* + * ReadUndoBuffer + * Read an undo log block into the shared buffer pool. + * + * This is the primary entry point for reading undo data. It translates + * the undo log number and block number into a virtual RelFileLocator and + * calls ReadBufferWithoutRelcache() to obtain a shared buffer. + * + * The returned Buffer must be released with ReleaseUndoBuffer() when the + * caller is done. The caller may also need to lock the buffer (via + * LockBuffer) depending on the access pattern. + * + * Parameters: + * log_number - undo log number + * block_number - block within the undo log + * mode - RBM_NORMAL, RBM_ZERO_AND_LOCK, etc. + * + * Returns: a valid Buffer handle. + */ +extern Buffer ReadUndoBuffer(uint32 log_number, BlockNumber block_number, + ReadBufferMode mode); + +/* + * ReadUndoBufferExtended + * Like ReadUndoBuffer but with explicit strategy control. + * + * Allows the caller to specify a buffer access strategy (e.g., for + * sequential undo log scans during discard or recovery). + */ +extern Buffer ReadUndoBufferExtended(uint32 log_number, + BlockNumber block_number, + ReadBufferMode mode, + BufferAccessStrategy strategy); + +/* + * ReleaseUndoBuffer + * Release a previously read undo buffer. + * + * This is a thin wrapper around ReleaseBuffer() for API symmetry. + * If the buffer was locked, it must be unlocked first (or use + * UnlockReleaseUndoBuffer). + */ +extern void ReleaseUndoBuffer(Buffer buffer); + +/* + * UnlockReleaseUndoBuffer + * Unlock and release an undo buffer in one call. + */ +extern void UnlockReleaseUndoBuffer(Buffer buffer); + +/* + * MarkUndoBufferDirty + * Mark an undo buffer as dirty. + * + * This is a thin wrapper around MarkBufferDirty() for API consistency. + */ +extern void MarkUndoBufferDirty(Buffer buffer); + + +/* ---------------------------------------------------------------- + * Buffer tag construction (requires buf_internals.h) + * ---------------------------------------------------------------- + */ + +/* + * UndoMakeBufferTag + * Initialize a BufferTag for an undo log block. + * + * This constructs the BufferTag that the shared buffer manager will use + * to identify this undo block in its hash table. It uses the virtual + * RelFileLocator mapping and UndoLogForkNum. + * + * Callers must include storage/buf_internals.h before this header to + * make these declarations visible. + */ +#ifdef BUFMGR_INTERNALS_H +extern void UndoMakeBufferTag(BufferTag *tag, uint32 log_number, + BlockNumber block_number); + +/* + * IsUndoBufferTag + * Check whether a BufferTag refers to an undo log buffer. + * + * Undo buffers are identified by the UNDO_DB_OID in the dbOid field + * of the buffer tag. + */ +static inline bool +IsUndoBufferTag(const BufferTag *tag) +{ + return (tag->dbOid == UNDO_DB_OID); +} +#endif /* BUFMGR_INTERNALS_H */ + + +/* ---------------------------------------------------------------- + * Invalidation + * ---------------------------------------------------------------- + */ + +/* + * InvalidateUndoBuffers + * Drop all shared buffers for a given undo log. + * + * Called when an undo log is discarded to remove stale entries from + * the shared buffer pool. This is analogous to DropRelationBuffers() + * for regular relations. + */ +extern void InvalidateUndoBuffers(uint32 log_number); + +/* + * InvalidateUndoBufferRange + * Drop shared buffers for a range of blocks in an undo log. + * + * Called during undo log truncation/discard to invalidate only the + * blocks that are being reclaimed. Blocks starting from first_block + * onward are invalidated. + */ +extern void InvalidateUndoBufferRange(uint32 log_number, + BlockNumber first_block, + BlockNumber last_block); + +#endif /* UNDO_BUFMGR_H */ diff --git a/src/include/access/undo_xlog.h b/src/include/access/undo_xlog.h new file mode 100644 index 0000000000000..f8b0db7fd49db --- /dev/null +++ b/src/include/access/undo_xlog.h @@ -0,0 +1,332 @@ +/*------------------------------------------------------------------------- + * + * undo_xlog.h + * UNDO resource manager WAL record definitions + * + * This file contains the WAL record format definitions for UNDO log + * operations. These records are logged by the RM_UNDO_ID resource manager. + * + * Record types: + * XLOG_UNDO_ALLOCATE - Log UNDO space allocation + * XLOG_UNDO_DISCARD - Log UNDO record discard + * XLOG_UNDO_EXTEND - Log UNDO log file extension + * XLOG_UNDO_APPLY_RECORD - CLR: Log physical UNDO application to a page + * + * The XLOG_UNDO_APPLY_RECORD type is a Compensation Log Record (CLR). + * CLRs record the fact that an UNDO operation was applied to a page + * during transaction rollback. This ensures crash safety: if we crash + * during rollback, the already-applied UNDO operations are preserved + * via WAL replay of the CLR's full page image. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undo_xlog.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDO_XLOG_H +#define UNDO_XLOG_H + +#include "access/transam.h" +#include "access/xlogdefs.h" +#include "access/xlogreader.h" +#include "lib/stringinfo.h" +#include "storage/block.h" +#include "storage/off.h" +#include "storage/relfilelocator.h" + +/* + * UndoRecPtr type definition. We use undodefs.h which is lightweight + * and can be included in both frontend and backend code. If undodefs.h + * has already been included (via undolog.h or directly), this is a no-op. + */ +#include "access/undodefs.h" + +/* + * WAL record types for UNDO operations + * + * These are the info codes for UNDO WAL records. The low 4 bits are used + * for operation type, leaving the upper 4 bits for flags. + */ +#define XLOG_UNDO_ALLOCATE 0x00 /* Allocate UNDO log space + * (legacy) */ +#define XLOG_UNDO_DISCARD 0x10 /* Discard old UNDO records */ +#define XLOG_UNDO_EXTEND 0x20 /* Extend UNDO log file (legacy) */ +#define XLOG_UNDO_APPLY_RECORD 0x30 /* CLR: UNDO applied to page */ +#define XLOG_UNDO_ROTATE 0x40 /* Seal old log, activate new + * (legacy) */ +#define XLOG_UNDO_PAGE_WRITE 0x50 /* Write UNDO data to a page + * (legacy) */ +#define XLOG_UNDO_BATCH 0x60 /* Batched UNDO records in WAL */ + +/* + * xl_undo_allocate - WAL record for UNDO space allocation + * + * Logged when a backend allocates space in an UNDO log for writing + * UNDO records. This ensures crash recovery can reconstruct the + * insert pointer state. + */ +typedef struct xl_undo_allocate +{ + UndoRecPtr start_ptr; /* Starting position of allocation */ + uint32 length; /* Length of allocation in bytes */ + TransactionId xid; /* Transaction that allocated this space */ + uint32 log_number; /* Log number (extracted from start_ptr) */ +} xl_undo_allocate; + +#define SizeOfUndoAllocate (offsetof(xl_undo_allocate, log_number) + sizeof(uint32)) + +/* + * xl_undo_discard - WAL record for UNDO discard operation + * + * Logged when the UNDO worker discards old UNDO records that are no + * longer needed by any active transaction. This allows space to be + * reclaimed. + */ +typedef struct xl_undo_discard +{ + UndoRecPtr discard_ptr; /* New discard pointer (oldest still needed) */ + uint32 log_number; /* Which log is being discarded */ + TransactionId oldest_xid; /* Oldest XID still needing UNDO */ +} xl_undo_discard; + +#define SizeOfUndoDiscard (offsetof(xl_undo_discard, oldest_xid) + sizeof(TransactionId)) + +/* + * xl_undo_extend - WAL record for UNDO log file extension + * + * Logged when an UNDO log file is extended to accommodate more UNDO + * records. This ensures the file size is correctly restored during + * crash recovery. + */ +typedef struct xl_undo_extend +{ + uint32 log_number; /* Which log is being extended */ + uint64 new_size; /* New size of log file in bytes */ +} xl_undo_extend; + +#define SizeOfUndoExtend (offsetof(xl_undo_extend, new_size) + sizeof(uint64)) + +/* + * xl_undo_apply - CLR for physical UNDO application (physiological) + * + * This is a Compensation Log Record (CLR) generated when an UNDO record + * is physically applied to a heap or index page during transaction rollback. + * + * Physiological CLR approach: + * Instead of storing a full 8KB page image (REGBUF_FORCE_IMAGE), we log + * just the operation and its data. During redo, we re-apply the exact + * same page modification. This reduces WAL volume from ~8KB to + * ~100-500 bytes per CLR. + * + * For operations that only change LP state (INSERT undo, HOT_UPDATE kill), + * no additional data is needed -- the metadata in xl_undo_apply suffices. + * + * For operations that restore tuple data (DELETE/UPDATE/INPLACE undo), + * the tuple data follows the fixed header as registered buffer data. + * + * For full page image operations (DEDUP undo), REGBUF_FORCE_IMAGE is + * still used since the entire page is being replaced. + * + * CLR flags (in clr_flags): + * UNDO_CLR_HAS_TUPLE - Tuple data follows (for DELETE/UPDATE/INPLACE) + * UNDO_CLR_HAS_DELTA - Delta-encoded tuple data (for UPDATE) + * UNDO_CLR_LP_DEAD - Mark line pointer LP_DEAD (for INSERT undo) + * UNDO_CLR_LP_UNUSED - Mark line pointer LP_UNUSED (for INSERT undo) + * UNDO_CLR_FULL_PAGE - Full page image (fallback, DEDUP undo) + * UNDO_CLR_HOT_RESTORE - HOT update rollback (restore infomask + kill new) + */ + +/* CLR operation flags */ +#define UNDO_CLR_HAS_TUPLE 0x0001 /* Tuple data in buffer data */ +#define UNDO_CLR_HAS_DELTA 0x0002 /* Delta-encoded tuple restoration */ +#define UNDO_CLR_LP_DEAD 0x0004 /* Mark target LP_DEAD */ +#define UNDO_CLR_LP_UNUSED 0x0008 /* Mark target LP_UNUSED */ +#define UNDO_CLR_FULL_PAGE 0x0010 /* Full page image (DEDUP) */ +#define UNDO_CLR_HOT_RESTORE 0x0020 /* HOT update rollback */ +#define UNDO_CLR_HAS_VISIBILITY 0x0040 /* Visibility-delta (xmax+infomask) + * for DELETE */ + +typedef struct xl_undo_apply +{ + UndoRecPtr urec_ptr; /* UNDO record pointer that was applied */ + TransactionId xid; /* Transaction being rolled back */ + RelFileLocator target_locator; /* Target relation file locator */ + BlockNumber target_block; /* Target block number */ + OffsetNumber target_offset; /* Target item offset within page */ + uint16 operation_type; /* UNDO subtype (HEAP_UNDO_INSERT, etc.) */ + uint16 clr_flags; /* UNDO_CLR_* flags */ + uint32 tuple_len; /* Restored tuple length (0 if no tuple) */ +} xl_undo_apply; + +#define SizeOfUndoApply (offsetof(xl_undo_apply, tuple_len) + sizeof(uint32)) + +/* + * xl_undo_apply_hot - Additional data for HOT update CLR redo + * + * Follows xl_undo_apply when UNDO_CLR_HOT_RESTORE is set. + * Registered as additional XLogRegisterData after the main record. + */ +typedef struct xl_undo_apply_hot +{ + OffsetNumber new_offset; /* New (killed) tuple's offset */ + uint16 old_infomask; /* Restored infomask for old tuple */ + uint16 old_infomask2; /* Restored infomask2 for old tuple */ +} xl_undo_apply_hot; + +#define SizeOfUndoApplyHot (offsetof(xl_undo_apply_hot, old_infomask2) + sizeof(uint16)) + +/* + * xl_undo_apply_visibility - Additional data for DELETE visibility-delta CLR + * + * Follows xl_undo_apply when UNDO_CLR_HAS_VISIBILITY is set. + * Stores only the three header fields changed by DELETE, not the full tuple. + * This reduces DELETE UNDO WAL payload from ~160-560 bytes to 8 bytes. + */ +typedef struct xl_undo_apply_visibility +{ + TransactionId old_xmax; /* t_xmax before delete */ + uint16 old_infomask; /* t_infomask before delete */ + uint16 old_infomask2; /* t_infomask2 before delete */ +} xl_undo_apply_visibility; + +#define SizeOfUndoApplyVisibility \ + (offsetof(xl_undo_apply_visibility, old_infomask2) + sizeof(uint16)) + +/* + * xl_undo_page_write - WAL record for UNDO page data write + * + * Logged when UNDO data is written to a shared-buffer-managed page. + * The actual data follows the record header and is also registered + * via XLogRegisterBufData as buffer-specific data (block reference 0). + * + * During redo, the data is memcpy'd into the page at page_offset. + * If a full page image was stored (REGBUF_STANDARD enables FPI after + * checkpoints), XLogReadBufferForRedo restores it automatically and + * no additional replay is needed. + */ +typedef struct xl_undo_page_write +{ + uint32 page_offset; /* Offset within the page to write at */ + uint32 data_len; /* Length of data written */ +} xl_undo_page_write; + +#define SizeOfUndoPageWrite (offsetof(xl_undo_page_write, data_len) + sizeof(uint32)) + +/* + * Rotation trigger reasons for XLOG_UNDO_ROTATE records + */ +#define UNDO_ROTATE_CAPACITY 0x01 /* Rotated due to capacity threshold */ +#define UNDO_ROTATE_CHECKPOINT 0x02 /* Rotated at checkpoint boundary */ +#define UNDO_ROTATE_PRESSURE 0x03 /* Rotated under allocation pressure */ +#define UNDO_ROTATE_MANUAL 0x04 /* Rotated by pg_undo_force_discard() */ + +/* + * xl_undo_rotate - WAL record for UNDO log segment rotation + * + * Logged when the active UNDO log is sealed and a new one is activated. + * During recovery, the old log is marked SEALED and the new log is + * marked ACTIVE, restoring the correct lifecycle state. + */ +typedef struct xl_undo_rotate +{ + uint32 old_log_number; /* Log being sealed (0 if first log) */ + UndoRecPtr old_seal_ptr; /* Insert pointer at seal time */ + uint32 new_log_number; /* Newly activated log */ + uint8 trigger; /* UNDO_ROTATE_* reason */ +} xl_undo_rotate; + +#define SizeOfUndoRotate (offsetof(xl_undo_rotate, trigger) + sizeof(uint8)) + +/* + * xl_undo_batch - WAL record for batched UNDO data (XLOG_UNDO_BATCH) + * + * This record type replaces the old pwrite()-to-segment-file path. + * All UNDO records for a batch are serialized into a single WAL record. + * The batch payload contains concatenated UndoRecordHeader+payload pairs + * in their exact serialized format. + * + * The chain_prev field links this batch to the previous batch for the + * same transaction. During rollback, the UNDO chain is walked backward + * by reading WAL records at successive chain_prev LSNs. + * + * Coalescing: The existing UndoRecordSet mechanism batches records + * (flush at 256KB or 1000 records). This batch becomes one WAL record. + * A 1000-row INSERT produces ~1 WAL record containing 1000 UNDO records. + */ +typedef struct xl_undo_batch +{ + TransactionId xid; /* Owning transaction */ + XLogRecPtr chain_prev; /* LSN of previous batch for this xact + * (InvalidXLogRecPtr if first batch) */ + uint32 nrecords; /* Number of UNDO records in batch */ + uint32 total_len; /* Total bytes of serialized UNDO data */ + Oid primary_reloid; /* Relation OID (optimization for + * single-relation batches) */ + UndoPersistenceLevel persistence; /* Persistence level of this batch */ + /* Followed by total_len bytes of serialized UndoRecordHeader+payload */ +} xl_undo_batch; + +#define SizeOfUndoBatch (offsetof(xl_undo_batch, persistence) + sizeof(UndoPersistenceLevel)) + +/* + * xl_undo_chain_state - UNDO chain state for prepared transactions + * + * Saved in the two-phase state file during PREPARE TRANSACTION, so the + * UNDO chain can be restored during COMMIT/ROLLBACK PREPARED. + */ +typedef struct xl_undo_chain_state +{ + UndoRecPtr firstUndoPtr; /* First UNDO record in transaction chain */ + UndoRecPtr currentUndoPtr; /* Most recent UNDO record in chain */ +} xl_undo_chain_state; + +/* Function declarations for WAL operations */ +extern void undo_redo(XLogReaderState *record); +extern void undo_desc(StringInfo buf, XLogReaderState *record); +extern const char *undo_identify(uint8 info); + +/* Two-phase commit support */ +extern void undo_twophase_recover(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void undo_twophase_postcommit(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); +extern void undo_twophase_postabort(FullTransactionId fxid, uint16 info, + void *recdata, uint32 len); + +/* + * UNDO batch reading from WAL for rollback and recovery. + * + * UndoReadBatchFromWAL reads a single XLOG_UNDO_BATCH record at the + * given LSN and returns the header plus a pointer to the payload data. + * The caller must pfree the returned data when done. + */ +typedef struct UndoBatchData +{ + xl_undo_batch header; /* Batch header */ + char *payload; /* Serialized UNDO records (palloc'd) */ + Size payload_len; /* Length of payload */ +} UndoBatchData; + +extern bool UndoValidateBatchLSN(XLogRecPtr batch_lsn); +extern UndoBatchData * UndoReadBatchFromWAL(XLogRecPtr batch_lsn); +extern void UndoFreeBatchData(UndoBatchData * batch); +extern void UndoResetBatchReader(void); + +/* + * Recovery UNDO phase support. + * + * During WAL redo, XLOG_UNDO_BATCH records are tracked so that after + * redo completes, incomplete transactions can be identified and their + * UNDO chains walked for rollback. + */ +extern void UndoRecoveryTrackBatch(TransactionId xid, XLogRecPtr batch_lsn, + XLogRecPtr chain_prev, + UndoPersistenceLevel persistence); +extern void UndoRecoveryRemoveXid(TransactionId xid); +extern bool UndoRecoveryNeeded(void); +extern void PerformUndoRecovery(void); +extern void FlushDeferredUndoXacts(void); + +#endif /* UNDO_XLOG_H */ diff --git a/src/include/access/undobuffer.h b/src/include/access/undobuffer.h new file mode 100644 index 0000000000000..5579f8aaa8e4b --- /dev/null +++ b/src/include/access/undobuffer.h @@ -0,0 +1,113 @@ +/*------------------------------------------------------------------------- + * + * undobuffer.h + * AM-agnostic Tier 2 UNDO write buffer + * + * The Tier 2 buffer accumulates serialized UNDO records for the current DML + * operation in a per-backend byte buffer. At WAL-write time, the buffer + * contents are embedded directly inside the AM's DML WAL record via + * XLogRegisterData(), eliminating a separate XLOG_UNDO_BATCH record for + * single-tuple operations. + * + * If the buffer grows beyond the configured threshold before the DML WAL + * record is written, the overflow path flushes it as a standalone + * XLOG_UNDO_BATCH record (preserving bulk-operation semantics). + * + * The buffer is per-backend; only one relation can be active at a time. + * This matches the executor's single-ModifyTable-node pattern. + * + * Any access method (table or index) can use this buffer. The UNDO record + * header format (UndoRecordHeader) is AM-agnostic: each record carries an + * RM ID (urec_rmid) that identifies the resource manager responsible for + * interpreting and applying the record during rollback. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undobuffer.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDOBUFFER_H +#define UNDOBUFFER_H + +#include "access/xlogdefs.h" +#include "utils/relcache.h" + +/* + * UndoBufferBegin - Activate the Tier 2 UNDO buffer for a relation. + * + * Only one relation can have an active buffer at a time. If the buffer is + * already active for a different relation, the previous buffer is flushed + * and deactivated before switching. + * + * 'nrows' is the planner's estimate (0 if unknown); reserved for future + * pre-sizing but not used currently. + */ +extern void UndoBufferBegin(Relation rel, int64 nrows); + +/* + * UndoBufferEnd - Deactivate the Tier 2 UNDO buffer. + * + * Any records accumulated since the last flush or WAL embedding will be + * flushed as an overflow batch. + */ +extern void UndoBufferEnd(Relation rel); + +/* + * UndoBufferAddRecord - Add an UNDO record to the Tier 2 buffer. + * + * Auto-flushes via the overflow path if size/count thresholds are exceeded. + */ +extern void UndoBufferAddRecord(Relation rel, uint8 rmid, uint16 info, + const char *payload, Size payload_len); + +/* + * UndoBufferAddRecordParts - Add an UNDO record with scatter-gather payload. + * + * Avoids an intermediate buffer for operations where the payload is a + * fixed header struct + variable-length data (e.g., index tuple). + */ +extern void UndoBufferAddRecordParts(Relation rel, uint8 rmid, uint16 info, + const char *part1, Size part1_len, + const char *part2, Size part2_len); + +/* + * UndoBufferFlush - Overflow flush: emit a standalone XLOG_UNDO_BATCH. + * + * Used when the buffer grows too large before the DML WAL record is written + * (bulk operations), or at UndoBufferEnd time. + */ +extern void UndoBufferFlush(void); + +/* + * UndoBufferIsActive - Check if the Tier 2 buffer is active for a relation. + */ +extern bool UndoBufferIsActive(Relation rel); + +/* + * UndoBufferHasPendingData - Return true if the buffer has records to embed. + */ +extern bool UndoBufferHasPendingData(void); + +/* + * UndoBufferTakePayload - Hand off buffer contents to the caller. + * + * Called from the DML WAL section before XLogInsert(). The caller embeds + * the returned data via XLogRegisterData() to carry UNDO inside the DML + * WAL record. After XLogInsert(), the caller must invoke UndoBufferReset() + * to release ownership and update chain tracking. + */ +extern void UndoBufferTakePayload(char **data_out, Size *len_out, + int *nrecords_out, + XLogRecPtr *chain_prev_out); + +/* + * UndoBufferReset - Reset after the DML WAL record has been written. + * + * Updates chain_prev to the LSN of the WAL record that embedded the UNDO, + * then clears len/nrecords so the buffer can accept new records. + */ +extern void UndoBufferReset(XLogRecPtr embedded_lsn); + +#endif /* UNDOBUFFER_H */ diff --git a/src/include/access/undodefs.h b/src/include/access/undodefs.h new file mode 100644 index 0000000000000..b21915bff1004 --- /dev/null +++ b/src/include/access/undodefs.h @@ -0,0 +1,56 @@ +/*------------------------------------------------------------------------- + * + * undodefs.h + * + * Basic definitions for PostgreSQL undo layer. These are separated into + * their own header file to avoid including more things than necessary + * into widely-used headers like xact.h. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undodefs.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDODEFS_H +#define UNDODEFS_H + +/* The type used to identify an undo log and position within it. */ +typedef uint64 UndoRecPtr; + +/* The type used for undo record lengths. */ +typedef uint16 UndoRecordSize; + +/* Type for offsets within undo logs */ +typedef uint64 UndoLogOffset; + +/* Type for numbering undo logs. */ +typedef int UndoLogNumber; + +/* Special value for undo record pointer which indicates that it is invalid. */ +#define InvalidUndoRecPtr ((UndoRecPtr) 0) + +/* + * UndoRecPtrIsValid + * True iff undoRecPtr is valid. + */ +#define UndoRecPtrIsValid(undoRecPtr) \ + ((bool) ((UndoRecPtr) (undoRecPtr) != InvalidUndoRecPtr)) + +/* Persistence levels as small integers that can be used as array indexes. */ +typedef enum +{ + UNDOPERSISTENCE_PERMANENT = 0, + UNDOPERSISTENCE_UNLOGGED = 1, + UNDOPERSISTENCE_TEMP = 2 +} UndoPersistenceLevel; + +/* Number of supported persistence levels for undo. */ +#define NUndoPersistenceLevels 3 + +/* Opaque types. */ +struct UndoRecordSet; +typedef struct UndoRecordSet UndoRecordSet; + +#endif diff --git a/src/include/access/undolog.h b/src/include/access/undolog.h new file mode 100644 index 0000000000000..3e9a74d75ac55 --- /dev/null +++ b/src/include/access/undolog.h @@ -0,0 +1,207 @@ +/*------------------------------------------------------------------------- + * + * undolog.h + * PostgreSQL UNDO log manager -- WAL-integrated version + * + * With UNDO-in-WAL, UNDO records are stored in the standard WAL stream + * as XLOG_UNDO_BATCH records. The separate base/undo/ segment files + * and direct I/O path have been removed. This header retains: + * + * - UndoRecPtr encoding macros (still used for addressing) + * - Shared memory structures (UndoLogControl, UndoLogSharedData) + * - GUC parameter declarations + * - Functions for shmem init, discard, and checkpoint + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undolog.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDOLOG_H +#define UNDOLOG_H + +#include "access/transam.h" +#include "access/undodefs.h" +#include "access/xlogdefs.h" +#include "datatype/timestamp.h" +#include "port/atomics.h" +#include "port/pg_crc32c.h" +#include "storage/lwlock.h" +#include "storage/shmem.h" + +/* + * UndoRecPtr: 64-bit pointer to UNDO record + * + * Format: + * Bits 0-39: Offset within log (40 bits = 1TB per log) + * Bits 40-63: Log number (24 bits = 16M logs) + * + * The actual UndoRecPtr typedef and InvalidUndoRecPtr are in undodefs.h + * to avoid circular include dependencies. + */ + +/* Extract log number and offset from UndoRecPtr */ +#define UndoRecPtrGetLogNo(ptr) ((uint32) (((uint64) (ptr)) >> 40)) +#define UndoRecPtrGetOffset(ptr) (((uint64) (ptr)) & 0xFFFFFFFFFFULL) + +/* Construct UndoRecPtr from log number and offset */ +#define MakeUndoRecPtr(logno, offset) \ + ((((uint64) (logno)) << 40) | ((uint64) (offset))) + +/* + * Legacy define -- no longer used (UNDO records are in WAL, not segment + * files). Retained for any code that still references it at compile time. + */ +#define UNDO_LOG_SEGMENT_SIZE (1024 * 1024 * 1024) + +/* Maximum number of concurrent UNDO logs */ +#define MAX_UNDO_LOGS 100 + +/* + * Legacy thresholds -- retained for compatibility with code that + * references them (undostats.c, etc.). No longer used for segment + * rotation since UNDO data is now in WAL. + */ +#define UNDO_ROTATE_THRESHOLD_PCT 85 +#define UNDO_PRESSURE_THRESHOLD_PCT 95 +#define UNDO_CHECKPOINT_ROTATE_PCT 50 +#define UNDO_BACKPRESSURE_MIN_US 100 +#define UNDO_BACKPRESSURE_MAX_US 100000 + +/* + * UndoLogState: Lifecycle state of an UNDO log slot + * + * With UNDO-in-WAL, the segment lifecycle is simplified -- these states + * are retained for shared memory structure compatibility but the + * ACTIVE->SEALED->DISCARDABLE rotation no longer occurs. + */ +typedef enum UndoLogState +{ + UNDO_LOG_FREE = 0, /* Slot available */ + UNDO_LOG_ACTIVE, /* Accepting writes */ + UNDO_LOG_SEALED, /* No more writes */ + UNDO_LOG_DISCARDABLE /* All records discarded */ +} UndoLogState; + +/* + * UndoLogControl: Shared memory control structure for one UNDO log + */ +typedef struct UndoLogControl +{ + uint32 log_number; /* Log number */ + pg_atomic_uint64 insert_ptr; /* Next insertion point (atomic) */ + UndoRecPtr discard_ptr; /* Can discard older than this */ + TransactionId oldest_xid; /* Oldest transaction needing this log */ + LWLock lock; /* Protects metadata (NOT insert_ptr) */ + bool in_use; /* Is this log slot active? */ + UndoLogState state; /* Current lifecycle state */ + pg_atomic_uint64 seal_ptr; /* insert_ptr frozen at seal time */ + TimestampTz sealed_time; /* When this log was sealed */ +} UndoLogControl; + +/* + * UndoLogSharedData: Shared memory for all UNDO logs + * + * Note: backend_undo_lsns is a flexible array member; the struct must be + * allocated with room for MaxBackends entries. Use UndoLogShmemSize() to + * get the correct allocation size. + */ +typedef struct UndoLogSharedData +{ + UndoLogControl logs[MAX_UNDO_LOGS]; + uint32 next_log_number; + LWLock allocation_lock; + pg_atomic_uint32 active_log_idx; + pg_atomic_uint64 total_allocated; + pg_atomic_uint64 total_discarded; + + /* + * UNDO discard horizon: the oldest XLogRecPtr of an XLOG_UNDO_BATCH + * record that is still needed for rollback or index pruning. WAL + * segments containing data at or after this LSN must be retained. Updated + * by the UNDO discard worker as transactions complete and their UNDO + * records are no longer needed. + */ + pg_atomic_uint64 undo_discard_horizon; + + /* + * Per-backend first UNDO batch LSN. + * + * Each active backend stores the XLogRecPtr of its first XLOG_UNDO_BATCH + * record here when it writes UNDO data for a transaction. Cleared at + * commit or abort. The UNDO discard worker scans this array to find the + * global minimum, which becomes the new undo_discard_horizon, preventing + * WAL recycling past the oldest in-flight UNDO batch. + * + * Indexed by MyProcNumber (0-based, range [0, MaxBackends)). + * + * Must be last field -- UndoLogShmemSize() uses + * offsetof(UndoLogSharedData, backend_undo_lsns). + */ + pg_atomic_uint64 backend_undo_lsns[FLEXIBLE_ARRAY_MEMBER]; +} UndoLogSharedData; + +StaticAssertDecl(sizeof(XLogRecPtr) == sizeof(uint64), + "XLogRecPtr must be 64 bits for UNDO per-backend atomic LSN slots to be correct"); + +/* Global shared memory pointer (set during startup) */ +extern UndoLogSharedData *UndoLogShared; + +/* GUC parameters */ +/* Note: UNDO records are embedded in WAL (no separate segment files). + * UNDO_LOG_SEGMENT_SIZE and MAX_UNDO_LOGS are legacy defines retained + * for compile-time compatibility. + */ +extern int undo_retention_time; +extern int undo_worker_naptime; +extern int undo_buffer_size; +extern int undo_max_wal_retention_size; +extern int undo_batch_size_kb; +extern int undo_batch_record_limit; + +/* + * Shared memory initialization + */ +extern Size UndoLogShmemSize(void); +extern void UndoLogShmemInit(void); + +/* + * Discard, retention, and checkpoint + */ +extern void UndoLogDiscard(UndoRecPtr oldest_needed); +extern UndoRecPtr UndoLogGetOldestDiscardPtr(void); +extern void CheckPointUndoLog(void); + +/* WAL retention for UNDO: get/set the discard horizon */ +extern XLogRecPtr UndoGetDiscardHorizon(void); +extern void UndoSetDiscardHorizon(XLogRecPtr horizon); + +/* Per-backend UNDO batch LSN registration for WAL retention */ +extern void UndoRegisterBatchLSN(XLogRecPtr batch_lsn); +extern void UndoClearBatchLSN(void); +extern XLogRecPtr UndoGetOldestBatchLSN(void); + +/* + * Utility functions + */ +extern UndoRecPtr UndoLogGetInsertPtr(uint32 log_number); +extern UndoRecPtr UndoLogGetDiscardPtr(uint32 log_number); +extern char *UndoLogPath(uint32 log_number, char *path); + +/* + * Legacy no-op stubs -- retained for callers not yet fully updated. + * These are all no-ops in the UNDO-in-WAL architecture. + */ +extern void UndoLogSync(void); +extern void UndoLogCloseFiles(void); +extern void ExtendUndoLogFile(uint32 log_number, uint64 new_size); +extern void ExtendUndoLogSmgrFile(uint32 log_number, uint64 logical_end); +extern UndoRecPtr UndoFlushGetMaxWritePtr(void); +extern void UndoFlushResetMaxWritePtr(void); +extern void UndoLogSealAndRotate(uint8 trigger); +extern void UndoLogDeleteSegmentFile(uint32 log_number); +extern bool UndoLogTryPressureDiscard(void); + +#endif /* UNDOLOG_H */ diff --git a/src/include/access/undorecord.h b/src/include/access/undorecord.h new file mode 100644 index 0000000000000..fe08f3e0b9724 --- /dev/null +++ b/src/include/access/undorecord.h @@ -0,0 +1,221 @@ +/*------------------------------------------------------------------------- + * + * undorecord.h + * UNDO record format and insertion API + * + * This file defines the generic UNDO record format that can be used by + * any access method or subsystem. UNDO records are AM-agnostic: each + * record carries an RM ID (urec_rmid) that identifies the resource + * manager responsible for interpreting and applying the record. + * + * Design principles: + * - Physical: UNDO stores opaque payload data for direct restore + * - Generic: Usable by any AM or subsystem + * - Compact: Variable-length format to minimize space + * - Chained: Records form backward chains via urec_prev pointer + * - Batch-oriented: API encourages batching for performance + * - AM-agnostic: No AM-specific types in the generic header or API + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undorecord.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDORECORD_H +#define UNDORECORD_H + +#include "access/undodefs.h" +#include "access/undolog.h" +#include "access/xlogdefs.h" +#include "storage/block.h" +#include "storage/itemptr.h" + +/* + * UNDO record info flags + * + * These flags provide additional metadata about the UNDO record. + * The lower byte is reserved for generic flags; the upper byte is + * available for RM-specific use. + */ +#define UNDO_INFO_HAS_PAYLOAD 0x01 /* Record contains opaque payload */ +#define UNDO_INFO_XID_VALID 0x08 /* urec_xid is valid */ +#define UNDO_INFO_HAS_CLR 0x20 /* CLR has been written for this + * record (urec_clr_ptr is valid) */ + +/* + * UndoRecordHeader - Fixed header for all UNDO records + * + * Every UNDO record starts with this header, followed by an optional + * opaque payload whose interpretation is RM-specific. + * + * The urec_rmid field identifies which resource manager owns this record. + * The urec_info field carries RM-specific subtype/flags (e.g., the heap + * RM uses it to distinguish INSERT vs DELETE vs UPDATE). + * + * Size: 48 bytes (optimized for alignment) + */ +typedef struct UndoRecordHeader +{ + uint8 urec_rmid; /* UNDO RM ID (UNDO_RMID_HEAP, etc.) */ + uint8 urec_flags; /* Generic flags (UNDO_INFO_*) */ + uint16 urec_info; /* RM-specific subtype and flags */ + uint32 urec_len; /* Total length including header + payload */ + + TransactionId urec_xid; /* Transaction that created this */ + UndoRecPtr urec_prev; /* Previous UNDO for same xact (chain) */ + + Oid urec_reloid; /* Relation OID (InvalidOid if N/A) */ + + /* + * Payload length: size of the RM-specific opaque data that follows the + * header. Interpretation is entirely RM-specific. + */ + uint32 urec_payload_len; + + /* + * CLR (Compensation Log Record) pointer. When this UNDO record is + * applied during rollback, the XLogRecPtr of the CLR WAL record is stored + * here. This links the UNDO record to its compensation record in WAL, + * enabling crash recovery to determine which UNDO records have already + * been applied. Set to InvalidXLogRecPtr until the record is applied. + */ + XLogRecPtr urec_clr_ptr; /* CLR WAL pointer, InvalidXLogRecPtr if not + * yet applied */ + + /* Followed by variable-length RM-specific payload */ +} UndoRecordHeader; + +#define SizeOfUndoRecordHeader (offsetof(UndoRecordHeader, urec_clr_ptr) + sizeof(XLogRecPtr)) + +/* + * Access macros for payload data following the header + * + * The payload immediately follows the fixed header in the serialized + * record. Its interpretation is entirely RM-specific. + */ +#define UndoRecGetPayload(header) \ + ((char *)(header) + SizeOfUndoRecordHeader) + +/* + * UndoRecordSetChunkHeader - Header at the start of each chunk. + * + * When an UndoRecordSet spans multiple undo logs (rare, since each log + * is up to 1TB), the data is organized into chunks, each with a header + * that records the chunk size and a back-pointer to the previous chunk. + * This design follows the EDB undo-record-set branch architecture. + */ +typedef struct UndoRecordSetChunkHeader +{ + UndoLogOffset size; + UndoRecPtr previous_chunk; + uint8 type; +} UndoRecordSetChunkHeader; + +#define SizeOfUndoRecordSetChunkHeader \ + (offsetof(UndoRecordSetChunkHeader, type) + sizeof(uint8)) + +/* + * Possible undo record set types. + */ +typedef enum UndoRecordSetType +{ + URST_INVALID = 0, /* Placeholder when there's no record set. */ + URST_TRANSACTION = 'T', /* Normal xact undo; apply on abort. */ + URST_MULTI = 'M', /* Informational undo. */ + URST_EPHEMERAL = 'E' /* Ephemeral data for testing purposes. */ +} UndoRecordSetType; + +/* + * UndoRecordSet - Batch container for UNDO records + * + * This structure accumulates multiple UNDO records before writing them + * to the UNDO log in a single operation. This improves performance by + * reducing the number of I/O operations and lock acquisitions. + * + * The records are serialized into a contiguous buffer that grows + * dynamically. The design follows the EDB undo-record-set branch + * architecture with chunk-based organization and per-persistence-level + * separation. + */ +typedef struct UndoRecordSet +{ + TransactionId xid; /* Transaction ID for all records */ + UndoRecPtr prev_undo_ptr; /* Previous UNDO pointer in chain (legacy) */ + UndoPersistenceLevel persistence; /* Persistence level of this set */ + UndoRecordSetType type; /* Record set type */ + + int nrecords; /* Number of records in set */ + + /* + * Dynamic buffer for serialized records. Grows as needed; no fixed + * maximum. This replaces the old fixed-capacity max_records array. + */ + char *buffer; /* Serialized record buffer */ + Size buffer_size; /* Current buffer size */ + Size buffer_capacity; /* Allocated buffer capacity */ + + /* + * WAL-based UNDO chain tracking. When UNDO records are written to WAL + * via XLOG_UNDO_BATCH, last_batch_lsn tracks the LSN of the most recent + * batch for this record set. This is used as the chain_prev link when + * the next batch is written. + */ + XLogRecPtr last_batch_lsn; /* LSN of last XLOG_UNDO_BATCH record */ + + MemoryContext mctx; /* Memory context for allocations */ +} UndoRecordSet; + +/* + * Public API for UNDO record management + */ + +/* Create/destroy/reset UNDO record sets */ +extern UndoRecordSet *UndoRecordSetCreate(TransactionId xid, + UndoRecPtr prev_undo_ptr); +extern void UndoRecordSetFree(UndoRecordSet *uset); +extern void UndoRecordSetReset(UndoRecordSet *uset); +extern void UndoRecordSetResetCache(void); + +/* Add records to a set - generic payload API */ +extern void UndoRecordAddPayload(UndoRecordSet *uset, + uint8 rmid, + uint16 info, + Oid reloid, + const char *payload, + Size payload_len); + +/* Add records with scatter-gather payload (avoids intermediate buffer) */ +extern void UndoRecordAddPayloadParts(UndoRecordSet *uset, + uint8 rmid, + uint16 info, + Oid reloid, + const char *part1, + Size part1_len, + const char *part2, + Size part2_len); + +/* Insert the accumulated records into UNDO log */ +extern UndoRecPtr UndoRecordSetInsert(UndoRecordSet *uset); + +/* WAL batch management for deferred UNDO allocation logging */ +extern void UndoWalBatchFlush(void); +extern void UndoWalBatchReset(void); + +/* Utility functions for record manipulation */ +extern Size UndoRecordGetPayloadSize(Size payload_len); +extern void UndoRecordSerialize(char *dest, UndoRecordHeader *header, + const char *payload, Size payload_len); +extern bool UndoRecordDeserialize(const char *src, UndoRecordHeader *header, + char **payload); + +/* Statistics and debugging */ +extern Size UndoRecordSetGetSize(UndoRecordSet *uset); + +/* UNDO application during rollback */ +extern bool ApplyUndoChainFromWAL(XLogRecPtr last_batch_lsn); +extern bool ApplyUndoChainFromWALBounded(XLogRecPtr last_batch_lsn, + XLogRecPtr stop_at_lsn); + +#endif /* UNDORECORD_H */ diff --git a/src/include/access/undormgr.h b/src/include/access/undormgr.h new file mode 100644 index 0000000000000..8933798553668 --- /dev/null +++ b/src/include/access/undormgr.h @@ -0,0 +1,118 @@ +/*------------------------------------------------------------------------- + * + * undormgr.h + * UNDO resource manager dispatch definitions + * + * This module provides a dispatch mechanism for UNDO record application, + * analogous to the WAL resource manager (rmgr) system. Each access method + * or subsystem that writes UNDO records registers an UndoRmgrData entry + * with callbacks for applying UNDO records and describing them for debugging. + * + * The generic UNDO infrastructure (undoapply.c) dispatches to the appropriate + * RM callback based on the urec_rmid field in the UNDO record header. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undormgr.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDORMGR_H +#define UNDORMGR_H + +#include "postgres_ext.h" +#include "access/undodefs.h" +#include "access/xlogdefs.h" +#include "lib/stringinfo.h" + +/* + * UNDO Resource Manager IDs + * + * Each AM or subsystem that writes UNDO records is assigned a unique ID. + * This ID is stored in the urec_rmid field of every UNDO record header, + * enabling the generic UNDO infrastructure to dispatch to the correct + * apply callback during rollback. + * + * The core defines only UNDO_RMID_INVALID and the built-in index-AM IDs. + * Every other consumer defines its own UNDO_RMID_* constant in its own + * header (the same commit that registers it in access/undormgrlist.h), so + * this core header names no specific table AM or subsystem. The ID number + * space is a shared resource: IDs must be unique and stable across releases + * (they are stamped into WAL-durable UNDO records), so a new consumer takes + * the next free value in [1, MAX_UNDO_RMGRS) and never reuses one. + */ +#define UNDO_RMID_INVALID 0 +#define UNDO_RMID_NBTREE 1 +#define UNDO_RMID_HASH 3 +/* 2, 4, and 5 are reserved by out-of-core consumers (see their own headers) */ + +#define MAX_UNDO_RMGRS 256 + +/* + * UndoApplyResult - Return value from undo apply callbacks + */ +typedef enum UndoApplyResult +{ + UNDO_APPLY_SUCCESS = 0, /* Successfully applied */ + UNDO_APPLY_SKIPPED, /* Skipped (e.g., relation dropped) */ + UNDO_APPLY_ERROR /* Error during application */ +} UndoApplyResult; + +/* + * UndoRmgrData - Resource manager registration entry + * + * Each UNDO RM provides: + * rm_name: Human-readable name for debugging/logging + * rm_undo: Apply one UNDO record (rollback callback) + * rm_desc: Describe an UNDO record for debugging output + * + * The rm_undo callback receives: + * - rmid: The RM ID (for verification) + * - info: RM-specific subtype/flags from urec_info + * - xid: Transaction being rolled back + * - reloid: Target relation OID (may be InvalidOid for non-relation ops) + * - payload: RM-specific opaque payload data + * - payload_len: Length of payload + * - urec_ptr: Position of this record in UNDO log (for CLR generation) + * + * The callback is responsible for: + * - Opening the relation (if applicable) + * - Locking and modifying the target page + * - Generating a CLR WAL record + * - Releasing all locks and buffers + */ +typedef UndoApplyResult (*UndoRmgrApplyFunc) (uint8 rmid, + uint16 info, + TransactionId xid, + Oid reloid, + const char *payload, + Size payload_len, + UndoRecPtr urec_ptr); + +typedef void (*UndoRmgrDescFunc) (StringInfo buf, + uint8 rmid, + uint16 info, + const char *payload, + Size payload_len); + +typedef struct UndoRmgrData +{ + const char *rm_name; /* Human-readable name */ + UndoRmgrApplyFunc rm_undo; /* Apply callback */ + UndoRmgrDescFunc rm_desc; /* Describe callback */ +} UndoRmgrData; + +/* Global registration table */ +extern const UndoRmgrData *UndoRmgrs[MAX_UNDO_RMGRS]; + +/* Registration function (called during _PG_init or startup) */ +extern void RegisterUndoRmgr(uint8 rmid, const UndoRmgrData *rmgr); + +/* Lookup function */ +extern const UndoRmgrData *GetUndoRmgr(uint8 rmid); + +/* Initialization */ +extern void InitUndoRmgrs(void); + +#endif /* UNDORMGR_H */ diff --git a/src/include/access/undormgrlist.h b/src/include/access/undormgrlist.h new file mode 100644 index 0000000000000..cfb5a5d7da4da --- /dev/null +++ b/src/include/access/undormgrlist.h @@ -0,0 +1,43 @@ +/*------------------------------------------------------------------------- + * + * undormgrlist.h + * + * List of *UndoRmgrInit() initialization calls for built-in UNDO resource + * managers. Kept in its own source file, mirroring the pattern used by + * storage/subsystemlist.h for ShmemCallbacks registration, so that the + * generic UNDO core (undo.c) can register every built-in resource manager + * without knowing any of their names at compile time: undo.c only expands + * this list through a macro it controls, and the extern declarations are + * generated the same way (see access/undormgrs.h). + * + * Each access method or subsystem that writes UNDO records adds itself here + * when it is compiled in. This file is edited by whichever commit + * introduces a new UNDO-writing consumer; undo.c itself is never touched to + * add a new consumer. + * + * UNDO_RMGR_INIT is defined by the caller depending on how the list is used. + * + * No built-in resource managers exist yet at this point in the UNDO + * subsystem's history; this file starts empty and grows as each consumer + * (an index AM, a table AM, or a subsystem that writes UNDO records) is + * compiled in and adds itself here. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undormgrlist.h + * + *------------------------------------------------------------------------- + */ + +/* there is deliberately not an #ifndef UNDORMGRLIST_H here */ + +/* built-in index AM UNDO resource managers */ +UNDO_RMGR_INIT(NbtreeUndoRmgrInit) +UNDO_RMGR_INIT(HashUndoRmgrInit) + +/* FILEOPS: transactional filesystem-ops UNDO resource manager */ +UNDO_RMGR_INIT(FileopsUndoRmgrInit) + +/* FLUX: UNDO-based heap-replacement table AM UNDO resource manager */ +UNDO_RMGR_INIT(FluxUndoRmgrInit) diff --git a/src/include/access/undormgrs.h b/src/include/access/undormgrs.h new file mode 100644 index 0000000000000..2a399f78dfab2 --- /dev/null +++ b/src/include/access/undormgrs.h @@ -0,0 +1,28 @@ +/*------------------------------------------------------------------------- + * + * undormgrs.h + * Provide extern declarations for all the built-in UNDO resource + * manager *Init() functions. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undormgrs.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDORMGRS_H +#define UNDORMGRS_H + +/* + * Extern declarations of all the built-in *UndoRmgrInit() functions. + * + * The actual list is in undormgrlist.h, so that the same list can be used + * for other purposes (e.g. undo.c's RegisterUndoRmgrs()). + */ +#define UNDO_RMGR_INIT(initfunc) \ + extern void initfunc(void); +#include "access/undormgrlist.h" +#undef UNDO_RMGR_INIT + +#endif /* UNDORMGRS_H */ diff --git a/src/include/access/undostats.h b/src/include/access/undostats.h new file mode 100644 index 0000000000000..9a5a55e3956b3 --- /dev/null +++ b/src/include/access/undostats.h @@ -0,0 +1,63 @@ +/*------------------------------------------------------------------------- + * + * undostats.h + * UNDO log statistics collection and reporting + * + * Provides monitoring and observability for the UNDO subsystem, + * including per-log statistics and buffer cache statistics. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undostats.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDOSTATS_H +#define UNDOSTATS_H + +#include "access/undolog.h" +#include "fmgr.h" + +/* + * UndoLogStat - Per-log statistics snapshot + * + * Point-in-time snapshot of a single UNDO log's state. + */ +typedef struct UndoLogStat +{ + uint32 log_number; /* UNDO log number */ + UndoRecPtr insert_ptr; /* Current insert pointer */ + UndoRecPtr discard_ptr; /* Current discard pointer */ + TransactionId oldest_xid; /* Oldest transaction in this log */ + uint64 size_bytes; /* Active size (insert - discard) */ + UndoLogState state; /* Current lifecycle state */ +} UndoLogStat; + +/* + * UndoBufferStat - UNDO buffer cache statistics + * + * Aggregate statistics from the UNDO buffer cache. + */ +typedef struct UndoBufferStat +{ + int num_buffers; /* Number of buffer slots */ + uint64 cache_hits; /* Total cache hits */ + uint64 cache_misses; /* Total cache misses */ + uint64 cache_evictions; /* Total evictions */ + uint64 cache_writes; /* Total dirty buffer writes */ +} UndoBufferStat; + +/* Functions for collecting statistics */ +extern int GetUndoLogStats(UndoLogStat *stats, int max_stats); +extern void GetUndoBufferStats(UndoBufferStat *stats); + +/* + * pg_undo_force_discard is declared via PG_FUNCTION_INFO_V1 in + * undostats.c. Do not redeclare it here: on Windows that emits a + * __declspec(dllimport) prototype that conflicts with the implicit + * dllexport from the V1 info macro. Catalog references go through + * pg_proc by name. + */ + +#endif /* UNDOSTATS_H */ diff --git a/src/include/access/undoworker.h b/src/include/access/undoworker.h new file mode 100644 index 0000000000000..9b2cb6069b122 --- /dev/null +++ b/src/include/access/undoworker.h @@ -0,0 +1,66 @@ +/*------------------------------------------------------------------------- + * + * undoworker.h + * UNDO worker background process + * + * The UNDO worker is a background process that periodically scans active + * transactions and discards UNDO records that are no longer needed. + * This reclaims space in UNDO logs. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/undoworker.h + * + *------------------------------------------------------------------------- + */ +#ifndef UNDOWORKER_H +#define UNDOWORKER_H + +#include "access/transam.h" +#include "access/undolog.h" +#include "fmgr.h" +#include "storage/lwlock.h" +#include "storage/procnumber.h" +#include "storage/shmem.h" + +/* + * UndoWorkerShmemData - Shared memory for UNDO worker coordination + * + * This structure tracks the state of UNDO discard operations and + * coordinates between the worker and other backends. + */ +typedef struct UndoWorkerShmemData +{ + LWLock lock; /* Protects this structure */ + + pg_atomic_uint64 last_discard_time; /* Last discard operation time */ + TransactionId oldest_xid_checked; /* Last XID used for discard */ + UndoRecPtr last_discard_ptr; /* Last UNDO pointer discarded */ + + int naptime_ms; /* Current sleep time in ms */ + bool shutdown_requested; /* Worker should exit */ + + /* Rotation coordination fields */ + ProcNumber worker_proc; /* For latch-based wakeup */ + pg_atomic_uint32 sealed_log_count; /* Number of SEALED logs pending */ +} UndoWorkerShmemData; + +/* GUC parameters */ +extern int undo_worker_naptime; +extern int undo_retention_time; + +/* Shared memory functions */ +extern Size UndoWorkerShmemSize(void); +extern void UndoWorkerShmemInit(void); + +/* Worker lifecycle functions */ +pg_noreturn extern void UndoWorkerMain(Datum main_arg); +extern void UndoWorkerRegister(void); + +/* Utility functions */ +extern TransactionId UndoWorkerGetOldestXid(void); +extern void UndoWorkerRequestShutdown(void); +extern void WakeUndoDiscardWorker(void); + +#endif /* UNDOWORKER_H */ diff --git a/src/include/access/xact.h b/src/include/access/xact.h index a8cbdf247c866..b10932c6344e4 100644 --- a/src/include/access/xact.h +++ b/src/include/access/xact.h @@ -138,6 +138,39 @@ typedef enum typedef void (*XactCallback) (XactEvent event, void *arg); +/* + * TableAMPrepare_hook: called from PrepareTransaction(), between + * StartPrepare() and EndPrepare(), where RegisterTwoPhaseRecord() is valid. + * + * This window is narrower than any XactCallback event: XACT_EVENT_PRE_PREPARE + * fires BEFORE StartPrepare() (before "the remaining actions cannot call any + * user-defined code" section begins), so a table AM that needs to register + * two-phase-commit records for tuples/rows it is tracking (e.g. an in-place + * MVCC AM saving per-tuple UNDO state so COMMIT PREPARED / ROLLBACK PREPARED + * can locate and finalize them) cannot do so from an ordinary XactCallback + * and needs this dedicated, narrower hook instead. NULL is a valid no-op + * for a build with no such AM compiled in. + */ +extern void (*TableAMPrepare_hook) (void); + +/* + * PendingPhysOps{Do,PostPrepare,AtSubCommit,AtSubAbort}_hook: mirror the + * matching core smgr{DoPendingDeletes,PostPrepare,AtSubCommit,AtSubAbort} + * calls in xact.c. A subsystem that manages pending structural filesystem + * operations needs the SAME commit/abort-sequencing position + * as smgr's own pending-deletes housekeeping -- e.g. + * PendingPhysOpsDo_hook(true) is placed immediately after + * smgrDoPendingDeletes(true), which core's own comment there documents as + * "best done after releasing relcache and buffer pins... this ordering is + * definitely critical during abort" -- a physical-file-cleanup ordering + * requirement no ordinary XactCallback/SubXactCallback event exposes. NULL + * is a valid no-op for a build with no such subsystem compiled in. + */ +extern void (*PendingPhysOpsDo_hook) (bool isCommit); +extern void (*PendingPhysOpsPostPrepare_hook) (void); +extern void (*PendingPhysOpsAtSubCommit_hook) (void); +extern void (*PendingPhysOpsAtSubAbort_hook) (void); + typedef enum { SUBXACT_EVENT_START_SUB, @@ -368,8 +401,28 @@ typedef struct xl_xact_prepare uint16 gidlen; /* length of the GID - GID follows the header */ XLogRecPtr origin_lsn; /* lsn of this record at origin node */ TimestampTz origin_timestamp; /* time of prepare at origin node */ + + /* + * UNDO chain head LSN per persistence level (3 == NUndoPersistenceLevels + * from undodefs.h; hardcoded here to keep xact.h free of UNDO headers). + * If NUndoPersistenceLevels changes, this array must be updated and both + * XLOG_PAGE_MAGIC (xlog_internal.h) and TWOPHASE_MAGIC (twophase.c) must + * be bumped. See StaticAssertDecl in xactundo.c for compile-time guard. + */ + XLogRecPtr last_batch_lsn[3]; } xl_xact_prepare; +#define SizeOfXactPrepare sizeof(xl_xact_prepare) + +/* + * Verify xl_xact_prepare contains the UNDO last_batch_lsn field. This struct + * is written into WAL as part of XLOG_XACT_PREPARE records, and into 2PC + * state files via TwoPhaseFileHeader. Any layout change requires bumping both + * XLOG_PAGE_MAGIC (xlog_internal.h) and TWOPHASE_MAGIC (twophase.c). + */ +StaticAssertDecl(offsetof(xl_xact_prepare, last_batch_lsn) > 0, + "xl_xact_prepare must contain last_batch_lsn for UNDO WAL compat"); + /* * Commit/Abort records in the above form are a bit verbose to parse, so * there's a deconstructed versions generated by ParseCommit/AbortRecord() for @@ -439,6 +492,8 @@ typedef struct xl_xact_parsed_abort */ extern bool IsTransactionState(void); extern bool IsAbortedTransactionBlockState(void); +extern int EnterInlineUndoApplyState(void); +extern void LeaveInlineUndoApplyState(int saved); extern TransactionId GetTopTransactionId(void); extern TransactionId GetTopTransactionIdIfAny(void); extern TransactionId GetCurrentTransactionId(void); @@ -535,4 +590,8 @@ extern void EnterParallelMode(void); extern void ExitParallelMode(void); extern bool IsInParallelMode(void); +/* UNDO chain management */ +extern void SetCurrentTransactionUndoRecPtr(uint64 undo_ptr); +extern uint64 GetCurrentTransactionUndoRecPtr(void); + #endif /* XACT_H */ diff --git a/src/include/access/xactundo.h b/src/include/access/xactundo.h new file mode 100644 index 0000000000000..b6a359678ac5a --- /dev/null +++ b/src/include/access/xactundo.h @@ -0,0 +1,117 @@ +/*------------------------------------------------------------------------- + * + * xactundo.h + * Transaction-level undo management + * + * This module manages per-transaction undo record sets. It maintains + * up to NUndoPersistenceLevels (3) record sets per transaction -- one + * for each persistence level (permanent, unlogged, temporary). This + * design follows the EDB undo-record-set branch architecture where + * undo records for different persistence levels are kept separate. + * + * Code that wants to write transactional undo should interface with + * these functions rather than manipulating UndoRecordSet directly. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/access/xactundo.h + * + *------------------------------------------------------------------------- + */ +#ifndef XACTUNDO_H +#define XACTUNDO_H + +#include "access/relundo.h" +#include "access/undodefs.h" +#include "access/undorecord.h" +#include "access/xlogdefs.h" + +/* + * XactUndoContext - Context for a single undo insertion within a transaction. + * + * Created by PrepareXactUndoData(), consumed by InsertXactUndoData() + * and cleaned up by CleanupXactUndoInsertion(). The plevel tracks which + * persistence-level record set this insertion belongs to. + */ +typedef struct XactUndoContext +{ + UndoPersistenceLevel plevel; + UndoRecordSet *uset; /* borrowed reference, do not free */ +} XactUndoContext; + +/* Shared memory initialization */ +extern Size XactUndoShmemSize(void); +extern void XactUndoShmemInit(void); + +/* Per-backend initialization */ +extern void InitializeXactUndo(void); + +/* + * Undo insertion API for any AM or subsystem. + * + * PrepareXactUndoData: Find or create the appropriate per-persistence-level + * UndoRecordSet for the current transaction and prepare it for a new + * record. Returns the UndoRecPtr where the record will be written. + * + * Parameters are AM-agnostic: the caller provides an RM ID, RM-specific + * info flags, a relation OID, and an opaque payload. + * + * InsertXactUndoData: Actually write the record data into the undo log. + * + * CleanupXactUndoInsertion: Release any resources held by the context. + */ +extern UndoRecPtr PrepareXactUndoData(XactUndoContext *ctx, + char persistence, + uint8 rmid, + uint16 info, + Oid reloid, + const char *payload, + Size payload_len); +extern UndoRecPtr PrepareXactUndoDataParts(XactUndoContext *ctx, + char persistence, + uint8 rmid, + uint16 info, + Oid reloid, + const char *part1, + Size part1_len, + const char *part2, + Size part2_len); +extern void InsertXactUndoData(XactUndoContext *ctx); +extern void CleanupXactUndoInsertion(XactUndoContext *ctx); + +/* Transaction lifecycle hooks */ +extern void AtCommit_XactUndo(void); +extern void AtAbort_XactUndo(void); +extern void AtSubCommit_XactUndo(int level); +extern void AtSubAbort_XactUndo(int level); +extern void AtProcExit_XactUndo(void); + +/* Per-relation UNDO chain registration (used by AMs on the per-relation fork) */ +extern void RegisterPerRelUndo(Oid relid, RelUndoRecPtr start_urec_ptr); +extern RelUndoRecPtr GetPerRelUndoPtr(Oid relid); + +/* Callback for IteratePerRelUndo: one call per registered chain head. */ +typedef void (*PerRelUndoIterCB) (Oid relid, RelUndoRecPtr start_urec_ptr, + void *arg); +extern void IteratePerRelUndo(PerRelUndoIterCB callback, void *arg); +extern bool XactUndoHasUnrecoverableUndo(void); + +/* Undo chain traversal for rollback */ +extern UndoRecPtr GetCurrentXactUndoRecPtr(UndoPersistenceLevel plevel); +extern XLogRecPtr GetCurrentXactLastBatchLSN(UndoPersistenceLevel plevel); +extern void XActUndoUpdateLastBatchLSN(XLogRecPtr lsn, + UndoPersistenceLevel plevel); + +/* + * GUC: UNDO bytes threshold for instant abort via ATM. + * + * Transactions with estimated UNDO bytes >= this threshold use ATM instant + * abort (deferred rollback via Logical Revert worker). Transactions below + * the threshold use synchronous rollback inline during transaction abort. + * + * A value of 0 means always use ATM instant abort regardless of size. + */ +extern int undo_instant_abort_threshold; + +#endif /* XACTUNDO_H */ diff --git a/src/include/catalog/pg_am.dat b/src/include/catalog/pg_am.dat index 46d361047fe67..708786dce6119 100644 --- a/src/include/catalog/pg_am.dat +++ b/src/include/catalog/pg_am.dat @@ -33,5 +33,8 @@ { oid => '3580', oid_symbol => 'BRIN_AM_OID', descr => 'block range index (BRIN) access method', amname => 'brin', amhandler => 'brinhandler', amtype => 'i' }, +{ oid => '9316', oid_symbol => 'FLUX_TABLE_AM_OID', + descr => 'flux table access method', + amname => 'flux', amhandler => 'flux_tableam_handler', amtype => 't' }, ] diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index f8a021987b5e5..d82ab28e4125b 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -912,6 +912,10 @@ proname => 'heap_tableam_handler', provolatile => 'v', prorettype => 'table_am_handler', proargtypes => 'internal', prosrc => 'heap_tableam_handler' }, +{ oid => '9402', descr => 'flux table access method handler', + proname => 'flux_tableam_handler', provolatile => 'v', + prorettype => 'table_am_handler', proargtypes => 'internal', + prosrc => 'flux_tableam_handler' }, # Index access method handlers { oid => '330', descr => 'btree index access method handler', diff --git a/src/include/common/relpath.h b/src/include/common/relpath.h index 9772125be7398..674a0dc58e1ac 100644 --- a/src/include/common/relpath.h +++ b/src/include/common/relpath.h @@ -60,6 +60,7 @@ typedef enum ForkNumber FSM_FORKNUM, VISIBILITYMAP_FORKNUM, INIT_FORKNUM, + RELUNDO_FORKNUM, /* * NOTE: if you add a new fork, change MAX_FORKNUM and possibly @@ -68,9 +69,9 @@ typedef enum ForkNumber */ } ForkNumber; -#define MAX_FORKNUM INIT_FORKNUM +#define MAX_FORKNUM RELUNDO_FORKNUM -#define FORKNAMECHARS 4 /* max chars for a fork name */ +#define FORKNAMECHARS 7 /* max chars for a fork name ("relundo") */ extern PGDLLIMPORT const char *const forkNames[]; diff --git a/src/include/pgstat.h b/src/include/pgstat.h index 58a44857f1311..07d6bcebc7134 100644 --- a/src/include/pgstat.h +++ b/src/include/pgstat.h @@ -194,12 +194,18 @@ typedef struct PgStat_TableXactStatus { PgStat_Counter tuples_inserted; /* tuples inserted in (sub)xact */ PgStat_Counter tuples_updated; /* tuples updated in (sub)xact */ + PgStat_Counter tuples_updated_no_dead; /* subset of tuples_updated that + * created no dead tuple (in-place + * update AM; see + * am_inplace_update_no_dead_tuple in + * tableam.h) */ PgStat_Counter tuples_deleted; /* tuples deleted in (sub)xact */ bool truncdropped; /* relation truncated/dropped in this * (sub)xact */ /* tuples i/u/d prior to truncate/drop */ PgStat_Counter inserted_pre_truncdrop; PgStat_Counter updated_pre_truncdrop; + PgStat_Counter updated_no_dead_pre_truncdrop; PgStat_Counter deleted_pre_truncdrop; int nest_level; /* subtransaction nest level */ /* links to other structs for same relation: */ diff --git a/src/include/port/pg_xattr.h b/src/include/port/pg_xattr.h new file mode 100644 index 0000000000000..7159fc27a26a1 --- /dev/null +++ b/src/include/port/pg_xattr.h @@ -0,0 +1,40 @@ +/*------------------------------------------------------------------------- + * + * pg_xattr.h + * Cross-platform extended attribute abstraction + * + * Provides pg_setxattr() and pg_removexattr() that work across: + * - Linux: setxattr/removexattr + * - macOS: setxattr/removexattr (extra options param) + * - FreeBSD: extattr_set_file/extattr_delete_file + * - Windows: NTFS Alternate Data Streams + * - Fallback: returns ENOTSUP with WARNING + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * src/include/port/pg_xattr.h + * + *------------------------------------------------------------------------- + */ +#ifndef PG_XATTR_H +#define PG_XATTR_H + +/* + * ENODATA is Linux-specific. FreeBSD/macOS use ENOATTR for "attribute not + * found". Provide a portable PG_ENOATTR so callers don't need #ifdefs. + */ +#if defined(ENOATTR) +#define PG_ENOATTR ENOATTR +#elif defined(ENODATA) +#define PG_ENOATTR ENODATA +#else +#define PG_ENOATTR ENOENT /* last-resort fallback */ +#endif + +extern int pg_setxattr(const char *path, const char *name, + const void *value, size_t size); +extern ssize_t pg_getxattr(const char *path, const char *name, + void *value, size_t size); +extern int pg_removexattr(const char *path, const char *name); + +#endif /* PG_XATTR_H */ diff --git a/src/include/storage/buf_internals.h b/src/include/storage/buf_internals.h index e4ff5619b79ca..a1dfded233ba1 100644 --- a/src/include/storage/buf_internals.h +++ b/src/include/storage/buf_internals.h @@ -146,6 +146,20 @@ StaticAssertDecl(MAX_BACKENDS_BITS <= (BUF_LOCK_BITS - 2), StaticAssertDecl(BM_MAX_USAGE_COUNT < (UINT64CONST(1) << BUF_USAGECOUNT_BITS), "BM_MAX_USAGE_COUNT doesn't fit in BUF_USAGECOUNT_BITS bits"); +/* + * Reserved fork number for UNDO log buffers. + * + * This constant is reserved for future use when the smgr layer is extended + * to support undo-specific file management. Currently, undo buffers use + * MAIN_FORKNUM (following ZHeap's UndoLogForkNum convention) because the + * smgr layer sizes internal arrays to MAX_FORKNUM+1. Undo buffers are + * distinguished from regular relation data by using a pseudo-database OID + * (UNDO_DB_OID = 9) in the BufferTag's dbOid field. + * + * See src/include/access/undo_bufmgr.h for the undo buffer manager API. + */ +#define UNDO_FORKNUM 5 + /* * Buffer tag identifies which disk block the buffer contains. * diff --git a/src/include/storage/bufmgr.h b/src/include/storage/bufmgr.h index 6837b35fc6d0b..9421614e64267 100644 --- a/src/include/storage/bufmgr.h +++ b/src/include/storage/bufmgr.h @@ -259,6 +259,15 @@ extern bool BufferIsLockedByMe(Buffer buffer); extern bool BufferIsLockedByMeInMode(Buffer buffer, BufferLockMode mode); extern bool BufferIsDirty(Buffer buffer); extern void MarkBufferDirty(Buffer buffer); +/* + * MarkBufferDirtyShared -- mark buffer dirty while holding only BUFFER_LOCK_SHARE. + * + * Safe ONLY when the page modification is performed via an atomic CAS and the + * buffer's dirty bit is set atomically (no exclusive content lock needed). + * Currently used by an in-place-update table AM's CAS-update path where the + * tuple's writer field is modified atomically under shared buffer lock. + */ +extern void MarkBufferDirtyShared(Buffer buffer); extern void IncrBufferRefCount(Buffer buffer); extern void CheckBufferIsPinnedOnce(Buffer buffer); extern Buffer ReleaseAndReadBuffer(Buffer buffer, Relation relation, @@ -321,6 +330,7 @@ extern bool BufferBeginSetHintBits(Buffer buffer); extern void BufferFinishSetHintBits(Buffer buffer, bool mark_dirty, bool buffer_std); extern void UnlockBuffers(void); +extern void BufferLockReleaseAll(void); extern void UnlockBuffer(Buffer buffer); extern void LockBufferInternal(Buffer buffer, BufferLockMode mode); diff --git a/src/include/storage/copydir.h b/src/include/storage/copydir.h index 40849c2ef9947..31faa51cd6af7 100644 --- a/src/include/storage/copydir.h +++ b/src/include/storage/copydir.h @@ -22,7 +22,19 @@ typedef enum FileCopyMethod /* GUC parameters */ extern PGDLLIMPORT int file_copy_method; -extern void copydir(const char *fromdir, const char *todir, bool recurse); +/* + * copydir(): copy a directory tree. + * + * If register_for_abort_cleanup is true, the destination tree is registered + * with FILEOPS for delete-on-abort. Forward-path callers in transactional + * commands (CREATE DATABASE STRATEGY=FILE_COPY, ALTER DATABASE SET + * TABLESPACE) should pass true so that a transaction abort removes the + * partial copy. Recovery callers (XLOG_DBASE_CREATE_FILE_COPY redo) must + * pass false because there is no surrounding transaction context and the + * redo handler is itself the recovery action. + */ +extern void copydir(const char *fromdir, const char *todir, bool recurse, + bool register_for_abort_cleanup); extern void copy_file(const char *fromfile, const char *tofile); #endif /* COPYDIR_H */ diff --git a/src/include/storage/fileops.h b/src/include/storage/fileops.h new file mode 100644 index 0000000000000..b4f1b004b96fc --- /dev/null +++ b/src/include/storage/fileops.h @@ -0,0 +1,452 @@ +/*------------------------------------------------------------------------- + * + * fileops.h + * Transactional file operations API + * + * This module provides transactional filesystem operations that are + * WAL-logged and integrated with PostgreSQL's transaction management. + * File operations are deferred until transaction commit/abort, ensuring + * atomicity with the rest of the transaction. + * + * The RM_FILEOPS_ID resource manager handles WAL replay for these + * operations, ensuring correct behavior during crash recovery and + * standby replay. + * + * The operation set follows the Berkeley DB fileops.src model: each + * filesystem operation is a composable unit with its own WAL record + * type, redo handler, and descriptor. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/storage/fileops.h + * + *------------------------------------------------------------------------- + */ +#ifndef FILEOPS_H +#define FILEOPS_H + +#include "access/xlogreader.h" +#include "lib/stringinfo.h" + +/* + * WAL record types for FILEOPS operations. + * + * The high 4 bits of the info byte are used for record type, + * leaving the low bits for flags (following PostgreSQL convention). + * + * Following the Berkeley DB fileops.src model, each filesystem + * operation has its own WAL record type for independent redo. + */ +#define XLOG_FILEOPS_CREATE 0x00 +#define XLOG_FILEOPS_DELETE 0x10 +#define XLOG_FILEOPS_RENAME 0x20 +#define XLOG_FILEOPS_WRITE 0x30 +#define XLOG_FILEOPS_TRUNCATE 0x40 +#define XLOG_FILEOPS_CHMOD 0x50 +#define XLOG_FILEOPS_CHOWN 0x60 +#define XLOG_FILEOPS_MKDIR 0x70 +#define XLOG_FILEOPS_RMDIR 0x80 +#define XLOG_FILEOPS_SYMLINK 0x90 +#define XLOG_FILEOPS_LINK 0xA0 +#define XLOG_FILEOPS_SETXATTR 0xB0 +#define XLOG_FILEOPS_REMOVEXATTR 0xC0 + +/* + * PendingFileOp - Deferred file operation entry + * + * File operations are collected in a linked list during a transaction + * and executed at commit or abort time. This follows the same pattern + * used by PendingRelDelete in catalog/storage.c. + */ +typedef enum PendingFileOpType +{ + PENDING_FILEOP_CREATE, + PENDING_FILEOP_DELETE, + PENDING_FILEOP_RENAME, + PENDING_FILEOP_WRITE, + PENDING_FILEOP_TRUNCATE, + PENDING_FILEOP_CHMOD, + PENDING_FILEOP_CHOWN, + PENDING_FILEOP_MKDIR, + PENDING_FILEOP_RMDIR, + PENDING_FILEOP_RMTREE, /* recursive removal at execute time */ + PENDING_FILEOP_SYMLINK, + PENDING_FILEOP_LINK, + PENDING_FILEOP_SETXATTR, + PENDING_FILEOP_REMOVEXATTR +} PendingFileOpType; + +typedef struct PendingFileOp +{ + PendingFileOpType type; /* operation type */ + char *path; /* primary file path */ + char *newpath; /* new path (RENAME/SYMLINK/LINK), or xattr + * name */ + off_t length; /* truncation length, write offset, or mode */ + void *data; /* generic data (e.g., original xattr value) */ + size_t data_len; /* length of data */ + bool at_commit; /* execute at commit (true) or abort (false) */ + int nestLevel; /* transaction nesting level */ + struct PendingFileOp *next; /* linked list link */ +} PendingFileOp; + +/* + * Public API for transactional file operations + * + * These functions handle platform-specific differences automatically: + * - O_DIRECT: PG_O_DIRECT (Linux/FreeBSD native, macOS F_NOCACHE, + * Windows FILE_FLAG_NO_BUFFERING) + * - fsync: pg_fsync() (Linux fdatasync, macOS F_FULLFSYNC, + * BSD fsync, Windows FlushFileBuffers) + * - Directory sync: fsync_parent_path() (Unix only, no-op on Windows) + * - Durable ops: durable_rename()/durable_unlink() with proper + * fsync ordering for crash safety + * + * Operation-specific API functions are declared below their WAL + * record structures in subsequent sections. + */ + +/* Utility functions */ +extern void FileOpsCancelPendingDelete(const char *path, bool at_commit); +extern void FileOpsSync(const char *path); + +/* Transaction lifecycle hooks */ +extern void FileOpsDoPendingOps(bool isCommit); +extern void AtSubCommit_FileOps(void); +extern void AtSubAbort_FileOps(void); +extern void PostPrepare_FileOps(void); + +/* + * xl_fileops_create - WAL record for file creation + * + * Records that a file was created within a transaction. If the transaction + * aborts, the file will be deleted. The path is stored as variable-length + * data following the fixed header. + */ +typedef struct xl_fileops_create +{ + int flags; /* open flags used for creation */ + mode_t mode; /* file permission mode */ + bool register_delete; /* register for delete-on-abort */ + /* variable-length path follows */ +} xl_fileops_create; + +#define SizeOfFileOpsCreate (offsetof(xl_fileops_create, register_delete) + sizeof(bool)) + +/* File creation API */ +extern int FileOpsCreate(const char *path, int flags, mode_t mode, + bool register_delete); + +/* + * xl_fileops_delete - WAL record for file deletion + * + * Records that a file deletion was requested. The at_commit flag indicates + * whether the deletion should happen at commit (true) or was registered + * as a delete-on-abort from a prior create (false). + */ +typedef struct xl_fileops_delete +{ + bool at_commit; /* true = delete at commit, false = at abort */ + /* variable-length path follows */ +} xl_fileops_delete; + +#define SizeOfFileOpsDelete (offsetof(xl_fileops_delete, at_commit) + sizeof(bool)) + +/* File deletion API */ +extern void FileOpsDelete(const char *path, bool at_commit); + +/* + * xl_fileops_rename - WAL record for file rename + * + * Records that a file was renamed. Both old and new paths are stored + * as variable-length data: oldpath_len bytes of old path, then the + * new path follows. + */ +typedef struct xl_fileops_rename +{ + uint16 oldpath_len; /* length of old path (including NUL) */ + /* variable-length old path follows, then new path */ +} xl_fileops_rename; + +#define SizeOfFileOpsRename (offsetof(xl_fileops_rename, oldpath_len) + sizeof(uint16)) + +/* File rename API */ +extern int FileOpsRename(const char *oldpath, const char *newpath); + +/* + * xl_fileops_write - WAL record for file write at offset + * + * Records that data was written to a file at a specific offset. + * The path and data are stored as variable-length data following + * the fixed header. + */ +typedef struct xl_fileops_write +{ + off_t offset; /* write offset in file */ + uint32 len; /* data length */ + uint16 path_len; /* length of path (including NUL) */ + /* variable-length path follows, then data */ +} xl_fileops_write; + +#define SizeOfFileOpsWrite (offsetof(xl_fileops_write, path_len) + sizeof(uint16)) + +/* File write API */ +extern int FileOpsWrite(const char *path, off_t offset, + const void *data, uint32 len); + +/* + * xl_fileops_truncate - WAL record for file truncation + */ +typedef struct xl_fileops_truncate +{ + off_t length; /* new file length */ + /* variable-length path follows */ +} xl_fileops_truncate; + +#define SizeOfFileOpsTruncate (offsetof(xl_fileops_truncate, length) + sizeof(off_t)) + +/* File truncation API */ +extern void FileOpsTruncate(const char *path, off_t length); + +/* + * xl_fileops_chmod - WAL record for file permission change + */ +typedef struct xl_fileops_chmod +{ + mode_t mode; /* new permission mode */ + /* variable-length path follows */ +} xl_fileops_chmod; + +#define SizeOfFileOpsChmod (offsetof(xl_fileops_chmod, mode) + sizeof(mode_t)) + +/* + * xl_fileops_chown - WAL record for file ownership change + */ +typedef struct xl_fileops_chown +{ + uid_t uid; /* new owner user id */ + gid_t gid; /* new owner group id */ + /* variable-length path follows */ +} xl_fileops_chown; + +#define SizeOfFileOpsChown (offsetof(xl_fileops_chown, gid) + sizeof(gid_t)) + +/* File metadata API */ +extern int FileOpsChmod(const char *path, mode_t mode); +extern int FileOpsChown(const char *path, uid_t uid, gid_t gid); + +/* + * xl_fileops_mkdir - WAL record for directory creation + */ +typedef struct xl_fileops_mkdir +{ + mode_t mode; /* directory permission mode */ + /* variable-length path follows */ +} xl_fileops_mkdir; + +#define SizeOfFileOpsMkdir (offsetof(xl_fileops_mkdir, mode) + sizeof(mode_t)) + +/* + * xl_fileops_rmdir - WAL record for directory removal + */ +typedef struct xl_fileops_rmdir +{ + bool at_commit; /* true = rmdir at commit, false = at abort */ + /* variable-length path follows */ +} xl_fileops_rmdir; + +#define SizeOfFileOpsRmdir (offsetof(xl_fileops_rmdir, at_commit) + sizeof(bool)) + +/* Directory lifecycle API */ +extern int FileOpsMkdir(const char *path, mode_t mode); +extern void FileOpsRmdir(const char *path, bool at_commit); +extern void FileOpsRmdirRecursive(const char *path, bool at_commit); + +/* + * FileOpsRmtree - schedule deferred recursive directory removal. + * + * Use this when the destination tree is empty (or nonexistent) at + * registration time but is expected to contain files by the time of + * commit or abort -- e.g., when registering the destination of an + * in-progress copydir() for delete-on-abort. Unlike + * FileOpsRmdirRecursive, the tree walk is deferred to execute time, + * so whatever ended up on disk gets removed. + */ +extern void FileOpsRmtree(const char *path, bool at_commit); + +/* + * xl_fileops_symlink - WAL record for symbolic link creation + */ +typedef struct xl_fileops_symlink +{ + uint16 target_len; /* length of target (including NUL) */ + /* variable-length target follows, then linkpath */ +} xl_fileops_symlink; + +#define SizeOfFileOpsSymlink (offsetof(xl_fileops_symlink, target_len) + sizeof(uint16)) + +/* + * xl_fileops_link - WAL record for hard link creation + */ +typedef struct xl_fileops_link +{ + uint16 oldpath_len; /* length of old path (including NUL) */ + /* variable-length old path follows, then new path */ +} xl_fileops_link; + +#define SizeOfFileOpsLink (offsetof(xl_fileops_link, oldpath_len) + sizeof(uint16)) + +/* Link operations API */ +extern int FileOpsSymlink(const char *target, const char *linkpath); +extern int FileOpsLink(const char *oldpath, const char *newpath); + +/* + * xl_fileops_setxattr - WAL record for setting an extended attribute + */ +typedef struct xl_fileops_setxattr +{ + uint16 name_len; /* attribute name length (including NUL) */ + uint32 value_len; /* attribute value length */ + uint16 path_len; /* file path length (including NUL) */ + /* variable-length: path, name, value */ +} xl_fileops_setxattr; + +#define SizeOfFileOpsSetxattr (offsetof(xl_fileops_setxattr, path_len) + sizeof(uint16)) + +/* + * xl_fileops_removexattr - WAL record for removing an extended attribute + */ +typedef struct xl_fileops_removexattr +{ + uint16 name_len; /* attribute name length (including NUL) */ + uint16 path_len; /* file path length (including NUL) */ + /* variable-length: path, name */ +} xl_fileops_removexattr; + +#define SizeOfFileOpsRemovexattr (offsetof(xl_fileops_removexattr, path_len) + sizeof(uint16)) + +/* Extended attribute API */ +extern int FileOpsSetXattr(const char *path, const char *name, + const void *value, size_t len); +extern int FileOpsRemoveXattr(const char *path, const char *name); + +/* WAL redo and descriptor functions */ +extern void fileops_redo(XLogReaderState *record); +extern void fileops_desc(StringInfo buf, XLogReaderState *record); +extern const char *fileops_identify(uint8 info); + +/* FILEOPS UNDO RM (fileops_undo.c) */ +extern void FileopsUndoRmgrInit(void); + +/* + * UNDO resource-manager ID for FILEOPS (stored in urec_rmid). Defined here, + * with FILEOPS, rather than in the generic access/undormgr.h, so the UNDO + * core names no specific consumer. See access/undormgr.h for the shared, + * WAL-durable ID number space. + */ +#define UNDO_RMID_FILEOPS 2 + +/* FILEOPS UNDO subtypes (stored in urec_info) */ +#define FILEOPS_UNDO_CREATE 0x0001 +#define FILEOPS_UNDO_RENAME 0x0002 +#define FILEOPS_UNDO_TRUNCATE 0x0003 +#define FILEOPS_UNDO_CHMOD 0x0004 +#define FILEOPS_UNDO_CHOWN 0x0005 +#define FILEOPS_UNDO_MKDIR 0x0006 +#define FILEOPS_UNDO_SYMLINK 0x0007 +#define FILEOPS_UNDO_LINK 0x0008 +#define FILEOPS_UNDO_SETXATTR 0x0009 +#define FILEOPS_UNDO_REMOVEXATTR 0x000A + +/* + * FILEOPS UNDO payload structures + * + * These structs define the on-disk layout of UNDO record payloads for each + * FILEOPS operation. Variable-length paths and data follow the fixed header. + */ + +/* CREATE undo payload: just the path to unlink */ +typedef struct FileopsUndoCreate +{ + uint16 path_len; /* including NUL */ + /* followed by path */ +} FileopsUndoCreate; + +/* RENAME undo payload: oldpath and newpath for reverse rename */ +typedef struct FileopsUndoRename +{ + uint16 oldpath_len; + uint16 newpath_len; + /* followed by oldpath, then newpath */ +} FileopsUndoRename; + +/* TRUNCATE undo payload: path + original length */ +typedef struct FileopsUndoTruncate +{ + off_t orig_length; + uint16 path_len; + /* followed by path */ +} FileopsUndoTruncate; + +/* CHMOD undo payload: path + original mode */ +typedef struct FileopsUndoChmod +{ + mode_t orig_mode; + uint16 path_len; + /* followed by path */ +} FileopsUndoChmod; + +/* CHOWN undo payload: path + original uid/gid */ +typedef struct FileopsUndoChown +{ + uid_t orig_uid; + gid_t orig_gid; + uint16 path_len; + /* followed by path */ +} FileopsUndoChown; + +/* MKDIR undo payload: just the path to rmdir */ +typedef struct FileopsUndoMkdir +{ + uint16 path_len; + /* followed by path */ +} FileopsUndoMkdir; + +/* SYMLINK undo payload: just the linkpath to unlink */ +typedef struct FileopsUndoSymlink +{ + uint16 linkpath_len; + /* followed by linkpath */ +} FileopsUndoSymlink; + +/* LINK undo payload: just the newpath to unlink */ +typedef struct FileopsUndoLink +{ + uint16 newpath_len; + /* followed by newpath */ +} FileopsUndoLink; + +/* SETXATTR undo payload: path + name + original value (or empty if new) */ +typedef struct FileopsUndoSetxattr +{ + uint16 path_len; + uint16 name_len; + uint32 orig_value_len; /* 0 if xattr didn't exist before */ + bool had_value; /* true if xattr existed before setxattr */ + /* followed by path, name, original value (if had_value) */ +} FileopsUndoSetxattr; + +/* REMOVEXATTR undo payload: path + name + removed value */ +typedef struct FileopsUndoRemovexattr +{ + uint16 path_len; + uint16 name_len; + uint32 value_len; + /* followed by path, name, value */ +} FileopsUndoRemovexattr; + +/* Recursive directory removal */ +extern void FileOpsRmdirRecursive(const char *path, bool at_commit); + +#endif /* FILEOPS_H */ diff --git a/src/include/storage/lwlocklist.h b/src/include/storage/lwlocklist.h index d7eb648bd2758..c9bb50f3001ce 100644 --- a/src/include/storage/lwlocklist.h +++ b/src/include/storage/lwlocklist.h @@ -140,3 +140,7 @@ PG_LWLOCKTRANCHE(XACT_SLRU, XactSLRU) PG_LWLOCKTRANCHE(PARALLEL_VACUUM_DSA, ParallelVacuumDSA) PG_LWLOCKTRANCHE(AIO_URING_COMPLETION, AioUringCompletion) PG_LWLOCKTRANCHE(SHMEM_INDEX, ShmemIndex) +PG_LWLOCKTRANCHE(UNDO_LOG, UndoLog) +PG_LWLOCKTRANCHE(UNDO_WORKER, UndoWorker) +PG_LWLOCKTRANCHE(ATM, AbortedTxnMap) +PG_LWLOCKTRANCHE(SLOG, SecondaryLog) diff --git a/src/include/storage/seqlock.h b/src/include/storage/seqlock.h new file mode 100644 index 0000000000000..be951249a627a --- /dev/null +++ b/src/include/storage/seqlock.h @@ -0,0 +1,182 @@ +/*------------------------------------------------------------------------- + * + * seqlock.h + * Sequence lock: a low-overhead reader/writer primitive for + * read-mostly, rarely-written shared data. + * + * A seqlock protects data with a single sequence counter (even = stable, + * odd = write in progress). Readers take no lock and pay no atomic + * read-modify-write and no StoreLoad fence on the common path: they read + * the counter, read the data into local variables, re-read the counter, + * and retry if it changed. A writer bumps the counter to odd, mutates the + * data in place, and bumps it back to even. + * + * Trade-offs vs LWLock and vs the left-right lock: + * - Reads acquire no lock: two relaxed counter loads plus read barriers, + * no CAS, no spinlock, no StoreLoad fence. This is cheaper than a + * left-right read (which requires a per-read SeqCst fence) and far + * cheaper than an LWLock shared acquire (a CAS on a shared counter). + * - Readers may RETRY if a writer intervenes, so a reader is not + * wait-free and can be starved by a continuous stream of writers. + * Seqlocks are therefore suited to read-mostly data with short, + * infrequent writes. + * - The protected data has a SINGLE copy (unlike left-right's two), so a + * reader must copy the fields it needs into locals inside the read + * section and only use them after a clean re-check; it must not retain + * pointers into the protected data across the re-check, because the + * writer mutates that data in place. + * - Writer serialization is the CALLER's responsibility (e.g. an LWLock + * or spinlock already held for other reasons). The seqlock itself is + * only the reader/writer coordination counter; it provides no mutual + * exclusion between writers. + * + * Why a new primitive rather than LWLock or bare atomics: + * - The motivating consumer is a read-mostly, heavily-concurrent shared + * hash whose readers vastly outnumber writers and read several words + * (a struct) that must be observed as one consistent snapshot. An + * LWLock shared-acquire is a CAS (read-modify-write) on a shared + * counter on every read; under many readers that shared cache line + * ping-pongs between cores and becomes the bottleneck, even though no + * reader mutates anything. The seqlock read path issues only two + * relaxed loads of the counter plus read barriers -- no atomic RMW, no + * contended cache line -- so read throughput scales with cores. + * - Plain atomics alone cannot give a multi-word consistent snapshot: an + * atomic per field lets a reader observe a torn mix of one writer's + * old and new values across fields. The seqlock's begin/retry frames + * the whole struct read as a single versioned transaction, so a reader + * either sees one writer's complete update or retries. That is the + * property neither an LWLock (correct but not scalable for pure reads) + * nor per-field atomics (scalable but not snapshot-consistent) provide + * on their own. + * + * Memory ordering: the writer's odd store is followed by a write barrier + * before the data mutation, and a write barrier precedes the even store; + * the reader issues a read barrier after the initial (even) counter load + * and before the re-read. No StoreLoad (full/SeqCst) fence is required + * because a reader never announces itself to the writer. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/storage/seqlock.h + * + *------------------------------------------------------------------------- + */ +#ifndef SEQLOCK_H +#define SEQLOCK_H + +#ifdef FRONTEND +#error "seqlock.h may not be included from frontend code" +#endif + +#include "port/atomics.h" +#include "storage/s_lock.h" + +/* + * A sequence lock. Embed one alongside the data it protects. The counter + * is even when the data is stable and odd while a writer is mutating it. + */ +typedef struct SeqLock +{ + pg_atomic_uint32 seq; +} SeqLock; + +/* + * Initialize a seqlock to the stable (even) state. Call once before any + * concurrent access. + */ +static inline void +SeqLockInit(SeqLock *lock) +{ + pg_atomic_init_u32(&lock->seq, 0); +} + +/* ---------------------------------------------------------------- + * Writer API + * + * The caller MUST serialize writers by other means (an LWLock or spinlock + * held across the whole write). SeqLockWriteBegin/End only publish the + * write window to readers; they do not exclude concurrent writers. + * ---------------------------------------------------------------- + */ + +/* + * Begin a write. Bumps the counter to odd so concurrent readers retry, + * then a write barrier so the subsequent data mutation is not reordered + * before the odd store becomes visible. + */ +static inline void +SeqLockWriteBegin(SeqLock *lock) +{ + uint32 s = pg_atomic_read_u32(&lock->seq); + + Assert((s & 1) == 0); + pg_atomic_write_u32(&lock->seq, s + 1); + pg_write_barrier(); +} + +/* + * End a write. A write barrier ensures the data mutation is visible + * before the counter returns to even (+2 total from the matching Begin). + */ +static inline void +SeqLockWriteEnd(SeqLock *lock) +{ + uint32 s = pg_atomic_read_u32(&lock->seq); + + Assert((s & 1) == 1); + pg_write_barrier(); + pg_atomic_write_u32(&lock->seq, s + 1); +} + +/* ---------------------------------------------------------------- + * Reader API + * + * Usage: + * uint32 seq; + * do { + * seq = SeqLockReadBegin(lock); + * ... read protected fields into LOCAL variables only ... + * } while (!SeqLockReadRetry(lock, seq)); + * ... act on the locals here (a consistent snapshot) ... + * + * A reader must NOT act on values read inside the loop, nor retain + * pointers into the protected data, until SeqLockReadRetry() has confirmed + * a consistent read (returned true). + * ---------------------------------------------------------------- + */ + +/* + * Begin a read. Spins while a writer is active (odd counter) and returns + * the even counter value observed. A read barrier orders the subsequent + * data reads after the counter load. + */ +static inline uint32 +SeqLockReadBegin(SeqLock *lock) +{ + uint32 s; + + for (;;) + { + s = pg_atomic_read_u32(&lock->seq); + if ((s & 1) == 0) + break; + SPIN_DELAY(); + } + pg_read_barrier(); + return s; +} + +/* + * Finish a read. Returns true if the read was consistent (no writer + * intervened since SeqLockReadBegin returned 'startseq'); false if the + * caller must discard its locals and retry. + */ +static inline bool +SeqLockReadRetry(SeqLock *lock, uint32 startseq) +{ + pg_read_barrier(); + return pg_atomic_read_u32(&lock->seq) == startseq; +} + +#endif /* SEQLOCK_H */ diff --git a/src/include/storage/subsystemlist.h b/src/include/storage/subsystemlist.h index 9ad619080be22..9a9937823f99f 100644 --- a/src/include/storage/subsystemlist.h +++ b/src/include/storage/subsystemlist.h @@ -88,3 +88,19 @@ PG_SHMEM_SUBSYSTEM(DataChecksumsShmemCallbacks) /* AIO subsystem. This delegates to the method-specific callbacks */ PG_SHMEM_SUBSYSTEM(AioShmemCallbacks) + +/* UNDO subsystem */ +PG_SHMEM_SUBSYSTEM(UndoShmemCallbacks) + +/* + * sLog: the UNDO subsystem's shared-memory Aborted Transaction Map (and an + * optional per-tuple tracking hash a consumer AM may extend it with). + * Registered as its own subsystem (not nested inside UndoShmemCallbacks) so + * the generic UNDO subsystem has no compile-time or link-time dependency on + * any consumer. See slog.c's SLogShmemCallbacks for details. + */ +PG_SHMEM_SUBSYSTEM(SLogShmemCallbacks) + +/* FLUX table access method subsystems */ +PG_SHMEM_SUBSYSTEM(FluxMvccShmemCallbacks) +PG_SHMEM_SUBSYSTEM(FluxDirtyMapShmemCallbacks) diff --git a/src/port/Makefile b/src/port/Makefile index 7e9b58776529a..39e1388227adc 100644 --- a/src/port/Makefile +++ b/src/port/Makefile @@ -51,6 +51,7 @@ OBJS = \ pg_popcount_aarch64.o \ pg_popcount_x86.o \ pg_strong_random.o \ + pg_xattr.o \ pgcheckdir.o \ pgmkdirp.o \ pgsleep.o \ diff --git a/src/port/meson.build b/src/port/meson.build index 922b3f646768d..20e1ca0043110 100644 --- a/src/port/meson.build +++ b/src/port/meson.build @@ -14,6 +14,7 @@ pgport_sources = [ 'pg_popcount_aarch64.c', 'pg_popcount_x86.c', 'pg_strong_random.c', + 'pg_xattr.c', 'pgcheckdir.c', 'pgmkdirp.c', 'pgsleep.c', diff --git a/src/port/pg_xattr.c b/src/port/pg_xattr.c new file mode 100644 index 0000000000000..c7968c4af4ea4 --- /dev/null +++ b/src/port/pg_xattr.c @@ -0,0 +1,202 @@ +/*------------------------------------------------------------------------- + * + * pg_xattr.c + * Cross-platform extended attribute abstraction + * + * Platform detection uses compiler-defined macros rather than + * configure-time checks, avoiding meson.build/configure.ac changes. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/port/pg_xattr.c + * + *------------------------------------------------------------------------- + */ +#include "c.h" + +#include +#include "port/pg_xattr.h" + +/* + * Platform detection via compiler macros. + * Linux and macOS both provide but with different APIs. + * FreeBSD uses . Windows uses NTFS Alternate Data Streams. + */ +#if defined(__linux__) || defined(__APPLE__) +#include +#define PG_HAVE_XATTR 1 +#elif defined(__FreeBSD__) +#include +#include +#define PG_HAVE_EXTATTR 1 +#elif defined(WIN32) +#define PG_HAVE_ADS 1 +#endif + +/* + * pg_setxattr - Set an extended attribute on a file + * + * Returns 0 on success, -1 on failure (errno set). + */ +int +pg_setxattr(const char *path, const char *name, + const void *value, size_t size) +{ +#if defined(PG_HAVE_XATTR) +#if defined(__APPLE__) + return setxattr(path, name, value, size, 0, 0); +#else + return setxattr(path, name, value, size, 0); +#endif + +#elif defined(PG_HAVE_EXTATTR) + ssize_t ret; + + ret = extattr_set_file(path, EXTATTR_NAMESPACE_USER, + name, value, size); + return (ret >= 0) ? 0 : -1; + +#elif defined(PG_HAVE_ADS) + char ads_path[MAXPGPATH]; + HANDLE hFile; + DWORD written; + + snprintf(ads_path, sizeof(ads_path), "%s:%s", path, name); + + hFile = CreateFileA(ads_path, GENERIC_WRITE, 0, NULL, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile == INVALID_HANDLE_VALUE) + { + _dosmaperr(GetLastError()); + return -1; + } + + if (!WriteFile(hFile, value, (DWORD) size, &written, NULL) || + written != (DWORD) size) + { + _dosmaperr(GetLastError()); + CloseHandle(hFile); + return -1; + } + + CloseHandle(hFile); + return 0; + +#else + /* Unsupported platform: succeed in WAL but no-op locally */ + (void) path; + (void) name; + (void) value; + (void) size; + errno = ENOTSUP; + return -1; +#endif +} + +/* + * pg_getxattr - Get an extended attribute value from a file + * + * Returns the number of bytes placed in value on success, + * or -1 on failure (errno set). If value is NULL or size is 0, + * returns the size of the attribute value without reading it. + */ +ssize_t +pg_getxattr(const char *path, const char *name, + void *value, size_t size) +{ +#if defined(PG_HAVE_XATTR) +#if defined(__APPLE__) + return getxattr(path, name, value, size, 0, 0); +#else + return getxattr(path, name, value, size); +#endif + +#elif defined(PG_HAVE_EXTATTR) + return extattr_get_file(path, EXTATTR_NAMESPACE_USER, + name, value, size); + +#elif defined(PG_HAVE_ADS) + char ads_path[MAXPGPATH]; + HANDLE hFile; + DWORD bytesRead; + LARGE_INTEGER fileSize; + + snprintf(ads_path, sizeof(ads_path), "%s:%s", path, name); + + hFile = CreateFileA(ads_path, GENERIC_READ, FILE_SHARE_READ, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (hFile == INVALID_HANDLE_VALUE) + { + _dosmaperr(GetLastError()); + return -1; + } + + if (!GetFileSizeEx(hFile, &fileSize)) + { + _dosmaperr(GetLastError()); + CloseHandle(hFile); + return -1; + } + + if (value == NULL || size == 0) + { + CloseHandle(hFile); + return (ssize_t) fileSize.QuadPart; + } + + if (!ReadFile(hFile, value, (DWORD) size, &bytesRead, NULL)) + { + _dosmaperr(GetLastError()); + CloseHandle(hFile); + return -1; + } + + CloseHandle(hFile); + return (ssize_t) bytesRead; + +#else + (void) path; + (void) name; + (void) value; + (void) size; + errno = ENOTSUP; + return -1; +#endif +} + +/* + * pg_removexattr - Remove an extended attribute from a file + * + * Returns 0 on success, -1 on failure (errno set). + */ +int +pg_removexattr(const char *path, const char *name) +{ +#if defined(PG_HAVE_XATTR) +#if defined(__APPLE__) + return removexattr(path, name, 0); +#else + return removexattr(path, name); +#endif + +#elif defined(PG_HAVE_EXTATTR) + return extattr_delete_file(path, EXTATTR_NAMESPACE_USER, name); + +#elif defined(PG_HAVE_ADS) + char ads_path[MAXPGPATH]; + + snprintf(ads_path, sizeof(ads_path), "%s:%s", path, name); + if (DeleteFileA(ads_path)) + return 0; + + _dosmaperr(GetLastError()); + return -1; + +#else + (void) path; + (void) name; + errno = ENOTSUP; + return -1; +#endif +} diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 098bb8142ae76..797e8c7fb51a6 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -50,6 +50,7 @@ SUBDIRS = \ test_rls_hooks \ test_saslprep \ test_shmem \ + test_seqlock \ test_shm_mq \ test_slru \ test_tidstore \ diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index 4bca42bb3706a..0099856a96385 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -32,6 +32,7 @@ subdir('test_dsa') subdir('test_dsm_registry') subdir('test_escape') subdir('test_extensions') +subdir('test_fileops') subdir('test_ginpostinglist') subdir('test_int128') subdir('test_integerset') @@ -51,6 +52,7 @@ subdir('test_resowner') subdir('test_rls_hooks') subdir('test_saslprep') subdir('test_shmem') +subdir('test_seqlock') subdir('test_shm_mq') subdir('test_slru') subdir('test_tidstore') diff --git a/src/test/modules/test_fileops/expected/test_fileops.out b/src/test/modules/test_fileops/expected/test_fileops.out new file mode 100644 index 0000000000000..c3d4dc531fd87 --- /dev/null +++ b/src/test/modules/test_fileops/expected/test_fileops.out @@ -0,0 +1,404 @@ +-- +-- Tests for FILEOPS UNDO rollback of direct file operations. +-- +-- This test module provides SQL-callable wrappers around FileOps C functions +-- that have no DDL-level callers, enabling direct testing of UNDO rollback +-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr, +-- FileOpsRemoveXattr. +-- +-- REQUIRES: UNDO subsystem (always active) +-- +CREATE EXTENSION test_fileops; +-- ================================================================ +-- Setup: create test files in the data directory +-- ================================================================ +SELECT test_fileops_create_tempfile('test_fileops_a.dat') AS filepath_a \gset +SELECT test_fileops_create_tempfile('test_fileops_b.dat') AS filepath_b \gset +-- Record initial state (umask may affect mode) +SELECT test_fileops_file_size(:'filepath_a') AS initial_size; + initial_size +-------------- + 1024 +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS initial_mode; + initial_mode +-------------- + 384 +(1 row) + +-- ================================================================ +-- Test 1: FileOpsTruncate UNDO - rollback restores original size +-- ================================================================ +BEGIN; +SELECT test_fileops_truncate(:'filepath_a', 256); + test_fileops_truncate +----------------------- + +(1 row) + +SELECT test_fileops_file_size(:'filepath_a') AS size_during_txn; + size_during_txn +----------------- + 256 +(1 row) + +ROLLBACK; +-- After rollback, UNDO should restore original size (1024) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback; + size_after_rollback +--------------------- + 1024 +(1 row) + +-- ================================================================ +-- Test 2: FileOpsTruncate commit - change persists +-- ================================================================ +BEGIN; +SELECT test_fileops_truncate(:'filepath_b', 512); + test_fileops_truncate +----------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_file_size(:'filepath_b') AS size_after_commit; + size_after_commit +------------------- + 512 +(1 row) + +-- ================================================================ +-- Test 3: FileOpsChmod UNDO - rollback restores original permissions +-- ================================================================ +-- Change to a known starting mode first (committed) +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 = 420 decimal + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_baseline; + mode_baseline +--------------- + 420 +(1 row) + +BEGIN; +-- Change to 0600 = 384 decimal +SELECT test_fileops_chmod(:'filepath_a', 384); + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_during_txn; + mode_during_txn +----------------- + 384 +(1 row) + +ROLLBACK; +-- After rollback, UNDO should restore to 0644 = 420 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback; + mode_after_rollback +--------------------- + 420 +(1 row) + +-- ================================================================ +-- Test 4: FileOpsChmod commit - change persists +-- ================================================================ +BEGIN; +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 = 448 decimal + test_fileops_chmod +-------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + mode_after_commit +------------------- + 448 +(1 row) + +-- Restore for subsequent tests +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + test_fileops_chmod +-------------------- + +(1 row) + +-- ================================================================ +-- Test 5: FileOpsLink UNDO - rollback removes the hard link +-- ================================================================ +SELECT test_fileops_data_dir() || '/test_fileops_link.dat' AS linkpath \gset +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); + test_fileops_link +------------------- + +(1 row) + +SELECT test_fileops_file_exists(:'linkpath') AS link_during_txn; + link_during_txn +----------------- + t +(1 row) + +ROLLBACK; +-- After rollback, UNDO should remove the hard link +SELECT test_fileops_file_exists(:'linkpath') AS link_after_rollback; + link_after_rollback +--------------------- + f +(1 row) + +-- ================================================================ +-- Test 6: FileOpsLink commit - link persists +-- ================================================================ +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); + test_fileops_link +------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_file_exists(:'linkpath') AS link_after_commit; + link_after_commit +------------------- + t +(1 row) + +-- ================================================================ +-- Test 7: FileOpsTruncate in subtransaction - ROLLBACK TO undoes it +-- ================================================================ +-- Ensure file is 1024 bytes +SELECT test_fileops_file_size(:'filepath_a') AS size_before_test7; + size_before_test7 +------------------- + 1024 +(1 row) + +BEGIN; +-- Outer truncate (should persist after commit) +SELECT test_fileops_truncate(:'filepath_a', 800); + test_fileops_truncate +----------------------- + +(1 row) + +SAVEPOINT sp1; +-- Inner truncate (should be rolled back) +SELECT test_fileops_truncate(:'filepath_a', 100); + test_fileops_truncate +----------------------- + +(1 row) + +SELECT test_fileops_file_size(:'filepath_a') AS size_in_savepoint; + size_in_savepoint +------------------- + 100 +(1 row) + +ROLLBACK TO sp1; +-- After ROLLBACK TO, should be back to 800 (the outer truncate value) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback_to; + size_after_rollback_to +------------------------ + 800 +(1 row) + +COMMIT; +-- After commit, the outer truncate to 800 should persist +SELECT test_fileops_file_size(:'filepath_a') AS size_after_commit; + size_after_commit +------------------- + 800 +(1 row) + +-- ================================================================ +-- Test 8: FileOpsChmod in subtransaction - ROLLBACK TO restores mode +-- ================================================================ +-- Set a known mode baseline +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + test_fileops_chmod +-------------------- + +(1 row) + +BEGIN; +-- Outer chmod (persists) +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 + test_fileops_chmod +-------------------- + +(1 row) + +SAVEPOINT sp1; +-- Inner chmod (rolled back) +SELECT test_fileops_chmod(:'filepath_a', 256); -- 0400 + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_in_savepoint; + mode_in_savepoint +------------------- + 256 +(1 row) + +ROLLBACK TO sp1; +-- After ROLLBACK TO, should be back to 0700 = 448 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback_to; + mode_after_rollback_to +------------------------ + 448 +(1 row) + +COMMIT; +-- After commit, outer chmod (0700 = 448) persists +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + mode_after_commit +------------------- + 448 +(1 row) + +-- ================================================================ +-- Test 9: FileOpsLink in subtransaction - ROLLBACK TO removes link +-- ================================================================ +SELECT test_fileops_data_dir() || '/test_fileops_link2.dat' AS linkpath2 \gset +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_link(:'filepath_a', :'linkpath2'); + test_fileops_link +------------------- + +(1 row) + +SELECT test_fileops_file_exists(:'linkpath2') AS link2_in_savepoint; + link2_in_savepoint +-------------------- + t +(1 row) + +ROLLBACK TO sp1; +-- Link should be removed by UNDO +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_rollback_to; + link2_after_rollback_to +------------------------- + f +(1 row) + +COMMIT; +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_commit; + link2_after_commit +-------------------- + f +(1 row) + +-- ================================================================ +-- Test 10: FileOpsSetXattr / FileOpsRemoveXattr UNDO +-- (skipped on platforms without xattr support) +-- ================================================================ +-- Try to set an xattr; returns false if unsupported (ENOTSUP/EPERM/EACCES) +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'initial_value') + AS xattr_supported \gset +\if :xattr_supported +-- Test 10a: SetXattr UNDO - rollback removes newly-set xattr +BEGIN; +SELECT test_fileops_setxattr(:'filepath_a', 'user.rollback_test', 'will_vanish'); + test_fileops_setxattr +----------------------- + t +(1 row) + +SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_during_txn; + xattr_during_txn +------------------ + will_vanish +(1 row) + +ROLLBACK; +SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_after_rollback; + xattr_after_rollback +---------------------- + +(1 row) + +-- Test 10b: SetXattr overwrite UNDO - rollback restores old value +BEGIN; +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'overwritten'); + test_fileops_setxattr +----------------------- + t +(1 row) + +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_overwritten; + xattr_overwritten +------------------- + overwritten +(1 row) + +ROLLBACK; +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_restored; + xattr_restored +---------------- + initial_value +(1 row) + +-- Test 10c: RemoveXattr UNDO - rollback restores removed xattr +BEGIN; +SELECT test_fileops_removexattr(:'filepath_a', 'user.test_key'); + test_fileops_removexattr +-------------------------- + t +(1 row) + +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove; + xattr_after_remove +-------------------- + +(1 row) + +ROLLBACK; +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove_rollback; + xattr_after_remove_rollback +----------------------------- + initial_value +(1 row) + +-- Test 10d: Xattr in subtransaction +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_setxattr(:'filepath_a', 'user.sub_key', 'sub_value'); + test_fileops_setxattr +----------------------- + t +(1 row) + +SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_in_savepoint; + xattr_in_savepoint +-------------------- + sub_value +(1 row) + +ROLLBACK TO sp1; +SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_after_sp_rollback; + xattr_after_sp_rollback +------------------------- + +(1 row) + +COMMIT; +\else +SELECT 'xattr tests skipped (not supported on this platform/filesystem)' AS notice; +\endif +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP EXTENSION test_fileops; diff --git a/src/test/modules/test_fileops/expected/test_fileops_1.out b/src/test/modules/test_fileops/expected/test_fileops_1.out new file mode 100644 index 0000000000000..c0a1721cc1f80 --- /dev/null +++ b/src/test/modules/test_fileops/expected/test_fileops_1.out @@ -0,0 +1,323 @@ +-- +-- Tests for FILEOPS UNDO rollback of direct file operations. +-- +-- This test module provides SQL-callable wrappers around FileOps C functions +-- that have no DDL-level callers, enabling direct testing of UNDO rollback +-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr, +-- FileOpsRemoveXattr. +-- +-- REQUIRES: UNDO subsystem (always active) +-- +CREATE EXTENSION test_fileops; +-- ================================================================ +-- Setup: create test files in the data directory +-- ================================================================ +SELECT test_fileops_create_tempfile('test_fileops_a.dat') AS filepath_a \gset +SELECT test_fileops_create_tempfile('test_fileops_b.dat') AS filepath_b \gset +-- Record initial state (umask may affect mode) +SELECT test_fileops_file_size(:'filepath_a') AS initial_size; + initial_size +-------------- + 1024 +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS initial_mode; + initial_mode +-------------- + 384 +(1 row) + +-- ================================================================ +-- Test 1: FileOpsTruncate UNDO - rollback restores original size +-- ================================================================ +BEGIN; +SELECT test_fileops_truncate(:'filepath_a', 256); + test_fileops_truncate +----------------------- + +(1 row) + +SELECT test_fileops_file_size(:'filepath_a') AS size_during_txn; + size_during_txn +----------------- + 256 +(1 row) + +ROLLBACK; +-- After rollback, UNDO should restore original size (1024) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback; + size_after_rollback +--------------------- + 1024 +(1 row) + +-- ================================================================ +-- Test 2: FileOpsTruncate commit - change persists +-- ================================================================ +BEGIN; +SELECT test_fileops_truncate(:'filepath_b', 512); + test_fileops_truncate +----------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_file_size(:'filepath_b') AS size_after_commit; + size_after_commit +------------------- + 512 +(1 row) + +-- ================================================================ +-- Test 3: FileOpsChmod UNDO - rollback restores original permissions +-- ================================================================ +-- Change to a known starting mode first (committed) +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 = 420 decimal + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_baseline; + mode_baseline +--------------- + 420 +(1 row) + +BEGIN; +-- Change to 0600 = 384 decimal +SELECT test_fileops_chmod(:'filepath_a', 384); + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_during_txn; + mode_during_txn +----------------- + 384 +(1 row) + +ROLLBACK; +-- After rollback, UNDO should restore to 0644 = 420 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback; + mode_after_rollback +--------------------- + 420 +(1 row) + +-- ================================================================ +-- Test 4: FileOpsChmod commit - change persists +-- ================================================================ +BEGIN; +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 = 448 decimal + test_fileops_chmod +-------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + mode_after_commit +------------------- + 448 +(1 row) + +-- Restore for subsequent tests +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + test_fileops_chmod +-------------------- + +(1 row) + +-- ================================================================ +-- Test 5: FileOpsLink UNDO - rollback removes the hard link +-- ================================================================ +SELECT test_fileops_data_dir() || '/test_fileops_link.dat' AS linkpath \gset +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); + test_fileops_link +------------------- + +(1 row) + +SELECT test_fileops_file_exists(:'linkpath') AS link_during_txn; + link_during_txn +----------------- + t +(1 row) + +ROLLBACK; +-- After rollback, UNDO should remove the hard link +SELECT test_fileops_file_exists(:'linkpath') AS link_after_rollback; + link_after_rollback +--------------------- + f +(1 row) + +-- ================================================================ +-- Test 6: FileOpsLink commit - link persists +-- ================================================================ +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); + test_fileops_link +------------------- + +(1 row) + +COMMIT; +SELECT test_fileops_file_exists(:'linkpath') AS link_after_commit; + link_after_commit +------------------- + t +(1 row) + +-- ================================================================ +-- Test 7: FileOpsTruncate in subtransaction - ROLLBACK TO undoes it +-- ================================================================ +-- Ensure file is 1024 bytes +SELECT test_fileops_file_size(:'filepath_a') AS size_before_test7; + size_before_test7 +------------------- + 1024 +(1 row) + +BEGIN; +-- Outer truncate (should persist after commit) +SELECT test_fileops_truncate(:'filepath_a', 800); + test_fileops_truncate +----------------------- + +(1 row) + +SAVEPOINT sp1; +-- Inner truncate (should be rolled back) +SELECT test_fileops_truncate(:'filepath_a', 100); + test_fileops_truncate +----------------------- + +(1 row) + +SELECT test_fileops_file_size(:'filepath_a') AS size_in_savepoint; + size_in_savepoint +------------------- + 100 +(1 row) + +ROLLBACK TO sp1; +-- After ROLLBACK TO, should be back to 800 (the outer truncate value) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback_to; + size_after_rollback_to +------------------------ + 800 +(1 row) + +COMMIT; +-- After commit, the outer truncate to 800 should persist +SELECT test_fileops_file_size(:'filepath_a') AS size_after_commit; + size_after_commit +------------------- + 800 +(1 row) + +-- ================================================================ +-- Test 8: FileOpsChmod in subtransaction - ROLLBACK TO restores mode +-- ================================================================ +-- Set a known mode baseline +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + test_fileops_chmod +-------------------- + +(1 row) + +BEGIN; +-- Outer chmod (persists) +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 + test_fileops_chmod +-------------------- + +(1 row) + +SAVEPOINT sp1; +-- Inner chmod (rolled back) +SELECT test_fileops_chmod(:'filepath_a', 256); -- 0400 + test_fileops_chmod +-------------------- + +(1 row) + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_in_savepoint; + mode_in_savepoint +------------------- + 256 +(1 row) + +ROLLBACK TO sp1; +-- After ROLLBACK TO, should be back to 0700 = 448 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback_to; + mode_after_rollback_to +------------------------ + 448 +(1 row) + +COMMIT; +-- After commit, outer chmod (0700 = 448) persists +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + mode_after_commit +------------------- + 448 +(1 row) + +-- ================================================================ +-- Test 9: FileOpsLink in subtransaction - ROLLBACK TO removes link +-- ================================================================ +SELECT test_fileops_data_dir() || '/test_fileops_link2.dat' AS linkpath2 \gset +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_link(:'filepath_a', :'linkpath2'); + test_fileops_link +------------------- + +(1 row) + +SELECT test_fileops_file_exists(:'linkpath2') AS link2_in_savepoint; + link2_in_savepoint +-------------------- + t +(1 row) + +ROLLBACK TO sp1; +-- Link should be removed by UNDO +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_rollback_to; + link2_after_rollback_to +------------------------- + f +(1 row) + +COMMIT; +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_commit; + link2_after_commit +-------------------- + f +(1 row) + +-- ================================================================ +-- Test 10: FileOpsSetXattr / FileOpsRemoveXattr UNDO +-- (skipped on platforms without xattr support) +-- ================================================================ +-- Try to set an xattr; returns false if unsupported (ENOTSUP/EPERM/EACCES) +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'initial_value') + AS xattr_supported \gset +\if :xattr_supported +\else +SELECT 'xattr tests skipped (not supported on this platform/filesystem)' AS notice; + notice +----------------------------------------------------------------- + xattr tests skipped (not supported on this platform/filesystem) +(1 row) + +\endif +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP EXTENSION test_fileops; diff --git a/src/test/modules/test_fileops/meson.build b/src/test/modules/test_fileops/meson.build new file mode 100644 index 0000000000000..9773eb9880c36 --- /dev/null +++ b/src/test/modules/test_fileops/meson.build @@ -0,0 +1,33 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +test_fileops_sources = files( + 'test_fileops.c', +) + +if host_system == 'windows' + test_fileops_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_fileops', + '--FILEDESC', 'test_fileops - regression testing of FILEOPS subsystem',]) +endif + +test_fileops = shared_module('test_fileops', + test_fileops_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_fileops + +test_install_data += files( + 'test_fileops.control', + 'test_fileops--1.0.sql', +) + +tests += { + 'name': 'test_fileops', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'test_fileops', + ], + }, +} diff --git a/src/test/modules/test_fileops/sql/test_fileops.sql b/src/test/modules/test_fileops/sql/test_fileops.sql new file mode 100644 index 0000000000000..085b90257f610 --- /dev/null +++ b/src/test/modules/test_fileops/sql/test_fileops.sql @@ -0,0 +1,220 @@ +-- +-- Tests for FILEOPS UNDO rollback of direct file operations. +-- +-- This test module provides SQL-callable wrappers around FileOps C functions +-- that have no DDL-level callers, enabling direct testing of UNDO rollback +-- for: FileOpsTruncate, FileOpsChmod, FileOpsLink, FileOpsSetXattr, +-- FileOpsRemoveXattr. +-- +-- REQUIRES: UNDO subsystem (always active) +-- + +CREATE EXTENSION test_fileops; + +-- ================================================================ +-- Setup: create test files in the data directory +-- ================================================================ + +SELECT test_fileops_create_tempfile('test_fileops_a.dat') AS filepath_a \gset +SELECT test_fileops_create_tempfile('test_fileops_b.dat') AS filepath_b \gset + +-- Record initial state (umask may affect mode) +SELECT test_fileops_file_size(:'filepath_a') AS initial_size; +SELECT test_fileops_get_mode(:'filepath_a') AS initial_mode; + +-- ================================================================ +-- Test 1: FileOpsTruncate UNDO - rollback restores original size +-- ================================================================ + +BEGIN; +SELECT test_fileops_truncate(:'filepath_a', 256); +SELECT test_fileops_file_size(:'filepath_a') AS size_during_txn; +ROLLBACK; + +-- After rollback, UNDO should restore original size (1024) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback; + +-- ================================================================ +-- Test 2: FileOpsTruncate commit - change persists +-- ================================================================ + +BEGIN; +SELECT test_fileops_truncate(:'filepath_b', 512); +COMMIT; + +SELECT test_fileops_file_size(:'filepath_b') AS size_after_commit; + +-- ================================================================ +-- Test 3: FileOpsChmod UNDO - rollback restores original permissions +-- ================================================================ + +-- Change to a known starting mode first (committed) +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 = 420 decimal +SELECT test_fileops_get_mode(:'filepath_a') AS mode_baseline; + +BEGIN; +-- Change to 0600 = 384 decimal +SELECT test_fileops_chmod(:'filepath_a', 384); +SELECT test_fileops_get_mode(:'filepath_a') AS mode_during_txn; +ROLLBACK; + +-- After rollback, UNDO should restore to 0644 = 420 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback; + +-- ================================================================ +-- Test 4: FileOpsChmod commit - change persists +-- ================================================================ + +BEGIN; +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 = 448 decimal +COMMIT; + +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + +-- Restore for subsequent tests +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + +-- ================================================================ +-- Test 5: FileOpsLink UNDO - rollback removes the hard link +-- ================================================================ + +SELECT test_fileops_data_dir() || '/test_fileops_link.dat' AS linkpath \gset + +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); +SELECT test_fileops_file_exists(:'linkpath') AS link_during_txn; +ROLLBACK; + +-- After rollback, UNDO should remove the hard link +SELECT test_fileops_file_exists(:'linkpath') AS link_after_rollback; + +-- ================================================================ +-- Test 6: FileOpsLink commit - link persists +-- ================================================================ + +BEGIN; +SELECT test_fileops_link(:'filepath_a', :'linkpath'); +COMMIT; + +SELECT test_fileops_file_exists(:'linkpath') AS link_after_commit; + +-- ================================================================ +-- Test 7: FileOpsTruncate in subtransaction - ROLLBACK TO undoes it +-- ================================================================ + +-- Ensure file is 1024 bytes +SELECT test_fileops_file_size(:'filepath_a') AS size_before_test7; + +BEGIN; +-- Outer truncate (should persist after commit) +SELECT test_fileops_truncate(:'filepath_a', 800); + +SAVEPOINT sp1; +-- Inner truncate (should be rolled back) +SELECT test_fileops_truncate(:'filepath_a', 100); +SELECT test_fileops_file_size(:'filepath_a') AS size_in_savepoint; +ROLLBACK TO sp1; + +-- After ROLLBACK TO, should be back to 800 (the outer truncate value) +SELECT test_fileops_file_size(:'filepath_a') AS size_after_rollback_to; +COMMIT; + +-- After commit, the outer truncate to 800 should persist +SELECT test_fileops_file_size(:'filepath_a') AS size_after_commit; + +-- ================================================================ +-- Test 8: FileOpsChmod in subtransaction - ROLLBACK TO restores mode +-- ================================================================ + +-- Set a known mode baseline +SELECT test_fileops_chmod(:'filepath_a', 420); -- 0644 + +BEGIN; +-- Outer chmod (persists) +SELECT test_fileops_chmod(:'filepath_a', 448); -- 0700 + +SAVEPOINT sp1; +-- Inner chmod (rolled back) +SELECT test_fileops_chmod(:'filepath_a', 256); -- 0400 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_in_savepoint; +ROLLBACK TO sp1; + +-- After ROLLBACK TO, should be back to 0700 = 448 +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_rollback_to; +COMMIT; + +-- After commit, outer chmod (0700 = 448) persists +SELECT test_fileops_get_mode(:'filepath_a') AS mode_after_commit; + +-- ================================================================ +-- Test 9: FileOpsLink in subtransaction - ROLLBACK TO removes link +-- ================================================================ + +SELECT test_fileops_data_dir() || '/test_fileops_link2.dat' AS linkpath2 \gset + +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_link(:'filepath_a', :'linkpath2'); +SELECT test_fileops_file_exists(:'linkpath2') AS link2_in_savepoint; +ROLLBACK TO sp1; + +-- Link should be removed by UNDO +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_rollback_to; +COMMIT; + +SELECT test_fileops_file_exists(:'linkpath2') AS link2_after_commit; + +-- ================================================================ +-- Test 10: FileOpsSetXattr / FileOpsRemoveXattr UNDO +-- (skipped on platforms without xattr support) +-- ================================================================ + +-- Try to set an xattr; returns false if unsupported (ENOTSUP/EPERM/EACCES) +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'initial_value') + AS xattr_supported \gset + +\if :xattr_supported + +-- Test 10a: SetXattr UNDO - rollback removes newly-set xattr +BEGIN; +SELECT test_fileops_setxattr(:'filepath_a', 'user.rollback_test', 'will_vanish'); +SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_during_txn; +ROLLBACK; + +SELECT test_fileops_getxattr(:'filepath_a', 'user.rollback_test') AS xattr_after_rollback; + +-- Test 10b: SetXattr overwrite UNDO - rollback restores old value +BEGIN; +SELECT test_fileops_setxattr(:'filepath_a', 'user.test_key', 'overwritten'); +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_overwritten; +ROLLBACK; + +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_restored; + +-- Test 10c: RemoveXattr UNDO - rollback restores removed xattr +BEGIN; +SELECT test_fileops_removexattr(:'filepath_a', 'user.test_key'); +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove; +ROLLBACK; + +SELECT test_fileops_getxattr(:'filepath_a', 'user.test_key') AS xattr_after_remove_rollback; + +-- Test 10d: Xattr in subtransaction +BEGIN; +SAVEPOINT sp1; +SELECT test_fileops_setxattr(:'filepath_a', 'user.sub_key', 'sub_value'); +SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_in_savepoint; +ROLLBACK TO sp1; + +SELECT test_fileops_getxattr(:'filepath_a', 'user.sub_key') AS xattr_after_sp_rollback; +COMMIT; + +\else +SELECT 'xattr tests skipped (not supported on this platform/filesystem)' AS notice; +\endif + +-- ================================================================ +-- Cleanup +-- ================================================================ + +DROP EXTENSION test_fileops; diff --git a/src/test/modules/test_fileops/test_fileops--1.0.sql b/src/test/modules/test_fileops/test_fileops--1.0.sql new file mode 100644 index 0000000000000..36698019c12c8 --- /dev/null +++ b/src/test/modules/test_fileops/test_fileops--1.0.sql @@ -0,0 +1,90 @@ +/* src/test/modules/test_fileops/test_fileops--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_fileops" to load this file. \quit + +-- Create a temporary file and return its path +CREATE FUNCTION test_fileops_create_tempfile(filename text) + RETURNS text + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Create a file via FileOpsCreate (exercises the WAL-before-syscall path) +CREATE FUNCTION test_fileops_create(filepath text, mode int) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Truncate a file to the given length (uses FileOpsTruncate) +CREATE FUNCTION test_fileops_truncate(filepath text, length bigint) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Get file size +CREATE FUNCTION test_fileops_file_size(filepath text) + RETURNS bigint + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Chmod a file (uses FileOpsChmod) +CREATE FUNCTION test_fileops_chmod(filepath text, mode int) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Get file permissions (mode) +CREATE FUNCTION test_fileops_get_mode(filepath text) + RETURNS int + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Create a hard link (uses FileOpsLink) +CREATE FUNCTION test_fileops_link(oldpath text, newpath text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Check if a file exists +CREATE FUNCTION test_fileops_file_exists(filepath text) + RETURNS boolean + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Set extended attribute (uses FileOpsSetXattr) +CREATE FUNCTION test_fileops_setxattr(filepath text, attrname text, attrvalue text) + RETURNS boolean + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Get extended attribute value +CREATE FUNCTION test_fileops_getxattr(filepath text, attrname text) + RETURNS text + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Remove extended attribute (uses FileOpsRemoveXattr) +CREATE FUNCTION test_fileops_removexattr(filepath text, attrname text) + RETURNS boolean + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Get the data directory path (for constructing absolute paths) +CREATE FUNCTION test_fileops_data_dir() + RETURNS text + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Read the oldest un-reverted ATM last_batch_lsn (the LSN that pins UNDO WAL +-- against recycling via ATMGetOldestUnrevertedLSN -> KeepLogSeg). NULL when +-- no un-reverted entry exists. Test-only, for the retention-invariant test. +CREATE FUNCTION test_fileops_atm_oldest_lsn() + RETURNS pg_lsn + AS 'MODULE_PATHNAME' LANGUAGE C; +-- Deferred delete at commit (uses FileOpsDelete, at_commit=true) +CREATE FUNCTION test_fileops_delete(filepath text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Deferred rename at commit (uses FileOpsRename) +CREATE FUNCTION test_fileops_rename(oldpath text, newpath text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Create a directory (uses FileOpsMkdir) +CREATE FUNCTION test_fileops_mkdir(path text, mode int) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; + +-- Deferred rmdir at commit (uses FileOpsRmdir, at_commit=true) +CREATE FUNCTION test_fileops_rmdir(path text) + RETURNS void + AS 'MODULE_PATHNAME' LANGUAGE C STRICT; diff --git a/src/test/modules/test_fileops/test_fileops.c b/src/test/modules/test_fileops/test_fileops.c new file mode 100644 index 0000000000000..7c35c161193e6 --- /dev/null +++ b/src/test/modules/test_fileops/test_fileops.c @@ -0,0 +1,445 @@ +/*------------------------------------------------------------------------- + * + * test_fileops.c + * Test module exposing FileOps C API to SQL for regression testing. + * + * This module provides SQL-callable wrappers around the FileOps functions + * that have no DDL-level callers, enabling direct testing of: + * - FileOpsTruncate (with UNDO rollback of file size) + * - FileOpsChmod (with UNDO rollback of permissions) + * - FileOpsLink (with UNDO rollback of hard links) + * - FileOpsSetXattr / FileOpsRemoveXattr (with UNDO rollback) + * + * Copyright (c) 2024-2026, PostgreSQL Global Development Group + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include +#include +#include + +#include "fmgr.h" +#include "funcapi.h" +#include "miscadmin.h" +#include "port/pg_xattr.h" +#include "access/atm.h" +#include "access/xlogdefs.h" +#include "storage/fd.h" +#include "storage/fileops.h" +#include "utils/builtins.h" +#include "utils/pg_lsn.h" + +PG_MODULE_MAGIC; + +PG_FUNCTION_INFO_V1(test_fileops_create_tempfile); +PG_FUNCTION_INFO_V1(test_fileops_create); +PG_FUNCTION_INFO_V1(test_fileops_truncate); +PG_FUNCTION_INFO_V1(test_fileops_file_size); +PG_FUNCTION_INFO_V1(test_fileops_chmod); +PG_FUNCTION_INFO_V1(test_fileops_get_mode); +PG_FUNCTION_INFO_V1(test_fileops_link); +PG_FUNCTION_INFO_V1(test_fileops_file_exists); +PG_FUNCTION_INFO_V1(test_fileops_setxattr); +PG_FUNCTION_INFO_V1(test_fileops_getxattr); +PG_FUNCTION_INFO_V1(test_fileops_removexattr); +PG_FUNCTION_INFO_V1(test_fileops_data_dir); +PG_FUNCTION_INFO_V1(test_fileops_atm_oldest_lsn); + +/* + * test_fileops_atm_oldest_lsn - Read the oldest un-reverted ATM last_batch_lsn. + * + * This is the exact value ATMGetOldestUnrevertedLSN() feeds into + * UndoGetOldestBatchLSN() -> KeepLogSeg(), i.e. the LSN that pins UNDO WAL + * against recycling. Returning it lets a recovery test assert, after crash + + * restart, that the ATM was faithfully reconstructed and still pins the WAL -- + * NOT merely that the WAL file happens to exist on disk. Returns NULL when no + * un-reverted entry exists. + */ +Datum +test_fileops_atm_oldest_lsn(PG_FUNCTION_ARGS) +{ + XLogRecPtr lsn = ATMGetOldestUnrevertedLSN(); + + if (XLogRecPtrIsInvalid(lsn)) + PG_RETURN_NULL(); + + PG_RETURN_LSN(lsn); +} +PG_FUNCTION_INFO_V1(test_fileops_delete); +PG_FUNCTION_INFO_V1(test_fileops_rename); +PG_FUNCTION_INFO_V1(test_fileops_mkdir); +PG_FUNCTION_INFO_V1(test_fileops_rmdir); + +/* + * test_fileops_create_tempfile - Create a test file in data directory. + * + * Creates a file with some content using FileOpsCreate + write, returning + * the absolute path. The file is created with 1024 bytes of content. + */ +Datum +test_fileops_create_tempfile(PG_FUNCTION_ARGS) +{ + text *filename = PG_GETARG_TEXT_PP(0); + char *fname = text_to_cstring(filename); + char filepath[MAXPGPATH]; + int fd; + char buf[1024]; + + snprintf(filepath, MAXPGPATH, "%s/%s", DataDir, fname); + + /* Use raw create + write so we have a file to manipulate */ + fd = open(filepath, O_CREAT | O_WRONLY | O_TRUNC | PG_BINARY, 0644); + if (fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", filepath))); + + /* Fill with 1024 bytes */ + memset(buf, 'X', sizeof(buf)); + if (write(fd, buf, sizeof(buf)) != sizeof(buf)) + { + int save_errno = errno; + + close(fd); + errno = save_errno; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\": %m", filepath))); + } + close(fd); + + PG_RETURN_TEXT_P(cstring_to_text(filepath)); +} + +/* + * test_fileops_create - Create a file using FileOpsCreate. + * + * Unlike test_fileops_create_tempfile (which uses raw open()/write() to set + * up fixture files outside any transactional path), this calls FileOpsCreate + * directly so tests can exercise its WAL-before-syscall crash-recovery + * behavior. + */ +Datum +test_fileops_create(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + int mode = PG_GETARG_INT32(1); + char *filepath = text_to_cstring(filepath_text); + int fd; + + fd = FileOpsCreate(filepath, O_WRONLY | PG_BINARY, (mode_t) mode, true); + if (fd >= 0) + CloseTransientFile(fd); + + pfree(filepath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_truncate - Truncate a file using FileOpsTruncate. + * + * This exercises the UNDO path: on rollback, the original file size + * should be restored. + */ +Datum +test_fileops_truncate(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + int64 length = PG_GETARG_INT64(1); + char *filepath = text_to_cstring(filepath_text); + + FileOpsTruncate(filepath, (off_t) length); + + pfree(filepath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_file_size - Get the current size of a file. + */ +Datum +test_fileops_file_size(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + char *filepath = text_to_cstring(filepath_text); + struct stat st; + + if (stat(filepath, &st) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not stat file \"%s\": %m", filepath))); + + pfree(filepath); + PG_RETURN_INT64((int64) st.st_size); +} + +/* + * test_fileops_chmod - Change file permissions using FileOpsChmod. + * + * This exercises the UNDO path: on rollback, the original permissions + * should be restored. + */ +Datum +test_fileops_chmod(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + int mode = PG_GETARG_INT32(1); + char *filepath = text_to_cstring(filepath_text); + + if (FileOpsChmod(filepath, (mode_t) mode) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not chmod file \"%s\": %m", filepath))); + + pfree(filepath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_get_mode - Get file permission bits. + */ +Datum +test_fileops_get_mode(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + char *filepath = text_to_cstring(filepath_text); + struct stat st; + + if (stat(filepath, &st) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not stat file \"%s\": %m", filepath))); + + pfree(filepath); + PG_RETURN_INT32((int32) (st.st_mode & 0777)); +} + +/* + * test_fileops_link - Create a hard link using FileOpsLink. + * + * This exercises the UNDO path: on rollback, the link should be removed. + */ +Datum +test_fileops_link(PG_FUNCTION_ARGS) +{ + text *oldpath_text = PG_GETARG_TEXT_PP(0); + text *newpath_text = PG_GETARG_TEXT_PP(1); + char *oldpath = text_to_cstring(oldpath_text); + char *newpath = text_to_cstring(newpath_text); + + if (FileOpsLink(oldpath, newpath) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not link \"%s\" to \"%s\": %m", + oldpath, newpath))); + + pfree(oldpath); + pfree(newpath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_file_exists - Check whether a file exists. + */ +Datum +test_fileops_file_exists(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + char *filepath = text_to_cstring(filepath_text); + struct stat st; + bool exists; + + exists = (stat(filepath, &st) == 0); + + pfree(filepath); + PG_RETURN_BOOL(exists); +} + +/* + * test_fileops_setxattr - Set an extended attribute using FileOpsSetXattr. + * + * Returns true if the platform supports xattrs, false if ENOTSUP. + * This exercises the UNDO path: on rollback, the original xattr value + * (or absence) should be restored. + */ +Datum +test_fileops_setxattr(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + text *name_text = PG_GETARG_TEXT_PP(1); + text *value_text = PG_GETARG_TEXT_PP(2); + char *filepath = text_to_cstring(filepath_text); + char *name = text_to_cstring(name_text); + char *value = text_to_cstring(value_text); + int ret; + + ret = FileOpsSetXattr(filepath, name, value, strlen(value)); + if (ret != 0) + { + if (errno == ENOTSUP || errno == EOPNOTSUPP || + errno == EPERM || errno == EACCES) + { + pfree(filepath); + pfree(name); + pfree(value); + PG_RETURN_BOOL(false); + } + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not set xattr \"%s\" on \"%s\": %m", + name, filepath))); + } + + pfree(filepath); + pfree(name); + pfree(value); + PG_RETURN_BOOL(true); +} + +/* + * test_fileops_getxattr - Get an extended attribute value. + * + * Returns NULL if the attribute doesn't exist or xattrs are unsupported. + */ +Datum +test_fileops_getxattr(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + text *name_text = PG_GETARG_TEXT_PP(1); + char *filepath = text_to_cstring(filepath_text); + char *name = text_to_cstring(name_text); + char buf[1024]; + ssize_t len; + + len = pg_getxattr(filepath, name, buf, sizeof(buf) - 1); + if (len < 0) + { + pfree(filepath); + pfree(name); + PG_RETURN_NULL(); + } + + buf[len] = '\0'; + + pfree(filepath); + pfree(name); + PG_RETURN_TEXT_P(cstring_to_text(buf)); +} + +/* + * test_fileops_removexattr - Remove an extended attribute using + * FileOpsRemoveXattr. + * + * Returns true if successful, false if xattrs are unsupported. + * This exercises the UNDO path: on rollback, the removed xattr should + * be restored. + */ +Datum +test_fileops_removexattr(PG_FUNCTION_ARGS) +{ + text *filepath_text = PG_GETARG_TEXT_PP(0); + text *name_text = PG_GETARG_TEXT_PP(1); + char *filepath = text_to_cstring(filepath_text); + char *name = text_to_cstring(name_text); + int ret; + + ret = FileOpsRemoveXattr(filepath, name); + if (ret != 0) + { + if (errno == ENOTSUP || errno == EOPNOTSUPP) + { + pfree(filepath); + pfree(name); + PG_RETURN_BOOL(false); + } + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not remove xattr \"%s\" from \"%s\": %m", + name, filepath))); + } + + pfree(filepath); + pfree(name); + PG_RETURN_BOOL(true); +} + +/* + * test_fileops_data_dir - Return the data directory path. + */ +Datum +test_fileops_data_dir(PG_FUNCTION_ARGS) +{ + PG_RETURN_TEXT_P(cstring_to_text(DataDir)); +} + +/* + * test_fileops_delete - Deferred delete via FileOpsDelete(at_commit=true). + * + * Exercises the B3 log-at-commit-execution path: the delete happens (and is + * WAL-logged) in FileOpsDoPendingOps() at commit, so a physical standby + * reproduces it by replay. + */ +Datum +test_fileops_delete(PG_FUNCTION_ARGS) +{ + char *filepath = text_to_cstring(PG_GETARG_TEXT_PP(0)); + + FileOpsDelete(filepath, true); + pfree(filepath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_rename - Deferred rename via FileOpsRename (always at_commit). + */ +Datum +test_fileops_rename(PG_FUNCTION_ARGS) +{ + char *oldpath = text_to_cstring(PG_GETARG_TEXT_PP(0)); + char *newpath = text_to_cstring(PG_GETARG_TEXT_PP(1)); + + if (FileOpsRename(oldpath, newpath) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not rename \"%s\" to \"%s\": %m", + oldpath, newpath))); + pfree(oldpath); + pfree(newpath); + PG_RETURN_VOID(); +} + +/* + * test_fileops_mkdir - Create a directory via FileOpsMkdir. + * + * FileOpsMkdir creates the directory immediately and registers an + * rmdir-on-abort; on commit the directory persists. Used to set up a + * directory the test can then remove with test_fileops_rmdir. + */ +Datum +test_fileops_mkdir(PG_FUNCTION_ARGS) +{ + char *path = text_to_cstring(PG_GETARG_TEXT_PP(0)); + int mode = PG_GETARG_INT32(1); + + if (FileOpsMkdir(path, (mode_t) mode) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create directory \"%s\": %m", path))); + pfree(path); + PG_RETURN_VOID(); +} + +/* + * test_fileops_rmdir - Deferred rmdir via FileOpsRmdir(at_commit=true). + */ +Datum +test_fileops_rmdir(PG_FUNCTION_ARGS) +{ + char *path = text_to_cstring(PG_GETARG_TEXT_PP(0)); + + FileOpsRmdir(path, true); + pfree(path); + PG_RETURN_VOID(); +} diff --git a/src/test/modules/test_fileops/test_fileops.control b/src/test/modules/test_fileops/test_fileops.control new file mode 100644 index 0000000000000..1d39ab2a9166b --- /dev/null +++ b/src/test/modules/test_fileops/test_fileops.control @@ -0,0 +1,4 @@ +comment = 'Test module for transactional file operations (FILEOPS)' +default_version = '1.0' +module_pathname = '$libdir/test_fileops' +relocatable = true diff --git a/src/test/modules/test_seqlock/Makefile b/src/test/modules/test_seqlock/Makefile new file mode 100644 index 0000000000000..8bf2e3ffe7e30 --- /dev/null +++ b/src/test/modules/test_seqlock/Makefile @@ -0,0 +1,23 @@ +# src/test/modules/test_seqlock/Makefile + +MODULE_big = test_seqlock +OBJS = \ + $(WIN32RES) \ + test_seqlock.o +PGFILEDESC = "test_seqlock - test code for src/include/storage/seqlock.h" + +EXTENSION = test_seqlock +DATA = test_seqlock--1.0.sql + +REGRESS = test_seqlock + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_seqlock +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_seqlock/expected/test_seqlock.out b/src/test/modules/test_seqlock/expected/test_seqlock.out new file mode 100644 index 0000000000000..acf17ab1c740e --- /dev/null +++ b/src/test/modules/test_seqlock/expected/test_seqlock.out @@ -0,0 +1,7 @@ +CREATE EXTENSION test_seqlock; +SELECT test_seqlock(); + test_seqlock +-------------- + +(1 row) + diff --git a/src/test/modules/test_seqlock/meson.build b/src/test/modules/test_seqlock/meson.build new file mode 100644 index 0000000000000..c7683e8e008c8 --- /dev/null +++ b/src/test/modules/test_seqlock/meson.build @@ -0,0 +1,33 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +test_seqlock_sources = files( + 'test_seqlock.c', +) + +if host_system == 'windows' + test_seqlock_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_seqlock', + '--FILEDESC', 'test_seqlock - test code for src/include/storage/seqlock.h',]) +endif + +test_seqlock = shared_module('test_seqlock', + test_seqlock_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_seqlock + +test_install_data += files( + 'test_seqlock.control', + 'test_seqlock--1.0.sql', +) + +tests += { + 'name': 'test_seqlock', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'test_seqlock', + ], + }, +} diff --git a/src/test/modules/test_seqlock/sql/test_seqlock.sql b/src/test/modules/test_seqlock/sql/test_seqlock.sql new file mode 100644 index 0000000000000..a445cf7317426 --- /dev/null +++ b/src/test/modules/test_seqlock/sql/test_seqlock.sql @@ -0,0 +1,2 @@ +CREATE EXTENSION test_seqlock; +SELECT test_seqlock(); diff --git a/src/test/modules/test_seqlock/test_seqlock--1.0.sql b/src/test/modules/test_seqlock/test_seqlock--1.0.sql new file mode 100644 index 0000000000000..575cec9559a83 --- /dev/null +++ b/src/test/modules/test_seqlock/test_seqlock--1.0.sql @@ -0,0 +1,8 @@ +/* src/test/modules/test_seqlock/test_seqlock--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION test_seqlock" to load this file. \quit + +CREATE FUNCTION test_seqlock() + RETURNS pg_catalog.void + AS 'MODULE_PATHNAME' LANGUAGE C; diff --git a/src/test/modules/test_seqlock/test_seqlock.c b/src/test/modules/test_seqlock/test_seqlock.c new file mode 100644 index 0000000000000..146233036fb94 --- /dev/null +++ b/src/test/modules/test_seqlock/test_seqlock.c @@ -0,0 +1,138 @@ +/*-------------------------------------------------------------------------- + * + * test_seqlock.c + * Test module for the sequence lock (storage/seqlock.h). + * + * These are single-process unit tests of the seqlock protocol contract: + * the counter transitions, the read/retry handshake, and a simulated + * writer-interleaving that must force a reader retry. Cross-process + * stress is covered by the sLog tuple-tracking tests that exercise the + * seqlock under real concurrency. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/test/modules/test_seqlock/test_seqlock.c + * + * ------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "fmgr.h" +#include "storage/seqlock.h" + +PG_MODULE_MAGIC; + +#define EXPECT_TRUE(expr) \ + do { \ + if (!(expr)) \ + elog(ERROR, "%s was unexpectedly false in file \"%s\" line %u", \ + #expr, __FILE__, __LINE__); \ + } while (0) + +#define EXPECT_EQ_U32(a, b) \ + do { \ + uint32 a_ = (a), b_ = (b); \ + if (a_ != b_) \ + elog(ERROR, "%u != %u (%s vs %s) in file \"%s\" line %u", \ + a_, b_, #a, #b, __FILE__, __LINE__); \ + } while (0) + +/* + * A tiny protected payload: two fields a writer keeps in a known invariant + * (b == a + 1) so a reader can detect a torn read. + */ +typedef struct GuardedData +{ + SeqLock lock; + uint64 a; + uint64 b; +} GuardedData; + +static void +guarded_write(GuardedData *g, uint64 v) +{ + SeqLockWriteBegin(&g->lock); + g->a = v; + g->b = v + 1; + SeqLockWriteEnd(&g->lock); +} + +/* Consistent read; loops until it observes a torn-free snapshot. */ +static void +guarded_read(GuardedData *g, uint64 *a_out, uint64 *b_out) +{ + uint32 seq; + + do + { + seq = SeqLockReadBegin(&g->lock); + *a_out = g->a; + *b_out = g->b; + } while (!SeqLockReadRetry(&g->lock, seq)); +} + +PG_FUNCTION_INFO_V1(test_seqlock); +Datum +test_seqlock(PG_FUNCTION_ARGS) +{ + GuardedData g; + uint64 a, + b; + uint32 s0, + s1; + + /* Init: counter even (stable). */ + SeqLockInit(&g.lock); + g.a = 0; + g.b = 1; + EXPECT_TRUE((pg_atomic_read_u32(&g.lock.seq) & 1) == 0); + + /* A begin/end pair advances the counter by 2 and leaves it even. */ + s0 = pg_atomic_read_u32(&g.lock.seq); + SeqLockWriteBegin(&g.lock); + EXPECT_TRUE((pg_atomic_read_u32(&g.lock.seq) & 1) == 1); /* odd mid-write */ + SeqLockWriteEnd(&g.lock); + s1 = pg_atomic_read_u32(&g.lock.seq); + EXPECT_EQ_U32(s1, s0 + 2); + EXPECT_TRUE((s1 & 1) == 0); + + /* A clean read (no interleaving writer) succeeds on the first try. */ + s0 = SeqLockReadBegin(&g.lock); + a = g.a; + b = g.b; + EXPECT_TRUE(SeqLockReadRetry(&g.lock, s0)); + EXPECT_TRUE(b == a + 1); + + /* Values a writer stored are visible to a subsequent consistent read. */ + guarded_write(&g, 42); + guarded_read(&g, &a, &b); + EXPECT_EQ_U32((uint32) a, 42); + EXPECT_EQ_U32((uint32) b, 43); + EXPECT_TRUE(b == a + 1); + + /* + * Simulate a writer interleaving between a reader's begin and retry: the + * retry must FAIL (return false), forcing the reader to loop. This is + * the core seqlock guarantee -- a snapshot spanning a write is rejected. + */ + s0 = SeqLockReadBegin(&g.lock); + guarded_write(&g, 100); /* writer completes a full cycle in the window */ + EXPECT_TRUE(!SeqLockReadRetry(&g.lock, s0)); + + /* After the retry-forced loop, the reader gets the invariant-holding pair. */ + guarded_read(&g, &a, &b); + EXPECT_EQ_U32((uint32) a, 100); + EXPECT_TRUE(b == a + 1); + + /* Many begin/end cycles keep the counter even and monotonically rising. */ + { + uint32 before = pg_atomic_read_u32(&g.lock.seq); + + for (int i = 0; i < 1000; i++) + guarded_write(&g, (uint64) i); + EXPECT_EQ_U32(pg_atomic_read_u32(&g.lock.seq), before + 2000); + } + + PG_RETURN_VOID(); +} diff --git a/src/test/modules/test_seqlock/test_seqlock.control b/src/test/modules/test_seqlock/test_seqlock.control new file mode 100644 index 0000000000000..c919694696cae --- /dev/null +++ b/src/test/modules/test_seqlock/test_seqlock.control @@ -0,0 +1,4 @@ +comment = 'Test code for sequence lock' +default_version = '1.0' +module_pathname = '$libdir/test_seqlock' +relocatable = true diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index 39ec8c4946ddf..4588692739ee6 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -63,7 +63,12 @@ tests += { 't/052_checkpoint_segment_missing.pl', 't/053_standby_login_event_trigger.pl', 't/054_unlogged_sequence_promotion.pl', + 't/054_fileops_recovery.pl', 't/055_cascade_reconnect.pl', + 't/065_undo_adversarial_crash.pl', + 't/068_fileops_2pc.pl', + 't/069_undo_revert_retention.pl', + 't/070_fileops_standby_divergence.pl', ], }, } diff --git a/src/test/recovery/t/054_fileops_recovery.pl b/src/test/recovery/t/054_fileops_recovery.pl new file mode 100644 index 0000000000000..f1e08b7869d14 --- /dev/null +++ b/src/test/recovery/t/054_fileops_recovery.pl @@ -0,0 +1,435 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# Test crash recovery for transactional file operations (FILEOPS). +# +# These tests verify that FILEOPS WAL replay correctly handles: +# - Crash during file creation (with delete-on-abort) +# - Crash during deferred file deletion +# - Crash during file operations on standby +# - Multiple sequential crashes + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_recovery'); +$node->init; +$node->append_conf( + "postgresql.conf", qq( +autovacuum = off +log_min_messages = debug2 +)); +$node->start; + +# ================================================================ +# Test 1: CREATE TABLE survives crash +# ================================================================ + +$node->safe_psql("postgres", qq( +CREATE TABLE fileops_test (id int, data text); +INSERT INTO fileops_test VALUES (1, 'created_table'); +)); + +$node->stop('immediate'); +$node->start; + +my $result = $node->safe_psql("postgres", + "SELECT data FROM fileops_test WHERE id = 1"); +is($result, 'created_table', 'CREATE TABLE survives crash'); + +# ================================================================ +# Test 2: DROP TABLE is properly handled after crash +# ================================================================ + +$node->safe_psql("postgres", qq( +CREATE TABLE drop_me (id int); +INSERT INTO drop_me VALUES (1); +)); + +# Get the relfilenode before dropping +my $relpath = $node->safe_psql("postgres", + "SELECT pg_relation_filepath('drop_me')"); + +$node->safe_psql("postgres", "DROP TABLE drop_me"); + +$node->stop('immediate'); +$node->start; + +# Table should be gone +my ($ret, $stdout, $stderr) = $node->psql("postgres", + "SELECT * FROM drop_me"); +isnt($ret, 0, 'dropped table is gone after crash recovery'); + +# ================================================================ +# Test 3: Crash during transaction with CREATE TABLE (uncommitted) +# ================================================================ + +# This table is committed +$node->safe_psql("postgres", qq( +CREATE TABLE committed_table (id int); +INSERT INTO committed_table VALUES (42); +)); + +# Crash the server +$node->stop('immediate'); +$node->start; + +# Committed table should exist +$result = $node->safe_psql("postgres", + "SELECT id FROM committed_table"); +is($result, '42', 'committed CREATE TABLE survives crash'); + +# ================================================================ +# Test 4: Multiple CREATE and DROP operations then crash +# ================================================================ + +$node->safe_psql("postgres", qq( +CREATE TABLE t1 (id int); +CREATE TABLE t2 (id int); +CREATE TABLE t3 (id int); +INSERT INTO t1 VALUES (1); +INSERT INTO t2 VALUES (2); +INSERT INTO t3 VALUES (3); +DROP TABLE t2; +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", + "SELECT id FROM t1"); +is($result, '1', 't1 survives crash'); + +($ret, $stdout, $stderr) = $node->psql("postgres", + "SELECT * FROM t2"); +isnt($ret, 0, 't2 (dropped) is gone after crash'); + +$result = $node->safe_psql("postgres", + "SELECT id FROM t3"); +is($result, '3', 't3 survives crash'); + +# ================================================================ +# Test 5: Crash after checkpoint with file operations +# ================================================================ + +$node->safe_psql("postgres", qq( +DROP TABLE IF EXISTS t1; +DROP TABLE IF EXISTS t3; +CREATE TABLE checkpoint_test (id int); +INSERT INTO checkpoint_test VALUES (1); +CHECKPOINT; +INSERT INTO checkpoint_test VALUES (2); +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", + "SELECT count(*) FROM checkpoint_test"); +is($result, '2', 'data after checkpoint survives crash'); + +# ================================================================ +# Test 6: Multiple crashes in sequence with file operations +# ================================================================ + +$node->safe_psql("postgres", qq( +DROP TABLE IF EXISTS checkpoint_test; +CREATE TABLE multi_crash (id int); +INSERT INTO multi_crash VALUES (1); +)); + +$node->stop('immediate'); +$node->start; + +$node->safe_psql("postgres", qq( +INSERT INTO multi_crash VALUES (2); +CREATE TABLE multi_crash_2 (id int); +INSERT INTO multi_crash_2 VALUES (10); +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", + "SELECT count(*) FROM multi_crash"); +is($result, '2', 'multi_crash table correct after double crash'); + +$result = $node->safe_psql("postgres", + "SELECT id FROM multi_crash_2"); +is($result, '10', 'multi_crash_2 table correct after double crash'); + +# ================================================================ +# Test 7: CREATE TABLESPACE survives crash +# ================================================================ + +$node->safe_psql("postgres", qq( +DROP TABLE IF EXISTS multi_crash; +DROP TABLE IF EXISTS multi_crash_2; +SET allow_in_place_tablespaces = on; +CREATE TABLESPACE fileops_crash_ts LOCATION ''; +CREATE TABLE ts_crash_table (id int) TABLESPACE fileops_crash_ts; +INSERT INTO ts_crash_table VALUES (99); +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", + "SELECT id FROM ts_crash_table"); +is($result, '99', 'table in tablespace survives crash'); + +$result = $node->safe_psql("postgres", + "SELECT spcname FROM pg_tablespace WHERE spcname = 'fileops_crash_ts'"); +is($result, 'fileops_crash_ts', 'tablespace survives crash'); + +# ================================================================ +# Test 8: DROP TABLESPACE completes after crash +# ================================================================ + +$node->safe_psql("postgres", qq( +DROP TABLE ts_crash_table; +DROP TABLESPACE fileops_crash_ts; +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", + "SELECT count(*) FROM pg_tablespace WHERE spcname = 'fileops_crash_ts'"); +is($result, '0', 'dropped tablespace is gone after crash'); + +# ================================================================ +# Test 9: CREATE DATABASE survives crash +# ================================================================ + +$node->safe_psql("postgres", qq( +CREATE DATABASE fileops_crash_db; +)); + +$node->stop('immediate'); +$node->start; + +$result = $node->safe_psql("postgres", + "SELECT datname FROM pg_database WHERE datname = 'fileops_crash_db'"); +is($result, 'fileops_crash_db', 'CREATE DATABASE survives crash'); + +$node->safe_psql("postgres", "DROP DATABASE fileops_crash_db"); + +# ================================================================ +# Test 10: Standby crash during FILEOPS replay +# ================================================================ + +# Set up primary + standby +my $primary = PostgreSQL::Test::Cluster->new('fileops_primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf("postgresql.conf", qq( +autovacuum = off +)); +$primary->start; +$primary->backup('backup'); + +my $standby = PostgreSQL::Test::Cluster->new('fileops_standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; + +# Create table on primary and wait for standby to catch up +$primary->safe_psql("postgres", qq( +CREATE TABLE standby_test (id int); +INSERT INTO standby_test VALUES (1); +)); + +$primary->wait_for_catchup($standby); + +# Verify on standby +$result = $standby->safe_psql("postgres", + "SELECT id FROM standby_test"); +is($result, '1', 'CREATE TABLE replicated to standby'); + +# Crash the standby +$standby->stop('immediate'); +$standby->start; + +# Add more data on primary +$primary->safe_psql("postgres", qq( +INSERT INTO standby_test VALUES (2); +)); + +$primary->wait_for_catchup($standby); + +$result = $standby->safe_psql("postgres", + "SELECT count(*) FROM standby_test"); +is($result, '2', 'standby recovers and catches up after crash'); + +# Clean up primary/standby +$standby->stop; +$primary->stop; + +# Clean up original node +$node->stop; + +# ================================================================ +# Test 11/12: Crash exactly between WAL-flush and the physical syscall +# for the write-ordering fix (FileOpsCreate, FileOpsChmod). +# +# Before the fix, these two functions ran the physical syscall (and, for +# Create, the fsync) BEFORE the WAL record reached disk. After the fix, +# WAL/UNDO is durably flushed first, and only then does the physical +# syscall run, inside a critical section. These tests use injection +# points to pause the backend in that exact window -- after XLogFlush(), +# before open()/chmod() -- then hard-kill the postmaster. +# +# The injection point fires from inside the enclosing single-statement +# implicit transaction, before that transaction's COMMIT record could be +# written. So crashing there means the transaction is, correctly, +# recovered as never-committed: WAL redo physically replays the CREATE/ +# CHMOD unconditionally (redo does not know yet whether the surrounding +# xact committed), and then crash recovery's abort processing applies the +# UNDO record for that not-committed transaction, reversing the physical +# change. The end state must be as if the transaction never ran at all -- +# no orphaned file, no leftover mode change -- which is exactly the +# invariant broken before this fix: with the syscall running before WAL, +# a crash in this window used to leave a physical create()/chmod() with +# NO corresponding WAL or UNDO record for recovery to reconcile against +# (the syscall had already happened, but WAL/UNDO for it had not), +# leaving a permanently orphaned file or an un-reversible mode change even +# though the owning transaction never committed. Now WAL/UNDO is always +# durable before the syscall runs, so recovery always has a consistent +# decision to make -- keep (if committed) or reverse (if not) -- no +# matter which side of the syscall the crash lands on. +# ================================================================ + +if ($ENV{enable_injection_points} eq 'yes') +{ + my $inode = PostgreSQL::Test::Cluster->new('fileops_wal_ordering'); + $inode->init; + $inode->append_conf( + 'postgresql.conf', qq( +shared_preload_libraries = 'injection_points' +autovacuum = off +log_min_messages = debug2 +wal_level = replica +)); + $inode->start; + + if ($inode->check_extension('injection_points') + && $inode->check_extension('test_fileops')) + { + $inode->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + $inode->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + + my $idatadir = $inode->data_dir; + + # --- Test 11: FileOpsCreate ----------------------------------- + $inode->safe_psql('postgres', + q{SELECT injection_points_attach('fileops-create-after-wal-flush', 'wait')} + ); + + my $create_path = "$idatadir/wal_ordering_create.dat"; + my $bgpsql = $inode->background_psql('postgres'); + $bgpsql->query_until( + qr/starting_create/, + qq(\\echo starting_create +SELECT test_fileops_create('$create_path', 384); +\\q +)); + + $inode->wait_for_event('client backend', + 'fileops-create-after-wal-flush'); + + # The WAL record is durably flushed, but the physical create() has + # not run yet -- the file must not exist on disk right now. + ok(!-e $create_path, + 'Test 11: file does not exist yet at the injection point ' + . '(WAL durable, physical create() not yet run)'); + + # Hard-kill the postmaster from exactly this window. The wrapping + # implicit transaction is still in progress (blocked on the + # injection point), so it never committed. + $inode->stop('immediate'); + $inode->start; + + # Recovery redoes the physical CREATE from the durable WAL record + # unconditionally, then determines the transaction never committed + # and applies its UNDO record to reverse the create. Net effect: + # the file must NOT exist -- exactly as if the transaction had + # never run, with no orphaned state left behind by the crash. + ok(!-e $create_path, + 'Test 11: FileOpsCreate of a not-committed transaction is ' + . 'cleanly reversed after a crash mid-critical-section'); + + # Server is healthy after the reconciliation. + my $result11 = $inode->safe_psql('postgres', 'SELECT 1'); + is($result11, '1', 'Test 11: server operational after recovery'); + + $bgpsql->{run}->finish; + + # --- Test 12: FileOpsChmod ------------------------------------- + $inode->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('wal_ordering_chmod.dat')}); + my $chmod_path = "$idatadir/wal_ordering_chmod.dat"; + $inode->safe_psql('postgres', + qq{SELECT test_fileops_chmod('$chmod_path', 420)}); # 0644 + my $mode_before = $inode->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$chmod_path')}); + is($mode_before, '420', 'Test 12: baseline mode is 0644'); + + $inode->safe_psql('postgres', + q{SELECT injection_points_attach('fileops-chmod-after-wal-flush', 'wait')} + ); + + $bgpsql = $inode->background_psql('postgres'); + $bgpsql->query_until( + qr/starting_chmod/, + qq(\\echo starting_chmod +SELECT test_fileops_chmod('$chmod_path', 448); -- 0700 +\\q +)); + + $inode->wait_for_event('client backend', + 'fileops-chmod-after-wal-flush'); + + # WAL is durably flushed, but chmod() has not run yet -- the mode + # on disk must still be the baseline 0644. + my $mode_at_injection = $inode->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$chmod_path')}); + is($mode_at_injection, '420', + 'Test 12: mode unchanged on disk at the injection point ' + . '(WAL durable, physical chmod() not yet run)'); + + # Hard-kill mid-critical-section, same as Test 11. The chmod's + # wrapping implicit transaction never committed. + $inode->stop('immediate'); + $inode->start; + + # Recovery redoes the physical CHMOD unconditionally, then reverses + # it via UNDO because the transaction never committed. Net effect: + # mode must be back to the pre-transaction baseline (0644), not the + # attempted 0700, and not some undefined intermediate state. + my $mode_after = $inode->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$chmod_path')}); + is($mode_after, '420', + 'Test 12: FileOpsChmod of a not-committed transaction is ' + . 'cleanly reversed after a crash mid-critical-section'); + + # Server is healthy after the reconciliation. + my $result12 = $inode->safe_psql('postgres', 'SELECT 1'); + is($result12, '1', 'Test 12: server operational after recovery'); + + $bgpsql->{run}->finish; + } + else + { + note + 'skipping Test 11/12 (injection_points or test_fileops extension not installed)'; + } + + $inode->stop; +} +else +{ + note 'skipping Test 11/12 (injection points not supported by this build)'; +} + +done_testing(); diff --git a/src/test/recovery/t/065_undo_adversarial_crash.pl b/src/test/recovery/t/065_undo_adversarial_crash.pl new file mode 100644 index 0000000000000..32cf8cff72a9f --- /dev/null +++ b/src/test/recovery/t/065_undo_adversarial_crash.pl @@ -0,0 +1,380 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# Adversarial crash tests for the UNDO infrastructure. +# +# Uses injection_points to halt execution at precise points in the UNDO +# subsystem, then crashes the server to verify that recovery handles +# partially-applied or incomplete UNDO operations correctly. +# +# Test vehicle: FILEOPS (the sole active UNDO RM after heap UNDO removal). +# FILEOPS writes UNDO records for transactional file operations and provides +# legitimate crash scenarios for the generic UNDO infrastructure. +# +# Operations used: +# - test_fileops_truncate: calls FileOpsTruncate (writes UNDO record) +# - test_fileops_chmod: calls FileOpsChmod (writes UNDO record) +# - CREATE TABLESPACE: calls FileOpsMkdir (writes UNDO record) + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use File::Path qw(make_path remove_tree); +use Test::More; + +if ($ENV{enable_injection_points} ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +# ================================================================ +# Helper: set up a node with injection_points and test_fileops +# ================================================================ +sub setup_node +{ + my ($name) = @_; + my $node = PostgreSQL::Test::Cluster->new($name); + $node->init; + $node->append_conf( + 'postgresql.conf', qq( +shared_preload_libraries = 'injection_points' +log_min_messages = debug2 +autovacuum = off +wal_level = replica +)); + $node->start; + + if (!$node->check_extension('injection_points')) + { + plan skip_all => 'Extension injection_points not installed'; + } + if (!$node->check_extension('test_fileops')) + { + plan skip_all => 'Extension test_fileops not installed'; + } + + $node->safe_psql('postgres', 'CREATE EXTENSION injection_points'); + $node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + return $node; +} + +# ================================================================ +# Test 1: Crash during transaction abort with UNDO records +# +# Verifies that when a transaction that wrote UNDO records crashes +# mid-abort (after ATM registration but before cleanup), recovery +# properly handles the aborted transaction state. +# ================================================================ + +my $node = setup_node('test1'); + +my $datadir = $node->data_dir; + +# Create a test file that we'll truncate inside a transaction +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('test1_file.dat')}); + +# Verify initial file size (1024 bytes from create_tempfile) +my $result = $node->safe_psql('postgres', + qq{SELECT test_fileops_file_size('$datadir/test1_file.dat')}); +is($result, '1024', 'Test 1: initial file size is 1024'); + +# Set up injection point on the abort path +$node->safe_psql('postgres', + q{SELECT injection_points_attach('undo-xact-abort-after-atm', 'wait')}); + +# Run a transaction that truncates the file and then aborts +my $bgpsql = $node->background_psql('postgres'); +$bgpsql->query_until( + qr/starting_abort/, + qq(\\echo starting_abort +BEGIN; +SELECT test_fileops_truncate('$datadir/test1_file.dat', 0); +ABORT; +\\q +)); + +# Wait for the abort to reach our injection point +$node->wait_for_event('client backend', 'undo-xact-abort-after-atm'); + +# Crash the server while mid-abort +$node->stop('immediate'); + +# Restart and verify recovery completes +$node->start; + +# The file size should have been restored by UNDO (either during abort +# completion or during recovery) -- truncate reversed to original 1024 +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_file_size('$datadir/test1_file.dat')}); +is($result, '1024', 'Test 1: file size restored after crash during abort'); + +# Server is healthy +$result = $node->safe_psql('postgres', 'SELECT 1'); +is($result, '1', 'Test 1: server operational after recovery'); + +$node->stop; + +# ================================================================ +# Test 2: Crash during UNDO batch WAL insertion +# +# Verifies that if we crash between XLogBeginInsert and XLogInsert +# for an UNDO batch, the incomplete batch is not visible and the +# transaction is treated as aborted. +# ================================================================ + +$node = setup_node('test2'); +$datadir = $node->data_dir; + +# Create a test file +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('test2_file.dat')}); + +$node->safe_psql('postgres', + q{SELECT injection_points_attach('undo-batch-before-wal-insert', 'wait')}); + +# Start a transaction that will trigger batch flush on commit +$bgpsql = $node->background_psql('postgres'); +$bgpsql->query_until( + qr/starting_op/, + qq(\\echo starting_op +BEGIN; +SELECT test_fileops_truncate('$datadir/test2_file.dat', 512); +COMMIT; +\\q +)); + +# Wait for the batch insertion to reach our injection point +$node->wait_for_event('client backend', 'undo-batch-before-wal-insert'); + +# Crash before the WAL insert completes +$node->stop('immediate'); +$node->start; + +# The key guarantee: server recovers cleanly regardless of whether the +# truncate committed or not (the UNDO batch may not have been written). +$result = $node->safe_psql('postgres', 'SELECT 1'); +is($result, '1', 'Test 2: server operational after crash during batch WAL insert'); + +$node->stop; + +# ================================================================ +# Test 3: Crash during UNDO worker discard cycle +# +# Verifies that crashing the UNDO background worker mid-discard +# does not corrupt the UNDO log state, and that the worker resumes +# correctly after restart. +# ================================================================ + +$node = setup_node('test3'); +$datadir = $node->data_dir; + +$node->safe_psql('postgres', + q{SELECT injection_points_attach('undo-worker-before-discard', 'wait')}); + +# Generate some committed transactions with UNDO records so the worker +# has something to process +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('test3_file.dat')}); +$node->safe_psql('postgres', + qq{SELECT test_fileops_chmod('$datadir/test3_file.dat', 493)}); + +# Wait for the UNDO worker to hit the injection point +$node->wait_for_event('undo worker', 'undo-worker-before-discard'); + +# Crash while worker is about to discard +$node->stop('immediate'); +$node->start; + +# UNDO log integrity maintained - server operational +$result = $node->safe_psql('postgres', 'SELECT 1'); +is($result, '1', 'Test 3: server operational after crash during worker discard'); + +# Can still perform transactional operations with UNDO +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('test3_after.dat')}); +$node->safe_psql('postgres', + qq{SELECT test_fileops_chmod('$datadir/test3_after.dat', 420)}); +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$datadir/test3_after.dat')}); +is($result, '420', 'Test 3: FILEOPS operations work after worker crash recovery'); + +$node->stop; + +# ================================================================ +# Test 4: Crash during FILEOPS UNDO apply +# +# Verifies that crashing mid-way through FILEOPS UNDO application +# (while reversing file operations) results in correct recovery. +# The UNDO chain should be re-applied during crash recovery. +# ================================================================ + +$node = setup_node('test4'); +$datadir = $node->data_dir; + +# Create a test file +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('test4_file.dat')}); + +# Verify initial permissions (0644 = 420 decimal) +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$datadir/test4_file.dat')}); +my $orig_mode = $result; + +# Set up injection point on FILEOPS UNDO apply +$node->safe_psql('postgres', + q{SELECT injection_points_attach('fileops-undo-apply-begin', 'wait')}); + +$bgpsql = $node->background_psql('postgres'); +$bgpsql->query_until( + qr/starting_fileops/, + qq(\\echo starting_fileops +BEGIN; +SELECT test_fileops_chmod('$datadir/test4_file.dat', 511); +ABORT; +\\q +)); + +# Wait for the UNDO apply to start +$node->wait_for_event('client backend', 'fileops-undo-apply-begin'); + +# Crash while UNDO is being applied +$node->stop('immediate'); +$node->start; + +# After recovery, the permissions should be restored (UNDO completed) +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$datadir/test4_file.dat')}); +is($result, $orig_mode, + 'Test 4: file permissions restored after crash during UNDO apply'); + +$result = $node->safe_psql('postgres', 'SELECT 1'); +is($result, '1', 'Test 4: server operational after crash during FILEOPS UNDO apply'); + +$node->stop; + +# ================================================================ +# Test 5: Deep subtransaction UNDO chain +# +# Verifies that deeply nested savepoints with FILEOPS operations +# are all properly reversed after a crash. Tests UNDO chain traversal +# with deep nesting. +# ================================================================ + +$node = setup_node('test5'); +$datadir = $node->data_dir; + +# Create test files +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('test5_file.dat')}); + +# Verify initial size +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_file_size('$datadir/test5_file.dat')}); +is($result, '1024', 'Test 5: initial file size is 1024'); + +# Build a transaction with 20 nested savepoints, each truncating the file +# to a smaller size +my $depth = 20; +$bgpsql = $node->background_psql('postgres'); + +my $sql = "BEGIN;\n"; +for my $i (1 .. $depth) +{ + my $size = 1024 - ($i * 40); # 984, 944, ..., 224 + $sql .= "SAVEPOINT sp$i;\n"; + $sql .= "SELECT test_fileops_truncate('$datadir/test5_file.dat', $size);\n"; +} + +$bgpsql->query_until( + qr/deep_done/, + $sql . "\\echo deep_done\n"); + +# Verify the file was truncated to final size (1024 - 20*40 = 224) +my $final_size = 1024 - ($depth * 40); +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_file_size('$datadir/test5_file.dat')}); +is($result, "$final_size", 'Test 5: file truncated through nested savepoints'); + +# Crash the server (transaction not committed) +$node->stop('immediate'); +$node->start; + +# After recovery, the file size should be restored to original 1024 +# (uncommitted transaction rolled back via UNDO chain traversal) +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_file_size('$datadir/test5_file.dat')}); +is($result, '1024', + 'Test 5: file size restored after deep subtransaction crash'); + +$result = $node->safe_psql('postgres', 'SELECT 1'); +is($result, '1', 'Test 5: server operational after deep subtransaction crash'); + +$node->stop; + +# ================================================================ +# Test 6: Repeated crashes during UNDO apply (idempotency) +# +# Verifies that crashing multiple times during UNDO application +# produces the correct final state. UNDO apply must be idempotent: +# partially-applied operations should not cause errors on retry. +# ================================================================ + +$node = setup_node('test6'); +$datadir = $node->data_dir; + +# Create test file +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('test6_file.dat')}); +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_file_size('$datadir/test6_file.dat')}); +is($result, '1024', 'Test 6: initial file size is 1024'); + +# Set up injection point for UNDO apply +$node->safe_psql('postgres', + q{SELECT injection_points_attach('fileops-undo-apply-begin', 'wait')}); + +$bgpsql = $node->background_psql('postgres'); +$bgpsql->query_until( + qr/starting_idempotent/, + qq(\\echo starting_idempotent +BEGIN; +SELECT test_fileops_truncate('$datadir/test6_file.dat', 100); +SELECT test_fileops_chmod('$datadir/test6_file.dat', 511); +ABORT; +\\q +)); + +# Wait for first UNDO apply attempt +$node->wait_for_event('client backend', 'fileops-undo-apply-begin'); + +# First crash during UNDO apply +$node->stop('immediate'); + +# First recovery +$node->start; + +# Verify file state is correct after first recovery +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_file_size('$datadir/test6_file.dat')}); +is($result, '1024', + 'Test 6: file size restored after first crash during UNDO apply'); + +# Second crash for idempotency verification +$node->stop('immediate'); +$node->start; + +# File should still be in correct state +$result = $node->safe_psql('postgres', + qq{SELECT test_fileops_file_size('$datadir/test6_file.dat')}); +is($result, '1024', + 'Test 6: file size correct after repeated crashes (idempotent recovery)'); + +$result = $node->safe_psql('postgres', 'SELECT 1'); +is($result, '1', + 'Test 6: server stable after repeated crashes'); + +$node->stop; + +done_testing(); diff --git a/src/test/recovery/t/068_fileops_2pc.pl b/src/test/recovery/t/068_fileops_2pc.pl new file mode 100644 index 0000000000000..61a58884f58c9 --- /dev/null +++ b/src/test/recovery/t/068_fileops_2pc.pl @@ -0,0 +1,146 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# Test two-phase commit (PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK +# PREPARED) for transactions that generated cluster-wide FILEOPS UNDO. +# +# FILEOPS uses the undo-in-common-WAL mechanism. At PREPARE the permanent +# UNDO chain-head LSN is durably saved in the 2PC state (xl_xact_prepare / +# gxact->undo_batch_lsn), and its WAL is retained while the xact stays +# prepared. ROLLBACK PREPARED (even from a different backend, post-crash) +# hands that LSN to the async logical-revert machinery via ATMAddAborted(), +# which reverses the filesystem change. COMMIT PREPARED keeps it. +# +# Vehicle: the test_fileops extension's SQL wrappers for FileOpsChmod, which +# are legal inside a transaction block that can then be PREPARE'd. +# +# NB: FILEOPS only writes UNDO WAL when XLogIsNeeded() (wal_level >= replica). +# At wal_level = minimal it relies on the backend-private pending-op list, +# which is discarded at PREPARE and cannot survive 2PC. This test therefore +# runs at wal_level = replica, which is what any FILEOPS + 2PC deployment +# requires. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('fileops_2pc'); +$node->init; +$node->append_conf( + "postgresql.conf", qq( +autovacuum = off +max_prepared_transactions = 10 +logical_revert_naptime = 1000 +wal_level = replica +)); +$node->start; + +if (!$node->check_extension('test_fileops')) +{ + plan skip_all => 'Extension test_fileops not installed'; +} +$node->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $datadir = $node->data_dir; +my $file = "$datadir/twopc.dat"; + +# Poll until the file mode equals $want (async revert takes a moment) or fail. +sub wait_for_mode +{ + my ($want, $desc) = @_; + my $ok = $node->poll_query_until('postgres', + qq{SELECT test_fileops_get_mode('$file') = $want}); + is($ok, '1', $desc); +} + +# 0644 = 420, 0700 = 448, 0600 = 384 (decimal) +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('twopc.dat')}); +$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$file', 420)}); +my $baseline = $node->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$file')}); +is($baseline, '420', 'baseline mode is 0644'); + +# ================================================================ +# Test 1: ROLLBACK PREPARED reverses the chmod +# ================================================================ +$node->safe_psql('postgres', qq( +BEGIN; +SELECT test_fileops_chmod('$file', 448); -- 0700 +PREPARE TRANSACTION 'fo_rb'; +)); +my $m = $node->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$file')}); +is($m, '448', 'Test 1: chmod applied and still visible while prepared'); + +$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo_rb'}); +wait_for_mode(420, 'Test 1: ROLLBACK PREPARED reverses chmod (0700 -> 0644)'); + +# ================================================================ +# Test 2: COMMIT PREPARED keeps the chmod +# ================================================================ +$node->safe_psql('postgres', qq( +BEGIN; +SELECT test_fileops_chmod('$file', 448); -- 0700 +PREPARE TRANSACTION 'fo_cp'; +)); +$node->safe_psql('postgres', q{COMMIT PREPARED 'fo_cp'}); +$m = $node->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$file')}); +is($m, '448', 'Test 2: COMMIT PREPARED keeps chmod (0700)'); + +# Reset to baseline for the crash test. +$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$file', 420)}); + +# ================================================================ +# Test 3: WAL retention across checkpoints while prepared +# ================================================================ +$node->safe_psql('postgres', qq( +BEGIN; +SELECT test_fileops_chmod('$file', 384); -- 0600 +PREPARE TRANSACTION 'fo_ckpt'; +)); +# Churn WAL and checkpoint; the prepared xact's UNDO batch must be retained. +$node->safe_psql('postgres', 'CHECKPOINT'); +for my $i (1 .. 20) +{ + $node->safe_psql('postgres', + "CREATE TABLE junk_$i(x int); " + . "INSERT INTO junk_$i SELECT g FROM generate_series(1,5000) g; " + . "DROP TABLE junk_$i;"); +} +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo_ckpt'}); +wait_for_mode(420, + 'Test 3: ROLLBACK PREPARED still reverses after WAL churn + checkpoints'); + +# ================================================================ +# Test 4: crash recovery -- prepared UNDO xact survives, then reverts +# ================================================================ +$node->safe_psql('postgres', qq{SELECT test_fileops_chmod('$file', 420)}); +$node->safe_psql('postgres', qq( +BEGIN; +SELECT test_fileops_chmod('$file', 448); -- 0700 +PREPARE TRANSACTION 'fo_crash'; +)); +$node->stop('immediate'); +$node->start; + +my $prep = $node->safe_psql('postgres', + q{SELECT gid FROM pg_prepared_xacts}); +is($prep, 'fo_crash', 'Test 4: prepared xact survives crash recovery'); +$m = $node->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$file')}); +is($m, '448', 'Test 4: chmod still applied (prepared, not yet rolled back)'); + +$node->safe_psql('postgres', q{ROLLBACK PREPARED 'fo_crash'}); +wait_for_mode(420, + 'Test 4: ROLLBACK PREPARED after crash+restart reverses chmod'); + +# Server healthy at the end. +is($node->safe_psql('postgres', 'SELECT 1'), '1', + 'server operational after all 2PC UNDO scenarios'); + +$node->stop; +done_testing(); diff --git a/src/test/recovery/t/069_undo_revert_retention.pl b/src/test/recovery/t/069_undo_revert_retention.pl new file mode 100644 index 0000000000000..e2a1f245335eb --- /dev/null +++ b/src/test/recovery/t/069_undo_revert_retention.pl @@ -0,0 +1,172 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# UNDO retention-invariant recovery test (bug B2 + a deeper defect it exposes). +# +# UNDO's core CLAIM (src/backend/access/undo/README) is the CTR guarantee: +# an aborted transaction's UNDO is GUARANTEED to be applied, eventually and +# completely. The logical revert worker's PG_CATCH used to *silently abandon* +# an un-reverted abort when it thought the UNDO WAL had been recycled +# (last_batch_lsn < redo), marking it reverted without doing the work. That +# was justified as dead code: the retention chain +# +# ATMGetOldestUnrevertedLSN (atm.c) +# -> UndoGetOldestBatchLSN (undolog.c) +# -> KeepLogSeg (xlog.c) +# +# pins the UNDO WAL *segment* against recycling until ATMForget runs, so the +# branch "should be impossible". We converted it to ereport(PANIC, ...). +# +# This test proves the two properties the retention invariant needs: +# +# LEG 1 -- WAL RETAINED across checkpoint: create an un-reverted ATM abort +# (ROLLBACK PREPARED of a FILEOPS transaction, with the logical revert +# worker frozen at the 'logical-revert-before-process' injection point so +# it cannot revert+forget), force a CHECKPOINT, and assert the WAL segment +# holding the abort's last_batch_lsn is STILL PRESENT on disk. +# +# LEG 2 -- ATM FAITHFULLY RECONSTRUCTED on restart: crash (-m immediate), +# restart, and assert the ATM entry that justifies the retention is STILL +# THERE, read via ATMGetOldestUnrevertedLSN (exposed as the test-only SQL +# function test_fileops_atm_oldest_lsn() and logged by ATMRecoveryFinalize +# as "ATM recovery complete: ... oldest unreverted LSN X/X"). This is NOT +# "the WAL file exists on disk" -- it reads the reconstructed ATM state +# directly. +# +# RESULT: leg 1 holds; leg 2 FAILS. The retention invariant is NOT airtight. +# The WAL *segment* is retained (KeepLogSeg), but the checkpoint *redo pointer* +# is NOT held back to the oldest un-forgotten ATM entry. A CHECKPOINT taken +# after the XLOG_ATM_ABORT record advances redo past that record; crash +# recovery replays from the redo point and never sees the abort, so atm_redo +# never reconstructs the ATM entry. The aborted work is then never reverted -- +# a SILENT loss of the guaranteed rollback, occurring at recovery, before the +# worker's PANIC branch can ever run. See the deliverable report for the exact +# LSN evidence. +# +# Vehicle: test_fileops (FILEOPS common-WAL UNDO) + ROLLBACK PREPARED, which is +# the only deterministic way to create a durable un-reverted ATM entry: +# ordinary FILEOPS aborts are applied inline (undo_instant_abort_threshold), +# and RECNO uses the per-relation UNDO fork, not the ATM. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if (($ENV{enable_injection_points} // 'no') ne 'yes') +{ + plan skip_all => 'Injection points not supported by this build'; +} + +my $node = PostgreSQL::Test::Cluster->new('undo_revert_retention'); +$node->init; +$node->append_conf( + 'postgresql.conf', qq( +shared_preload_libraries = 'injection_points' +autovacuum = off +max_prepared_transactions = 10 +wal_level = replica +logical_revert_naptime = 60000 +)); +$node->start; + +if (!$node->check_extension('injection_points')) +{ + plan skip_all => 'Extension injection_points not installed'; +} +if (!$node->check_extension('test_fileops')) +{ + plan skip_all => 'Extension test_fileops not installed'; +} +$node->safe_psql('postgres', + 'CREATE EXTENSION injection_points; CREATE EXTENSION test_fileops;'); + +my $datadir = $node->data_dir; +my $file = "$datadir/retention.dat"; + +# 0644 = 420, 0700 = 448 (decimal) +$node->safe_psql('postgres', + qq{SELECT test_fileops_create_tempfile('retention.dat'); + SELECT test_fileops_chmod('$file', 420);}); + +# Freeze the logical revert worker so it cannot revert+forget the ATM entry +# we are about to create. Without this, the worker reverts within +# milliseconds and there is nothing left to checkpoint/crash. +$node->safe_psql('postgres', + q{SELECT injection_points_attach('logical-revert-before-process', 'wait')}); + +# Create a durable un-reverted ATM entry: PREPARE a FILEOPS chmod, then +# ROLLBACK PREPARED it. ROLLBACK PREPARED calls ATMAddAborted(), which emits +# XLOG_ATM_ABORT and pins the UNDO WAL via the retention chain. +$node->safe_psql('postgres', qq( +BEGIN; +SELECT test_fileops_chmod('$file', 448); +PREPARE TRANSACTION 'p_ret'; +)); +$node->safe_psql('postgres', q{ROLLBACK PREPARED 'p_ret'}); + +# Wait until the worker is provably parked at the injection point, i.e. the +# ATM entry exists and is un-reverted. +$node->poll_query_until('postgres', + q{SELECT count(*) = 1 FROM pg_stat_activity + WHERE wait_event = 'logical-revert-before-process'}) + or die "logical revert worker never reached the freeze point"; + +# ---------------------------------------------------------------------------- +# LEG 1: WAL segment retained across checkpoint +# ---------------------------------------------------------------------------- +my $atm_lsn = $node->safe_psql('postgres', + q{SELECT test_fileops_atm_oldest_lsn()}); +isnt($atm_lsn, '', + 'leg 1: un-reverted ATM entry exists, pinning last_batch_lsn'); + +my $wal_seg = $node->safe_psql('postgres', + q{SELECT pg_walfile_name(test_fileops_atm_oldest_lsn())}); +my $mode = $node->safe_psql('postgres', + qq{SELECT test_fileops_get_mode('$file')}); +is($mode, '448', 'leg 1: chmod still applied (worker frozen, not yet reverted)'); + +$node->safe_psql('postgres', 'CHECKPOINT'); + +ok(-f "$datadir/pg_wal/$wal_seg", + "leg 1: WAL segment $wal_seg holding the abort's last_batch_lsn is " + . "STILL PRESENT after CHECKPOINT (retention chain pinned it)"); + +# ---------------------------------------------------------------------------- +# LEG 2: ATM faithfully reconstructed on restart +# ---------------------------------------------------------------------------- +$node->stop('immediate'); +$node->start; + +# Read the reconstructed ATM state. The 'logical-revert-before-process' +# injection point does NOT survive restart, so the worker is free to run; but +# the ATM entry must exist (reconstructed by atm_redo from XLOG_ATM_ABORT) +# regardless -- reconstruction happens during recovery, before any worker. +# ATMRecoveryFinalize logs it at recovery end, before the worker launches. +my $recovery_log = slurp_file($node->logfile); +like( + $recovery_log, + qr/ATM recovery complete: \d+ entries, [1-9]\d* unreverted, oldest unreverted LSN [0-9A-F]+\/[0-9A-F]+/, + 'leg 2: recovery reconstructed the un-reverted ATM entry ' + . '(ATMGetOldestUnrevertedLSN pins the WAL) -- not merely file presence'); + +# ---------------------------------------------------------------------------- +# Completion: the deferred rollback must actually happen, and the ATM entry +# must then be forgotten. (The mode returns to 0644 and the ATM empties.) +# ---------------------------------------------------------------------------- +$node->poll_query_until('postgres', + qq{SELECT test_fileops_get_mode('$file') = 420}) + or die "aborted work was never reverted after restart -- rollback LOST"; +is($node->safe_psql('postgres', qq{SELECT test_fileops_get_mode('$file')}), + '420', 'completion: deferred rollback finished (0700 -> 0644)'); + +is($node->safe_psql('postgres', q{SELECT test_fileops_atm_oldest_lsn()}), + '', 'completion: ATM entry forgotten after successful revert'); + +# The B2 PANIC must never have fired anywhere in this run. +unlike(slurp_file($node->logfile), qr/retention invariant violated/, + 'no spurious B2 PANIC (recycled-WAL branch not taken)'); + +$node->stop; + +done_testing(); diff --git a/src/test/recovery/t/070_fileops_standby_divergence.pl b/src/test/recovery/t/070_fileops_standby_divergence.pl new file mode 100644 index 0000000000000..d6a14fc7e437b --- /dev/null +++ b/src/test/recovery/t/070_fileops_standby_divergence.pl @@ -0,0 +1,122 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group +# +# B3 regression test: physical-standby reproduction of FILEOPS deferred +# DESTRUCTIVE operations (DELETE, RENAME, RMDIR). +# +# These three ops execute on the primary at commit inside +# FileOpsDoPendingOps() and, before the B3 fix, had no-op redo handlers, so a +# physical standby (which only replays WAL, never running +# FileOpsDoPendingOps) never reproduced them -- its filesystem diverged from +# the primary's. The fix logs each op at commit-EXECUTION time with a real, +# idempotent redo handler, so the standby converges. +# +# This test sets up a streaming physical standby, performs a committed +# deferred DELETE, RENAME, and RMDIR on the primary, waits for catchup, and +# asserts the standby's filesystem MATCHES the primary's. It MUST FAIL on the +# pre-fix (no-op redo) code -- see the header note in fileops.c and the +# "prove non-vacuity" comment below. +# +# All FILEOPS paths are RELATIVE to the data directory, matching every core +# FILEOPS caller (dbcommands.c, tablespace.c): the backend's cwd is PGDATA, so +# a relative path resolves to the right file on both the primary and a standby +# whose absolute data_dir differs. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $primary = PostgreSQL::Test::Cluster->new('fileops_div_primary'); +$primary->init(allows_streaming => 1); +$primary->append_conf("postgresql.conf", qq( +autovacuum = off +wal_level = replica +)); +$primary->start; + +if (!$primary->check_extension('test_fileops')) +{ + plan skip_all => 'Extension test_fileops not installed'; +} +$primary->safe_psql('postgres', 'CREATE EXTENSION test_fileops'); + +my $primary_dir = $primary->data_dir; + +# Fixture, all committed and WAL-logged so the standby has them before we +# start mutating. Use FileOps CREATE (WAL-logged, standby-reproduced) and +# FileOps MKDIR (WAL-logged) rather than raw open(), so the fixture itself +# replicates. Paths are relative to PGDATA. +$primary->safe_psql('postgres', qq( +BEGIN; +SELECT test_fileops_create('div_delete_me.dat', 384); -- 0600 +SELECT test_fileops_create('div_rename_src.dat', 384); +SELECT test_fileops_mkdir('div_rmdir_me', 448); -- 0700 +COMMIT; +)); + +# Take the base backup AFTER the fixture exists so init_from_backup gives the +# standby the fixture files, then stream the mutations. +$primary->backup('backup'); +my $standby = PostgreSQL::Test::Cluster->new('fileops_div_standby'); +$standby->init_from_backup($primary, 'backup', has_streaming => 1); +$standby->start; + +my $standby_dir = $standby->data_dir; + +$primary->wait_for_catchup($standby); + +# Sanity: the fixture replicated to the standby. +ok(-e "$standby_dir/div_delete_me.dat", 'fixture: delete target on standby'); +ok(-e "$standby_dir/div_rename_src.dat", 'fixture: rename source on standby'); +ok(-d "$standby_dir/div_rmdir_me", 'fixture: rmdir target on standby'); + +# Perform the three deferred destructive ops in a committed transaction on the +# primary. Each goes through the FileOps deferred path (at_commit=true) and, +# with the fix, is WAL-logged at commit-execution. +$primary->safe_psql('postgres', qq( +BEGIN; +SELECT test_fileops_delete('div_delete_me.dat'); +SELECT test_fileops_rename('div_rename_src.dat', 'div_rename_dst.dat'); +SELECT test_fileops_rmdir('div_rmdir_me'); +COMMIT; +)); + +# Confirm the primary applied them (the op ran in FileOpsDoPendingOps). +ok(!-e "$primary_dir/div_delete_me.dat", 'primary: delete applied'); +ok(!-e "$primary_dir/div_rename_src.dat", 'primary: rename source gone'); +ok(-e "$primary_dir/div_rename_dst.dat", 'primary: rename dest present'); +ok(!-d "$primary_dir/div_rmdir_me", 'primary: rmdir applied'); + +$primary->wait_for_catchup($standby); + +# The diff the reviewer asked for: the standby's filesystem must MATCH the +# primary's. Pre-fix (no-op redo) the standby still has the original file / +# dir and lacks the renamed file -- these four assertions all fail, which is +# the non-vacuity proof. Post-fix they pass. +ok(!-e "$standby_dir/div_delete_me.dat", + 'B3: deferred DELETE reproduced on standby (file gone)'); +ok(!-e "$standby_dir/div_rename_src.dat", + 'B3: deferred RENAME reproduced on standby (old name gone)'); +ok(-e "$standby_dir/div_rename_dst.dat", + 'B3: deferred RENAME reproduced on standby (new name present)'); +ok(!-d "$standby_dir/div_rmdir_me", + 'B3: deferred RMDIR reproduced on standby (dir gone)'); + +# Idempotent redo across a standby restart: replaying the same records again +# after a crash must not error or diverge. +$standby->stop('immediate'); +$standby->start; +$primary->safe_psql('postgres', 'SELECT txid_current()'); +$primary->wait_for_catchup($standby); +ok(!-e "$standby_dir/div_delete_me.dat", + 'B3: standby end state stable after restart (delete)'); +ok(-e "$standby_dir/div_rename_dst.dat", + 'B3: standby end state stable after restart (rename)'); +ok(!-d "$standby_dir/div_rmdir_me", + 'B3: standby end state stable after restart (rmdir)'); + +$standby->stop; +$primary->stop; + +done_testing(); diff --git a/src/test/regress/expected/create_am.out b/src/test/regress/expected/create_am.out index c1a951572512c..4018dc0bab665 100644 --- a/src/test/regress/expected/create_am.out +++ b/src/test/regress/expected/create_am.out @@ -131,9 +131,10 @@ ERROR: function bthandler must return type table_am_handler SELECT amname, amhandler, amtype FROM pg_am where amtype = 't' ORDER BY 1, 2; amname | amhandler | amtype --------+----------------------+-------- + flux | flux_tableam_handler | t heap | heap_tableam_handler | t heap2 | heap_tableam_handler | t -(2 rows) +(3 rows) -- First create tables employing the new AM using USING -- plain CREATE TABLE diff --git a/src/test/regress/expected/fileops.out b/src/test/regress/expected/fileops.out new file mode 100644 index 0000000000000..eb0b88af3e510 --- /dev/null +++ b/src/test/regress/expected/fileops.out @@ -0,0 +1,203 @@ +-- +-- Tests for transactional file operations (FILEOPS) +-- +-- ================================================================ +-- Section 1: CREATE TABLE with transactional fileops +-- ================================================================ +CREATE TABLE fileops_t1 (id int, data text); +INSERT INTO fileops_t1 VALUES (1, 'created'); +SELECT * FROM fileops_t1; + id | data +----+--------- + 1 | created +(1 row) + +-- Verify the file was created +SELECT pg_relation_filepath('fileops_t1') IS NOT NULL AS has_filepath; + has_filepath +-------------- + t +(1 row) + +-- ================================================================ +-- Section 2: DROP TABLE with transactional fileops +-- ================================================================ +CREATE TABLE fileops_drop_me (id int); +INSERT INTO fileops_drop_me VALUES (1); +DROP TABLE fileops_drop_me; +-- Table should no longer exist +SELECT * FROM fileops_drop_me; +ERROR: relation "fileops_drop_me" does not exist +LINE 1: SELECT * FROM fileops_drop_me; + ^ +-- ================================================================ +-- Section 3: CREATE TABLE in transaction then rollback +-- ================================================================ +BEGIN; +CREATE TABLE fileops_rollback (id int); +INSERT INTO fileops_rollback VALUES (1); +SELECT count(*) FROM fileops_rollback; + count +------- + 1 +(1 row) + +ROLLBACK; +-- Table should not exist after rollback +SELECT * FROM fileops_rollback; +ERROR: relation "fileops_rollback" does not exist +LINE 1: SELECT * FROM fileops_rollback; + ^ +-- ================================================================ +-- Section 4: DROP TABLE in transaction then rollback +-- ================================================================ +CREATE TABLE fileops_keep (id int); +INSERT INTO fileops_keep VALUES (42); +BEGIN; +DROP TABLE fileops_keep; +ROLLBACK; +-- Table should still exist after rollback of DROP +SELECT * FROM fileops_keep; + id +---- + 42 +(1 row) + +-- ================================================================ +-- Section 5: Multiple DDL operations in a single transaction +-- ================================================================ +BEGIN; +CREATE TABLE fileops_multi1 (id int); +CREATE TABLE fileops_multi2 (id int); +CREATE TABLE fileops_multi3 (id int); +INSERT INTO fileops_multi1 VALUES (1); +INSERT INTO fileops_multi2 VALUES (2); +INSERT INTO fileops_multi3 VALUES (3); +DROP TABLE fileops_multi2; +COMMIT; +-- multi1 and multi3 should exist, multi2 should not +SELECT * FROM fileops_multi1; + id +---- + 1 +(1 row) + +SELECT * FROM fileops_multi3; + id +---- + 3 +(1 row) + +SELECT * FROM fileops_multi2; +ERROR: relation "fileops_multi2" does not exist +LINE 1: SELECT * FROM fileops_multi2; + ^ +-- ================================================================ +-- Section 6: DDL with subtransactions +-- ================================================================ +BEGIN; +CREATE TABLE fileops_sp_parent (id int); +INSERT INTO fileops_sp_parent VALUES (1); +SAVEPOINT sp1; +CREATE TABLE fileops_sp_child (id int); +INSERT INTO fileops_sp_child VALUES (2); +ROLLBACK TO sp1; +-- parent table should still exist within the transaction +SELECT * FROM fileops_sp_parent; + id +---- + 1 +(1 row) + +COMMIT; +-- After commit, verify parent exists and child does not +SELECT * FROM fileops_sp_parent; + id +---- + 1 +(1 row) + +SELECT * FROM fileops_sp_child; +ERROR: relation "fileops_sp_child" does not exist +LINE 1: SELECT * FROM fileops_sp_child; + ^ +-- ================================================================ +-- Section 7: TRUNCATE with transactional fileops +-- ================================================================ +CREATE TABLE fileops_trunc (id int); +INSERT INTO fileops_trunc SELECT generate_series(1, 100); +SELECT count(*) FROM fileops_trunc; + count +------- + 100 +(1 row) + +BEGIN; +TRUNCATE fileops_trunc; +SELECT count(*) FROM fileops_trunc; + count +------- + 0 +(1 row) + +ROLLBACK; +-- Should have all rows back after rollback +SELECT count(*) FROM fileops_trunc; + count +------- + 100 +(1 row) + +-- ================================================================ +-- Section 8: CREATE INDEX (also creates files) +-- ================================================================ +CREATE TABLE fileops_idx (id int); +INSERT INTO fileops_idx SELECT generate_series(1, 100); +BEGIN; +CREATE INDEX fileops_idx_id ON fileops_idx(id); +-- Verify index is usable within transaction +SET enable_seqscan = off; +SELECT count(*) FROM fileops_idx WHERE id = 50; + count +------- + 1 +(1 row) + +RESET enable_seqscan; +COMMIT; +-- Index should persist +SELECT count(*) FROM fileops_idx WHERE id = 50; + count +------- + 1 +(1 row) + +-- ================================================================ +-- Section 9: CREATE DATABASE with FILEOPS integration +-- (WAL_LOG strategy uses CreateDirAndVersionFile with FileOps) +-- ================================================================ +CREATE DATABASE fileops_testdb; +-- Verify database exists +SELECT datname FROM pg_database WHERE datname = 'fileops_testdb'; + datname +---------------- + fileops_testdb +(1 row) + +DROP DATABASE fileops_testdb; +-- Verify database is gone +SELECT datname FROM pg_database WHERE datname = 'fileops_testdb'; + datname +--------- +(0 rows) + +-- ================================================================ +-- Cleanup +-- ================================================================ +DROP TABLE fileops_t1; +DROP TABLE fileops_keep; +DROP TABLE fileops_multi1; +DROP TABLE fileops_multi3; +DROP TABLE fileops_sp_parent; +DROP TABLE fileops_trunc; +DROP TABLE fileops_idx; diff --git a/src/test/regress/expected/flux.out b/src/test/regress/expected/flux.out new file mode 100644 index 0000000000000..a55e07f3cf342 --- /dev/null +++ b/src/test/regress/expected/flux.out @@ -0,0 +1,129 @@ +-- +-- FLUX table access method: core functional + index-integrity tests. +-- +-- The critical property (the one RECNO's stable-TID + RowID model exists to +-- work around, and that FLUX satisfies without any index-AM change) is that +-- index scans, sequential scans, and bitmap scans agree after key-changing +-- UPDATEs, including A -> B -> A recurrences. FLUX achieves this by doing a +-- non-in-place (new-TID) UPDATE whenever an indexed column changes, so +-- secondary indexes are maintained by the standard heap-TID path. +-- +CREATE TABLE flux_basic (id int, k int, v text) USING flux; +INSERT INTO flux_basic SELECT g, g, 'v' || g FROM generate_series(1, 20) g; +CREATE INDEX flux_basic_k_idx ON flux_basic (k); +-- non-key UPDATE (in place): TID and index unchanged +UPDATE flux_basic SET v = 'updated' WHERE id = 5; +SELECT id, k, v FROM flux_basic WHERE id = 5; + id | k | v +----+---+--------- + 5 | 5 | updated +(1 row) + +-- key-changing UPDATEs (out of place): old index entry dies, new one inserted +UPDATE flux_basic SET k = 105 WHERE id = 5; +UPDATE flux_basic SET k = 106 WHERE id = 6; +-- A -> B -> A recurrence on an indexed key +UPDATE flux_basic SET k = 999 WHERE id = 7; +UPDATE flux_basic SET k = 7 WHERE id = 7; +DELETE FROM flux_basic WHERE id = 20; +-- The gate: idxscan == seqscan == bitmapscan. +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_basic; + seq_count | seq_sumk +-----------+---------- + 19 | 390 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_basic WHERE k > -2147483648; + idx_count | idx_sumk +-----------+---------- + 19 | 390 +(1 row) + +SELECT count(*) AS idx_k_eq7 FROM flux_basic WHERE k = 7; -- 1, not 2 + idx_k_eq7 +----------- + 1 +(1 row) + +SELECT count(*) AS idx_k_eq5 FROM flux_basic WHERE k = 5; -- 0 (moved to 105) + idx_k_eq5 +----------- + 0 +(1 row) + +SELECT count(*) AS idx_k_eq105 FROM flux_basic WHERE k = 105; -- 1 + idx_k_eq105 +------------- + 1 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = off; SET enable_bitmapscan = on; +SELECT count(*) AS bmp_count, sum(k) AS bmp_sumk FROM flux_basic WHERE k > -2147483648; + bmp_count | bmp_sumk +-----------+---------- + 19 | 390 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- VACUUM then amcheck (heapallindexed): no error means the index is consistent +-- with the heap after key churn. +VACUUM flux_basic; +CREATE EXTENSION IF NOT EXISTS amcheck; +SELECT bt_index_check('flux_basic_k_idx'::regclass, true); + bt_index_check +---------------- + +(1 row) + +-- ROLLBACK restores the old value (non-key) and old key + index entry (key). +BEGIN; +UPDATE flux_basic SET v = 'should_not_persist' WHERE id = 10; +ROLLBACK; +SELECT v FROM flux_basic WHERE id = 10; + v +----- + v10 +(1 row) + +BEGIN; +UPDATE flux_basic SET k = 5000 WHERE id = 11; +ROLLBACK; +SELECT k FROM flux_basic WHERE id = 11; + k +---- + 11 +(1 row) + +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_k_eq11 FROM flux_basic WHERE k = 11; -- 1 + idx_k_eq11 +------------ + 1 +(1 row) + +SELECT count(*) AS idx_k_eq5000 FROM flux_basic WHERE k = 5000; -- 0 + idx_k_eq5000 +-------------- + 0 +(1 row) + +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; +-- TOAST: a >8KB value round-trips exactly and the relation gets a TOAST table. +CREATE TABLE flux_toast (id int, big text) USING flux; +INSERT INTO flux_toast VALUES (1, repeat('X', 100000)); +SELECT id, length(big) AS len, (big = repeat('X', 100000)) AS exact FROM flux_toast; + id | len | exact +----+--------+------- + 1 | 100000 | t +(1 row) + +SELECT reltoastrelid <> 0 AS has_toast_table FROM pg_class WHERE relname = 'flux_toast'; + has_toast_table +----------------- + t +(1 row) + +DROP TABLE flux_basic; +DROP TABLE flux_toast; diff --git a/src/test/regress/expected/psql.out b/src/test/regress/expected/psql.out index 42635a56a06c3..249fa21fe64dd 100644 --- a/src/test/regress/expected/psql.out +++ b/src/test/regress/expected/psql.out @@ -5191,13 +5191,14 @@ List of access methods --------+------- brin | Index btree | Index + flux | Table gin | Index gist | Index hash | Index heap | Table heap2 | Table spgist | Index -(8 rows) +(9 rows) \dA * List of access methods @@ -5205,13 +5206,14 @@ List of access methods --------+------- brin | Index btree | Index + flux | Table gin | Index gist | Index hash | Index heap | Table heap2 | Table spgist | Index -(8 rows) +(9 rows) \dA h* List of access methods @@ -5241,13 +5243,14 @@ List of access methods --------+-------+----------------------+---------------------------------------- brin | Index | brinhandler | block range index (BRIN) access method btree | Index | bthandler | b-tree index access method + flux | Table | flux_tableam_handler | flux table access method gin | Index | ginhandler | GIN index access method gist | Index | gisthandler | GiST index access method hash | Index | hashhandler | hash index access method heap | Table | heap_tableam_handler | heap table access method heap2 | Table | heap_tableam_handler | spgist | Index | spghandler | SP-GiST index access method -(8 rows) +(9 rows) \dA+ * List of access methods @@ -5255,13 +5258,14 @@ List of access methods --------+-------+----------------------+---------------------------------------- brin | Index | brinhandler | block range index (BRIN) access method btree | Index | bthandler | b-tree index access method + flux | Table | flux_tableam_handler | flux table access method gin | Index | ginhandler | GIN index access method gist | Index | gisthandler | GiST index access method hash | Index | hashhandler | hash index access method heap | Table | heap_tableam_handler | heap table access method heap2 | Table | heap_tableam_handler | spgist | Index | spghandler | SP-GiST index access method -(8 rows) +(9 rows) \dA+ h* List of access methods diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule index 8fa0a6c47fb30..225f221d6a06d 100644 --- a/src/test/regress/parallel_schedule +++ b/src/test/regress/parallel_schedule @@ -63,6 +63,11 @@ test: sanity_check # ---------- test: select_into select_distinct select_distinct_on select_implicit select_having subselect union case join aggregates transactions random portals arrays btree_index hash_index update delete namespace prepared_xacts +# ---------- +# Transactional file operations tests +# ---------- +test: fileops + # ---------- # Another group of parallel tests # ---------- @@ -72,6 +77,7 @@ test: brin gin gist spgist privileges init_privs security_label collate matview # Additional BRIN tests # ---------- test: brin_bloom brin_multi +test: flux # ---------- # Another group of parallel tests diff --git a/src/test/regress/regress.c b/src/test/regress/regress.c index 14d301b3499ec..ffc4cc58893e2 100644 --- a/src/test/regress/regress.c +++ b/src/test/regress/regress.c @@ -1306,9 +1306,13 @@ test_relpath(PG_FUNCTION_ARGS) if ((int) ceil(log10(MAX_BACKENDS)) != PROCNUMBER_CHARS) elog(WARNING, "mismatch between MAX_BACKENDS and PROCNUMBER_CHARS"); - /* verify that the max-length relpath is generated ok */ + /* + * Verify that the max-length relpath is generated ok. Use the fork with + * the longest name (currently RELUNDO_FORKNUM, "relundo"), since that is + * what REL_PATH_STR_MAXLEN budgets FORKNAMECHARS for. + */ rpath = GetRelationPath(OID_MAX, OID_MAX, OID_MAX, MAX_BACKENDS - 1, - INIT_FORKNUM); + RELUNDO_FORKNUM); if (strlen(rpath.str) != REL_PATH_STR_MAXLEN) elog(WARNING, "maximum length relpath is if length %zu instead of %zu", diff --git a/src/test/regress/sql/fileops.sql b/src/test/regress/sql/fileops.sql new file mode 100644 index 0000000000000..4a86f7324d8d2 --- /dev/null +++ b/src/test/regress/sql/fileops.sql @@ -0,0 +1,154 @@ +-- +-- Tests for transactional file operations (FILEOPS) +-- + +-- ================================================================ +-- Section 1: CREATE TABLE with transactional fileops +-- ================================================================ + +CREATE TABLE fileops_t1 (id int, data text); +INSERT INTO fileops_t1 VALUES (1, 'created'); +SELECT * FROM fileops_t1; + +-- Verify the file was created +SELECT pg_relation_filepath('fileops_t1') IS NOT NULL AS has_filepath; + +-- ================================================================ +-- Section 2: DROP TABLE with transactional fileops +-- ================================================================ + +CREATE TABLE fileops_drop_me (id int); +INSERT INTO fileops_drop_me VALUES (1); + +DROP TABLE fileops_drop_me; + +-- Table should no longer exist +SELECT * FROM fileops_drop_me; + +-- ================================================================ +-- Section 3: CREATE TABLE in transaction then rollback +-- ================================================================ + +BEGIN; +CREATE TABLE fileops_rollback (id int); +INSERT INTO fileops_rollback VALUES (1); +SELECT count(*) FROM fileops_rollback; +ROLLBACK; + +-- Table should not exist after rollback +SELECT * FROM fileops_rollback; + +-- ================================================================ +-- Section 4: DROP TABLE in transaction then rollback +-- ================================================================ + +CREATE TABLE fileops_keep (id int); +INSERT INTO fileops_keep VALUES (42); + +BEGIN; +DROP TABLE fileops_keep; +ROLLBACK; + +-- Table should still exist after rollback of DROP +SELECT * FROM fileops_keep; + +-- ================================================================ +-- Section 5: Multiple DDL operations in a single transaction +-- ================================================================ + +BEGIN; +CREATE TABLE fileops_multi1 (id int); +CREATE TABLE fileops_multi2 (id int); +CREATE TABLE fileops_multi3 (id int); +INSERT INTO fileops_multi1 VALUES (1); +INSERT INTO fileops_multi2 VALUES (2); +INSERT INTO fileops_multi3 VALUES (3); +DROP TABLE fileops_multi2; +COMMIT; + +-- multi1 and multi3 should exist, multi2 should not +SELECT * FROM fileops_multi1; +SELECT * FROM fileops_multi3; +SELECT * FROM fileops_multi2; + +-- ================================================================ +-- Section 6: DDL with subtransactions +-- ================================================================ + +BEGIN; +CREATE TABLE fileops_sp_parent (id int); +INSERT INTO fileops_sp_parent VALUES (1); + +SAVEPOINT sp1; +CREATE TABLE fileops_sp_child (id int); +INSERT INTO fileops_sp_child VALUES (2); +ROLLBACK TO sp1; + +-- parent table should still exist within the transaction +SELECT * FROM fileops_sp_parent; +COMMIT; + +-- After commit, verify parent exists and child does not +SELECT * FROM fileops_sp_parent; +SELECT * FROM fileops_sp_child; + +-- ================================================================ +-- Section 7: TRUNCATE with transactional fileops +-- ================================================================ + +CREATE TABLE fileops_trunc (id int); +INSERT INTO fileops_trunc SELECT generate_series(1, 100); +SELECT count(*) FROM fileops_trunc; + +BEGIN; +TRUNCATE fileops_trunc; +SELECT count(*) FROM fileops_trunc; +ROLLBACK; + +-- Should have all rows back after rollback +SELECT count(*) FROM fileops_trunc; + +-- ================================================================ +-- Section 8: CREATE INDEX (also creates files) +-- ================================================================ + +CREATE TABLE fileops_idx (id int); +INSERT INTO fileops_idx SELECT generate_series(1, 100); + +BEGIN; +CREATE INDEX fileops_idx_id ON fileops_idx(id); +-- Verify index is usable within transaction +SET enable_seqscan = off; +SELECT count(*) FROM fileops_idx WHERE id = 50; +RESET enable_seqscan; +COMMIT; + +-- Index should persist +SELECT count(*) FROM fileops_idx WHERE id = 50; + +-- ================================================================ +-- Section 9: CREATE DATABASE with FILEOPS integration +-- (WAL_LOG strategy uses CreateDirAndVersionFile with FileOps) +-- ================================================================ + +CREATE DATABASE fileops_testdb; + +-- Verify database exists +SELECT datname FROM pg_database WHERE datname = 'fileops_testdb'; + +DROP DATABASE fileops_testdb; + +-- Verify database is gone +SELECT datname FROM pg_database WHERE datname = 'fileops_testdb'; + +-- ================================================================ +-- Cleanup +-- ================================================================ + +DROP TABLE fileops_t1; +DROP TABLE fileops_keep; +DROP TABLE fileops_multi1; +DROP TABLE fileops_multi3; +DROP TABLE fileops_sp_parent; +DROP TABLE fileops_trunc; +DROP TABLE fileops_idx; diff --git a/src/test/regress/sql/flux.sql b/src/test/regress/sql/flux.sql new file mode 100644 index 0000000000000..776b99608ab56 --- /dev/null +++ b/src/test/regress/sql/flux.sql @@ -0,0 +1,69 @@ +-- +-- FLUX table access method: core functional + index-integrity tests. +-- +-- The critical property (the one RECNO's stable-TID + RowID model exists to +-- work around, and that FLUX satisfies without any index-AM change) is that +-- index scans, sequential scans, and bitmap scans agree after key-changing +-- UPDATEs, including A -> B -> A recurrences. FLUX achieves this by doing a +-- non-in-place (new-TID) UPDATE whenever an indexed column changes, so +-- secondary indexes are maintained by the standard heap-TID path. +-- + +CREATE TABLE flux_basic (id int, k int, v text) USING flux; +INSERT INTO flux_basic SELECT g, g, 'v' || g FROM generate_series(1, 20) g; +CREATE INDEX flux_basic_k_idx ON flux_basic (k); + +-- non-key UPDATE (in place): TID and index unchanged +UPDATE flux_basic SET v = 'updated' WHERE id = 5; +SELECT id, k, v FROM flux_basic WHERE id = 5; + +-- key-changing UPDATEs (out of place): old index entry dies, new one inserted +UPDATE flux_basic SET k = 105 WHERE id = 5; +UPDATE flux_basic SET k = 106 WHERE id = 6; +-- A -> B -> A recurrence on an indexed key +UPDATE flux_basic SET k = 999 WHERE id = 7; +UPDATE flux_basic SET k = 7 WHERE id = 7; + +DELETE FROM flux_basic WHERE id = 20; + +-- The gate: idxscan == seqscan == bitmapscan. +SET enable_seqscan = on; SET enable_indexscan = off; SET enable_bitmapscan = off; +SELECT count(*) AS seq_count, sum(k) AS seq_sumk FROM flux_basic; +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_count, sum(k) AS idx_sumk FROM flux_basic WHERE k > -2147483648; +SELECT count(*) AS idx_k_eq7 FROM flux_basic WHERE k = 7; -- 1, not 2 +SELECT count(*) AS idx_k_eq5 FROM flux_basic WHERE k = 5; -- 0 (moved to 105) +SELECT count(*) AS idx_k_eq105 FROM flux_basic WHERE k = 105; -- 1 +SET enable_seqscan = off; SET enable_indexscan = off; SET enable_bitmapscan = on; +SELECT count(*) AS bmp_count, sum(k) AS bmp_sumk FROM flux_basic WHERE k > -2147483648; +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; + +-- VACUUM then amcheck (heapallindexed): no error means the index is consistent +-- with the heap after key churn. +VACUUM flux_basic; +CREATE EXTENSION IF NOT EXISTS amcheck; +SELECT bt_index_check('flux_basic_k_idx'::regclass, true); + +-- ROLLBACK restores the old value (non-key) and old key + index entry (key). +BEGIN; +UPDATE flux_basic SET v = 'should_not_persist' WHERE id = 10; +ROLLBACK; +SELECT v FROM flux_basic WHERE id = 10; + +BEGIN; +UPDATE flux_basic SET k = 5000 WHERE id = 11; +ROLLBACK; +SELECT k FROM flux_basic WHERE id = 11; +SET enable_seqscan = off; SET enable_indexscan = on; SET enable_bitmapscan = off; +SELECT count(*) AS idx_k_eq11 FROM flux_basic WHERE k = 11; -- 1 +SELECT count(*) AS idx_k_eq5000 FROM flux_basic WHERE k = 5000; -- 0 +RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; + +-- TOAST: a >8KB value round-trips exactly and the relation gets a TOAST table. +CREATE TABLE flux_toast (id int, big text) USING flux; +INSERT INTO flux_toast VALUES (1, repeat('X', 100000)); +SELECT id, length(big) AS len, (big = repeat('X', 100000)) AS exact FROM flux_toast; +SELECT reltoastrelid <> 0 AS has_toast_table FROM pg_class WHERE relname = 'flux_toast'; + +DROP TABLE flux_basic; +DROP TABLE flux_toast; diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 85d989f395d41..5c69789d02d64 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -2823,6 +2823,7 @@ Selectivity SelfJoinCandidate SemTPadded SemiAntiJoinFactors +SeqLock SeqScan SeqScanInstrumentation SeqScanState @@ -4525,6 +4526,7 @@ xl_multixact_truncate xl_overwrite_contrecord xl_parameter_change xl_relmap_update +xl_relundo_truncate xl_replorigin_drop xl_replorigin_set xl_restore_point @@ -4587,3 +4589,18 @@ yyscan_t z_stream z_streamp zic_t +PendingFileOp +PendingFileOpType +xl_fileops_chmod +xl_fileops_chown +xl_fileops_create +xl_fileops_delete +xl_fileops_link +xl_fileops_mkdir +xl_fileops_removexattr +xl_fileops_rename +xl_fileops_rmdir +xl_fileops_setxattr +xl_fileops_symlink +xl_fileops_truncate +xl_fileops_write