-
Notifications
You must be signed in to change notification settings - Fork 287
feat: /install/tasks support singleflight #675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,44 +1,66 @@ | ||
| package service | ||
|
|
||
| import ( | ||
| "fmt" | ||
|
|
||
| "github.com/langgenius/dify-plugin-daemon/internal/db" | ||
| "github.com/langgenius/dify-plugin-daemon/internal/types/exception" | ||
| "github.com/langgenius/dify-plugin-daemon/internal/types/models" | ||
| "github.com/langgenius/dify-plugin-daemon/pkg/entities" | ||
| "github.com/langgenius/dify-plugin-daemon/pkg/entities/plugin_entities" | ||
| "golang.org/x/sync/singleflight" | ||
| "gorm.io/gorm" | ||
| ) | ||
|
|
||
| var ( | ||
| installationTasksGroup singleflight.Group | ||
| installationTaskGroup singleflight.Group | ||
| ) | ||
|
|
||
| func FetchPluginInstallationTasks( | ||
| tenant_id string, | ||
| page int, | ||
| page_size int, | ||
| ) *entities.Response { | ||
| tasks, err := db.GetAll[models.InstallTask]( | ||
| db.Equal("tenant_id", tenant_id), | ||
| db.OrderBy("created_at", true), | ||
| db.Page(page, page_size), | ||
| ) | ||
| key := fmt.Sprintf("tasks:%s:%d:%d", tenant_id, page, page_size) | ||
| v, err, _ := installationTasksGroup.Do(key, func() (interface{}, error) { | ||
| tasks, err := db.GetAll[models.InstallTask]( | ||
| db.Equal("tenant_id", tenant_id), | ||
| db.OrderBy("created_at", true), | ||
| db.Page(page, page_size), | ||
| ) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return tasks, nil | ||
| }) | ||
|
Comment on lines
+26
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The use of |
||
| if err != nil { | ||
| return exception.InternalServerError(err).ToResponse() | ||
| } | ||
|
|
||
| return entities.NewSuccessResponse(tasks) | ||
| return entities.NewSuccessResponse(v) | ||
| } | ||
|
|
||
| func FetchPluginInstallationTask( | ||
| tenant_id string, | ||
| task_id string, | ||
| ) *entities.Response { | ||
| task, err := db.GetOne[models.InstallTask]( | ||
| db.Equal("id", task_id), | ||
| db.Equal("tenant_id", tenant_id), | ||
| ) | ||
| key := fmt.Sprintf("task:%s:%s", tenant_id, task_id) | ||
| v, err, _ := installationTaskGroup.Do(key, func() (interface{}, error) { | ||
| task, err := db.GetOne[models.InstallTask]( | ||
| db.Equal("id", task_id), | ||
| db.Equal("tenant_id", tenant_id), | ||
| ) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return task, nil | ||
| }) | ||
|
Comment on lines
+49
to
+58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to the list fetch, returning a pointer ( |
||
| if err != nil { | ||
| return exception.InternalServerError(err).ToResponse() | ||
| } | ||
|
|
||
| return entities.NewSuccessResponse(task) | ||
| return entities.NewSuccessResponse(v) | ||
| } | ||
|
|
||
| func DeletePluginInstallationTask( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| package service | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "sync" | ||
| "sync/atomic" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/langgenius/dify-plugin-daemon/internal/db" | ||
| "github.com/langgenius/dify-plugin-daemon/internal/types/models" | ||
| "golang.org/x/sync/singleflight" | ||
| "gorm.io/driver/sqlite" | ||
| "gorm.io/gorm" | ||
| ) | ||
|
|
||
| func setupTestDB(t *testing.T) *gorm.DB { | ||
| t.Helper() | ||
| testDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | ||
| if err != nil { | ||
| t.Fatalf("failed to open test db: %v", err) | ||
| } | ||
| sqlDB, err := testDB.DB() | ||
| if err != nil { | ||
| t.Fatalf("failed to get underlying sql.DB: %v", err) | ||
| } | ||
| sqlDB.SetMaxOpenConns(1) | ||
|
|
||
| if err := testDB.AutoMigrate(&models.InstallTask{}); err != nil { | ||
| t.Fatalf("failed to auto migrate: %v", err) | ||
| } | ||
| db.DifyPluginDB = testDB | ||
| t.Cleanup(func() { | ||
| sqlDB.Close() | ||
| }) | ||
| return testDB | ||
| } | ||
|
|
||
| func TestFetchPluginInstallationTasks_Singleflight(t *testing.T) { | ||
| testDB := setupTestDB(t) | ||
| installationTasksGroup = singleflight.Group{} | ||
|
|
||
| var queryCount atomic.Int32 | ||
| testDB.Callback().Query().Before("gorm:query").Register("test:count_tasks", func(tx *gorm.DB) { | ||
| queryCount.Add(1) | ||
| time.Sleep(100 * time.Millisecond) | ||
| }) | ||
| defer testDB.Callback().Query().Remove("test:count_tasks") | ||
|
|
||
| const concurrency = 50 | ||
| var wg sync.WaitGroup | ||
| wg.Add(concurrency) | ||
| start := make(chan struct{}) | ||
| errs := make([]int, concurrency) | ||
|
|
||
| for i := 0; i < concurrency; i++ { | ||
| go func(idx int) { | ||
| defer wg.Done() | ||
| <-start | ||
| resp := FetchPluginInstallationTasks("tenant-1", 1, 10) | ||
| errs[idx] = resp.Code | ||
| }(i) | ||
| } | ||
|
|
||
| close(start) | ||
| wg.Wait() | ||
|
|
||
| for i, code := range errs { | ||
| if code != 0 { | ||
| t.Errorf("goroutine %d: expected code 0, got %d", i, code) | ||
| } | ||
| } | ||
|
|
||
| if count := queryCount.Load(); count != 1 { | ||
| t.Errorf("singleflight not working: expected 1 db query for same key, got %d", count) | ||
| } | ||
| } | ||
|
|
||
| func TestFetchPluginInstallationTask_Singleflight(t *testing.T) { | ||
| testDB := setupTestDB(t) | ||
| installationTaskGroup = singleflight.Group{} | ||
|
|
||
| // Insert a test record before registering the callback. | ||
| task := models.InstallTask{ | ||
| TenantID: "tenant-1", | ||
| Status: models.InstallTaskStatusPending, | ||
| TotalPlugins: 1, | ||
| } | ||
| if err := testDB.Create(&task).Error; err != nil { | ||
| t.Fatalf("failed to create test task: %v", err) | ||
| } | ||
|
|
||
| var queryCount atomic.Int32 | ||
| testDB.Callback().Query().Before("gorm:query").Register("test:count_task", func(tx *gorm.DB) { | ||
| queryCount.Add(1) | ||
| time.Sleep(100 * time.Millisecond) | ||
| }) | ||
| defer testDB.Callback().Query().Remove("test:count_task") | ||
|
|
||
| const concurrency = 50 | ||
| var wg sync.WaitGroup | ||
| wg.Add(concurrency) | ||
| start := make(chan struct{}) | ||
| errs := make([]int, concurrency) | ||
|
|
||
| for i := 0; i < concurrency; i++ { | ||
| go func(idx int) { | ||
| defer wg.Done() | ||
| <-start | ||
| resp := FetchPluginInstallationTask("tenant-1", task.ID) | ||
| errs[idx] = resp.Code | ||
| }(i) | ||
| } | ||
|
|
||
| close(start) | ||
| wg.Wait() | ||
|
|
||
| for i, code := range errs { | ||
| if code != 0 { | ||
| t.Errorf("goroutine %d: expected code 0, got %d", i, code) | ||
| } | ||
| } | ||
|
|
||
| if count := queryCount.Load(); count != 1 { | ||
| t.Errorf("singleflight not working: expected 1 db query for same key, got %d", count) | ||
| } | ||
| } | ||
|
|
||
| func TestFetchPluginInstallationTasks_DifferentKeysNotDeduplicated(t *testing.T) { | ||
| testDB := setupTestDB(t) | ||
| installationTasksGroup = singleflight.Group{} | ||
|
|
||
| var queryCount atomic.Int32 | ||
| testDB.Callback().Query().Before("gorm:query").Register("test:count_diff", func(tx *gorm.DB) { | ||
| queryCount.Add(1) | ||
| time.Sleep(50 * time.Millisecond) | ||
| }) | ||
| defer testDB.Callback().Query().Remove("test:count_diff") | ||
|
|
||
| const numKeys = 3 | ||
| var wg sync.WaitGroup | ||
| wg.Add(numKeys) | ||
| start := make(chan struct{}) | ||
|
|
||
| for i := 0; i < numKeys; i++ { | ||
| go func(idx int) { | ||
| defer wg.Done() | ||
| <-start | ||
| FetchPluginInstallationTasks(fmt.Sprintf("tenant-%d", idx), 1, 10) | ||
| }(i) | ||
| } | ||
|
|
||
| close(start) | ||
| wg.Wait() | ||
|
|
||
| if count := queryCount.Load(); count != int32(numKeys) { | ||
| t.Errorf("different keys should not be deduplicated: expected %d db queries, got %d", numKeys, count) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using multiple
singleflight.Groupinstances within the same service is redundant when the keys are already prefixed with distinct identifiers (e.g.,tasks:andtask:). A singlesingleflight.Groupcan handle deduplication for all keys in this service, which simplifies the code and reduces global state.