mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-16 12:22:17 +01:00
feat(chromium): re-add concurrency support for Chromium (#1467)
* feat: add concurrency support to ProcessSupervisor - Replace the single-slot mutex channel with a configurable semaphore to allow multiple concurrent tasks. - Add drain logic to ensure all active tasks complete before process restarts. * feat: add chromium-max-concurrency flag - Add a --chromium-max-concurrency flag (1-6) to the Chromium module to control how many conversions run in parallel. - Update LibreOffice to pass maxConcurrency=1 as LibreOffice only supports a single concurrent conversion. * test: add integration tests for concurrent Chromium conversions - Add concurrent request support to the integration test framework with new step definitions for sending parallel requests and asserting on all responses. - Add a feature file for concurrent HTML to PDF conversions.
This commit is contained in:
5
Makefile
5
Makefile
@@ -31,8 +31,9 @@ API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
|
|||||||
API-DISABLE-DOWNLOAD-FROM=false
|
API-DISABLE-DOWNLOAD-FROM=false
|
||||||
API_DISABLE_HEALTH_CHECK_LOGGING=false
|
API_DISABLE_HEALTH_CHECK_LOGGING=false
|
||||||
API_ENABLE_DEBUG_ROUTE=false
|
API_ENABLE_DEBUG_ROUTE=false
|
||||||
CHROMIUM_RESTART_AFTER=10
|
CHROMIUM_RESTART_AFTER=100
|
||||||
CHROMIUM_MAX_QUEUE_SIZE=0
|
CHROMIUM_MAX_QUEUE_SIZE=0
|
||||||
|
CHROMIUM_MAX_CONCURRENCY=6
|
||||||
CHROMIUM_AUTO_START=false
|
CHROMIUM_AUTO_START=false
|
||||||
CHROMIUM_START_TIMEOUT=20s
|
CHROMIUM_START_TIMEOUT=20s
|
||||||
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
|
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
|
||||||
@@ -111,6 +112,7 @@ run: ## Start a Gotenberg container
|
|||||||
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
|
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
|
||||||
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
|
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
|
||||||
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
|
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
|
||||||
|
--chromium-max-concurrency=$(CHROMIUM_MAX_CONCURRENCY) \
|
||||||
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
|
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
|
||||||
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
|
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
|
||||||
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
|
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
|
||||||
@@ -166,6 +168,7 @@ PLATFORM=
|
|||||||
NO_CONCURRENCY=false
|
NO_CONCURRENCY=false
|
||||||
# Available tags:
|
# Available tags:
|
||||||
# chromium
|
# chromium
|
||||||
|
# chromium-concurrent
|
||||||
# chromium-convert-html
|
# chromium-convert-html
|
||||||
# chromium-convert-markdown
|
# chromium-convert-markdown
|
||||||
# chromium-convert-url
|
# chromium-convert-url
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -79,27 +80,38 @@ type processSupervisor struct {
|
|||||||
process Process
|
process Process
|
||||||
maxReqLimit int64
|
maxReqLimit int64
|
||||||
maxQueueSize int64
|
maxQueueSize int64
|
||||||
mutexChan chan struct{}
|
maxConcurrency int64
|
||||||
|
semaphore chan struct{}
|
||||||
firstStart atomic.Bool
|
firstStart atomic.Bool
|
||||||
|
firstStartOnce sync.Once
|
||||||
|
firstStartErr error
|
||||||
reqCounter atomic.Int64
|
reqCounter atomic.Int64
|
||||||
reqQueueSize atomic.Int64
|
reqQueueSize atomic.Int64
|
||||||
restartsCounter atomic.Int64
|
restartsCounter atomic.Int64
|
||||||
isRestarting atomic.Bool
|
isRestarting atomic.Bool
|
||||||
|
activeTasks atomic.Int64
|
||||||
|
restartMutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||||
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize int64) ProcessSupervisor {
|
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor {
|
||||||
|
if maxConcurrency < 1 {
|
||||||
|
maxConcurrency = 1
|
||||||
|
}
|
||||||
|
|
||||||
b := &processSupervisor{
|
b := &processSupervisor{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
process: process,
|
process: process,
|
||||||
mutexChan: make(chan struct{}, 1),
|
semaphore: make(chan struct{}, maxConcurrency),
|
||||||
maxReqLimit: maxReqLimit,
|
maxReqLimit: maxReqLimit,
|
||||||
maxQueueSize: maxQueueSize,
|
maxQueueSize: maxQueueSize,
|
||||||
|
maxConcurrency: maxConcurrency,
|
||||||
}
|
}
|
||||||
b.reqCounter.Store(0)
|
b.reqCounter.Store(0)
|
||||||
b.reqQueueSize.Store(0)
|
b.reqQueueSize.Store(0)
|
||||||
b.restartsCounter.Store(0)
|
b.restartsCounter.Store(0)
|
||||||
b.isRestarting.Store(false)
|
b.isRestarting.Store(false)
|
||||||
|
b.activeTasks.Store(0)
|
||||||
|
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
@@ -130,15 +142,7 @@ func (s *processSupervisor) Shutdown() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *processSupervisor) restart() error {
|
func (s *processSupervisor) restart() error {
|
||||||
if s.isRestarting.Load() {
|
|
||||||
s.logger.Debug("process already restarting, skip restart")
|
|
||||||
|
|
||||||
return ErrProcessAlreadyRestarting
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Debug("restart process")
|
s.logger.Debug("restart process")
|
||||||
s.isRestarting.Store(true)
|
|
||||||
defer s.isRestarting.Store(false)
|
|
||||||
|
|
||||||
err := s.Shutdown()
|
err := s.Shutdown()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -197,33 +201,43 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
|||||||
for {
|
for {
|
||||||
err := func() error {
|
err := func() error {
|
||||||
select {
|
select {
|
||||||
case s.mutexChan <- struct{}{}:
|
case s.semaphore <- struct{}{}:
|
||||||
logger.Debug("process lock acquired")
|
logger.Debug("process lock acquired")
|
||||||
|
|
||||||
|
// If a restart drain is in progress, release the slot
|
||||||
|
// immediately so the drain can acquire it instead.
|
||||||
|
if s.isRestarting.Load() {
|
||||||
|
<-s.semaphore
|
||||||
|
return ErrProcessAlreadyRestarting
|
||||||
|
}
|
||||||
|
|
||||||
s.reqQueueSize.Add(-1)
|
s.reqQueueSize.Add(-1)
|
||||||
s.reqCounter.Add(1)
|
s.reqCounter.Add(1)
|
||||||
releaseMutexChan := true
|
s.activeTasks.Add(1)
|
||||||
|
releaseSemaphore := true
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if releaseMutexChan {
|
s.activeTasks.Add(-1)
|
||||||
|
if releaseSemaphore {
|
||||||
logger.Debug("process lock released")
|
logger.Debug("process lock released")
|
||||||
<-s.mutexChan
|
<-s.semaphore
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if !s.firstStart.Load() {
|
if !s.firstStart.Load() {
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
s.firstStartOnce.Do(func() {
|
||||||
return s.Launch()
|
s.firstStartErr = s.runWithDeadline(ctx, func() error {
|
||||||
|
return s.Launch()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
if err != nil {
|
if s.firstStartErr != nil {
|
||||||
return fmt.Errorf("process first start: %w", err)
|
return fmt.Errorf("process first start: %w", s.firstStartErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.Healthy() {
|
if !s.Healthy() {
|
||||||
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
|
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
|
||||||
err := s.runWithDeadline(ctx, func() error {
|
err := s.doRestart(ctx)
|
||||||
return s.restart()
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("process restart before task: %w", err)
|
return fmt.Errorf("process restart before task: %w", err)
|
||||||
}
|
}
|
||||||
@@ -232,19 +246,21 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
|||||||
err := s.runWithDeadline(ctx, task)
|
err := s.runWithDeadline(ctx, task)
|
||||||
|
|
||||||
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
|
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
|
||||||
s.logger.Debug("max request limit reached, restarting eagerly...")
|
// Only one goroutine should trigger the restart.
|
||||||
releaseMutexChan = false
|
if s.restartMutex.TryLock() {
|
||||||
|
s.logger.Debug("max request limit reached, restarting eagerly...")
|
||||||
|
releaseSemaphore = false
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
err := s.runWithDeadline(context.Background(), func() error {
|
restartErr := s.doRestartLocked(context.Background())
|
||||||
return s.restart()
|
s.restartMutex.Unlock()
|
||||||
})
|
if restartErr != nil {
|
||||||
if err != nil {
|
s.logger.Error(fmt.Sprintf("process restart after task: %v", restartErr))
|
||||||
s.logger.Error(fmt.Sprintf("process restart after task: %v", err))
|
}
|
||||||
}
|
logger.Debug("process lock released")
|
||||||
logger.Debug("process lock released")
|
<-s.semaphore
|
||||||
<-s.mutexChan
|
}()
|
||||||
}()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: no error wrapping because it leaks on Chromium console exceptions output.
|
// Note: no error wrapping because it leaks on Chromium console exceptions output.
|
||||||
@@ -259,7 +275,6 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
|||||||
|
|
||||||
if errors.Is(err, ErrProcessAlreadyRestarting) {
|
if errors.Is(err, ErrProcessAlreadyRestarting) {
|
||||||
logger.Debug("process is already restarting, trying to acquire process lock again...")
|
logger.Debug("process is already restarting, trying to acquire process lock again...")
|
||||||
s.reqQueueSize.Add(1)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +283,47 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doRestart coordinates a process restart, draining all active concurrent
|
||||||
|
// tasks before stopping and restarting the process.
|
||||||
|
func (s *processSupervisor) doRestart(ctx context.Context) error {
|
||||||
|
s.restartMutex.Lock()
|
||||||
|
defer s.restartMutex.Unlock()
|
||||||
|
|
||||||
|
return s.doRestartLocked(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
|
||||||
|
func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
|
||||||
|
s.isRestarting.Store(true)
|
||||||
|
defer s.isRestarting.Store(false)
|
||||||
|
|
||||||
|
// Drain all other active semaphore slots so no other tasks are running during the restart.
|
||||||
|
slotsToAcquire := s.maxConcurrency - 1
|
||||||
|
acquired := make([]struct{}, 0, slotsToAcquire)
|
||||||
|
|
||||||
|
for range slotsToAcquire {
|
||||||
|
select {
|
||||||
|
case s.semaphore <- struct{}{}:
|
||||||
|
acquired = append(acquired, struct{}{})
|
||||||
|
case <-ctx.Done():
|
||||||
|
for range acquired {
|
||||||
|
<-s.semaphore
|
||||||
|
}
|
||||||
|
return fmt.Errorf("drain active tasks before restart: %w", ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.runWithDeadline(ctx, func() error {
|
||||||
|
return s.restart()
|
||||||
|
})
|
||||||
|
|
||||||
|
for range acquired {
|
||||||
|
<-s.semaphore
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *processSupervisor) runWithDeadline(ctx context.Context, task func() error) error {
|
func (s *processSupervisor) runWithDeadline(ctx context.Context, task func() error) error {
|
||||||
runChan := make(chan error, 1)
|
runChan := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
|
||||||
if tc.firstStartSet {
|
if tc.firstStartSet {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0)
|
ps := NewProcessSupervisor(logger, process, 5, 0, 1)
|
||||||
err := ps.Shutdown()
|
err := ps.Shutdown()
|
||||||
|
|
||||||
if !tc.expectError && err != nil {
|
if !tc.expectError && err != nil {
|
||||||
@@ -110,19 +110,11 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
|||||||
|
|
||||||
func TestProcessSupervisor_restart(t *testing.T) {
|
func TestProcessSupervisor_restart(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
scenario string
|
scenario string
|
||||||
initiallyRestarting bool
|
startError error
|
||||||
startError error
|
stopError error
|
||||||
stopError error
|
expectError bool
|
||||||
expectError bool
|
|
||||||
expectedError error
|
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
scenario: "already restarting",
|
|
||||||
initiallyRestarting: true,
|
|
||||||
expectError: true,
|
|
||||||
expectedError: ErrProcessAlreadyRestarting,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
scenario: "successful restart",
|
scenario: "successful restart",
|
||||||
startError: nil,
|
startError: nil,
|
||||||
@@ -154,10 +146,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
|
||||||
if tc.initiallyRestarting {
|
|
||||||
ps.isRestarting.Store(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := ps.restart()
|
err := ps.restart()
|
||||||
|
|
||||||
@@ -168,10 +157,6 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
|||||||
if tc.expectError && err == nil {
|
if tc.expectError && err == nil {
|
||||||
t.Fatal("expected error but got none")
|
t.Fatal("expected error but got none")
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
|
|
||||||
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +202,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
|
||||||
if tc.initiallyStarted {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -402,7 +387,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1).(*processSupervisor)
|
||||||
if tc.initiallyStarted {
|
if tc.initiallyStarted {
|
||||||
ps.firstStart.Store(true)
|
ps.firstStart.Store(true)
|
||||||
}
|
}
|
||||||
@@ -452,8 +437,8 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Making sure restarts are finished.
|
// Making sure restarts are finished.
|
||||||
ps.mutexChan <- struct{}{}
|
ps.semaphore <- struct{}{}
|
||||||
<-ps.mutexChan
|
<-ps.semaphore
|
||||||
|
|
||||||
if startCalls.Load() != tc.expectedStartCalls {
|
if startCalls.Load() != tc.expectedStartCalls {
|
||||||
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
|
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
|
||||||
@@ -488,7 +473,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
|||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.scenario, func(t *testing.T) {
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0, 1).(*processSupervisor)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
if tc.ctxDone {
|
if tc.ctxDone {
|
||||||
@@ -522,10 +507,10 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
|
||||||
|
|
||||||
// Simulating a lock.
|
// Simulating a lock.
|
||||||
ps.mutexChan <- struct{}{}
|
ps.semaphore <- struct{}{}
|
||||||
|
|
||||||
if ps.ReqQueueSize() != 0 {
|
if ps.ReqQueueSize() != 0 {
|
||||||
t.Fatalf("expected queue size to be 0 but got %d", ps.ReqQueueSize())
|
t.Fatalf("expected queue size to be 0 but got %d", ps.ReqQueueSize())
|
||||||
@@ -623,7 +608,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
|
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
|
||||||
ps.restartsCounter.Store(tc.initialRestartsCount)
|
ps.restartsCounter.Store(tc.initialRestartsCount)
|
||||||
|
|
||||||
for i := 0; i < tc.restartAttempts; i++ {
|
for i := 0; i < tc.restartAttempts; i++ {
|
||||||
@@ -637,3 +622,126 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
||||||
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
var startCalls atomic.Int64
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(logger *zap.Logger) error {
|
||||||
|
startCalls.Add(1)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
StopMock: func(logger *zap.Logger) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
HealthyMock: func(logger *zap.Logger) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
maxConcurrency := int64(3)
|
||||||
|
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency).(*processSupervisor)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var running atomic.Int64
|
||||||
|
var maxRunning atomic.Int64
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
tasks := 6
|
||||||
|
|
||||||
|
for i := 0; i < tasks; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
err := ps.Run(ctx, logger, func() error {
|
||||||
|
cur := running.Add(1)
|
||||||
|
for {
|
||||||
|
old := maxRunning.Load()
|
||||||
|
if cur <= old || maxRunning.CompareAndSwap(old, cur) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
running.Add(-1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
observed := maxRunning.Load()
|
||||||
|
if observed > maxConcurrency {
|
||||||
|
t.Fatalf("expected at most %d concurrent tasks, but observed %d", maxConcurrency, observed)
|
||||||
|
}
|
||||||
|
if observed < 2 {
|
||||||
|
t.Fatalf("expected concurrent execution (at least 2 tasks running simultaneously), but observed max %d", observed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startCalls.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 start call, got %d", startCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
||||||
|
logger := zap.NewNop()
|
||||||
|
|
||||||
|
process := &ProcessMock{
|
||||||
|
StartMock: func(logger *zap.Logger) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
StopMock: func(logger *zap.Logger) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
HealthyMock: func(logger *zap.Logger) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
maxConcurrency := int64(3)
|
||||||
|
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency).(*processSupervisor)
|
||||||
|
ps.firstStart.Store(true)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
tasks := 3
|
||||||
|
|
||||||
|
for i := 0; i < tasks; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
err := ps.Run(ctx, logger, func() error {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Wait for the async restart goroutine to complete.
|
||||||
|
deadline := time.After(5 * time.Second)
|
||||||
|
for ps.RestartsCount() < 1 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("timed out waiting for restart to complete")
|
||||||
|
default:
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps.RestartsCount() != 1 {
|
||||||
|
t.Fatalf("expected 1 restart, got %d", ps.RestartsCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,9 +86,10 @@ var (
|
|||||||
// Chromium is a module that provides both an [Api] and routes for converting
|
// Chromium is a module that provides both an [Api] and routes for converting
|
||||||
// an HTML document to PDF.
|
// an HTML document to PDF.
|
||||||
type Chromium struct {
|
type Chromium struct {
|
||||||
autoStart bool
|
autoStart bool
|
||||||
disableRoutes bool
|
disableRoutes bool
|
||||||
args browserArguments
|
maxConcurrency int64
|
||||||
|
args browserArguments
|
||||||
|
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
browser browser
|
browser browser
|
||||||
@@ -391,8 +392,9 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
ID: "chromium",
|
ID: "chromium",
|
||||||
FlagSet: func() *flag.FlagSet {
|
FlagSet: func() *flag.FlagSet {
|
||||||
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
|
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
|
||||||
fs.Int64("chromium-restart-after", 10, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
|
fs.Int64("chromium-restart-after", 100, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
|
||||||
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
|
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
|
||||||
|
fs.Int64("chromium-max-concurrency", 6, "Maximum number of concurrent conversions. Chromium supports up to 6")
|
||||||
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
|
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
|
||||||
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
|
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
|
||||||
fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost")
|
fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost")
|
||||||
@@ -426,6 +428,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
flags := ctx.ParsedFlags()
|
flags := ctx.ParsedFlags()
|
||||||
mod.autoStart = flags.MustBool("chromium-auto-start")
|
mod.autoStart = flags.MustBool("chromium-auto-start")
|
||||||
mod.disableRoutes = flags.MustBool("chromium-disable-routes")
|
mod.disableRoutes = flags.MustBool("chromium-disable-routes")
|
||||||
|
mod.maxConcurrency = flags.MustInt64("chromium-max-concurrency")
|
||||||
|
|
||||||
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -468,7 +471,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Process.
|
// Process.
|
||||||
mod.browser = newChromiumBrowser(mod.args)
|
mod.browser = newChromiumBrowser(mod.args)
|
||||||
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"))
|
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency)
|
||||||
|
|
||||||
// PDF Engine.
|
// PDF Engine.
|
||||||
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
|
||||||
@@ -486,6 +489,10 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Validate validates the module properties.
|
// Validate validates the module properties.
|
||||||
func (mod *Chromium) Validate() error {
|
func (mod *Chromium) Validate() error {
|
||||||
|
if mod.maxConcurrency < 1 || mod.maxConcurrency > 6 {
|
||||||
|
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
_, err := os.Stat(mod.args.binPath)
|
_, err := os.Stat(mod.args.binPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("chromium binary path does not exist: %w", err)
|
return fmt.Errorf("chromium binary path does not exist: %w", err)
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
// Process.
|
// Process.
|
||||||
a.libreOffice = newLibreOfficeProcess(a.args)
|
a.libreOffice = newLibreOfficeProcess(a.args)
|
||||||
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
|
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
20
test/integration/features/chromium_concurrent.feature
Normal file
20
test/integration/features/chromium_concurrent.feature
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
@chromium
|
||||||
|
@chromium-concurrent
|
||||||
|
Feature: Chromium concurrent conversions
|
||||||
|
|
||||||
|
Scenario: Concurrent HTML to PDF conversions with max concurrency 3
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_MAX_CONCURRENCY | 3 |
|
||||||
|
When I make 3 concurrent "POST" requests to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page-1-html/index.html | file |
|
||||||
|
Then all concurrent response status codes should be 200
|
||||||
|
Then all concurrent responses should have 1 PDF(s)
|
||||||
|
|
||||||
|
Scenario: Concurrent conversions exceeding restart-after limit
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_MAX_CONCURRENCY | 3 |
|
||||||
|
| CHROMIUM_RESTART_AFTER | 5 |
|
||||||
|
When I make 10 concurrent "POST" requests to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page-1-html/index.html | file |
|
||||||
|
Then all concurrent response status codes should be 200
|
||||||
|
Then all concurrent responses should have 1 PDF(s)
|
||||||
@@ -204,8 +204,9 @@ Feature: /debug
|
|||||||
"chromium-ignore-certificate-errors": "false",
|
"chromium-ignore-certificate-errors": "false",
|
||||||
"chromium-incognito": "false",
|
"chromium-incognito": "false",
|
||||||
"chromium-max-queue-size": "0",
|
"chromium-max-queue-size": "0",
|
||||||
|
"chromium-max-concurrency": "6",
|
||||||
"chromium-proxy-server": "",
|
"chromium-proxy-server": "",
|
||||||
"chromium-restart-after": "10",
|
"chromium-restart-after": "100",
|
||||||
"chromium-start-timeout": "20s",
|
"chromium-start-timeout": "20s",
|
||||||
"gotenberg-build-debug-data": "true",
|
"gotenberg-build-debug-data": "true",
|
||||||
"gotenberg-graceful-shutdown-duration": "30s",
|
"gotenberg-graceful-shutdown-duration": "30s",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cucumber/godog"
|
"github.com/cucumber/godog"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
|
|
||||||
type scenario struct {
|
type scenario struct {
|
||||||
resp *httptest.ResponseRecorder
|
resp *httptest.ResponseRecorder
|
||||||
|
concurrentResps []*httptest.ResponseRecorder
|
||||||
workdir string
|
workdir string
|
||||||
gotenbergContainer testcontainers.Container
|
gotenbergContainer testcontainers.Container
|
||||||
gotenbergContainerNetwork *testcontainers.DockerNetwork
|
gotenbergContainerNetwork *testcontainers.DockerNetwork
|
||||||
@@ -33,6 +35,7 @@ type scenario struct {
|
|||||||
|
|
||||||
func (s *scenario) reset(ctx context.Context) error {
|
func (s *scenario) reset(ctx context.Context) error {
|
||||||
s.resp = httptest.NewRecorder()
|
s.resp = httptest.NewRecorder()
|
||||||
|
s.concurrentResps = nil
|
||||||
|
|
||||||
err := os.RemoveAll(s.workdir)
|
err := os.RemoveAll(s.workdir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -281,6 +284,171 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count int, method, endpoint string, dataTable *godog.Table) error {
|
||||||
|
if s.gotenbergContainer == nil {
|
||||||
|
return errors.New("no Gotenberg container")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := make(map[string]string)
|
||||||
|
files := make(map[string][]string)
|
||||||
|
headers := make(map[string]string)
|
||||||
|
|
||||||
|
for _, row := range dataTable.Rows {
|
||||||
|
name := row.Cells[0].Value
|
||||||
|
value := row.Cells[1].Value
|
||||||
|
kind := row.Cells[2].Value
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case "field":
|
||||||
|
fields[name] = value
|
||||||
|
case "file":
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get current directory: %w", err)
|
||||||
|
}
|
||||||
|
value = fmt.Sprintf("%s/%s", wd, value)
|
||||||
|
files[name] = append(files[name], value)
|
||||||
|
case "header":
|
||||||
|
headers[name] = value
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unexpected %q %q", kind, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get container HTTP endpoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
s.concurrentResps = make([]*httptest.ResponseRecorder, 0, count)
|
||||||
|
errs := make([]error, 0)
|
||||||
|
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
resp, reqErr := doFormDataRequest(method, fmt.Sprintf("%s%s", base, endpoint), fields, files, headers)
|
||||||
|
if reqErr != nil {
|
||||||
|
mu.Lock()
|
||||||
|
errs = append(errs, fmt.Errorf("do request: %w", reqErr))
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, reqErr := io.ReadAll(resp.Body)
|
||||||
|
if reqErr != nil {
|
||||||
|
mu.Lock()
|
||||||
|
errs = append(errs, fmt.Errorf("read response body: %w", reqErr))
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
rec.Code = resp.StatusCode
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
for _, v := range values {
|
||||||
|
rec.Header().Add(key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = rec.Body.Write(body)
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
cd := resp.Header.Get("Content-Disposition")
|
||||||
|
if cd != "" {
|
||||||
|
_, params, parseErr := mime.ParseMediaType(cd)
|
||||||
|
if parseErr == nil {
|
||||||
|
if filename, ok := params["filename"]; ok {
|
||||||
|
traceID := resp.Header.Get("Gotenberg-Trace")
|
||||||
|
dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
mkErr := os.MkdirAll(dirPath, 0o755)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if mkErr == nil {
|
||||||
|
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
|
||||||
|
f, fErr := os.Create(fpath)
|
||||||
|
if fErr == nil {
|
||||||
|
_, _ = f.Write(body)
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
s.concurrentResps = append(s.concurrentResps, rec)
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("concurrent requests failed: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scenario) allConcurrentResponseStatusCodesShouldBe(expected int) error {
|
||||||
|
if len(s.concurrentResps) == 0 {
|
||||||
|
return errors.New("no concurrent responses recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, resp := range s.concurrentResps {
|
||||||
|
if resp.Code != expected {
|
||||||
|
return fmt.Errorf("concurrent response %d: expected status %d, got %d %q", i+1, expected, resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scenario) allConcurrentResponsesShouldHavePdfs(expected int) error {
|
||||||
|
if len(s.concurrentResps) == 0 {
|
||||||
|
return errors.New("no concurrent responses recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, resp := range s.concurrentResps {
|
||||||
|
traceID := resp.Header().Get("Gotenberg-Trace")
|
||||||
|
dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID)
|
||||||
|
|
||||||
|
_, err := os.Stat(dirPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("concurrent response %d: directory %q does not exist", i+1, dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
|
||||||
|
if pathErr != nil {
|
||||||
|
return pathErr
|
||||||
|
}
|
||||||
|
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("concurrent response %d: walk %q: %w", i+1, dirPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(paths) != expected {
|
||||||
|
return fmt.Errorf("concurrent response %d: expected %d PDF(s), got %d", i+1, expected, len(paths))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *scenario) iWaitForTheAsynchronousRequestToWebhook(ctx context.Context) error {
|
func (s *scenario) iWaitForTheAsynchronousRequestToWebhook(ctx context.Context) error {
|
||||||
if s.server == nil {
|
if s.server == nil {
|
||||||
return errors.New("server not initialized")
|
return errors.New("server not initialized")
|
||||||
@@ -965,9 +1133,12 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
|||||||
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint$`, s.iMakeARequestToGotenberg)
|
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint$`, s.iMakeARequestToGotenberg)
|
||||||
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders)
|
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders)
|
||||||
ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders)
|
ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders)
|
||||||
|
ctx.When(`^I make (\d+) concurrent "(POST)" requests to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeConcurrentRequestsToGotenberg)
|
||||||
ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook)
|
ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook)
|
||||||
ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries)
|
ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries)
|
||||||
ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe)
|
ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe)
|
||||||
|
ctx.Then(`^all concurrent response status codes should be (\d+)$`, s.allConcurrentResponseStatusCodesShouldBe)
|
||||||
|
ctx.Then(`^all concurrent responses should have (\d+) PDF\(s\)$`, s.allConcurrentResponsesShouldHavePdfs)
|
||||||
ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
|
ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
|
||||||
ctx.Then(`^the (response|webhook request|file request|server request) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe)
|
ctx.Then(`^the (response|webhook request|file request|server request) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe)
|
||||||
ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString)
|
ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString)
|
||||||
|
|||||||
Reference in New Issue
Block a user