@@ -9,14 +9,17 @@ import (
9
9
"bytes"
10
10
"context"
11
11
"fmt"
12
+ "path"
12
13
"strings"
13
14
14
15
"github.com/cockroachdb/cockroach/pkg/backup/backupbase"
15
16
"github.com/cockroachdb/cockroach/pkg/backup/backuppb"
16
17
"github.com/cockroachdb/cockroach/pkg/backup/backuputils"
17
18
"github.com/cockroachdb/cockroach/pkg/cloud"
19
+ "github.com/cockroachdb/cockroach/pkg/clusterversion"
18
20
"github.com/cockroachdb/cockroach/pkg/jobs/jobspb"
19
21
"github.com/cockroachdb/cockroach/pkg/security/username"
22
+ "github.com/cockroachdb/cockroach/pkg/sql"
20
23
"github.com/cockroachdb/cockroach/pkg/util/hlc"
21
24
"github.com/cockroachdb/cockroach/pkg/util/protoutil"
22
25
"github.com/cockroachdb/cockroach/pkg/util/tracing"
@@ -31,11 +34,25 @@ import (
31
34
// information.
32
35
func WriteBackupIndexMetadata (
33
36
ctx context.Context ,
37
+ execCfg * sql.ExecutorConfig ,
34
38
user username.SQLUsername ,
35
39
makeExternalStorageFromURI cloud.ExternalStorageFromURIFactory ,
36
40
details jobspb.BackupDetails ,
37
41
) error {
38
- ctx , sp := tracing .ChildSpan (ctx , "backupdest.WriteBackupIndexMetadata" )
42
+ indexStore , err := makeExternalStorageFromURI (
43
+ ctx , details .CollectionURI , user ,
44
+ )
45
+ if err != nil {
46
+ return errors .Wrapf (err , "creating external storage" )
47
+ }
48
+
49
+ if shouldWrite , err := shouldWriteIndex (
50
+ ctx , execCfg , indexStore , details ,
51
+ ); ! shouldWrite {
52
+ return err
53
+ }
54
+
55
+ ctx , sp := tracing .ChildSpan (ctx , "backupinfo.WriteBackupIndexMetadata" )
39
56
defer sp .Finish ()
40
57
41
58
if details .EndTime .IsEmpty () {
@@ -74,13 +91,6 @@ func WriteBackupIndexMetadata(
74
91
return errors .Wrapf (err , "marshal backup index metadata" )
75
92
}
76
93
77
- indexStore , err := makeExternalStorageFromURI (
78
- ctx , details .CollectionURI , user ,
79
- )
80
- if err != nil {
81
- return errors .Wrapf (err , "creating external storage" )
82
- }
83
-
84
94
indexFilePath , err := getBackupIndexFilePath (
85
95
details .Destination .Subdir ,
86
96
details .StartTime ,
@@ -95,22 +105,78 @@ func WriteBackupIndexMetadata(
95
105
)
96
106
}
97
107
108
+ // IndexExists checks if for a given full backup subdirectory there exists a
109
+ // corresponding index in the backup collection. This is used to determine when
110
+ // we should use the index or the legacy path.
111
+ //
112
+ // This works under the assumption that we only ever write an index iff:
113
+ // 1. For an incremental backup, an index exists for its full backup.
114
+ // 2. The backup was taken on a v25.4+ cluster.
115
+ //
116
+ // The store should be rooted at the default collection URI (the one that
117
+ // contains the `index/` directory).
118
+ //
119
+ // Note: v25.4+ backups will always contain an index file. In other words, we
120
+ // can remove these checks in v26.2+.
121
+ func IndexExists (ctx context.Context , store cloud.ExternalStorage , subdir string ) (bool , error ) {
122
+ var indexExists bool
123
+ indexSubdir := path .Join (backupbase .BackupIndexDirectoryPath , flattenSubdirForIndex (subdir ))
124
+ if err := store .List (
125
+ ctx ,
126
+ indexSubdir ,
127
+ "/" ,
128
+ func (file string ) error {
129
+ indexExists = true
130
+ // Because we delimit on `/` and the index subdir does not contain a
131
+ // trailing slash, we should only find one file as a result of this list.
132
+ // The error is just being returned defensively just in case.
133
+ return errors .New ("found index" )
134
+ },
135
+ ); err != nil && ! indexExists {
136
+ return false , errors .Wrapf (err , "checking index exists in %s" , subdir )
137
+ }
138
+ return indexExists , nil
139
+ }
140
+
141
+ // shouldWriteIndex determines if a backup index file should be written for a
142
+ // given backup. The rule is:
143
+ // 1. An index should only be written on a v25.4+ cluster.
144
+ // 2. An incremental backup only writes an index if its parent full has written
145
+ // an index file.
146
+ //
147
+ // This ensures that if a backup chain exists in the index directory, then every
148
+ // backup in that chain has an index file, ensuring that the index is usable.
149
+ func shouldWriteIndex (
150
+ ctx context.Context ,
151
+ execCfg * sql.ExecutorConfig ,
152
+ store cloud.ExternalStorage ,
153
+ details jobspb.BackupDetails ,
154
+ ) (bool , error ) {
155
+ // This version check can be removed in v26.1 when we no longer need to worry
156
+ // about a mixed-version cluster where we have both v25.4+ nodes and pre-v25.4
157
+ // nodes.
158
+ if ! execCfg .Settings .Version .IsActive (ctx , clusterversion .V25_4 ) {
159
+ return false , nil
160
+ }
161
+
162
+ // Full backups can write an index as long as the cluster is on v25.4+.
163
+ if details .StartTime .IsEmpty () {
164
+ return true , nil
165
+ }
166
+
167
+ return IndexExists (ctx , store , details .Destination .Subdir )
168
+ }
169
+
98
170
// getBackupIndexFilePath returns the path to the backup index file representing
99
171
// a backup that starts and ends at the given timestamps, including
100
172
// the filename and extension. The path is relative to the collection URI.
101
173
func getBackupIndexFilePath (subdir string , startTime , endTime hlc.Timestamp ) (string , error ) {
102
174
if strings .EqualFold (subdir , backupbase .LatestFileName ) {
103
175
return "" , errors .AssertionFailedf ("expected subdir to be resolved and not be 'LATEST'" )
104
176
}
105
- // We flatten the subdir so that when listing from the index, we can list with
106
- // the `index/` prefix and delimit on `/`.
107
- flattenedSubdir := strings .ReplaceAll (
108
- strings .TrimPrefix (subdir , "/" ),
109
- "/" , "-" ,
110
- )
111
177
return backuputils .JoinURLPath (
112
178
backupbase .BackupIndexDirectoryPath ,
113
- flattenedSubdir ,
179
+ flattenSubdirForIndex ( subdir ) ,
114
180
getBackupIndexFileName (startTime , endTime ),
115
181
), nil
116
182
}
@@ -130,3 +196,29 @@ func getBackupIndexFileName(startTime, endTime hlc.Timestamp) string {
130
196
descEndTs , formattedStartTime , formattedEndTime ,
131
197
)
132
198
}
199
+
200
+ // flattenSubdirForIndex flattens a full backup subdirectory to be used in the
201
+ // index. Note that this path does not contain a trailing or leading slash.
202
+ // It assumes subdir is not `LATEST` and has been resolved.
203
+ // We flatten the subdir so that when listing from the index, we can list with
204
+ // the `index/` prefix and delimit on `/`. e.g.:
205
+ //
206
+ // index/
207
+ //
208
+ // |_ 2025-08-13-120000.00/
209
+ // | |_ <index_meta>.pb
210
+ // |_ 2025-08-14-120000.00/
211
+ // | |_ <index_meta>.pb
212
+ // |_ 2025-08-14-120000.00/
213
+ // |_ <index_meta>.pb
214
+ //
215
+ // Listing on `index/` and delimiting on `/` will return the subdirectories
216
+ // without listing the files in them.
217
+ func flattenSubdirForIndex (subdir string ) string {
218
+ return strings .ReplaceAll (
219
+ // Trimming any trailing and leading slashes guarantees a specific format when
220
+ // returning the flattened subdir, so callers can expect a consistent result.
221
+ strings .TrimSuffix (strings .TrimPrefix (subdir , "/" ), "/" ),
222
+ "/" , "-" ,
223
+ )
224
+ }
0 commit comments