Skip to content

Add database transactions where necessary #391

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

Merged
merged 10 commits into from
Apr 29, 2025
Merged

Add database transactions where necessary #391

merged 10 commits into from
Apr 29, 2025

Conversation

sodic
Copy link
Collaborator

@sodic sodic commented Feb 28, 2025

No description provided.

orderBy: {
date: 'desc',
},
include: {
sources: true,
},
});
} as const;
Copy link
Collaborator Author

@sodic sodic Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: Necessary to prevent type widening.

For example, fi I didn't use as const, TS would infer the type of statsQuery.orderBy.date as string, which is too permissive to be accepted by findFirst and findMany.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. Why do that instead of directly typing it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, what exactly do you mean by typing it directly?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok I get it now. You want statsQuery.orderBy.date to be of type desc or asc and using as const is easier than typing the variable like statsQuery: statsQueryInfo = {...}

@@ -35,11 +35,15 @@ export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput,
context
) => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This operation went through many changes.

It's not 100% foolproof (i.e., doesn't cover all the edge cases).

It can be done, but I can't do it in a way that's simple enough to make sense for an OpenSaas example app. I believe I'd need an extra table with pending responses and a job that periodically deletes unfinished responses and refunds the user.

In any case, I'm not merging this before a call with Vinny since I don't fully understand the old code. I left a bunch of TODOs and NOTEs with my questions.

Copy link
Collaborator

@vincanger vincanger Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about packing everyting into the transaction itself (by doing this do we lose Wasp's automatic cache invalidation) ?

  const eligiblityCheckPrismaTx = await prisma.$transaction(async (tx) => {

    const freshUserData = await tx.user.findUnique({
      where: { id: context.user!.id },
    });

    if (!freshUserData) {
      throw new HttpError(404, 'User not found');
    }

    if (!isEligibleForResponse(freshUserData)) {
      throw new HttpError(402, 'User has not paid or is out of credits');
    }

    if (!userHasSubscriptionPlanEffect(freshUserData)) {
      const updatedUser = await tx.user.update({
        where: { id: context.user!.id },
        data: { credits: { decrement: 1 } },
      });
    }

    const gptResponse = await tx.gptResponse.create({
      data: {
        user: { connect: { id: context.user!.id } },
        content: dailyPlanJson,
      },
    });

    return { updatedUser, gptResponse };
  });

Copy link
Collaborator Author

@sodic sodic Apr 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had that idea too. These are called interactive transactions I believe.

The thing is, transactions should be very short-lived. Calling an API over the network in a transaction is a dangerous (if the call hangs, it can block the entire database).

Prisma recommends against this as well:

Use interactive transactions with caution. Keeping transactions open for a long time hurts database performance and can even cause deadlocks. Try to avoid performing network requests and executing slow queries inside your transaction functions. We recommend you get in and out as quick as possible!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having credits as a number is the problem here, credits should somehow be calculated based on some list of usage e.g. there are 10 entries for successful GPT generations, that -10 credits.

Another thing that could help us here is some sort of a queue of pending generations that "freeze" credits e.g. 2 pending generations temporarily block 2 credits and on success, decrement 2 credits.

Maybe it's too complex for an example app, but maybe not, it's just one extra table + maybe a cleanup job. It maybe makes sense to create an issue for an "ideal system" if we want to go that way and solve it in the future.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can revisit it, sure. I'll create an issue.

I still think an extra table and cleanup jobs are a bit too much though, but could be convinced otherwise :)

Copy link
Collaborator Author

@sodic sodic Apr 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue created: #421

@@ -296,3 +211,98 @@ export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (_args,
});
};
//#endregion

// TODO: Why is hours a string?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be easily be changed, update the operations validation, parse the input as number and change it here as well. I guess it was easier to just send the string that was received from input directly.


// TODO: Why is hours a string?
async function getDailyPlanFromGpt(tasks: Task[], hours: string): Promise<string | null> {
// TODO: Why was this a singleton
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's like creating an instance of axios and using it everywhere. We init the OpenAI client and use it in multiple places. In some projects we would even put it in openai.ts and import it as a service we use. I'd keep it that way, since most users are used to that kind of usage.

// NOTE: I changed this up, first I do the request, and then I decrement
// credits. Is that dangerous? Protecting from it could be too complicated
// TODO: Potential issue here, user can lose credits between the point we
// inject it into the context and here
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what you mean here by "user can lose credits between the point we inject it into context and here".

But, yes, I see the problem. When using prisma transactions we have to run them at the end after the openai request, and I think that's fine.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what you mean here by "user can lose credits between the point we inject it into context and here".

Made an issue that explains it in detail (it's a core Wasp issue more than an Open Saas one): wasp-lang/wasp#2675

When using prisma transactions we have to run them at the end after the openai request, and I think that's fine.

This is a different problem, but I think it's fine too.

// credits. Is that dangerous? Protecting from it could be too complicated
// TODO: Potential issue here, user can lose credits between the point we
// inject it into the context and here
// Seems to me that there should be users in the database with negative credits
Copy link
Collaborator

@vincanger vincanger Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    where: {
      id: user.id,
      credits: { gte: 1 },
    },

This could solve that issue, but I've also suggested another approach which is putting the eligibility check within the transaction itself (see my other comment)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice trick, but doesn't help us unfortunately because we still call the API between checking the user's eligibility and decrementing their credits.

I've decided it's no big deal after all and left a comment in the code.

console.log('gpt function call arguments: ', gptArgs);
// NOTE: Since these two are now brought together, I put them in a single transaction - so no rollback necessary
// TODO: But what if the server crashes after the transaction and before
// the response? I guess no big deal, since the response is in the db.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

throw new HttpError(statusCode, errorMessage);
}
// TODO: Can this ever fail?
return JSON.parse(dailyPlanJson);
Copy link
Collaborator

@vincanger vincanger Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's an invalid json string then yes it can. We could check it first to make sure it is before parsing perhaps.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But can GPT even return invalid JSON or is it deterministic?

Also, if it can return invalid json and this does fail, what do we want to do about user credits?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But can GPT even return invalid JSON or is it deterministic?

I think that it can't return invalid JSON because we are using function calling with a certain JSON schema.

Also, if it can return invalid json and this does fail, what do we want to do about user credits?

It feels like the same case as we had here: https://github.com/wasp-lang/open-saas/pull/391/files#diff-a29403ed1508d66f40eb6cf37393b27eef76a0ed649ad79211a29444071664a1R59

Maybe we should parse ASAP and fail sooner?

Copy link
Collaborator Author

@sodic sodic Apr 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that it can't return invalid JSON because we are using function calling with a certain JSON schema.

That's how it looks to me as well. @vincanger what makes you think the json string can be invalid?

In the meantime, I'm assuming it's always valid and am treating it as such.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that was me being ignorant. keep assuming it's valid.

// TODO: Why not check for allowed states?
const isUserSubscribed =
user.subscriptionStatus !== SubscriptionStatus.Deleted &&
user.subscriptionStatus !== SubscriptionStatus.PastDue;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assumed that it's easier to check for two unapproved states rather than more allowed states (the app could add more allowed states like trialing, for example). What's best practice?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The best practice is usually to always default to the less damaging/more secure state. This pattern is called "fail-safe defaults" or "implicit deny."

It's the principle behind defining firewall rules (e.g., deny everything by default, specify a list of IPs and protocols you allow) and authorization (unauthorized unless proven otherwise).

The idea is that, if a new thing appears and you forget to update the rules, you want it to fail in a way that's safe. Let's assume you add a new state (allowed or unapproved) and forget to update this check.
It's better to temporarily reject some eligible users (they'll complain and you'll fix it quickly) than to allow non-eligible users (bad actors will drain your resoruces and keep it to themselves).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense. thanks!

// shouldn't take priority over credits since it makes no sece spending
// credits when a subscription is active?
// If they aren't subscribed, then it would make sense to spend credits.
// The old code always subtracted credits so I kept that, is this a bug?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it is. We could add a function like returnPaymentPlanEffect and if it's subscription then we don't pass the credit decrement user update.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made an issue: #418

orderBy: {
date: 'desc',
},
include: {
sources: true,
},
});
} as const;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. Why do that instead of directly typing it?

@@ -35,11 +35,15 @@ export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput,
context
) => {
Copy link
Collaborator

@vincanger vincanger Mar 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about packing everyting into the transaction itself (by doing this do we lose Wasp's automatic cache invalidation) ?

  const eligiblityCheckPrismaTx = await prisma.$transaction(async (tx) => {

    const freshUserData = await tx.user.findUnique({
      where: { id: context.user!.id },
    });

    if (!freshUserData) {
      throw new HttpError(404, 'User not found');
    }

    if (!isEligibleForResponse(freshUserData)) {
      throw new HttpError(402, 'User has not paid or is out of credits');
    }

    if (!userHasSubscriptionPlanEffect(freshUserData)) {
      const updatedUser = await tx.user.update({
        where: { id: context.user!.id },
        data: { credits: { decrement: 1 } },
      });
    }

    const gptResponse = await tx.gptResponse.create({
      data: {
        user: { connect: { id: context.user!.id } },
        content: dailyPlanJson,
      },
    });

    return { updatedUser, gptResponse };
  });

@sodic sodic self-assigned this Apr 8, 2025
Comment on lines 61 to 65
// TODO: Do I need a try catch now that I'm saving a response and
// decrementing credits in a transaction?

const gptArgs = completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments;
// NOTE: I changed this up, first I do the request, and then I decrement
// credits. Is that dangerous? Protecting from it could be too complicated
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@infomiho Please comment on this

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are concerned here with the case call to OpenAI failed:

  • if we decrement before -> user loses credits
  • if we decrement after -> use can potentially spam many requests at once and abuse the system

I guess it depends on what the Open Saas user optimises for, for some it might be easier just to give users more credits if something goes wrong. Some maybe want to make sure users don't feel cheated.

What I'm trying to say, I'm not sure which way to go - decrement before or after. I think it maybe makes sense to lay out the pros and cons in a comment so users can maybe adjust it to their needs?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say decrement after. They could spam a bit but wouldn't get very far. I like keeping it simple for the purposes of an example and giving the user the benefit of the doubt.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, that's what we did. And the potential improvements are documented here: #421

// NOTE: I changed this up, first I do the request, and then I decrement
// credits. Is that dangerous? Protecting from it could be too complicated
// TODO: Potential issue here, user can lose credits between the point we
// inject it into the context and here
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what you mean here by "user can lose credits between the point we inject it into context and here".

Made an issue that explains it in detail (it's a core Wasp issue more than an Open Saas one): wasp-lang/wasp#2675

When using prisma transactions we have to run them at the end after the openai request, and I think that's fine.

This is a different problem, but I think it's fine too.

@infomiho infomiho self-requested a review April 18, 2025 11:16
Comment on lines 17 to 24
const openAi = getOpenAi();
function getOpenAi(): OpenAI | null {
if (process.env.OPENAI_API_KEY) {
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
} else {
return null;
}
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
Copy link
Collaborator

@infomiho infomiho Apr 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind of change goes against what we have been doing in the framework ourselves: throw if env vars are missing immediately. We should migrate all of the env vars validation to use the new Zod env vars validation feature (in a separate PR of course) and when we do it - it will throw an error when the env vars is missing. To keep the behavior consistent - I'd do the same here.

Copy link
Collaborator

@infomiho infomiho left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments and thoughts :)

Copy link
Collaborator

@vincanger vincanger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noice 👊


const { hours } = ensureArgsSchemaOrThrowHttpError(generateGptResponseInputSchema, rawArgs);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We parse user input only after the user successfully authenticated.

Copy link
Collaborator

@vincanger vincanger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i like

Copy link
Collaborator

@vincanger vincanger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed responses. Looks good.

throw new HttpError(statusCode, errorMessage);
}
// TODO: Can this ever fail?
return JSON.parse(dailyPlanJson);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that was me being ignorant. keep assuming it's valid.

// TODO: Why not check for allowed states?
const isUserSubscribed =
user.subscriptionStatus !== SubscriptionStatus.Deleted &&
user.subscriptionStatus !== SubscriptionStatus.PastDue;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense. thanks!

Comment on lines 61 to 65
// TODO: Do I need a try catch now that I'm saving a response and
// decrementing credits in a transaction?

const gptArgs = completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments;
// NOTE: I changed this up, first I do the request, and then I decrement
// credits. Is that dangerous? Protecting from it could be too complicated
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say decrement after. They could spam a bit but wouldn't get very far. I like keeping it simple for the purposes of an example and giving the user the benefit of the doubt.

@sodic sodic merged commit 855ddf1 into main Apr 29, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants