Skip to content

Commit c051d68

Browse files
Yingjie WangYingjie Wang
authored andcommitted
hardening(1.22): v1.0.4 — directory naming + allocator idempotency + L16 drop + WAL ABI lock
User 复审 v1.0.3 ship 后(commit dba746a)发现 4 项 finding 全 valid: 3 个 P1 + 1 个 P2。本 commit 一次 ship 4 类修订。 P1-1 (directory naming separation): v1.0.3 cluster_undo_path_resolve / ensure_undo_instance_subdir / build_undo_segment_path 直接用 owner_instance 当目录名,但 initdb 用 instance_0/(目录名 = cluster_node_id 0)。两套约定: - initdb: 目录名 cluster_node_id (0..127); header 值 owner_instance (1..128 = node_id + 1) - allocator (v1.0.3): 目录名 owner_instance (1..128) — 错 单节点默认 (cluster_node_id=0, owner=1) → allocator 写到 pg_undo/instance_1/ 与 seed 段 instance_0/ 完全脱节。 v1.0.4 修法: 目录名 = cluster_node_id = owner_instance - 1; header / WAL payload 保留 owner_instance (sentinel 0 = unallocated)。 Allocator 现在写 instance_0/seg_N.dat 与 seed 同子目录。 代码改动: 3 处 path snprintf 加 (owner_instance - 1) 转目录编号; Assert 锁 owner_instance ∈ [1, MAX]。 P1-2 (allocator unconditional ftruncate): v1.0.3 只在 created=true 时 ftruncate。但 allocator crash 在 O_CREAT 与 ftruncate 之间留下 orphan zero-size 文件 → 下次 allocate 看到 existing file → created=false → 跳过 ftruncate → 文件永远不到 64MB → segment 中段 EOF。 v1.0.4 修法: ftruncate 无条件触发(idempotent);加 fsync 父目录 (与 redo handler 6 步幂等模式对称)。 P1-3 (drop TAP L16 + remove broken skip_all): L16 v1.0.3 假设删除 seed seg_0.dat 后重启 WAL replay 能重建。 错: initdb 写 seed 直接 pg_pwrite 不 emit WAL (类 pg_control, initial cluster image),只有 cluster_undo_segment_allocate 才 emit XLOG_UNDO_SEGMENT_INIT。L16 不可能 pass。 另发现: 070_undo_tablespace.pl 顶部 plan skip_all 检查 pg_config --configure 不工作,导致 CI run 25416097363 整个 test 文件 "skipped: cluster build not enabled",L16 没真跑过 — CI 绿是假阳性。其他 TAP (060-069) 没此检查,直接用 Makefile- level 配置过滤。 v1.0.4 修法: - 删除 broken plan skip_all 检查(让 070 在 CI 真跑) - 删 L16 4 个 assertions + 加 docstring 说明 seed 是 base image 不靠 WAL (类 pg_control corruption 必须 base-backup restore);redo 幂等性 defer 到 cluster_unit harness 通过 direct call cluster_undo_segment_allocate (Stage 1.22 不 暴露 SQL UDF,等 feature-117) P2-1 (WAL ABI 文档错 + StaticAssertDecl): cluster_undo_xlog.h 注释 "payload = 6-byte fixed header" 错; 实际 struct 因 segment_id uint32 alignment 落 offset 8 → size 12 byte。代码用 sizeof(hdr) emit/redo 一致,但文档 + cross-version replay invariant 缺。 v1.0.4 修法: - 注释改 "12-byte fixed header" - 加 StaticAssertDecl(sizeof == 12) + offsetof(segment_id) == 8 - Lessons SSOT L45 扩展(WAL ABI struct 也是 on-disk struct, 需 per-field C alignment + StaticAssertDecl 锁) Verification: - PG 219/219 disable mode - cluster_unit 31/31 binaries (含 21 spec-1.22 tests) - cluster_regress 2/2 - L38 hygiene: clang-format 0 violations / comment-headers 0 errors / cppcheck baseline 47 不变 - initdb spot-check: pg_undo/instance_0/seg_0.dat (单节点默认正确 落 instance_0/ 与 seed 一致) - spec-1.22 ## Hardening v1.0.4 amend (pgrac amend pending separate commit)
1 parent dba746a commit c051d68

4 files changed

Lines changed: 133 additions & 78 deletions

File tree

src/backend/cluster/storage/cluster_undo_alloc.c

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,33 @@
5151
* cluster_undo_path_resolve
5252
*
5353
* Pure path builder (no I/O, no errors apart from buffer overflow).
54+
*
55+
* Hardening v1.0.4 P1-1 (directory naming separation):
56+
* - The owner_instance VALUE in headers / WAL payloads is
57+
* (cluster_node_id + 1), so 0 stays the "unallocated" sentinel.
58+
* - The DIRECTORY NAME on disk uses cluster_node_id directly
59+
* (= owner_instance - 1) so that single-node default
60+
* (cluster_node_id = 0) lays out at pg_undo/instance_0/...
61+
* matching the initdb seed segment (also at instance_0/).
62+
* Otherwise allocator segments would land at instance_1/ and
63+
* split from the seed, breaking the per-instance subdir
64+
* invariant.
65+
*
66+
* Caller MUST pass owner_instance in [1, UNDO_OWNER_INSTANCE_MAX];
67+
* the assert catches sentinel-0 misuse.
5468
*/
5569
int
56-
cluster_undo_path_resolve(uint8 instance, uint32 segment_id, char *buf, size_t buf_size)
70+
cluster_undo_path_resolve(uint8 owner_instance, uint32 segment_id, char *buf, size_t buf_size)
5771
{
5872
int ret;
5973

6074
if (buf == NULL || buf_size == 0)
6175
return -1;
76+
Assert(owner_instance >= 1 && owner_instance <= UNDO_OWNER_INSTANCE_MAX);
6277

63-
ret = snprintf(buf, buf_size, "%s/pg_undo/instance_%u/seg_%u.dat", DataDir, (unsigned)instance,
64-
(unsigned)segment_id);
78+
/* directory uses cluster_node_id (= owner_instance - 1) */
79+
ret = snprintf(buf, buf_size, "%s/pg_undo/instance_%u/seg_%u.dat", DataDir,
80+
(unsigned)(owner_instance - 1), (unsigned)segment_id);
6581
if (ret < 0 || (size_t)ret >= buf_size)
6682
return -1;
6783
return 0;
@@ -111,16 +127,21 @@ open_or_create_segment(const char *path, bool *out_created)
111127
* rejects it, but if a future caller does, mkdir() handles it.
112128
*/
113129
static void
114-
ensure_instance_subdir(uint8 instance)
130+
ensure_instance_subdir(uint8 owner_instance)
115131
{
116132
char path[MAXPGPATH];
117133
int ret;
118134

119-
ret = snprintf(path, sizeof(path), "%s/pg_undo/instance_%u", DataDir, (unsigned)instance);
135+
Assert(owner_instance >= 1 && owner_instance <= UNDO_OWNER_INSTANCE_MAX);
136+
137+
/* directory uses cluster_node_id (= owner_instance - 1); see
138+
* cluster_undo_path_resolve docstring. */
139+
ret = snprintf(path, sizeof(path), "%s/pg_undo/instance_%u", DataDir,
140+
(unsigned)(owner_instance - 1));
120141
if (ret < 0 || (size_t)ret >= sizeof(path))
121-
ereport(ERROR,
122-
(errcode(ERRCODE_NAME_TOO_LONG),
123-
errmsg("undo instance subdir path too long: instance=%u", (unsigned)instance)));
142+
ereport(ERROR, (errcode(ERRCODE_NAME_TOO_LONG),
143+
errmsg("undo instance subdir path too long: owner_instance=%u",
144+
(unsigned)owner_instance)));
124145

125146
if (mkdir(path, S_IRWXU) < 0 && errno != EEXIST)
126147
ereport(ERROR, (errcode_for_file_access(),
@@ -171,24 +192,30 @@ cluster_undo_segment_allocate(uint32 segment_id, uint8 owner_instance)
171192
ensure_instance_subdir(owner_instance);
172193

173194
fd = open_or_create_segment(path, &created);
195+
(void)created; /* tracked for logging/observability; ftruncate is unconditional */
174196

175197
cluster_undo_segment_make_header_bytes(segment_id, owner_instance, page.data);
176198

177199
/*
178-
* Extend file to UNDO_SEGMENT_SIZE_BYTES on first creation. ftruncate
179-
* gives a sparse file (tail bytes read as zero); the allocator path
180-
* writes actual undo records lazily later.
200+
* Hardening v1.0.4 P1-2: unconditional ftruncate.
201+
*
202+
* v1.0.3 only ftruncate'd when created=true, but a crash between
203+
* O_CREAT and ftruncate leaves a partial / zero-size orphan file.
204+
* The next allocate call sees the existing file, so created=false,
205+
* and the file would never reach UNDO_SEGMENT_SIZE_BYTES -- segment
206+
* tail accesses would EOF. ftruncate is idempotent (shrink-to-same
207+
* and extend-to-target are both no-ops when size already matches),
208+
* so unconditional is safe and self-healing. Mirrors the redo
209+
* handler's 6-step idempotent pattern (cluster_undo_xlog.c).
181210
*/
182-
if (created) {
183-
if (ftruncate(fd, (off_t)UNDO_SEGMENT_SIZE_BYTES) != 0) {
184-
int save_errno = errno;
185-
186-
close(fd);
187-
errno = save_errno;
188-
ereport(ERROR, (errcode_for_file_access(),
189-
errmsg("could not extend undo segment file \"%s\" to %d bytes: %m",
190-
path, UNDO_SEGMENT_SIZE_BYTES)));
191-
}
211+
if (ftruncate(fd, (off_t)UNDO_SEGMENT_SIZE_BYTES) != 0) {
212+
int save_errno = errno;
213+
214+
close(fd);
215+
errno = save_errno;
216+
ereport(ERROR, (errcode_for_file_access(),
217+
errmsg("could not extend undo segment file \"%s\" to %d bytes: %m", path,
218+
UNDO_SEGMENT_SIZE_BYTES)));
192219
}
193220

194221
written = pg_pwrite(fd, page.data, BLCKSZ, 0);
@@ -216,6 +243,21 @@ cluster_undo_segment_allocate(uint32 segment_id, uint8 owner_instance)
216243
ereport(ERROR, (errcode_for_file_access(),
217244
errmsg("could not close undo segment file \"%s\": %m", path)));
218245

246+
/*
247+
* Hardening v1.0.4 P1-2: fsync parent directory after file creation /
248+
* truncate. Required for the create case so the dirent is durable;
249+
* harmless for the already-exists case. Mirrors redo handler.
250+
*/
251+
{
252+
char dir[MAXPGPATH];
253+
int dret;
254+
255+
dret = snprintf(dir, sizeof(dir), "%s/pg_undo/instance_%u", DataDir,
256+
(unsigned)(owner_instance - 1));
257+
if (dret >= 0 && (size_t)dret < sizeof(dir))
258+
fsync_fname(dir, true);
259+
}
260+
219261
/*
220262
* Emit WAL record so crash recovery can recreate the header (the
221263
* segment file itself is created earlier in this function; a crash

src/backend/cluster/storage/cluster_undo_xlog.c

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,23 @@
6060
* only instance_0 from initdb; redo path may need other instance
6161
* subdirs on standbys / cross-instance crash recovery).
6262
*
63+
* Hardening v1.0.4 P1-1: directory naming uses cluster_node_id
64+
* (= owner_instance - 1) so that single-node default
65+
* (cluster_node_id = 0, owner_instance = 1) lands at instance_0/
66+
* matching the initdb seed. See cluster_undo_alloc.c
67+
* cluster_undo_path_resolve docstring for full rationale.
68+
*
6369
* Returns 0 on success, -1 on path-too-long. Caller supplies
6470
* buf with capacity >= MAXPGPATH.
6571
*/
6672
static int
67-
build_undo_segment_path(uint8 instance, uint32 segment_id, char *buf, size_t buf_size)
73+
build_undo_segment_path(uint8 owner_instance, uint32 segment_id, char *buf, size_t buf_size)
6874
{
6975
int ret;
7076

71-
ret = snprintf(buf, buf_size, "%s/pg_undo/instance_%u/seg_%u.dat", DataDir, (unsigned)instance,
72-
(unsigned)segment_id);
77+
Assert(owner_instance >= 1 && owner_instance <= UNDO_OWNER_INSTANCE_MAX);
78+
ret = snprintf(buf, buf_size, "%s/pg_undo/instance_%u/seg_%u.dat", DataDir,
79+
(unsigned)(owner_instance - 1), (unsigned)segment_id);
7380
if (ret < 0 || (size_t)ret >= buf_size)
7481
return -1;
7582
return 0;
@@ -88,15 +95,19 @@ build_undo_segment_path(uint8 instance, uint32 segment_id, char *buf, size_t buf
8895
* established by initdb (D4) and assumed to exist.
8996
*/
9097
static void
91-
ensure_undo_instance_subdir(uint8 instance)
98+
ensure_undo_instance_subdir(uint8 owner_instance)
9299
{
93100
char path[MAXPGPATH];
94101
int ret;
95102

96-
ret = snprintf(path, sizeof(path), "%s/pg_undo/instance_%u", DataDir, (unsigned)instance);
103+
Assert(owner_instance >= 1 && owner_instance <= UNDO_OWNER_INSTANCE_MAX);
104+
105+
/* directory uses cluster_node_id (= owner_instance - 1) per Hardening v1.0.4 P1-1 */
106+
ret = snprintf(path, sizeof(path), "%s/pg_undo/instance_%u", DataDir,
107+
(unsigned)(owner_instance - 1));
97108
if (ret < 0 || (size_t)ret >= sizeof(path))
98-
ereport(PANIC,
99-
(errmsg("undo instance subdir path too long: instance=%u", (unsigned)instance)));
109+
ereport(PANIC, (errmsg("undo instance subdir path too long: owner_instance=%u",
110+
(unsigned)owner_instance)));
100111

101112
if (mkdir(path, S_IRWXU) != 0 && errno != EEXIST)
102113
ereport(PANIC, (errcode_for_file_access(),
@@ -245,8 +256,9 @@ cluster_undo_redo_segment_init(XLogReaderState *record)
245256
char dir[MAXPGPATH];
246257
int dret;
247258

259+
/* directory uses cluster_node_id (= owner_instance - 1) per Hardening v1.0.4 P1-1 */
248260
dret = snprintf(dir, sizeof(dir), "%s/pg_undo/instance_%u", DataDir,
249-
(unsigned)hdr->instance);
261+
(unsigned)(hdr->instance - 1));
250262
if (dret >= 0 && (size_t)dret < sizeof(dir))
251263
fsync_fname(dir, true);
252264
}

src/include/cluster/storage/cluster_undo_xlog.h

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,23 +55,45 @@
5555
/*
5656
* On-disk WAL payload layout for XLOG_UNDO_SEGMENT_INIT.
5757
*
58-
* payload = 6-byte fixed header + BLCKSZ-byte page image.
58+
* payload = 12-byte fixed header + BLCKSZ-byte page image.
5959
* Stored sequentially in a single XLogRegisterData block.
6060
*
6161
* v0.2 P1-A: payload uses a custom path-based identification
6262
* (instance + segment_id) rather than RelFileLocator/BlockNumber
6363
* because pg_undo/ files live outside PG's relfilenode namespace.
6464
* redo handler resolves the path via cluster_undo_path_resolve()
6565
* and pwrites directly.
66+
*
67+
* Hardening v1.0.4 P2-1: header is 12 bytes (not the "6-byte"
68+
* v1.0.3 doc claimed); _pad[1] + _pad2[2] add up to 5 padding bytes
69+
* that round segment_id to its uint32 8-byte alignment slot
70+
* (1 + 1 + 4 + 4 = 10 bytes data; layout pads to offset 8 for
71+
* segment_id, total struct size 12). StaticAssertDecl below locks
72+
* the on-disk WAL ABI; future maintainers see the assert fire if
73+
* a field add / reorder breaks cross-version replay.
6674
*/
6775
typedef struct xl_cluster_undo_segment_init {
68-
uint8 instance; /* owner instance (1..128); spec-1.21 Q5 */
69-
uint8 _pad[1]; /* alignment pad */
70-
uint16 _pad2[2]; /* alignment pad to 8 bytes */
76+
uint8 instance; /* offset 0; 1 byte; owner instance (1..128); spec-1.21 Q5 */
77+
uint8 _pad[1]; /* offset 1; 1 byte; alignment pad */
78+
uint16 _pad2[2]; /* offset 2; 4 bytes; pad up to uint32 alignment */
7179
uint32 segment_id; /* offset 8; 4 bytes */
7280
/* Followed by char page_image[BLCKSZ] (segment header block 0). */
7381
} xl_cluster_undo_segment_init;
7482

83+
/*
84+
* WAL ABI invariants (Hardening v1.0.4 P2-1; lessons SSOT L45 ext).
85+
*
86+
* StaticAssertDecl locks struct size + segment_id offset so cross-
87+
* version replay (mixed 1.22 / 1.23+ binaries) catches incompatible
88+
* field add / reorder at compile time.
89+
*/
90+
StaticAssertDecl(sizeof(xl_cluster_undo_segment_init) == 12,
91+
"xl_cluster_undo_segment_init must be exactly 12 bytes "
92+
"(WAL ABI lock; Hardening v1.0.4 P2-1)");
93+
StaticAssertDecl(offsetof(xl_cluster_undo_segment_init, segment_id) == 8,
94+
"xl_cluster_undo_segment_init.segment_id must be at offset 8 "
95+
"(uint32 8-byte alignment slot; Hardening v1.0.4 P2-1)");
96+
7597

7698
/*
7799
* Public API.

src/test/cluster_tap/t/070_undo_tablespace.pl

Lines changed: 24 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,12 @@
5252
use File::Path qw(make_path);
5353
use File::Spec;
5454

55-
# Skip if cluster build is disabled.
56-
if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bcluster\b/)
57-
{
58-
if (`@{[$ENV{PG_CONFIG} || 'pg_config']} --configure 2>/dev/null` !~ /--enable-pgrac-cluster/)
59-
{
60-
plan skip_all => 'cluster build not enabled';
61-
}
62-
}
63-
55+
# Hardening v1.0.4 P1-3: removed plan skip_all check.
56+
# v1.0.3 had a "skip if --enable-pgrac-cluster not configured" guard
57+
# that was triggering "skipped: cluster build not enabled" on every
58+
# CI run, masking real test failures. Other cluster_tap tests
59+
# (060-069) don't gate on this -- the cluster_tap Makefile-level
60+
# check is sufficient.
6461

6562
my $node = PostgreSQL::Test::Cluster->new('spec122_undo_tbs');
6663
$node->init;
@@ -243,43 +240,25 @@
243240

244241

245242
# ----------
246-
# L16: redo handler idempotent file/dir/size restore (Hardening v1.0.3 P1-B).
247-
# Stop server -> delete seg_0.dat (simulate operator error / standby-with-no-
248-
# allocator-history) -> start server. Server should NOT FATAL; WAL replay
249-
# should rebuild the segment file (mkdir + O_CREAT + ftruncate + pwrite +
250-
# fsync), restoring the byte-perfect header.
243+
# L16: REMOVED in Hardening v1.0.4 P1-3.
244+
#
245+
# v1.0.3 attempted "rm seg_0.dat -> restart -> WAL replay rebuilds it"
246+
# but this premise is wrong: the initdb seed segment is part of the
247+
# initial cluster image (similar to pg_control / template1), not
248+
# WAL-protected. initdb writes the seed via libpgport pg_pwrite
249+
# directly with no WAL emit; the only WAL records that could rebuild
250+
# a segment are emitted by cluster_undo_segment_allocate (backend-only
251+
# path), which never touched the seed. Deleting the seed file is
252+
# corruption equivalent to deleting pg_control -- nothing in the
253+
# recovery path can heal it; users must base-backup-restore. Redo
254+
# idempotency (the actual Hardening v1.0.3 P1-B fix) is exercised
255+
# instead by allocator-created segments. Stage 1.22 doesn't expose
256+
# a SQL UDF for cluster_undo_segment_allocate, so the redo idempotency
257+
# is verified by a future cluster_unit harness once feature-117 lands
258+
# a public allocator API.
259+
#
260+
# Spec: spec-1.22-undo-tablespace-bootstrap.md ## Hardening v1.0.4.
251261
# ----------
252-
$node->stop;
253-
254-
# Read pre-deletion bytes for byte-equality check after replay.
255-
open(my $pre_del_fh, '<:raw', $seed_path)
256-
or die "could not open seed pre-deletion: $!";
257-
my $pre_del_bytes;
258-
read $pre_del_fh, $pre_del_bytes, 128;
259-
close $pre_del_fh;
260-
261-
# Delete the seed segment file.
262-
unlink($seed_path) or die "could not unlink $seed_path: $!";
263-
ok(! -f $seed_path, 'L16a seg_0.dat deleted');
264-
265-
# Restart -- WAL replay should rebuild the segment file.
266-
$node->start;
267-
268-
ok(-f $seed_path, 'L16b redo handler recreated seg_0.dat after deletion');
269-
270-
my $post_del_size = -s $seed_path;
271-
is($post_del_size, 64 * 1024 * 1024,
272-
'L16c redo handler restored seg_0.dat size to 64 MB (ftruncate idempotent)');
273-
274-
open(my $post_del_fh, '<:raw', $seed_path)
275-
or die "could not open seed post-replay: $!";
276-
my $post_del_bytes;
277-
read $post_del_fh, $post_del_bytes, 128;
278-
close $post_del_fh;
279-
280-
is($post_del_bytes, $pre_del_bytes,
281-
'L16d redo handler restored byte-perfect block 0 (segment_id / state / owner_instance unchanged)');
282-
283262

284263

285264
# ----------

0 commit comments

Comments
 (0)