docs/plans/2026-01-02-release-level-tasks-design.md
Change GitOps tasks from (database, sheet) granularity to (database, release) granularity to simplify rollout management and reduce task count.
Current State:
Problem:
Goal:
File: proto/store/task.proto
Before:
message Task {
string sheet_sha256 = 4;
string schema_version = 10;
TaskReleaseSource task_release_source = 13;
// ... other fields
}
After:
message Task {
oneof source {
string sheet_sha256 = 10; // For regular (non-release) tasks
string release = 13; // For release-based tasks: projects/{project}/releases/{release}
}
// ... other fields
// Removed: schema_version (field 10, replaced by sheet_sha256)
// Removed: task_release_source (field 13, replaced by release)
}
Key changes:
oneof source to make sheet vs release mutually exclusivesheet_sha256 (moved from field 4)release (replaces task_release_source)schema_version (was only used for release tasks, now tracked per file)File: backend/api/v1/rollout_service_task.go
Function: getTaskCreatesFromChangeDatabaseConfigWithRelease() (line 352)
Before:
// Creates N×M tasks
for _, database := range databases {
for _, file := range release.Payload.Files {
// Filter by applied versions
if alreadyApplied(file.Version) {
continue
}
// Create task(database, file)
task := &store.TaskMessage{
Payload: &storepb.Task{
SheetSha256: file.SheetSha256,
SchemaVersion: file.Version,
TaskReleaseSource: &storepb.TaskReleaseSource{
File: formatReleaseFile(file.Id),
},
},
}
taskCreates = append(taskCreates, task)
}
}
After:
// Creates N tasks
for _, database := range databases {
// Create task(database, release)
task := &store.TaskMessage{
InstanceID: database.InstanceID,
DatabaseName: &database.DatabaseName,
Environment: database.EffectiveEnvironmentID,
Type: storepb.Task_DATABASE_MIGRATE,
Payload: &storepb.Task{
SpecId: spec.Id,
Release: c.Release, // Store release name, not individual files
// Remove SheetSha256, SchemaVersion, TaskReleaseSource
},
}
taskCreates = append(taskCreates, task)
}
Benefits:
File: backend/runner/taskrun/database_migrate_executor.go
Current execution:
func (exec *DatabaseMigrateExecutor) RunOnce(ctx context.Context, ...) {
sheet := getSheet(task.Payload.GetSheetSha256())
runMigration(ctx, ..., sheet, task.Payload.GetSchemaVersion())
}
New execution for release tasks:
func (exec *DatabaseMigrateExecutor) RunOnce(ctx context.Context, ...) {
// Check if this is a release-based task
if releaseName := task.Payload.GetRelease(); releaseName != "" {
return exec.runReleaseTask(ctx, task, taskRunUID, releaseName)
}
// Fall back to single-sheet execution
sheet := getSheet(task.Payload.GetSheetSha256())
runMigration(ctx, ..., sheet, "")
}
func (exec *DatabaseMigrateExecutor) runReleaseTask(
ctx context.Context,
task *store.TaskMessage,
taskRunUID int,
releaseName string,
) error {
// 1. Fetch release
_, releaseUID, err := common.GetProjectReleaseUID(releaseName)
release, err := exec.store.GetReleaseByUID(ctx, releaseUID)
// 2. Get existing revisions for this database
revisions, err := exec.store.ListRevisions(ctx, &store.FindRevisionMessage{
InstanceID: &task.InstanceID,
DatabaseName: task.DatabaseName,
})
// 3. Build map of applied versions
appliedVersions := make(map[string]bool)
for _, revision := range revisions {
if revision.Payload.Type == storepb.SchemaChangeType_VERSIONED {
appliedVersions[revision.Version] = true
}
}
// 4. Execute unapplied files in order
for _, file := range release.Payload.Files {
if file.Type != storepb.SchemaChangeType_VERSIONED {
continue // Skip declarative for now
}
// Skip if already applied
if appliedVersions[file.Version] {
continue
}
// Fetch sheet and execute
sheet, err := exec.store.GetSheet(ctx, &store.FindSheetMessage{
Sha256: &file.SheetSha256,
})
// Run this file's migration
_, result, err := runMigration(
ctx,
...,
sheet,
file.Version, // Pass version from file, not task
)
if err != nil {
return err // Stop on first failure
}
// Migration succeeded, revision already recorded by runMigration
}
return nil
}
Key points:
task.Payload.GetRelease() to detect release tasksFile: backend/migrator/migration/<version>/XXXXX##release_level_tasks.sql
-- Update task payload JSONB structure
-- Remove schema_version and task_release_source fields
-- Existing tasks remain unchanged (backward compatible at DB level)
-- New tasks use the new structure
-- No migration needed since:
-- 1. We're reusing existing field numbers in proto
-- 2. JSONB is flexible - old tasks keep old structure
-- 3. New tasks will have new structure
-- 4. Execution logic checks which fields are present
No actual migration needed - proto field reuse and JSONB flexibility handle this.
File: backend/store/task.go
Update TaskPatch:
type TaskPatch struct {
// ... existing fields
// Remove or deprecate:
// SchemaVersion *string
// Add:
Release *string // For setting release name on task
}
Update payload update logic (line 406-410):
// Remove:
// if v := patch.SchemaVersion; v != nil {
// payloadParts.Join(" || ", "jsonb_build_object('schemaVersion', ?::TEXT)", *v)
// }
// Add:
if v := patch.Release; v != nil {
payloadParts.Join(" || ", "jsonb_build_object('release', ?::TEXT)", *v)
}
File: backend/api/v1/rollout_service_converter.go
Update task conversion (line 346-350):
// Before:
DatabaseUpdate: &v1pb.Task_DatabaseUpdate{
Sheet: common.FormatSheet(project.ResourceID, task.Payload.GetSheetSha256()),
SchemaVersion: task.Payload.GetSchemaVersion(),
DatabaseChangeType: databaseChangeType,
}
// After:
databaseUpdate := &v1pb.Task_DatabaseUpdate{
DatabaseChangeType: databaseChangeType,
}
// Set either sheet or release
if releaseName := task.Payload.GetRelease(); releaseName != "" {
databaseUpdate.Release = releaseName
} else {
databaseUpdate.Sheet = common.FormatSheet(project.ResourceID, task.Payload.GetSheetSha256())
}
DatabaseUpdate: databaseUpdate
Update v1 proto if needed:
Check if v1pb.Task_DatabaseUpdate needs a release field for API responses.
Files to update:
frontend/src/utils/v1/issue/rollout.ts:187 - extractSchemaVersionFromTask()frontend/src/components/RolloutV1/components/TaskView.vuefrontend/src/components/RolloutV1/components/TaskTable.vuefrontend/src/components/IssueV1/components/TaskListSection/TaskCard.vueChanges:
getTaskCreatesFromChangeDatabaseConfigWithRelease()runReleaseTask() to executorUnit tests:
Integration tests:
Backward compatibility:
Before:
After: