Support compression for Actions logs (#31761)
Support compression for Actions logs to save storage space and bandwidth. Inspired by https://github.com/go-gitea/gitea/issues/24256#issuecomment-1521153015 The biggest challenge is that the compression format should support [seekable](https://github.com/facebook/zstd/blob/dev/contrib/seekable_format/zstd_seekable_compression_format.md). So when users are viewing a part of the log lines, Gitea doesn't need to download the whole compressed file and decompress it. That means gzip cannot help here. And I did research, there aren't too many choices, like bgzip and xz, but I think zstd is the most popular one. It has an implementation in Golang with [zstd](https://github.com/klauspost/compress/tree/master/zstd) and [zstd-seekable-format-go](https://github.com/SaveTheRbtz/zstd-seekable-format-go), and what is better is that it has good compatibility: a seekable format zstd file can be read by a regular zstd reader. This PR introduces a new package `zstd` to combine and wrap the two packages, to provide a unified and easy-to-use API. And a new setting `LOG_COMPRESSION` is added to the config, although I don't see any reason why not to use compression, I think's it's a good idea to keep the default with `none` to be consistent with old versions. `LOG_COMPRESSION` takes effect for only new log files, it adds `.zst` as an extension to the file name, so Gitea can determine if it needs decompression according to the file name when reading. Old files will keep the format since it's not worth converting them, as they will be cleared after #31735. <img width="541" alt="image" src="https://github.com/user-attachments/assets/e9598764-a4e0-4b68-8c2b-f769265183c9"> (cherry picked from commit 33cc5837a655ad544b936d4d040ca36d74092588) Conflicts: assets/go-licenses.json go.mod go.sum resolved with make tidy
This commit is contained in:
parent
8b6747173a
commit
a627b885c7
15 changed files with 615 additions and 9 deletions
10
assets/go-licenses.json
generated
10
assets/go-licenses.json
generated
File diff suppressed because one or more lines are too long
|
@ -2712,6 +2712,12 @@ LEVEL = Info
|
||||||
;DEFAULT_ACTIONS_URL = https://code.forgejo.org
|
;DEFAULT_ACTIONS_URL = https://code.forgejo.org
|
||||||
;; Logs retention time in days. Old logs will be deleted after this period.
|
;; Logs retention time in days. Old logs will be deleted after this period.
|
||||||
;LOG_RETENTION_DAYS = 365
|
;LOG_RETENTION_DAYS = 365
|
||||||
|
;; Log compression type, `none` for no compression, `zstd` for zstd compression.
|
||||||
|
;; Other compression types like `gzip` if NOT supported, since seekable stream is required for log view.
|
||||||
|
;; It's always recommended to use compression when using local disk as log storage if CPU or memory is not a bottleneck.
|
||||||
|
;; And for object storage services like S3, which is billed for requests, it would cause extra 2 times of get requests for each log view.
|
||||||
|
;; But it will save storage space and network bandwidth, so it's still recommended to use compression.
|
||||||
|
;LOG_COMPRESSION = none
|
||||||
;; Default artifact retention time in days. Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step.
|
;; Default artifact retention time in days. Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step.
|
||||||
;ARTIFACT_RETENTION_DAYS = 90
|
;ARTIFACT_RETENTION_DAYS = 90
|
||||||
;; Timeout to stop the task which have running status, but haven't been updated for a long time
|
;; Timeout to stop the task which have running status, but haven't been updated for a long time
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -19,6 +19,7 @@ require (
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
|
||||||
github.com/ProtonMail/go-crypto v1.0.0
|
github.com/ProtonMail/go-crypto v1.0.0
|
||||||
github.com/PuerkitoBio/goquery v1.9.2
|
github.com/PuerkitoBio/goquery v1.9.2
|
||||||
|
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.7.2
|
||||||
github.com/alecthomas/chroma/v2 v2.14.0
|
github.com/alecthomas/chroma/v2 v2.14.0
|
||||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
|
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
|
||||||
github.com/blevesearch/bleve/v2 v2.4.2
|
github.com/blevesearch/bleve/v2 v2.4.2
|
||||||
|
@ -200,6 +201,7 @@ require (
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
|
github.com/google/btree v1.1.2 // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/go-tpm v0.9.0 // indirect
|
github.com/google/go-tpm v0.9.0 // indirect
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -65,6 +65,8 @@ github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4
|
||||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||||
github.com/RoaringBitmap/roaring v1.9.3 h1:t4EbC5qQwnisr5PrP9nt0IRhRTb9gMUgQF4t4S2OByM=
|
github.com/RoaringBitmap/roaring v1.9.3 h1:t4EbC5qQwnisr5PrP9nt0IRhRTb9gMUgQF4t4S2OByM=
|
||||||
github.com/RoaringBitmap/roaring v1.9.3/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
|
github.com/RoaringBitmap/roaring v1.9.3/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
|
||||||
|
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.7.2 h1:cSXom2MoKJ9KPPw29RoZtHvUETY4F4n/kXl8m9btnQ0=
|
||||||
|
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.7.2/go.mod h1:JitQWJ8JuV4Y87l8VsHiiwhb3cgdyn68mX40s7NT6PA=
|
||||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||||
|
@ -350,6 +352,8 @@ github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
|
||||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
|
||||||
|
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
|
|
@ -502,7 +502,13 @@ func convertTimestamp(timestamp *timestamppb.Timestamp) timeutil.TimeStamp {
|
||||||
}
|
}
|
||||||
|
|
||||||
func logFileName(repoFullName string, taskID int64) string {
|
func logFileName(repoFullName string, taskID int64) string {
|
||||||
return fmt.Sprintf("%s/%02x/%d.log", repoFullName, taskID%256, taskID)
|
ret := fmt.Sprintf("%s/%02x/%d.log", repoFullName, taskID%256, taskID)
|
||||||
|
|
||||||
|
if setting.Actions.LogCompression.IsZstd() {
|
||||||
|
ret += ".zst"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTaskIDFromCache(token string) int64 {
|
func getTaskIDFromCache(token string) int64 {
|
||||||
|
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"code.gitea.io/gitea/models/dbfs"
|
"code.gitea.io/gitea/models/dbfs"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/storage"
|
"code.gitea.io/gitea/modules/storage"
|
||||||
|
"code.gitea.io/gitea/modules/zstd"
|
||||||
|
|
||||||
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
runnerv1 "code.gitea.io/actions-proto-go/runner/v1"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
@ -28,6 +29,9 @@ const (
|
||||||
defaultBufSize = MaxLineSize
|
defaultBufSize = MaxLineSize
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// WriteLogs appends logs to DBFS file for temporary storage.
|
||||||
|
// It doesn't respect the file format in the filename like ".zst", since it's difficult to reopen a closed compressed file and append new content.
|
||||||
|
// Why doesn't it store logs in object storage directly? Because it's not efficient to append content to object storage.
|
||||||
func WriteLogs(ctx context.Context, filename string, offset int64, rows []*runnerv1.LogRow) ([]int, error) {
|
func WriteLogs(ctx context.Context, filename string, offset int64, rows []*runnerv1.LogRow) ([]int, error) {
|
||||||
flag := os.O_WRONLY
|
flag := os.O_WRONLY
|
||||||
if offset == 0 {
|
if offset == 0 {
|
||||||
|
@ -106,6 +110,17 @@ func ReadLogs(ctx context.Context, inStorage bool, filename string, offset, limi
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// logZstdBlockSize is the block size for zstd compression.
|
||||||
|
// 128KB leads the compression ratio to be close to the regular zstd compression.
|
||||||
|
// And it means each read from the underlying object storage will be at least 128KB*(compression ratio).
|
||||||
|
// The compression ratio is about 30% for text files, so the actual read size is about 38KB, which should be acceptable.
|
||||||
|
logZstdBlockSize = 128 * 1024 // 128KB
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransferLogs transfers logs from DBFS to object storage.
|
||||||
|
// It happens when the file is complete and no more logs will be appended.
|
||||||
|
// It respects the file format in the filename like ".zst", and compresses the content if needed.
|
||||||
func TransferLogs(ctx context.Context, filename string) (func(), error) {
|
func TransferLogs(ctx context.Context, filename string) (func(), error) {
|
||||||
name := DBFSPrefix + filename
|
name := DBFSPrefix + filename
|
||||||
remove := func() {
|
remove := func() {
|
||||||
|
@ -119,7 +134,26 @@ func TransferLogs(ctx context.Context, filename string) (func(), error) {
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
if _, err := storage.Actions.Save(filename, f, -1); err != nil {
|
var reader io.Reader = f
|
||||||
|
if strings.HasSuffix(filename, ".zst") {
|
||||||
|
r, w := io.Pipe()
|
||||||
|
reader = r
|
||||||
|
zstdWriter, err := zstd.NewSeekableWriter(w, logZstdBlockSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zstd NewSeekableWriter: %w", err)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
_ = w.CloseWithError(zstdWriter.Close())
|
||||||
|
}()
|
||||||
|
if _, err := io.Copy(zstdWriter, f); err != nil {
|
||||||
|
_ = w.CloseWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := storage.Actions.Save(filename, reader, -1); err != nil {
|
||||||
return nil, fmt.Errorf("storage save %q: %w", filename, err)
|
return nil, fmt.Errorf("storage save %q: %w", filename, err)
|
||||||
}
|
}
|
||||||
return remove, nil
|
return remove, nil
|
||||||
|
@ -150,11 +184,22 @@ func OpenLogs(ctx context.Context, inStorage bool, filename string) (io.ReadSeek
|
||||||
}
|
}
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := storage.Actions.Open(filename)
|
f, err := storage.Actions.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("storage open %q: %w", filename, err)
|
return nil, fmt.Errorf("storage open %q: %w", filename, err)
|
||||||
}
|
}
|
||||||
return f, nil
|
|
||||||
|
var reader io.ReadSeekCloser = f
|
||||||
|
if strings.HasSuffix(filename, ".zst") {
|
||||||
|
r, err := zstd.NewSeekableReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zstd NewSeekableReader: %w", err)
|
||||||
|
}
|
||||||
|
reader = r
|
||||||
|
}
|
||||||
|
|
||||||
|
return reader, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatLog(timestamp time.Time, content string) string {
|
func FormatLog(timestamp time.Time, content string) string {
|
||||||
|
|
|
@ -13,8 +13,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/json"
|
"code.gitea.io/gitea/modules/json"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/modules/validation"
|
"code.gitea.io/gitea/modules/validation"
|
||||||
|
"code.gitea.io/gitea/modules/zstd"
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
@ -10,8 +10,9 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/zstd"
|
||||||
|
|
||||||
"github.com/dsnet/compress/bzip2"
|
"github.com/dsnet/compress/bzip2"
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
|
@ -14,9 +14,9 @@ import (
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/modules/validation"
|
"code.gitea.io/gitea/modules/validation"
|
||||||
|
"code.gitea.io/gitea/modules/zstd"
|
||||||
|
|
||||||
"github.com/blakesmith/ar"
|
"github.com/blakesmith/ar"
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
"github.com/ulikunitz/xz"
|
"github.com/ulikunitz/xz"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -10,8 +10,9 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/zstd"
|
||||||
|
|
||||||
"github.com/blakesmith/ar"
|
"github.com/blakesmith/ar"
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/ulikunitz/xz"
|
"github.com/ulikunitz/xz"
|
||||||
|
|
|
@ -15,6 +15,7 @@ var (
|
||||||
Enabled bool
|
Enabled bool
|
||||||
LogStorage *Storage // how the created logs should be stored
|
LogStorage *Storage // how the created logs should be stored
|
||||||
LogRetentionDays int64 `ini:"LOG_RETENTION_DAYS"`
|
LogRetentionDays int64 `ini:"LOG_RETENTION_DAYS"`
|
||||||
|
LogCompression logCompression `ini:"LOG_COMPRESSION"`
|
||||||
ArtifactStorage *Storage // how the created artifacts should be stored
|
ArtifactStorage *Storage // how the created artifacts should be stored
|
||||||
ArtifactRetentionDays int64 `ini:"ARTIFACT_RETENTION_DAYS"`
|
ArtifactRetentionDays int64 `ini:"ARTIFACT_RETENTION_DAYS"`
|
||||||
DefaultActionsURL defaultActionsURL `ini:"DEFAULT_ACTIONS_URL"`
|
DefaultActionsURL defaultActionsURL `ini:"DEFAULT_ACTIONS_URL"`
|
||||||
|
@ -50,6 +51,20 @@ const (
|
||||||
defaultActionsURLSelf = "self" // the root URL of the self-hosted instance
|
defaultActionsURLSelf = "self" // the root URL of the self-hosted instance
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type logCompression string
|
||||||
|
|
||||||
|
func (c logCompression) IsValid() bool {
|
||||||
|
return c.IsNone() || c.IsZstd()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c logCompression) IsNone() bool {
|
||||||
|
return c == "" || strings.ToLower(string(c)) == "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c logCompression) IsZstd() bool {
|
||||||
|
return strings.ToLower(string(c)) == "zstd"
|
||||||
|
}
|
||||||
|
|
||||||
func loadActionsFrom(rootCfg ConfigProvider) error {
|
func loadActionsFrom(rootCfg ConfigProvider) error {
|
||||||
sec := rootCfg.Section("actions")
|
sec := rootCfg.Section("actions")
|
||||||
err := sec.MapTo(&Actions)
|
err := sec.MapTo(&Actions)
|
||||||
|
@ -83,5 +98,9 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
|
||||||
Actions.EndlessTaskTimeout = sec.Key("ENDLESS_TASK_TIMEOUT").MustDuration(3 * time.Hour)
|
Actions.EndlessTaskTimeout = sec.Key("ENDLESS_TASK_TIMEOUT").MustDuration(3 * time.Hour)
|
||||||
Actions.AbandonedJobTimeout = sec.Key("ABANDONED_JOB_TIMEOUT").MustDuration(24 * time.Hour)
|
Actions.AbandonedJobTimeout = sec.Key("ABANDONED_JOB_TIMEOUT").MustDuration(24 * time.Hour)
|
||||||
|
|
||||||
|
if !Actions.LogCompression.IsValid() {
|
||||||
|
return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
46
modules/zstd/option.go
Normal file
46
modules/zstd/option.go
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package zstd
|
||||||
|
|
||||||
|
import "github.com/klauspost/compress/zstd"
|
||||||
|
|
||||||
|
type WriterOption = zstd.EOption
|
||||||
|
|
||||||
|
var (
|
||||||
|
WithEncoderCRC = zstd.WithEncoderCRC
|
||||||
|
WithEncoderConcurrency = zstd.WithEncoderConcurrency
|
||||||
|
WithWindowSize = zstd.WithWindowSize
|
||||||
|
WithEncoderPadding = zstd.WithEncoderPadding
|
||||||
|
WithEncoderLevel = zstd.WithEncoderLevel
|
||||||
|
WithZeroFrames = zstd.WithZeroFrames
|
||||||
|
WithAllLitEntropyCompression = zstd.WithAllLitEntropyCompression
|
||||||
|
WithNoEntropyCompression = zstd.WithNoEntropyCompression
|
||||||
|
WithSingleSegment = zstd.WithSingleSegment
|
||||||
|
WithLowerEncoderMem = zstd.WithLowerEncoderMem
|
||||||
|
WithEncoderDict = zstd.WithEncoderDict
|
||||||
|
WithEncoderDictRaw = zstd.WithEncoderDictRaw
|
||||||
|
)
|
||||||
|
|
||||||
|
type EncoderLevel = zstd.EncoderLevel
|
||||||
|
|
||||||
|
const (
|
||||||
|
SpeedFastest EncoderLevel = zstd.SpeedFastest
|
||||||
|
SpeedDefault EncoderLevel = zstd.SpeedDefault
|
||||||
|
SpeedBetterCompression EncoderLevel = zstd.SpeedBetterCompression
|
||||||
|
SpeedBestCompression EncoderLevel = zstd.SpeedBestCompression
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReaderOption = zstd.DOption
|
||||||
|
|
||||||
|
var (
|
||||||
|
WithDecoderLowmem = zstd.WithDecoderLowmem
|
||||||
|
WithDecoderConcurrency = zstd.WithDecoderConcurrency
|
||||||
|
WithDecoderMaxMemory = zstd.WithDecoderMaxMemory
|
||||||
|
WithDecoderDicts = zstd.WithDecoderDicts
|
||||||
|
WithDecoderDictRaw = zstd.WithDecoderDictRaw
|
||||||
|
WithDecoderMaxWindow = zstd.WithDecoderMaxWindow
|
||||||
|
WithDecodeAllCapLimit = zstd.WithDecodeAllCapLimit
|
||||||
|
WithDecodeBuffersBelow = zstd.WithDecodeBuffersBelow
|
||||||
|
IgnoreChecksum = zstd.IgnoreChecksum
|
||||||
|
)
|
163
modules/zstd/zstd.go
Normal file
163
modules/zstd/zstd.go
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package zstd provides a high-level API for reading and writing zstd-compressed data.
|
||||||
|
// It supports both regular and seekable zstd streams.
|
||||||
|
// It's not a new wheel, but a wrapper around the zstd and zstd-seekable-format-go packages.
|
||||||
|
package zstd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
seekable "github.com/SaveTheRbtz/zstd-seekable-format-go/pkg"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Writer zstd.Encoder
|
||||||
|
|
||||||
|
var _ io.WriteCloser = (*Writer)(nil)
|
||||||
|
|
||||||
|
// NewWriter returns a new zstd writer.
|
||||||
|
func NewWriter(w io.Writer, opts ...WriterOption) (*Writer, error) {
|
||||||
|
zstdW, err := zstd.NewWriter(w, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return (*Writer)(zstdW), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Writer) Write(p []byte) (int, error) {
|
||||||
|
return (*zstd.Encoder)(w).Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Writer) Close() error {
|
||||||
|
return (*zstd.Encoder)(w).Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reader zstd.Decoder
|
||||||
|
|
||||||
|
var _ io.ReadCloser = (*Reader)(nil)
|
||||||
|
|
||||||
|
// NewReader returns a new zstd reader.
|
||||||
|
func NewReader(r io.Reader, opts ...ReaderOption) (*Reader, error) {
|
||||||
|
zstdR, err := zstd.NewReader(r, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return (*Reader)(zstdR), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reader) Read(p []byte) (int, error) {
|
||||||
|
return (*zstd.Decoder)(r).Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reader) Close() error {
|
||||||
|
(*zstd.Decoder)(r).Close() // no error returned
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeekableWriter struct {
|
||||||
|
buf []byte
|
||||||
|
n int
|
||||||
|
w seekable.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ io.WriteCloser = (*SeekableWriter)(nil)
|
||||||
|
|
||||||
|
// NewSeekableWriter returns a zstd writer to compress data to seekable format.
|
||||||
|
// blockSize is an important parameter, it should be decided according to the actual business requirements.
|
||||||
|
// If it's too small, the compression ratio could be very bad, even no compression at all.
|
||||||
|
// If it's too large, it could cost more traffic when reading the data partially from underlying storage.
|
||||||
|
func NewSeekableWriter(w io.Writer, blockSize int, opts ...WriterOption) (*SeekableWriter, error) {
|
||||||
|
zstdW, err := zstd.NewWriter(nil, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seekableW, err := seekable.NewWriter(w, zstdW)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SeekableWriter{
|
||||||
|
buf: make([]byte, blockSize),
|
||||||
|
w: seekableW,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *SeekableWriter) Write(p []byte) (int, error) {
|
||||||
|
written := 0
|
||||||
|
for len(p) > 0 {
|
||||||
|
n := copy(w.buf[w.n:], p)
|
||||||
|
w.n += n
|
||||||
|
written += n
|
||||||
|
p = p[n:]
|
||||||
|
|
||||||
|
if w.n == len(w.buf) {
|
||||||
|
if _, err := w.w.Write(w.buf); err != nil {
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
|
w.n = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *SeekableWriter) Close() error {
|
||||||
|
if w.n > 0 {
|
||||||
|
if _, err := w.w.Write(w.buf[:w.n]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeekableReader struct {
|
||||||
|
r seekable.Reader
|
||||||
|
c func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ io.ReadSeekCloser = (*SeekableReader)(nil)
|
||||||
|
|
||||||
|
// NewSeekableReader returns a zstd reader to decompress data from seekable format.
|
||||||
|
func NewSeekableReader(r io.ReadSeeker, opts ...ReaderOption) (*SeekableReader, error) {
|
||||||
|
zstdR, err := zstd.NewReader(nil, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seekableR, err := seekable.NewReader(r, zstdR)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := &SeekableReader{
|
||||||
|
r: seekableR,
|
||||||
|
}
|
||||||
|
if closer, ok := r.(io.Closer); ok {
|
||||||
|
ret.c = closer.Close
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SeekableReader) Read(p []byte) (int, error) {
|
||||||
|
return r.r.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SeekableReader) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
return r.r.Seek(offset, whence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SeekableReader) Close() error {
|
||||||
|
return errors.Join(
|
||||||
|
func() error {
|
||||||
|
if r.c != nil {
|
||||||
|
return r.c()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}(),
|
||||||
|
r.r.Close(),
|
||||||
|
)
|
||||||
|
}
|
304
modules/zstd/zstd_test.go
Normal file
304
modules/zstd/zstd_test.go
Normal file
|
@ -0,0 +1,304 @@
|
||||||
|
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package zstd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriterReader(t *testing.T) {
|
||||||
|
testData := prepareTestData(t, 20_000_000)
|
||||||
|
|
||||||
|
result := bytes.NewBuffer(nil)
|
||||||
|
|
||||||
|
t.Run("regular", func(t *testing.T) {
|
||||||
|
result.Reset()
|
||||||
|
writer, err := NewWriter(result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, bytes.NewReader(testData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
|
||||||
|
|
||||||
|
reader, err := NewReader(result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, testData, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with options", func(t *testing.T) {
|
||||||
|
result.Reset()
|
||||||
|
writer, err := NewWriter(result, WithEncoderLevel(SpeedBestCompression))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, bytes.NewReader(testData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
|
||||||
|
|
||||||
|
reader, err := NewReader(result, WithDecoderLowmem(true))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, testData, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeekableWriterReader(t *testing.T) {
|
||||||
|
testData := prepareTestData(t, 20_000_000)
|
||||||
|
|
||||||
|
result := bytes.NewBuffer(nil)
|
||||||
|
|
||||||
|
t.Run("regular", func(t *testing.T) {
|
||||||
|
result.Reset()
|
||||||
|
blockSize := 100_000
|
||||||
|
|
||||||
|
writer, err := NewSeekableWriter(result, blockSize)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, bytes.NewReader(testData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
|
||||||
|
|
||||||
|
reader, err := NewSeekableReader(bytes.NewReader(result.Bytes()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, testData, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("seek read", func(t *testing.T) {
|
||||||
|
result.Reset()
|
||||||
|
blockSize := 100_000
|
||||||
|
|
||||||
|
writer, err := NewSeekableWriter(result, blockSize)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, bytes.NewReader(testData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
|
||||||
|
|
||||||
|
assertReader := &assertReadSeeker{r: bytes.NewReader(result.Bytes())}
|
||||||
|
|
||||||
|
reader, err := NewSeekableReader(assertReader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = reader.Seek(10_000_000, io.SeekStart)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data := make([]byte, 1000)
|
||||||
|
_, err = io.ReadFull(reader, data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, testData[10_000_000:10_000_000+1000], data)
|
||||||
|
|
||||||
|
// Should seek 3 times,
|
||||||
|
// the first two times are for getting the index,
|
||||||
|
// and the third time is for reading the data.
|
||||||
|
assert.Equal(t, 3, assertReader.SeekTimes)
|
||||||
|
// Should read less than 2 blocks,
|
||||||
|
// even if the compression ratio is not good and the data is not in the same block.
|
||||||
|
assert.Less(t, assertReader.ReadBytes, blockSize*2)
|
||||||
|
// Should close the underlying reader if it is Closer.
|
||||||
|
assert.True(t, assertReader.Closed)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tidy data", func(t *testing.T) {
|
||||||
|
testData := prepareTestData(t, 1000) // data size is less than a block
|
||||||
|
|
||||||
|
result.Reset()
|
||||||
|
blockSize := 100_000
|
||||||
|
|
||||||
|
writer, err := NewSeekableWriter(result, blockSize)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, bytes.NewReader(testData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
|
||||||
|
|
||||||
|
reader, err := NewSeekableReader(bytes.NewReader(result.Bytes()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, testData, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tidy block", func(t *testing.T) {
|
||||||
|
result.Reset()
|
||||||
|
blockSize := 100
|
||||||
|
|
||||||
|
writer, err := NewSeekableWriter(result, blockSize)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, bytes.NewReader(testData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
|
||||||
|
// A too small block size will cause a bad compression rate,
|
||||||
|
// even the compressed data is larger than the original data.
|
||||||
|
assert.Greater(t, result.Len(), len(testData))
|
||||||
|
|
||||||
|
reader, err := NewSeekableReader(bytes.NewReader(result.Bytes()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, testData, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("compatible reader", func(t *testing.T) {
|
||||||
|
result.Reset()
|
||||||
|
blockSize := 100_000
|
||||||
|
|
||||||
|
writer, err := NewSeekableWriter(result, blockSize)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, bytes.NewReader(testData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
|
||||||
|
|
||||||
|
// It should be able to read the data with a regular reader.
|
||||||
|
reader, err := NewReader(bytes.NewReader(result.Bytes()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, reader.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, testData, data)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong reader", func(t *testing.T) {
|
||||||
|
result.Reset()
|
||||||
|
|
||||||
|
// Use a regular writer to compress the data.
|
||||||
|
writer, err := NewWriter(result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, bytes.NewReader(testData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
t.Logf("original size: %d, compressed size: %d, rate: %.2f%%", len(testData), result.Len(), float64(result.Len())/float64(len(testData))*100)
|
||||||
|
|
||||||
|
// But use a seekable reader to read the data, it should fail.
|
||||||
|
_, err = NewSeekableReader(bytes.NewReader(result.Bytes()))
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareTestData prepares test data to test compression.
|
||||||
|
// Random data is not suitable for testing compression,
|
||||||
|
// so it collects code files from the project to get enough data.
|
||||||
|
func prepareTestData(t *testing.T, size int) []byte {
|
||||||
|
// .../gitea/modules/zstd
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
// .../gitea/
|
||||||
|
dir = filepath.Join(dir, "../../")
|
||||||
|
|
||||||
|
textExt := []string{".go", ".tmpl", ".ts", ".yml", ".css"} // add more if not enough data collected
|
||||||
|
isText := func(info os.FileInfo) bool {
|
||||||
|
if info.Size() == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ext := range textExt {
|
||||||
|
if strings.HasSuffix(info.Name(), ext) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := make([]byte, size)
|
||||||
|
n := 0
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
queue := []string{dir}
|
||||||
|
for len(queue) > 0 && n < size {
|
||||||
|
file := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
info, err := os.Stat(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if info.IsDir() {
|
||||||
|
entries, err := os.ReadDir(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, entry := range entries {
|
||||||
|
queue = append(queue, filepath.Join(file, entry.Name()))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isText(info) { // text file only
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
n += copy(ret[n:], data)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if n < size {
|
||||||
|
require.Failf(t, "Not enough data", "Only %d bytes collected from %d files", n, count)
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
type assertReadSeeker struct {
|
||||||
|
r io.ReadSeeker
|
||||||
|
SeekTimes int
|
||||||
|
ReadBytes int
|
||||||
|
Closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *assertReadSeeker) Read(p []byte) (int, error) {
|
||||||
|
n, err := a.r.Read(p)
|
||||||
|
a.ReadBytes += n
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *assertReadSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
a.SeekTimes++
|
||||||
|
return a.r.Seek(offset, whence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *assertReadSeeker) Close() error {
|
||||||
|
a.Closed = true
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -17,10 +17,10 @@ import (
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
conda_module "code.gitea.io/gitea/modules/packages/conda"
|
conda_module "code.gitea.io/gitea/modules/packages/conda"
|
||||||
|
"code.gitea.io/gitea/modules/zstd"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
"github.com/dsnet/compress/bzip2"
|
"github.com/dsnet/compress/bzip2"
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
Loading…
Add table
Reference in a new issue