github.com/google/syzkaller@v0.0.0-20240517125934-c0f1611a36d6/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, db.RunInTransaction(c, tx, &db.TransactionOptions{
   212  		XG:       true,
   213  		Attempts: 10,
   214  	})
   215  }
   216  
   217  func findSubsystemReportByID(c context.Context, ID string) (*Subsystem,
   218  	*SubsystemReport, *SubsystemReportStage, error) {
   219  	var subsystemReports []*SubsystemReport
   220  	reportKeys, err := db.NewQuery("SubsystemReport").
   221  		Filter("Stages.ID=", ID).
   222  		Limit(1).
   223  		GetAll(c, &subsystemReports)
   224  	if err != nil {
   225  		return nil, nil, nil, fmt.Errorf("failed to query subsystem reports: %w", err)
   226  	}
   227  	if len(subsystemReports) == 0 {
   228  		return nil, nil, nil, nil
   229  	}
   230  	stage := subsystemReports[0].findStage(ID)
   231  	if stage == nil {
   232  		// This should never happen (provided that all the code is correct).
   233  		return nil, nil, nil, fmt.Errorf("bug list is found, but the stage is missing")
   234  	}
   235  	subsystem := new(Subsystem)
   236  	if err := db.Get(c, reportKeys[0].Parent(), subsystem); err != nil {
   237  		return nil, nil, nil, fmt.Errorf("failed to query subsystem: %w", err)
   238  	}
   239  	return subsystem, subsystemReports[0], stage, nil
   240  }
   241  
   242  // querySubsystemReport queries the open bugs and constructs a new SubsystemReport object.
   243  func querySubsystemReport(c context.Context, subsystem *Subsystem, reporting *Reporting,
   244  	config *BugListReportingConfig) (*SubsystemReport, error) {
   245  	rawOpenBugs, fixedBugs, err := queryMatchingBugs(c, subsystem.Namespace,
   246  		subsystem.Name, reporting)
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  	withRepro, noRepro := []*Bug{}, []*Bug{}
   251  	for _, bug := range rawOpenBugs {
   252  		const possiblyFixedTimespan = 24 * time.Hour * 14
   253  		if bug.LastTime.Before(timeNow(c).Add(-possiblyFixedTimespan)) {
   254  			// The bug didn't happen recently, possibly it was already fixed.
   255  			// Let's not display such bugs in reminders.
   256  			continue
   257  		}
   258  		if bug.FirstTime.After(timeNow(c).Add(-config.MinBugAge)) {
   259  			// Don't take bugs which are too new -- they're still fresh in memory.
   260  			continue
   261  		}
   262  		if bug.prio() == LowPrioBug {
   263  			// Don't include low priority bugs in reports because the community
   264  			// actually perceives them as non-actionable.
   265  			continue
   266  		}
   267  		discussions := bug.discussionSummary()
   268  		if discussions.ExternalMessages > 0 &&
   269  			discussions.LastMessage.After(timeNow(c).Add(-config.UserReplyFrist)) {
   270  			// Don't take bugs with recent user replies.
   271  			// As we don't keep exactly the date of the last user message, approximate it.
   272  			continue
   273  		}
   274  		if bug.HasLabel(NoRemindersLabel, "") {
   275  			// The bug was intentionally excluded from monthly reminders.
   276  			continue
   277  		}
   278  		if bug.ReproLevel == dashapi.ReproLevelNone {
   279  			noRepro = append(noRepro, bug)
   280  		} else {
   281  			withRepro = append(withRepro, bug)
   282  		}
   283  	}
   284  	// Let's reduce noise and don't remind about just one bug.
   285  	if len(noRepro)+len(withRepro) < config.MinBugsCount {
   286  		return nil, nil
   287  	}
   288  	// Even if we have enough bugs with a reproducer, there might still be bugs
   289  	// without a reproducer that have a lot of crashes. So let's take a small number
   290  	// of such bugs and give them a chance to be present in the final list.
   291  	takeNoRepro := 2
   292  	if takeNoRepro+len(withRepro) < config.BugsInReport {
   293  		takeNoRepro = config.BugsInReport - len(withRepro)
   294  	}
   295  	if takeNoRepro > len(noRepro) {
   296  		takeNoRepro = len(noRepro)
   297  	}
   298  	sort.Slice(noRepro, func(i, j int) bool {
   299  		return noRepro[i].NumCrashes > noRepro[j].NumCrashes
   300  	})
   301  	takeBugs := append(withRepro, noRepro[:takeNoRepro]...)
   302  	sort.Slice(takeBugs, func(i, j int) bool {
   303  		firstPrio, secondPrio := takeBugs[i].prio(), takeBugs[j].prio()
   304  		if firstPrio != secondPrio {
   305  			return !firstPrio.LessThan(secondPrio)
   306  		}
   307  		if takeBugs[i].NumCrashes != takeBugs[j].NumCrashes {
   308  			return takeBugs[i].NumCrashes > takeBugs[j].NumCrashes
   309  		}
   310  		return takeBugs[i].Title < takeBugs[j].Title
   311  	})
   312  	keys := []*db.Key{}
   313  	for _, bug := range takeBugs {
   314  		keys = append(keys, bug.key(c))
   315  	}
   316  	if len(keys) > config.BugsInReport {
   317  		keys = keys[:config.BugsInReport]
   318  	}
   319  	report := makeSubsystemReport(c, config, keys)
   320  	report.TotalStats = makeSubsystemReportStats(c, rawOpenBugs, fixedBugs, 0)
   321  	report.PeriodStats = makeSubsystemReportStats(c, rawOpenBugs, fixedBugs, config.PeriodDays)
   322  	return report, nil
   323  }
   324  
   325  func makeSubsystemReportStats(c context.Context, open, fixed []*Bug, days int) SubsystemReportStats {
   326  	after := timeNow(c).Add(-time.Hour * 24 * time.Duration(days))
   327  	ret := SubsystemReportStats{}
   328  	for _, bug := range open {
   329  		if days > 0 && bug.FirstTime.Before(after) {
   330  			continue
   331  		}
   332  		if bug.prio() == LowPrioBug {
   333  			ret.LowPrio++
   334  		} else {
   335  			ret.Reported++
   336  		}
   337  	}
   338  	for _, bug := range fixed {
   339  		if len(bug.CommitInfo) == 0 {
   340  			continue
   341  		}
   342  		if days > 0 && bug.CommitInfo[0].Date.Before(after) {
   343  			continue
   344  		}
   345  		ret.Fixed++
   346  	}
   347  	return ret
   348  }
   349  
   350  func queryMatchingBugs(c context.Context, ns, name string, reporting *Reporting) ([]*Bug, []*Bug, error) {
   351  	allOpenBugs, _, err := loadAllBugs(c, func(query *db.Query) *db.Query {
   352  		return query.Filter("Namespace=", ns).
   353  			Filter("Status=", BugStatusOpen).
   354  			Filter("Labels.Label=", SubsystemLabel).
   355  			Filter("Labels.Value=", name)
   356  	})
   357  	if err != nil {
   358  		return nil, nil, fmt.Errorf("failed to query open bugs for subsystem: %w", err)
   359  	}
   360  	allFixedBugs, _, err := loadAllBugs(c, func(query *db.Query) *db.Query {
   361  		return query.Filter("Namespace=", ns).
   362  			Filter("Status=", BugStatusFixed).
   363  			Filter("Labels.Label=", SubsystemLabel).
   364  			Filter("Labels.Value=", name)
   365  	})
   366  	if err != nil {
   367  		return nil, nil, fmt.Errorf("failed to query fixed bugs for subsystem: %w", err)
   368  	}
   369  	open, fixed := []*Bug{}, []*Bug{}
   370  	for _, bug := range append(allOpenBugs, allFixedBugs...) {
   371  		if len(bug.Commits) != 0 || bug.Status == BugStatusFixed {
   372  			// This bug is no longer really open.
   373  			fixed = append(fixed, bug)
   374  			continue
   375  		}
   376  		currReporting, _, _, _, err := currentReporting(c, bug)
   377  		if err != nil {
   378  			continue
   379  		}
   380  		if reporting.Name != currReporting.Name {
   381  			// The bug is not at the expected reporting stage.
   382  			continue
   383  		}
   384  		if currReporting.AccessLevel > reporting.AccessLevel {
   385  			continue
   386  		}
   387  		open = append(open, bug)
   388  	}
   389  	return open, fixed, nil
   390  }
   391  
   392  // makeSubsystemReport creates a new SubsystemReminder object.
   393  func makeSubsystemReport(c context.Context, config *BugListReportingConfig,
   394  	keys []*db.Key) *SubsystemReport {
   395  	ret := &SubsystemReport{
   396  		Created: timeNow(c),
   397  	}
   398  	for _, key := range keys {
   399  		ret.BugKeys = append(ret.BugKeys, key.Encode())
   400  	}
   401  	baseID := hash.String([]byte(fmt.Sprintf("%v-%v", timeNow(c), ret.BugKeys)))
   402  	if config.ModerationConfig != nil {
   403  		ret.Stages = append(ret.Stages, SubsystemReportStage{
   404  			ID:         bugListReportingHash(baseID, "moderation"),
   405  			Moderation: true,
   406  		})
   407  	}
   408  	ret.Stages = append(ret.Stages, SubsystemReportStage{
   409  		ID: bugListReportingHash(baseID, "public"),
   410  	})
   411  	return ret
   412  }
   413  
   414  const bugListHashPrefix = "list"
   415  
   416  func bugListReportingHash(base, name string) string {
   417  	return bugListHashPrefix + bugReportingHash(base, name)
   418  }
   419  
   420  func isBugListHash(hash string) bool {
   421  	return strings.HasPrefix(hash, bugListHashPrefix)
   422  }
   423  
   424  func reportingBugListReport(c context.Context, subsystemReport *SubsystemReport,
   425  	ns, name, targetReportingType string) (*dashapi.BugListReport, error) {
   426  	for _, stage := range subsystemReport.Stages {
   427  		if !stage.Closed.IsZero() {
   428  			continue
   429  		}
   430  		repConfig := bugListReportingConfig(c, ns, &stage)
   431  		if repConfig == nil {
   432  			// It might happen if e.g. Moderation was set to nil.
   433  			// Just skip the stage then.
   434  			continue
   435  		}
   436  		if !stage.Reported.IsZero() || repConfig.Type() != targetReportingType {
   437  			break
   438  		}
   439  		configJSON, err := json.Marshal(repConfig)
   440  		if err != nil {
   441  			return nil, err
   442  		}
   443  		ret := &dashapi.BugListReport{
   444  			ID:          stage.ID,
   445  			Created:     subsystemReport.Created,
   446  			Config:      configJSON,
   447  			Link:        fmt.Sprintf("%v/%s/s/%s", appURL(c), ns, name),
   448  			Subsystem:   name,
   449  			Maintainers: subsystemMaintainers(c, ns, name),
   450  			Moderation:  stage.Moderation,
   451  			TotalStats:  subsystemReport.TotalStats.toDashapi(),
   452  			PeriodStats: subsystemReport.PeriodStats.toDashapi(),
   453  			PeriodDays:  getNsConfig(c, ns).Subsystems.Reminder.PeriodDays,
   454  		}
   455  		bugKeys, err := subsystemReport.getBugKeys()
   456  		if err != nil {
   457  			return nil, fmt.Errorf("failed to get bug keys: %w", err)
   458  		}
   459  		bugs := make([]*Bug, len(bugKeys))
   460  		err = db.GetMulti(c, bugKeys, bugs)
   461  		if err != nil {
   462  			return nil, fmt.Errorf("failed to get bugs: %w", err)
   463  		}
   464  		for _, bug := range bugs {
   465  			bugReporting := bugReportingByName(bug,
   466  				getNsConfig(c, ns).Subsystems.Reminder.SourceReporting)
   467  			ret.Bugs = append(ret.Bugs, dashapi.BugListItem{
   468  				Title:      bug.displayTitle(),
   469  				Link:       fmt.Sprintf("%v/bug?extid=%v", appURL(c), bugReporting.ID),
   470  				ReproLevel: bug.ReproLevel,
   471  				Hits:       bug.NumCrashes,
   472  			})
   473  		}
   474  		return ret, nil
   475  	}
   476  	return nil, nil
   477  }
   478  
   479  func bugListReportingConfig(c context.Context, ns string, stage *SubsystemReportStage) ReportingType {
   480  	cfg := getNsConfig(c, ns).Subsystems.Reminder
   481  	if stage.Moderation {
   482  		return cfg.ModerationConfig
   483  	}
   484  	return cfg.Config
   485  }
   486  
   487  func makeSubsystem(ns, name string) *Subsystem {
   488  	return &Subsystem{
   489  		Namespace: ns,
   490  		Name:      name,
   491  	}
   492  }
   493  
   494  func subsystemKey(c context.Context, s *Subsystem) *db.Key {
   495  	return db.NewKey(c, "Subsystem", fmt.Sprintf("%v-%v", s.Namespace, s.Name), 0, nil)
   496  }
   497  
   498  func subsystemReportKey(c context.Context, subsystemKey *db.Key, r *SubsystemReport) *db.Key {
   499  	return db.NewKey(c, "SubsystemReport", r.Created.UTC().Format(time.RFC822), 0, subsystemKey)
   500  }
   501  
   502  type subsystemsRegistry struct {
   503  	entities map[string]map[string]*Subsystem
   504  }
   505  
   506  func makeSubsystemRegistry(c context.Context) (*subsystemsRegistry, error) {
   507  	var subsystems []*Subsystem
   508  	if _, err := db.NewQuery("Subsystem").GetAll(c, &subsystems); err != nil {
   509  		return nil, err
   510  	}
   511  	ret := &subsystemsRegistry{
   512  		entities: map[string]map[string]*Subsystem{},
   513  	}
   514  	for _, item := range subsystems {
   515  		ret.store(item)
   516  	}
   517  	return ret, nil
   518  }
   519  
   520  func (sr *subsystemsRegistry) get(ns, name string) *Subsystem {
   521  	ret := sr.entities[ns][name]
   522  	if ret == nil {
   523  		ret = makeSubsystem(ns, name)
   524  	}
   525  	return ret
   526  }
   527  
   528  func (sr *subsystemsRegistry) store(item *Subsystem) {
   529  	if sr.entities[item.Namespace] == nil {
   530  		sr.entities[item.Namespace] = map[string]*Subsystem{}
   531  	}
   532  	sr.entities[item.Namespace][item.Name] = item
   533  }
   534  
   535  func (sr *subsystemsRegistry) updatePoll(c context.Context, s *Subsystem, success bool) error {
   536  	key := subsystemKey(c, s)
   537  	return db.RunInTransaction(c, func(c context.Context) error {
   538  		dbSubsystem := new(Subsystem)
   539  		err := db.Get(c, key, dbSubsystem)
   540  		if err == db.ErrNoSuchEntity {
   541  			dbSubsystem = s
   542  		} else if err != nil {
   543  			return fmt.Errorf("failed to get Subsystem '%v': %w", key, err)
   544  		}
   545  		dbSubsystem.ListsQueried = timeNow(c)
   546  		if success {
   547  			dbSubsystem.LastBugList = timeNow(c)
   548  		}
   549  		if _, err := db.Put(c, key, dbSubsystem); err != nil {
   550  			return fmt.Errorf("failed to save Subsystem: %w", err)
   551  		}
   552  		sr.store(dbSubsystem)
   553  		return nil
   554  	}, nil)
   555  }
   556  
   557  type subsystemReportRegistry struct {
   558  	entities map[string]map[string][]*SubsystemReport
   559  }
   560  
   561  func makeSubsystemReportRegistry(c context.Context) (*subsystemReportRegistry, error) {
   562  	var reports []*SubsystemReport
   563  	reportKeys, err := db.NewQuery("SubsystemReport").GetAll(c, &reports)
   564  	if err != nil {
   565  		return nil, err
   566  	}
   567  	var subsystemKeys []*db.Key
   568  	for _, key := range reportKeys {
   569  		subsystemKeys = append(subsystemKeys, key.Parent())
   570  	}
   571  	subsystems := make([]*Subsystem, len(subsystemKeys))
   572  	if err := db.GetMulti(c, subsystemKeys, subsystems); err != nil {
   573  		return nil, fmt.Errorf("failed to query subsystems: %w", err)
   574  	}
   575  	ret := &subsystemReportRegistry{
   576  		entities: map[string]map[string][]*SubsystemReport{},
   577  	}
   578  	for i, item := range reports {
   579  		ret.store(subsystems[i].Namespace, subsystems[i].Name, item)
   580  	}
   581  	return ret, nil
   582  }
   583  
   584  func (srr *subsystemReportRegistry) get(ns, name string) []*SubsystemReport {
   585  	return srr.entities[ns][name]
   586  }
   587  
   588  func (srr *subsystemReportRegistry) store(ns, name string, item *SubsystemReport) {
   589  	if srr.entities[ns] == nil {
   590  		srr.entities[ns] = map[string][]*SubsystemReport{}
   591  	}
   592  	srr.entities[ns][name] = append(srr.entities[ns][name], item)
   593  }
   594  
   595  func storeSubsystemReport(c context.Context, s *Subsystem, report *SubsystemReport) error {
   596  	key := subsystemKey(c, s)
   597  	return db.RunInTransaction(c, func(c context.Context) error {
   598  		// First close all previouly active per-subsystem reports.
   599  		var previous []*SubsystemReport
   600  		prevKeys, err := db.NewQuery("SubsystemReport").
   601  			Ancestor(key).
   602  			Filter("Stages.Closed=", time.Time{}).
   603  			GetAll(c, &previous)
   604  		if err != nil {
   605  			return fmt.Errorf("failed to query old subsystem reports: %w", err)
   606  		}
   607  		for i, subsystem := range previous {
   608  			for i := range subsystem.Stages {
   609  				subsystem.Stages[i].Closed = timeNow(c)
   610  			}
   611  			if _, err := db.Put(c, prevKeys[i], subsystem); err != nil {
   612  				return fmt.Errorf("failed to save SubsystemReport: %w", err)
   613  			}
   614  		}
   615  		// Now save a new one.
   616  		reportKey := subsystemReportKey(c, key, report)
   617  		if _, err := db.Put(c, reportKey, report); err != nil {
   618  			return fmt.Errorf("failed to store new SubsystemReport: %w", err)
   619  		}
   620  		return nil
   621  	}, nil)
   622  }