Path: blob/main/components/gitpod-db/go/workspace_instance.go
2498 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package db56import (7"context"8"database/sql"9"fmt"10"strings"11"time"1213"github.com/gitpod-io/gitpod/common-go/log"1415"github.com/google/uuid"16"gorm.io/datatypes"17"gorm.io/gorm"18)1920type WorkspaceInstance struct {21ID uuid.UUID `gorm:"primary_key;column:id;type:char;size:36;" json:"id"`22WorkspaceID string `gorm:"column:workspaceId;type:char;size:36;" json:"workspaceId"`23Configuration datatypes.JSON `gorm:"column:configuration;type:text;size:65535;" json:"configuration"`24Region string `gorm:"column:region;type:varchar;size:255;" json:"region"`25ImageBuildInfo sql.NullString `gorm:"column:imageBuildInfo;type:text;size:65535;" json:"imageBuildInfo"`26IdeURL string `gorm:"column:ideUrl;type:varchar;size:255;" json:"ideUrl"`27WorkspaceBaseImage string `gorm:"column:workspaceBaseImage;type:varchar;size:255;" json:"workspaceBaseImage"`28WorkspaceImage string `gorm:"column:workspaceImage;type:varchar;size:255;" json:"workspaceImage"`29UsageAttributionID AttributionID `gorm:"column:usageAttributionId;type:varchar;size:60;" json:"usageAttributionId"`30WorkspaceClass string `gorm:"column:workspaceClass;type:varchar;size:255;" json:"workspaceClass"`3132CreationTime VarcharTime `gorm:"column:creationTime;type:varchar;size:255;" json:"creationTime"`33StartedTime VarcharTime `gorm:"column:startedTime;type:varchar;size:255;" json:"startedTime"`34DeployedTime VarcharTime `gorm:"column:deployedTime;type:varchar;size:255;" json:"deployedTime"`35StoppedTime VarcharTime `gorm:"column:stoppedTime;type:varchar;size:255;" json:"stoppedTime"`36LastModified time.Time `gorm:"column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"`37StoppingTime VarcharTime `gorm:"column:stoppingTime;type:varchar;size:255;" json:"stoppingTime"`3839LastHeartbeat string `gorm:"column:lastHeartbeat;type:varchar;size:255;" json:"lastHeartbeat"`40StatusOld sql.NullString `gorm:"column:status_old;type:varchar;size:255;" json:"status_old"`41Status datatypes.JSON `gorm:"column:status;type:json;" json:"status"`42// Phase is derived from Status by extracting JSON from it. Read-only (-> property).43Phase sql.NullString `gorm:"->:column:phase;type:char;size:32;" json:"phase"`44PhasePersisted string `gorm:"column:phasePersisted;type:char;size:32;" json:"phasePersisted"`4546// deleted is restricted for use by periodic deleter47_ bool `gorm:"column:deleted;type:tinyint;default:0;" json:"deleted"`48}4950// TableName sets the insert table name for this struct type51func (i *WorkspaceInstance) TableName() string {52return "d_b_workspace_instance"53}5455// FindStoppedWorkspaceInstancesInRange finds WorkspaceInstanceForUsage that have been stopped between from (inclusive) and to (exclusive).56func FindStoppedWorkspaceInstancesInRange(ctx context.Context, conn *gorm.DB, from, to time.Time) ([]WorkspaceInstanceForUsage, error) {57var instances []WorkspaceInstanceForUsage58var instancesInBatch []WorkspaceInstanceForUsage5960tx := queryWorkspaceInstanceForUsage(ctx, conn).61Where("wsi.stoppingTime >= ?", TimeToISO8601(from)).62Where("wsi.stoppingTime < ?", TimeToISO8601(to)).63Where("wsi.stoppingTime != ?", "").64Where("wsi.usageAttributionId != ?", "").65FindInBatches(&instancesInBatch, 1000, func(_ *gorm.DB, _ int) error {66instances = append(instances, instancesInBatch...)67return nil68})69if tx.Error != nil {70return nil, fmt.Errorf("failed to find workspace instances: %w", tx.Error)71}7273return instances, nil74}7576// FindRunningWorkspaceInstances finds WorkspaceInstanceForUsage that are running at the point in time the query is executed.77func FindRunningWorkspaceInstances(ctx context.Context, conn *gorm.DB) ([]WorkspaceInstanceForUsage, error) {78var instances []WorkspaceInstanceForUsage79var instancesInBatch []WorkspaceInstanceForUsage8081tx := queryWorkspaceInstanceForUsage(ctx, conn).82Where("wsi.phasePersisted = ?", "running").83// We are only interested in instances that have been started within the last 10 days.84Where("wsi.startedTime > ?", TimeToISO8601(time.Now().Add(-10*24*time.Hour))).85// All other selectors are there to ensure data quality86Where("wsi.stoppingTime = ?", "").87Where("wsi.usageAttributionId != ?", "").88FindInBatches(&instancesInBatch, 1000, func(_ *gorm.DB, _ int) error {89instances = append(instances, instancesInBatch...)90return nil91})92if tx.Error != nil {93return nil, fmt.Errorf("failed to find running workspace instances: %w", tx.Error)94}9596return instances, nil97}9899// FindWorkspaceInstancesByIds finds WorkspaceInstanceForUsage by Id.100func FindWorkspaceInstancesByIds(ctx context.Context, conn *gorm.DB, workspaceInstanceIds []uuid.UUID) ([]WorkspaceInstanceForUsage, error) {101var instances []WorkspaceInstanceForUsage102var instancesInBatch []WorkspaceInstanceForUsage103var idChunks [][]uuid.UUID104chunkSize, totalSize := 1000, len(workspaceInstanceIds)105// explicit batching to reduce the lengths of the 'in'-part in the SELECT statement below106for i := 0; i < totalSize; i += chunkSize {107end := i + chunkSize108if end > totalSize {109end = totalSize110}111idChunks = append(idChunks, workspaceInstanceIds[i:end])112}113114for _, idChunk := range idChunks {115err := queryWorkspaceInstanceForUsage(ctx, conn).116Where("wsi.id in ?", idChunk).117Where("wsi.usageAttributionId != ?", "").118Find(&instancesInBatch).Error119if err != nil {120return nil, fmt.Errorf("failed to find workspace instances by id: %w", err)121}122instances = append(instances, instancesInBatch...)123}124125return instances, nil126}127128func queryWorkspaceInstanceForUsage(ctx context.Context, conn *gorm.DB) *gorm.DB {129return conn.WithContext(ctx).130Table(fmt.Sprintf("%s as wsi", (&WorkspaceInstance{}).TableName())).131Select("wsi.id as id, "+132"ws.projectId as projectId, "+133"ws.contextUrl as contextUrl, "+134"ws.type as workspaceType, "+135"wsi.workspaceClass as workspaceClass, "+136"wsi.usageAttributionId as usageAttributionId, "+137"wsi.creationTime as creationTime, "+138"wsi.startedTime as startedTime, "+139"wsi.stoppingTime as stoppingTime, "+140"wsi.stoppedTime as stoppedTime, "+141"ws.ownerId as ownerId, "+142"wsi.workspaceId as workspaceId, "+143"ws.ownerId as userId, "+144"u.name as userName, "+145"u.avatarURL as userAvatarURL ",146).147Joins(fmt.Sprintf("LEFT JOIN %s AS ws ON wsi.workspaceId = ws.id", (&Workspace{}).TableName())).148Joins(fmt.Sprintf("LEFT JOIN %s AS u ON ws.ownerId = u.id", "d_b_user")).149// Instances without a StartedTime never actually started, we're not interested in these.150Where("wsi.startedTime != ?", "")151}152153const (154attributionEntity_Team = "team"155)156157func NewTeamAttributionID(teamID string) AttributionID {158return AttributionID(fmt.Sprintf("%s:%s", attributionEntity_Team, teamID))159}160161// AttributionID consists of an entity, and an identifier in the form:162// <entity>:<identifier>, e.g. team:a7dcf253-f05e-4dcf-9a47-cf8fccc74717163type AttributionID string164165func (a AttributionID) Values() (entity string, identifier string) {166tokens := strings.Split(string(a), ":")167if len(tokens) != 2 || tokens[0] != attributionEntity_Team || tokens[1] == "" {168return "", ""169}170171return tokens[0], tokens[1]172}173174func ParseAttributionID(s string) (AttributionID, error) {175tokens := strings.Split(s, ":")176if len(tokens) != 2 {177return "", fmt.Errorf("attribution ID (%s) does not have two parts", s)178}179_, err := uuid.Parse(tokens[1])180if err != nil {181return "", fmt.Errorf("The uuid part of attribution ID (%s) is not a valid UUID. %w", tokens[1], err)182}183184switch tokens[0] {185case attributionEntity_Team:186return NewTeamAttributionID(tokens[1]), nil187default:188return "", fmt.Errorf("unknown attribution ID type: %s", s)189}190}191192const (193WorkspaceClass_Default = "default"194)195196type WorkspaceInstanceForUsage struct {197ID uuid.UUID `gorm:"column:id;type:char;size:36;" json:"id"`198WorkspaceID string `gorm:"column:workspaceId;type:char;size:36;" json:"workspaceId"`199OwnerID uuid.UUID `gorm:"column:ownerId;type:char;size:36;" json:"ownerId"`200ProjectID sql.NullString `gorm:"column:projectId;type:char;size:36;" json:"projectId"`201WorkspaceClass string `gorm:"column:workspaceClass;type:varchar;size:255;" json:"workspaceClass"`202Type WorkspaceType `gorm:"column:workspaceType;type:char;size:16;default:regular;" json:"workspaceType"`203UsageAttributionID AttributionID `gorm:"column:usageAttributionId;type:varchar;size:60;" json:"usageAttributionId"`204ContextURL string `gorm:"column:contextUrl;type:varchar;size:255;" json:"contextUrl"`205UserID uuid.UUID `gorm:"column:userId;type:varchar;size:255;" json:"userId"`206UserName string `gorm:"column:userName;type:varchar;size:255;" json:"userName"`207UserAvatarURL string `gorm:"column:userAvatarURL;type:varchar;size:255;" json:"userAvatarURL"`208209CreationTime VarcharTime `gorm:"column:creationTime;type:varchar;size:255;" json:"creationTime"`210StartedTime VarcharTime `gorm:"column:startedTime;type:varchar;size:255;" json:"startedTime"`211StoppingTime VarcharTime `gorm:"column:stoppingTime;type:varchar;size:255;" json:"stoppingTime"`212StoppedTime VarcharTime `gorm:"column:stoppedTime;type:varchar;size:255;" json:"stoppedTime"`213}214215// WorkspaceRuntimeSeconds computes how long this WorkspaceInstance has been running.216// If the instance is still running (no stopping time set), maxStopTime is used to to compute the duration - this is an upper bound on stop217func (i *WorkspaceInstanceForUsage) WorkspaceRuntimeSeconds(stopTimeIfInstanceIsStillRunning time.Time) int64 {218start := i.StartedTime.Time()219stop := stopTimeIfInstanceIsStillRunning220221if i.StoppingTime.IsSet() {222stop = i.StoppingTime.Time()223} else if i.StoppedTime.IsSet() {224stop = i.StoppedTime.Time()225}226227if stop.Before(start) {228log.229WithField("instance_id", i.ID).230WithField("workspace_id", i.WorkspaceID).231WithField("started_time", TimeToISO8601(i.StartedTime.Time())).232WithField("started_time_set", i.StartedTime.IsSet()).233WithField("stopping_time_set", i.StoppingTime.IsSet()).234WithField("stopping_time", TimeToISO8601(i.StoppingTime.Time())).235WithField("stopped_time_set", i.StoppedTime.IsSet()).236WithField("stopped_time", TimeToISO8601(i.StoppedTime.Time())).237WithField("stop_time_if_instance_still_running", stopTimeIfInstanceIsStillRunning).238Errorf("Instance %s had stop time before start time. Using startedTime as stop time.", i.ID)239240stop = start241}242243return int64(stop.Sub(start).Round(time.Second).Seconds())244}245246func ListWorkspaceInstanceIDsWithPhaseStoppedButNoStoppingTime(ctx context.Context, conn *gorm.DB) ([]uuid.UUID, error) {247var ids []uuid.UUID248//var chunk []uuid.UUID249250tx := conn.WithContext(ctx).251Table(fmt.Sprintf("%s as wsi", (&WorkspaceInstance{}).TableName())).252Joins(fmt.Sprintf("LEFT JOIN %s AS u ON wsi.id = u.id", (&Usage{}).TableName())).253Where("wsi.phasePersisted = ?", "stopped").254Where("wsi.stoppingTime = ''"). // empty255Pluck("wsi.id", &ids)256if tx.Error != nil {257return nil, fmt.Errorf("failed to list workspace instances with phase stopped but no stopping time: %w", tx.Error)258}259return ids, nil260}261262263