feat(ci): cache the refs of github.com actions across jobs
- the refs of a github.com repository are listed once for 5 minutes for every job; a re-run still asks again - when github.com does not answer, the last list (up to 24 h old) stands in instead of failing the job - an unanswered ls-remote names the action in the error
- commit
- de33daa3c48e3ba0dfccdfadb70fd3c0e7560cf1
- author
- brice · · 2026-10-17 19:38 UTC
- parent
- 590f27c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +179 -6
M internal/usecase/ci.go +2 -1
| @@ -252,6 +252,7 @@ type CI struct { | |||
| 252 | 252 | wg sync.WaitGroup | |
| 253 | 253 | ||
| 254 | 254 | remoteSlots chan struct{} // slots of the git ls-remote processes | |
| 255 | + | remoteCache *refsCache // the refs of github.com repositories, for every job | |
| 255 | 256 | locks sync.Map // run id -> *sync.Mutex | |
| 256 | 257 | assign sync.Mutex | |
| 257 | 258 | jobs notifier // woken when a job changes: key is the job reference | |
| @@ -304,7 +305,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||
| 304 | 305 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | |
| 305 | 306 | return &CI{ | |
| 306 | 307 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts), | |
| 307 | - | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), | |
| 308 | + | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(), | |
| 308 | 309 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | |
| 309 | 310 | archiveTokens: map[string]map[string]string{}, | |
| 310 | 311 | perClient: map[string]int{}, | |
| @@ -252,6 +252,7 @@ type CI struct { | |||||
| 252 | wg sync.WaitGroup | 252 | wg sync.WaitGroup | ||
| 253 | 253 | ||||
| 254 | remoteSlots chan struct{} // slots of the git ls-remote processes | 254 | remoteSlots chan struct{} // slots of the git ls-remote processes | ||
| 255 | + | remoteCache *refsCache // the refs of github.com repositories, for every job | |||
| 255 | locks sync.Map // run id -> *sync.Mutex | 256 | locks sync.Map // run id -> *sync.Mutex | ||
| 256 | assign sync.Mutex | 257 | assign sync.Mutex | ||
| 257 | jobs notifier // woken when a job changes: key is the job reference | 258 | jobs notifier // woken when a job changes: key is the job reference | ||
| @@ -304,7 +305,7 @@ func NewCI(ctx context.Context, cfg CIConfig) *CI { | |||||
| 304 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | 305 | run, stop := context.WithCancel(context.WithoutCancel(ctx)) | ||
| 305 | return &CI{ | 306 | return &CI{ | ||
| 306 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts), | 307 | cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue), runEvents: make(chan runEvent, cfg.TriggerQueue), sched: newScheduler(cfg.Clock.Now()), pull: newPullQueue(cfg.TriggerQueue, cfg.PullRefreshes, cfg.PullAttempts), | ||
| 307 | - | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), | 308 | + | ctx: run, stop: stop, flows: newWfCache(64), remoteSlots: make(chan struct{}, cfg.RemoteConcurrency), remoteCache: newRefsCache(), |
| 308 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | 309 | heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{}, | ||
| 309 | archiveTokens: map[string]map[string]string{}, | 310 | archiveTokens: map[string]map[string]string{}, | ||
| 310 | perClient: map[string]int{}, | 311 | perClient: map[string]int{}, | ||
M internal/usecase/ci_actions.go +98 -4
| @@ -8,6 +8,10 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "slices" | |
| 10 | 10 | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/zerowrap" | |
| 11 | 15 | ||
| 12 | 16 | "bnema.dev/ermite/internal/domain" | |
| 13 | 17 | "bnema.dev/ermite/internal/ports" | |
| @@ -74,7 +78,7 @@ func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []st | |||
| 74 | 78 | // run: a ls-remote takes seconds and must not hold up the job's calls. | |
| 75 | 79 | found := map[string]domain.CIActionResolution{} | |
| 76 | 80 | fails := map[string]string{} | |
| 77 | - | remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots) | |
| 81 | + | remote := c.newRemoteRefs(j.RunAttempt) | |
| 78 | 82 | for _, p := range parsed { | |
| 79 | 83 | if p.err != "" { | |
| 80 | 84 | continue | |
| @@ -404,6 +408,9 @@ func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, rem | |||
| 404 | 408 | switch { | |
| 405 | 409 | case errors.Is(err, domain.ErrNotFound): | |
| 406 | 410 | return res, errUnresolvedRepo(a), nil | |
| 411 | + | case errors.Is(err, domain.ErrUnavailable) && ctx.Err() == nil: | |
| 412 | + | // The job learns which action and why, not only "unavailable". | |
| 413 | + | return res, "", fmt.Errorf("%w: resolving `%s`: github.com did not answer (%v)", domain.ErrUnavailable, a.uses, err) | |
| 407 | 414 | case err != nil: | |
| 408 | 415 | return res, "", err | |
| 409 | 416 | } | |
| @@ -459,11 +466,18 @@ func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref stri | |||
| 459 | 466 | return "", false, nil | |
| 460 | 467 | } | |
| 461 | 468 | ||
| 462 | - | // remoteRefs lists the refs of a remote once per repository within a call. | |
| 469 | + | // remoteRefs lists the refs of a remote once per repository within a call, | |
| 470 | + | // through the cache of the server (see refsCache). | |
| 463 | 471 | type remoteRefs struct { | |
| 464 | 472 | remote ports.GitRemote | |
| 465 | 473 | slots chan struct{} | |
| 474 | + | cache *refsCache | |
| 475 | + | now func() time.Time | |
| 466 | 476 | seen map[string]remoteList | |
| 477 | + | // refresh asks github.com even when the cache is fresh: a re-run resolves | |
| 478 | + | // again (a moved tag is often why it is re-run). The cache still stands | |
| 479 | + | // in when github.com does not answer. | |
| 480 | + | refresh bool | |
| 467 | 481 | } | |
| 468 | 482 | ||
| 469 | 483 | type remoteList struct { | |
| @@ -471,8 +485,10 @@ type remoteList struct { | |||
| 471 | 485 | err error | |
| 472 | 486 | } | |
| 473 | 487 | ||
| 474 | - | func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs { | |
| 475 | - | return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}} | |
| 488 | + | // newRemoteRefs lists for the jobs of an attempt: a re-run (attempt > 1) | |
| 489 | + | // refreshes. | |
| 490 | + | func (c *CI) newRemoteRefs(attempt int32) *remoteRefs { | |
| 491 | + | return &remoteRefs{remote: c.cfg.Remote, slots: c.remoteSlots, cache: c.remoteCache, now: c.now, seen: map[string]remoteList{}, refresh: attempt > 1} | |
| 476 | 492 | } | |
| 477 | 493 | ||
| 478 | 494 | func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | |
| @@ -483,6 +499,10 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo | |||
| 483 | 499 | if l, ok := r.seen[key]; ok { | |
| 484 | 500 | return l.refs, l.err | |
| 485 | 501 | } | |
| 502 | + | if refs, fresh := r.cache.get(key, r.now()); fresh && !r.refresh { | |
| 503 | + | r.seen[key] = remoteList{refs, nil} | |
| 504 | + | return refs, nil | |
| 505 | + | } | |
| 486 | 506 | // At most a few ls-remote at once on the whole server, whatever the number | |
| 487 | 507 | // of jobs resolving: a process each, and github.com is slow to answer. | |
| 488 | 508 | select { | |
| @@ -491,10 +511,84 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo | |||
| 491 | 511 | case <-ctx.Done(): | |
| 492 | 512 | return nil, ctx.Err() | |
| 493 | 513 | } | |
| 514 | + | // Another job may have listed it while this one waited for a slot. | |
| 515 | + | if refs, fresh := r.cache.get(key, r.now()); fresh && !r.refresh { | |
| 516 | + | r.seen[key] = remoteList{refs, nil} | |
| 517 | + | return refs, nil | |
| 518 | + | } | |
| 494 | 519 | refs, err := r.remote.ListRefs(ctx, owner, repo) | |
| 495 | 520 | if ctx.Err() != nil { | |
| 496 | 521 | return nil, ctx.Err() | |
| 497 | 522 | } | |
| 523 | + | switch { | |
| 524 | + | case err == nil: | |
| 525 | + | r.cache.put(key, refs, r.now()) | |
| 526 | + | case errors.Is(err, domain.ErrUnavailable): | |
| 527 | + | // github.com did not answer: the last list it gave is better than a | |
| 528 | + | // failed job, for a while (refsStale). | |
| 529 | + | if old, ok := r.cache.stale(key, r.now()); ok { | |
| 530 | + | log := zerowrap.FromCtx(ctx) | |
| 531 | + | log.Warn().Err(err).Str("repo", key).Msg("github.com did not answer: using the refs listed earlier") | |
| 532 | + | refs, err = old, nil | |
| 533 | + | } | |
| 534 | + | } | |
| 498 | 535 | r.seen[key] = remoteList{refs, err} | |
| 499 | 536 | return refs, err | |
| 500 | 537 | } | |
| 538 | + | ||
| 539 | + | const ( | |
| 540 | + | // refsFresh is how long a list of the refs of a repository of github.com | |
| 541 | + | // is used without asking again: the jobs of the next minutes resolve | |
| 542 | + | // actions/checkout@v4 without a ls-remote. A tag or branch moved meanwhile | |
| 543 | + | // is seen after at most that long. | |
| 544 | + | refsFresh = 5 * time.Minute | |
| 545 | + | // refsStale is how long a list is kept for when github.com does not answer. | |
| 546 | + | refsStale = 24 * time.Hour | |
| 547 | + | // refsMax bounds the repositories in the cache. | |
| 548 | + | refsMax = 256 | |
| 549 | + | ) | |
| 550 | + | ||
| 551 | + | // refsCache keeps the refs of the repositories of github.com the actions come | |
| 552 | + | // from, for the whole server. Safe for concurrent use. | |
| 553 | + | type refsCache struct { | |
| 554 | + | mu sync.Mutex | |
| 555 | + | m map[string]cachedRefs | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | type cachedRefs struct { | |
| 559 | + | refs []ports.RemoteRef | |
| 560 | + | at time.Time | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | func newRefsCache() *refsCache { return &refsCache{m: map[string]cachedRefs{}} } | |
| 564 | + | ||
| 565 | + | // get returns the list of key and whether it is fresh. | |
| 566 | + | func (c *refsCache) get(key string, now time.Time) ([]ports.RemoteRef, bool) { | |
| 567 | + | c.mu.Lock() | |
| 568 | + | defer c.mu.Unlock() | |
| 569 | + | e, ok := c.m[key] | |
| 570 | + | return e.refs, ok && now.Sub(e.at) < refsFresh | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | // stale returns the list of key when it is not older than refsStale. | |
| 574 | + | func (c *refsCache) stale(key string, now time.Time) ([]ports.RemoteRef, bool) { | |
| 575 | + | c.mu.Lock() | |
| 576 | + | defer c.mu.Unlock() | |
| 577 | + | e, ok := c.m[key] | |
| 578 | + | return e.refs, ok && now.Sub(e.at) < refsStale | |
| 579 | + | } | |
| 580 | + | ||
| 581 | + | func (c *refsCache) put(key string, refs []ports.RemoteRef, now time.Time) { | |
| 582 | + | c.mu.Lock() | |
| 583 | + | defer c.mu.Unlock() | |
| 584 | + | if _, ok := c.m[key]; !ok && len(c.m) >= refsMax { | |
| 585 | + | var oldest string | |
| 586 | + | for k, e := range c.m { | |
| 587 | + | if oldest == "" || e.at.Before(c.m[oldest].at) { | |
| 588 | + | oldest = k | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | delete(c.m, oldest) | |
| 592 | + | } | |
| 593 | + | c.m[key] = cachedRefs{refs: refs, at: now} | |
| 594 | + | } | |
| @@ -8,6 +8,10 @@ import ( | |||||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "slices" | 9 | "slices" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | + | "sync" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/zerowrap" | |||
| 11 | 15 | ||||
| 12 | "bnema.dev/ermite/internal/domain" | 16 | "bnema.dev/ermite/internal/domain" | ||
| 13 | "bnema.dev/ermite/internal/ports" | 17 | "bnema.dev/ermite/internal/ports" | ||
| @@ -74,7 +78,7 @@ func (c *CI) resolveActions(ctx context.Context, secret, jobID string, uses []st | |||||
| 74 | // run: a ls-remote takes seconds and must not hold up the job's calls. | 78 | // run: a ls-remote takes seconds and must not hold up the job's calls. | ||
| 75 | found := map[string]domain.CIActionResolution{} | 79 | found := map[string]domain.CIActionResolution{} | ||
| 76 | fails := map[string]string{} | 80 | fails := map[string]string{} | ||
| 77 | - | remote := newRemoteRefs(c.cfg.Remote, c.remoteSlots) | 81 | + | remote := c.newRemoteRefs(j.RunAttempt) |
| 78 | for _, p := range parsed { | 82 | for _, p := range parsed { | ||
| 79 | if p.err != "" { | 83 | if p.err != "" { | ||
| 80 | continue | 84 | continue | ||
| @@ -404,6 +408,9 @@ func (c *CI) resolveAction(ctx context.Context, j domain.CIJob, a actionRef, rem | |||||
| 404 | switch { | 408 | switch { | ||
| 405 | case errors.Is(err, domain.ErrNotFound): | 409 | case errors.Is(err, domain.ErrNotFound): | ||
| 406 | return res, errUnresolvedRepo(a), nil | 410 | return res, errUnresolvedRepo(a), nil | ||
| 411 | + | case errors.Is(err, domain.ErrUnavailable) && ctx.Err() == nil: | |||
| 412 | + | // The job learns which action and why, not only "unavailable". | |||
| 413 | + | return res, "", fmt.Errorf("%w: resolving `%s`: github.com did not answer (%v)", domain.ErrUnavailable, a.uses, err) | |||
| 407 | case err != nil: | 414 | case err != nil: | ||
| 408 | return res, "", err | 415 | return res, "", err | ||
| 409 | } | 416 | } | ||
| @@ -459,11 +466,18 @@ func (c *CI) resolveOnErmite(ctx context.Context, repo domain.RepoName, ref stri | |||||
| 459 | return "", false, nil | 466 | return "", false, nil | ||
| 460 | } | 467 | } | ||
| 461 | 468 | ||||
| 462 | - | // remoteRefs lists the refs of a remote once per repository within a call. | 469 | + | // remoteRefs lists the refs of a remote once per repository within a call, |
| 470 | + | // through the cache of the server (see refsCache). | |||
| 463 | type remoteRefs struct { | 471 | type remoteRefs struct { | ||
| 464 | remote ports.GitRemote | 472 | remote ports.GitRemote | ||
| 465 | slots chan struct{} | 473 | slots chan struct{} | ||
| 474 | + | cache *refsCache | |||
| 475 | + | now func() time.Time | |||
| 466 | seen map[string]remoteList | 476 | seen map[string]remoteList | ||
| 477 | + | // refresh asks github.com even when the cache is fresh: a re-run resolves | |||
| 478 | + | // again (a moved tag is often why it is re-run). The cache still stands | |||
| 479 | + | // in when github.com does not answer. | |||
| 480 | + | refresh bool | |||
| 467 | } | 481 | } | ||
| 468 | 482 | ||||
| 469 | type remoteList struct { | 483 | type remoteList struct { | ||
| @@ -471,8 +485,10 @@ type remoteList struct { | |||||
| 471 | err error | 485 | err error | ||
| 472 | } | 486 | } | ||
| 473 | 487 | ||||
| 474 | - | func newRemoteRefs(r ports.GitRemote, slots chan struct{}) *remoteRefs { | 488 | + | // newRemoteRefs lists for the jobs of an attempt: a re-run (attempt > 1) |
| 475 | - | return &remoteRefs{remote: r, slots: slots, seen: map[string]remoteList{}} | 489 | + | // refreshes. |
| 490 | + | func (c *CI) newRemoteRefs(attempt int32) *remoteRefs { | |||
| 491 | + | return &remoteRefs{remote: c.cfg.Remote, slots: c.remoteSlots, cache: c.remoteCache, now: c.now, seen: map[string]remoteList{}, refresh: attempt > 1} | |||
| 476 | } | 492 | } | ||
| 477 | 493 | ||||
| 478 | func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | 494 | func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.RemoteRef, error) { | ||
| @@ -483,6 +499,10 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo | |||||
| 483 | if l, ok := r.seen[key]; ok { | 499 | if l, ok := r.seen[key]; ok { | ||
| 484 | return l.refs, l.err | 500 | return l.refs, l.err | ||
| 485 | } | 501 | } | ||
| 502 | + | if refs, fresh := r.cache.get(key, r.now()); fresh && !r.refresh { | |||
| 503 | + | r.seen[key] = remoteList{refs, nil} | |||
| 504 | + | return refs, nil | |||
| 505 | + | } | |||
| 486 | // At most a few ls-remote at once on the whole server, whatever the number | 506 | // At most a few ls-remote at once on the whole server, whatever the number | ||
| 487 | // of jobs resolving: a process each, and github.com is slow to answer. | 507 | // of jobs resolving: a process each, and github.com is slow to answer. | ||
| 488 | select { | 508 | select { | ||
| @@ -491,10 +511,84 @@ func (r *remoteRefs) list(ctx context.Context, owner, repo string) ([]ports.Remo | |||||
| 491 | case <-ctx.Done(): | 511 | case <-ctx.Done(): | ||
| 492 | return nil, ctx.Err() | 512 | return nil, ctx.Err() | ||
| 493 | } | 513 | } | ||
| 514 | + | // Another job may have listed it while this one waited for a slot. | |||
| 515 | + | if refs, fresh := r.cache.get(key, r.now()); fresh && !r.refresh { | |||
| 516 | + | r.seen[key] = remoteList{refs, nil} | |||
| 517 | + | return refs, nil | |||
| 518 | + | } | |||
| 494 | refs, err := r.remote.ListRefs(ctx, owner, repo) | 519 | refs, err := r.remote.ListRefs(ctx, owner, repo) | ||
| 495 | if ctx.Err() != nil { | 520 | if ctx.Err() != nil { | ||
| 496 | return nil, ctx.Err() | 521 | return nil, ctx.Err() | ||
| 497 | } | 522 | } | ||
| 523 | + | switch { | |||
| 524 | + | case err == nil: | |||
| 525 | + | r.cache.put(key, refs, r.now()) | |||
| 526 | + | case errors.Is(err, domain.ErrUnavailable): | |||
| 527 | + | // github.com did not answer: the last list it gave is better than a | |||
| 528 | + | // failed job, for a while (refsStale). | |||
| 529 | + | if old, ok := r.cache.stale(key, r.now()); ok { | |||
| 530 | + | log := zerowrap.FromCtx(ctx) | |||
| 531 | + | log.Warn().Err(err).Str("repo", key).Msg("github.com did not answer: using the refs listed earlier") | |||
| 532 | + | refs, err = old, nil | |||
| 533 | + | } | |||
| 534 | + | } | |||
| 498 | r.seen[key] = remoteList{refs, err} | 535 | r.seen[key] = remoteList{refs, err} | ||
| 499 | return refs, err | 536 | return refs, err | ||
| 500 | } | 537 | } | ||
| 538 | + | ||||
| 539 | + | const ( | |||
| 540 | + | // refsFresh is how long a list of the refs of a repository of github.com | |||
| 541 | + | // is used without asking again: the jobs of the next minutes resolve | |||
| 542 | + | // actions/checkout@v4 without a ls-remote. A tag or branch moved meanwhile | |||
| 543 | + | // is seen after at most that long. | |||
| 544 | + | refsFresh = 5 * time.Minute | |||
| 545 | + | // refsStale is how long a list is kept for when github.com does not answer. | |||
| 546 | + | refsStale = 24 * time.Hour | |||
| 547 | + | // refsMax bounds the repositories in the cache. | |||
| 548 | + | refsMax = 256 | |||
| 549 | + | ) | |||
| 550 | + | ||||
| 551 | + | // refsCache keeps the refs of the repositories of github.com the actions come | |||
| 552 | + | // from, for the whole server. Safe for concurrent use. | |||
| 553 | + | type refsCache struct { | |||
| 554 | + | mu sync.Mutex | |||
| 555 | + | m map[string]cachedRefs | |||
| 556 | + | } | |||
| 557 | + | ||||
| 558 | + | type cachedRefs struct { | |||
| 559 | + | refs []ports.RemoteRef | |||
| 560 | + | at time.Time | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | func newRefsCache() *refsCache { return &refsCache{m: map[string]cachedRefs{}} } | |||
| 564 | + | ||||
| 565 | + | // get returns the list of key and whether it is fresh. | |||
| 566 | + | func (c *refsCache) get(key string, now time.Time) ([]ports.RemoteRef, bool) { | |||
| 567 | + | c.mu.Lock() | |||
| 568 | + | defer c.mu.Unlock() | |||
| 569 | + | e, ok := c.m[key] | |||
| 570 | + | return e.refs, ok && now.Sub(e.at) < refsFresh | |||
| 571 | + | } | |||
| 572 | + | ||||
| 573 | + | // stale returns the list of key when it is not older than refsStale. | |||
| 574 | + | func (c *refsCache) stale(key string, now time.Time) ([]ports.RemoteRef, bool) { | |||
| 575 | + | c.mu.Lock() | |||
| 576 | + | defer c.mu.Unlock() | |||
| 577 | + | e, ok := c.m[key] | |||
| 578 | + | return e.refs, ok && now.Sub(e.at) < refsStale | |||
| 579 | + | } | |||
| 580 | + | ||||
| 581 | + | func (c *refsCache) put(key string, refs []ports.RemoteRef, now time.Time) { | |||
| 582 | + | c.mu.Lock() | |||
| 583 | + | defer c.mu.Unlock() | |||
| 584 | + | if _, ok := c.m[key]; !ok && len(c.m) >= refsMax { | |||
| 585 | + | var oldest string | |||
| 586 | + | for k, e := range c.m { | |||
| 587 | + | if oldest == "" || e.at.Before(c.m[oldest].at) { | |||
| 588 | + | oldest = k | |||
| 589 | + | } | |||
| 590 | + | } | |||
| 591 | + | delete(c.m, oldest) | |||
| 592 | + | } | |||
| 593 | + | c.m[key] = cachedRefs{refs: refs, at: now} | |||
| 594 | + | } | |||
M internal/usecase/ci_actions_test.go +78 -0
| @@ -3,11 +3,16 @@ | |||
| 3 | 3 | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 6 | 8 | "strings" | |
| 7 | 9 | "testing" | |
| 10 | + | "time" | |
| 8 | 11 | ||
| 9 | 12 | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 10 | 14 | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | 16 | "bnema.dev/ermite/internal/ports" | |
| 12 | 17 | ) | |
| 13 | 18 | ||
| @@ -66,3 +71,76 @@ func TestPickRemoteRef(t *testing.T) { | |||
| 66 | 71 | assert.Empty(t, pickRemoteRef(refs, "nope")) | |
| 67 | 72 | assert.Empty(t, pickRemoteRef(nil, "v1")) | |
| 68 | 73 | } | |
| 74 | + | ||
| 75 | + | // countingRemote answers ListRefs from a function and counts the calls. | |
| 76 | + | type countingRemote struct { | |
| 77 | + | calls int | |
| 78 | + | answer func() ([]ports.RemoteRef, error) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (r *countingRemote) ListRefs(context.Context, string, string) ([]ports.RemoteRef, error) { | |
| 82 | + | r.calls++ | |
| 83 | + | return r.answer() | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // The refs of a github.com repository are listed once for the jobs of the next | |
| 87 | + | // minutes; when github.com does not answer, the last list is used. | |
| 88 | + | func TestRemoteRefsCache(t *testing.T) { | |
| 89 | + | v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}} | |
| 90 | + | remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }} | |
| 91 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |
| 92 | + | c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()} | |
| 93 | + | list := func() ([]ports.RemoteRef, error) { | |
| 94 | + | r := c.newRemoteRefs(1) | |
| 95 | + | r.now = func() time.Time { return now } | |
| 96 | + | return r.list(context.Background(), "Actions", "checkout") | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | got, err := list() | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | assert.Equal(t, v4, got) | |
| 102 | + | now = now.Add(refsFresh - time.Second) | |
| 103 | + | _, err = list() | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | assert.Equal(t, 1, remote.calls, "a fresh list is not asked again, whatever the case of the name") | |
| 106 | + | rerun := c.newRemoteRefs(2) | |
| 107 | + | rerun.now = func() time.Time { return now } | |
| 108 | + | _, err = rerun.list(context.Background(), "actions", "checkout") | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | assert.Equal(t, 2, remote.calls, "a re-run asks again") | |
| 111 | + | ||
| 112 | + | now = now.Add(refsFresh + time.Second) // the re-run listed it afresh | |
| 113 | + | remote.answer = func() ([]ports.RemoteRef, error) { return nil, fmt.Errorf("%w: timeout", domain.ErrUnavailable) } | |
| 114 | + | got, err = list() | |
| 115 | + | require.NoError(t, err, "github.com did not answer: the last list is used") | |
| 116 | + | assert.Equal(t, v4, got) | |
| 117 | + | assert.Equal(t, 3, remote.calls, "an old list is asked again") | |
| 118 | + | ||
| 119 | + | now = now.Add(refsStale) | |
| 120 | + | _, err = list() | |
| 121 | + | require.ErrorIs(t, err, domain.ErrUnavailable, "too old to stand in") | |
| 122 | + | ||
| 123 | + | remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrNotFound } | |
| 124 | + | c.remoteCache = newRefsCache() | |
| 125 | + | _, err = list() | |
| 126 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 127 | + | _, err = list() | |
| 128 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 129 | + | assert.Equal(t, 6, remote.calls, "a missing repository is not cached") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // An unanswered ls-remote fails the call with the action named. | |
| 133 | + | func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) { | |
| 134 | + | remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { | |
| 135 | + | return nil, fmt.Errorf("%w: git ls-remote actions/checkout did not answer within 20s (2 attempts)", domain.ErrUnavailable) | |
| 136 | + | }} | |
| 137 | + | c := &CI{cfg: CIConfig{Remote: remote, Owner: "owner", Clock: stillClock{}}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()} | |
| 138 | + | _, _, err := c.resolveAction(context.Background(), domain.CIJob{}, parseActionRef("actions/checkout@v4"), c.newRemoteRefs(1), pusherAccess{}) | |
| 139 | + | require.ErrorIs(t, err, domain.ErrUnavailable) | |
| 140 | + | assert.Contains(t, err.Error(), "actions/checkout@v4") | |
| 141 | + | assert.Contains(t, err.Error(), "github.com did not answer") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | type stillClock struct{} | |
| 145 | + | ||
| 146 | + | func (stillClock) Now() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } | |
| @@ -3,11 +3,16 @@ | |||||
| 3 | package usecase | 3 | package usecase | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 6 | "strings" | 8 | "strings" | ||
| 7 | "testing" | 9 | "testing" | ||
| 10 | + | "time" | |||
| 8 | 11 | ||||
| 9 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 10 | 14 | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | "bnema.dev/ermite/internal/ports" | 16 | "bnema.dev/ermite/internal/ports" | ||
| 12 | ) | 17 | ) | ||
| 13 | 18 | ||||
| @@ -66,3 +71,76 @@ func TestPickRemoteRef(t *testing.T) { | |||||
| 66 | assert.Empty(t, pickRemoteRef(refs, "nope")) | 71 | assert.Empty(t, pickRemoteRef(refs, "nope")) | ||
| 67 | assert.Empty(t, pickRemoteRef(nil, "v1")) | 72 | assert.Empty(t, pickRemoteRef(nil, "v1")) | ||
| 68 | } | 73 | } | ||
| 74 | + | ||||
| 75 | + | // countingRemote answers ListRefs from a function and counts the calls. | |||
| 76 | + | type countingRemote struct { | |||
| 77 | + | calls int | |||
| 78 | + | answer func() ([]ports.RemoteRef, error) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (r *countingRemote) ListRefs(context.Context, string, string) ([]ports.RemoteRef, error) { | |||
| 82 | + | r.calls++ | |||
| 83 | + | return r.answer() | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // The refs of a github.com repository are listed once for the jobs of the next | |||
| 87 | + | // minutes; when github.com does not answer, the last list is used. | |||
| 88 | + | func TestRemoteRefsCache(t *testing.T) { | |||
| 89 | + | v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}} | |||
| 90 | + | remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }} | |||
| 91 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |||
| 92 | + | c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()} | |||
| 93 | + | list := func() ([]ports.RemoteRef, error) { | |||
| 94 | + | r := c.newRemoteRefs(1) | |||
| 95 | + | r.now = func() time.Time { return now } | |||
| 96 | + | return r.list(context.Background(), "Actions", "checkout") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | got, err := list() | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | assert.Equal(t, v4, got) | |||
| 102 | + | now = now.Add(refsFresh - time.Second) | |||
| 103 | + | _, err = list() | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | assert.Equal(t, 1, remote.calls, "a fresh list is not asked again, whatever the case of the name") | |||
| 106 | + | rerun := c.newRemoteRefs(2) | |||
| 107 | + | rerun.now = func() time.Time { return now } | |||
| 108 | + | _, err = rerun.list(context.Background(), "actions", "checkout") | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | assert.Equal(t, 2, remote.calls, "a re-run asks again") | |||
| 111 | + | ||||
| 112 | + | now = now.Add(refsFresh + time.Second) // the re-run listed it afresh | |||
| 113 | + | remote.answer = func() ([]ports.RemoteRef, error) { return nil, fmt.Errorf("%w: timeout", domain.ErrUnavailable) } | |||
| 114 | + | got, err = list() | |||
| 115 | + | require.NoError(t, err, "github.com did not answer: the last list is used") | |||
| 116 | + | assert.Equal(t, v4, got) | |||
| 117 | + | assert.Equal(t, 3, remote.calls, "an old list is asked again") | |||
| 118 | + | ||||
| 119 | + | now = now.Add(refsStale) | |||
| 120 | + | _, err = list() | |||
| 121 | + | require.ErrorIs(t, err, domain.ErrUnavailable, "too old to stand in") | |||
| 122 | + | ||||
| 123 | + | remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrNotFound } | |||
| 124 | + | c.remoteCache = newRefsCache() | |||
| 125 | + | _, err = list() | |||
| 126 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 127 | + | _, err = list() | |||
| 128 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |||
| 129 | + | assert.Equal(t, 6, remote.calls, "a missing repository is not cached") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // An unanswered ls-remote fails the call with the action named. | |||
| 133 | + | func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) { | |||
| 134 | + | remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { | |||
| 135 | + | return nil, fmt.Errorf("%w: git ls-remote actions/checkout did not answer within 20s (2 attempts)", domain.ErrUnavailable) | |||
| 136 | + | }} | |||
| 137 | + | c := &CI{cfg: CIConfig{Remote: remote, Owner: "owner", Clock: stillClock{}}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()} | |||
| 138 | + | _, _, err := c.resolveAction(context.Background(), domain.CIJob{}, parseActionRef("actions/checkout@v4"), c.newRemoteRefs(1), pusherAccess{}) | |||
| 139 | + | require.ErrorIs(t, err, domain.ErrUnavailable) | |||
| 140 | + | assert.Contains(t, err.Error(), "actions/checkout@v4") | |||
| 141 | + | assert.Contains(t, err.Error(), "github.com did not answer") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | type stillClock struct{} | |||
| 145 | + | ||||
| 146 | + | func (stillClock) Now() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) } | |||
M internal/usecase/ci_reusable.go +1 -1
| @@ -158,7 +158,7 @@ func (c *CI) loadCallee(ctx context.Context, rs *runState, home *domain.CICall, | |||
| 158 | 158 | res = stored | |
| 159 | 159 | case errors.Is(err, domain.ErrNotFound): | |
| 160 | 160 | var msg string | |
| 161 | - | res, msg, err = c.resolveAction(ctx, rec, ar, newRemoteRefs(c.cfg.Remote, c.remoteSlots), by) | |
| 161 | + | res, msg, err = c.resolveAction(ctx, rec, ar, c.newRemoteRefs(rec.RunAttempt), by) | |
| 162 | 162 | if err != nil { | |
| 163 | 163 | return f, err | |
| 164 | 164 | } | |
| @@ -158,7 +158,7 @@ func (c *CI) loadCallee(ctx context.Context, rs *runState, home *domain.CICall, | |||||
| 158 | res = stored | 158 | res = stored | ||
| 159 | case errors.Is(err, domain.ErrNotFound): | 159 | case errors.Is(err, domain.ErrNotFound): | ||
| 160 | var msg string | 160 | var msg string | ||
| 161 | - | res, msg, err = c.resolveAction(ctx, rec, ar, newRemoteRefs(c.cfg.Remote, c.remoteSlots), by) | 161 | + | res, msg, err = c.resolveAction(ctx, rec, ar, c.newRemoteRefs(rec.RunAttempt), by) |
| 162 | if err != nil { | 162 | if err != nil { | ||
| 163 | return f, err | 163 | return f, err | ||
| 164 | } | 164 | } | ||