diff --git a/server/backend/database/database.go b/server/backend/database/database.go index be0118e2f..f95274dfe 100644 --- a/server/backend/database/database.go +++ b/server/backend/database/database.go @@ -134,13 +134,19 @@ type Database interface { // after handling PushPull. UpdateClientInfoAfterPushPull(ctx context.Context, clientInfo *ClientInfo, docInfo *DocInfo) error - // FindDeactivateCandidates finds the housekeeping candidates. - FindDeactivateCandidates( + // FindNextNCyclingProjectInfos finds the next N cycling projects from the given projectID. + FindNextNCyclingProjectInfos( ctx context.Context, - candidatesLimitPerProject int, - projectFetchSize int, + pageSize int, lastProjectID types.ID, - ) (types.ID, []*ClientInfo, error) + ) ([]*ProjectInfo, error) + + // FindDeactivateCandidatesPerProject finds the clients that need housekeeping per project. + FindDeactivateCandidatesPerProject( + ctx context.Context, + project *ProjectInfo, + candidatesLimit int, + ) ([]*ClientInfo, error) // FindDocInfoByKey finds the document of the given key. FindDocInfoByKey( diff --git a/server/backend/database/memory/database.go b/server/backend/database/memory/database.go index 7a45335cb..fb7511afe 100644 --- a/server/backend/database/memory/database.go +++ b/server/backend/database/memory/database.go @@ -224,11 +224,11 @@ func (d *DB) CreateProjectInfo( return info, nil } -// listProjectInfos returns all project infos rotationally. -func (d *DB) listProjectInfos( +// FindNextNCyclingProjectInfos finds the next N cycling projects from the given projectID. +func (d *DB) FindNextNCyclingProjectInfos( _ context.Context, pageSize int, - housekeepingLastProjectID types.ID, + lastProjectID types.ID, ) ([]*database.ProjectInfo, error) { txn := d.db.Txn(false) defer txn.Abort() @@ -236,26 +236,46 @@ func (d *DB) listProjectInfos( iter, err := txn.LowerBound( tblProjects, "id", - housekeepingLastProjectID.String(), + lastProjectID.String(), ) if err != nil { return nil, fmt.Errorf("fetch projects: %w", err) } var infos []*database.ProjectInfo + isCircular := false for i := 0; i < pageSize; i++ { raw := iter.Next() if raw == nil { - break + if isCircular { + break + } + + iter, err = txn.LowerBound( + tblProjects, + "id", + database.DefaultProjectID.String(), + ) + if err != nil { + return nil, fmt.Errorf("fetch projects: %w", err) + } + + i-- + isCircular = true + continue } info := raw.(*database.ProjectInfo).DeepCopy() - if i == 0 && info.ID == housekeepingLastProjectID { + if i == 0 && info.ID == lastProjectID { pageSize++ continue } + if len(infos) > 0 && infos[0].ID == info.ID { + break + } + infos = append(infos, info) } @@ -563,8 +583,8 @@ func (d *DB) UpdateClientInfoAfterPushPull( return nil } -// findDeactivateCandidatesPerProject finds the clients that need housekeeping per project. -func (d *DB) findDeactivateCandidatesPerProject( +// FindDeactivateCandidatesPerProject finds the clients that need housekeeping per project. +func (d *DB) FindDeactivateCandidatesPerProject( _ context.Context, project *database.ProjectInfo, candidatesLimit int, @@ -599,41 +619,12 @@ func (d *DB) findDeactivateCandidatesPerProject( info.UpdatedAt.After(offset) { break } - infos = append(infos, info) - } - return infos, nil -} -// FindDeactivateCandidates finds the clients that need housekeeping. -func (d *DB) FindDeactivateCandidates( - ctx context.Context, - candidatesLimitPerProject int, - projectFetchSize int, - lastProjectID types.ID, -) (types.ID, []*database.ClientInfo, error) { - projects, err := d.listProjectInfos(ctx, projectFetchSize, lastProjectID) - if err != nil { - return database.DefaultProjectID, nil, err - } - - var candidates []*database.ClientInfo - for _, project := range projects { - infos, err := d.findDeactivateCandidatesPerProject(ctx, project, candidatesLimitPerProject) - if err != nil { - return database.DefaultProjectID, nil, err + if info.ProjectID == project.ID { + infos = append(infos, info) } - - candidates = append(candidates, infos...) - } - - var topProjectID types.ID - if len(projects) < projectFetchSize { - topProjectID = database.DefaultProjectID - } else { - topProjectID = projects[len(projects)-1].ID } - - return topProjectID, candidates, nil + return infos, nil } // FindDocInfoByKeyAndOwner finds the document of the given key. If the diff --git a/server/backend/database/memory/database_test.go b/server/backend/database/memory/database_test.go index 2486f8bf1..bc376f8f7 100644 --- a/server/backend/database/memory/database_test.go +++ b/server/backend/database/memory/database_test.go @@ -36,6 +36,14 @@ func TestDB(t *testing.T) { db, err := memory.New() assert.NoError(t, err) + t.Run("FindNextNCyclingProjectInfos test", func(t *testing.T) { + testcases.RunFindNextNCyclingProjectInfosTest(t, db) + }) + + t.Run("FindDeactivateCandidatesPerProject test", func(t *testing.T) { + testcases.RunFindDeactivateCandidatesPerProjectTest(t, db) + }) + t.Run("RunFindDocInfo test", func(t *testing.T) { testcases.RunFindDocInfoTest(t, db, projectID) }) diff --git a/server/backend/database/memory/housekeeping_test.go b/server/backend/database/memory/housekeeping_test.go deleted file mode 100644 index 5c1aeaeb0..000000000 --- a/server/backend/database/memory/housekeeping_test.go +++ /dev/null @@ -1,133 +0,0 @@ -//go:build amd64 - -/* - * Copyright 2022 The Yorkie Authors. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package memory_test - -import ( - "context" - "fmt" - "log" - "testing" - gotime "time" - - "github.com/stretchr/testify/assert" - monkey "github.com/undefinedlabs/go-mpatch" - - "github.com/yorkie-team/yorkie/server/backend/database" - "github.com/yorkie-team/yorkie/server/backend/database/memory" -) - -func TestHousekeeping(t *testing.T) { - memdb, err := memory.New() - assert.NoError(t, err) - - t.Run("housekeeping test", func(t *testing.T) { - ctx := context.Background() - - clientDeactivateThreshold := "23h" - - userInfo, err := memdb.CreateUserInfo(ctx, "test", "test") - assert.NoError(t, err) - project, err := memdb.CreateProjectInfo(ctx, database.DefaultProjectName, userInfo.ID, clientDeactivateThreshold) - assert.NoError(t, err) - - yesterday := gotime.Now().Add(-24 * gotime.Hour) - patch, err := monkey.PatchMethod(gotime.Now, func() gotime.Time { return yesterday }) - if err != nil { - log.Fatal(err) - } - clientA, err := memdb.ActivateClient(ctx, project.ID, fmt.Sprintf("%s-A", t.Name())) - assert.NoError(t, err) - clientB, err := memdb.ActivateClient(ctx, project.ID, fmt.Sprintf("%s-B", t.Name())) - assert.NoError(t, err) - err = patch.Unpatch() - if err != nil { - log.Fatal(err) - } - - clientC, err := memdb.ActivateClient(ctx, project.ID, fmt.Sprintf("%s-C", t.Name())) - assert.NoError(t, err) - - _, candidates, err := memdb.FindDeactivateCandidates( - ctx, - 10, - 10, - database.DefaultProjectID, - ) - assert.NoError(t, err) - assert.Len(t, candidates, 2) - assert.Contains(t, candidates, clientA) - assert.Contains(t, candidates, clientB) - assert.NotContains(t, candidates, clientC) - }) - - t.Run("housekeeping pagination test", func(t *testing.T) { - ctx := context.Background() - memdb, projects := createDBandProjects(t) - - fetchSize := 4 - lastProjectID, _, err := memdb.FindDeactivateCandidates( - ctx, - 0, - fetchSize, - database.DefaultProjectID, - ) - assert.NoError(t, err) - assert.Equal(t, projects[fetchSize-1].ID, lastProjectID) - - lastProjectID, _, err = memdb.FindDeactivateCandidates( - ctx, - 0, - fetchSize, - lastProjectID, - ) - assert.NoError(t, err) - assert.Equal(t, projects[fetchSize*2-1].ID, lastProjectID) - - lastProjectID, _, err = memdb.FindDeactivateCandidates( - ctx, - 0, - fetchSize, - lastProjectID, - ) - assert.NoError(t, err) - assert.Equal(t, database.DefaultProjectID, lastProjectID) - }) -} - -func createDBandProjects(t *testing.T) (*memory.DB, []*database.ProjectInfo) { - t.Helper() - - ctx := context.Background() - memdb, err := memory.New() - assert.NoError(t, err) - - clientDeactivateThreshold := "23h" - userInfo, err := memdb.CreateUserInfo(ctx, "test", "test") - assert.NoError(t, err) - - projects := make([]*database.ProjectInfo, 0) - for i := 0; i < 10; i++ { - p, err := memdb.CreateProjectInfo(ctx, fmt.Sprintf("%d project", i), userInfo.ID, clientDeactivateThreshold) - assert.NoError(t, err) - - projects = append(projects, p) - } - - return memdb, projects -} diff --git a/server/backend/database/mongo/client.go b/server/backend/database/mongo/client.go index 5429f58f7..4e6cc869f 100644 --- a/server/backend/database/mongo/client.go +++ b/server/backend/database/mongo/client.go @@ -240,13 +240,13 @@ func (c *Client) CreateProjectInfo( return info, nil } -// listProjectInfos returns all project infos rotationally. -func (c *Client) listProjectInfos( +// FindNextNCyclingProjectInfos finds the next N cycling projects from the given projectID. +func (c *Client) FindNextNCyclingProjectInfos( ctx context.Context, pageSize int, - housekeepingLastProjectID types.ID, + lastProjectID types.ID, ) ([]*database.ProjectInfo, error) { - encodedID, err := encodeID(housekeepingLastProjectID) + encodedID, err := encodeID(lastProjectID) if err != nil { return nil, err } @@ -268,6 +268,25 @@ func (c *Client) listProjectInfos( return nil, fmt.Errorf("fetch project infos: %w", err) } + if len(infos) < pageSize { + opts.SetLimit(int64(pageSize - len(infos))) + + cursor, err := c.collection(colProjects).Find(ctx, bson.M{ + "_id": bson.M{ + "$lte": encodedID, + }, + }, opts) + if err != nil { + return nil, fmt.Errorf("find project infos: %w", err) + } + + var newInfos []*database.ProjectInfo + if err := cursor.All(ctx, &newInfos); err != nil { + return nil, fmt.Errorf("fetch project infos: %w", err) + } + infos = append(infos, newInfos...) + } + return infos, nil } @@ -633,8 +652,8 @@ func (c *Client) UpdateClientInfoAfterPushPull( return nil } -// findDeactivateCandidatesPerProject finds the clients that need housekeeping per project. -func (c *Client) findDeactivateCandidatesPerProject( +// FindDeactivateCandidatesPerProject finds the clients that need housekeeping per project. +func (c *Client) FindDeactivateCandidatesPerProject( ctx context.Context, project *database.ProjectInfo, candidatesLimit int, @@ -669,37 +688,6 @@ func (c *Client) findDeactivateCandidatesPerProject( return clientInfos, nil } -// FindDeactivateCandidates finds the clients that need housekeeping. -func (c *Client) FindDeactivateCandidates( - ctx context.Context, - candidatesLimitPerProject int, - projectFetchSize int, - lastProjectID types.ID, -) (types.ID, []*database.ClientInfo, error) { - projects, err := c.listProjectInfos(ctx, projectFetchSize, lastProjectID) - if err != nil { - return database.DefaultProjectID, nil, err - } - - var candidates []*database.ClientInfo - for _, project := range projects { - clientInfos, err := c.findDeactivateCandidatesPerProject(ctx, project, candidatesLimitPerProject) - if err != nil { - return database.DefaultProjectID, nil, err - } - - candidates = append(candidates, clientInfos...) - } - - var topProjectID types.ID - if len(projects) < projectFetchSize { - topProjectID = database.DefaultProjectID - } else { - topProjectID = projects[len(projects)-1].ID - } - return topProjectID, candidates, nil -} - // FindDocInfoByKeyAndOwner finds the document of the given key. If the // createDocIfNotExist condition is true, create the document if it does not // exist. diff --git a/server/backend/database/mongo/client_test.go b/server/backend/database/mongo/client_test.go index 9eaf04034..fc0c187c9 100644 --- a/server/backend/database/mongo/client_test.go +++ b/server/backend/database/mongo/client_test.go @@ -51,6 +51,14 @@ func setupTestWithDummyData(t *testing.T) *mongo.Client { func TestClient(t *testing.T) { cli := setupTestWithDummyData(t) + t.Run("FindNextNCyclingProjectInfos test", func(t *testing.T) { + testcases.RunFindNextNCyclingProjectInfosTest(t, cli) + }) + + t.Run("FindDeactivateCandidatesPerProject test", func(t *testing.T) { + testcases.RunFindDeactivateCandidatesPerProjectTest(t, cli) + }) + t.Run("RunFindDocInfo test", func(t *testing.T) { testcases.RunFindDocInfoTest(t, cli, dummyProjectID) }) @@ -100,8 +108,4 @@ func TestClient(t *testing.T) { t.Run("IsDocumentAttached test", func(t *testing.T) { testcases.RunIsDocumentAttachedTest(t, cli, dummyProjectID) }) - - t.Run("FindDeactivateCandidates test", func(t *testing.T) { - testcases.RunFindDeactivateCandidates(t, cli) - }) } diff --git a/server/backend/database/testcases/testcases.go b/server/backend/database/testcases/testcases.go index 3cc19121c..6f9661a72 100644 --- a/server/backend/database/testcases/testcases.go +++ b/server/backend/database/testcases/testcases.go @@ -19,10 +19,8 @@ package testcases import ( - "bytes" "context" "fmt" - "sort" "strconv" "testing" gotime "time" @@ -624,52 +622,6 @@ func RunFindDocInfosByPagingTest(t *testing.T, db database.Database, projectID t }) } -// RunFindDeactivateCandidates runs the FindDeactivateCandidates tests for the given db. -func RunFindDeactivateCandidates(t *testing.T, db database.Database) { - t.Run("housekeeping pagination test", func(t *testing.T) { - ctx := context.Background() - - // Lists all projects of the dummyOwnerID and otherOwnerID. - projects, err := db.ListProjectInfos(ctx, dummyOwnerID) - assert.NoError(t, err) - otherProjects, err := db.ListProjectInfos(ctx, otherOwnerID) - assert.NoError(t, err) - - projects = append(projects, otherProjects...) - - sort.Slice(projects, func(i, j int) bool { - iBytes, err := projects[i].ID.Bytes() - assert.NoError(t, err) - jBytes, err := projects[j].ID.Bytes() - assert.NoError(t, err) - return bytes.Compare(iBytes, jBytes) < 0 - }) - - fetchSize := 3 - lastProjectID := database.DefaultProjectID - - for i := 0; i < len(projects)/fetchSize; i++ { - lastProjectID, _, err = db.FindDeactivateCandidates( - ctx, - 0, - fetchSize, - lastProjectID, - ) - assert.NoError(t, err) - assert.Equal(t, projects[((i+1)*fetchSize)-1].ID, lastProjectID) - } - - lastProjectID, _, err = db.FindDeactivateCandidates( - ctx, - 0, - fetchSize, - lastProjectID, - ) - assert.NoError(t, err) - assert.Equal(t, database.DefaultProjectID, lastProjectID) - }) -} - // RunCreateChangeInfosTest runs the CreateChangeInfos tests for the given db. func RunCreateChangeInfosTest(t *testing.T, db database.Database, projectID types.ID) { t.Run("set RemovedAt in docInfo test", func(t *testing.T) { @@ -1074,3 +1026,86 @@ func RunIsDocumentAttachedTest(t *testing.T, db database.Database, projectID typ assert.False(t, attached) }) } + +// RunFindNextNCyclingProjectInfosTest runs the FindNextNCyclingProjectInfos tests for the given db. +func RunFindNextNCyclingProjectInfosTest(t *testing.T, db database.Database) { + t.Run("FindNextNCyclingProjectInfos cyclic search test", func(t *testing.T) { + ctx := context.Background() + + projectCnt := 10 + projects := make([]*database.ProjectInfo, 0) + for i := 0; i < projectCnt; i++ { + p, err := db.CreateProjectInfo( + ctx, + fmt.Sprintf("%s-%d-RunFindNextNCyclingProjectInfos", t.Name(), i), + otherOwnerID, + clientDeactivateThreshold, + ) + assert.NoError(t, err) + projects = append(projects, p) + } + + lastProjectID := database.DefaultProjectID + pageSize := 2 + + for i := 0; i < 10; i++ { + projectInfos, err := db.FindNextNCyclingProjectInfos(ctx, pageSize, lastProjectID) + assert.NoError(t, err) + + lastProjectID = projectInfos[len(projectInfos)-1].ID + + assert.Equal(t, projects[((i+1)*pageSize-1)%projectCnt].ID, lastProjectID) + } + + }) +} + +// RunFindDeactivateCandidatesPerProjectTest runs the FindDeactivateCandidatesPerProject tests for the given db. +func RunFindDeactivateCandidatesPerProjectTest(t *testing.T, db database.Database) { + t.Run("FindDeactivateCandidatesPerProject candidate search test", func(t *testing.T) { + ctx := context.Background() + + p1, err := db.CreateProjectInfo( + ctx, + fmt.Sprintf("%s-FindDeactivateCandidatesPerProject", t.Name()), + otherOwnerID, + clientDeactivateThreshold, + ) + assert.NoError(t, err) + + _, err = db.ActivateClient(ctx, p1.ID, t.Name()+"1-1") + assert.NoError(t, err) + + _, err = db.ActivateClient(ctx, p1.ID, t.Name()+"1-2") + assert.NoError(t, err) + + p2, err := db.CreateProjectInfo( + ctx, + fmt.Sprintf("%s-FindDeactivateCandidatesPerProject-2", t.Name()), + otherOwnerID, + "0s", + ) + assert.NoError(t, err) + + c1, err := db.ActivateClient(ctx, p2.ID, t.Name()+"2-1") + assert.NoError(t, err) + + c2, err := db.ActivateClient(ctx, p2.ID, t.Name()+"2-2") + assert.NoError(t, err) + + candidates1, err := db.FindDeactivateCandidatesPerProject(ctx, p1, 10) + assert.NoError(t, err) + assert.Equal(t, 0, len(candidates1)) + + candidates2, err := db.FindDeactivateCandidatesPerProject(ctx, p2, 10) + assert.NoError(t, err) + + idList := make([]types.ID, len(candidates2)) + for i, candidate := range candidates2 { + idList[i] = candidate.ID + } + assert.Equal(t, 2, len(candidates2)) + assert.Contains(t, idList, c1.ID) + assert.Contains(t, idList, c2.ID) + }) +} diff --git a/server/backend/housekeeping/housekeeping.go b/server/backend/housekeeping/housekeeping.go index 2672ab182..0d8934012 100644 --- a/server/backend/housekeeping/housekeeping.go +++ b/server/backend/housekeeping/housekeeping.go @@ -148,7 +148,7 @@ func (h *Housekeeping) deactivateCandidates( } }() - lastProjectID, candidates, err := h.database.FindDeactivateCandidates( + lastProjectID, candidates, err := h.FindDeactivateCandidates( ctx, h.candidatesLimitPerProject, h.projectFetchSize, @@ -183,3 +183,35 @@ func (h *Housekeeping) deactivateCandidates( return lastProjectID, nil } + +// FindDeactivateCandidates finds the housekeeping candidates. +func (h *Housekeeping) FindDeactivateCandidates( + ctx context.Context, + candidatesLimitPerProject int, + projectFetchSize int, + lastProjectID types.ID, +) (types.ID, []*database.ClientInfo, error) { + projects, err := h.database.FindNextNCyclingProjectInfos(ctx, projectFetchSize, lastProjectID) + if err != nil { + return database.DefaultProjectID, nil, err + } + + var candidates []*database.ClientInfo + for _, project := range projects { + infos, err := h.database.FindDeactivateCandidatesPerProject(ctx, project, candidatesLimitPerProject) + if err != nil { + return database.DefaultProjectID, nil, err + } + + candidates = append(candidates, infos...) + } + + var topProjectID types.ID + if len(projects) < projectFetchSize { + topProjectID = database.DefaultProjectID + } else { + topProjectID = projects[len(projects)-1].ID + } + + return topProjectID, candidates, nil +} diff --git a/test/integration/housekeeping_test.go b/test/integration/housekeeping_test.go new file mode 100644 index 000000000..3e62c4cec --- /dev/null +++ b/test/integration/housekeeping_test.go @@ -0,0 +1,161 @@ +//go:build integration && amd64 + +/* + * Copyright 2021 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package integration + +import ( + "bytes" + "context" + "fmt" + "log" + "sort" + "testing" + + gotime "time" + + monkey "github.com/undefinedlabs/go-mpatch" + + "github.com/stretchr/testify/assert" + + "github.com/yorkie-team/yorkie/api/types" + "github.com/yorkie-team/yorkie/server/backend/database" + "github.com/yorkie-team/yorkie/server/backend/database/mongo" + "github.com/yorkie-team/yorkie/server/backend/housekeeping" + "github.com/yorkie-team/yorkie/server/backend/sync/memory" + "github.com/yorkie-team/yorkie/test/helper" +) + +const ( + dummyOwnerID = types.ID("000000000000000000000003") + otherOwnerID = types.ID("000000000000000000000004") + clientDeactivateThreshold = "23h" +) + +func setupTest(t *testing.T) *mongo.Client { + config := &mongo.Config{ + ConnectionTimeout: "5s", + ConnectionURI: "mongodb://localhost:27017", + YorkieDatabase: helper.TestDBName() + "-integration", + PingTimeout: "5s", + } + assert.NoError(t, config.Validate()) + + cli, err := mongo.Dial(config) + assert.NoError(t, err) + + return cli +} + +func TestHousekeeping(t *testing.T) { + config := helper.TestConfig() + db := setupTest(t) + + projects := createProjects(t, db) + + coordinator := memory.NewCoordinator(nil) + + h, err := housekeeping.New(config.Housekeeping, db, coordinator) + assert.NoError(t, err) + + t.Run("FindDeactivateCandidates return lastProjectID test", func(t *testing.T) { + ctx := context.Background() + + fetchSize := 3 + lastProjectID := database.DefaultProjectID + + for i := 0; i < len(projects)/fetchSize; i++ { + lastProjectID, _, err = h.FindDeactivateCandidates( + ctx, + 0, + fetchSize, + lastProjectID, + ) + assert.NoError(t, err) + assert.Equal(t, projects[((i+1)*fetchSize)-1].ID, lastProjectID) + } + + lastProjectID, _, err = h.FindDeactivateCandidates( + ctx, + 0, + fetchSize, + lastProjectID, + ) + assert.NoError(t, err) + assert.Equal(t, projects[fetchSize-(len(projects)%3)-1].ID, lastProjectID) + }) + + t.Run("FindDeactivateCandidates return clients test", func(t *testing.T) { + ctx := context.Background() + + yesterday := gotime.Now().Add(-24 * gotime.Hour) + patch, err := monkey.PatchMethod(gotime.Now, func() gotime.Time { return yesterday }) + if err != nil { + log.Fatal(err) + } + clientA, err := db.ActivateClient(ctx, projects[0].ID, fmt.Sprintf("%s-A", t.Name())) + assert.NoError(t, err) + clientB, err := db.ActivateClient(ctx, projects[0].ID, fmt.Sprintf("%s-B", t.Name())) + assert.NoError(t, err) + err = patch.Unpatch() + if err != nil { + log.Fatal(err) + } + + clientC, err := db.ActivateClient(ctx, projects[0].ID, fmt.Sprintf("%s-C", t.Name())) + assert.NoError(t, err) + + _, candidates, err := h.FindDeactivateCandidates( + ctx, + 10, + 10, + database.DefaultProjectID, + ) + + assert.NoError(t, err) + assert.Len(t, candidates, 2) + assert.Equal(t, candidates[0].ID, clientA.ID) + assert.Equal(t, candidates[1].ID, clientB.ID) + assert.NotContains(t, candidates, clientC) + }) +} + +func createProjects(t *testing.T, db *mongo.Client) []*database.ProjectInfo { + t.Helper() + + ctx := context.Background() + + projects := make([]*database.ProjectInfo, 0) + for i := 0; i < 10; i++ { + p, err := db.CreateProjectInfo(ctx, fmt.Sprintf("%d project", i), dummyOwnerID, clientDeactivateThreshold) + assert.NoError(t, err) + projects = append(projects, p) + p, err = db.CreateProjectInfo(ctx, fmt.Sprintf("%d project", i), otherOwnerID, clientDeactivateThreshold) + assert.NoError(t, err) + projects = append(projects, p) + } + + sort.Slice(projects, func(i, j int) bool { + iBytes, err := projects[i].ID.Bytes() + assert.NoError(t, err) + jBytes, err := projects[j].ID.Bytes() + assert.NoError(t, err) + return bytes.Compare(iBytes, jBytes) < 0 + }) + + return projects +}