blob: e92f6a95671ce88b32245785abf33cabd4177a06 [file] [log] [blame]
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file or at
"""A set of functions that provide persistence for projects.
This module provides functions to get, update, create, and (in some
cases) delete each type of project business object. It provides
a logical persistence layer on top of the database.
Business objects are described in
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import collections
import logging
import time
import settings
from framework import exceptions
from framework import framework_constants
from framework import framework_helpers
from framework import permissions
from framework import sql
from services import caches
from project import project_helpers
from proto import project_pb2
'project_id', 'project_name', 'summary', 'description', 'state', 'access',
'read_only_reason', 'state_reason', 'delete_time', 'issue_notify_address',
'attachment_bytes_used', 'attachment_quota', 'cached_content_timestamp',
'recent_activity_timestamp', 'moved_to', 'process_inbound_email',
'only_owners_remove_restrictions', 'only_owners_see_contributors',
'revision_url_format', 'home_page', 'docs_url', 'source_url', 'logo_gcs_id',
'logo_file_name', 'issue_notify_always_detailed'
USER2PROJECT_COLS = ['project_id', 'user_id', 'role_name']
EXTRAPERM_COLS = ['project_id', 'user_id', 'perm']
MEMBERNOTES_COLS = ['project_id', 'user_id', 'notes']
'project_id', 'user_id', 'ac_exclude', 'no_expand']
class ProjectTwoLevelCache(caches.AbstractTwoLevelCache):
"""Class to manage both RAM and memcache for Project PBs."""
def __init__(self, cachemanager, project_service):
super(ProjectTwoLevelCache, self).__init__(
cachemanager, 'project', 'project:', project_pb2.Project)
self.project_service = project_service
def _DeserializeProjects(
self, project_rows, role_rows, extraperm_rows):
"""Convert database rows into a dictionary of Project PB keyed by ID."""
project_dict = {}
for project_row in project_rows:
project_id, project_name, summary, description, state_name,
access_name, read_only_reason, state_reason, delete_time,
issue_notify_address, attachment_bytes_used, attachment_quota, cct,
recent_activity_timestamp, moved_to, process_inbound_email, oorr,
oosc, revision_url_format, home_page, docs_url, source_url,
logo_gcs_id, logo_file_name,
issue_notify_always_detailed) = project_row
project = project_pb2.Project()
project.project_id = project_id
project.project_name = project_name
project.summary = summary
project.description = description
project.state = project_pb2.ProjectState(state_name.upper())
project.state_reason = state_reason or ''
project.access = project_pb2.ProjectAccess(access_name.upper())
project.read_only_reason = read_only_reason or ''
project.issue_notify_address = issue_notify_address or ''
project.attachment_bytes_used = attachment_bytes_used or 0
project.attachment_quota = attachment_quota
project.recent_activity = recent_activity_timestamp or 0
project.cached_content_timestamp = cct or 0
project.delete_time = delete_time or 0
project.moved_to = moved_to or ''
project.process_inbound_email = bool(process_inbound_email)
project.only_owners_remove_restrictions = bool(oorr)
project.only_owners_see_contributors = bool(oosc)
project.revision_url_format = revision_url_format or ''
project.home_page = home_page or ''
project.docs_url = docs_url or ''
project.source_url = source_url or ''
project.logo_gcs_id = logo_gcs_id or ''
project.logo_file_name = logo_file_name or ''
project.issue_notify_always_detailed = bool(issue_notify_always_detailed)
project_dict[project_id] = project
for project_id, user_id, role_name in role_rows:
project = project_dict[project_id]
if role_name == 'owner':
elif role_name == 'committer':
elif role_name == 'contributor':
perms = {}
for project_id, user_id, perm in extraperm_rows:
perms.setdefault(project_id, {}).setdefault(user_id, []).append(perm)
for project_id, perms_by_user in perms.items():
project = project_dict[project_id]
for user_id, extra_perms in sorted(perms_by_user.items()):
member_id=user_id, perms=extra_perms))
return project_dict
def FetchItems(self, cnxn, keys):
"""On RAM and memcache miss, hit the database to get missing projects."""
project_rows = self.project_service.project_tbl.Select(
cnxn, cols=PROJECT_COLS, project_id=keys)
role_rows = self.project_service.user2project_tbl.Select(
cnxn, cols=['project_id', 'user_id', 'role_name'],
extraperm_rows = self.project_service.extraperm_tbl.Select(
cnxn, cols=EXTRAPERM_COLS, project_id=keys)
retrieved_dict = self._DeserializeProjects(
project_rows, role_rows, extraperm_rows)
return retrieved_dict
class UserToProjectIdTwoLevelCache(caches.AbstractTwoLevelCache):
"""Class to manage both RAM and memcache for project_ids.
Keys for this cache are int, user_ids, which might correspond to a group.
This cache should be used to fetch a set of project_ids that the user_id
is a member of.
def __init__(self, cachemanager, project_service):
# type: cachemanager_svc.CacheManager, ProjectService -> None
super(UserToProjectIdTwoLevelCache, self).__init__(
cachemanager, 'project_id', 'project_id:', pb_class=None)
self.project_service = project_service
# Store the last time the table was fetched for rate limit purposes.
self.last_fetched = 0
def FetchItems(self, cnxn, keys):
# type MonorailConnection, Collection[int] -> Mapping[int, Collection[int]]
"""On RAM and memcache miss, hit the database to get missing user_ids."""
# Unlike with other caches, we fetch and store the entire table.
# Thus, for cache misses we limit the rate we re-fetch the table to 60s.
now = self._GetCurrentTime()
result_dict = collections.defaultdict(set)
if (now - self.last_fetched) > 60:
project_to_user_rows = self.project_service.user2project_tbl.Select(
cnxn, cols=['project_id', 'user_id'])
self.last_fetched = now
# Cache the whole User2Project table.
for project_id, user_id in project_to_user_rows:
# Assume any requested user missing from result is not in any project.
(user_id, set()) for user_id in keys if user_id not in result_dict)
return result_dict
def _GetCurrentTime(self):
""" Returns the current time. We made a separate method for this to make it
easier to unit test. This was a better solution than @mock.patch because
the test had several unrelated time.time() calls. Modifying those calls
would be more onerous, having to fix calls for this test.
return time.time()
class ProjectService(object):
"""The persistence layer for project data."""
def __init__(self, cache_manager):
"""Initialize this module so that it is ready to use.
cache_manager: local cache with distributed invalidation.
self.project_tbl = sql.SQLTableManager(PROJECT_TABLE_NAME)
self.user2project_tbl = sql.SQLTableManager(USER2PROJECT_TABLE_NAME)
self.extraperm_tbl = sql.SQLTableManager(EXTRAPERM_TABLE_NAME)
self.membernotes_tbl = sql.SQLTableManager(MEMBERNOTES_TABLE_NAME)
self.usergroupprojects_tbl = sql.SQLTableManager(
self.acexclusion_tbl = sql.SQLTableManager(
# Like a dictionary {project_id: project}
self.project_2lc = ProjectTwoLevelCache(cache_manager, self)
# A dictionary of user_id to a set of project ids.
# Mapping[int, Collection[int]]
self.user_to_project_2lc = UserToProjectIdTwoLevelCache(cache_manager, self)
# The project name to ID cache can never be invalidated by individual
# project changes because it is keyed by strings instead of ints. In
# the case of rare operations like deleting a project (or a future
# project renaming feature), we just InvalidateAll().
self.project_names_to_ids = caches.RamCache(cache_manager, 'project')
### Creating projects
def CreateProject(
self, cnxn, project_name, owner_ids, committer_ids, contributor_ids,
summary, description, state=project_pb2.ProjectState.LIVE,
access=None, read_only_reason=None, home_page=None, docs_url=None,
source_url=None, logo_gcs_id=None, logo_file_name=None):
"""Create and store a Project with the given attributes.
cnxn: connection to SQL database.
project_name: a valid project name, all lower case.
owner_ids: a list of user IDs for the project owners.
committer_ids: a list of user IDs for the project members.
contributor_ids: a list of user IDs for the project contributors.
summary: one-line explanation of the project.
description: one-page explanation of the project.
state: a project state enum defined in project_pb2.
access: optional project access enum defined in project.proto.
read_only_reason: if given, provides a status message and marks
the project as read-only.
home_page: home page of the project
docs_url: url to redirect to for wiki/documentation links
source_url: url to redirect to for source browser links
logo_gcs_id: google storage object id of the project's logo
logo_file_name: uploaded file name of the project's logo
The int project_id of the new project.
ProjectAlreadyExists: if a project with that name already exists.
assert project_helpers.IsValidProjectName(project_name)
if self.LookupProjectIDs(cnxn, [project_name]):
raise exceptions.ProjectAlreadyExists()
project = project_pb2.MakeProject(
project_name, state=state, access=access,
description=description, summary=summary,
owner_ids=owner_ids, committer_ids=committer_ids,
contributor_ids=contributor_ids, read_only_reason=read_only_reason,
home_page=home_page, docs_url=docs_url, source_url=source_url,
logo_gcs_id=logo_gcs_id, logo_file_name=logo_file_name)
project.project_id = self._InsertProject(cnxn, project)
return project.project_id
def _InsertProject(self, cnxn, project):
"""Insert the given project into the database."""
# Note: project_id is not specified because it is auto_increment.
project_id = self.project_tbl.InsertRow(
cnxn, project_name=project.project_name,
summary=project.summary, description=project.description,
state=str(project.state), access=str(project.access),
home_page=project.home_page, docs_url=project.docs_url,
logo_gcs_id=project.logo_gcs_id, logo_file_name=project.logo_file_name)'stored project was given project_id %d', project_id)
cnxn, ['project_id', 'user_id', 'role_name'],
[(project_id, user_id, 'owner')
for user_id in project.owner_ids] +
[(project_id, user_id, 'committer')
for user_id in project.committer_ids] +
[(project_id, user_id, 'contributor')
for user_id in project.contributor_ids])
return project_id
### Lookup project names and IDs
def LookupProjectIDs(self, cnxn, project_names):
"""Return a list of project IDs for the specified projects."""
id_dict, missed_names = self.project_names_to_ids.GetAll(project_names)
if missed_names:
rows = self.project_tbl.Select(
cnxn, cols=['project_name', 'project_id'], project_name=missed_names)
retrieved_dict = dict(rows)
return id_dict
def LookupProjectNames(self, cnxn, project_ids):
"""Lookup the names of the projects with the given IDs."""
projects_dict = self.GetProjects(cnxn, project_ids)
return {p.project_id: p.project_name
for p in projects_dict.values()}
### Retrieving projects
def GetAllProjects(self, cnxn, use_cache=True):
"""Return A dict mapping IDs to all live project PBs."""
project_rows = self.project_tbl.Select(
cnxn, cols=['project_id'], state=project_pb2.ProjectState.LIVE)
project_ids = [row[0] for row in project_rows]
projects_dict = self.GetProjects(cnxn, project_ids, use_cache=use_cache)
return projects_dict
def GetVisibleLiveProjects(
self, cnxn, logged_in_user, effective_ids, domain=None, use_cache=True):
"""Return all user visible live project ids.
cnxn: connection to SQL database.
logged_in_user: protocol buffer of the logged in user. Can be None.
effective_ids: set of user IDs for this user. Can be None.
domain: optional string with HTTP request hostname.
use_cache: pass False to force database query to find Project protocol
A list of project ids of user visible live projects sorted by the names
of the projects. If host was provided, only projects with that host
as their branded domain will be returned.
project_rows = self.project_tbl.Select(
cnxn, cols=['project_id'], state=project_pb2.ProjectState.LIVE)
project_ids = [row[0] for row in project_rows]
projects_dict = self.GetProjects(cnxn, project_ids, use_cache=use_cache)
projects_on_host = {
project_id: project for project_id, project in projects_dict.items()
if not framework_helpers.GetNeededDomain(project.project_name, domain)}
visible_projects = []
for project in projects_on_host.values():
if permissions.UserCanViewProject(logged_in_user, effective_ids, project):
visible_projects.sort(key=lambda p: p.project_name)
return [project.project_id for project in visible_projects]
def GetProjects(self, cnxn, project_ids, use_cache=True):
"""Load all the Project PBs for the given projects.
cnxn: connection to SQL database.
project_ids: list of int project IDs
use_cache: pass False to force database query.
A dict mapping IDs to the corresponding Project protocol buffers.
NoSuchProjectException: if any of the projects was not found.
project_dict, missed_ids = self.project_2lc.GetAll(
cnxn, project_ids, use_cache=use_cache)
# Also, update the project name cache.
{p.project_name: p.project_id for p in project_dict.values()})
if missed_ids:
raise exceptions.NoSuchProjectException()
return project_dict
def GetProject(self, cnxn, project_id, use_cache=True):
"""Load the specified project from the database."""
project_id_dict = self.GetProjects(cnxn, [project_id], use_cache=use_cache)
return project_id_dict[project_id]
def GetProjectsByName(self, cnxn, project_names, use_cache=True):
"""Load all the Project PBs for the given projects.
cnxn: connection to SQL database.
project_names: list of project names.
use_cache: specifify False to force database query.
A dict mapping names to the corresponding Project protocol buffers.
project_ids = list(self.LookupProjectIDs(cnxn, project_names).values())
projects = self.GetProjects(cnxn, project_ids, use_cache=use_cache)
return {p.project_name: p for p in projects.values()}
def GetProjectByName(self, cnxn, project_name, use_cache=True):
"""Load the specified project from the database, None if does not exist."""
project_dict = self.GetProjectsByName(
cnxn, [project_name], use_cache=use_cache)
return project_dict.get(project_name)
### Deleting projects
def ExpungeProject(self, cnxn, project_id):
"""Wipes a project from the system."""'expunging project %r', project_id)
self.user2project_tbl.Delete(cnxn, project_id=project_id)
self.usergroupprojects_tbl.Delete(cnxn, project_id=project_id)
self.extraperm_tbl.Delete(cnxn, project_id=project_id)
self.membernotes_tbl.Delete(cnxn, project_id=project_id)
self.acexclusion_tbl.Delete(cnxn, project_id=project_id)
self.project_tbl.Delete(cnxn, project_id=project_id)
### Updating projects
def UpdateProject(
"""Update the DB with the given project information."""
exists = self.project_tbl.SelectValue(
cnxn, 'project_name', project_id=project_id)
if not exists:
raise exceptions.NoSuchProjectException()
delta = {}
if summary is not None:
delta['summary'] = summary
if description is not None:
delta['description'] = description
if state is not None:
delta['state'] = str(state).lower()
if state is not None:
delta['state_reason'] = state_reason
if access is not None:
delta['access'] = str(access).lower()
if read_only_reason is not None:
delta['read_only_reason'] = read_only_reason
if issue_notify_address is not None:
delta['issue_notify_address'] = issue_notify_address
if attachment_bytes_used is not None:
delta['attachment_bytes_used'] = attachment_bytes_used
if attachment_quota is not None:
delta['attachment_quota'] = attachment_quota
if moved_to is not None:
delta['moved_to'] = moved_to
if process_inbound_email is not None:
delta['process_inbound_email'] = process_inbound_email
if only_owners_remove_restrictions is not None:
delta['only_owners_remove_restrictions'] = (
if only_owners_see_contributors is not None:
delta['only_owners_see_contributors'] = only_owners_see_contributors
if delete_time is not None:
delta['delete_time'] = delete_time
if recent_activity is not None:
delta['recent_activity_timestamp'] = recent_activity
if revision_url_format is not None:
delta['revision_url_format'] = revision_url_format
if home_page is not None:
delta['home_page'] = home_page
if docs_url is not None:
delta['docs_url'] = docs_url
if source_url is not None:
delta['source_url'] = source_url
if logo_gcs_id is not None:
delta['logo_gcs_id'] = logo_gcs_id
if logo_file_name is not None:
delta['logo_file_name'] = logo_file_name
if issue_notify_always_detailed is not None:
delta['issue_notify_always_detailed'] = issue_notify_always_detailed
if cached_content_timestamp is not None:
delta['cached_content_timestamp'] = cached_content_timestamp
self.project_tbl.Update(cnxn, delta, project_id=project_id, commit=False)
self.project_2lc.InvalidateKeys(cnxn, [project_id])
if commit:
def UpdateCachedContentTimestamp(self, cnxn, project_id, now=None):
now = now or int(time.time())
cnxn, {'cached_content_timestamp': now},
project_id=project_id, commit=False)
return now
def UpdateProjectRoles(
self, cnxn, project_id, owner_ids, committer_ids, contributor_ids,
"""Store the project's roles in the DB and set cached_content_timestamp."""
exists = self.project_tbl.SelectValue(
cnxn, 'project_name', project_id=project_id)
if not exists:
raise exceptions.NoSuchProjectException()
self.UpdateCachedContentTimestamp(cnxn, project_id, now=now)
cnxn, project_id=project_id, role_name='owner', commit=False)
cnxn, project_id=project_id, role_name='committer', commit=False)
cnxn, project_id=project_id, role_name='contributor', commit=False)
cnxn, ['project_id', 'user_id', 'role_name'],
[(project_id, user_id, 'owner') for user_id in owner_ids],
cnxn, ['project_id', 'user_id', 'role_name'],
[(project_id, user_id, 'committer')
for user_id in committer_ids], commit=False)
cnxn, ['project_id', 'user_id', 'role_name'],
[(project_id, user_id, 'contributor')
for user_id in contributor_ids], commit=False)
self.project_2lc.InvalidateKeys(cnxn, [project_id])
updated_user_ids = owner_ids + committer_ids + contributor_ids
self.user_to_project_2lc.InvalidateKeys(cnxn, updated_user_ids)
def MarkProjectDeletable(self, cnxn, project_id, config_service):
"""Update the project's state to make it DELETABLE and free up the name.
cnxn: connection to SQL database.
project_id: int ID of the project that will be deleted soon.
config_service: issue tracker configuration persistence service, needed
to invalidate cached issue tracker results.
generated_name = 'DELETABLE_%d' % project_id
delta = {'project_name': generated_name, 'state': 'deletable'}
self.project_tbl.Update(cnxn, delta, project_id=project_id)
self.project_2lc.InvalidateKeys(cnxn, [project_id])
# We cannot invalidate a specific part of the name->proj cache by name,
# So, tell every job to just drop the whole cache. It should refill
# efficiently and incrementally from memcache.
def UpdateRecentActivity(self, cnxn, project_id, now=None):
"""Set the project's recent_activity to the current time."""
now = now or int(time.time())
project = self.GetProject(cnxn, project_id)
if now > project.recent_activity + RECENT_ACTIVITY_THRESHOLD:
self.UpdateProject(cnxn, project_id, recent_activity=now)
### Roles, memberships, and extra perms
def GetUserRolesInAllProjects(self, cnxn, effective_ids):
"""Return three sets of project IDs where the user has a role."""
owned_project_ids = set()
membered_project_ids = set()
contrib_project_ids = set()
rows = []
if effective_ids:
rows = self.user2project_tbl.Select(
cnxn, cols=['project_id', 'role_name'], user_id=effective_ids)
for project_id, role_name in rows:
if role_name == 'owner':
elif role_name == 'committer':
elif role_name == 'contributor':
logging.warn('Unexpected role name %r', role_name)
return owned_project_ids, membered_project_ids, contrib_project_ids
def GetProjectMemberships(self, cnxn, effective_ids, use_cache=True):
# type: MonorailConnection, Collection[int], Optional[bool] ->
# Mapping[int, Collection[int]]
"""Return a list of project IDs where the user has a membership."""
project_id_dict, missed_ids = self.user_to_project_2lc.GetAll(
cnxn, effective_ids, use_cache=use_cache)
# Users that were missed are assumed to not have any projects.
assert not missed_ids
return project_id_dict
def UpdateExtraPerms(
self, cnxn, project_id, member_id, extra_perms, now=None):
"""Load the project, update the member's extra perms, and store.
cnxn: connection to SQL database.
project_id: int ID of the current project.
member_id: int user id of the user that was edited.
extra_perms: list of strings for perms that the member
should have over-and-above what their role gives them.
now: fake int(time.time()) value passed in during unit testing.
# This will be a newly constructed object, not from the cache and not
# shared with any other thread.
project = self.GetProject(cnxn, project_id, use_cache=False)
idx, member_extra_perms = permissions.FindExtraPerms(project, member_id)
if not member_extra_perms and not extra_perms:
if member_extra_perms and list(member_extra_perms.perms) == extra_perms:
# Either project is None or member_id is not a member of the project.
if idx is None:
if member_extra_perms:
member_extra_perms.perms = extra_perms
member_extra_perms = project_pb2.Project.ExtraPerms(
member_id=member_id, perms=extra_perms)
# Keep the list of extra_perms sorted by member id.
project.extra_perms.insert(idx, member_extra_perms)
cnxn, project_id=project_id, user_id=member_id, commit=False)
[(project_id, member_id, perm) for perm in extra_perms],
project.cached_content_timestamp = self.UpdateCachedContentTimestamp(
cnxn, project_id, now=now)
self.project_2lc.InvalidateKeys(cnxn, [project_id])
### Project Commitments
def GetProjectCommitments(self, cnxn, project_id):
"""Get the project commitments (notes) from the DB.
cnxn: connection to SQL database.
project_id: int project ID.
A the specified project's ProjectCommitments instance, or an empty one,
if the project doesn't exist, or has not documented member
# Get the notes. Don't get the project_id column
# since we already know that value.
notes_rows = self.membernotes_tbl.Select(
cnxn, cols=['user_id', 'notes'], project_id=project_id)
notes_dict = dict(notes_rows)
project_commitments = project_pb2.ProjectCommitments()
project_commitments.project_id = project_id
for user_id in notes_dict.keys():
commitment = project_pb2.ProjectCommitments.MemberCommitment(
notes=notes_dict.get(user_id, ''))
return project_commitments
def _StoreProjectCommitments(self, cnxn, project_commitments):
"""Store an updated set of project commitments in the DB.
cnxn: connection to SQL database.
project_commitments: ProjectCommitments PB
project_id = project_commitments.project_id
notes_rows = []
for commitment in project_commitments.commitments:
(project_id, commitment.member_id, commitment.notes))
# TODO(jrobbins): this should be in a transaction.
self.membernotes_tbl.Delete(cnxn, project_id=project_id)
cnxn, MEMBERNOTES_COLS, notes_rows, ignore=True)
def UpdateCommitments(self, cnxn, project_id, member_id, notes):
"""Update the member's commitments in the specified project.
cnxn: connection to SQL database.
project_id: int ID of the current project.
member_id: int user ID of the user that was edited.
notes: further notes on the member's expected involvment
in the project.
project_commitments = self.GetProjectCommitments(cnxn, project_id)
commitment = None
for c in project_commitments.commitments:
if c.member_id == member_id:
commitment = c
commitment = project_pb2.ProjectCommitments.MemberCommitment(
dirty = False
if commitment.notes != notes:
commitment.notes = notes
dirty = True
if dirty:
self._StoreProjectCommitments(cnxn, project_commitments)
def GetProjectAutocompleteExclusion(self, cnxn, project_id):
"""Get user ids who are excluded from autocomplete list.
cnxn: connection to SQL database.
project_id: int ID of the current project.
A pair containing: a list of user IDs who are excluded from the
autocomplete list for given project, and a list of group IDs to
not expand.
ac_exclusion_rows = self.acexclusion_tbl.Select(
cnxn, cols=['user_id'], project_id=project_id, ac_exclude=True)
ac_exclusion_ids = [row[0] for row in ac_exclusion_rows]
no_expand_rows = self.acexclusion_tbl.Select(
cnxn, cols=['user_id'], project_id=project_id, no_expand=True)
no_expand_ids = [row[0] for row in no_expand_rows]
return ac_exclusion_ids, no_expand_ids
def UpdateProjectAutocompleteExclusion(
self, cnxn, project_id, member_id, ac_exclude, no_expand):
"""Update autocomplete exclusion for given user.
cnxn: connection to SQL database.
project_id: int ID of the current project.
member_id: int user ID of the user that was edited.
ac_exclude: Whether this user should be excluded.
no_expand: Whether this group should not be expanded.
if ac_exclude or no_expand:
[(project_id, member_id, ac_exclude, no_expand)],
cnxn, project_id=project_id, user_id=member_id)
self.UpdateCachedContentTimestamp(cnxn, project_id)
self.project_2lc.InvalidateKeys(cnxn, [project_id])
def ExpungeUsersInProjects(self, cnxn, user_ids, limit=None):
"""Wipes the given users from the projects system.
This method will not commit the operation. This method will
not make changes to in-memory data.
self.extraperm_tbl.Delete(cnxn, user_id=user_ids, limit=limit, commit=False)
cnxn, user_id=user_ids, limit=limit, commit=False)
cnxn, user_id=user_ids, limit=limit, commit=False)
cnxn, user_id=user_ids, limit=limit, commit=False)