View Javadoc

1   //
2   //  ========================================================================
3   //  Copyright (c) 1995-2014 Mort Bay Consulting Pty. Ltd.
4   //  ------------------------------------------------------------------------
5   //  All rights reserved. This program and the accompanying materials
6   //  are made available under the terms of the Eclipse Public License v1.0
7   //  and Apache License v2.0 which accompanies this distribution.
8   //
9   //      The Eclipse Public License is available at
10  //      http://www.eclipse.org/legal/epl-v10.html
11  //
12  //      The Apache License v2.0 is available at
13  //      http://www.opensource.org/licenses/apache2.0.php
14  //
15  //  You may elect to redistribute this code under either of these licenses.
16  //  ========================================================================
17  //
18  
19  package org.eclipse.jetty.nosql.mongodb;
20  
21  import java.io.ByteArrayInputStream;
22  import java.io.ByteArrayOutputStream;
23  import java.io.IOException;
24  import java.io.ObjectInputStream;
25  import java.io.ObjectOutputStream;
26  import java.net.UnknownHostException;
27  import java.util.Date;
28  import java.util.HashMap;
29  import java.util.Map;
30  import java.util.Set;
31  
32  import org.eclipse.jetty.nosql.NoSqlSession;
33  import org.eclipse.jetty.nosql.NoSqlSessionManager;
34  import org.eclipse.jetty.server.SessionIdManager;
35  import org.eclipse.jetty.util.annotation.ManagedAttribute;
36  import org.eclipse.jetty.util.annotation.ManagedObject;
37  import org.eclipse.jetty.util.annotation.ManagedOperation;
38  import org.eclipse.jetty.util.log.Log;
39  import org.eclipse.jetty.util.log.Logger;
40  
41  import com.mongodb.BasicDBObject;
42  import com.mongodb.DBCollection;
43  import com.mongodb.DBObject;
44  import com.mongodb.MongoException;
45  
46  
47  /**
48   * MongoSessionManager
49   *
50   * Clustered session manager using MongoDB as the shared DB instance.
51   * The document model is an outer object that contains the elements:
52   * <ul>
53   *  <li>"id"      : session_id </li>
54   *  <li>"created" : create_time </li>
55   *  <li>"accessed": last_access_time </li>
56   *  <li>"maxIdle" : max_idle_time setting as session was created </li>
57   *  <li>"expiry"  : time at which session should expire </li>
58   *  <li>"valid"   : session_valid </li>
59   *  <li>"context" : a nested object containing 1 nested object per context for which the session id is in use
60   * </ul>
61   * Each of the nested objects inside the "context" element contains:
62   * <ul>
63   *  <li>unique_context_name : nested object containing name:value pairs of the session attributes for that context</li>
64   * </ul>
65   * <p>
66   * One of the name:value attribute pairs will always be the special attribute "__metadata__". The value 
67   * is an object representing a version counter which is incremented every time the attributes change.
68   * </p>
69   * <p>
70   * For example:
71   * <code>
72   * { "_id"       : ObjectId("52845534a40b66410f228f23"), 
73   *    "accessed" :  NumberLong("1384818548903"), 
74   *    "maxIdle"  : 1,
75   *    "context"  : { "::/contextA" : { "A"            : "A", 
76   *                                     "__metadata__" : { "version" : NumberLong(2) } 
77   *                                   },
78   *                   "::/contextB" : { "B"            : "B", 
79   *                                     "__metadata__" : { "version" : NumberLong(1) } 
80   *                                   } 
81   *                 }, 
82   *    "created"  : NumberLong("1384818548903"),
83   *    "expiry"   : NumberLong("1384818549903"),
84   *    "id"       : "w01ijx2vnalgv1sqrpjwuirprp7", 
85   *    "valid"    : true 
86   * }
87   * </code>
88   * </p>
89   * <p>
90   * In MongoDB, the nesting level is indicated by "." separators for the key name. Thus to
91   * interact with a session attribute, the key is composed of:
92   * "context".unique_context_name.attribute_name
93   *  Eg  "context"."::/contextA"."A"
94   *  </p>
95   */
96  @ManagedObject("Mongo Session Manager")
97  public class MongoSessionManager extends NoSqlSessionManager
98  {
99      private static final Logger LOG = Log.getLogger(MongoSessionManager.class);
100   
101     private final static Logger __log = Log.getLogger("org.eclipse.jetty.server.session");
102    
103     /*
104      * strings used as keys or parts of keys in mongo
105      */
106     /**
107      * Special attribute for a session that is context-specific
108      */
109     private final static String __METADATA = "__metadata__";
110 
111     
112     /**
113      * Session id
114      */
115     public final static String __ID = "id";
116     
117     /**
118      * Time of session creation
119      */
120     private final static String __CREATED = "created";
121     
122     /**
123      * Whether or not session is valid
124      */
125     public final static String __VALID = "valid";
126     
127     /**
128      * Time at which session was invalidated
129      */
130     public final static String __INVALIDATED = "invalidated";
131     
132     /**
133      * Last access time of session
134      */
135     public final static String __ACCESSED = "accessed";
136     
137     /**
138      * Time this session will expire, based on last access time and maxIdle
139      */
140     public final static String __EXPIRY = "expiry";
141     
142     /**
143      * The max idle time of a session (smallest value across all contexts which has a session with the same id)
144      */
145     public final static String __MAX_IDLE = "maxIdle";
146     
147     /**
148      * Name of nested document field containing 1 sub document per context for which the session id is in use
149      */
150     private final static String __CONTEXT = "context";   
151     
152     
153     /**
154      * Special attribute per session per context, incremented each time attributes are modified
155      */
156     public final static String __VERSION = __METADATA + ".version";
157 
158     /**
159     * the context id is only set when this class has been started
160     */
161     private String _contextId = null;
162 
163     
164     /**
165      * Access to MongoDB
166      */
167     private DBCollection _dbSessions;
168     
169     
170     /**
171      * Utility value of 1 for a session version for this context
172      */
173     private DBObject _version_1;
174 
175 
176     /* ------------------------------------------------------------ */
177     public MongoSessionManager() throws UnknownHostException, MongoException
178     {
179         
180     }
181     
182     
183     
184     /*------------------------------------------------------------ */
185     @Override
186     public void doStart() throws Exception
187     {
188         super.doStart();
189         String[] hosts = getContextHandler().getVirtualHosts();
190 
191         if (hosts == null || hosts.length == 0)
192             hosts = new String[]
193             { "::" }; // IPv6 equiv of 0.0.0.0
194 
195         String contextPath = getContext().getContextPath();
196         if (contextPath == null || "".equals(contextPath))
197         {
198             contextPath = "*";
199         }
200 
201         _contextId = createContextId(hosts,contextPath);
202         _version_1 = new BasicDBObject(getContextAttributeKey(__VERSION),1);
203     }
204 
205     /* ------------------------------------------------------------ */
206     /**
207      * @see org.eclipse.jetty.server.session.AbstractSessionManager#setSessionIdManager(org.eclipse.jetty.server.SessionIdManager)
208      */
209     @Override
210     public void setSessionIdManager(SessionIdManager metaManager)
211     {
212         MongoSessionIdManager msim = (MongoSessionIdManager)metaManager;
213         _dbSessions=msim.getSessions();
214         super.setSessionIdManager(metaManager);
215         
216     }
217 
218     /* ------------------------------------------------------------ */
219     @Override
220     protected synchronized Object save(NoSqlSession session, Object version, boolean activateAfterSave)
221     {
222         try
223         {
224             __log.debug("MongoSessionManager:save session {}", session.getClusterId());
225             session.willPassivate();
226 
227             // Form query for upsert
228             BasicDBObject key = new BasicDBObject(__ID,session.getClusterId());
229 
230             // Form updates
231             BasicDBObject update = new BasicDBObject();
232             boolean upsert = false;
233             BasicDBObject sets = new BasicDBObject();
234             BasicDBObject unsets = new BasicDBObject();
235 
236             
237             // handle valid or invalid
238             if (session.isValid())
239             {
240                 long expiry = (session.getMaxInactiveInterval() > 0?(session.getAccessed()+(1000*getMaxInactiveInterval())):0);
241                 __log.debug("MongoSessionManager: calculated expiry {} for session {}", expiry, session.getId());
242                 
243                 // handle new or existing
244                 if (version == null)
245                 {
246                     // New session
247                     upsert = true;
248                     version = new Long(1);
249                     sets.put(__CREATED,session.getCreationTime());
250                     sets.put(__VALID,true);
251                    
252                     sets.put(getContextAttributeKey(__VERSION),version);
253                     sets.put(__MAX_IDLE, getMaxInactiveInterval());
254                     sets.put(__EXPIRY, expiry);
255                 }
256                 else
257                 {
258                     version = new Long(((Number)version).longValue() + 1);
259                     update.put("$inc",_version_1); 
260                     //if max idle time and/or expiry is smaller for this context, then choose that for the whole session doc
261                     BasicDBObject fields = new BasicDBObject();
262                     fields.append(__MAX_IDLE, true);
263                     fields.append(__EXPIRY, true);
264                     DBObject o = _dbSessions.findOne(new BasicDBObject("id",session.getClusterId()), fields);
265                     if (o != null)
266                     {
267                         Integer currentMaxIdle = (Integer)o.get(__MAX_IDLE);
268                         Long currentExpiry = (Long)o.get(__EXPIRY);
269                         if (currentMaxIdle != null && getMaxInactiveInterval() > 0 && getMaxInactiveInterval() < currentMaxIdle)
270                             sets.put(__MAX_IDLE, getMaxInactiveInterval());
271                         if (currentExpiry != null && expiry > 0 && expiry < currentExpiry)
272                             sets.put(__EXPIRY, currentExpiry);
273                     }
274                 }
275                 
276                 sets.put(__ACCESSED,session.getAccessed());
277                 Set<String> names = session.takeDirty();
278                 if (isSaveAllAttributes() || upsert)
279                 {
280                     names.addAll(session.getNames()); // note dirty may include removed names
281                 }
282                     
283                 for (String name : names)
284                 {
285                     Object value = session.getAttribute(name);
286                     if (value == null)
287                         unsets.put(getContextKey() + "." + encodeName(name),1);
288                     else
289                         sets.put(getContextKey() + "." + encodeName(name),encodeName(value));
290                 }
291             }
292             else
293             {
294                 sets.put(__VALID,false);
295                 sets.put(__INVALIDATED, System.currentTimeMillis());
296                 unsets.put(getContextKey(),1); 
297             }
298 
299             // Do the upsert
300             if (!sets.isEmpty())
301                 update.put("$set",sets);
302             if (!unsets.isEmpty())
303                 update.put("$unset",unsets);
304 
305             _dbSessions.update(key,update,upsert,false);
306             __log.debug("MongoSessionManager:save:db.sessions.update( {}, {}, true) ", key, update);
307 
308             if (activateAfterSave)
309                 session.didActivate();
310 
311             return version;
312         }
313         catch (Exception e)
314         {
315             LOG.warn(e);
316         }
317         return null;
318     }
319 
320     /*------------------------------------------------------------ */
321     @Override
322     protected Object refresh(NoSqlSession session, Object version)
323     {
324         __log.debug("MongoSessionManager:refresh session {}", session.getId());
325 
326         // check if our in memory version is the same as what is on the disk
327         if (version != null)
328         {
329             DBObject o = _dbSessions.findOne(new BasicDBObject(__ID,session.getClusterId()),_version_1);
330 
331             if (o != null)
332             {
333                 Object saved = getNestedValue(o, getContextAttributeKey(__VERSION));
334                 
335                 if (saved != null && saved.equals(version))
336                 {
337                     __log.debug("MongoSessionManager:refresh not needed session {}", session.getId());
338                     return version;
339                 }
340                 version = saved;
341             }
342         }
343 
344         // If we are here, we have to load the object
345         DBObject o = _dbSessions.findOne(new BasicDBObject(__ID,session.getClusterId()));
346 
347         // If it doesn't exist, invalidate
348         if (o == null)
349         {
350             __log.debug("MongoSessionManager:refresh:marking session {} invalid, no object", session.getClusterId());
351             session.invalidate();
352             return null;
353         }
354         
355         // If it has been flagged invalid, invalidate
356         Boolean valid = (Boolean)o.get(__VALID);
357         if (valid == null || !valid)
358         {
359             __log.debug("MongoSessionManager:refresh:marking session {} invalid, valid flag {}", session.getClusterId(), valid);
360             session.invalidate();
361             return null;
362         }
363 
364         // We need to update the attributes. We will model this as a passivate,
365         // followed by bindings and then activation.
366         session.willPassivate();
367         try
368         {
369             session.clearAttributes();
370             
371             DBObject attrs = (DBObject)getNestedValue(o,getContextKey());         
372             
373             if (attrs != null)
374             {
375                 for (String name : attrs.keySet())
376                 {
377                     //skip special metadata field which is not one of the session attributes
378                     if (__METADATA.equals(name))
379                         continue;
380 
381                     String attr = decodeName(name);
382                     Object value = decodeValue(attrs.get(name));
383 
384                     if (attrs.keySet().contains(name))
385                     {
386                         session.doPutOrRemove(attr,value);
387                         session.bindValue(attr,value);
388                     }
389                     else
390                     {
391                         session.doPutOrRemove(attr,value);
392                     }
393                 }
394                 // cleanup, remove values from session, that don't exist in data anymore:
395                 for (String name : session.getNames())
396                 {
397                     if (!attrs.keySet().contains(name))
398                     {
399                         session.doPutOrRemove(name,null);
400                         session.unbindValue(name,session.getAttribute(name));
401                     }
402                 }
403             }
404 
405             /*
406              * We are refreshing so we should update the last accessed time.
407              */
408             BasicDBObject key = new BasicDBObject(__ID,session.getClusterId());
409             BasicDBObject sets = new BasicDBObject();
410             // Form updates
411             BasicDBObject update = new BasicDBObject();
412             sets.put(__ACCESSED,System.currentTimeMillis());
413             // Do the upsert
414             if (!sets.isEmpty())
415             {
416                 update.put("$set",sets);
417             }            
418             
419             _dbSessions.update(key,update,false,false);
420             
421             session.didActivate();
422 
423             return version;
424         }
425         catch (Exception e)
426         {
427             LOG.warn(e);
428         }
429 
430         return null;
431     }
432 
433     /*------------------------------------------------------------ */
434     @Override
435     protected synchronized NoSqlSession loadSession(String clusterId)
436     {
437         DBObject o = _dbSessions.findOne(new BasicDBObject(__ID,clusterId));
438         
439         __log.debug("MongoSessionManager:id={} loaded={}", clusterId, o);
440         if (o == null)
441             return null;
442         
443         Boolean valid = (Boolean)o.get(__VALID);
444         __log.debug("MongoSessionManager:id={} valid={}", clusterId, valid);
445         if (valid == null || !valid)
446             return null;
447         
448         try
449         {
450             Object version = o.get(getContextAttributeKey(__VERSION));
451             Long created = (Long)o.get(__CREATED);
452             Long accessed = (Long)o.get(__ACCESSED);
453           
454             NoSqlSession session = null;
455 
456             // get the session for the context
457             DBObject attrs = (DBObject)getNestedValue(o,getContextKey());
458 
459             __log.debug("MongoSessionManager:attrs {}", attrs);
460             if (attrs != null)
461             {
462                 __log.debug("MongoSessionManager: session {} present for context {}", clusterId, getContextKey());
463                 //only load a session if it exists for this context
464                 session = new NoSqlSession(this,created,accessed,clusterId,version);
465                 
466                 for (String name : attrs.keySet())
467                 {
468                     //skip special metadata attribute which is not one of the actual session attributes
469                     if ( __METADATA.equals(name) )
470                         continue;
471                     
472                     String attr = decodeName(name);
473                     Object value = decodeValue(attrs.get(name));
474 
475                     session.doPutOrRemove(attr,value);
476                     session.bindValue(attr,value);
477                 }
478                 session.didActivate();
479             }
480             else
481                 __log.debug("MongoSessionManager: session  {} not present for context {}",clusterId, getContextKey());        
482 
483             return session;
484         }
485         catch (Exception e)
486         {
487             LOG.warn(e);
488         }
489         return null;
490     }
491 
492     
493     
494     /*------------------------------------------------------------ */
495     /** 
496      * Remove the per-context sub document for this session id.
497      * @see org.eclipse.jetty.nosql.NoSqlSessionManager#remove(org.eclipse.jetty.nosql.NoSqlSession)
498      */
499     @Override
500     protected boolean remove(NoSqlSession session)
501     {
502         __log.debug("MongoSessionManager:remove:session {} for context {}",session.getClusterId(), getContextKey());
503 
504         /*
505          * Check if the session exists and if it does remove the context
506          * associated with this session
507          */
508         BasicDBObject key = new BasicDBObject(__ID,session.getClusterId());
509         
510         DBObject o = _dbSessions.findOne(key,_version_1);
511 
512         if (o != null)
513         {
514             BasicDBObject remove = new BasicDBObject();
515             BasicDBObject unsets = new BasicDBObject();
516             unsets.put(getContextKey(),1);
517             remove.put("$unset",unsets);
518             _dbSessions.update(key,remove);
519 
520             return true;
521         }
522         else
523         {
524             return false;
525         }
526     }
527 
528     
529     
530     /** 
531      * @see org.eclipse.jetty.nosql.NoSqlSessionManager#expire(java.lang.String)
532      */
533     @Override
534     protected void expire (String idInCluster)
535     {
536         __log.debug("MongoSessionManager:expire session {} ", idInCluster);
537 
538         //Expire the session for this context
539         super.expire(idInCluster);
540         
541         //If the outer session document has not already been marked invalid, do so.
542         DBObject validKey = new BasicDBObject(__VALID, true);       
543         DBObject o = _dbSessions.findOne(new BasicDBObject(__ID,idInCluster), validKey);
544         
545         if (o != null && (Boolean)o.get(__VALID))
546         {
547             BasicDBObject update = new BasicDBObject();
548             BasicDBObject sets = new BasicDBObject();
549             sets.put(__VALID,false);
550             sets.put(__INVALIDATED, System.currentTimeMillis());
551             update.put("$set",sets);
552                         
553             BasicDBObject key = new BasicDBObject(__ID,idInCluster);
554             _dbSessions.update(key,update);
555         }       
556     }
557     
558     
559     /*------------------------------------------------------------ */
560     /** 
561      * Change the session id. Note that this will change the session id for all contexts for which the session id is in use.
562      * @see org.eclipse.jetty.nosql.NoSqlSessionManager#update(org.eclipse.jetty.nosql.NoSqlSession, java.lang.String, java.lang.String)
563      */
564     @Override
565     protected void update(NoSqlSession session, String newClusterId, String newNodeId) throws Exception
566     {
567         BasicDBObject key = new BasicDBObject(__ID, session.getClusterId());
568         BasicDBObject sets = new BasicDBObject();
569         BasicDBObject update = new BasicDBObject(__ID, newClusterId);
570         sets.put("$set", update);
571         _dbSessions.update(key, sets, false, false);
572     }
573 
574     /*------------------------------------------------------------ */
575     protected String encodeName(String name)
576     {
577         return name.replace("%","%25").replace(".","%2E");
578     }
579 
580     /*------------------------------------------------------------ */
581     protected String decodeName(String name)
582     {
583         return name.replace("%2E",".").replace("%25","%");
584     }
585 
586     /*------------------------------------------------------------ */
587     protected Object encodeName(Object value) throws IOException
588     {
589         if (value instanceof Number || value instanceof String || value instanceof Boolean || value instanceof Date)
590         {
591             return value;
592         }
593         else if (value.getClass().equals(HashMap.class))
594         {
595             BasicDBObject o = new BasicDBObject();
596             for (Map.Entry<?, ?> entry : ((Map<?, ?>)value).entrySet())
597             {
598                 if (!(entry.getKey() instanceof String))
599                 {
600                     o = null;
601                     break;
602                 }
603                 o.append(encodeName(entry.getKey().toString()),encodeName(entry.getValue()));
604             }
605 
606             if (o != null)
607                 return o;
608         }
609         
610         ByteArrayOutputStream bout = new ByteArrayOutputStream();
611         ObjectOutputStream out = new ObjectOutputStream(bout);
612         out.reset();
613         out.writeUnshared(value);
614         out.flush();
615         return bout.toByteArray();
616     }
617 
618     /*------------------------------------------------------------ */
619     protected Object decodeValue(final Object valueToDecode) throws IOException, ClassNotFoundException
620     {
621         if (valueToDecode == null || valueToDecode instanceof Number || valueToDecode instanceof String || valueToDecode instanceof Boolean || valueToDecode instanceof Date)
622         {
623             return valueToDecode;
624         }
625         else if (valueToDecode instanceof byte[])
626         {
627             final byte[] decodeObject = (byte[])valueToDecode;
628             final ByteArrayInputStream bais = new ByteArrayInputStream(decodeObject);
629             final ClassLoadingObjectInputStream objectInputStream = new ClassLoadingObjectInputStream(bais);
630             return objectInputStream.readUnshared();
631         }
632         else if (valueToDecode instanceof DBObject)
633         {
634             Map<String, Object> map = new HashMap<String, Object>();
635             for (String name : ((DBObject)valueToDecode).keySet())
636             {
637                 String attr = decodeName(name);
638                 map.put(attr,decodeValue(((DBObject)valueToDecode).get(name)));
639             }
640             return map;
641         }
642         else
643         {
644             throw new IllegalStateException(valueToDecode.getClass().toString());
645         }
646     }
647 
648    
649     /*------------------------------------------------------------ */
650     private String getContextKey()
651     {
652     	return __CONTEXT + "." + _contextId;
653     }
654     
655     /*------------------------------------------------------------ */
656     /** Get a dot separated key for 
657      * @param key
658      * @return
659      */
660     private String getContextAttributeKey(String attr)
661     {
662     	return getContextKey()+ "." + attr;
663     }
664     
665     @ManagedOperation(value="purge invalid sessions in the session store based on normal criteria", impact="ACTION")
666     public void purge()
667     {   
668         ((MongoSessionIdManager)_sessionIdManager).purge();
669     }
670     
671     
672     @ManagedOperation(value="full purge of invalid sessions in the session store", impact="ACTION")
673     public void purgeFully()
674     {   
675         ((MongoSessionIdManager)_sessionIdManager).purgeFully();
676     }
677     
678     @ManagedOperation(value="scavenge sessions known to this manager", impact="ACTION")
679     public void scavenge()
680     {
681         ((MongoSessionIdManager)_sessionIdManager).scavenge();
682     }
683     
684     @ManagedOperation(value="scanvenge all sessions", impact="ACTION")
685     public void scavengeFully()
686     {
687         ((MongoSessionIdManager)_sessionIdManager).scavengeFully();
688     }
689     
690     /*------------------------------------------------------------ */
691     /**
692      * returns the total number of session objects in the session store
693      * 
694      * the count() operation itself is optimized to perform on the server side
695      * and avoid loading to client side.
696      */
697     @ManagedAttribute("total number of known sessions in the store")
698     public long getSessionStoreCount()
699     {
700         return _dbSessions.find().count();      
701     }
702     
703     /*------------------------------------------------------------ */
704     /**
705      * MongoDB keys are . delimited for nesting so .'s are protected characters
706      * 
707      * @param virtualHosts
708      * @param contextPath
709      * @return
710      */
711     private String createContextId(String[] virtualHosts, String contextPath)
712     {
713         String contextId = virtualHosts[0] + contextPath;
714         
715         contextId.replace('/', '_');
716         contextId.replace('.','_');
717         contextId.replace('\\','_');
718         
719         return contextId;
720     }
721 
722     /**
723      * Dig through a given dbObject for the nested value
724      */
725     private Object getNestedValue(DBObject dbObject, String nestedKey)
726     {
727         String[] keyChain = nestedKey.split("\\.");
728 
729         DBObject temp = dbObject;
730 
731         for (int i = 0; i < keyChain.length - 1; ++i)
732         {
733             temp = (DBObject)temp.get(keyChain[i]);
734             
735             if ( temp == null )
736             {
737                 return null;
738             }
739         }
740 
741         return temp.get(keyChain[keyChain.length - 1]);
742     }
743 
744     
745      /**
746      * ClassLoadingObjectInputStream
747      *
748      *
749      */
750     protected class ClassLoadingObjectInputStream extends ObjectInputStream
751     {
752         public ClassLoadingObjectInputStream(java.io.InputStream in) throws IOException
753         {
754             super(in);
755         }
756 
757         public ClassLoadingObjectInputStream () throws IOException
758         {
759             super();
760         }
761 
762         @Override
763         public Class<?> resolveClass (java.io.ObjectStreamClass cl) throws IOException, ClassNotFoundException
764         {
765             try
766             {
767                 return Class.forName(cl.getName(), false, Thread.currentThread().getContextClassLoader());
768             }
769             catch (ClassNotFoundException e)
770             {
771                 return super.resolveClass(cl);
772             }
773         }
774     }
775 
776 
777 }