Implemented tar+lz4 compression + unit tests for it.

This commit is contained in:
eleksir
2026-03-27 23:37:13 +03:00
parent a73354b59d
commit b494479128
11 changed files with 172 additions and 10 deletions

View File

@@ -182,7 +182,7 @@ Config is JSON (HJSON with comments supported). Default path depends on OS:
| compression_level | `9` | Gzip level (1-9) | | compression_level | `9` | Gzip level (1-9) |
| exclude_patterns | `[]` | Regex patterns to exclude from backup | | exclude_patterns | `[]` | Regex patterns to exclude from backup |
| filename_prefix | `backup` | Prefix for backup filename in Content-Disposition header | | filename_prefix | `backup` | Prefix for backup filename in Content-Disposition header |
| compression_algorithm | `gzip` | Compression algorithm: gzip, bzip2 or zstd | | compression_algorithm | `gzip` | Compression algorithm: gzip, bzip2, zstd or lz4 |
## Architecture & Key Patterns ## Architecture & Key Patterns

View File

@@ -57,7 +57,7 @@ AI suggests to fill this section with useful information, despite that all optio
| compression_level | 9 | Gzip compression level for tar.gz archives. | 1 (fastest) to 9 (best compression). Higher values produce smaller files but take longer to compress. | | compression_level | 9 | Gzip compression level for tar.gz archives. | 1 (fastest) to 9 (best compression). Higher values produce smaller files but take longer to compress. |
| exclude_patterns | [] | Regex patterns to exclude from backup. | E.g., `[".*\\.tmp$", "/node_modules/"]`. Files matching any pattern are skipped. | | exclude_patterns | [] | Regex patterns to exclude from backup. | E.g., `[".*\\.tmp$", "/node_modules/"]`. Files matching any pattern are skipped. |
| filename_prefix | "backup" | Prefix for backup filename in Content-Disposition header. | E.g., "mybackup" produces `mybackup-20260325-092341.tar.gz`. | | filename_prefix | "backup" | Prefix for backup filename in Content-Disposition header. | E.g., "mybackup" produces `mybackup-20260325-092341.tar.gz`. |
| compression_algorithm | "gzip" | Compression algorithm for archive. | Options: "gzip", "bzip2", "zstd". Gzip is fastest, bzip2 compresses better, zstd is a good balance. | | compression_algorithm | "gzip" | Compression algorithm for archive. | Options: "gzip", "bzip2", "zstd", "lz4". Gzip is fastest, bzip2 compresses best, zstd is balanced, lz4 is very fast with moderate compression. |
## Best practices ## Best practices

View File

@@ -4,14 +4,13 @@
* TODO Backer Tasks * TODO Backer Tasks
** TODO Consider different compression algorithms ** DONE Consider different compression algorithms
- [X] Evaluate alternatives to gzip: bz2, xz, lzo, lz4 - [X] Evaluate alternatives to gzip: bz2, xz, lzo, lz4
- CANCELLED lzo. It has cgo implementaion which depends in liblzo, and native lid that does not support streaming. - CANCELLED lzo. It has cgo implementaion which depends in liblzo, and native lid that does not support streaming.
- [X] Implemented bzip2 as alternative (pure Go, via github.com/dsnet/compress) - [X] Implemented bzip2 as alternative (pure Go, via github.com/dsnet/compress)
- [ ] Implement xz as another alternative (pure Go, via github.com/ulikunitz/xz) - [X] Implemented zstd as alternative (pure Go, via github.com/klauspost/compress/zstd)
- [X] Implement zstd as another alternative (pure Go, via github.com/klauspost/compress/zstd)
- [ ] Implement lz4 as another alternative (pure Go, via github.com/pierrec/lz4)
- [X] Add compression_algorithm config option - [X] Add compression_algorithm config option
- Note: xz and lz4 can be added later if needed (pure Go libs available)
** DONE Configurable filename prefix ** DONE Configurable filename prefix
- [X] Move "backup" prefix to config (currently hardcoded in newServer.go) - [X] Move "backup" prefix to config (currently hardcoded in newServer.go)

View File

@@ -47,6 +47,6 @@
/* Prefix for backup filename in Content-Disposition header. */ /* Prefix for backup filename in Content-Disposition header. */
"filename_prefix": "backup", "filename_prefix": "backup",
/* Compression algorithm: gzip, bzip2 or zstd. */ /* Compression algorithm: gzip, bzip2, zstd or lz4. */
"compression_algorithm": "gzip" "compression_algorithm": "gzip"
} }

1
go.mod
View File

@@ -7,4 +7,5 @@ require github.com/hjson/hjson-go v3.3.0+incompatible
require ( require (
github.com/dsnet/compress v0.0.1 // indirect github.com/dsnet/compress v0.0.1 // indirect
github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/compress v1.18.5 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
) )

2
go.sum
View File

@@ -7,4 +7,6 @@ github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=

View File

@@ -142,8 +142,8 @@ func LoadConfig(path string) error {
log.Warnf("Config option compression_algorithm is not set, fallback to %s", C.CompressionAlgorithm) log.Warnf("Config option compression_algorithm is not set, fallback to %s", C.CompressionAlgorithm)
} }
if C.CompressionAlgorithm != "gzip" && C.CompressionAlgorithm != "bzip2" && C.CompressionAlgorithm != "zstd" { if C.CompressionAlgorithm != "gzip" && C.CompressionAlgorithm != "bzip2" && C.CompressionAlgorithm != "zstd" && C.CompressionAlgorithm != "lz4" {
return fmt.Errorf("Compression_algorithm must be gzip, bzip2 or zstd, got %s", C.CompressionAlgorithm) return fmt.Errorf("Compression_algorithm must be gzip, bzip2, zstd or lz4, got %s", C.CompressionAlgorithm)
} }
// Compile exclude patterns. // Compile exclude patterns.

View File

@@ -274,6 +274,27 @@ func TestLoadConfigCompressionAlgorithmZstd(t *testing.T) {
} }
} }
// TestLoadConfigCompressionAlgorithmLz4 tests lz4 compression algorithm.
func TestLoadConfigCompressionAlgorithmLz4(t *testing.T) {
config := `{
"user": "test",
"password": "test",
"directories": ["../../test_data/test1/foo"],
"nohttps": true,
"compression_algorithm": "lz4"
}`
tmpFile := writeTempConfig(t, config)
err := LoadConfig(tmpFile)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if C.CompressionAlgorithm != "lz4" {
t.Errorf("C.CompressionAlgorithm should be lz4, got %s", C.CompressionAlgorithm)
}
}
// TestLoadConfigCompressionAlgorithmInvalid tests invalid compression algorithm. // TestLoadConfigCompressionAlgorithmInvalid tests invalid compression algorithm.
func TestLoadConfigCompressionAlgorithmInvalid(t *testing.T) { func TestLoadConfigCompressionAlgorithmInvalid(t *testing.T) {
config := `{ config := `{

View File

@@ -87,6 +87,10 @@ func NewServer(configPath string) (*http.Server, error) {
archive = CreateTarZstdStream(ctx, files) archive = CreateTarZstdStream(ctx, files)
extension = "tar.zst" extension = "tar.zst"
case "lz4":
archive = CreateTarLz4Stream(ctx, files)
extension = "tar.lz4"
default: default:
archive = CreateTarGzStream(ctx, files) archive = CreateTarGzStream(ctx, files)
extension = "tar.gz" extension = "tar.gz"

View File

@@ -14,6 +14,7 @@ import (
"github.com/dsnet/compress/bzip2" "github.com/dsnet/compress/bzip2"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
"github.com/pierrec/lz4"
) )
// CreateTarGzStream takes a list of files and returns a reader that reads from a tar archive containing these files. // CreateTarGzStream takes a list of files and returns a reader that reads from a tar archive containing these files.
@@ -455,6 +456,140 @@ func CreateTarZstdStream(ctx context.Context, filepaths []string) io.ReadCloser
return pr return pr
} }
// CreateTarLz4Stream takes a list of files and returns a reader that reads from an lz4-compressed tar archive.
func CreateTarLz4Stream(ctx context.Context, filepaths []string) io.ReadCloser {
pr, pw := io.Pipe()
go func() {
var pipeErr error
closePipeWithError := func(err error) {
if pipeErr != nil {
return
}
pipeErr = err
pw.CloseWithError(err)
}
defer func() {
if pipeErr == nil {
pw.Close()
}
}()
lw := lz4.NewWriter(pw)
defer func() {
if pipeErr == nil {
if err := lw.Close(); err != nil {
if !isPipeClosedError(err) {
log.Errorf("Failed to close lz4 writer: %v", err)
}
}
}
}()
tw := tar.NewWriter(lw)
defer func() {
if pipeErr == nil {
if err := tw.Close(); err != nil {
if !isPipeClosedError(err) {
log.Errorf("Failed to close tar writer: %v", err)
}
}
}
}()
for _, fpath := range filepaths {
var (
err error
linkTarget string
)
select {
case <-ctx.Done():
closePipeWithError(ctx.Err())
return
default:
}
log.Debugf("Adding file: %s", fpath)
st, err := os.Lstat(fpath)
if err != nil {
log.Warnf("Skipping %s: %v", fpath, err)
continue
}
if st.Mode()&os.ModeSymlink != 0 {
linkTarget, err = os.Readlink(fpath)
if err != nil {
log.Warnf("Skipping %s: readlink failed: %v", fpath, err)
continue
}
}
if st.Mode()&os.ModeSocket != 0 {
log.Debugf("Skipping socket: %s", fpath)
continue
}
header, err := tar.FileInfoHeader(st, linkTarget)
if err != nil {
log.Warnf("Skipping %s: tar header failed: %v", fpath, err)
continue
}
header.Format = tar.FormatGNU
header.Name = strings.TrimPrefix(filepath.ToSlash(filepath.Clean(fpath)), "/")
mode := st.Mode()
if err := writeWithContext(ctx, func() error {
return tw.WriteHeader(header)
}); err != nil {
closePipeWithError(err)
return
}
if mode.IsRegular() {
f, err := os.Open(fpath)
if err != nil {
log.Warnf("Skipping %s: open failed: %v", fpath, err)
continue
}
err = copyWithContext(ctx, tw, f)
if e := f.Close(); e != nil {
log.Warnf("Failed to close file %s: %v", fpath, e)
}
if err != nil {
log.Warnf("Skipping %s: copy failed: %v", fpath, err)
continue
}
}
}
}()
return pr
}
// isPipeClosedError checks if the error is due to a closed pipe. // isPipeClosedError checks if the error is due to a closed pipe.
func isPipeClosedError(err error) bool { func isPipeClosedError(err error) bool {
if err == nil { if err == nil {

View File

@@ -62,7 +62,7 @@ curl -u user:pass https://server:8086/archive -o backup.tar.gz
| compression_level | 9 | Gzip level (1-9) | | compression_level | 9 | Gzip level (1-9) |
| exclude_patterns | [] | Regex patterns to exclude from backup | | exclude_patterns | [] | Regex patterns to exclude from backup |
| filename_prefix | backup | Prefix for backup filename in Content-Disposition header | | filename_prefix | backup | Prefix for backup filename in Content-Disposition header |
| compression_algorithm | gzip | Compression algorithm (gzip, bzip2 or zstd) | | compression_algorithm | gzip | Compression algorithm (gzip, bzip2, zstd or lz4) |
## Key Patterns ## Key Patterns