Skip to content

Commit 34ca980

Browse files
authored
Fix memory leak by reusing Odoo client (#209)
The Odoo client uses http.Client under the hood which should be reused since it keeps connections open. The initialization of the Odoo client is not thread-safe so I added a wrapper that fully initializes the client before any parallelized call. See comments on `FullInitialization()`.
1 parent cde575c commit 34ca980

File tree

3 files changed

+128
-7
lines changed

3 files changed

+128
-7
lines changed

apiserver/billing/odoostorage/odoo/odoo16/odoo16.go

+73-6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"strconv"
99
"strings"
10+
"sync"
1011
"time"
1112

1213
"github.com/google/uuid"
@@ -68,20 +69,82 @@ type Config struct {
6869

6970
var _ odoo.OdooStorage = &Odoo16Storage{}
7071

72+
// NewOdoo16Storage returns a new storage provider for BillingEntities
73+
// The storage provider uses Odoo 16 as the backend.
7174
func NewOdoo16Storage(credentials OdooCredentials, conf Config) *Odoo16Storage {
7275
return &Odoo16Storage{
7376
config: conf,
74-
sessionCreator: func(ctx context.Context) (Odoo16Client, error) {
75-
return odooclient.NewClient(&credentials)
76-
},
77+
sessionCreator: CachingClientCreator(func(ctx context.Context) (Odoo16Client, error) {
78+
c, err := odooclient.NewClient(&credentials)
79+
return &OdooClientWithFullInitialization{c}, err
80+
}),
7781
}
7882
}
7983

8084
func NewFailedRecordScrubber(credentials OdooCredentials) *FailedRecordScrubber {
8185
return &FailedRecordScrubber{
82-
sessionCreator: func(ctx context.Context) (Odoo16Client, error) {
83-
return odooclient.NewClient(&credentials)
84-
},
86+
sessionCreator: CachingClientCreator(func(ctx context.Context) (Odoo16Client, error) {
87+
c, err := odooclient.NewClient(&credentials)
88+
return &OdooClientWithFullInitialization{c}, err
89+
}),
90+
}
91+
}
92+
93+
// OdooClientWithFullInitialization is a wrapper around the Odoo client that provides a FullInitialization method.
94+
type OdooClientWithFullInitialization struct {
95+
*odooclient.Client
96+
}
97+
98+
// FullInitialization is a workaround for the odooclient initialization which is not thread-safe.
99+
// This function performs a full initialization of the client by calling execute_kw which initializes the "object" client.
100+
// https://github.com/appuio/go-odoo/blob/a2a337fdf12becaeaa920ee8093402d6d01144f3/odoo.go#L337
101+
// After this call the client should be thread-safe and ready to use.
102+
// The "common" client is already initialized in the NewClient function through the call of "authenticate".
103+
// https://github.com/appuio/go-odoo/blob/a2a337fdf12becaeaa920ee8093402d6d01144f3/odoo.go#L53
104+
// https://github.com/appuio/go-odoo/blob/a2a337fdf12becaeaa920ee8093402d6d01144f3/odoo.go#L346
105+
func (c *OdooClientWithFullInitialization) FullInitialization() error {
106+
_, err := c.Client.ExecuteKw("search_count", odooclient.ResPartnerModel, []any{*odooclient.NewCriteria()}, nil)
107+
return err
108+
}
109+
110+
// CachingClientCreator accepts and returns a function that creates a new Odoo16Client instance.
111+
// The function caches the client instance returned from the upstream client creation function and returns it on subsequent calls.
112+
// If an error occurs during the creation of the client, the error is returned and the client creation is retried on the next call.
113+
// No client is cached if an error occurs during the creation.
114+
//
115+
// This function is useful to avoid creating a new client instance for every request.
116+
// Odoo clients track connections and session state internally, so reusing a client instance is beneficial.
117+
// The upstream Odoo client is not thread-safe until a full initialization is performed.
118+
// See the FullInitialization method on OdooClientWithFullInitialization for a workaround.
119+
func CachingClientCreator(sc func(context.Context) (Odoo16Client, error)) func(context.Context) (Odoo16Client, error) {
120+
clientMux := sync.RWMutex{}
121+
var client Odoo16Client
122+
123+
return func(ctx context.Context) (Odoo16Client, error) {
124+
clientMux.RLock()
125+
if client != nil {
126+
clientMux.RUnlock()
127+
return client, nil
128+
}
129+
clientMux.RUnlock()
130+
131+
clientMux.Lock()
132+
defer clientMux.Unlock()
133+
134+
// recheck if client was created between RUnlock and Lock
135+
if client != nil {
136+
return client, nil
137+
}
138+
139+
c, err := sc(ctx)
140+
if err != nil {
141+
return nil, err
142+
}
143+
if err := c.FullInitialization(); err != nil {
144+
return nil, fmt.Errorf("error during full initialization: %w", err)
145+
}
146+
client = c
147+
return client, nil
85148
}
86149
}
87150

@@ -97,6 +160,10 @@ type FailedRecordScrubber struct {
97160

98161
//go:generate go run go.uber.org/mock/mockgen -destination=./odoo16mock/$GOFILE -package odoo16mock . Odoo16Client
99162
type Odoo16Client interface {
163+
// FullInitialization performs a full initialization of the client.
164+
// After this call the client must be thread-safe and ready to use.
165+
FullInitialization() error
166+
100167
Update(string, []int64, interface{}) error
101168
FindResPartners(*odooclient.Criteria, *odooclient.Options) (*odooclient.ResPartners, error)
102169
CreateResPartner(*odooclient.ResPartner) (int64, error)

apiserver/billing/odoostorage/odoo/odoo16/odoo16_test.go

+41-1
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import (
66
"testing"
77
"time"
88

9+
odooclient "github.com/appuio/go-odoo"
910
"github.com/stretchr/testify/assert"
1011
"github.com/stretchr/testify/require"
1112
"go.uber.org/mock/gomock"
1213
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1314

1415
billingv1 "github.com/appuio/control-api/apis/billing/v1"
1516
"github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo16/odoo16mock"
16-
odooclient "github.com/appuio/go-odoo"
1717
)
1818

1919
func TestGet(t *testing.T) {
@@ -398,3 +398,43 @@ func TestCleanup(t *testing.T) {
398398
require.NoError(t, err)
399399

400400
}
401+
402+
func Test_CachingClientCreator(t *testing.T) {
403+
ctrl := gomock.NewController(t)
404+
defer ctrl.Finish()
405+
406+
calls := 0
407+
shouldFail := true
408+
409+
subject := CachingClientCreator(func(ctx context.Context) (Odoo16Client, error) {
410+
calls++
411+
if shouldFail {
412+
return nil, errors.New("failed to create client")
413+
}
414+
mock := odoo16mock.NewMockOdoo16Client(ctrl)
415+
mock.EXPECT().FullInitialization().Times(1).Return(nil)
416+
return mock, nil
417+
})
418+
419+
// Failing call should return an error
420+
_, err := subject(context.Background())
421+
require.Error(t, err)
422+
assert.Equal(t, 1, calls)
423+
_, err = subject(context.Background())
424+
require.Error(t, err)
425+
assert.Equal(t, 2, calls)
426+
427+
shouldFail = false
428+
429+
// First successful call should create a new client
430+
client, err := subject(context.Background())
431+
require.NoError(t, err)
432+
assert.Equal(t, 3, calls)
433+
prevClient := client
434+
435+
// Second successful call should return the same client
436+
client, err = subject(context.Background())
437+
require.NoError(t, err)
438+
assert.Equal(t, prevClient, client)
439+
assert.Equal(t, 3, calls)
440+
}

apiserver/billing/odoostorage/odoo/odoo16/odoo16mock/odoo16.go

+14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)