r4903 - trunk/sage/python/sage/wallaby
by tmckay@fedoraproject.org
Author: tmckay
Date: 2011-08-02 20:22:05 +0000 (Tue, 02 Aug 2011)
New Revision: 4903
Modified:
trunk/sage/python/sage/wallaby/wallabyoperations.py
Log:
Add edit_tags(self, node, tags)
Modified: trunk/sage/python/sage/wallaby/wallabyoperations.py
===================================================================
--- trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-02 18:54:59 UTC (rev 4902)
+++ trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-02 20:22:05 UTC (rev 4903)
@@ -11,7 +11,7 @@
try:
import wallaby
from wallaby_collections import StorePatches
- from tagging import StorePatches, NodePatches
+ from tagging import StorePatches, NodePatches, setup
except:
imports_ok = False
@@ -78,6 +78,10 @@
# Cache a list of nodes that are members of a tag
self._nodes_by_tag = dict()
+ # Store the name of the partition group so we can filter it out
+ # of tags/groups that we return
+ self._partition_group = None
+
# Lock is used for synchronization with the caching thread and
# for thread safety of any and all data that could be accessed
# by multiple threads.
@@ -127,6 +131,8 @@
while not self._stop:
self._store = self._get_store()
if self._store is not None:
+ setup(self._store)
+ self._partition_group = self._store.getPartitionGroup().name
log.debug("WallabyOperations: found wallaby store object")
break
@@ -374,9 +380,7 @@
n = node.name
self._lock.acquire()
if n in self._cache[WBTypes.NODES].data:
- for g in self._cache[WBTypes.NODES].data[n].memberships:
- if g in self._cache[WBTypes.TAGS].data:
- names.append(g)
+ names = self._cache[WBTypes.NODES].data[n].getTags()
self._lock.release()
return names
@@ -390,7 +394,7 @@
self._store.addExplicitGroup(name)
self.refresh((WBTypes.GROUPS,WBTypes.TAGS))
except Exception, e:
- log.debug("Wallaby: create_tag, exception suppressed, %s" % str(e))
+ log.debug("WallabyOperations: create_tag, exception suppressed, %s" % str(e))
return False
return True
@@ -407,10 +411,38 @@
self._store.removeGroup(name)
self.refresh()
except Exception, e:
- log.debug("Wallaby: remove_tag, exception suppressed, %s" % str(e))
+ log.debug("WallabyOperations: remove_tag, exception suppressed, %s" % str(e))
return False
return True
+ def edit_tags(self, node, tags):
+ '''
+ Replace existing tags on a node with the specified tags.
+
+ node -- a wallaby.Node object or the name of a wallaby.Node object
+
+ tags -- the new set of tags for the node, list or tuple.
+ '''
+ if type(node) in (str, unicode):
+ n = node
+ else:
+ n = node.name
+ self._lock.acquire()
+ if n in self._cache[WBTypes.NODES].data:
+ n = self._cache[WBTypes.NODES].data[n]
+ self._lock.release()
+ try:
+ # cast to list in case tags is a tuple, list required
+ start = time.time()
+ n.modifyTags("REPLACE", list(tags), create_missing_tags=True)
+ n.update()
+ except Exception, e:
+ log.debug("WallabyOperations: edit_tags, exception suppressed, %s" % str(e))
+ return False
+ delta = time.time() - start
+ log.debug("WallabyOperations: edit_tags %s" % delta)
+ return True
+
def set_interval(self, which, refresh):
'''
Set an individual refresh interval for a data item.
@@ -468,7 +500,9 @@
tags = []
nodes_by_tag = dict()
for g in groups:
- if not g.name.startswith("+++") and g.getConfig() == {}:
+ if not g.name.startswith("+++") and \
+ g.name != self._partition_group and \
+ g.getConfig() == {}:
tags.append(g)
nodes = g.membership()
nodes_by_tag[g.name] = nodes
12 years, 9 months
r4902 - trunk/sage/python/sage/wallaby
by tmckay@fedoraproject.org
Author: tmckay
Date: 2011-08-02 18:54:59 +0000 (Tue, 02 Aug 2011)
New Revision: 4902
Modified:
trunk/sage/python/sage/wallaby/wallabyoperations.py
Log:
Add create_tag(self, tag) and remove_tag(self, tag)
Modified: trunk/sage/python/sage/wallaby/wallabyoperations.py
===================================================================
--- trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-02 15:35:44 UTC (rev 4901)
+++ trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-02 18:54:59 UTC (rev 4902)
@@ -380,6 +380,37 @@
self._lock.release()
return names
+ def create_tag(self, name):
+ '''
+ Create a new tag in the Wallaby store.
+
+ Refresh the cached lists of groups and tags.
+ '''
+ try:
+ self._store.addExplicitGroup(name)
+ self.refresh((WBTypes.GROUPS,WBTypes.TAGS))
+ except Exception, e:
+ log.debug("Wallaby: create_tag, exception suppressed, %s" % str(e))
+ return False
+ return True
+
+ def remove_tag(self, name):
+ '''
+ Remove a tag from the Wallaby store.
+
+ Check the cached list of tags for the
+ tag name first. Refresh cached lists of
+ groups, tags, and nodes.
+ '''
+ if self.get_tag_by_name(name) is not None:
+ try:
+ self._store.removeGroup(name)
+ self.refresh()
+ except Exception, e:
+ log.debug("Wallaby: remove_tag, exception suppressed, %s" % str(e))
+ return False
+ return True
+
def set_interval(self, which, refresh):
'''
Set an individual refresh interval for a data item.
12 years, 9 months
r4901 - trunk/sage/python/sage/wallaby
by tmckay@fedoraproject.org
Author: tmckay
Date: 2011-08-02 15:35:44 +0000 (Tue, 02 Aug 2011)
New Revision: 4901
Modified:
trunk/sage/python/sage/wallaby/wallabyoperations.py
Log:
Add get_tag_names(self, node)
Modified: trunk/sage/python/sage/wallaby/wallabyoperations.py
===================================================================
--- trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-02 15:23:08 UTC (rev 4900)
+++ trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-02 15:35:44 UTC (rev 4901)
@@ -359,6 +359,26 @@
names = self._nodes_by_tag[n]
self._lock.release()
return names
+
+ def get_tag_names(self, node):
+ '''
+ Return a list of tag names associated with the node.
+
+ The return result is a list containing the names of tags
+ on the specified node.
+ '''
+ names = []
+ if type(node) in (str, unicode):
+ n = node
+ else:
+ n = node.name
+ self._lock.acquire()
+ if n in self._cache[WBTypes.NODES].data:
+ for g in self._cache[WBTypes.NODES].data[n].memberships:
+ if g in self._cache[WBTypes.TAGS].data:
+ names.append(g)
+ self._lock.release()
+ return names
def set_interval(self, which, refresh):
'''
@@ -495,5 +515,8 @@
def get_node_names(self, *args, **kwargs):
return []
+ def get_tag_names(self, *args, **kwargs):
+ return []
+
def set_interval(self, *args, **kwargs):
pass
12 years, 9 months
r4900 - trunk/sage/python/sage/wallaby
by tmckay@fedoraproject.org
Author: tmckay
Date: 2011-08-02 15:23:08 +0000 (Tue, 02 Aug 2011)
New Revision: 4900
Modified:
trunk/sage/python/sage/wallaby/wallabyoperations.py
Log:
Additional routines added to the API, internal tweaks.
Modified: trunk/sage/python/sage/wallaby/wallabyoperations.py
===================================================================
--- trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-01 20:13:05 UTC (rev 4899)
+++ trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-02 15:23:08 UTC (rev 4900)
@@ -67,14 +67,15 @@
self._stop = False
# Cached data. Each of the keys in this dictionary is the name of
- # an attribute on the Wallaby Store object.
+ # an attribute on the Wallaby Store object, with the exception of
+ # WBTypes.TAGS. The TAGS data is a subset of the GROUPS produced
+ # in this module.
self._cache = {WBTypes.NODES: self.CacheData(refresh_interval),
- WBTypes.GROUPS: self.CacheData(refresh_interval)}
+ WBTypes.GROUPS: self.CacheData(refresh_interval),
+ WBTypes.TAGS: self.CacheData(refresh_interval,
+ synthetic=self._generate_tag_data)}
- # Tags are special. They are just a subset of groups, but it
- # takes time to filter the list so it is handled in a separate
- # thread when group data is received.
- self._tags = []
+ # Cache a list of nodes that are members of a tag
self._nodes_by_tag = dict()
# Lock is used for synchronization with the caching thread and
@@ -143,24 +144,14 @@
# Okay, now we're ready to retrieve data
while not self._stop:
-
start_processing = time.time()
for attr, val in self._cache.iteritems():
# val.remaining is the number of seconds left before
# the next update of this data item. None is "forever".
- if val.remaining is not None and val.remaining <= 0:
- log.debug("WallabyOperations: refreshing %s" % attr)
- try:
- # Wallaby API uses extensions to __getattr__ on
- # the Store to retrieve objects from the Broker
- # and return a list of proxy objects.
- start = time.time()
- d = getattr(self._store, attr, [])
- except:
- d = []
- delta = time.time() - start
- log.debug("WallabyOperations: %s seconds to refresh %s" % (delta, attr))
-
+ # Synthetic items are not retreived from the store.
+ if not val.synthetic and \
+ val.remaining is not None and val.remaining <= 0:
+ d = get_values(attr, getattr, self._store, attr, [])
# If the data is empty, _set_cache will leave the
# remaining field set to 0 for the attribute so we
# will try to get it again on our next retry.
@@ -168,6 +159,13 @@
# interval for this attribute.
self._set_cache(attr, d)
+ # Now handle the synthetics. val.synthetic generates
+ # and stores it's own results.
+ for attr, val in self._cache.iteritems():
+ if val.synthetic and \
+ val.remaining is not None and val.remaining <= 0:
+ get_values(attr, val.synthetic, *val.args)
+
log.debug("WallabyOperations: total refresh processing time %s" \
% (time.time() - start_processing))
@@ -204,6 +202,20 @@
self._set_cache(attr, [])
#end maintain_cache
+ def get_values(attr, call, *args):
+ log.debug("WallabyOperations: refreshing %s" % attr)
+ try:
+ # Wallaby API uses extensions to __getattr__ on
+ # the Store to retrieve objects from the Broker
+ # and return a list of proxy objects.
+ start = time.time()
+ d = call(*args)
+ except:
+ d = []
+ delta = time.time() - start
+ log.debug("WallabyOperations: %s seconds to refresh %s" % (delta, attr))
+ return d
+
if self._maintain_cache is not None and \
self._maintain_cache.isAlive():
# No, you can't start another one.
@@ -261,10 +273,6 @@
else:
do_notify = False
for attr in items:
- if attr == WBTypes.TAGS:
- # Tags are really just groups with a filter
- # in get_data()
- attr = WBTypes.GROUPS
if attr in self._cache:
do_notify = True
self._cache[attr].refresh = True
@@ -275,20 +283,65 @@
def get_data(self, which):
'''
- Return the most recently cached value for the specified data.
+ Return a list of cached values for the specified category.
- which -- specifies the data item. Attributes of WBTypes
+ The values returned will be proxy objects constructed by
+ the Wallaby client library.
+
+ which -- specifies the category. Attributes of WBTypes
define valid values for "which"
'''
d = []
self._lock.acquire()
if which in self._cache:
- d = self._cache[which].data
- elif which == WBTypes.TAGS:
- d = self._tags
+ d = self._cache[which].data.values()
self._lock.release()
return d
+ def get_names(self, which):
+ '''
+ Return a list of cached names for the specified category.
+
+ The values returned will be the names of objects constructed
+ by the Wallaby client library.
+
+ which -- specifies the category. Attributes of WBTypes
+ define valid values for "which"
+ '''
+ d = []
+ self._lock.acquire()
+ if which in self._cache:
+ d = self._cache[which].data.keys()
+ self._lock.release()
+ return d
+
+ def get_node_by_name(self, name):
+ '''
+ Return a cached wallaby.Node object by name.
+
+ If name does not designate a currently cached
+ object, None is returned.
+ '''
+ return self._lookup_by_name(WBTypes.NODES, name)
+
+ def get_group_by_name(self, name):
+ '''
+ Return a cached wallaby.Group object by name.
+
+ If name does not designate a currently cached
+ object, None is returned.
+ '''
+ return self._lookup_by_name(WBTypes.GROUPS, name)
+
+ def get_tag_by_name(self, name):
+ '''
+ Return a cached wallaby.Tag object by name.
+
+ If name does not designate a currently cached
+ object, None is returned.
+ '''
+ return self._lookup_by_name(WBTypes.TAGS, name)
+
def get_node_names(self, tag):
'''
Return a list of node names associated with the tag.
@@ -320,9 +373,6 @@
refresh -- the interval in seconds. If None, the specified item
will be updated iff refresh() is called.
'''
- if which == WBTypes.TAGS:
- which = WBTypes.GROUPS
-
if which in self._cache:
self._lock.acquire()
self._cache[which].interval = refresh
@@ -343,41 +393,7 @@
if t is not None and t < min:
t = min
return t
-
- def _set_cache(self, attr, data):
- self._lock.acquire()
- self._cache[attr].data = data
- self._cache[attr].reset_remaining(len(data) == 0)
- self._lock.release()
- log.debug("WallabyOperations: %s list updated (%s items)" % (attr, len(data)))
-
- # Filter groups for the subset of tags
- if attr == WBTypes.GROUPS:
- self._generate_tag_list(data)
-
- def _generate_tag_list(self, data):
- def process_tags(groups):
-
- # get the tag list
- tags = []
- nodes_by_tag = dict()
- for g in groups:
- if g.getConfig() == {} and not g.name.startswith("+++"):
- tags.append(g)
- nodes = g.membership()
- nodes_by_tag[g.name] = nodes
- self._store_tag_list(tags, nodes_by_tag)
-
- t = CallThread(process_tags, None, data)
- t.start()
-
- def _store_tag_list(self, tags, nodes_by_tag):
- self._lock.acquire()
- self._tags = tags
- self._nodes_by_tag = nodes_by_tag
- self._lock.release()
-
def _get_store(self):
# Ideally there should only be a single Store object from a single agent.
# We constrain the search a bit, hopefully more efficient
@@ -395,16 +411,54 @@
pass
return store
+ def _generate_tag_data(self):
+ # figure out the tag list and nodes per tag
+ groups = self.get_data(WBTypes.GROUPS)
+ tags = []
+ nodes_by_tag = dict()
+ for g in groups:
+ if not g.name.startswith("+++") and g.getConfig() == {}:
+ tags.append(g)
+ nodes = g.membership()
+ nodes_by_tag[g.name] = nodes
+
+ self._lock.acquire()
+ self._cache[WBTypes.TAGS].data = self._to_dict(tags)
+ self._cache[WBTypes.TAGS].reset_remaining(len(tags) == 0)
+ self._nodes_by_tag = nodes_by_tag
+ self._lock.release()
+ log.debug("WallabyOperations: %s list updated (%s items)" % (WBTypes.TAGS, len(tags)))
+
+ def _set_cache(self, attr, data):
+ self._lock.acquire()
+ self._cache[attr].data = self._to_dict(data)
+ self._cache[attr].reset_remaining(len(data) == 0)
+ self._lock.release()
+ log.debug("WallabyOperations: %s list updated (%s items)" % (attr, len(data)))
+
+ def _to_dict(self, data):
+ return dict([(x.name, x) for x in data])
+
+ def _lookup_by_name(self, which, name):
+ n = None
+ self._lock.acquire()
+ if name in self._cache[which].data:
+ n = self._cache[which].data[name]
+ self._lock.release()
+ return n
+
class CacheData(object):
- def __init__(self, interval):
- # This attribure is only referenced from the cache thread
- # so it does not need to be protected
+ def __init__(self, interval, synthetic=False, args=()):
+ # These attributes are only referenced from the cache thread
+ # so they do not need to be protected
self.remaining = 0
+ self.synthetic = synthetic
+ self.args = args
# Use of these these items need to be protected by the lock
self.refresh = False
self.interval = interval
- self.data = []
+ self.data = {}
def reset_remaining(self, retry):
# If retry is True, we want to try to get the data
@@ -435,5 +489,11 @@
def get_data(self, *args, **kwargs):
return []
+ def get_names(self, *args, **kwargs):
+ return []
+
+ def get_node_names(self, *args, **kwargs):
+ return []
+
def set_interval(self, *args, **kwargs):
pass
12 years, 9 months
r4899 - trunk/sage/python/sage/wallaby
by tmckay@fedoraproject.org
Author: tmckay
Date: 2011-08-01 20:13:05 +0000 (Mon, 01 Aug 2011)
New Revision: 4899
Modified:
trunk/sage/python/sage/wallaby/wallabyoperations.py
Log:
Replace get_nodes_by_tag(self) with get_node_names(self, tag)
Modified: trunk/sage/python/sage/wallaby/wallabyoperations.py
===================================================================
--- trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-01 19:46:49 UTC (rev 4898)
+++ trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-01 20:13:05 UTC (rev 4899)
@@ -289,18 +289,23 @@
self._lock.release()
return d
- def get_nodes_by_tag(self):
+ def get_node_names(self, tag):
'''
- Return a list of node names associated with each tag.
+ Return a list of node names associated with the tag.
- The return result is a dictionary with tag names as keys and
- lists of node names as values. The nodes listed for each tag
- are members of the tag group.
+ The return result is a list containing the names of nodes
+ in the tag group.
'''
+ names = []
+ if type(tag) in (str, unicode):
+ n = tag
+ else:
+ n = tag.name
self._lock.acquire()
- n = self._nodes_by_tag
+ if n in self._nodes_by_tag:
+ names = self._nodes_by_tag[n]
self._lock.release()
- return n
+ return names
def set_interval(self, which, refresh):
'''
12 years, 9 months
r4898 - trunk/sage/python/sage/wallaby
by tmckay@fedoraproject.org
Author: tmckay
Date: 2011-08-01 19:46:49 +0000 (Mon, 01 Aug 2011)
New Revision: 4898
Modified:
trunk/sage/python/sage/wallaby/wallabyoperations.py
Log:
Add get_nodes_by_tag()
Modified: trunk/sage/python/sage/wallaby/wallabyoperations.py
===================================================================
--- trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-01 13:39:46 UTC (rev 4897)
+++ trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-01 19:46:49 UTC (rev 4898)
@@ -5,6 +5,8 @@
from sage.util import CallThread
from threading import Lock, Condition
+#import cProfile
+
imports_ok = True
try:
import wallaby
@@ -73,6 +75,7 @@
# takes time to filter the list so it is handled in a separate
# thread when group data is received.
self._tags = []
+ self._nodes_by_tag = dict()
# Lock is used for synchronization with the caching thread and
# for thread safety of any and all data that could be accessed
@@ -151,9 +154,12 @@
# Wallaby API uses extensions to __getattr__ on
# the Store to retrieve objects from the Broker
# and return a list of proxy objects.
+ start = time.time()
d = getattr(self._store, attr, [])
except:
d = []
+ delta = time.time() - start
+ log.debug("WallabyOperations: %s seconds to refresh %s" % (delta, attr))
# If the data is empty, _set_cache will leave the
# remaining field set to 0 for the attribute so we
@@ -162,7 +168,7 @@
# interval for this attribute.
self._set_cache(attr, d)
- log.debug("WallabyOperations: refresh processing time %s" \
+ log.debug("WallabyOperations: total refresh processing time %s" \
% (time.time() - start_processing))
# Find out how long we should sleep for.
@@ -204,6 +210,8 @@
return False
self._stop = False
+
+# self._maintain_cache = CallThread(cProfile.runctx('maintain_cache(self)', globals(), locals(), filename='sage.stats'), None)
self._maintain_cache = CallThread(maintain_cache, None, self)
self._maintain_cache.daemon = True
self._maintain_cache.start()
@@ -280,6 +288,19 @@
d = self._tags
self._lock.release()
return d
+
+ def get_nodes_by_tag(self):
+ '''
+ Return a list of node names associated with each tag.
+
+ The return result is a dictionary with tag names as keys and
+ lists of node names as values. The nodes listed for each tag
+ are members of the tag group.
+ '''
+ self._lock.acquire()
+ n = self._nodes_by_tag
+ self._lock.release()
+ return n
def set_interval(self, which, refresh):
'''
@@ -327,23 +348,29 @@
# Filter groups for the subset of tags
if attr == WBTypes.GROUPS:
- self._get_tag_list(data)
+ self._generate_tag_list(data)
- def _get_tag_list(self, data):
+ def _generate_tag_list(self, data):
def process_tags(groups):
- d = []
+
+ # get the tag list
+ tags = []
+ nodes_by_tag = dict()
for g in groups:
if g.getConfig() == {} and not g.name.startswith("+++"):
- d.append(g)
- self._store_tag_list(d)
+ tags.append(g)
+ nodes = g.membership()
+ nodes_by_tag[g.name] = nodes
+ self._store_tag_list(tags, nodes_by_tag)
t = CallThread(process_tags, None, data)
t.start()
- def _store_tag_list(self, tags):
+ def _store_tag_list(self, tags, nodes_by_tag):
self._lock.acquire()
self._tags = tags
+ self._nodes_by_tag = nodes_by_tag
self._lock.release()
def _get_store(self):
12 years, 9 months
r4897 - trunk/sage/python/sage/wallaby
by tmckay@fedoraproject.org
Author: tmckay
Date: 2011-08-01 13:39:46 +0000 (Mon, 01 Aug 2011)
New Revision: 4897
Modified:
trunk/sage/python/sage/wallaby/wallabyoperations.py
Log:
Cache the tag list instead of generating on demand.
Modified: trunk/sage/python/sage/wallaby/wallabyoperations.py
===================================================================
--- trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-07-29 20:05:00 UTC (rev 4896)
+++ trunk/sage/python/sage/wallaby/wallabyoperations.py 2011-08-01 13:39:46 UTC (rev 4897)
@@ -69,6 +69,11 @@
self._cache = {WBTypes.NODES: self.CacheData(refresh_interval),
WBTypes.GROUPS: self.CacheData(refresh_interval)}
+ # Tags are special. They are just a subset of groups, but it
+ # takes time to filter the list so it is handled in a separate
+ # thread when group data is received.
+ self._tags = []
+
# Lock is used for synchronization with the caching thread and
# for thread safety of any and all data that could be accessed
# by multiple threads.
@@ -268,27 +273,14 @@
define valid values for "which"
'''
d = []
- if which == WBTypes.TAGS:
- # Tags are special for the time being.
- # Tags are defined as non-internal groups with no parameters.
- # Get groups and filter.
- target = WBTypes.GROUPS
- else:
- target = which
-
- if target in self._cache:
- self._lock.acquire()
- d = self._cache[target].data
- self._lock.release()
-
- if which == WBTypes.TAGS:
- groups = d
- d = []
- for g in groups:
- if g.getConfig() == {} and not g.name.startswith("+++"):
- d.append(g)
+ self._lock.acquire()
+ if which in self._cache:
+ d = self._cache[which].data
+ elif which == WBTypes.TAGS:
+ d = self._tags
+ self._lock.release()
return d
-
+
def set_interval(self, which, refresh):
'''
Set an individual refresh interval for a data item.
@@ -333,6 +325,27 @@
self._lock.release()
log.debug("WallabyOperations: %s list updated (%s items)" % (attr, len(data)))
+ # Filter groups for the subset of tags
+ if attr == WBTypes.GROUPS:
+ self._get_tag_list(data)
+
+ def _get_tag_list(self, data):
+
+ def process_tags(groups):
+ d = []
+ for g in groups:
+ if g.getConfig() == {} and not g.name.startswith("+++"):
+ d.append(g)
+ self._store_tag_list(d)
+
+ t = CallThread(process_tags, None, data)
+ t.start()
+
+ def _store_tag_list(self, tags):
+ self._lock.acquire()
+ self._tags = tags
+ self._lock.release()
+
def _get_store(self):
# Ideally there should only be a single Store object from a single agent.
# We constrain the search a bit, hopefully more efficient
12 years, 9 months