Skip to content

Commit

Permalink
resource: Fix multi-threaded image processing issue
Browse files Browse the repository at this point in the history
When doing something like this with the same image from a partial used in, say, both the home page and the single page:

```bash
{{ with $img }}
{{ $big := .Fill "1024x512 top" }}
{{ $small := $big.Resize "512x" }}
{{ end }}
```

There would be timing issues making Hugo in some cases try to process the same image with the same instructions in parallel.

You would experience errors of type:

```bash
png: invalid format: not enough pixel data
```

This commit works around that by adding a mutex per image. This should also improve the performance, sligthly, as it avoids duplicate work.

The current workaround before this fix is to always operate on the original:

```bash
{{ with $img }}
{{ $big := .Fill "1024x512 top" }}
{{ $small := .Fill "512x256 top" }}
{{ end }}
```

Fixes gohugoio#4404
  • Loading branch information
bep committed Feb 14, 2018
1 parent 53dac9a commit 383f45d
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 5 deletions.
3 changes: 3 additions & 0 deletions resource/image.go
Expand Up @@ -112,6 +112,9 @@ type Image struct {

copyToDestinationInit sync.Once

// Lock used when creating alternate versions of this image.
createMu sync.Mutex

imaging *Imaging

hash string
Expand Down
8 changes: 7 additions & 1 deletion resource/image_cache.go
Expand Up @@ -28,7 +28,8 @@ type imageCache struct {
absCacheDir string
pathSpec *helpers.PathSpec
mu sync.RWMutex
store map[string]*Image

store map[string]*Image
}

func (c *imageCache) isInCache(key string) bool {
Expand Down Expand Up @@ -69,6 +70,11 @@ func (c *imageCache) getOrCreate(
}

// Now look in the file cache.
// Multiple Go routines can invoke same operation on the same image, so
// we need to make sure this is serialized per source image.
parent.createMu.Lock()
defer parent.createMu.Unlock()

cacheFilename := filepath.Join(c.absCacheDir, key)

// The definition of this counter is not that we have processed that amount
Expand Down
68 changes: 68 additions & 0 deletions resource/image_test.go
Expand Up @@ -15,8 +15,12 @@ package resource

import (
"fmt"
"math/rand"
"strconv"
"testing"

"sync"

"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -141,6 +145,51 @@ func TestImageTransformLongFilename(t *testing.T) {
assert.Equal("/a/_hu59e56ffff1bc1d8d122b1403d34e039f_90587_c876768085288f41211f768147ba2647.jpg", resized.RelPermalink())
}

func TestImageTransformConcurrent(t *testing.T) {

var wg sync.WaitGroup

assert := require.New(t)

spec := newTestResourceOsFs(assert)

image := fetchImageForSpec(spec, assert, "sunset.jpg")

for i := 0; i < 4; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
img := image
for k := 0; k < 2; k++ {
r1, err := img.Resize(fmt.Sprintf("%dx", id-k))
if err != nil {
t.Fatal(err)
}

if r1.Width() != id-k {
t.Fatalf("Width: %d:%d", r1.Width(), j)
}

r2, err := r1.Resize(fmt.Sprintf("%dx", id-k-1))
if err != nil {
t.Fatal(err)
}

_, err = r2.decodeSource()
if err != nil {
t.Fatal("Err decode:", err)
}

img = r1
}
}
}(i + 20)
}

wg.Wait()
}

func TestDecodeImaging(t *testing.T) {
assert := require.New(t)
m := map[string]interface{}{
Expand Down Expand Up @@ -208,3 +257,22 @@ func TestImageWithMetadata(t *testing.T) {
assert.Equal("Sunset #1", resized.Name())

}

func BenchmarkResizeParallel(b *testing.B) {
assert := require.New(b)
img := fetchSunset(assert)

b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
w := rand.Intn(10) + 10
resized, err := img.Resize(strconv.Itoa(w) + "x")
if err != nil {
b.Fatal(err)
}
_, err = resized.Resize(strconv.Itoa(w-1) + "x")
if err != nil {
b.Fatal(err)
}
}
})
}
46 changes: 42 additions & 4 deletions resource/testhelpers_test.go
Expand Up @@ -6,8 +6,11 @@ import (

"image"
"io"
"io/ioutil"
"os"
"path"
"runtime"
"strings"

"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/hugofs"
Expand Down Expand Up @@ -45,17 +48,53 @@ func newTestResourceSpecForBaseURL(assert *require.Assertions, baseURL string) *
return spec
}

func newTestResourceOsFs(assert *require.Assertions) *Spec {
cfg := viper.New()
cfg.Set("baseURL", "https://example.com")

workDir, err := ioutil.TempDir("", "hugores")

if runtime.GOOS == "darwin" && !strings.HasPrefix(workDir, "/private") {
// To get the entry folder in line with the rest. This its a little bit
// mysterious, but so be it.
workDir = "/private" + workDir
}

contentDir := "base"
cfg.Set("workingDir", workDir)
cfg.Set("contentDir", contentDir)
cfg.Set("resourceDir", filepath.Join(workDir, "res"))

fs := hugofs.NewFrom(hugofs.Os, cfg)
fs.Destination = &afero.MemMapFs{}

s, err := helpers.NewPathSpec(fs, cfg)

assert.NoError(err)

spec, err := NewSpec(s, media.DefaultTypes)
assert.NoError(err)
return spec

}

func fetchSunset(assert *require.Assertions) *Image {
return fetchImage(assert, "sunset.jpg")
}

func fetchImage(assert *require.Assertions, name string) *Image {
spec := newTestResourceSpec(assert)
return fetchImageForSpec(spec, assert, name)
}

func fetchImageForSpec(spec *Spec, assert *require.Assertions, name string) *Image {
src, err := os.Open("testdata/" + name)
assert.NoError(err)

spec := newTestResourceSpec(assert)
workingDir := spec.Cfg.GetString("workingDir")
f := filepath.Join(workingDir, name)

out, err := spec.Fs.Source.Create("/b/" + name)
out, err := spec.Fs.Source.Create(f)
assert.NoError(err)
_, err = io.Copy(out, src)
out.Close()
Expand All @@ -66,11 +105,10 @@ func fetchImage(assert *require.Assertions, name string) *Image {
return path.Join("/a", s)
}

r, err := spec.NewResourceFromFilename(factory, "/public", "/b/"+name, name)
r, err := spec.NewResourceFromFilename(factory, "/public", f, name)
assert.NoError(err)
assert.IsType(&Image{}, r)
return r.(*Image)

}

func assertFileCache(assert *require.Assertions, fs *hugofs.Fs, filename string, width, height int) {
Expand Down

0 comments on commit 383f45d

Please sign in to comment.