Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ftl.Singleton wrapper around a handle #1295

Closed
alecthomas opened this issue Apr 18, 2024 · 6 comments
Closed

Add ftl.Singleton wrapper around a handle #1295

alecthomas opened this issue Apr 18, 2024 · 6 comments
Assignees
Labels
good first issue Good for newcomers

Comments

@alecthomas
Copy link
Collaborator

alecthomas commented Apr 18, 2024

This would provide a context-aware thread-safe singleton value:

var bearerDID = ftl.Singleton(func(ctx context.Context) (did.BearerDID, error) {
	return did.FromPortableDID(portableDID.Get(ctx))
})

This avoids having to write weird wrapper functions to do this for us.

This should return a handle with a .Get(context.Context) method for retrieving the actual value. It should panic if the lambda returns an error.

@alecthomas alecthomas added the good first issue Good for newcomers label Apr 18, 2024
@github-actions github-actions bot added the triage Issue needs triaging label Apr 18, 2024
@github-actions github-actions bot removed the triage Issue needs triaging label Apr 18, 2024
deniseli added a commit that referenced this issue Apr 18, 2024
@mistermoe
Copy link
Member

I know that the ftl.Singleton idea sort of morphed into ftl.Map here but i still think there's value in the initial api surface of ftl.Singleton.

ftl.Map is great for scenarios where you need to map a single resource to whatever a function returns e.g.

var portableDIDHandle = ftl.Secret[string]("did_web_portable_did")
var bearerDIDHandle = ftl.Map(portableDIDHandle, func(ctx context.Context, sec string) (did.BearerDID, error) {
	var pd did.PortableDID
	err := json.Unmarshal([]byte(sec), &pd)
	if err != nil {
		return did.BearerDID{}, fmt.Errorf("failed to unmarshal portable DID: %w", err)
	}

	bearerDID, err := did.FromPortableDID(pd)
	if err != nil {
		return did.BearerDID{}, fmt.Errorf("failed to import bearer DID: %w", err)
	}

	return bearerDID, nil
})

however, if i want to do the same for something that requires multiple resources (specifically secrets or 1 secret and 1 config value), i can't. This is actually the case quite often with vendor API clients that require an API Key and and API Secret.

Something like this would be very useful:

var vendorAPIKeyHandle = ftl.Secret[string]("vendor_api_key")
var vendorAPISecretHandle = ftl.Secret[string]("vendor_api_secret")

var vendorClientHandle = ftl.Plz(func(ctx context.Context) (vendor.Client, error) {
	apiKey := vendorAPIKeyHandle.Get(ctx)
	apiSecret := vendorAPISecretHandle.Get(ctx)

	return vendor.NewClient(apiKey, apiSecret), nil
})

@mistermoe mistermoe reopened this Apr 27, 2024
@alecthomas
Copy link
Collaborator Author

alecthomas commented Apr 27, 2024

There are a couple of problems with ftl.Singleton:

  1. It makes it more difficult to trace the provenance of the underlying resources. With ftl.Map we know exactly what resource is being utilised.
  2. I think the bigger issue is that it really encourages the use of global state.

Another option might be ftl.Map2(a, b) => c

@alecthomas
Copy link
Collaborator Author

alecthomas commented Apr 27, 2024

Or we could add pair, triple, etc.

type Pair[T, U any] struct {
	Left  T
	Right U
}

type PairHandle[T, U any] struct {
	left  Handle[T]
	right Handle[U]
}

func (p PairHandle[T, U]) Get() Pair[T, U] {
	return Pair[T, U]{p.left.Get(), p.right.Get()}
}

func PairOf[T, U any](left Handle[T], right Handle[U]) PairHandle[T, U] {
	return PairHandle[T, U]{left, right}
}

Which might be used like so:

var vendorClientHandle = ftl.Map(ftl.PairOf(vendorAPIKeyHandle, vendorAPISecretHandle), func(ctx context.Context, pair Pair[string, string]) (vendor.Client, error) {
	return vendor.NewClient(pair.Left, pair.Right), nil
})

@mistermoe
Copy link
Member

mistermoe commented Apr 27, 2024

Random thoughts:

It makes it more difficult to trace the provenance of the underlying resources. With ftl.Map we know exactly what resource is being utilised.

makes sense!


I think the bigger issue is that it really encourages the use of global state.

does this apply to both Map and Singleton in that both would return handles that are defined within global state?

if yes to ^, trying to think about how to avoid global state within a given FTL module without having to rely on receiving an initial request to realize that something is borked: e.g. db connection, vendor api secrets etc.

Ideally these sort of checks would happen prior to a module even starting and just insta borking instead of bringing it online and ready to receive requests. I suppose the concept of Readiness as discussed here: #1294 will help with that so long as Readiness doesn't set anything in global state.

Outside of FTL, conventionally, projects avoid global state by initializing and performing any necessary checks for all resources in a main and then inject them into various parts of an application yeah?


re: alternative suggestions you've mentioned above

Actually thinking about it a bit more now, it may make more sense to store everything related to a given vendor in a single secret that is a json object. thinking back to using AWS Secrets Manager and that's actually how it's done: reference in which case Map as it is works totally fine.


@alecthomas
Copy link
Collaborator Author

Actually thinking about it a bit more now, it may make more sense to store everything related to a given vendor in a single secret that is a json object. thinking back to using AWS Secrets Manager and that's actually how it's done: reference in which case Map as it is works totally fine.

For combined state you can also use an FTL data structure directly.

type VendorAPICredentials struct {
  Key string
  Secret string
}

var vendorAPICredentials = ftl.Secret[VendorAPICredentials]("vendorAPICredentials")

@alecthomas
Copy link
Collaborator Author

does this apply to both Map and Singleton in that both would return handles that are defined within global state?

The difference is subtle, but as ftl.Singleton() doesn't take an existing resource, it's more likely to encourage pulling in arbitrary global state.

if yes to ^, trying to think about how to avoid global state within a given FTL module without having to rely on receiving an initial request to realize that something is borked: e.g. db connection, vendor api secrets etc.
Ideally these sort of checks would happen prior to a module even starting and just insta borking instead of bringing it online and ready to receive requests.
Outside of FTL, conventionally, projects avoid global state by initializing and performing any necessary checks for all resources in a main and then inject them into various parts of an application yeah?

I'm not totally convinced that this is a preferable approach. IME debugging initialisation errors is usually more painful than debugging errors after the service is serving. Think of debugging Kubernetes container startup issues - it's usually more painful. Though that is somewhat due to the weird separation between container logs and application logs.

A "readiness" function might be a preferable place to evaluate any lazy values like credentials, clients, etc.

I'm not necessarily against the idea though, don't get me wrong, but I would like to evaluate different use-cases on a case-by-case basic and see what is the best approach.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
good first issue Good for newcomers
Projects
None yet
Development

No branches or pull requests

3 participants