Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cloudformation/stacks/CUDly/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ Parameters:
AllowedValues:
- "true"
- "false"
Description: Run the finalize_revocations task on a schedule (idempotent audit repair of revoked purchases)
Description: Run the finalize_revocations task on a schedule (idempotent audit repair of revoked purchases, plus the RI Marketplace listing poll, which has no schedule of its own; disabling this also stops recording listing sales and proceeds)

FinalizeRevocationsSchedule:
Type: String
Expand Down
9 changes: 9 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,15 @@ Scheduling for AWS Fargate, CloudFormation and GCP is tracked by #712.

Alternatively, use pg_cron for database-native scheduling.

### RI Marketplace Listing Poll

CUDly learns that an RI listed on the AWS Reserved Instance Marketplace sold, closed or was canceled on AWS, and records its estimated proceeds and AWS fee, only through the marketplace listing poll. The poll has no schedule of its own: it runs inside the server's `finalize_revocations` task, after the revocation repair. It therefore runs only where `finalize_revocations` is scheduled, and turning that schedule off also stops the poll:

- **AWS Lambda:** hourly by default. `enable_finalize_revocations_schedule` (default `true`) and `finalize_revocations_schedule` (default `rate(1 hour)`) in `terraform/environments/aws`.
- **CloudFormation:** hourly by default. The `EnableFinalizeRevocationsSchedule` (default `true`) and `FinalizeRevocationsSchedule` (default `rate(1 hour)`) stack parameters on `cloudformation/stacks/CUDly`.
- **AKS:** hourly by default, as a Kubernetes CronJob, when `deploy_kubernetes_resources` is true. `enable_finalize_revocations_schedule` (default `true`) and `finalize_revocations_schedule` (default `30 * * * *`) on the AKS module.
- **AWS Fargate, GCP Cloud Run, GKE and Azure Container Apps:** nothing schedules `finalize_revocations`, so the poll does not run and listings stay as the list and cancel endpoints recorded them. Until #712 wires the task there, call `POST /api/scheduled/finalize_revocations` (authenticated like the other `/api/scheduled/` tasks) from your own scheduler, about hourly.

---

## Troubleshooting
Expand Down
42 changes: 27 additions & 15 deletions internal/api/handler_marketplace_poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ package api

// handler_marketplace_poller.go is the RI Marketplace status poller (issue
// #27). The list and cancel endpoints record a listing as active or canceled;
// nothing else told CUDly when AWS sold it. The poller reads every active
// listing from AWS (DescribeReservedInstancesListings, signed as the purchase's
// own account like the endpoints) and records the listing's AWS CreateDate
// while it is active, and its terminal state with the realized proceeds and
// the AWS fee once it closes (all sold) or is canceled after a partial sale.
// nothing else told CUDly when AWS sold it. The poller reads from AWS
// (DescribeReservedInstancesListings, signed as the purchase's own account
// like the endpoints) every active listing, and every listing recorded as
// pending because AWS gave no status when it was created (issue #1494). It
// records the listing as active with its AWS CreateDate while it is for sale,
// and its terminal state with the realized proceeds and the AWS fee once it
// closes (all sold) or is canceled after a partial sale.
//
// It runs from the finalize_revocations scheduled sweep (see
// internal/server/handler.go). Its writes are the store's status-guarded
// UPDATEs, so a second run, the cancel handler or a relist racing a poll never
// double-record proceeds or undo a recorded cancel.
// It runs from the finalize_revocations scheduled task (see
// internal/server/handler_marketplace_poll.go), so only where that task is
// scheduled; docs/DEPLOYMENT.md lists the platforms. Its writes are the
// store's status-guarded UPDATEs, so a second run, the cancel handler or a
// relist racing a poll never double-record proceeds or undo a recorded cancel.

import (
"context"
Expand Down Expand Up @@ -218,12 +221,7 @@ func (h *Handler) pollMarketplaceListing(ctx context.Context, row *config.Purcha
func (h *Handler) recordMarketplaceListingStatus(ctx context.Context, row *config.PurchaseHistoryRecord, st marketplaceListingStatus) (marketplacePollOutcome, error) {
switch st.State {
case config.ListingStateActive:
if !st.CreatedAt.IsZero() {
if _, err := h.config.RecordMarketplaceListingActive(ctx, row.PurchaseID, row.ListingID, st.CreatedAt); err != nil {
return marketplacePollSkipped, err
}
}
return marketplacePollActive, nil
return h.recordMarketplaceListingActive(ctx, row, st)
case config.ListingStateClosed, config.ListingStateCancelled:
settlement, priced := marketplaceSettlement(row, st)
applied, err := h.config.SettleMarketplaceListing(ctx, settlement)
Expand All @@ -239,3 +237,17 @@ func (h *Handler) recordMarketplaceListingStatus(ctx context.Context, row *confi
}
return marketplacePollSkipped, nil
}

// recordMarketplaceListingActive records a listing AWS reports for sale. An
// active row needs a write only to stamp the CreateDate. A row recorded as
// pending is moved to active even when AWS reported no CreateDate, so it can
// be canceled and relisted like any active listing (issue #1494).
func (h *Handler) recordMarketplaceListingActive(ctx context.Context, row *config.PurchaseHistoryRecord, st marketplaceListingStatus) (marketplacePollOutcome, error) {
if st.CreatedAt.IsZero() && !strings.EqualFold(row.ListingState, config.ListingStatePending) {
return marketplacePollActive, nil
}
if _, err := h.config.RecordMarketplaceListingActive(ctx, row.PurchaseID, row.ListingID, st.CreatedAt); err != nil {
return marketplacePollSkipped, err
}
return marketplacePollActive, nil
}
58 changes: 58 additions & 0 deletions internal/api/handler_marketplace_poller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"context"
"errors"
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -157,6 +158,63 @@ func TestPollMarketplaceListings_ActiveStampsListedAtOnly(t *testing.T) {
store.AssertNotCalled(t, "SettleMarketplaceListing")
}

func pendingRow(purchaseID, listingID string) config.PurchaseHistoryRecord {
row := listedRow(purchaseID, listingID)
row.ListingState = config.ListingStatePending
return row
}

// A listing recorded as pending (AWS gave no status when it was created) that
// AWS now reports active is recorded as active, even without a CreateDate
// (issue #1494).
func TestPollMarketplaceListings_PendingRowBecomesActive(t *testing.T) {
for _, created := range []time.Time{{}, pollerCreateDate} {
t.Run(fmt.Sprintf("create date %v", !created.IsZero()), func(t *testing.T) {
store := &MockConfigStore{}
f := newPollerFixtureWithStore(t, store, pendingRow("ri-1", "ril-1"))
f.aws.statuses["ril-1"] = marketplaceListingStatus{ListingID: "ril-1", State: config.ListingStateActive, CreatedAt: created}
f.store.On("RecordMarketplaceListingActive", mock.Anything, "ri-1", "ril-1", created).Return(true, nil).Once()

res, err := f.h.PollMarketplaceListings(context.Background())

require.NoError(t, err)
assert.Equal(t, MarketplacePollResult{Found: 1, Active: 1}, *res)
store.AssertExpectations(t)
store.AssertNotCalled(t, "SettleMarketplaceListing")
})
}
}

// An active row whose CreateDate AWS did not report needs no write.
func TestPollMarketplaceListings_ActiveRowWithoutCreateDateNeedsNoWrite(t *testing.T) {
store := &MockConfigStore{}
f := newPollerFixtureWithStore(t, store, listedRow("ri-1", "ril-1"))
f.aws.statuses["ril-1"] = marketplaceListingStatus{ListingID: "ril-1", State: config.ListingStateActive}

res, err := f.h.PollMarketplaceListings(context.Background())

require.NoError(t, err)
assert.Equal(t, MarketplacePollResult{Found: 1, Active: 1}, *res)
store.AssertNotCalled(t, "RecordMarketplaceListingActive")
}

// A pending row whose listing sold out before any poll is settled like an
// active one (issue #1494).
func TestPollMarketplaceListings_PendingRowSettles(t *testing.T) {
f := newPollerFixture(t, pendingRow("ri-1", "ril-1"))
f.aws.statuses["ril-1"] = marketplaceListingStatus{ListingID: "ril-1", State: config.ListingStateClosed,
CreatedAt: pollerCreateDate, SoldCount: 1, UnitPrice: 1000, PriceKnown: true}
settled := f.captureSettle(true)

res, err := f.h.PollMarketplaceListings(context.Background())

require.NoError(t, err)
assert.Equal(t, MarketplacePollResult{Found: 1, Settled: 1}, *res)
require.Len(t, *settled, 1)
assert.Equal(t, config.ListingStateClosed, (*settled)[0].State)
assert.InDelta(t, 880, *(*settled)[0].ProceedsReceived, 1e-9)
}

func TestPollMarketplaceListings_CanceledAfterPartialSale(t *testing.T) {
f := newPollerFixture(t, listedRow("ri-1", "ril-1"))
f.aws.statuses["ril-1"] = marketplaceListingStatus{ListingID: "ril-1", State: config.ListingStateCancelled,
Expand Down
18 changes: 11 additions & 7 deletions internal/config/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,20 +487,24 @@ type StoreInterface interface {
ReleaseMarketplaceListingClaim(ctx context.Context, purchaseID, clientToken, priorState string, keepAttempt bool) error

// ListMarketplaceListingsToPoll returns the AWS rows whose listing the
// marketplace status poller reads (issue #27): active listings, and
// marketplace status poller reads (issue #27): active listings, listings
// recorded as pending with no list attempt in flight (issue #1494), and
// closed/canceled ones it has seen whose proceeds are not recorded yet.
ListMarketplaceListingsToPoll(ctx context.Context) ([]PurchaseHistoryRecord, error)

// RecordMarketplaceListingActive stamps listed_at on a row whose listing
// AWS still reports active. It writes only while the row holds listingID
// in the active state and never writes listing_state. Reports whether the
// row changed.
// RecordMarketplaceListingActive records a listing AWS reports active:
// it stamps listed_at (a zero listedAt keeps the stored one) while the row
// holds listingID as active, and also moves the state to active while the
// row holds it as pending with no list attempt in flight (issue #1494).
// It never writes the state of any other row. Reports whether the row
// changed.
RecordMarketplaceListingActive(ctx context.Context, purchaseID, listingID string, listedAt time.Time) (bool, error)

// SettleMarketplaceListing records a listing that closed or was canceled
// on AWS, with its proceeds and AWS fee when known. It applies once per
// listing, only while the row still holds it and is active or already in
// the same terminal state. Reports whether the row was written.
// listing, only while the row still holds it and is active, pending with
// no list attempt in flight, or already in the same terminal state.
// Reports whether the row was written.
SettleMarketplaceListing(ctx context.Context, settlement MarketplaceListingSettlement) (bool, error)

// ClaimRIExchangeIdempotencyKey atomically claims key for an RI exchange
Expand Down
Loading
Loading