diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 0c00b0aa..f2e0f398 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1129,29 +1129,30 @@ class JupyterHub(Application): for orm_user in db.query(orm.User): self.users[orm_user.id] = user = User(orm_user, self.tornado_settings) self.log.debug("Loading state for %s from db", user.name) - spawner = user.spawner - status = 0 - if user.server: - try: - status = yield spawner.poll() - except Exception: - self.log.exception("Failed to poll spawner for %s, assuming the spawner is not running.", user.name) - status = -1 + for name, spawner in user.spawners.items(): + status = 0 + if spawner.server: + try: + status = yield spawner.poll() + except Exception: + self.log.exception("Failed to poll spawner for %s, assuming the spawner is not running.", + user.name if name else '%s|%s' % (user.name, name)) + status = -1 - if status is None: - self.log.info("%s still running", user.name) - spawner.add_poll_callback(user_stopped, user) - spawner.start_polling() - else: - # user not running. This is expected if server is None, - # but indicates the user's server died while the Hub wasn't running - # if user.server is defined. - log = self.log.warning if user.server else self.log.debug - log("%s not running.", user.name) - # remove all server or servers entry from db related to the user - for server in user.servers: - db.delete(server) - db.commit() + if status is None: + self.log.info("%s still running", user.name) + spawner.add_poll_callback(user_stopped, user) + spawner.start_polling() + else: + # user not running. This is expected if server is None, + # but indicates the user's server died while the Hub wasn't running + # if user.server is defined. + log = self.log.warning if spawner.server else self.log.debug + log("%s not running.", user.name) + # remove all server or servers entry from db related to the user + if spawner.server: + db.delete(spawner.orm_spawner.server) + db.commit() user_summaries.append(_user_summary(user)) diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 7ddc4f91..57c6d8b0 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -66,7 +66,6 @@ class Server(Base): __tablename__ = 'servers' id = Column(Integer, primary_key=True) - name = Column(Unicode(32), default='') # must be unique between user's servers proto = Column(Unicode(15), default='http') ip = Column(Unicode(255), default='') # could also be a DNS name port = Column(Integer, default=random_port) @@ -133,7 +132,10 @@ class User(Base): id = Column(Integer, primary_key=True, autoincrement=True) name = Column(Unicode(1023), unique=True) - servers = association_proxy("user_to_servers", "server", creator=lambda server: UserServer(server=server)) + _orm_spawners = relationship("Spawner", backref="user") + @property + def orm_spawners(self): + return {s.name: s for s in self._orm_spawners} admin = Column(Boolean, default=False) last_activity = Column(DateTime, default=datetime.utcnow) @@ -149,19 +151,12 @@ class User(Base): groups = relationship('Group', secondary='user_group_map', back_populates='users') def __repr__(self): - if self.servers: - server = self.servers[0] - return "<{cls}({name}@{ip}:{port})>".format( - cls=self.__class__.__name__, - name=self.name, - ip=server.ip, - port=server.port, - ) - else: - return "<{cls}({name} [unconfigured])>".format( - cls=self.__class__.__name__, - name=self.name, - ) + return "<{cls}({name} {running}/{total} running)>".format( + cls=self.__class__.__name__, + name=self.name, + total=len(self._orm_spawners), + running=sum(bool(s.server) for s in self._orm_spawners), + ) def new_api_token(self, token=None): """Create a new API token @@ -177,34 +172,18 @@ class User(Base): """ return db.query(cls).filter(cls.name == name).first() +class Spawner(Base): + """"State about a Spawner""" + __tablename__ = 'spawners' + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE')) -class UserServer(Base): - """The UserServer table + server_id = Column(Integer, ForeignKey('servers.id')) + server = relationship(Server) - A table storing the One-To-Many relationship between a user and servers. - Each user may have one or more servers. - A server can have only one (1) user. This condition is maintained by UniqueConstraint. - """ - __tablename__ = 'users_servers' - - _user_id = Column(Integer, ForeignKey('users.id'), primary_key=True) - _server_id = Column(Integer, ForeignKey('servers.id'), primary_key=True) - - user = relationship(User, backref=backref('user_to_servers', cascade='all, delete-orphan')) - server = relationship(Server, backref=backref('server_to_users', cascade='all, delete-orphan') - ) - - __table_args__ = (UniqueConstraint('_server_id'), - Index('server_user_index', '_server_id', '_user_id'), - ) - - def __repr__(self): - return "<{cls}({name}@{ip}:{port})>".format( - cls=self.__class__.__name__, - name=self.user.name, - ip=self.server.ip, - port=self.server.port, - ) + state = Column(JSONDict) + name = Column(Unicode(512)) class Service(Base): diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index c3f1e234..fc6e1c88 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -51,6 +51,7 @@ class Spawner(LoggingConfigurable): _stop_pending = False _waiting_for_response = False + orm_spawner = Any() db = Any() user = Any() hub = Any() diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index fba81203..998ff277 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -405,7 +405,7 @@ def test_spawn(app, io_loop): data=json.dumps(options), ) assert r.status_code == 201 - assert 'pid' in user.state[''] + assert 'pid' in user.orm_spawners[''].state app_user = get_app_user(app, name) assert app_user.spawner is not None assert app_user.spawner.user_options == options @@ -433,7 +433,7 @@ def test_spawn(app, io_loop): r = api_request(app, 'users', name, 'server', method='delete') assert r.status_code == 204 - assert 'pid' not in user.state.get('', {}) + assert 'pid' not in user.orm_spawners[''].state status = io_loop.run_sync(app_user.spawner.poll) assert status == 0 @@ -531,7 +531,7 @@ def test_cookie(app): user = add_user(db, app=app, name=name) r = api_request(app, 'users', name, 'server', method='post') assert r.status_code == 201 - assert 'pid' in user.state[''] + assert 'pid' in user.orm_spawners[''].state app_user = get_app_user(app, name) cookies = app.login_user(name) diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index 928fd400..8abaaf70 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -153,8 +153,8 @@ def test_spawner_poll(db, io_loop): assert status is None if user.state is None: user.state = {} - user.state[''] = first_spawner.get_state() - assert 'pid' in user.state[''] + first_spawner.orm_spawner.state = first_spawner.get_state() + assert 'pid' in first_spawner.orm_spawner.state # create a new Spawner, loading from state of previous spawner = new_spawner(db, user=first_spawner.user) diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 509f19a2..0b3eb12d 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -147,17 +147,34 @@ class User(HasTraits): self.settings.get('base_url', '/'), 'user', self.escaped_name) + '/' self.spawners = _SpawnerDict(self._new_spawner) + # load existing named spawners + for name in self.orm_spawners: + self.log.debug("Loading spawner %s:%s", self.name, name) + self.spawners[name] = self._new_spawner(name) def _new_spawner(self, name): """Create a new spawner""" + orm_spawner = self.orm_spawners.get(name) + if orm_spawner is None: + orm_spawner = orm.Spawner(user=self.orm_user, name=name) + self.db.add(orm_spawner) + self.db.commit() + assert name in self.orm_spawners + if name == '' and self.state: + # migrate user.state to spawner.state + orm_spawner.state = self.state + self.state = None spawner = self.spawner_class( user=self, db=self.db, + orm_spawner=orm_spawner, hub=self.settings.get('hub'), authenticator=self.authenticator, config=self.settings.get('config'), ) - spawner.load_state((self.state or {}).get(name, {})) + if orm_spawner.server: + spawner.server = Server(orm_spawner.server) + spawner.load_state(orm_spawner.state or {}) return spawner # singleton property, self.spawner maps onto spawner with empty server_name @@ -198,10 +215,7 @@ class User(HasTraits): @property def server(self): - if len(self.servers) == 0: - return None - else: - return Server(orm_server=self.servers[0]) + return self.spawner.server @property def escaped_name(self): @@ -264,10 +278,9 @@ class User(HasTraits): base_url = url_path_join(self.base_url, server_name) + '/' orm_server = orm.Server( - name=server_name, base_url=base_url, ) - self.servers.append(orm_server) + db.add(orm_server) api_token = self.new_api_token() db.commit() @@ -275,6 +288,7 @@ class User(HasTraits): server = Server(orm_server=orm_server) spawner = self.spawners[server_name] + spawner.orm_spawner.server = orm_server # Passing server, server_name and options to the spawner spawner.server = server @@ -353,7 +367,7 @@ class User(HasTraits): # store state if self.state is None: self.state = {} - self.state[server_name] = spawner.get_state() + spawner.orm_spawner.state = spawner.get_state() self.last_activity = datetime.utcnow() db.commit() spawner._waiting_for_response = True @@ -407,7 +421,7 @@ class User(HasTraits): if status is None: yield spawner.stop() spawner.clear_state() - self.state = spawner.get_state() + spawner.orm_spawner.state = spawner.get_state() self.last_activity = datetime.utcnow() # remove server entry from db if spawner.server is not None: diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 1ac76a2e..b556172c 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -225,7 +225,7 @@ def default_server_name(user): Will be the first available integer string, e.g. '1' or '2'. """ - existing_names = { server.name for server in user.servers } + existing_names = set(user.spawners) # if there are 5 servers, count from 1 to 6 for n in range(1, len(existing_names) + 2): name = str(n)