Skip to content

feat: restrict periodic storage GC to a configurable gcTimeWindow#4223

Open
shcherbak wants to merge 7 commits into
project-zot:mainfrom
shcherbak:feat-gcTimeWindow
Open

feat: restrict periodic storage GC to a configurable gcTimeWindow#4223
shcherbak wants to merge 7 commits into
project-zot:mainfrom
shcherbak:feat-gcTimeWindow

Conversation

@shcherbak

@shcherbak shcherbak commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Adds a gcTimeWindow storage config option (e.g. "01:00-08:00") that restricts periodic garbage collection to a daily off-peak window, avoiding lock contention on storage during high-load periods. Resolves #3001.

  • New gcTimeWindow field on StorageConfig (top-level and per-subpath), start inclusive / end exclusive, wraps past midnight (e.g. "22:00-06:00"). Applied at server startup, same as the existing
    gcDelay/gcInterval settings - a config file reload updates the stored value but doesn't affect already-running periodic GC tasks; a restart is required to pick up changes.
  • gc.ValidateGCTimeWindow validates the "HH:MM-HH:MM" format at config load time; windows that wrap past midnight (e.g. "22:00-06:00") are supported.
  • GCTaskGenerator.IsReady now also checks the configured window before allowing a GC run to proceed.
  • Unit tests for parsing, validation, wrap-around containment, and generator behavior; config-load tests for valid/invalid windows and the case where a window is set without GC enabled.
  • Docs and example config updated (examples/README.md, examples/config-gc.json).

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a configurable daily GC time window (gcTimeWindow, e.g. 01:00-08:00) to restrict periodic storage garbage collection to off-peak hours, reducing lock contention on busy registries.

Changes:

  • Introduces parsing/validation for "HH:MM-HH:MM" windows (including wrap-around past midnight) and enforces the window in GCTaskGenerator.IsReady().
  • Wires the new config field through API config, controller GC task setup, and CLI config validation (including per-subpath).
  • Adds unit/config-load tests plus documentation and example config updates.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pkg/storage/gc/gc.go Adds time window parsing/validation helpers and enforces the window in periodic GC readiness.
pkg/storage/gc/gc_internal_test.go Adds tests for parsing/validation/containment and generator readiness behavior.
pkg/cli/server/root.go Validates gcTimeWindow at config load (top-level + subpaths) and warns if GC disabled.
pkg/cli/server/root_test.go Adds config-load tests for valid/invalid windows and GC-disabled-with-window case.
pkg/api/controller.go Passes gcTimeWindow into GC options when scheduling periodic GC tasks.
pkg/api/config/config.go Adds GCTimeWindow to StorageConfig and includes it in reloadable config updates.
examples/README.md Documents gcTimeWindow usage and wrap-around behavior.
examples/config-gc.json Adds gcTimeWindow to the GC example config.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/storage/gc/gc.go
Comment thread pkg/storage/gc/gc_internal_test.go
Comment thread pkg/api/controller.go
Comment thread examples/README.md Outdated
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.76%. Comparing base (fea81a5) to head (96973cd).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4223      +/-   ##
==========================================
+ Coverage   91.73%   91.76%   +0.03%     
==========================================
  Files         207      207              
  Lines       30989    31065      +76     
==========================================
+ Hits        28428    28508      +80     
+ Misses       1645     1642       -3     
+ Partials      916      915       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment thread pkg/storage/gc/gc.go
Comment thread pkg/storage/gc/gc_internal_test.go
Comment thread pkg/api/controller.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread pkg/cli/server/root.go Outdated
@rchincha

Copy link
Copy Markdown
Contributor

The downside of this approach is that the gc work is no longer amortized but has to finish within that time window - which it may not.

@shcherbak

Copy link
Copy Markdown
Contributor Author

The downside of this approach is that the gc work is no longer amortized but has to finish within that time window - which it may not.

Good catch - updated the design so gcTimeWindow only gates when a new sweep is allowed to start, not every repo-task within it. Once a sweep begins inside the window it runs to completion even past the window's end, so GC work stays fully amortized and won't stall mid-sweep on a large registry until the next day's window. Docs and tests updated accordingly.

Comment thread pkg/cli/server/root.go
logger.Error().Err(err).Str("gcTimeWindow", storageConfig.GCTimeWindow).
Msg("invalid garbage-collect time window specified")

return err

@andaaron andaaron Jul 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return err
return fmt.Errorf("%w: invalid garbage-collect time window specified %s",
zerr.ErrBadConfig, storageConfig.GCTimeWindow)

Same for substores.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is still valid.

Comment thread pkg/cli/server/root.go Outdated
Comment thread pkg/api/config/config.go
CacheDriver map[string]any `mapstructure:",omitempty"`
// GCTimeWindow restricts periodic garbage-collection runs to a daily time-of-day
// window, e.g. "01:00-08:00". Empty means GC can run at any time.
GCTimeWindow string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GCTimeWindow is a raw string parsed again in validateGC and again in newGCTimeWindow.
How about making this a typed field (e.g. gc.TimeWindow) decoded at LoadConfiguration time via a mapstructure decode hook alongside StringToTimeDurationHookFunc (root.go:1261-1263). That matches how gcDelay/gcInterval work and avoids double-parsing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - agreed the double-parse isn't ideal. Only wrinkle: gc already imports config (for ImageRetention), so a gc.TimeWindow type would create an import cycle. Fix would be defining the typed window in config instead (like ImageRetention already is), decoded via a custom mapstructure hook next to StringToTimeDurationHookFunc, with gc.Options.TimeWindow taking that type directly and parseGCTimeWindow/newGCTimeWindow/ValidateGCTimeWindow going away. That's a decent-sized change for this PR's scope. Do we want it here, or as a follow-up?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I meant is defining a GCTimeWindow struct in this package (e.g. in config.go), with a custom mapstructure decode hook in the same package — similar in spirit to SinkConfigDecoderHook in pkg/extensions/config/events/decoder.go.

Register that hook in root.go next to StringToTimeDurationHookFunc(). Move parsing/validation (currently in parseGCTimeWindow, Contains, empty string = no restriction) into pkg/api/config; invalid values would then fail at unmarshal time.

gc.Options.TimeWindow would take config.GCTimeWindow directly (like it already takes config.ImageRetention), so pkg/storage/gc would not re-parse or validate — it would only consume the already-decoded value.

This should be cleaner and require no extra packages to import in root.go or gc.go

Comment thread pkg/cli/server/root.go Outdated
Comment thread pkg/storage/gc/gc.go
// inside the window is allowed to run to completion even past the window's end,
// so GC work stays amortized and orphaned blobs don't outpace a narrow window.
// Empty means no restriction.
TimeWindow string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once config decodes to a typed window, pass it through Options directly instead of re-parsing the string in newGCTimeWindow (:124-137).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same suggestion as the one on config.go:39-41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my other comment in config.go.

Comment thread pkg/storage/gc/gc.go
log.Error().Err(err).Str("gcTimeWindow", window).
Msg("invalid gcTimeWindow, ignoring and running GC without a time restriction")

return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After config-load validation, newGCTimeWindow's "log and ignore invalid window" path is likely unreachable in normal operation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree - with the current call graph it's unreachable, since validateGC already rejects the config before the server ever calls this. Kept it as a safety net rather than removing it: gc is a library package with an exported Options.TimeWindow string, and if a direct caller skips validation, silently trusting the parse would leave a zero-value window (startMin=0, endMin=0), which makes contains() always false - GC would stop running permanently and silently, which is worse than the current logged fallback. Added a comment explaining why it's there.

Comment thread pkg/storage/gc/gc.go
Signed-off-by: shcherbak <ju.shcherbak@gmail.com>
- clarify in docs that gcTimeWindow is start-inclusive/end-exclusive
    and only applied at server startup, not on config reload
  - read time.Now() once in GCTaskGenerator.IsReady to avoid an
    inconsistent readiness check across a minute boundary
  - fix a flaky test where the "inside window" case excluded the
    last minute of the day (23:59)

Signed-off-by: shcherbak <ju.shcherbak@gmail.com>
…t, and add missing test coverage

Signed-off-by: shcherbak <ju.shcherbak@gmail.com>
Signed-off-by: shcherbak <ju.shcherbak@gmail.com>
@andaaron

andaaron commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

@shcherbak I forgot to ask: what timezone is this supposed to be in? I think it would be very ambiguous for the user as well

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread pkg/cli/server/root.go
Comment on lines +1892 to +1900
Convey("inside the window, generator is ready", func() {
insideWindow := gcTimeWindow{
startMin: 0,
endMin: 24 * minutesInHour,
}

gen := &GCTaskGenerator{timeWindow: &insideWindow}
So(gen.IsReady(), ShouldBeTrue)
})
@shcherbak

Copy link
Copy Markdown
Contributor Author

@shcherbak I forgot to ask: what timezone is this supposed to be in? I think it would be very ambiguous for the user as well

Fixed: the window is now explicitly evaluated in UTC. Docs and comments updated to say UTC explicitly.

@thees

thees commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

The downside of this approach is that the gc work is no longer amortized but has to finish within that time window - which it may not.

I created this fix to speed up gc massively: #4236

@rchincha rchincha added this to the v2.1.19 milestone Jul 16, 2026
@rchincha

Copy link
Copy Markdown
Contributor

@shcherbak pls take care of the review comments and ci failures.

We can include this PR in the next release.

@andaaron

Copy link
Copy Markdown
Contributor

What I meant is defining a GCTimeWindow struct in this package (e.g. in config.go), with a custom mapstructure decode hook in the same package — similar in spirit to SinkConfigDecoderHook in pkg/extensions/config/events/decoder.go.

Register that hook in root.go next to StringToTimeDurationHookFunc(). Move parsing/validation (currently in parseGCTimeWindow, Contains, empty string = no restriction) into pkg/api/config; invalid values would then fail at unmarshal time.

gc.Options.TimeWindow would take config.GCTimeWindow directly (like it already takes config.ImageRetention), so pkg/storage/gc would not re-parse or validate — it would only consume the already-decoded value.

This should be cleaner and require no extra packages to import in root.go or gc.go

Please look into this

Comment thread pkg/storage/gc/gc.go
}

// ValidateGCTimeWindow returns an error if window is non-empty and not a valid
// "HH:MM-HH:MM" daily time-of-day range. An empty window is valid and means no restriction.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

00:00-00:00?

Comment thread examples/config-gc.json
"gcDelay": "2h",
"gcInterval": "1h"
"gcInterval": "1h",
"gcTimeWindow": "01:00-08:00"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not including this value means it defaults to a 24h window?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat]: Implement gcTimeWindow in order to reduce storage locking during active usage time

5 participants