Simpler image service (#580)

* fix potential race on close

* move validate inside

* demote commit and cleanup in image.Store to non-exposed functions.

* replace immediate image commit with delayed via Submit

* minor: remove error logging, rename tests

* minor: err wrapping, comments wording

* clarify FileSystem.Save code

* attempt to fix #584 by making submitted image commits on a half of TTL

Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
This commit is contained in:
Umputun
2020-01-27 14:17:58 -06:00
committed by GitHub
co-authored by Dmitry Verkhoturov
parent be3e5f0246
commit 019fa9e5ac
11 changed files with 136 additions and 129 deletions
+2 -6
View File
@@ -159,10 +159,7 @@ func (p Image) cacheImage(r io.Reader, imgID string) {
if err != nil {
log.Printf("[WARN] unable to save image to the storage: %+v", err)
}
// In the future we can do something smarter than just committing everything (eg, some kind of LFU/LRU)
if err := p.ImageService.Commit(id); err != nil {
log.Printf("[WARN] unable to commit image %s", imgID)
}
p.ImageService.Submit(func() []string { return []string{id} })
}
// download an image. Returns a Reader which has to be closed by a caller
@@ -189,8 +186,7 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) (io.ReadCloser,
return e
})
if err != nil {
log.Print(err.Error())
return nil, err
return nil, errors.Wrapf(err, "can't download image %s", imgURL)
}
if resp.StatusCode != http.StatusOK {
+15 -15
View File
@@ -19,7 +19,7 @@ import (
"github.com/umputun/remark/backend/app/store/image"
)
func TestPicture_Extract(t *testing.T) {
func TestImage_Extract(t *testing.T) {
tbl := []struct {
inp string
@@ -61,7 +61,7 @@ func TestPicture_Extract(t *testing.T) {
}
}
func TestPicture_Replace(t *testing.T) {
func TestImage_Replace(t *testing.T) {
img := Image{HTTP2HTTPS: true, RoutePath: "/img"}
r := img.replace(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`,
[]string{"http://radio-t.com/img3.png", "http://images.pexels.com/67636/img4.jpeg"})
@@ -73,7 +73,7 @@ func TestImage_Routes(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
httpSrv := imgHTTPServer(t)
httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close()
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
@@ -95,7 +95,7 @@ func TestImage_Routes(t *testing.T) {
assert.Equal(t, 400, resp.StatusCode)
}
func TestImage_Routes_CachingImage(t *testing.T) {
func TestImage_RoutesCachingImage(t *testing.T) {
imageStore := image.MockStore{}
img := Image{
CacheExternal: true,
@@ -106,7 +106,7 @@ func TestImage_Routes_CachingImage(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
httpSrv := imgHTTPServer(t)
httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close()
imgURL := httpSrv.URL + "/image/img1.png"
@@ -114,7 +114,7 @@ func TestImage_Routes_CachingImage(t *testing.T) {
imageStore.On("Load", mock.Anything).Once().Return(nil, int64(0), nil)
imageStore.On("SaveWithID", mock.Anything, mock.Anything).Once().Run(func(args mock.Arguments) { _, _ = ioutil.ReadAll(args.Get(1).(io.Reader)) }).Return("", nil)
imageStore.On("Commit", mock.Anything).Once().Return(nil)
imageStore.On("commit", mock.Anything).Once().Return(nil)
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
require.Nil(t, err)
@@ -124,10 +124,10 @@ func TestImage_Routes_CachingImage(t *testing.T) {
imageStore.AssertCalled(t, "Load", mock.Anything)
imageStore.AssertCalled(t, "SaveWithID", "cached_images/4b84b15bff6ee5796152495a230e45e3d7e947d9-"+sha1Str(imgURL), mock.Anything)
imageStore.AssertCalled(t, "Commit", mock.Anything)
imageStore.AssertCalled(t, "commit", mock.Anything)
}
func TestImage_Routes_Using_Cachded_Image(t *testing.T) {
func TestImage_RoutesUsingCachedImage(t *testing.T) {
imageStore := image.MockStore{}
img := Image{
CacheExternal: true,
@@ -138,12 +138,12 @@ func TestImage_Routes_Using_Cachded_Image(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
httpSrv := imgHTTPServer(t)
httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close()
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
// In order to validate that cached data is used cache "will return" some other data from what http server would
// In order to validate that cached data used cache "will return" some other data from what http server would
imageReader := ioutil.NopCloser(bytes.NewReader([]byte(fmt.Sprintf("%256s", "X"))))
imageStore.On("Load", mock.Anything).Once().Return(imageReader, int64(256), nil)
@@ -161,7 +161,7 @@ func TestImage_RoutesTimedOut(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
defer ts.Close()
httpSrv := imgHTTPServer(t)
httpSrv := imgHTTPTestsServer(t)
defer httpSrv.Close()
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img-slow.png"))
@@ -174,7 +174,7 @@ func TestImage_RoutesTimedOut(t *testing.T) {
assert.True(t, strings.Contains(string(b), "deadline exceeded"))
}
func TestPicture_Convert_ProxyMode(t *testing.T) {
func TestImage_ConvertProxyMode(t *testing.T) {
img := Image{HTTP2HTTPS: true, RoutePath: "/img"}
r := img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
@@ -191,7 +191,7 @@ func TestPicture_Convert_ProxyMode(t *testing.T) {
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/> xyz`, r, "disabled, no proxy")
}
func TestPicture_Convert_CachingMode(t *testing.T) {
func TestImage_ConvertCachingMode(t *testing.T) {
img := Image{CacheExternal: true, RoutePath: "/img", RemarkURL: "https://remark42.com"}
r := img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://remark42.com/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="https://remark42.com/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
@@ -206,13 +206,13 @@ func TestPicture_Convert_CachingMode(t *testing.T) {
r = img.Convert(`<img src="http://radio-t.com/img3.png"/>`)
assert.Equal(t, `<img src="http://radio-t.com/img3.png"/>`, r)
// both Caching and Proxy are enabled
// both Caching and Proxy enabled
img = Image{CacheExternal: true, HTTP2HTTPS: true, RoutePath: "/img", RemarkURL: "https://remark42.com"}
r = img.Convert(`<img src="http://radio-t.com/img3.png"/> xyz <img src="http://images.pexels.com/67636/img4.jpeg">`)
assert.Equal(t, `<img src="https://remark42.com/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="https://remark42.com/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
}
func imgHTTPServer(t *testing.T) *httptest.Server {
func imgHTTPTestsServer(t *testing.T) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/image/img1.png" {
t.Log("http img request", r.URL)
+5 -5
View File
@@ -29,7 +29,7 @@ type Bolt struct {
MaxWidth int
}
// Create Bolt Store.
// NewBoltStorage create bolt image store
func NewBoltStorage(fileName string, maxSize int, maxHeight int, maxWidth int, options bolt.Options) (*Bolt, error) {
db, err := bolt.Open(fileName, 0600, &options)
if err != nil {
@@ -60,7 +60,7 @@ func NewBoltStorage(fileName string, maxSize int, maxHeight int, maxWidth int, o
}, nil
}
// SaveWithID saves data from reader with given id
// SaveWithID saves data from a reader, for given id
func (b *Bolt) SaveWithID(id string, r io.Reader) (string, error) {
data, err := readAndValidateImage(r, b.MaxSize)
if err != nil {
@@ -87,14 +87,14 @@ func (b *Bolt) SaveWithID(id string, r io.Reader) (string, error) {
}
// Save data from reader to staging bucket in DB
func (b *Bolt) Save(fileName string, userID string, r io.Reader) (id string, err error) {
func (b *Bolt) Save(_ string, userID string, r io.Reader) (id string, err error) {
id = path.Join(userID, guid())
return b.SaveWithID(id, r)
}
// Commit file stored in staging bucket by copying it to permanent bucket
// Data from staging bucket not removed immediately, but would be removed on cleanup
func (b *Bolt) Commit(id string) error {
func (b *Bolt) commit(id string) error {
err := b.db.Update(func(tx *bolt.Tx) error {
data := tx.Bucket([]byte(imagesStagedBktName)).Get([]byte(id))
if data == nil {
@@ -127,7 +127,7 @@ func (b *Bolt) Load(id string) (io.ReadCloser, int64, error) {
}
// Cleanup runs scan of staging and removes old data based on ttl
func (b *Bolt) Cleanup(ctx context.Context, ttl time.Duration) error {
func (b *Bolt) cleanup(_ context.Context, ttl time.Duration) error {
err := b.db.Update(func(tx *bolt.Tx) error {
c := tx.Bucket([]byte(insertTimeBktName)).Cursor()
+4 -4
View File
@@ -31,7 +31,7 @@ func TestBoltStore_SaveCommit(t *testing.T) {
})
assert.NoError(t, err)
err = svc.Commit(id)
err = svc.commit(id)
require.NoError(t, err)
err = svc.db.View(func(tx *bolt.Tx) error {
@@ -90,7 +90,7 @@ func TestBoltStore_Cleanup(t *testing.T) {
time.Sleep(100 * time.Millisecond)
img3 := save("blah_ff3.png", "user2")
err := svc.Cleanup(context.Background(), time.Since(img1ts)) // clean first images
err := svc.cleanup(context.Background(), time.Since(img1ts)) // clean first images
assert.NoError(t, err)
assertBoltImgNil(t, svc.db, imagesStagedBktName, img1)
@@ -98,10 +98,10 @@ func TestBoltStore_Cleanup(t *testing.T) {
assertBoltImgNotNil(t, svc.db, imagesStagedBktName, img2)
assertBoltImgNotNil(t, svc.db, imagesStagedBktName, img3)
err = svc.Commit(img3)
err = svc.commit(img3)
require.NoError(t, err)
err = svc.Cleanup(context.Background(), time.Millisecond*10)
err = svc.cleanup(context.Background(), time.Millisecond*10)
assert.NoError(t, err)
assertBoltImgNil(t, svc.db, imagesStagedBktName, img2)
+12 -11
View File
@@ -36,7 +36,7 @@ type FileSystem struct {
}
}
// SaveWithID saves data from reader with given id
// SaveWithID saves data from a reader, with given id
func (f *FileSystem) SaveWithID(id string, r io.Reader) (string, error) {
data, err := readAndValidateImage(r, f.MaxSize)
if err != nil {
@@ -51,26 +51,26 @@ func (f *FileSystem) SaveWithID(id string, r io.Reader) (string, error) {
}
if err = ioutil.WriteFile(dst, data, 0600); err != nil {
return "", errors.Wrapf(err, "can't write file")
return "", errors.Wrapf(err, "can't write image file with id %s", id)
}
log.Printf("[DEBUG] file %s saved for image %s, size=%d", dst, id, len(data))
return id, nil
}
// Save data from reader for given file name to local FS, staging directory. Returns id as user/uuid
// Files partitioned across multiple subdirectories and the final path includes part, i.e. /location/user1/03/123-4567
// Save data from a reader for given file name to local FS, staging directory. Returns id as user/uuid
// Files partitioned across multiple subdirectories, and the final path includes part, i.e. /location/user1/03/123-4567
func (f *FileSystem) Save(fileName string, userID string, r io.Reader) (id string, err error) {
id = path.Join(userID, guid()) // make id as user/uuid
finalID, err := f.SaveWithID(id, r)
tempId := path.Join(userID, guid()) // make id as user/uuid
id, err = f.SaveWithID(tempId, r)
if err != nil {
err = errors.Wrapf(err, "can't save file %s", fileName)
err = errors.Wrapf(err, "can't save image file %s", fileName)
}
return finalID, err
return id, err
}
// Commit file stored in staging location by moving it to permanent location
func (f *FileSystem) Commit(id string) error {
func (f *FileSystem) commit(id string) error {
log.Printf("[DEBUG] commit image %s", id)
stagingImage, permImage := f.location(f.Staging, id), f.location(f.Location, id)
@@ -110,12 +110,13 @@ func (f *FileSystem) Load(id string) (io.ReadCloser, int64, error) {
}
// Cleanup runs scan of staging and removes old files based on ttl
func (f *FileSystem) Cleanup(ctx context.Context, ttl time.Duration) error {
func (f *FileSystem) cleanup(_ context.Context, ttl time.Duration) error {
if _, err := os.Stat(f.Staging); os.IsNotExist(err) {
return nil
}
// we can ignore context as on local FS remove is relatively fast operation
err := filepath.Walk(f.Staging, func(fpath string, info os.FileInfo, err error) error {
if err != nil {
return err
@@ -124,7 +125,7 @@ func (f *FileSystem) Cleanup(ctx context.Context, ttl time.Duration) error {
return nil
}
age := time.Since(info.ModTime())
if age > ttl {
if age > (ttl + 100*time.Millisecond) { // delay cleanup triggering to allow commit
log.Printf("[INFO] remove staging image %s, age %v", fpath, age)
rmErr := os.Remove(fpath)
_ = os.Remove(path.Dir(fpath)) // try to remove directory
+5 -5
View File
@@ -129,7 +129,7 @@ func TestFsStore_SaveAndCommit(t *testing.T) {
id, err := svc.Save("file1.png", "user1", gopherPNG())
require.NoError(t, err)
err = svc.Commit(id)
err = svc.commit(id)
require.NoError(t, err)
imgStaging := svc.location(svc.Staging, id)
@@ -180,7 +180,7 @@ func TestFsStore_LoadAfterCommit(t *testing.T) {
id, err := svc.Save("blah_ff1.png", "user1", gopherPNG())
assert.NoError(t, err)
t.Log(id)
err = svc.Commit(id)
err = svc.commit(id)
require.NoError(t, err)
r, sz, err := svc.Load(id)
@@ -259,8 +259,8 @@ func TestFsStore_Cleanup(t *testing.T) {
time.Sleep(100 * time.Millisecond)
img3 := save("blah_ff3.png", "user2")
time.Sleep(100 * time.Millisecond) // make first image expired
err := svc.Cleanup(context.Background(), time.Millisecond*300)
time.Sleep(200 * time.Millisecond) // make first image expired
err := svc.cleanup(context.Background(), time.Millisecond*300)
assert.NoError(t, err)
_, err = os.Stat(img1)
@@ -280,7 +280,7 @@ func TestFsStore_Cleanup(t *testing.T) {
assert.NoError(t, err, "file on staging")
time.Sleep(200 * time.Millisecond) // make all images expired
err = svc.Cleanup(context.Background(), time.Millisecond*300)
err = svc.cleanup(context.Background(), time.Millisecond*300)
assert.NoError(t, err)
_, err = os.Stat(img2)
+41 -29
View File
@@ -1,6 +1,6 @@
// Package image handles storing, resizing and retrieval of images
// Provides Store with Save and Load and one implementation on top of local file system.
// Service object encloses Store and add common methods, this is the one consumer should use
// Provides Store with Save and Load implementations on top of local file system and bolt db.
// Service object encloses Store and add common methods, this is the one consumer should use.
package image
//go:generate sh -c "mockery -inpkg -name Store -print > /tmp/mock.tmp && mv /tmp/mock.tmp image_mock.go"
@@ -27,18 +27,9 @@ import (
"golang.org/x/image/draw"
)
// Store defines interface for saving and loading pictures.
// Declares two-stage save with commit
type Store interface {
SaveWithID(id string, r io.Reader) (string, error)
Save(fileName string, userID string, r io.Reader) (id string, err error) // get name and reader and returns ID of stored image
Commit(id string) error // move image from staging to permanent
Load(id string) (io.ReadCloser, int64, error) // load image by ID. Caller has to close the reader.
Cleanup(ctx context.Context, ttl time.Duration) error // run removal loop for old images on staging
SizeLimit() int // max image size
}
// Service extends Store with common functions needed for any store implementation
// Service wraps Store with common functions needed for any store implementation
// It also provides async Submit with func param retrieving all submitting ids.
// Submitted ids committed (i.e. moved from staging to final) on TTL expiration.
type Service struct {
Store
TTL time.Duration // for how long file allowed on staging
@@ -47,7 +38,19 @@ type Service struct {
wg sync.WaitGroup
submitCh chan submitReq
once sync.Once
term int32
term int32 // term value used atomically to detect emergency termination
}
// Store defines interface for saving and loading pictures.
// Declares two-stage save with commit. Save stores to staging area and Commit moves to the final location
type Store interface {
Save(fileName string, userID string, r io.Reader) (id string, err error) // get name and reader and returns ID of stored (staging) image
SaveWithID(id string, r io.Reader) (string, error) // store image for passed id to staging
Load(id string) (io.ReadCloser, int64, error) // load image by ID. Caller has to close the reader.
SizeLimit() int // max image size
commit(id string) error // move image from staging to permanent
cleanup(ctx context.Context, ttl time.Duration) error // run removal loop for old images on staging
}
const submitQueueSize = 5000
@@ -71,14 +74,15 @@ func (s *Service) Submit(idsFn func() []string) {
defer s.wg.Done()
for req := range s.submitCh {
// wait for TTL expiration with emergency pass on term
for atomic.LoadInt32(&s.term) == 0 && time.Since(req.TS) <= s.TTL {
for atomic.LoadInt32(&s.term) == 0 && time.Since(req.TS) <= s.TTL/2 { // commit on a half of TTL
time.Sleep(time.Millisecond * 10) // small sleep to relive busy wait but keep reactive for term (close)
}
for _, id := range req.idsFn() {
if err := s.Commit(id); err != nil {
if err := s.commit(id); err != nil {
log.Printf("[WARN] failed to commit image %s", id)
}
}
atomic.StoreInt32(&s.term, 0) // indicates completion of ids commits
}
log.Printf("[INFO] image submitter terminated")
}()
@@ -119,8 +123,8 @@ func (s *Service) Cleanup(ctx context.Context) {
case <-ctx.Done():
log.Printf("[INFO] cleanup terminated, %v", ctx.Err())
return
case <-time.After(s.TTL / 2):
if err := s.Store.Cleanup(ctx, s.TTL); err != nil {
case <-time.After(s.TTL / 2): // cleanup call on every 1/2 TTL
if err := s.Store.cleanup(ctx, s.TTL); err != nil {
log.Printf("[WARN] failed to cleanup, %v", err)
}
}
@@ -130,15 +134,22 @@ func (s *Service) Cleanup(ctx context.Context) {
// Close flushes all in-progress submits and enforces waiting commits
func (s *Service) Close() {
log.Printf("[INFO] close image service ")
atomic.AddInt32(&s.term, 1) // enforce non-delayed commits for all ids left in submitCh
atomic.StoreInt32(&s.term, 1) // enforce non-delayed commits for all ids left in submitCh
for {
// set to 0 by commit goroutine after everything waited on TTL sent
if atomic.LoadInt32(&s.term) == 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if s.submitCh != nil {
close(s.submitCh)
}
s.wg.Wait()
}
// resize an image of supported format (PNG, JPG, GIF) to the size of "limit" px of the
// biggest side (width or height) preserving aspect ratio.
// resize an image of supported format (PNG, JPG, GIF) to the size of "limit" px of
// the biggest side (width or height) preserving aspect ratio.
// Returns original data if resizing is not needed or failed.
// If resized the result will be for png format
func resize(data []byte, limitW, limitH int) []byte {
@@ -191,13 +202,14 @@ func getProportionalSizes(srcW, srcH int, limitW, limitH int) (resW, resH int) {
return limitW, int(propH)
}
// check if file f is a valid image format, i.e. gif, png, jpeg or webp
func isValidImage(b []byte) bool {
ct := http.DetectContentType(b)
return ct == "image/gif" || ct == "image/png" || ct == "image/jpeg" || ct == "image/webp"
}
// check if file f is a valid image format, i.e. gif, png, jpeg or webp and reads up to maxSize.
func readAndValidateImage(r io.Reader, maxSize int) ([]byte, error) {
isValidImage := func(b []byte) bool {
ct := http.DetectContentType(b)
return ct == "image/gif" || ct == "image/png" || ct == "image/jpeg" || ct == "image/webp"
}
lr := io.LimitReader(r, int64(maxSize)+1)
data, err := ioutil.ReadAll(lr)
if err != nil {
@@ -210,7 +222,7 @@ func readAndValidateImage(r io.Reader, maxSize int) ([]byte, error) {
// read header first, needs it to check if data is valid png/gif/jpeg
if !isValidImage(data[:512]) {
return nil, errors.Errorf("file format is not allowed")
return nil, errors.Errorf("file format not allowed")
}
return data, nil
+28 -28
View File
@@ -16,34 +16,6 @@ type MockStore struct {
mock.Mock
}
// Cleanup provides a mock function with given fields: ctx, ttl
func (_m *MockStore) Cleanup(ctx context.Context, ttl time.Duration) error {
ret := _m.Called(ctx, ttl)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, time.Duration) error); ok {
r0 = rf(ctx, ttl)
} else {
r0 = ret.Error(0)
}
return r0
}
// Commit provides a mock function with given fields: id
func (_m *MockStore) Commit(id string) error {
ret := _m.Called(id)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(id)
} else {
r0 = ret.Error(0)
}
return r0
}
// Load provides a mock function with given fields: id
func (_m *MockStore) Load(id string) (io.ReadCloser, int64, error) {
ret := _m.Called(id)
@@ -129,3 +101,31 @@ func (_m *MockStore) SizeLimit() int {
return r0
}
// cleanup provides a mock function with given fields: ctx, ttl
func (_m *MockStore) cleanup(ctx context.Context, ttl time.Duration) error {
ret := _m.Called(ctx, ttl)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, time.Duration) error); ok {
r0 = rf(ctx, ttl)
} else {
r0 = ret.Error(0)
}
return r0
}
// commit provides a mock function with given fields: id
func (_m *MockStore) commit(id string) error {
ret := _m.Called(id)
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(id)
} else {
r0 = ret.Error(0)
}
return r0
}
+15 -16
View File
@@ -37,63 +37,63 @@ func TestService_ExtractPictures2(t *testing.T) {
func TestService_Cleanup(t *testing.T) {
store := MockStore{}
store.On("Cleanup", mock.Anything, mock.Anything).Times(10).Return(nil)
store.On("cleanup", mock.Anything, mock.Anything).Times(10).Return(nil)
svc := Service{Store: &store, TTL: 100 * time.Millisecond}
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*549)
defer cancel()
svc.Cleanup(ctx)
store.AssertNumberOfCalls(t, "Cleanup", 10)
store.AssertNumberOfCalls(t, "cleanup", 10)
}
func TestService_Submit(t *testing.T) {
store := MockStore{}
store.On("Commit", mock.Anything, mock.Anything).Times(5).Return(nil)
store.On("commit", mock.Anything, mock.Anything).Times(5).Return(nil)
svc := Service{Store: &store, ImageAPI: "/blah/", TTL: time.Millisecond * 100}
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
svc.Submit(func() []string { return []string{"id4", "id5"} })
svc.Submit(nil)
store.AssertNumberOfCalls(t, "Commit", 0)
store.AssertNumberOfCalls(t, "commit", 0)
time.Sleep(time.Millisecond * 150)
store.AssertNumberOfCalls(t, "Commit", 5)
store.AssertNumberOfCalls(t, "commit", 5)
}
func TestService_Close(t *testing.T) {
store := MockStore{}
store.On("Commit", mock.Anything, mock.Anything).Times(5).Return(nil)
store.On("commit", mock.Anything, mock.Anything).Times(5).Return(nil)
svc := Service{Store: &store, ImageAPI: "/blah/", TTL: time.Millisecond * 500}
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
svc.Submit(func() []string { return []string{"id4", "id5"} })
svc.Submit(nil)
svc.Close()
store.AssertNumberOfCalls(t, "Commit", 5)
store.AssertNumberOfCalls(t, "commit", 5)
}
func TestService_SubmitDelay(t *testing.T) {
store := MockStore{}
store.On("Commit", mock.Anything, mock.Anything).Times(5).Return(nil)
store.On("commit", mock.Anything, mock.Anything).Times(5).Return(nil)
svc := Service{Store: &store, ImageAPI: "/blah/", TTL: time.Millisecond * 100}
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
time.Sleep(150 * time.Millisecond) // let first batch to pass TTL
svc.Submit(func() []string { return []string{"id4", "id5"} })
svc.Submit(nil)
store.AssertNumberOfCalls(t, "Commit", 3)
store.AssertNumberOfCalls(t, "commit", 3)
svc.Close()
store.AssertNumberOfCalls(t, "Commit", 5)
store.AssertNumberOfCalls(t, "commit", 5)
}
func TestService_resize(t *testing.T) {
// Reader is nil.
// reader is nil
resized := resize(nil, 100, 100)
assert.Nil(t, resized)
// Negative limit error.
// negative limit error
resized = resize([]byte("some picture bin data"), -1, -1)
require.NotNil(t, resized)
assert.Equal(t, resized, []byte("some picture bin data"))
// Decode error.
// decode error
resized = resize([]byte("invalid image content"), 100, 100)
assert.NotNil(t, resized)
assert.Equal(t, resized, []byte("invalid image content"))
@@ -110,15 +110,14 @@ func TestService_resize(t *testing.T) {
img, err := ioutil.ReadFile(c.file)
require.NoError(t, err, "can't open test file %s", c.file)
// No need for resize, image dimensions are smaller than resize limit.
// no need for resize, image dimensions are smaller than resize limit
resized = resize(img, 800, 800)
assert.NotNil(t, resized, "file %s", c.file)
assert.Equal(t, resized, img)
// Resizing to half of width. Check resized image format PNG.
// resizing to half of width
resized = resize(img, 400, 400)
assert.NotNil(t, resized, "file %s", c.file)
imgRz, format, err := image.Decode(bytes.NewBuffer(resized))
assert.NoError(t, err, "file %s", c.file)
assert.Equal(t, "png", format, "file %s", c.file)
+7 -8
View File
@@ -102,7 +102,7 @@ func (s *DataStore) Create(comment store.Comment) (commentID string, err error)
comment.PostTitle = title
}()
s.submitImages(comment)
s.submitImages(comment.Locator, comment.ID)
if e := s.AdminStore.OnEvent(comment.Locator.SiteID, admin.EvCreate); e != nil {
log.Printf("[WARN] failed to send create event, %s", e)
}
@@ -202,23 +202,22 @@ func (s *DataStore) DeleteUserDetail(siteID string, userID string, detail engine
}
// submitImages initiated delayed commit of all images from the comment uploaded to remark42
func (s *DataStore) submitImages(comment store.Comment) {
func (s *DataStore) submitImages(locator store.Locator, commentID string) {
s.ImageService.Submit(func() []string {
c := comment
s.ImageService.Submit(func() []string { // get all ids from comment's text
// this can be called after last edit, we have to retrieve fresh comment
cc, err := s.Engine.Get(engine.GetRequest{Locator: c.Locator, CommentID: c.ID})
cc, err := s.Engine.Get(engine.GetRequest{Locator: locator, CommentID: commentID})
if err != nil {
log.Printf("[WARN] can't get comment's %s text for image extraction, %v", c.ID, err)
log.Printf("[WARN] can't get comment's %s text for image extraction, %v", commentID, err)
return nil
}
imgIds, err := s.ImageService.ExtractPictures(cc.Text)
if err != nil {
log.Printf("[WARN] can't get extract pictures from %s, %v", c.ID, err)
log.Printf("[WARN] can't get extract pictures from %s, %v", commentID, err)
return nil
}
if len(imgIds) > 0 {
log.Printf("[DEBUG] image ids extracted from %s - %+v", c.ID, imgIds)
log.Printf("[DEBUG] image ids extracted from %s - %+v", commentID, imgIds)
}
return imgIds
})
+2 -2
View File
@@ -1277,7 +1277,7 @@ func TestService_submitImages(t *testing.T) {
lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc)
mockStore := image.MockStore{}
mockStore.On("Commit", mock.Anything, mock.Anything).Times(2).Return(nil)
mockStore.On("commit", mock.Anything, mock.Anything).Times(2).Return(nil)
imgSvc := &image.Service{Store: &mockStore, TTL: time.Millisecond * 50}
// two comments for https://radio-t.com
@@ -1296,7 +1296,7 @@ func TestService_submitImages(t *testing.T) {
_, err := b.Engine.Create(c) // create directly with engine, doesn't call submitImages
assert.NoError(t, err)
b.submitImages(c)
b.submitImages(c.Locator, c.ID)
time.Sleep(250 * time.Millisecond)
}