github.com/Schaudge/grailbase@v0.0.0-20240223061707-44c758a471c0/cloud/spotfeed/spotfeed.go (about) 1 // Package spotfeed is used for querying spot-data-feeds provided by AWS. 2 // See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-data-feeds.html for a description of the 3 // spot data feed format. 4 // 5 // This package provides two interfaces for interacting with the AWS spot data feed format for files hosted 6 // on S3. 7 // 8 // 1. Fetch - makes a single blocking call to fetch feed files for some historical period, then parses and 9 // returns the results as a single slice. 10 // 2. Stream - creates a goroutine that asynchronously checks (once per 30mins by default) the specified S3 11 // location for new spot data feed files (and sends parsed entries into a channel provided to 12 // the user at invocation). 13 // 14 // This package also provides a LocalLoader which can perform a Fetch operation against feed files already 15 // downloaded to local disk. This is often useful for analyzing spot usage over long periods of time, since 16 // the download phase can take some time. 17 package spotfeed 18 19 import ( 20 "compress/gzip" 21 "context" 22 "fmt" 23 "io/ioutil" 24 "log" 25 "os" 26 "path" 27 "strings" 28 "sync" 29 "time" 30 31 "github.com/aws/aws-sdk-go/aws" 32 "github.com/aws/aws-sdk-go/aws/request" 33 "github.com/aws/aws-sdk-go/aws/session" 34 "github.com/aws/aws-sdk-go/service/ec2" 35 "github.com/aws/aws-sdk-go/service/s3" 36 "github.com/aws/aws-sdk-go/service/s3/s3iface" 37 "github.com/Schaudge/grailbase/errors" 38 "github.com/Schaudge/grailbase/retry" 39 "golang.org/x/sync/errgroup" 40 "golang.org/x/time/rate" 41 ) 42 43 var ( 44 // RetryPolicy is used to retry failed S3 API calls. 45 retryPolicy = retry.Backoff(time.Second, 10*time.Second, 2) 46 47 // Used to rate limit S3 calls. 48 limiter = rate.NewLimiter(rate.Limit(16), 4) 49 ) 50 51 type filterable interface { 52 accountId() string 53 timestamp() time.Time 54 version() int64 55 } 56 57 type filters struct { 58 // AccountId configures the Loader to only return Entry objects that belong to the specified 59 // 12-digit AWS account number (ID). If zero, no AccountId filter is applied. 60 AccountId string 61 62 // StartTime configures the Loader to only return Entry objects younger than StartTime. 63 // If nil, no StartTime filter is applied. 64 StartTime *time.Time 65 66 // EndTime configures the Loader to only return Entry objects older than EndTime. 67 // If nil, no EndTime filter is applied. 68 EndTime *time.Time 69 70 // Version configures the Loader to only return Entry objects with version equal to Version. 71 // If zero, no Version filter is applied, and if multiple feed versions declare the same 72 // instance-hour, de-duping based on the maximum value seen for that hour will be applied. 73 Version int64 74 } 75 76 // filter returns true if the entry does not match loader criteria and should be filtered out. 77 func (l *filters) filter(f filterable) bool { 78 if l.AccountId != "" && f.accountId() != l.AccountId { 79 return true 80 } 81 if l.StartTime != nil && f.timestamp().Before(*l.StartTime) { // inclusive 82 return true 83 } 84 if l.EndTime != nil && !f.timestamp().Before(*l.EndTime) { // exclusive 85 return true 86 } 87 if l.Version != 0 && f.version() != l.Version { 88 return true 89 } 90 return false 91 } 92 93 // filterTruncatedStartTime performs the same checks as filter but truncates the start boundary down to the hour. 94 func (l *filters) filterTruncatedStartTime(f filterable) bool { 95 if l.AccountId != "" && f.accountId() != l.AccountId { 96 return true 97 } 98 if l.StartTime != nil { 99 truncatedStart := l.StartTime.Truncate(time.Hour) 100 if f.timestamp().Before(truncatedStart) { // inclusive 101 return true 102 } 103 } 104 if l.EndTime != nil && !f.timestamp().Before(*l.EndTime) { // exclusive 105 return true 106 } 107 if l.Version != 0 && f.version() != l.Version { 108 return true 109 } 110 return false 111 112 } 113 114 type localFile struct { 115 *fileMeta 116 path string 117 } 118 119 func (f *localFile) read() ([]*Entry, error) { 120 fd, err := os.Open(f.path) 121 defer func() { _ = fd.Close() }() 122 if err != nil { 123 err = errors.E(err, fmt.Sprintf("failed to open local spot feed data file %s", f.path)) 124 return nil, err 125 } 126 127 if f.IsGzip { 128 gz, err := gzip.NewReader(fd) 129 defer func() { _ = gz.Close() }() 130 if err != nil { 131 return nil, fmt.Errorf("failed to read gzipped file %s", f.Name) 132 } 133 return ParseFeedFile(gz, f.AccountId) 134 } 135 136 return ParseFeedFile(fd, f.AccountId) 137 } 138 139 type s3File struct { 140 *fileMeta 141 bucket, key string 142 client s3iface.S3API 143 } 144 145 func (s *s3File) read(ctx context.Context) ([]*Entry, error) { 146 // Pull feed file from S3 with rate limiting and retries. 147 var output *s3.GetObjectOutput 148 for retries := 0; ; { 149 if err := limiter.Wait(ctx); err != nil { 150 return nil, err 151 } 152 var getObjErr error 153 if output, getObjErr = s.client.GetObjectWithContext(ctx, &s3.GetObjectInput{ 154 Bucket: aws.String(s.bucket), 155 Key: aws.String(s.key), 156 }); getObjErr != nil { 157 if !request.IsErrorThrottle(getObjErr) { 158 return nil, getObjErr 159 } 160 if err := retry.Wait(ctx, retryPolicy, retries); err != nil { 161 return nil, err 162 } 163 retries++ 164 continue 165 } 166 break 167 } 168 // If the file is gzipped, unpack before attempting to read. 169 if s.IsGzip { 170 gz, err := gzip.NewReader(output.Body) 171 if err != nil { 172 return nil, fmt.Errorf("failed to read gzipped file s3://%s/%s", s.bucket, s.key) 173 } 174 defer func() { _ = gz.Close() }() 175 return ParseFeedFile(gz, s.AccountId) 176 } 177 178 return ParseFeedFile(output.Body, s.AccountId) 179 } 180 181 // Loader provides an API for pulling Spot Data Feed Entry objects from some repository. 182 // The tolerateErr parameter configures how the Loader responds to errors parsing 183 // individual files or entries; if true, the Loader will continue to parse and yield Entry 184 // objects if an error is encountered during parsing. 185 type Loader interface { 186 // Fetch performs a single blocking call to fetch a discrete set of Entry objects. 187 Fetch(ctx context.Context, tolerateErr bool) ([]*Entry, error) 188 189 // Stream asynchronously retrieves, parses and sends Entry objects on the returned channel. 190 // To graciously terminate the goroutine managing the Stream, the client terminates the given context. 191 Stream(ctx context.Context, tolerateErr bool) (<-chan *Entry, error) 192 } 193 194 type s3Loader struct { 195 Loader 196 filters 197 198 log *log.Logger 199 client s3iface.S3API 200 bucket string 201 rootURI string 202 } 203 204 // commonFilePrefix returns the most specific prefix common to all spot feed data files that 205 // match the loader criteria. 206 func (s *s3Loader) commonFilePrefix() string { 207 if s.AccountId == "" { 208 return "" 209 } 210 211 if s.StartTime == nil || s.EndTime == nil || s.StartTime.Year() != s.EndTime.Year() { 212 return s.AccountId 213 } 214 215 if s.StartTime.Month() != s.EndTime.Month() { 216 return fmt.Sprintf("%s.%d", s.AccountId, s.StartTime.Year()) 217 } 218 219 if s.StartTime.Day() != s.EndTime.Day() { 220 return fmt.Sprintf("%s.%d-%02d", s.AccountId, s.StartTime.Year(), s.StartTime.Month()) 221 } 222 223 if s.StartTime.Hour() != s.EndTime.Hour() { 224 return fmt.Sprintf("%s.%d-%02d-%02d", s.AccountId, s.StartTime.Year(), s.StartTime.Month(), s.StartTime.Day()) 225 } 226 227 return fmt.Sprintf("%s.%d-%02d-%02d-%02d", s.AccountId, s.StartTime.Year(), s.StartTime.Month(), s.StartTime.Day(), s.StartTime.Hour()) 228 } 229 230 // timePrefix returns a prefix which matches the given time in UTC. 231 func (s *s3Loader) timePrefix(t time.Time) string { 232 if s.AccountId == "" { 233 panic("nowPrefix cannot be given without an account id") 234 } 235 236 t = t.UTC() 237 return fmt.Sprintf("%s.%d-%02d-%02d-%02d", s.AccountId, t.Year(), t.Month(), t.Day(), t.Hour()) 238 } 239 240 // path returns a prefix which joins the loader rootURI with the given uri. 241 func (s *s3Loader) path(uri string) string { 242 if s.rootURI == "" { 243 return uri 244 } else { 245 return fmt.Sprintf("%s/%s", s.rootURI, uri) 246 } 247 } 248 249 // list queries the AWS S3 ListBucket API for feed files. 250 func (s *s3Loader) list(ctx context.Context, startAfter string, tolerateErr bool) ([]*s3File, error) { 251 prefix := s.path(s.commonFilePrefix()) 252 253 s3Files := make([]*s3File, 0) 254 var parseMetaErr error 255 if err := s.client.ListObjectsV2PagesWithContext(ctx, &s3.ListObjectsV2Input{ 256 Bucket: aws.String(s.bucket), 257 Prefix: aws.String(prefix), 258 StartAfter: aws.String(startAfter), 259 }, func(output *s3.ListObjectsV2Output, lastPage bool) bool { 260 for _, object := range output.Contents { 261 filename := aws.StringValue(object.Key) 262 fileMeta, err := parseFeedFileName(filename) 263 if err != nil { 264 parseMetaErr = errors.E(err, fmt.Sprintf("failed to parse spot feed data file name %s", filename)) 265 if tolerateErr { 266 s.log.Print(parseMetaErr) 267 continue 268 } else { 269 return false 270 } 271 } 272 273 // skips s3Files that do not match the loader criteria. Truncate the startTime of the filter to ensure that 274 // we do not skip files at hour HH:00 with a startTime of (i.e.) HH:30. 275 if s.filterTruncatedStartTime(fileMeta) { 276 s.log.Printf("%s does not pass fileMeta filter, skipping", filename) 277 continue 278 } 279 s3Files = append(s3Files, &s3File{ 280 fileMeta, 281 s.bucket, 282 filename, 283 s.client, 284 }) 285 } 286 return true 287 }); err != nil { 288 return nil, fmt.Errorf("list on path %s failed with error: %s", prefix, err) 289 } 290 if !tolerateErr && parseMetaErr != nil { 291 return nil, parseMetaErr 292 } 293 return s3Files, nil 294 } 295 296 // fetchAfter builds a list of S3 feed file objects using the S3 ListBucket API. It then concurrently 297 // fetches and parses the feed files, observing rate and concurrency limits. 298 func (s *s3Loader) fetchAfter(ctx context.Context, startAfter string, tolerateErr bool) ([]*Entry, error) { 299 s3Files, err := s.list(ctx, startAfter, tolerateErr) 300 if err != nil { 301 return nil, err 302 } 303 304 mu := &sync.Mutex{} 305 spotDataEntries := make([]*Entry, 0) 306 group, groupCtx := errgroup.WithContext(ctx) 307 for _, file := range s3Files { 308 file := file 309 group.Go(func() error { 310 if entries, err := file.read(groupCtx); err != nil { 311 err = errors.E(err, fmt.Sprintf("failed to parse spot feed data file s3://%s/%s", file.bucket, file.key)) 312 if tolerateErr { 313 s.log.Printf("encountered error %s, tolerating and skipping file s3://%s/%s", err, file.bucket, file.key) 314 return nil 315 } else { 316 return err 317 } 318 } else { 319 mu.Lock() 320 spotDataEntries = append(spotDataEntries, entries...) 321 mu.Unlock() 322 } 323 return nil 324 }) 325 } 326 if err := group.Wait(); err != nil { 327 return nil, err 328 } 329 330 filteredEntries := make([]*Entry, 0) 331 for _, e := range spotDataEntries { 332 if !s.filter(e) { 333 filteredEntries = append(filteredEntries, e) 334 } 335 } 336 337 return filteredEntries, nil 338 } 339 340 // Fetch makes a single blocking call to fetch feed files for some historical period, 341 // then parses and returns the results as a single slice. The call attempts to start 342 // from the first entry such that Key > l.StartTime and breaks when it encounters the 343 // first entry such that Key > l.EndTime 344 func (s *s3Loader) Fetch(ctx context.Context, tolerateErr bool) ([]*Entry, error) { 345 prefix := s.path(s.commonFilePrefix()) 346 return s.fetchAfter(ctx, prefix, tolerateErr) 347 } 348 349 var ( 350 // streamSleepDuration specifies how long to wait between calls to S3 ListBucket 351 streamSleepDuration = 30 * time.Minute 352 ) 353 354 // Stream creates a goroutine that asynchronously checks (once per 30mins by default) the specified S3 355 // location for new spot data feed files (and sends parsed entries into a channel provided to the user at invocation). 356 // s3Loader must be configured with an account id to support the Stream interface. To stream events for multiple account ids 357 // which share a feed bucket, create multiple s3Loader objects. 358 // TODO: Allow caller to pass channel, allowing a single reader to manage multiple s3Loader.Stream calls. 359 func (s *s3Loader) Stream(ctx context.Context, tolerateErr bool) (<-chan *Entry, error) { 360 if s.AccountId == "" { 361 return nil, fmt.Errorf("s3Loader must be configured with an account id to provide asynchronous event streaming") 362 } 363 364 entryChan := make(chan *Entry) 365 go func() { 366 startAfter := s.timePrefix(time.Now()) 367 for { 368 if ctx.Err() != nil { 369 close(entryChan) 370 return 371 } 372 373 entries, err := s.fetchAfter(ctx, startAfter, tolerateErr) 374 if err != nil { 375 close(entryChan) 376 return 377 } 378 379 for _, entry := range entries { 380 entryChan <- entry 381 } 382 383 if len(entries) != 0 { 384 finalEntry := entries[len(entries)-1] 385 startAfter = s.timePrefix(finalEntry.Timestamp) 386 } 387 388 time.Sleep(streamSleepDuration) 389 } 390 }() 391 392 return entryChan, nil 393 } 394 395 // NewSpotFeedLoader returns a Loader which queries the spot data feed subscription using the given session and 396 // returns a Loader which queries the S3 API for feed files (if a subscription does exist). 397 // NewSpotFeedLoader will return an error if the spot data feed subscription is missing. 398 func NewSpotFeedLoader(sess *session.Session, log *log.Logger, startTime, endTime *time.Time, version int64) (Loader, error) { 399 ec2api := ec2.New(sess) 400 resp, err := ec2api.DescribeSpotDatafeedSubscription(&ec2.DescribeSpotDatafeedSubscriptionInput{}) 401 if err != nil { 402 return nil, errors.E("DescribeSpotDatafeedSubscription", err) 403 } 404 bucket := aws.StringValue(resp.SpotDatafeedSubscription.Bucket) 405 rootURI := aws.StringValue(resp.SpotDatafeedSubscription.Prefix) 406 accountID := aws.StringValue(resp.SpotDatafeedSubscription.OwnerId) 407 return NewS3Loader(bucket, rootURI, s3.New(sess), log, accountID, startTime, endTime, version), nil 408 } 409 410 // NewS3Loader returns a Loader which queries the S3 API for feed files. It supports the Fetch and Stream APIs. 411 func NewS3Loader(bucket, rootURI string, client s3iface.S3API, log *log.Logger, accountId string, startTime, endTime *time.Time, version int64) Loader { 412 // Remove any trailing slash from bucket and trailing/leading slash from rootURI. 413 if strings.HasSuffix(bucket, "/") { 414 bucket = bucket[:len(bucket)-1] 415 } 416 if strings.HasPrefix(rootURI, "/") { 417 rootURI = rootURI[1:] 418 } 419 if strings.HasSuffix(rootURI, "/") { 420 rootURI = rootURI[:len(rootURI)-1] 421 } 422 423 return &s3Loader{ 424 filters: filters{ 425 AccountId: accountId, 426 StartTime: startTime, 427 EndTime: endTime, 428 Version: version, 429 }, 430 log: log, 431 client: client, 432 bucket: bucket, 433 rootURI: rootURI, 434 } 435 } 436 437 type localLoader struct { 438 Loader 439 filters 440 441 log *log.Logger 442 rootPath string 443 } 444 445 // Fetch queries the local filesystem for feed files at the given path which match the given filename filters. 446 // It then parses, filters again and returns the Entry objects. 447 func (l *localLoader) Fetch(ctx context.Context, tolerateErr bool) ([]*Entry, error) { 448 // Iterate over files in directory, filter and build slice of feed files. 449 spotFiles := make([]*localFile, 0) 450 items, _ := ioutil.ReadDir(l.rootPath) 451 for _, item := range items { 452 // Skip subdirectories. 453 if item.IsDir() { 454 continue 455 } 456 457 p := path.Join(l.rootPath, item.Name()) 458 fileMeta, err := parseFeedFileName(item.Name()) 459 if err != nil { 460 err = errors.E(err, fmt.Sprintf("failed to parse spot feed data file name %s", p)) 461 if tolerateErr { 462 l.log.Printf("encountered error %s, tolerating and skipping file %s", err, p) 463 continue 464 } else { 465 return nil, err 466 } 467 } 468 469 // skips files that do not match the loader criteria. Truncate the startTime of the filter to ensure that 470 // we do not skip files at hour HH:00 with a startTime of (i.e.) HH:30. 471 if l.filterTruncatedStartTime(fileMeta) { 472 l.log.Printf("%s does not pass fileMeta filter, skipping", p) 473 continue 474 } 475 476 spotFiles = append(spotFiles, &localFile{ 477 fileMeta, 478 p, 479 }) 480 } 481 482 // Concurrently iterate over spot data feed files and build a slice of entries. 483 mu := &sync.Mutex{} 484 spotDataEntries := make([]*Entry, 0) 485 group, _ := errgroup.WithContext(ctx) 486 for _, file := range spotFiles { 487 file := file 488 group.Go(func() error { 489 if entries, err := file.read(); err != nil { 490 err = errors.E(err, fmt.Sprintf("failed to parse spot feed data file %s", file.path)) 491 if tolerateErr { 492 l.log.Printf("encountered error %s, tolerating and skipping file %s", err, file.path) 493 return nil 494 } else { 495 return err 496 } 497 } else { 498 mu.Lock() 499 spotDataEntries = append(spotDataEntries, entries...) 500 mu.Unlock() 501 } 502 return nil 503 }) 504 } 505 if err := group.Wait(); err != nil { 506 return nil, err 507 } 508 509 // Filter entries 510 filteredEntries := make([]*Entry, 0) 511 for _, e := range spotDataEntries { 512 if !l.filter(e) { 513 filteredEntries = append(filteredEntries, e) 514 } 515 } 516 517 return filteredEntries, nil 518 } 519 520 // NewLocalLoader returns a Loader which fetches feed files from a path on the local filesystem. It does not support 521 // the Stream API. 522 func NewLocalLoader(path string, log *log.Logger, accountId string, startTime, endTime *time.Time, version int64) Loader { 523 return &localLoader{ 524 filters: filters{ 525 AccountId: accountId, 526 StartTime: startTime, 527 EndTime: endTime, 528 Version: version, 529 }, 530 log: log, 531 rootPath: path, 532 } 533 }