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
45 changes: 35 additions & 10 deletions model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,11 +311,11 @@ func withNormalizedEmailLock(tx *gorm.DB, email string, fn func(tx *gorm.DB) err
return fn(tx)
case common.UsingMySQL:
lockName := normalizedEmailLockName(email)
var acquired sql.NullInt64
if err := tx.Raw("SELECT GET_LOCK(?, ?)", lockName, 10).Scan(&acquired).Error; err != nil {
acquired, err := acquireMySQLNamedLock(tx, lockName)
if err != nil {
return err
}
if !acquired.Valid || acquired.Int64 != 1 {
if !acquired {
return errors.New("failed to acquire user email lock")
}
released := false
Expand All @@ -324,7 +324,7 @@ func withNormalizedEmailLock(tx *gorm.DB, email string, fn func(tx *gorm.DB) err
_ = releaseMySQLNamedLock(tx, lockName)
}
}()
err := fn(tx)
err = fn(tx)
releaseErr := releaseMySQLNamedLock(tx, lockName)
released = true
if releaseErr != nil && err == nil {
Expand Down Expand Up @@ -353,11 +353,11 @@ func WithNormalizedEmailWriteTx(email string, fn func(tx *gorm.DB) error) error

lockName := normalizedEmailLockName(email)
return DB.Connection(func(conn *gorm.DB) error {
var acquired sql.NullInt64
if err := conn.Raw("SELECT GET_LOCK(?, ?)", lockName, 10).Scan(&acquired).Error; err != nil {
acquired, err := acquireMySQLNamedLock(conn, lockName)
if err != nil {
return err
}
if !acquired.Valid || acquired.Int64 != 1 {
if !acquired {
return errors.New("failed to acquire user email lock")
}

Expand All @@ -368,7 +368,7 @@ func WithNormalizedEmailWriteTx(email string, fn func(tx *gorm.DB) error) error
}
}()

err := conn.Transaction(fn)
err = conn.Transaction(fn)
releaseErr := releaseMySQLNamedLock(conn, lockName)
released = true
if err != nil {
Expand All @@ -378,9 +378,34 @@ func WithNormalizedEmailWriteTx(email string, fn func(tx *gorm.DB) error) error
})
}

// Use database/sql row scanning so sql.NullInt64 is not parsed as a GORM model.
func scanNullableInt64(row *sql.Row) (sql.NullInt64, error) {
var value sql.NullInt64
err := row.Scan(&value)
return value, err
}

func acquireMySQLNamedLock(tx *gorm.DB, lockName string) (bool, error) {
acquired, err := scanNullableInt64(tx.Raw("SELECT GET_LOCK(?, ?)", lockName, 10).Row())
if err != nil {
return false, err
}
return isMySQLNamedLockSuccess(acquired), nil
}

func releaseMySQLNamedLock(tx *gorm.DB, lockName string) error {
var released sql.NullInt64
return tx.Raw("SELECT RELEASE_LOCK(?)", lockName).Scan(&released).Error
released, err := scanNullableInt64(tx.Raw("SELECT RELEASE_LOCK(?)", lockName).Row())
if err != nil {
return err
}
if !isMySQLNamedLockSuccess(released) {
return errors.New("failed to release user email lock")
}
return nil
}
Comment on lines 396 to +405

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect release/error-resolution flow around the two write paths
ast-grep outline model/user.go --match 'withNormalizedEmailLock|WithNormalizedEmailWriteTx' --view expanded
rg -n -C3 'releaseMySQLNamedLock' model/user.go

Repository: MAX-API-Next/MAX-API

Length of output: 1214


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '301,382p' model/user.go | cat -n
printf '\n--- controller/oauth.go ---\n'
rg -n -C3 'WithNormalizedEmailWriteTx|withNormalizedEmailLock|releaseMySQLNamedLock' controller/oauth.go model/topup.go

Repository: MAX-API-Next/MAX-API

Length of output: 4287


Don't let RELEASE_LOCK override a successful write. In model/user.go:321-331 and model/user.go:371-377, the MySQL paths return releaseErr after the callback/transaction succeeds, so a committed change can still surface as a failure to callers like controller/oauth.go and model/topup.go. Keep the callback result as the primary error and handle lock-release failures separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/user.go` around lines 396 - 405, Update the MySQL transaction/callback
paths around the callers of releaseMySQLNamedLock so a successful callback or
committed transaction remains the primary result. Do not return releaseErr when
the write succeeds; only return the callback/transaction error, while handling
or logging lock-release failures separately. Preserve releaseMySQLNamedLock’s
existing behavior and ensure callers such as OAuth and top-up still receive
committed writes as success.


func isMySQLNamedLockSuccess(value sql.NullInt64) bool {
return value.Valid && value.Int64 == 1
}

func GetMaxUserId() int {
Expand Down
27 changes: 27 additions & 0 deletions model/user_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package model

import (
"context"
"database/sql"
"errors"
"fmt"
"net"
Expand All @@ -12,6 +13,7 @@ import (

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/dto"
"github.com/glebarez/sqlite"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -80,6 +82,31 @@ func TestNormalizedEmailLockNameIsStableAndShort(t *testing.T) {
assert.Contains(t, lockName, "maxapi:user-email:")
}

func TestScanNullableInt64UsesSQLRow(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() {
_ = sqlDB.Close()
})
}

got, err := scanNullableInt64(db.Raw("SELECT ?", 1).Row())
require.NoError(t, err)
require.True(t, got.Valid)
assert.EqualValues(t, 1, got.Int64)

nullValue, err := scanNullableInt64(db.Raw("SELECT NULL").Row())
require.NoError(t, err)
assert.False(t, nullValue.Valid)
}

func TestMySQLNamedLockResultSuccessRequiresOne(t *testing.T) {
assert.True(t, isMySQLNamedLockSuccess(sql.NullInt64{Int64: 1, Valid: true}))
assert.False(t, isMySQLNamedLockSuccess(sql.NullInt64{Int64: 0, Valid: true}))
assert.False(t, isMySQLNamedLockSuccess(sql.NullInt64{Valid: false}))
}

func mysqlDSNWithClientFoundRowsFalse(dsn string) string {
base, rawQuery, ok := strings.Cut(dsn, "?")
values, err := url.ParseQuery(rawQuery)
Expand Down
58 changes: 24 additions & 34 deletions web/default/src/features/pricing/components/model-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_AUTO_ROUTE_KEY, type AutoGroupRoute } from '@/lib/auto-routes'
import type { AutoGroupRoute } from '@/lib/auto-routes'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
Expand Down Expand Up @@ -54,6 +54,10 @@ import {
} from '@/features/performance-metrics/lib/format'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { usePricingData } from '../hooks/use-pricing-data'
import {
getAutoRouteLabelOverride,
getConfiguredAutoRouteChains,
} from '../lib/auto-route-view'
import {
getDynamicPriceEntries,
getDynamicPricingSummary,
Expand Down Expand Up @@ -748,46 +752,32 @@ function AutoGroupChain(props: {
const modelEnableGroups = Array.isArray(props.model.enable_groups)
? props.model.enable_groups
: []
const routes =
props.autoRoutes && props.autoRoutes.length > 0
? props.autoRoutes
: [
{
key: DEFAULT_AUTO_ROUTE_KEY,
enabled: true,
user_selectable: true,
groups: props.autoGroups,
},
]
const routeChains = routes
.filter((route) => route.enabled)
.map((route) => ({
route,
groups: route.groups.filter((g) => modelEnableGroups.includes(g)),
}))
.filter((item) => item.groups.length > 0)
const routeChains = getConfiguredAutoRouteChains({
autoGroups: props.autoGroups,
autoRoutes: props.autoRoutes,
groupFilter: (group) => modelEnableGroups.includes(group),
})

if (routeChains.length === 0) return null

const getRouteLabelOverride = (route: AutoGroupRoute) => {
const name = route.name?.trim()
if (
route.key === DEFAULT_AUTO_ROUTE_KEY &&
(!name || name === 'Auto' || name === DEFAULT_AUTO_ROUTE_KEY)
) {
return undefined
}
return name || route.key
}

return (
<div className='text-muted-foreground mb-3 flex flex-col gap-1.5 text-xs'>
<span className='font-medium'>{t('Auto Route Chains')}</span>
<div className='text-muted-foreground mb-3 flex flex-col gap-2 text-xs'>
<div className='flex flex-col gap-1'>
<span className='font-medium'>{t('Auto Route Chains')}</span>
<p className='leading-relaxed'>
{t(
'When a token uses an auto route, the system tries the route groups from top to bottom until it finds an available group.'
)}{' '}
{t(
'Billing and group ratios are calculated from the real group that handles the request.'
)}
</p>
</div>
{routeChains.map(({ route, groups }) => (
<div key={route.key} className='flex flex-wrap items-center gap-1'>
<GroupBadge
group={route.key}
label={getRouteLabelOverride(route)}
label={getAutoRouteLabelOverride(route)}
size='sm'
/>
<span className='text-muted-foreground/40'>→</span>
Expand Down Expand Up @@ -1419,7 +1409,7 @@ export function ModelDetails() {
groupRatio={groupRatio || {}}
usableGroup={usableGroup || {}}
autoGroups={autoGroups || []}
autoRoutes={autoRoutes || []}
autoRoutes={autoRoutes}
priceRate={priceRate ?? 1}
usdExchangeRate={usdExchangeRate ?? 1}
tokenUnit={tokenUnit}
Expand Down
81 changes: 79 additions & 2 deletions web/default/src/features/pricing/components/pricing-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,31 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
import type { ReactNode } from 'react'
import { ChevronDown, RotateCcw } from 'lucide-react'
import { ChevronDown, Info, RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { AutoGroupRoute } from '@/lib/auto-routes'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { GroupBadge } from '@/components/group-badge'
import {
ENDPOINT_TYPES,
FILTER_ALL,
QUOTA_TYPES,
getEndpointTypeLabels,
getQuotaTypeLabels,
} from '../constants'
import {
getAutoRouteLabelOverride,
getConfiguredAutoRouteChains,
} from '../lib/auto-route-view'
import { parseTags } from '../lib/filters'
import type { PricingModel, PricingVendor } from '../types'

Expand All @@ -51,6 +58,7 @@ type FilterSectionProps = {
value: string
options: FilterOption[]
onChange: (value: string) => void
children?: ReactNode
}

export interface PricingSidebarProps {
Expand All @@ -67,6 +75,8 @@ export interface PricingSidebarProps {
vendors: PricingVendor[]
groups: string[]
groupRatios?: Record<string, number>
autoGroups?: string[]
autoRoutes?: AutoGroupRoute[]
tags: string[]
models: PricingModel[]
hasActiveFilters: boolean
Expand Down Expand Up @@ -149,11 +159,72 @@ function FilterSection(props: FilterSectionProps) {
/>
))}
</div>
{props.children}
</CollapsibleContent>
</Collapsible>
)
}

function AutoRouteGroupIntro(props: {
autoGroups?: string[]
autoRoutes?: AutoGroupRoute[]
visibleGroups: string[]
}) {
const { t } = useTranslation()
const visibleGroupSet = new Set(props.visibleGroups)
const routeChains = getConfiguredAutoRouteChains({
autoGroups: props.autoGroups ?? [],
autoRoutes: props.autoRoutes,
groupFilter: (group) => visibleGroupSet.has(group),
})

if (routeChains.length === 0) return null

return (
<Alert className='mt-3'>
<Info aria-hidden />
<AlertTitle className='text-xs'>
{t('Configured auto route groups')}
</AlertTitle>
<AlertDescription className='flex flex-col gap-2 text-xs leading-relaxed'>
<p>
{t(
'Auto route groups are selectable aliases that point to administrator-configured billing groups.'
)}
</p>
<div className='flex flex-col gap-1.5'>
{routeChains.map(({ route, groups }) => (
<div key={route.key} className='flex flex-wrap items-center gap-1'>
<GroupBadge
group={route.key}
label={getAutoRouteLabelOverride(route)}
size='sm'
/>
<span className='text-muted-foreground/40'>→</span>
{groups.map((group, index) => (
<span
key={`${route.key}-${group}-${index}`}
className='flex items-center gap-1'
>
<GroupBadge group={group} size='sm' />
{index < groups.length - 1 && (
<span className='text-muted-foreground/40'>→</span>
)}
</span>
))}
</div>
))}
</div>
<p>
{t(
'Billing and group ratios are calculated from the real group that handles the request.'
)}
</p>
</AlertDescription>
</Alert>
)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
export function PricingSidebar(props: PricingSidebarProps) {
const { t } = useTranslation()
const quotaTypeLabels = getQuotaTypeLabels(t)
Expand Down Expand Up @@ -277,7 +348,13 @@ export function PricingSidebar(props: PricingSidebarProps) {
value={props.groupFilter}
options={groupOptions}
onChange={props.onGroupChange}
/>
>
<AutoRouteGroupIntro
autoGroups={props.autoGroups}
autoRoutes={props.autoRoutes}
visibleGroups={props.groups}
/>
</FilterSection>
<FilterSection
title={t('All Vendors')}
value={props.vendorFilter}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API
import { useCallback, useState } from 'react'
import { ArrowUpDown, Check, Filter, Grid2X2, Table2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { AutoGroupRoute } from '@/lib/auto-routes'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
Expand Down Expand Up @@ -85,6 +86,8 @@ export interface PricingToolbarProps {
vendors: PricingVendor[]
groups: string[]
groupRatios?: Record<string, number>
autoGroups?: string[]
autoRoutes?: AutoGroupRoute[]
tags: string[]
models: PricingModel[]
hasActiveFilters: boolean
Expand Down Expand Up @@ -297,6 +300,8 @@ export function PricingToolbar(props: PricingToolbarProps) {
vendors={props.vendors}
groups={props.groups}
groupRatios={props.groupRatios}
autoGroups={props.autoGroups}
autoRoutes={props.autoRoutes}
tags={props.tags}
models={props.models}
hasActiveFilters={props.hasActiveFilters}
Expand Down
2 changes: 1 addition & 1 deletion web/default/src/features/pricing/hooks/use-pricing-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function usePricingData() {
usableGroup: data?.usable_group ?? {},
endpointMap: data?.supported_endpoint ?? {},
autoGroups: data?.auto_groups ?? [],
autoRoutes: data?.auto_routes ?? [],
autoRoutes: data?.auto_routes,
isLoading,
error,
refetch,
Expand Down
Loading
Loading