diff --git a/.golangci.yml b/.golangci.yml index 0121ce5e5..6e6463729 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -78,6 +78,9 @@ linters: - hasher.Write - os.Setenv - os.RemoveAll + # bytes.Buffer writes are documented to never return an error. + - bytes.Buffer.Write + - bytes.Buffer.WriteByte - name: var-naming arguments: - - ID diff --git a/pb/c1/c1z/v3/manifest.pb.go b/pb/c1/c1z/v3/manifest.pb.go index 487a1b678..dcda1a7e0 100644 --- a/pb/c1/c1z/v3/manifest.pb.go +++ b/pb/c1/c1z/v3/manifest.pb.go @@ -195,8 +195,19 @@ type C1ZManifestV3 struct { // tooling inspect legacy-vs-structured index state without extracting the // Pebble payload. PebbleIdIndexFormat PebbleIdIndexFormat `protobuf:"varint,42,opt,name=pebble_id_index_format,json=pebbleIdIndexFormat,proto3,enum=c1.c1z.v3.PebbleIdIndexFormat" json:"pebble_id_index_format,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whole-file grant digest root: the XOR fold of every per-entitlement + // grant-digest root's content-hash XOR in the file's single sync (i.e. + // the fold of every entitlement's digest, not just entitlements with + // grants), plus the total grant count. Lets a consumer answer "did + // anything change?" from a header read alone, without unpacking the + // Pebble payload. Absent means "no digest built for this file" (older + // writers, or a file whose digest state was invalidated after the + // last save) — never "zero grants"; see + // c1/dotc1z/engine/pebble/digest.go's present-means-exact contract. + // Additive field: old readers ignore it, old files simply lack it. + GrantDigestRoot *GrantDigestRoot `protobuf:"bytes,43,opt,name=grant_digest_root,json=grantDigestRoot,proto3" json:"grant_digest_root,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *C1ZManifestV3) Reset() { @@ -287,6 +298,13 @@ func (x *C1ZManifestV3) GetPebbleIdIndexFormat() PebbleIdIndexFormat { return PebbleIdIndexFormat_PEBBLE_ID_INDEX_FORMAT_UNSPECIFIED } +func (x *C1ZManifestV3) GetGrantDigestRoot() *GrantDigestRoot { + if x != nil { + return x.GrantDigestRoot + } + return nil +} + func (x *C1ZManifestV3) SetEngine(v string) { x.Engine = v } @@ -323,6 +341,10 @@ func (x *C1ZManifestV3) SetPebbleIdIndexFormat(v PebbleIdIndexFormat) { x.PebbleIdIndexFormat = v } +func (x *C1ZManifestV3) SetGrantDigestRoot(v *GrantDigestRoot) { + x.GrantDigestRoot = v +} + func (x *C1ZManifestV3) HasEngineConfig() bool { if x == nil { return false @@ -337,6 +359,13 @@ func (x *C1ZManifestV3) HasDescriptors() bool { return x.Descriptors != nil } +func (x *C1ZManifestV3) HasGrantDigestRoot() bool { + if x == nil { + return false + } + return x.GrantDigestRoot != nil +} + func (x *C1ZManifestV3) ClearEngineConfig() { x.EngineConfig = nil } @@ -345,6 +374,10 @@ func (x *C1ZManifestV3) ClearDescriptors() { x.Descriptors = nil } +func (x *C1ZManifestV3) ClearGrantDigestRoot() { + x.GrantDigestRoot = nil +} + type C1ZManifestV3_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. @@ -394,6 +427,17 @@ type C1ZManifestV3_builder struct { // tooling inspect legacy-vs-structured index state without extracting the // Pebble payload. PebbleIdIndexFormat PebbleIdIndexFormat + // Whole-file grant digest root: the XOR fold of every per-entitlement + // grant-digest root's content-hash XOR in the file's single sync (i.e. + // the fold of every entitlement's digest, not just entitlements with + // grants), plus the total grant count. Lets a consumer answer "did + // anything change?" from a header read alone, without unpacking the + // Pebble payload. Absent means "no digest built for this file" (older + // writers, or a file whose digest state was invalidated after the + // last save) — never "zero grants"; see + // c1/dotc1z/engine/pebble/digest.go's present-means-exact contract. + // Additive field: old readers ignore it, old files simply lack it. + GrantDigestRoot *GrantDigestRoot } func (b0 C1ZManifestV3_builder) Build() *C1ZManifestV3 { @@ -409,6 +453,111 @@ func (b0 C1ZManifestV3_builder) Build() *C1ZManifestV3 { x.SyncRuns = b.SyncRuns x.FoldDeadBytes = b.FoldDeadBytes x.PebbleIdIndexFormat = b.PebbleIdIndexFormat + x.GrantDigestRoot = b.GrantDigestRoot + return m0 +} + +// GrantDigestRoot is the whole-file grant digest: present iff the +// engine successfully built grant digests for the file's sync (seal +// time, or a compaction that ran the same build). See +// c1.c1z.v3.C1ZManifestV3.grant_digest_root. +type GrantDigestRoot struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + // XOR of every grant's content hash in the sync. hashLen (8) bytes. + XorDigest []byte `protobuf:"bytes,1,opt,name=xor_digest,json=xorDigest,proto3" json:"xor_digest,omitempty"` + // Total grant count the digest was built over. + Count int64 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + // Version of the grant content-hash / bucket-hash ABI (grant_digest.go) + // this root was computed under. A future ABI bump changes this, which + // makes roots computed under different versions incomparable by + // construction instead of silently comparing unrelated hash schemes. + AbiVersion uint32 `protobuf:"varint,3,opt,name=abi_version,json=abiVersion,proto3" json:"abi_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GrantDigestRoot) Reset() { + *x = GrantDigestRoot{} + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GrantDigestRoot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GrantDigestRoot) ProtoMessage() {} + +func (x *GrantDigestRoot) ProtoReflect() protoreflect.Message { + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GrantDigestRoot) GetXorDigest() []byte { + if x != nil { + return x.XorDigest + } + return nil +} + +func (x *GrantDigestRoot) GetCount() int64 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *GrantDigestRoot) GetAbiVersion() uint32 { + if x != nil { + return x.AbiVersion + } + return 0 +} + +func (x *GrantDigestRoot) SetXorDigest(v []byte) { + if v == nil { + v = []byte{} + } + x.XorDigest = v +} + +func (x *GrantDigestRoot) SetCount(v int64) { + x.Count = v +} + +func (x *GrantDigestRoot) SetAbiVersion(v uint32) { + x.AbiVersion = v +} + +type GrantDigestRoot_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // XOR of every grant's content hash in the sync. hashLen (8) bytes. + XorDigest []byte + // Total grant count the digest was built over. + Count int64 + // Version of the grant content-hash / bucket-hash ABI (grant_digest.go) + // this root was computed under. A future ABI bump changes this, which + // makes roots computed under different versions incomparable by + // construction instead of silently comparing unrelated hash schemes. + AbiVersion uint32 +} + +func (b0 GrantDigestRoot_builder) Build() *GrantDigestRoot { + m0 := &GrantDigestRoot{} + b, x := &b0, m0 + _, _ = b, x + x.XorDigest = b.XorDigest + x.Count = b.Count + x.AbiVersion = b.AbiVersion return m0 } @@ -457,7 +606,7 @@ type IndexedFrameIndex struct { func (x *IndexedFrameIndex) Reset() { *x = IndexedFrameIndex{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -469,7 +618,7 @@ func (x *IndexedFrameIndex) String() string { func (*IndexedFrameIndex) ProtoMessage() {} func (x *IndexedFrameIndex) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -587,7 +736,7 @@ type IndexedFrameEntry struct { func (x *IndexedFrameEntry) Reset() { *x = IndexedFrameEntry{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -599,7 +748,7 @@ func (x *IndexedFrameEntry) String() string { func (*IndexedFrameEntry) ProtoMessage() {} func (x *IndexedFrameEntry) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -737,7 +886,7 @@ type RecordTypeInfo struct { func (x *RecordTypeInfo) Reset() { *x = RecordTypeInfo{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -749,7 +898,7 @@ func (x *RecordTypeInfo) String() string { func (*RecordTypeInfo) ProtoMessage() {} func (x *RecordTypeInfo) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -835,7 +984,7 @@ type SyncRunSummary struct { func (x *SyncRunSummary) Reset() { *x = SyncRunSummary{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -847,7 +996,7 @@ func (x *SyncRunSummary) String() string { func (*SyncRunSummary) ProtoMessage() {} func (x *SyncRunSummary) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1001,7 +1150,7 @@ type PebbleEngineConfig struct { func (x *PebbleEngineConfig) Reset() { *x = PebbleEngineConfig{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1013,7 +1162,7 @@ func (x *PebbleEngineConfig) String() string { func (*PebbleEngineConfig) ProtoMessage() {} func (x *PebbleEngineConfig) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1069,7 +1218,7 @@ var File_c1_c1z_v3_manifest_proto protoreflect.FileDescriptor const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\n" + - "\x18c1/c1z/v3/manifest.proto\x12\tc1.c1z.v3\x1a\x1bc1/storage/v3/records.proto\x1a\x19google/protobuf/any.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x96\x04\n" + + "\x18c1/c1z/v3/manifest.proto\x12\tc1.c1z.v3\x1a\x1bc1/storage/v3/records.proto\x1a\x19google/protobuf/any.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x04\n" + "\rC1ZManifestV3\x12\x16\n" + "\x06engine\x18\x01 \x01(\tR\x06engine\x122\n" + "\x15engine_schema_version\x18\x02 \x01(\rR\x13engineSchemaVersion\x129\n" + @@ -1080,7 +1229,14 @@ const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\frecord_types\x18\v \x03(\v2\x19.c1.c1z.v3.RecordTypeInfoR\vrecordTypes\x126\n" + "\tsync_runs\x18( \x03(\v2\x19.c1.c1z.v3.SyncRunSummaryR\bsyncRuns\x12&\n" + "\x0ffold_dead_bytes\x18) \x01(\x03R\rfoldDeadBytes\x12S\n" + - "\x16pebble_id_index_format\x18* \x01(\x0e2\x1e.c1.c1z.v3.PebbleIdIndexFormatR\x13pebbleIdIndexFormat\"\xcc\x01\n" + + "\x16pebble_id_index_format\x18* \x01(\x0e2\x1e.c1.c1z.v3.PebbleIdIndexFormatR\x13pebbleIdIndexFormat\x12F\n" + + "\x11grant_digest_root\x18+ \x01(\v2\x1a.c1.c1z.v3.GrantDigestRootR\x0fgrantDigestRoot\"g\n" + + "\x0fGrantDigestRoot\x12\x1d\n" + + "\n" + + "xor_digest\x18\x01 \x01(\fR\txorDigest\x12\x14\n" + + "\x05count\x18\x02 \x01(\x03R\x05count\x12\x1f\n" + + "\vabi_version\x18\x03 \x01(\rR\n" + + "abiVersion\"\xcc\x01\n" + "\x11IndexedFrameIndex\x126\n" + "\aentries\x18\x01 \x03(\v2\x1c.c1.c1z.v3.IndexedFrameEntryR\aentries\x12$\n" + "\x0etotal_raw_size\x18\x02 \x01(\x03R\ftotalRawSize\x122\n" + @@ -1120,39 +1276,41 @@ const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\x1dPAYLOAD_ENCODING_INDEXED_ZSTD\x10\x05\"\x04\b\x03\x10\x03\"\x04\b\x04\x10\x04B0Z.github.com/conductorone/baton-sdk/pb/c1/c1z/v3b\x06proto3" var file_c1_c1z_v3_manifest_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_c1_c1z_v3_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_c1_c1z_v3_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_c1_c1z_v3_manifest_proto_goTypes = []any{ (PebbleIdIndexFormat)(0), // 0: c1.c1z.v3.PebbleIdIndexFormat (PayloadEncoding)(0), // 1: c1.c1z.v3.PayloadEncoding (*C1ZManifestV3)(nil), // 2: c1.c1z.v3.C1ZManifestV3 - (*IndexedFrameIndex)(nil), // 3: c1.c1z.v3.IndexedFrameIndex - (*IndexedFrameEntry)(nil), // 4: c1.c1z.v3.IndexedFrameEntry - (*RecordTypeInfo)(nil), // 5: c1.c1z.v3.RecordTypeInfo - (*SyncRunSummary)(nil), // 6: c1.c1z.v3.SyncRunSummary - (*PebbleEngineConfig)(nil), // 7: c1.c1z.v3.PebbleEngineConfig - (*anypb.Any)(nil), // 8: google.protobuf.Any - (*descriptorpb.FileDescriptorSet)(nil), // 9: google.protobuf.FileDescriptorSet - (v3.SyncType)(0), // 10: c1.storage.v3.SyncType - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp - (*v3.SyncStatsRecord)(nil), // 12: c1.storage.v3.SyncStatsRecord + (*GrantDigestRoot)(nil), // 3: c1.c1z.v3.GrantDigestRoot + (*IndexedFrameIndex)(nil), // 4: c1.c1z.v3.IndexedFrameIndex + (*IndexedFrameEntry)(nil), // 5: c1.c1z.v3.IndexedFrameEntry + (*RecordTypeInfo)(nil), // 6: c1.c1z.v3.RecordTypeInfo + (*SyncRunSummary)(nil), // 7: c1.c1z.v3.SyncRunSummary + (*PebbleEngineConfig)(nil), // 8: c1.c1z.v3.PebbleEngineConfig + (*anypb.Any)(nil), // 9: google.protobuf.Any + (*descriptorpb.FileDescriptorSet)(nil), // 10: google.protobuf.FileDescriptorSet + (v3.SyncType)(0), // 11: c1.storage.v3.SyncType + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp + (*v3.SyncStatsRecord)(nil), // 13: c1.storage.v3.SyncStatsRecord } var file_c1_c1z_v3_manifest_proto_depIdxs = []int32{ - 8, // 0: c1.c1z.v3.C1ZManifestV3.engine_config:type_name -> google.protobuf.Any + 9, // 0: c1.c1z.v3.C1ZManifestV3.engine_config:type_name -> google.protobuf.Any 1, // 1: c1.c1z.v3.C1ZManifestV3.payload_encoding:type_name -> c1.c1z.v3.PayloadEncoding - 9, // 2: c1.c1z.v3.C1ZManifestV3.descriptors:type_name -> google.protobuf.FileDescriptorSet - 5, // 3: c1.c1z.v3.C1ZManifestV3.record_types:type_name -> c1.c1z.v3.RecordTypeInfo - 6, // 4: c1.c1z.v3.C1ZManifestV3.sync_runs:type_name -> c1.c1z.v3.SyncRunSummary + 10, // 2: c1.c1z.v3.C1ZManifestV3.descriptors:type_name -> google.protobuf.FileDescriptorSet + 6, // 3: c1.c1z.v3.C1ZManifestV3.record_types:type_name -> c1.c1z.v3.RecordTypeInfo + 7, // 4: c1.c1z.v3.C1ZManifestV3.sync_runs:type_name -> c1.c1z.v3.SyncRunSummary 0, // 5: c1.c1z.v3.C1ZManifestV3.pebble_id_index_format:type_name -> c1.c1z.v3.PebbleIdIndexFormat - 4, // 6: c1.c1z.v3.IndexedFrameIndex.entries:type_name -> c1.c1z.v3.IndexedFrameEntry - 10, // 7: c1.c1z.v3.SyncRunSummary.type:type_name -> c1.storage.v3.SyncType - 11, // 8: c1.c1z.v3.SyncRunSummary.started_at:type_name -> google.protobuf.Timestamp - 11, // 9: c1.c1z.v3.SyncRunSummary.ended_at:type_name -> google.protobuf.Timestamp - 12, // 10: c1.c1z.v3.SyncRunSummary.stats:type_name -> c1.storage.v3.SyncStatsRecord - 11, // [11:11] is the sub-list for method output_type - 11, // [11:11] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 3, // 6: c1.c1z.v3.C1ZManifestV3.grant_digest_root:type_name -> c1.c1z.v3.GrantDigestRoot + 5, // 7: c1.c1z.v3.IndexedFrameIndex.entries:type_name -> c1.c1z.v3.IndexedFrameEntry + 11, // 8: c1.c1z.v3.SyncRunSummary.type:type_name -> c1.storage.v3.SyncType + 12, // 9: c1.c1z.v3.SyncRunSummary.started_at:type_name -> google.protobuf.Timestamp + 12, // 10: c1.c1z.v3.SyncRunSummary.ended_at:type_name -> google.protobuf.Timestamp + 13, // 11: c1.c1z.v3.SyncRunSummary.stats:type_name -> c1.storage.v3.SyncStatsRecord + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_c1_c1z_v3_manifest_proto_init() } @@ -1166,7 +1324,7 @@ func file_c1_c1z_v3_manifest_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_c1_c1z_v3_manifest_proto_rawDesc), len(file_c1_c1z_v3_manifest_proto_rawDesc)), NumEnums: 2, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/pb/c1/c1z/v3/manifest.pb.validate.go b/pb/c1/c1z/v3/manifest.pb.validate.go index a69d20d1f..432a659f5 100644 --- a/pb/c1/c1z/v3/manifest.pb.validate.go +++ b/pb/c1/c1z/v3/manifest.pb.validate.go @@ -197,6 +197,35 @@ func (m *C1ZManifestV3) validate(all bool) error { // no validation rules for PebbleIdIndexFormat + if all { + switch v := interface{}(m.GetGrantDigestRoot()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, C1ZManifestV3ValidationError{ + field: "GrantDigestRoot", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, C1ZManifestV3ValidationError{ + field: "GrantDigestRoot", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetGrantDigestRoot()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return C1ZManifestV3ValidationError{ + field: "GrantDigestRoot", + reason: "embedded message failed validation", + cause: err, + } + } + } + if len(errors) > 0 { return C1ZManifestV3MultiError(errors) } @@ -275,6 +304,112 @@ var _ interface { ErrorName() string } = C1ZManifestV3ValidationError{} +// Validate checks the field values on GrantDigestRoot with the rules defined +// in the proto definition for this message. If any rules are violated, the +// first error encountered is returned, or nil if there are no violations. +func (m *GrantDigestRoot) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on GrantDigestRoot with the rules +// defined in the proto definition for this message. If any rules are +// violated, the result is a list of violation errors wrapped in +// GrantDigestRootMultiError, or nil if none found. +func (m *GrantDigestRoot) ValidateAll() error { + return m.validate(true) +} + +func (m *GrantDigestRoot) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for XorDigest + + // no validation rules for Count + + // no validation rules for AbiVersion + + if len(errors) > 0 { + return GrantDigestRootMultiError(errors) + } + + return nil +} + +// GrantDigestRootMultiError is an error wrapping multiple validation errors +// returned by GrantDigestRoot.ValidateAll() if the designated constraints +// aren't met. +type GrantDigestRootMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m GrantDigestRootMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m GrantDigestRootMultiError) AllErrors() []error { return m } + +// GrantDigestRootValidationError is the validation error returned by +// GrantDigestRoot.Validate if the designated constraints aren't met. +type GrantDigestRootValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e GrantDigestRootValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e GrantDigestRootValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e GrantDigestRootValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e GrantDigestRootValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e GrantDigestRootValidationError) ErrorName() string { return "GrantDigestRootValidationError" } + +// Error satisfies the builtin error interface +func (e GrantDigestRootValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sGrantDigestRoot.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = GrantDigestRootValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = GrantDigestRootValidationError{} + // Validate checks the field values on IndexedFrameIndex with the rules defined // in the proto definition for this message. If any rules are violated, the // first error encountered is returned, or nil if there are no violations. diff --git a/pb/c1/c1z/v3/manifest_protoopaque.pb.go b/pb/c1/c1z/v3/manifest_protoopaque.pb.go index a5ab7cbf1..1732c6d2f 100644 --- a/pb/c1/c1z/v3/manifest_protoopaque.pb.go +++ b/pb/c1/c1z/v3/manifest_protoopaque.pb.go @@ -158,6 +158,7 @@ type C1ZManifestV3 struct { xxx_hidden_SyncRuns *[]*SyncRunSummary `protobuf:"bytes,40,rep,name=sync_runs,json=syncRuns,proto3"` xxx_hidden_FoldDeadBytes int64 `protobuf:"varint,41,opt,name=fold_dead_bytes,json=foldDeadBytes,proto3"` xxx_hidden_PebbleIdIndexFormat PebbleIdIndexFormat `protobuf:"varint,42,opt,name=pebble_id_index_format,json=pebbleIdIndexFormat,proto3,enum=c1.c1z.v3.PebbleIdIndexFormat"` + xxx_hidden_GrantDigestRoot *GrantDigestRoot `protobuf:"bytes,43,opt,name=grant_digest_root,json=grantDigestRoot,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -254,6 +255,13 @@ func (x *C1ZManifestV3) GetPebbleIdIndexFormat() PebbleIdIndexFormat { return PebbleIdIndexFormat_PEBBLE_ID_INDEX_FORMAT_UNSPECIFIED } +func (x *C1ZManifestV3) GetGrantDigestRoot() *GrantDigestRoot { + if x != nil { + return x.xxx_hidden_GrantDigestRoot + } + return nil +} + func (x *C1ZManifestV3) SetEngine(v string) { x.xxx_hidden_Engine = v } @@ -290,6 +298,10 @@ func (x *C1ZManifestV3) SetPebbleIdIndexFormat(v PebbleIdIndexFormat) { x.xxx_hidden_PebbleIdIndexFormat = v } +func (x *C1ZManifestV3) SetGrantDigestRoot(v *GrantDigestRoot) { + x.xxx_hidden_GrantDigestRoot = v +} + func (x *C1ZManifestV3) HasEngineConfig() bool { if x == nil { return false @@ -304,6 +316,13 @@ func (x *C1ZManifestV3) HasDescriptors() bool { return x.xxx_hidden_Descriptors != nil } +func (x *C1ZManifestV3) HasGrantDigestRoot() bool { + if x == nil { + return false + } + return x.xxx_hidden_GrantDigestRoot != nil +} + func (x *C1ZManifestV3) ClearEngineConfig() { x.xxx_hidden_EngineConfig = nil } @@ -312,6 +331,10 @@ func (x *C1ZManifestV3) ClearDescriptors() { x.xxx_hidden_Descriptors = nil } +func (x *C1ZManifestV3) ClearGrantDigestRoot() { + x.xxx_hidden_GrantDigestRoot = nil +} + type C1ZManifestV3_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. @@ -361,6 +384,17 @@ type C1ZManifestV3_builder struct { // tooling inspect legacy-vs-structured index state without extracting the // Pebble payload. PebbleIdIndexFormat PebbleIdIndexFormat + // Whole-file grant digest root: the XOR fold of every per-entitlement + // grant-digest root's content-hash XOR in the file's single sync (i.e. + // the fold of every entitlement's digest, not just entitlements with + // grants), plus the total grant count. Lets a consumer answer "did + // anything change?" from a header read alone, without unpacking the + // Pebble payload. Absent means "no digest built for this file" (older + // writers, or a file whose digest state was invalidated after the + // last save) — never "zero grants"; see + // c1/dotc1z/engine/pebble/digest.go's present-means-exact contract. + // Additive field: old readers ignore it, old files simply lack it. + GrantDigestRoot *GrantDigestRoot } func (b0 C1ZManifestV3_builder) Build() *C1ZManifestV3 { @@ -376,6 +410,105 @@ func (b0 C1ZManifestV3_builder) Build() *C1ZManifestV3 { x.xxx_hidden_SyncRuns = &b.SyncRuns x.xxx_hidden_FoldDeadBytes = b.FoldDeadBytes x.xxx_hidden_PebbleIdIndexFormat = b.PebbleIdIndexFormat + x.xxx_hidden_GrantDigestRoot = b.GrantDigestRoot + return m0 +} + +// GrantDigestRoot is the whole-file grant digest: present iff the +// engine successfully built grant digests for the file's sync (seal +// time, or a compaction that ran the same build). See +// c1.c1z.v3.C1ZManifestV3.grant_digest_root. +type GrantDigestRoot struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_XorDigest []byte `protobuf:"bytes,1,opt,name=xor_digest,json=xorDigest,proto3"` + xxx_hidden_Count int64 `protobuf:"varint,2,opt,name=count,proto3"` + xxx_hidden_AbiVersion uint32 `protobuf:"varint,3,opt,name=abi_version,json=abiVersion,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GrantDigestRoot) Reset() { + *x = GrantDigestRoot{} + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GrantDigestRoot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GrantDigestRoot) ProtoMessage() {} + +func (x *GrantDigestRoot) ProtoReflect() protoreflect.Message { + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *GrantDigestRoot) GetXorDigest() []byte { + if x != nil { + return x.xxx_hidden_XorDigest + } + return nil +} + +func (x *GrantDigestRoot) GetCount() int64 { + if x != nil { + return x.xxx_hidden_Count + } + return 0 +} + +func (x *GrantDigestRoot) GetAbiVersion() uint32 { + if x != nil { + return x.xxx_hidden_AbiVersion + } + return 0 +} + +func (x *GrantDigestRoot) SetXorDigest(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_XorDigest = v +} + +func (x *GrantDigestRoot) SetCount(v int64) { + x.xxx_hidden_Count = v +} + +func (x *GrantDigestRoot) SetAbiVersion(v uint32) { + x.xxx_hidden_AbiVersion = v +} + +type GrantDigestRoot_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // XOR of every grant's content hash in the sync. hashLen (8) bytes. + XorDigest []byte + // Total grant count the digest was built over. + Count int64 + // Version of the grant content-hash / bucket-hash ABI (grant_digest.go) + // this root was computed under. A future ABI bump changes this, which + // makes roots computed under different versions incomparable by + // construction instead of silently comparing unrelated hash schemes. + AbiVersion uint32 +} + +func (b0 GrantDigestRoot_builder) Build() *GrantDigestRoot { + m0 := &GrantDigestRoot{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_XorDigest = b.XorDigest + x.xxx_hidden_Count = b.Count + x.xxx_hidden_AbiVersion = b.AbiVersion return m0 } @@ -413,7 +546,7 @@ type IndexedFrameIndex struct { func (x *IndexedFrameIndex) Reset() { *x = IndexedFrameIndex{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -425,7 +558,7 @@ func (x *IndexedFrameIndex) String() string { func (*IndexedFrameIndex) ProtoMessage() {} func (x *IndexedFrameIndex) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[1] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -527,7 +660,7 @@ type IndexedFrameEntry struct { func (x *IndexedFrameEntry) Reset() { *x = IndexedFrameEntry{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -539,7 +672,7 @@ func (x *IndexedFrameEntry) String() string { func (*IndexedFrameEntry) ProtoMessage() {} func (x *IndexedFrameEntry) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[2] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -672,7 +805,7 @@ type RecordTypeInfo struct { func (x *RecordTypeInfo) Reset() { *x = RecordTypeInfo{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -684,7 +817,7 @@ func (x *RecordTypeInfo) String() string { func (*RecordTypeInfo) ProtoMessage() {} func (x *RecordTypeInfo) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[3] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -765,7 +898,7 @@ type SyncRunSummary struct { func (x *SyncRunSummary) Reset() { *x = SyncRunSummary{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -777,7 +910,7 @@ func (x *SyncRunSummary) String() string { func (*SyncRunSummary) ProtoMessage() {} func (x *SyncRunSummary) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[4] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -928,7 +1061,7 @@ type PebbleEngineConfig struct { func (x *PebbleEngineConfig) Reset() { *x = PebbleEngineConfig{} - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -940,7 +1073,7 @@ func (x *PebbleEngineConfig) String() string { func (*PebbleEngineConfig) ProtoMessage() {} func (x *PebbleEngineConfig) ProtoReflect() protoreflect.Message { - mi := &file_c1_c1z_v3_manifest_proto_msgTypes[5] + mi := &file_c1_c1z_v3_manifest_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -996,7 +1129,7 @@ var File_c1_c1z_v3_manifest_proto protoreflect.FileDescriptor const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\n" + - "\x18c1/c1z/v3/manifest.proto\x12\tc1.c1z.v3\x1a\x1bc1/storage/v3/records.proto\x1a\x19google/protobuf/any.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x96\x04\n" + + "\x18c1/c1z/v3/manifest.proto\x12\tc1.c1z.v3\x1a\x1bc1/storage/v3/records.proto\x1a\x19google/protobuf/any.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x04\n" + "\rC1ZManifestV3\x12\x16\n" + "\x06engine\x18\x01 \x01(\tR\x06engine\x122\n" + "\x15engine_schema_version\x18\x02 \x01(\rR\x13engineSchemaVersion\x129\n" + @@ -1007,7 +1140,14 @@ const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\frecord_types\x18\v \x03(\v2\x19.c1.c1z.v3.RecordTypeInfoR\vrecordTypes\x126\n" + "\tsync_runs\x18( \x03(\v2\x19.c1.c1z.v3.SyncRunSummaryR\bsyncRuns\x12&\n" + "\x0ffold_dead_bytes\x18) \x01(\x03R\rfoldDeadBytes\x12S\n" + - "\x16pebble_id_index_format\x18* \x01(\x0e2\x1e.c1.c1z.v3.PebbleIdIndexFormatR\x13pebbleIdIndexFormat\"\xcc\x01\n" + + "\x16pebble_id_index_format\x18* \x01(\x0e2\x1e.c1.c1z.v3.PebbleIdIndexFormatR\x13pebbleIdIndexFormat\x12F\n" + + "\x11grant_digest_root\x18+ \x01(\v2\x1a.c1.c1z.v3.GrantDigestRootR\x0fgrantDigestRoot\"g\n" + + "\x0fGrantDigestRoot\x12\x1d\n" + + "\n" + + "xor_digest\x18\x01 \x01(\fR\txorDigest\x12\x14\n" + + "\x05count\x18\x02 \x01(\x03R\x05count\x12\x1f\n" + + "\vabi_version\x18\x03 \x01(\rR\n" + + "abiVersion\"\xcc\x01\n" + "\x11IndexedFrameIndex\x126\n" + "\aentries\x18\x01 \x03(\v2\x1c.c1.c1z.v3.IndexedFrameEntryR\aentries\x12$\n" + "\x0etotal_raw_size\x18\x02 \x01(\x03R\ftotalRawSize\x122\n" + @@ -1047,39 +1187,41 @@ const file_c1_c1z_v3_manifest_proto_rawDesc = "" + "\x1dPAYLOAD_ENCODING_INDEXED_ZSTD\x10\x05\"\x04\b\x03\x10\x03\"\x04\b\x04\x10\x04B0Z.github.com/conductorone/baton-sdk/pb/c1/c1z/v3b\x06proto3" var file_c1_c1z_v3_manifest_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_c1_c1z_v3_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_c1_c1z_v3_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_c1_c1z_v3_manifest_proto_goTypes = []any{ (PebbleIdIndexFormat)(0), // 0: c1.c1z.v3.PebbleIdIndexFormat (PayloadEncoding)(0), // 1: c1.c1z.v3.PayloadEncoding (*C1ZManifestV3)(nil), // 2: c1.c1z.v3.C1ZManifestV3 - (*IndexedFrameIndex)(nil), // 3: c1.c1z.v3.IndexedFrameIndex - (*IndexedFrameEntry)(nil), // 4: c1.c1z.v3.IndexedFrameEntry - (*RecordTypeInfo)(nil), // 5: c1.c1z.v3.RecordTypeInfo - (*SyncRunSummary)(nil), // 6: c1.c1z.v3.SyncRunSummary - (*PebbleEngineConfig)(nil), // 7: c1.c1z.v3.PebbleEngineConfig - (*anypb.Any)(nil), // 8: google.protobuf.Any - (*descriptorpb.FileDescriptorSet)(nil), // 9: google.protobuf.FileDescriptorSet - (v3.SyncType)(0), // 10: c1.storage.v3.SyncType - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp - (*v3.SyncStatsRecord)(nil), // 12: c1.storage.v3.SyncStatsRecord + (*GrantDigestRoot)(nil), // 3: c1.c1z.v3.GrantDigestRoot + (*IndexedFrameIndex)(nil), // 4: c1.c1z.v3.IndexedFrameIndex + (*IndexedFrameEntry)(nil), // 5: c1.c1z.v3.IndexedFrameEntry + (*RecordTypeInfo)(nil), // 6: c1.c1z.v3.RecordTypeInfo + (*SyncRunSummary)(nil), // 7: c1.c1z.v3.SyncRunSummary + (*PebbleEngineConfig)(nil), // 8: c1.c1z.v3.PebbleEngineConfig + (*anypb.Any)(nil), // 9: google.protobuf.Any + (*descriptorpb.FileDescriptorSet)(nil), // 10: google.protobuf.FileDescriptorSet + (v3.SyncType)(0), // 11: c1.storage.v3.SyncType + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp + (*v3.SyncStatsRecord)(nil), // 13: c1.storage.v3.SyncStatsRecord } var file_c1_c1z_v3_manifest_proto_depIdxs = []int32{ - 8, // 0: c1.c1z.v3.C1ZManifestV3.engine_config:type_name -> google.protobuf.Any + 9, // 0: c1.c1z.v3.C1ZManifestV3.engine_config:type_name -> google.protobuf.Any 1, // 1: c1.c1z.v3.C1ZManifestV3.payload_encoding:type_name -> c1.c1z.v3.PayloadEncoding - 9, // 2: c1.c1z.v3.C1ZManifestV3.descriptors:type_name -> google.protobuf.FileDescriptorSet - 5, // 3: c1.c1z.v3.C1ZManifestV3.record_types:type_name -> c1.c1z.v3.RecordTypeInfo - 6, // 4: c1.c1z.v3.C1ZManifestV3.sync_runs:type_name -> c1.c1z.v3.SyncRunSummary + 10, // 2: c1.c1z.v3.C1ZManifestV3.descriptors:type_name -> google.protobuf.FileDescriptorSet + 6, // 3: c1.c1z.v3.C1ZManifestV3.record_types:type_name -> c1.c1z.v3.RecordTypeInfo + 7, // 4: c1.c1z.v3.C1ZManifestV3.sync_runs:type_name -> c1.c1z.v3.SyncRunSummary 0, // 5: c1.c1z.v3.C1ZManifestV3.pebble_id_index_format:type_name -> c1.c1z.v3.PebbleIdIndexFormat - 4, // 6: c1.c1z.v3.IndexedFrameIndex.entries:type_name -> c1.c1z.v3.IndexedFrameEntry - 10, // 7: c1.c1z.v3.SyncRunSummary.type:type_name -> c1.storage.v3.SyncType - 11, // 8: c1.c1z.v3.SyncRunSummary.started_at:type_name -> google.protobuf.Timestamp - 11, // 9: c1.c1z.v3.SyncRunSummary.ended_at:type_name -> google.protobuf.Timestamp - 12, // 10: c1.c1z.v3.SyncRunSummary.stats:type_name -> c1.storage.v3.SyncStatsRecord - 11, // [11:11] is the sub-list for method output_type - 11, // [11:11] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 3, // 6: c1.c1z.v3.C1ZManifestV3.grant_digest_root:type_name -> c1.c1z.v3.GrantDigestRoot + 5, // 7: c1.c1z.v3.IndexedFrameIndex.entries:type_name -> c1.c1z.v3.IndexedFrameEntry + 11, // 8: c1.c1z.v3.SyncRunSummary.type:type_name -> c1.storage.v3.SyncType + 12, // 9: c1.c1z.v3.SyncRunSummary.started_at:type_name -> google.protobuf.Timestamp + 12, // 10: c1.c1z.v3.SyncRunSummary.ended_at:type_name -> google.protobuf.Timestamp + 13, // 11: c1.c1z.v3.SyncRunSummary.stats:type_name -> c1.storage.v3.SyncStatsRecord + 12, // [12:12] is the sub-list for method output_type + 12, // [12:12] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_c1_c1z_v3_manifest_proto_init() } @@ -1093,7 +1235,7 @@ func file_c1_c1z_v3_manifest_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_c1_c1z_v3_manifest_proto_rawDesc), len(file_c1_c1z_v3_manifest_proto_rawDesc)), NumEnums: 2, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/pkg/connectorstore/connectorstore.go b/pkg/connectorstore/connectorstore.go index fa1f03feb..d393a7bec 100644 --- a/pkg/connectorstore/connectorstore.go +++ b/pkg/connectorstore/connectorstore.go @@ -105,6 +105,113 @@ type LatestFinishedSyncIDFetcher interface { LatestFinishedSyncID(ctx context.Context, syncType SyncType) (string, error) } +// GrantDigest is the root of an entitlement's grant digest: a +// content-defined hash over all of the entitlement's grants plus the +// grant count. Two entitlements (in the same or different c1z files) +// with the same Count and Hash hold the same set of grants. +type GrantDigest struct { + // Hash is the digest of every grant under the entitlement. Opaque + // bytes — compare for equality, don't interpret. Empty when Count + // is 0. + Hash []byte + // Count is the number of grants under the entitlement. + Count int64 + // Level is the digest's native (stored) rollup level: the leaf + // granularity it was built at, expressed as a count of + // principal-hash bits, so the leaf level holds up to 2^Level + // buckets. Level 0 means only the root is stored (small or empty + // entitlements). It is the finest level GetEntitlementGrantDigestNodes + // can serve cheaply (by folding the stored leaves); a finer level + // would require a full grant-index scan. + Level int +} + +// GrantDigestNode is one bucket of an entitlement's grant digest at a +// requested rollup level: a content hash + grant count over the grants +// whose principal-hash falls in that bucket. +type GrantDigestNode struct { + // Index is the bucket index, in [0, 2^level). For level >= 1 nodes + // are returned sparsely and in ascending Index — only non-empty + // buckets appear, so a returned leaf node always has Count > 0. (At + // level 0 the single root node is always returned, even with + // Count 0.) + Index uint32 + // Hash is the digest over the bucket's grants. + Hash []byte + // Count is the number of grants in the bucket. + Count int64 +} + +// GrantDigestBucket addresses one rollup bucket of an entitlement's +// grant digest: the grants whose principal-hash falls under Index at the +// given Level (2^Level buckets; Level 0 = the whole entitlement, Index +// ignored). Index is exactly a GrantDigestNode.Index returned by +// GetEntitlementGrantDigestNodes at the same Level — list nodes to find +// the buckets, then scan the ones you want. +type GrantDigestBucket struct { + // Level is the bucket width in principal-hash bits (2^Level buckets); + // 0 = the whole entitlement. + Level int + // Index is the bucket index in [0, 2^Level); ignored when Level == 0. + Index uint32 +} + +// EntitlementGrantDigestReader is an optional Reader capability that +// returns the precomputed grant digest for an entitlement. It is +// engine-specific: only the Pebble engine maintains the digest, so +// callers must type-assert and fall back when the assertion fails or +// found is false. +// +// Digests are keyed by the entitlement's STRUCTURAL identity — +// (resource_type_id, resource_id, entitlement id) — so every method +// takes a *v2.Entitlement stub, the same addressing the grants-for- +// entitlement readers use. Pass the resource ref when you have it (any +// caller iterating entitlement records does): identity then derives +// exactly from the structured parts, with no lookup and no ambiguity. +// A stub carrying only the Id falls back to bare-id resolution under +// the exactly-one rule; an id that matches entitlements on more than +// one resource is an error, never a guess. A nil stub or empty Id is +// an error. +// +// found is false when the reader has no digest built for the +// entitlement (it was never synced, the file predates / opted out of +// the digest index — see WithGrantDigestIndex — or a bare id resolved +// to nothing). A nil error with found=false means "no digest", not +// "error". +type EntitlementGrantDigestReader interface { + // GetEntitlementGrantDigest returns the entitlement's digest root — + // the whole-entitlement hash + grant count, plus the digest's native + // rollup Level. One key read. + GetEntitlementGrantDigest(ctx context.Context, entitlement *v2.Entitlement) (digest GrantDigest, found bool, err error) + + // GetEntitlementGrantDigestNodes lists the digest's rollup nodes for + // the entitlement at `level`: 2^level principal-hash buckets, with + // level 0 returning a single node (the root) covering the whole + // entitlement. + // + // For 0 <= level <= the native Level (GrantDigest.Level) this folds + // the stored leaves — one contiguous scan of the digest keyspace, no + // grant-index scan. For a finer level it falls back to scanning the + // grant index directly (O(grants)) — slower, but it never errors on a + // "too deep" level. The principal-hash carries a bounded number of + // bits, so a level beyond that resolution is served at the maximum + // (you may get fewer than 2^level distinct buckets). found is false + // when no digest exists. + GetEntitlementGrantDigestNodes(ctx context.Context, entitlement *v2.Entitlement, level int) (nodes []GrantDigestNode, found bool, err error) + + // ScanEntitlementGrantBucket yields every grant in one digest bucket + // of the entitlement (see GrantDigestBucket) as a v2.Grant, stopping + // early if yield returns false. Bucket Level 0 scans the whole + // entitlement; a Level finer than the bucket-hash resolution is + // clamped (matching GetEntitlementGrantDigestNodes). It reads the + // grant hash index, which exists only on files whose digest was + // built (they are derived together at seal): callers must check + // GetEntitlementGrantDigest first and treat found=false as "scan + // unavailable — read the grants directly", not as "no grants". It + // yields nothing when there is no active sync or no matching grants. + ScanEntitlementGrantBucket(ctx context.Context, entitlement *v2.Entitlement, bucket GrantDigestBucket, yield func(grant *v2.Grant) bool) error +} + // DBSizeProvider is an optional capability for a store that can report its // current uncompressed working-set size (e.g. dotc1z.C1File stat'ing its // sqlite file). Consumed by the syncer's ProgressLog to include diff --git a/pkg/dotc1z/c1file.go b/pkg/dotc1z/c1file.go index 2845ae7f3..a9feeec68 100644 --- a/pkg/dotc1z/c1file.go +++ b/pkg/dotc1z/c1file.go @@ -340,6 +340,12 @@ type c1zOptions struct { v2GrantsWriter bool bulkLoad bool + // disableGrantDigestIndex turns off the Pebble engine's seal-time + // build of the by_entitlement_principal_hash index + grant digests. + // Inverted so the zero value keeps the build on (current behavior). + // See WithGrantDigestIndex. + disableGrantDigestIndex bool + // engine is the storage engine to use for newly created files. // Reads dispatch on magic byte regardless. Default EngineSQLite. engine c1zstore.Engine @@ -445,6 +451,21 @@ func WithV2GrantsWriter(enabled bool) C1ZOption { } } +// WithGrantDigestIndex toggles the Pebble engine's seal-time build of +// the by_entitlement_principal_hash index and per-entitlement grant +// digests (the substrate for cross-file grant diffing). Default true. +// +// Pass false for files that will never be grant-diffed (local CLI syncs, +// connector development) to skip the seal-time derivation pass. Safe to +// toggle: a file sealed with this off stores no digest roots, which +// readers treat as "missing — recalculate", never as "no grants". No +// effect on the SQLite engine. +func WithGrantDigestIndex(enabled bool) C1ZOption { + return func(o *c1zOptions) { + o.disableGrantDigestIndex = !enabled + } +} + // WithBulkLoad enables deferred secondary-index creation for a // freshly-created destination c1z. See WithC1FBulkLoad for the full contract: // it is opt-in and never the default; indexes are deferred only on EMPTY diff --git a/pkg/dotc1z/engine/pebble/adapter.go b/pkg/dotc1z/engine/pebble/adapter.go index c18ef1b54..7eec0b8cc 100644 --- a/pkg/dotc1z/engine/pebble/adapter.go +++ b/pkg/dotc1z/engine/pebble/adapter.go @@ -75,9 +75,10 @@ func NewAdapter(e *Engine) *Adapter { // Compile-time checks for the full Writer interface and the optional // connectorstore capabilities that SQLite's *C1File also exposes. var ( - _ connectorstore.Writer = (*Adapter)(nil) - _ connectorstore.LatestFinishedSyncIDFetcher = (*Adapter)(nil) - _ connectorstore.DBSizeProvider = (*Adapter)(nil) + _ connectorstore.Writer = (*Adapter)(nil) + _ connectorstore.LatestFinishedSyncIDFetcher = (*Adapter)(nil) + _ connectorstore.DBSizeProvider = (*Adapter)(nil) + _ connectorstore.EntitlementGrantDigestReader = (*Adapter)(nil) ) // === sync lifecycle === @@ -344,6 +345,25 @@ func (e *Engine) endSyncFinalize(ctx context.Context, existing *v3.SyncRunRecord if err := e.clearDeferredIdxPending(); err != nil { return fmt.Errorf("EndSync: clear deferred index marker: %w", err) } + } else if e.GrantDigestIndexEnabled() { + // The deferred pass didn't run (no grant went through the + // deferred index paths — inline-index writes like PutGrantRecords + // never arm the marker). RepairMissingGrantDigests reduces to a + // full BuildGrantDigests-equivalent scan for the common case (a + // brand-new sync always has grantDigestsPresent false, since + // ResetForNewSync excised the digest keyspace at StartNewSync) — + // no behavior change there. It only does LESS work when this + // EndSync is a second call on an already-digested sync that was + // rebound via SetCurrentSync rather than started fresh (grant + // expansion's own follow-up sync is exactly this shape): trusting + // a still-valid whole-file root outright, or rebuilding only the + // entitlements invalidated since the prior seal, instead of + // rescanning every grant in the file again. Build failures are + // downgraded to a loud digest-state drop inside; an error + // surfacing here (cancellation, drop failure) is fatal. + if err := e.RepairMissingGrantDigests(ctx); err != nil { + return fmt.Errorf("EndSync: repair grant digests: %w", err) + } } updated := v3.SyncRunRecord_builder{ SyncId: existing.GetSyncId(), diff --git a/pkg/dotc1z/engine/pebble/adapter_reader.go b/pkg/dotc1z/engine/pebble/adapter_reader.go index 624a9ecf0..8dd4ca6bc 100644 --- a/pkg/dotc1z/engine/pebble/adapter_reader.go +++ b/pkg/dotc1z/engine/pebble/adapter_reader.go @@ -647,3 +647,133 @@ func (a *Adapter) InitCurrentSync(ctx context.Context) error { } return nil } + +// digestEntitlementIdentity maps a request-supplied entitlement stub to +// the structural identity the digest keyspace is addressed by, via the +// same entitlementIdentityForRequest the grants-for-entitlement readers +// use: exact derivation from the resource ref when present, bare-id +// resolution (exactly-one rule) otherwise. ok is false when a bare id +// matches nothing; an AMBIGUOUS id stays an error — a lossy string must +// never guess which digest to answer with. A nil stub or empty Id is an +// error, matching ListGrantsForEntitlement. +func (a *Adapter) digestEntitlementIdentity(ctx context.Context, ent *v2.Entitlement) (entitlementIdentity, bool, error) { + id, err := a.entitlementIdentityForRequest(ctx, ent) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return entitlementIdentity{}, false, nil + } + return entitlementIdentity{}, false, err + } + return id, true, nil +} + +// GetEntitlementGrantDigest implements connectorstore.EntitlementGrantDigestReader. +// It returns the stored grant-digest root (content hash + grant count) +// for the entitlement under the reader's active sync. found is false +// when no digest exists — either no active sync, an unknown bare id, or +// no digest was built for it (e.g. WithGrantDigestIndex(false), a file +// that predates the digest, or a post-seal mutation invalidated it). A +// nil error with found=false means "no digest". +func (a *Adapter) GetEntitlementGrantDigest(ctx context.Context, ent *v2.Entitlement) (connectorstore.GrantDigest, bool, error) { + syncID, err := a.resolveActiveSyncForReader(ctx, nil) + if err != nil { + return connectorstore.GrantDigest{}, false, err + } + if syncID == "" { + return connectorstore.GrantDigest{}, false, nil + } + id, ok, err := a.digestEntitlementIdentity(ctx, ent) + if err != nil || !ok { + return connectorstore.GrantDigest{}, false, err + } + root, ok, err := a.engine.GetEntitlementDigestRoot(ctx, id) + if err != nil || !ok { + return connectorstore.GrantDigest{}, false, err + } + return connectorstore.GrantDigest{Hash: root.Hash, Count: root.Count, Level: root.Bits}, true, nil +} + +// GetEntitlementGrantDigestNodes implements +// connectorstore.EntitlementGrantDigestReader. It lists the entitlement's +// grant-digest rollup nodes at the requested level (2^level buckets; +// level 0 = the root). For 0 <= level <= the digest's native level it +// folds the stored leaves — one scan of the digest keyspace. For a finer +// level it scans the grant index directly (O(grants)) instead of +// erroring; the level is clamped to the bucket-hash resolution +// (digestMaxWidthBits). +func (a *Adapter) GetEntitlementGrantDigestNodes(ctx context.Context, ent *v2.Entitlement, level int) ([]connectorstore.GrantDigestNode, bool, error) { + if level < 0 { + return nil, false, fmt.Errorf("pebble: negative grant-digest level %d", level) + } + syncID, err := a.resolveActiveSyncForReader(ctx, nil) + if err != nil { + return nil, false, err + } + if syncID == "" { + return nil, false, nil + } + id, ok, err := a.digestEntitlementIdentity(ctx, ent) + if err != nil || !ok { + return nil, false, err + } + root, ok, err := a.engine.GetEntitlementDigestRoot(ctx, id) + if err != nil || !ok { + return nil, false, err + } + // Level 0 is the whole-entitlement root — return it directly (covers + // the root-only digest, which has no stored leaves to fold). + if level == 0 { + return []connectorstore.GrantDigestNode{{Index: 0, Hash: root.Hash, Count: root.Count}}, true, nil + } + // The bucket hash carries at most digestMaxWidthBits of resolution; + // a finer level can't address more buckets, so clamp. + bits := min(level, digestMaxWidthBits) + // At or below the stored width, fold the digest leaves (cheap). Finer + // than what we stored, scan the grant index to compute the rollup. + partition := digestPartitionForEntitlement(id) + var folded []foldedBucket + if bits <= root.Bits { + folded, err = a.engine.foldedLeafBuckets(ctx, grantDigestSpec, partition, bits) + } else { + folded, err = a.engine.computeBucketsAtWidth(ctx, grantDigestSpec, partition, bits) + } + if err != nil { + return nil, false, err + } + nodes := make([]connectorstore.GrantDigestNode, len(folded)) + for i := range folded { + nodes[i] = connectorstore.GrantDigestNode{ + Index: folded[i].idx, + Hash: append([]byte(nil), folded[i].digest[:]...), + Count: folded[i].count, + } + } + return nodes, true, nil +} + +// ScanEntitlementGrantBucket implements +// connectorstore.EntitlementGrantDigestReader. It yields every grant in +// the given digest bucket of the entitlement, translated to v2.Grant. +// Bucket Level 0 scans the whole entitlement; a finer Level is clamped +// to the bucket-hash resolution. Yields nothing when there is no active +// sync or a bare entitlement id resolves to nothing. +func (a *Adapter) ScanEntitlementGrantBucket(ctx context.Context, ent *v2.Entitlement, bucket connectorstore.GrantDigestBucket, yield func(*v2.Grant) bool) error { + if bucket.Level < 0 { + return fmt.Errorf("pebble: negative grant-digest level %d", bucket.Level) + } + syncID, err := a.resolveActiveSyncForReader(ctx, nil) + if err != nil { + return err + } + if syncID == "" { + return nil + } + id, ok, err := a.digestEntitlementIdentity(ctx, ent) + if err != nil || !ok { + return err + } + bits := min(bucket.Level, digestMaxWidthBits) + return a.engine.IterateGrantsByEntitlementBucket(ctx, id, DigestBucket{Index: bucket.Index, Bits: bits}, func(r *v3.GrantRecord) bool { + return yield(V3GrantToV2(r)) + }) +} diff --git a/pkg/dotc1z/engine/pebble/bulk_import.go b/pkg/dotc1z/engine/pebble/bulk_import.go index 621779a31..ce280cebb 100644 --- a/pkg/dotc1z/engine/pebble/bulk_import.go +++ b/pkg/dotc1z/engine/pebble/bulk_import.go @@ -50,7 +50,10 @@ const bulkSpillKeyChunkBytes = 8 << 20 const bulkSpillBufferSize = 1 << 20 // grantIndexFamilies are the index-discriminator bytes AddGrants can -// emit (see grantIndexKeys). +// emit (see grantIndexKeys). The by_entitlement_principal_hash index +// is deliberately absent: it and the grant digests are derived from +// the primaries by the fused deferred pass at seal time +// (BuildDeferredGrantIndexes), never written inline. var grantIndexFamilies = []byte{ idxGrantByPrincipal, idxGrantByNeedsExpansion, diff --git a/pkg/dotc1z/engine/pebble/cleanup.go b/pkg/dotc1z/engine/pebble/cleanup.go index a132572d6..66f30a06a 100644 --- a/pkg/dotc1z/engine/pebble/cleanup.go +++ b/pkg/dotc1z/engine/pebble/cleanup.go @@ -35,6 +35,8 @@ func scopedRanges() [][2][]byte { {GrantByPrincipalLowerBound(), GrantByPrincipalUpperBound()}, {GrantByPrincipalResourceTypeLowerBound(), GrantByPrincipalResourceTypeUpperBound()}, {GrantByNeedsExpansionLowerBound(), GrantByNeedsExpansionUpperBound()}, + {GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()}, + {DigestLowerBound(), DigestUpperBound()}, {encodeAssetPrefix(), upperBoundOf(encodeAssetPrefix())}, // Stats sidecar — single key; the half-open range shape // contains exactly that one key. @@ -94,6 +96,19 @@ func (e *Engine) ResetForNewSync(ctx context.Context) error { return fmt.Errorf("ResetForNewSync: excise [%x, %x): %w", span.Start, span.End, err) } } + // The record-type span above covers typeDigest and the hash + // index too; disarm the mutation-path digest invalidation. + e.grantDigestsPresent.Store(false) + // The digest-build crash marker lives in the preserved + // engine-meta range, but the excise just removed everything it + // was guarding against trusting — consume it (only reachable + // when an interrupted build's cleanup drop itself failed; + // writable Opens consume it before anything else runs). + if e.grantDigestBuildPending.Load() { + if err := e.clearGrantDigestBuildPending(); err != nil { + return err + } + } e.noteEntitlementKeyspaceWrite() return nil }) diff --git a/pkg/dotc1z/engine/pebble/deferred_index.go b/pkg/dotc1z/engine/pebble/deferred_index.go index 6fe511c9c..9710d665a 100644 --- a/pkg/dotc1z/engine/pebble/deferred_index.go +++ b/pkg/dotc1z/engine/pebble/deferred_index.go @@ -19,15 +19,25 @@ import ( ) // deferredIndexSpillChunkBytes is the spill-chunk arena size for the deferred -// by_principal index build. The shared bulkSpillKeyChunkBytes (8MiB) is sized +// index build's sorters. The shared bulkSpillKeyChunkBytes (8MiB) is sized // for the bulk import, where lanes × index-families sorters are alive at once // and small arenas bound aggregate memory. The deferred build is the opposite -// shape — one producer, one sorter, nothing else running — and with 8MiB -// chunks a whale (57M+ index keys ≈ 6.4GB) produced an 801-way final merge: -// ~10 heap comparisons per entry plus 801 open chunk files with 1MiB readers -// (~800MB of buffers). 128MiB chunks cut that to ~50 runs. Peak memory is the -// active arena plus up to `sorters` chunks being sorted in background -// (~640MB), in the same ballpark as what the wide merge's read buffers used. +// shape — one producer, at most two sorter families, nothing else running — +// and with 8MiB chunks a whale (57M+ index keys ≈ 6.4GB) produced an 801-way +// final merge: ~10 heap comparisons per entry plus 801 open chunk files with +// 1MiB readers (~800MB of buffers). 128MiB chunks cut that to ~50 runs. +// +// Memory budget (revised for the second family): with the grant digest +// index enabled the scan feeds TWO sorter families — by_principal +// (key-only) and by_entitlement_principal_hash (8-byte values) — which +// share the sort semaphore, so the peak is two filling arenas plus up +// to `sorters` (≤4) arenas in background sorts: ≈ 6 × 128MiB = 768MiB, +// roughly double the single-family budget. That stays acceptable +// because the build runs alone at EndSync (the sync's write pipeline +// and its arenas are already drained) and each family's merge width +// stays ~50 for whale-scale inputs. Each family gets its own bounded +// freelist sized to the sort concurrency, so retained (idle) arenas +// are capped at the same order. const deferredIndexSpillChunkBytes = 128 << 20 // appendGrantByPrincipalKeyFromPrimary builds the by_principal index key @@ -58,7 +68,7 @@ func appendGrantByPrincipalKeyFromPrimary(dst, primaryKey []byte) ([]byte, bool) if sep < 0 { return dst, false } - segs[i] = rest[:sep] + segs[i] = rest[:sep] // #nosec G602 -- false positive: IndexByte returned >= 0 above, so sep < len(rest). rest = rest[sep+1:] } if bytes.IndexByte(rest, 0) >= 0 { @@ -338,6 +348,18 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { // no-op once finalize has drained the sorter. defer principal.abort() + // Second sorter family: the by_entitlement_principal_hash rows the + // grant digests fold over (valued entries — the 8-byte content + // hash). Shares the sort semaphore with by_principal; see the + // deferredIndexSpillChunkBytes budget note. Nil when the digest + // index is disabled. + var hashIdx *spillSorter + if e.opts.grantDigestIndex { + hashIdx = newSpillSorter(dir, fmt.Sprintf("index-%02x", idxGrantByEntitlementPrincipalHash), sem, deferredIndexSpillChunkBytes) + hashIdx.free = newSpillArenaFreeList(deferredIndexSpillChunkBytes, sorters+1) + defer hashIdx.abort() + } + iter, err := e.db.NewIter(&pebble.IterOptions{ LowerBound: GrantLowerBound(), UpperBound: GrantUpperBound(), @@ -372,6 +394,12 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { var scanned, droppedMalformedKeys int64 var idxKeyScratch []byte + // Hash-index row emission is non-fatal, mirroring the rebuild tee: + // remember the first error, stop emitting, keep scanning — + // by_principal and the stats stash don't depend on the digests, and + // the seal treats a failed digest build as "drop and re-read". + var hashScanErr error + var hashScratch grantHashRowScratch lastLog := start for iter.First(); iter.Valid(); iter.Next() { totalKeys++ @@ -401,14 +429,20 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { // Only possible on key-layout drift or corruption: every grant // write path derives its key from the same 6-segment encoder. // The row still rides the primary rebuild (totalKeys counts it), - // but it cannot be represented in by_principal — count and warn - // below so a principal silently losing grants is observable. + // but it cannot be represented in by_principal (nor in the hash + // index/digests) — count and warn below so a principal silently + // losing grants is observable. droppedMalformedKeys++ continue } if err := principal.add(idxKey, nil); err != nil { return err } + if hashIdx != nil && hashScanErr == nil { + if err := appendGrantHashIndexRow(hashIdx, iter.Key(), iter.Value(), &hashScratch); err != nil { + hashScanErr = err + } + } scanned++ // Throttle per-row bookkeeping; ctx.Err and time.Now are measurable // at 57M+ rows. @@ -516,33 +550,60 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { zap.Int64("index_keys_added", scanned), zap.Duration("total", time.Since(start)), ) - return nil - } - l.Info("deferred grant index build: merging sorted chunks", - zap.Int("chunks", len(chunks)), - zap.Int64("index_keys_added", scanned), - zap.Duration("scan", scanDone.Sub(start)), - ) - sstPath := filepath.Join(dir, fmt.Sprintf("index-%02x.sst", idxGrantByPrincipal)) - if err := mergeSortedSpillChunksToSST(ctx, sstPath, fmt.Sprintf("index-%02x", idxGrantByPrincipal), chunks); err != nil { - return err - } - mergeDone := time.Now() + } else { + l.Info("deferred grant index build: merging sorted chunks", + zap.Int("chunks", len(chunks)), + zap.Int64("index_keys_added", scanned), + zap.Duration("scan", scanDone.Sub(start)), + ) + sstPath := filepath.Join(dir, fmt.Sprintf("index-%02x.sst", idxGrantByPrincipal)) + if err := mergeSortedSpillChunksToSST(ctx, sstPath, fmt.Sprintf("index-%02x", idxGrantByPrincipal), chunks); err != nil { + return err + } + mergeDone := time.Now() + + _, err = e.db.IngestAndExcise(ctx, []string{sstPath}, nil, nil, pebble.KeyRange{ + Start: GrantByPrincipalLowerBound(), + End: GrantByPrincipalUpperBound(), + }) + if err != nil { + return fmt.Errorf("BuildDeferredGrantIndexes: ingest/excise: %w", err) + } - _, err = e.db.IngestAndExcise(ctx, []string{sstPath}, nil, nil, pebble.KeyRange{ - Start: GrantByPrincipalLowerBound(), - End: GrantByPrincipalUpperBound(), - }) - if err != nil { - return fmt.Errorf("BuildDeferredGrantIndexes: ingest/excise: %w", err) + l.Info("deferred grant index build complete", + zap.Int64("index_keys_scanned", scanned), + zap.Duration("scan", scanDone.Sub(start)), + zap.Duration("merge", mergeDone.Sub(scanDone)), + zap.Duration("ingest", time.Since(mergeDone)), + zap.Duration("total", time.Since(start)), + ) } - ctxzap.Extract(ctx).Info("deferred grant index build complete", - zap.Int64("index_keys_scanned", scanned), - zap.Duration("scan", scanDone.Sub(start)), - zap.Duration("merge", mergeDone.Sub(scanDone)), - zap.Duration("ingest", time.Since(mergeDone)), - zap.Duration("total", time.Since(start)), - ) + // Grant digests + hash index, fused onto the same scan. Non-fatal, + // like the stats sidecar's treatment of seal-time extras: on ANY + // failure — a row emission error remembered mid-scan, a merge or + // ingest error here — every digest node and hash-index row is + // dropped, because a partially built digest that LOOKS present + // would violate the present-means-exact contract (digest.go). + // Absent digests just mean a diff consumer re-reads the grants; the + // next successful seal recalculates them. Context cancellation + // stays fatal — the whole EndSync is being abandoned. + if hashIdx != nil { + digestErr := hashScanErr + if digestErr == nil { + digestErr = e.buildGrantDigestsFromSpill(ctx, dir, hashIdx) + } + if digestErr != nil { + if ctx.Err() != nil { + return digestErr + } + l.Error("deferred grant index build: grant digest/hash-index build failed; dropping digest state — grant-diff callers must re-read grants until the next successful seal", + zap.Error(digestErr), + ) + if dropErr := e.dropAllGrantDigestStateLocked(); dropErr != nil { + return fmt.Errorf("BuildDeferredGrantIndexes: drop grant digest state after failed build: %w", dropErr) + } + } + } return nil } diff --git a/pkg/dotc1z/engine/pebble/digest.go b/pkg/dotc1z/engine/pebble/digest.go new file mode 100644 index 000000000..c63a214db --- /dev/null +++ b/pkg/dotc1z/engine/pebble/digest.go @@ -0,0 +1,757 @@ +package pebble + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/cockroachdb/pebble/v2" +) + +// Bucketed XOR set digests over bucket-hash indexes. +// +// Goal: answer "does this partition hold exactly the same records as +// some other sync/file?" with a single key read, and when the answer is +// no, identify which hash buckets differ so a caller can load only +// those records instead of re-reading the whole partition. +// +// The digest is generic over any secondary index matching the shape +// described on digestIndexSpec — a partition prefix followed by a raw +// fixed-width bucket hash, with a per-record content hash as the +// value. Two properties make such an index the right substrate: +// +// - hash-major order is identical across two files that hold the same +// records, so a streamed fold produces the same digest; and +// - a bucket is a contiguous bit-range of the hash, which is a +// contiguous range of the index key, so "all records in bucket P" +// is a single range scan. +// +// The grant instantiation (partition = entitlement identity tail, +// bucket hash = hash(principal identity)) lives in grant_digest.go. +// +// Combiner. A node's digest is the XOR of every content hash beneath +// it (the content hashes themselves are whatever the index writer +// chose — only the combiner is XOR). XOR is homomorphic (parent = XOR +// of children) and order-independent, which buys two things: +// +// - split-independence: a bucket's digest depends only on the records +// in its hash range, never on how the range is subdivided, so +// buckets from digests of different widths compare directly (a +// width-w bucket is the XOR of its two width-(w+1) halves); +// - the empty digest is all-zero (the XOR identity), so an absent +// leaf reads as {count: 0, digest: 0}. +// +// Lifecycle: PRESENT means EXACT. Digest nodes (and the index they +// fold over) are written only by the seal-time build (the deferred +// EndSync pass, see deferred_index.go) — never maintained incrementally +// on the record write paths. Starting a new sync excises the digest +// keyspace along with the rest of the data keyspace, and the mutation +// paths that can run against a file holding built digests drop the +// touched partition's nodes (and its index range) instead of updating +// them. A reader that finds a root may therefore trust it +// unconditionally; a missing root means "not built (or invalidated) — +// recalculate", and comparison treats the whole partition as dirty +// rather than deriving a digest from an index whose presence it cannot +// verify. +// +// Every node also stores its record COUNT. Comparison always checks the +// (count, digest) pair, so a non-empty node whose hashes happened to XOR +// to zero can never be conflated with an empty/absent one. XOR +// set-hashing is not adversarially collision-resistant +// (Bellare–Micciancio); a digest is an optimization, not a trust +// boundary — see RFC 0003 §9. Index writers should fold every +// key-distinguishing field into the content hash so duplicate leaves +// (and thus in-partition self-cancellation) are impossible by +// construction. +// +// Shape. A flat hash table, not a multi-level radix tree: one root plus +// a single leaf level of 2^width buckets, where width — in BITS of the +// bucket hash, 0..digestMaxWidthBits — is chosen per partition so the +// average bucket holds at most digestTargetBucketSize records +// (chooseDigestWidth). Width 0 means root-only (small and empty +// partitions cost exactly one stored node). Growing capacity one bit at +// a time keeps realized bucket occupancy within a 2x band of the +// target; a byte-per-level radix could only pick capacities of 256^k, +// so a partition just past a boundary would store up to ~256x more +// nodes than needed. +// +// Interior levels are deliberately absent: hierarchical pruning only +// pays when the leaf level is too large to scan, and at <= 2^16 leaves +// a contiguous range scan beats a level-by-level descent. Comparison is +// a single merge scan of both sides' leaf levels (see +// dirtyPartitionBuckets); cross-width comparison folds the finer side's +// leaves down to the coarser width on the fly, which split-independence +// makes exact. For the same reason digestTargetBucketSize is a soft +// tuning knob, not an ABI constant: digests built with different +// targets still compare correctly. +// +// Leaves are stored sparsely — a leaf is materialized iff its bucket +// holds >=1 record. The root is always materialized (it is the "digest +// was built" marker — absence of the root means "never built", never +// "empty"). +// +// On-disk ABI. The bucket-hash width, each index's content-hash +// definition, the combiner, the leaf-prefix encoding, and the node +// value framing are all part of the stored format: changing +// digestBucketHashLen, an index's content-hash field set, the combiner, +// or the framing requires an index-migration version bump (see +// index_migrations.go). digestTargetBucketSize is NOT part of the ABI +// (see above). + +const ( + // digestBucketHashLen is the width, in bytes, of the raw bucket + // hash embedded in a digested index's key. It is exactly the bytes + // digestMaxWidthBits can address (16 bits = 2 bytes); storing more + // would be dead weight on every index row, since only the top + // digestMaxWidthBits ever select a bucket. Collisions in the bucket + // address are harmless (they only co-locate records — the index + // key's tail still distinguishes rows). ABI. + digestBucketHashLen = 2 + + // digestTargetBucketSize is the record count a single leaf bucket + // aims to hold. The width is grown until 2^width buckets bring the + // average bucket under this. Tunable without a migration: the width + // is read from the stored root and cross-width comparison folds to + // the coarser side. + digestTargetBucketSize = 512 + + // digestMaxWidthBits caps the leaf-level width. 2^16 buckets keeps + // the comparison's full leaf scan trivially cheap; past the cap + // (digestTargetBucketSize << 16 records) buckets simply grow beyond + // the target. + digestMaxWidthBits = 16 + + // digestLeafPrefixLen is the stored byte width of a leaf key's + // bucket prefix: the bucket index LEFT-ALIGNED in 16 bits, so leaf + // keys sort in bucket-hash order at every width and folding to a + // coarser width is "take the top bits". ABI. + digestLeafPrefixLen = 2 +) + +// Node-key levels: the root is level 0 (empty prefix); the single leaf +// level is 1 (digestLeafPrefixLen-byte prefix). See encodeDigestNodeKey. +const ( + digestLevelRoot byte = 0 + digestLevelLeaf byte = 1 +) + +// hashLen is the width of a content hash (index value) and of a node +// digest. +const hashLen = 8 + +// zeroDigest is the XOR identity — the digest of an empty/absent node. +var zeroDigest [hashLen]byte + +// xorInto XORs src into dst in place, over min(len(dst), len(src)). +func xorInto(dst, src []byte) { + for i := range min(len(dst), len(src)) { + dst[i] ^= src[i] + } +} + +// digestIndexSpec describes one bucket-hash index the digest core can +// fold. The index must have the shape +// +// index key = partitionPrefix(partition) | | +// index value = content hash (hashLen bytes) +// +// The content hash defines record identity for the diff and must fold +// every key-distinguishing field (see the package comment); the bucket +// hash must be derived from fields that are stable across syncs. +type digestIndexSpec struct { + // indexID discriminates this index's nodes inside the typeDigest + // keyspace. Conventionally the digested index's own idx* byte. ABI. + indexID byte + + // partitionPrefix returns the index-key prefix covering one + // partition's entries, ending immediately before the raw bucket + // hash (trailing separator included). + partitionPrefix func(partition string) []byte +} + +// DigestBucket addresses one hash bucket of a partition: the records +// whose bucket hash starts with the top Bits bits of Index. The zero +// value (Bits 0) addresses the whole partition. +type DigestBucket struct { + Index uint32 + Bits int +} + +// leafKeyPrefix returns the stored digestLeafPrefixLen-byte node-key +// prefix for a leaf bucket: the index left-aligned in 16 bits. +// Requires 1 <= Bits <= digestMaxWidthBits. +func (b DigestBucket) leafKeyPrefix() []byte { + out := make([]byte, digestLeafPrefixLen) + binary.BigEndian.PutUint16(out, uint16(b.Index)<<(16-b.Bits)) //nolint:gosec // Index < 2^Bits <= 2^16 by construction + return out +} + +// bucketOfHash returns the width-`bits` bucket holding bucket hash bh. +// Requires 1 <= bits <= digestMaxWidthBits. +func bucketOfHash(bh []byte, bits int) DigestBucket { + u := binary.BigEndian.Uint16(bh[:digestLeafPrefixLen]) + return DigestBucket{Index: uint32(u >> (16 - bits)), Bits: bits} +} + +// bucketBounds returns the index key range [lower, upper) covering a +// bucket's records in one partition. The raw bucket hash is a clean +// byte region of the index key, so a bit-granular bucket maps to plain +// uint64 arithmetic on that region. +func (s digestIndexSpec) bucketBounds(partition string, b DigestBucket) ([]byte, []byte) { + prefix := s.partitionPrefix(partition) + if b.Bits == 0 { + return prefix, upperBoundOf(prefix) + } + boundAt := func(hash uint64) []byte { + // hash carries its bucket bits in the top digestMaxWidthBits, so + // the bound is the top digestBucketHashLen bytes of the big-endian + // uint64 appended to the partition prefix. + var full [8]byte + binary.BigEndian.PutUint64(full[:], hash) + out := make([]byte, 0, len(prefix)+digestBucketHashLen) + out = append(out, prefix...) + return append(out, full[:digestBucketHashLen]...) + } + // Signed shift count (Go >= 1.13): b.Bits is in [1, digestMaxWidthBits] + // here (the Bits==0 case returned above), so shift is in [48, 63] — + // no uint conversion, so no overflow-checker warning to suppress. + shift := 64 - b.Bits + lower := boundAt(uint64(b.Index) << shift) + if uint64(b.Index)+1 == uint64(1)< digestMaxWidthBits { + return 0, 0, nil, false + } + widthBits := int(val[0]) + count := int64(binary.BigEndian.Uint64(val[1:9])) //nolint:gosec // count is a non-negative row count + return widthBits, count, val[9:], true +} + +func packDigestLeaf(count int64, digest []byte) []byte { + buf := make([]byte, 0, 8+len(digest)) + var n [8]byte + binary.BigEndian.PutUint64(n[:], uint64(count)) //nolint:gosec // non-negative row count + buf = append(buf, n[:]...) + return append(buf, digest...) +} + +// unpackDigestLeaf returns (count, digest, ok) for a leaf node body. +func unpackDigestLeaf(val []byte) (int64, []byte, bool) { + if len(val) != 8+hashLen { + return 0, nil, false + } + count := int64(binary.BigEndian.Uint64(val[:8])) //nolint:gosec // non-negative count + return count, val[8:], true +} + +// digestLeafNode is one materialized leaf produced by a partition +// fold: the stored left-aligned bucket prefix and the packed +// count||digest value. +type digestLeafNode struct { + prefix [digestLeafPrefixLen]byte + val []byte +} + +// foldPartitionNodes folds one partition's index range into its digest +// nodes: the packed root value plus every non-empty leaf, in ascending +// bucket order (the order the sorted index scan produces them). +// Read-only — callers decide persistence: a batch for the standalone +// single-partition rebuild (buildPartitionDigestAtWidth); the seal-time +// bulk build streams its own fold instead (see deferred_index.go). +// Memory is O(stored leaves), bounded by 2^digestMaxWidthBits nodes. +// +// Sorted index order means each bucket's entries are contiguous, so the +// single "open" leaf closes exactly when its prefix changes. Only +// non-empty leaves are ever opened, so sparsity is automatic, not a +// prune pass. +// +// Callers must hold writeMu (all do): folding outside the writer mutex +// could interleave with a record write and persist a digest that +// matches neither the before nor the after state. +func (e *Engine) foldPartitionNodes(ctx context.Context, spec digestIndexSpec, partition string, widthBits int) ([]byte, []digestLeafNode, error) { + prefix := spec.partitionPrefix(partition) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: prefix, UpperBound: upperBoundOf(prefix)}) + if err != nil { + return nil, nil, err + } + defer iter.Close() + + // lowMask clears the sub-bucket bits of a left-aligned 16-bit + // prefix, leaving the leaf's stored key prefix value. + var lowMask uint16 + if widthBits > 0 { + lowMask = ^uint16(0) >> widthBits + } + + var ( + leaves []digestLeafNode + leafOpen bool + leafLV uint16 // left-aligned stored prefix of the open leaf + leafDigest [hashLen]byte + leafCount int64 + rootDigest [hashLen]byte + total int64 + ) + flushLeaf := func() { + if !leafOpen { + return + } + var n digestLeafNode + binary.BigEndian.PutUint16(n.prefix[:], leafLV) + n.val = packDigestLeaf(leafCount, leafDigest[:]) + leaves = append(leaves, n) + } + + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + key := iter.Key() + if len(key) < len(prefix)+digestBucketHashLen { + continue // malformed; skip defensively + } + val := iter.Value() // per-record content hash + if len(val) != hashLen { + // Index writers always emit exactly hashLen bytes; a wrong + // length is a corrupt or mis-encoded entry. xorInto would + // fold only a prefix and quietly corrupt the digest, so + // reject it. At seal the caller downgrades a build error to + // "no digest" (drop + re-read), so this fails safe. + return nil, nil, fmt.Errorf("foldPartitionNodes: content hash for %q is %d bytes, want %d", partition, len(val), hashLen) + } + if widthBits > 0 { + lv := binary.BigEndian.Uint16(key[len(prefix):]) &^ lowMask + if !leafOpen || lv != leafLV { + flushLeaf() + leafLV = lv + leafDigest = [hashLen]byte{} + leafCount = 0 + leafOpen = true + } + xorInto(leafDigest[:], val) + leafCount++ + } + xorInto(rootDigest[:], val) + total++ + } + if err := iter.Error(); err != nil { + return nil, nil, err + } + flushLeaf() + + // The root is produced unconditionally — even at count 0 — as the + // "digest was built" marker. + return packDigestRoot(widthBits, total, rootDigest[:]), leaves, nil +} + +// buildPartitionDigestAtWidth rebuilds ONE partition's digest in place +// through a write batch. This is the standalone/repair path (tests, +// explicit rebuilds); the seal-time bulk build streams every +// partition's nodes during the deferred-pass merge instead (see +// deferred_index.go). +// +// The batch starts by range-deleting the partition's whole node +// keyspace: the build only ever Sets nodes, so without the clear a +// rebuild that changes width or empties a bucket would leave stale +// leaves that the comparison merge scan (which enumerates leaves from +// the node keyspace) would read — and a stale digest that happens to +// match the peer prunes a real diff. Old and new framings are +// byte-length identical, so stale nodes are not detectable by +// inspection. +// +// The width is taken as a parameter rather than derived so the +// width-selection seam can be exercised directly: tests force a width +// that the natural count→width mapping would only produce at a very +// large record count, which is how the cross-width comparison path gets +// covered without seeding hundreds of thousands of records. +// +// Test-only today (no production caller). If a future production path +// reuses this for the grant digest specifically, it MUST also either +// invalidate or recompute the whole-file global root +// (globalGrantDigestNodeKey) alongside it: +// RepairMissingGrantDigests's fast path trusts the global root's mere +// presence to mean every partition is present and correct, and this +// function rewrites one partition's content without touching that +// root at all. +func (e *Engine) buildPartitionDigestAtWidth(ctx context.Context, spec digestIndexSpec, partition string, widthBits int) error { + nodeLower := encodeDigestPartitionPrefix(spec.indexID, partition) + nodeUpper := upperBoundOf(nodeLower) + + return e.withWrite(func() error { + rootVal, leaves, err := e.foldPartitionNodes(ctx, spec, partition, widthBits) + if err != nil { + return err + } + + batch := e.db.NewBatch() + defer batch.Close() + + // Clear any prior build (see function comment). In-batch + // ordering makes this safe: the Sets below land after the + // tombstone and survive it. + if err := batch.DeleteRange(nodeLower, nodeUpper, nil); err != nil { + return err + } + rootKey := encodeDigestNodeKey(spec.indexID, partition, digestLevelRoot, nil) + if err := batch.Set(rootKey, rootVal, nil); err != nil { + return err + } + for i := range leaves { + key := encodeDigestNodeKey(spec.indexID, partition, digestLevelLeaf, leaves[i].prefix[:]) + if err := batch.Set(key, leaves[i].val, nil); err != nil { + return err + } + } + + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + if err := batch.Commit(opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + return nil + }) +} + +// DigestRoot is a partition's stored root digest. +type DigestRoot struct { + Hash []byte + Bits int // leaf-level width in bits; 0 = root-only digest + Count int64 +} + +// getPartitionDigestRoot returns the stored root for a partition. ok +// is false when no digest has been built for it (or it was +// invalidated) — which means RECALCULATE FROM THE PRIMARY RECORDS (or +// treat the whole partition as dirty), never "fold the index instead": +// the index is only ever written and dropped alongside the digest +// nodes, so with no root the index range is absent too and +// computeBucketDigest would read that absence as "zero records" — the +// false-clean trap dirtyPartitionBuckets' doc comment describes. +func (e *Engine) getPartitionDigestRoot(spec digestIndexSpec, partition string) (DigestRoot, bool, error) { + if e.grantDigestBuildPending.Load() { + // An interrupted digest build's half-committed nodes may be + // durable while its hash index never ingested; until the pending + // state is consumed (a writable Open drops it; a read-only open + // cannot), no stored root may be trusted — report "never built", + // which every consumer already treats as "recalculate". + return DigestRoot{}, false, nil + } + val, closer, err := e.db.Get(encodeDigestNodeKey(spec.indexID, partition, digestLevelRoot, nil)) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return DigestRoot{}, false, nil + } + return DigestRoot{}, false, err + } + defer closer.Close() + widthBits, count, h, valid := unpackDigestRoot(val) + if !valid { + return DigestRoot{}, false, fmt.Errorf("getPartitionDigestRoot: malformed root for %q", partition) + } + out := make([]byte, len(h)) + copy(out, h) + return DigestRoot{Hash: out, Bits: widthBits, Count: count}, true, nil +} + +// getDigestLeaf reads one stored leaf by its key prefix. An absent leaf +// returns (0, zero digest, present=false, nil) — the XOR identity. +func (e *Engine) getDigestLeaf(spec digestIndexSpec, partition string, leafPrefix []byte) (int64, []byte, bool, error) { + val, closer, err := e.db.Get(encodeDigestNodeKey(spec.indexID, partition, digestLevelLeaf, leafPrefix)) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return 0, zeroDigest[:], false, nil + } + return 0, nil, false, err + } + defer closer.Close() + count, digest, ok := unpackDigestLeaf(val) + if !ok { + return 0, nil, false, fmt.Errorf("getDigestLeaf: malformed leaf for %q", partition) + } + out := make([]byte, hashLen) + copy(out, digest) + return count, out, true, nil +} + +// foldedBucket is one entry of a folded leaf scan: the (XOR, count) +// aggregate of the consecutive stored leaves sharing the top `bits` +// bits of their bucket index. +type foldedBucket struct { + idx uint32 + count int64 + digest [hashLen]byte +} + +// foldedLeafBuckets scans a partition's stored leaf level and folds it +// to width foldBits (which must be <= the width the digest was built +// at), returning the non-empty buckets in index order. One contiguous +// range scan; folding is exact because leaf prefixes are left-aligned +// (so keys sort in bucket-hash order at any width) and XOR digests are +// split-independent. +func (e *Engine) foldedLeafBuckets(ctx context.Context, spec digestIndexSpec, partition string, foldBits int) ([]foldedBucket, error) { + stem := encodeDigestNodeKey(spec.indexID, partition, digestLevelLeaf, nil) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: stem, UpperBound: upperBoundOf(stem)}) + if err != nil { + return nil, err + } + defer iter.Close() + var out []foldedBucket + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return nil, err + } + key := iter.Key() + if len(key) != len(stem)+digestLeafPrefixLen { + continue // malformed; skip defensively + } + lv := binary.BigEndian.Uint16(key[len(stem):]) + idx := uint32(lv >> (16 - foldBits)) + count, digest, ok := unpackDigestLeaf(iter.Value()) + if !ok { + return nil, fmt.Errorf("foldedLeafBuckets: malformed leaf for %q", partition) + } + if n := len(out); n > 0 && out[n-1].idx == idx { + out[n-1].count += count + xorInto(out[n-1].digest[:], digest) + continue + } + fb := foldedBucket{idx: idx, count: count} + copy(fb.digest[:], digest) + out = append(out, fb) + } + return out, iter.Error() +} + +// computeBucketDigest folds the index over a single bucket (the zero +// bucket = whole partition = the root) and returns the content-defined +// XOR digest plus the record count. This is the authoritative +// definition of a node; stored nodes are a cache of it. +// Split-independent: the digest depends only on the records in the +// bucket's hash range, not on any digest's width. +// +// PRECONDITION: only meaningful while the partition's digest is built +// (its root is stored) — the index rows this folds exist exactly as +// long as the digest nodes do. Against a never-built or invalidated +// partition the fold reads an absent index range and returns {0, 0}, +// indistinguishable from a truly empty partition; it must never be +// used as a fallback for a missing root (see getPartitionDigestRoot). +func (e *Engine) computeBucketDigest(ctx context.Context, spec digestIndexSpec, partition string, bucket DigestBucket) ([]byte, int64, error) { + lower, upper := spec.bucketBounds(partition, bucket) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) + if err != nil { + return nil, 0, err + } + defer iter.Close() + digest := make([]byte, hashLen) + var count int64 + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return nil, 0, err + } + val := iter.Value() + if len(val) != hashLen { + // See buildPartitionDigestAtWidth: a mis-length index value is + // corruption; reject it rather than silently fold a prefix. + return nil, 0, fmt.Errorf("computeBucketDigest: content hash for %q is %d bytes, want %d", partition, len(val), hashLen) + } + xorInto(digest, val) + count++ + } + if err := iter.Error(); err != nil { + return nil, 0, err + } + return digest, count, nil +} + +// computeBucketsAtWidth scans the index for one partition and rolls it +// up into width-`bits` buckets directly — O(records), one contiguous +// index scan, O(1) memory. It is the fallback for +// GetEntitlementGrantDigestNodes when the requested level is finer than +// the stored digest's width (foldedLeafBuckets only goes as fine as what +// was built). bits must be in [1, digestMaxWidthBits]; the caller clamps +// to the bucket-hash resolution. Returns the non-empty buckets in index +// order — index entries are bucket-hash-major, so each bucket's records +// are contiguous and close when the top-`bits` prefix changes. +func (e *Engine) computeBucketsAtWidth(ctx context.Context, spec digestIndexSpec, partition string, bits int) ([]foldedBucket, error) { + prefix := spec.partitionPrefix(partition) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: prefix, UpperBound: upperBoundOf(prefix)}) + if err != nil { + return nil, err + } + defer iter.Close() + var out []foldedBucket + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return nil, err + } + key := iter.Key() + if len(key) < len(prefix)+digestBucketHashLen { + continue // malformed; skip defensively + } + // The bucket hash is the digestBucketHashLen raw bytes right after + // the partition prefix; its top `bits` bits select the bucket. + idx := uint32(binary.BigEndian.Uint16(key[len(prefix):]) >> (16 - bits)) + val := iter.Value() + if len(val) != hashLen { + // See buildPartitionDigestAtWidth: a mis-length index value is + // corruption; reject it rather than fold a prefix. + return nil, fmt.Errorf("computeBucketsAtWidth: content hash for %q is %d bytes, want %d", partition, len(val), hashLen) + } + if len(out) == 0 || out[len(out)-1].idx != idx { + out = append(out, foldedBucket{idx: idx}) + } + i := len(out) - 1 + xorInto(out[i].digest[:], val) + out[i].count++ + } + return out, iter.Error() +} + +// dirtyPartitionBuckets compares this engine's partition against +// other's and returns the buckets whose records differ. A single zero +// bucket (Bits 0) means "the whole partition differs" (used when the +// comparison granularity is the root, e.g. small partitions). A nil +// (empty) result means the two are identical. +// +// The fast path is a single root read per side. On mismatch both sides' +// leaf levels are folded to compareBits — the narrower digest's width, +// where both sides have directly comparable buckets (XOR digests are +// split-independent) — and merge-compared in one pass. Each side's fold +// is a single contiguous range scan of its stored leaves, so cost is +// O(leaves), bounded by count/digestTargetBucketSize per side. +// +// A missing root means the digest was never built (or was invalidated) +// on that side — NOT that the partition is empty. There is no safe +// cheaper answer in that state: deriving a digest from the index would +// trust an index whose presence this side can't verify (a file written +// with the index disabled folds to "zero records" and two such files +// would compare falsely clean). So the whole partition is reported +// dirty; the caller re-reads it, or rebuilds the digest first. +func (e *Engine) dirtyPartitionBuckets(ctx context.Context, spec digestIndexSpec, other *Engine, partition string) ([]DigestBucket, error) { + rootA, okA, err := e.getPartitionDigestRoot(spec, partition) + if err != nil { + return nil, err + } + rootB, okB, err := other.getPartitionDigestRoot(spec, partition) + if err != nil { + return nil, err + } + + if !okA || !okB { + return []DigestBucket{{}}, nil + } + + if rootA.Count == rootB.Count && bytes.Equal(rootA.Hash, rootB.Hash) { + return nil, nil + } + + // Roots differ. The comparison granularity is the narrower digest's + // width; at width 0 there is nothing below the root. + compareBits := min(rootA.Bits, rootB.Bits) + if compareBits == 0 { + return []DigestBucket{{}}, nil + } + + fa, err := e.foldedLeafBuckets(ctx, spec, partition, compareBits) + if err != nil { + return nil, err + } + fb, err := other.foldedLeafBuckets(ctx, spec, partition, compareBits) + if err != nil { + return nil, err + } + + // Merge the two sorted folded-bucket streams. A bucket present on + // only one side is dirty by construction (stored leaves are never + // empty); a shared bucket is dirty iff its (count, digest) differs. + var dirty []DigestBucket + i, j := 0, 0 + for i < len(fa) || j < len(fb) { + switch { + case j == len(fb) || (i < len(fa) && fa[i].idx < fb[j].idx): + dirty = append(dirty, DigestBucket{Index: fa[i].idx, Bits: compareBits}) + i++ + case i == len(fa) || fb[j].idx < fa[i].idx: + dirty = append(dirty, DigestBucket{Index: fb[j].idx, Bits: compareBits}) + j++ + default: + if fa[i].count != fb[j].count || fa[i].digest != fb[j].digest { + dirty = append(dirty, DigestBucket{Index: fa[i].idx, Bits: compareBits}) + } + i++ + j++ + } + } + + // Roots differed but the merge found nothing: with consistent + // digests that's impossible (the root is the XOR of the leaves), so + // a stored node is stale or corrupt. Fail safe — whole partition + // dirty; the next rebuild heals the digest. + if len(dirty) == 0 { + return []DigestBucket{{}}, nil + } + return dirty, nil +} + +// --- Invalidation --- + +// dropPartitionDigest stages a DeleteRange over one partition's digest +// nodes (root included). Record mutations that can run against a file +// holding a built digest use this instead of updating nodes in place: +// under the present-means-exact contract, a mutated partition's digest +// is simply MISSING until the next seal-time build recalculates it. +func dropPartitionDigest(batch *pebble.Batch, spec digestIndexSpec, partition string) error { + lo := encodeDigestPartitionPrefix(spec.indexID, partition) + return batch.DeleteRange(lo, upperBoundOf(lo), nil) +} diff --git a/pkg/dotc1z/engine/pebble/digest_test.go b/pkg/dotc1z/engine/pebble/digest_test.go new file mode 100644 index 000000000..882076bde --- /dev/null +++ b/pkg/dotc1z/engine/pebble/digest_test.go @@ -0,0 +1,1523 @@ +package pebble + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "testing" + + "github.com/cockroachdb/pebble/v2" + "github.com/segmentio/ksuid" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/connectorstore" +) + +// putEnt writes an entitlement record whose external_id is the +// canonical SDK shape ("app:github:"+entID) — the same string grants +// reference via EntitlementRef.EntitlementId (see makeGrant), so the +// entitlement record and its grants share one structural identity and +// therefore one digest partition. +func putEnt(t testing.TB, e *Engine, ctx context.Context, entID string) { + t.Helper() + rec := v3.EntitlementRecord_builder{ + ExternalId: canonicalTestEntID(entID), + Resource: v3.ResourceRef_builder{ + ResourceTypeId: "app", + ResourceId: "github", + }.Build(), + }.Build() + if err := e.PutEntitlementRecord(ctx, rec); err != nil { + t.Fatalf("PutEntitlementRecord: %v", err) + } +} + +// testEntIdentity is the structural identity the digest keyspace is +// addressed by for a makeGrant/putEnt-shaped entitlement. +func testEntIdentity(entID string) entitlementIdentity { + return entitlementIdentityFromParts("app", "github", canonicalTestEntID(entID)) +} + +// testEntPartition is the digest partition for a test entitlement. +func testEntPartition(entID string) string { + return digestPartitionForEntitlement(testEntIdentity(entID)) +} + +// testV2Ent is the full entitlement stub (id + resource ref) the digest +// reader methods take — the same shape ListGrantsForEntitlement +// requests carry, so identity derives exactly from the structured +// parts with no bare-id resolution. +func testV2Ent(entID string) *v2.Entitlement { + return v2.Entitlement_builder{ + Id: canonicalTestEntID(entID), + Resource: v2.Resource_builder{ + Id: v2.ResourceId_builder{ + ResourceType: "app", + Resource: "github", + }.Build(), + }.Build(), + }.Build() +} + +// makeGrantWithSources is makeGrant plus an optional source-entitlement +// set, which the content hash folds in — so two grants with the same +// identity but different sources produce different content hashes while +// keeping the SAME index key. +func makeGrantWithSources(syncID, externalID, entID, principalID string, sources ...string) *v3.GrantRecord { + g := makeGrant(syncID, externalID, entID, principalID) + if len(sources) > 0 { + m := make(map[string]*v3.GrantSourceRecord, len(sources)) + for _, s := range sources { + m[s] = v3.GrantSourceRecord_builder{}.Build() + } + g.SetSources(m) + } + return g +} + +// sealGrantDigests runs the seal-time deferred pass (whose fused digest +// build is the production writer of the hash index + digests). +func sealGrantDigests(t testing.TB, e *Engine) { + t.Helper() + if err := e.BuildDeferredGrantIndexes(context.Background()); err != nil { + t.Fatalf("BuildDeferredGrantIndexes: %v", err) + } +} + +// digestNodeCount counts stored PER-PARTITION digest nodes (across all +// partitions and digested indexes) — i.e. everything callers of this +// helper actually assert shapes about (root-only, root+leaves, ...). +// It excludes the whole-file grant-digest global root (see +// globalGrantDigestNodeKey): that node lives in the same typeDigest +// keyspace but is a single fold-of-everything summary the seal build +// writes once per file, not a per-partition node, so counting it here +// would throw off every existing "N nodes for this one entitlement" +// assertion by a constant +1. +func digestNodeCount(t testing.TB, e *Engine) int { + t.Helper() + n := countKeyRangeTest(t, e, DigestLowerBound(), DigestUpperBound()) + if _, ok, err := e.GetGrantDigestGlobalRoot(context.Background()); err != nil { + t.Fatalf("GetGrantDigestGlobalRoot: %v", err) + } else if ok { + n-- + } + return n +} + +// rawLeafPrefixes returns the stored 2-byte leaf key prefixes for one +// entitlement's grant digest, in key order. +func rawLeafPrefixes(t testing.TB, e *Engine, entID string) [][]byte { + t.Helper() + stem := encodeDigestNodeKey(grantDigestSpec.indexID, testEntPartition(entID), digestLevelLeaf, nil) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: stem, UpperBound: upperBoundOf(stem)}) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + defer iter.Close() + var out [][]byte + for iter.First(); iter.Valid(); iter.Next() { + key := iter.Key() + if len(key) != len(stem)+digestLeafPrefixLen { + t.Fatalf("leaf key with prefix length %d, want %d", len(key)-len(stem), digestLeafPrefixLen) + } + out = append(out, append([]byte(nil), key[len(stem):]...)) + } + if err := iter.Error(); err != nil { + t.Fatalf("iter: %v", err) + } + return out +} + +// countKeyRangeTest counts stored keys in [lo, hi). +func countKeyRangeTest(t testing.TB, e *Engine, lo, hi []byte) int { + t.Helper() + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lo, UpperBound: hi}) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + defer iter.Close() + n := 0 + for iter.First(); iter.Valid(); iter.Next() { + n++ + } + if err := iter.Error(); err != nil { + t.Fatalf("iter: %v", err) + } + return n +} + +// entHashIndexRowCount counts hash-index rows under one entitlement's +// partition. +func entHashIndexRowCount(t testing.TB, e *Engine, entID string) int { + t.Helper() + prefix := encodeGrantByEntPrincHashEntPrefix(testEntPartition(entID)) + return countKeyRangeTest(t, e, prefix, upperBoundOf(prefix)) +} + +// TestGrantDigestIndexSealOnly verifies the seal-only lifecycle: grant +// writes never produce hash-index rows or digest nodes inline; the +// adapter's EndSync produces both when the digest index is enabled and +// neither when it is disabled. The grants here go through the INLINE +// index path (PutGrantRecords), so this also covers the standalone +// EndSync digest build (deferredIdxPending never arms). +func TestGrantDigestIndexSealOnly(t *testing.T) { + ctx := context.Background() + grants := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + } + write := func(e *Engine) *Adapter { + t.Helper() + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + return a + } + + // Default (index on): nothing inline; seal derives rows + digests. + on, _ := newTestEngine(t) + a := write(on) + if got := countKeyRangeTest(t, on, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()); got != 0 { + t.Fatalf("pre-seal: hash index rows = %d, want 0 (never written inline)", got) + } + if got := digestNodeCount(t, on); got != 0 { + t.Fatalf("pre-seal: digest nodes = %d, want 0 (never written inline)", got) + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + if got := countKeyRangeTest(t, on, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()); got != len(grants) { + t.Fatalf("sealed: hash index rows = %d, want %d", got, len(grants)) + } + if got := digestNodeCount(t, on); got == 0 { + t.Fatal("sealed: no digest nodes built") + } + + // Disabled: seal skips the derivation entirely. + off, _ := newTestEngine(t, WithGrantDigestIndex(false)) + aOff := write(off) + if err := aOff.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + if got := countKeyRangeTest(t, off, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()); got != 0 { + t.Fatalf("digest index off: hash index rows = %d, want 0", got) + } + if got := digestNodeCount(t, off); got != 0 { + t.Fatalf("digest index off: digest nodes = %d, want 0", got) + } + // The other grant indexes are still maintained. + if got := countKeyRangeTest(t, off, GrantByPrincipalLowerBound(), GrantByPrincipalUpperBound()); got != len(grants) { + t.Fatalf("digest index off: by_principal rows = %d, want %d", got, len(grants)) + } +} + +// TestGrantDigestIncludesExpandedGrants guards the seal-time +// derivation end-to-end through the FUSED path: expanded grants go +// through the deferred index write path, arming the marker, so EndSync +// runs BuildDeferredGrantIndexes and the digest build fuses onto its +// scan — covering directly-synced and expanded grants alike, because +// both are derived from the grant primaries. +func TestGrantDigestIncludesExpandedGrants(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + + // One directly-synced grant. + if err := e.PutGrantRecords(ctx, makeGrant("", "g-direct", "ent-A", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + // One expanded grant (with a source), via the expansion write path — + // this arms the deferred-index marker. + exp := makeGrantWithSources("", "g-expanded", "ent-A", "bob", "src-ent") + if err := e.PutExpandedGrantRecords(ctx, []*v3.GrantRecord{exp}); err != nil { + t.Fatalf("PutExpandedGrantRecords: %v", err) + } + if !e.deferredIdxPending.Load() { + t.Fatal("expected the expansion write to arm the deferred-index marker") + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + + if got := countKeyRangeTest(t, e, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()); got != 2 { + t.Fatalf("hash index rows = %d, want 2 (direct + expanded)", got) + } + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("root: ok=%v err=%v", ok, err) + } + if root.Count != 2 { + t.Fatalf("digest root count = %d, want 2 (expanded grant must be in the digest)", root.Count) + } +} + +// TestAdapterGetEntitlementGrantDigest exercises the reader capability +// (connectorstore.EntitlementGrantDigestReader) end-to-end through the +// Adapter: a sealed sync resolves and returns the root hash + count, an +// unknown entitlement reports not-found, and an engine with the digest +// index disabled reports not-found even for a real entitlement. +func TestAdapterGetEntitlementGrantDigest(t *testing.T) { + ctx := context.Background() + + seal := func(e *Engine) *Adapter { + t.Helper() + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + if err := e.PutGrantRecords(ctx, + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + return a + } + + // Digest index on: the entitlement resolves with its grant count. + on, _ := newTestEngine(t) + a := seal(on) + d, found, err := a.GetEntitlementGrantDigest(ctx, testV2Ent("ent-A")) + if err != nil { + t.Fatalf("GetEntitlementGrantDigest: %v", err) + } + if !found { + t.Fatal("expected found for ent-A") + } + if d.Count != 2 { + t.Fatalf("count = %d, want 2", d.Count) + } + if len(d.Hash) == 0 { + t.Fatal("expected non-empty hash") + } + + // An id-only stub (no resource ref) resolves through the bare-id + // fallback to the same digest. + bare := v2.Entitlement_builder{Id: canonicalTestEntID("ent-A")}.Build() + dBare, found, err := a.GetEntitlementGrantDigest(ctx, bare) + if err != nil || !found { + t.Fatalf("bare-id stub: found=%v err=%v", found, err) + } + if dBare.Count != d.Count || !bytes.Equal(dBare.Hash, d.Hash) { + t.Fatal("bare-id stub resolved to a different digest than the full stub") + } + + // Unknown entitlement: not found, no error — both as a full stub + // (identity derives, no root exists) and as a bare id (resolution + // misses). + if _, found, err := a.GetEntitlementGrantDigest(ctx, testV2Ent("ent-missing")); err != nil || found { + t.Fatalf("unknown entitlement (full stub): found=%v err=%v, want found=false err=nil", found, err) + } + bareMissing := v2.Entitlement_builder{Id: canonicalTestEntID("ent-missing")}.Build() + if _, found, err := a.GetEntitlementGrantDigest(ctx, bareMissing); err != nil || found { + t.Fatalf("unknown entitlement (bare id): found=%v err=%v, want found=false err=nil", found, err) + } + + // Disabled: a real entitlement reports not-found (no digest built). + off, _ := newTestEngine(t, WithGrantDigestIndex(false)) + aOff := seal(off) + if _, found, err := aOff.GetEntitlementGrantDigest(ctx, testV2Ent("ent-A")); err != nil || found { + t.Fatalf("digest off: found=%v err=%v, want found=false err=nil", found, err) + } +} + +// TestAdapterGrantDigestNodes exercises the rollup-node reader: the +// native level is exposed, level 0 returns the root, level == native +// returns the stored leaves (whose counts sum to the total and whose +// hashes XOR to the root), and a finer level falls back to an index +// scan without error. +func TestAdapterGrantDigestNodes(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + const n = 600 // > digestTargetBucketSize, so the native level is >= 1 + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), "ent-A", ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + + ent := testV2Ent("ent-A") + d, found, err := a.GetEntitlementGrantDigest(ctx, ent) + if err != nil || !found { + t.Fatalf("digest: found=%v err=%v", found, err) + } + if d.Count != n { + t.Fatalf("count = %d, want %d", d.Count, n) + } + if d.Level < 1 { + t.Fatalf("native level = %d, want >= 1 for %d grants", d.Level, n) + } + + // Level 0 → a single root node matching the digest root. + roots, found, err := a.GetEntitlementGrantDigestNodes(ctx, ent, 0) + if err != nil || !found { + t.Fatalf("nodes(0): found=%v err=%v", found, err) + } + if len(roots) != 1 || roots[0].Count != n || !bytes.Equal(roots[0].Hash, d.Hash) { + t.Fatalf("level 0 = %+v, want one root node with count %d and the root hash", roots, n) + } + + // Level == native → the stored leaves: sparse (no zero-count nodes), + // counts sum to the total, hashes XOR to the root. + leaves, found, err := a.GetEntitlementGrantDigestNodes(ctx, ent, d.Level) + if err != nil || !found { + t.Fatalf("nodes(native): found=%v err=%v", found, err) + } + var sum int64 + xor := make([]byte, len(d.Hash)) + for _, nd := range leaves { + if nd.Count == 0 { + t.Fatal("leaf node with count 0 returned; leaves must be sparse") + } + sum += nd.Count + for j := range xor { + xor[j] ^= nd.Hash[j] + } + } + if sum != n { + t.Fatalf("leaf counts sum = %d, want %d", sum, n) + } + if !bytes.Equal(xor, d.Hash) { + t.Fatal("XOR of leaf hashes != root hash") + } + + // Finer than native → index-scan fallback (no error). Same totals: + // counts sum to n and hashes XOR to the root. + finer, found, err := a.GetEntitlementGrantDigestNodes(ctx, ent, d.Level+1) + if err != nil || !found { + t.Fatalf("nodes(native+1): found=%v err=%v, want a scanned result", found, err) + } + sum, xor = 0, make([]byte, len(d.Hash)) + for _, nd := range finer { + sum += nd.Count + for j := range xor { + xor[j] ^= nd.Hash[j] + } + } + if sum != n || !bytes.Equal(xor, d.Hash) { + t.Fatalf("finer-level scan: sum=%d xor-matches-root=%v, want sum %d and matching root", sum, bytes.Equal(xor, d.Hash), n) + } + + // Absurdly fine level → clamped to the hash resolution, still no error. + if _, found, err := a.GetEntitlementGrantDigestNodes(ctx, ent, 999); err != nil || !found { + t.Fatalf("nodes(999): found=%v err=%v, want clamped scan with no error", found, err) + } +} + +// TestAdapterScanGrantBucket exercises the bucket grant scan: level 0 +// scans the whole entitlement, per-bucket scans at the native level +// agree with the rollup node counts and partition the grants, yield can +// stop early, and an unknown entitlement scans nothing without error. +func TestAdapterScanGrantBucket(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + const n = 600 // > digestTargetBucketSize so the native level is >= 1 + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), "ent-A", ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + + ent := testV2Ent("ent-A") + // Level 0 = whole entitlement → every grant. + total := 0 + if err := a.ScanEntitlementGrantBucket(ctx, ent, connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { + total++ + return true + }); err != nil { + t.Fatalf("scan level 0: %v", err) + } + if total != n { + t.Fatalf("level-0 scan yielded %d grants, want %d", total, n) + } + + // Per native-level bucket: each scan's count matches the rollup node, + // and the buckets partition all the grants. + d, _, err := a.GetEntitlementGrantDigest(ctx, ent) + if err != nil { + t.Fatalf("digest: %v", err) + } + nodes, _, err := a.GetEntitlementGrantDigestNodes(ctx, ent, d.Level) + if err != nil { + t.Fatalf("nodes: %v", err) + } + sum := 0 + for _, nd := range nodes { + c := int64(0) + if err := a.ScanEntitlementGrantBucket(ctx, ent, connectorstore.GrantDigestBucket{Level: d.Level, Index: nd.Index}, func(*v2.Grant) bool { + c++ + return true + }); err != nil { + t.Fatalf("scan bucket %d: %v", nd.Index, err) + } + if c != nd.Count { + t.Fatalf("bucket %d: scanned %d grants, node count says %d", nd.Index, c, nd.Count) + } + sum += int(c) + } + if sum != n { + t.Fatalf("per-bucket scans covered %d grants, want %d", sum, n) + } + + // yield can stop early. + calls := 0 + if err := a.ScanEntitlementGrantBucket(ctx, ent, connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { + calls++ + return false + }); err != nil { + t.Fatalf("scan early-stop: %v", err) + } + if calls != 1 { + t.Fatalf("early stop: yield called %d times, want 1", calls) + } + + // Unknown entitlement → nothing, no error. + missing := 0 + if err := a.ScanEntitlementGrantBucket(ctx, testV2Ent("ent-missing"), connectorstore.GrantDigestBucket{Level: 0}, func(*v2.Grant) bool { + missing++ + return true + }); err != nil || missing != 0 { + t.Fatalf("scan unknown entitlement: count=%d err=%v, want 0/nil", missing, err) + } +} + +// seedEntitlement writes the entitlement record + grants and runs the +// seal-time build (hash index + digests), returning the syncID. +func seedEntitlement(t testing.TB, e *Engine, entID string, grants []*v3.GrantRecord) string { + t.Helper() + ctx := context.Background() + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, entID) + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + return syncID +} + +// rebuildDigestAtWidth re-derives the hash index + digests from the +// primaries (the seal-style rebuild) and then rebuilds one +// entitlement's digest at a forced leaf-level width, so a test can +// build two digests of different widths over a small grant set. +func rebuildDigestAtWidth(t testing.TB, e *Engine, entID string, widthBits int) { + t.Helper() + sealGrantDigests(t, e) + if err := e.buildPartitionDigestAtWidth(context.Background(), grantDigestSpec, testEntPartition(entID), widthBits); err != nil { + t.Fatalf("buildPartitionDigestAtWidth: %v", err) + } +} + +// seedEntitlementAtWidth is seedEntitlement but forces a specific +// leaf-level width instead of deriving it from the grant count. +func seedEntitlementAtWidth(t testing.TB, e *Engine, entID string, grants []*v3.GrantRecord, widthBits int) string { + t.Helper() + ctx := context.Background() + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, entID) + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + rebuildDigestAtWidth(t, e, entID, widthBits) + return syncID +} + +// TestDigestDifferentWidthsComparison builds two digests of different +// widths (4 vs 8 bits) over the SAME entitlement and exercises +// DirtyEntitlementBuckets across them. It validates two things the +// equal-width tests cannot: +// +// - split-independence: identical grant content yields the same root +// hash regardless of digest width, and compares as zero dirty +// buckets; +// - the cross-width merge: after one principal's grant changes, the +// comparison (at compareBits = min(4,8) = 4) localizes the change to +// that principal's width-4 bucket — the finer (width-8) side's +// leaves fold down to width 4 during the scan — and leaves a known +// principal in a different bucket clean. +func TestDigestDifferentWidthsComparison(t *testing.T) { + ctx := context.Background() + + const nPrincipals = 40 + principals := make([]string, nPrincipals) + for i := range principals { + principals[i] = fmt.Sprintf("user-%03d", i) + } + mkGrants := func() []*v3.GrantRecord { + gs := make([]*v3.GrantRecord, 0, nPrincipals) + for i, p := range principals { + gs = append(gs, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", p)) + } + return gs + } + + // bucket4 returns the width-4 bucket index for a principal, matching + // how grants are keyed (principal type "user" per makeGrant). + bucket4 := func(principalID string) uint32 { + return bucketOfHash(principalBucketHash("user", principalID), 4).Index + } + + ea, _ := newTestEngine(t) + eb, _ := newTestEngine(t) + seedEntitlementAtWidth(t, ea, "ent-A", mkGrants(), 4) + seedEntitlementAtWidth(t, eb, "ent-A", mkGrants(), 8) + entA := testEntIdentity("ent-A") + + ra, okA, err := ea.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !okA { + t.Fatalf("root A: ok=%v err=%v", okA, err) + } + rb, okB, err := eb.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !okB { + t.Fatalf("root B: ok=%v err=%v", okB, err) + } + if ra.Bits != 4 || rb.Bits != 8 { + t.Fatalf("widths = %d, %d; want 4, 8", ra.Bits, rb.Bits) + } + // Split-independence: identical content -> identical root despite + // different digest widths. + if !bytes.Equal(ra.Hash, rb.Hash) { + t.Fatalf("different-width digests over identical content disagree on root:\n A(w4)=%x\n B(w8)=%x", ra.Hash, rb.Hash) + } + dirty, err := ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets (identical): %v", err) + } + if len(dirty) != 0 { + t.Fatalf("identical content across widths: dirty=%d, want 0", len(dirty)) + } + + // Pick the principal to change and a "clean" principal known to sit + // in a different width-4 bucket. + changed := principals[0] + changedBucket := bucket4(changed) + cleanP := "" + for _, p := range principals[1:] { + if bucket4(p) != changedBucket { + cleanP = p + break + } + } + if cleanP == "" { + t.Skip("no principal landed in a different width-4 bucket from the changed one; can't assert localization") + } + + // Mutate the changed principal's grant in B (same identity -> same + // index key, new content hash via an added source), then re-derive + // B's index + digest at width 8 — the seal-style rebuild, since + // mutations never maintain either inline. + g := makeGrantWithSources("", "g-000", "ent-A", changed, "src-ent") + if err := eb.PutGrantRecord(ctx, g); err != nil { + t.Fatalf("PutGrantRecord (mutate): %v", err) + } + rebuildDigestAtWidth(t, eb, "ent-A", 8) + + rb2, _, _ := eb.GetEntitlementDigestRoot(ctx, entA) + if bytes.Equal(ra.Hash, rb2.Hash) { + t.Fatal("mutation did not change B's root") + } + + dirty, err = ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets (changed): %v", err) + } + if len(dirty) == 0 { + t.Fatal("changed principal across widths produced no dirty buckets") + } + // Localization: every dirty entry is a width-4 bucket (not the + // whole-entitlement zero bucket). + for _, b := range dirty { + if b.Bits != 4 { + t.Fatalf("dirty bucket bits = %d, want 4 (compareBits); got whole-entitlement or wrong-width bucket", b.Bits) + } + } + // Loading the dirty buckets in B surfaces the changed principal and + // excludes the known-clean principal. + loaded := map[string]bool{} + for _, b := range dirty { + if err := eb.IterateGrantsByEntitlementBucket(ctx, entA, b, func(g *v3.GrantRecord) bool { + loaded[g.GetPrincipal().GetResourceId()] = true + return true + }); err != nil { + t.Fatalf("IterateGrantsByEntitlementBucket: %v", err) + } + } + if !loaded[changed] { + t.Fatalf("dirty buckets did not include the changed principal %q; loaded=%v", changed, loaded) + } + if loaded[cleanP] { + t.Fatalf("dirty buckets wrongly included clean principal %q (different bucket); change was not localized", cleanP) + } +} + +func TestDigestEmptyEntitlementSingleRoot(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-empty", nil) + + if got := digestNodeCount(t, e); got != 1 { + t.Fatalf("empty entitlement: digest node count = %d, want 1 (root only)", got) + } + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-empty")) + if err != nil || !ok { + t.Fatalf("GetEntitlementDigestRoot: ok=%v err=%v", ok, err) + } + if root.Bits != 0 { + t.Fatalf("empty entitlement width = %d, want 0", root.Bits) + } + if root.Count != 0 { + t.Fatalf("empty entitlement count = %d, want 0", root.Count) + } +} + +// TestGrantDigestZeroGrantRootsAtEndSync covers the standalone EndSync +// build with a mix: an entitlement with inline-written grants (the +// deferred marker never arms) and a zero-grant entitlement. Both get +// digests at seal — the zero-grant one a {count: 0} root. +func TestGrantDigestZeroGrantRootsAtEndSync(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + if _, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-with") + putEnt(t, e, ctx, "ent-zero") + if err := e.PutGrantRecords(ctx, makeGrant("", "g1", "ent-with", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if e.deferredIdxPending.Load() { + t.Fatal("inline grant writes must not arm the deferred marker (precondition for this test)") + } + if err := a.EndSync(ctx); err != nil { + t.Fatalf("EndSync: %v", err) + } + + d, found, err := a.GetEntitlementGrantDigest(ctx, testV2Ent("ent-with")) + if err != nil || !found { + t.Fatalf("ent-with digest: found=%v err=%v", found, err) + } + if d.Count != 1 { + t.Fatalf("ent-with count = %d, want 1", d.Count) + } + dz, found, err := a.GetEntitlementGrantDigest(ctx, testV2Ent("ent-zero")) + if err != nil || !found { + t.Fatalf("ent-zero digest: found=%v err=%v, want a {count: 0} root (empty, not never-built)", found, err) + } + if dz.Count != 0 || dz.Level != 0 { + t.Fatalf("ent-zero root = count %d level %d, want 0/0", dz.Count, dz.Level) + } +} + +func TestDigestIdenticalGrantsSameRoot(t *testing.T) { + ctx := context.Background() + mk := func() []*v3.GrantRecord { + return []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + makeGrant("", "g3", "ent-A", "carol"), + } + } + ea, _ := newTestEngine(t) + eb, _ := newTestEngine(t) + seedEntitlement(t, ea, "ent-A", mk()) + seedEntitlement(t, eb, "ent-A", mk()) + entA := testEntIdentity("ent-A") + + ra, okA, err := ea.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !okA { + t.Fatalf("root A: ok=%v err=%v", okA, err) + } + rb, okB, err := eb.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !okB { + t.Fatalf("root B: ok=%v err=%v", okB, err) + } + if !bytes.Equal(ra.Hash, rb.Hash) { + t.Fatalf("identical grants produced different roots:\n A=%x\n B=%x", ra.Hash, rb.Hash) + } + dirty, err := ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets: %v", err) + } + if len(dirty) != 0 { + t.Fatalf("identical grants: dirty buckets = %d, want 0", len(dirty)) + } +} + +func TestDigestContentChangeDirtyBucket(t *testing.T) { + ctx := context.Background() + // Base set: same in both engines except bob's grant gains a source + // in B. The identity is unchanged, so the index KEY is identical and + // only the content hash (and thus bob's bucket) differs. + baseA := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + makeGrant("", "g3", "ent-A", "carol"), + } + baseB := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrantWithSources("", "g2", "ent-A", "bob", "src-ent"), + makeGrant("", "g3", "ent-A", "carol"), + } + ea, _ := newTestEngine(t) + eb, _ := newTestEngine(t) + seedEntitlement(t, ea, "ent-A", baseA) + seedEntitlement(t, eb, "ent-A", baseB) + entA := testEntIdentity("ent-A") + + ra, _, _ := ea.GetEntitlementDigestRoot(ctx, entA) + rb, _, _ := eb.GetEntitlementDigestRoot(ctx, entA) + if bytes.Equal(ra.Hash, rb.Hash) { + t.Fatal("content change did not change the root hash") + } + + dirty, err := ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets: %v", err) + } + if len(dirty) == 0 { + t.Fatal("content change produced no dirty buckets") + } + + // Loading the dirty buckets in B must surface bob (the changed + // principal). + found := map[string]bool{} + for _, b := range dirty { + if err := eb.IterateGrantsByEntitlementBucket(ctx, entA, b, func(g *v3.GrantRecord) bool { + found[g.GetPrincipal().GetResourceId()] = true + return true + }); err != nil { + t.Fatalf("IterateGrantsByEntitlementBucket: %v", err) + } + } + if !found["bob"] { + t.Fatalf("dirty buckets did not include the changed principal bob; found=%v", found) + } +} + +func TestDigestAddedGrantDirtyBucket(t *testing.T) { + ctx := context.Background() + baseA := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + } + baseB := []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + makeGrant("", "g3", "ent-A", "dave"), // added in B + } + ea, _ := newTestEngine(t) + eb, _ := newTestEngine(t) + seedEntitlement(t, ea, "ent-A", baseA) + seedEntitlement(t, eb, "ent-A", baseB) + entA := testEntIdentity("ent-A") + + dirty, err := ea.DirtyEntitlementBuckets(ctx, eb, entA) + if err != nil { + t.Fatalf("DirtyEntitlementBuckets: %v", err) + } + if len(dirty) == 0 { + t.Fatal("added grant produced no dirty buckets") + } + found := map[string]bool{} + for _, b := range dirty { + if err := eb.IterateGrantsByEntitlementBucket(ctx, entA, b, func(g *v3.GrantRecord) bool { + found[g.GetPrincipal().GetResourceId()] = true + return true + }); err != nil { + t.Fatalf("IterateGrantsByEntitlementBucket: %v", err) + } + } + if !found["dave"] { + t.Fatalf("dirty buckets did not include the added principal dave; found=%v", found) + } +} + +func TestDigestVariableWidth(t *testing.T) { + ctx := context.Background() + + // Small entitlement: under one target bucket -> width 0, single node. + small := make([]*v3.GrantRecord, 0, 10) + for range 10 { + small = append(small, makeGrant("", ksuid.New().String(), "ent-small", ksuid.New().String())) + } + es, _ := newTestEngine(t) + seedEntitlement(t, es, "ent-small", small) + rootS, ok, err := es.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-small")) + if err != nil || !ok { + t.Fatalf("small root: ok=%v err=%v", ok, err) + } + if rootS.Bits != 0 { + t.Fatalf("small entitlement width = %d, want 0", rootS.Bits) + } + if rootS.Count != 10 { + t.Fatalf("small entitlement count = %d, want 10", rootS.Count) + } + if got := digestNodeCount(t, es); got != 1 { + t.Fatalf("small entitlement node count = %d, want 1", got) + } + + // Large entitlement: well over the target bucket size -> the width + // grows one bit at a time, and the digest gains leaf nodes beyond + // the root. + const n = digestTargetBucketSize*3 + 7 + large := make([]*v3.GrantRecord, 0, n) + for range n { + large = append(large, makeGrant("", ksuid.New().String(), "ent-large", ksuid.New().String())) + } + el, _ := newTestEngine(t) + seedEntitlement(t, el, "ent-large", large) + rootL, ok, err := el.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-large")) + if err != nil || !ok { + t.Fatalf("large root: ok=%v err=%v", ok, err) + } + if want := chooseDigestWidth(n); rootL.Bits != want { + t.Fatalf("large entitlement width = %d, want %d", rootL.Bits, want) + } + if rootL.Count != int64(n) { + t.Fatalf("large entitlement count = %d, want %d", rootL.Count, n) + } + // root + at least 2 leaves (width>=1 over n grants spreads across + // multiple buckets). + if got := digestNodeCount(t, el); got < 3 { + t.Fatalf("large entitlement node count = %d, want >= 3 (root + leaves)", got) + } + // Capacity invariant: 2^width buckets at the target size must cover + // the count, and width-1 must not (else the width is too large). + if int64(1)< 0 && int64(1)<<(rootL.Bits-1)*digestTargetBucketSize >= n { + t.Fatalf("width %d is one bit wider than the count %d needs", rootL.Bits, n) + } +} + +// TestHashIndexIsHashOrdered verifies the index iterates in +// hash(principal) order: the embedded bucket-hash region is +// non-decreasing across the entitlement's index range. +func TestHashIndexIsHashOrdered(t *testing.T) { + e, _ := newTestEngine(t) + grants := make([]*v3.GrantRecord, 0, 200) + for range 200 { + grants = append(grants, makeGrant("", ksuid.New().String(), "ent-A", ksuid.New().String())) + } + seedEntitlement(t, e, "ent-A", grants) + + entPrefix := encodeGrantByEntPrincHashEntPrefix(testEntPartition("ent-A")) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: entPrefix, UpperBound: upperBoundOf(entPrefix)}) + if err != nil { + t.Fatal(err) + } + defer iter.Close() + var prev uint16 + havePrev := false + count := 0 + for iter.First(); iter.Valid(); iter.Next() { + _, bucket, ok := splitGrantHashIndexKey(iter.Key()) + if !ok { + t.Fatal("failed to split index key") + } + if havePrev && bucket < prev { + t.Fatalf("index not hash-ordered: %x < %x", bucket, prev) + } + prev, havePrev = bucket, true + count++ + } + if count != 200 { + t.Fatalf("hash index entry count = %d, want 200", count) + } +} + +// dumpDigestNodes snapshots every digest node key/value. Used to +// byte-compare independently built digests. +func dumpDigestNodes(t testing.TB, e *Engine) map[string][]byte { + t.Helper() + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: DigestLowerBound(), + UpperBound: DigestUpperBound(), + }) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + defer iter.Close() + out := map[string][]byte{} + for iter.First(); iter.Valid(); iter.Next() { + out[string(iter.Key())] = append([]byte(nil), iter.Value()...) + } + if err := iter.Error(); err != nil { + t.Fatalf("iter: %v", err) + } + return out +} + +// requireSameDigestNodes fails with a per-key diff when two node +// snapshots differ. +func requireSameDigestNodes(t *testing.T, got, want map[string][]byte) { + t.Helper() + for k, wv := range want { + gv, ok := got[k] + if !ok { + t.Errorf("missing node %x (want %x)", k, wv) + continue + } + if !bytes.Equal(gv, wv) { + t.Errorf("node %x differs:\n got %x\nwant %x", k, gv, wv) + } + } + for k, gv := range got { + if _, ok := want[k]; !ok { + t.Errorf("extra node %x = %x", k, gv) + } + } +} + +// TestFusedFoldMatchesPartitionRebuild pins the seal-time streaming +// fold (grantDigestFold, which accumulates at max width and folds down +// at each partition boundary) against the independent single-partition +// rebuild (buildPartitionDigestAtWidth → foldPartitionNodes, a direct +// index-range fold): both must produce byte-identical nodes for every +// partition, including the zero-grant root. +func TestFusedFoldMatchesPartitionRebuild(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + counts := map[string]int64{"ent-big": 600, "ent-small": 10, "ent-zero": 0} + for entID, n := range counts { + putEnt(t, e, ctx, entID) + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), entID, ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords(%s): %v", entID, err) + } + } + sealGrantDigests(t, e) + fused := dumpDigestNodes(t, e) + + for entID, n := range counts { + if err := e.buildPartitionDigestAtWidth(ctx, grantDigestSpec, testEntPartition(entID), chooseDigestWidth(n)); err != nil { + t.Fatalf("buildPartitionDigestAtWidth(%s): %v", entID, err) + } + } + requireSameDigestNodes(t, dumpDigestNodes(t, e), fused) +} + +// TestDigestLeafFoldConsistent verifies the leaf-level build and the +// fold machinery the comparison rests on: every stored leaf is +// non-empty, the root is exactly the XOR (and count-sum) of the leaves, +// no nodes exist beyond root + leaves, folding the leaf level to a +// coarser width matches a manual regrouping, and a stored leaf +// byte-matches the authoritative on-demand fold of its bucket. +func TestDigestLeafFoldConsistent(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + const n = 60 + grants := make([]*v3.GrantRecord, 0, n) + for i := range n { + grants = append(grants, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", fmt.Sprintf("user-%03d", i))) + } + seedEntitlementAtWidth(t, e, "ent-A", grants, 8) + entA := testEntIdentity("ent-A") + partition := testEntPartition("ent-A") + + root, ok, err := e.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !ok { + t.Fatalf("root: ok=%v err=%v", ok, err) + } + if root.Bits != 8 || root.Count != n { + t.Fatalf("root width=%d count=%d, want 8, %d", root.Bits, root.Count, n) + } + + // Folding at the build width returns the stored leaves one-to-one. + leaves, err := e.foldedLeafBuckets(ctx, grantDigestSpec, partition, 8) + if err != nil { + t.Fatal(err) + } + if len(leaves) == 0 { + t.Fatal("no leaf nodes stored") + } + var ( + rootXor [hashLen]byte + rootCount int64 + ) + for _, l := range leaves { + if l.count < 1 { + t.Fatalf("leaf %d stored with count %d; empty leaves must not be materialized", l.idx, l.count) + } + xorInto(rootXor[:], l.digest[:]) + rootCount += l.count + } + if rootCount != root.Count || !bytes.Equal(rootXor[:], root.Hash) { + t.Fatalf("root != fold of leaves: count %d vs %d", root.Count, rootCount) + } + + // Exactly root + leaves — nothing else in the keyspace. + if got, want := digestNodeCount(t, e), 1+len(leaves); got != want { + t.Fatalf("total node count = %d, want %d (root + leaves only)", got, want) + } + + // Folding to a coarser width matches a manual regroup of the + // build-width leaves. + leaves4, err := e.foldedLeafBuckets(ctx, grantDigestSpec, partition, 4) + if err != nil { + t.Fatal(err) + } + manual := map[uint32]*foldedBucket{} + var order []uint32 + for _, l := range leaves { + idx := l.idx >> 4 + fb, ok := manual[idx] + if !ok { + fb = &foldedBucket{idx: idx} + manual[idx] = fb + order = append(order, idx) + } + fb.count += l.count + xorInto(fb.digest[:], l.digest[:]) + } + if len(leaves4) != len(order) { + t.Fatalf("fold to width 4: %d buckets, want %d", len(leaves4), len(order)) + } + for i, idx := range order { + got, want := leaves4[i], manual[idx] + if got.idx != want.idx || got.count != want.count || got.digest != want.digest { + t.Fatalf("folded bucket %d mismatch: got {%d %d %x}, want {%d %d %x}", + i, got.idx, got.count, got.digest, want.idx, want.count, want.digest) + } + } + + // A stored leaf is a cache of the authoritative fold. + b := DigestBucket{Index: leaves[0].idx, Bits: 8} + h, c, err := e.ComputeEntitlementBucketDigest(ctx, entA, b) + if err != nil { + t.Fatal(err) + } + lc, ld, present, err := e.getDigestLeaf(grantDigestSpec, partition, b.leafKeyPrefix()) + if err != nil || !present { + t.Fatalf("leaf %d: present=%v err=%v", b.Index, present, err) + } + if c != lc || !bytes.Equal(h, ld) { + t.Fatalf("stored leaf disagrees with ComputeEntitlementBucketDigest: count %d vs %d", lc, c) + } +} + +// TestDigestRebuildClearsStaleNodes verifies the leading DeleteRange in +// the build: a rebuild at a narrower width must remove the prior +// build's finer-grained leaves, or the comparison merge scan would read +// them. +func TestDigestRebuildClearsStaleNodes(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + grants := make([]*v3.GrantRecord, 0, 40) + for i := range 40 { + grants = append(grants, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", fmt.Sprintf("user-%03d", i))) + } + seedEntitlementAtWidth(t, e, "ent-A", grants, 8) + + before := rawLeafPrefixes(t, e, "ent-A") + if len(before) == 0 { + t.Fatal("width-8 build produced no leaves") + } + rootBefore, _, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil { + t.Fatal(err) + } + + if err := e.buildPartitionDigestAtWidth(ctx, grantDigestSpec, testEntPartition("ent-A"), 4); err != nil { + t.Fatalf("rebuild at width 4: %v", err) + } + + rootAfter, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("root after rebuild: ok=%v err=%v", ok, err) + } + if rootAfter.Bits != 4 { + t.Fatalf("root width after rebuild = %d, want 4", rootAfter.Bits) + } + // Split-independence: same content, same root digest. + if !bytes.Equal(rootBefore.Hash, rootAfter.Hash) || rootBefore.Count != rootAfter.Count { + t.Fatal("rebuild at different width changed the root digest/count over identical content") + } + // Every surviving leaf prefix must be width-4 aligned (low 12 bits + // of the left-aligned prefix zero) — a width-8 leaf that escaped the + // range-clear would fail this. + after := rawLeafPrefixes(t, e, "ent-A") + if len(after) == 0 || len(after) > 16 { + t.Fatalf("width-4 rebuild stored %d leaves, want 1..16", len(after)) + } + for _, p := range after { + if lv := binary.BigEndian.Uint16(p); lv&0x0FFF != 0 { + t.Fatalf("stale leaf prefix %x survived the width-4 rebuild", p) + } + } +} + +// TestDigestDeleteInvalidatesAndResealRecalculates pins the +// present-means-exact lifecycle around post-seal mutation: +// DeleteGrantRecord drops the touched entitlement's digest partition +// AND its hash-index range (both are seal-derived and equally stale), +// so the digest reads as "missing — recalculate"; a seal-style rebuild +// then byte-matches a from-scratch build over the surviving grants. +func TestDigestDeleteInvalidatesAndResealRecalculates(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + grants := make([]*v3.GrantRecord, 0, 30) + for i := range 30 { + grants = append(grants, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", fmt.Sprintf("user-%03d", i))) + } + seedEntitlement(t, e, "ent-A", grants) + entA := testEntIdentity("ent-A") + + if _, ok, err := e.GetEntitlementDigestRoot(ctx, entA); err != nil || !ok { + t.Fatalf("sealed root: ok=%v err=%v", ok, err) + } + if rows := entHashIndexRowCount(t, e, "ent-A"); rows != 30 { + t.Fatalf("sealed hash index rows = %d, want 30", rows) + } + + // Post-seal delete: the digest must be dropped, not silently stale. + // v1 invalidation drops the whole entitlement's hash-index range too. + if err := e.DeleteGrantRecord(ctx, "g-008"); err != nil { + t.Fatalf("DeleteGrantRecord: %v", err) + } + if _, ok, err := e.GetEntitlementDigestRoot(ctx, entA); err != nil || ok { + t.Fatalf("root after delete: ok=%v err=%v, want missing (invalidated)", ok, err) + } + if got := digestNodeCount(t, e); got != 0 { + t.Fatalf("digest nodes after delete = %d, want 0 (partition dropped)", got) + } + if got := entHashIndexRowCount(t, e, "ent-A"); got != 0 { + t.Fatalf("hash index rows after delete = %d, want 0 (stale index range dropped)", got) + } + + // Reseal: the digest is recalculated from the surviving primaries + // and byte-matches an independent from-scratch build. + sealGrantDigests(t, e) + root, ok, err := e.GetEntitlementDigestRoot(ctx, entA) + if err != nil || !ok { + t.Fatalf("root after reseal: ok=%v err=%v", ok, err) + } + if root.Count != 29 { + t.Fatalf("resealed root count = %d, want 29", root.Count) + } + resealed := dumpDigestNodes(t, e) + + fresh, _ := newTestEngine(t) + survivors := make([]*v3.GrantRecord, 0, 29) + for i := range 30 { + if i == 8 { + continue + } + survivors = append(survivors, makeGrant("", fmt.Sprintf("g-%03d", i), "ent-A", fmt.Sprintf("user-%03d", i))) + } + seedEntitlement(t, fresh, "ent-A", survivors) + requireSameDigestNodes(t, resealed, dumpDigestNodes(t, fresh)) +} + +// TestDigestPutInvalidatesOnlyTouchedPartition: a post-seal grant WRITE +// (not just delete) invalidates the touched entitlement's digest state +// while other entitlements' digests survive intact. +func TestDigestPutInvalidatesOnlyTouchedPartition(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + putEnt(t, e, ctx, "ent-B") + if err := e.PutGrantRecords(ctx, + makeGrant("", "ga1", "ent-A", "alice"), + makeGrant("", "gb1", "ent-B", "bob"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + + if err := e.PutGrantRecord(ctx, makeGrant("", "ga2", "ent-A", "dave")); err != nil { + t.Fatalf("PutGrantRecord (post-seal): %v", err) + } + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")); err != nil || ok { + t.Fatalf("ent-A root after post-seal write: ok=%v err=%v, want missing", ok, err) + } + if got := entHashIndexRowCount(t, e, "ent-A"); got != 0 { + t.Fatalf("ent-A hash index rows after post-seal write = %d, want 0", got) + } + rootB, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-B")) + if err != nil || !ok { + t.Fatalf("ent-B root: ok=%v err=%v, want intact", ok, err) + } + if rootB.Count != 1 { + t.Fatalf("ent-B root count = %d, want 1", rootB.Count) + } + if got := entHashIndexRowCount(t, e, "ent-B"); got != 1 { + t.Fatalf("ent-B hash index rows = %d, want 1 (untouched)", got) + } +} + +// TestSealRebuildDropsStaleIndexRows verifies the seal-time index +// derivation is a full re-derivation, not an append: rows for grants +// that changed principal (or were removed) since the last seal do not +// survive a reseal, because the build excises the whole index range. +func TestSealRebuildDropsStaleIndexRows(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + }) + + // Move g2 to a different principal (a new identity row + a delete of + // the old one, addressed by refs — the identity scheme's exact + // delete) and reseal. The old (bob) row must be gone and the digest + // must reflect the new content. + if err := e.PutGrantRecord(ctx, makeGrant("", "g2", "ent-A", "carol")); err != nil { + t.Fatalf("PutGrantRecord: %v", err) + } + if err := e.DeleteGrantByIdentityRefs(ctx, makeGrant("", "g2", "ent-A", "bob")); err != nil { + t.Fatalf("DeleteGrantByIdentityRefs: %v", err) + } + sealGrantDigests(t, e) + + entPrefix := encodeGrantByEntPrincHashEntPrefix(testEntPartition("ent-A")) + principals := map[string]bool{} + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: entPrefix, UpperBound: upperBoundOf(entPrefix)}) + if err != nil { + t.Fatal(err) + } + defer iter.Close() + for iter.First(); iter.Valid(); iter.Next() { + priKey, ok := grantPrimaryKeyFromHashIndexKey(nil, iter.Key()) + if !ok { + t.Fatal("failed to reconstruct primary key from index key") + } + id, ok := decodeGrantIdentityKey(priKey) + if !ok { + t.Fatal("failed to decode reconstructed primary key") + } + principals[id.principalID] = true + } + if principals["bob"] || !principals["carol"] || !principals["alice"] || len(principals) != 2 { + t.Fatalf("index principals after reseal = %v, want {alice, carol}", principals) + } + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("root: ok=%v err=%v", ok, err) + } + if root.Count != 2 { + t.Fatalf("root count = %d, want 2", root.Count) + } +} + +// TestGrantDigestSpillMerge forces the digest build through the +// multi-run spill path: a tiny chunk size makes every few rows cut a +// sorted run file, so the index SST and the digest fold come out of the +// k-way merge rather than a single in-memory chunk. The merged result +// must byte-equal a single-chunk build — same rows, same hash-major +// order (bulkSSTWriter rejects ordering violations outright) — and the +// digests folded over it must match too. +func TestGrantDigestSpillMerge(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + // One big entitlement that will span many tiny runs, plus small + // ones that each fit inside a single run. + putEnt(t, e, ctx, "ent-big") + putEnt(t, e, ctx, "ent-small-1") + putEnt(t, e, ctx, "ent-small-2") + var grants []*v3.GrantRecord + for i := range 300 { + grants = append(grants, makeGrant("", fmt.Sprintf("g-big-%03d", i), "ent-big", fmt.Sprintf("user-%03d", i))) + } + grants = append(grants, + makeGrant("", "g-s1", "ent-small-1", "alice"), + makeGrant("", "g-s2", "ent-small-2", "bob"), + ) + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + + dumpIndex := func() map[string][]byte { + t.Helper() + out := map[string][]byte{} + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: GrantByEntPrincHashLowerBound(), + UpperBound: GrantByEntPrincHashUpperBound(), + }) + if err != nil { + t.Fatal(err) + } + defer iter.Close() + for iter.First(); iter.Valid(); iter.Next() { + out[string(iter.Key())] = append([]byte(nil), iter.Value()...) + } + if err := iter.Error(); err != nil { + t.Fatal(err) + } + return out + } + + // buildChunked runs the scan + spill + merge/fold/ingest with a + // forced chunk size, through the same buildGrantDigestsFromSpill the + // production seal uses. + buildChunked := func(chunkBytes int) { + t.Helper() + dir := t.TempDir() + sem := make(chan struct{}, 2) + sorter := newSpillSorter(dir, "hash-chunked", sem, chunkBytes) + if err := e.withWrite(func() error { + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: GrantLowerBound(), + UpperBound: GrantUpperBound(), + }) + if err != nil { + return err + } + var scratch grantHashRowScratch + for iter.First(); iter.Valid(); iter.Next() { + if err := appendGrantHashIndexRow(sorter, iter.Key(), iter.Value(), &scratch); err != nil { + _ = iter.Close() + return err + } + } + if err := iter.Close(); err != nil { + return err + } + return e.buildGrantDigestsFromSpill(ctx, dir, sorter) + }); err != nil { + t.Fatalf("chunked digest build (chunk=%d): %v", chunkBytes, err) + } + } + + // Tiny 512-byte chunks: ~5 rows per run, so ent-big spans dozens of + // runs and reassembles during the merge. + buildChunked(512) + spilled := dumpIndex() + spilledNodes := dumpDigestNodes(t, e) + + // Single-chunk build (1MiB holds all ~300 rows comfortably). + buildChunked(1 << 20) + inMemory := dumpIndex() + memNodes := dumpDigestNodes(t, e) + + if len(spilled) != len(grants) { + t.Fatalf("spilled index rows = %d, want %d", len(spilled), len(grants)) + } + for k, v := range inMemory { + sv, ok := spilled[k] + if !ok { + t.Fatalf("row missing from spilled index: %x", k) + } + if !bytes.Equal(sv, v) { + t.Fatalf("row %x differs: spilled %x, in-memory %x", k, sv, v) + } + } + if len(spilled) != len(inMemory) { + t.Fatalf("row counts differ: spilled %d, in-memory %d", len(spilled), len(inMemory)) + } + requireSameDigestNodes(t, spilledNodes, memNodes) + + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-big")) + if err != nil || !ok { + t.Fatalf("root: ok=%v err=%v", ok, err) + } + if root.Count != 300 { + t.Fatalf("ent-big root count = %d, want 300", root.Count) + } +} + +// TestDigestMissingRootWholeDirty: a missing root means "digest never +// built (or invalidated)" — never "no grants", and never something to +// silently derive from an index whose presence can't be verified. The +// comparison must report the whole entitlement dirty, even when the +// underlying content is identical: correctness comes from re-reading, +// not from trusting an unverifiable shortcut. +func TestDigestMissingRootWholeDirty(t *testing.T) { + ctx := context.Background() + mk := func() []*v3.GrantRecord { + return []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + makeGrant("", "g3", "ent-A", "carol"), + } + } + ea, _ := newTestEngine(t) + seedEntitlement(t, ea, "ent-A", mk()) + entA := testEntIdentity("ent-A") + + // B holds the same grants but never builds a digest. + eb, _ := newTestEngine(t) + syncB := ksuid.New().String() + if err := eb.SetCurrentSync(syncB); err != nil { + t.Fatal(err) + } + putEnt(t, eb, ctx, "ent-A") + for _, g := range mk() { + if err := eb.PutGrantRecord(ctx, g); err != nil { + t.Fatal(err) + } + } + if _, ok, err := eb.GetEntitlementDigestRoot(ctx, entA); err != nil || ok { + t.Fatalf("B unexpectedly has a root: ok=%v err=%v", ok, err) + } + + for name, dirtyFn := range map[string]func() ([]DigestBucket, error){ + "A vs B": func() ([]DigestBucket, error) { return ea.DirtyEntitlementBuckets(ctx, eb, entA) }, + "B vs A": func() ([]DigestBucket, error) { return eb.DirtyEntitlementBuckets(ctx, ea, entA) }, + } { + dirty, err := dirtyFn() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(dirty) != 1 || dirty[0].Bits != 0 { + t.Fatalf("%s: unbuilt digest on one side: dirty=%v, want one whole-entitlement bucket", name, dirty) + } + } +} diff --git a/pkg/dotc1z/engine/pebble/endsync_repair_test.go b/pkg/dotc1z/engine/pebble/endsync_repair_test.go new file mode 100644 index 000000000..e50cf44b6 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/endsync_repair_test.go @@ -0,0 +1,184 @@ +package pebble + +import ( + "bytes" + "context" + "sync" + "testing" + + "github.com/segmentio/ksuid" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/connectorstore" +) + +// capturingCore/newCapturingLogger let a test observe the zap messages +// EndSync's finalize logs, without hooking any production code — +// used here to prove a SECOND EndSync on an already-digested, +// rebound sync takes the targeted repair path (RepairMissingGrantDigests) +// rather than a full from-scratch rescan (BuildGrantDigests). +type capturingCore struct { + zapcore.LevelEnabler + mu *sync.Mutex + messages *[]string +} + +func newCapturingLogger() (*zap.Logger, func() []string) { + mu := &sync.Mutex{} + messages := &[]string{} + core := capturingCore{LevelEnabler: zapcore.DebugLevel, mu: mu, messages: messages} + return zap.New(core), func() []string { + mu.Lock() + defer mu.Unlock() + out := make([]string, len(*messages)) + copy(out, *messages) + return out + } +} + +func (c capturingCore) With([]zapcore.Field) zapcore.Core { return c } +func (c capturingCore) Check(e zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(e.Level) { + return ce.AddCore(e, c) + } + return ce +} +func (c capturingCore) Write(e zapcore.Entry, _ []zapcore.Field) error { + c.mu.Lock() + defer c.mu.Unlock() + *c.messages = append(*c.messages, e.Message) + return nil +} +func (c capturingCore) Sync() error { return nil } + +// TestEndSyncSecondCallTakesTargetedRepairPath is the core pin for +// wiring RepairMissingGrantDigests into Adapter.endSyncFinalize: a +// SECOND EndSync on a sync that was already fully digested and is +// REBOUND (SetCurrentSync, not StartNewSync — the shape grant +// expansion's own follow-up sync produces) must take the targeted +// repair path for the one entitlement a post-seal write touched, +// leaving every other entitlement's digest nodes byte-identical to +// what the first EndSync built — not a full rescan of every grant in +// the file. +func TestEndSyncSecondCallTakesTargetedRepairPath(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + a := NewAdapter(e) + + syncID, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + if err != nil { + t.Fatalf("StartNewSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + putEnt(t, e, ctx, "ent-B") + grantsA := make([]*v3.GrantRecord, 0, 20) + for range 20 { + id := ksuid.New().String() + grantsA = append(grantsA, makeGrant("", id, "ent-A", id)) + } + if err := e.PutGrantRecords(ctx, grantsA...); err != nil { + t.Fatalf("PutGrantRecords(ent-A): %v", err) + } + if err := e.PutGrantRecords(ctx, makeGrant("", "gb1", "ent-B", "bob")); err != nil { + t.Fatalf("PutGrantRecords(ent-B): %v", err) + } + + // First EndSync: fresh sync, grantDigestsPresent starts false, so + // RepairMissingGrantDigests must fall back to the full build — + // exactly today's behavior, no regression. + if err := a.EndSync(ctx); err != nil { + t.Fatalf("first EndSync: %v", err) + } + afterFirstSeal := dumpDigestNodes(t, e) + if len(afterFirstSeal) == 0 { + t.Fatal("expected digest nodes after first EndSync") + } + + // Rebind (NOT StartNewSync — that would excise the digest keyspace + // and defeat the whole point) and write one more grant under ent-A + // only. This is the shape grant expansion's follow-up sync takes: + // resume an already-sealed sync, write some grants, end it again. + if err := a.SetCurrentSync(ctx, syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + if err := e.PutGrantRecord(ctx, makeGrant("", "ga-extra", "ent-A", "extra-user")); err != nil { + t.Fatalf("PutGrantRecord (post-seal): %v", err) + } + + // Sanity: only ent-A (and the global root) should be invalidated by + // that write. + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")); err != nil || ok { + t.Fatalf("ent-A root after post-seal write: ok=%v err=%v, want missing", ok, err) + } + rootB, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-B")) + if err != nil || !ok { + t.Fatalf("ent-B root after post-seal write: ok=%v err=%v, want intact", ok, err) + } + if rootB.Count != 1 { + t.Fatalf("ent-B root count = %d, want 1 (untouched)", rootB.Count) + } + + logger, capture := newCapturingLogger() + endSyncCtx := ctxzap.ToContext(ctx, logger) + if err := a.EndSync(endSyncCtx); err != nil { + t.Fatalf("second EndSync: %v", err) + } + + var sawTargetedRepair, sawFullBuild bool + for _, msg := range capture() { + switch msg { + case "grant digest repair: rebuilt missing entitlement partitions": + sawTargetedRepair = true + case "grant digest build complete": + sawFullBuild = true + } + } + if !sawTargetedRepair { + t.Error("expected the second EndSync to log a targeted repair") + } + if sawFullBuild { + t.Error("second EndSync ran a full from-scratch rebuild instead of the targeted repair") + } + + // ent-B's digest nodes must be byte-identical to what the FIRST + // seal produced — proof the targeted repair never touched it. + afterSecondSeal := dumpDigestNodes(t, e) + entBPrefix := encodeDigestPartitionPrefix(grantDigestSpec.indexID, testEntPartition("ent-B")) + for k, v := range afterFirstSeal { + if !bytesHasPrefix(k, entBPrefix) { + continue + } + got, ok := afterSecondSeal[k] + if !ok { + t.Fatalf("ent-B node %x missing after targeted repair", k) + } + if !bytes.Equal(got, v) { + t.Fatalf("ent-B node %x changed by targeted repair: got %x, want %x", k, got, v) + } + } + + // ent-A must be correctly repaired (21 grants now), and the global + // root must be recomputed and correct. + rootA, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("ent-A root after repair: ok=%v err=%v", ok, err) + } + if rootA.Count != 21 { + t.Fatalf("ent-A root count = %d, want 21", rootA.Count) + } + globalRoot, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil || !ok { + t.Fatalf("global root after repair: ok=%v err=%v", ok, err) + } + if globalRoot.Count != 22 { + t.Fatalf("global root count = %d, want 22 (21 ent-A + 1 ent-B)", globalRoot.Count) + } +} + +func bytesHasPrefix(k string, prefix []byte) bool { + return len(k) >= len(prefix) && k[:len(prefix)] == string(prefix) +} diff --git a/pkg/dotc1z/engine/pebble/engine.go b/pkg/dotc1z/engine/pebble/engine.go index fccb73db3..166104e0e 100644 --- a/pkg/dotc1z/engine/pebble/engine.go +++ b/pkg/dotc1z/engine/pebble/engine.go @@ -12,6 +12,7 @@ import ( "github.com/cockroachdb/pebble/v2" "github.com/cockroachdb/pebble/v2/vfs" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "google.golang.org/protobuf/proto" v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" @@ -84,6 +85,35 @@ type Engine struct { // as one sorted SST at EndSync — see BuildDeferredGrantIndexes). deferredIdxPending atomic.Bool + // grantDigestsPresent reports whether the digest keyspace holds any + // nodes — i.e. whether a grant mutation must invalidate the touched + // entitlement's digest + hash-index ranges + // (stageGrantDigestInvalidation). Probed once at Open, set by the + // seal-time build, cleared by ResetForNewSync and the Drop* paths. + grantDigestsPresent atomic.Bool + + // grantDigestBuildPending mirrors the durable digest-build marker + // (encodeGrantDigestBuildPendingKey): true between a digest build's + // arm and its completion (or the drop that cleans up after it). A + // writable Open that finds the durable marker drops all digest state + // immediately, so on a writable engine this is only ever true while + // a build owns the write barrier or after an in-process build + // failure whose cleanup drop itself failed. A READ-ONLY Open cannot + // drop, so the flag stays set and the digest root getters + // (getPartitionDigestRoot, GetGrantDigestGlobalRoot) report "never + // built" instead of trusting nodes a crashed build half-committed. + grantDigestBuildPending atomic.Bool + + // Test seams for the digest build/repair tests: a hook fired at + // named points inside buildGrantDigestsFromSpill + // (grant_digest_build_crash_test.go) and a batch flush-threshold + // override — shared by the build's fold and the streaming partition + // repair (repairOneGrantDigestPartitionLocked) — so a small test + // dataset exercises the mid-stream commit paths. Nil/zero in + // production. + testDigestBuildHook func(stage string) error + testDigestNodeFlushBytes int + // synthLayer is the open wave-scoped layer session, if any (see // BeginSynthesizedGrantLayer). Single producer: the expansion driver // opens/adds/finishes sessions strictly sequentially. synthLayerMu @@ -212,6 +242,35 @@ func Open(ctx context.Context, dir string, opts ...Option) (*Engine, error) { _ = e.Close() return nil, err } + // Honor the durable digest-build marker (see + // encodeGrantDigestBuildPendingKey): a prior process was killed + // mid-digest-build, after some digest-node commits were durable but + // before the hash-index ingest completed. Those nodes LOOK present + // while the index beneath them is empty or stale, so nothing stored + // may be trusted: drop it all before probing presence — absent + // digests are always safe (present-means-exact, digest.go). A + // read-only open cannot drop; it keeps the flag set instead, which + // makes the digest root getters report "never built". + if _, closer, err := e.db.Get(encodeGrantDigestBuildPendingKey()); err == nil { + closer.Close() + e.grantDigestBuildPending.Store(true) + if !o.readOnly { + ctxzap.Extract(ctx).Warn("pebble: interrupted grant digest build detected at open; dropping all digest state — the next EndSync rebuilds it from scratch") + if err := e.dropAllGrantDigestStateLocked(); err != nil { + _ = e.Close() + return nil, fmt.Errorf("pebble: drop digest state left by an interrupted build: %w", err) + } + } + } else if !errors.Is(err, pebble.ErrNotFound) { + _ = e.Close() + return nil, err + } + // Arm the mutation-path digest invalidation iff the file actually + // holds digest nodes (one bounded seek; see grant_digest.go). + if err := e.probeGrantDigestsPresent(); err != nil { + _ = e.Close() + return nil, err + } // Run secondary-index migrations before returning. Migrations // are skipped for read-only opens (the on-disk file is // immutable, so we'd error out trying to backfill). @@ -389,6 +448,11 @@ func (e *Engine) IsFreshSync() bool { return e.freshSync } +// GrantDigestIndexEnabled reports whether the seal-time deferred pass +// builds the by_entitlement_principal_hash index and grant digests. +// See WithGrantDigestIndex. +func (e *Engine) GrantDigestIndexEnabled() bool { return e.opts.grantDigestIndex } + // takeFreshGrantsEmpty / takeFreshResourcesEmpty return true // exactly once per fresh sync, for the first PutXxxRecords call // of that type after diff --git a/pkg/dotc1z/engine/pebble/grant_digest.go b/pkg/dotc1z/engine/pebble/grant_digest.go new file mode 100644 index 000000000..018a0e4d3 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest.go @@ -0,0 +1,499 @@ +package pebble + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/cespare/xxhash/v2" + "github.com/cockroachdb/pebble/v2" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/codec" +) + +// Grant instantiation of the digest core (digest.go): the per- +// entitlement grant digest, folded over the +// by_entitlement_principal_hash index. +// +// partition = the entitlement's encoded primary-key tail +// (see the partition convention in keys.go) +// bucket hash = grantPrincipalBucketHash (identity of the principal) +// content hash = grantContentHash64 (the membership edge) +// +// This answers "does this entitlement have exactly the same grants as +// some other sync/file?" with a single root read, and localizes any +// difference to principal-hash buckets so a diff driver loads only +// those grants. +// +// Everything on this path works on the ENCODED key bytes: the index +// key is spliced out of the grant primary key, and both hashes are +// computed over encoded segment bytes — no decode, no re-encode, no +// proto unmarshal. The encoded tuple form is injective and its escape +// is order-preserving, so hashing encoded bytes is exactly as +// collision-free as hashing the decoded fields with a canonical +// framing, while letting the seal-time build run allocation-free. + +// grantDigestSpec wires the grant hash index into the digest core. The +// index's key layout (see encodeGrantByEntPrincHashEntPrefix) satisfies +// the digestIndexSpec shape contract: partition prefix, then the raw +// digestBucketHashLen-byte bucket hash, then the principal tail; the +// value is the grant content hash. +var grantDigestSpec = digestIndexSpec{ + indexID: idxGrantByEntitlementPrincipalHash, + partitionPrefix: encodeGrantByEntPrincHashEntPrefix, +} + +// grantDigestABIVersion is the version of the content-hash / bucket-hash +// definitions below (grantContentHash64, grantPrincipalBucketHash64). +// Stamped into the manifest's GrantDigestRoot.abi_version so a future +// ABI bump (a change to either hash's input framing) makes stored +// manifest roots computed under different versions incomparable by +// construction, rather than silently comparing unrelated hash schemes. +// Bump alongside any index-migration version bump that touches these +// hashes (see index_migrations.go). +const grantDigestABIVersion uint32 = 1 + +// digestLevelGlobalRoot is the node-key level for the whole-file grant +// digest root (see globalGrantDigestNodeKey): the XOR fold of every +// per-entitlement root's digest, plus the total grant count, across +// the whole sync. It is NOT part of the generic digest.go core (which +// only knows about per-partition roots/leaves at digestLevelRoot / +// digestLevelLeaf) — the "whole index" summary is a grant-digest- +// specific manifest need, so a level value the core never produces +// keeps the global node's key disjoint from every per-partition node +// regardless of what any entitlement's partition bytes happen to be. +const digestLevelGlobalRoot byte = 2 + +// globalGrantDigestNodeKey returns the storage key for the whole-file +// grant digest root: a level-digestLevelGlobalRoot node under an empty +// partition. Its value is packed exactly like a leaf (packDigestLeaf / +// unpackDigestLeaf: count(8 BE) || digest(hashLen)) — there is no +// bucket-width concept for a whole-sync aggregate. +func globalGrantDigestNodeKey() []byte { + return encodeDigestNodeKey(grantDigestSpec.indexID, "", digestLevelGlobalRoot, nil) +} + +// digestPartitionForEntitlement returns the digest partition for an +// entitlement identity: its encoded primary-key tail as a string (see +// the partition convention in keys.go). +func digestPartitionForEntitlement(id entitlementIdentity) string { + return string(appendEntitlementIdentityTail(make([]byte, 0, 96), id)) +} + +// --- Hashes (on-disk ABI) --- +// +// ABI: the two hash definitions below are part of the stored format. +// Two SDK builds must hash identical grants identically or the digest +// comparison reads "everything differs"; changing either input framing +// requires an index-migration bump (index_migrations.go). + +// grantPrincipalBucketHash64 is the bucket address for a principal: +// xxHash64 over the ENCODED principal segments +// +// esc(principal_rt) | 0x00 | esc(principal_id) +// +// — byte-identical to the principal region of the grant's primary key, +// which is what the seal-time build actually hashes (a raw sub-slice, +// no re-encode). Identity only — never the principal's full object — +// so the address is stable across syncs even when the principal's +// attributes change. Only the top digestBucketHashLen bytes of the +// big-endian hash are stored in index keys (the bucket-selecting +// bits). ABI. +func grantPrincipalBucketHash64(encodedPrincipalSegments []byte) uint64 { + return xxhash.Sum64(encodedPrincipalSegments) +} + +// principalBucketHash is the from-identity form of the bucket hash: +// the stored digestBucketHashLen key bytes for a principal given its +// decoded identity. Encodes the segments exactly as the primary grant +// key does, then hashes — so it MUST agree with hashing the spliced +// key region (pinned by TestGrantDigestSpliceMatchesEncode). Returns a +// fresh slice. +func principalBucketHash(principalRT, principalID string) []byte { + enc := codec.AppendTupleStrings(make([]byte, 0, 64), principalRT, principalID) + var full [8]byte + binary.BigEndian.PutUint64(full[:], grantPrincipalBucketHash64(enc)) + out := make([]byte, digestBucketHashLen) + copy(out, full[:]) + return out +} + +// grantContentHash64 is the canonical content hash of a grant — the +// value stored in the hash index and the unit the grant digest folds. +// +// ABI: "the same grant" is defined as +// +// xxHash64( primaryKeyTail ‖ ( 0x00 ‖ esc(source_id) )* ) +// +// where primaryKeyTail is the grant's encoded primary-key tail (the +// 6-segment identity tuple ent_rt|ent_rid|ent_flag|ent_tail|p_rt|p_id, +// escaped and separator-delimited exactly as stored) and the source +// ids — the keys of the grant's sources map, its expansion +// provenance — are appended as additional tuple segments in ascending +// byte order (sortedSourceKeys must already be sorted; the escape is +// order-preserving so raw order == encoded order). +// +// The field set deliberately covers the membership EDGE (the identity +// tuple) plus the grant's source-entitlement set, and deliberately +// EXCLUDES everything sync-relative or transient — external_id (not +// identity under the injective-key scheme; the same edge keeps its +// hash when a connector changes its id grammar), discovered_at, +// needs_expansion, expansion state, and annotations — none of which +// change "which principal holds which entitlement". The source map +// VALUES (GrantSourceRecord) are not folded in v1 — only the set of +// source ids, which is the membership-composition signal. +// +// This is a hand-rolled framing, NOT proto marshal: deterministic-proto +// output is not canonical across protobuf library versions, which +// would make two files written by different SDK builds hash identical +// grants differently. The tuple framing is injective: every segment is +// escaped and separator-delimited, and the identity is a fixed six +// segments, so no source list can alias a different identity split (a +// naive 0x00-joined concatenation would collide e.g. sources +// ["a","b"] vs ["a\x00b"]). +// +// tuple is a caller-reused scratch buffer, returned grown for reuse. +func grantContentHash64(tuple, primaryKeyTail []byte, sortedSourceKeys [][]byte) (uint64, []byte) { + if len(sortedSourceKeys) == 0 { + // Common case: no sources — hash the tail bytes in place. + return xxhash.Sum64(primaryKeyTail), tuple + } + tuple = append(tuple[:0], primaryKeyTail...) + for _, k := range sortedSourceKeys { + tuple = codec.AppendTupleSeparator(tuple) + tuple = codec.AppendTupleBytes(tuple, k) + } + return xxhash.Sum64(tuple), tuple +} + +// grantContentHashForRecord is the from-record form of the content +// hash: encodes the grant's identity tuple and sorts its source keys, +// then delegates to grantContentHash64. The seal-time build never uses +// this (it splices key bytes and raw-scans the value); it exists for +// readers, tests, and any future repair path, and is pinned against +// the splice form by TestGrantDigestSpliceMatchesEncode. +func grantContentHashForRecord(r *v3.GrantRecord) ([]byte, error) { + id, err := grantIdentityFromRecord(r) + if err != nil { + return nil, err + } + key := encodeGrantIdentityKey(id) + srcs := make([][]byte, 0, len(r.GetSources())) + for k := range r.GetSources() { + srcs = append(srcs, []byte(k)) + } + sortByteSlices(srcs) + h, _ := grantContentHash64(nil, key[grantPrimaryKeyPrefixLen:], srcs) + out := make([]byte, hashLen) + binary.BigEndian.PutUint64(out, h) + return out, nil +} + +// sortByteSlices sorts byte slices ascending (bytes.Compare order). +func sortByteSlices(s [][]byte) { + // Small-n insertion sort: source sets are tiny (usually 0–4). + for i := 1; i < len(s); i++ { + for j := i; j > 0 && bytes.Compare(s[j], s[j-1]) < 0; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +// --- Key splices --- + +// grantPrimaryKeyPrefixLen is the byte length of the grant primary-key +// header: versionV3 | typeGrant | separator. +const grantPrimaryKeyPrefixLen = 3 + +// grantHashIndexKeyPrefixLen is the byte length of the hash-index key +// header: versionV3 | typeIndex | idxGrantByEntitlementPrincipalHash | +// separator. +const grantHashIndexKeyPrefixLen = 4 + +// splitGrantPrimaryKey locates the partition/principal boundary of a +// grant primary key: the byte offset of the 4th tuple separator in the +// key (the one that ends the entitlement's 4 identity segments). It +// also validates the overall 6-segment shape, exactly like +// appendGrantByPrincipalKeyFromPrimary. Returns ok=false for keys that +// are not well-formed 6-segment grant identities. +// +// With the returned sep4 in hand every region is a plain sub-slice: +// +// partition = key[grantPrimaryKeyPrefixLen:sep4] +// principal segments = key[sep4+1:] +func splitGrantPrimaryKey(primaryKey []byte) (int, bool) { + if len(primaryKey) < grantPrimaryKeyPrefixLen || + primaryKey[0] != versionV3 || primaryKey[1] != typeGrant || primaryKey[2] != 0 { + return 0, false + } + sep4 := 0 + off := grantPrimaryKeyPrefixLen + for i := range 5 { + sep := bytes.IndexByte(primaryKey[off:], 0) + if sep < 0 { + return 0, false + } + off += sep + if i == 3 { + sep4 = off + } + off++ + } + if bytes.IndexByte(primaryKey[off:], 0) >= 0 { + return 0, false + } + return sep4, true +} + +// appendGrantHashIndexKeyFromPrimary builds the +// by_entitlement_principal_hash index key by SPLICING a grant primary +// key around the raw bucket hash, into dst: +// +// primary = v3|typeGrant|0x00| ent_rt|0|ent_rid|0|ent_flag|0|ent_tail |0| p_rt|0|p_id +// index = v3|typeIndex|idx |0x00| ent_rt|0|ent_rid|0|ent_flag|0|ent_tail |0| bh[0:2] | p_rt|0|p_id +// +// The segments are already escaped and the tuple encoding is +// canonical, so the raw byte splice is byte-identical to +// decode + re-encode (same trick as appendGrantByPrincipalKeyFromPrimary, +// pinned by TestGrantDigestSpliceMatchesEncode). sep4 must come from +// splitGrantPrimaryKey on the same key. +func appendGrantHashIndexKeyFromPrimary(dst, primaryKey []byte, sep4 int, bucketHash64 uint64) []byte { + var bh [8]byte + binary.BigEndian.PutUint64(bh[:], bucketHash64) + dst = append(dst, versionV3, typeIndex, idxGrantByEntitlementPrincipalHash, 0) + dst = append(dst, primaryKey[grantPrimaryKeyPrefixLen:sep4+1]...) // partition + its trailing separator + dst = append(dst, bh[:digestBucketHashLen]...) + return append(dst, primaryKey[sep4+1:]...) // principal segments +} + +// grantPrimaryKeyFromHashIndexKey reconstructs the grant primary key +// from a hash-index key by removing the raw hash region — a byte +// splice, no decode. Returns ok=false for keys that don't parse as +// hash-index entries. The hash bytes may contain 0x00, so the 4th +// separator is found by counting separators from the LEFT (the walk +// never crosses the hash region — see the positional-decoding note on +// encodeGrantByEntPrincHashEntPrefix). +func grantPrimaryKeyFromHashIndexKey(dst, idxKey []byte) ([]byte, bool) { + if len(idxKey) < grantHashIndexKeyPrefixLen || + idxKey[0] != versionV3 || idxKey[1] != typeIndex || + idxKey[2] != idxGrantByEntitlementPrincipalHash || idxKey[3] != 0 { + return dst, false + } + off := grantHashIndexKeyPrefixLen + for range 4 { + sep := bytes.IndexByte(idxKey[off:], 0) + if sep < 0 { + return dst, false + } + off += sep + 1 + } + // off now points at the raw bucket hash. + if len(idxKey) < off+digestBucketHashLen { + return dst, false + } + dst = append(dst, versionV3, typeGrant, 0) + dst = append(dst, idxKey[grantHashIndexKeyPrefixLen:off]...) // partition + trailing separator + return append(dst, idxKey[off+digestBucketHashLen:]...), true +} + +// --- Engine API --- + +// GetEntitlementDigestRoot returns the stored grant-digest root for an +// entitlement. ok is false when no digest has been built for it (or it +// was invalidated) — which means the caller must re-read the +// entitlement's grants (or treat the whole entitlement as dirty), NOT +// fall back to ComputeEntitlementBucketDigest: the hash index that +// fold reads is only ever written and dropped alongside the digest +// nodes, so with no root it is absent too and the fold would report +// "zero grants" for an entitlement that may have millions — the +// false-clean trap dirtyPartitionBuckets' doc comment describes. +func (e *Engine) GetEntitlementDigestRoot(ctx context.Context, id entitlementIdentity) (DigestRoot, bool, error) { + return e.getPartitionDigestRoot(grantDigestSpec, digestPartitionForEntitlement(id)) +} + +// GetGrantDigestGlobalRoot returns the whole-file grant digest root — +// the XOR fold of every entitlement's grant-digest root's content-hash +// plus the total grant count, across the sync's single sync. ok is +// false when no digest has been built for this file (present-means- +// exact: absence means "recalculate", never "zero grants"). Written +// only alongside a full grant-digest build (the seal-time build, or a +// compaction that runs BuildGrantDigests) and dropped by the same +// invalidation paths that drop any per-entitlement root — see +// stageGrantDigestInvalidation and the Drop* functions below. +func (e *Engine) GetGrantDigestGlobalRoot(ctx context.Context) (DigestRoot, bool, error) { + if e.grantDigestBuildPending.Load() { + // Same guard as getPartitionDigestRoot: a global root committed + // by an interrupted build must read as absent, not certify a + // hash index that was never ingested. + return DigestRoot{}, false, nil + } + val, closer, err := e.db.Get(globalGrantDigestNodeKey()) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return DigestRoot{}, false, nil + } + return DigestRoot{}, false, err + } + defer closer.Close() + count, digest, ok := unpackDigestLeaf(val) + if !ok { + return DigestRoot{}, false, fmt.Errorf("GetGrantDigestGlobalRoot: malformed global root") + } + out := make([]byte, len(digest)) + copy(out, digest) + return DigestRoot{Hash: out, Count: count}, true, nil +} + +// ComputeEntitlementBucketDigest folds the grant hash index over a +// single bucket of an entitlement (the zero bucket = the whole +// entitlement) — the authoritative on-demand counterpart of the stored +// digest nodes, for verifying or subdividing a digest that EXISTS. +// Only meaningful while the entitlement's digest is built (its root is +// stored): the hash index lives and dies with the digest nodes, so +// against a never-built or invalidated entitlement this folds an +// absent index range and returns {0, 0} — "zero grants", not "unknown". +// Never use it as a fallback for a missing root; see +// GetEntitlementDigestRoot and computeBucketDigest's precondition. +func (e *Engine) ComputeEntitlementBucketDigest(ctx context.Context, id entitlementIdentity, bucket DigestBucket) ([]byte, int64, error) { + return e.computeBucketDigest(ctx, grantDigestSpec, digestPartitionForEntitlement(id), bucket) +} + +// DirtyEntitlementBuckets compares this engine's entitlement against +// other's and returns the buckets whose grants differ — see +// dirtyPartitionBuckets for the comparison contract (zero bucket = +// whole entitlement; nil = identical). +func (e *Engine) DirtyEntitlementBuckets(ctx context.Context, other *Engine, id entitlementIdentity) ([]DigestBucket, error) { + return e.dirtyPartitionBuckets(ctx, grantDigestSpec, other, digestPartitionForEntitlement(id)) +} + +// IterateGrantsByEntitlementBucket yields the grants in one +// principal-hash bucket of an entitlement (the zero bucket = the whole +// entitlement). This is the dirty-bucket loader: after a digest +// comparison flags a bucket, the caller materializes only those grants. +// The primary key is reconstructed from each index key by byte splice +// (no decode); the point Get per entry is the cost of MATERIALIZING a +// changed grant, not of finding it. Orphan index entries are skipped. +func (e *Engine) IterateGrantsByEntitlementBucket(ctx context.Context, id entitlementIdentity, bucket DigestBucket, yield func(*v3.GrantRecord) bool) error { + lower, upper := grantDigestSpec.bucketBounds(digestPartitionForEntitlement(id), bucket) + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) + if err != nil { + return err + } + defer iter.Close() + var keyScratch []byte + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + return err + } + priKey, ok := grantPrimaryKeyFromHashIndexKey(keyScratch[:0], iter.Key()) + keyScratch = priKey + if !ok { + continue + } + val, closer, getErr := e.db.Get(priKey) + if getErr != nil { + if errors.Is(getErr, pebble.ErrNotFound) { + continue + } + return getErr + } + r := &v3.GrantRecord{} + uErr := unmarshalRecord(val, r) + closer.Close() + if uErr != nil { + return fmt.Errorf("IterateGrantsByEntitlementBucket: unmarshal: %w", uErr) + } + if !yield(r) { + return nil + } + } + return iter.Error() +} + +// DropAllGrantDigests removes every stored digest node. Called when a +// seal-time build fails partway: a partially built digest that LOOKS +// present would violate the present-means-exact contract, whereas +// absent digests just make readers re-read the grants until the next +// successful seal recalculates them. +func (e *Engine) DropAllGrantDigests(ctx context.Context) error { + return e.withWrite(func() error { + e.grantDigestsPresent.Store(false) + return e.db.DeleteRange(DigestLowerBound(), DigestUpperBound(), writeOpts(e.opts.durability)) + }) +} + +// DropAllGrantDigestState removes every stored digest node AND the +// whole by_entitlement_principal_hash index, and clears the engine's +// digests-present flag. For callers that are about to mutate grants +// through paths that bypass the engine's index maintenance entirely +// (the synccompactor's fold merge): after this, digests read as +// "missing — recalculate", never as stale-but-present, and subsequent +// engine-path writes skip per-entitlement invalidation tombstones. +// Two range tombstones: the digest keyspace and the hash-index +// keyspace are NOT adjacent (typeCounter/typeSession sit between +// them), so this must never be collapsed into one span. +func (e *Engine) DropAllGrantDigestState(ctx context.Context) error { + return e.withWrite(func() error { + e.grantDigestsPresent.Store(false) + opts := writeOpts(e.opts.durability) + if err := e.db.DeleteRange(DigestLowerBound(), DigestUpperBound(), opts); err != nil { + return err + } + return e.db.DeleteRange(GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound(), opts) + }) +} + +// stageGrantDigestInvalidation stages the post-seal invalidation for +// one entitlement into batch: DeleteRange over the entitlement's +// digest partition (present-means-exact — a mutated partition's digest +// must read as missing, see digest.go) AND over its hash-index range +// (the index is derived at seal and is equally stale after a +// mutation). v1 never updates either in place. +// +// Gated on grantDigestsPresent so the ordinary sync paths pay nothing: +// during a fresh sync both keyspaces are empty by construction +// (ResetForNewSync wiped them; only the seal build writes them), and a +// resumed unfinished sync never built them — emitting two range +// tombstones per record there (e.g. PutExpandedGrantRecords over +// millions of grants) would bloat the LSM for no reason. The flag is +// true only when digests actually exist: probed once at Open, set by +// the seal build, cleared by ResetForNewSync and the Drop* paths. +func (e *Engine) stageGrantDigestInvalidation(batch *pebble.Batch, id entitlementIdentity) error { + if !e.grantDigestsPresent.Load() { + return nil + } + partition := digestPartitionForEntitlement(id) + if err := dropPartitionDigest(batch, grantDigestSpec, partition); err != nil { + return err + } + // The whole-file root is the fold of every partition's root; once + // one partition's digest is invalidated the aggregate is stale too, + // so it must drop alongside it rather than linger looking present + // (present-means-exact — digest.go). + if err := batch.Delete(globalGrantDigestNodeKey(), nil); err != nil { + return err + } + lo := encodeGrantByEntPrincHashEntPrefix(partition) + return batch.DeleteRange(lo, upperBoundOf(lo), nil) +} + +// probeGrantDigestsPresent initializes the digests-present flag at +// Open: one bounded seek over the digest keyspace. Present digests on +// a reopened sealed file arm the mutation-path invalidation +// (stageGrantDigestInvalidation); absent digests keep those paths +// tombstone-free. +func (e *Engine) probeGrantDigestsPresent() error { + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: DigestLowerBound(), + UpperBound: DigestUpperBound(), + }) + if err != nil { + return err + } + defer iter.Close() + e.grantDigestsPresent.Store(iter.First()) + return iter.Error() +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_build.go b/pkg/dotc1z/engine/pebble/grant_digest_build.go new file mode 100644 index 000000000..a4548a83b --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_build.go @@ -0,0 +1,740 @@ +package pebble + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/cockroachdb/pebble/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +// Seal-time construction of the by_entitlement_principal_hash index and +// the per-entitlement grant digests, fused into the deferred EndSync +// pass (BuildDeferredGrantIndexes). This file is the ONLY writer of +// either keyspace. +// +// The deferred pass's primary-grant scan emits one hash-index row per +// grant into a second spill-sorter family (appendGrantHashIndexRow). +// The sorted rows come back in whole-key byte order, which by the index +// layout is (entitlement partition, bucket hash, principal) — exactly +// digest fold order. The final k-way merge therefore does double duty: +// it streams the rows into the index SST (ingested over the excised +// index range, like by_principal) AND folds the digests on the fly +// (grantDigestFold), accumulating each partition's buckets at max width +// and folding down to chooseDigestWidth's pick when the partition +// closes — the total count isn't known until then, and XOR +// split-independence makes the fold-down exact. +// +// Digest nodes do NOT ride the index SST: typeDigest is separated from +// the index keyspace by typeCounter/typeSession, and an excise span +// covering all three would wipe counters and sessions. The nodes are +// tiny (one root + sparse leaves per entitlement), so they go through +// ordinary batch Sets, committed in bounded chunks during the merge. + +// grantHashRowScratch is the reusable per-row scratch for +// appendGrantHashIndexRow: the deferred scan calls it once per grant +// and none of it may allocate per row. +type grantHashRowScratch struct { + keyBuf []byte + tupleBuf []byte + srcKeys [][]byte +} + +// appendGrantHashIndexRow derives one hash-index row from a raw +// primary-grant (key, value) and adds it to the sorter: +// +// - the index key is spliced out of the primary key +// (appendGrantHashIndexKeyFromPrimary — no decode); +// - the bucket hash is xxHash64 of the primary key's principal +// region (grantPrincipalBucketHash64 — a raw sub-slice); +// - the content hash covers the primary-key tail plus the grant's +// sorted source-entitlement ids, pulled from the value with a raw +// protobuf field scan (scanGrantSourceKeysRawBytes — no proto +// unmarshal anywhere on this path). +// +// key/value are only borrowed (the sorter copies before returning). +func appendGrantHashIndexRow(sorter *spillSorter, primaryKey, value []byte, s *grantHashRowScratch) error { + sep4, ok := splitGrantPrimaryKey(primaryKey) + if !ok { + // The caller skips rows that already failed the by_principal + // splice; reaching here means the two splitters disagree. + return fmt.Errorf("grant hash index: primary key %x did not split as a 6-segment identity", primaryKey) + } + srcs, err := scanGrantSourceKeysRawBytes(value, s.srcKeys[:0]) + if err != nil { + return fmt.Errorf("grant hash index: scan sources: %w", err) + } + s.srcKeys = srcs + if len(srcs) > 1 { + sortByteSlices(srcs) + } + ch64, tuple := grantContentHash64(s.tupleBuf, primaryKey[grantPrimaryKeyPrefixLen:], srcs) + s.tupleBuf = tuple + bh64 := grantPrincipalBucketHash64(primaryKey[sep4+1:]) + s.keyBuf = appendGrantHashIndexKeyFromPrimary(s.keyBuf[:0], primaryKey, sep4, bh64) + var chb [hashLen]byte + binary.BigEndian.PutUint64(chb[:], ch64) + return sorter.add(s.keyBuf, chb[:]) +} + +// digestNodeBatchFlushBytes bounds how much digest-node data a single +// batch accumulates before committing mid-merge. +const digestNodeBatchFlushBytes = 4 << 20 + +// markGrantDigestBuildPending durably arms the digest-build marker +// (encodeGrantDigestBuildPendingKey). MUST be fsync'd (pebble.Sync, +// even during a fresh sync) and MUST precede the build's first digest +// write: the build's own node commits may ride NoSync, and a node +// batch becoming durable ahead of the marker would reopen exactly the +// trust-a-crashed-build window the marker closes. +func (e *Engine) markGrantDigestBuildPending() error { + if err := e.db.Set(encodeGrantDigestBuildPendingKey(), nil, pebble.Sync); err != nil { + return fmt.Errorf("arm grant digest build marker: %w", err) + } + e.grantDigestBuildPending.Store(true) + return nil +} + +// clearGrantDigestBuildPending drops both forms of the marker. Called +// only once the digest state is a complete, self-consistent whole: a +// finished build (buildGrantDigestsFromSpill) or a full drop +// (dropAllGrantDigestStateLocked). The delete is fsync'd deliberately — +// the WAL is prefix-durable, so a durable clear implies every +// (possibly NoSync) write that preceded it is durable too. The +// marker's absence therefore certifies COMPLETE digest state on disk, +// never a lucky partial one. +func (e *Engine) clearGrantDigestBuildPending() error { + if err := e.db.Delete(encodeGrantDigestBuildPendingKey(), pebble.Sync); err != nil { + return fmt.Errorf("clear grant digest build marker: %w", err) + } + e.grantDigestBuildPending.Store(false) + return nil +} + +// grantDigestFold streams the merge's (index key, content hash) rows +// into digest nodes. Rows arrive in (partition, bucket hash) order, so +// each partition is a contiguous run and its buckets are touched in +// ascending order; the fold keeps a max-width (2^16) scratch — ~1.5MiB +// of counts+xors, reused across all partitions — plus the list of +// touched buckets, so per-partition close and reset are O(touched), +// never O(2^16). +type grantDigestFold struct { + e *Engine + opts *pebble.WriteOptions + flushBytes int // digestNodeBatchFlushBytes, or the engine's test override + + counts []int64 // 1< 0 { + flushBytes = e.testDigestNodeFlushBytes + } + f := &grantDigestFold{ + e: e, + opts: opts, + flushBytes: flushBytes, + counts: make([]int64, 1< 0 { + // Fold the touched max-width buckets (ascending) down to the + // chosen width: consecutive buckets sharing the top `width` + // bits merge into one leaf. Exact by XOR split-independence. + shift := digestMaxWidthBits - width + for i := 0; i < len(f.touched); { + leafIdx := f.touched[i] >> shift + var digest [hashLen]byte + var count int64 + for i < len(f.touched) && f.touched[i]>>shift == leafIdx { + b := f.touched[i] + xorInto(digest[:], f.xors[b][:]) + count += f.counts[b] + i++ + } + var prefix [digestLeafPrefixLen]byte + binary.BigEndian.PutUint16(prefix[:], leafIdx<= f.flushBytes { + if err := f.batch.Commit(f.opts); err != nil { + return err + } + f.batch.Close() + f.batch = f.e.db.NewBatch() + if f.e.testDigestBuildHook != nil { + // Crash-window seam: digest-node batches are now committed + // (durable under WAL replay) while the hash-index ingest has + // not run. See grant_digest_build_crash_test.go. + if err := f.e.testDigestBuildHook("node-batch-committed"); err != nil { + return err + } + } + } + return nil +} + +// finish closes the last partition, writes the whole-file global root +// (the fold of every partition this build touched — see globalXor/ +// globalTotal), and commits the tail batch. The global root lands in +// the same final batch as the last partition's nodes, so it is never +// visible without them: a crash between batches can only leave the +// global root ABSENT, never present ahead of a partition it should +// have folded in. +func (f *grantDigestFold) finish() error { + if err := f.closePartition(); err != nil { + return err + } + if err := f.batch.Set(globalGrantDigestNodeKey(), packDigestLeaf(f.globalTotal, f.globalXor[:]), nil); err != nil { + return err + } + f.nodes++ + err := f.batch.Commit(f.opts) + f.batch.Close() + f.batch = nil + return err +} + +// abort discards the un-committed tail batch. Safe after finish (no-op). +func (f *grantDigestFold) abort() { + if f.batch != nil { + f.batch.Close() + f.batch = nil + } +} + +// splitGrantHashIndexKey locates the partition and raw-hash regions of +// a hash-index key: partition = key[grantHashIndexKeyPrefixLen:sepEnd], +// bucket hash at [sepEnd+1, sepEnd+1+digestBucketHashLen). Counts the +// four partition separators from the LEFT so the walk never crosses +// the raw hash region (whose bytes may be 0x00). +func splitGrantHashIndexKey(key []byte) ([]byte, uint16, bool) { + if len(key) < grantHashIndexKeyPrefixLen || + key[0] != versionV3 || key[1] != typeIndex || + key[2] != idxGrantByEntitlementPrincipalHash || key[3] != 0 { + return nil, 0, false + } + off := grantHashIndexKeyPrefixLen + for range 4 { + sep := bytes.IndexByte(key[off:], 0) + if sep < 0 { + return nil, 0, false + } + off += sep + 1 + } + if len(key) < off+digestBucketHashLen { + return nil, 0, false + } + return key[grantHashIndexKeyPrefixLen : off-1], binary.BigEndian.Uint16(key[off : off+digestBucketHashLen]), true +} + +// mergeGrantHashChunksToSST heap-merges the hash-index sorter's chunk +// files into one SST while streaming every row through the digest +// fold. Same merge shape as mergeSortedSpillChunksToSST; duplicate +// keys are corruption here too — (entitlement, principal) is the grant +// primary identity, so the sorter sees exactly one row per grant. +func mergeGrantHashChunksToSST(ctx context.Context, sstPath, name string, chunks []string, fold *grantDigestFold) error { + start := time.Now() + l := ctxzap.Extract(ctx) + readers := make([]*os.File, 0, len(chunks)) + defer func() { + for _, r := range readers { + _ = r.Close() + } + }() + bufReaders := make([]*bufio.Reader, len(chunks)) + keyBufs := make([][]byte, len(chunks)) + valBufs := make([][]byte, len(chunks)) + h := &spillChunkHeap{} + var lenBuf [4]byte + for i, chunk := range chunks { + f, err := os.Open(chunk) // #nosec G304 - staged under the build's MkdirTemp dir. + if err != nil { + return err + } + readers = append(readers, f) + bufReaders[i] = bufio.NewReaderSize(f, bulkSpillBufferSize) + ok, err := readSpillEntry(bufReaders[i], &keyBufs[i], &valBufs[i], &lenBuf) + if err != nil { + return err + } + if ok { + h.push(spillChunkItem{chunkIdx: i, key: keyBufs[i], val: valBufs[i]}) + } + } + + writer, err := newBulkSSTWriter(filepath.Dir(sstPath), name) + if err != nil { + return err + } + success := false + defer func() { + _ = writer.finish() + if !success { + _ = os.Remove(sstPath) + } + }() + var last []byte + var written int64 + lastLog := start + for len(*h) > 0 { + item := h.pop() + if bytes.Equal(item.key, last) { + return fmt.Errorf("%w: bucket %s key %x", errBulkImportDuplicateKey, name, item.key) + } + partition, bucket, ok := splitGrantHashIndexKey(item.key) + if !ok { + // Impossible for keys this build emitted; corruption. + return fmt.Errorf("grant hash index merge: malformed index key %x", item.key) + } + if err := fold.add(partition, bucket, item.val); err != nil { + return err + } + if err := writer.add(item.key, item.val); err != nil { + return err + } + written++ + // Throttled bookkeeping, same rationale as the primary merge. + if written&0xFFFF == 0 { + if err := ctx.Err(); err != nil { + return err + } + if now := time.Now(); now.Sub(lastLog) >= 15*time.Second { + l.Info("grant digest build: merging hash-index chunks", + zap.Int("chunks", len(chunks)), + zap.Int64("entries_written", written), + zap.Int64("partitions_folded", fold.partitions), + zap.Duration("elapsed", now.Sub(start)), + ) + lastLog = now + } + } + last = append(last[:0], item.key...) + ok, err := readSpillEntry(bufReaders[item.chunkIdx], &keyBufs[item.chunkIdx], &valBufs[item.chunkIdx], &lenBuf) + if err != nil { + return err + } + if ok { + h.push(spillChunkItem{chunkIdx: item.chunkIdx, key: keyBufs[item.chunkIdx], val: valBufs[item.chunkIdx]}) + } + } + if err := writer.finish(); err != nil { + return err + } + l.Info("grant digest build: hash-index merge complete", + zap.Int("chunks", len(chunks)), + zap.Int64("entries_written", written), + zap.Int64("partitions_folded", fold.partitions), + zap.Duration("elapsed", time.Since(start)), + ) + success = true + return nil +} + +// buildGrantDigestsFromSpill finalizes the deferred pass's hash-index +// sorter into the ingested index SST and the digest nodes, then +// backfills zero-grant entitlement roots. Runs under the engine write +// barrier (the deferred pass holds it). Any error leaves partially +// written digest nodes behind — the caller MUST drop the digest state +// on failure (dropAllGrantDigestStateLocked) so no half-built digest +// survives looking present. +// +// An in-process error the caller can drop on is only half the failure +// surface: node batches commit DURING the merge and at fold.finish(), +// before the index ingest, and committed WAL writes survive a process +// kill. The durable build-pending marker armed here (and cleared only +// once everything below has completed, or by the drop) is what keeps a +// crash inside that window from resurrecting correct-looking digest +// roots over a never-ingested hash index — see +// encodeGrantDigestBuildPendingKey. +func (e *Engine) buildGrantDigestsFromSpill(ctx context.Context, dir string, hashIdx *spillSorter) error { + start := time.Now() + l := ctxzap.Extract(ctx) + chunks, err := hashIdx.finalize() + if err != nil { + return err + } + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + // Arm the durable crash marker before the first digest write on + // EITHER branch below. + if err := e.markGrantDigestBuildPending(); err != nil { + return err + } + if len(chunks) == 0 { + // No grants at all — pebble rejects an empty SST, so clear any + // stale state directly, then give every entitlement its + // {count: 0} root (the "empty vs. never built" distinction). + if err := e.db.DeleteRange(DigestLowerBound(), DigestUpperBound(), opts); err != nil { + return err + } + if err := e.db.DeleteRange(GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound(), opts); err != nil { + return err + } + if err := e.writeMissingEntitlementDigestRoots(ctx, opts); err != nil { + return err + } + // Zero grants still means the digest WAS built (present-means- + // exact — an absent global root would tell a manifest reader to + // recalculate instead of trusting "nothing to diff"). + if err := e.db.Set(globalGrantDigestNodeKey(), packDigestLeaf(0, zeroDigest[:]), opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + return e.clearGrantDigestBuildPending() + } + + fold, err := newGrantDigestFold(e) + if err != nil { + return err + } + defer fold.abort() + sstPath := filepath.Join(dir, fmt.Sprintf("index-%02x.sst", idxGrantByEntitlementPrincipalHash)) + if err := mergeGrantHashChunksToSST(ctx, sstPath, fmt.Sprintf("index-%02x", idxGrantByEntitlementPrincipalHash), chunks, fold); err != nil { + return err + } + if err := fold.finish(); err != nil { + return err + } + mergeDone := time.Now() + if e.testDigestBuildHook != nil { + // Crash-window seam: every digest node INCLUDING the global root + // is committed, the ingest has not run. See + // grant_digest_build_crash_test.go. + if err := e.testDigestBuildHook("post-finish"); err != nil { + return err + } + } + + // Atomically replace the whole hash-index range with the merged SST. + if _, err := e.db.IngestAndExcise(ctx, []string{sstPath}, nil, nil, pebble.KeyRange{ + Start: GrantByEntPrincHashLowerBound(), + End: GrantByEntPrincHashUpperBound(), + }); err != nil { + return fmt.Errorf("grant digest build: ingest/excise: %w", err) + } + + // Grant-bearing partitions got their digests from the fold; + // entitlements with zero grants still need a root. + if err := e.writeMissingEntitlementDigestRoots(ctx, opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + // The digest state is complete: consume the crash marker. Its + // fsync'd delete also makes every NoSync node batch above durable + // (WAL prefix ordering), so "marker absent" always means "complete + // state on disk". + if err := e.clearGrantDigestBuildPending(); err != nil { + return err + } + + l.Info("grant digest build complete", + zap.Int64("index_rows", hashIdx.count), + zap.Int64("partitions", fold.partitions), + zap.Int64("digest_nodes", fold.nodes), + zap.Duration("merge", mergeDone.Sub(start)), + zap.Duration("total", time.Since(start)), + ) + return nil +} + +// writeMissingEntitlementDigestRoots gives every entitlement RECORD +// with no stored digest root a {width: 0, count: 0} root. Grant-bearing +// partitions were covered by the fold (including orphans that have +// grants but no entitlement record), so a missing root here means zero +// grants by construction; this pass covers entitlements the sync saw +// but granted nothing under. One pass over the entitlement primary +// keyspace — by far the smallest record type — with a point Get per +// entitlement; deliberately not fused into the grant scan. +func (e *Engine) writeMissingEntitlementDigestRoots(ctx context.Context, opts *pebble.WriteOptions) error { + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: EntitlementLowerBound(), + UpperBound: EntitlementUpperBound(), + }) + if err != nil { + return err + } + defer iter.Close() + batch := e.db.NewBatch() + // Closure, not a bare defer: the loop rotates `batch` on flush and a + // receiver-bound defer would double-Close the first batch. + defer func() { batch.Close() }() + emptyRoot := packDigestRoot(0, 0, zeroDigest[:]) + var scanned int64 + for iter.First(); iter.Valid(); iter.Next() { + scanned++ + if scanned&0x3FF == 0 { + if err := ctx.Err(); err != nil { + return err + } + } + key := iter.Key() + if len(key) <= grantPrimaryKeyPrefixLen { + continue + } + // The entitlement primary tail IS the digest partition (see the + // partition convention in keys.go) — a raw splice, no decode. + partition := string(key[grantPrimaryKeyPrefixLen:]) + rootKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelRoot, nil) + if _, closer, err := e.db.Get(rootKey); err == nil { + closer.Close() + continue + } else if !errors.Is(err, pebble.ErrNotFound) { + return err + } + if err := batch.Set(rootKey, emptyRoot, nil); err != nil { + return err + } + if batch.Len() >= digestNodeBatchFlushBytes { + if err := batch.Commit(opts); err != nil { + return err + } + batch.Close() + batch = e.db.NewBatch() + } + } + if err := iter.Error(); err != nil { + return err + } + return batch.Commit(opts) +} + +// BuildGrantDigests is the full-file standalone digest build: grants +// written through PutGrantRecords / UnsafePutUniqueGrantRecords / the +// bulk importer maintain by_principal inline and never arm the +// deferred-index marker, so a sync without expansion-path writes +// reaches EndSync with deferredIdxPending false — and possibly +// millions of grants. This runs its own primary-grant scan feeding the +// same spill-sorter → merge+fold → ingest machinery the fused pass +// uses (buildGrantDigestsFromSpill). When the deferred pass DOES run, +// the fused build supersedes this (same output, one shared scan). +// +// Callers: Adapter.endSyncFinalize calls RepairMissingGrantDigests +// rather than this directly — RepairMissingGrantDigests falls back to +// this exact function whenever no digest exists yet at all +// (grantDigestsPresent false), which is always true for a brand-new +// sync (ResetForNewSync excises the digest keyspace at StartNewSync), +// so the common case is unchanged. This function stays exported and +// self-sufficient for direct callers (tests, explicit repair) that +// want an unconditional from-scratch rebuild regardless of what +// already exists. +// +// Failure semantics match the fused pass: any build error (except +// context cancellation, which stays fatal) downgrades to a loud drop of +// the digest state — absent digests are safe, half-built ones are not. +func (e *Engine) BuildGrantDigests(ctx context.Context) error { + return e.withWriteAllowSealed(func() error { + err := e.buildGrantDigestsStandaloneLocked(ctx) + if err == nil { + return nil + } + if ctx.Err() != nil { + return err + } + ctxzap.Extract(ctx).Error("grant digest build failed; dropping digest state — grant-diff callers must re-read grants until the next successful seal", + zap.Error(err), + ) + if dropErr := e.dropAllGrantDigestStateLocked(); dropErr != nil { + return fmt.Errorf("BuildGrantDigests: drop grant digest state after failed build: %w", dropErr) + } + return nil + }) +} + +func (e *Engine) buildGrantDigestsStandaloneLocked(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + dir, err := os.MkdirTemp("", "pebble-grant-digest-") + if err != nil { + return fmt.Errorf("BuildGrantDigests: mkdir temp: %w", err) + } + defer os.RemoveAll(dir) + + sorters := min(4, max(2, runtime.GOMAXPROCS(0)/2)) + sem := make(chan struct{}, sorters) + hashIdx := newSpillSorter(dir, fmt.Sprintf("index-%02x", idxGrantByEntitlementPrincipalHash), sem, deferredIndexSpillChunkBytes) + hashIdx.free = newSpillArenaFreeList(deferredIndexSpillChunkBytes, sorters+1) + // Wait out background chunk sorts before the deferred RemoveAll + // deletes the directory they write into; no-op after finalize. + defer hashIdx.abort() + + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: GrantLowerBound(), + UpperBound: GrantUpperBound(), + }) + if err != nil { + return fmt.Errorf("BuildGrantDigests: iter: %w", err) + } + iterClosed := false + defer func() { + if !iterClosed { + _ = iter.Close() + } + }() + var scanned, droppedMalformedKeys int64 + var scratch grantHashRowScratch + for iter.First(); iter.Valid(); iter.Next() { + if _, ok := splitGrantPrimaryKey(iter.Key()); !ok { + // Same key-layout-drift/corruption case the deferred pass + // counts; such rows cannot be represented in the digests. + droppedMalformedKeys++ + continue + } + if err := appendGrantHashIndexRow(hashIdx, iter.Key(), iter.Value(), &scratch); err != nil { + return err + } + scanned++ + if scanned&0xFFFF == 0 { + if err := ctx.Err(); err != nil { + return err + } + } + } + if err := iter.Error(); err != nil { + return fmt.Errorf("BuildGrantDigests: iter error: %w", err) + } + if droppedMalformedKeys > 0 { + ctxzap.Extract(ctx).Error("grant digest build: grant primary keys did not decode as 6-segment identities; their rows are NOT represented in the digests", + zap.Int64("dropped", droppedMalformedKeys), + ) + } + // Release the iterator's pinned version before the build's + // IngestAndExcise replaces the hash-index range. + iterClosed = true + if err := iter.Close(); err != nil { + return fmt.Errorf("BuildGrantDigests: close iter: %w", err) + } + return e.buildGrantDigestsFromSpill(ctx, dir, hashIdx) +} + +// dropAllGrantDigestStateLocked is DropAllGrantDigestState for callers +// already holding the engine write barrier (the failed-build handlers, +// Open's interrupted-build recovery), plus the marker consumption those +// callers need: the drop restores the always-safe "digests absent" +// state, which is exactly what the build-pending marker demands, so it +// is cleared here in the same stroke. The clear's fsync'd delete runs +// LAST — WAL prefix ordering makes the (possibly NoSync) tombstones +// above durable before the marker's absence is. +func (e *Engine) dropAllGrantDigestStateLocked() error { + e.grantDigestsPresent.Store(false) + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + if err := e.db.DeleteRange(DigestLowerBound(), DigestUpperBound(), opts); err != nil { + return err + } + if err := e.db.DeleteRange(GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound(), opts); err != nil { + return err + } + return e.clearGrantDigestBuildPending() +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_build_crash_test.go b/pkg/dotc1z/engine/pebble/grant_digest_build_crash_test.go new file mode 100644 index 000000000..5838ce392 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_build_crash_test.go @@ -0,0 +1,214 @@ +package pebble + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/cockroachdb/pebble/v2" + "github.com/stretchr/testify/require" + + "github.com/conductorone/baton-sdk/pkg/connectorstore" +) + +// Crash-window tests for the grant digest build's durable pending +// marker (encodeGrantDigestBuildPendingKey). The build commits digest +// nodes in bounded batches DURING the merge and again at fold.finish() +// — including the global root — before the hash-index IngestAndExcise, +// and committed WAL writes survive a process kill. Without the marker, +// a resumed EndSync's RepairMissingGrantDigests trusted every root the +// crashed build left behind (or fast-pathed on its global root) and +// sealed the file with correct-looking digests over an empty hash +// index — the silent skip-entitlements-at-uplift failure mode. +// +// The kill is simulated by driving the REAL standalone build to a +// sentinel error at a named point (testDigestBuildHook), bypassing +// BuildGrantDigests's in-process drop handler — exactly a process +// death's durable footprint (a clean Close then persists everything +// committed, the maximal state a WAL replay could resurrect). + +var errDigestBuildKilled = errors.New("test: digest build killed") + +// seedDigestCrashRecords writes a deterministic entitlement/grant set — +// identical bytes on every engine it is applied to, so the recovered +// build can be compared byte-for-byte against a from-scratch reference. +func seedDigestCrashRecords(t *testing.T, e *Engine) { + t.Helper() + ctx := context.Background() + for entID, principals := range map[string][]string{ + "ent-a": {"alice", "bob", "carol"}, + "ent-b": {"dave", "erin"}, + "ent-c": {"frank"}, + "ent-zero": nil, + } { + putEnt(t, e, ctx, entID) + for _, p := range principals { + g := makeGrant("", "g-"+entID+"-"+p, entID, p) + require.NoError(t, e.PutGrantRecords(ctx, g), "PutGrantRecords(%s/%s)", entID, p) + } + } +} + +// dumpKeyRangeTest snapshots a keyspace range as key→value bytes for +// byte-for-byte comparison between engines. +func dumpKeyRangeTest(t *testing.T, e *Engine, lo, hi []byte) map[string][]byte { + t.Helper() + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lo, UpperBound: hi}) + require.NoError(t, err, "NewIter") + defer iter.Close() + out := map[string][]byte{} + for iter.First(); iter.Valid(); iter.Next() { + out[string(iter.Key())] = append([]byte(nil), iter.Value()...) + } + require.NoError(t, iter.Error(), "iter") + return out +} + +func rawKeyPresent(t *testing.T, e *Engine, key []byte) bool { + t.Helper() + _, closer, err := e.db.Get(key) + if err == nil { + closer.Close() + return true + } + require.ErrorIs(t, err, pebble.ErrNotFound) + return false +} + +// TestGrantDigestBuildCrashMidMerge kills the standalone build right +// after a mid-merge digest-node batch commit: some partitions' roots +// are durable, the global root and the hash-index ingest are not. +func TestGrantDigestBuildCrashMidMerge(t *testing.T) { + testGrantDigestBuildCrash(t, + func(e *Engine) { + // Commit (and fire the hook) on every partition close so a + // small dataset exercises the mid-merge commit path; die + // after the first committed batch. + e.testDigestNodeFlushBytes = 1 + fired := 0 + e.testDigestBuildHook = func(stage string) error { + if stage != "node-batch-committed" { + return nil + } + fired++ + if fired == 1 { + return errDigestBuildKilled + } + return nil + } + }, + func(t *testing.T, e *Engine) { + // Partial: at least one partition's nodes committed, but the + // global root only rides fold.finish()'s final batch. + require.NotZero(t, countKeyRangeTest(t, e, DigestLowerBound(), DigestUpperBound()), + "mid-merge kill must leave committed digest nodes behind") + require.False(t, rawKeyPresent(t, e, globalGrantDigestNodeKey()), + "the global root must not be durable before fold.finish()") + }, + ) +} + +// TestGrantDigestBuildCrashPostFinish kills the build between +// fold.finish() and the hash-index IngestAndExcise: EVERY digest node +// including the global root is durable over an index that was never +// ingested — the exact state RepairMissingGrantDigests's global-root +// fast path would have trusted wholesale. +func TestGrantDigestBuildCrashPostFinish(t *testing.T) { + testGrantDigestBuildCrash(t, + func(e *Engine) { + e.testDigestBuildHook = func(stage string) error { + if stage == "post-finish" { + return errDigestBuildKilled + } + return nil + } + }, + func(t *testing.T, e *Engine) { + require.True(t, rawKeyPresent(t, e, globalGrantDigestNodeKey()), + "post-finish kill must leave the global root durable") + }, + ) +} + +func testGrantDigestBuildCrash(t *testing.T, arm func(*Engine), assertPoisoned func(*testing.T, *Engine)) { + ctx := context.Background() + dbDir := filepath.Join(t.TempDir(), "db") + + // Process A: sync some grants (inline-index writes — the deferred + // marker is never armed, so EndSync would take the standalone + // RepairMissingGrantDigests→BuildGrantDigests path), then die inside + // the digest build. + e, err := Open(ctx, dbDir) + require.NoError(t, err) + a := NewAdapter(e) + syncID, err := a.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + require.NoError(t, err) + seedDigestCrashRecords(t, e) + + arm(e) + buildErr := e.withWriteAllowSealed(func() error { return e.buildGrantDigestsStandaloneLocked(ctx) }) + require.ErrorIs(t, buildErr, errDigestBuildKilled) + + // The poisoned durable state the marker exists for: the marker and + // (some or all) digest nodes are committed, the ingest never ran. + require.True(t, rawKeyPresent(t, e, encodeGrantDigestBuildPendingKey()), + "the build-pending marker must be armed before any node commit") + require.Zero(t, countKeyRangeTest(t, e, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + "the hash index must be empty — the ingest never ran") + assertPoisoned(t, e) + + // Even in-process, the root getters must refuse the crashed build's + // nodes while the pending flag is set. + _, ok, gerr := e.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, gerr) + require.False(t, ok, "a pending build's global root must read as absent") + + // The "crash": stop without any cleanup. A clean Close persists + // every committed batch — the maximal state WAL replay could hand a + // real crash's successor. + require.NoError(t, e.Close()) + + // Process B: reopen. The marker must be consumed by dropping ALL + // digest state — a crash always converts to "digests absent". + e2, err := Open(ctx, dbDir) + require.NoError(t, err) + defer e2.Close() + require.False(t, e2.grantDigestBuildPending.Load(), "reopen must consume the durable marker") + require.False(t, rawKeyPresent(t, e2, encodeGrantDigestBuildPendingKey())) + require.Zero(t, countKeyRangeTest(t, e2, DigestLowerBound(), DigestUpperBound()), + "reopen must drop every digest node the crashed build committed") + require.False(t, e2.grantDigestsPresent.Load()) + + // Resume the sync and rerun EndSync — the standalone build runs + // again, from scratch. + a2 := NewAdapter(e2) + require.NoError(t, a2.SetCurrentSync(ctx, syncID)) + require.NoError(t, a2.EndSync(ctx)) + require.False(t, e2.grantDigestBuildPending.Load(), "a successful build must clear the marker") + require.False(t, rawKeyPresent(t, e2, encodeGrantDigestBuildPendingKey())) + + // The recovered digest state must be byte-for-byte identical to a + // from-scratch build over the same records — digest nodes, hash + // index, and global root carry no sync-relative bytes. + ref, _ := newTestEngine(t) + aRef := NewAdapter(ref) + _, err = aRef.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + require.NoError(t, err) + seedDigestCrashRecords(t, ref) + require.NoError(t, aRef.EndSync(ctx)) + + require.Equal(t, + dumpKeyRangeTest(t, ref, DigestLowerBound(), DigestUpperBound()), + dumpKeyRangeTest(t, e2, DigestLowerBound(), DigestUpperBound()), + "digest nodes after crash recovery must match a from-scratch build") + require.Equal(t, + dumpKeyRangeTest(t, ref, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + dumpKeyRangeTest(t, e2, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), + "hash-index rows after crash recovery must match a from-scratch build") + + root, rootOK, err := e2.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, rootOK, "global root must be present after the recovered seal") + require.EqualValues(t, 6, root.Count) +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_global_root_test.go b/pkg/dotc1z/engine/pebble/grant_digest_global_root_test.go new file mode 100644 index 000000000..fd6c7493f --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_global_root_test.go @@ -0,0 +1,191 @@ +package pebble + +import ( + "bytes" + "context" + "testing" + + "github.com/segmentio/ksuid" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore" +) + +// TestGrantDigestGlobalRootMatchesFold verifies the whole-file grant +// digest root (the manifest-level summary) equals the XOR/count fold +// of every entitlement's own digest root, independently recomputed via +// the authoritative on-demand fold (ComputeEntitlementBucketDigest) — +// not via the same fold code path that produced the global root. +func TestGrantDigestGlobalRootMatchesFold(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + counts := map[string]int64{"ent-a": 5, "ent-b": 3, "ent-zero": 0} + for entID, n := range counts { + putEnt(t, e, ctx, entID) + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), entID, ksuid.New().String())) + } + if len(grants) > 0 { + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords(%s): %v", entID, err) + } + } + } + sealGrantDigests(t, e) + + var wantXor [hashLen]byte + var wantCount int64 + for entID := range counts { + digest, count, err := e.ComputeEntitlementBucketDigest(ctx, testEntIdentity(entID), DigestBucket{}) + if err != nil { + t.Fatalf("ComputeEntitlementBucketDigest(%s): %v", entID, err) + } + xorInto(wantXor[:], digest) + wantCount += count + } + + got, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil { + t.Fatalf("GetGrantDigestGlobalRoot: %v", err) + } + if !ok { + t.Fatal("expected global root present after seal") + } + if got.Count != wantCount { + t.Fatalf("global root count = %d, want %d", got.Count, wantCount) + } + if !bytes.Equal(got.Hash, wantXor[:]) { + t.Fatalf("global root digest = %x, want %x", got.Hash, wantXor[:]) + } +} + +// TestGrantDigestGlobalRootEmptySync verifies a sync with no grants at +// all still gets a PRESENT (count 0, zero digest) global root — the +// "digest was built" marker, distinguishing "nothing to diff" from +// "never built" (present-means-exact, digest.go). +func TestGrantDigestGlobalRootEmptySync(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + sealGrantDigests(t, e) + + root, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil { + t.Fatalf("GetGrantDigestGlobalRoot: %v", err) + } + if !ok { + t.Fatal("expected global root present even with zero grants") + } + if root.Count != 0 { + t.Fatalf("global root count = %d, want 0", root.Count) + } + if !bytes.Equal(root.Hash, zeroDigest[:]) { + t.Fatalf("global root digest = %x, want zero", root.Hash) + } +} + +// TestGrantDigestGlobalRootDroppedOnInvalidation verifies that +// invalidating any single entitlement's digest (a post-seal +// DeleteGrantRecord) also drops the whole-file global root: the +// aggregate is a fold over every partition, so it goes stale the +// moment any partition changes and must read as "missing — +// recalculate" rather than a silently wrong cached value. +func TestGrantDigestGlobalRootDroppedOnInvalidation(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + makeGrant("", "g2", "ent-A", "bob"), + }) + + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || !ok { + t.Fatalf("global root before delete: ok=%v err=%v", ok, err) + } + + if err := e.DeleteGrantRecord(ctx, "g1"); err != nil { + t.Fatalf("DeleteGrantRecord: %v", err) + } + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || ok { + t.Fatalf("global root after delete: ok=%v err=%v, want missing (invalidated)", ok, err) + } + + // Reseal recalculates it from the surviving primaries. + sealGrantDigests(t, e) + root, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil || !ok { + t.Fatalf("global root after reseal: ok=%v err=%v", ok, err) + } + if root.Count != 1 { + t.Fatalf("resealed global root count = %d, want 1", root.Count) + } +} + +// TestManifestGrantDigestRoot verifies BuildManifestWithSyncRuns stamps +// the manifest's GrantDigestRoot from the engine's stored global root, +// byte-identical to reading it directly, with the current ABI version. +func TestManifestGrantDigestRoot(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{ + makeGrant("", "g1", "ent-A", "alice"), + }) + + m, err := BuildManifestWithSyncRuns(ctx, e, c1zstore.PayloadEncodingUnspecified) + if err != nil { + t.Fatalf("BuildManifestWithSyncRuns: %v", err) + } + root := m.GetGrantDigestRoot() + if root == nil { + t.Fatal("expected manifest GrantDigestRoot to be present") + } + if root.GetAbiVersion() != grantDigestABIVersion { + t.Fatalf("manifest abi_version = %d, want %d", root.GetAbiVersion(), grantDigestABIVersion) + } + want, ok, err := e.GetGrantDigestGlobalRoot(ctx) + if err != nil || !ok { + t.Fatalf("GetGrantDigestGlobalRoot: ok=%v err=%v", ok, err) + } + if root.GetCount() != want.Count { + t.Fatalf("manifest count = %d, want %d", root.GetCount(), want.Count) + } + if !bytes.Equal(root.GetXorDigest(), want.Hash) { + t.Fatalf("manifest xor_digest = %x, want %x", root.GetXorDigest(), want.Hash) + } +} + +// TestManifestGrantDigestRootAbsentWithoutDigests verifies that a file +// sealed with the digest index disabled — never having built any +// digest state — has no GrantDigestRoot in its manifest: absent means +// "no digest, full read", never "zero grants" (see the manifest proto +// doc comment). +func TestManifestGrantDigestRootAbsentWithoutDigests(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t, WithGrantDigestIndex(false)) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + if err := e.PutGrantRecords(ctx, makeGrant("", "g1", "ent-A", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + if err := e.BuildDeferredGrantIndexes(ctx); err != nil { + t.Fatalf("BuildDeferredGrantIndexes: %v", err) + } + + m, err := BuildManifestWithSyncRuns(ctx, e, c1zstore.PayloadEncodingUnspecified) + if err != nil { + t.Fatalf("BuildManifestWithSyncRuns: %v", err) + } + if m.GetGrantDigestRoot() != nil { + t.Fatalf("expected absent GrantDigestRoot, got %v", m.GetGrantDigestRoot()) + } +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go b/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go new file mode 100644 index 000000000..0ef370d9c --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_hash_test.go @@ -0,0 +1,144 @@ +package pebble + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/cespare/xxhash/v2" + "github.com/stretchr/testify/require" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/codec" +) + +// The seal-time digest build never decodes anything: the hash-index key +// is spliced out of the grant primary key, the bucket hash is computed +// over a raw sub-slice of it, the content hash over the primary tail +// plus raw-scanned source keys. All of that is on-disk ABI, so each +// splice/byte form is pinned here against the from-decoded-record +// reference path (encode identity → hash), the way +// TestAppendGrantByPrincipalKeyFromPrimary pins the by_principal +// splice. A divergence would make two SDK builds hash identical grants +// differently, which the digest comparison reads as "everything +// differs". +func TestGrantDigestSpliceMatchesEncode(t *testing.T) { + cases := []struct { + name string + entRT, entRID, entID string + prt, pid, ext string + srcs []string + }{ + {name: "plain opaque ent id", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", ext: "grant-1"}, + {name: "stripped ent id", entRT: "app", entRID: "github", entID: "app:github:member", prt: "user", pid: "user-42", ext: "app:github:member:user:user-42"}, + {name: "sources", entRT: "app", entRID: "github", entID: "ent-1", prt: "user", pid: "user-42", ext: "g", srcs: []string{"c-src", "a-src", "b-src"}}, + {name: "embedded NUL", entRT: "a\x00pp", entRID: "git\x00hub", entID: "ent\x00x", prt: "us\x00er", pid: "id\x00", ext: "g", srcs: []string{"s\x00rc", "\x00"}}, + {name: "escape byte", entRT: "a\x01pp", entRID: "hub", entID: "ent\x01x", prt: "us\x01er", pid: "\x01id", ext: "g", srcs: []string{"\x01", "\x00"}}, + {name: "unicode", entRT: "приложение", entRID: "гитхаб", entID: "entitlé", prt: "usér", pid: "ид-42", ext: "грант"}, + {name: "duplicate-source keys impossible but sorted singleton", entRT: "app", entRID: "gh", entID: "e", prt: "u", pid: "p", ext: "", srcs: []string{"only"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := v3.GrantRecord_builder{ + ExternalId: tc.ext, + Entitlement: v3.EntitlementRef_builder{ + ResourceTypeId: tc.entRT, + ResourceId: tc.entRID, + EntitlementId: tc.entID, + }.Build(), + Principal: v3.PrincipalRef_builder{ + ResourceTypeId: tc.prt, + ResourceId: tc.pid, + }.Build(), + }.Build() + if len(tc.srcs) > 0 { + m := make(map[string]*v3.GrantSourceRecord, len(tc.srcs)) + for _, s := range tc.srcs { + m[s] = v3.GrantSourceRecord_builder{}.Build() + } + rec.SetSources(m) + } + + id, err := grantIdentityFromRecord(rec) + require.NoError(t, err) + priKey := encodeGrantIdentityKey(id) + val, err := marshalRecord(rec) + require.NoError(t, err) + + sep4, ok := splitGrantPrimaryKey(priKey) + require.True(t, ok, "splitGrantPrimaryKey") + + // The partition region of the primary key is exactly the + // encoded entitlement identity tail. + partition := appendEntitlementIdentityTail(nil, id.entitlement) + require.Equal(t, partition, priKey[grantPrimaryKeyPrefixLen:sep4], "partition region") + require.Equal(t, string(partition), digestPartitionForEntitlement(id.entitlement)) + + // Bucket hash over the spliced principal region == bucket hash + // over freshly encoded principal segments. + wantBH64 := xxhash.Sum64(codec.AppendTupleStrings(nil, tc.prt, tc.pid)) + require.Equal(t, wantBH64, grantPrincipalBucketHash64(priKey[sep4+1:]), "bucket hash from key splice") + var full [8]byte + binary.BigEndian.PutUint64(full[:], wantBH64) + require.Equal(t, full[:digestBucketHashLen], principalBucketHash(tc.prt, tc.pid), "principalBucketHash top bytes") + + // Content hash from raw key+value bytes == content hash from + // the decoded record. + srcs, err := scanGrantSourceKeysRawBytes(val, nil) + require.NoError(t, err) + sortByteSlices(srcs) + ch64, _ := grantContentHash64(nil, priKey[grantPrimaryKeyPrefixLen:], srcs) + fromRecord, err := grantContentHashForRecord(rec) + require.NoError(t, err) + require.Equal(t, fromRecord, binary.BigEndian.AppendUint64(nil, ch64), "content hash: raw scan vs decoded record") + + // Index key splice == reference built entirely from encoders. + idxKey := appendGrantHashIndexKeyFromPrimary(nil, priKey, sep4, wantBH64) + ref := encodeGrantByEntPrincHashEntPrefix(string(partition)) + ref = append(ref, principalBucketHash(tc.prt, tc.pid)...) + ref = codec.AppendTupleStrings(ref, tc.prt, tc.pid) + require.Equal(t, ref, idxKey, "index key: splice vs encode") + + // Round trip: removing the hash region reconstructs the + // primary key byte-exactly. + back, ok := grantPrimaryKeyFromHashIndexKey(nil, idxKey) + require.True(t, ok, "grantPrimaryKeyFromHashIndexKey") + require.Equal(t, priKey, back, "primary key round trip") + + // The merge-side splitter agrees on the partition and bucket. + gotPartition, bucket, ok := splitGrantHashIndexKey(idxKey) + require.True(t, ok, "splitGrantHashIndexKey") + require.Equal(t, partition, gotPartition) + require.Equal(t, binary.BigEndian.Uint16(full[:digestBucketHashLen]), bucket) + }) + } +} + +// TestGrantDigestPartitionPrefixFree pins the property bucketBounds and +// the partition-contiguity of the index rest on: no partition's index +// prefix is a byte-prefix of another's, even for entitlements whose +// tails extend each other or contain separator-adjacent escapes. +func TestGrantDigestPartitionPrefixFree(t *testing.T) { + ids := []entitlementIdentity{ + entitlementIdentityFromParts("app", "github", "ent"), + entitlementIdentityFromParts("app", "github", "ent-1"), + entitlementIdentityFromParts("app", "github", "app:github:ent"), + entitlementIdentityFromParts("app", "github", "ent\x00"), + entitlementIdentityFromParts("app", "github", "ent\x01"), + entitlementIdentityFromParts("app", "gith", "ub:ent"), + entitlementIdentityFromParts("ap", "pgithub", "ent"), + } + prefixes := make([][]byte, len(ids)) + for i, id := range ids { + prefixes[i] = encodeGrantByEntPrincHashEntPrefix(digestPartitionForEntitlement(id)) + } + for i := range prefixes { + for j := range prefixes { + if i == j { + continue + } + require.False(t, bytes.HasPrefix(prefixes[i], prefixes[j]), + "partition prefix %d (%x) extends %d (%x)", i, prefixes[i], j, prefixes[j]) + } + } +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair.go b/pkg/dotc1z/engine/pebble/grant_digest_repair.go new file mode 100644 index 000000000..819734b16 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair.go @@ -0,0 +1,589 @@ +package pebble + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/cockroachdb/pebble/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +// Targeted repair of the grant hash index + digests: heal exactly the +// entitlement partitions missing a digest root, trusting every +// partition that already has one, instead of the seal-time build's +// unconditional full-file rebuild. This is the load-bearing primitive +// behind compaction's fold strategy carrying built digests without +// paying an O(base) rebuild on every fold (see +// InvalidateGrantDigestPartitions and RepairMissingGrantDigests below, +// and compactPebbleFold in pkg/synccompactor). +// +// Contract: present-means-exact (digest.go) still holds throughout — +// nothing here ever leaves a partition looking present with wrong +// data. A partition is either fully correct (untouched, or freshly +// rebuilt from its own primaries) or absent (never repaired, never +// half-built); the whole-file global root is recomputed whenever +// anything changes, from the digest keyspace itself (cheap — bounded +// by entitlement count, not grant count), so it can never drift from +// the sum of what's actually stored. + +// GrantPartitionFromPrimaryKey returns the entitlement partition — the +// same string digestPartitionForEntitlement (and this file's repair +// functions) key off — that a raw grant primary key belongs to, by +// splicing the key rather than decoding it. For callers that mutate +// grants through the raw DB handle and need to report which +// partitions they touched without ever constructing an +// entitlementIdentity (the fold compactor's raw merge — see +// InvalidateGrantDigestPartitions). ok is false for a key that does +// not parse as a 6-segment grant identity. +func GrantPartitionFromPrimaryKey(key []byte) (string, bool) { + sep4, ok := splitGrantPrimaryKey(key) + if !ok { + return "", false + } + return string(key[grantPrimaryKeyPrefixLen:sep4]), true +} + +// grantPrimaryEntitlementBoundsFromPartition returns the primary grant +// key range for one entitlement partition, derived directly from the +// raw partition bytes. Identical to what encodeGrantPrimaryEntitlementPrefix +// produces from a decoded identity, since partition IS that identity's +// encoded tail (digestPartitionForEntitlement) — this just skips +// needing an entitlementIdentity value to get there. +func grantPrimaryEntitlementBoundsFromPartition(partition string) ([]byte, []byte) { + lower := make([]byte, 0, grantPrimaryKeyPrefixLen+len(partition)+1) + lower = append(lower, versionV3, typeGrant, 0) + lower = append(lower, partition...) + lower = append(lower, 0) + return lower, upperBoundOf(lower) +} + +// isGrantDigestRootKey reports whether key is a per-entitlement grant- +// digest ROOT node (level digestLevelRoot) for the grant digest index +// specifically — the unit recomputeGrantDigestGlobalRootLocked folds +// into the whole-file root. The global root's own key uses +// digestLevelGlobalRoot, a different level value, so it never matches +// here by construction (globalGrantDigestNodeKey). Finding the level +// byte by scanning for the next bare 0x00 after indexID is safe +// because the partition region is TUPLE-ESCAPED (encodeDigestPartitionPrefix), +// unlike the hash-index key's raw splice — a bare 0x00 there can only +// be the separator ending it, never embedded partition data. +func isGrantDigestRootKey(key []byte) bool { + const headerLen = 3 // versionV3, typeDigest, indexID + if len(key) < headerLen+1 || key[0] != versionV3 || key[1] != typeDigest || key[2] != grantDigestSpec.indexID { + return false + } + if key[headerLen] != 0 { + return false + } + _, afterSep, found := bytes.Cut(key[headerLen+1:], []byte{0}) + if !found { + return false + } + return len(afterSep) == 1 && afterSep[0] == digestLevelRoot +} + +// InvalidateGrantDigestPartitions drops the digest + hash-index state +// for exactly the given entitlement partitions, plus the whole-file +// global root — which folds over every partition and goes stale the +// moment any one of them does. Every other partition's state is left +// completely untouched. +// +// For callers that mutate the primary grant keyspace directly, +// bypassing the engine's own per-record invalidation path +// (stageGrantDigestInvalidation) — the fold compactor's raw merge — to +// report exactly what they changed. RepairMissingGrantDigests +// recomputes exactly what this drops (and only that), so the pair +// gives compaction per-entitlement-scoped digest maintenance without +// an O(file) rebuild. No-op when partitions is empty or no digest has +// ever been built for this file. +func (e *Engine) InvalidateGrantDigestPartitions(ctx context.Context, partitions []string) error { + if len(partitions) == 0 || !e.grantDigestsPresent.Load() { + return nil + } + return e.withWrite(func() error { + batch := e.db.NewBatch() + defer batch.Close() + for _, partition := range partitions { + if err := dropPartitionDigest(batch, grantDigestSpec, partition); err != nil { + return err + } + lo := encodeGrantByEntPrincHashEntPrefix(partition) + if err := batch.DeleteRange(lo, upperBoundOf(lo), nil); err != nil { + return err + } + } + if err := batch.Delete(globalGrantDigestNodeKey(), nil); err != nil { + return err + } + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + return batch.Commit(opts) + }) +} + +// RepairMissingGrantDigests rebuilds the by_entitlement_principal_hash +// rows and digest nodes for exactly the entitlements missing a digest +// root — trusting every entitlement that already has one — then +// recomputes the whole-file global root if anything changed. The +// whole operation holds the engine's write lock for its entire +// duration, like BuildGrantDigests/BuildDeferredGrantIndexes, so no +// concurrent grant write can interleave with a partition mid-repair +// and leave it silently wrong. +// +// When digests were never built for this file at all (or were dropped +// wholesale — grantDigestsPresent false), a full BuildGrantDigests is +// strictly cheaper than a point-Get per entitlement that would all +// miss anyway, so this delegates to it instead. +// +// Failure policy: a repair error for one partition is logged and that +// partition is left missing (safe — present-means-exact — and +// self-healing on the next repair or seal) rather than failing the +// whole call; only context cancellation propagates. Unlike a fused +// full rebuild, a partial failure here does not require dropping +// already-repaired partitions — each one commits its own correct, +// complete state independently. +// +// Orphan grants (an entitlement identity with grants but no +// entitlement RECORD) are covered: the missing-partition scan +// discovers grant-bearing partitions from the grant primary keyspace +// itself, not from entitlement records, so an invalidated orphan is +// rebuilt like any other partition and the recomputed global root +// always includes it. That keeps the global root a pure function of +// the stored grant set — identical whether it was produced by a seal +// (whose fold covers orphans too) or by a fold + targeted repair — +// which the manifest's header-only "did the grants change?" +// comparison depends on. Without it, a touched orphan would go +// missing forever: the recomputed root's presence would satisfy the +// repair fast path (repairMissingGrantDigestsAttempt) on every later +// call. +// +// Called from Adapter.endSyncFinalize in place of a bare +// BuildGrantDigests whenever the deferred (by_principal-rebuilding) +// pass didn't run: for a brand-new sync grantDigestsPresent is always +// false (ResetForNewSync excises the digest keyspace at StartNewSync), +// so this reduces to exactly BuildGrantDigests's existing full scan — +// no behavior change for the common single-EndSync-per-sync case. It +// only diverges — into the cheap fast path or the targeted repair — +// when EndSync runs a SECOND time on an already-digested, rebound sync +// (SetCurrentSync, not StartNewSync) without a fresh reset in between: +// the shape grant-expansion's own follow-up sync produces, and exactly +// the case that made a full rebuild here wasteful (see RFC 0003 / +// compactPebbleFold). +// +// Like BuildGrantDigests, a failure that isn't context cancellation is +// logged and downgraded to a full state drop rather than propagated — +// digest problems must never fail EndSync. This only covers failures +// in the outer scan/orchestration (e.g. the entitlement-keyspace scan +// itself erroring): a failure repairing one INDIVIDUAL partition is +// already handled without dropping anything (see +// repairMissingGrantDigestsLocked). +func (e *Engine) RepairMissingGrantDigests(ctx context.Context) error { + if !e.opts.grantDigestIndex { + return nil + } + if e.grantDigestBuildPending.Load() { + // A prior digest build died mid-commit and the drop that should + // have consumed the durable marker never completed (a writable + // Open normally does it; in-process, a failed build's own + // cleanup). Whatever nodes it left look present over a hash + // index that was never ingested, so nothing stored is + // trustworthy — restore the safe absent state first and let the + // full rebuild below recalculate from scratch. + if err := e.withWriteAllowSealed(func() error { return e.dropAllGrantDigestStateLocked() }); err != nil { + return fmt.Errorf("RepairMissingGrantDigests: drop digest state left by an interrupted build: %w", err) + } + } + if !e.grantDigestsPresent.Load() { + return e.BuildGrantDigests(ctx) + } + err := e.repairMissingGrantDigestsAttempt(ctx) + if err == nil { + return nil + } + if ctx.Err() != nil { + return err + } + ctxzap.Extract(ctx).Error("grant digest repair failed; dropping digest state — grant-diff callers must re-read grants until the next successful repair or seal", + zap.Error(err)) + if dropErr := e.withWriteAllowSealed(func() error { return e.dropAllGrantDigestStateLocked() }); dropErr != nil { + return fmt.Errorf("RepairMissingGrantDigests: drop grant digest state after failed repair: %w", dropErr) + } + return nil +} + +// repairMissingGrantDigestsAttempt is the un-wrapped repair attempt: +// the fast path (trust a present global root) plus the locked +// scan-and-repair. RepairMissingGrantDigests applies the uniform +// "downgrade to a full drop, log, never fail the caller" policy to +// whatever this returns. +func (e *Engine) repairMissingGrantDigestsAttempt(ctx context.Context) error { + // Fast path: EVERY code path that can make a single entitlement's + // digest go missing also drops the whole-file global root in the + // same commit (stageGrantDigestInvalidation, InvalidateGrantDigestPartitions, + // the Drop* family — see their doc comments), and the loop below + // only ever writes the global root back once it has verified NOTHING + // is missing (repaired everything it found, zero failures). So the + // root's mere presence certifies every entitlement's digest is + // present and correct, without walking the entitlement keyspace at + // all: one point Get instead of a scan, for what should be the + // overwhelmingly common "nothing to repair" case (e.g. a fold that + // invalidated nothing, or a periodic health-check call). + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil { + return err + } else if ok { + return nil + } + return e.withWriteAllowSealed(func() error { + return e.repairMissingGrantDigestsLocked(ctx) + }) +} + +func (e *Engine) repairMissingGrantDigestsLocked(ctx context.Context) error { + l := ctxzap.Extract(ctx) + missing, err := e.findMissingGrantDigestPartitionsLocked(ctx) + if err != nil { + return err + } + var repaired, failed int + for _, partition := range missing { + if err := ctx.Err(); err != nil { + return err + } + if err := e.repairOneGrantDigestPartitionLocked(ctx, partition); err != nil { + if ctx.Err() != nil { + return err + } + l.Error("grant digest repair: rebuilding one entitlement partition failed; it remains missing (recalculated on the next repair or seal)", + zap.Error(err)) + failed++ + continue + } + repaired++ + } + if repaired > 0 || failed > 0 { + l.Info("grant digest repair: rebuilt missing entitlement partitions", + zap.Int("repaired", repaired), + zap.Int("failed", failed), + zap.Int("candidates", len(missing)), + ) + } + if failed > 0 { + // At least one entitlement is still missing: the whole-file + // root must stay absent too. Writing it now — even folded only + // over what IS present — would make RepairMissingGrantDigests's + // own fast path trust an incomplete file as fully repaired on + // its next call. + return nil + } + if err := e.recomputeGrantDigestGlobalRootLocked(ctx); err != nil { + if ctx.Err() != nil { + return err + } + l.Error("grant digest repair: recomputing the whole-file root failed; it remains missing (recalculated on the next repair or seal)", + zap.Error(err)) + } + return nil +} + +// findMissingGrantDigestPartitionsLocked returns the partitions with +// no stored digest root, in two passes. Pass 1 discovers every +// grant-BEARING partition from the grant primary keyspace itself — +// one boundary seek plus one root point-Get per partition, so still +// bounded by partition count, not grant count. Deriving these from +// the grants rather than the entitlement records is what makes orphan +// partitions (grants with no entitlement record) repairable: the +// seal-time fold includes orphans in the global root, so a repair +// that couldn't rediscover an invalidated orphan would recompute a +// root that represents the same grant set differently (see +// RepairMissingGrantDigests's doc comment). Pass 2 walks the +// entitlement records (one point Get each) and adds only what pass 1 +// can't see: entitlements with zero grants, whose {count: 0} root +// (writeMissingEntitlementDigestRoots) may equally have been +// invalidated — e.g. by deleting a partition's last grant. +func (e *Engine) findMissingGrantDigestPartitionsLocked(ctx context.Context) ([]string, error) { + var missing []string + missingSet := map[string]struct{}{} + + giter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: GrantLowerBound(), + UpperBound: GrantUpperBound(), + }) + if err != nil { + return nil, err + } + defer giter.Close() + for valid := giter.First(); valid; { + if err := ctx.Err(); err != nil { + return nil, err + } + partition, ok := GrantPartitionFromPrimaryKey(giter.Key()) + if !ok { + // Malformed key: no partition to derive seek bounds from, so + // step one key. (One inside a well-formed partition's range + // is jumped by the seek below instead, and counted when that + // partition is repaired — repairOneGrantDigestPartitionLocked.) + valid = giter.Next() + continue + } + present, err := e.grantDigestRootPresent(partition) + if err != nil { + return nil, err + } + if !present { + missing = append(missing, partition) + missingSet[partition] = struct{}{} + } + _, partitionUpper := grantPrimaryEntitlementBoundsFromPartition(partition) + valid = giter.SeekGE(partitionUpper) + } + if err := giter.Error(); err != nil { + return nil, err + } + + eiter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: EntitlementLowerBound(), + UpperBound: EntitlementUpperBound(), + }) + if err != nil { + return nil, err + } + defer eiter.Close() + for eiter.First(); eiter.Valid(); eiter.Next() { + if err := ctx.Err(); err != nil { + return nil, err + } + key := eiter.Key() + if len(key) <= grantPrimaryKeyPrefixLen { + continue + } + // The entitlement primary tail IS the digest partition (see the + // partition convention in keys.go) — a raw splice, no decode. + partition := string(key[grantPrimaryKeyPrefixLen:]) + if _, dup := missingSet[partition]; dup { + continue + } + present, err := e.grantDigestRootPresent(partition) + if err != nil { + return nil, err + } + if !present { + missing = append(missing, partition) + } + } + return missing, eiter.Error() +} + +// grantDigestRootPresent reports whether partition has a stored grant- +// digest root node: one point Get, no value read. +func (e *Engine) grantDigestRootPresent(partition string) (bool, error) { + rootKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelRoot, nil) + _, closer, err := e.db.Get(rootKey) + if err == nil { + closer.Close() + return true, nil + } + if errors.Is(err, pebble.ErrNotFound) { + return false, nil + } + return false, err +} + +// repairOneGrantDigestPartitionLocked rebuilds BOTH the hash-index +// rows and the digest nodes for one entitlement partition, from a scan +// of its primary grants — it does NOT assume the hash index has +// anything usable for this partition (invalidation always drops both +// together, see InvalidateGrantDigestPartitions / +// stageGrantDigestInvalidation). Must be called with the engine's +// write lock already held (RepairMissingGrantDigests): it commits +// through the raw DB handle directly rather than nesting another +// withWrite call. +// +// The scan STREAMS each derived row straight into the hash-index +// batch, rotated at digestNodeBatchFlushBytes, instead of buffering +// the partition's rows in memory: an "everyone"-style entitlement can +// hold millions of grants, and one allocation per row made a targeted +// repair of such a partition multi-GB. No sort is needed — batch Sets +// accept any order, (entitlement, principal) is the primary identity +// so duplicates are impossible, and the digest fold (pass 2) reads +// the committed index, which pebble returns sorted. A crash between +// rotated commits leaves hash rows without a digest root, which still +// reads as "missing"; the next repair's leading DeleteRange re-cleans +// them — the same window that already exists between the hash-index +// and digest commits. +func (e *Engine) repairOneGrantDigestPartitionLocked(ctx context.Context, partition string) error { + lower, upper := grantPrimaryEntitlementBoundsFromPartition(partition) + + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + flushBytes := digestNodeBatchFlushBytes + if e.testDigestNodeFlushBytes > 0 { + flushBytes = e.testDigestNodeFlushBytes + } + + // Pass 1: replace the hash-index range with the freshly-derived + // rows (a clean DeleteRange first: nothing may assume what, if + // anything, was left there). Closure defer, not a bound one: the + // loop rotates `hiBatch` on flush. + hiBatch := e.db.NewBatch() + defer func() { hiBatch.Close() }() + hiLower := encodeGrantByEntPrincHashEntPrefix(partition) + if err := hiBatch.DeleteRange(hiLower, upperBoundOf(hiLower), nil); err != nil { + return err + } + + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) + if err != nil { + return err + } + var rows, droppedMalformedKeys int64 + var scratch grantHashRowScratch + var chb [hashLen]byte + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + _ = iter.Close() + return err + } + key, value := iter.Key(), iter.Value() + sep4, ok := splitGrantPrimaryKey(key) + if !ok { + // Same key-layout-drift/corruption case the build paths + // count: such rows cannot be represented in the digests. + droppedMalformedKeys++ + continue + } + srcs, serr := scanGrantSourceKeysRawBytes(value, scratch.srcKeys[:0]) + if serr != nil { + _ = iter.Close() + return serr + } + scratch.srcKeys = srcs + if len(srcs) > 1 { + sortByteSlices(srcs) + } + ch64, tuple := grantContentHash64(scratch.tupleBuf, key[grantPrimaryKeyPrefixLen:], srcs) + scratch.tupleBuf = tuple + bh64 := grantPrincipalBucketHash64(key[sep4+1:]) + scratch.keyBuf = appendGrantHashIndexKeyFromPrimary(scratch.keyBuf[:0], key, sep4, bh64) + binary.BigEndian.PutUint64(chb[:], ch64) + if err := hiBatch.Set(scratch.keyBuf, chb[:], nil); err != nil { + _ = iter.Close() + return err + } + rows++ + if hiBatch.Len() >= flushBytes { + if err := hiBatch.Commit(opts); err != nil { + _ = iter.Close() + return err + } + hiBatch.Close() + hiBatch = e.db.NewBatch() + } + } + if err := iter.Error(); err != nil { + _ = iter.Close() + return err + } + if err := iter.Close(); err != nil { + return err + } + if droppedMalformedKeys > 0 { + // Mirrors the build paths' loud skip (BuildGrantDigests / + // BuildDeferredGrantIndexes): a principal silently losing grants + // must be observable. + ctxzap.Extract(ctx).Error("grant digest repair: grant primary keys did not decode as 6-segment identities; their rows are NOT represented in the repaired digest", + zap.Int64("dropped", droppedMalformedKeys), + ) + } + if err := hiBatch.Commit(opts); err != nil { + return err + } + + // Pass 2: fold the just-committed hash index into the digest, + // reusing the exact fold logic the seal-time build runs + // (foldPartitionNodes) instead of re-deriving the same computation + // a second way from the scanned rows. + width := chooseDigestWidth(rows) + rootVal, leaves, err := e.foldPartitionNodes(ctx, grantDigestSpec, partition, width) + if err != nil { + return err + } + digestBatch := e.db.NewBatch() + defer digestBatch.Close() + digestLower := encodeDigestPartitionPrefix(grantDigestSpec.indexID, partition) + if err := digestBatch.DeleteRange(digestLower, upperBoundOf(digestLower), nil); err != nil { + return err + } + rootKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelRoot, nil) + if err := digestBatch.Set(rootKey, rootVal, nil); err != nil { + return err + } + for i := range leaves { + leafKey := encodeDigestNodeKey(grantDigestSpec.indexID, partition, digestLevelLeaf, leaves[i].prefix[:]) + if err := digestBatch.Set(leafKey, leaves[i].val, nil); err != nil { + return err + } + } + if err := digestBatch.Commit(opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + return nil +} + +// recomputeGrantDigestGlobalRootLocked folds every CURRENTLY STORED +// per-entitlement grant-digest root into the whole-file global root +// and writes it — a scan of the (small) digest ROOT keyspace, not the +// grants, so it stays cheap even for a whale-scale file (bounded by +// entitlement count, not grant count). Must be called with the +// engine's write lock already held. +func (e *Engine) recomputeGrantDigestGlobalRootLocked(ctx context.Context) error { + var xor [hashLen]byte + var total int64 + iter, err := e.db.NewIter(&pebble.IterOptions{ + LowerBound: DigestLowerBound(), + UpperBound: DigestUpperBound(), + }) + if err != nil { + return err + } + for iter.First(); iter.Valid(); iter.Next() { + if err := ctx.Err(); err != nil { + _ = iter.Close() + return err + } + if !isGrantDigestRootKey(iter.Key()) { + continue + } + _, count, digest, ok := unpackDigestRoot(iter.Value()) + if !ok { + _ = iter.Close() + return fmt.Errorf("recomputeGrantDigestGlobalRoot: malformed root at %x", iter.Key()) + } + xorInto(xor[:], digest) + total += count + } + if err := iter.Error(); err != nil { + _ = iter.Close() + return err + } + if err := iter.Close(); err != nil { + return err + } + opts := writeOpts(e.opts.durability) + if e.IsFreshSync() { + opts = pebble.NoSync + } + if err := e.db.Set(globalGrantDigestNodeKey(), packDigestLeaf(total, xor[:]), opts); err != nil { + return err + } + e.grantDigestsPresent.Store(true) + return nil +} diff --git a/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go b/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go new file mode 100644 index 000000000..65eea43b7 --- /dev/null +++ b/pkg/dotc1z/engine/pebble/grant_digest_repair_test.go @@ -0,0 +1,439 @@ +package pebble + +import ( + "context" + "strings" + "testing" + + "github.com/cockroachdb/pebble/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "github.com/segmentio/ksuid" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" +) + +// TestRepairMissingGrantDigestsLeavesGlobalRootMissingOnPartialFailure +// pins the invariant RepairMissingGrantDigests's own fast path relies +// on: if even one invalidated entitlement fails to repair, the +// whole-file global root must stay absent rather than get recomputed +// over an incomplete file — otherwise the NEXT call's fast path +// (trusting the root's mere presence) would wrongly skip the scan and +// leave the failed entitlement missing forever. +func TestRepairMissingGrantDigestsLeavesGlobalRootMissingOnPartialFailure(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-good") + putEnt(t, e, ctx, "ent-bad") + if err := e.PutGrantRecords(ctx, + makeGrant("", "g-good", "ent-good", "alice"), + makeGrant("", "g-bad", "ent-bad", "bob"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + + goodPartition := testEntPartition("ent-good") + badPartition := testEntPartition("ent-bad") + if err := e.InvalidateGrantDigestPartitions(ctx, []string{goodPartition, badPartition}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + + // Corrupt ent-bad's grant VALUE (leaving its key, and ent-good, + // untouched) so its repair scan fails deriving the content hash. + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: GrantLowerBound(), UpperBound: GrantUpperBound()}) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + var badKey []byte + for iter.First(); iter.Valid(); iter.Next() { + if p, ok := GrantPartitionFromPrimaryKey(iter.Key()); ok && p == badPartition { + badKey = append([]byte(nil), iter.Key()...) + } + } + if err := iter.Close(); err != nil { + t.Fatalf("iter close: %v", err) + } + if badKey == nil { + t.Fatal("could not find ent-bad's grant primary key") + } + if err := e.db.Set(badKey, []byte{0xFF, 0xFF, 0xFF}, pebble.NoSync); err != nil { + t.Fatalf("corrupt ent-bad grant value: %v", err) + } + + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests must not fail the caller: %v", err) + } + + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-good")); err != nil || !ok { + t.Fatalf("ent-good root: ok=%v err=%v, want repaired", ok, err) + } + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-bad")); err != nil || ok { + t.Fatalf("ent-bad root: ok=%v err=%v, want still missing (repair failed)", ok, err) + } + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || ok { + t.Fatalf("global root: ok=%v err=%v, want still missing (repair incomplete)", ok, err) + } +} + +// TestRepairMissingGrantDigestsHealsOnlyInvalidatedPartition is the +// core pin for the targeted repair path: after invalidating ONE +// entitlement's digest + hash-index state out of several, repair must +// reproduce a byte-identical whole-file digest keyspace to the +// original fused build — proving both that the invalidated partition +// is correctly rebuilt AND that every untouched partition (and the +// recomputed whole-file root) is left exactly as it was, not +// coincidentally recomputed to the same values. +func TestRepairMissingGrantDigestsHealsOnlyInvalidatedPartition(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + counts := map[string]int64{"ent-a": 5, "ent-b": 600, "ent-c": 3} + for entID, n := range counts { + putEnt(t, e, ctx, entID) + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), entID, ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords(%s): %v", entID, err) + } + } + sealGrantDigests(t, e) + want := dumpDigestNodes(t, e) + + partition := testEntPartition("ent-b") + if err := e.InvalidateGrantDigestPartitions(ctx, []string{partition}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + + // Sanity: exactly ent-b and the global root went missing; ent-a and + // ent-c are untouched. + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-b")); err != nil || ok { + t.Fatalf("ent-b root after invalidate: ok=%v err=%v, want missing", ok, err) + } + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || ok { + t.Fatalf("global root after invalidate: ok=%v err=%v, want missing", ok, err) + } + for _, entID := range []string{"ent-a", "ent-c"} { + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity(entID)); err != nil || !ok { + t.Fatalf("%s root after invalidate: ok=%v err=%v, want intact", entID, ok, err) + } + } + + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + requireSameDigestNodes(t, dumpDigestNodes(t, e), want) +} + +// TestRepairMissingGrantDigestsRediscoversInvalidatedOrphan pins that +// the missing-partition scan discovers partitions from the grant +// primary keyspace, not entitlement records: after invalidating an +// ORPHAN partition (grants with no entitlement record) alongside a +// regular one — the shape a fold merge produces when the incoming +// sync carries a grant whose entitlement record is absent — repair +// must rebuild both and leave the whole digest keyspace (global root +// included) byte-identical to the seal-time build's. An +// entitlement-record-only scan would never rediscover the orphan, and +// the recomputed global root would silently exclude its grants — +// permanently, since the root's presence satisfies the repair fast +// path on every later call. +func TestRepairMissingGrantDigestsRediscoversInvalidatedOrphan(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-real") + // No putEnt for ent-orphan: its grants have no entitlement record. + if err := e.PutGrantRecords(ctx, + makeGrant("", "g1", "ent-real", "alice"), + makeGrant("", "g2", "ent-orphan", "bob"), + makeGrant("", "g3", "ent-orphan", "carol"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + if _, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-orphan")); err != nil || !ok { + t.Fatalf("orphan root after seal: ok=%v err=%v, want present (seal covers orphans)", ok, err) + } + want := dumpDigestNodes(t, e) + + if err := e.InvalidateGrantDigestPartitions(ctx, []string{ + testEntPartition("ent-orphan"), + testEntPartition("ent-real"), + }); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + requireSameDigestNodes(t, dumpDigestNodes(t, e), want) +} + +// TestRepairMissingGrantDigestsRediscoversZeroGrantEntitlement pins +// the scan's second pass: a zero-grant entitlement's {count: 0} root +// is invisible to the grant-keyspace pass (it has no grant keys), so +// after invalidation it must be rediscovered from the entitlement +// records and rebuilt — again byte-identical to the seal-time build. +func TestRepairMissingGrantDigestsRediscoversZeroGrantEntitlement(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-empty") + putEnt(t, e, ctx, "ent-a") + if err := e.PutGrantRecords(ctx, makeGrant("", "g1", "ent-a", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + want := dumpDigestNodes(t, e) + + if err := e.InvalidateGrantDigestPartitions(ctx, []string{testEntPartition("ent-empty")}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + requireSameDigestNodes(t, dumpDigestNodes(t, e), want) +} + +// TestRepairMissingGrantDigestsNoOpWhenNothingMissing verifies a +// repair call against a fully-built file changes nothing. +func TestRepairMissingGrantDigestsNoOpWhenNothingMissing(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{makeGrant("", "g1", "ent-A", "alice")}) + before := dumpDigestNodes(t, e) + + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + requireSameDigestNodes(t, dumpDigestNodes(t, e), before) +} + +// TestRepairMissingGrantDigestsFallsBackWhenNeverBuilt verifies that a +// file whose digests were never built at all (grantDigestsPresent +// false) is repaired via a full BuildGrantDigests rather than a +// point-Get-per-entitlement scan that would all miss anyway. +func TestRepairMissingGrantDigestsFallsBackWhenNeverBuilt(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-A") + if err := e.PutGrantRecords(ctx, makeGrant("", "g1", "ent-A", "alice")); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-A")) + if err != nil || !ok { + t.Fatalf("ent-A root: ok=%v err=%v", ok, err) + } + if root.Count != 1 { + t.Fatalf("ent-A root count = %d, want 1", root.Count) + } + if _, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil || !ok { + t.Fatalf("global root: ok=%v err=%v", ok, err) + } +} + +// TestRepairMissingGrantDigestsDisabledIsNoOp verifies the digest- +// index-disabled gate matches EndSync's own (GrantDigestIndexEnabled). +func TestRepairMissingGrantDigestsDisabledIsNoOp(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t, WithGrantDigestIndex(false)) + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } +} + +// TestGrantPartitionFromPrimaryKey pins the raw splice against the +// decoded-identity form the digest keyspace itself keys off. +func TestGrantPartitionFromPrimaryKey(t *testing.T) { + e, _ := newTestEngine(t) + seedEntitlement(t, e, "ent-A", []*v3.GrantRecord{makeGrant("", "g1", "ent-A", "alice")}) + + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: GrantLowerBound(), UpperBound: GrantUpperBound()}) + if err != nil { + t.Fatalf("NewIter: %v", err) + } + defer iter.Close() + if !iter.First() { + t.Fatal("expected at least one grant primary key") + } + key := append([]byte(nil), iter.Key()...) + + got, ok := GrantPartitionFromPrimaryKey(key) + if !ok { + t.Fatal("GrantPartitionFromPrimaryKey: ok = false") + } + if want := testEntPartition("ent-A"); got != want { + t.Fatalf("partition = %q, want %q", got, want) + } +} + +// repairLogCore is a minimal zapcore.Core recording entries in-memory +// so tests can assert on repair logs without zaptest/observer (not +// vendored) — same pattern as progresslog's capturingCore. +type repairLogCore struct { + zapcore.LevelEnabler + entries []repairLogEntry +} + +type repairLogEntry struct { + msg string + fields map[string]any +} + +func newRepairLogCore() *repairLogCore { + return &repairLogCore{LevelEnabler: zapcore.ErrorLevel} +} + +func (c *repairLogCore) With([]zapcore.Field) zapcore.Core { return c } +func (c *repairLogCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(ent.Level) { + return ce.AddCore(ent, c) + } + return ce +} + +func (c *repairLogCore) Write(ent zapcore.Entry, fields []zapcore.Field) error { + enc := zapcore.NewMapObjectEncoder() + for _, f := range fields { + f.AddTo(enc) + } + c.entries = append(c.entries, repairLogEntry{msg: ent.Message, fields: enc.Fields}) + return nil +} + +func (c *repairLogCore) Sync() error { return nil } + +// TestRepairMissingGrantDigestsCountsMalformedKeys pins the repair +// path's loud skip of grant primary keys that fail the 6-segment +// split, matching the build paths: the row cannot be represented in +// the digest, and a principal silently losing grants must be +// observable. The repair itself still succeeds, covering only the +// well-formed rows. +func TestRepairMissingGrantDigestsCountsMalformedKeys(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + putEnt(t, e, ctx, "ent-a") + if err := e.PutGrantRecords(ctx, + makeGrant("", "g1", "ent-a", "alice"), + makeGrant("", "g2", "ent-a", "bob"), + ); err != nil { + t.Fatalf("PutGrantRecords: %v", err) + } + sealGrantDigests(t, e) + + // A key inside ent-a's primary range with SEVEN tuple segments — + // the key-layout-drift/corruption shape splitGrantPrimaryKey + // rejects (a well-formed grant identity has exactly six). + partition := testEntPartition("ent-a") + lower, _ := grantPrimaryEntitlementBoundsFromPartition(partition) + malformed := append(append([]byte(nil), lower...), 'p', 0, 'q', 0, 'r') + if err := e.db.Set(malformed, []byte("junk"), pebble.NoSync); err != nil { + t.Fatalf("inject malformed key: %v", err) + } + + if err := e.InvalidateGrantDigestPartitions(ctx, []string{partition}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + + core := newRepairLogCore() + lctx := ctxzap.ToContext(ctx, zap.New(core)) + if err := e.RepairMissingGrantDigests(lctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + root, ok, err := e.GetEntitlementDigestRoot(ctx, testEntIdentity("ent-a")) + if err != nil || !ok { + t.Fatalf("ent-a root after repair: ok=%v err=%v, want repaired", ok, err) + } + if root.Count != 2 { + t.Fatalf("repaired root count = %d, want 2 (malformed row must not be represented)", root.Count) + } + + var logged bool + for _, entry := range core.entries { + if !strings.Contains(entry.msg, "NOT represented in the repaired digest") { + continue + } + logged = true + if dropped, _ := entry.fields["dropped"].(int64); dropped != 1 { + t.Fatalf("dropped field = %v, want 1", entry.fields["dropped"]) + } + } + if !logged { + t.Fatal("expected an Error log counting the malformed key the repair skipped") + } +} + +// TestRepairStreamsPartitionInBoundedBatches pins the repair's +// streaming pass-1: with the flush threshold forced to rotate the +// hash-index batch after every row (the smallest possible bound, i.e. +// maximum rotations — the shape a multi-million-grant "everyone" +// entitlement would produce at the real 4MiB threshold), the repaired +// hash index and digest nodes must still be byte-identical to the +// seal-time build's. +func TestRepairStreamsPartitionInBoundedBatches(t *testing.T) { + ctx := context.Background() + e, _ := newTestEngine(t) + syncID := ksuid.New().String() + if err := e.SetCurrentSync(syncID); err != nil { + t.Fatalf("SetCurrentSync: %v", err) + } + for entID, n := range map[string]int{"ent-big": 700, "ent-small": 2} { + putEnt(t, e, ctx, entID) + grants := make([]*v3.GrantRecord, 0, n) + for range n { + grants = append(grants, makeGrant("", ksuid.New().String(), entID, ksuid.New().String())) + } + if err := e.PutGrantRecords(ctx, grants...); err != nil { + t.Fatalf("PutGrantRecords(%s): %v", entID, err) + } + } + sealGrantDigests(t, e) + wantNodes := dumpDigestNodes(t, e) + wantHash := dumpKeyRangeTest(t, e, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()) + + // Rotate the repair's hash-index batch on every Set. + e.testDigestNodeFlushBytes = 1 + + partition := testEntPartition("ent-big") + if err := e.InvalidateGrantDigestPartitions(ctx, []string{partition}); err != nil { + t.Fatalf("InvalidateGrantDigestPartitions: %v", err) + } + if err := e.RepairMissingGrantDigests(ctx); err != nil { + t.Fatalf("RepairMissingGrantDigests: %v", err) + } + + requireSameDigestNodes(t, dumpDigestNodes(t, e), wantNodes) + requireSameDigestNodes(t, dumpKeyRangeTest(t, e, GrantByEntPrincHashLowerBound(), GrantByEntPrincHashUpperBound()), wantHash) +} diff --git a/pkg/dotc1z/engine/pebble/grant_write_scale_bench_test.go b/pkg/dotc1z/engine/pebble/grant_write_scale_bench_test.go index 2e61d6f44..a838202f2 100644 --- a/pkg/dotc1z/engine/pebble/grant_write_scale_bench_test.go +++ b/pkg/dotc1z/engine/pebble/grant_write_scale_bench_test.go @@ -71,7 +71,7 @@ func makeGrantRecordBatch(syncID string, offset, count int) []*v3.GrantRecord { return out } -func benchmarkGrantWriteScale(b *testing.B, putUnique bool) { +func benchmarkGrantWriteScale(b *testing.B, putUnique, grantIndex bool) { n := benchGrantCount(b) ctx := context.Background() const batchSize = 10000 @@ -79,7 +79,7 @@ func benchmarkGrantWriteScale(b *testing.B, putUnique bool) { b.ReportMetric(float64(n), "grants") b.ResetTimer() for i := 0; i < b.N; i++ { - e, err := Open(ctx, b.TempDir()) + e, err := Open(ctx, b.TempDir(), WithGrantDigestIndex(grantIndex)) require.NoError(b, err) require.NoError(b, e.MarkFreshSync(benchGrantSyncID)) @@ -103,7 +103,9 @@ func benchmarkGrantWriteScale(b *testing.B, putUnique bool) { } } -func BenchmarkGrantWriteScaleBaseline(b *testing.B) { benchmarkGrantWriteScale(b, false) } -func BenchmarkGrantWriteScaleUnsafePutUnique(b *testing.B) { - benchmarkGrantWriteScale(b, true) -} +// The digest index is built at seal time, never inline, so the write +// path has no per-grant digest cost to isolate; the flag is kept in the +// matrix only to confirm that. +func BenchmarkGrantWriteScale_NoDigestIndex(b *testing.B) { benchmarkGrantWriteScale(b, false, false) } +func BenchmarkGrantWriteScale(b *testing.B) { benchmarkGrantWriteScale(b, false, true) } +func BenchmarkGrantWriteScaleUnsafePutUnique(b *testing.B) { benchmarkGrantWriteScale(b, true, true) } diff --git a/pkg/dotc1z/engine/pebble/grants.go b/pkg/dotc1z/engine/pebble/grants.go index e004584f4..1abf1d3d8 100644 --- a/pkg/dotc1z/engine/pebble/grants.go +++ b/pkg/dotc1z/engine/pebble/grants.go @@ -994,13 +994,36 @@ func grantIndexKeys(r *v3.GrantRecord) [][]byte { return keys } -// writeGrantIndexes adds index entries for r to batch. +// writeGrantIndexes adds index entries for r to batch. The +// by_entitlement_principal_hash index and the digests over it are +// deliberately NOT written here: both are derived from the primaries in +// the fused deferred pass at seal time (BuildDeferredGrantIndexes), +// never maintained inline. A write landing on a file whose digests are +// already built (post-seal mutation) instead invalidates the touched +// entitlement's digest state. func (e *Engine) writeGrantIndexes(batch *pebble.Batch, r *v3.GrantRecord) error { for _, k := range grantIndexKeys(r) { if err := batch.Set(k, nil, nil); err != nil { return err } } + if e.grantDigestsPresent.Load() { + // Propagate a derivation failure rather than fail-open: the + // sibling paths (writeGrantIndexesForIdentityScratch, + // deleteGrantIndexesScratch) already take id as a given and + // can't silently skip invalidation either — a record that + // reaches here already had its identity derived to build the + // primary key, so this should be unreachable in practice, but + // swallowing it would leave a stale-but-present digest on the + // touched entitlement, violating present-means-exact. + id, err := grantIdentityFromRecord(r) + if err != nil { + return err + } + if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { + return err + } + } return nil } @@ -1068,6 +1091,11 @@ func (e *Engine) writeGrantIndexesForIdentityScratch(batch *pebble.Batch, id gra return scratch, err } } + // Post-seal mutation invalidation; no-op (one atomic load) during + // ordinary syncs — see stageGrantDigestInvalidation. + if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { + return scratch, err + } return scratch, nil } @@ -1096,6 +1124,11 @@ func (e *Engine) deleteGrantIndexesScratch(batch *pebble.Batch, externalID strin if err := batch.Delete(scratch, nil); err != nil { return scratch, err } + // Post-seal mutation invalidation; no-op (one atomic load) during + // ordinary syncs — see stageGrantDigestInvalidation. + if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { + return scratch, err + } return scratch, nil } diff --git a/pkg/dotc1z/engine/pebble/grants_synth_encode.go b/pkg/dotc1z/engine/pebble/grants_synth_encode.go index 8fa0a780d..b398712fd 100644 --- a/pkg/dotc1z/engine/pebble/grants_synth_encode.go +++ b/pkg/dotc1z/engine/pebble/grants_synth_encode.go @@ -112,14 +112,14 @@ func appendGrantSourcesWire(dst []byte, scratch batonGrant.Sources, sources bato } keyLen := len(src.EntitlementID) entryLen := 1 + protowire.SizeVarint(uint64(keyLen)) + keyLen + - 1 + protowire.SizeVarint(uint64(valLen)) + valLen + 1 + protowire.SizeVarint(uint64(valLen)) + valLen // #nosec G115 -- valLen is 0 or 2. dst = protowire.AppendVarint(dst, grantSourcesFieldTag) dst = protowire.AppendVarint(dst, uint64(entryLen)) // #nosec G115 -- entryLen is a small positive length. dst = protowire.AppendVarint(dst, grantSourceKeyTag) dst = protowire.AppendVarint(dst, uint64(keyLen)) dst = append(dst, src.EntitlementID...) dst = protowire.AppendVarint(dst, grantSourceValTag) - dst = protowire.AppendVarint(dst, uint64(valLen)) + dst = protowire.AppendVarint(dst, uint64(valLen)) // #nosec G115 -- valLen is 0 or 2. if src.IsDirect { dst = protowire.AppendVarint(dst, grantSourceDirectTag) dst = protowire.AppendVarint(dst, 1) diff --git a/pkg/dotc1z/engine/pebble/id_index_format.go b/pkg/dotc1z/engine/pebble/id_index_format.go index 77f9fbc9a..d6b153fa7 100644 --- a/pkg/dotc1z/engine/pebble/id_index_format.go +++ b/pkg/dotc1z/engine/pebble/id_index_format.go @@ -42,6 +42,33 @@ func encodeDeferredIdxPendingKey() []byte { return codec.AppendTupleStrings(buf, "deferred_grant_idx_pending") } +// encodeGrantDigestBuildPendingKey is the durable marker that a grant +// digest build is mid-flight: armed (fsync'd) before the build's first +// digest-node commit, cleared only after the whole build — merge, hash +// index ingest, zero-grant root backfill — has completed +// (buildGrantDigestsFromSpill), and consumed by the drop that restores +// the safe "digests absent" state (dropAllGrantDigestStateLocked). +// +// It exists because the build commits digest nodes in bounded batches +// DURING the merge and again at fold.finish() — including the global +// root — before the hash-index IngestAndExcise. Committed WAL writes +// survive a process kill, so a crash inside that window leaves +// correct-looking digest roots durable over a hash index that was never +// ingested; a resumed EndSync's RepairMissingGrantDigests would trust +// every surviving root (or fast-path on the global root) and seal the +// file with digests over an empty index — the silent +// skip-entitlements-at-uplift failure mode. The fused seal pass is +// additionally protected by deferredIdxPending (a crash reruns the full +// build), but the standalone BuildGrantDigests path has no such marker, +// and this one covers both. Marker present at Open (or at repair) → +// drop all digest state first: every crash converts to "digests +// absent", which present-means-exact (digest.go) already treats as safe. +func encodeGrantDigestBuildPendingKey() []byte { + buf := make([]byte, 0, 2+len("grant_digest_build_pending")) + buf = append(buf, versionV3, typeEngineMeta) + return codec.AppendTupleStrings(buf, "grant_digest_build_pending") +} + func (e *Engine) readIDIndexFormat() (uint32, error) { val, closer, err := e.db.Get(encodeIDIndexFormatKey()) if err != nil { diff --git a/pkg/dotc1z/engine/pebble/if_newer.go b/pkg/dotc1z/engine/pebble/if_newer.go index 347091a09..1b20dcc81 100644 --- a/pkg/dotc1z/engine/pebble/if_newer.go +++ b/pkg/dotc1z/engine/pebble/if_newer.go @@ -40,6 +40,12 @@ func (e *Engine) PutGrantRecordsIfNewer(ctx context.Context, records ...*v3.Gran } batch := e.db.NewBatch() defer batch.Close() + // No inline hash-index or digest maintenance here: both are + // derived at seal time (the fused deferred pass). But IfNewer is + // the partial-sync path — it mutates a CLONED sealed file whose + // digests are built — so the index write/delete helpers below + // stage the touched entitlements' digest invalidation + // (stageGrantDigestInvalidation) whenever digests are present. written := 0 for _, r := range records { if r == nil { diff --git a/pkg/dotc1z/engine/pebble/index_migrations.go b/pkg/dotc1z/engine/pebble/index_migrations.go index cd7098b95..e1cc9007a 100644 --- a/pkg/dotc1z/engine/pebble/index_migrations.go +++ b/pkg/dotc1z/engine/pebble/index_migrations.go @@ -62,9 +62,17 @@ type indexMigration struct { // adding an entry here means the next engine Open will backfill // the corresponding index for any existing c1z that doesn't have // it yet. -var indexMigrations = []indexMigration{ - // none yet, because we have no existing data. -} +// +// Deliberately empty today. The by_entitlement_principal_hash index + +// grant digests are NOT backfilled at Open: they are rebuilt from the +// primaries at every seal (the fused deferred pass / BuildGrantDigests), +// so a file that predates them simply has no digests — readers see +// "missing, recalculate" (never a wrong answer) and the file's next +// sync seals them in. Open-time grant backfills have historically been incident +// bait (unbounded latency and memory at Open on large files); prefer +// seal-time derivation or explicit rebuild commands over registering +// one here. +var indexMigrations []indexMigration // applyIndexMigrations runs on engine Open (writable opens only — // read-only files are immutable on disk). For each registered diff --git a/pkg/dotc1z/engine/pebble/keys.go b/pkg/dotc1z/engine/pebble/keys.go index f1484d08d..440793d5e 100644 --- a/pkg/dotc1z/engine/pebble/keys.go +++ b/pkg/dotc1z/engine/pebble/keys.go @@ -63,6 +63,7 @@ const ( typeIndex byte = 0x07 typeCounter byte = 0x08 typeSession byte = 0x09 + typeDigest byte = 0x0A typeEngineMeta byte = 0xFF ) @@ -78,6 +79,13 @@ const ( idxGrantByNeedsExpansion byte = 0x05 idxGrantByPrincipalResourceType byte = 0x06 // retired: served by idxGrantByPrincipal prefix scans. idxGrantByEntitlementResource byte = 0x07 // retired: served by grant primary entitlement-resource prefix scans. + // idxGrantByEntitlementPrincipalHash sorts grants by + // (entitlement identity, hash(principal identity)). Unlike every + // other grant index its entries carry a VALUE (the grant content + // hash). It is the substrate the per-entitlement grant digest + // (typeDigest) folds over; built ONLY by the seal-time deferred + // pass, never maintained inline. See digest.go and grant_digest.go. + idxGrantByEntitlementPrincipalHash byte = 0x08 ) // --- Grant --- @@ -270,6 +278,127 @@ func encodeGrantByPrincipalResourceTypeIdentityPrefix(principalRT string) []byte return codec.AppendTupleSeparator(buf) } +// --- Grant by (entitlement, principal-hash) + digest nodes --- +// +// PARTITION CONVENTION. Both keyspaces below are addressed by a digest +// "partition": the raw encoded tail of the entitlement's PRIMARY key — +// the already-escaped, separator-delimited 4-segment tuple +// +// ent_rt | 0x00 | ent_rid | 0x00 | ent_flag | 0x00 | ent_tail +// +// exactly as appendEntitlementIdentityKey produces it (minus the 3-byte +// header), and exactly as it appears inside every grant primary key. +// Using the encoded bytes (spliced, never decode+re-encoded) keeps the +// seal-time build allocation-free and makes index/digest partition +// order byte-identical to primary entitlement key order. Segments are +// escaped and the segment COUNT is fixed at four, so partitions are +// mutually prefix-free even though they contain bare 0x00 separators. +// appendEntitlementIdentityTail is the from-identity constructor. + +// appendEntitlementIdentityTail appends the digest-partition bytes for +// an entitlement identity: the 4-segment encoded tuple tail of its +// primary key (no header, no trailing separator). MUST stay in +// byte-lockstep with appendEntitlementIdentityKey's tail. +func appendEntitlementIdentityTail(dst []byte, id entitlementIdentity) []byte { + return codec.AppendTupleStrings(dst, id.resourceTypeID, id.resourceID, id.flagComponent(), id.tail) +} + +// encodeGrantByEntPrincHashEntPrefix is the by-value prefix for "all +// hash-index rows under this entitlement partition", in principal-hash +// order. partition is the raw encoded entitlement tail (see the +// partition convention above) and is appended RAW — it is already +// tuple-encoded. The trailing separator is load-bearing (see the +// keys.go convention doc); the raw bucket hash follows it. Its output +// length is also the offset decoders use to locate the raw hash region. +// +// The full index key shape (see grant_digest.go for the hash and value +// definitions): +// +// v3 | typeIndex | idxGrantByEntitlementPrincipalHash | 0x00 | +// ent_rt | 0x00 | ent_rid | 0x00 | ent_flag | 0x00 | ent_tail | 0x00 | +// | +// principal_rt | 0x00 | principal_id +// -> value: grant content hash (xxHash64, 8 bytes) +// +// (entitlement identity, principal identity) is the grant PRIMARY +// identity, so the index holds exactly one row per grant by +// construction. Because the bucket hash is raw it can contain 0x00, so +// generic tuple walkers must NOT be pointed past the partition prefix — +// their walk would derail on hash bytes. Decode positionally: the hash +// occupies exactly digestBucketHashLen bytes after the prefix. +func encodeGrantByEntPrincHashEntPrefix(partition string) []byte { + buf := make([]byte, 0, 6+len(partition)) + buf = append(buf, versionV3, typeIndex, idxGrantByEntitlementPrincipalHash) + buf = codec.AppendTupleSeparator(buf) + buf = append(buf, partition...) + return codec.AppendTupleSeparator(buf) +} + +// GrantByEntPrincHashLowerBound / UpperBound bound the entire +// by_entitlement_principal_hash index. Exported for the +// cleanup/clone/compaction keyspace plans. +func GrantByEntPrincHashLowerBound() []byte { + return []byte{versionV3, typeIndex, idxGrantByEntitlementPrincipalHash} +} + +func GrantByEntPrincHashUpperBound() []byte { + return upperBoundOf(GrantByEntPrincHashLowerBound()) +} + +// Digest node keys. +// +// v3 | typeDigest | index_id(1 byte) | 0x00 | esc(partition) | 0x00 | level(1 byte) | bucket_prefix +// +// index_id discriminates WHICH digested index the node belongs to (the +// digested index's own idx* byte, see digestIndexSpec) — it leads the +// tail so all of a file's digests for every index are still a single +// contiguous range for the cleanup/clone plans. The partition (which +// for the grant digest contains bare 0x00 separators — see the +// partition convention above) is tuple-ESCAPED here, unlike in the +// index key: node keys carry a level byte and a raw bucket prefix after +// it, so the partition must parse as a single ordinary tuple element. +// The tuple escape is order-preserving, so node keys still sort in raw +// partition order. level 0 is the root (bucket_prefix empty); level 1 +// is the single leaf level, one node per non-empty bucket, whose +// bucket_prefix is the bucket index LEFT-ALIGNED in 2 raw bytes +// (digestLeafPrefixLen). The left alignment makes leaf keys sort in +// bucket-hash order at every digest width, so the comparison's +// fold-to-coarser-width merge is a single contiguous scan of this +// range. See digest.go for the node value framing. +func encodeDigestNodeKey(indexID byte, partition string, level byte, bucketPrefix []byte) []byte { + buf := encodeDigestPartitionPrefix(indexID, partition) + buf = append(buf, level) + return append(buf, bucketPrefix...) +} + +// encodeDigestPartitionPrefix is the prefix of every digest node key +// for one (index, partition) — the range a rebuild clears before +// writing (the build only Sets nodes; without the leading DeleteRange a +// width change or an emptied bucket would leave stale nodes for the +// comparison merge scan to read) and the range the invalidation paths +// remove when a record mutation invalidates the partition. +func encodeDigestPartitionPrefix(indexID byte, partition string) []byte { + buf := make([]byte, 0, 7+len(partition)) + buf = append(buf, versionV3, typeDigest) + buf = append(buf, indexID) + buf = codec.AppendTupleSeparator(buf) + buf = codec.AppendTupleStrings(buf, partition) + return codec.AppendTupleSeparator(buf) +} + +// DigestLowerBound / UpperBound bound the entire digest keyspace (all +// digested indexes). Exported for the cleanup/clone/compaction keyspace +// plans. typeDigest is NOT adjacent to typeIndex — typeCounter and +// typeSession sit between them — so an excise span must never be +// widened to cover both the hash index and the digests in one range. +func DigestLowerBound() []byte { + return []byte{versionV3, typeDigest} +} + +func DigestUpperBound() []byte { + return upperBoundOf(DigestLowerBound()) +} + // --- ResourceType --- // encodeResourceTypeKey returns the primary key for a resource_type: diff --git a/pkg/dotc1z/engine/pebble/manifest.go b/pkg/dotc1z/engine/pebble/manifest.go index 9a7cdabd9..65b44a2af 100644 --- a/pkg/dotc1z/engine/pebble/manifest.go +++ b/pkg/dotc1z/engine/pebble/manifest.go @@ -50,6 +50,15 @@ func BuildManifestWithSyncRuns(ctx context.Context, e *Engine, encoding c1zstore } m.SetSyncRuns(syncRuns) m.SetPebbleIdIndexFormat(e.manifestIDIndexFormat()) + if root, ok, err := e.GetGrantDigestGlobalRoot(ctx); err != nil { + return nil, fmt.Errorf("pebble: manifest grant digest root: %w", err) + } else if ok { + m.SetGrantDigestRoot(c1zv3.GrantDigestRoot_builder{ + XorDigest: root.Hash, + Count: root.Count, + AbiVersion: grantDigestABIVersion, + }.Build()) + } return m, nil } diff --git a/pkg/dotc1z/engine/pebble/options.go b/pkg/dotc1z/engine/pebble/options.go index 343a30bba..479521083 100644 --- a/pkg/dotc1z/engine/pebble/options.go +++ b/pkg/dotc1z/engine/pebble/options.go @@ -42,6 +42,14 @@ type Options struct { // readOnly opens the engine without write permission. Save is // disallowed in this mode. readOnly bool + + // grantDigestIndex controls whether the seal-time deferred pass + // (BuildDeferredGrantIndexes) also constructs the + // by_entitlement_principal_hash index and the per-entitlement grant + // digests — the substrate for cross-file grant diffing. Default on. + // The write paths never maintain either inline, so this gates only + // the fused derivation at EndSync. See WithGrantDigestIndex. + grantDigestIndex bool } // Option is a functional option passed to Open. @@ -65,6 +73,20 @@ func WithDurability(d Durability) Option { return func(o *Options) { o.durabilit // WithReadOnly opens the engine in read-only mode. Save is disallowed. func WithReadOnly(readOnly bool) Option { return func(o *Options) { o.readOnly = readOnly } } +// WithGrantDigestIndex toggles the seal-time construction of the +// by_entitlement_principal_hash index and the per-entitlement grant +// digests. Default true. +// +// Set false on files that will never be grant-diffed (e.g. local CLI +// syncs, connector development) to skip the derivation work in the +// EndSync deferred pass. Safe to toggle per Open: a file sealed with +// this off simply stores no digest roots, which readers and the +// cross-file comparison treat as "missing — recalculate / +// whole-entitlement dirty", never as "no grants". +func WithGrantDigestIndex(enabled bool) Option { + return func(o *Options) { o.grantDigestIndex = enabled } +} + // WithSlowQueryThreshold overrides the default 5 s threshold for // slow-iterator logging. func WithSlowQueryThreshold(d time.Duration) Option { @@ -140,6 +162,7 @@ func defaultOptions() *Options { return &Options{ durability: DurabilitySync, slowQueryThreshold: 5 * time.Second, + grantDigestIndex: true, } } diff --git a/pkg/dotc1z/engine/pebble/production_bench_test.go b/pkg/dotc1z/engine/pebble/production_bench_test.go index 52ffc0fc5..c98704e6b 100644 --- a/pkg/dotc1z/engine/pebble/production_bench_test.go +++ b/pkg/dotc1z/engine/pebble/production_bench_test.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "testing" + "time" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/connectorstore" @@ -51,32 +52,44 @@ func prepareRegisteredC1Z(b *testing.B, n int, engine c1zstore.Engine) (string, return path, syncID } -func benchmarkRegisteredWritePack(b *testing.B, engine c1zstore.Engine, n int) { +func benchmarkRegisteredWritePack(b *testing.B, engine c1zstore.Engine, n int, storeOpts ...dotc1z.C1ZOption) { ctx := context.Background() root := b.TempDir() grants := benchmarkGrants(n) b.ReportAllocs() b.ReportMetric(float64(n), "grants/op") b.ResetTimer() + + var totalPutGrants, totalEndSync int64 for i := 0; i < b.N; i++ { path := fmt.Sprintf("%s/%s-sync-%06d.c1z", root, engine, i) - store, err := dotc1z.NewStore(ctx, path, dotc1z.WithEngine(engine)) + openOpts := append([]dotc1z.C1ZOption{dotc1z.WithEngine(engine)}, storeOpts...) + store, err := dotc1z.NewStore(ctx, path, openOpts...) if err != nil { b.Fatalf("NewStore: %v", err) } if _, err := store.StartNewSync(ctx, connectorstore.SyncTypeFull, ""); err != nil { b.Fatalf("StartNewSync: %v", err) } + + t0 := time.Now() if err := store.PutGrants(ctx, grants...); err != nil { b.Fatalf("PutGrants: %v", err) } + totalPutGrants += time.Since(t0).Milliseconds() + + t1 := time.Now() if err := store.EndSync(ctx); err != nil { b.Fatalf("EndSync: %v", err) } + totalEndSync += time.Since(t1).Milliseconds() + if err := store.Close(ctx); err != nil { b.Fatalf("Close: %v", err) } } + b.ReportMetric(float64(totalPutGrants)/float64(b.N), "ms/PutGrants") + b.ReportMetric(float64(totalEndSync)/float64(b.N), "ms/EndSync") } func benchmarkRegisteredUnpackReadGrants(b *testing.B, engine c1zstore.Engine, n int) { @@ -223,6 +236,16 @@ func BenchmarkRegisteredPebbleWritePack(b *testing.B) { } } +func BenchmarkRegisteredPebbleWritePack_NoDigestIndex(b *testing.B) { + for _, n := range grantsScales() { + b.Run(fmt.Sprintf("grants=%d", n), func(b *testing.B) { + benchmarkRegisteredWritePack(b, c1zstore.EnginePebble, n, + dotc1z.WithGrantDigestIndex(false), + ) + }) + } +} + func BenchmarkRegisteredSQLiteWritePack(b *testing.B) { for _, n := range grantsScales() { b.Run(fmt.Sprintf("grants=%d", n), func(b *testing.B) { diff --git a/pkg/dotc1z/engine/pebble/raw_records.go b/pkg/dotc1z/engine/pebble/raw_records.go index 89ddfd37f..b56c4c439 100644 --- a/pkg/dotc1z/engine/pebble/raw_records.go +++ b/pkg/dotc1z/engine/pebble/raw_records.go @@ -136,6 +136,12 @@ func (e *Engine) deleteGrantIndexesRaw(batch *pebble.Batch, externalID string, v if err := batch.Delete(encodeGrantByPrincipalIdentityIndexKey(id), nil); err != nil { return err } + // Post-seal mutation of a grant invalidates the touched entitlement's + // digest + hash-index state (no-op unless digests exist — see + // stageGrantDigestInvalidation). + if err := e.stageGrantDigestInvalidation(batch, id.entitlement); err != nil { + return err + } return batch.Delete(encodeGrantByNeedsExpansionIdentityIndexKey(id), nil) } @@ -212,6 +218,62 @@ func scanGrantEntitlementResourceTypeRaw(value []byte) ([]byte, error) { return entRT, nil } +// scanGrantSourceKeysRawBytes extracts the source-entitlement ID keys +// from a marshaled GrantRecord without a full unmarshal. Sources are +// field 9 (map), encoded as repeated embedded +// messages each with sub-field 1 = key string. The keys are views +// borrowed from value (valid only while value's backing bytes are), +// appended to keys — pass a recycled keys[:0] to reuse its backing +// array across calls. The seal-time grant digest build calls this once +// per grant (see appendGrantHashIndexRow). +func scanGrantSourceKeysRawBytes(value []byte, keys [][]byte) ([][]byte, error) { + for len(value) > 0 { + num, typ, n := protowire.ConsumeTag(value) + if n < 0 { + return nil, protowire.ParseError(n) + } + value = value[n:] + if num != 9 { + n = protowire.ConsumeFieldValue(num, typ, value) + if n < 0 { + return nil, protowire.ParseError(n) + } + value = value[n:] + continue + } + if typ != protowire.BytesType { + return nil, fmt.Errorf("raw record: grant sources entry has wire type %v", typ) + } + entry, n := protowire.ConsumeBytes(value) + if n < 0 { + return nil, protowire.ParseError(n) + } + value = value[n:] + for len(entry) > 0 { + eNum, eTyp, en := protowire.ConsumeTag(entry) + if en < 0 { + return nil, protowire.ParseError(en) + } + entry = entry[en:] + if eNum == 1 && eTyp == protowire.BytesType { + k, kn := protowire.ConsumeBytes(entry) + if kn < 0 { + return nil, protowire.ParseError(kn) + } + keys = append(keys, k) + entry = entry[kn:] + } else { + en = protowire.ConsumeFieldValue(eNum, eTyp, entry) + if en < 0 { + return nil, protowire.ParseError(en) + } + entry = entry[en:] + } + } + } + return keys, nil +} + // scanEntitlementResourceTypeRaw extracts only the entitlement's // resource_type_id (its own resource's type) from a marshaled // EntitlementRecord, borrowing the bytes from value. The stats grouping diff --git a/pkg/dotc1z/engine_registry.go b/pkg/dotc1z/engine_registry.go index 403f1683e..7d110f122 100644 --- a/pkg/dotc1z/engine_registry.go +++ b/pkg/dotc1z/engine_registry.go @@ -32,7 +32,13 @@ type StoreOptions struct { SyncLimit int SkipCleanup bool V2GrantsWriter bool - Engine c1zstore.Engine + + // DisableGrantDigestIndex turns off the Pebble engine's seal-time + // build of the by_entitlement_principal_hash index + grant digests. + // Inverted so the zero value keeps the build on (current behavior). + DisableGrantDigestIndex bool + + Engine c1zstore.Engine // PayloadEncoding selects the v3 envelope payload framing for // engines that produce a v3 envelope (currently Pebble). Zero @@ -180,18 +186,19 @@ func storeOptionsFromC1ZOptions(options *c1zOptions) StoreOptions { maxDecodedPayloadBytes, _ := explicitMaxDecodedSizeForDecoderOptions(options.decoderOptions...) maxDecoderMemoryBytes, _ := explicitMaxMemorySizeForDecoderOptions(options.decoderOptions...) out := StoreOptions{ - TmpDir: options.tmpDir, - DecoderOptions: append([]DecoderOption(nil), options.decoderOptions...), - ReadOnly: options.readOnly, - EncoderConcurrency: options.encoderConcurrency, - SyncLimit: options.syncLimit, - SkipCleanup: options.skipCleanup, - V2GrantsWriter: options.v2GrantsWriter, - Engine: options.engine, - PayloadEncoding: options.payloadEncoding, - DecoderPool: options.decoderPool, - MaxDecodedPayloadBytes: maxDecodedPayloadBytes, - MaxDecoderMemoryBytes: maxDecoderMemoryBytes, + TmpDir: options.tmpDir, + DecoderOptions: append([]DecoderOption(nil), options.decoderOptions...), + ReadOnly: options.readOnly, + EncoderConcurrency: options.encoderConcurrency, + SyncLimit: options.syncLimit, + SkipCleanup: options.skipCleanup, + V2GrantsWriter: options.v2GrantsWriter, + DisableGrantDigestIndex: options.disableGrantDigestIndex, + Engine: options.engine, + PayloadEncoding: options.payloadEncoding, + DecoderPool: options.decoderPool, + MaxDecodedPayloadBytes: maxDecodedPayloadBytes, + MaxDecoderMemoryBytes: maxDecoderMemoryBytes, } if out.Engine == "" { out.Engine = c1zstore.EngineSQLite @@ -351,5 +358,8 @@ func (sqliteDriver) OpenStore(ctx context.Context, outputFilePath string, opts S if opts.V2GrantsWriter { c1zOpts = append(c1zOpts, WithV2GrantsWriter(true)) } + if opts.DisableGrantDigestIndex { + c1zOpts = append(c1zOpts, WithGrantDigestIndex(false)) + } return NewC1ZFile(ctx, outputFilePath, c1zOpts...) } diff --git a/pkg/dotc1z/format/v3/envelope.go b/pkg/dotc1z/format/v3/envelope.go index 042577c56..0bf308615 100644 --- a/pkg/dotc1z/format/v3/envelope.go +++ b/pkg/dotc1z/format/v3/envelope.go @@ -576,8 +576,9 @@ func readEnvelope(r io.Reader, headerOnly bool, pool *DecoderPool) (*Envelope, e // unmarshalManifestHeader decodes the cheap manifest fields by hand: // engine (1), engine_schema_version (2), payload_encoding (4), the -// sync_runs projection (40), and fold_dead_bytes (41). The descriptor -// closure (field 10) — by far +// sync_runs projection (40), fold_dead_bytes (41), +// pebble_id_index_format (42), and grant_digest_root (43). The +// descriptor closure (field 10) — by far // the largest field — is skipped, which is what makes header reads // cheap enough for engine dispatch on every open. Sync run summaries // are small and few (bounded by the sync retention limit), so decoding @@ -664,6 +665,20 @@ func unmarshalManifestHeader(b []byte) (*c1zv3.C1ZManifestV3, error) { } out.SetPebbleIdIndexFormat(c1zv3.PebbleIdIndexFormat(v)) b = b[n:] + case 43: + if typ != protowire.BytesType { + return nil, fmt.Errorf("c1z v3: manifest grant_digest_root has wire type %v", typ) + } + v, n := protowire.ConsumeBytes(b) + if n < 0 { + return nil, protowire.ParseError(n) + } + root := &c1zv3.GrantDigestRoot{} + if err := proto.Unmarshal(v, root); err != nil { + return nil, fmt.Errorf("%w: grant_digest_root: %w", ErrManifestInvalid, err) + } + out.SetGrantDigestRoot(root) + b = b[n:] default: n := protowire.ConsumeFieldValue(num, typ, b) if n < 0 { diff --git a/pkg/dotc1z/format/v3/envelope_test.go b/pkg/dotc1z/format/v3/envelope_test.go index 51aad2bd4..905cca227 100644 --- a/pkg/dotc1z/format/v3/envelope_test.go +++ b/pkg/dotc1z/format/v3/envelope_test.go @@ -229,7 +229,7 @@ func TestPayloadBudgetForFileSize(t *testing.T) { require.Equal(t, defaultMaxDecodedPayloadBytes, payloadBudgetForFileSize(-1)) }) t.Run("large file scales with its size", func(t *testing.T) { - size := int64(1 << 30) // 1 GiB file → 100 GiB budget + const size = 1 << 30 // 1 GiB file → 100 GiB budget require.Equal(t, uint64(size)*maxPayloadCompressionRatio, payloadBudgetForFileSize(size)) }) t.Run("env var overrides scaling", func(t *testing.T) { diff --git a/pkg/dotc1z/pebble_store.go b/pkg/dotc1z/pebble_store.go index f30f25b08..9714d4281 100644 --- a/pkg/dotc1z/pebble_store.go +++ b/pkg/dotc1z/pebble_store.go @@ -98,7 +98,11 @@ func (pebbleDriver) OpenStore(ctx context.Context, outputFilePath string, opts S } } - e, err := pebble.Open(ctx, dbDir, pebble.WithReadOnly(opts.ReadOnly)) + engineOpts := []pebble.Option{pebble.WithReadOnly(opts.ReadOnly)} + if opts.DisableGrantDigestIndex { + engineOpts = append(engineOpts, pebble.WithGrantDigestIndex(false)) + } + e, err := pebble.Open(ctx, dbDir, engineOpts...) if err != nil { return nil, cleanupOnError(err) } diff --git a/pkg/dotc1z/pebble_store_registry_test.go b/pkg/dotc1z/pebble_store_registry_test.go index c1012d3ca..14cba6ca0 100644 --- a/pkg/dotc1z/pebble_store_registry_test.go +++ b/pkg/dotc1z/pebble_store_registry_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/binary" + "errors" "os" "path/filepath" "testing" @@ -179,7 +180,13 @@ func TestPebbleStoreOpenHonorsDecoderMaxMemory(t *testing.T) { WithReadOnly(true), WithDecoderOptions(WithDecoderMaxMemory(1)), ) - require.ErrorIs(t, err, zstd.ErrWindowSizeExceeded, "NewStore with tiny decoder memory error = %v, want zstd.ErrWindowSizeExceeded", err) + // Depending on the frame shape, the 1-byte cap surfaces as either the + // window-size or decoded-size violation; both prove the custom cap + // reached the decoder. + require.True(t, + errors.Is(err, zstd.ErrWindowSizeExceeded) || errors.Is(err, zstd.ErrDecoderSizeExceeded), + "NewStore with tiny decoder memory error = %v, want a zstd memory-cap violation", err, + ) } func TestReadOnlyPebbleOpenMigratesLegacyIDIndexTempCopy(t *testing.T) { diff --git a/pkg/sync/expand/expand_benchmark_test.go b/pkg/sync/expand/expand_benchmark_test.go index d8c342448..33527ea7f 100644 --- a/pkg/sync/expand/expand_benchmark_test.go +++ b/pkg/sync/expand/expand_benchmark_test.go @@ -400,6 +400,16 @@ func BenchmarkExpandWhalePebble(b *testing.B) { benchmarkExpandPebbleAtPath(b, pebblePath, false) } +func BenchmarkExpandWhalePebble_NoDigestIndex(b *testing.B) { + pebblePath := os.Getenv("BATON_BENCH_PEBBLE_PATH") + if pebblePath == "" { + b.Skip("BATON_BENCH_PEBBLE_PATH not set") + } + benchmarkExpandPebbleAtPath(b, pebblePath, false, + dotc1z.WithGrantDigestIndex(false), + ) +} + func benchmarkExpandPebble(b *testing.B, syncID string, perStep bool) { pebblePath := getPebbleTestdataPath(syncID) if _, err := os.Stat(pebblePath); os.IsNotExist(err) { @@ -408,13 +418,13 @@ func benchmarkExpandPebble(b *testing.B, syncID string, perStep bool) { benchmarkExpandPebbleAtPath(b, pebblePath, perStep) } -func benchmarkExpandPebbleAtPath(b *testing.B, pebblePath string, perStep bool) { +func benchmarkExpandPebbleAtPath(b *testing.B, pebblePath string, perStep bool, storeOpts ...dotc1z.C1ZOption) { if _, err := os.Stat(pebblePath); os.IsNotExist(err) { // #nosec G703 -- pebblePath is a test-controlled fixture/env path in this benchmark. b.Skipf("Pebble file not found: %s", pebblePath) } ctx := context.Background() - storeWriter, err := dotc1z.NewStore(ctx, pebblePath) + storeWriter, err := dotc1z.NewStore(ctx, pebblePath, storeOpts...) require.NoError(b, err) latest, err := storeWriter.SyncMeta().LatestFullSync(ctx) require.NoError(b, err) @@ -466,7 +476,7 @@ func benchmarkExpandPebbleAtPath(b *testing.B, pebblePath string, perStep bool) require.NoError(b, err) require.NoError(b, os.WriteFile(tmpPath, srcData, 0600)) // #nosec G703 -- tmpPath is generated by os.CreateTemp in this benchmark. - storeWriter, err := dotc1z.NewStore(ctx, tmpPath) + storeWriter, err := dotc1z.NewStore(ctx, tmpPath, storeOpts...) require.NoError(b, err) _, started, err := storeWriter.StartOrResumeSync(ctx, connectorstore.SyncTypeFull, destSyncID) require.NoError(b, err) diff --git a/pkg/sync/expand/fanout_disk_benchmark_test.go b/pkg/sync/expand/fanout_disk_benchmark_test.go index 5d6b4eb4b..f0a894e56 100644 --- a/pkg/sync/expand/fanout_disk_benchmark_test.go +++ b/pkg/sync/expand/fanout_disk_benchmark_test.go @@ -161,7 +161,10 @@ func BenchmarkExpandFanout10MDisk(b *testing.B) { _ = tmp.Close() runPath := tmp.Name() // runPath is from os.CreateTemp().Name(), not external input — benchmark-only. - if err := os.WriteFile(runPath, baseData, 0600); err != nil { //nolint:gosec // G703: path is a test-created temp file, not tainted + // #nosec G703 -- path is a test-created temp file, not tainted. (#nosec, not + // //nolint: CI's pinned gosec fires G703 here, newer local builds don't — + // nolintlint would report an unused directive on whichever side doesn't.) + if err := os.WriteFile(runPath, baseData, 0600); err != nil { b.Fatal(err) } c1f, err := dotc1z.NewC1ZFile(ctx, runPath) diff --git a/pkg/sync/expander_store_guard_test.go b/pkg/sync/expander_store_guard_test.go index 6a0d6f832..47d30047d 100644 --- a/pkg/sync/expander_store_guard_test.go +++ b/pkg/sync/expander_store_guard_test.go @@ -1,4 +1,4 @@ -package sync +package sync //nolint:revive,nolintlint // we can't change the package name for backwards compatibility import ( "context" diff --git a/pkg/synccompactor/compactor_grant_digest_test.go b/pkg/synccompactor/compactor_grant_digest_test.go new file mode 100644 index 000000000..88bc5002c --- /dev/null +++ b/pkg/synccompactor/compactor_grant_digest_test.go @@ -0,0 +1,451 @@ +package synccompactor + +import ( + "bytes" + "context" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/cockroachdb/pebble/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/connectorstore" + "github.com/conductorone/baton-sdk/pkg/dotc1z" + "github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore" + enginepkg "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble" + formatv3 "github.com/conductorone/baton-sdk/pkg/dotc1z/format/v3" +) + +// capturingCore/newCapturingLogger let a test observe the zap messages +// a compaction run logs, without hooking any production code — used +// here to prove the fold's "no grant writes, skip the rebuild" branch +// actually fired, which the resulting digest bytes alone can't show +// (a rebuild over unchanged data would produce the identical bytes). +type capturingCore struct { + zapcore.LevelEnabler + mu *sync.Mutex + messages *[]string +} + +func newCapturingLogger() (*zap.Logger, func() []string) { + mu := &sync.Mutex{} + messages := &[]string{} + core := capturingCore{LevelEnabler: zapcore.DebugLevel, mu: mu, messages: messages} + return zap.New(core), func() []string { + mu.Lock() + defer mu.Unlock() + out := make([]string, len(*messages)) + copy(out, *messages) + return out + } +} + +func (c capturingCore) With([]zapcore.Field) zapcore.Core { return c } +func (c capturingCore) Check(e zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(e.Level) { + return ce.AddCore(e, c) + } + return ce +} +func (c capturingCore) Write(e zapcore.Entry, _ []zapcore.Field) error { + c.mu.Lock() + defer c.mu.Unlock() + *c.messages = append(*c.messages, e.Message) + return nil +} +func (c capturingCore) Sync() error { return nil } + +// buildOracleFromCompacted copies every record the compacted output at +// out actually holds into a brand-new Pebble store (via the public v2 +// reader/writer surface — no shared state, no internal helpers) and +// seals it there. Comparing the oracle's independently-built grant +// digest against the compacted output's is a from-scratch parity +// check: if compaction's digest build were wrong (built over stale or +// partial data), it would not byte-match a digest built from a fresh +// engine over the same final records. +func buildOracleFromCompacted(t testing.TB, ctx context.Context, out *CompactableSync, path string) string { + t.Helper() + src, err := dotc1z.NewStore(ctx, out.FilePath, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer src.Close(ctx) + require.NoError(t, src.SetCurrentSync(ctx, out.SyncID)) + + dst, err := dotc1z.NewStore(ctx, path, dotc1z.WithEngine(c1zstore.EnginePebble), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + syncID, err := dst.StartNewSync(ctx, connectorstore.SyncTypeFull, "") + require.NoError(t, err) + + rtResp, err := src.ListResourceTypes(ctx, v2.ResourceTypesServiceListResourceTypesRequest_builder{PageSize: 1000}.Build()) + require.NoError(t, err) + require.NoError(t, dst.PutResourceTypes(ctx, rtResp.GetList()...)) + + rResp, err := src.ListResources(ctx, v2.ResourcesServiceListResourcesRequest_builder{PageSize: 1000}.Build()) + require.NoError(t, err) + require.NoError(t, dst.PutResources(ctx, rResp.GetList()...)) + + eResp, err := src.ListEntitlements(ctx, v2.EntitlementsServiceListEntitlementsRequest_builder{PageSize: 1000}.Build()) + require.NoError(t, err) + require.NoError(t, dst.PutEntitlements(ctx, eResp.GetList()...)) + + gResp, err := src.ListGrants(ctx, v2.GrantsServiceListGrantsRequest_builder{PageSize: 1000}.Build()) + require.NoError(t, err) + require.NoError(t, dst.PutGrants(ctx, gResp.GetList()...)) + + require.NoError(t, dst.EndSync(ctx)) + require.NoError(t, dst.Close(ctx)) + return syncID +} + +// TestCompactPebbleGrantDigestsBuiltPerMode pins Deliverable 1 (RFC +// 0003): a compacted output — under every merge strategy — must carry +// grant-digest state (the by_entitlement_principal_hash index + digest +// nodes) matching a from-scratch build over its final grant set, and +// the saved envelope's manifest must carry a matching whole-file +// GrantDigestRoot. Before this, compaction shipped digest-free outputs +// in every mode, defeating the digest feature for exactly the files +// downstream diff consumers ingest most. +func TestCompactPebbleGrantDigestsBuiltPerMode(t *testing.T) { + for _, mode := range []PebbleCompactorMode{PebbleCompactorModeFold, PebbleCompactorModeKWay, PebbleCompactorModeOverlay} { + t.Run(string(mode), func(t *testing.T) { + ctx := context.Background() + inDir := t.TempDir() + outDir := t.TempDir() + + p1 := filepath.Join(inDir, "in1.c1z") + p2 := filepath.Join(inDir, "in2.c1z") + s1 := buildPebbleInput(t, ctx, p1, connectorstore.SyncTypeFull, "g-shared", "g-only1") + s2 := buildPebbleInput(t, ctx, p2, connectorstore.SyncTypePartial, "g-shared", "g-only2") + + entries := []*CompactableSync{{FilePath: p1, SyncID: s1}, {FilePath: p2, SyncID: s2}} + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(mode)) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + store, err := dotc1z.NewStore(ctx, out.FilePath, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer store.Close(ctx) + eng, ok := enginepkg.AsEngine(store) + require.True(t, ok, "compacted output must be a pebble engine") + require.NoError(t, store.SetCurrentSync(ctx, out.SyncID)) + + gotRoot, ok, err := eng.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, ok, "compacted output must carry a built grant digest") + require.EqualValues(t, 3, gotRoot.Count, "digest built over the deduped union of 3 grants") + + // Oracle: an independent from-scratch build over the same + // final records. + oraclePath := filepath.Join(outDir, "oracle-"+string(mode)+".c1z") + oracleSyncID := buildOracleFromCompacted(t, ctx, out, oraclePath) + oracleStore, err := dotc1z.NewStore(ctx, oraclePath, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer oracleStore.Close(ctx) + oracleEng, ok := enginepkg.AsEngine(oracleStore) + require.True(t, ok) + require.NoError(t, oracleStore.SetCurrentSync(ctx, oracleSyncID)) + + wantRoot, ok, err := oracleEng.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, wantRoot.Count, gotRoot.Count, "compacted vs. from-scratch grant count") + require.True(t, bytes.Equal(wantRoot.Hash, gotRoot.Hash), + "compacted digest %x must byte-equal a from-scratch build over the same final grants %x", gotRoot.Hash, wantRoot.Hash) + + // The saved manifest header carries the same root, readable + // without unpacking the payload. + f, err := os.Open(out.FilePath) + require.NoError(t, err) + defer f.Close() + m, err := formatv3.ReadManifestHeader(f) + require.NoError(t, err) + mRoot := m.GetGrantDigestRoot() + require.NotNil(t, mRoot, "manifest must carry the grant digest root") + require.Equal(t, gotRoot.Count, mRoot.GetCount()) + require.True(t, bytes.Equal(gotRoot.Hash, mRoot.GetXorDigest())) + }) + } +} + +// grantDigestGlobalRootOf opens path read-only, binds syncID, and +// returns its stored whole-file grant digest root. +func grantDigestGlobalRootOf(t testing.TB, ctx context.Context, path, syncID string) enginepkg.DigestRoot { + t.Helper() + store, err := dotc1z.NewStore(ctx, path, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer store.Close(ctx) + eng, ok := enginepkg.AsEngine(store) + require.True(t, ok, "%s is not a pebble engine", path) + require.NoError(t, store.SetCurrentSync(ctx, syncID)) + root, ok, err := eng.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.True(t, ok, "%s has no built grant digest", path) + return root +} + +// TestCompactPebbleFoldSkipsDigestRebuildWhenGrantsUnchanged pins the +// fix for the "every fold throws away and regenerates the whole grant +// digest, even when nothing changed" gap: a fold whose partial touches +// zero grants must leave the base's already-correct digest state +// completely untouched — no drop, no rebuild — rather than paying a +// full from-scratch rebuild for zero benefit. The resulting bytes +// alone can't distinguish "left alone" from "rebuilt to the same +// answer" (both are byte-identical for unchanged data), so this +// captures the compactor's log output to confirm the skip branch +// actually fired, and separately confirms the base's own root survives +// into the compacted output unchanged. +func TestCompactPebbleFoldSkipsDigestRebuildWhenGrantsUnchanged(t *testing.T) { + logger, capture := newCapturingLogger() + ctx := ctxzap.ToContext(context.Background(), logger) + inDir := t.TempDir() + outDir := t.TempDir() + + basePath := filepath.Join(inDir, "base.c1z") + partialPath := filepath.Join(inDir, "partial-no-grants.c1z") + baseSync := buildPebbleInput(t, ctx, basePath, connectorstore.SyncTypeFull, "g1", "g2") + // A partial that shares the base's resource/entitlement shape but + // carries ZERO grants: the fold's grants-bucket scan iterates + // nothing, so FoldStats.GrantWrites is exactly zero. + partialSync := buildPebbleInput(t, ctx, partialPath, connectorstore.SyncTypePartial) + + wantRoot := grantDigestGlobalRootOf(t, ctx, basePath, baseSync) + + entries := []*CompactableSync{{FilePath: basePath, SyncID: baseSync}, {FilePath: partialPath, SyncID: partialSync}} + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(PebbleCompactorModeFold)) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + var sawSkip bool + for _, msg := range capture() { + if msg == "compactPebbleFold: no grant writes; base grant digest state left untouched" { + sawSkip = true + } + } + require.True(t, sawSkip, "expected the fold to log that it skipped the grant digest rebuild") + + gotRoot := grantDigestGlobalRootOf(t, ctx, out.FilePath, out.SyncID) + require.Equal(t, wantRoot.Count, gotRoot.Count, "grant count must be exactly the base's, unchanged") + require.True(t, bytes.Equal(wantRoot.Hash, gotRoot.Hash), + "compacted root %x must byte-equal the base's untouched root %x", gotRoot.Hash, wantRoot.Hash) +} + +// TestCompactPebbleFoldRepairsOnlyTouchedEntitlements is the +// end-to-end companion to +// TestRepairMissingGrantDigestsHealsOnlyInvalidatedPartition (which +// pins the byte-exact per-entitlement scoping at the engine level): +// a fold whose partial adds a grant under only ONE of two base +// entitlements must take the targeted invalidate+repair path (not the +// full-file rebuild, not the no-op skip), and the result must still +// match a from-scratch build over the final data. +func TestCompactPebbleFoldRepairsOnlyTouchedEntitlements(t *testing.T) { + logger, capture := newCapturingLogger() + ctx := ctxzap.ToContext(context.Background(), logger) + inDir := t.TempDir() + outDir := t.TempDir() + + basePath := filepath.Join(inDir, "base.c1z") + partialPath := filepath.Join(inDir, "partial.c1z") + baseSync := buildOverlayInput(t, ctx, basePath, overlayInputSpec{ + syncType: connectorstore.SyncTypeFull, + suffix: "base", + grants: []overlayGrantSpec{ + {id: "g-ent1-alice", principalID: "alice", entitlementID: "ent-1"}, + {id: "g-ent2-bob", principalID: "bob", entitlementID: "ent-2"}, + }, + }) + // The partial adds a NEW grant only under ent-1; ent-2 is never + // mentioned, so its partition must never be touched by the fold. + partialSync := buildOverlayInput(t, ctx, partialPath, overlayInputSpec{ + syncType: connectorstore.SyncTypePartial, + suffix: "partial", + grants: []overlayGrantSpec{ + {id: "g-ent1-bob", principalID: "bob", entitlementID: "ent-1"}, + }, + }) + + entries := []*CompactableSync{{FilePath: basePath, SyncID: baseSync}, {FilePath: partialPath, SyncID: partialSync}} + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(PebbleCompactorModeFold), + WithSkipGrantExpansion()) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + var repairedMsg bool + for _, msg := range capture() { + if msg == "compactPebbleFold: repaired grant digests for touched entitlements" { + repairedMsg = true + } + } + require.True(t, repairedMsg, "expected the fold to log a targeted repair, not a full rebuild or a skip") + + // Oracle: the compacted output's whole-file root must still match + // a from-scratch build over the same final grants, proving the + // targeted repair produced a correct result even though it only + // touched ent-1. + oraclePath := filepath.Join(outDir, "oracle.c1z") + oracleSyncID := buildOracleFromCompacted(t, ctx, out, oraclePath) + gotRoot := grantDigestGlobalRootOf(t, ctx, out.FilePath, out.SyncID) + wantRoot := grantDigestGlobalRootOf(t, ctx, oraclePath, oracleSyncID) + require.Equal(t, wantRoot.Count, gotRoot.Count) + require.True(t, bytes.Equal(wantRoot.Hash, gotRoot.Hash), + "compacted root %x must byte-equal a from-scratch build %x", gotRoot.Hash, wantRoot.Hash) +} + +// requireEmptyKeyRange asserts the engine's raw keyspace holds nothing +// in [lo, hi). +func requireEmptyKeyRange(t testing.TB, eng *enginepkg.Engine, lo, hi []byte, what string) { + t.Helper() + iter, err := eng.DB().NewIter(&pebble.IterOptions{LowerBound: lo, UpperBound: hi}) + require.NoError(t, err) + defer iter.Close() + require.False(t, iter.First(), "%s keyspace must be empty, found key %x", what, iter.Key()) + require.NoError(t, iter.Error()) +} + +// TestCompactPebbleFoldDigestIndexDisabledDropsDigests pins the fold's +// disabled-digest-index escape hatch: the dest starts as a byte copy of +// the sealed base INCLUDING its digest + hash-index keyspaces, readers +// trust whatever is stored regardless of the writer's flag, and with +// WithGrantDigestIndex(false) every rebuild path (the targeted repair, +// EndSync's finalize) is gated off — so once the merge writes a grant, +// the copied digests are stale and nothing downstream can heal them. +// The fold must DROP the copied digest state instead (absent is always +// safe; present-but-stale silently corrupts grant diffs). Before this, +// the output shipped the base's untouched-but-wrong digest root. +func TestCompactPebbleFoldDigestIndexDisabledDropsDigests(t *testing.T) { + logger, capture := newCapturingLogger() + ctx := ctxzap.ToContext(context.Background(), logger) + inDir := t.TempDir() + outDir := t.TempDir() + + basePath := filepath.Join(inDir, "base.c1z") + partialPath := filepath.Join(inDir, "partial.c1z") + baseSync := buildOverlayInput(t, ctx, basePath, overlayInputSpec{ + syncType: connectorstore.SyncTypeFull, + suffix: "base", + grants: []overlayGrantSpec{ + {id: "g-ent1-alice", principalID: "alice", entitlementID: "ent-1"}, + {id: "g-ent2-bob", principalID: "bob", entitlementID: "ent-2"}, + }, + }) + // The partial adds a grant under ent-1, so the fold's merge writes + // into the grants keyspace and ent-1's copied digest goes stale. + partialSync := buildOverlayInput(t, ctx, partialPath, overlayInputSpec{ + syncType: connectorstore.SyncTypePartial, + suffix: "partial", + grants: []overlayGrantSpec{ + {id: "g-ent1-bob", principalID: "bob", entitlementID: "ent-1"}, + }, + }) + // Sanity: the sealed base carries the digest state the fold copies. + grantDigestGlobalRootOf(t, ctx, basePath, baseSync) + + entries := []*CompactableSync{{FilePath: basePath, SyncID: baseSync}, {FilePath: partialPath, SyncID: partialSync}} + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(PebbleCompactorModeFold), + WithSkipGrantExpansion(), + WithC1ZOptions(dotc1z.WithGrantDigestIndex(false))) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + var sawDrop bool + for _, msg := range capture() { + if msg == "compactPebbleFold: grant writes with digest index disabled; dropped the base's copied digest state" { + sawDrop = true + } + } + require.True(t, sawDrop, "expected the fold to log dropping the copied digest state") + + store, err := dotc1z.NewStore(ctx, out.FilePath, dotc1z.WithReadOnly(true), dotc1z.WithTmpDir(t.TempDir())) + require.NoError(t, err) + defer store.Close(ctx) + eng, ok := enginepkg.AsEngine(store) + require.True(t, ok, "compacted output must be a pebble engine") + require.NoError(t, store.SetCurrentSync(ctx, out.SyncID)) + + // A default-options reader must see digests as ABSENT (recalculate), + // never the base's stale-but-present state. + _, ok, err = eng.GetGrantDigestGlobalRoot(ctx) + require.NoError(t, err) + require.False(t, ok, "output must carry no whole-file digest root") + requireEmptyKeyRange(t, eng, enginepkg.DigestLowerBound(), enginepkg.DigestUpperBound(), "digest node") + requireEmptyKeyRange(t, eng, enginepkg.GrantByEntPrincHashLowerBound(), enginepkg.GrantByEntPrincHashUpperBound(), "grant hash index") + + // And the saved envelope header must not stamp a stale root either. + f, err := os.Open(out.FilePath) + require.NoError(t, err) + defer f.Close() + m, err := formatv3.ReadManifestHeader(f) + require.NoError(t, err) + require.Nil(t, m.GetGrantDigestRoot(), "manifest must not carry a grant digest root after the drop") +} + +// TestCompactPebbleFoldWithExpansionRebuildsFullyRegardless closes the +// loop on a real question about the targeted repair's safety: grant +// expansion (run by Compact whenever it isn't skipped — here, because +// the union sync type is Full) writes grants through its OWN paths +// (PutExpandedGrantRecords / PutSynthesizedGrantRecords / the +// layer-session ingest), never through compactPebbleFold's tracked +// merge. This does not need those paths to invalidate perfectly on +// their own: expansion's syncer.Sync always calls store.EndSync +// afterward, and EndSync's finalize always runs a full digest rebuild +// when the digest index is enabled, unconditionally superseding +// whatever the targeted repair produced. This test exercises the full +// Compact() flow (not compactPebbleFold directly) with expansion NOT +// skipped, and asserts the final output's whole-file root still +// matches a from-scratch build. +func TestCompactPebbleFoldWithExpansionRebuildsFullyRegardless(t *testing.T) { + ctx := context.Background() + inDir := t.TempDir() + outDir := t.TempDir() + + p1 := filepath.Join(inDir, "in1.c1z") + p2 := filepath.Join(inDir, "in2.c1z") + s1 := buildPebbleInput(t, ctx, p1, connectorstore.SyncTypeFull, "g-shared", "g-only1") + s2 := buildPebbleInput(t, ctx, p2, connectorstore.SyncTypePartial, "g-shared", "g-only2") + + entries := []*CompactableSync{{FilePath: p1, SyncID: s1}, {FilePath: p2, SyncID: s2}} + // No WithSkipGrantExpansion(): the base is Full, so the compacted + // sync's union type is Full too, skipExpansion is false in + // Compact(), and a real grant-expansion syncer.Sync + EndSync runs + // on the compacted output before it is saved. + c, cleanup, err := NewCompactor(ctx, outDir, entries, + WithTmpDir(t.TempDir()), WithEngine(c1zstore.EnginePebble), WithPebbleCompactorMode(PebbleCompactorModeFold)) + require.NoError(t, err) + defer func() { _ = cleanup() }() + + out, err := c.Compact(ctx) + require.NoError(t, err) + require.NotNil(t, out) + + oraclePath := filepath.Join(outDir, "oracle.c1z") + oracleSyncID := buildOracleFromCompacted(t, ctx, out, oraclePath) + gotRoot := grantDigestGlobalRootOf(t, ctx, out.FilePath, out.SyncID) + wantRoot := grantDigestGlobalRootOf(t, ctx, oraclePath, oracleSyncID) + require.Equal(t, wantRoot.Count, gotRoot.Count) + require.True(t, bytes.Equal(wantRoot.Hash, gotRoot.Hash), + "post-expansion compacted root %x must byte-equal a from-scratch build %x", gotRoot.Hash, wantRoot.Hash) +} diff --git a/pkg/synccompactor/compactor_pebble.go b/pkg/synccompactor/compactor_pebble.go index 5b4f7b83b..9983ba315 100644 --- a/pkg/synccompactor/compactor_pebble.go +++ b/pkg/synccompactor/compactor_pebble.go @@ -571,6 +571,82 @@ func (c *Compactor) compactPebbleFold(ctx context.Context) (string, error) { } } + // Only touch grant digests for the entitlement partitions this fold + // actually wrote a grant into. The dest started as a byte copy of a + // sealed base, whose digest state is already exactly correct for + // every OTHER entitlement; invalidating (and later recalling + // RepairMissingGrantDigests to rebuild) the whole file on every + // fold — even one where a single entitlement out of thousands + // changed — would reintroduce the O(base) cost fold exists to + // avoid. InvalidateGrantDigestPartitions drops exactly the touched + // partitions (+ the now-stale whole-file root); + // RepairMissingGrantDigests then rebuilds exactly what's missing, + // each from a targeted scan of just that entitlement's own grants — + // never a full-file scan — and recomputes the whole-file root from + // the (small) digest keyspace itself. A fold whose partials add + // nothing new (a common steady-state case — re-running compaction + // with no new data, or partials that only touch + // resources/entitlements) touches nothing at all. + // + // This result is FINAL only when the caller (Compact) goes on to + // skip grant expansion (WithSkipGrantExpansion, or a partial-typed + // union) — nothing else touches c.compactedC1z before Close in that + // path (GetSync is a pure read; Cleanup is a hard no-op for the + // Pebble engine). When expansion runs instead, its own grant writes + // use dedicated write paths (PutExpandedGrantRecords / + // PutSynthesizedGrantRecords / the layer-session ingest, not + // PutGrantRecords) whose invalidation correctness doesn't matter + // here: expandGrants' syncer.Sync ALWAYS calls store.EndSync + // afterward — even for a no-op expansion — and Adapter.EndSync's + // finalize unconditionally runs a FULL digest rebuild + // (BuildDeferredGrantIndexes or BuildGrantDigests) whenever the + // digest index is enabled, with no branch that skips both. So + // whatever this targeted repair produces gets unconditionally + // superseded by a full, correct rebuild the moment expansion runs — + // safe, but this optimization's actual win is scoped to + // skip-expansion compactions; a fold whose base is a full sync + // (expansion NOT skipped) pays for both the targeted repair AND the + // subsequent full rebuild. See + // TestCompactPebbleFoldWithExpansionRebuildsFullyRegardless. + // + // When the dest engine has the digest index DISABLED, touched + // digests must be dropped instead of repaired: the byte copy + // carried the sealed base's digest state unconditionally, readers + // serve whatever is stored regardless of this writer's flag + // (grantDigestsPresent is probed from the keyspace at Open), and + // every rebuild path — this one, EndSync's finalize, + // RepairMissingGrantDigests itself — gates on the same flag, so + // stale digests would ship as present-but-wrong with nothing left + // to heal them. Absent is always safe (present-means-exact). + // Dropping only on a grant write, rather than skipping the digest + // bucket copy up front, keeps the no-grant-write fold preserving + // the base's still-exact digests for free even on a disabled-index + // engine. See TestCompactPebbleFoldDigestIndexDisabledDropsDigests. + if len(foldStats.TouchedGrantPartitions) > 0 { + if !destEng.GrantDigestIndexEnabled() { + if err := destEng.DropAllGrantDigestState(ctx); err != nil { + return "", fmt.Errorf("compactPebbleFold: drop grant digest state (digest index disabled): %w", err) + } + l.Info("compactPebbleFold: grant writes with digest index disabled; dropped the base's copied digest state", + zap.Int("touched_partitions", len(foldStats.TouchedGrantPartitions))) + } else { + partitions := make([]string, 0, len(foldStats.TouchedGrantPartitions)) + for p := range foldStats.TouchedGrantPartitions { + partitions = append(partitions, p) + } + if err := destEng.InvalidateGrantDigestPartitions(ctx, partitions); err != nil { + return "", fmt.Errorf("compactPebbleFold: invalidate grant digest partitions: %w", err) + } + if err := destEng.RepairMissingGrantDigests(ctx); err != nil { + return "", fmt.Errorf("compactPebbleFold: repair grant digests: %w", err) + } + l.Info("compactPebbleFold: repaired grant digests for touched entitlements", + zap.Int("touched_partitions", len(partitions))) + } + } else { + l.Info("compactPebbleFold: no grant writes; base grant digest state left untouched") + } + // Optionally compact the folded LSM before save. Off by default: // a compaction rewrites the SSTs that overlap the partials' writes // — for scattered overrides that is most of the base — which both @@ -659,6 +735,66 @@ func (c *Compactor) compactPebbleFold(ctx context.Context) (string, error) { return newSyncID, nil } +// rebuildCompactedGrantDigests builds the by_entitlement_principal_hash +// index and per-entitlement grant digests (plus the whole-file root, +// see manifest.go) for a compacted output, once the merge has finished +// writing its final grant keyspace — so a compacted file carries built +// digests indistinguishable from a seal-time build, instead of shipping +// with none (every merge strategy writes the final grant records +// through its own path — raw keep-newer for fold, run-file/SST +// materialization for k-way and overlay — and none of them maintains +// the hash index or digests inline). +// +// This reuses Engine.BuildGrantDigests verbatim: the SAME standalone +// build EndSync runs when a sync's deferred index pass never fired +// (grants already have a correct by_principal / by_needs_expansion, +// nothing to rebuild there). It scans the primary grants exactly once — +// it does NOT re-derive by_principal or rewrite the primary grant +// keyspace, so it does not repeat the O(grants) rebuild work the merge +// strategies already did. Because every hash-index row and digest node +// is recomputed from scratch off the FINAL winner records, there is no +// verbatim-copy path that could carry forward a superseded row's stale +// content hash — unlike a fused emit-during-merge design, this build +// has no notion of "copy an index entry from a source", so the classic +// hazard (two sources sharing a grant identity but different source +// sets, whose content hash covers the source set) cannot arise: every +// row is derived from the one winner value that survived the merge. +// +// Failure policy matches the seal build exactly (same function): an +// error other than context cancellation drops all digest state and +// logs, and returns nil — a compacted file with no digests is safe +// (present-means-exact), so a digest-build hiccup must never fail the +// compaction. Interrupt/restart safety is free: this runs once, after +// the merge's writes are already durably committed, so a crash or +// cancellation before this call simply leaves the output with no +// digest state at all (never a partial one) — the conservative design +// the digest package's present-means-exact contract calls for. +// +// No-op when the destination engine was opened with the digest index +// disabled (WithGrantDigestIndex(false)), matching EndSync's own gate +// (GrantDigestIndexEnabled). +// +// Callers: only k-way and overlay use this (unconditionally) — those +// modes materialize every record fresh on every run regardless of +// digests, so a full rebuild is proportional to work already +// unavoidable. Fold does NOT call this: its dest starts as a byte copy +// of a sealed base whose digest state is already correct for every +// untouched entitlement, so a full rebuild on every fold — even one +// where a handful of entitlements out of thousands changed — would +// reintroduce the O(base) cost fold exists to avoid. Fold instead +// calls Engine.InvalidateGrantDigestPartitions + +// Engine.RepairMissingGrantDigests to invalidate and rebuild EXACTLY +// the entitlements it touched (see compactPebbleFold). +func rebuildCompactedGrantDigests(ctx context.Context, destEng *enginepkg.Engine) error { + if !destEng.GrantDigestIndexEnabled() { + return nil + } + if err := destEng.BuildGrantDigests(ctx); err != nil { + return fmt.Errorf("build grant digests: %w", err) + } + return nil +} + // runPebbleRebuild is the standard (non-fold) Pebble compaction: start // an empty partial sync on the dest and merge every input into it. // compactPebble updates the sync's type to the union of the input @@ -1038,6 +1174,10 @@ func (c *Compactor) compactPebble(ctx context.Context, newSyncId string) error { return fmt.Errorf("compactPebble: merge: %w", err) } + if err := rebuildCompactedGrantDigests(ctx, destEng); err != nil { + return fmt.Errorf("compactPebble: %w", err) + } + // Set the compacted sync_run's type + ended_at to the union / max // across the inputs so downstream gating (e.g. grant expansion) // behaves identically to the sqlite path, then recompute stats. diff --git a/pkg/synccompactor/pebble/bucket_plans.go b/pkg/synccompactor/pebble/bucket_plans.go index 5b5b1d943..33d9be541 100644 --- a/pkg/synccompactor/pebble/bucket_plans.go +++ b/pkg/synccompactor/pebble/bucket_plans.go @@ -60,6 +60,20 @@ func buildBucketPlans() []bucketPlan { lower: enginepkg.GrantByNeedsExpansionLowerBound(), upper: enginepkg.GrantByNeedsExpansionUpperBound(), }, + { + name: "grant_by_entitlement_principal_hash", + lower: enginepkg.GrantByEntPrincHashLowerBound(), + upper: enginepkg.GrantByEntPrincHashUpperBound(), + }, + { + // Digest nodes (all digested indexes, e.g. the per-entitlement + // grant digest). Copied byte-for-byte; because the records move + // verbatim during Compact, the digests stay valid in the + // destination without a rebuild. + name: "digest", + lower: enginepkg.DigestLowerBound(), + upper: enginepkg.DigestUpperBound(), + }, { name: "asset", lower: enginepkg.AssetLowerBound(), diff --git a/pkg/synccompactor/pebble/kway_bench_test.go b/pkg/synccompactor/pebble/kway_bench_test.go index f6494a10f..855ef02a5 100644 --- a/pkg/synccompactor/pebble/kway_bench_test.go +++ b/pkg/synccompactor/pebble/kway_bench_test.go @@ -18,10 +18,22 @@ import ( enginepkg "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble" ) -func BenchmarkMergeFilesIntoKWay(b *testing.B) { +// kwayBenchSourceCount / kwayBenchGrantsPerSource fix the "syncs=50" +// shape referenced by RFC 0003 Deliverable 1's verification +// requirement: BenchmarkMergeFilesIntoKWay is the merge-only baseline, +// BenchmarkMergeFilesIntoKWayWithGrantDigests adds the post-merge +// digest build compaction now performs, and the delta between the two +// is the wall-clock cost that build adds at this scale. +const ( + kwayBenchSourceCount = 50 + kwayBenchGrantsPerSource = 2500 +) + +func buildKWayBenchSources(b *testing.B) (string, []SourceFile) { + b.Helper() ctx := context.Background() - const sourceCount = 50 - const grantsPerSource = 2500 + const sourceCount = kwayBenchSourceCount + const grantsPerSource = kwayBenchGrantsPerSource fixtureDir := b.TempDir() sources := make([]SourceFile, 0, sourceCount) @@ -86,10 +98,18 @@ func BenchmarkMergeFilesIntoKWay(b *testing.B) { } sources = append(sources, SourceFile{Path: path, SyncID: syncID}) } + return fixtureDir, sources +} + +// BenchmarkMergeFilesIntoKWay is the merge-only baseline: no grant +// digest build, matching pre-Deliverable-1 compaction output. +func BenchmarkMergeFilesIntoKWay(b *testing.B) { + ctx := context.Background() + fixtureDir, sources := buildKWayBenchSources(b) b.ReportAllocs() - b.ReportMetric(sourceCount, "sources/op") - b.ReportMetric(grantsPerSource, "grants_per_source") + b.ReportMetric(kwayBenchSourceCount, "sources/op") + b.ReportMetric(kwayBenchGrantsPerSource, "grants_per_source") b.ResetTimer() for i := 0; i < b.N; i++ { dest, _ := benchNewEngine(b, fmt.Sprintf("dest-%d", i)) @@ -106,3 +126,39 @@ func BenchmarkMergeFilesIntoKWay(b *testing.B) { } } } + +// BenchmarkMergeFilesIntoKWayWithGrantDigests is +// BenchmarkMergeFilesIntoKWay plus the post-merge BuildGrantDigests +// call the compactor now runs on every compacted output (RFC 0003 +// Deliverable 1, compactor_pebble.go's rebuildCompactedGrantDigests). +// The delta against BenchmarkMergeFilesIntoKWay is the wall-clock cost +// that build adds at the syncs=50 / grants=125000 scale. +func BenchmarkMergeFilesIntoKWayWithGrantDigests(b *testing.B) { + ctx := context.Background() + fixtureDir, sources := buildKWayBenchSources(b) + + b.ReportAllocs() + b.ReportMetric(kwayBenchSourceCount, "sources/op") + b.ReportMetric(kwayBenchGrantsPerSource, "grants_per_source") + b.ResetTimer() + for i := 0; i < b.N; i++ { + dest, _ := benchNewEngine(b, fmt.Sprintf("dest-%d", i)) + destSyncID := ksuid.New().String() + tmpDir := filepath.Join(fixtureDir, fmt.Sprintf("tmp-%d", i)) + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + b.Fatal(err) + } + if err := dest.SetCurrentSync(destSyncID); err != nil { + b.Fatal(err) + } + if _, err := MergeFilesInto(ctx, dest, sources, destSyncID, tmpDir); err != nil { + b.Fatal(err) + } + if err := dest.BuildGrantDigests(ctx); err != nil { + b.Fatal(err) + } + if err := dest.Close(); err != nil { + b.Fatal(err) + } + } +} diff --git a/pkg/synccompactor/pebble/merge.go b/pkg/synccompactor/pebble/merge.go index 52257b46a..40e2e4da3 100644 --- a/pkg/synccompactor/pebble/merge.go +++ b/pkg/synccompactor/pebble/merge.go @@ -39,6 +39,23 @@ type FoldStats struct { // ReplacedByBucket counts strictly-newer overrides per bucket name. // Sums to OverriddenRecords. ReplacedByBucket map[string]int64 + // GrantWrites counts every grant record actually written to dest — + // both fresh admissions (no incumbent at the key) and strictly- + // newer overrides. Byte-identical resubmissions and not-strictly- + // newer candidates are NOT counted (mergeBucketRawIfNewer's no-op + // fast paths). The fold compactor uses this to decide whether the + // grants keyspace changed at all: when it's zero across a whole + // fold, the base's already-correct grant-digest state was never + // touched and needs no rebuild (see compactPebbleFold). + GrantWrites int64 + // TouchedGrantPartitions is the set of entitlement partitions + // (enginepkg.GrantPartitionFromPrimaryKey) that had at least one + // grant actually written to dest, under the same counting rule as + // GrantWrites. The fold compactor uses this to invalidate and + // repair EXACTLY the entitlements a fold touched + // (Engine.InvalidateGrantDigestPartitions + + // Engine.RepairMissingGrantDigests), instead of the whole file. + TouchedGrantPartitions map[string]struct{} } func (s *FoldStats) Add(o FoldStats) { @@ -50,6 +67,13 @@ func (s *FoldStats) Add(o FoldStats) { for bucket, n := range o.ReplacedByBucket { s.bumpReplaced(bucket, n) } + s.GrantWrites += o.GrantWrites + for p := range o.TouchedGrantPartitions { + if s.TouchedGrantPartitions == nil { + s.TouchedGrantPartitions = make(map[string]struct{}, len(o.TouchedGrantPartitions)) + } + s.TouchedGrantPartitions[p] = struct{}{} + } } func (s *FoldStats) bumpAdded(bucket string, n int64) { @@ -96,6 +120,22 @@ func (s *FoldStats) bumpReplaced(bucket string, n int64) { // the already-written (earlier source, or pre-existing dest) record is // kept. Callers pass sources newest-first so the tie winner matches the // SQLite fold. +// +// MergeInto does NOT touch grant-digest state itself. The fold dest +// starts as a byte copy of a SEALED base, which carries an already- +// CORRECT grant hash index and digests; this merge mutates grants +// through the raw DB handle without maintaining either (nothing does, +// outside the seal-time build), so once ANY grant write lands the +// dest's digest state is stale relative to it. Rather than drop it +// pre-emptively on every call — which would force a full from-scratch +// digest rebuild even for a fold that changes nothing, defeating +// fold's whole point of doing O(partials) work, not O(base) — callers +// inspect the returned FoldStats.GrantWrites: zero means the grants +// keyspace was never touched and the base's digest state is still +// exactly correct, left alone; nonzero means the caller must rebuild +// (Engine.BuildGrantDigests rebuilds both keyspaces atomically from +// scratch, so no separate drop is needed even then — see +// compactPebbleFold). func MergeInto(ctx context.Context, dest *enginepkg.Engine, sources []SourceSync, destSyncID string) (FoldStats, error) { var stats FoldStats if dest == nil { @@ -252,6 +292,15 @@ func mergeBucketRawIfNewer(ctx context.Context, dest *enginepkg.Engine, src *peb if err := batch.Set(key, value, nil); err != nil { return stats, err } + if bucket.id == runBucketGrants { + stats.GrantWrites++ + if partition, ok := enginepkg.GrantPartitionFromPrimaryKey(key); ok { + if stats.TouchedGrantPartitions == nil { + stats.TouchedGrantPartitions = map[string]struct{}{} + } + stats.TouchedGrantPartitions[partition] = struct{}{} + } + } if err := forEachIndexKeyFromRaw(bucket, key, lower, value, &scratch, nil, setIndexKey); err != nil { return stats, err } diff --git a/pkg/synccompactor/pebble/merge_test.go b/pkg/synccompactor/pebble/merge_test.go index 0eace2029..c57285b46 100644 --- a/pkg/synccompactor/pebble/merge_test.go +++ b/pkg/synccompactor/pebble/merge_test.go @@ -88,6 +88,55 @@ func TestMergeIntoUnionNewerWins(t *testing.T) { assertIndexesMatchDerived(t, ctx, dst) } +// TestMergeIntoGrantWritesTracksActualChanges pins the FoldStats.GrantWrites +// signal the fold compactor uses to skip a from-scratch grant-digest +// rebuild when a fold changed nothing: it must be zero whenever no +// grant record was actually written to dest (a byte-identical +// resubmission, or a source with no grants at all), and nonzero +// whenever one was (a fresh admission or a strictly-newer override). +func TestMergeIntoGrantWritesTracksActualChanges(t *testing.T) { + ctx := context.Background() + src, _ := newEngine(t, "gw-src") + dst, _ := newEngine(t, "gw-dst") + + syncID := ksuid.New().String() + destSync := ksuid.New().String() + at := time.Unix(1000, 0).UTC() + + require.NoError(t, src.SetCurrentSync(syncID)) + require.NoError(t, src.PutGrantRecords(ctx, grantAt(syncID, "g1", at))) + + // First merge: a genuinely new grant is admitted. + stats, err := MergeInto(ctx, dst, []SourceSync{{Engine: src, SyncID: syncID}}, destSync) + require.NoError(t, err) + require.EqualValues(t, 1, stats.GrantWrites, "first merge admits one new grant") + + // Re-merging the SAME source into the SAME dest sync: the incoming + // record is now byte-identical to the incumbent, which + // mergeBucketRawIfNewer treats as a true no-op — zero grant writes, + // even though a grant record was iterated and compared. + stats2, err := MergeInto(ctx, dst, []SourceSync{{Engine: src, SyncID: syncID}}, destSync) + require.NoError(t, err) + require.Zero(t, stats2.GrantWrites, "resubmitting an identical grant must not count as a write") + + // A source with no grants at all contributes zero grant writes. + empty, _ := newEngine(t, "gw-empty-src") + emptySyncID := ksuid.New().String() + require.NoError(t, empty.SetCurrentSync(emptySyncID)) + stats3, err := MergeInto(ctx, dst, []SourceSync{{Engine: empty, SyncID: emptySyncID}}, destSync) + require.NoError(t, err) + require.Zero(t, stats3.GrantWrites, "a source with no grants contributes zero grant writes") + + // A genuinely newer record on the same identity DOES count. + newer := time.Unix(2000, 0).UTC() + src2, _ := newEngine(t, "gw-src2") + require.NoError(t, src2.SetCurrentSync(syncID)) + require.NoError(t, src2.PutGrantRecords(ctx, grantAt(syncID, "g1", newer))) + stats4, err := MergeInto(ctx, dst, []SourceSync{{Engine: src2, SyncID: syncID}}, destSync) + require.NoError(t, err) + require.EqualValues(t, 1, stats4.GrantWrites, "a strictly-newer override counts as a grant write") +} + // TestMergeIntoTieKeepsIncumbent pins the equal-discovered_at tie rule // to SQLite's strict `>`: on a tie, the earlier-applied source's record // is kept (it is the incumbent the newer write does not replace). diff --git a/proto/c1/c1z/v3/manifest.proto b/proto/c1/c1z/v3/manifest.proto index 837dd3eeb..fbda58999 100644 --- a/proto/c1/c1z/v3/manifest.proto +++ b/proto/c1/c1z/v3/manifest.proto @@ -75,6 +75,34 @@ message C1ZManifestV3 { // tooling inspect legacy-vs-structured index state without extracting the // Pebble payload. PebbleIdIndexFormat pebble_id_index_format = 42; + + // Whole-file grant digest root: the XOR fold of every per-entitlement + // grant-digest root's content-hash XOR in the file's single sync (i.e. + // the fold of every entitlement's digest, not just entitlements with + // grants), plus the total grant count. Lets a consumer answer "did + // anything change?" from a header read alone, without unpacking the + // Pebble payload. Absent means "no digest built for this file" (older + // writers, or a file whose digest state was invalidated after the + // last save) — never "zero grants"; see + // c1/dotc1z/engine/pebble/digest.go's present-means-exact contract. + // Additive field: old readers ignore it, old files simply lack it. + GrantDigestRoot grant_digest_root = 43; +} + +// GrantDigestRoot is the whole-file grant digest: present iff the +// engine successfully built grant digests for the file's sync (seal +// time, or a compaction that ran the same build). See +// c1.c1z.v3.C1ZManifestV3.grant_digest_root. +message GrantDigestRoot { + // XOR of every grant's content hash in the sync. hashLen (8) bytes. + bytes xor_digest = 1; + // Total grant count the digest was built over. + int64 count = 2; + // Version of the grant content-hash / bucket-hash ABI (grant_digest.go) + // this root was computed under. A future ABI bump changes this, which + // makes roots computed under different versions incomparable by + // construction instead of silently comparing unrelated hash schemes. + uint32 abi_version = 3; } enum PebbleIdIndexFormat {