167 lines
7.3 KiB
Go
167 lines
7.3 KiB
Go
package organization
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// AcquireProvisioningLease claims an organization that is still being
|
|
// provisioned. The update is atomic: it only matches a pending organization
|
|
// that carries a provisioning record and whose lease has expired or is already
|
|
// owned by this attempt, so two concurrent attempts cannot provision the same
|
|
// organization. Requiring the pending state here (not only in the caller's
|
|
// earlier read) closes the race with a concurrent finalization: an organization
|
|
// that just went active stops matching even though its lease fields are gone.
|
|
func (db *OrganizationDB) AcquireProvisioningLease(
|
|
ctx context.Context,
|
|
organizationRef bson.ObjectID,
|
|
leaseOwner string,
|
|
now time.Time,
|
|
expiresAt time.Time,
|
|
) (bool, error) {
|
|
// A freshly created attempt carries no lease yet, so "missing" counts as free
|
|
// alongside "expired"; otherwise the first attempt would block itself.
|
|
unset := repository.Exists(repository.Field(model.OrganizationProvisioningLeaseField), false)
|
|
expired := repository.Query().Comparison(repository.Field(model.OrganizationProvisioningLeaseField), builder.Lte, now)
|
|
mine := repository.Query().Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner)
|
|
filter := repository.Query().
|
|
Filter(repository.IDField(), organizationRef).
|
|
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)).
|
|
Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true).
|
|
Or(unset, expired, mine)
|
|
|
|
patch := repository.Patch().
|
|
Set(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner).
|
|
Set(repository.Field(model.OrganizationProvisioningLeaseField), expiresAt)
|
|
|
|
matched, err := db.DBImp.PatchMany(ctx, filter, patch)
|
|
if err != nil {
|
|
db.DBImp.Logger.Warn("Failed to acquire organization provisioning lease",
|
|
zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner))
|
|
return false, err
|
|
}
|
|
return matched > 0, nil
|
|
}
|
|
|
|
// ReleaseProvisioningLease frees the lease so another attempt can take over
|
|
// immediately instead of waiting out the expiry. It only matches the lease of
|
|
// the given owner: a lease taken over by a newer attempt stays untouched.
|
|
func (db *OrganizationDB) ReleaseProvisioningLease(
|
|
ctx context.Context,
|
|
organizationRef bson.ObjectID,
|
|
leaseOwner string,
|
|
) error {
|
|
filter := repository.Query().
|
|
Filter(repository.IDField(), organizationRef).
|
|
Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner)
|
|
|
|
patch := repository.Patch().
|
|
Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)).
|
|
Unset(repository.Field(model.OrganizationProvisioningLeaseField))
|
|
|
|
if _, err := db.DBImp.PatchMany(ctx, filter, patch); err != nil {
|
|
db.DBImp.Logger.Warn("Failed to release organization provisioning lease",
|
|
zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner))
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FinalizeProvisioning atomically activates a pending organization while the
|
|
// given attempt still owns its provisioning lease. It reports false when the
|
|
// organization is no longer pending or the lease was taken over, so a slow
|
|
// attempt cannot finalize an organization another attempt now works on.
|
|
// Only the lease is cleared: the provisioning provenance stays on the document
|
|
// so a retry after a lost success response can recognize the activated
|
|
// organization instead of creating a duplicate.
|
|
func (db *OrganizationDB) FinalizeProvisioning(
|
|
ctx context.Context,
|
|
organizationRef bson.ObjectID,
|
|
leaseOwner string,
|
|
) (bool, error) {
|
|
filter := repository.Query().
|
|
Filter(repository.IDField(), organizationRef).
|
|
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)).
|
|
Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner)
|
|
|
|
patch := repository.Patch().
|
|
Set(repository.Field(model.OrganizationSignupStateField), model.OrganizationSignupStateActive).
|
|
Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)).
|
|
Unset(repository.Field(model.OrganizationProvisioningLeaseField))
|
|
|
|
matched, err := db.DBImp.PatchMany(ctx, filter, patch)
|
|
if err != nil {
|
|
db.DBImp.Logger.Warn("Failed to finalize organization provisioning",
|
|
zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner))
|
|
return false, err
|
|
}
|
|
return matched > 0, nil
|
|
}
|
|
|
|
// FindPendingProvisioning returns the organization that a previous attempt left
|
|
// pending for the same owner and name, if any. The name matches the immutable
|
|
// provisioning snapshot, not the mutable display name, so a rename cannot
|
|
// detach the organization from the retries of the create that produced it.
|
|
func (db *OrganizationDB) FindPendingProvisioning(
|
|
ctx context.Context,
|
|
ownerRef bson.ObjectID,
|
|
name string,
|
|
) (*model.Organization, error) {
|
|
query := repository.Query().
|
|
Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef).
|
|
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)).
|
|
Filter(repository.Field(model.OrganizationProvisioningNameField), name)
|
|
|
|
org := &model.Organization{}
|
|
if err := db.DBImp.FindOne(ctx, query, org); err != nil {
|
|
if errors.Is(err, merrors.ErrNoData) {
|
|
return nil, nil
|
|
}
|
|
db.DBImp.Logger.Warn("Failed to look up pending organization provisioning",
|
|
zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name))
|
|
return nil, err
|
|
}
|
|
return org, nil
|
|
}
|
|
|
|
// FindActiveProvisioned returns the already activated organization a previous
|
|
// attempt provisioned for the same owner and name, if any. It is the retry
|
|
// barrier for the success that got lost on the wire: without it a repeated
|
|
// create after a timed out but committed finalization would provision a
|
|
// duplicate organization. The name matches the immutable provisioning
|
|
// snapshot, so the barrier survives a rename of the activated organization.
|
|
func (db *OrganizationDB) FindActiveProvisioned(
|
|
ctx context.Context,
|
|
ownerRef bson.ObjectID,
|
|
name string,
|
|
) (*model.Organization, error) {
|
|
// Archived organizations release their name: they are excluded here and in
|
|
// the unique index alike, so the checks and the constraint stay in step.
|
|
query := repository.Query().
|
|
Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef).
|
|
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStateActive)).
|
|
Filter(repository.Field(model.OrganizationProvisioningNameField), name).
|
|
Filter(repository.Field(storable.IsArchivedField), false)
|
|
|
|
org := &model.Organization{}
|
|
if err := db.DBImp.FindOne(ctx, query, org); err != nil {
|
|
if errors.Is(err, merrors.ErrNoData) {
|
|
return nil, nil
|
|
}
|
|
db.DBImp.Logger.Warn("Failed to look up activated organization provisioning",
|
|
zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name))
|
|
return nil, err
|
|
}
|
|
return org, nil
|
|
}
|