package service import ( "errors" "fmt" "sort" "strings" "browser.local/platform/domain" "browser.local/platform/repo" "browser.local/platform/validator" ) const ( maxMetricSamplesPerServer = 1000 maxBackupsPerServer = 100 maxBackupBytesPerServer = int64(4 * 1024 * 1024 * 1024) ) func (svc *CoreService) IngestMetricBatch(batch domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error) { batch = domain.CopyMetricBatchIngest(batch) if len(batch.Samples) == 0 || len(batch.Samples) > 256 { return domain.MetricBatchIngestResult{}, validationError("metric batch must contain between 1 and 256 samples") } if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil { return domain.MetricBatchIngestResult{}, err } latest := svc.now() for index, sample := range batch.Samples { instance, err := svc.store.ServerInstances().Get(sample.ServerInstanceID) if err != nil { return domain.MetricBatchIngestResult{}, err } if instance.RunEndpointID != batch.RunEndpointID { return domain.MetricBatchIngestResult{}, validationError("metric sample server must belong to runEndpointId") } sample.RunEndpointID = batch.RunEndpointID if sample.ID == "" { sample.ID = fmt.Sprintf("metric:%s:%d:%d", sample.ServerInstanceID, sample.CollectedAt.UnixNano(), index) } if sample.CollectedAt.IsZero() { sample.CollectedAt = svc.now() } if err := validator.ValidateMetricSample(sample); err != nil { return domain.MetricBatchIngestResult{}, err } if err := svc.store.MetricSamples().Create(sample); err != nil { if !errors.Is(err, repo.ErrDuplicate) { return domain.MetricBatchIngestResult{}, err } existing, getErr := svc.store.MetricSamples().Get(sample.ID) if getErr != nil || existing.ServerInstanceID != sample.ServerInstanceID || existing.CollectedAt != sample.CollectedAt { return domain.MetricBatchIngestResult{}, validationError("metric sample id conflicts with persisted sample") } } if sample.CollectedAt.After(latest) { latest = sample.CollectedAt } if err := svc.pruneMetricSamples(sample.ServerInstanceID); err != nil { return domain.MetricBatchIngestResult{}, err } } return domain.MetricBatchIngestResult{Accepted: true, AcceptedCount: len(batch.Samples), LatestAt: latest, ServerTime: svc.now()}, nil } func (svc *CoreService) ListMetricSamplesForSession(sessionID string, filter domain.MetricSampleFilter) ([]domain.MetricSample, error) { if err := validator.ValidateMetricSampleFilter(filter); err != nil { return nil, err } if strings.TrimSpace(filter.ServerInstanceID) == "" { return nil, validationError("serverInstanceId is required") } instance, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID) if err != nil { return nil, err } items, err := svc.store.MetricSamples().List(filter) if err != nil { return nil, err } sort.SliceStable(items, func(i, j int) bool { return items[i].CollectedAt.Before(items[j].CollectedAt) }) limit := filter.Limit if limit == 0 { limit = 100 } if len(items) > limit { items = items[len(items)-limit:] } for _, sample := range items { if sample.ServerInstanceID != instance.ID || sample.RunEndpointID != instance.RunEndpointID { return nil, ErrForbidden } } return domain.CopyMetricSamples(items), nil } func (svc *CoreService) CreateBackupForSession(sessionID string, record domain.BackupRecord) (domain.BackupRecord, error) { instance, err := svc.GetServerInstanceForSession(sessionID, record.ServerInstanceID) if err != nil { return domain.BackupRecord{}, err } artifact, err := svc.store.Artifacts().Get(record.ArtifactID) if err != nil { return domain.BackupRecord{}, err } if err := svc.validateBackupArtifactOwner(instance, artifact); err != nil { return domain.BackupRecord{}, err } stamp := svc.now() if record.ID == "" { record.ID = fmt.Sprintf("backup:%s:%d", instance.ID, stamp.UnixNano()) } if record.State == "" { record.State = domain.BackupStatePending } if record.Checksum == "" { record.Checksum = artifact.Checksum } if record.SizeBytes == 0 { record.SizeBytes = artifact.SizeBytes } if record.CreatedAt.IsZero() { record.CreatedAt = stamp } record.UpdatedAt = stamp if record.State == domain.BackupStateAvailable && artifact.State != domain.ArtifactStateAvailable { return domain.BackupRecord{}, validationError("backup artifact must be available") } if err := validator.ValidateBackupRecord(record); err != nil { return domain.BackupRecord{}, err } if err := svc.store.Backups().Create(record); err != nil { return domain.BackupRecord{}, err } if err := svc.pruneBackups(instance.ID); err != nil { return domain.BackupRecord{}, err } return domain.CopyBackupRecord(record), nil } func (svc *CoreService) GetBackupForSession(sessionID string, backupID string) (domain.BackupRecord, error) { record, err := svc.store.Backups().Get(strings.TrimSpace(backupID)) if err != nil { return domain.BackupRecord{}, err } if _, err := svc.GetServerInstanceForSession(sessionID, record.ServerInstanceID); err != nil { return domain.BackupRecord{}, err } return domain.CopyBackupRecord(record), nil } func (svc *CoreService) ListBackupsForSession(sessionID string, filter domain.BackupFilter) ([]domain.BackupRecord, error) { if err := validator.ValidateBackupFilter(filter); err != nil { return nil, err } if strings.TrimSpace(filter.ServerInstanceID) == "" { return nil, validationError("serverInstanceId is required") } if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { return nil, err } items, err := svc.store.Backups().List(filter) if err != nil { return nil, err } sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) }) if len(items) > validator.MaxBackupRecordsPerQuery { items = items[:validator.MaxBackupRecordsPerQuery] } return domain.CopyBackupRecords(items), nil } func (svc *CoreService) RecoverIncompleteBackups() error { items, err := svc.store.Backups().List(domain.BackupFilter{State: domain.BackupStatePending}) if err != nil { return err } for _, record := range items { record.State = domain.BackupStateFailed record.RecoveryStatus = "recoverable-after-interrupted-transfer" record.UpdatedAt = svc.now() if err := svc.store.Backups().Update(record); err != nil { return err } } return nil } func (svc *CoreService) pruneMetricSamples(serverInstanceID string) error { items, err := svc.store.MetricSamples().List(domain.MetricSampleFilter{ServerInstanceID: serverInstanceID}) if err != nil || len(items) <= maxMetricSamplesPerServer { return err } sort.SliceStable(items, func(i, j int) bool { return items[i].CollectedAt.Before(items[j].CollectedAt) }) for _, sample := range items[:len(items)-maxMetricSamplesPerServer] { if err := svc.store.MetricSamples().Delete(sample.ID); err != nil { return err } } return nil } func (svc *CoreService) pruneBackups(serverInstanceID string) error { items, err := svc.store.Backups().List(domain.BackupFilter{ServerInstanceID: serverInstanceID}) if err != nil { return err } sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) }) total := int64(0) for _, record := range items { if record.State != domain.BackupStateExpired { total += record.SizeBytes } } for len(items) > maxBackupsPerServer || total > maxBackupBytesPerServer { record := items[0] items = items[1:] if record.State != domain.BackupStateExpired { total -= record.SizeBytes } record.State = domain.BackupStateExpired record.RecoveryStatus = "retention-expired" record.UpdatedAt = svc.now() if err := svc.store.Backups().Update(record); err != nil { return err } } return nil } func (svc *CoreService) validateBackupArtifactOwner(instance domain.ServerInstance, artifact domain.Artifact) error { switch artifact.OwnerKind { case domain.ArtifactOwnerKindServerInstance: if artifact.OwnerID != instance.ID { return ErrForbidden } case domain.ArtifactOwnerKindJob: job, err := svc.store.Jobs().Get(artifact.OwnerID) if err != nil { return err } if job.ServerInstanceID != instance.ID || job.RunEndpointID != instance.RunEndpointID { return ErrForbidden } default: return ErrForbidden } return nil }