github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/dashboard/app/reporting_lists.go (about)

     1  // Copyright 2023 syzkaller project authors. All rights reserved.
     2  // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
     3  
     4  package main
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"fmt"
    10  	"net/http"
    11  	"sort"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/google/syzkaller/dashboard/dashapi"
    16  	"github.com/google/syzkaller/pkg/hash"
    17  	"google.golang.org/appengine/v2"
    18  	db "google.golang.org/appengine/v2/datastore"
    19  	"google.golang.org/appengine/v2/log"
    20  )
    21  
    22  // reportingPollBugLists is called by backends to get bug lists that need to be reported.
    23  func reportingPollBugLists(c context.Context, typ string) []*dashapi.BugListReport {
    24  	state, err := loadReportingState(c)
    25  	if err != nil {
    26  		log.Errorf(c, "%v", err)
    27  		return nil
    28  	}
    29  	registry, err := makeSubsystemReportRegistry(c)
    30  	if err != nil {
    31  		log.Errorf(c, "%v", err)
    32  		return nil
    33  	}
    34  	ret := []*dashapi.BugListReport{}
    35  	for ns, nsConfig := range getConfig(c).Namespaces {
    36  		rConfig := nsConfig.Subsystems.Reminder
    37  		if rConfig == nil {
    38  			continue
    39  		}
    40  		reporting := nsConfig.ReportingByName(rConfig.SourceReporting)
    41  		stateEntry := state.getEntry(timeNow(c), ns, reporting.Name)
    42  		// The DB might well contain info about stale entities, but by querying the latest
    43  		// list of subsystems from the configuration, we make sure we only consider what's
    44  		// currently relevant.
    45  		rawSubsystems := nsConfig.Subsystems.Service.List()
    46  		// Sort to keep output stable.
    47  		sort.Slice(rawSubsystems, func(i, j int) bool {
    48  			return rawSubsystems[i].Name < rawSubsystems[j].Name
    49  		})
    50  		for _, entry := range rawSubsystems {
    51  			if entry.NoReminders {
    52  				continue
    53  			}
    54  			for _, dbReport := range registry.get(ns, entry.Name) {
    55  				if stateEntry.Sent >= reporting.DailyLimit {
    56  					break
    57  				}
    58  				report, err := reportingBugListReport(c, dbReport, ns, entry.Name, typ)
    59  				if err != nil {
    60  					log.Errorf(c, "%v", err)
    61  					return nil
    62  				}
    63  				if report != nil {
    64  					ret = append(ret, report)
    65  					stateEntry.Sent++
    66  				}
    67  			}
    68  		}
    69  	}
    70  	return ret
    71  }
    72  
    73  const maxNewListsPerNs = 5
    74  
    75  // handleSubsystemReports is periodically invoked to construct fresh SubsystemReport objects.
    76  func handleSubsystemReports(w http.ResponseWriter, r *http.Request) {
    77  	c := appengine.NewContext(r)
    78  	registry, err := makeSubsystemRegistry(c)
    79  	if err != nil {
    80  		log.Errorf(c, "failed to load subsystems: %v", err)
    81  		return
    82  	}
    83  	for ns, nsConfig := range getConfig(c).Namespaces {
    84  		rConfig := nsConfig.Subsystems.Reminder
    85  		if rConfig == nil {
    86  			continue
    87  		}
    88  		minPeriod := 24 * time.Hour * time.Duration(rConfig.PeriodDays)
    89  		reporting := nsConfig.ReportingByName(rConfig.SourceReporting)
    90  		var subsystems []*Subsystem
    91  		for _, entry := range nsConfig.Subsystems.Service.List() {
    92  			if entry.NoReminders {
    93  				continue
    94  			}
    95  			subsystems = append(subsystems, registry.get(ns, entry.Name))
    96  		}
    97  		// Poll subsystems in a round-robin manner.
    98  		sort.Slice(subsystems, func(i, j int) bool {
    99  			return subsystems[i].ListsQueried.Before(subsystems[j].ListsQueried)
   100  		})
   101  		updateLimit := maxNewListsPerNs
   102  		for _, subsystem := range subsystems {
   103  			if updateLimit == 0 {
   104  				break
   105  			}
   106  			if timeNow(c).Before(subsystem.LastBugList.Add(minPeriod)) {
   107  				continue
   108  			}
   109  			report, err := querySubsystemReport(c, subsystem, reporting, rConfig)
   110  			if err != nil {
   111  				log.Errorf(c, "failed to query bug lists: %v", err)
   112  				return
   113  			}
   114  			if err := registry.updatePoll(c, subsystem, report != nil); err != nil {
   115  				log.Errorf(c, "failed to update subsystem: %v", err)
   116  				return
   117  			}
   118  			if report == nil {
   119  				continue
   120  			}
   121  			updateLimit--
   122  			if err := storeSubsystemReport(c, subsystem, report); err != nil {
   123  				log.Errorf(c, "failed to save subsystem: %v", err)
   124  				return
   125  			}
   126  		}
   127  	}
   128  }
   129  
   130  func reportingBugListCommand(c context.Context, cmd *dashapi.BugListUpdate) (string, error) {
   131  	// We have to execute it outside of the transacation, otherwise we get the
   132  	// "Only ancestor queries are allowed inside transactions." error.
   133  	subsystem, rawReport, _, err := findSubsystemReportByID(c, cmd.ID)
   134  	if err != nil {
   135  		return "", err
   136  	}
   137  	if subsystem == nil {
   138  		return "", fmt.Errorf("the bug list was not found")
   139  	}
   140  	reply := ""
   141  	tx := func(c context.Context) error {
   142  		subsystemKey := subsystemKey(c, subsystem)
   143  		reportKey := subsystemReportKey(c, subsystemKey, rawReport)
   144  		report := new(SubsystemReport)
   145  		if err := db.Get(c, reportKey, report); err != nil {
   146  			return fmt.Errorf("failed to query SubsystemReport (%v): %w", reportKey, err)
   147  		}
   148  		stage := report.findStage(cmd.ID)
   149  		if stage.ExtID == "" {
   150  			stage.ExtID = cmd.ExtID
   151  		}
   152  		if stage.Link == "" {
   153  			stage.Link = cmd.Link
   154  		}
   155  		// It might e.g. happen that we skipped a stage in reportingBugListReport.
   156  		// Make sure all skipped stages have non-nil Closed.
   157  		for i := range report.Stages {
   158  			item := &report.Stages[i]
   159  			if cmd.Command != dashapi.BugListRegenerateCmd && item == stage {
   160  				break
   161  			}
   162  			item.Closed = timeNow(c)
   163  		}
   164  		switch cmd.Command {
   165  		case dashapi.BugListSentCmd:
   166  			if !stage.Reported.IsZero() {
   167  				return fmt.Errorf("the reporting stage was already reported")
   168  			}
   169  			stage.Reported = timeNow(c)
   170  
   171  			state, err := loadReportingState(c)
   172  			if err != nil {
   173  				return fmt.Errorf("failed to query state: %w", err)
   174  			}
   175  			stateEnt := state.getEntry(timeNow(c), subsystem.Namespace,
   176  				getConfig(c).Namespaces[subsystem.Namespace].Subsystems.Reminder.SourceReporting)
   177  			stateEnt.Sent++
   178  			if err := saveReportingState(c, state); err != nil {
   179  				return fmt.Errorf("failed to save state: %w", err)
   180  			}
   181  		case dashapi.BugListUpstreamCmd:
   182  			if !stage.Moderation {
   183  				reply = `The report cannot be sent further upstream.
   184  It's already at the last reporting stage.`
   185  				return nil
   186  			}
   187  			if !stage.Closed.IsZero() {
   188  				reply = `The bug list was already upstreamed.
   189  Please visit the new discussion thread.`
   190  				return nil
   191  			}
   192  			stage.Closed = timeNow(c)
   193  		case dashapi.BugListRegenerateCmd:
   194  			dbSubsystem := new(Subsystem)
   195  			err := db.Get(c, subsystemKey, dbSubsystem)
   196  			if err != nil {
   197  				return fmt.Errorf("failed to get subsystem: %w", err)
   198  			}
   199  			dbSubsystem.LastBugList = time.Time{}
   200  			_, err = db.Put(c, subsystemKey, dbSubsystem)
   201  			if err != nil {
   202  				return fmt.Errorf("failed to save subsystem: %w", err)
   203  			}
   204  		}
   205  		_, err = db.Put(c, reportKey, report)
   206  		if err != nil {
   207  			return fmt.Errorf("failed to save the object: %w", err)
   208  		}
   209  		return nil
   210  	}
   211  	return reply, runInTransaction(c, tx, &db.TransactionOptions{XG: true})
   212  }
   213  
   214  func findSubsystemReportByID(c context.Context, ID string) (*Subsystem,
   215  	*SubsystemReport, *SubsystemReportStage, error) {
   216  	var subsystemReports []*SubsystemReport
   217  	reportKeys, err := db.NewQuery("SubsystemReport").
   218  		Filter("Stages.ID=", ID).
   219  		Limit(1).
   220  		GetAll(c, &subsystemReports)
   221  	if err != nil {
   222  		return nil, nil, nil, fmt.Errorf("failed to query subsystem reports: %w", err)
   223  	}
   224  	if len(subsystemReports) == 0 {
   225  		return nil, nil, nil, nil
   226  	}
   227  	stage := subsystemReports[0].findStage(ID)
   228  	if stage == nil {
   229  		// This should never happen (provided that all the code is correct).
   230  		return nil, nil, nil, fmt.Errorf("bug list is found, but the stage is missing")
   231  	}
   232  	subsystem := new(Subsystem)
   233  	if err := db.Get(c, reportKeys[0].Parent(), subsystem); err != nil {
   234  		return nil, nil, nil, fmt.Errorf("failed to query subsystem: %w", err)
   235  	}
   236  	return subsystem, subsystemReports[0], stage, nil
   237  }
   238  
   239  // querySubsystemReport queries the open bugs and constructs a new SubsystemReport object.
   240  func querySubsystemReport(c context.Context, subsystem *Subsystem, reporting *Reporting,
   241  	config *BugListReportingConfig) (*SubsystemReport, error) {
   242  	rawOpenBugs, fixedBugs, err := queryMatchingBugs(c, subsystem.Namespace,
   243  		subsystem.Name, reporting)
   244  	if err != nil {
   245  		return nil, err
   246  	}
   247  	withRepro, noRepro := []*Bug{}, []*Bug{}
   248  	for _, bug := range rawOpenBugs {
   249  		const possiblyFixedTimespan = 24 * time.Hour * 14
   250  		if bug.LastTime.Before(timeNow(c).Add(-possiblyFixedTimespan)) {
   251  			// The bug didn't happen recently, possibly it was already fixed.
   252  			// Let's not display such bugs in reminders.
   253  			continue
   254  		}
   255  		if bug.FirstTime.After(timeNow(c).Add(-config.MinBugAge)) {
   256  			// Don't take bugs which are too new -- they're still fresh in memory.
   257  			continue
   258  		}
   259  		if bug.prio() == LowPrioBug {
   260  			// Don't include low priority bugs in reports because the community
   261  			// actually perceives them as non-actionable.
   262  			continue
   263  		}
   264  		discussions := bug.discussionSummary()
   265  		if discussions.ExternalMessages > 0 &&
   266  			discussions.LastMessage.After(timeNow(c).Add(-config.UserReplyFrist)) {
   267  			// Don't take bugs with recent user replies.
   268  			// As we don't keep exactly the date of the last user message, approximate it.
   269  			continue
   270  		}
   271  		if bug.HasLabel(NoRemindersLabel, "") {
   272  			// The bug was intentionally excluded from monthly reminders.
   273  			continue
   274  		}
   275  		if bug.ReproLevel == dashapi.ReproLevelNone {
   276  			noRepro = append(noRepro, bug)
   277  		} else {
   278  			withRepro = append(withRepro, bug)
   279  		}
   280  	}
   281  	// Let's reduce noise and don't remind about just one bug.
   282  	if len(noRepro)+len(withRepro) < config.MinBugsCount {
   283  		return nil, nil
   284  	}
   285  	// Even if we have enough bugs with a reproducer, there might still be bugs
   286  	// without a reproducer that have a lot of crashes. So let's take a small number
   287  	// of such bugs and give them a chance to be present in the final list.
   288  	takeNoRepro := 2
   289  	if takeNoRepro+len(withRepro) < config.BugsInReport {
   290  		takeNoRepro = config.BugsInReport - len(withRepro)
   291  	}
   292  	takeNoRepro = min(takeNoRepro, len(noRepro))
   293  	sort.Slice(noRepro, func(i, j int) bool {
   294  		return noRepro[i].NumCrashes > noRepro[j].NumCrashes
   295  	})
   296  	takeBugs := append(withRepro, noRepro[:takeNoRepro]...)
   297  	sort.Slice(takeBugs, func(i, j int) bool {
   298  		firstPrio, secondPrio := takeBugs[i].prio(), takeBugs[j].prio()
   299  		if firstPrio != secondPrio {
   300  			return !firstPrio.LessThan(secondPrio)
   301  		}
   302  		if takeBugs[i].NumCrashes != takeBugs[j].NumCrashes {
   303  			return takeBugs[i].NumCrashes > takeBugs[j].NumCrashes
   304  		}
   305  		return takeBugs[i].Title < takeBugs[j].Title
   306  	})
   307  	keys := []*db.Key{}
   308  	for _, bug := range takeBugs {
   309  		keys = append(keys, bug.key(c))
   310  	}
   311  	if len(keys) > config.BugsInReport {
   312  		keys = keys[:config.BugsInReport]
   313  	}
   314  	report := makeSubsystemReport(c, config, keys)
   315  	report.TotalStats = makeSubsystemReportStats(c, rawOpenBugs, fixedBugs, 0)
   316  	report.PeriodStats = makeSubsystemReportStats(c, rawOpenBugs, fixedBugs, config.PeriodDays)
   317  	return report, nil
   318  }
   319  
   320  func makeSubsystemReportStats(c context.Context, open, fixed []*Bug, days int) SubsystemReportStats {
   321  	after := timeNow(c).Add(-time.Hour * 24 * time.Duration(days))
   322  	ret := SubsystemReportStats{}
   323  	for _, bug := range open {
   324  		if days > 0 && bug.FirstTime.Before(after) {
   325  			continue
   326  		}
   327  		if bug.prio() == LowPrioBug {
   328  			ret.LowPrio++
   329  		} else {
   330  			ret.Reported++
   331  		}
   332  	}
   333  	for _, bug := range fixed {
   334  		if len(bug.CommitInfo) == 0 {
   335  			continue
   336  		}
   337  		if days > 0 && bug.CommitInfo[0].Date.Before(after) {
   338  			continue
   339  		}
   340  		ret.Fixed++
   341  	}
   342  	return ret
   343  }
   344  
   345  func queryMatchingBugs(c context.Context, ns, name string, reporting *Reporting) ([]*Bug, []*Bug, error) {
   346  	allOpenBugs, _, err := loadAllBugs(c, func(query *db.Query) *db.Query {
   347  		return query.Filter("Namespace=", ns).
   348  			Filter("Status=", BugStatusOpen).
   349  			Filter("Labels.Label=", SubsystemLabel).
   350  			Filter("Labels.Value=", name)
   351  	})
   352  	if err != nil {
   353  		return nil, nil, fmt.Errorf("failed to query open bugs for subsystem: %w", err)
   354  	}
   355  	allFixedBugs, _, err := loadAllBugs(c, func(query *db.Query) *db.Query {
   356  		return query.Filter("Namespace=", ns).
   357  			Filter("Status=", BugStatusFixed).
   358  			Filter("Labels.Label=", SubsystemLabel).
   359  			Filter("Labels.Value=", name)
   360  	})
   361  	if err != nil {
   362  		return nil, nil, fmt.Errorf("failed to query fixed bugs for subsystem: %w", err)
   363  	}
   364  	open, fixed := []*Bug{}, []*Bug{}
   365  	for _, bug := range append(allOpenBugs, allFixedBugs...) {
   366  		if len(bug.Commits) != 0 || bug.Status == BugStatusFixed {
   367  			// This bug is no longer really open.
   368  			fixed = append(fixed, bug)
   369  			continue
   370  		}
   371  		currReporting, _, _, _, err := currentReporting(c, bug)
   372  		if err != nil {
   373  			continue
   374  		}
   375  		if reporting.Name != currReporting.Name {
   376  			// The bug is not at the expected reporting stage.
   377  			continue
   378  		}
   379  		if currReporting.AccessLevel > reporting.AccessLevel {
   380  			continue
   381  		}
   382  		open = append(open, bug)
   383  	}
   384  	return open, fixed, nil
   385  }
   386  
   387  // makeSubsystemReport creates a new SubsystemReminder object.
   388  func makeSubsystemReport(c context.Context, config *BugListReportingConfig,
   389  	keys []*db.Key) *SubsystemReport {
   390  	ret := &SubsystemReport{
   391  		Created: timeNow(c),
   392  	}
   393  	for _, key := range keys {
   394  		ret.BugKeys = append(ret.BugKeys, key.Encode())
   395  	}
   396  	baseID := hash.String([]byte(fmt.Sprintf("%v-%v", timeNow(c), ret.BugKeys)))
   397  	if config.ModerationConfig != nil {
   398  		ret.Stages = append(ret.Stages, SubsystemReportStage{
   399  			ID:         bugListReportingHash(baseID, "moderation"),
   400  			Moderation: true,
   401  		})
   402  	}
   403  	ret.Stages = append(ret.Stages, SubsystemReportStage{
   404  		ID: bugListReportingHash(baseID, "public"),
   405  	})
   406  	return ret
   407  }
   408  
   409  const bugListHashPrefix = "list"
   410  
   411  func bugListReportingHash(base, name string) string {
   412  	return bugListHashPrefix + bugReportingHash(base, name)
   413  }
   414  
   415  func isBugListHash(hash string) bool {
   416  	return strings.HasPrefix(hash, bugListHashPrefix)
   417  }
   418  
   419  func reportingBugListReport(c context.Context, subsystemReport *SubsystemReport,
   420  	ns, name, targetReportingType string) (*dashapi.BugListReport, error) {
   421  	for _, stage := range subsystemReport.Stages {
   422  		if !stage.Closed.IsZero() {
   423  			continue
   424  		}
   425  		repConfig := bugListReportingConfig(c, ns, &stage)
   426  		if repConfig == nil {
   427  			// It might happen if e.g. Moderation was set to nil.
   428  			// Just skip the stage then.
   429  			continue
   430  		}
   431  		if !stage.Reported.IsZero() || repConfig.Type() != targetReportingType {
   432  			break
   433  		}
   434  		configJSON, err := json.Marshal(repConfig)
   435  		if err != nil {
   436  			return nil, err
   437  		}
   438  		ret := &dashapi.BugListReport{
   439  			ID:          stage.ID,
   440  			Created:     subsystemReport.Created,
   441  			Config:      configJSON,
   442  			Link:        fmt.Sprintf("%v/%s/s/%s", appURL(c), ns, name),
   443  			Subsystem:   name,
   444  			Maintainers: subsystemMaintainers(c, ns, name),
   445  			Moderation:  stage.Moderation,
   446  			TotalStats:  subsystemReport.TotalStats.toDashapi(),
   447  			PeriodStats: subsystemReport.PeriodStats.toDashapi(),
   448  			PeriodDays:  getNsConfig(c, ns).Subsystems.Reminder.PeriodDays,
   449  		}
   450  		bugKeys, err := subsystemReport.getBugKeys()
   451  		if err != nil {
   452  			return nil, fmt.Errorf("failed to get bug keys: %w", err)
   453  		}
   454  		bugs := make([]*Bug, len(bugKeys))
   455  		err = db.GetMulti(c, bugKeys, bugs)
   456  		if err != nil {
   457  			return nil, fmt.Errorf("failed to get bugs: %w", err)
   458  		}
   459  		for _, bug := range bugs {
   460  			bugReporting := bugReportingByName(bug,
   461  				getNsConfig(c, ns).Subsystems.Reminder.SourceReporting)
   462  			ret.Bugs = append(ret.Bugs, dashapi.BugListItem{
   463  				Title:      bug.displayTitle(),
   464  				Link:       fmt.Sprintf("%v/bug?extid=%v", appURL(c), bugReporting.ID),
   465  				ReproLevel: bug.ReproLevel,
   466  				Hits:       bug.NumCrashes,
   467  			})
   468  		}
   469  		return ret, nil
   470  	}
   471  	return nil, nil
   472  }
   473  
   474  func bugListReportingConfig(c context.Context, ns string, stage *SubsystemReportStage) ReportingType {
   475  	cfg := getNsConfig(c, ns).Subsystems.Reminder
   476  	if stage.Moderation {
   477  		return cfg.ModerationConfig
   478  	}
   479  	return cfg.Config
   480  }
   481  
   482  func makeSubsystem(ns, name string) *Subsystem {
   483  	return &Subsystem{
   484  		Namespace: ns,
   485  		Name:      name,
   486  	}
   487  }
   488  
   489  func subsystemKey(c context.Context, s *Subsystem) *db.Key {
   490  	return db.NewKey(c, "Subsystem", fmt.Sprintf("%v-%v", s.Namespace, s.Name), 0, nil)
   491  }
   492  
   493  func subsystemReportKey(c context.Context, subsystemKey *db.Key, r *SubsystemReport) *db.Key {
   494  	return db.NewKey(c, "SubsystemReport", r.Created.UTC().Format(time.RFC822), 0, subsystemKey)
   495  }
   496  
   497  type subsystemsRegistry struct {
   498  	entities map[string]map[string]*Subsystem
   499  }
   500  
   501  func makeSubsystemRegistry(c context.Context) (*subsystemsRegistry, error) {
   502  	var subsystems []*Subsystem
   503  	if _, err := db.NewQuery("Subsystem").GetAll(c, &subsystems); err != nil {
   504  		return nil, err
   505  	}
   506  	ret := &subsystemsRegistry{
   507  		entities: map[string]map[string]*Subsystem{},
   508  	}
   509  	for _, item := range subsystems {
   510  		ret.store(item)
   511  	}
   512  	return ret, nil
   513  }
   514  
   515  func (sr *subsystemsRegistry) get(ns, name string) *Subsystem {
   516  	ret := sr.entities[ns][name]
   517  	if ret == nil {
   518  		ret = makeSubsystem(ns, name)
   519  	}
   520  	return ret
   521  }
   522  
   523  func (sr *subsystemsRegistry) store(item *Subsystem) {
   524  	if sr.entities[item.Namespace] == nil {
   525  		sr.entities[item.Namespace] = map[string]*Subsystem{}
   526  	}
   527  	sr.entities[item.Namespace][item.Name] = item
   528  }
   529  
   530  func (sr *subsystemsRegistry) updatePoll(c context.Context, s *Subsystem, success bool) error {
   531  	key := subsystemKey(c, s)
   532  	return runInTransaction(c, func(c context.Context) error {
   533  		dbSubsystem := new(Subsystem)
   534  		err := db.Get(c, key, dbSubsystem)
   535  		if err == db.ErrNoSuchEntity {
   536  			dbSubsystem = s
   537  		} else if err != nil {
   538  			return fmt.Errorf("failed to get Subsystem '%v': %w", key, err)
   539  		}
   540  		dbSubsystem.ListsQueried = timeNow(c)
   541  		if success {
   542  			dbSubsystem.LastBugList = timeNow(c)
   543  		}
   544  		if _, err := db.Put(c, key, dbSubsystem); err != nil {
   545  			return fmt.Errorf("failed to save Subsystem: %w", err)
   546  		}
   547  		sr.store(dbSubsystem)
   548  		return nil
   549  	}, nil)
   550  }
   551  
   552  type subsystemReportRegistry struct {
   553  	entities map[string]map[string][]*SubsystemReport
   554  }
   555  
   556  func makeSubsystemReportRegistry(c context.Context) (*subsystemReportRegistry, error) {
   557  	var reports []*SubsystemReport
   558  	reportKeys, err := db.NewQuery("SubsystemReport").GetAll(c, &reports)
   559  	if err != nil {
   560  		return nil, err
   561  	}
   562  	ret := &subsystemReportRegistry{
   563  		entities: map[string]map[string][]*SubsystemReport{},
   564  	}
   565  	loader := &dependencyLoader[Subsystem]{}
   566  	for i, key := range reportKeys {
   567  		report := reports[i]
   568  		loader.add(key.Parent(), func(subsystem *Subsystem) {
   569  			ret.store(subsystem.Namespace, subsystem.Name, report)
   570  		})
   571  	}
   572  	if err := loader.load(c); err != nil {
   573  		return nil, err
   574  	}
   575  	return ret, nil
   576  }
   577  
   578  func (srr *subsystemReportRegistry) get(ns, name string) []*SubsystemReport {
   579  	return srr.entities[ns][name]
   580  }
   581  
   582  func (srr *subsystemReportRegistry) store(ns, name string, item *SubsystemReport) {
   583  	if srr.entities[ns] == nil {
   584  		srr.entities[ns] = map[string][]*SubsystemReport{}
   585  	}
   586  	srr.entities[ns][name] = append(srr.entities[ns][name], item)
   587  }
   588  
   589  func storeSubsystemReport(c context.Context, s *Subsystem, report *SubsystemReport) error {
   590  	key := subsystemKey(c, s)
   591  	return runInTransaction(c, func(c context.Context) error {
   592  		// First close all previouly active per-subsystem reports.
   593  		var previous []*SubsystemReport
   594  		prevKeys, err := db.NewQuery("SubsystemReport").
   595  			Ancestor(key).
   596  			Filter("Stages.Closed=", time.Time{}).
   597  			GetAll(c, &previous)
   598  		if err != nil {
   599  			return fmt.Errorf("failed to query old subsystem reports: %w", err)
   600  		}
   601  		for i, subsystem := range previous {
   602  			for i := range subsystem.Stages {
   603  				subsystem.Stages[i].Closed = timeNow(c)
   604  			}
   605  			if _, err := db.Put(c, prevKeys[i], subsystem); err != nil {
   606  				return fmt.Errorf("failed to save SubsystemReport: %w", err)
   607  			}
   608  		}
   609  		// Now save a new one.
   610  		reportKey := subsystemReportKey(c, key, report)
   611  		if _, err := db.Put(c, reportKey, report); err != nil {
   612  			return fmt.Errorf("failed to store new SubsystemReport: %w", err)
   613  		}
   614  		return nil
   615  	}, nil)
   616  }