Repository: nektos/act (language: go)

Issue:
running two act commands in parallel results in timeout
### Bug report info

```plain text
❯ act --bug-report
act version:            0.2.45
GOOS:                   linux
GOARCH:                 amd64
NumCPU:                 16
Docker host:            DOCKER_HOST environment variable is not set
Sockets found:
	/var/run/docker.sock
Config files:
Build info:
	Go version:            go1.20.4
	Module path:           github.com/nektos/act
	Main version:          (devel)
	Main path:             github.com/nektos/act
	Main checksum:
	Build settings:
		-buildmode:           pie
		-compiler:            gc
		-trimpath:            true
		CGO_ENABLED:          1
		GOARCH:               amd64
		GOOS:                 linux
		GOAMD64:              v1
		vcs:                  git
		vcs.revision:         25dd7a13c00002e8fad19eca9f03a67d4ff8163a
		vcs.time:             2023-05-02T14:22:58Z
		vcs.modified:         true
Docker Engine:
	Engine version:        24.0.0
	Engine runtime:        runc
	Cgroup version:        2
	Cgroup driver:         systemd
	Storage driver:        overlay2
	Registry URI:          https://index.docker.io/v1/
	OS:                    Arch Linux
	OS type:               linux
	OS version:
	OS arch:               x86_64
	OS kernel:             6.3.3-arch1-1
	OS CPU:                16
	OS memory:             31532 MB
	Security options:
		name=seccomp,profile=builtin
		name=cgroupns
```


### Command used with act

```sh
act -P ubuntu-latest=catthehacker/ubuntu:full-latest --rm --bind --container-options '-u 1000:1000' -W .github/workflows/tests.yml
```


### Describe issue

I am trying to run two act commands in parallel (not necessarily on the same project, we also tried running different workflows from different projects in parallel).

The first command runs as usual.
However, the second and later commands always end up in the same error: `Error: timeout.

Does act support parallel executions of different workflows? I could not find any mention to that in the documentation nor the issues.

### Link to GitHub repository

https://github.com/ASSERT-KTH/flacoco

### Workflow content

```yml
name: tests

on: [push, pull_request]

jobs:
  build:
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        java-version: [ 8 , 11 , 16 , 17 ]
        compiler-version: [ 1.1 , 1.2 , 1.3 , 1.4 , 5 , 6 , 7 , 8 , 9 , 10 , 11 , 12 , 13 , 14 , 15 , 16 , 17 ]
        os: [ ubuntu-latest , macos-latest , windows-latest ]

    steps:
      - name: Checkout
        uses: actions/checkout@v3.5.2
        if: ${{ matrix.compiler-version <= matrix.java-version && (matrix.java-version < 14 || matrix.compiler-version >= 7) && (matrix.java-version < 11 || matrix.compiler-version >= 6) }}

      - name: Setup JDK${{ matrix.java-version }}
        uses: actions/setup-java@v3
        if: ${{ matrix.compiler-version <= matrix.java-version && (matrix.java-version < 14 || matrix.compiler-version >= 7) && (matrix.java-version < 11 || matrix.compiler-version >= 6) }}
        with:
          java-version: ${{ matrix.java-version }}
          distribution: 'temurin'

      - name: Install example projects
        run: ./.github/install_examples.sh
        if: ${{ matrix.compiler-version <= matrix.java-version && (matrix.java-version < 14 || matrix.compiler-version >= 7) && (matrix.java-version < 11 || matrix.compiler-version >= 6) }}
        shell: bash
        env:
          SRC_VERSION: ${{ matrix.compiler-version }}
      - name: Build and run tests
        run: mvn --batch-mode clean test
        if: ${{ matrix.compiler-version <= matrix.java-version && (matrix.java-version < 14 || matrix.compiler-version >= 7) && (matrix.java-version < 11 || matrix.compiler-version >= 6) }}
        env:
          SRC_VERSION: ${{ matrix.compiler-version }}

      - name: Codecov
        uses: codecov/codecov-action@v3.1.2
        if: ${{ matrix.compiler-version == 8 && matrix.java-version == 8 && matrix.os == 'ubuntu-latest' }}
```


### Relevant log output

```sh
❯ act -P ubuntu-latest=catthehacker/ubuntu:full-latest --rm --bind --container-options '-u 1000:1000' -W .github/workflows/tests.yml -v
DEBU[0000] Loading environment from /home/andre/Repos/flacoco/.env
DEBU[0000] Loading action inputs from /home/andre/Repos/flacoco/.input
DEBU[0000] Loading secrets from /home/andre/Repos/flacoco/.secrets
DEBU[0000] Evaluated matrix inclusions: map[]
DEBU[0000] Loading workflow '/home/andre/Repos/flacoco/.github/workflows/tests.yml'
DEBU[0000] Reading workflow '/home/andre/Repos/flacoco/.github/workflows/tests.yml'
DEBU[0000] Conditional GET for notices etag=0d767207-f640-4fe2-81f0-b0185bc1bc73
DEBU[0000] Preparing plan with all jobs
DEBU[0000] Using default workflow event: push
DEBU[0000] Planning jobs for event: push
DEBU[0000] Saving notices etag=0d767207-f640-4fe2-81f0-b0185bc1bc73
DEBU[0000] No new notices
Error: timeout
```


### Additional information

_No response_


Discussion / hints:
Seems like the bug only occurs in version `0.2.45`. The issue does not arise in version `0.2.44`
Hi @andre15silva , Please can you let me know how you have install `0.2.44` i want to use `0.2.44`
Hi @grahul ,

You can get the binary from the release: https://github.com/nektos/act/releases/tag/v0.2.44
We have tracked it down to the cache server (introduced in `v0.2.45`). When we run with `--no-cache-server`, the problem with running act in parallel no longer exists.
bolthold db used by cache server doesn't support concurrent access to database.
https://github.com/nektos/act/blob/4fc176f556dcf41e69854570ce65680f127a9357/pkg/artifactcache/handler.go#L65C13-L65C26

Using a different `--cache-server-path` also resolves the problem.

Fix patch:
diff --git a/pkg/artifactcache/handler.go b/pkg/artifactcache/handler.go
index f11def68f77..ac4755ef7dd 100644
--- a/pkg/artifactcache/handler.go
+++ b/pkg/artifactcache/handler.go
@@ -27,7 +27,7 @@ const (
 )
 
 type Handler struct {
-	db       *bolthold.Store
+	dir      string
 	storage  *Storage
 	router   *httprouter.Router
 	listener net.Listener
@@ -62,19 +62,7 @@ func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger
 		return nil, err
 	}
 
-	db, err := bolthold.Open(filepath.Join(dir, "bolt.db"), 0o644, &bolthold.Options{
-		Encoder: json.Marshal,
-		Decoder: json.Unmarshal,
-		Options: &bbolt.Options{
-			Timeout:      5 * time.Second,
-			NoGrowSync:   bbolt.DefaultOptions.NoGrowSync,
-			FreelistType: bbolt.DefaultOptions.FreelistType,
-		},
-	})
-	if err != nil {
-		return nil, err
-	}
-	h.db = db
+	h.dir = dir
 
 	storage, err := NewStorage(filepath.Join(dir, "cache"))
 	if err != nil {
@@ -150,16 +138,21 @@ func (h *Handler) Close() error {
 		}
 		h.listener = nil
 	}
-	if h.db != nil {
-		err := h.db.Close()
-		if err != nil {
-			retErr = err
-		}
-		h.db = nil
-	}
 	return retErr
 }
 
+func (h *Handler) openDB() (*bolthold.Store, error) {
+	return bolthold.Open(filepath.Join(h.dir, "bolt.db"), 0o644, &bolthold.Options{
+		Encoder: json.Marshal,
+		Decoder: json.Unmarshal,
+		Options: &bbolt.Options{
+			Timeout:      5 * time.Second,
+			NoGrowSync:   bbolt.DefaultOptions.NoGrowSync,
+			FreelistType: bbolt.DefaultOptions.FreelistType,
+		},
+	})
+}
+
 // GET /_apis/artifactcache/cache
 func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
 	keys := strings.Split(r.URL.Query().Get("keys"), ",")
@@ -169,7 +162,14 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
 	}
 	version := r.URL.Query().Get("version")
 
-	cache, err := h.findCache(keys, version)
+	db, err := h.openDB()
+	if err != nil {
+		h.responseJSON(w, r, 500, err)
+		return
+	}
+	defer db.Close()
+
+	cache, err := h.findCache(db, keys, version)
 	if err != nil {
 		h.responseJSON(w, r, 500, err)
 		return
@@ -183,7 +183,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
 		h.responseJSON(w, r, 500, err)
 		return
 	} else if !ok {
-		_ = h.db.Delete(cache.ID, cache)
+		_ = db.Delete(cache.ID, cache)
 		h.responseJSON(w, r, 204)
 		return
 	}
@@ -206,7 +206,13 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
 
 	cache := api.ToCache()
 	cache.FillKeyVersionHash()
-	if err := h.db.FindOne(cache, bolthold.Where("KeyVersionHash").Eq(cache.KeyVersionHash)); err != nil {
+	db, err := h.openDB()
+	if err != nil {
+		h.responseJSON(w, r, 500, err)
+		return
+	}
+	defer db.Close()
+	if err := db.FindOne(cache, bolthold.Where("KeyVersionHash").Eq(cache.KeyVersionHash)); err != nil {
 		if !errors.Is(err, bolthold.ErrNotFound) {
 			h.responseJSON(w, r, 500, err)
 			return
@@ -219,12 +225,12 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
 	now := time.Now().Unix()
 	cache.CreatedAt = now
 	cache.UsedAt = now
-	if err := h.db.Insert(bolthold.NextSequence(), cache); err != nil {
+	if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
 		h.responseJSON(w, r, 500, err)
 		return
 	}
 	// write back id to db
-	if err := h.db.Update(cache.ID, cache); err != nil {
+	if err := db.Update(cache.ID, cache); err != nil {
 		h.responseJSON(w, r, 500, err)
 		return
 	}
@@ -242,7 +248,13 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
 	}
 
 	cache := &Cache{}
-	if err := h.db.Get(id, cache); err != nil {
+	db, err := h.openDB()
+	if err != nil {
+		h.responseJSON(w, r, 500, err)
+		return
+	}
+	defer db.Close()
+	if err := db.Get(id, cache); err != nil {
 		if errors.Is(err, bolthold.ErrNotFound) {
 			h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
 			return
@@ -255,6 +267,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
 		h.responseJSON(w, r, 400, fmt.Errorf("cache %v %q: already complete", cache.ID, cache.Key))
 		return
 	}
+	db.Close()
 	start, _, err := parseContentRange(r.Header.Get("Content-Range"))
 	if err != nil {
 		h.responseJSON(w, r, 400, err)
@@ -276,7 +289,13 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
 	}
 
 	cache := &Cache{}
-	if err := h.db.Get(id, cache); err != nil {
+	db, err := h.openDB()
+	if err != nil {
+		h.responseJSON(w, r, 500, err)
+		return
+	}
+	defer db.Close()
+	if err := db.Get(id, cache); err != nil {
 		if errors.Is(err, bolthold.ErrNotFound) {
 			h.responseJSON(w, r, 400, fmt.Errorf("cache %d: not reserved", id))
 			return
@@ -290,13 +309,22 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
 		return
 	}
 
+	db.Close()
+
 	if err := h.storage.Commit(cache.ID, cache.Size); err != nil {
 		h.responseJSON(w, r, 500, err)
 		return
 	}
 
+	db, err = h.openDB()
+	if err != nil {
+		h.responseJSON(w, r, 500, err)
+		return
+	}
+	defer db.Close()
+
 	cache.Complete = true
-	if err := h.db.Update(cache.ID, cache); err != nil {
+	if err := db.Update(cache.ID, cache); err != nil {
 		h.responseJSON(w, r, 500, err)
 		return
 	}
@@ -332,7 +360,7 @@ func (h *Handler) middleware(handler httprouter.Handle) httprouter.Handle {
 }
 
 // if not found, return (nil, nil) instead of an error.
-func (h *Handler) findCache(keys []string, version string) (*Cache, error) {
+func (h *Handler) findCache(db *bolthold.Store, keys []string, version string) (*Cache, error) {
 	if len(keys) == 0 {
 		return nil, nil
 	}
@@ -344,7 +372,7 @@ func (h *Handler) findCache(keys []string, version string) (*Cache, error) {
 	}
 	cache.FillKeyVersionHash()
 
-	if err := h.db.FindOne(cache, bolthold.Where("KeyVersionHash").Eq(cache.KeyVersionHash)); err != nil {
+	if err := db.FindOne(cache, bolthold.Where("KeyVersionHash").Eq(cache.KeyVersionHash)); err != nil {
 		if !errors.Is(err, bolthold.ErrNotFound) {
 			return nil, err
 		}
@@ -355,7 +383,7 @@ func (h *Handler) findCache(keys []string, version string) (*Cache, error) {
 
 	for _, prefix := range keys[1:] {
 		found := false
-		if err := h.db.ForEach(bolthold.Where("Key").Ge(prefix).And("Version").Eq(version).SortBy("Key"), func(v *Cache) error {
+		if err := db.ForEach(bolthold.Where("Key").Ge(prefix).And("Version").Eq(version).SortBy("Key"), func(v *Cache) error {
 			if !strings.HasPrefix(v.Key, prefix) {
 				return stop
 			}
@@ -378,12 +406,17 @@ func (h *Handler) findCache(keys []string, version string) (*Cache, error) {
 }
 
 func (h *Handler) useCache(id int64) {
+	db, err := h.openDB()
+	if err != nil {
+		return
+	}
+	defer db.Close()
 	cache := &Cache{}
-	if err := h.db.Get(id, cache); err != nil {
+	if err := db.Get(id, cache); err != nil {
 		return
 	}
 	cache.UsedAt = time.Now().Unix()
-	_ = h.db.Update(cache.ID, cache)
+	_ = db.Update(cache.ID, cache)
 }
 
 func (h *Handler) gcCache() {
@@ -408,8 +441,14 @@ func (h *Handler) gcCache() {
 		keepTemp   = 5 * time.Minute
 	)
 
+	db, err := h.openDB()
+	if err != nil {
+		return
+	}
+	defer db.Close()
+
 	var caches []*Cache
-	if err := h.db.Find(&caches, bolthold.Where("UsedAt").Lt(time.Now().Add(-keepTemp).Unix())); err != nil {
+	if err := db.Find(&caches, bolthold.Where("UsedAt").Lt(time.Now().Add(-keepTemp).Unix())); err != nil {
 		h.logger.Warnf("find caches: %v", err)
 	} else {
 		for _, cache := range caches {
@@ -417,7 +456,7 @@ func (h *Handler) gcCache() {
 				continue
 			}
 			h.storage.Remove(cache.ID)
-			if err := h.db.Delete(cache.ID, cache); err != nil {
+			if err := db.Delete(cache.ID, cache); err != nil {
 				h.logger.Warnf("delete cache: %v", err)
 				continue
 			}
@@ -426,12 +465,12 @@ func (h *Handler) gcCache() {
 	}
 
 	caches = caches[:0]
-	if err := h.db.Find(&caches, bolthold.Where("UsedAt").Lt(time.Now().Add(-keepUnused).Unix())); err != nil {
+	if err := db.Find(&caches, bolthold.Where("UsedAt").Lt(time.Now().Add(-keepUnused).Unix())); err != nil {
 		h.logger.Warnf("find caches: %v", err)
 	} else {
 		for _, cache := range caches {
 			h.storage.Remove(cache.ID)
-			if err := h.db.Delete(cache.ID, cache); err != nil {
+			if err := db.Delete(cache.ID, cache); err != nil {
 				h.logger.Warnf("delete cache: %v", err)
 				continue
 			}
@@ -440,12 +479,12 @@ func (h *Handler) gcCache() {
 	}
 
 	caches = caches[:0]
-	if err := h.db.Find(&caches, bolthold.Where("CreatedAt").Lt(time.Now().Add(-keepUsed).Unix())); err != nil {
+	if err := db.Find(&caches, bolthold.Where("CreatedAt").Lt(time.Now().Add(-keepUsed).Unix())); err != nil {
 		h.logger.Warnf("find caches: %v", err)
 	} else {
 		for _, cache := range caches {
 			h.storage.Remove(cache.ID)
-			if err := h.db.Delete(cache.ID, cache); err != nil {
+			if err := db.Delete(cache.ID, cache); err != nil {
 				h.logger.Warnf("delete cache: %v", err)
 				continue
 			}


Test patch:
diff --git a/pkg/artifactcache/handler_test.go b/pkg/artifactcache/handler_test.go
index 7c6840a19fc..35ec75371be 100644
--- a/pkg/artifactcache/handler_test.go
+++ b/pkg/artifactcache/handler_test.go
@@ -25,7 +25,10 @@ func TestHandler(t *testing.T) {
 
 	defer func() {
 		t.Run("inpect db", func(t *testing.T) {
-			require.NoError(t, handler.db.Bolt().View(func(tx *bbolt.Tx) error {
+			db, err := handler.openDB()
+			require.NoError(t, err)
+			defer db.Close()
+			require.NoError(t, db.Bolt().View(func(tx *bbolt.Tx) error {
 				return tx.Bucket([]byte("Cache")).ForEach(func(k, v []byte) error {
 					t.Logf("%s: %s", k, v)
 					return nil
@@ -36,7 +39,6 @@ func TestHandler(t *testing.T) {
 			require.NoError(t, handler.Close())
 			assert.Nil(t, handler.server)
 			assert.Nil(t, handler.listener)
-			assert.Nil(t, handler.db)
 			_, err := http.Post(fmt.Sprintf("%s/caches/%d", base, 1), "", nil)
 			assert.Error(t, err)
 		})