master
go 709 lines 19.5 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package mssql
4
5 import (
6 "context"
7 "database/sql"
8 "encoding/xml"
9 "errors"
10 "fmt"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 mssqlDriver "github.com/microsoft/go-mssqldb"
17
18 "github.com/netdata/netdata/go/plugins/pkg/funcapi"
19 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/strmutil"
20 )
21
22 const (
23 deadlockInfoHelp = "Latest deadlock from the system_health Extended Events session. WARNING: query text may include unmasked sensitive literals; restrict dashboard access."
24 deadlockParseErrorStatus = 561
25 )
26
27 const deadlockInfoMethodID = "deadlock-info"
28
29 // deadlockRowData holds computed values for a single deadlock row.
30 type deadlockRowData struct {
31 rowID string
32 deadlockID string
33 timestamp string
34 processID string
35 spid any
36 ecid any
37 isVictim string
38 queryText string
39 lockMode string
40 lockStatus string
41 waitResource string
42 database any
43 }
44
45 // deadlockColumn defines a column for the deadlock-info function.
46 type deadlockColumn struct {
47 funcapi.ColumnMeta
48 Value func(*deadlockRowData) any
49 }
50
51 func deadlockColumnSet(cols []deadlockColumn) funcapi.ColumnSet[deadlockColumn] {
52 return funcapi.Columns(cols, func(c deadlockColumn) funcapi.ColumnMeta { return c.ColumnMeta })
53 }
54
55 var deadlockColumns = []deadlockColumn{
56 {
57 ColumnMeta: funcapi.ColumnMeta{
58 Name: "row_id",
59 Tooltip: "Unique identifier for this row",
60 Type: funcapi.FieldTypeString,
61 Sort: funcapi.FieldSortAscending,
62 Sortable: true,
63 Summary: funcapi.FieldSummaryCount,
64 Filter: funcapi.FieldFilterMultiselect,
65 UniqueKey: true,
66 Visible: false,
67 },
68 Value: func(r *deadlockRowData) any { return r.rowID },
69 },
70 {
71 ColumnMeta: funcapi.ColumnMeta{
72 Name: "timestamp",
73 Tooltip: "When the deadlock occurred",
74 Type: funcapi.FieldTypeTimestamp,
75 Sort: funcapi.FieldSortDescending,
76 Sortable: true,
77 Summary: funcapi.FieldSummaryMax,
78 Filter: funcapi.FieldFilterRange,
79 Visible: true,
80 Transform: funcapi.FieldTransformDatetime,
81 },
82 Value: func(r *deadlockRowData) any { return r.timestamp },
83 },
84 {
85 ColumnMeta: funcapi.ColumnMeta{
86 Name: "is_victim",
87 Tooltip: "Whether this process was rolled back to resolve the deadlock",
88 Type: funcapi.FieldTypeString,
89 Visualization: funcapi.FieldVisualPill,
90 Sort: funcapi.FieldSortAscending,
91 Sortable: true,
92 Summary: funcapi.FieldSummaryCount,
93 Filter: funcapi.FieldFilterMultiselect,
94 Visible: true,
95 },
96 Value: func(r *deadlockRowData) any { return r.isVictim },
97 },
98 {
99 ColumnMeta: funcapi.ColumnMeta{
100 Name: "query_text",
101 Tooltip: "The SQL statement being executed",
102 Type: funcapi.FieldTypeString,
103 Sort: funcapi.FieldSortAscending,
104 Sortable: false,
105 Sticky: true,
106 Summary: funcapi.FieldSummaryCount,
107 Filter: funcapi.FieldFilterMultiselect,
108 FullWidth: true,
109 Wrap: true,
110 Visible: true,
111 },
112 Value: func(r *deadlockRowData) any { return r.queryText },
113 },
114 {
115 ColumnMeta: funcapi.ColumnMeta{
116 Name: "database",
117 Tooltip: "Database where the deadlock occurred",
118 Type: funcapi.FieldTypeString,
119 Sort: funcapi.FieldSortAscending,
120 Sortable: true,
121 Summary: funcapi.FieldSummaryCount,
122 Filter: funcapi.FieldFilterMultiselect,
123 Visible: true,
124 },
125 Value: func(r *deadlockRowData) any { return r.database },
126 },
127 {
128 ColumnMeta: funcapi.ColumnMeta{
129 Name: "lock_mode",
130 Tooltip: "Type of lock (S=Shared, X=Exclusive, U=Update, etc.)",
131 Type: funcapi.FieldTypeString,
132 Sort: funcapi.FieldSortAscending,
133 Sortable: true,
134 Summary: funcapi.FieldSummaryCount,
135 Filter: funcapi.FieldFilterMultiselect,
136 Visible: true,
137 },
138 Value: func(r *deadlockRowData) any { return r.lockMode },
139 },
140 {
141 ColumnMeta: funcapi.ColumnMeta{
142 Name: "lock_status",
143 Tooltip: "Whether the lock was granted or still waiting",
144 Type: funcapi.FieldTypeString,
145 Visualization: funcapi.FieldVisualPill,
146 Sort: funcapi.FieldSortAscending,
147 Sortable: true,
148 Summary: funcapi.FieldSummaryCount,
149 Filter: funcapi.FieldFilterMultiselect,
150 Visible: true,
151 },
152 Value: func(r *deadlockRowData) any { return r.lockStatus },
153 },
154 {
155 ColumnMeta: funcapi.ColumnMeta{
156 Name: "wait_resource",
157 Tooltip: "The resource this process was waiting to acquire",
158 Type: funcapi.FieldTypeString,
159 Sort: funcapi.FieldSortAscending,
160 Sortable: false,
161 Summary: funcapi.FieldSummaryCount,
162 Filter: funcapi.FieldFilterMultiselect,
163 FullWidth: true,
164 Wrap: true,
165 Visible: true,
166 },
167 Value: func(r *deadlockRowData) any { return r.waitResource },
168 },
169 {
170 ColumnMeta: funcapi.ColumnMeta{
171 Name: "spid",
172 Tooltip: "Server Process ID (SQL Server session ID)",
173 Type: funcapi.FieldTypeInteger,
174 Sort: funcapi.FieldSortAscending,
175 Sortable: true,
176 Summary: funcapi.FieldSummaryCount,
177 Filter: funcapi.FieldFilterRange,
178 Visible: true,
179 Transform: funcapi.FieldTransformNumber,
180 },
181 Value: func(r *deadlockRowData) any { return r.spid },
182 },
183 {
184 ColumnMeta: funcapi.ColumnMeta{
185 Name: "ecid",
186 Tooltip: "Execution Context ID for parallel query threads",
187 Type: funcapi.FieldTypeInteger,
188 Sort: funcapi.FieldSortAscending,
189 Sortable: true,
190 Summary: funcapi.FieldSummaryCount,
191 Filter: funcapi.FieldFilterRange,
192 Visible: true,
193 Transform: funcapi.FieldTransformNumber,
194 },
195 Value: func(r *deadlockRowData) any { return r.ecid },
196 },
197 {
198 ColumnMeta: funcapi.ColumnMeta{
199 Name: "process_id",
200 Tooltip: "Internal process identifier from the deadlock graph",
201 Type: funcapi.FieldTypeString,
202 Sort: funcapi.FieldSortAscending,
203 Sortable: true,
204 Summary: funcapi.FieldSummaryCount,
205 Filter: funcapi.FieldFilterMultiselect,
206 Visible: true,
207 },
208 Value: func(r *deadlockRowData) any { return r.processID },
209 },
210 {
211 ColumnMeta: funcapi.ColumnMeta{
212 Name: "deadlock_id",
213 Tooltip: "Unique identifier for this deadlock event",
214 Type: funcapi.FieldTypeString,
215 Sort: funcapi.FieldSortAscending,
216 Sortable: true,
217 Summary: funcapi.FieldSummaryCount,
218 Filter: funcapi.FieldFilterMultiselect,
219 Visible: true,
220 },
221 Value: func(r *deadlockRowData) any { return r.deadlockID },
222 },
223 }
224
225 func deadlockInfoMethodConfig() funcapi.MethodConfig {
226 return funcapi.MethodConfig{
227 ID: deadlockInfoMethodID,
228 Name: "Deadlock Info",
229 UpdateEvery: 10,
230 Help: deadlockInfoHelp,
231 RequireCloud: true,
232 RequiredParams: []funcapi.ParamConfig{},
233 }
234 }
235
236 // funcDeadlockInfo handles the deadlock-info function.
237 type funcDeadlockInfo struct {
238 router *funcRouter
239 }
240
241 func newFuncDeadlockInfo(r *funcRouter) *funcDeadlockInfo {
242 return &funcDeadlockInfo{router: r}
243 }
244
245 // Compile-time interface check.
246 var _ funcapi.MethodHandler = (*funcDeadlockInfo)(nil)
247
248 func (f *funcDeadlockInfo) MethodParams(ctx context.Context, method string) ([]funcapi.ParamConfig, error) {
249 if f.router.collector.Functions.DeadlockInfo.Disabled {
250 return nil, fmt.Errorf("deadlock-info function disabled in configuration")
251 }
252 return []funcapi.ParamConfig{}, nil
253 }
254
255 func (f *funcDeadlockInfo) Handle(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
256 if f.router.collector.db == nil {
257 db, err := f.router.collector.openConnection()
258 if err != nil {
259 return funcapi.UnavailableResponse("collector is still initializing, please retry in a few seconds")
260 }
261 f.router.collector.db = db
262 }
263 queryCtx, cancel := context.WithTimeout(ctx, f.router.collector.deadlockInfoTimeout())
264 defer cancel()
265 return f.collectData(queryCtx)
266 }
267
268 func (f *funcDeadlockInfo) Cleanup(ctx context.Context) {}
269
270 func (f *funcDeadlockInfo) collectData(ctx context.Context) *funcapi.FunctionResponse {
271 if f.router.collector.Functions.DeadlockInfo.Disabled {
272 return funcapi.UnavailableResponse("deadlock-info function has been disabled in configuration")
273 }
274
275 deadlockTime, deadlockXML, err := f.queryLatestDeadlock(ctx)
276 if err != nil {
277 if errors.Is(err, context.DeadlineExceeded) {
278 return f.buildResponse(504, "deadlock query timed out", nil)
279 }
280 if isDeadlockPermissionError(err) {
281 return f.buildResponse(403, deadlockPermissionMessage(), nil)
282 }
283 f.router.collector.Warningf("deadlock-info: query failed: %v", err)
284 return f.buildResponse(500, fmt.Sprintf("deadlock query failed: %v", err), nil)
285 }
286
287 if deadlockXML == "" {
288 return f.buildResponse(200, "no deadlock found in system_health Extended Events", nil)
289 }
290
291 dbNames, dbErr := f.queryDatabaseNames(ctx)
292 if dbErr != nil {
293 f.router.collector.Debugf("deadlock-info: database name mapping failed: %v", dbErr)
294 dbNames = map[int]string{}
295 }
296
297 parseRes := parseDeadlockGraph(deadlockXML, deadlockTime)
298 if parseRes.parseErr != nil {
299 f.router.collector.Warningf("deadlock-info: parse failed: %v", parseRes.parseErr)
300 return f.buildResponse(deadlockParseErrorStatus, "deadlock graph could not be parsed", nil)
301 }
302
303 if !parseRes.found {
304 return f.buildResponse(200, "no deadlock found in system_health Extended Events", nil)
305 }
306
307 deadlockID := generateDeadlockID(parseRes.deadlockTime)
308 rows := buildDeadlockRows(parseRes, deadlockID, dbNames)
309
310 if len(rows) == 0 {
311 return f.buildResponse(200, "deadlock detected but no processes could be parsed", nil)
312 }
313
314 return f.buildResponse(200, "latest detected deadlock", rows)
315 }
316
317 func (f *funcDeadlockInfo) buildResponse(status int, message string, rowsData []deadlockRowData) *funcapi.FunctionResponse {
318 data := make([][]any, 0, len(rowsData))
319 for i := range rowsData {
320 row := make([]any, len(deadlockColumns))
321 for j, col := range deadlockColumns {
322 row[j] = col.Value(&rowsData[i])
323 }
324 data = append(data, row)
325 }
326
327 cs := deadlockColumnSet(deadlockColumns)
328
329 return &funcapi.FunctionResponse{
330 Status: status,
331 Help: deadlockInfoHelp,
332 Message: message,
333 Columns: cs.BuildColumns(),
334 Data: data,
335 DefaultSortColumn: "timestamp",
336 }
337 }
338
339 func (f *funcDeadlockInfo) queryLatestDeadlock(ctx context.Context) (time.Time, string, error) {
340 qctx, cancel := context.WithTimeout(ctx, f.router.collector.Timeout.Duration())
341 defer cancel()
342
343 query := querySystemHealthLatestDeadlockEventFile
344 if f.router.collector.Functions.DeadlockInfo.UseRingBuffer {
345 query = querySystemHealthLatestDeadlockRingBuffer
346 }
347
348 var deadlockTime sql.NullTime
349 var deadlockXML sql.NullString
350 err := f.router.collector.db.QueryRowContext(qctx, query).Scan(&deadlockTime, &deadlockXML)
351 if err != nil {
352 if errors.Is(err, sql.ErrNoRows) {
353 return time.Time{}, "", nil
354 }
355 return time.Time{}, "", err
356 }
357
358 if !deadlockXML.Valid || strings.TrimSpace(deadlockXML.String) == "" {
359 return time.Time{}, "", nil
360 }
361
362 if deadlockTime.Valid {
363 return deadlockTime.Time, deadlockXML.String, nil
364 }
365 return time.Now().UTC(), deadlockXML.String, nil
366 }
367
368 func (f *funcDeadlockInfo) queryDatabaseNames(ctx context.Context) (map[int]string, error) {
369 qctx, cancel := context.WithTimeout(ctx, f.router.collector.Timeout.Duration())
370 defer cancel()
371
372 rows, err := f.router.collector.db.QueryContext(qctx, queryDatabaseNamesByID)
373 if err != nil {
374 return nil, err
375 }
376 defer rows.Close()
377
378 names := make(map[int]string)
379 for rows.Next() {
380 var id int
381 var name string
382 if err := rows.Scan(&id, &name); err != nil {
383 return nil, err
384 }
385 names[id] = name
386 }
387 if err := rows.Err(); err != nil {
388 return nil, err
389 }
390 return names, nil
391 }
392
393 type mssqlDeadlockTxn struct {
394 processID string
395 spid string
396 ecid string
397 dbid string
398 queryText string
399 lockMode string
400 lockStatus string
401 waitResource string
402 }
403
404 type mssqlDeadlockParseResult struct {
405 deadlockTime time.Time
406 transactions []*mssqlDeadlockTxn
407 victimProcessID string
408 parseErr error
409 found bool
410 }
411
412 type mssqlDeadlockGraph struct {
413 XMLName xml.Name `xml:"deadlock"`
414 VictimList mssqlDeadlockVictimList `xml:"victim-list"`
415 ProcessList mssqlDeadlockProcessList `xml:"process-list"`
416 ResourceList mssqlDeadlockResourceList `xml:"resource-list"`
417 }
418
419 type mssqlDeadlockResourceList struct {
420 Resources []mssqlDeadlockResource `xml:",any"`
421 }
422
423 type mssqlDeadlockVictimList struct {
424 Victims []mssqlDeadlockVictim `xml:"victimProcess"`
425 }
426
427 type mssqlDeadlockVictim struct {
428 ID string `xml:"id,attr"`
429 }
430
431 type mssqlDeadlockProcessList struct {
432 Processes []mssqlDeadlockProcess `xml:"process"`
433 }
434
435 type mssqlDeadlockProcess struct {
436 ID string `xml:"id,attr"`
437 SPID string `xml:"spid,attr"`
438 ECID string `xml:"ecid,attr"`
439 DBID string `xml:"dbid,attr"`
440 LockMode string `xml:"lockMode,attr"`
441 WaitResource string `xml:"waitresource,attr"`
442 InputBuf string `xml:"inputbuf"`
443 }
444
445 type mssqlDeadlockResource struct {
446 XMLName xml.Name
447 DBID string `xml:"dbid,attr"`
448 OwnerList mssqlDeadlockOwnerList `xml:"owner-list"`
449 WaiterList mssqlDeadlockWaiterList `xml:"waiter-list"`
450 }
451
452 type mssqlDeadlockOwnerList struct {
453 Owners []mssqlDeadlockResourceEntry `xml:"owner"`
454 }
455
456 type mssqlDeadlockWaiterList struct {
457 Waiters []mssqlDeadlockResourceEntry `xml:"waiter"`
458 }
459
460 type mssqlDeadlockResourceEntry struct {
461 ID string `xml:"id,attr"`
462 Mode string `xml:"mode,attr"`
463 }
464
465 func parseDeadlockGraph(deadlockXML string, deadlockTime time.Time) mssqlDeadlockParseResult {
466 now := time.Now().UTC()
467 result := mssqlDeadlockParseResult{
468 deadlockTime: now,
469 found: strings.TrimSpace(deadlockXML) != "",
470 }
471 if result.found && !deadlockTime.IsZero() {
472 result.deadlockTime = deadlockTime.UTC()
473 }
474
475 if !result.found {
476 return result
477 }
478
479 var graph mssqlDeadlockGraph
480 if err := xml.Unmarshal([]byte(deadlockXML), &graph); err != nil {
481 result.parseErr = fmt.Errorf("failed to parse deadlock XML: %w", err)
482 return result
483 }
484
485 if len(graph.VictimList.Victims) > 0 {
486 result.victimProcessID = strings.TrimSpace(graph.VictimList.Victims[0].ID)
487 }
488
489 txnByID := make(map[string]*mssqlDeadlockTxn)
490 ensureTxn := func(id string) *mssqlDeadlockTxn {
491 if id == "" {
492 return &mssqlDeadlockTxn{}
493 }
494 if txn, ok := txnByID[id]; ok {
495 return txn
496 }
497 txn := &mssqlDeadlockTxn{processID: id}
498 txnByID[id] = txn
499 return txn
500 }
501
502 for _, proc := range graph.ProcessList.Processes {
503 processID := strings.TrimSpace(proc.ID)
504 if processID == "" {
505 continue
506 }
507 txn := ensureTxn(processID)
508 txn.spid = strings.TrimSpace(proc.SPID)
509 txn.ecid = strings.TrimSpace(proc.ECID)
510 txn.dbid = strings.TrimSpace(proc.DBID)
511 txn.queryText = strings.TrimSpace(proc.InputBuf)
512 txn.lockMode = strings.TrimSpace(proc.LockMode)
513 txn.waitResource = strings.TrimSpace(proc.WaitResource)
514 if txn.waitResource != "" {
515 txn.lockStatus = "WAITING"
516 } else if txn.lockStatus == "" {
517 txn.lockStatus = "GRANTED"
518 }
519 }
520
521 for _, resource := range graph.ResourceList.Resources {
522 resourceDBID := strings.TrimSpace(resource.DBID)
523 for _, owner := range resource.OwnerList.Owners {
524 id := strings.TrimSpace(owner.ID)
525 if id == "" {
526 continue
527 }
528 txn := ensureTxn(id)
529 if txn.dbid == "" && resourceDBID != "" {
530 txn.dbid = resourceDBID
531 }
532 if txn.lockStatus != "WAITING" {
533 if txn.lockStatus == "" {
534 txn.lockStatus = "GRANTED"
535 }
536 mode := strings.TrimSpace(owner.Mode)
537 if mode != "" {
538 txn.lockMode = mode
539 }
540 }
541 }
542 for _, waiter := range resource.WaiterList.Waiters {
543 id := strings.TrimSpace(waiter.ID)
544 if id == "" {
545 continue
546 }
547 txn := ensureTxn(id)
548 if txn.dbid == "" && resourceDBID != "" {
549 txn.dbid = resourceDBID
550 }
551 txn.lockStatus = "WAITING"
552 mode := strings.TrimSpace(waiter.Mode)
553 if mode != "" {
554 txn.lockMode = mode
555 }
556 }
557 }
558
559 if len(txnByID) == 0 {
560 result.parseErr = fmt.Errorf("deadlock graph detected but no processes could be parsed")
561 return result
562 }
563
564 result.transactions = make([]*mssqlDeadlockTxn, 0, len(txnByID))
565 for _, txn := range txnByID {
566 if txn.processID == "" {
567 continue
568 }
569 if txn.lockStatus == "" {
570 if strings.TrimSpace(txn.waitResource) != "" {
571 txn.lockStatus = "WAITING"
572 } else {
573 txn.lockStatus = "GRANTED"
574 }
575 }
576 result.transactions = append(result.transactions, txn)
577 }
578
579 sort.Slice(result.transactions, func(i, j int) bool {
580 return result.transactions[i].processID < result.transactions[j].processID
581 })
582
583 if len(result.transactions) == 0 {
584 result.parseErr = fmt.Errorf("deadlock graph detected but no valid processes could be parsed")
585 }
586
587 return result
588 }
589
590 func buildDeadlockRows(parseRes mssqlDeadlockParseResult, deadlockID string, dbNames map[int]string) []deadlockRowData {
591 timestamp := parseRes.deadlockTime.UTC().Format(time.RFC3339Nano)
592 rows := make([]deadlockRowData, 0, len(parseRes.transactions))
593
594 for _, txn := range parseRes.transactions {
595 processID := strings.TrimSpace(txn.processID)
596 if processID == "" {
597 continue
598 }
599
600 spid := parseOptionalInt(txn.spid)
601 ecid := parseOptionalInt(txn.ecid)
602 dbidInt, hasDBID := parseIntString(txn.dbid)
603
604 var database any
605 if hasDBID {
606 if name, ok := dbNames[dbidInt]; ok {
607 database = name
608 }
609 }
610
611 isVictim := "false"
612 if parseRes.victimProcessID != "" && processID == parseRes.victimProcessID {
613 isVictim = "true"
614 }
615
616 queryText := strmutil.TruncateText(strings.TrimSpace(txn.queryText), topQueriesMaxTextLength)
617 lockMode := formatLockMode(strings.TrimSpace(txn.lockMode))
618 lockStatus := strings.TrimSpace(txn.lockStatus)
619 waitResource := strmutil.TruncateText(strings.TrimSpace(txn.waitResource), topQueriesMaxTextLength)
620
621 rows = append(rows, deadlockRowData{
622 rowID: fmt.Sprintf("%s:%s", deadlockID, processID),
623 deadlockID: deadlockID,
624 timestamp: timestamp,
625 processID: processID,
626 spid: spid,
627 ecid: ecid,
628 isVictim: isVictim,
629 queryText: queryText,
630 lockMode: lockMode,
631 lockStatus: lockStatus,
632 waitResource: waitResource,
633 database: database,
634 })
635 }
636
637 return rows
638 }
639
640 func parseIntString(s string) (int, bool) {
641 s = strings.TrimSpace(s)
642 if s == "" {
643 return 0, false
644 }
645 n, err := strconv.Atoi(s)
646 if err != nil {
647 return 0, false
648 }
649 return n, true
650 }
651
652 func parseOptionalInt(s string) any {
653 if n, ok := parseIntString(s); ok {
654 return n
655 }
656 return nil
657 }
658
659 func generateDeadlockID(t time.Time) string {
660 if t.IsZero() {
661 t = time.Now().UTC()
662 }
663 t = t.UTC()
664 micros := t.Nanosecond() / 1000
665 return t.Format("20060102150405") + fmt.Sprintf("%06d", micros)
666 }
667
668 func isDeadlockPermissionError(err error) bool {
669 var sqlErr mssqlDriver.Error
670 if errors.As(err, &sqlErr) {
671 if sqlErr.Number == 297 || sqlErr.Number == 229 {
672 return true
673 }
674 if permissionMessage := strings.ToLower(sqlErr.Message); permissionMessage != "" {
675 if strings.Contains(permissionMessage, "view server state") ||
676 strings.Contains(permissionMessage, "permission") ||
677 strings.Contains(permissionMessage, "denied") {
678 return true
679 }
680 }
681 }
682
683 msg := strings.ToLower(err.Error())
684 return strings.Contains(msg, "view server state") ||
685 strings.Contains(msg, "permission") ||
686 strings.Contains(msg, "denied")
687 }
688
689 func deadlockPermissionMessage() string {
690 return "deadlock info requires VIEW SERVER STATE permission. Grant with: GRANT VIEW SERVER STATE TO [netdata_user];"
691 }
692
693 func formatLockMode(mode string) string {
694 names := map[string]string{
695 "X": "Exclusive",
696 "S": "Shared",
697 "U": "Update",
698 "IX": "Intent Exclusive",
699 "IS": "Intent Shared",
700 "SIX": "Shared Intent Exclusive",
701 "Sch-S": "Schema Stability",
702 "Sch-M": "Schema Modification",
703 "BU": "Bulk Update",
704 }
705 if name, ok := names[mode]; ok {
706 return fmt.Sprintf("%s (%s)", name, mode)
707 }
708 return mode
709 }